原文链接 作者:Tal Weiss 译者:iDestiny 校对:郭蕾
同步区有点像拜访你的公公婆婆。你当然是希望待的时间越短越好。说到锁的话情况也是一样的,你希望获取锁以及进入临界区域的时间越短越好,这样才不会造成瓶颈。
对于方法和代码块,语言层面的加锁机制是synchronized关键字,该关键字是由HotSpot虚拟机内置的。我们在代码中分配的每一个对象,如String、Array或者一个完整的JSON文档,在本地垃圾回收级别都具有内置的加锁能力。JIT编译器也是类似的,它在进行字节码的编译和反编译的时候,都取决于特定的某个锁的具体的状态和竞争级别。
同步块的关键是:进入临界区域内的线程不能超过一个 。这一点对于生产者消费者场景中来说非常糟糕,当一些线程独占地修改某些数据时,而另外一些线程只是希望读取数据,这个是可以和别的线程同时进行的。
读写锁(ReadWriteLock)是这种情况最好的解决方案。你指定哪些线程可以阻塞其他线程(写线程),哪些线程可以与其他线程共享数据(读线程)。这是一个完美的解决方案?恐怕不是。
读写锁不像同步块,它不是JVM内建的,它只不过是段普通的代码。为了实现加锁的语义,它得命令CPU原子地或者以特定的顺序执行操作,以避免竞态条件。这通常都是通过JVM预留的一个后门来实现的——Unsafe类。读写锁使用比较并交换(CAS)操作直接将值设置到内存中去,这是它们线程排队算法中的一部分。
即便如此,读写锁还是不够快,并且有时候慢得要死,慢到你觉得就不应该使用它。然而JDK的伙计们并没有放弃读写锁,现在他们带来了一个全新的StampedLock。StampedLock使用了一组新的算法以及Java 8 JDK中引入的内存屏障的特性,这使得这个锁更高效也更健壮。
它兑现了自己的诺言了吗?让我们拭目以待。
使用锁。从表面上看StampedLock使用起来更复杂。它们使用了一个票据(stamp)的概念,这是一个long值,在加锁和解锁操作时,它被用作一张门票。这意味着要解锁一个操作你需要传递相应的的门票。如果传递错误的门票,那么可能会抛出一个异常,或者其他意想不到的错误。
另外一个值得关注的重要问题是,不像ReadWriteLock,StampedLocks是不可重入的。因此尽管StampedLocks可能更快,但可能产生死锁。在实践中,这意味着你应该始终确保锁以及对应的门票不要逃逸出所在的代码块。
1 |
long stamp = lock.writeLock(); //blocking lock, returns a stamp
|
2 |
try {
|
3 |
write(stamp); // this is a bad move, you’re letting the stamp escape
|
4 |
} finally {
|
5 |
lock.unlock(stamp); // release the lock in the same block - way better
|
6 |
} |
这个设计还有个让人无法忍受的地方就是这个long类型的票据对你而言没有任何意义。我希望锁操作返回的是一个描述票据的对象——包括它的类型(读/写)、加锁时间、所有者线程等等。这样处理的话更容易调试和跟踪日志。不过这么做很有可能是故意的,以便阻止开发人员不要将这个戳在代码里传来传去,同时也减少了分配对象的开销。
乐观锁。StampedLocks最重要的一个新功能就是它的乐观锁模式。研究和实践经验表明,读操作是在大多数情况下不会与写操作竞争。因此,获取全占的读锁可能就如杀鸡用牛刀了。一个更好的方法可能是继续执行读,并且结束后同时判断该值是否被修改,如果被修改,你再进行重试,或者升级成一个更重的锁。
01 |
long stamp = lock.tryOptimisticRead(); // non blocking
|
02 |
read(); |
03 |
if (!lock.validate(stamp)){ // if a write occurred, try again with a read lock
|
04 |
long stamp = lock.readLock();
|
05 |
try {
|
06 |
read();
|
07 |
} finally {
|
08 |
lock.unlock(stamp);
|
09 |
}
|
10 |
} |
选择一个锁,最大的难点之一是其在生产环境中的表现会因应用状态的不同而有所差异。也就是说你不能凭空选择使用何种锁,而是得将代码执行的具体环境也考虑进来 。
并发读写线程的数量将决定你应该使用哪一种锁——同步块或者读写锁。如果这些线程数在JVM的执行生命周期内发生改变的话,这个问题就更棘手了,这取决于应用的状态以及线程的竞争级别。
为了解释,我对四种模式下的锁分别进行了压力测试——在不同竞争级别和读写线程组合下的synchronized、读写锁、StampedLock的读写锁以及读写乐观锁。读线程将读取一个计数器的值,写线程会将它从0增加到1M。
5个读线程和5个写线程:5个读写线程分别在并发地执行,我们发现StampedLock表现得最好,比synchronized性能高3倍多。读写锁也表现得不错。这里奇怪的是乐观锁,表面上看它该是最快的,实际却是最慢的。
10个读线程和10个写线程:接下来,我增加竞争级别提高到10个读线程和10个写线程。现在情况开始发生了变化。在同级别执行下,读写锁现在要比StampedLock和synchronized慢一个数量级。请注意,乐观锁令人惊讶的仍然比StampedLock的读写锁慢。
16个读线程和4个写线程:接下来,我保持同样的竞争级别,不过将读写线程的比重调整了下:16个读线程和4个写线程。读写锁再说次说明了为什么它要被替换掉了——它慢了百倍以上。Stamped以及乐观锁都表现得不错,synchronized也紧随其后。
19个读线程和1个写线程:最后,我看看19个读线程和1个写线程会怎样。注意到结果慢得多了,因为单线程需要更长的时间来完成计数增加操作。在这里我们得到了一些非常有趣的结果。读写锁需要太多时间来完成。尽管Stamped锁没有表现得很好…乐观锁明显是这里的赢家,打败了读写锁100倍。即使如此,记住这种锁定模式可能会失败,因为这段时间内可能会出现一个写线程。Synchronized, 我们的老朋友,继续表现出可靠的结果。
完整的结果可以在这里找到。硬件:MBP, Core i7。
基础测试代码可以在这里下载。
总结
总体看来, 整体性能表现最好的仍然是内置的同步锁。但是,这里并不是说内置的同步锁会在所有的情况下都执行得最好。这里主要想表达的是在你将你的代码投入生产之前,应该基于预期的竞争级别和读写线程之间的分配进行测试,再选择适当一个适当的锁。否则你会面临线上故障的风险。
其他的关于StampedLocks的资料请点击这里。