在 jdk1.6 以前咱们会说 synchronized 是个重量级锁,在此以后 JVM 对其作了不少的优化,以后使用 synchronized 线程在获取锁的时候根据竞争的状态能够是偏向锁、轻量级锁和重量级锁。java
而在关于锁的技术中,又出现了一些好比锁粗化、锁消除、自旋锁、自适应自旋锁他们又是什么,本文后续会一一说明。编程
注意的是咱们讨论的都是 synchronized 同步,即隐式加锁。使用 Lock 加锁的话它是另外的实现方式。安全
要想知道 JVM 为何对其进行优化,咱们就要先来了解下重量级锁究竟是什么,为何要对其进行优化,咱们来看一段代码bash
public synchronized void f() {
System.out.println("hello world");
}
复制代码
javap 反编译后多线程
public synchronized void f();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
复制代码
当某个线程访问这个方法的时候,首先会去检查是否有 ACC_SYNCHRONIZED 有的话就须要先得到对应的监视器锁才能执行。并发
当方法结束或者中间抛出未被处理的异常的时候,监视器锁就会被释放。性能
在 Hotspot 中这些操做是经过 ObjectMonitor 来实现的,经过它提供的功能就可能作到获取锁,释放锁,阻塞中等待锁释放再去竞争锁,锁等待被唤醒等功能,咱们来探讨下它是如何作到的。优化
每一个对象都持有一个 Monitor, Monitor 是一种同步机制,经过它咱们就能够实现线程之间的互斥访问,首先来列举下 ObjectMonitor 的几个咱们须要讨论的关键字段ui
从一个线程开始竞争锁到方法结束释放锁后阻塞队列线程竞争锁的执行的流程如上图,而后来分别分析一下,在获取锁和释放锁着两种状况。this
获取锁的时候
释放锁的时候
在 jdk1.6 以前,synchronized 就直接会去调用 ObjectMonitor 的 enter 方法获取锁(第一张图)了,而后释放锁的时候回去调用 ObjectMonitor 的 exit 方法(第二张图)这被称之为重量级锁,能够看出它涉及到的操做复杂性。
那么思考一下
若是说同一时间自己就只有一个线程去访问它,那么就算它存在共享变量,因为不会被多线程同时访问也不存在线程安全问题,这个时候其实就不须要执行重量级加锁的过程。只须要在出现竞争的时候在使用线程安全的操做就好了
从而就引出了偏向锁和轻量级锁
自旋锁自 jdk1.6 开始就默认开启。因为重量级锁的唤醒以及挂起对都须要从用户态转入内核态调用来完成,大量并发的时候会给系统带来比较大的压力,因此就出现了自旋锁,来避免频繁的挂起以及恢复操做。
自旋锁的意思是线程 A 已经得到了锁在执行,那么线程 B 在获取锁的时候,不阻塞,不放弃 CPU 执行时间直接进行死循环(有限定次数)不断的去争抢锁,若是线程 A 执行速度很是快的完成了,那么线程 B 可以较快的就得到锁对象执行,从而避免了挂起和恢复线程的开销,也能进一步的提高响应时间。
自旋锁默认的次数为 10 次能够经过 -XX:PreBlockSpin 来更改
跟自旋锁相似,不一样的是它的自旋时间和次数再也不固定了。好比在同一个锁对象上,上次自旋成功的得到了锁,那么 JVM 就会认为下一次也能成功得到锁,进而容许自旋更长的时间去获取锁。若是在同一个锁对象上,不多有自旋成功得到过锁,那额 JVM 可能就会直接省略掉自旋的过程。
自旋锁和自适应锁相似,虽然自旋等待避免了线程切换的开销,可是他们都不放弃 CPU 的执行时间,若是锁被占用的时间很长,那么可能就会存在大量的自旋从而浪费 CPU 的资源,因此自旋锁是不能用来替代阻塞的,它有它适用的场景
锁会偏向于第一个执行它的线程,若是该锁后续没有其余线程访问过,那咱们就不须要加锁直接执行便可。
若是后续发现了有其它线程正在获取该锁,那么会根据以前得到锁的线程的状态来决定要么将锁从新偏向新的线程,要么撤销偏向锁升级为轻量级锁。
Mark Word 锁标识以下
thread ID | - | 是不是偏向锁 | 锁标志位 |
---|---|---|---|
thread ID | epoch | 1 | 01(未被锁定) |
线程 A - thread ID 为 100,去获取锁的时候,发现锁标志位为 01 ,偏向锁标志位为 1 (能够偏向),而后 CAS 将线程 ID 记录在对象头的 Mark Word,成功后
thread ID | - | 是不是偏向锁 | 锁标志位 |
---|---|---|---|
100 | epoch | 1 | 01(未被锁定) |
之后先 A 再次执行该方法的时候,只须要简单的判断一下对象头的 Mark Word 中 thread ID 是不是当前线程便可,若是是的话就直接运行
假如此时有另一个线程线程 B 尝试获取该锁,线程 B - thread ID 为 101,一样的去检查锁标志位和是否能够偏向的状态发现能够后,而后 CAS 将 Mark Word 的 thread ID 指向本身,发现失败了,由于 thread ID 已经指向了线程 A ,那么此时就会去执行撤销偏向锁的操做了,会在一个全局安全点(没有字节码在执行)去暂停拥有偏向锁的线程(线程 A),而后检查线程 A 的状态,那么此时线程 A 就有 2 种状况了。
第一种状况,线程 A 已经已经终止,那么将 Mark Word 的线程 ID 置位空后,CAS 将线程 ID 偏向线程 B 而后就又回到上述又是偏向锁线程的运行状态了
thread ID | - | 是不是偏向锁 | 锁标志位 |
---|---|---|---|
101 | epoch | 1 | 01(未被锁定) |
第二种状况,线程 A 处于活动状态,那么就会将偏向锁升级为轻量级锁,而后唤醒线程 A 执行完后续操做,线程 B 自旋获取轻量级锁。
thread ID | 是不是偏向锁 | 锁标志位 |
---|---|---|
空 | 0 | 00(轻量级锁定) |
能够发现偏向锁适用于从始至终都只有一个线程在运行的状况,省略掉了自旋获取锁,以及重量级锁互斥的开销,这种锁的开销最低,性能最好接近于无锁状态,可是若是线程之间存在竞争的话,就须要频繁的去暂停拥有偏向锁的线程而后检查状态,决定是否从新偏向仍是升级为轻量级别锁,性能就会大打折扣了,若是事先可以知道可能会存在竞争那么能够选择关闭掉偏向锁
有的小伙伴会说存在竞争不就应该立马升级为重量级别锁了吗,不必定,下面讲了轻量级锁就会明白了。
若是说线程之间不存在竞争或者偶尔出现竞争的状况而且执行锁里面的代码的速度很是快那么就很适合轻量级锁的场景了,若是说偏向锁是彻底取消了同步而且也取消了 CAS 和自旋获取锁的流程,它是只须要判断 Mark Word 里面的 thread ID 是否指向本身便可(其它时间点有少量的判断能够忽略),那么轻量级锁就是使用 CAS 和自旋锁来获取锁从而下降使用操做系统互斥量来完成重量级锁的性能消耗
轻量级锁的实现以下
JVM 会在当前线程的栈帧中建立用于存储锁记录的空间,而后将对象头的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word 而后线程尝试使用 CAS 将对象头的 Mark Word 替换为指向锁记录的指针
假设线程 B 替换成功,代表成功得到该锁,而后继续执行代码,此时 Mark Word 以下
线程栈的指针 | 锁状态 |
---|---|
stack pointer 1 -> 执行线程 B | 00(轻量级锁) |
此时线程 C 来获取该锁,CAS 修改对象头的时候失败发现已经被线程 B 占用,而后它就自旋获取锁,结果线程 B 这时正好执行完成,线程 C 自旋获取成功
线程栈的指针 | 锁状态 |
---|---|
stack pointer 2 -> 线程 C | 00(轻量级锁) |
此时线程 D 又获取该锁,发现被线程 C 占用,而后它自旋获取锁,自旋默认 10 次后发现仍是没法得到对应的锁(线程 C 尚未释放),那么线程 D 就将 Mark Word 修改成重量级锁
线程栈的指针 | 锁状态 |
---|---|
stack pointer 2 -> 线程 C | 10(重量级锁) |
而后这时线程 C 执行完成了,将栈帧中的 Mark Word 替换回对象头的 Mark Word 的时候,发现有其它线程竞争该锁(被线程 D 修改了锁状态)而后它释放锁而且唤醒在等待的线程,后续的线程操做就所有都是重量级锁了
线程栈的指针 | 锁状态 |
---|---|
空 | 10(重量量级锁) |
须要注意的是锁一旦升级就不会降级了
锁消除主要是 JIT 编译器的优化操做,首先对于热点代码 JIT 编译器会将其编译为机器码,后续执行的时候就不须要在对每一条 class 字节码解释为机器码而后再执行了从而提高效率,它会根据逃逸分析来对代码作必定程度的优化好比锁消除,栈上分配等等
public void f() {
Object obj = new Object();
synchronized(obj) {
System.out.println(obj);
}
}
复制代码
JIT 编译器发现 f() 中的对象只会被一个线程访问,那么就会取消同步
public void f() {
Object obj = new Object();
System.out.println(obj);
}
复制代码
若是在一段代码中连续的对同一个对象反复加锁解锁,实际上是相对耗费资源的,这种状况下能够适当放宽加锁的范围,减小性能消耗。
当 JIT 发现一系列连续的操做都对同一个对象反复加锁和解锁,甚至加锁操做出如今循环体中的时候,会将加锁同步的范围扩散到整个操做序列的外部。
for (int i = 0; i < 10000; i++) {
synchronized(this) {
do();
}
}
复制代码
粗化后的代码
synchronized(this) {
for (int i = 0; i < 10000; i++) {
do();
}
}
复制代码
参考: