Synchronized原理解析
Synchronized
是 Java 中用于保证线程安全的关键字。你可以把它想象成一个“房间的钥匙”:同一时间只有一个线程能拿着这把钥匙进入房间(同步代码块或方法),执行任务,其他线程只能在门口排队等待。
下面我会用通俗的方式,由浅入深地讲解它的用法、原理和优化。
🔐 一、Synchronized 的用法:三种“锁门”方式
Synchronized 主要有三种用法,对应三种不同的“锁门”方式。
1. 修饰实例方法(锁当前实例)
public class Counter {private int count = 0;// 锁住当前实例(this)public synchronized void increment() {count++;}
}
• 锁的是谁:当前对象实例(this)。
• 好比:你家的大门钥匙。只有拿到你家钥匙的人(线程)才能进入你家(执行此方法),但别人可以去其他家(其他实例)。
2. 修饰静态方法(锁类的Class对象)
public class Logger {private static int logCount = 0;// 锁住整个类(Logger.class)public static synchronized void log() {logCount++;}
}
• 锁的是谁:类的 Class 对象(如 Logger.class)。这个对象在 JVM 中只有一个。
• 好比:公司大门的钥匙。所有员工(所有实例)进出公司(调用此静态方法)都必须用这一把钥匙,同一时间只允许一个人进出。
3. 修饰代码块(灵活指定锁对象)
public class Cache {private final Object lock = new Object(); // 专用锁对象private Map<String, String> data = new HashMap<>();public void put(String key, String value) {// 只锁需要同步的代码部分synchronized (lock) {data.put(key, value);}}
}
• 锁的是谁:括号里指定的任意对象(通常是私有 final 对象)。
• 好比:只锁保险柜,而不是锁整个房间。这样别人可以同时进房间做其他事,只有操作保险柜时才需要排队。这是最推荐的方式,因为它粒度最细,性能最好。
为了更直观地理解这三种用法及其锁对象的区别,可以参考下面的表格:
使用方式 锁对象 影响范围 类比
修饰实例方法 当前实例 (this) 所有同步的实例方法(同一个实例) 自家大门钥匙
修饰静态方法 类的 Class 对象 所有同步的静态方法(所有实例) 公司大门钥匙
修饰代码块 指定任意对象 同步的代码块(取决于锁对象) 专用保险柜钥匙
⚙️ 二、Synchronized 的原理:钥匙如何工作
理解了怎么用,我们再来看看它底层是怎么实现的。这涉及到 对象头(Object Header) 和 Monitor(监视器锁) 的概念。
1. 对象头与 Mark Word
在 JVM 中,每个 Java 对象在内存中都包含一个对象头,其中有一部分叫 Mark Word。
• Mark Word 就像是对象的“身份证”,记录了一些运行时信息,例如:
- 哈希码(HashCode)
- 垃圾回收(GC)分代年龄
- 锁的状态标志
- 持有锁的线程 ID
synchronized 的锁信息就存储在 Mark Word 中。随着竞争加剧,锁的状态会发生变化,Mark Word 里存储的内容也会随之改变。
2. Monitor 机制
你可以把 Monitor 理解为一个结构特殊的“房间”,它只允许一个线程进入。每个 Java 对象都关联着一个 Monitor。这个 Monitor 主要有三个部分:
• Owner(所有者):当前持有锁、正在房间内执行任务的线程。
• EntryList(入口队列):其他想进入房间的线程都在这里排队等待(状态为 BLOCKED)。
• WaitSet(等待集合):已经进入过房间的线程,因为某些条件不满足(调用了 wait() 方法),主动释放锁并在这里等待唤醒(状态为 WAITING)。
当一个线程想执行 synchronized 代码块时,它需要先拿到对应对象的 Monitor。如果 Owner 为空,它就成功成为 Owner。如果 Owner 已有线程,它就进入 EntryList 排队等待。
为了更直观地理解对象头、Mark Word 和 Monitor 之间的关系,以及线程如何获取锁,可以参考下面的示意图:
🚀 三、锁升级:JVM 的智能优化
早期的 synchronized 性能较差,一有竞争就直接让线程排队等待(重量级锁)。但在很多情况下,竞争并不激烈。所以 JVM 在 JDK 1.6 之后引入了锁升级机制,根据实际竞争情况,智能地选择最合适的锁。
锁升级是单向的,路径如下:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
1. 偏向锁 (Biased Locking)
• 场景:只有一个线程访问同步块。比如,项目刚开始只有你一个人在做。
• 做法:JVM 会在 Mark Word 中记录这个线程的 ID。以后这个线程再来加锁时,发现是自己,就直接进去,连简单的 CAS 操作都省了,开销极小。
• 升级:一旦有第二个线程来尝试获取锁,偏向锁就立刻撤销,升级为轻量级锁。
2. 轻量级锁 (Lightweight Locking)
• 场景:多个线程交替访问同步块,没有同时竞争。比如,你和同事轮流使用会议室,但从不撞车。
• 做法:线程通过 CAS 操作(一种乐观锁,简单理解为比较并交换)来尝试获取锁。如果成功,就拿到锁;如果失败,并不会立刻阻塞,而是自旋(循环重试 CAS)一小段时间。
• 升级:如果自旋了一定次数后还没拿到锁(说明竞争加剧了),锁就会升级为重量级锁。
3. 重量级锁 (Heavyweight Locking)
• 场景:多线程激烈竞争,多个线程同时想获取锁。
• 做法:这就是最传统的锁。没拿到锁的线程会直接进入阻塞状态(进入 Monitor 的 EntryList 排队),等待操作系统调度唤醒。这涉及到用户态到内核态的切换,开销最大。
• 这是最终兜底的方案,保证了在高并发场景下的正确性。
下图总结了锁升级的全过程及其触发条件:
🛠️ 四、优化建议:写出更高效的同步代码
理解了原理,我们就能更好地使用和优化它。
-
减小锁粒度:这是最重要的原则。只锁必要的代码,用同步代码块代替同步方法。就像前面只锁保险柜的例子。
-
缩短锁持有时间:同步代码块里不要执行耗时操作(如IO操作),尽快做完事,释放锁。
-
选择专用锁对象:不要使用 String、Integer 等常量对象或可能被共享的对象作为锁,容易导致意想不到的阻塞。应该使用 private final Object lock = new Object() 这种专为锁而生的对象。
-
考虑替代方案:
◦ 如果是读多写少的场景(比如缓存),可以考虑使用 ReadWriteLock,它允许多个线程同时读,比 synchronized 吞吐量更高。◦ 对于简单的原子操作(如自增),可以使用 AtomicInteger 等原子类,它基于 CAS,通常性能更好。
◦ 对于复杂并发结构,直接使用 ConcurrentHashMap 等并发容器,它们内部实现了更高效的同步机制。
💎 小结
• 用法:三种方式——实例方法、静态方法、代码块。优先使用同步代码块,指定专用锁对象。
• 原理:基于 JVM 的 Monitor 机制,通过对象头的 Mark Word 记录锁状态。
• 优化:JVM 会自动进行锁升级(无锁 → 偏向锁 → 轻量级锁 → 重量级锁),根据竞争激烈程度智能选择,兼顾性能和安全性。
• 实践:编写代码时牢记减小锁粒度、缩短持有时间的原则,并在合适场景选择更合适的并发工具。
为了让您对 synchronized 如何保证线程安全有一个全面的认识,我准备了一张核心特性图:
上图是 synchronized 能够解决线程安全问题的三大理论基石。JVM 为了实现这些特性,在底层做了大量工作。
一、原子性 (Atomicity)
原子性 是指一个或多个操作作为一个不可分割的整体执行。这些操作要么全部成功,要么全部不执行,不会出现中间状态
-
为何需要原子性:看似简单的操作,如 count++,实际上由读取值、增加、写回值多个步骤组成。在没有同步的情况下,多个线程交错执行这些步骤会导致最终结果错误
-
synchronized 如何保证:synchronized通过互斥锁确保同一时间只有一个线程能够执行同步代码块或方法。它将一系列操作“捆绑”成一个原子操作,从而避免了线程交错执行带来的问题
👁️ 二、可见性 (Visibility)
可见性 是指当一个线程修改了共享变量的值,其他线程能够立即看到这个修改后的最新值
- 为何需要可见性:由于每个线程都有自己的工作内存,它们可能会暂时将共享变量拷贝到本地操作。若一个线程修改了其工作内存中的副本而未及时写回主内存,其他线程就无法感知这一变化,从而操作过期的数据
- synchronized 如何保证:Java 内存模型为 synchronized规定了以下内存语义
线程在进入 synchronized代码块(monitorenter)时,会清空工作内存,从主内存中重新加载共享变量的最新值。
-
线程在退出 synchronized代码块(monitorexit)时,会强制将工作内存中对共享变量所做的修改刷新到主内存。
-
这一“取最新,写回主”的机制,保证了随后获得锁的线程能看到前一个线程所做的修改。
🔄 三、有序性 (Ordering)
有序性 指的是程序执行的顺序按照代码的先后顺序执行
-
为何需要有序性:编译器和处理器为了优化性能,可能会对指令进行重排序。在单线程下,这不会影响最终结果,但在多线程环境下,不恰当的重排序可能导致程序逻辑错误
-
synchronized 如何保证:synchronized通过 as-if-serial语义 来保证有序性。即,不管编译器和 CPU 如何重排序,都必须保证在单线程情况下程序的结果是正确的
-
由于 synchronized保证了同一时刻只有一个线程执行同步代码块,因此在这个代码块内部,线程看到的指令执行顺序总是符合 as-if-serial语义的,从而保证了正确性。需要注意的是,synchronized允许其内部代码发生重排序,但只要不影响单线程的执行结果即可
🧠 五、从 JVM 到 CPU:深入理解 synchronized 的底层实现
synchronized 的底层实现是 JVM 的 Monitor(监视器锁) 和 对象头(Object Header)。
1. 对象头(Object Header)与 Mark Word
在 HotSpot 虚拟机中,每个 Java 对象在内存中的布局都包含一个对象头。它就像是对象的“身份证”,包含了两类信息:
• Mark Word:存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、持有锁的线程 ID 等。
• Klass Pointer:指向对象的类元数据的指针,虚拟机通过它来确定这个对象是哪个类的实例。
synchronized 的锁信息就存储在 Mark Word 中。为了在极小的空间内存储这些信息,Mark Word 被设计成一个动态变化的数据结构,它会根据对象的状态(如是否被锁定、是否被偏向)复用相同的存储空间。
为了更直观地理解 Mark Word 在不同锁状态下的结构变化,可以参考下面的示意图:
2. Monitor 机制
你可以把 Monitor 理解为一个结构特殊的“房间”,它只允许一个线程进入。每个 Java 对象都关联着一个潜在的 Monitor。这个 Monitor 在底层(C++实现)主要包含以下部分:
• _owner:当前持有锁、正在房间内执行任务的线程。
• _EntryList:其他想进入房间的线程都在这里排队等待(状态为 BLOCKED)。
• _WaitSet:已经进入过房间的线程,因为某些条件不满足(调用了 wait() 方法),主动释放锁并在这里等待唤醒(状态为 WAITING)。
当一个线程想执行 synchronized 代码块时,它需要先拿到对应对象的 Monitor(成为 _owner)。如果 _owner 为空,它就成功成为所有者。如果 _owner 已有线程,它就进入 _EntryList 排队等待。
字节码层面,synchronized 代码块是通过 monitorenter 和 monitorexit 这一对指令来实现的。而 synchronized 方法则通过方法常量池中的 ACC_SYNCHRONIZED 标志来隐式实现。
⚡ 六、超越锁升级:JVM 的其它优化策略
除了锁升级,JVM 的即时编译器(JIT)还会在编译时进行一些非常聪明的锁优化。
1. 锁消除(Lock Elimination)
JIT 编译器会进行逃逸分析,如果它发现一个对象(如 StringBuffer)的生命周期不会“逃逸”出当前方法,即不可能被其他线程访问到,那么即使你写了 synchronized 代码,JVM 也会自动移除这些无意义的锁操作。
示例:
public String createString() {// sb 是局部变量,不会逃逸出方法,其他线程无法访问StringBuffer sb = new StringBuffer();sb.append("Hello"); // JVM 会消除这里的同步操作sb.append("World");return sb.toString();
}
2. 锁粗化(Lock Coarsening)
如果 JVM 检测到有一连串的操作都在反复对同一个对象加锁和解锁,即使是在循环中,它可能会将多个连续的同步块合并为一个更大的同步块,从而减少锁请求的次数。
示例:
// 优化前:在循环中反复加锁解锁,效率低下for (int i = 0; i < 1000; i++) {synchronized(lock) {doSomething();}
}// 优化后(JVM 可能做的锁粗化):只加锁一次
synchronized(lock) {for (int i = 0; i < 1000; i++) {doSomething();}
}
3. 自适应自旋(Adaptive Spinning)
在轻量级锁竞争时,线程会进行“自旋”(循环重试)。自适应自旋意味着 JVM 不再是固定自旋 10 次,而是根据上次自旋的成功情况来动态调整下次的自旋次数。如果上次自旋很快就成功拿到了锁,那么这次可能会允许它多自旋一会儿;如果很少成功,则可能直接省略自旋过程,避免浪费 CPU。
🔄 七、synchronized 与 ReentrantLock:如何选择?
ReentrantLock 是 java.util.concurrent.locks 包下的一个显式锁。它们都是可重入锁,但在功能上有区别:
特性维度 | synchronized | ReentrantLock |
---|---|---|
实现机制 | JVM 内置关键字,自动管理锁的获取与释放 JDK | API 实现,需要手动 lock() 和 unlock() |
灵活性 | 基本,非公平 | 灵活,可指定公平/非公平锁,提供了丰富的 API |
尝试获取锁 | 阻塞式,无法中断 | tryLock() 可尝试非阻塞获取,lockInterruptibly() 可响应中断 |
条件变量 | 单一等待队列(通过 wait()/notify() 操作整个 _WaitSet) | 可创建多个 Condition,实现更精细的线程等待与唤醒(如生产者-消费者模型) |
性能 | Java 6 后优化得很好,在大部分常见场景下相差无几 | 在极高竞争环境下可能略有优势 |
选型建议:
• 优先使用 synchronized:语法简洁,自动释放锁,不易出错,在大部分场景下性能足够好。这是默认选择。
• 考虑 ReentrantLock 当你需要:可中断的锁获取、超时获取锁、公平锁、或者多个条件变量的复杂同步需求时。
🏆 八、最佳实践与常见陷阱
-
锁对象的选择:
◦ 使用私有 final 对象:private final Object lock = new Object();。这可以防止锁对象被意外修改,也保证了封装性。◦ 避免使用 String 常量或基本类型包装类:如 synchronized(“LOCK”) 或 synchronized(Integer(1))。因为它们可能被其他地方意外共享,导致意想不到的互斥和死锁。
-
保持同步块内代码简短:只锁必要的代码,尽快释放锁,减少线程争用时间。
-
警惕死锁:
◦ 死锁是多个线程互相等待对方持有的锁。◦ 避免方案:按固定的全局顺序获取锁;使用 tryLock() 尝试获取锁。
-
明确锁的范围:
◦ 实例锁 (synchronized 实例方法/this):锁的是特定对象实例。◦ 类锁 (synchronized 静态方法/X.class):锁的是整个类(Class 对象),所有实例都会受影响。
-
异常处理:确保即使在同步块中抛出异常,锁也能被释放。synchronized 会自动释放,但清理其他资源可能需要 finally 块。
💎 总结与核心思想
synchronized 的演进史,就是一部在线程安全和性能之间不断寻求平衡的历史。
• 用法:三种方式——实例方法、静态方法、代码块。优先使用同步代码块,指定专用锁对象。
• 原理:基于 JVM 的 Monitor 机制,通过对象头的 Mark Word 记录锁状态。
• 优化:JVM 会自动进行锁升级(无锁 → 偏向锁 → 轻量级锁 → 重量级锁)和锁消除/粗化,根据竞争激烈程度智能选择。
• 核心:编写代码时牢记减小锁粒度、缩短持有时间的原则。
最终建议:对于大多数业务场景,放心使用 synchronized。它的性能在现代 JVM 上已经非常优秀。只有在需要其不具备的高级特性时,才去考虑更复杂的ReentrantLock。