Java并发安全核心:JMM、synchronized、死锁与线程池实战
1. 为什么并发安全这一块我们总在背八股却没真正吃透做Java开发的人面试时几乎躲不开并发安全这个话题。我面试过不少候选人问synchronized和ReentrantLock的区别能答上来的不少但问到你觉得Java内存模型和synchronized在底层是怎么配合的或者线上遇到死锁你怎么排查很多人就卡住了。说白了大家背的是结论缺的是把结论串起来的那根线。这篇内容主要围绕日常面试和实战里最常考的线程并发安全问题展开包括原子性、可见性、有序性问题synchronized与volatile的底层原理死锁的成因与排查以及并发容器和线程池的经典考点。不管你是准备面试的初级开发还是想系统梳理并发知识的中级工程师这篇文章都会尽量用大白话把为什么讲清楚。我自己最开始学并发时也走过弯路买了一堆书背了一堆八股结果一上生产环境遇到诡异的数据错乱完全不知道从哪下手。后来把这些问题逐一拆开结合HotSpot源码和排查实践才真正有了遇到并发问题知道怎么想的底气。这篇文章就是把当年我自己最希望有人提前告诉我的那些点完整整理出来。2. 一切并发问题的总根源缓存不一致与指令乱序在展开具体技术点之前先把并发不安全这个问题的根源讲明白。Java并发编程领域有三类经典问题原子性、可见性、有序性。很多面试题绕来绕去归根结底都是在问这三件事。2.1 现代CPU缓存与读到旧值的真相现在的CPU动辄几十个核每个核又有自己的L1、L2缓存。多线程场景下CPU0和CPU1同时访问内存地址X各自会把X加载到各自的缓存里。线程A改了CPU0缓存里的X没有立刻写回主存线程B在CPU1缓存里读到的还是旧值。这就是可见性问题的硬件根源。从Java层面看这抽象成了Java内存模型JMM其中的核心规则是线程对共享变量的修改必须先更新到主内存才能被其他线程看到其他线程要读到最新值也必须从主内存重新加载。JMM规定了很多规则比如happens-before原则本质上就是在约束编译器、CPU做什么优化是合法的。2.2 指令重排序与单线程障眼法编译器、JIT、CPU处理器为了提升性能都可能对指令进行重排序。它们的底线是无论如何重排单线程语义不能变。这句话隐藏了一个大坑——单线程下看不出来多线程下才露馅。举个经典例子。线程1执行boolean ready false; int num 0; // 线程1 num 42; ready true; // 线程2 if (ready) { System.out.println(num); }如果编译器把ready true重排到num 42之前线程2看到ready为true时打印出来的num可能是0。即使你是按顺序写的代码也不能保证实际执行顺序。2.3 三把锁的对应关系理解了三类问题对应解决方案就自然出来了问题类型解决手段代表工具原子性锁、CAS、原子类synchronized、ReentrantLock、AtomicInteger可见性volatile、锁、finalvolatile、synchronized、final有序性volatile、锁、happens-before规则volatile、synchronized其中synchronized比较特殊它同时解决三类问题进入和退出同步块时清空工作内存、重新加载主内存变量解决可见性同步块内的代码不允许乱序执行解决有序性锁的互斥特性解决原子性。volatile只能解决可见性和有序性不能解决原子性——这句话面试里十有八九会问到。3. i不是原子操作从字节码到生产事故很多人第一次对并发安全有体感就是从i开始的。这个看似简单的操作在并发环境下会出各种奇怪的结果。3.1 一条Java语句背后的三步操作在Java中i对应的字节码实际上是GETSTATIC i ICONST_1 IADD PUTSTATIC i这是一个读-改-写三步操作不是原子操作。两个线程同时执行i时完全可能都读到i0各自加1后都写成1最终结果比预期少了1。线上真实场景中这体现为库存扣减不对、统计计数丢失非常难排查。3.2 用synchronized能否解决很多人第一步就错了先说结论synchronized可以解决这个问题但要放在正确的位置。如果你在方法声明上写synchronized void increment()可以保证线程安全因为锁的是this对象。但如果你只对i这一行代码加锁而其他读i的地方没加锁照样会出问题——读操作可能发生在写操作持有锁的间隙也可能读到旧值。更隐蔽的问题是如果把锁加在读写方法的调用方比如在循环里每次new一个对象来调用increment方法那么每次用的都是不同的锁完全没有任何互斥效果。这类问法在面试里也很常见我这代码加了synchronized为什么还是出问题八成是锁的对象不对或者锁的粒度不对。3.3 AtomicInteger与CAS的适用边界生产上处理计数器累加优先考虑AtomicIntegerAtomicInteger count new AtomicInteger(0); count.incrementAndGet();底层用的是CAS比较并交换它依赖处理器提供的原子指令在无锁情况下实现原子更新。CAS的问题有两个第一ABA问题就是变量从A变成B又变回ACAS无法感知中间的变化可以用AtomicStampedReference配版本号解决第二高竞争下的自旋开销线程一直在循环重试CPU会飙升。4. volatile的可见性保证与双重检查锁的恩怨纠葛volatile在并发编程中是个高频考点但也是最容易被误解的关键字之一。4.1 volatile到底保证了什么、不保证什么先说保证的部分。volatile保证了两件事可见性和有序性禁止重排序。它通过在变量读写时插入内存屏障比如写volatile变量时会插入StoreStore屏障和StoreLoad屏障确保障写不会被重排到前面的普通写之前读操作也不会被重排到后面普通读之后。但它不保证原子性。volatile int count的count依然是读-改-写三步依然会丢更新。面试里常有个坑volatile能不能保证线程安全准确回答是它只能保证可见性与有序性无法保证原子性因此不能用它替代锁。4.2 双重检查锁为什么要加volatile单例模式里的双重检查锁DCL是面试必考题public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }问题在于new Singleton()不是一个原子操作。它底层大致分三步分配内存空间调用构造器初始化对象将引用指向分配的内存地址编译器和CPU可能把第2、3步重排。如果线程A执行完第3步引用非空但还没执行完第2步对象未完全初始化线程B看到instance ! null直接拿来使用就会拿到一个半初始化的对象。加volatile后禁止了引用赋值和构造器之间的重排序问题才被堵住。4.3 volatile在服务端框架中的真实用法实际项目中volatile经常用来做开关标志、状态位。比如一个动态配置中心负责监听配置变更的线程把最新配置写入volatile变量其他业务线程直接读。因为配置只写一次或低频更新不存在读-改-写竞争volatile完全够用。我自己写过一个限流组件热更新规则就是用volatile引用指向不可变的规则对象。每次更新规则时构建一个新对象然后把volatile引用指向它。读取方永远读到的是完整一致的对象不需要加锁。这种Copy-on-Write风格比在每次读时加锁高效得多。5. synchronized底层的锁升级与对象头以及和ReentrantLock的取舍synchronized是Java并发里最基础的锁机制。很多八股文章只讲用法不讲底层导致面试一问为什么synchronized是重量级锁就答不上来。5.1 从偏向锁到重量级锁的完整路径HotSpot虚拟机里synchronized是基于Monitor监视器锁实现的对象头中的Mark Word记录了锁状态。锁共有四种状态按竞争程度逐级升级无锁状态偏向锁只有一个线程反复获取锁时锁会偏向该线程通过CAS在Mark Word里记录线程ID后续该线程再次进入无需同步。轻量级锁当有第二个线程尝试获取锁时偏向锁被撤销升级为轻量级锁。竞争的线程通过自旋等待不阻塞。重量级锁如果自旋失败或竞争激烈锁会升级为重量级锁线程进入阻塞状态涉及操作系统内核态的系统调用。这里有个重要细节锁能升级但不能降级。所以线上环境如果出现某个锁竞争非常激烈它会稳定在重量级锁状态线程切换开销就会成为性能瓶颈。5.2 可重入性、锁粒度与中断响应synchronized和ReentrantLock都支持可重入也就是同一个线程可以重复获取同一把锁。它们的核心区别我整理了一张表对比维度synchronizedReentrantLock锁获取方式自动释放异常时自动解锁需要手动lock/unlock最好在finally中unlock锁中断响应不支持支持lockInterruptibly()尝试非阻塞获取锁不支持支持tryLock()公平锁非公平可配公平/非公平条件变量依赖wait/notifyCondition更灵活底层实现JVM Monitor对象头AQS LockSupport如果是简单的互斥场景比如对某个方法加锁synchronized语法简洁、不易出错我还是推荐优先用它。但如果你需要尝试获取锁、超时等待、可中断等待、多条件队列等更细粒度的控制ReentrantLock更适合。5.3 AQS为什么是并发工具的地基说到ReentrantLock就必须提AQSAbstractQueuedSynchronizer。很多并发工具类包括CountDownLatch、Semaphore、ReentrantReadWriteLock内部都依赖AQS的同步队列和状态管理。AQS的核心是一个volatile int state变量加一个双向等待队列。获取锁的线程会通过CAS尝试把state从0改为1获取失败就进入等待队列挂起。释放锁时把state改回0并唤醒队列里的下一个线程。这个设计把如何管理等待线程这个通用问题抽象出来上层只需要实现tryAcquire和tryRelease两个方法就能定制不同语义的锁。面试官如果深挖一般会问公平锁和非公平锁的区别在AQS里怎么体现。公平锁在tryAcquire时会先检查等待队列里有没有排队的线程非公平锁则直接CAS抢锁抢不到才进队列。非公平锁的性能更好但会有线程饿死的可能所以公平锁适合对公平性有硬要求的场景。6. 死锁的四个条件以及一次真实死锁的排查过程死锁在并发面试里的出现频率极高而且它不是一个理论问题——线上应用真的会死锁而且非常隐蔽。6.1 死锁的本质一张图讲完死锁的经典定义是两个或多个线程互相持有对方需要的锁且都在等待对方释放导致所有线程永久阻塞。它需要满足四个必要条件互斥条件资源同时只能被一个线程占用持有并等待线程持有一个资源同时等待另一个资源不可剥夺资源不能被强占循环等待多个线程之间形成环形等待链实际代码中最常见的就是嵌套锁。线程A持有锁1等待锁2线程B持有锁2等待锁1。两边都在等谁也不会释放就死锁了。6.2 一次线上死锁的完整推演我印象很深的一次是一个订单导出任务偶发性卡住。重启后好一阵过几天又卡最后定位到是死锁。当时代码大致长这样// 线程A更新订单并记录操作日志 public void updateOrder(Order order) { synchronized (orderLock) { // 更新订单 synchronized (logLock) { // 写操作日志 } } } // 线程B批量清日志并回写订单状态 public void clearLogs(Order order) { synchronized (logLock) { // 清理日志 synchronized (orderLock) { // 回写订单状态 } } }两个方法获取锁的顺序相反只要线程A拿到orderLock、线程B拿到logLock两边就会互相等待死锁。排查时先用jstack导出线程快照能看到线程都在waiting to lock某把锁并且形成了环形等待链。6.3 避免死锁的工程手段死锁一旦发生除了重启应用几乎没有太好的办法所以预防是关键。这里分享几个我在实际项目里用过的有效手段锁顺序一致所有线程以相同的顺序获取锁。这是最直接也最有效的方法。用tryLock加超时获取锁超过一定时间就放弃避免无限等待。ReentrantLock的tryLock(3, TimeUnit.SECONDS)就能做到。缩小锁范围能用局部变量就不用全局锁锁的粒度越小死锁概率越低。用并发工具替代手写锁比如用ConcurrentHashMap的computeIfAbsent来替代先查后写的模式。面试里如果被问到如何排查死锁标准思路是先jps找到进程号再用jstack导出线程快照搜deadlock或waiting to lock确认线程间是否存在循环等待。能现场把jstack命令写出来并解释关键日志的候选人基本都是真做过排查的。7. ConcurrentHashMap为何能替代HashTable分段锁的思想演进Java集合类的线程安全是面试几乎必问的专题。这一节的考点非常集中HashTable为什么性能差、ConcurrentHashMap为什么性能好、以及不同JDK版本里的实现差异。7.1 HashTable的瓶颈全表一把锁HashTable的做法最简单粗暴在所有public方法上都加了synchronized。这保证了线程安全但代价是同一时刻只有一个线程能操作整个Map。在线程竞争稍微激烈一点的应用里HashTable直接就变成性能瓶颈了。7.2 JDK 7的分段锁与JDK 8的CAS加synchronizedJDK 7里ConcurrentHashMap把整个Map分成多个Segment每个Segment是一把独立的锁。不同线程操作不同Segment时互不影响锁粒度比HashTable细了很多。JDK 8则彻底放弃了分段锁改为对桶的头节点加锁锁粒度更细。写入时先判断桶是否为空为空则用CAS直接插入桶非空时对头节点synchronized加锁。这样即使两个线程冲突也只在同一个桶内阻塞其他桶的读写完全不受影响。同时JDK 8在扩容时引入了ForwardingNode配合sizeCtl变量管理扩容状态性能比JDK 7更好。7.3 size()不是精确值并发容器的经典陷阱有个容易踩坑的知识点ConcurrentHashMap的size()返回的是一个近似值。因为Map在并发写入过程中各桶的计数可能还没汇总完size()是基于baseCount和CounterCell的估算。如果你依赖size()做精确判断比如当Map大小等于100时触发某个操作就有逻辑漏洞。还有一个常见考点是ConcurrentHashMap不允许null键和null值。这是因为在并发场景下null会产生二义性——get(key)返回null到底是key不存在还是key对应的值是null这个问题在HashTable时代就被设计者规避了ConcurrentHashMap延续了这个约定。8. 线程池的三大核心参数与四种拒绝策略八股之外的实战细节线程池大概是Java并发里最实用的面试题了因为它直接和线上性能挂钩。常问的ThreadPoolExecutor参数、执行流程、拒绝策略每一个都有实际生产含义。8.1 核心线程数、最大线程数、队列长度怎么配ThreadPoolExecutor的核心参数有三个corePoolSize核心线程数核心线程即使空闲也不会被回收。maximumPoolSize最大线程数线程数超过核心数后新任务先进入队列。workQueue任务队列用来存放核心线程都忙时的多余任务。执行流程是提交任务时如果当前线程数小于corePoolSize创建新线程执行任务否则尝试放入队列如果队列也满了且线程数小于maximumPoolSize创建新线程执行任务如果线程数已经等于maximumPoolSize触发拒绝策略。这里最容易搞混的是先加线程还是先排队。按默认流程ThreadPoolExecutor在核心线程满了之后是优先把任务放进队列而不是优先创建新线程。很多人以为线程会马上扩容实际上队列满之前都不会碰maximumPoolSize。8.2 四种拒绝策略分别适合什么场景拒绝策略行为适用场景AbortPolicy默认直接抛RejectedExecutionException不适合丢弃任务要求快速暴露问题CallerRunsPolicy让提交任务的线程自己执行该任务需要降速且不想丢弃任务DiscardPolicy静默丢弃任务允许任务丢失追求不中断DiscardOldestPolicy丢弃队头任务重新提交新任务允许丢弃旧任务适合获取最新结果我在生产环境最常用的是CallerRunsPolicy。任务满了之后让提交线程自己执行天然形成背压机制提交线程被占住就不会继续疯狂提交任务系统能自我限流。8.3 线程池中线程出现异常时的处理线程池有个隐蔽的坑任务抛异常时execute()提交的任务异常会被吞掉线程直接终止并重新创建新线程submit()提交的任务异常则包在Future里必须调用future.get()才能看到。如果线上任务频繁抛异常但代码里没有获取返回值日志里什么都看不到只能看到线程数不断变化。排查思路是把异常捕获并统一记录日志比如execute方式提交时包一层try-catch。8.4 线程池状态机与优雅关闭ThreadPoolExecutor内部用一个AtomicInteger保存线程池状态和工作线程数。状态从RUNNING到SHUTDOWN、STOP、TIDYING、TERMINATED关键是shutdown()只停止接收新任务已经在执行的和队列里的任务会被继续执行shutdownNow()则是中断所有正在执行的任务并返回未执行的任务列表。关闭时需要根据业务容忍度选择能等就shutdown()等待终止不能等就shutdownNow()补偿处理未完成任务。9. 并发编程实战中我建议你记住的几件事写到这里最核心的并发安全知识点都过了一遍。最后说几个我在实际工作中沉淀下来的体会不一定写进教科书但遇到问题时非常有用。第一个体会是并发问题的排查不要在脑子里猜一定要用工具留证据。jstack是最基础也是最有效的排查利器死锁、线程卡死、线程池打满都能在上面看到痕迹。遇到问题先把线程快照存下来再慢慢分析。第二个体会是并发问题的复现往往需要压力测试。很多偶发性并发问题在单元测试里根本测不出来。我自己的习惯是涉及共享状态的代码都会额外写一个并发压测用例用CountDownLatch同时释放多个线程制造竞争条件。如果压测几十万次没有异常才敢上线。第三个体会是锁能不用就不用但该用时不要贪图花哨。无锁编程确实很酷但复杂度高、难调试。实际项目里能用AtomicInteger解决的问题就不要上用AQS自制的同步器能用ConcurrentHashMap先解决并发Map问题就不要去手写分段锁。简单可靠才是生产代码的第一原则。第四个体会是关于背八股本身这些知识点背下来不难难的是把它们变成你真正理解的东西。我的建议是每学一个并发机制都去JDK源码里把对应的类翻出来看一眼。比如看完AQS的acquire流程自己画一遍状态转换图看完ConcurrentHashMap的put逻辑自己去想如果两个线程同时put进同一个桶会发生什么。只要做几次这样的推演面试里的很多深挖问题你就不再是背答案而是真正想明白了。