Skip to content

加了 synchronized,计数还是少了 ​

一个统计方法加了 synchronized,上线后数字还是对不上,总比实际的少。代码评审时所有人都看到了那个关键字,没有人问一句:两个线程进入这个方法时,锁的是同一个对象吗?

synchronized 的互斥是针对对象的:两个线程要在同一个对象上加锁,才会一个等另一个。关键字本身不保证这一点。锁对象选错了,代码能编译,单线程测试全部通过,并发时与没加锁一样。

本文把常见的几种选法分成三类:锁不住、锁得住、锁得太宽。实验见文末。

锁不住:两个线程拿到的不是同一个对象synchronized 实例方法每个线程用的是不同的实例synchronized (new Object())每次进入都新建synchronized (boxed)boxed++ 之后变量指向新对象synchronized ("order-" + id)每次拼出一个新字符串synchronized (userId)Long 超出缓存范围,每次是新对象synchronized + ReentrantLock同一个字段,两把不同的锁锁得住:相关的线程拿到同一个对象,别的代码拿不到private final Object lock与被保护的状态同生命周期static synchronized保护静态状态:锁类对象locks.computeIfAbsent(key, …)按业务键取同一个锁对象锁得太宽:不相干的代码也拿到了这个对象synchronized ("LOCK")同内容的字面量全进程一份synchronized (key.intern())字符串常量池里的同一个对象synchronized (count)Integer 在 -128 到 127 内是缓存对象判断方法:一个线程拿着锁不放,看另一个线程能否进入。能进入就是两把锁三类写法编译都能通过,单线程测试的结果也都正确多个实例部署时,以上任何一种都只在一个 JVM 内有效
图 1 · 实测:synchronized 保护的范围由括号里那个对象的身份决定。两个线程拿到不同的对象,同步块就各进各的;拿到同一个对象才互斥。还有一类是对象的范围比预想的大:字符串字面量、intern() 的结果、缓存范围内的包装类型对象在整个进程里只有一份,不相干的代码会在同一把锁上排队。

一、怎么判断是不是同一把锁 ​

实验用了两种判断方法。

看有没有丢更新。 两个线程各做 20 万次「读、加一、写」,结果应该是 40 万。少了就是有两次加一互相覆盖:

text
不加锁:丢了更新(结果小于 400000)
两个线程锁同一个对象:5 轮都是 400000

直接看能不能进入。 一个线程拿着锁 A 不放,另一个线程去拿锁 B。300 毫秒内进去了,说明 A 和 B 是两把不相干的锁;进不去,说明是同一把。下文的「照样进入」与「被挡住」指的都是这个测试。

二、锁不住的写法 ​

实例方法上的 synchronized ​

java
class Service {
    static int total;
    synchronized void add() { total++; }      // 锁的是 this
}

方法上的 synchronized 锁的是当前实例。两个线程各自持有一个 Service 实例时,锁的是两个对象:

text
两个实例各自的 synchronized 方法修改同一个静态变量:丢了更新(结果小于 400000)
一个线程在实例 1 的同步方法里:另一个线程照样进入(两把锁)
改成 static synchronized(锁的是类对象):5 轮都是 400000

这种情况在框架里很常见:一个类被声明成每次请求新建一个实例(原型作用域的 Bean、每个请求一个的处理器),它修改的却是静态字段、单例里的集合或数据库里的同一行。实例上的锁对这些共享状态没有任何保护。

规则只有一条:锁对象的范围要与被保护的状态一致。状态是静态的,锁就得是静态的;状态属于某个单例,锁就放在那个单例里。

两处代码用了两种锁 ​

java
synchronized void viaSynchronized() { n++; }
void viaLock() { lock.lock(); try { n++; } finally { lock.unlock(); } }
text
一个方法用 synchronized、另一个方法用 ReentrantLock 保护同一个字段:丢了更新(结果小于 400000)

两个方法各自都「加了锁」,但一个锁的是 this 的监视器,另一个是 ReentrantLock 对象,互不相干。重构时把一部分方法从 synchronized 改成显式锁,漏掉一个,就是这个结果。

锁对象是新建的,或者会变 ​

java
void add() {
    Object lock = new Object();
    synchronized (lock) { counter++; }       // 每次进入都是一个新对象
}
text
同步块锁的是方法里刚 new 出来的对象:丢了更新(结果小于 400000)

这个写法很少直接出现,更常见的是它的变体:锁对象是一个会被重新赋值的字段。

java
Integer boxed = 0;
synchronized (boxed) { boxed++; }

boxed++ 不是修改那个 Integer 对象,而是算出一个新的 Integer 再赋给变量。下一个线程进来时,锁的已经是另一个对象:

text
synchronized (boxed) { boxed++; }:丢了更新(结果小于 400000)
Integer 自增之后,变量指向的还是原来那个对象 = false
javac 对这种写法的警告:WARNING attempt to synchronize on an instance of a value-based class

JDK 25 的 javac 对在包装类型上加锁会直接给出警告。同样的问题也出现在「锁在集合字段上,然后给这个字段赋了一个新集合」的写法里。锁对象的字段应该是 final 的。

三、拿业务键当锁 ​

按订单号、用户 ID 加锁是合理的需求:不同的订单可以并行,同一个订单要串行。直接把业务键放进 synchronized 的括号里,结果取决于这个键的对象是怎么来的。

锁对象另一个线程
两个运行时拼出来的 "order-42"(equals 为 true,== 为 false)照样进入
Long.valueOf(20261010L) 取两次照样进入
Integer.valueOf(1000) 取两次照样进入
Integer.valueOf(100) 取两次被挡住
各自调用 intern() 之后的 "order-42"被挡住
两个互不相干的类各自的字面量 "LOCK"被挡住

前三行是锁不住:每个请求从参数里解析出来的字符串、从数据库读出来的 Long,内容相同但不是同一个对象。

Integer.valueOf(100) 被挡住,是因为 -128 到 127 的包装对象有缓存,取到的是同一个。这带来一个很隐蔽的现象:用小的 ID 测试时锁是生效的,换成线上的真实 ID 就不生效了。

后两行是锁得太宽。intern() 之后的字符串和字面量来自全进程共享的字符串常量池。两个完全无关的模块恰好都写了 synchronized ("LOCK"),它们就在同一把锁上排队;第三方库里也这么写的话,自己的代码会被别人的代码阻塞,从线程转储里很难看出两者的关系。intern() 还会让每一个出现过的键永久留在常量池的哈希表里。

可靠的做法是自己维护一张从键到锁对象的表:

java
Map<String, Object> locks = new ConcurrentHashMap<>();
synchronized (locks.computeIfAbsent(key, k -> new Object())) { … }
text
按键从 ConcurrentHashMap 里 computeIfAbsent 取锁对象:另一个线程被挡住(同一把锁)
同一张表里另一个键 "order-43":另一个线程照样进入(两把锁)

相同的键拿到同一个对象,不同的键互不影响,别的代码拿不到这张表。要注意的是这张表只增不减,键的数量没有上限时需要回收;回收与加锁之间有竞争,通常用固定数量的分段锁(对键取哈希后落到 N 把锁之一)来避开,代价是不同的键偶尔共用一把锁。

四、锁对了,但不是每一处都用了 ​

读的一方没有加锁。 写方在同一把锁里先改 a 再改 b,保持 a + b = 0。读方不加锁:

text
写方在锁内先改 a 再改 b,读方不加锁,在两次修改之间读到 a + b = 1(约定恒为 0)
读方也加同一把锁:读到 a + b = 0

锁保护的是「拿到这把锁的代码之间」的互斥。不拿锁的代码可以在任何时刻读到中间状态。涉及多个字段的不变量,读和写都要在同一把锁里;单个字段的可见性问题见 JMM 与 happens-before。

每个操作都同步,组合起来不同步。 Collections.synchronizedList 的每个方法都加了锁,但「先判断是否为空,再添加」是两次调用:

text
synchronizedList 上两个线程各自「为空才添加」,都先检查完再添加:列表里有 2 个元素

两个线程都看到了「空」,然后都添加了。线程安全的容器只保证单个方法是原子的。复合操作要么用容器提供的原子方法(putIfAbsent、computeIfAbsent),要么在外面再加一层锁,而且得是容器内部用的那一把。

五、单个进程之外 ​

以上所有的锁都在一个 JVM 里。服务部署了多个实例之后,两个请求落在不同的实例上,任何进程内的锁都管不到。保护的对象如果是数据库里的一行、一个外部账户、一个文件,需要的是数据库的行锁、唯一约束、条件更新,或者分布式锁,见分布式锁的三种实现。

进程内的锁仍然有用的场景,是保护进程内的状态:内存里的缓存、计数器、连接池。先分清被保护的东西在哪里,再决定锁放在哪里。

六、检查清单 ​

  • 能说出每个同步块锁的是哪个对象,以及所有访问这份状态的线程是否都会拿到同一个。
  • 锁对象是 private final 的字段;不锁在会被重新赋值的字段、包装类型、字符串上。
  • 同一份状态只用一种锁;读与写、所有的修改路径都经过它。
  • 静态状态配静态锁;每次请求新建的对象上的锁保护不了任何共享的东西。
  • 按业务键加锁时,用自己的表把键映射到锁对象。
  • 多实例部署时,进程内的锁不能用来保护进程外的数据。

ReentrantLock 内部怎样排队、条件变量怎么用,见从 AQS 看 ReentrantLock。


配套实验

  • codesphere-labs/java/lock-identity:JDK 25.0.4.1 上的确定性实验:实例锁与类锁、两种锁混用、每次新建与被重新赋值的锁对象、字符串与包装类型当锁、按键取锁对象的表、不加锁的读、组合操作(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权