偏向锁实战

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();
}

image

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(类对象)指针都进行了压缩.

image

  • 可以看到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。

posted on 2024-02-23 16:57  ccblblog  阅读(5)  评论(0编辑  收藏  举报

导航