前阵子在调一个后台批量任务时,遇到挺邪门的事:一段加了Synchronized修饰的方法,跑多线程居然还是出现数据错乱。第一反是代码写错了吗?翻来覆去看了好几遍,锁确实加了,但问题出在锁的对象根本不是同一个实例上。这种问题在Java并发的实际项目里太常见了,很多人会把synchronized当成一种"给代码块加锁"的魔法,却没有真正理解它锁住的到底是什么。这篇就从用法到字节码,从对象头到锁升级,再到我踩过的几个坑,把Synchronized整个链条系统性地捋一遍,希望对正在并发编程上摸索的人有帮助。
1. 从一次并发计数事故说起:Synchronized到底锁住了什么
1.1 一个经典的"count++不是20万"问题
先看一个最经典的反直觉案例。假设有一个共享变量count,两个线程分别对它执行count++十万次,很多人想当然地以为结果是20万。但你实际跑起来会发现,结果经常是19万8、19万7这种数,极少能精确到20万。
原因并不神秘。count++在Java代码里看起来是一行语句,但在字节码层面它是好几条指令:读取当前值、把值加1、把新值写回。两个线程同时执行时,完全可能同时读到同一个旧值,然后各自加1再写回,结果就是两次加操作只有一次生效,这个现象一般叫"丢失更新"。
当初某模拟项目X里出现库存扣减异常,追根溯源就是这么个道理。分布式之前,先在单机多线程这一层解决互斥问题是最基础的。而Java里最直接、最基础的手段就是synchronized。
1.2 锁的是对象,不是代码
synchronized这个关键词有一个核心认知:它锁的是Java对象,而不是某一行代码或某一个函数。在JVM的视角里,每个对象都有一个隐含的"监视器锁",叫作Monitor。线程进入synchronized临界区前,必须先拿到这个Monitor的所有权;拿不到就阻塞等待;拿到之后独占执行,执行完释放。
有一个很容易被忽略的推论:如果两个线程操作的是同一个对象,它们之间才会有互斥;如果它们锁的根本是两个不同的对象,那synchronized形同虚设。这也就是我一开始提到的那个问题——方法上明明写了synchronized,日志里还是有并发冲突,最后发现不同请求拿到的锁对象是各自new出来的实例,自然谈不上互斥。
理解这一点之后,再看synchronized的三种使用方式就会很清晰:修饰实例方法锁的是this;修饰静态方法锁的是Class对象;修饰代码块锁的是括号里指定的对象。三者本质一样,只是锁的目标不同。
2. 三种加锁姿势与字节码背后的机制
2.1 实例方法、静态方法、代码块怎么选
通常一个类里写synchronized有下面三种位置:
// 方式一:修饰实例方法 public synchronized void add() { count++; } // 方式二:修饰静态方法 public static synchronized void addStatic() { staticCount++; } // 方式三:修饰代码块 public void addBlock() { synchronized (lockObj) { count++; } }三者锁的目标对象完全不一样。方式一眼花缭乱但本质是锁住this,因此同一个实例的不同synchronized方法之间会互斥,不同实例之间不会;方式二锁住的是当前类的Class对象,因此不管创建多少个实例,所有通过该类静态方法进入临界区的线程都会互斥;方式三最灵活,能精确锁定某一个对象。
实际写代码时我的习惯是:能用代码块就不用方法修饰。原因很直接——方法级Synchronized锁的粒度是整个方法体,如果你方法里前半段在做耗时的初始化、IO,后半段才操作共享数据,那这耗时期间也把其他线程堵在外面了。用代码块可以精确保护中间那几行,把锁的临界区缩到最小,这是降低锁竞争最有效的代码层面的手段之一。
2.2 从javap看monitorenter和monitorexit
如果反编译一个加锁的方法,能看到更底层的真相。例如对如下代码执行javap -c:
public void addBlock() { synchronized (lockObj) { count++; } }反编译结果里核心指令是monitorenter和monitorexit。monitorenter出现在进入同步块的入口位置,monitorexit出现在同步块正常结束之后,而且编译器会自动生成一个monitorexit放在异常处理器里,保证即使同步块内抛出异常,锁也会被释放。
这一步初看是一个实现细节,其实解释了为什么synchronized不会像手动lock()/unlock()那样容易造成死锁和锁泄漏——因为释放锁的操作是JVM帮你兜底的,无论正常执行还是异常抛出,都会走到同一个出口。这是synchronized比ReentrantLock更"安全"的根本原因之一。
2.3 可重入性到底是怎么来的
提到synchronized就不能不说可重入。所谓可重入,就是同一个线程已经持有一把锁的前提下,它可以再次进入同一把锁保护的临界区,而不会自己把自己死锁。
比如下面这段典型的递归代码:
public synchronized void recursion(int n) { if (n <= 0) return; recursion(n - 1); }如果没有可重入机制,递归进入第二层时就会发现自己还没释放锁,直接死锁。而JVM的实现方式是:Monitor对象内部维护了一个计数器,线程第一次进入时计数器为1,之后每进入一次就加1,每正常退出一次减1,减到0时才真正释放锁。这也是为什么synchronized不像显式锁需要记录"持有者的重入次数",这些信息JVM全都帮你维护好了。
3. 对象头与Monitor:底层到底发生了什么
3.1 对象在内存里不只是数据
要理解synchronized为什么能锁对象,得先知道Java对象在堆内存里的布局。一个普通对象的存储结构大致分为三块:对象头、实例数据、对齐填充。其中对象头又分为两部分:Mark Word和Klass Pointer。Mark Word里存放的是对象的元数据,例如哈希码、GC分代年龄、锁相关的标记位;Klass Pointer指向这个对象所属的Class元数据。
以64位JVM为例,Mark Word是8字节。这8个字节在不同状态下存的含义不一样:无锁时可能存哈希码和分代年龄;轻量级锁时指向栈中的锁记录;重量级锁时指向Monitor对象的指针;偏向锁时存持有线程的ID。所以说,锁状态信息是直接内嵌在对象头里的,这也是为什么"锁对象"必须是活生生的Java对象,而不是一个方法名或一行代码。
3.2 Monitor的等待与唤醒逻辑
Monitor这个概念可以理解为一把"有排号机制的锁"。HotSpot虚拟机中,每个对象关联的Monitor由C++层面的ObjectMonitor实现,核心结构包含Owner、EntryList、WaitSet。Owner是当前持锁线程;EntryList是等待获取锁的线程队列;WaitSet是调用了wait()方法后被阻塞的线程队列。
打个生活化的比方:一个公共厕所只有一个坑位。Owner就是正在坑位里的人;EntryList是排队等着用坑的;WaitSet是中途觉得肚子疼想去旁边休息一会儿的人。wait()相当于从坑位出来去WaitSet蹲着等通知;notify()相当于通知WaitSet里的某个人重新回EntryList排队;只有重新拿到Owner资格才能继续执行。
3.3 为什么wait/notify必须在synchronized块里
有个初学者经常问的问题:为什么Object.wait()和Object.notify()只能在synchronized代码块里调用?这个限制跟Monitor的内部机制正好对应。
wait()的本质是释放当前线程持有的Monitor并进入WaitSet,这要求线程必须已经被Owner身份验证过。如果在没有持有锁的情况下直接调用wait(),JVM无法确定要释放哪个Monitor,也不知道该把你放进哪个对象的WaitSet里。notify()也一样,它要操作的是某个对象的WaitSet,前提是你必须拥有这个对象的Monitor权限,否则就是"你凭什么指挥我队列里的人",线程安全性直接就崩了。
4. 锁升级全流程:从偏向锁一路走向重量级锁
4.1 为什么需要锁升级
聊到锁升级,得先讲一点历史。早期JDK里synchronized只有一种重量级锁实现,线程竞争时会直接进入内核态阻塞和唤醒,涉及系统调用、线程切换,开销非常大。那时候性能测试经常能看到一个结论:能不用synchronized就别用。这也是很多老程序员对synchronized形成"性能差"刻板印象的来源。
JDK 6之后,HotSpot对synchronized做了大量优化,引入偏向锁、轻量级锁、自适应自旋,锁状态只能单向升级,不能降级。方向很明确:能不用系统调用就不用系统调用,能用CPU自旋解决就不用线程挂起。
4.2 锁状态转移的完整过程
先看Mark Word里锁标志位的变化吧。无锁和偏向锁对应标志位01,轻量级锁对应00,重量级锁对应10。四个状态的破坏力递增,代价也递增。
- 偏向锁:只有第一个线程进入临界区时,JVM会把线程ID记录在Mark Word里。之后这个线程再次进入,只需比对线程ID一致,直接放行,全程没有CAS、没有内核切换。适合单线程反复访问临界区的场景。
- 轻量级锁:当第二个线程也来竞争时,偏向锁需要撤销。此时Mark Word变成指向线程栈中Lock Record的指针,通过CAS去替换对象头,抢不到就自旋等待。
- 重量级锁:自旋也有成本。当自旋达到一定次数或者竞争线程太多时,JVM会把轻量级锁膨胀为重量级锁,Mark Word指向Monitor地址,拿不到锁的线程直接进入阻塞状态,由操作系统调度唤醒。
大概路径就是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。每个升级步骤的处理逻辑我在这里简化了,但理解这个主流程,对排查线上问题已经足够。
4.3 自旋为什么不是万能药
轻量级锁失败后,JVM不会直接挂起线程,而是让它自旋一小段时间。自旋的本质是让线程用CPU循环去反复尝试获取锁,好处是避免了线程上下文切换,坏处是占着CPU空转。
这里有个很关键的权衡:如果临界区非常短,共享数据的写操作就一两行代码,那么自旋通常一两微秒就能等到锁,远胜于线程挂起再唤醒的开销;但如果临界区里有数据库操作、远程调用这种耗时操作,自旋很可能白白浪费大量CPU时间。因此在实际项目中,不要把耗时的业务逻辑放在锁里面,这个原则比任何锁参数调优都重要。
4.4 锁消除和锁粗化是JVM的小动作
除了偏向锁、轻量级锁,JIT还有两个自动优化可能被忽略。
锁消除很典型:如果一个锁对象只有当前线程能访问,根本不存在共享竞态,JVM检测到之后会直接消除这个synchronized块。这也是为什么你在某些本来会出现明显不必要加锁的代码里跑性能测试,结果居然不太离谱——因为JVM已经悄悄帮你把锁扔掉了。
锁粗化则相反。如果JVM检测到连续多个monitorenter和monitorexit之间没有足够的工作量,比如for循环里的重复加锁,它可能把多个同步块合并成一个更大的同步范围,减少锁的获取和释放次数。这两个优化在源码层面是不可见的,但它们解释了为什么同样的代码在不同JVM版本下表现可能差异很大。
5. 与ReentrantLock如何选型:性能真相与取舍建议
5.1 一张表看完两者的能力差异
很多人纠结synchronized和ReentrantLock怎么选。先看核心差异:
| 维度 | Synchronized | ReentrantLock |
|---|---|---|
| 锁的释放 | JVM自动释放,异常也安全 | 必须显式unlock,需要finally配合 |
| 可中断获取 | 不支持,无法响应中断 | 支持lockInterruptibly |
| 尝试获取 | 不支持 | 支持tryLock,抢不到立刻返回 |
| 公平性 | 非公平 | 可配置公平或非公平 |
| 条件等待 | 每个锁只有一个wait/notify队列 | 支持多个Condition,更精细 |
| 性能 | JDK6后经过优化,常规场景不虚 | 竞争激烈场景表现稳定 |
这张表看过之后,最常见的场景其实已经能定论了:默认优先用synchronized。它写起来简单,不用手动管理释放,也不容易因为漏写unlock()导致后续线程全部卡死。模拟项目X里的并发任务模块,我全面清理过一次,凡是能用synchronized的地方统统换了回来。
5.2 性能真相:别拿十年前的结论说事
关于性能,我不止一次看到有人张口就是"ReentrantLock比synchronized性能好很多"。这个说法在JDK 5时代大概率成立,但JDK 6之后synchronized有了锁升级机制之后,两者在低竞争场景下的性能已经非常接近,甚至在很多基准测试里synchronized还略占上风,因为它没有额外的锁对象分配成本和显式释放成本。
ReentrantLock真正的优势并不在于"更快",而在于能力更丰富:可以tryLock指定时间内抢锁,可以响应中断,可以创建多个条件变量,可以做公平排队。这些能力是synchronized给不了的,而不是性能给的信心。
5.3 什么场景才应该切换ReentrantLock
我的选型原则比较简单,满足下面任一条件才建议切到ReentrantLock:
- 需要尝试获取锁,比如抢不到就去做别的事情,能用
tryLock(2, TimeUnit.SECONDS) - 需要可中断获取锁,比如用户取消请求后线程能立刻响应
- 需要多个条件队列协调,一个锁需要精细地唤醒特定类型的等待线程
- 需要公平锁,避免高并发下某些线程长期抢不到锁
如果只是想保护一个共享变量或者一段短的临界区,老老实实写synchronized就够了。
6. 翻车现场复盘:锁失效、死锁与排查思路
6.1 最隐蔽的坑:换锁对象导致锁形同虚设
之前我在某模拟项目X里踩过一个非常隐蔽的坑。代码结构大概是这样的:
private Object lock = new Object(); public void update() { synchronized (lock) { // 更新逻辑 } } public void resetLock() { lock = new Object(); }表面看所有线程都锁同一个成员变量lock,但如果某个线程在运行时把lock重新指向一个新对象,那后续线程锁的就是新对象,和之前线程锁的旧对象完全不是一把锁。互斥直接失效,数据又开始错乱了。
排查这类问题有一个直接经验:锁对象尽量用final修饰,或者直接锁this和类字面量。如果你发现自己需要"动态换锁对象",多半是设计思路出了问题,而不是真的有什么高级玩法。
6.2 字符串和包装类型做锁的隐患
另一个容易翻车的是用字符串字面量做锁对象。比如:
synchronized ("cache_lock") { // 缓存清理逻辑 }问题在于字符串常量在JVM的字符串常量池里是共享的。两个毫无关系的模块,如果都用了"cache_lock"这个字符串做锁,它们之间就会互相阻塞;反过来,你以为两端代码都在用同一个字符串,但如果其中一端用了new String("cache_lock"),锁对象又不是同一个了。更常见的是Integer等包装类型,因为自动装箱带来的对象复用(比如-128到127缓存),用它做锁对象会引出非常诡异的连带阻塞问题。所以我的建议很明确:锁对象的类型就老老实实用Object,不要搞花样。
6.3 死锁定位:jstack操作实录
synchronized虽然不会因为异常造成锁泄漏,但可重入并不解决多线程互相等待的问题。经典死锁就是线程A持有锁1去拿锁2,线程B持有锁2去拿锁1,两个线程都在等对方释放,谁也不让步。
遇到死锁,第一件事不是翻代码猜,而是把现场的线程信息抓出来。操作非常简单,先用jps找到Java进程ID,然后执行jstack <pid>。输出里如果存在死锁,会在末尾直接打印类似Found one Java-level deadlock的提示,并且列出每个线程当前阻塞在哪一行、等待哪一把锁、持有哪些锁。
有一次线上任务卡死,我抓完dump后一眼就定位到两个定时任务互相持有对方的锁对象。修复方式也不复杂,让两个线程按照固定的全局顺序获取锁,比如先锁序号小的再锁序号大的,就能从根上避免循环等待。这个"按固定顺序加锁"的思路在单体应用里往往就够用了。
6.4 临界区过长是竞争的放大器
最后一类问题不是Synchronized本身的问题,而是使用方式的问题。有些同学把远程调用、数据库事务、文件写入都塞进同步块里,锁的持有时间拉得极长,其他线程全部堆积在锁外,服务吞吐量骤降。
处理思路其实就一句话:把锁的范围缩小到操作共享数据的那几行代码。IO操作、RPC调用这些尽量放到锁外面。如果数据一致性要求实在太高,锁外面拿到的中间结果也必须保证幂等和可重试。这个原则配合前面的锁升级机制理解,就能明白为什么长临界区对锁性能的破坏几乎是灾难性的。
最后聊点实际操作层面的感受吧。我在维护这段并发代码的过程里慢慢养成一个习惯:每次改动涉及共享变量,都会先问自己三个问题——这个变量真的需要同步吗?锁定的对象是不是所有写线程都持有的同一个实例?临界区还能不能再缩小一点?这三个问题问完,大部分并发Bug都能在写代码阶段就被拦住。Java并发里的坑很多,但Synchronized这套链路如果你能从上到下理解透,至少单机多线程竞争这一层,你已经能处理得明明白白了。