
有人把Java并发编程比作“玄学”其实根源就在于锁的细节太多、陷阱太隐蔽。而synchronized作为JVM内置的互斥锁几乎是每个Java开发者最早接触、也最容易被“用错”的并发工具。这篇文章我打算把它彻底讲透从它到底锁住了什么到HotSpot底层如何一步步升级锁再到日常开发中怎么写得稳、出了问题怎么查全部按我实际排查和调优的经验来写。内容面向已经会写线程、但想真正理解并发底层的同学也能给正在准备面试的人一些深度素材。1. synchronized到底在锁什么1.1 一段代码的同步语义很多同学对synchronized的第一印象是“给代码块加个锁”但这个理解其实非常模糊。加锁的真正含义是把某段代码与某个对象关联起来进入这段代码前必须先拿到这个对象的“监视器锁”Monitor Lock。语法上有三种写法本质却只有一个——锁的是对象不是代码。// 写法一修饰实例方法锁的是 this public synchronized void deductStock() { // 临界区 } // 写法二修饰静态方法锁的是 Class 对象 public static synchronized void resetStock() { // 临界区 } // 写法三同步代码块锁的是括号里指定的对象 public void pay() { synchronized (lockObj) { // 临界区 } }第一种写法锁的是当前实例对象第二种锁的是类的Class对象第三种最灵活你想锁哪个对象就传哪个对象。很多人踩过的一个坑是实例方法的synchronized和静态方法的synchronized完全不互斥因为它们锁的对象根本不是一个东西。比如扣库存用的是实例方法锁而重置库存用的是静态方法锁这两个操作可以同时并发执行如果它们操作的是同一个库存变量数据照样会错乱。我在模拟项目X里做抢券系统时就把这个理解成了“方法加锁”直到压测时发现库存多扣了才反应过来。并发问题不会因为你用了synchronized就自动消失它只保证“锁同一个对象”的临界区互斥。1.2 可重入性与互斥的本质synchronized还有一个非常重要、但经常被忽略的特性可重入。意思是同一个线程已经拿到了某个对象的锁再次进入这个对象锁保护的代码时不需要重新等待可以直接进入。JVM会为每个对象记录一个锁持有线程和一个计数器同一线程每次进入计数加一每次退出减一计数归零时锁才真正释放。我见过不少新手写递归加锁的代码担心自己把自己锁死这其实大可不必。可重入性设计的初衷就是避免“自己锁自己”造成的死锁。比如一个同步方法调用了另一个同步方法而这两个方法锁的是同一个对象如果锁不可重入程序直接就卡死了。互斥性则保证了临界区的串行访问同一时刻最多只有一个线程能执行被锁保护的代码。但除了互斥synchronized还隐含了一层内存可见性的保证。JMMJava内存模型规定线程在释放锁时会把修改强制刷新到主内存获取锁时则会使本地缓存失效、强制重新从主内存读取。所以它不仅是“拦住别人”还解决了“别人看不到你改了数据”的问题。这一点在实际开发里经常被遗忘很多人以为加锁只是为了互斥忽略了可见性才是并发数据不一致的另一个元凶。2. 底层实现与锁升级HotSpot是怎么玩出花的2.1 对象头和Mark Word里的玄机要说清楚synchronized绕不开对象头。HotSpot JVM里每个对象在内存中都有一块被称为对象头的区域其中包含两部分Mark Word和类型指针。Mark Word里存储的是对象运行时数据包括哈希码、GC分代年龄和锁状态标志位。不同锁状态下Mark Word里装的东西完全不同。无锁状态下存的是哈希码和分代年龄偏向锁状态下存的是持有偏向锁的线程ID轻量级锁状态下存的是指向线程栈中锁记录的指针重量级锁状态下则是指向Monitor对象的指针。正是这块数据的动态变化驱动了整个锁升级的流程。这种设计本质上是一种“空间换状态”的妥协锁信息要尽量不影响对象正常的数据布局又不能占太大空间。Mark Word在64位JVM下是8字节要在8个字节里表达这么多状态只能靠位标志来区分。2.2 偏向锁、轻量级锁、重量级锁的升级路径JDK 1.6以前synchronized是出了名的“重”因为一旦竞争就会直接挂起线程涉及用户态到内核态的切换。1.6之后做了一系列优化引入了锁升级机制。我把这条路径梳理成三个阶段偏向锁JVM认为大多数场景下一个对象只会被同一个线程反复加锁于是干脆把第一次加锁的线程ID写进Mark Word。之后这个线程再来加锁只要检查一下线程ID是否一致就行连CAS都不用。直到有另一个线程来竞争偏向锁才会被撤销。轻量级锁偏向锁撤销后竞争线程会尝试用CAS把Mark Word改成指向自己栈中锁记录的指针。如果成功就拿到了锁没有阻塞也没有系统调用整个过程都在用户态完成。只要持有锁的线程在很短的时间内完成任务释放锁其他线程自旋等待一会儿就能抢到这种体验很接近无锁。重量级锁如果CAS失败或者竞争激烈到自旋超过阈值JVM会根据历史自适应调整锁就会升级为重量级锁。重量级锁依赖操作系统底层的互斥量线程加锁失败会进入阻塞状态后续唤醒需要操作系统介入这才是传统意义上“很重”的锁。这条升级路径是单向的锁只能从偏向锁一路升级到重量级锁绝不可能反向降级。所以从性能角度来看“尽量少的竞争”比“用性能好的锁”更关键。另外补一点偏向锁在JDK 15之后被标记为废弃默认不再启用。因为现代应用线程池模式普遍偏向锁的撤销成本有时反而超过了收益。2.3 JIT层面的锁消除与锁粗化很多人不知道synchronized的性能问题JVM已经在背后帮你做了大量优化其中锁消除和锁粗化是容易被忽视的两个。锁消除发生在JIT编译阶段。如果JVM通过逃逸分析发现某个锁对象只存在于方法内部、不会被其他线程访问到那么这个锁就是多余的直接去掉。最典型的案例是StringBuffer的append方法。它内部是synchronized的但如果编译器发现这个StringBuffer实例没逃逸出方法加锁动作会被直接消除性能等同于无锁。锁粗化则是把相邻的多个加锁解锁操作合并成一个更大的锁块。比如在一个循环里反复加锁解锁JVM可能直接把锁范围扩大到整个循环减少反复获取锁带来的开销。这个优化也解释了为什么我在实际调优时不太纠结于“是不是每一行都要锁”而更关心临界区里的耗时操作和竞争频度。3. 实操细节与编码要点怎么写才不容易埋雷3.1 三种写法的真正区别我在实际开发中的建议是能用代码块就不要用方法修饰。原因很简单方法修饰意味着整个方法都在临界区里而很多时候一个方法只有几行代码真正涉及共享数据。其余逻辑被锁住只会白白增加线程等待时间。比如支付方法里有日志记录、有组装返回值、有调用外部接口真正需要互斥的可能只是“校验库存并扣减”那几行。这种情况下public void pay(Order order) { // 不需要加锁的前置逻辑 String traceId buildTraceId(order); // 只锁必要的一段 synchronized (stockLock) { int stock stockMapper.getStock(order.getSkuId()); if (stock order.getCount()) { throw new IllegalStateException(库存不足); } stockMapper.reduceStock(order.getSkuId(), order.getCount()); } // 后续的不需要加锁逻辑 notifyPaymentResult(order, traceId); }锁的范围越小并发能力越高。这个观点我反复在团队里强调不要有“反正加了锁就安全”的心态加锁是有代价的别让锁帮你扛不该扛的活。类锁和对象锁的选择也值得聊。静态方法加synchronized锁的是Class对象在JVM中一个类只有一个Class实例所以它是全局唯一的。而实例方法锁的是每个实例对象。如果多个线程访问的是同一个实例结果互斥但如果访问的是不同实例加锁形同虚设。所以判断锁是否生效先看一眼你锁的对象是“大家共享”的还是“各自独有的”。3.2 锁对象选择最容易踩的三个坑锁对象选择不当是线上并发问题的重灾区。我踩过以及帮别人排查过的坑基本集中在三类字符串字面量作为锁。比如直接在代码里写synchronized (lock)。字符串字面量会进入常量池同一个虚拟机里所有引用同样字面量的代码共享同一个String对象。这意味着你本来只想锁自己的库存逻辑结果隔壁模块也拿这个字符串加锁两边就被莫名其妙地关联上了互相拖慢甚至死锁。我排查的一个案例就是这样“某个订单接口突然超时”最后发现另一个无关模块用同一个字符串常量做锁两边形成了循环等待。包装类型作为锁。Integer、Long这类对象存在缓存机制比如Integer默认缓存-128到127。synchronized (Integer.valueOf(1))拿到的是缓存里的同一个对象所有线程都在这同一个对象上竞争而一旦数据超过缓存范围每次valueOf都会产生新对象锁又“各锁各的”完全失效。再加上包装类型不可变给锁变量重新赋值相当于换了一把锁老锁形同虚设。锁对象的引用被改变。用非final字段做锁对象代码里又给这个字段重新赋值等于把一个可变目标当成锁。线程A拿到旧对象的锁进入临界区线程B却在新对象的锁上等待两个线程根本没竞争同一把锁互斥被完全破坏。所以锁对象一定要是final的或者在初始化后永远不改引用。我现在的习惯是专门声明一个final Object作为锁或者用已有的final业务对象比如库存领域对象。语义清晰也不容易被误改。private final Object stockLock new Object();3.3 与wait/notify写作时要格外小心synchronized经常和wait/notify一起出现这里有个硬性规则必须刻在脑子里wait、notify、notifyAll只能在synchronized块或synchronized方法中调用否则运行时抛出IllegalMonitorStateException。wait做的事情比“傻等”复杂得多它会释放当前线程持有的监视器锁并把线程放入该对象的等待集。这正是让其他线程有机会进入临界区的前提。经典的生产者消费者结构是这样的synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } Item item queue.poll(); // 处理 item queue.notifyAll(); }注意循环判断条件用的是while不是if。原因是线程被唤醒后需要重新检查条件是否真的满足。如果是多个生产者和多个消费者在协作一个消费者被唤醒时队列可能已经被其他消费者取空了。而wait在循环中重新判断能够避免“虚假唤醒”导致的数据错乱。notify和notifyAll的选择也有很多讲究。notify只唤醒一个等待线程如果被唤醒的线程发现条件不满足会继续等待此时组内其他线程却没有被唤醒就可能发生“信号丢失”。我建议在没有十足把握的情况下优先用notifyAll虽然代价是多唤醒一些线程但至少不会出现永久等待。4. 常见问题与排查技巧实录4.1 死锁定位与预防死锁是synchronized场景里最有名的问题。四个必要条件被说过无数遍互斥、持有并等待、不可剥夺、循环等待。一旦四个条件同时成立死锁就发生了。定位死锁我有一套固定的操作流程。先找到疑似卡死的线程用jstack导出线程快照。如果存在死锁线程Dump里会明确输出“Found one Java-level deadlock”字样并列出循环等待的链路。比如线程A持有锁A等待锁B线程B持有锁B等待锁A链路一目了然。jstack pid thread_dump.txt预防死锁最有效的办法是破坏“循环等待”所有线程按照相同的全局顺序获取多把锁。比如锁对象定义时就规定必须先拿库存锁再拿账户锁所有人都遵守就不会形成环路。另一个做法是尽量用带超时语义的并发工具替代synchronized做多锁操作比如ReentrantLock的tryLock拿不到锁可以快速失败而不是无限等下去。这两点我会在下一节细讲。4.2 锁粒度与性能权衡锁粒度是小到代码级别就值得关注的性能问题。持锁时间越长其他线程等待的概率越高。常见的不良习惯有三类在锁里做远程调用或IO操作、在锁里执行重计算、在锁里写日志。这些操作耗时高且与共享数据无关建议一律移出临界区。延迟初始化的双重检查锁写法也值得单独说一下。用synchronized做延迟初始化时很多人会写双重检查来避免每次获取锁private volatile Cache cache null; public Cache getCache() { if (cache null) { synchronized (this) { if (cache null) { cache new Cache(); } } } return cache; }这个写法里cache变量必须用volatile修饰。因为new Cache()并不是原子操作它在字节码层面要经过分配内存、初始化对象、把引用赋值给变量这几个步骤。如果变量不加volatile另一个线程可能看到“已赋值但还没有完成初始化”的中间状态拿到的就是一个半成品对象。这是并发编程里非常经典的“安全发布”问题。4.3 synchronized与ReentrantLock怎么选很多人一见到并发就条件反射式地用Lock接口但我觉得这个选择应该更理性一些。synchronized和ReentrantLock的能力并不完全重叠。ReentrantLock多了几个synchronized给不了的东西可中断等待、带超时的获取锁、公平锁选项、多条件变量Condition。如果你的业务需要tryLock这种“等不到就放弃”的能力或者需要多个等待队列协作那就应该用ReentrantLock。如果只是普通的临界区互斥没有中断和超时需求我倾向于直接用synchronized。理由有三点它的语法更简单代码可读性更好JVM会对它做锁升级、锁消除、锁粗化一系列优化不需要手动在finally里释放锁从根本上避免“忘记解锁”导致的水久锁死。ReentrantLock一旦忘记解锁锁可能一直处于被持有状态排查起来相当痛苦。我经历过一个比较典型的误用场景接口只需要保证同一时间只有一个线程执行某个操作却用了ReentrantLock还加了非常复杂的Condition管理代码难读不说还出现过唤醒丢失。最后改成synchronized代码块十几行代码搞定问题消失。工具不是越强大越好是越匹配场景越好。5. 一点个人体会最后分享一个我印象很深的调试经历。某次做并发压测一个订单接口在高并发下出现了超时线程Dump显示大量线程在等待一个锁。按图索骥找过去发现锁对象是一个数据库实体的实例。这个实体每次从数据库查出来都是新对象也就是说每次请求都在“各自的锁”上等待看起来代码里加了锁实际上锁完全没有互斥作用。后来我把锁改成一个静态final Object常量超时问题立刻消失。事后复盘这种问题比死锁更隐蔽因为死锁会立刻暴露而“锁失效”只会表现为诡异的数据错乱和性能问题。锁这种东西用得对是并发安全的基础用得不对就是隐患源头。我写这篇文章想传达的核心观点其实是朴素的一句话先搞清楚你锁的是哪个对象再考虑锁的粒度够不够小最后才轮到讨论用什么锁。顺序反过来迟早要踩坑。这套思路在我维护过的多个并发项目里都验证过希望你也能少走弯路。