Java 多线程:synchronized 的使用与底层原理
一、synchronized 的作用与三种使用方式
1. synchronized 解决什么问题?
synchronized 是 Java 提供的内置同步机制,主要解决多线程访问共享资源时的线程安全问题。
它具有三个重要特性:
原子性:通过互斥执行,保证临界区中的操作不会被其他持有同一把锁的线程交叉执行。
可见性:通过锁的释放和获取建立 happens-before 关系,使共享数据的修改对后续获取同一把锁的线程可见。
有序性:通过同步规则限制可能破坏线程间正确性的重排序。
例如:
public class CounterService {
private int count = 0;
public void increment() {
count++;
}
}count++ 包含读取、计算、写回三个步骤,不是原子操作。
多个线程同时执行时,可能出现丢失更新。
使用 synchronized:
public synchronized void increment() {
count++;
}同一时刻,只允许一个线程持有对应的锁并执行该同步方法。
其他线程需要等待锁释放。
重点:synchronized 锁住的不是代码,而是对象对应的监视器锁。只有使用同一把锁的线程,才能相互互斥。
2. synchronized 的三种使用方式
方式一:普通同步方法
public class CounterService {
private int count = 0;
public synchronized void increment() {
count++;
}
}等价于:
public void increment() {
synchronized (this) {
count++;
}
}锁对象是当前实例 this。
假设:
CounterService serviceA = new CounterService();
CounterService serviceB = new CounterService();线程 A 调用:
serviceA.increment();线程 B 调用:
serviceB.increment();两个线程不会因为这两个方法上的 synchronized 而互斥。
原因是 serviceA 和 serviceB 属于不同实例,锁对象不同。
如果两个线程调用同一个 serviceA 实例的同步方法,则会竞争同一把锁。
方式二:静态同步方法
public class CounterService {
private static int count = 0;
public static synchronized void increment() {
count++;
}
}等价于:
public static void increment() {
synchronized (CounterService.class) {
count++;
}
}锁对象为:
CounterService.class同一个 JVM 中,使用同一个 Class 对象作为锁的线程会互斥。
注意:
普通同步方法锁的是 this,静态同步方法锁的是 Class 对象。
因此,即使它们在同一个类中,也不会因为 synchronized 自动相互互斥。
方式三:同步代码块
public class CounterService {
private final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
}这种方式的优势是可以明确锁对象,并且只保护真正需要同步的代码。
生产环境中,专用锁对象推荐定义为:
private final Object lock = new Object();原因:
private:防止外部代码随意使用相同锁对象。final:防止锁引用被重新赋值。new Object():创建专用锁对象,避免意外与其他业务共用锁。
不推荐直接使用字符串常量作为锁对象,因为字符串常量可能被复用,导致不相关代码意外竞争同一把锁。
二、synchronized 的可重入性与锁释放
1. 什么是可重入锁?
可重入锁是指同一个线程已经持有某把锁时,可以再次获取这把锁,而不会被自己阻塞。
例如:
public class CounterService {
public synchronized void methodA() {
System.out.println("执行 methodA");
methodB();
}
public synchronized void methodB() {
System.out.println("执行 methodB");
}
public static void main(String[] args) {
CounterService service = new CounterService();
service.methodA();
}
}执行结果:
执行 methodA
执行 methodB执行流程:
main 线程
|
v
调用 methodA()
|
v
获取 this 对象锁
|
v
调用 methodB()
|
v
再次请求 this 对象锁
|
v
发现锁已经属于当前线程
|
v
允许再次进入
|
v
methodB() 执行结束
|
v
methodA() 执行结束
|
v
完全释放锁这里的关键在于:
JVM 能够识别锁的当前持有线程,并正确管理重复进入和退出的层次。
同一个线程每成功进入一层同步区域,都需要有对应的退出操作。
只有所有重入层次都退出后,其他线程才有机会获得这把锁。
2. synchronized 会自动释放锁吗?
会。
当线程正常退出同步区域,或者因为异常退出同步区域时,JVM 都会释放对应的锁。
例如:
public class CounterService {
private int count = 0;
public synchronized void increment() {
count++;
throw new RuntimeException("业务异常");
}
}即使出现 RuntimeException,锁也会释放。
但是需要注意:
synchronized 释放锁,不代表业务数据自动回滚。
上面的 count++ 已经执行,不会因为抛出异常而自动撤销。
因此:
synchronized 负责线程同步。
数据库事务负责数据库操作的事务性。
synchronized 不具备数据库事务的回滚能力。
3. sleep()、wait() 与 synchronized
例如:
synchronized (lock) {
Thread.sleep(5000);
}线程进入 TIMED_WAITING 状态,但仍然持有 lock。
其他线程无法获取这把锁。
而:
synchronized (lock) {
lock.wait();
}调用 wait() 后,线程会释放 lock 对应的 Monitor,并进入等待状态。
它被通知后还需要重新获取 lock,才能继续执行。
注意:wait() 只释放调用对象对应的 Monitor,不会自动释放当前线程持有的其他锁。
三、synchronized 底层原理:Monitor、Mark Word 与锁优化
1. Monitor 监视器
HotSpot JVM 使用对象头、锁记录、Monitor 等机制实现 synchronized。
可以把 Monitor 理解成负责管理锁持有者、锁竞争以及线程等待的底层同步结构。
重量级 Monitor 的概念模型:
Monitor
|
┌───────────┼────────────┐
| | |
v v v
Owner EntryList WaitSet
| | |
线程 A 线程 B 线程 D
线程 C主要概念:
例如:
线程 A 获取 Monitor,成为 Owner。
线程 B 尝试获取相同的锁。
如果锁被 A 持有,B 需要等待。
A 释放锁后,B 才有机会获取锁。
如果 A 在持锁期间调用对应对象的 wait(),A 会释放 Monitor,并进入等待集合。
需要注意:
EntryList 和 WaitSet 是帮助理解 Monitor 的概念模型,HotSpot 的实际队列实现更复杂。
synchronized 不保证公平性,也不保证等待时间最长的线程优先获取锁。
2. synchronized 的字节码原理
同步代码块
synchronized (lock) {
count++;
}编译后主要通过两个字节码指令控制同步:
monitorenter
|
v
执行同步代码
|
v
monitorexitmonitorenter:尝试获取对象的监视器锁。monitorexit:退出同步区域,释放相应的锁持有层次。
如果同步代码块发生异常,编译器也会生成对应的异常退出逻辑,确保释放锁。
同步方法
public synchronized void increment() {
count++;
}同步方法通常不会在方法体中生成 monitorenter 和 monitorexit。
而是通过方法访问标志:
ACC_SYNCHRONIZED由 JVM 在方法调用和退出时处理同步。
总结:
3. Mark Word 是什么?
在 HotSpot JVM 中,普通 Java 对象具有对象头。
简化结构:
Java 对象
|
v
对象头
|
|-- Mark Word
|
|-- Klass Pointer
|
v
实例数据
|
v
对齐填充(按需)其中:
Mark Word:保存对象运行状态相关信息。Klass Pointer:指向对象的类型元数据。实例数据:存储对象字段。
对齐填充:根据对象大小及 JVM 对齐要求决定。
Mark Word 可能包含的信息:
对象哈希码。
GC 分代年龄。
锁状态标记。
偏向锁相关信息。
锁记录或 Monitor 的相关信息。
注意:Mark Word 的内容会根据对象状态变化,不是同时存储所有这些信息。
Mark Word 和 Monitor 的关系:
对象头中的 Mark Word 可以记录或关联锁状态信息。
在某些锁状态下,JVM 会通过 Mark Word 和其他内部结构定位对应的锁记录或 Monitor。
但不能简单理解成所有对象都始终对应一个已经创建的重量级 Monitor。
4. Java 8 的锁优化
Java 8 HotSpot 对 synchronized 做了多种优化。
常见锁状态:
偏向锁
假设某个锁长期只被一个线程使用。
如果每次获取锁都需要进行较多同步操作,会产生额外开销。
偏向锁可以优化同一线程反复进入同步区域的场景,减少不必要的同步成本。
Java 8 HotSpot 通常默认启用偏向锁,但实际行为受 JVM 参数、启动延迟等因素影响。
当其他线程尝试获取偏向锁时,JVM 可能需要撤销偏向状态。
轻量级锁
如果没有严重锁竞争,JVM 可以通过轻量级同步机制避免直接使用重量级 Monitor。
在 Java 8 HotSpot 的传统轻量级锁实现中,涉及:
线程栈中的锁记录。
对象头 Mark Word。
CAS 等原子操作。
当竞争无法通过轻量级机制有效处理时,可能发生锁膨胀。
重量级锁
竞争加剧时,JVM 可能使用重量级 Monitor 机制管理线程竞争。
没有获得锁的线程可能需要等待或挂起,再由 JVM 协调后续获取锁的过程。
需要注意:
不能把无锁、偏向锁、轻量级锁、重量级锁理解成每次加锁都会完整经历的四个固定步骤。
实际锁状态变化与竞争情况、JVM 版本和配置有关。
在 Java 8 中,学习偏向锁和轻量级锁的底层机制很有意义;但在较新 JDK 中,一些锁实现已经改变,不能直接套用全部细节。
5. 锁消除与锁粗化
除了锁状态优化,JIT 编译器还可能进行其他优化。
锁消除(Lock Elimination)
当 JVM 通过逃逸分析确认某个锁对象不会被其他线程共享时,可能消除不必要的同步操作。
例如:
public void process() {
Object lock = new Object();
synchronized (lock) {
// 不涉及共享对象的操作
}
}如果满足优化条件,JIT 可能消除这个同步操作。
注意:是否真正发生锁消除取决于实际编译优化情况。
锁粗化(Lock Coarsening)
如果代码连续多次对同一把锁进行加锁和解锁,JIT 可能合并相邻同步区域,减少反复加锁的开销。
例如:
synchronized (lock) {
count++;
}
synchronized (lock) {
count++;
}在满足优化条件的情况下,可能进行锁粗化。
不过锁范围增大也可能影响并发程度,所以 JVM 并不会无条件进行这种优化。
四、synchronized 的生产实践与常见问题
1. 为什么 synchronized 能保证可见性?
核心原因是 JMM 的 happens-before 规则:
对同一把锁的释放 happens-before 后续对该锁的获取。
例如:
public class CounterService {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}假设:
线程 A 获取锁,执行 count++。
线程 A 释放锁。
线程 B 随后获取同一把锁。
线程 B 读取 count。
根据 happens-before 规则,线程 A 在释放锁之前的修改,对之后获取同一把锁的线程 B 可见。
所以 synchronized 不仅实现互斥,也提供可见性和有序性保证。
2. 为什么锁对象必须相同?
例如:
public class CounterService {
private final Object lockA = new Object();
private final Object lockB = new Object();
private int count = 0;
public void incrementA() {
synchronized (lockA) {
count++;
}
}
public void incrementB() {
synchronized (lockB) {
count++;
}
}
}这段代码存在并发安全风险。
因为两个方法使用不同锁对象。
线程 A 可以进入 incrementA(),同时线程 B 也可以进入 incrementB()。
它们同时修改同一个 count,仍然可能出现丢失更新。
正确方式是使用同一把锁:
public class CounterService {
private final Object lock = new Object();
private int count = 0;
public void incrementA() {
synchronized (lock) {
count++;
}
}
public void incrementB() {
synchronized (lock) {
count++;
}
}
}所以:
需要互斥访问同一个共享状态的操作,必须建立一致的同步机制,而不是简单地给每个方法都加 synchronized。
3. 为什么不推荐在 synchronized 中执行耗时操作?
例如:
public synchronized void processOrder() {
// 调用第三方 HTTP 接口
callRemoteService();
// 更新数据库
updateDatabase();
}假设 HTTP 请求耗时 5 秒。
当前线程持有锁期间,其他需要相同锁的线程可能等待 5 秒甚至更长。
这会导致:
锁竞争增加。
请求响应变慢。
线程池中的线程大量等待。
系统吞吐量降低。
因此,生产环境应尽量缩小临界区,只保护必须同步的共享数据操作。
但不能为了缩小锁范围而破坏业务操作所需的原子性。
4. synchronized 能响应 interrupt() 吗?
如果线程正在等待获取 synchronized 锁:
synchronized (lock) {
// 业务操作
}其他线程执行:
thread.interrupt();等待线程不会因为中断而直接退出锁等待。
它通常仍然处于 BLOCKED 状态,直到有机会获取锁。
但是它的中断标记会被设置。
如果需要等待锁时能够响应中断,可以考虑使用 ReentrantLock:
ReentrantLock lock = new ReentrantLock();
lock.lockInterruptibly();
try {
// 业务操作
} finally {
lock.unlock();
}注意:lockInterruptibly() 会抛出 InterruptedException,实际方法需要声明或处理该异常。
5. Spring Boot 多实例环境的限制
例如:
@Service
public class CounterService {
private int count = 0;
public synchronized void increment() {
count++;
}
}Spring 默认的普通 @Service Bean 是单例。
在同一个 Spring 容器内,多个线程调用同一实例的 synchronized 方法,可以实现互斥。
但如果通过 Docker 部署多个 Spring Boot 实例:
Spring Boot A
|
| synchronized 锁 A
|
v
MySQL
Spring Boot B
|
| synchronized 锁 B
|
v
MySQL两个 JVM 中的锁是独立的。
它们不能互相感知对方是否持有 synchronized 锁。
因此:
synchronized 只能实现单个 JVM 内基于相同锁对象的线程同步,不能直接实现跨 JVM 的分布式锁。
如果是数据库库存扣减,可以优先考虑单条条件更新:
UPDATE product
SET stock = stock - 1
WHERE id = #{productId}
AND stock > 0;通过数据库的事务与并发控制机制避免简单库存扣减中的丢失更新。
如果业务涉及多资源、多实例协作等更复杂情况,再根据实际需求选择事务、幂等、分布式锁等方案。
五、重点总结
synchronized 核心知识
需要重点记住的五个结论:
synchronized 锁住的是对象对应的监视器,不是代码本身。
普通同步方法和静态同步方法使用不同锁对象,不会天然互斥。
synchronized 支持可重入,同一个线程可以重复获取同一把锁。
synchronized 底层涉及 Monitor、对象头 Mark Word、锁记录等机制,Java 8 HotSpot 存在多种锁优化。
synchronized 能保证单 JVM 内正确同步的共享状态安全,但不能替代数据库事务或分布式锁。
六、常见问题与答案
1. synchronized 锁的是对象还是代码?
锁的是对象对应的监视器。
只有多个线程使用同一个锁对象时,才会产生互斥。
2. synchronized 为什么是可重入锁?
因为 JVM 能够识别锁的当前持有线程,并允许该线程再次获取同一把锁,同时正确管理重复进入和退出的层次。
3. synchronized 普通方法和静态方法会互斥吗?
不会自动互斥。
普通同步方法锁的是 this,静态同步方法锁的是对应 Class 对象。
4. synchronized 底层如何实现?
HotSpot JVM 使用对象头、锁记录、Monitor 等机制。
同步代码块通过 monitorenter 和 monitorexit 实现,同步方法通过 ACC_SYNCHRONIZED 标志实现。
5. synchronized 一定是重量级锁吗?
不是。
Java 8 HotSpot 提供偏向锁、轻量级锁等优化机制,可以减少部分场景下的同步开销。
6. synchronized 为什么能够保证可见性?
因为 JMM 规定,同一把锁的释放 happens-before 后续对该锁的获取。
7. synchronized 抛出异常后会释放锁吗?
会。
线程正常退出或异常退出同步区域时,JVM 都会正确释放锁。
但不会自动回滚已经执行的业务操作。
8. synchronized 和 ReentrantLock 有什么区别?
二者都支持互斥和可重入。
synchronized 由 JVM 自动管理锁释放。
ReentrantLock 需要手动调用 unlock(),但支持 tryLock()、lockInterruptibly()、公平锁和 Condition 等功能。
9. 为什么不推荐在 synchronized 中执行耗时 IO?
因为线程持锁期间,其他竞争相同锁的线程无法进入临界区。
耗时 IO 会延长持锁时间,增加锁竞争和请求延迟。
10. synchronized 能实现分布式锁吗?
不能。
synchronized 只在当前 JVM 内生效,不同 JVM 实例之间不能直接共享同一把对象监视器锁。
七、脑子过一遍
synchronized 的三种使用方式分别锁住什么对象?
为什么两个不同实例调用同一个普通同步方法,不一定会互斥?
什么是可重入锁?synchronized 如何支持可重入?
synchronized 的同步代码块和同步方法在字节码层面有什么区别?
什么是 Monitor?Owner、EntryList、WaitSet 分别有什么作用?
Mark Word 主要保存哪些信息?它与锁有什么关系?
Java 8 中的偏向锁、轻量级锁、重量级锁分别解决什么问题?
为什么 synchronized 可以保证共享数据的可见性?
synchronized 方法执行过程中抛出 RuntimeException,会不会释放锁?
为什么多个 Spring Boot 实例不能只依靠 synchronized 实现库存扣减安全?
