多线程是Java进阶的必修课,但也是最容易翻车的区域。很多Bug在单线程测试下毫无征兆,一上生产就偶发、难复现、破坏力极强。下面这7个坑,踩中任何一个,都可能让系统在流量高峰时突然崩溃。
坑一:用Executors创建线程池
Executors.newFixedThreadPool和newSingleThreadExecutor使用无界队列,任务堆积时可能耗尽内存导致OOM;newCachedThreadPool最大线程数为Integer.MAX_VALUE,高并发下会创建海量线程,直接拖垮系统。正确做法是手动new ThreadPoolExecutor,明确核心线程数、最大线程数、队列容量和拒绝策略,让线程池行为可控。
坑二:共享可变变量缺少同步
多个线程读写同一个变量时,如果没有同步,会出现可见性、原子性和有序性问题。比如i++看似一行,实际是读-改-写三步,多线程下必然丢更新。解决方案:用synchronized、Lock或AtomicInteger等原子类。注意volatile只保证可见性和有序性,不保证原子性,不能替代锁。
坑三:死锁
两个线程互相持有对方需要的锁,同时等待对方释放,程序永久卡死。典型场景:A线程锁住资源1请求资源2,B线程锁住资源2请求资源1。避免方法:统一加锁顺序;使用tryLock带超时;减少锁嵌套。一旦线上死锁,只能重启,代价极高。
坑四:双重检查锁定的单例忘加volatile
经典DCL单例中,instance = new Singleton()不是原子操作,可能发生指令重排:分配内存、赋值引用、初始化对象。如果另一个线程在初始化完成前拿到非null引用,就会访问到未初始化的对象。必须给instance加volatile禁止重排,否则看似正确的代码暗藏杀机。
坑五:误用ConcurrentHashMap的复合操作
ConcurrentHashMap的单个操作是线程安全的,但“检查再执行”的复合操作不是。比如if (!map.containsKey(k)) map.put(k, v);两个线程可能同时通过检查,导致重复写入。应使用putIfAbsent、computeIfAbsent等原子方法。同理,Collections.synchronizedList的遍历仍需手动加锁。
坑六:ThreadLocal内存泄漏
ThreadLocal的 key 是弱引用,但 value 是强引用。线程池中的线程长期存活,如果使用完不调用remove(),value 会一直挂在 ThreadLocalMap 中,导致内存泄漏。正确做法:在finally中调用threadLocal.remove(),尤其是 Web 容器和线程池场景。
坑七:SimpleDateFormat线程不安全
SimpleDateFormat内部使用 Calendar 等可变状态,多线程共享同一个实例时,解析和格式化结果会错乱,甚至抛出NumberFormatException。解决方案:每个线程创建独立实例;用ThreadLocal包装;或直接使用 Java 8 的DateTimeFormatter,它是线程安全的。
结语
多线程并发的坑远不止这7个,但这7个是最常见、最容易在生产环境引爆的。它们的共同点是:单线程测试正常,高并发下偶发,排查困难,修复成本高。避免翻车的关键不是背下所有坑,而是建立并发安全意识:共享可变状态必须同步,线程池必须手动配置,复合操作必须原子化,线程局部变量必须清理。下次写多线程代码时,多问一句:这里有没有竞态?有没有可见性问题?如果答案不确定,宁可加锁,也不要赌运气。