有人说 synchronized 最慢,非常耗性能,这话靠谱吗?你知道偏斜锁、自旋锁、轻量锁、重级锁是什么吗?你知道锁升级的过程吗?
回答这些问题前,先看一下 synchronized 的实现原理;
synchronized 的原理是什么?
synchronized 代码块是由一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。它提供了互斥的语义和可见性, Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个重量级操作。
在 Java 5 以前,synchronized 是仅有的同步手段,在代码中, synchronized 可以用来修饰方法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。
对象锁(monitor)机制
用一个简单的demo说明一下
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}
private static void method() {
}
}
上述的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。
使用javap -v SynchronizedDemo.class查看字节码文件
反编译后的字节码
如图所示,黄色高亮的部分就是需要注意的部分,这也是添 synchronized
关键字之后独有的。执行同步代码块后首先要先执行monitorenter
指令,退出的时候monitorexit
指令。
通过分析之后可以看出,使用synchronized
进行同步,其关键就是必须要获取对象的监视器monitor
,当线程获取monitor
后才能继续往下执行,否则就只能等待。而且这个获取的过程是互斥的,同一时刻只有一个线程能够获取到monitor
。
上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?
答案是不必的,从上图中也可以看出来,执行静态同步方法的时候就只有一条monitorexit
指令,并没有monitorenter
获取锁的指令。
这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。synchronized
先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会 +1,释放锁后就会将计数器 -1。
需要注意的是:synchronized 是非公平锁。
Java 6 之后的 synchronized
Java 6 之后,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
偏斜锁
偏斜锁(Biased Locking)是Java6引入的一项多线程优化。它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程竞争的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
偏斜锁的实现
- 偏向锁获取过程:
1 访问markword中代表偏向锁的标识的标志位是否为"01",确认为可偏向状态。
2 如果为可偏向状态,则判断线程ID是否指向当前线程,如果是当前线程,进入步骤5,否则进入步骤3。
3 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将markword中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
4 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
5 执行同步代码。
- 偏向锁的释放:
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量级锁
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
- 轻量级锁的加锁过程:
1 进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“00”),虚拟机首先将在当前线程的栈帧中建立一个称为锁记录(Lock Record)的空间,用于存储锁对象当前的markword的拷贝。这时候线程堆栈与对象头的状态如图:
2 拷贝对象头中的markword复制到锁记录中;
3 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的markword更新为指向 Lock record的指针,并将 Lock record 里的 owner 指针指向对象的markword。这个操作会有两个结果。
如果更新成功,那么这个线程就拥有了该对象的锁,并且对象markword的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
更新失败,虚拟机先会检查对象的 markword 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为"10", markword中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的释放
- 释放锁线程视角
由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,线程在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。
此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。
- 尝试获取锁线程视角:
如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。需要注意的地方是:等待轻量锁的线程不会阻塞,它会一直自旋获取锁,并且修改修改锁对象的markword。
这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。
小结
在网上找了一张图,对象锁升级的过程
synchronized的执行过程:
检测 markword 里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁;
如果不是,则使用CAS将当前线程的ID替换markword,如果成功则表示当前线程获得偏向锁,置偏向标志位1;
如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁;
当前线程使用CAS将对象头的markword替换为锁记录指针,如果成功,当前线程获得锁;
如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁;
如果自旋成功则依然处于轻量级状态;
如果自旋失败,则升级为重量级锁。
上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
在 JVM 默认所有的锁都启用的情况下,线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁。
启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁;
如果开发已经明确了业务中线程竞争激烈,那么应该禁用偏向锁,降低锁升级带来的性能消耗。
锁优化
以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以在代码中优化我们自己线程的加锁操作;
减少锁的时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;
减少锁的粒度
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间
锁获取的过程
这是我借鉴过来的,获取过程描述的很详细