{T}

线程安全与并发问题

线程安全与并发问题排查

并发问题的一个难点在于它不一定能够稳定复现。很多 bug 在线下运行多次不出现问题,一旦线上出现并发压力便暴露,例如:

  • 数据重复写入
  • 余额扣错
  • 缓存和数据库状态不一致
  • 死锁或线程阻塞

因此,掌握从现象回溯到竞态条件的能力,比单纯记忆概念更为重要。

一、线程安全的核心概念

1.1 线程安全的定义

线程安全的核心不是"代码里用了锁",而是:

  • 多线程同时执行时
  • 共享状态仍然正确
  • 结果可预期

如果一段代码在并发下会出现:

  • 丢失更新:两个线程同时修改,其中一个修改被覆盖
  • 状态覆盖:一个线程的修改覆盖了另一个线程的修改
  • 顺序错乱:操作执行顺序与预期不符

那它就是线程不安全的。

Brian Goetz 在《Java Concurrency in Practice》中对线程安全的定义是:

当多个线程访问同一个对象时,如果不需要考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

1.2 线程安全的三大特性

线程安全必须同时满足三个核心特性:原子性可见性有序性

1.2.1 原子性(Atomicity)

定义:一个或多个操作,要么全部执行成功,要么全部不执行,不会出现执行到一半被中断的情况。

问题场景

java
public class Counter {
    private int count = 0;
    
    public void increment() {
        count++;  // 非原子操作
    }
}

count++ 看起来是一行代码,但实际上包含三个步骤:

  1. 读取 count 的值
  2. 将值加 1
  3. 将新值写回 count

在多线程环境下,可能出现:

plaintext
线程1: 读取 count=0
线程2: 读取 count=0
线程1: 计算 0+1=1
线程2: 计算 0+1=1
线程1: 写入 count=1
线程2: 写入 count=1  // 覆盖了线程1的结果

最终 count 应该是 2,但实际是 1,这就是原子性问题。

原子性保障方式

  1. 使用原子类
java
public class Counter {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();  // 原子操作
    }
    
    public int getCount() {
        return count.get();
    }
}
  1. 使用 synchronized
java
public class Counter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}
  1. 使用 Lock
java
public class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

常见原子类

原子类说明典型应用场景
AtomicInteger整型原子类计数器、序列号生成
AtomicLong长整型原子类计数器、统计信息
AtomicBoolean布尔原子类状态标记、开关
AtomicReference<V>引用原子类更新对象引用
AtomicIntegerArray整型数组原子类数组元素的原子更新
AtomicStampedReference<V>带版本戳的原子引用类解决 ABA 问题
LongAdder长整型累加器高并发计数场景
DoubleAdder双精度累加器高并发累加统计

LongAdder vs AtomicInteger

java
// 高并发下性能对比
public class CounterComparison {
    private AtomicInteger atomicInt = new AtomicInteger();
    private LongAdder longAdder = new LongAdder();
    
    // AtomicInteger: 高并发下有竞争热点
    public void incrementAtomic() {
        atomicInt.incrementAndGet();  // CAS 循环重试
    }
    
    // LongAdder: 分散热点,性能更好
    public void incrementLongAdder() {
        longAdder.increment();  // 多个 Cell 分散更新
    }
}

LongAdder 在高并发场景下性能优于 AtomicInteger,因为:

  • AtomicInteger 多个线程同时 CAS 更新同一个值,竞争激烈
  • LongAdder 内部维护多个 Cell,分散热点,最后求和

1.2.2 可见性(Visibility)

定义:当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。

问题场景

java
public class VisibilityProblem {
    private boolean running = true;
    
    public void stop() {
        running = false;  // 主线程修改
    }
    
    public void doWork() {
        while (running) {  // 工作线程可能看不到修改
            // do something
        }
    }
}

工作线程可能一直看不到 running = false 的修改,导致无法停止。

可见性问题的根本原因

  1. CPU 缓存:每个 CPU 核心有自己的缓存,线程可能从缓存读取旧值
  2. 指令重排序:编译器和处理器可能对指令进行重排序
  3. JIT 优化:JIT 可能优化掉一些内存读取操作

可见性保障方式

  1. 使用 volatile
java
public class VisibilitySolved {
    private volatile boolean running = true;
    
    public void stop() {
        running = false;
    }
    
    public void doWork() {
        while (running) {
            // volatile 保证每次都从主内存读取最新值
        }
    }
}

volatile 的内存语义:

  • 写操作:强制刷新到主内存
  • 读操作:强制从主内存读取
  1. 使用 synchronized
java
public class VisibilitySolved {
    private boolean running = true;
    
    public synchronized void stop() {
        running = false;
    }
    
    public synchronized boolean isRunning() {
        return running;
    }
}

synchronized 的内存语义:

  • 进入同步块:清空工作内存,从主内存读取
  • 退出同步块:将工作内存的值刷新到主内存
  1. 使用 final
java
public class FinalVisibility {
    private final int value;
    
    public FinalVisibility(int value) {
        this.value = value;  // final 字段在构造函数中初始化后,其他线程立即可见
    }
    
    public int getValue() {
        return value;  // 不需要同步
    }
}

volatile vs synchronized

特性volatilesynchronized
原子性不保证保证
可见性保证保证
有序性保证保证
阻塞不会阻塞会阻塞
适用场景单一变量的读写复杂的临界区操作
性能较高较低

1.2.3 有序性(Ordering)

定义:程序执行的顺序按照代码的先后顺序执行。

问题场景

java
public class OrderProblem {
    private int a = 0;
    private boolean flag = false;
    
    // 线程1
    public void writer() {
        a = 1;           // 1
        flag = true;     // 2
    }
    
    // 线程2
    public void reader() {
        if (flag) {      // 3
            int i = a;   // 4
        }
    }
}

由于指令重排序,可能的执行顺序:

  • 语句 2 可能在语句 1 之前执行
  • 线程 2 看到 flag = true 后,a 可能还是 0

指令重排序类型

  1. 编译器重排序:编译器在不改变单线程执行结果的前提下优化指令顺序
  2. 处理器重排序:现代处理器采用指令级并行技术,可以乱序执行
  3. 内存系统重排序:缓存和写缓冲区的存在导致加载/存储操作看起来是乱序的

有序性保障方式

  1. 使用 volatile:禁止指令重排序
java
public class OrderSolved {
    private int a = 0;
    private volatile boolean flag = false;
    
    public void writer() {
        a = 1;
        flag = true;  // volatile 写,前面的普通写不会被重排序到后面
    }
    
    public void reader() {
        if (flag) {  // volatile 读,后面的普通读不会被重排序到前面
            int i = a;  // 此时 a 一定已经是 1
        }
    }
}
  1. 使用 synchronized:保证同步块内的有序性
java
public class OrderSolved {
    private int a = 0;
    private boolean flag = false;
    
    public synchronized void writer() {
        a = 1;
        flag = true;
    }
    
    public synchronized void reader() {
        if (flag) {
            int i = a;
        }
    }
}

Happens-Before 原则

JMM 通过 happens-before 原则来保证有序性:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
  2. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C
  5. 线程启动规则:Thread 对象的 start() 方法 happens-before 该线程的每一个动作
  6. 线程终止规则:线程中的所有操作都 happens-before 其他线程从该线程的 join() 方法成功返回
  7. 线程中断规则:对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件
  8. 对象终结规则:一个对象的初始化完成 happens-before 它的 finalize() 方法的开始

1.3 三大特性的关系

plaintext
线程安全
    ├── 原子性: 操作不可分割
    ├── 可见性: 修改立即可见
    └── 有序性: 执行顺序可预测
 
保障方式:
    ├── synchronized: 保证全部三个特性
    ├── volatile: 保证可见性和有序性,不保证原子性
    ├── final: 保证初始化完成后的可见性
    ├── 原子类: 保证原子性
    └── Lock: 保证全部三个特性

面试要点

问题:volatile 能保证线程安全吗?

回答:volatile 只能保证可见性和有序性,不能保证原子性。对于单一变量的读写操作是线程安全的,但对于复合操作(如 i++)不是线程安全的。如果要保证原子性,需要使用原子类或锁。

问题:synchronized 和 volatile 有什么区别?

回答

  1. volatile 是轻量级的同步机制,只保证可见性和有序性,不保证原子性
  2. synchronized 是重量级的同步机制,保证原子性、可见性和有序性
  3. volatile 不会阻塞线程,synchronized 会阻塞线程
  4. volatile 适用于单一变量的读写场景,synchronized 适用于复杂的临界区操作

二、线程安全的实现方式

2.1 不可变对象(Immutable Object)

核心思想:状态不可变的对象天然是线程安全的。

实现方式

java
public final class ImmutableObject {
    private final int value;
    private final String name;
    private final List<String> list;
    
    public ImmutableObject(int value, String name, List<String> list) {
        this.value = value;
        this.name = name;
        this.list = Collections.unmodifiableList(new ArrayList<>(list));  // 防御性拷贝
    }
    
    public int getValue() {
        return value;
    }
    
    public String getName() {
        return name;
    }
    
    public List<String> getList() {
        return list;  // 已经是不可变的
    }
    
    // 不提供 setter 方法
}

不可变对象的设计原则

  1. 类使用 final 修饰,防止子类覆盖方法
  2. 所有字段使用 final 修饰,保证初始化后不可变
  3. 不提供 setter 方法
  4. 对于可变对象的引用,使用防御性拷贝
  5. 不暴露 this 引用

典型应用

java
// String 是典型的不可变对象
String s1 = "hello";
String s2 = s1.toUpperCase();  // 返回新对象,原对象不变
 
// BigInteger、BigDecimal 也是不可变对象
BigInteger b1 = new BigInteger("123");
BigInteger b2 = b1.add(new BigInteger("456"));  // 返回新对象
 
// 使用不可变对象作为 Map 的 key
Map<String, Integer> map = new HashMap<>();
map.put("key", 1);  // String 是不可变的,线程安全

2.2 同步机制

2.2.1 synchronized 关键字

三种使用方式

  1. 实例方法:锁当前实例对象
java
public class SynchronizedExample {
    private int count = 0;
    
    public synchronized void increment() {
        count++;  // 锁 this
    }
    
    public synchronized int getCount() {
        return count;  // 锁 this
    }
}
  1. 静态方法:锁 Class 对象
java
public class SynchronizedExample {
    private static int count = 0;
    
    public static synchronized void increment() {
        count++;  // 锁 SynchronizedExample.class
    }
    
    public static synchronized int getCount() {
        return count;  // 锁 SynchronizedExample.class
    }
}
  1. 代码块:锁指定对象
java
public class SynchronizedExample {
    private final Object lock = new Object();
    private int count = 0;
    
    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
}

synchronized 的锁升级

Java 6 之后,synchronized 的锁状态会根据竞争情况逐步升级:

plaintext
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
锁状态说明适用场景性能
无锁没有锁竞争单线程访问最高
偏向锁锁偏向第一个获取它的线程同一个线程多次获取
轻量级锁CAS 自旋获取锁交替执行或短时间竞争
重量级锁操作系统互斥量长时间竞争

锁升级过程

java
// 1. 对象创建,默认无锁或偏向锁(取决于 JVM 配置)
Object obj = new Object();
 
// 2. 第一个线程访问,获取偏向锁
synchronized (obj) {
    // 偏向锁: Mark Word 记录线程 ID
}
 
// 3. 另一个线程尝试获取,升级为轻量级锁
synchronized (obj) {
    // 轻量级锁: CAS 操作 Mark Word
}
 
// 4. 竞争激烈,升级为重量级锁
synchronized (obj) {
    // 重量级锁: 监视器锁,线程阻塞
}

注意:锁只能升级,不能降级。

2.2.2 ReentrantLock

基本使用

java
public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;
    
    public void increment() {
        lock.lock();  // 获取锁
        try {
            count++;
        } finally {
            lock.unlock();  // 必须在 finally 中释放锁
        }
    }
    
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock 的高级特性

  1. 可中断锁
java
public void tryLockWithTimeout() throws InterruptedException {
    // 尝试获取锁,最多等待 5 秒
    if (lock.tryLock(5, TimeUnit.SECONDS)) {
        try {
            // 获取锁成功
        } finally {
            lock.unlock();
        }
    } else {
        // 获取锁失败
    }
}
 
public void lockInterruptibly() throws InterruptedException {
    // 可响应中断的锁获取
    lock.lockInterruptibly();
    try {
        // 执行业务逻辑
    } finally {
        lock.unlock();
    }
}
  1. 公平锁与非公平锁
java
// 非公平锁(默认): 效率高,但可能导致线程饥饿
ReentrantLock unfairLock = new ReentrantLock();
 
// 公平锁: 按照请求锁的顺序获取,但效率低
ReentrantLock fairLock = new ReentrantLock(true);
  1. 条件变量
java
public class BoundedBuffer {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    
    private final Object[] items = new Object[100];
    private int putIndex, takeIndex, count;
    
    public void put(Object item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();  // 等待不满条件
            }
            items[putIndex] = item;
            if (++putIndex == items.length) putIndex = 0;
            count++;
            notEmpty.signal();  // 通知不空条件
        } finally {
            lock.unlock();
        }
    }
    
    public Object take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();  // 等待不空条件
            }
            Object item = items[takeIndex];
            if (++takeIndex == items.length) takeIndex = 0;
            count--;
            notFull.signal();  // 通知不满条件
            return item;
        } finally {
            lock.unlock();
        }
    }
}

synchronized vs ReentrantLock

特性synchronizedReentrantLock
锁获取方式JVM 关键字API 调用
锁释放方式自动释放手动释放(finally)
可中断性不可中断可中断
公平性非公平可选公平/非公平
条件变量单一条件多个条件
锁状态检测不支持支持
性能优化后差距不大竞争激烈时略优

2.3 并发容器

2.3.1 同步容器

Vector 和 Hashtable

java
// 同步容器: 所有方法都用 synchronized 修饰
Vector<String> vector = new Vector<>();
vector.add("element");  // synchronized
 
Hashtable<String, String> hashtable = new Hashtable<>();
hashtable.put("key", "value");  // synchronized

Collections.synchronizedXXX

java
// 将普通容器包装成同步容器
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
Set<String> synchronizedSet = Collections.synchronizedSet(new HashSet<>());

同步容器的问题

java
// 复合操作不是原子的
List<String> list = Collections.synchronizedList(new ArrayList<>());
 
// 线程不安全的复合操作
if (!list.contains("element")) {
    list.add("element");  // 在 contains 和 add 之间可能被其他线程修改
}
 
// 需要额外的同步
synchronized (list) {
    if (!list.contains("element")) {
        list.add("element");
    }
}
 
// 迭代时需要同步
synchronized (list) {
    for (String s : list) {
        // 如果不同步,其他线程修改可能导致 ConcurrentModificationException
    }
}

2.3.2 并发容器

ConcurrentHashMap

java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
 
// 原子复合操作
map.putIfAbsent("key", 1);  // 如果 key 不存在才插入
map.computeIfAbsent("key", k -> calculateValue(k));  // 如果 key 不存在才计算并插入
map.computeIfPresent("key", (k, v) -> v + 1);  // 如果 key 存在才更新
map.replace("key", 1, 2);  // 如果值匹配才替换
 
// 批量操作(Java 8+)
map.forEach((k, v) -> System.out.println(k + "=" + v));
map.replaceAll((k, v) -> v * 2);
map.merge("key", 1, (oldVal, newVal) -> oldVal + newVal);

ConcurrentHashMap 的实现原理

  • Java 7:分段锁(Segment),默认 16 个段,并发度最高为 16
  • Java 8+:CAS + synchronized,锁粒度更细,并发度更高
java
// Java 8 ConcurrentHashMap 的 put 操作大致流程
public V put(K key, V value) {
    // 1. 计算 hash
    int hash = spread(key.hashCode());
    
    // 2. 遍历 table
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        
        // 3. 如果桶为空,CAS 插入新节点
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;  // CAS 成功,插入完成
        }
        // 4. 如果正在扩容,帮助扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 5. 否则锁住头节点,插入或更新
        else {
            synchronized (f) {
                // 遍历链表或红黑树,插入或更新节点
            }
        }
    }
    return null;
}

CopyOnWriteArrayList

java
// 适用于读多写少的场景
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
 
// 读操作: 不加锁,直接访问
String element = list.get(0);
 
// 写操作: 复制整个数组
list.add("element");  // 复制原数组,添加元素,替换原数组
list.remove(0);       // 复制原数组,删除元素,替换原数组
 
// 迭代: 不需要同步,不会抛出 ConcurrentModificationException
for (String s : list) {
    // 迭代的是原数组的快照
}

CopyOnWriteArrayList 的问题

  1. 写操作性能低:每次写都要复制整个数组
  2. 内存占用高:每次写都创建新数组
  3. 数据一致性:迭代时可能看不到最新修改

BlockingQueue

java
// ArrayBlockingQueue: 有界阻塞队列
BlockingQueue<String> arrayQueue = new ArrayBlockingQueue<>(100);
arrayQueue.put("element");  // 队列满时阻塞
String element = arrayQueue.take();  // 队列空时阻塞
 
// LinkedBlockingQueue: 可选有界/无界阻塞队列
BlockingQueue<String> linkedQueue = new LinkedBlockingQueue<>(100);
BlockingQueue<String> unboundedQueue = new LinkedBlockingQueue<>();  // 无界
 
// SynchronousQueue: 无缓冲,直接传递
BlockingQueue<String> syncQueue = new SynchronousQueue<>();
new Thread(() -> {
    try {
        syncQueue.put("element");  // 等待消费者
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();
new Thread(() -> {
    try {
        String e = syncQueue.take();  // 等待生产者
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();
 
// PriorityBlockingQueue: 优先级队列
BlockingQueue<Integer> priorityQueue = new PriorityBlockingQueue<>();
priorityQueue.put(3);
priorityQueue.put(1);
priorityQueue.put(2);
// take 时按优先级(自然排序)出队: 1, 2, 3
 
// DelayQueue: 延迟队列
class DelayedElement implements Delayed {
    private final long expireTime;
    
    public DelayedElement(long delay) {
        this.expireTime = System.nanoTime() + delay;
    }
    
    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(expireTime - System.nanoTime(), TimeUnit.NANOSECONDS);
    }
    
    @Override
    public int compareTo(Delayed o) {
        return Long.compare(this.expireTime, ((DelayedElement) o).expireTime);
    }
}
 
DelayQueue<DelayedElement> delayQueue = new DelayQueue<>();
delayQueue.put(new DelayedElement(TimeUnit.SECONDS.toNanos(5)));  // 5 秒后可用

ConcurrentLinkedQueue

java
// 高性能无界非阻塞队列
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
 
// 无阻塞操作
queue.offer("element");  // 入队
String element = queue.poll();  // 出队,队列为空返回 null
String peek = queue.peek();  // 查看队首,不出队
 
// 适用于高并发场景,性能优于 BlockingQueue

2.4 ThreadLocal

基本使用

java
// 每个线程独立的变量副本
public class ThreadLocalExample {
    private static final ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0);
    
    public void setValue(int value) {
        threadLocalValue.set(value);
    }
    
    public int getValue() {
        return threadLocalValue.get();
    }
    
    public void remove() {
        threadLocalValue.remove();  // 防止内存泄漏
    }
}

典型应用场景

  1. 数据库连接
java
public class ConnectionManager {
    private static final ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
    
    public static Connection getConnection() {
        Connection conn = connectionHolder.get();
        if (conn == null) {
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb");
            connectionHolder.set(conn);
        }
        return conn;
    }
    
    public static void closeConnection() {
        Connection conn = connectionHolder.get();
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException e) {
                // 处理异常
            } finally {
                connectionHolder.remove();  // 必须清理
            }
        }
    }
}
  1. 用户上下文
java
public class UserContext {
    private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
    
    public static void setUser(User user) {
        userHolder.set(user);
    }
    
    public static User getUser() {
        return userHolder.get();
    }
    
    public static void clear() {
        userHolder.remove();
    }
}
 
// 在拦截器中设置用户上下文
public class UserInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        User user = getUserFromToken(request.getHeader("Authorization"));
        UserContext.setUser(user);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear();  // 必须清理
    }
}
  1. 日期格式化
java
// SimpleDateFormat 不是线程安全的
public class DateFormatter {
    private static final ThreadLocal<SimpleDateFormat> dateFormatHolder = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    
    public static String format(Date date) {
        return dateFormatHolder.get().format(date);
    }
    
    public static Date parse(String dateStr) throws ParseException {
        return dateFormatHolder.get().parse(dateStr);
    }
}

ThreadLocal 的内存泄漏问题

java
// ThreadLocal 的内部结构
Thread
    └── threadLocals (ThreadLocalMap)
            └── Entry (WeakReference<ThreadLocal<?>> key, Object value)
 
// Entry 的 key 是弱引用,会被 GC 回收
// 但 value 是强引用,如果线程长期存活,value 不会被回收
 
// 必须手动清理
try {
    threadLocal.set(value);
    // 使用 value
} finally {
    threadLocal.remove();  // 防止内存泄漏
}

ThreadLocal 的最佳实践

java
// 1. 使用 static final 修饰
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
 
// 2. 在 finally 块中清理
try {
    userHolder.set(user);
    // 业务逻辑
} finally {
    userHolder.remove();
}
 
// 3. 在线程池中使用时要特别注意
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
    try {
        userHolder.set(user);
        // 业务逻辑
    } finally {
        userHolder.remove();  // 线程池中线程会复用,必须清理
    }
});

面试要点

问题:ThreadLocal 为什么会导致内存泄漏?

回答:ThreadLocal 的 Entry 中,key 是弱引用,会被 GC 回收,但 value 是强引用。如果线程长期存活(如线程池中的线程),value 对象不会被回收,导致内存泄漏。解决方法是使用完 ThreadLocal 后调用 remove() 方法清理。

三、常见的并发问题类型

3.1 竞态条件(Race Condition)

定义:当多个线程访问和操作同一对象时,最终执行结果与执行时序有关,可能正确也可能不正确。

经典案例:双重检查锁定(DCL)

java
// 错误的双重检查锁定
public class Singleton {
    private static Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查
                    instance = new Singleton();    // 非原子操作
                }
            }
        }
        return instance;
    }
}

问题:instance = new Singleton() 不是原子操作,分为三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存

指令重排序可能导致 2 和 3 交换顺序:

  • 线程1 执行了 1 和 3(instance 不为 null,但对象未初始化)
  • 线程2 判断 instance 不为 null,直接使用未初始化的对象

正确的双重检查锁定

java
public class Singleton {
    private static volatile Singleton instance;  // volatile 禁止指令重排序
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

3.2 死锁(Deadlock)

定义:两个或多个线程互相持有对方需要的锁,导致所有线程都无法继续执行。

经典案例:哲学家进餐问题

java
public class DeadlockDemo {
    private static final Object fork1 = new Object();
    private static final Object fork2 = new Object();
    private static final Object fork3 = new Object();
    
    public static void main(String[] args) {
        // 哲学家1: 先拿 fork1,再拿 fork2
        new Thread(() -> {
            synchronized (fork1) {
                System.out.println("哲学家1 拿起 fork1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork2) {
                    System.out.println("哲学家1 拿起 fork2,开始进餐");
                }
            }
        }).start();
        
        // 哲学家2: 先拿 fork2,再拿 fork3
        new Thread(() -> {
            synchronized (fork2) {
                System.out.println("哲学家2 拿起 fork2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork3) {
                    System.out.println("哲学家2 拿起 fork3,开始进餐");
                }
            }
        }).start();
        
        // 哲学家3: 先拿 fork3,再拿 fork1
        new Thread(() -> {
            synchronized (fork3) {
                System.out.println("哲学家3 拿起 fork3");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork1) {
                    System.out.println("哲学家3 拿起 fork1,开始进餐");
                }
            }
        }).start();
    }
}

死锁的四个必要条件

  1. 互斥条件:资源一次只能被一个线程使用
  2. 持有并等待:线程持有资源同时等待其他资源
  3. 不可剥夺:资源不能被强制抢占
  4. 循环等待:线程间形成循环等待资源的关系

死锁的预防策略

  1. 破坏循环等待:所有线程按相同顺序获取锁
java
// 修改哲学家进餐问题:所有哲学家按 fork 编号从小到大获取
public class DeadlockFree {
    public static void main(String[] args) {
        // 所有哲学家按 fork 编号从小到大获取
        new Thread(() -> {
            synchronized (fork1) {
                System.out.println("哲学家1 拿起 fork1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork2) {
                    System.out.println("哲学家1 拿起 fork2,开始进餐");
                }
            }
        }).start();
        
        new Thread(() -> {
            synchronized (fork2) {  // 先拿 fork2
                System.out.println("哲学家2 拿起 fork2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork3) {  // 再拿 fork3
                    System.out.println("哲学家2 拿起 fork3,开始进餐");
                }
            }
        }).start();
        
        new Thread(() -> {
            synchronized (fork1) {  // 先拿 fork1(编号最小的)
                System.out.println("哲学家3 拿起 fork1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                synchronized (fork3) {  // 再拿 fork3
                    System.out.println("哲学家3 拿起 fork3,开始进餐");
                }
            }
        }).start();
    }
}
  1. 破坏持有并等待:一次性获取所有资源
java
public class HoldAndWaitFree {
    private static final Object lock = new Object();
    
    public void transfer(Account from, Account to, int amount) {
        synchronized (lock) {  // 先获取全局锁
            synchronized (from) {
                synchronized (to) {
                    from.debit(amount);
                    to.credit(amount);
                }
            }
        }
    }
}
  1. 使用超时机制
java
public class TimeoutLock {
    private final Lock lock1 = new ReentrantLock();
    private final Lock lock2 = new ReentrantLock();
    
    public void transfer() {
        boolean lock1Acquired = false;
        boolean lock2Acquired = false;
        
        try {
            lock1Acquired = lock1.tryLock(100, TimeUnit.MILLISECONDS);
            if (lock1Acquired) {
                lock2Acquired = lock2.tryLock(100, TimeUnit.MILLISECONDS);
                if (lock2Acquired) {
                    // 执行业务逻辑
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock2Acquired) {
                lock2.unlock();
            }
            if (lock1Acquired) {
                lock1.unlock();
            }
        }
    }
}

死锁的检测工具

bash
# 1. 使用 jstack 检测死锁
jstack <pid>
 
# 2. 使用 jconsole 检测死锁
jconsole
 
# 3. 使用 VisualVM 检测死锁
jvisualvm

3.3 活锁(Livelock)

定义:线程不断改变状态,尝试响应对方,但实际没有进展。

案例

java
public class LivelockDemo {
    static class Spoon {
        private Diner owner;
        
        public Spoon(Diner owner) {
            this.owner = owner;
        }
        
        public Diner getOwner() {
            return owner;
        }
        
        public synchronized void setOwner(Diner owner) {
            this.owner = owner;
        }
        
        public synchronized void use() {
            System.out.println(owner.name + " 使用勺子");
        }
    }
    
    static class Diner {
        private String name;
        private boolean isHungry;
        
        public Diner(String name) {
            this.name = name;
            this.isHungry = true;
        }
        
        public void eatWith(Spoon spoon, Diner spouse) {
            while (isHungry) {
                // 如果勺子不在自己手里,等待
                if (spoon.getOwner() != this) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    continue;
                }
                
                // 如果配偶也饿,就把勺子给配偶
                if (spouse.isHungry) {
                    System.out.println(name + ": " + spouse.name + " 也饿了,把勺子给他");
                    spoon.setOwner(spouse);
                    continue;
                }
                
                // 使用勺子吃饭
                spoon.use();
                isHungry = false;
                System.out.println(name + " 吃完了");
                spoon.setOwner(spouse);
            }
        }
    }
    
    public static void main(String[] args) {
        Diner husband = new Diner("丈夫");
        Diner wife = new Diner("妻子");
        Spoon spoon = new Spoon(husband);
        
        new Thread(() -> husband.eatWith(spoon, wife)).start();
        new Thread(() -> wife.eatWith(spoon, husband)).start();
    }
}

活锁的特点

  • 线程没有阻塞,一直在运行
  • 但实际没有进展,一直在做无用功
  • 类似两个人在走廊相遇,都想让路,结果同时往同一个方向让

活锁的解决方法

  1. 引入随机性
  2. 设置重试次数上限
  3. 使用指数退避策略
java
public class LivelockFree {
    static class Diner {
        private String name;
        private boolean isHungry;
        private int retryCount = 0;
        private static final int MAX_RETRY = 3;
        
        public void eatWith(Spoon spoon, Diner spouse) {
            while (isHungry && retryCount < MAX_RETRY) {
                if (spoon.getOwner() != this) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    continue;
                }
                
                // 如果配偶也饿,有概率把勺子给配偶
                if (spouse.isHungry) {
                    retryCount++;
                    // 引入随机性,避免同时让步
                    if (Math.random() > 0.5) {
                        spoon.setOwner(spouse);
                    }
                    continue;
                }
                
                spoon.use();
                isHungry = false;
                spoon.setOwner(spouse);
            }
        }
    }
}

3.4 饥饿(Starvation)

定义:线程长期无法获取所需资源,导致无法执行。

饥饿的场景

  1. 不公平的锁:非公平锁可能导致某些线程长期获取不到锁
java
// 非公平锁可能导致饥饿
ReentrantLock unfairLock = new ReentrantLock(false);  // 默认非公平
 
// 使用公平锁避免饥饿
ReentrantLock fairLock = new ReentrantLock(true);
  1. 线程优先级问题:低优先级线程可能长期得不到执行
java
public class PriorityStarvation {
    public static void main(String[] args) {
        // 高优先级线程
        Thread highPriority = new Thread(() -> {
            while (true) {
                // CPU 密集型任务
            }
        });
        highPriority.setPriority(Thread.MAX_PRIORITY);  // 优先级 10
        
        // 低优先级线程
        Thread lowPriority = new Thread(() -> {
            while (true) {
                // 可能长期得不到执行
            }
        });
        lowPriority.setPriority(Thread.MIN_PRIORITY);  // 优先级 1
        
        highPriority.start();
        lowPriority.start();
    }
}
  1. 同步块执行时间长:某些线程持有锁时间过长,其他线程等待过久
java
public class LongLockTime {
    private final Object lock = new Object();
    
    public void longTask() {
        synchronized (lock) {
            // 长时间持有锁
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
    
    public void shortTask() {
        synchronized (lock) {  // 长时间等待
            // 短时间任务
        }
    }
}

饥饿的解决方法

  1. 使用公平锁
  2. 合理设置线程优先级(一般不建议修改)
  3. 减小锁粒度,缩短临界区
  4. 使用锁分段

3.5 线程池和阻塞队列堆积

问题场景

java
public class ThreadPoolProblem {
    public static void main(String[] args) {
        // 线程池配置不合理
        ExecutorService executor = new ThreadPoolExecutor(
            10,  // 核心线程数
            10,  // 最大线程数
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<>(),  // 无界队列,可能导致 OOM
            new ThreadPoolExecutor.AbortPolicy()  // 拒绝策略
        );
        
        // 任务提交速度 > 处理速度
        for (int i = 0; i < 100000; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(1000);  // 慢任务
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
    }
}

线程池堆积的原因

  1. 任务处理速度慢(IO 阻塞、数据库查询慢、外部服务慢)
  2. 线程池配置不合理(核心线程数过小、队列过大)
  3. 任务提交速度过快(流量突增、批量任务)
  4. 任务依赖导致死锁(任务 A 等待任务 B,但都在同一线程池)

线程池堆积的解决方法

  1. 使用有界队列 + 合理的拒绝策略
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,
    20,
    60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(100),  // 有界队列
    new ThreadPoolExecutor.CallerRunsPolicy()  // 调用者执行
);
  1. 监控线程池指标
java
public void monitorThreadPool(ThreadPoolExecutor executor) {
    ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
    monitor.scheduleAtFixedRate(() -> {
        System.out.println("核心线程数: " + executor.getCorePoolSize());
        System.out.println("最大线程数: " + executor.getMaximumPoolSize());
        System.out.println("当前线程数: " + executor.getPoolSize());
        System.out.println("活跃线程数: " + executor.getActiveCount());
        System.out.println("已完成任务数: " + executor.getCompletedTaskCount());
        System.out.println("队列大小: " + executor.getQueue().size());
    }, 0, 1, TimeUnit.SECONDS);
}
  1. 动态调整线程池参数
java
public void adjustThreadPool(ThreadPoolExecutor executor, int corePoolSize, int maxPoolSize) {
    executor.setCorePoolSize(corePoolSize);
    executor.setMaximumPoolSize(maxPoolSize);
}

四、并发问题排查工具详解

4.1 jstack:线程堆栈分析

基本使用

bash
# 查看 Java 进程 PID
jps -l
 
# 输出线程堆栈
jstack <pid>
 
# 输出线程堆栈到文件
jstack <pid> > thread_dump.txt
 
# 检测死锁
jstack -l <pid>

线程堆栈分析

java
// 线程堆栈示例
"http-nio-8080-exec-1" #25 daemon prio=5 os_prio=0 tid=0x00007f8a9c001000 nid=0x7a03 waiting on condition [0x00007f8a8c1f8000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x00000000e1a2a8c0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
        at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209)
        at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285)
        at com.example.UserService.transfer(UserService.java:25)
        at com.example.TransferController.transfer(TransferController.java:15)
        ...

线程状态解析

状态说明可能问题
RUNNABLE运行中或等待 CPUCPU 使用率高
BLOCKED等待获取监视器锁锁竞争激烈
WAITING等待其他线程通知未被唤醒、等待队列
TIMED_WAITING有超时的等待sleep、wait、park
NEW新建未启动-
TERMINATED已终止-

常见问题诊断

  1. CPU 飙高
bash
# 找到 CPU 使用率高的进程
top -H -p <pid>
 
# 找到 CPU 使用率高的线程
printf "%x\n" <tid>  # 转换为十六进制
 
# 在 jstack 输出中搜索
jstack <pid> | grep -A 20 <hex_tid>
  1. 死锁检测
bash
jstack -l <pid> | grep -A 10 "Found one Java-level deadlock"

示例输出

plaintext
Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007f8a9c0034c8 (object 0x00000000e1a2a8c0, a java.lang.Object),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x00007f8a9c0032c8 (object 0x00000000e1a2a8d0, a java.lang.Object),
  which is held by "Thread-1"
 
Java stack information for the threads listed above:
===================================================
"Thread-1":
        at com.example.DeadlockDemo.run(DeadlockDemo.java:25)
        - waiting to lock <0x00000000e1a2a8c0> (a java.lang.Object)
        - locked <0x00000000e1a2a8d0> (a java.lang.Object)
"Thread-2":
        at com.example.DeadlockDemo.run(DeadlockDemo.java:35)
        - waiting to lock <0x00000000e1a2a8d0> (a java.lang.Object)
        - locked <0x00000000e1a2a8c0> (a java.lang.Object)
 
Found 1 deadlock.

4.2 jconsole:图形化监控工具

启动方式

bash
jconsole

功能介绍

  1. 内存监控

    • 堆内存使用情况
    • 非堆内存使用情况
    • 内存池使用情况
    • GC 统计
  2. 线程监控

    • 线程数量
    • 线程状态分布
    • 线程堆栈查看
    • 死锁检测
  3. 类加载监控

    • 已加载类数量
    • 已卸载类数量
  4. MBean 管理

    • 查看和操作 MBean
    • 修改配置参数

使用场景

bash
# 1. 启动应用,开启 JMX 远程监控
java -Dcom.sun.management.jmxremote \
     -Dcom.sun.management.jmxremote.port=9010 \
     -Dcom.sun.management.jmxremote.authenticate=false \
     -Dcom.sun.management.jmxremote.ssl=false \
     -jar app.jar
 
# 2. 使用 jconsole 连接
#    本地进程: 选择本地 Java 进程
#    远程进程: 输入 hostname:port

线程监控界面

plaintext
线程数: 25
峰值: 30
总启动线程数: 45
 
线程列表:
- main (RUNNABLE)
- http-nio-8080-exec-1 (WAITING)
- http-nio-8080-exec-2 (BLOCKED)
- ...
 
点击"检测死锁"按钮,自动检测是否存在死锁

4.3 VisualVM:全功能性能分析工具

启动方式

bash
jvisualvm

主要功能

  1. 内存分析

    • 堆内存使用趋势
    • 堆 dump 分析
    • 内存泄漏检测
    • 对象统计
  2. CPU 分析

    • CPU 采样
    • 方法执行时间统计
    • 热点方法识别
  3. 线程分析

    • 线程时间线
    • 线程状态可视化
    • 线程 dump
  4. 插件扩展

    • VisualGC: GC 可视化
    • BTrace: 动态追踪
    • JConsole 插件

内存泄漏排查

java
// 1. 应用启动时开启 JMX
java -Dcom.sun.management.jmxremote=true \
     -Dcom.sun.management.jmxremote.port=9010 \
     -Dcom.sun.management.jmxremote.authenticate=false \
     -Dcom.sun.management.jmxremote.ssl=false \
     -jar app.jar
 
// 2. VisualVM 连接后,点击"堆 Dump"
// 3. 在堆 dump 页面,点击"类",按实例数排序
// 4. 找到实例数异常多的类
// 5. 右键点击类,选择"在堆中显示"
// 6. 查看对象引用链,找到 GC Root

CPU 热点分析

java
// 1. VisualVM 连接应用
// 2. 点击"Sampler"标签
// 3. 点击"CPU"按钮开始采样
// 4. 执行业务操作
// 5. 点击"Stop"停止采样
// 6. 查看方法执行时间排序
// 7. 找到耗时最长的方法
 
// 示例输出:
Method                          Time (ms)  Invocations
com.example.UserService.getUser    5000        100
com.example.UserService.queryDB    4500        100
com.example.CacheService.get       500         1000

4.4 Arthas:阿里开源的 Java 诊断工具

安装与启动

bash
# 下载并启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
 
# 选择要诊断的 Java 进程
# Arthas 会自动列出所有 Java 进程

常用命令

  1. 查看线程信息
bash
# 查看所有线程
thread
 
# 查看指定线程
thread <thread_id>
 
# 查看 CPU 使用率最高的 3 个线程
thread -n 3
 
# 查看处于 BLOCKED 状态的线程
thread -state BLOCKED
 
# 检测死锁
thread -b

示例输出

plaintext
thread -n 3
"C1 CompilerThread0" [Internal] cpuUsage=45.23% deltaTime=0ms time=12345ms
"C2 CompilerThread0" [Internal] cpuUsage=32.15% deltaTime=0ms time=11234ms
"http-nio-8080-exec-1" cpuUsage=12.34% deltaTime=0ms time=5678ms
  1. 查看方法执行情况
bash
# 监控方法执行时间
trace com.example.UserService getUser
 
# 监控方法调用路径
trace com.example.UserService getUser '#cost > 100'  # 只显示耗时 > 100ms 的调用
 
# 监控方法调用次数和成功率
monitor -c 5 com.example.UserService getUser  # 每 5 秒输出一次统计

示例输出

plaintext
`---[12.345ms] com.example.UserService:getUser()
    +---[0.123ms] com.example.CacheService:get()
    `---[12.222ms] com.example.UserDao:queryById()
        `---[12.111ms] java.sql.PreparedStatement:executeQuery()
  1. 查看方法调用参数和返回值
bash
# 查看方法入参和返回值
watch com.example.UserService getUser '{params, returnObj}' -x 2
 
# 查看方法入参、返回值和异常
watch com.example.UserService getUser '{params, returnObj, throwExp}' -x 2
 
# 条件过滤
watch com.example.UserService getUser '{params, returnObj}' 'params[0] > 100' -x 2

示例输出

plaintext
method=com.example.UserService.getUser location=AtExit
ts=2024-01-01 10:00:00; result=@ArrayList[
    @Object[][
        @Integer[1],
    ],
    @User[
        id=@Integer[1],
        name=@String[张三],
        age=@Integer[25],
    ],
]
  1. 动态修改日志级别
bash
# 查看日志级别
logger
 
# 修改日志级别
logger --name com.example --level debug
  1. 查看类信息
bash
# 查看类信息
sc -d com.example.UserService
 
# 查看类的方法
sm com.example.UserService
 
# 查看 JVM 已加载的类
sc -d *UserService*
  1. 反编译类
bash
# 反编译类
jad com.example.UserService
 
# 反编译指定方法
jad com.example.UserService getUser
  1. 查看 JVM 信息
bash
# 查看 JVM 基本信息
jvm
 
# 查看 GC 信息
memory
 
# 查看 ClassLoader
classloader

实战案例:排查慢接口

bash
# 1. 使用 trace 监控接口方法
trace com.example.OrderController createOrder
 
# 2. 发现某方法耗时较长
`---[1234.567ms] com.example.OrderController:createOrder()
    +---[1.234ms] com.example.ValidationService:validate()
    +---[1233.333ms] com.example.InventoryService:checkStock()
    |   `---[1233.111ms] com.example.InventoryDao:queryStock()
    |       `---[1232.999ms] java.sql.PreparedStatement:executeQuery()
    `---[0.001ms] com.example.OrderService:createOrder()
 
# 3. 进一步追踪数据库查询
trace com.example.InventoryDao queryStock
 
# 4. 发现 SQL 执行慢
`---[1232.999ms] com.example.InventoryDao:queryStock()
    `---[1232.888ms] java.sql.PreparedStatement:executeQuery()
 
# 5. 查看数据库连接池状态
vmtool --action getInstances --className com.zaxxer.hikari.HikariDataSource --express 'instances.{? #this.poolName == "mypool"}.{#this.getHikariPoolMXBean().{#this.getActiveConnections(), #this.getIdleConnections(), #this.getThreadsAwaitingConnection()}}'

4.5 工具对比

工具类型优点缺点适用场景
jstack命令行快速、轻量、无需安装只能看线程堆栈线程问题排查
jconsole图形化简单易用、功能全面性能分析较弱日常监控
VisualVM图形化功能强大、插件丰富内存占用较高性能分析
Arthas命令行功能强大、动态诊断、无需重启学习成本较高线上问题诊断

版本差异(旧版 → Java 21)

特性旧版(Java 8/11)Java 21
竞态条件/死锁分析手段不变不变;虚拟线程引入新的并发场景
排查工具jstack/jmap/jcmd不变;新增虚拟线程堆栈识别
虚拟线程栈jstack 中虚拟线程显示为 virtual 标记
线程耗尽风险平台线程数受限虚拟线程无上限,但针垫效应(synchronized 阻塞)需关注
监控护栏线程数/队列深度新增虚拟线程调度器指标(jdk.virtualThreadScheduler)