性能优化-偏向锁
最近在研究如何提升MQ的性能,了解到了JVM的停顿,一个一个来,先看看Java的偏向锁带来的性能损耗
Java的锁
Java在锁方面做了好多优化,具体的一些介绍,另外,网上也有很多文档,可以自己搜索看看。这里大概摘抄总结下:
锁 | 优点 | 缺点 | 场景 |
---|---|---|---|
偏向锁 | 加锁和解锁(非竞争下)基本不需要额外的消耗 | 如果存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,而是通过适应性自旋(Adaptive Spinning)来空跑 | 消耗CPU | 借助于CAS操作实现锁,避免了依赖操作系统底层的mutex lock(重量级锁)造成的性能损耗 |
重量级锁 | 不必使用自旋,浪费cpu | 调用底层系统资源,由用户态切换至内核态,线程的挂起和唤醒会消耗大量的资源 | 同步快代码的执行一般耗时比较长 |
Java里面的锁是单向的:锁逐级往上膨胀(偏向锁->轻量级锁->重量级锁),但不能倒退
除了上面的各种锁,Java在锁方面还做了其他优化:
- 适应性自旋(Adaptive Spinning):线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少
- 锁粗化(Lock Coarsening):将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁
- 锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁
偏向锁的优势
看了锁的描述,就想压一下看看。先用jmh写了一个压测代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29@State(Scope.Benchmark)
@Threads(1)
@Warmup(iterations = 5)
@Measurement(iterations = 5, time = 20, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode(Mode.Throughput)
public class JVMLockJMH {
private static long value = 20000000;
private static Object lock = new Object();
private static int count;
@Benchmark
public void doRun() {
synchronized (lock) {
count++;
}
}
public static void main(String[] args) throws RunnerException, InterruptedException {
Thread.sleep(5_000);
Options opt = new OptionsBuilder()
.include(JVMLockJMH.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
注意:Hotspot虚拟机在开机启动后有个延迟(4s),经过延迟后才会对每个创建的对象开启偏向锁。我们可以通过设置下面的参数来修改这个延迟,或者直接sleep一段时间
-XX:BiasedLockingStartupDelay=0
因为偏向锁的使用场景是单线程,所以设置了线程数为1. 在我机器上(2.7 GHz Intel Core i5,2核)的对比结果:
使用偏向锁: java -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDetails -XX:+UseBiasedLocking -jar target/demo-1.0-SNAPSHOT.jar
JVMLockJMH.doRun thrpt 50 35056.419 ± 1559.525 ops/ms
取消偏向锁: java -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDetails -XX:-UseBiasedLocking -jar target/demo-1.0-SNAPSHOT.jar
JVMLockJMH.doRun thrpt 50 35312.731 ± 1386.977 ops/ms
看起来两者差别不大啊。又看了下相关文档,觉得偏向锁优势肯定不小,问题应该是我的压测方式不对。仔细看了下压测代码:
1 | @Threads(1) |
我觉得问题应该出在这里,jmh预热和实际跑的线程可能不是一个,虽然线程数指定的是一个。于是又写一份代码:
1 | new Thread(new Runnable() { |
不使用jmh,而是自己起一个单独的线程压测,再对比下:
使用偏向锁: java -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDetails -XX:+UseBiasedLocking -jar target/demo-1.0-SNAPSHOT.jar
operationsPerMillisecond:4.3478260869565216E11
取消偏向锁: java -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDetails -XX:-UseBiasedLocking -jar target/demo-1.0-SNAPSHOT.jar
operationsPerMillisecond:3.853564547206165E10
结果比较明显了:使用偏向锁的情况下,快了有一个量级。自己压一下感觉才明显:偏向锁在单线程访问同步块的场景下性能确实提高很多。
偏向锁竞争时的消耗
在适合的场景,偏向锁确实优势很大,但偏向锁最大的缺点在于,如果存在竞争,会造成额外的消耗,具体是因为:
- 持有偏向锁的线程不会主动释放
- 偏向锁的撤销,需要等待全局完全点(safepoint)
如果有线程在竞争偏向锁,这些线程会被阻塞到全局安全点,然后,才开始轻量级锁的竞争。
实际动手测试下,代码如下:
1 | /** |
使用下面的启动参数:
java
//打开这些选项能够记录下所有的安全点,而不止是GC暂停的时间
-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime
//GC的选项
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/tmp/logs/gc.log
//启用这些参数使得JVM会输出一些额外的信息记录,主要包括暂停的原因,暂停的线程数和暂停时间(可用于排查问题,线上不建议启动)
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
//来解锁任何额外的隐藏参数
-XX:+UnlockDiagnosticVMOptions//禁止在console输出vm参数
-XX:-DisplayVMOutput
//将虚拟机日志都会输出到vm.log文件中(线上不建议开启,用于排查问题)
-XX:+LogVMOutput -XX:LogFile=/tmp/logs/vm.log
-jar target/demo-1.0-SNAPSHOT.jar
看一下程序的暂停时间:
1 | 2017-07-10T14:27:33.089-0800: 4.107: Total time for which application threads were stopped: 0.0012580 seconds |
在vm日志里面可以看到暂停的原因:
1 | vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count |
日志的分析可以参考这里.可以比较直观地看到,线程被迫等待偏向锁被擦除
虽然在我的机器上程序的暂停时间很短,但如果应用复杂,涉及暂停的线程会更多。如果我们应用不是单线程模型,我估计大部分实际的线上业务都不是这种模型,建议在启动的时候关闭偏向锁。
1 | -XX:-UseBiasedLocking |