前言

在 Java 并发编程中,学会写 synchronized 只是幼儿园水平,学会选择“锁谁”才是迈入高阶开发的门槛。

很多严重的线上 Bug,不是因为没加锁,而是因为锁错了对象

  • 锁了 this,结果外部代码恶意占用导致死锁。
  • 锁了 Integer,结果自动装箱导致锁对象变了。
  • 锁了 String 常量,结果导致无关的业务模块互相阻塞。

本文将剥离所有复杂的并发理论,单刀直入:当代码存在线程安全问题时,我到底该拿哪个对象当锁?


⚡️ 精华速查版:锁对象决策清单

在写代码前,请对照下表进行“灵魂拷问”:

保护目标 ❌ 错误/不推荐写法 ✅ 最佳实践 (锁对象选择) 核心理由
实例变量 (非静态) synchronized(this) private final Object lock = new Object(); 封装性。避免外部代码持有你的对象锁造成干扰。
静态变量 (全局共享) synchronized(this)new Object() synchronized(ClassName.class) 生命周期匹配。类锁全局唯一,覆盖所有实例。
唯一标识/ID synchronized(idString) 基于 ConcurrentHashMapGuava Striped 管理的专用锁对象 防碰撞。String 常量池会导致全系统级别的锁冲突。
普通计数器 synchronized(countInteger) AtomicIntegersynchronized(lockObj) 对象一致性。包装类是不可变的,一修改引用就变了,锁失效。

一、 核心心法:N : 1 的映射法则

在选择锁对象之前,必须建立一个正确的心理模型:
锁是“监视器(Monitor)”,而资源是“受保护的数据”。

选择锁对象的唯一标准是:所有访问该资源的线程,必须能够看见同一个锁对象。

黄金法则:
锁对象的生命周期 $\ge$ 受保护资源的生命周期。

如果资源是全局的(Static),锁就必须是全局的;如果资源是对象私有的,锁可以是对象私有的。


二、 场景一:保护实例变量 (Instance Variables)

这是最常见的场景。比如一个 Account 对象里的 balance 余额。

1. 为什么 synchronized(this) 不是最优解?

虽然 JDK 源码中经常这么用(为了省事),但在企业级应用开发中,直接锁 this 有两个隐患:

  1. 锁泄露(Lock Leak)this 对象在类外部是可见的。外部代码可以执行 synchronized(account)
  2. 外部干扰:如果外部代码拿到你的对象锁后执行了耗时操作(甚至死循环),你类内部的方法就会被永久阻塞。

2. 最佳实践:专用私有锁

强烈建议显式定义一个 private final 的锁对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SafeCounter {
private int count;

// ✅ 1. private: 只有我自己能拿到,外部干扰不了
// ✅ 2. final: 确保锁对象一旦创建,引用永远不变
private final Object lock = new Object();

public void increment() {
synchronized (lock) {
count++;
}
}
}

这样做实现了**“锁的封装”**,你的并发逻辑是完全闭环的,不受外界影响。


三、 场景二:保护静态变量 (Static Variables)

静态变量属于类,所有实例共享这一份数据。

1. 典型的错误:用实例锁保护静态资源

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ErrorDemo {
private static int staticCount = 0;

public void add() {
// ❌ 错误!
// 每次 new ErrorDemo() 都会产生一个新的 this 锁。
// 线程A 锁的是 thisA,线程B 锁的是 thisB。
// 它们根本没有互斥,staticCount 会被并发写坏。
synchronized (this) {
staticCount++;
}
}
}

2. 最佳实践:类锁 (Class Lock)

必须使用该类的 Class 对象,因为它在 JVM 中是全局唯一的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StaticSafeDemo {
private static int staticCount = 0;

public void add() {
// ✅ 正确:锁住类对象
synchronized (StaticSafeDemo.class) {
staticCount++;
}
}

// 或者直接写在 static 方法上,效果等同于锁 .class
public static synchronized void addStatic() {
staticCount++;
}
}

四、 避坑指南:绝对不能选的“地雷对象”

有些对象看起来人畜无害,一旦作为锁对象,就是生产事故的根源。

1. 禁区一:字符串常量 (String Literals)

1
2
// ☠️ 永远不要这么写
synchronized("user_123") { ... }

原因: Java 的字符串常量池(String Pool)
全 JVM 中,所有字面量为 "user_123" 的字符串,指向的都是同一个对象。

  • 你在 OrderService 里锁了 "user_123"
  • 同事在完全无关的 UserService 里也锁了 "user_123"
  • 结果:两个八竿子打不着的业务模块发生了阻塞,甚至死锁。

2. 禁区二:包装类 (Wrapper Classes)

1
2
3
4
5
Integer count = 0;
// ☠️ 永远不要这么写
synchronized(count) {
count++;
}

原因: IntegerLong 等包装类是**不可变(Immutable)**的。
当执行 count++ 时,本质上是 count = new Integer(count + 1)

  • 锁变了! 线程 A 进来锁的是 Obj(0),执行完后 count 变成了 Obj(1)。线程 B 进来锁的是 Obj(1)
  • 大家锁的都不是同一个房子,门锁形同虚设。

3. 禁区三:非 Final 的可变引用

1
2
3
4
5
6
7
8
9
10
11
12
public class MutableLock {
// ☠️ 忘了加 final
private Object lock = new Object();

public void doSomething() {
synchronized(lock) {
// 如果在某个地方(甚至在这个代码块内部)
// lock = new Object(); 被执行了
// 那么后续线程和当前线程就失去了互斥关系
}
}
}

结论: 做锁的成员变量,必须加上 final 关键字。


五、 总结

如何确定加锁对象?本质就是三个步骤:

  1. 看范围:资源是实例的?还是静态的?
    • 实例资源 $\to$ 找实例级别的锁(推荐 private final Object)。
    • 静态资源 $\to$ 找类级别的锁(ClassName.class)。
  2. 看稳定性
    • 这个锁对象会不会变?(排除包装类、非 final 变量)。
    • 这个锁对象会不会被别人共用?(排除 String 常量)。
  3. 看唯一性
    • 在并发这组线程的眼里,它们看到的必须是同一个引用

只要守住这三个原则,你就守住了线程安全的第一道防线。至于如何拆分锁来提升性能,那是“选对锁”之后才需要考虑的进阶话题。