Java 对象头
面试一:为什么有 Synchronized 升级过程。
简言之,最初无锁直接到重量级锁。由于重量级锁需要向内核申请额外的锁资源,涉及到用户态和内核态的转换,比较浪费资源。况且大多数情况下,都是同一个线程去争抢锁,完全不需要重量级锁。
Jdk1.6之前,被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本的看,持有锁是一个重量级(Heavy-Weight)的操作。在主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成
,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此 synchronized 是 Java 中一个重量级的操作。也因此虚拟机本身进行优化,有了 Synchronized 升级过程。
面试二:什么情况下 Synchronized 升级。
- 当只有一个线程a去争抢锁的时候,会先使用偏向锁。就是给一个标识,说明现在这个锁被线程a占有。
只有一个线程的时候就是偏向锁(当偏向锁开启的时候,偏向锁默认开启),当争抢的线程超过一个,升级为轻量级锁。
- 后来又来了线程b、线程c竞争,于是将标识去掉。也就是撤销偏向锁,升级为轻量级锁。三个线程通过CAS进行锁的争抢(其实还是偏向于原来持有偏向锁的线程)。
轻量级锁适用于任务执行时间很短的线程,可能通过一两次自旋,就能够获取到锁。
- 线程a占有锁,随后陆陆续续多个线程,一直在自旋抢锁,如此也耗费CPU资源,所以就将锁升级为重量级锁,向内核申请资源,直接将等待的线程进行阻塞。
当自旋的线程循环超过10次,或者线程等待的数量超过cpu的1/2,升级为重量级锁。
一、简述
synchronized 关键字是一把经典的JVM级别的锁。在加了它的方法、代码块中,一次只允许一个线程进入特定代码段,从而避免多线程同时修改同一数据。在 jdk1.6 之前,syncronized 是一把重量级的锁,不过随着 jdk 的升级,也在对它进行不断的优化,如今它变得不那么重了,甚至在某些场景下,它的性能反而优于轻量级锁。实现原理就是锁升级的过程。
1️⃣synchronized 的作用
- 原子性:保证语句块内操作是原子的。
- 可见性:保证可见性(通过“在执行 unlock 之前,必须先把此变量同步回主内存”实现)。
- 有序性:保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行 lock 操作”)。
2️⃣synchronized 的使用
- 修饰实例方法,对当前实例对象加锁。
- 修饰静态方法,对当前类的 Class 对象加锁。
- 修饰代码块,对 synchronized 括号内的对象加锁。
二、实现原理
JVM 的互斥同步(synchronized)基于进入和退出管程monitor,虚拟机规范中用的是管程一词
对象实现,无论是隐式同步还是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)都是如此。
1️⃣【隐式同步】方法级的同步
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法
。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置。如果设置了,执行线程将先持有 monitor,再执行方法,最后在方法(无论正常还是非正常)完成时释放 monitor。
2️⃣【显式同步】代码块的同步
代码块的同步是利用 monitorenter 和 monitorexit 这两个字节码指令实现的。被 synchronized 修饰的代码,编译器编译后在前后加上了一组字节指令。
- 在代码开始加了 monitorenter,在代码后面加了 monitorexit,这两个字节码指令配合完成了 synchronized 关键字修饰代码的互斥访问。
- 在虚拟机执行到 monitorenter 指令的时候,会请求获取对象的 monitor 锁,基于 monitor 锁又衍生出一个锁计数器的概念。
- 当执行 monitorenter 时,若对象未被锁定时,或者当前线程已经拥有了此对象的 monitor 锁,则锁计数器+1,该线程获取该对象锁。
- 当执行 monitorexit 时,锁计数器 -1,当计数器为 0 时,此对象锁就被释放了。那么其它阻塞的线程就可以请求获取该 monitor 锁。
- 如果获取 monitor 对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
注意:
- synchronized 是可重入的,不会自己把自己锁死。
- synchronized 锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。
关于 ACC_SYNCHRONIZED 、monitorenter、monitorexit 指令,可以看如下的反编译代码:
public class SynchronizedDemo {
public synchronized void f(){//这个是同步方法
System.out.println("Hello world");
}
public void g(){
synchronized (this){//这个是同步代码块
System.out.println("Hello world");
}
}
public static void main(String[] args) {
}
}
使用javap -verbose SynchronizedDemo
反编译后得到:
三、JVM 对 synchronized 的锁优化
Jdk1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁的状态总共有四种,级别从低到高依次是:无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以升级但不能降级。
1️⃣偏向锁
偏向锁是 JDK6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
假设虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK6起HotSpot虚拟机的默认值),当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的锁标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到该锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入该锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。如此就省去了大量有关锁申请的操作,从而也就提升了程序的性能。
所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为极有可能每次申请锁的线程都是不同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
偏向锁在 JDK1.8 中,其实默认是轻量级锁,但如果设定了-XX:BiasedLockingStartupDelay=0
,那在对一个 Object 做 synchronized 的时候,会立即上一把偏向锁。当处于偏向锁状态时,Mark Word 会记录当前线程 ID。
- 判断是否为可偏向状态。
- 如果为可偏向状态,则判断线程 ID 是否为当前线程,如果是进入同步块。
- 如果线程 ID 不是当前线程,利用 CAS 操作竞争锁。如果竞争成功,将 Mark Word 中线程 ID 更新为当前线程 ID,进入同步块。
- 如果竞争失败,等待全局安全点,准备撤销偏向锁。根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。
偏向锁的释放:偏向锁使用了遇到竞争才释放锁的机制
。
偏向锁的撤销需要等待全局安全点,首先暂停拥有偏向锁的线程,然后判断线程是否还活着。如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。
2️⃣轻量级锁
当又一个线程参与到偏向锁竞争时,会先判断 mark word 中保存的线程 ID 是否与竞争线程 ID 相等。如果不相等,会立即撤销偏向锁,升级为轻量级锁。
每个线程在自己的线程栈中生成一个 LockRecord(LR),然后各个线程通过 CAS 操作将锁对象头中的 mark word 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着获得锁。关于 synchronized 中此时执行的 CAS 操作是通过 native 的调用 HotSpot 中 bytecodeInterpreter.cpp 文件 C++ 代码实现的。
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
3️⃣自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换,时间成本相对较高。
自旋锁假设很快当前的线程就可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是 50 个循环或 100 循环。在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
4️⃣重量级锁
如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值,JDK1.6 之后,由 JVM 自己控制改动规则),就会升级为重量级锁。
此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间,也就是“重”的原因之一。
synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁) 来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁称之为“重量级锁”。
5️⃣锁消除
锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。如:
public void add() {
StringBuffer sb = new StringBuffer();
sb.append("a").append("b");
}
StringBuffer 的 append() 是一个同步方法。add() 中的局部对象 sb,只在该方法内的作用域有效,不可能被其他线程引用(因为是局部变量,栈私有)。不同线程同时调用 add() 时,都会创建不同的 sb 对象,sb 不可能存在共享资源竞争的情景。因此此时的 append() 若是同步,就是白白浪费的系统资源。JVM 会自动消除 StringBuffer 对象内部的锁。
6️⃣锁粗化
如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。如:
public String test() {
int i = 0;
StringBuffer sb = new StringBuffer();
while (i < 100) {
sb.append("a");
i++;
}
return sb.toString();
}
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append(),没有锁粗化就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 循环体外),使得这一连串操作只需要加一次锁即可。