一次线程同步问题的Bugfix

前两天碰到一个Java多线程未同步引起的Bug。Bug本身并没多少槽点,无非是本该同步的方法没加同步,造成最后执行结果非预期结果,但是在解决Bug的过程中发现还是有些知识点值得记录下来的。

本文分成两部分:1. Bug分析与解决。 2. synchronized注意事项。

1. Bug分析与解决

Watcher

这是出现Bug的业务场景和关键代码。Wathcer Service监听Etcd中产生的事件,然后提交给线程池处理。假设XJob是负责处理相关Etcd事件的类,其业务逻辑如代码所示,问题出在注释3-5处,当Etcd在很短的时间间隔内连续产生同一个操作对象的两个相同事件(该场景是我在设计之初未考虑到)时,会有多个XJob实例(线程)同时执行到注释3所在的区块。由于注释4处的rpc调用是个比较耗时的操作,因此可能会造成某个Entry在第一个线程中尚未写回到DB时,又被第二个线程取出来操作,导致数据不一致。

分析出问题所在后,最简单的方法就是通过synchronized关键字对相关代码进行同步。修复后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void run() {
    String id = getId(context);
    ......
    if (condition) {
        ......
    } else {
        synchronized(id.intern()) {
            Entry e = getFromDb(id, DEAD);
            rpc(e);
            update2Db(e, ALIVE);
            ......
        }
    }
}

这里有两个问题需在后面解答。

1). 在run()方法前加synchronized来修复Bug是否可行?

2). 为什么要通过synchronized(id.intern()){}而不是synchronized(id){}?

2. synchronized注意事项

1). synchronized关键字可以修饰方法(类方法/实例方法),也可以修饰代码块。本质上,进入synchronized修饰的区块都会得到一把锁,一块代码到底会不会同步执行,关键就是看多线程竞争的是否同一把锁。如此来理解所谓的对象锁和类锁就容易多了。先说对象锁,顾名思义,锁的粒度是对象。比如当synchronized修饰实例方法,它等同于用synchronized(this){}来修饰这个方法内的代码,也就是说除非是多个线程调用同一个实例(对象)的方法,否则synchronized不生效。以此类推,相同实例(对象)的不同synchronized方法也可以同步执行,因为他们的锁对象都是当前对象地址(this指针)。再说类锁,锁的粒度是类,一般修饰类方法或类变量,或者称为静态(static)方法或静态(static)变量。当synchronized修饰static方法时,它等同于用synchronized(类名.class){}来修饰这个静态方法内的代码。

举个栗子。某Test类有静态方法m1(),非静态方法m2()和m3(),三个方法都被synchronized关键字修饰,现有一个Test类的实例test。两个线程T1和T2,执行m2()和m3()时会同步,因为它们都要获得对象锁test。但是它们可以同时调用m1()和m2(),或者m1()和m3(),因为执行m1()是获得类锁Test.class,而执行m2()/m3()是获得对象锁test。

至此第1部分的两个问题原因显而易见了:

(1). run()方法前加synchronized来修复Bug是否可行?不行。因为提交给线程池执行的XJob对象不是单例的,XJob有很多个对象,不能用对象锁的方式。

(2). 为什么不是直接用synchronized(id){}?因为id对象是运行时生成的,这个String对象肯定分配在堆里。既然分配在堆里,即使id相同(equals()返回true),他们也属于不同的对象,不能用对象锁的方式。再看看String对象的intern()方法做了什么,intern()方法表示返回当前String对象在常量池中的地址,如果常量池中尚未存在该对象的值,那么就会将值放入常量池后再返回其地址。例如,两个String对象s1和s2,如果他们的值相同s1.equals(s2),那么s1.intern()==s2.intern()。当然它也有副作用,比如说频繁调用之后会引起Full GC。关于intern()实际上还有很多要注意的地方,涉及到GC性能,而且在不同的JDK版本下表现还不同,等有时间可以写一篇文章整理一下。

2). 虽然synchronized不可以修饰构造方法,但还是可以通过synchronized(this){}的方式修饰构造方法中代码块,然而这并没有什么意义,synchronized不会生效。想象一下,什么场景下多个线程需要初始化出相同地址的一个对象?如果构造方法中的初始化代码真的需要同步,可以通过类锁或其他的方式,但绝不会是synchronized(this)。

3). 进入synchronized代码块时获得的锁是由JVM负责释放的,这意味着无论代码是正常结束还是异常(checked, unchecked)退出,都不必关心锁未释放的问题。

--EOF--