🚀 JUC并发编程 - 4.2 synchronized 解决方案
在上节我们分析了多线程共享资源时出现的竞态问题,这一节正式进入解决方案:synchronized 互斥锁。
🔧 应用之互斥
为了避免临界区发生竞态条件,常见手段有两大类:
-
阻塞式解决方案:
synchronized
、Lock
-
非阻塞式解决方案:
原子变量(AtomicInteger
等)
本节使用阻塞式解决方案:synchronized
,即对象锁。
🔐 什么是对象锁synchronized
synchronized
采用互斥机制:让同一时刻最多只有一个线程持有对象锁,其它线程如果想获取锁,就会被阻塞。
这样就能保证持有锁的线程安全执行临界区代码,不受上下文切换干扰。
🧠 理论理解
synchronized
是 Java 提供的内置锁机制,用于解决多线程共享资源的安全问题。
它的核心作用是:
-
让多个线程互斥访问共享资源,保证临界区的原子性操作。
-
只有获得锁的线程能执行同步代码块,其他线程则阻塞等待。
synchronized(对象)
中的“对象”就是锁的标识,同一时间,同一个锁对象只允许一个线程持有。
🏢 企业实战理解
字节跳动/美团: 实际项目中,synchronized
常用于小粒度的锁对象保护,比如一个缓存实例的读写,或者防止并发初始化单例。
NVIDIA: 在多 GPU 任务中,避免不同线程同时提交同一个 GPU 资源的请求,也通过类似的“互斥”机制实现。
💬 面试题(字节跳动)
问:Java 中 synchronized
的原理是什么?它锁的到底是对象的什么?
✅ 参考答案
synchronized
是 JVM 层面的实现,底层依赖对象的 Monitor(监视器锁)。
每个对象在 Java 中都有一个对象头,里面有一块区域叫做Mark Word,用来存储锁信息。当线程进入 synchronized
块时会尝试获得对象的 Monitor,获取成功则进入,否则进入阻塞或自旋等待。
总结:锁的不是对象本身,而是对象头中的 Monitor 结构。
💬 场景题(字节跳动)
你在开发中发现,用 synchronized
锁住了一个方法,但程序仍然出现数据不一致问题。经过检查,发现你锁的对象是一个 new Object()
临时变量。请解释为什么这个锁无效。
✅ 参考答案
synchronized
必须锁住多线程间共享的同一个对象,而 new Object()
每次创建的都是不同实例,导致不同线程持有不同的锁对象,形同无锁。
解决:
-
把锁对象提取为全局共享变量(如
static final Object lock = new Object()
) -
或者在面向对象中用
synchronized(this)
锁住实例对象。
⚠ 注意区分:互斥 vs 同步
-
互斥:
保证临界区同一时刻只有一个线程执行,防止竞态(本节重点)。 -
同步:
不同线程间存在“先后顺序”的依赖,一个线程需要等另一个线程运行到某个点(如wait/notify
)。
🧠 理论理解
-
互斥(Mutual Exclusion):
目的是保证同一时刻只有一个线程执行临界区代码,防止数据竞争。 -
同步(Synchronization):
是为了协调线程之间的“执行顺序”,让一个线程等待另一个线程达到某个状态(如wait/notify
)。
虽然 synchronized
同时可以实现互斥和同步,但这两个概念本质不同。
🏢 企业实战理解
阿里巴巴: 在高并发下,互斥锁用于保证数据一致性,同步机制用于线程协作(比如生产者-消费者模式)。
💬 面试题(阿里巴巴)
问:Java 中 synchronized
既可以实现互斥也可以实现同步,请解释两者的区别及示例。
✅ 参考答案
-
互斥: 保证临界区代码同一时刻只能有一个线程执行,防止数据竞争。示例:两个线程同时对
counter++
加锁,避免冲突。 -
同步: 让线程间按照顺序协作,一个线程等待另一个线程达到某个条件。示例:
wait/notify
实现生产者-消费者模式。
区别在于:
-
互斥关注“同时只有一个线程”
-
同步关注“线程之间的先后顺序”
💬 场景题(腾讯)
高并发压测时,你发现业务接口偶发返回错误数据。检查后发现临界区确实加了 synchronized
,但是锁对象是一个 String 类型的 key。请解释这可能带来的问题。
✅ 参考答案
在 Java 中,字符串常量池会导致不同地方的相同字符串字面值共享同一对象。
如果多个无关的业务模块锁了相同的字符串字面值,会意外竞争同一把锁,导致本不该互斥的操作被阻塞,甚至引发死锁。
建议:
-
不要用字符串字面值作为锁对象
-
可以使用
new String("...")
强制创建独立对象,或用Object
类型锁代替
🔨 示例代码
✅ 临界区示例
static int counter = 0;
static void increment() { // 临界区
counter++;
}
static void decrement() { // 临界区
counter--;
}
✅ synchronized 语法
synchronized(对象) {
// 临界区
}
💻 完整示例
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("最终结果: {}", counter);
}
3️⃣ 为什么 synchronized 有效
🧠 理论理解
synchronized
是基于 JVM 层面的**对象监视器(Monitor)**实现的。它能保证:
-
同一个锁对象,只有一个线程能获得锁并进入同步块
-
其他线程要么进入阻塞状态,要么不断尝试(如重入锁)
-
锁释放后,等待的线程会被唤醒并竞争锁
底层实现:
-
在 HotSpot JVM 中,
synchronized
使用了偏向锁、轻量级锁、重量级锁三种状态,实现自适应锁优化。
🏢 企业实战理解
华为云: JVM 层面的锁机制对于高性能服务是关键,实际项目中通过 -XX:+PrintGCApplicationStoppedTime
和 jstack
等工具监控锁竞争情况,优化系统吞吐。
💬 面试题(美团)
问:Java 的 synchronized
为什么能保证原子性?它如何阻止上下文切换时被打断?
✅ 参考答案
synchronized
依靠操作系统底层的互斥机制(如 pthread_mutex),在进入同步代码块时,会获得对象的 Monitor,其他线程就算切换到 CPU,也进不了同步块,只能阻塞。
原子性保障的关键:
-
同步块内的指令虽然可以被挂起,但不会释放锁,保证在执行临界区时不会被其他线程打断执行。
💬 场景题(阿里巴巴)
你优化某段代码时,把 synchronized
放到了 for 循环外面,压测时发现性能急剧下降。请解释为什么。
✅ 参考答案
synchronized
放在循环外,相当于一次性锁住了整个循环,执行 5000 次都持有锁,导致其他线程长时间阻塞,锁竞争变严重。
原本每次循环加锁(小粒度锁)→ 优化成整个循环加锁(大粒度锁),虽然锁的次数减少了,但等待时间明显增加,反而拉低性能。
🏠 类比解释
-
synchronized(room)
中的room
就像一个房间,有唯一入口(门)。 -
线程
t1
好比一个人进入房间,锁上门并拿走钥匙。 -
在房间内执行
counter++
。 -
这时如果
t2
也到达synchronized(room)
,会发现门被锁,只能阻塞等待。
即使 t1
在房间内时 CPU 时间片用完了,它也不会释放锁,t2
依然进不来。
直到 t1
执行完毕离开房间,释放锁并唤醒等待线程,这时 t2
才能进入执行。
4️⃣ 类比房间的锁
🧠 理论理解
synchronized(对象)
就像是一个房间(room):
-
房间有一扇门(锁对象)
-
只有一个线程能拿到钥匙进去
-
其他线程只能在门口等待,直到持有锁的线程执行完释放锁。
🏢 企业实战理解
Google: 内部并发控制也强调“锁对象唯一性”,比如 Google Guava 提供的 Monitor
就是对这种房间锁的更高级封装。
4️⃣ 锁对象的作用
💬 面试题(华为)
问:synchronized 加锁时锁对象如何选择?如果锁对象选错了会发生什么?
✅ 参考答案
锁对象是互斥的标识,必须是多线程间共享的同一个对象。
常见错误:
-
锁对象是局部变量(每次 new 出来的锁对象其实是不同实例)
-
锁对象写成字符串常量(会被 String Pool 共享,导致意外锁竞争)
如果锁对象选错:
-
会导致加锁失效(多线程并未锁住同一个对象)
-
或者引发意外阻塞(锁了不该锁的对象)
4️⃣ 场景题:死锁风险
💬 场景题(美团)
你开发一个模块,发现线程 A 加了 synchronized(obj1)
后再加 synchronized(obj2)
,线程 B 加了 synchronized(obj2)
后再加 synchronized(obj1)
。请问这会发生什么问题?怎么解决?
✅ 参考答案
这是典型的死锁风险:
-
线程 A 拿到
obj1
锁,等待obj2
-
线程 B 拿到
obj2
锁,等待obj1
两者互相等待,谁也等不到释放,系统卡死。
解决办法:
-
统一加锁顺序,保证所有线程先锁
obj1
再锁obj2
-
或者使用
tryLock
超时机制避免死锁
🖼 运行过程图
线程1 | 线程2 | static i | 锁对象 |
---|---|---|---|
尝试获取锁 | |||
拥有锁 | |||
getstatic i 读取 0 | |||
iconst_1 加 1 | |||
isub 减 1 | |||
上下文切换 | 尝试获取锁,阻塞(BLOCKED) | ||
putstatic i 写入 | |||
释放锁并唤醒 | 拥有锁 | ||
getstatic i | |||
iconst_1 | |||
iadd | |||
putstatic i | |||
释放锁 |
🤔 思考题
1️⃣ 如果把 synchronized(obj)
放在 for 循环的外面,会发生什么?
👉 相当于把整个循环都包裹在锁中,实现“整体原子性”。性能上会更慢,但完全避免每次循环时抢锁的开销。
2️⃣ 如果 t1 用 synchronized(obj1)
而 t2 用 synchronized(obj2)
会怎样?
👉 它们加的不是同一把锁,相当于没有互斥效果,并发冲突依然存在。
3️⃣ 如果 t1 加锁而 t2 不加锁呢?
👉 这种情况形同虚设,只要有一方不加锁,临界区就不能保证安全。
🛠 面向对象改进
把共享变量封装到对象中,更加优雅 👇:
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("最终结果: {}", room.get());
}
}
🧠 理论理解
当我们把共享变量封装到类里时,synchronized(this)
可以把锁对象绑定到实例,简化管理,同时做到锁粒度最小化。
这种方式既符合面向对象封装思想,也让锁的管理变得更加清晰易控。
🏢 企业实战理解
字节跳动: 强调把共享资源“锁到最小作用域”,不仅提高了代码可读性,也减少了锁竞争范围,典型案例是数据库连接池、线程池的内部实现。
5️⃣ 面向对象加锁
💬 面试题(腾讯)
问:为什么推荐将共享资源封装到对象里,并在方法内部用 synchronized(this)
加锁?
✅ 参考答案
这种做法的好处是:
1️⃣ 封装了资源和锁,遵循面向对象的“高内聚”思想
2️⃣ this
作为锁对象,天然是唯一的实例锁,确保多线程访问时互斥
3️⃣ 锁的粒度更清晰,易于维护,不容易出错
这也是很多框架(如数据库连接池、线程池)的标准做法。
5️⃣ 场景题:面向对象加锁
💬 场景题(华为云)
你开发一个 Room 类,t1 和 t2 两个线程同时操作 Room 的不同方法,发现数据还是出错了。代码中每个方法内部都加了 synchronized(this)
。请问为什么还会有问题?
✅ 参考答案
synchronized(this)
锁住的是整个对象实例,如果 Room 类的多个方法都加了 synchronized(this)
,那么无论操作哪个方法,它们之间是互斥的,不会导致线程安全问题。
但出错的原因可能是:
-
Room 类内部还有非加锁的其他成员变量,或
-
你操作的是不同 Room 实例,t1/t2 实际锁住的不是同一个对象。
检查点:
-
确保多线程操作的是同一个对象实例
-
确保涉及共享资源的方法都加锁保护
✅ 总结
本节你学到了:
-
synchronized
的基本原理 -
互斥与同步的区别
-
如何用对象锁保护共享资源
-
面向对象的加锁思路
关键点: 任何时候,锁对象要统一,否则加锁就形同虚设。synchronized
的本质是用锁机制实现线程安全的“原子性”。