最近在研究如何提升MQ的性能,了解到了JVM的停顿,一个一个来,先看看Java的偏向锁带来的性能损耗

Java的锁

Java在锁方面做了好多优化,具体的一些介绍,另外,网上也有很多文档,可以自己搜索看看。这里大概摘抄总结下:

优点 缺点 场景
偏向锁 加锁和解锁(非竞争下)基本不需要额外的消耗 如果存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,而是通过适应性自旋(Adaptive Spinning)来空跑 消耗CPU 借助于CAS操作实现锁,避免了依赖操作系统底层的mutex lock(重量级锁)造成的性能损耗
重量级锁 不必使用自旋,浪费cpu 调用底层系统资源,由用户态切换至内核态,线程的挂起和唤醒会消耗大量的资源 同步快代码的执行一般耗时比较长

Java里面的锁是单向的:锁逐级往上膨胀(偏向锁->轻量级锁->重量级锁),但不能倒退

除了上面的各种锁,Java在锁方面还做了其他优化:

  1. 适应性自旋(Adaptive Spinning):线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少
  2. 锁粗化(Lock Coarsening):将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁
  3. 锁消除(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
2
@Threads(1)
@Warmup(iterations = 5)

我觉得问题应该出在这里,jmh预热和实际跑的线程可能不是一个,虽然线程数指定的是一个。于是又写一份代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
new Thread(new Runnable() {
@Override
public void run() {
/**
* warm up
*/
lockBench.doRun();
barrier.await();
/**
* 执行测试
*/
lockBench.doRun();
barrier.await();
}
}).start();

不使用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

结果比较明显了:使用偏向锁的情况下,快了有一个量级。自己压一下感觉才明显:偏向锁在单线程访问同步块的场景下性能确实提高很多。

偏向锁竞争时的消耗

在适合的场景,偏向锁确实优势很大,但偏向锁最大的缺点在于,如果存在竞争,会造成额外的消耗,具体是因为:

  1. 持有偏向锁的线程不会主动释放
  2. 偏向锁的撤销,需要等待全局完全点(safepoint)

如果有线程在竞争偏向锁,这些线程会被阻塞到全局安全点,然后,才开始轻量级锁的竞争。

实际动手测试下,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Created by lishoubo on 17/7/5.
*/
public class BiasedLocks {

private static synchronized void contend() {
LockSupport.parkNanos(1000);
}

public static void main(String[] args) throws InterruptedException {
Thread.sleep(5_000);

for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
BiasedLocks.contend();
}
}).start();
}
}

}

使用下面的启动参数:

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
2
3
4
5
6
7
2017-07-10T14:27:33.089-0800: 4.107: Total time for which application threads were stopped: 0.0012580 seconds
2017-07-10T14:27:34.077-0800: 5.094: Total time for which application threads were stopped: 0.0001490 seconds
2017-07-10T14:27:34.077-0800: 5.094: Total time for which application threads were stopped: 0.0000900 seconds
2017-07-10T14:27:34.078-0800: 5.095: Total time for which application threads were stopped: 0.0001180 seconds
2017-07-10T14:27:34.078-0800: 5.095: Total time for which application threads were stopped: 0.0001190 seconds
2017-07-10T14:27:34.078-0800: 5.095: Total time for which application threads were stopped: 0.0001350 seconds
2017-07-10T14:27:34.078-0800: 5.095: Total time for which application threads were stopped: 0.0000400 seconds

在vm日志里面可以看到暂停的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
4.106: EnableBiasedLocking [ 7 0 0 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.094: RevokeBias [ 13 1 2 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.094: RevokeBias [ 11 1 0 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.095: RevokeBias [ 10 1 1 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.095: RevokeBias [ 9 1 1 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.095: RevokeBias [ 9 1 2 ] [ 0 0 0 0 0 ] 0
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
5.095: RevokeBias [ 8 0 0 ] [ 0 0 0 0 0 ] 0

日志的分析可以参考这里.可以比较直观地看到,线程被迫等待偏向锁被擦除

虽然在我的机器上程序的暂停时间很短,但如果应用复杂,涉及暂停的线程会更多。如果我们应用不是单线程模型,我估计大部分实际的线上业务都不是这种模型,建议在启动的时候关闭偏向锁。

1
-XX:-UseBiasedLocking