
写Java时间久了你会发现“并发”这两个字是绕不过去的坎。你说你写个多线程程序两个线程同时执行一个count结果跑完不是 20000而是 17863这时候你懵不懵我第一次遇到这种情况时脑子里全是问号Java不是号称线程安全的语言吗后来才知道安全不代表“自动帮你处理好一切”你得自己用工具去保证数据一致性。而这其中最常见、最基础、也是面试和实际开发里都绕不开的一个工具就是synchronized。这篇文章我就把synchronized从头到尾掰开揉碎讲一遍先聊它到底解决了什么问题再把三种用法逐个说清然后深入 JVM 底层看它怎么干活聊透锁升级机制最后附上几个真实项目和面试里高频踩的坑。内容既覆盖刚学并发的朋友需要的基础也把面试官爱追问的原理部分讲透。不管你是在准备 Java 基础面试还是正式项目里需要保证数据一致性这篇都应该能给你点实在的东西。1. synchronized到底在解决什么先理解并发问题很多人一上来就背用法、记语法但synchronized到底在解决什么问题心里是模糊的。这就像你拿到一把钥匙却不知道它开哪扇门那再好的钥匙也是摆设。所以我不急着写代码先把并发问题的根源讲明白。1.1 并发三大特性一次讲明白并发场景下数据出错本质上逃不出三个原因原子性、可见性、有序性。先说原子性。一个count看着只有一行编译成字节码其实是好几条指令先读取 count 的值然后加 1最后写回。线程 A 刚把 count 从 10 读到 10准备加 1线程 B 也把 10 读走了两边都加完写回结果是 11 而不是 12这就是典型的“丢失更新”。生活里也好理解两个人同时看到架子上还剩最后一个包子一个伸手去拿另一个也伸手去拿最后一个人拿到包子另一个人扑了个空但账上可能都认为自己已经“拿走”了。然后是可见性。每核 CPU 都有自己的缓存线程可能把变量读到自己线程的工作内存里操作改完之后没有立刻同步回主内存其他线程读到的还是旧值。这就是为什么有时候一个线程改了变量另一个线程却一直看到旧值。最后是有序性。编译器、CPU 为了优化性能可能对指令进行重排。在单线程里重排不影响结果但多线程下A 线程先设置了标志位再写入数据B 线程可能先看到标志位变了数据还没写进去于是就用上了半成品。synchronized的厉害之处在于它一个机制同时管住了这三件事锁保证了临界区代码的原子执行锁的释放会强制把修改刷新到主内存、加锁会强制从主内存重新读取所以可见性也有了同时它内部通过内存屏障限制了重排。理解了这三点你就知道为什么并发编程里总提“加锁”因为这真的是最直接的兜底方案。1.2 不加锁的现场还原一个库存超卖的例子光说不练假把式咱们直接看一个库存扣减的场景。模拟有 10000 个线程同时扣减 10 个库存public class Stock { private int stock 10; public void deduct() { stock--; } }跑完你会发现结果不是 0而是负几十上百。原因就在stock--不是原子操作读旧值、算新值、写回三个步骤之间随时可能被其他线程插一脚。明明你判断过if (stock 0)才扣减的但并发场景下这个判断结果早就过期了。如果你给方法加上synchronizedpublic synchronized void deduct() { if (stock 0) { stock--; } }结果就会稳定变成 0。因为同一时刻只能有一个线程进入这个方法其他线程都在门外排队。你把这个例子跑一遍对synchronized的用途就有了体感它不是玄学而是真的能让“查库存-扣库存-写回”这段流程变成不可分割的整体。2. synchronized的三种用法一行一行说清楚synchronized可以用在三个地方修饰实例方法、修饰静态方法、修饰同步代码块。很多人背了概念但到真正写代码时搞不清楚“锁的到底是谁”这里我把每种情况都拆开讲。2.1 修饰实例方法锁的是当前对象不是代码看这段代码public class Account { private int balance 100; public synchronized void withdraw(int amount) { if (balance amount) { balance - amount; } } }这里synchronized锁的是this也就是调用这个方法的那个Account对象。换句话说两个线程如果操作的是同一个Account对象那必须排队执行withdraw如果两个线程各自 new 了一个Account对象那它们互不干扰各自跑各自的因为锁的对象根本不是同一个。这是一个特别容易踩的坑。你以为加了synchronized就万事大吉但如果在多线程环境下每次都 new 一个新对象去调方法那锁等于白加。记住一句话synchronized锁的是对象不是代码两个线程只有争抢同一个锁对象时才互斥。这种写法适合单个对象内部状态需要保护、且确实由多线程共享同一个实例的场景。2.2 修饰静态方法锁的是Class对象全局唯一静态方法加synchronizedpublic class Cache { private static MapString, Object map new HashMap(); public static synchronized void put(String key, Object value) { map.put(key, value); } }这时候锁的不是某个实例而是Cache.class这个 Class 对象。每个类在 JVM 里对应的 Class 对象是全局唯一的所以无论你 new 多少个Cache实例所有线程在调用这个静态同步方法时都是在竞争同一个锁。这就有个重要结论实例方法的锁和静态方法的锁不是同一把锁。一个线程执行实例同步方法时另一个线程完全可以执行静态同步方法因为它们锁的对象不同互不干扰。很多人面试时在这里栽过跟头说“加了 synchronized 所有的同步方法都互斥”这是不对的。什么时候用静态方法锁当你保护的是静态成员变量、或者这个操作本身与实例状态无关、是全局共享资源时用它没错。2.3 同步代码块锁粒度由你决定方法级别的锁有个问题一个方法里可能只有一两行真正需要保护但加上synchronized整个方法都串行执行其他无关的代码也被堵着并发度就低了。这时候同步代码块更合适public void process() { // 这里可以并发执行 doSomethingNotSafe(); // 只有这里需要同步 synchronized (this) { counter; } }代码块写法是synchronized(锁对象) { ... }你可以自己决定锁什么锁this和实例方法效果一样锁类名.class和静态方法效果一样锁自定义对象最灵活。我建议在复杂类里用自定义锁对象而不是直接锁thispublic class Service { private final Object lock new Object(); public void update() { synchronized (lock) { // sync logic } } }好处有两个一是锁的意图更明确二是如果直接锁this外部代码拿到对象引用后也能synchronized(obj)一旦有人这么干就可能和你内部的锁互相干扰甚至产生莫名其妙的阻塞。用一个私有lock对象外部碰不到锁的控制权完全在你手里。三种方式的选择逻辑也简单想让整个方法互斥方法上直接加只想保护其中一小段逻辑用代码块涉及静态共享数据用 Class 锁。3. 原理篇synchronized在JVM里到底怎么干活前面聊的都是“怎么用”现在进入“为什么”。去面试的时候光说“synchronized 是加锁”这种话是拿不到分的得能从对象头讲到 Monitor、从字节码讲到锁升级才算真正吃透。3.1 先从对象头说起锁信息就存在这里每个 Java 对象在内存里不只是数据本身它的布局大致分三块对象头Header、实例数据Instance Data、对齐填充Padding。其中对象头里有一个关键区域叫 Mark Word它占 64 bit64 位 JVM 下里面存的东西会根据锁状态动态变化。无锁状态存对象的 hashCode、分代年龄、锁标志位偏向锁状态存持有锁的线程 ID、偏移的 epoch 等轻量级锁状态存指向线程栈中锁记录的指针重量级锁状态存指向 Monitor管程/监视器对象的指针。这就是synchronized能工作的物理基础。一个线程进入同步代码块时JVM 并不是凭空记了一笔“这个代码被锁了”而是去修改synchronized后面那个对象的 Mark Word把锁状态刻在对象头上。所以再次强调锁是加在对象上的不是加在代码上的。这也解释了为什么两个线程必须用同一个锁对象才能互斥——因为它们改的是同一个对象头。3.2 字节码视角monitorenter和monitorexit如果你写一段同步代码块用javap -c反编译看看会发现在字节码层面多了一对指令monitorenter进入同步区域相当于“尝试获取 Monitor 的所有权”monitorexit退出同步区域相当于“释放 Monitor”。更妙的是编译器会在正常路径放一个monitorexit在异常路径也生成额外的monitorexit保证哪怕代码抛了异常锁也能被释放。这就是为什么synchronized相比手写 lock/unlock 更安全——JVM 帮你兜底了。如果是synchronized修饰的方法呢字节码里看不到monitorenter/monitorexit但方法的访问标志ACC_SYNCHRONIZED会被置上。JVM 识别到这个方法带有这个标志就会在进入方法时获取锁、方法返回或异常时统一释放锁。两条路径到最终目的地是一样的都是通过 Monitor 完成的。那 Monitor 本身是什么你可以把它理解为 JVM 内置的一个“门卫系统”底层是一个叫ObjectMonitor的 C 对象内部维护了持有者线程_owner、等待队列_WaitSet、阻塞队列_EntryList以及一个用来支持可重入的计数器。线程进入时计数器加 1同一条线程重复进入再加 1每离开一次减 1减到 0 才真正释放锁。这个计数器就是下一节要讲的可重入性的底层来源。3.3 可重入同一线程能再次进入自己持有的锁什么叫可重入就是一个线程拿到了某个对象的锁之后在锁没释放前它自己还能再次进入同一个锁保护的代码。比如public synchronized void methodA() { // do something methodB(); } public synchronized void methodB() { // do something }线程进入methodA时拿到了锁然后调用methodB如果锁不可重入这里立刻死锁——线程在等一个自己已经持有的锁。幸好synchronized是可重入的由前面的计数器机制保证每次进入都计数加 1退出计数减 1只有计数归零才真正把锁让给其他线程。所以方法之间互相调用也好、递归调用也好只要还是同一个线程都不会把自己锁死。这个特性在面试里经常被问到你可以直接现场写个递归同步方法展示一遍比口头背定义要可信得多。4. 锁升级机制为什么现在的synchronized没那么慢早年间synchronized被诟病性能差因为它直接依赖操作系统底层互斥量线程挂起和唤醒都要在内核态和用户态之间切换开销很大。但 JDK 1.6 之后HotSpot 对synchronized做了一整套优化引入了锁升级的路径让它在大多数场景下已经不是问题了。4.1 无锁、偏向锁、轻量级锁、重量级锁一条锁的“人生轨迹”大概是这样刚开始是无锁状态第一个线程来访问同步代码块时JVM 会把它升级成偏向锁。偏向锁的思路是大多数情况下同一个锁对象都是被同一个线程反复获取那能不能让这个线程“偏爱”这个锁省去反复加锁解锁的开销于是 JVM 会在 Mark Word 里记录持有锁的线程 ID之后这个线程再来直接比对是不是自己是的话什么都不用干就进入了。听起来很美好但有个前提一旦出现第二个线程竞争偏向锁就要被撤销。而撤销偏向锁需要等到安全点由 JVM 暂停所有线程去操作这个成本反而可能很高。所以当竞争真的来临时偏向锁会升级成轻量级锁。轻量级锁不再靠记录线程 ID而是把 Mark Word 复制到线程栈的锁记录里然后通过 CASCompare And Swap比较并交换尝试把对象头的锁指针指向自己。由于用 CAS 在用户态就能完成避免了线程阻塞适合“竞争不激烈、临界区很短”的场景。如果 CAS 失败并且多个线程真的在争轻量级锁就膨胀成重量级锁。重量级锁就是我们前面说的 ObjectMonitor它最终会依赖操作系统的互斥量线程抢不到锁就进入阻塞状态由操作系统来调度唤醒。好处是严谨可靠坏处是线程切换开销大。下面这个表格总结一下锁状态触发条件主要开销适用场景无锁初始状态无无竞争偏向锁同一线程反复进入CAS、撤销时安全点暂停单线程访问轻量级锁少量线程竞争CAS 自旋短临界区、竞争不激烈重量级锁竞争激烈、自旋失败线程阻塞与唤醒长临界区、竞争激烈这条链路的核心思想就是能用便宜的方式就不上贵的方式。锁升级是不可逆的一旦到了重量级锁直到锁被释放都不会再变回去。4.2 自旋锁与自适应自旋轻量级锁是用 CAS 去抢但如果没抢到呢这时不会立刻升级而是会进入自旋——也就是让线程原地空转循环反复尝试获取锁。因为很多情况下线程 B 只不过晚了几纳秒到线程 A 马上就执行完释放了如果 B 直接阻塞光线程切换的开销就够 JVM 跑几千条指令了反而不划算。早期的自旋是固定次数比如默认 10 次后来升级成自适应自旋JVM 会根据上一次竞争的情况动态决定本次最多自旋多久。如果前一次自旋成功过说明临界区短就多等一会儿如果前一次自旋很久都拿不到说明临界区可能长就早点放弃去阻塞避免浪费 CPU。这对开发者的启示是临界区要短、要快才能真正吃满轻量级锁的红利。如果锁里面做的是远程调用、读文件、访问数据库这种耗时不可控的操作自旋与阻塞来回切换性能必然是灾难。4.3 新版本JDK的变化偏向锁被“冷落”了这里补充一个大多数人会忽略的新变化JDK 15 开始HotSpot 已经标记废弃偏向锁JDK 18 之后默认不再启用。原因是偏向锁的维护和撤销成本太高特别是现代应用里线程竞争往往不是“全程只有一个人”一旦有竞争就要走撤销流程反而拖慢整体性能。现在面试官如果一个劲儿地追偏向锁细节你别慌可以如实说“新版本 JDK 里偏向锁路径已经不在默认开启范围内重点应该放在轻量级锁和重量级锁的协作上。”能说出这句话说明你真的关注过演进而不是只背 1.6 的八股文。5. 实战从代码到排查把锁用“对”原理说得再多最终还是要落到项目里能用对。这一章我来写几个真实的场景怎么写出正确高效的同步代码、死锁发生怎么看、线上怎么判断锁有问题。5.1 完整示例并发库存扣减继续用库存的业务来演示。假设有一个StockService库存存在实例变量里public class StockService { private int stock 10; public synchronized void deduct() { if (stock 0) { stock--; } } }这样写的前提是整个 JVM 里所有的扣减操作都在操作同一个StockService实例。比如用 Spring 容器管理它那 Bean 默认是单例多线程过来访问的都是同一个对象锁生效。但如果你这么写public void handleOrder() { StockService service new StockService(); service.deduct(); }每个线程进来都 new 一个全新的StockService彼此看到的锁对象根本不是同一个synchronized彻底失效。这类问题线上排查起来特别隐蔽因为代码能跑、日志正常就是数据偶尔不对。所以我要强调你动手写代码时的三条约束锁对象必须是被多线程共享的那一个锁的粒度应该尽量覆盖到“检查操作”整个不可分割的业务动作不要只锁其中某一句如果不想整个方法串行就把真正需要保护的逻辑抽出来放进同步代码块。如果库存是存在数据库里的不建议用synchronized去并发扣减那样只能保护单机内存里的计数解决不了数据库更新的一致性问题得用数据库行锁或乐观锁。synchronized是 Java 层内存数据的同步工具这点要拎清楚。5.2 死锁是怎么发生的怎么排查死锁的经典必要条件有四个互斥、占有并等待、不可剥夺、循环等待。看个最直观的例子public class DeadLockDemo { private final Object lockA new Object(); private final Object lockB new Object(); public void method1() { synchronized (lockA) { Thread.sleep(100); // 为了放大问题 synchronized (lockB) { // do something } } } public void method2() { synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // do something } } } }线程 1 先进method1拿住lockA然后去要lockB线程 2 同时进method2拿住lockB然后去要lockA。两边都攥着一个锁等另一个锁谁也没法继续程序就“挂”在那了。排查死锁最常用的手段是jstackjstack pid如果存在死锁线程栈里会明确打印出类似Found one Java-level deadlock的信息并列出两个线程各自持有的锁和正在等待的锁。看到这个输出顺着堆栈回去找代码里锁的嵌套顺序就能定位问题。破解死锁的常规手段也很简单让所有线程按同一个固定的全局顺序加锁。比如上面这个例子两条线程都先锁lockA再锁lockB就不会循环等待了。因为加锁顺序一致就不会出现“你拿A等B、我拿B等A”的闭环。如果能用tryLock带超时的方式更好拿不到锁就放弃重试而不是一直死等这也是ReentrantLock在需要发散排场时更受青睐的原因之一。5.3 性能排查怎么判断锁竞争激烈有时候线上系统变慢你会怀疑是锁的问题但不知道怎么确认。我的排查套路是这样先看线程转储。连续抓两次线程快照间隔几秒如果发现大量线程处于BLOCKED状态而且都阻塞在同一个锁对象的消息上说明这里锁竞争非常激烈。再配合业务日志看锁内代码的执行时间如果里面包着远程调用、SQL、网络请求那基本可以断定性能瓶颈就在这里。优化方向一般有四步缩小临界区把不需要同步的耗时操作挪到锁外锁粗化在某些循环里频繁加同一把锁反而不如把锁提出来粗化一次减少来回加锁开锁的开销读写分离读多写少的场景可以考虑ReadWriteLock或StampedLock让多个读线程并行无锁化如果能用volatile CAS、ThreadLocal、不可变对象替换那连锁都不需要上。这里重点说下第 4 点。很多场景下并发问题并不是必须用锁解决的。比如每个线程只操作自己私有的变量用ThreadLocal天然安全再比如状态变量本身是原子的用AtomicInteger的 CAS 就能保证一致同样不需要synchronized。锁是并发控制的兜底手段但别让它成为你的默认手段。6. 面试常问synchronized问题速查表几乎每次 Java 基础面试都绕不开synchronized这个题很多同学把它当“八股文”背但背会了还要会说、会举一反三。我按面试官的思路整理了几个高频问题附上回答要点。6.1 高频问题QA常见问题回答要点加分细节synchronized 锁的是什么锁的是对象不是代码块对象头 Mark Word 里的锁状态被修改能提到实例方法锁 this、静态方法锁 Class、代码块锁指定对象实例同步方法和静态同步方法互斥吗不互斥锁对象分别是实例和 Class 对象是两把锁能说清两个线程各自拿不同的锁volatile 和 synchronized 区别volatile 只保证可见性和有序性不保证原子性synchronized 三者都保证能举例i用 volatile 依然出错wait/notify 为什么必须放在 synchronized 里因为线程需要先持有 Monitor 才能进入对象的等待/通知机制能引出 ObjectMonitor 里的 WaitSet 和 EntryList同步方法抛异常锁会释放吗会字节码层面异常路径也有 monitorexit能对比 ReentrantLock强调手动 finally 释放的必要性synchronized 可重入吗可重入通过 Monitor 计数器实现能直接写递归同步方法验证synchronized 会引发死锁吗会锁嵌套顺序不当同样可能死锁能答出死锁四条件 jstack 排查6.2 八股文之外面试官真正想考察什么面试官提synchronized往往不是只为了听你把教材复述一遍他想考察的是你对并发问题的本质理解以及你的实践经验。比如你回答“锁升级”时如果只是背出偏向锁、轻量级锁、重量级锁这些名字那不稀奇但如果你能说出来“锁升级的本质是同步手段随着竞争激烈程度变化底层从 CAS 变为操作系统互斥量”并且提到“JDK 15 以后偏向锁被废弃的大背景”面试官基本就能判定你确实啃过原理而不仅仅是刷题。再比如问到你“项目里怎么保证数据一致性”你如果只知道说“我用了 synchronized”那大概率过不了。更好的回答方式是先分析共享变量是什么再说怎么识别锁对象然后说明锁粒度设计最后补充一句“如果有 DB 操作我会考虑数据库层面的锁或乐观锁” —— 这样整个思考链条就完整了。完全不像背题更像一个有经验的工程师在陈述自己的设计决策。我自己当面试官时最喜欢追问的一句就是“如果锁内操作耗时很长你会怎么处理”能答出“考虑用 ReentrantLock 的 tryLock或者把临界区调小甚至重构为无锁方案”的候选人我会觉得他的并发基础是真的扎实而不是停留在背几个关键字的层面。最后再分享一个我自己的体会。刚入行那两年我特喜欢写synchronized遇到数据不对就加锁好像加得越多越安全。结果有一回线上出现诡异的性能下降一查线程快照发现几十个线程堵在一个同步方法外面而那方法里居然有一段三秒的外部服务调用。后来我改掉了几个坏习惯先画清楚共享变量的边界再决定要不要锁锁内只干事关一致性的逻辑不掺任何耗时操作能用无锁并发的场景坚决不偷懒上锁。这些年下来真正需要synchronized的地方反而变少了但每次用到它都是清清楚楚知道自己在锁什么、锁多久、为什么锁。希望你也能从这篇开始把并发这道坎真正迈过去。