1. 从一次线上事故说起:为什么我们需要重新审视Synchronized
那天下午,系统监控突然报警,核心交易接口的响应时间从平均50毫秒飙升至5秒以上,紧接着就是一连串的“调用超时”错误。我们紧急排查,发现罪魁祸首是一个看似简单的库存扣减方法,它被@Transactional注解包裹,内部又用synchronized方法进行了“双重保险”。在低并发下相安无事,一旦流量起来,这个“保险”就成了性能瓶颈,大量线程在等待同一个锁,数据库连接池也被迅速耗尽。我们移除了synchronized,改用数据库行锁配合重试机制,问题才得以解决。
这次事故让我意识到,尽管synchronized是Java开发者最早接触、最“顺手”的锁,但很多人对它的理解仍停留在“加上就线程安全了”的层面。它背后的锁升级过程、与JVM内存模型的交互、以及在现代高并发架构中的适用边界,远比我们想象的要复杂。今天,我就结合自己踩过的坑和源码层面的理解,把synchronized这把“老锁”掰开揉碎了讲清楚,这可能是你能看到的关于它最细致的一篇解读。
2. Synchronized的“三重面孔”:语法、语义与底层实现
很多人觉得synchronized用法简单,无非是修饰方法或代码块。但它的每一种用法,都对应着JVM完全不同的处理逻辑和锁对象,理解这点是避免误用的第一步。
2.1 三种使用方式及其锁对象
1. 实例方法同步这是最常见的形式。当你用synchronized修饰一个非静态方法时,锁住的是当前调用该方法的对象实例(this)。
public class Counter { private int count = 0; public synchronized void increment() { count++; // 锁对象是 this } }这意味着,如果两个线程试图在同一个Counter对象上调用increment(),它们会互斥。但如果它们操作的是两个不同的Counter对象,则不会发生锁竞争,因为锁对象不同。我见过有团队在Spring管理的单例Service类上滥用实例方法同步,导致整个应用的所有相关请求串行化,性能惨不忍睹。
2. 静态方法同步当synchronized修饰静态方法时,锁住的是当前类的Class对象。
public class StaticCounter { private static int count = 0; public static synchronized void increment() { count++; // 锁对象是 StaticCounter.class } }这个锁的粒度非常大!因为一个JVM中一个类的Class对象是唯一的。无论你创建多少个StaticCounter实例,或者从哪个线程调用,所有对静态increment()方法的访问都是互斥的。它通常用于保护静态变量,但必须慎用,否则极易成为全局性能瓶颈。
3. 同步代码块这是最灵活,也最能体现你对锁范围控制能力的方式。你需要显式指定一个对象作为锁(monitor)。
public class FineGrainedCounter { private final Object lock = new Object(); // 专门的锁对象 private int countA = 0; private int countB = 0; public void incA() { // 只锁与countA相关的操作 synchronized (lock) { countA++; } // 其他不需要同步的操作可以放在外面 doSomethingElse(); } public void incB() { // 如果countB和countA完全独立,甚至可以用另一个锁对象 // 比如 private final Object lockB = new Object(); synchronized (lock) { countB++; } } }使用代码块的关键在于锁对象的选择。锁对象通常应该是private final的,防止外部代码意外获得你的锁导致死锁或性能问题。绝对不要使用可能会被修改的对象(如String字面量有常量池问题)或基础类型的包装类作为锁。
2.2 锁的本质:对象头与Monitor
synchronized的锁信息存储在哪里?答案是:Java对象的对象头(Object Header)里。
一个普通的Java对象在堆内存中的布局可以分为三部分:对象头、实例数据和对齐填充。其中对象头又包含:
- Mark Word:存储对象的哈希码、GC分代年龄、锁状态标志等。这是实现
synchronized的关键。 - Klass Pointer:指向对象元数据的指针。
- (如果是数组)数组长度。
在32位JVM上,Mark Word是32位;64位JVM上,是64位。为了在有限的空间里存储大量信息,Mark Word的设计是“动态”的,它的位模式会根据对象的状态(是否被锁定、是否被偏向等)而改变。
当线程进入synchronized块时,JVM需要关联一个Monitor(管程)对象来管理锁的竞争与等待。每个Java对象天生都关联着一个潜在的Monitor。你可以把Monitor想象成一个房间(临界区),它有一个所有者(持有锁的线程),一个入口队列(Entry Set,竞争锁的线程在此排队),和一个等待队列(Wait Set,调用wait()的线程在此等待)。
synchronized的加锁过程,本质上就是线程通过CAS操作去竞争对象Mark Word中指向的Monitor的所有权。成功则获取锁,失败则进入阻塞队列。
3. 锁的升级与优化:从偏向锁到重量级锁
早期的synchronized是纯粹的“重量级锁”,依赖操作系统底层的互斥量(Mutex Lock)实现,涉及用户态到内核态的切换,开销巨大。为了提升在无竞争或低竞争场景下的性能,HotSpot虚拟机从Java 6开始,实现了锁升级(Lock Coarsening)机制。锁的状态不再是固定的,而是根据竞争情况动态变化,主要经历四个阶段:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
这个升级过程是不可逆的(偏向锁可以被撤销回到无锁),理解这个过程对于性能调优至关重要。
3.1 偏向锁:单线程的“特权”
设计初衷:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。为了让线程获得锁的代价更低,引入了偏向锁。
工作原理:
- 加锁:当一个线程第一次访问同步块时,JVM会使用CAS操作,将线程ID记录到对象头的Mark Word中,并将锁标志位设置为“01”(偏向模式)。之后,这个线程再进入和退出同步块时,不需要进行任何同步操作(如CAS),只需简单检查Mark Word里是否存储着自己的线程ID。
- 撤销:一旦有另一个线程来尝试竞争这个锁,偏向模式就宣告结束。持有偏向锁的线程必须将锁撤销(Revoke Bias)。撤销过程需要等待全局安全点(即所有线程都暂停执行),然后检查原持有线程是否还活着或已退出同步块。如果已退出,则将对象头恢复为无锁状态,允许新线程竞争;如果还在同步块内,则升级为轻量级锁。
适用场景与坑点: 偏向锁适用于明确知道只有一个线程会频繁访问的同步场景。但在高并发、锁竞争激烈的环境下,偏向锁的撤销操作(尤其是需要STW的撤销)会带来额外的性能损耗。因此,在JDK 15之后,偏向锁被默认禁用(-XX:-UseBiasedLocking),并且在后续版本中被标记为废弃。对于新的应用,尤其是微服务架构,我通常建议在JVM参数中显式关闭偏向锁。
3.2 轻量级锁:线程间交替执行的“礼貌竞争”
当偏向锁被撤销,或者一开始就关闭了偏向锁,线程会尝试使用轻量级锁。
工作原理:
- 加锁:在代码即将进入同步块时,如果锁对象处于无锁状态,JVM会在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象当前的Mark Word拷贝(称为Displaced Mark Word)。然后,JVM使用CAS操作尝试将对象头的Mark Word更新为指向该锁记录的指针。
- 如果成功,当前线程获得锁,并将锁标志位设置为“00”(轻量级锁状态)。
- 如果失败,说明至少存在两条线程在竞争同一个锁,会先进行自旋。
- 自旋:竞争失败的线程不会立即阻塞,而是进行一个循环(自旋),不断地检查锁是否被释放。自旋的目的是为了避免线程切换(用户态到内核态)的开销。
- 解锁:解锁时,同样使用CAS操作,将Displaced Mark Word替换回对象头。如果成功,则表示没有竞争发生;如果失败,说明在持有锁期间,锁已经升级为重量级锁,此时需要唤醒被阻塞的线程。
适用场景与坑点: 轻量级锁适用于线程交替执行同步块,即锁竞争是“短暂”且“稀疏”的场景。它的性能开销在于CAS操作和自旋消耗的CPU周期。
注意:自旋并非无限进行。JVM采用了适应性自旋(Adaptive Spinning)策略,会根据之前自旋的成功历史动态调整自旋次数。如果自旋始终无法获得锁,或者自旋线程数过多(超过CPU核心数的一半),为了避免CPU空转,锁会膨胀为重量级锁。
3.3 重量级锁:真正的“排他”与阻塞
当轻量级锁竞争失败(自旋失败),锁就会膨胀为重量级锁。
工作原理: 此时,对象头的Mark Word中存储的是指向重量级锁(Monitor对象)的指针。其余所有试图获取锁的线程,都会进入阻塞(BLOCKED)状态。锁的获取和释放完全依赖操作系统底层的互斥量(Mutex)和条件变量(Condition Variable),涉及用户态到内核态的切换、线程的挂起和唤醒,开销最大。
适用场景: 这是synchronized的“保底”机制,适用于高并发、长时间持有锁、竞争激烈的场景。虽然开销大,但它能保证在极端情况下的正确性和公平性(操作系统调度器会管理阻塞队列)。
3.4 锁升级流程图与实战意义
我们可以用一个简单的流程图来概括这个动态过程:
线程尝试进入同步块 | v 检查对象Mark Word锁标志位 | v [无锁/可偏向] --(偏向锁开启且未偏向)--> [尝试CAS偏向当前线程] --> 成功? --> [偏向锁] | | | 失败/发生竞争 v v [偏向锁] --(其他线程竞争)--> [撤销偏向,升级为轻量级锁] | v [轻量级锁] --(CAS获取锁)--> 成功? --> [持有轻量级锁] | | | 失败(自旋失败/多线程竞争) v v [重量级锁] <------------------+实战意义:
- 不要为了“性能”而盲目使用
synchronized:在极低竞争或无竞争场景,它确实经过优化。但在你无法预知竞争程度的共享资源上,它可能退化为性能杀手。 - 锁对象的作用域至关重要:锁住一个成员变量和锁住整个
this对象,竞争范围天差地别。尽量减小同步块的范围。 - 理解“锁粗化”和“锁消除”:这是JIT编译器的另外两项优化。
- 锁粗化:如果虚拟机探测到有一串零碎的操作都对同一个对象加锁解锁,会把锁同步的范围扩展(粗化)到整个操作序列的外部,减少加锁解锁次数。
- 锁消除:如果JIT编译器通过逃逸分析,发现某个锁对象不可能被其他线程访问到(即不会发生共享),那么就会将这个锁消除。例如在方法内部创建的、仅用于同步的局部对象。
4. Synchronized与Java内存模型:可见性与有序性的保证
synchronized不仅仅是互斥锁,它还是Java内存模型(JMM)中一个重要的内存屏障(Memory Barrier)。它能解决并发编程中的三大问题:原子性、可见性、有序性。
- 原子性:由锁的互斥性自然保证,同步块内的操作作为一个不可分割的整体执行。
- 可见性:根据JMM规范,对一个锁的解锁(unlock)操作happens-before于后续对这个锁的加锁(lock)操作。这意味着,线程A在
synchronized块内修改了共享变量,在解锁后,线程B在进入同一个锁保护的synchronized块时,一定能看到线程A修改后的最新值。这是因为解锁时,JVM会将线程本地内存(工作内存)中的变量刷新到主内存;加锁时,会清空本地内存,从主内存重新加载变量。 - 有序性:
synchronized块内的代码虽然可能被编译器或处理器重排序,但由于“as-if-serial”语义和内存屏障,从其他线程的视角来看,其执行结果与程序顺序执行的结果一致。同时,它禁止了块内指令与块外指令的某些重排序,保证了临界区操作的顺序性。
一个常见的误解:有人认为volatile能替代synchronized实现同步。volatile只保证了可见性和禁止指令重排序(部分有序性),但不保证复合操作的原子性。例如count++这样的“读-改-写”操作,即使count是volatile的,在多线程下仍然会出错,因为它不是原子操作。而synchronized可以保证整个count++操作的原子性。
5. 深入对比:Synchronized vs Lock(ReentrantLock)
“既然有java.util.concurrent.locks.Lock接口和功能强大的ReentrantLock,为什么还要用synchronized?”这是面试常考题,也是技术选型的关键。
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM原生支持,关键字 | JDK API层面实现,类 |
| 锁的获取 | 隐式获取和释放,进入块自动获取,退出(正常或异常)自动释放 | 显式调用lock()和unlock(),必须在finally块中释放 |
| 灵活性 | 较差,锁的释放由JVM控制 | 很强,可尝试非阻塞获取(tryLock)、可中断获取(lockInterruptibly)、超时获取 |
| 公平性 | 非公平锁(但内部实现有适应性的优化) | 可选公平锁或非公平锁(构造参数指定) |
| 条件变量 | 通过Object.wait(),notify(),notifyAll(),一个锁对应一个等待队列 | 通过Condition接口,一个锁可以绑定多个Condition,实现更精细的线程等待/唤醒 |
| 性能 | Java 6后大幅优化,在低至中等竞争下与Lock相差无几,甚至更好 | 在高竞争场景下,由于其更复杂的逻辑和API开销,可能略逊一筹,但功能优势明显 |
| 调试 | 获取锁的堆栈信息在JDK工具中可能不直观 | 可以通过getHoldCount(),getQueueLength()等方法获取锁状态信息,便于调试 |
选型建议(个人经验):
- 优先使用
synchronized:除非你需要ReentrantLock提供的高级功能(如可中断、超时、公平锁、多个条件队列)。synchronized的简洁性和不易出错(自动释放)是巨大优势。大多数业务场景,它的性能已经足够好。 - 考虑使用
ReentrantLock:- 需要可定时的、可轮询的锁获取(
tryLock(long time, TimeUnit unit)),比如在死锁恢复场景。 - 需要可中断的锁获取(
lockInterruptibly()),让等待锁的线程能响应中断。 - 需要公平锁(虽然通常性能较差)。
- 需要绑定多个条件变量(
Condition),实现复杂的线程协作,如生产者-消费者模型中的多个等待队列。
- 需要可定时的、可轮询的锁获取(
- 性能不是首要考虑因素:在绝大多数应用里,锁竞争本身才是瓶颈,而不是
synchronized和ReentrantLock之间的细微性能差异。首先应该优化的是锁的粒度和持有锁的时间。
6. 实战避坑指南与性能优化
理论懂了,还得在实战中不踩坑。下面是我总结的几个关键点和优化技巧。
6.1 锁对象选择不当引发的“神秘”问题
案例:使用字符串常量作为锁。
private static final String LOCK = “LOCK”; public void method() { synchronized(LOCK) { // 危险! // ... } }问题:字符串常量具有驻留(intern)特性。“LOCK”在JVM字符串常量池中是唯一的。这意味着,如果你的代码其他模块(甚至是引用的第三方库)也鬼使神差地用“LOCK”这个字符串作为锁,就会导致意料之外的锁竞争,引发死锁或性能问题,且极难排查。
正确做法:使用专门创建的、不可变的对象作为锁。
private final Object lock = new Object(); // 实例锁 private static final Object STATIC_LOCK = new Object(); // 类锁6.2 死锁的经典场景与排查
synchronized嵌套使用不当是死锁的温床。
// 线程1 synchronized (lockA) { Thread.sleep(100); // 模拟业务操作,增加死锁概率 synchronized (lockB) { // ... } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 死锁发生! // ... } }排查与预防:
- 避免嵌套锁:尽量设计成只获取一把锁。如果必须嵌套,确保所有线程以相同的全局顺序获取锁(例如,都按
lockA->lockB的顺序)。 - 使用带超时的锁:如果使用
ReentrantLock,可以用tryLock(timeout)。 - 使用JDK工具:
jstack <pid>可以打印线程堆栈,能清晰看到哪些线程在等待哪些锁,是定位死锁的首选工具。
6.3 锁粒度控制:细粒度锁与锁分段
对于保护一个组合对象(如HashMap),锁住整个对象(synchronized(map))粒度太粗。可以采用更细粒度的策略。
示例:简易锁分段
public class StripedMap { private final int N_LOCKS = 16; private final Node[] buckets; private final Object[] locks; public StripedMap(int capacity) { buckets = new Node[capacity]; locks = new Object[N_LOCKS]; for (int i = 0; i < N_LOCKS; i++) { locks[i] = new Object(); } } private final int hash(Object key) { return Math.abs(key.hashCode() % buckets.length); } public Object get(Object key) { int hash = hash(key); // 只锁住这个桶对应的锁,而不是整个map synchronized (locks[hash % N_LOCKS]) { for (Node m = buckets[hash]; m != null; m = m.next) { if (m.key.equals(key)) { return m.value; } } } return null; } // ... put方法类似 static class Node { Object key, value; Node next; } }ConcurrentHashMap在JDK 7及之前就采用了分段锁(Segment),正是这种思想的体现。在JDK 8中,它改为使用synchronized锁住单个桶(链表头或红黑树根节点) + CAS,实现了更细粒度的并发控制。
6.4 性能监控与诊断
如何知道你的synchronized成了瓶颈?
- JVisualVM / JMC:监控线程状态,如果大量线程长时间处于
BLOCKED状态,很可能存在锁竞争。 - Arthas:使用
thread -b命令可以一键找出当前阻塞其他线程最多的“罪魁祸首”线程。 - 自定义监控:对于关键锁,可以简单包装一下,记录持有时间、等待时间等。
public class MonitoredSync { private final Object lock = new Object(); private long totalWaitTime = 0; private long holdCount = 0; public void doSomething() throws InterruptedException { long startWait = System.nanoTime(); synchronized (lock) { long waitTime = System.nanoTime() - startWait; totalWaitTime += waitTime; holdCount++; // 实际业务逻辑 Thread.sleep(10); // 模拟工作 } // 可以定期打印或上报平均等待时间 totalWaitTime / holdCount } }
7. 在现代架构中的定位:何时用,何时弃
在分布式、高并发的今天,synchronized的战场主要收缩到了单JVM进程内的线程同步。
适用场景:
- 单机多线程资源保护:如内存中的计数器、缓存、连接池状态等。
- Spring单例Bean的方法同步(需谨慎评估范围)。
- 实现简单的线程安全工具类,如懒加载的单例模式(双重检查锁定中,实例变量需用
volatile修饰)。 - 作为更复杂并发组件(如
ConcurrentHashMap在JDK8中的实现)的基础构建块。
不适用/需要替代的场景:
- 分布式环境下的共享资源:如分布式库存、分布式ID生成。这里需要分布式锁,如基于Redis(Redisson)、ZooKeeper或数据库的实现。
- 需要更复杂协调机制的场景:如多个条件队列、可中断锁等,使用
ReentrantLock和Condition。 - 超高并发且临界区极短的场景:可以考虑使用
java.util.concurrent.atomic包下的原子变量,它们基于CAS实现,在无竞争或低竞争时性能通常优于锁。 - 读写比例极高的场景:使用
ReadWriteLock(或StampedLock)实现读写分离,允许多个读线程同时访问。
最后一点体会:synchronized就像一把瑞士军刀中的主刀,简单、可靠、在大多数日常场景下够用。但作为一名专业的开发者,你必须清楚知道它何时会变钝(性能瓶颈),何时根本不适用(分布式场景),以及工具箱里还有哪些更专业的工具(Lock、原子类、并发容器)可供选择。深入理解其原理,是为了更精准、更自信地使用它,而不是盲目地回避或滥用。在微服务架构下,我越来越少在业务代码中直接使用synchronized,更多的并发控制被转移到了数据库的事务隔离级别、Redis的分布式锁或无锁的数据结构上,但它在框架底层和基础组件中,依然扮演着不可或缺的角色。