并发内功-如何确定加锁对象?
前言
在 Java 并发编程中,学会写 synchronized 只是幼儿园水平,学会选择“锁谁”才是迈入高阶开发的门槛。
很多严重的线上 Bug,不是因为没加锁,而是因为锁错了对象。
- 锁了
this,结果外部代码恶意占用导致死锁。 - 锁了
Integer,结果自动装箱导致锁对象变了。 - 锁了
String常量,结果导致无关的业务模块互相阻塞。
本文将剥离所有复杂的并发理论,单刀直入:当代码存在线程安全问题时,我到底该拿哪个对象当锁?
⚡️ 精华速查版:锁对象决策清单
在写代码前,请对照下表进行“灵魂拷问”:
| 保护目标 | ❌ 错误/不推荐写法 | ✅ 最佳实践 (锁对象选择) | 核心理由 |
|---|---|---|---|
| 实例变量 (非静态) | synchronized(this) |
private final Object lock = new Object(); |
封装性。避免外部代码持有你的对象锁造成干扰。 |
| 静态变量 (全局共享) | synchronized(this) 或 new Object() |
synchronized(ClassName.class) |
生命周期匹配。类锁全局唯一,覆盖所有实例。 |
| 唯一标识/ID | synchronized(idString) |
基于 ConcurrentHashMap 或 Guava Striped 管理的专用锁对象 |
防碰撞。String 常量池会导致全系统级别的锁冲突。 |
| 普通计数器 | synchronized(countInteger) |
AtomicInteger 或 synchronized(lockObj) |
对象一致性。包装类是不可变的,一修改引用就变了,锁失效。 |
一、 核心心法:N : 1 的映射法则
在选择锁对象之前,必须建立一个正确的心理模型:
锁是“监视器(Monitor)”,而资源是“受保护的数据”。
选择锁对象的唯一标准是:所有访问该资源的线程,必须能够看见同一个锁对象。
黄金法则:
锁对象的生命周期 $\ge$ 受保护资源的生命周期。
如果资源是全局的(Static),锁就必须是全局的;如果资源是对象私有的,锁可以是对象私有的。
二、 场景一:保护实例变量 (Instance Variables)
这是最常见的场景。比如一个 Account 对象里的 balance 余额。
1. 为什么 synchronized(this) 不是最优解?
虽然 JDK 源码中经常这么用(为了省事),但在企业级应用开发中,直接锁 this 有两个隐患:
- 锁泄露(Lock Leak):
this对象在类外部是可见的。外部代码可以执行synchronized(account)。 - 外部干扰:如果外部代码拿到你的对象锁后执行了耗时操作(甚至死循环),你类内部的方法就会被永久阻塞。
2. 最佳实践:专用私有锁
强烈建议显式定义一个 private final 的锁对象。
1 | public class SafeCounter { |
这样做实现了**“锁的封装”**,你的并发逻辑是完全闭环的,不受外界影响。
三、 场景二:保护静态变量 (Static Variables)
静态变量属于类,所有实例共享这一份数据。
1. 典型的错误:用实例锁保护静态资源
1 | public class ErrorDemo { |
2. 最佳实践:类锁 (Class Lock)
必须使用该类的 Class 对象,因为它在 JVM 中是全局唯一的。
1 | public class StaticSafeDemo { |
四、 避坑指南:绝对不能选的“地雷对象”
有些对象看起来人畜无害,一旦作为锁对象,就是生产事故的根源。
1. 禁区一:字符串常量 (String Literals)
1 | // ☠️ 永远不要这么写 |
原因: Java 的字符串常量池(String Pool)。
全 JVM 中,所有字面量为 "user_123" 的字符串,指向的都是同一个对象。
- 你在
OrderService里锁了"user_123"。 - 同事在完全无关的
UserService里也锁了"user_123"。 - 结果:两个八竿子打不着的业务模块发生了阻塞,甚至死锁。
2. 禁区二:包装类 (Wrapper Classes)
1 | Integer count = 0; |
原因: Integer、Long 等包装类是**不可变(Immutable)**的。
当执行 count++ 时,本质上是 count = new Integer(count + 1)。
- 锁变了! 线程 A 进来锁的是
Obj(0),执行完后count变成了Obj(1)。线程 B 进来锁的是Obj(1)。 - 大家锁的都不是同一个房子,门锁形同虚设。
3. 禁区三:非 Final 的可变引用
1 | public class MutableLock { |
结论: 做锁的成员变量,必须加上 final 关键字。
五、 总结
如何确定加锁对象?本质就是三个步骤:
- 看范围:资源是实例的?还是静态的?
- 实例资源 $\to$ 找实例级别的锁(推荐
private final Object)。 - 静态资源 $\to$ 找类级别的锁(
ClassName.class)。
- 实例资源 $\to$ 找实例级别的锁(推荐
- 看稳定性:
- 这个锁对象会不会变?(排除包装类、非 final 变量)。
- 这个锁对象会不会被别人共用?(排除 String 常量)。
- 看唯一性:
- 在并发这组线程的眼里,它们看到的必须是同一个引用。
只要守住这三个原则,你就守住了线程安全的第一道防线。至于如何拆分锁来提升性能,那是“选对锁”之后才需要考虑的进阶话题。


