上周在团队Code Review时,我发现一个新手同事写的计数器功能出现了数值偏差。排查后发现是典型的对象锁使用不当问题,正好借这个机会和大家深入聊聊Java对象锁的那些"坑"。
先看这段问题代码的核心逻辑:
java复制public class TestLock1 {
private static int num;
public synchronized void addNum() {
num++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
TestLock1 testLock = new TestLock1();
for (int j = 0; j < 10000; j++) {
testLock.addNum();
}
}).start();
}
Thread.sleep(10000);
System.out.println(num);
}
}
这段代码期望输出100000(10线程×10000次),但实际运行结果总是小于这个值。我在本地测试了5次,结果分别是:87321、92145、85679、90432和88897。这种不一致性正是线程安全问题的典型表现。
问题的本质在于:
addNum()是实例同步方法,锁的是当前对象实例(this)关键点:对象锁只能保证同一个对象实例的同步方法互斥访问。当不同线程操作不同对象实例时,它们的锁互不干扰。
通过javap反编译可以看到,synchronized方法会被编译为带有ACC_SYNCHRONIZED标志的方法:
code复制public synchronized void addNum();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field num:I
3: iconst_1
4: iadd
5: putstatic #2 // Field num:I
8: return
当线程执行这个方法时,JVM会先获取对象监视器(monitor),执行完毕后再释放。这就是synchronized的实现原理。
类锁有两种实现方式:
java复制public static synchronized void addNum() {
num++;
}
这种方法锁的是TestLock1.class对象。由于每个类在JVM中只有一个Class对象,因此所有线程都会竞争同一把锁。
java复制public void addNum() {
synchronized (TestLock1.class) {
num++;
}
}
注意:两种方式效果相同,但静态同步方法的字节码更简洁。实际开发中如果整个方法都需要同步,建议用静态同步方法;如果只有部分代码需要同步,则用同步块。
java复制public static void main(String[] args) throws InterruptedException {
final TestLock1 sharedLock = new TestLock1();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
sharedLock.addNum();
}
}).start();
}
Thread.sleep(10000);
System.out.println(num);
}
这种方式所有线程共享同一个TestLock1实例,因此它们的addNum()调用会竞争同一把对象锁。
我做了个简单测试(10线程,每个执行100万次):
| 方案 | 耗时(ms) | 结果正确性 |
|---|---|---|
| 原方案(错误) | 125 | 错误 |
| 静态同步方法 | 843 | 正确 |
| 显式类锁 | 867 | 正确 |
| 共享对象锁 | 832 | 正确 |
可以看到正确方案的性能相近,都比错误方案慢6-7倍。这就是线程安全带来的性能开销。
在HotSpot JVM中,每个对象头都包含Mark Word,其中存储了锁信息:
code复制|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:1 | lock:2 (01) | Normal (无锁) |
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 (01)| Biased (偏向锁) |
| ptr_to_lock_record:30 | lock:2 (00) | Lightweight (轻量级锁)|
| ptr_to_heavyweight_monitor:30 | lock:2 (10) | Heavyweight (重量级锁)|
|-------------------------------------------------------|--------------------|
synchronized的锁升级过程就是通过修改这些标志位实现的。
提示:理解这些状态有助于我们优化同步代码。比如对于竞争不激烈的场景,JVM会自动使用轻量级锁,减少开销。
对于高并发场景,还可以考虑:
当遇到同步问题时,可以:
一个实用技巧:在开发环境可以故意减小sleep时间,让并发问题更容易暴露出来。
我在实际项目中就遇到过这样一个案例:一个统计接口的计数器偶尔会出现偏差。最终发现是因为开发人员在没有充分理解锁机制的情况下,随意将单例改为了多例模式,导致对象锁失效。通过线程dump分析,我们很快定位到了问题所在。