Synchronized和lock区别
ReentrantLock可重入锁的使用
一、简述
synchronized 是一把经典的 JVM 级别的锁。在加了它的方法、代码块中,一次只允许一个线程进入特定代码段,从而避免多线程同时修改同一数据。在 JDK6 之前,syncronized 是一把重量级的锁,随着 JDK 的升级,不断的优化,如今它变得不那么重了,甚至在某些场景下,它的性能反而优于轻量级锁。实现原理就是锁升级的过程。
1️⃣synchronized 的作用
- 原子性:保证语句块内操作是原子的。
- 可见性:保证可见性(通过“在执行 unlock 之前,必须先把此变量同步回主内存”实现)。
- 有序性:保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行 lock 操作”)。
2️⃣synchronized 的使用
- 修饰实例方法,对当前实例对象加锁。
- 修饰静态方法,多当前类的 Class 对象加锁。
- 修饰代码块,对 synchronized 括号内的对象加锁。
二、实现原理
JVM 的同步(synchronized)基于进入和退出 Monitor 对象(即对象的监视器,虚拟机规范中用的是管程一词)实现,无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。synchronized 是可重入的,所以不会自己把自己锁死。
1️⃣代码块的同步显示同步
利用 monitorenter 和 monitorexit 这两个字节码指令,配合完成了 synchronized 修饰代码块的互斥访问。
- 被 synchronized 修饰的代码块,编译器编译后:在代码开始加入了 monitorenter,在代码后面加入了 monitorexit。
- 在虚拟机执行到 monitorenter 指令的时候,会请求获取对象的 monitor 锁,基于 monitor 锁又衍生出一个锁计数器的概念。
- 当执行 monitorenter 时,若对象未被锁定时,或者当前线程已经拥有该对象的 monitor 锁,则锁计数器 +1,该线程获取该对象锁。
- 当执行 monitorexit 时,锁计数器 -1。当计数器为 0 时,此对象锁就被释放了。此时,其它阻塞的线程可以请求获取该 monitor 锁。
- 如果获取 monitor 对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
2️⃣方法级的同步隐式同步
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构(method_info Structure)中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志,如果设置了,执行线程将先持有 monitor,然后再执行方法,最后在方法(正常/非正常)完成时释放 monitor。
3️⃣关于 monitorenter/monitorexit、ACC_SYNCHRONIZED 指令,可以看下下面的反编译代码:
public class SynchronizedDemo {
public void explicit() {
synchronized (this) {//这个是同步代码块
System.out.println("this method is explicit");
}
}
public synchronized void implicit() {//这个是同步方法
System.out.println("this method is implicit");
}
public static void main(String[] args) {
}
}
切换到目标类目录执行javac SynchronizedDemo.java
命令生成编译后的 .class 文件。执行javap -c -s -v -l SynchronizedDemo
或javap -verbose SynchronizedDemo
反编译后得到:
tips:通过javap SynchronizedDemo
可以查看其中的内容。
三、JVM 对 synchronized 的锁优化
Java6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁的状态总共有四种,级别从低到高依次是:无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以升级但不能降级。
1️⃣偏向锁
偏向锁是 Java6 之后加入的新锁,它是一种针对加锁操作的优化手段,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。通常,锁不仅不存在多线程竞争,而且还总是由同一线程多次获得,为了减少同一线程获取锁(涉及到 CAS 操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提升程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合每次申请锁的线程极有可能都是不相同的,使用偏向锁得不偿失。注意,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
偏向锁在 JDK8 中,默认是轻量级锁。但如果设定了-XX:BiasedLockingStartupDelay=0
,那在对一个 Object 做 synchronized 的时候,会立即上一把偏向锁。当处于偏向锁状态时,markwork 会记录当前线程 ID。
- 判断是否为可偏向状态。
- 如果为可偏向状态,则判断线程 ID 是否是当前线程,如果是进入同步块。
- 如果线程 ID 并未指向当前线程,利用 CAS 操作竞争锁,如果竞争成功,将 Mark Word 中线程 ID 更新为当前线程 ID,进入同步块。
- 如果竞争失败,等待全局安全点,准备撤销偏向锁,根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。
当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为“01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
偏向锁的释放:
偏向锁使用了遇到竞争才释放锁的机制。偏向锁的撤销需要等待全局安全点,然后它会首先暂停拥有偏向锁的线程,然后判断线程是否还活着,如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。2️⃣轻量级锁
当下一个线程参与到偏向锁竞争时,会先判断 markword 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个 LockRecord(LR),然后每个线程通过 CAS(自旋) 的操作将锁对象头中的 markwork 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着获得锁。关于 synchronized 中此时执行的 CAS 操作是通过 native 的调用 HotSpot 中 bytecodeInterpreter.cpp 文件 C++ 代码实现的。
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6 之后加入的),此时 Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
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 循环体外),使得这一连串操作只需要加一次锁即可。