二、多线程并发篇

1. JMM内存模型基础问题(可见性/原子性/有序性)

问题现象

java 复制代码
class Flag {
    boolean running = true; // 非volatile
    void stop() { running = false; }
    void loop() {
        while (running) { /* 空转 */ } // 可能永远不退出
    }
}

原因分析 每个线程有自己的工作内存(CPU缓存),对普通变量的修改不保证立即刷新到主内存并被其他线程感知,这就是"可见性"问题;此外i++这类操作不是原子的(读-改-写三步),多线程并发执行会丢失更新;编译器/CPU的指令重排序会破坏"有序性"(如DCL单例问题)。

修正方案

java 复制代码
volatile boolean running = true; // 保证可见性
AtomicInteger count = new AtomicInteger(0); // 保证原子性
count.incrementAndGet();

复合操作(如"检查再更新")需要synchronized/LockAtomic类的CAS方法保证原子性;volatile只保证可见性和禁止指令重排,不保证原子性。


2. 单例模式的多线程陷阱(DCL未加volatile)

问题现象

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;
    }
}

原因分析 new Singleton()分三步:分配内存、初始化对象、将引用指向内存地址。JIT/CPU可能对2、3步重排序,导致其他线程拿到一个"引用不为null但未初始化完成"的对象。

修正方案

java 复制代码
private static volatile Singleton instance; // 加volatile禁止重排序

更推荐使用静态内部类enum实现单例,天然线程安全且延迟加载:

java 复制代码
public class Singleton {
    private Singleton() {}
    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() { return Holder.INSTANCE; }
}

3. 线程池使用误区

问题现象

java 复制代码
ExecutorService pool = Executors.newFixedThreadPool(10);
// 底层用无界LinkedBlockingQueue,任务堆积导致OOM

ExecutorService cached = Executors.newCachedThreadPool();
// 底层最大线程数Integer.MAX_VALUE,突发流量下创建大量线程导致OOM/CPU飙高

原因分析 Executors的快捷方法内部队列/线程数配置不合理,容易在生产环境引发OOM,阿里巴巴Java开发手册明确建议禁止直接使用Executors创建线程池,应手动new ThreadPoolExecutor

修正方案

java 复制代码
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    8,                              // corePoolSize
    16,                             // maximumPoolSize
    60L, TimeUnit.SECONDS,          // keepAliveTime
    new ArrayBlockingQueue<>(500),  // 有界队列
    new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy() // 明确拒绝策略
);

核心线程数参考CPU密集型(核数+1)或IO密集型(核数*2左右,视IO等待比例调整),并结合业务压测调整。


4. 死锁产生与排查

问题现象 两个线程互相持有对方需要的锁,永久阻塞。

java 复制代码
// 线程1: synchronized(lockA) { synchronized(lockB) {...} }
// 线程2: synchronized(lockB) { synchronized(lockA) {...} }

原因分析 死锁四个必要条件:互斥、请求与保持、不可剥夺、循环等待。多个锁加锁顺序不一致是最常见诱因。

修正方案

  • 统一加锁顺序(如按对象hashCode或业务ID排序后加锁)。
  • tryLock设置超时,避免无限等待。
  • 排查手段:jstack <pid>查看线程dump,搜索"Found one Java-level deadlock";或用jconsole/arthas thread -b
java 复制代码
if (lockA.tryLock(3, TimeUnit.SECONDS)) {
    try {
        if (lockB.tryLock(3, TimeUnit.SECONDS)) {
            try { /* 业务逻辑 */ } finally { lockB.unlock(); }
        }
    } finally { lockA.unlock(); }
}

5. synchronized与Lock使用场景误用

问题现象

  • synchronized锁的对象是可变的(如锁一个会被重新赋值的成员变量),实际上锁失效。
  • Lock忘记在finallyunlock,导致锁永久占用。

原因分析 synchronized(obj)锁的是对象本身,若obj被重新赋值,新旧线程实际锁的不是同一把锁;ReentrantLock需要显式unlock,异常场景下如果不放在finally会导致锁泄漏。

修正方案

java 复制代码
private final Object lock = new Object(); // 用final保证锁对象不变
synchronized (lock) { ... }

Lock reentrantLock = new ReentrantLock();
reentrantLock.lock();
try {
    // 业务逻辑
} finally {
    reentrantLock.unlock(); // 必须在finally中
}

需要公平锁、可中断、多条件变量(Condition)、tryLock等高级特性时选Lock,简单场景synchronized足够且JDK已对其做了大量优化(偏向锁/轻量级锁)。


6. 并发集合陷阱

问题现象

java 复制代码
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
if (!map.containsKey("k")) {
    map.put("k", 1); // 复合操作,两步之间可能被其他线程插入,产生竞态
}

原因分析 ConcurrentHashMap保证单个方法调用的线程安全,但"先检查后操作"这种复合逻辑不是原子的,仍需额外同步。

修正方案

java 复制代码
map.putIfAbsent("k", 1);
map.computeIfAbsent("k", key -> loadValue(key));
map.compute("k", (key, oldVal) -> (oldVal == null ? 1 : oldVal + 1));

优先使用ConcurrentHashMap提供的原子性复合方法(putIfAbsent/compute/merge)而非手动"检查再操作"。


7. ThreadLocal内存泄漏

问题现象 线程池场景下使用ThreadLocal存储用户上下文,请求结束未清理,导致下一个复用该线程的请求读到上一个用户的数据;长期运行还会造成内存缓慢增长。

原因分析 ThreadLocal的值存储在Thread.threadLocalsThreadLocalMap)中,keyThreadLocal的弱引用,但value是强引用。若ThreadLocal对象被回收,key变为null但value不会自动清理,形成"内存泄漏";线程池中线程是复用的,不会随请求结束而销毁,ThreadLocal数据也就不会自动清空。

修正方案

java 复制代码
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
try {
    CONTEXT.set(userContext);
    // 业务逻辑
} finally {
    CONTEXT.remove(); // 必须显式清理,尤其在线程池场景
}

在Filter/拦截器的finally块或线程池任务包装器中统一做remove()


8. volatile误解(不保证复合操作原子性)

问题现象

java 复制代码
private volatile int count = 0;
public void increment() { count++; } // 多线程下count仍会丢失更新

原因分析 volatile只保证可见性和禁止指令重排,count++本质是"读取-加一-写回"三步操作,不具备原子性,多线程并发执行依然会出现竞态。

修正方案

java 复制代码
private final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();

// 或用synchronized包裹整个复合操作
private int count = 0;
public synchronized void increment() { count++; }

volatile适用场景:状态标志位(如running)、双重检查锁定中的单例引用,即"只有单纯赋值/读取,没有依赖当前值的复合运算"的场景。


9. CompletableFuture异步编排坑

问题现象

java 复制代码
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("boom");
}).thenApply(r -> r + 1); // 异常被吞掉,get()才会抛出,容易被忽略

CompletableFuture.supplyAsync(this::heavyTask); // 默认用ForkJoinPool.commonPool(),与其他业务共享,可能互相饿死

原因分析 异步任务中的异常不会主动抛出到主线程,若不调用get()/join()或不加exceptionally/handle,异常会被静默吞掉;不指定线程池默认使用ForkJoinPool.commonPool(),容易与JDK其他并行流等任务抢占资源。

修正方案

java 复制代码
CompletableFuture.supplyAsync(this::heavyTask, bizExecutor) // 显式传入业务线程池
    .exceptionally(ex -> {
        log.error("异步任务失败", ex);
        return defaultValue;
    })
    .thenAccept(this::handleResult);

// 多个任务编排要正确处理异常,避免用get()而不捕获ExecutionException

10. 线程池上下文丢失(MDC、事务上下文跨线程)

问题现象 主线程设置了日志链路ID(MDC)或事务上下文,提交到线程池异步执行后,子线程中MDC.get("traceId")为null,日志无法串联;异步方法中无法感知主线程的Spring事务。

原因分析 MDC底层基于ThreadLocal,线程池中的工作线程与提交任务的线程不是同一个线程,不会自动继承ThreadLocal数据;Spring事务同理基于ThreadLocal绑定连接,跨线程后事务不会传播。

修正方案

java 复制代码
// 手动传递MDC上下文
Map<String, String> context = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (context != null) MDC.setContextMap(context);
    try {
        // 业务逻辑
    } finally {
        MDC.clear();
    }
});

生产实践中建议封装TtlRunnable/TtlCallable(阿里TransmittableThreadLocal)统一处理链路透传,事务类操作则应避免跨线程共享同一事务,改为异步任务内部单独开启新事务。


11. wait/notify虚假唤醒问题

问题现象

java 复制代码
synchronized (lock) {
    if (!condition) { // 用if判断
        lock.wait();
    }
    doSomething(); // condition可能仍不满足,但线程已经继续执行
}

原因分析 wait()被唤醒不一定是因为notify()被显式调用------JVM规范允许"虚假唤醒"(spurious wakeup),线程可能在没有被通知的情况下从wait()中返回;此外多个线程等待同一个锁时,notifyAll()唤醒后各线程重新竞争锁,某个线程被唤醒并拿到锁执行时,条件可能已经被其他线程改变而不再满足。用if只检查一次条件,无法应对这两种情况。

修正方案

java 复制代码
synchronized (lock) {
    while (!condition) { // 必须用while循环检查,而非if
        lock.wait();
    }
    doSomething(); // 走到这里能确保condition一定成立
}
java 复制代码
// 更推荐使用JUC提供的高级同步工具替代原始wait/notify
Condition condition = reentrantLock.newCondition();
reentrantLock.lock();
try {
    while (!ready) { condition.await(); } // 同样需要while
    doSomething();
} finally { reentrantLock.unlock(); }

任何wait()/await()调用都必须放在while循环里重新检查条件,这是并发编程的强制规范,而不是可选的最佳实践。


12. 线程中断机制误用

问题现象

java 复制代码
Thread worker = new Thread(() -> {
    while (true) {
        doWork(); // 忽略中断信号,中断请求被完全无视,线程无法被优雅停止
    }
});
worker.interrupt(); // 调用后线程依然继续跑,没有任何效果

// 另一种误用
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // 吞掉中断异常,没有重新设置中断标志位,上层调用方无法感知中断发生过
}

原因分析 Thread.interrupt()并不会强制停止线程,只是给线程设置了一个"中断标志位",线程需要主动检查该标志位(isInterrupted())或者在阻塞方法(如sleep/wait/join)中响应InterruptedException才能感知并做出反应;如果业务代码捕获InterruptedException后什么都不做,中断信号就此"丢失",后续任何代码(包括上层调用方)都无法再感知曾经发生过中断请求。

修正方案

java 复制代码
Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) { // 主动检查中断标志
        doWork();
    }
});

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // 重新设置中断标志位,保留中断信号,不能吞掉
    return; // 通常应该结束当前任务
}

设计可中断的任务时,业务循环体内要定期检查中断状态并做优雅退出(释放资源、记录日志),捕获InterruptedException后除非明确不需要继续中断传播,否则必须重新调用Thread.currentThread().interrupt()恢复标志位。


13. CAS操作的ABA问题

问题现象

java 复制代码
AtomicInteger stock = new AtomicInteger(100);
// 线程1读到stock=100,准备CAS更新为99
// 此时线程2把stock从100改成50,又改回100
// 线程1的CAS(100, 99)依然成功执行,但实际上库存已经被别人动过,业务逻辑可能已经出错

原因分析 CAS(Compare And Swap)只比较"值"是否相同,无法感知这个值在此期间是否被其他线程修改过又改回来了(A→B→A的过程),这就是ABA问题。在简单的计数场景下ABA通常不影响正确性,但在涉及"基于值判断状态是否变化过"的复杂业务逻辑中(如无锁链表、订单状态流转)可能导致逻辑错误。

修正方案

java 复制代码
// 使用带版本号的原子引用,每次更新版本号递增,能感知中间被修改过的历史
AtomicStampedReference<Integer> stockRef = new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
int currentStock = stockRef.get(stampHolder);
int currentStamp = stampHolder[0];
boolean success = stockRef.compareAndSet(currentStock, currentStock - 1, currentStamp, currentStamp + 1);

大多数业务场景(如简单计数、库存扣减)并不需要严格解决ABA问题,只要最终值正确即可;只有当"中间变化过"这件事本身对业务有意义时(如乐观锁场景需要感知并发修改次数),才需要引入版本号机制,避免过度设计。


14. CountDownLatch/CyclicBarrier误用

问题现象

java 复制代码
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
    executor.submit(() -> {
        doTask();
        latch.countDown(); // 如果doTask()抛出异常,countDown()不会被执行
    });
}
latch.await(); // 永久阻塞,因为某个任务异常导致countDown次数不够

原因分析 CountDownLatch是一次性的门闩,计数减到0后无法重置,若线程在countDown()之前抛出未捕获异常,会导致计数永远无法归零,主线程await()永久阻塞(除非设置了超时);CyclicBarrier虽然可以重复使用,但如果某个参与线程异常退出未到达栅栏点,同样会导致其他等待线程永久阻塞。

修正方案

java 复制代码
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
    executor.submit(() -> {
        try {
            doTask();
        } catch (Exception e) {
            log.error("任务执行异常", e);
        } finally {
            latch.countDown(); // 无论成功失败都要确保countDown被执行
        }
    });
}
boolean completed = latch.await(30, TimeUnit.SECONDS); // 务必设置超时,避免永久阻塞
if (!completed) {
    log.warn("等待任务完成超时");
}

所有countDown()调用都必须放在finally块中,且await()必须使用带超时的重载方法,避免因为个别子任务异常导致主线程无限期等待。


15. 线程池关闭方式不当

问题现象

java 复制代码
executor.shutdownNow(); // 期望优雅关闭,但立即中断了所有正在执行的任务,未完成的业务被强制打断

或反过来:

java 复制代码
executor.shutdown(); // 已提交任务会继续执行完,但如果队列中堆积大量任务,应用退出时会等待很久甚至卡死

原因分析 shutdown()是"优雅关闭",不再接受新任务,但会等待已提交的任务(包括队列中排队的)全部执行完毕才真正终止;shutdownNow()会尝试立即停止所有正在执行的任务(通过中断)并返回队列中尚未执行的任务列表,未完成的业务逻辑可能被强制打断在中间状态。两者用错场景都会造成问题------用shutdownNow可能打断正在写文件/发消息的关键操作,用shutdown又可能因为堆积任务过多导致应用无法及时退出。

修正方案

java 复制代码
public void gracefulShutdown(ExecutorService executor) {
    executor.shutdown(); // 先尝试优雅关闭,不再接受新任务
    try {
        if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
            List<Runnable> notExecuted = executor.shutdownNow(); // 超时后强制关闭
            log.warn("线程池未能在规定时间内关闭,强制终止,剩余任务数: {}", notExecuted.size());
            if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
                log.error("线程池未能完全终止");
            }
        }
    } catch (InterruptedException e) {
        executor.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

标准做法是"先shutdown()优雅等待,超时后再shutdownNow()强制终止"的组合拳,并在Spring应用中通过@PreDestroy钩子确保应用关闭时线程池能被正确清理。


16. 读写锁与StampedLock乐观读陷阱

问题现象

java 复制代码
StampedLock lock = new StampedLock();
long stamp = lock.tryOptimisticRead();
int value = data; // 乐观读,不加锁
if (!lock.validate(stamp)) { // 忘记校验,直接使用了可能已经过期的value
    // 应该在这里升级为悲观读锁重新获取
}
return value; // 校验失败但代码继续往下走,返回了可能不一致的数据

原因分析 StampedLock的乐观读(tryOptimisticRead)在读取共享变量期间完全不加锁,性能最优但读到的数据可能在读取过程中被写线程修改,必须通过validate(stamp)检查读取期间是否发生过写操作;如果忘记校验或校验失败后没有正确降级为悲观读锁重新读取,会返回不一致的脏数据,这是StampedLock区别于普通读写锁最容易出错的地方。

修正方案

java 复制代码
long stamp = lock.tryOptimisticRead();
int value = data;
if (!lock.validate(stamp)) { // 校验失败,说明读取期间有写操作发生
    stamp = lock.readLock(); // 升级为悲观读锁,保证这次一定能读到一致的数据
    try {
        value = data;
    } finally {
        lock.unlockRead(stamp);
    }
}
return value;

StampedLock性能虽优于ReentrantReadWriteLock,但使用复杂度和出错风险也更高(不可重入、乐观读必须配合校验),且不支持Condition,只在读多写少、对性能要求极高的场景下谨慎引入,普通业务场景优先选择更简单可靠的ReentrantReadWriteLock

相关推荐
Zane19941 小时前
ThreadLocal 与 BlockingQueue:线程本地变量的内存泄漏原理,以及阻塞队列怎么选?
java·后端
橘色的喵1 小时前
一块 RISC-V MCU 是怎么启动的: 内存保护、中断与从 Flash 直接执行
后端
长栎1 小时前
JDBC 用了 30 年的 Bridge 模式,多数人以为它只是策略模式换了层皮
后端
Ai拆代码的曹操1 小时前
排查 3 小时,问题竟在 Dubbo 路由规则:一个 force 的坑
后端
Hilaku1 小时前
当 AI 一天写完一周的代码,我们还剩什么优势?
前端·javascript·程序员
无责任此方_修行中1 小时前
换个版本号就能升级?可没那简单:pnpm 11 升级踩坑记
javascript·后端·npm
SelectDB1 小时前
洋钱罐基于 SelectDB 实现 Hive 数据湖透明加速:查询 P95 从 300 秒降至 20 秒的完整实践
后端
Chen_LSN2 小时前
C语言——深度理解指针(4)
后端
用户638982245892 小时前
自定义注解@SyncNeo4j实现增删改导入时自动同步数据节点和关系到Neo4j
后端