1. JMM内存模型基础问题(可见性/原子性/有序性)
问题现象
class Flag {
boolean running = true; // 非volatile
void stop() { running = false; }
void loop() {
while (running) { /* 空转 */ } // 可能永远不退出
}
}
原因分析
每个线程有自己的工作内存(CPU缓存),对普通变量的修改不保证立即刷新到主内存并被其他线程感知,这就是"可见性"问题;此外i++这类操作不是原子的(读-改-写三步),多线程并发执行会丢失更新;编译器/CPU的指令重排序会破坏"有序性"(如DCL单例问题)。
修正方案
volatile boolean running = true; // 保证可见性
AtomicInteger count = new AtomicInteger(0); // 保证原子性
count.incrementAndGet();
复合操作(如"检查再更新")需要synchronized/Lock或Atomic类的CAS方法保证原子性;volatile只保证可见性和禁止指令重排,不保证原子性。
2. 单例模式的多线程陷阱(DCL未加volatile)
问题现象
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但未初始化完成"的对象。
修正方案
private static volatile Singleton instance; // 加volatile禁止重排序
更推荐使用静态内部类或enum实现单例,天然线程安全且延迟加载:
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}
3. 线程池使用误区
问题现象
ExecutorService pool = Executors.newFixedThreadPool(10);
// 底层用无界LinkedBlockingQueue,任务堆积导致OOM
ExecutorService cached = Executors.newCachedThreadPool();
// 底层最大线程数Integer.MAX_VALUE,突发流量下创建大量线程导致OOM/CPU飙高
原因分析
Executors的快捷方法内部队列/线程数配置不合理,容易在生产环境引发OOM,阿里巴巴Java开发手册明确建议禁止直接使用Executors创建线程池,应手动new ThreadPoolExecutor。
修正方案
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. 死锁产生与排查
问题现象 两个线程互相持有对方需要的锁,永久阻塞。
// 线程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。
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忘记在finally中unlock,导致锁永久占用。
原因分析
synchronized(obj)锁的是对象本身,若obj被重新赋值,新旧线程实际锁的不是同一把锁;ReentrantLock需要显式unlock,异常场景下如果不放在finally会导致锁泄漏。
修正方案
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. 并发集合陷阱
问题现象
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
if (!map.containsKey("k")) {
map.put("k", 1); // 复合操作,两步之间可能被其他线程插入,产生竞态
}
原因分析
ConcurrentHashMap保证单个方法调用的线程安全,但"先检查后操作"这种复合逻辑不是原子的,仍需额外同步。
修正方案
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.threadLocals(ThreadLocalMap)中,key是ThreadLocal的弱引用,但value是强引用。若ThreadLocal对象被回收,key变为null但value不会自动清理,形成"内存泄漏";线程池中线程是复用的,不会随请求结束而销毁,ThreadLocal数据也就不会自动清空。
修正方案
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
try {
CONTEXT.set(userContext);
// 业务逻辑
} finally {
CONTEXT.remove(); // 必须显式清理,尤其在线程池场景
}
在Filter/拦截器的finally块或线程池任务包装器中统一做remove()。
8. volatile误解(不保证复合操作原子性)
问题现象
private volatile int count = 0;
public void increment() { count++; } // 多线程下count仍会丢失更新
原因分析
volatile只保证可见性和禁止指令重排,count++本质是"读取-加一-写回"三步操作,不具备原子性,多线程并发执行依然会出现竞态。
修正方案
private final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
// 或用synchronized包裹整个复合操作
private int count = 0;
public synchronized void increment() { count++; }
volatile适用场景:状态标志位(如running)、双重检查锁定中的单例引用,即"只有单纯赋值/读取,没有依赖当前值的复合运算"的场景。
9. CompletableFuture异步编排坑
问题现象
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("boom");
}).thenApply(r -> r + 1); // 异常被吞掉,get()才会抛出,容易被忽略
CompletableFuture.supplyAsync(this::heavyTask); // 默认用ForkJoinPool.commonPool(),与其他业务共享,可能互相饿死
原因分析
异步任务中的异常不会主动抛出到主线程,若不调用get()/join()或不加exceptionally/handle,异常会被静默吞掉;不指定线程池默认使用ForkJoinPool.commonPool(),容易与JDK其他并行流等任务抢占资源。
修正方案
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绑定连接,跨线程后事务不会传播。
修正方案
// 手动传递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虚假唤醒问题
问题现象
synchronized (lock) {
if (!condition) { // 用if判断
lock.wait();
}
doSomething(); // condition可能仍不满足,但线程已经继续执行
}
原因分析
wait()被唤醒不一定是因为notify()被显式调用——JVM规范允许"虚假唤醒"(spurious wakeup),线程可能在没有被通知的情况下从wait()中返回;此外多个线程等待同一个锁时,notifyAll()唤醒后各线程重新竞争锁,某个线程被唤醒并拿到锁执行时,条件可能已经被其他线程改变而不再满足。用if只检查一次条件,无法应对这两种情况。
修正方案
synchronized (lock) {
while (!condition) { // 必须用while循环检查,而非if
lock.wait();
}
doSomething(); // 走到这里能确保condition一定成立
}
// 更推荐使用JUC提供的高级同步工具替代原始wait/notify
Condition condition = reentrantLock.newCondition();
reentrantLock.lock();
try {
while (!ready) { condition.await(); } // 同样需要while
doSomething();
} finally { reentrantLock.unlock(); }
任何wait()/await()调用都必须放在while循环里重新检查条件,这是并发编程的强制规范,而不是可选的最佳实践。
12. 线程中断机制误用
问题现象
Thread worker = new Thread(() -> {
while (true) {
doWork(); // 忽略中断信号,中断请求被完全无视,线程无法被优雅停止
}
});
worker.interrupt(); // 调用后线程依然继续跑,没有任何效果
// 另一种误用
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// 吞掉中断异常,没有重新设置中断标志位,上层调用方无法感知中断发生过
}
原因分析
Thread.interrupt()并不会强制停止线程,只是给线程设置了一个"中断标志位",线程需要主动检查该标志位(isInterrupted())或者在阻塞方法(如sleep/wait/join)中响应InterruptedException才能感知并做出反应;如果业务代码捕获InterruptedException后什么都不做,中断信号就此"丢失",后续任何代码(包括上层调用方)都无法再感知曾经发生过中断请求。
修正方案
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问题
问题现象
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通常不影响正确性,但在涉及"基于值判断状态是否变化过"的复杂业务逻辑中(如无锁链表、订单状态流转)可能导致逻辑错误。
修正方案
// 使用带版本号的原子引用,每次更新版本号递增,能感知中间被修改过的历史
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误用
问题现象
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虽然可以重复使用,但如果某个参与线程异常退出未到达栅栏点,同样会导致其他等待线程永久阻塞。
修正方案
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. 线程池关闭方式不当
问题现象
executor.shutdownNow(); // 期望优雅关闭,但立即中断了所有正在执行的任务,未完成的业务被强制打断
或反过来:
executor.shutdown(); // 已提交任务会继续执行完,但如果队列中堆积大量任务,应用退出时会等待很久甚至卡死
原因分析
shutdown()是"优雅关闭",不再接受新任务,但会等待已提交的任务(包括队列中排队的)全部执行完毕才真正终止;shutdownNow()会尝试立即停止所有正在执行的任务(通过中断)并返回队列中尚未执行的任务列表,未完成的业务逻辑可能被强制打断在中间状态。两者用错场景都会造成问题——用shutdownNow可能打断正在写文件/发消息的关键操作,用shutdown又可能因为堆积任务过多导致应用无法及时退出。
修正方案
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乐观读陷阱
问题现象
StampedLock lock = new StampedLock();
long stamp = lock.tryOptimisticRead();
int value = data; // 乐观读,不加锁
if (!lock.validate(stamp)) { // 忘记校验,直接使用了可能已经过期的value
// 应该在这里升级为悲观读锁重新获取
}
return value; // 校验失败但代码继续往下走,返回了可能不一致的数据
原因分析
StampedLock的乐观读(tryOptimisticRead)在读取共享变量期间完全不加锁,性能最优但读到的数据可能在读取过程中被写线程修改,必须通过validate(stamp)检查读取期间是否发生过写操作;如果忘记校验或校验失败后没有正确降级为悲观读锁重新读取,会返回不一致的脏数据,这是StampedLock区别于普通读写锁最容易出错的地方。
修正方案
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。