
网上讲 synchronized 的文章一抓一大把但多数停在“会用”的层面一追问底层就含糊了。我最近在系统梳理 Java 并发这一篇是线程学习笔记系列的第六篇正好把 synchronized 这个最老牌、但最容易翻车的并发原语从头到尾过一遍。先从一个早期写代码时踩过的真实场景说起。一个库存扣减模块逻辑是先查库存再扣减两个操作之间没做任何互斥。压测数据一乱超卖问题直接暴露。第一版用 synchronized 修复但锁加在整个方法上一个线程扣库存时其他线程全部排队性能立刻崩了。后来改成细粒度的锁对象才把吞吐量救回来。这个例子说明synchronized 本身不复杂复杂的是“怎么锁、锁什么、锁多大范围”这些选择。这篇文章面向正在进阶 Java 并发、或者准备面试技术细节的朋友我会把 synchronized 的使用方式、底层原理、锁升级机制、以及实际开发里的避坑经验全部串起来讲。内容不算特别“入门”但跟着走完一遍你对 synchronized 的理解会比大多数工作两三年的人扎实得多。1. synchronized到底在解决什么问题1.1 并发Bug的三个根源原子性、可见性、有序性先说个经典问题两个线程同时对 count 执行一万次自增操作最后结果几乎不可能等于 20000。原因很简单count 不是一步操作它其实拆成了三步读 count、加 1、写回 count。如果两个线程同时读到相同初始值各自加完再写回数据就丢了一次更新。这就是并发编程的第一个敌人原子性被破坏。一个复合操作被多个线程穿插导致结果不符合预期。第二个敌人是可见性。一个线程改了共享变量的值但另一个线程不一定能立刻看到。原因是 CPU 缓存的存在——线程可能操作的是本地缓存副本而不是主内存。比如线程 A 修改了 flag true线程 B 一直在自旋检查 flag如果没有合适的同步机制B 可能永远看不到 A 的修改进而陷入死循环。这在实际的高并发系统里非常隐蔽因为本地复现难度高出问题之后日志又看不出根因。第三个敌人是有序性。编译器为了提高执行效率可能会对代码指令做重排序。单线程下重排序不会改变最终结果因为 JMM 保证单线程内的执行语义一致。但多线程下指令重排可能导致一个线程先看到另一个线程“还没准备好”的数据。举个典型的双重检查锁单例问题new 对象的过程在 JVM 层面可能被拆成“分配内存、设置引用、执行构造器”几步指令重排后可能先设置引用再执行构造器另一个线程拿到引用后发现对象还没初始化完成。这三个问题单独用 volatile 只能解决可见性和一部分有序性解决不了复合操作的原子性单独用原子类能解决一些计数场景但在需要多步操作绑定在一个原子单元里时也不好办。synchronized 则是一次性解决这三个问题的经典方案。1.2 synchronized如何保证原子性、可见性和有序性synchronized 的核心逻辑很简单同一时刻只能有一个线程进入临界区被 synchronized 保护的代码块。这个“互斥”直接保证了原子性——你进去之前别人必须出来临界区里的多步操作不会被其他线程穿插。关键是可见性怎么保证。Java 内存模型里有一条和锁相关的重要规则解锁操作发生在后续对同一把锁的加锁操作之前。这意味着当线程 A 释放锁之后A 在临界区里改过的所有变量都会被刷回主内存当线程 B 获取同一把锁之后B 会从主内存重新加载这些变量。所以 A 写的内容对 B 100% 可见。这一条机制网上很少有人讲透但面试时只要提出来对方立刻知道你读过 JMM 的源码级资料。对于有序性synchronized 的保障是通过互斥实现的。你没法在持有锁的同时被其他线程观察到半成品状态因为其他线程根本进不来。加上 Happens-Before 规则后续线程拿锁后看到的一定是完整执行后的结果所以“有序性”在临界区里不会出幺蛾子。注意synchronized 能解决原子性的前提是“锁的对象一致”。如果两个线程操作同一份共享数据却分别用两把不同的锁那和没加锁没区别。这个细节第 2 节会详细展开。2. synchronized的三种写法锁对象千万别搞混2.1 同步实例方法锁的是this给实例方法加 synchronized锁的是当前对象也就是 this。同一个对象实例上多个同步实例方法之间互斥但不同对象实例上的同步方法互不干扰。这个很好理解因为每 new 一个对象它都有自己的内置锁。看一段代码public class SyncExample { private int count 0; public synchronized void increment() { count; } public synchronized int resetAndGet() { int temp count; count 0; return temp; } }两个线程如果都调用同一个 SyncExample 实例的 increment那么二者互斥。但如果是两个不同的 SyncExample 实例两个线程分别调用各自的 increment则可以并行执行。所以当你用“synchronized method”锁实例时必须确保操作的是同一个对象。2.2 同步静态方法锁的是Class对象静态方法的 synchronized 锁的不是 this而是这个类对应的 Class 对象。Class 对象在 JVM 中是全局唯一的所以多个线程、多个实例调用同一个类的同步静态方法时全部互斥。public class SyncStaticExample { private static int total 0; public static synchronized void add(int value) { total value; } }面试题里经常问一个普通 synchronized 方法一个 static synchronized 方法线程能同时进入吗答案是可以。因为普通方法锁的是实例对象静态方法锁的是 Class 对象两者是不同对象互不限制。这个考点我在面试别人时几乎每次都会问能答对的人不到一半。2.3 同步代码块锁的是你指定的对象代码块方式最灵活你可以指定任意对象作为锁。这是实际开发中最常用的写法因为它可以精确控制“锁的粒度”。public class BlockExample { private final Object lock new Object(); public void process() { // 不需要同步的代码可以放在外面 doPrepare(); synchronized (lock) { // 只有这一段是临界区 doCriticalWork(); } doAfter(); } }如果同步的是某一个共享变量也可以用“瘦锁”写法直接把那块代码包起来。用代码块方式你可以把耗时的、不涉及共享资源的操作放到临界区外面让并发能力最大化。2.4 三种方式的互斥关系对照我总结了一张表平时快速回顾很方便写法锁对象互斥范围使用场景synchronized 实例方法this 实例对象同一实例的同步方法之间互斥方法内所有操作都涉及同一组共享状态synchronized 静态方法Class 类对象该类的静态同步方法全局互斥静态共享资源、全局计数器等synchronized 同步代码块任意指定对象同一 lock 对象的块之间互斥只需要锁一部分关键代码兼顾性能搞清楚锁对象是谁比记住语法重要得多。我见过太多人把 synchronized 加上去就完了根本没搞明白自己锁的是哪个对象导致两种经典问题锁不住锁对象不一致和锁过头全局锁把并发性能拖垮。3. 从字节码到对象头看看synchronized底层到底干了啥3.1 反编译看到的monitorenter与monitorexit用 javap 反编译一个同步代码块你能看到非常直观的字节码指令。我写个例子public void syncBlock() { synchronized (this) { System.out.println(hello); } }核心字节码长这样public void syncBlock(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 // Field System.out 7: ldc #3 // String hello 9: invokevirtual #4 // Method println 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 19: monitorexit 20: aload_2 athrow注意看指令里有两次 monitorexit第 13 行是正常执行完临界区后的释放锁第 19 行是异常路径上用的确保临界区抛异常时锁也能被释放。所以 synchronized 的锁释放是 JVM 帮你兜底的——这点上它比手动加锁的老 C 代码友好太多。至于同步方法字节码层面并不需要 monitorenter/monitorexit而是通过在方法上标记 ACC_SYNCHRONIZED 标志位来实现。JVM 根据这个标志判断是否要获取对象锁后再进入方法。这个细节很多人没注意过但理解之后你就知道为什么 synchronized 方法在性能上能做到“零额外开销”的情况下抑制重排序。3.2 对象头里的Mark Word锁状态就藏在这几个bit里每个 Java 对象在内存中的布局由三部分组成对象头、实例数据、对齐填充。对象头里有一块叫 Mark Word占 8 个字节64 位 JVM 下它存放的信息会根据对象状态动态变化。无锁状态下Mark Word 记录的是对象的 hashCode、GC 分代年龄、偏向标志以及锁标志位给个粗粒度示意不同版本 JVM 实际位布局会有区别无锁/偏向锁偏置标志位 01轻量级锁标志位 00指向栈中锁记录重量级锁标志位 10指向 ObjectMonitor 对象GC 标记标志位 11也就是说synchronized 的锁状态并不是凭空存在的而是直接“借用”了对象头里的空间。这也是为什么 HotSpot 能在 JDK 6 之后做出锁升级优化——锁状态的变化本质上就是 Mark Word 里几个 bit 的改写。3.3 Monitor机制与可重入性每个对象都有一把“内部门锁”HotSpot 虚拟机里每个对象都可以关联一个 ObjectMonitor 对象这就是常说的 Monitor。ObjectMonitor 有几个关键字段owner当前持有锁的线程recursions重入计数器WaitSet调用了 wait 方法后被挂起的线程队列EntryList等待获取锁的线程队列当一个线程执行 monitorenter 时JVM 会尝试获取对象的 Monitor。如果 owner 为 null则直接设置当前线程为 owner如果 owner 已经是当前线程recursions 加 1这就是可重入性的来源。synchronized 是重入锁同一线程可以重复获取同一把锁不会死锁在自己手里。用一个生活类比会议室的门上有一把电子锁。谁刷卡进门系统就记录谁在占用。这个人中途如果先去里间的资料室拿文件资料室门上也是同一把锁的逻辑系统知道当前占用者还是他直接放行不需要重新刷卡。这就是重入。如果系统不开这个后门持有锁的人自己再去拿锁就得把自己锁死在门外了。至于 wait 和 notify它们必须写在 synchronized 块里本质原因就是它们依赖 Monitor 的 WaitSet。调用 wait 时线程会释放锁并进入 WaitSetnotify 时唤醒 WaitSet 中的等待线程。如果不在临界区里调用就会抛 IllegalMonitorStateException。这个机制同时解释了一个很反直觉的问题wait 会释放锁但 Thread.sleep 不会。sleep 只是让线程休眠锁纹丝不动地还攥在手里。4. 锁升级这一路才是synchronized性能反转的关键4.1 为什么JDK 6要费劲搞锁升级在 JDK 5 及之前synchronized 依赖 Monitor 实现每次加锁解锁都会涉及操作系统的互斥原语线程阻塞和唤醒时要切换到内核态。频繁竞争时上下文切换开销非常大所以那会儿大家普遍认为“synchronized 性能差”并造出了各种锁优化工具类。但到了 JDK 6HotSpot 团队引入了偏向锁、轻量级锁、锁膨胀等一系列优化把 synchronized 在不同竞争场景下的开销降下来了。锁升级的整体目标就是在不同竞争强度下用最经济的同步方式避免一上来就动刀动枪地调用操作系统互斥。偏个题说一句现在去网上搜腾讯阿里等大厂的面试题synchronized 相关必问“锁升级”。如果你能把这几层讲清楚是非常加分的因为很多工作几年的人都只记得结论、没理解过程。4.2 四个状态无锁、偏向锁、轻量级锁、重量级锁无锁状态很简单就是对象刚 new 出来没有人抢。偏向锁是为了处理“一段同步代码只会被一个线程反复执行”的常见情况。第一次拿到锁时线程通过 CAS 把自己的 ThreadID 写入对象头的 Mark Word同时把偏向标志位置 1。之后同一线程再次进入同步块时不需要再执行 CAS只要判断 ThreadID 一致就能直接进入。代价极低几乎接近无锁。但偏向锁不是没有成本。如果出现第二个线程来抢偏向锁就得撤销。撤销会发生在安全点JVM 需要暂停持有锁的线程判断它是否还在临界区。如果已经退出则把对象头恢复为无锁状态如果还在就升级为轻量级锁。单次偏向撤销的代价甚至比轻量级锁本身还高所以高并发抢锁场景下偏向锁反而可能帮倒忙。这也是后来 JDK 15 开始默认禁用偏向锁的原因之一。轻量级锁的处理方式是线程在栈帧中创建锁记录用 CAS 尝试把对象头 Mark Word 替换成指向锁记录的指针。如果成功说明抢到锁失败说明有竞争锁会膨胀为重量级锁。轻量级锁的设计初衷是“多个线程交替执行临界区没有真实竞争”所以用 CAS 自旋代替线程阻塞能避开操作系统级别的线程切换。重量级锁就是我们前面说的 Monitor 方式。当 CAS 自旋超过限制或自旋期间又来了新线程锁就会升级为重量级锁。这时候线程会被挂起真正进入操作系统的互斥模型。竞争激烈时这才是保底的正确方案——所有线程排队保证公平性其实也非绝对公平非公平锁。注意一个关键点锁升级是单向的只能升不能降。一旦升到重量级锁就一直保持重量级锁状态直到对象被 GC。我自己调试过很多并发程序见过这种“锁永久膨胀”导致的性能诡异下降排查时先得猜这层。4.3 锁消除与锁粗化JIT顺手做的两件好事除了这四层锁状态还有两个 JIT 编译优化很多人没留意但很值得知道。锁消除如果 JVM 通过逃逸分析发现某个对象只能在线程内部使用根本不会被其他线程共享那对这个对象加锁就是多余的。JVM 会直接消除掉这些锁典型场景是在方法内部 new 了一个临时对象并用 synchronized 包了一下但对象本身没有逃出方法。这时候同步代码实际执行起来可能完全没有成本。锁粗化如果代码里连续多个 synchronized 块用的都是同一把锁JVM 会把这些小块合并成一个更大的临界区减少反复加锁解锁的开销。比如循环内部每次都同步一把锁JIT 可能会把整个循环体都粗化成同步块。这个优化虽然减少了锁操作次数但也会让临界区被撑大所以并不是循环里一写 synchronized 就万无一失。JIT 的优化是黑盒的全凭运行时决定。这也是为什么性能调优时不能光靠推理一定要用真实负载在目标机器上压测。5. 实战避坑指南这些坑我替你踩过了5.1 用String和Integer当锁对象出问题都不好查用字符串字面量当锁是新手重灾区public void process(String key) { synchronized (key) { // 危险 // 业务逻辑 } }问题出在 JVM 的字符串常量池。多个地方如果传入的字面量值相同比如都传 vip那么它们可能指向同一个 String 对象。本来两个业务方法只是为了互斥访问各自的数据却因为锁对象碰撞被强行串行甚至出现完全不相干的两个业务互相锁死。更隐蔽的是 Integer 作锁。Integer 在 -128 到 127 之间走缓存超出这个范围时每次 valueOf 都会 new 新对象private Integer count 0; public void increment() { synchronized (count) { count; } }乍一看没问题但 count 在 1 之后成了一整个新的 Integer 对象锁也跟着变了。线程 A 拿着旧 Integer 对象的锁线程 B 拿着新 Integer 对象的锁两个线程互不干扰地同时改 count原子性瞬间破功。正确的做法是定义一个专门的私有 final 锁对象或者用类名.class。像这样private final Object lock new Object(); public void increment() { synchronized (lock) { count; } }锁对象既不会被全局池化也不会随着修改而更换引用是最稳妥的方案。5.2 锁粒度怎么选太大性能崩太小逻辑乱锁粒度这个问题光看教科书想不明白只能在真实系统里踩坑。我第二次做并发调优时把一个商品查询接口的整方法锁改成了只锁缓存读写部分压测吞吐直接提升了近一倍。原因很简单原本方法里包含网络 IO、序列化等耗时操作这些占用锁的时间完全被浪费了后面的线程全在原地等待。但锁粒度太小也会出问题。假设一个支付操作需要同时校验账户余额、扣减、记账三个步骤你把这些步骤拆成三个独立的同步块锁对象还不一样那一线程刚扣完钱还没记账时另一线程就可能看到余额未扣减的中间状态导致脏读。这种场景需要的是把这些步骤放在同一把锁的同一个临界区里。锁粒度的选择说白了是 trade-off临界区越大一致性越容易维护并发度越低临界区越小并发度越高但你需要更精细地管理多个共享变量的关系。我给一个新手的建议是先保证逻辑正确、用粗粒度锁跑通之后再逐步用压测和数据驱动的方式缩小临界区。上来就追求细粒度很容易把自己绕晕。5.3 死锁的定位与修复死锁是由锁嵌套导致的经典问题。两个线程各自持有一把锁又同时去抢对方手里的锁谁也不让谁public class DeadLockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(线程1 拿到锁A); sleep(100); synchronized (lockB) { System.out.println(线程1 拿到锁B); } } }, 线程1); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(线程2 拿到锁B); sleep(100); synchronized (lockA) { System.out.println(线程2 拿到锁A); } } }, 线程2); t1.start(); t2.start(); } }排查死锁的标准步骤我整理过实测非常管用先记下进程号用 jps 查看当前 JVM 进程列表。执行 jstack 进程号 输出线程快照。在快照文件里搜索 “deadlock” 关键字JVM 会直接告诉你检测到 Java-level deadlock并列出两个线程互相等待的锁对象。根据锁对象名称定位到具体代码行再通过代码审查找出锁的获取顺序。修复死锁的核心方案是让所有线程以相同的全局顺序获取多把锁。比如把上面示例里 t2 的获取顺序改成先 lockA 再 lockB死锁就解除了。另外也可以用 tryLock 加超时机制的 ReentrantLock 来兜底拿到锁就做事拿不到就放弃并释放已有锁不至于无限等待。5.4 synchronized和ReentrantLock到底怎么选这是老生常谈但很多人的选择依据还停留在十年前。先说结论默认场景直接用 synchronized只有当需要高级功能时才换 ReentrantLock。JDK 6 之后的 synchronized 性能已经不输 ReentrantLock而且语法更简洁、锁自动释放、不易出错。ReentrantLock 胜在三点支持公平锁按等待时间顺序分配、支持超时获取锁tryLock(timeout)、可以区分多个等待条件newCondition 创建多个等待队列。而 synchronized 的 wait/notify 只有单个等待队列条件多了之后写起来很别扭。比如需要多条件通知的场景线程 A 等待队列满线程 B 等待队列空用 synchronized 的 notify 容易唤醒错对象。用 ReentrantLock 两个 Condition 则清晰得多对应生产者和消费者语义。另一点是中断响应。synchronized 在获取锁期间无法响应中断如果锁被别人保有着它只能无限等下去。ReentrantLock 的 lockInterruptibly 可以感知中断并抛异常。对长任务场景里的线程池管理来说这个特性很实用。有锁需求的时候先想能不能用 synchronized 解决。不能再升级到 ReentrantLock。工具越多并不代表用的越多越好能把最简单的用对才是真正的进阶。5.5 常见问题速查表问题原因解决方法synchronized 修饰实例方法多个线程却不互斥操作的是不同实例需要锁同一个对象或改用 static synchronized锁 String 导致死锁或莫名阻塞字符串常量池导致不同代码块共用同一对象用私有 final 锁对象锁 Integer 后数据还是错乱Integer 自增后变换对象锁对象改变用 final 锁对象避免锁值对象wait 抛 IllegalMonitorStateException没有持有相应的 Monitor确保 wait/notify 写在 synchronized 块里线程持锁后调用 sleep其他线程全部阻塞sleep 不释放锁需要让出锁时使用 wait锁升级后性能断崖式下跌竞争激烈导致升级为重量级锁评估锁竞争减少临界区、降低竞争或换更合适的同步方案循环内同步块导致性能拖垮每个循环迭代反复加锁解锁让 JIT 锁粗化或手动扩大同步块范围这一张表我贴在工位上贴了很久每次排查同步问题优先级最高的就是对照着来。synchronized 是所有并发工具的基石把它吃透了后面的 Lock、AQS、并发容器理解起来会顺很多。我在实际项目里最终形成的一个习惯是凡是共享可变状态一律先明确锁的归属对象凡是临界区一律先问能不能进一步缩小凡是锁竞争难以控制就考虑换用其他并发模型。这套思路帮我避掉了大量隐蔽的并发故障也希望这篇笔记对你有所帮助。