做Java开发的朋友,只要接触过并发编程,synchronized这个关键字一定不陌生。它是Java里最基础、最直接的线程同步手段,也是面试八股里的常客。咱们继续Thread学习系列,这篇专门把synchronized从头到尾聊透。很多人用它写代码很顺手,但问到它到底锁的是什么、锁是怎么升级的、为什么有人说它“重”又有人说它“轻”,可能就说不清了。这篇文章会把synchronized的关键概念、三种用法、底层原理、性能取舍和实际排查都过一遍,目标是让你看完之后不只会用,还能在面试和实战里把原理讲明白。
1. synchronized到底在解决什么问题:从一个并发事故讲清楚
1.1 先看一段会出事的代码
先来看一段很典型的计数器代码。这个例子我几乎逢人就讲,因为它在并发环境下会稳定地“出事”,而且问题非常容易理解。
public class UnsafeCounter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } public static void main(String[] args) throws InterruptedException { UnsafeCounter counter = new UnsafeCounter(); int threadCount = 20; int loopCount = 10000; Thread[] threads = new Thread[threadCount]; for (int i = 0; i < threadCount; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < loopCount; j++) { counter.increment(); } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println("最终值: " + counter.getCount()); } }如果程序是线程安全的,20个线程各加10000次,最终值应该是200000。但你把这段代码丢到机器上跑几次,大概率看到的是十几万,而且每次结果都不一样。
原因其实很简单:count++不是一条原子指令。它内部至少拆成三步——读取当前count的值、把值加1、把新值写回。两个线程可能同时读到同一个旧值,都加1,再写回去,结果只增加了一次。这就是典型的“丢失更新”,也就是竞争条件。
我第一次遇到这个问题时也走了一段弯路,第一反应是给getCount()也加上同步,后来才明白读方法加不加要看具体业务语义。读方法只读一个int变量,在32位/64位JVM上不会出现“读到半个值”的撕裂读,所以只靠getCount()加锁解决不了计数丢失的问题。必须保证“读-改-写”这个整体过程不被别的线程插进来。
这其实就是synchronized最核心的用途:让一段代码在同一时刻只能被一个线程执行。这段被保护起来的代码,我们习惯叫“临界区”。
1.2 三大特性:原子性、可见性、有序性
聊并发编程,绕不开JMM(Java内存模型)里的三个概念:原子性、可见性、有序性。很多文章把它们摆在一起讲,结果读者越看越糊涂。我换个方式,用大白话拆开。
原子性,意思是操作不可分割。对应到我们的案例,就是“读count、count加1、写count”这三个动作要看作一个整体,中间不能被别的线程打断。synchronized通过互斥锁保证临界区的代码不会交错执行,这就解决了原子性问题。
可见性,意思是线程A改了变量之后,线程B要能看到最新值。JMM里有个约定:线程修改变量时是在自己的工作内存中操作,然后刷回主存。如果两个线程各忙各的,B读到的可能还是A修改前的旧值。synchronized在进入同步块时会让线程重新从主存读取变量,在退出同步块时把改过的变量强制刷回主存,所以可见性也有保障。
有序性,指的是代码不能随意被重排到影响程序语义的程度。现代CPU和编译器为了性能会做指令重排,但在单线程内要保证最终结果一致。在多线程环境下,重排可能让另一个线程观察到“不正常的执行顺序”。synchronized通过内存屏障限制了临界区与外部之间的重排,让并发环境下的执行顺序符合预期。
这里有一个容易混淆的点:volatile关键字也能保证可见性和有序性,但它保证不了复合操作的原子性。volatile适合那种“单线程写、多线程读”的标记位场景,而synchronized更适合“多个线程同时读改写同一份数据”的场景。两者不是替代关系,是不同层次的工具。
1.3 synchronized不是Thread类的方法,而是JVM内置的锁
很多人第一次接触synchronized时,会下意识把它和Thread类的方法放到一起记忆。实际上synchronized是Java语言层面的关键字,由JVM直接支持,它不是Thread类或者哪个工具类提供的方法。它和wait()、notify()、notifyAll()配合使用,构成了最经典的线程协作模型。
Thread类解决的是“线程怎么创建、怎么启动、怎么控制状态”的问题,而synchronized解决的是“多个线程访问共享资源时怎么互斥、怎么协作”的问题。两者经常一起出现,但职责完全不同。这也是为什么在Thread学习系列里,讲到线程安全问题时会专门腾出一篇来讲synchronized。只有把互斥和协作这套机制理解透了,后面再看ReentrantLock、CountDownLatch、Semaphore这些JUC组件,才会觉得它们是同一个问题域里的不同解法。
2. 三种用法,锁对象千万别搞混
2.1 同步实例方法:默认锁的是this
synchronized修饰实例方法,是最常见的用法,也是最初级的入门姿势。
public class TicketSystem { private int tickets = 100; public synchronized boolean buyTicket() { if (tickets <= 0) { return false; } // 模拟一些耗时处理 tickets--; return true; } }当buyTicket()被多个线程通过同一个TicketSystem实例调用时,JVM会拿当前实例this作为锁对象。同一时间只有一个线程能执行到buyTicket()内部。这看起来很美好,但有一个很容易被忽略的问题:如果两个线程调用的是不同实例的同步方法,它们之间是不会互斥的。
我见过一个真实案例:一开始项目里有个OrderService,把扣库存方法写成synchronized实例方法。测试环境单实例部署,一切正常。后来灰度验证时做了多实例部署,结果并发一上来就出现超卖。原因就是每台机器上都有一个独立的OrderService实例,锁的是各自不同的this,跨实例之间完全没有互斥效果。
所以用实例方法同步前,先问自己一句:这个共享资源是实例级别的,还是进程级别的?实例级别的用实例方法锁没问题,进程级别的就必须换成静态方法或代码块锁Class对象。
2.2 同步静态方法:锁的是Class对象
静态方法的同步,锁的不是某个实例,而是当前类的Class对象。
public class InventoryService { private static int stock = 100; public static synchronized boolean deductStock() { if (stock <= 0) { return false; } stock--; return true; } }这种情况下,不管你创建多少个InventoryService实例,锁都是同一个InventoryService.class对象。JVM里每个类有且只有一个Class对象,所以静态同步方法天然是进程级别的互斥。
这里就引出了另一个经典问题:一个类的同步实例方法和同步静态方法,它们之间会互斥吗?答案是不会。因为一个锁在具体实例上,另一个锁在Class对象上,两者是两把不同的锁。这个知识点在面试里经常被包装成“两个线程分别调用同一个类的同步实例方法和同步静态方法,能并发执行吗”来考察,答案是可以。
2.3 同步代码块:锁对象自己定
同步代码块是三种用法里最灵活、也最推荐的日常写法。它的核心就一句话:锁哪个对象,你自己说了算。
public class OrderService { private final Object lock = new Object(); public void createOrder() { // 不需要同步的校验、组装逻辑 synchronized (lock) { // 真正需要保护的关键区 saveOrder(); } // 不需要同步的后续逻辑 } }用private final Object lock作为锁对象,有几个好处:第一,锁对象对外不可见,别人没法利用你内部的锁去做一些奇怪的联锁操作;第二,final保证锁对象不会被重新赋值,避免出现“同一把锁”被替换导致锁失效的问题;第三,通过代码块可以精确控制临界区的范围,比把整个方法体都锁住要高效得多。
我曾经优化过一个定时任务,原代码是整个方法加上synchronized,每次执行要5秒,其中4秒都在做日志汇总和结果推送。改成代码块之后,只锁真正操作共享缓存的那500毫秒,整个任务耗时从5秒降到了2秒以内。这个案例让我意识到了锁粒度的重要性,后面专门整理成了一个优化清单。
2.4 锁对象选型的三个常见坑
锁对象选错,比不用锁还可怕,因为表面看起来“加了锁”,实际上竞争还是发生,只是隐藏得更深。
第一个坑是用字符串常量作为锁对象。比如synchronized ("lock")。Java的字符串常量存在字符串常量池里,只要字面量相同,多个地方拿到的就是同一个对象。这听起来好像还能用,但它有一个致命的副作用:字符串常量是整个JVM共享的,如果你依赖的第三方库内部也用了同样的字面量当锁,两边会莫名其妙地互相阻塞。反过来,如果你在同步块里对字符串做拼接、替换等操作,锁对象可能已经不是原来那个字符串了,也会导致锁失效。
第二个坑是锁对象为null。synchronized块内如果你引用了一个null对象,会直接抛NullPointerException。虽然是异常,但并发场景下异常不一定马上暴露,可能隔了很久才在某个特定调用链上炸出来,定位成本很高。
第三个坑是锁对象被重新赋值。比如你把一个普通成员变量当锁对象,某个方法执行到一半给它赋了新的对象,那其他线程拿到的锁对象就变了,相当于原本的锁被“偷换”了。所以锁对象要么用final,要么用不可变对象。
顺带提一句,这让我想起另一个像“关键字”这个说法一样容易踩的坑:MySQL建表时给字段起名叫group、order、desc之类的关系型数据库关键字,使用时要到处加反引号。道理差不多,越是看起来基础的东西,越要先搞清楚它在系统里的真正身份再动手用,不然早晚被反噬。
3. 底层原理:synchronized为什么“重”过,又为什么变快了
3.1 从字节码看monitorenter和monitorexit
很多八股文只告诉你结论,不告诉你结论是怎么来的。想真正搞懂synchronized,最好从字节码入手看一眼。
假设有这样一个类:
public class SyncDemo { private final Object lock = new Object(); public void sayHello() { synchronized (lock) { System.out.println("hello"); } } }用javap -c反编译SyncDemo.class,核心字节码长这样:
public void sayHello(); Code: 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 10: ldc #4 // String hello 12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return你会看到一对关键指令:进入同步块时执行monitorenter,退出同步块时执行monitorexit。而且要注意,正常路径和异常路径都有monitorexit。正常路径是执行完同步块后释放锁,异常路径是通过异常表捕获异常,在抛出异常之前先把锁释放掉。
这就是synchronized和后面要讲的ReentrantLock在API层面最大的区别之一:synchronized不用你手动释放锁,哪怕同步块里抛了异常,JVM也会保证monitorexit被执行。很多线上事故就是Lock忘记在finally里unlock()导致锁永远不释放,而synchronized天然规避了这个问题。
理解了字节码结构,你就知道“synchronized锁住的是对象,不是代码块”这句话的真正含义了。monitorenter和monitorexit操作的是同一个对象,这个对象就是锁。代码本身没有锁的概念,锁是每个对象都自带的一个监视器锁。
3.2 对象头与Mark Word到底记了啥
每个Java对象在内存里并不只是“字段数据”那么简单,它前面还有一块固定大小的对象头。对象头里最关键的部分叫Mark Word,锁状态就记录在这里。
以64位JVM为例,Mark Word是64位,不同的锁状态下这64位的含义完全不同。知识点比较多,我整理了一张简化对照表:
| 锁状态 | Mark Word的部分内容 | 标志位 |
|---|---|---|
| 无锁 | 对象哈希码、偏向锁位、分代年龄 | 01 |
| 偏向锁 | 偏向线程ID、偏向时间戳、分代年龄 | 01 |
| 轻量级锁 | 指向线程栈中Lock Record的指针 | 00 |
| 重量级锁 | 指向ObjectMonitor对象的指针 | 10 |
| GC标记 | 空 | 11 |
这里需要强调“简化”两个字,64位JVM和32位JVM的位分布不一样,不同JDK版本也可能有细微调整。但对开发来讲,最重要的是形成一个概念模型:锁信息就存在对象头里,线程竞争锁,本质是在竞争这个对象头里的状态位。
为什么轻量级锁是“指向线程栈中Lock Record的指针”?因为轻量级锁的设计思路是:每个线程在进入同步块时,会在自己的栈帧里创建一个Lock Record,里面保存对象头原来的Mark Word,然后通过CAS尝试把对象头的Mark Word替换成指向自己Lock Record的指针。如果替换成功,说明抢到了锁。如果替换失败,说明已经有人持锁了。
而重量级锁的Mark Word指向的是一个ObjectMonitor对象。ObjectMonitor是JVM内部用C++实现的一个监控器结构,里面记录了持有锁的线程、锁的重入次数、等待队列等关键信息。到了这一步,锁的获取和释放就会走操作系统层面的互斥量,涉及用户态和内核态的切换,所以被称为“重量级”。
3.3 锁升级:从无锁到重量级的完整路径
JDK 1.6之前,synchronized的性能被诟病,原因就是它动不动就直接上重量级锁,线程拿不到锁就进入BLOCKED状态,由操作系统调度,开销很大。JDK 1.6之后做了大量优化,引入了一个重要概念:锁升级。
一条完整的升级路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。正常情况下锁只能升级,不能降级(GC时可能会有例外,但那是JVM内部细节,普通开发不用纠结)。
偏向锁解决的是“只有同一个线程反复进入同一段同步代码”的场景。它的想法很大胆:既然只有一个线程在用这把锁,那锁直接记下这个线程的ID,之后它再进来,什么都不用做,直接放行。只有当另一个线程也来竞争时,偏向锁才需要撤销,这时锁会升级为轻量级锁。
轻量级锁解决的是“少量线程交替或竞争短暂”的场景。拿不到锁的线程不会立刻阻塞,而是先做一段时间的自旋等待——就是让CPU空转着不断尝试获取锁。自旋不是无限转,JVM有自适应自旋机制,会根据历史竞争情况动态调整次数。如果自旋能等到锁被释放,就避免了用户态和内核态的切换开销。
如果自旋也等不到,说明竞争已经非常激烈了,锁就会升级为重量级锁。此时没拿到锁的线程会真的阻塞住,进入ObjectMonitor的等待队列,等持有锁的线程释放后由操作系统唤醒。这个唤醒过程有性能成本,但面对高强度竞争,阻塞比自旋更节能,是一种防护措施。
这里要提一个很重要的变化:JDK 15开始,JVM默认禁用了偏向锁,JDK 18进一步移除了相关实现。原因是偏向锁在多线程竞争频繁的现代应用里,维护和撤销偏向锁的成本反而超过了收益。所以现在很多人讲synchronized还在大谈偏向锁,面试官问到就得心里有数:这是历史产物,不是当前默认行为。
3.4 锁消除和锁粗化:JIT帮你做的优化
了解完锁升级,再补充两个由JIT编译器在运行时做的优化:锁消除和锁粗化。
锁消除发生在“JVM能判断某段代码根本没有竞争”的情况下。最经典的例子是,在方法内部创建局部变量,并且没有把它逃逸出去,仅仅在当前线程内使用。如果对这个局部变量加锁,JVM可能直接锁落空,也就是消除锁。一个常见的例子是:
public String concat(String a, String b) { StringBuffer sb = new StringBuffer(); sb.append(a); sb.append(b); return sb.toString(); }StringBuffer的append方法是同步的,但sb是纯局部对象,不会暴露给其他线程。JIT分析后认为竞争不存在,就可能把这里的加锁去掉,这就是锁消除。
锁粗化则相反。JVM发现你在一个循环里反复地加锁、解锁,比如每执行一次循环体就进入一次同步代码块,这种频繁的同步开销很大,它会自动把锁的范围扩大,把整个循环都纳入临界区,减少获取锁和释放锁的次数。这其实是在“锁粒度”和“同步次数”之间做一个权衡。
但我要提醒一句:JIT的优化是我们控制不了的,不能依赖它来掩盖糟糕的锁设计。你能做的还是在业务代码里合理控制锁范围。把锁加在一个无谓的循环里,即使JIT帮你优化了,也只是“侥幸”,可读性也差,没必要赌。
4. 实操:三个案例搞懂锁粒度与性能
4.1 场景一:计数器累加的锁粒度
回到第1节那个UnsafeCounter,最简单的修复就是给increment()加上synchronized:
public synchronized void increment() { count++; }这样写完全能解决问题,却能看出来对性能的考虑不够细。因为increment()只有一行代码,方法级同步和代码块同步的差别其实不大。但现实中同步方法往往包含其他耗时操作,这时候方法级同步就会把很多没必要互斥的代码也锁住。
改造方案是这样的思路:把耗时操作移出临界区,只保留共享变量操作在锁内。
public void increment() { // 一些不需要同步的本地计算 synchronized (this) { count++; } }你可能会说“这优化了个寂寞,一行有什么区别?”那是因为例子太简单。换成真实场景,比如扣减库存时还要写日志、推送消息、做后续补偿,把耗时操作从锁内移到锁外,效果立竿见影。我做过一个压测,同样的并发量下,锁范围从整个方法缩小到只保护库存变更后,TPS直接翻倍。
锁粒度控制的铁律就是:临界区越小越好,但共享资源的原子性保护不能丢。不要为了性能把该保护的代码漏出锁外,否则会出现更严重的数据问题。
4.2 场景二:读多写少时怎么选
读多写少在并发开发里是绝对的热词,几乎每个业务系统都能遇到。如果直接用synchronized保护读写方法,读和读之间也会互斥,这在读频率远高于写频率的场景里是一个巨大瓶颈。
读操作本来就该允许并发,只有写的时候才需要互斥。ReentrantReadWriteLock就是为解决这个问题设计的:共享锁读、排他锁写。
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public Object readFromCache() { rwLock.readLock().lock(); try { // 读缓存逻辑 } finally { rwLock.readLock().unlock(); } } public void writeToCache(Object value) { rwLock.writeLock().lock(); try { // 写缓存逻辑 } finally { rwLock.writeLock().unlock(); } }简单说,如果是读多写少且数据量不是特别大的场景,ReentrantReadWriteLock比synchronized有明显优势。但如果写操作也很频繁,读写锁的维护成本会让自己变成一个折腾的“大锁”,反而不如synchronized简单直接。
还有一个更轻的替代方案:CopyOnWriteArrayList。它是“写时复制”思路,写操作直接复制整个数组,读操作不加锁。适合读极多、写极少、且写时允许短暂不一致的场景。我见过有人拿它当并发List的万能药,结果写频繁的场景下频繁复制数组,内存和CPU都崩了。工具选型一定要对着场景来,不能光靠印象。
在读多写少场景里还有一个细节:如果缓存数据本身是原子可见的不可变对象,很多情况下连锁都可以不用,直接以volatile引用指向新对象就行。这种方案性能最好,代价是需要把共享数据设计成不可变对象,每次更新直接替换整个引用。
4.3 场景三:需要超时抢锁和响应中断时
有一个问题很多面试官会问:synchronized和ReentrantLock到底怎么选?我的答案是:能用synchronized优先用synchronized,除非你明确需要下面这几个能力。
第一,需要尝试获取锁,拿不到就立刻离开,而不是无限期等待。synchronized做不到,ReentrantLock有tryLock()。
第二,需要等待锁时可中断。synchronized的等待无法响应线程中断,而ReentrantLock的lockInterruptibly()支持。
第三,需要实现公平锁。synchronized默认是非公平的,ReentrantLock可以指定为公平锁。
代码上直观感受一下:
ReentrantLock lock = new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 执行临界区逻辑 } finally { lock.unlock(); } } else { // 拿不到锁的兜底逻辑 }这是synchronized很难优雅表达的场景。如果业务上允许“抢不到锁就直接走降级逻辑”,用tryLock就是很合适的写法。
两者的选择我总结成一个经验:优先synchronized,它的代码更简洁、不需要手动释放锁、JVM做了大量优化,绝大多数业务场景足够。只有当业务需要中断、超时、多条件等待时,才切换到ReentrantLock,并且必须配合try/finally保证unlock()。
下面一张表把关键差异列出来,方便面试前翻一翻:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 获取和释放方式 | JVM内置,自动释放 | 需要手动lock/unlock |
| 是否可重入 | 是 | 是 |
| 锁中断 | 不支持 | 支持lockInterruptibly |
| 超时尝试 | 不支持 | 支持tryLock(timeout) |
| 公平性 | 非公平 | 默认非公平,可切换公平 |
| 条件变量 | 只配合wait/notify使用 | 支持多个Condition条件队列 |
| 异常释放 | 同步块抛异常自动释放 | 必须在finally中unlock |
4.4 synchronized和wait/notify的配合
前面聊了很多synchronized的用法,但Thread学习系列里还有一个关键知识点:wait()和notify()必须和synchronized配合使用,否则直接抛IllegalMonitorStateException。
究其原因,wait()的意思是“我暂时不竞争锁了,请你把我放到这个对象的等待集里,等别人notify()再来唤醒我”。它释放当前持有的锁,所以调用前必须先持有锁。notify()则是从等待集里挑一个线程唤醒,被唤醒的线程会重新去竞争锁。sleep()不会释放锁,wait()会释放锁——这个差异在面试里几乎必考。
有一个典型的基于synchronized的生产者消费者代码结构:
public synchronized void produce() { while (isFull) { wait(); } // 生产逻辑 notifyAll(); } public synchronized void consume() { while (isEmpty) { wait(); } // 消费逻辑 notifyAll(); }这里的while不是多余的,因为线程被唤醒后重新抢到锁,需要再次检查条件是否已经被其他线程改变,这叫“循环等待”,在实际项目里极其重要。如果你用if判断,一旦出现多个消费者等待同一个条件,就可能出现“唤醒一个线程后,另一个线程已经把资源拿走了,被唤醒的线程却直接继续执行”的问题。
5. 常见问题排查实录
5.1 锁失效的几种表现和根因
我总结了几个实际工作中容易遇到的“锁失效”场景,做成一个速查表,遇到类似表现可以直接对号入座:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 加了synchronized还是有多线程问题 | 锁的是不同对象 | 检查是实例方法还是静态方法,统一锁对象 |
| 同步代码块不互斥 | 锁对象被重新赋值 | 锁对象声明为final |
| 多个实例之间互斥失效 | 部署了多个JVM进程 | JVM层锁无法跨进程,引入分布式锁 |
| 使用字符串字面量当锁,莫名死锁 | 字符串常量池共享 | 换用私有final Object锁对象 |
| wait/notify抛异常 | 不在同步块内调用 | 移到synchronized块/方法中 |
| 定时任务多机重复执行 | 进程间无共享锁 | Redis分布式锁、数据库乐观锁 |
这里重点说下“多个实例之间互斥失效”。很多人会问:synchronized不是进程级的锁吗?为什么多实例部署就失效?答案很简单,synchronized锁的是当前JVM进程内的对象,不同JVM进程各自维护各自的对象,彼此之间根本看不到对方。跨进程的互斥需要借助外部存储,比如Redis锁、Zookeeper锁、数据库乐观锁。这不是synchronized的锅,而是你把它的能力边界用错了。
还有一个很低频但很坑的问题:如果你在synchronized块里调用了外部HTTP接口或数据库操作,锁的持有时间会非常长。一旦外部服务缓慢,整个系统的吞吐量骤降,排查时看起来像“线程卡死”,其实就是线程在等锁。这种“锁内做IO”的写法要尽量避免。
5.2 死锁:现象、复现、用jstack定位
死锁是并发开发里的灾难现场,也是最经典面试题。最简单的死锁长这样:
Object lockA = new Object(); Object lockB = new Object(); // 线程1 new Thread(() -> { synchronized (lockA) { System.out.println("线程1持有A"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { } synchronized (lockB) { System.out.println("线程1尝试拿B"); } } }).start(); // 线程2 new Thread(() -> { synchronized (lockB) { System.out.println("线程2持有B"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { } synchronized (lockA) { System.out.println("线程2尝试拿A"); } } }).start();线程1持有A等B,线程2持有B等A,谁也等不到谁。这个示例能稳定复现死锁,它是理解和排查复杂死锁的基础。
线下定位死锁的流程很简单:
jps -l jstack <pid>执行jstack后,如果存在死锁,输出最后会直接有一句Found one Java-level deadlock,并且会列出两个线程各自持有和等待的锁对象。看到这个输出,基本就可以确定死锁线程和锁的持有关系了。然后回到代码里,去调整两个线程获取锁的顺序,比如都先拿A再拿B,死锁自然消失。
死锁的根因通常是“锁的顺序不一致”。所以我在实际编码时有一个习惯:如果两个锁之间有关系,尽量让所有线程都按同一个全局顺序获取锁。如果锁的顺序没法统一,就引入超时机制,比如用ReentrantLock.tryLock而不是无限等待。留一条退路,比试图把所有情况都提前想到要现实得多。
5.3 锁竞争导致性能下降怎么定位
之前遇到过一个线上问题:某个接口偶发超时,但CPU和内存都正常。用jstack多次抓线程快照后,发现大量业务线程处于BLOCKED状态,阻塞在一个synchronized方法上。定位思路就三条:先确认线程状态,再找到锁的持有者,最后优化锁粒度。
jstack输出里,BLOCKED状态线程会有一行waiting to lock <0x...>,而持有锁的线程会有locked <0x...>。对比几次快照,就能看出谁长期占着锁不放。常见原因就是这个锁被某个慢操作占了太久,比如在锁里查数据库、调外部接口、做大对象拷贝。
定位到之后,常规优化路径是:把耗时操作移出临界区、用读写锁替换共享锁、或者把synchronized换成JUC组件。这一步如果数据量很大,还要配合压测验证,改完不能只看功能正确,要看吞吐量是否真的提升。
我个人的经验是,性能问题排查时先看锁竞争情况和线程状态,比先怀疑GC更有效。GC导致的性能问题往往伴随着GC日志的增加,而锁竞争问题的特征是线程大量BLOCKED,现象非常明显,一抓一个准。
5.4 Spring事务与synchronized叠加时的坑
这个坑我踩过一次之后记忆深刻。Spring的@Transactional是用代理实现的,默认情况下事务方法会先启动事务,再执行业务逻辑,最后提交事务。如果你给一个事务方法加上synchronized,整个锁的持有时间会覆盖事务提交之前的时间。
问题在于:线程A执行完业务逻辑,释放锁,但事务还没提交;线程B立刻拿到锁进入方法,读到数据库中旧数据,然后线程A才提交事务。这种情况下,线程A的同步块确实互斥了,但线程B读到的还是未提交前的旧数据,业务目的根本没达到。
正确姿势是把锁放到事务外层。常见做法有两种:一是把同步逻辑写在事务方法的外面,用编程式事务把锁内部的事务提交控制在自己手里;二是拆分方法,锁住的是调用事务方法的入口,而不是事务方法本身。
还有一个更容易中招的细节:Spring代理自调用时,this调用方法不会经过代理,@Transactional会失效,synchronized加的锁也可能是同一个this。我见过一个问题:同一个类里,方法A调用内部方法B,B上有@Transactional,结果事务和锁都没生效,线上数据出现不一致。排查下来才知道是代理自调用的问题。解决方式是注入自己的代理,或者把方法拆分到不同Spring Bean里。
如果你在做定时任务的多实例部署,还要注意:定时任务的调度器在不同节点上是独立的,单凭synchronized拦不住多节点重复执行。这时你需要分布式锁,或者让调度框架自带分布式协调能力。把synchronized当成跨进程万能锁,是并发开发里最常见的认知错误。
6. 写在最后:一点个人经验和建议
我用synchronized这些年,踩过不少坑,也一次次刷新对它认知。最后分享几个小习惯给大家参考。
第一个习惯,是动手写同步之前先问“这个共享资源到底属于谁”吗?是单实例的,还是进程级的,还是跨进程的?搞清楚这个问题再选锁,不然很容易做出“表面正确”的代码。
第二个习惯,是坚持最小临界区。宁可多写一个同步代码块,也不要图省事把整个方法锁起来。锁的范围越小,并发能力越强,排查问题也越容易。
第三个习惯,是能用synchronized就别急着上复杂锁。很多场景ReentrantLock能用,但synchronized也够用,那就用简单的。JVM把synchronized优化了这么多年,它仍然是Java并发编程里最可靠的锁基础。
真正的高手不是背得住底层原理,而是能在凌晨两点的线上事故里,通过jstack一眼看出锁导致的问题。synchronized只是起点,后面还有AQS、并发容器、线程池、分布式锁,一个个学起来,都是绕不开的积累。把这一篇的每一个细节都亲自跑一遍,比背十篇八股文都管用。