偏向锁实战
1. 偏向锁的核心原理
-
偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程。
-
在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。
-
偏向锁的核心原理是: 如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为
01
,偏向标志位(biased lock)被改为1
,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。以后该线程获取锁的时候判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。 -
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构。当这个线程再次请求锁时,无需再作任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。经过研究发现,在大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得锁,因此,在大多数情况下偏向锁是能提升性能的。
-
偏向锁的主要作用是消除无竞争情况下的系统底层的同步操作,进一步提升程序性能,所以在没有锁竞争的场合,偏向锁有很好的优化效果。但是,一旦有第二个线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
假如在大部分情况同步块是没有竞争的,那么可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己,如果是,那么该线程将不用再次获得锁,直接就可以进入同步块: 如果未指向当前线程,当前线程会采用CAS操作将Mark Word中线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,去执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。
偏向锁的缺点:如果锁对象时常被多条线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销。
public class ObjectLock {
private Integer amount = 0;
public void increase() {
synchronized (this) {
amount++;
}
}
public void printObjectStruct() {
String printable = ClassLayout.parseInstance(this).toPrintable();
Print.fo("lock = " + printable);
}
}
public void showBiasedLock() throws InterruptedException {
Print.tcfo(VM.current().details());
//JVM延迟偏向锁
sleepMilliSeconds(5000);
ObjectLock lock = new ObjectLock();
Print.tcfo("抢占锁前, lock 的状态: ");
lock.printObjectStruct();
sleepMilliSeconds(5000);
CountDownLatch latch = new CountDownLatch(1);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == MAX_TURN / 2) {
Print.tcfo("占有锁, lock 的状态: ");
lock.printObjectStruct();
//读取字符串型输入,阻塞线程
// Print.consoleInput();
}
}
//每一次循环等待10ms
sleepMilliSeconds(10);
}
latch.countDown();
};
new Thread(runnable, "biased-demo-thread").start();
//等待加锁线程执行完成
latch.await();
Print.tcfo("释放锁后, lock 的状态: ");
lock.printObjectStruct();
}
2. 结果说明
-
运行演示案例之后,在看到第一行输出结果之前,程序要等待5秒,其对应的代码为:
//JVM延迟偏向锁 sleepMilliseconds(5000); ObjectLock lock=newobjectLock(); Print.tcfo("抢占锁前,lock的状态:"); lock.printObjectstruct();
-
为什么要等待5秒呢?因为JVM在启动的时候会延迟启用偏向锁机制。JVM默认就把偏向锁延迟了4000毫秒,这就解释了为什么演示案例要等待5秒才能看到对象锁的偏向状态。
-
为什么偏向锁会延迟?因为JVM启动时会进行一系列的复杂活动,比如装载配置、系统类初始化等。在这个过程中会使用大量synchronized关键字对对象加锁,且大多数锁都存在多线程竞争,并不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
-
可以关闭偏向锁延迟开启,直接通过修改JVM的启动选项来禁止偏向锁延迟,其具体的启动选项如下:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
具体使用的方式为:
java -XX:+UseBiasedlocking -XX:BiasedLockinaStartupDelay=0 mainclass
通过ObjectLock的对象结构可以发现:biased lock(偏向锁)状态已经启用,值为1,lock(锁状态)的值为01。lock和biased lock组合在一起为101,表明当前的ObjectLock实例处于偏向锁状态。
ObjectLock实例的对象头中内容5b 0f 01 f8为其Class Pointer(类对象指针),这里的长度为32位,是由于开启了指针压缩所导致。从输出的结果也能看出,对oop(普通对象)、klass(类对象)指针都进行了压缩.
-
- 可以看到ObjectLock实例的Mark Word中已经记录了其偏向的线程ID,不过由于此线程ID不是Java中的Thread实例的ID,因此没有办法直接在Java程序中比对。偏向锁的线程ID(54位)和时间戳(epoch)合计为56位,其具体内容为
20 d9 25 00 00 00 00
- 偏向锁的加锁过程为: 新线程只需要判断内置锁对象的Mark Word中的线程ID是不是自己的ID,如果是就直接使用这个锁,而不用作CAS交换:如果不是,比如在第一次获得此锁时内置锁的线程ID为空,就使用CAS交换,新线程将自己的线程ID交换到内置锁的Mark Word中,如果交换成功,就加锁成功。
- 在案例的循环抢锁中,每执行一轮抢占,JVM内部都会比较内置锁的偏向线程ID与当前线程ID,如果匹配,就表明当前线程已经获得了偏向锁,当前线程可以快速进入临界区。所以,偏向锁的效率是非常高的。总之,偏向锁是针对一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。
在偏向锁释放之后,虽然抢锁的线程已经结束,但是ObjectLock实例的对象结构仍然记录了其之前的偏向线程ID,其锁状态还是偏向锁状态101。