Java中的锁升级过程 -- 无锁->偏向锁->轻量级锁->重量级锁

Mr.Dabao2022年11月22日大约 19 分钟

Java中的锁升级过程 -- 无锁->偏向锁->轻量级锁->重量级锁

四种锁的Markword

image-20221122213124956 image-20221122213242420

优先程度

  • 偏向锁->轻量级锁-(先自旋不行再膨胀)>重量级锁(不会自旋直接阻塞)

🎯轻量级锁

只是栈中一个锁对象,不是monitor这种重量级

轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的,所以不会出现阻塞的情况),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
  1. 每次指向到synchronized代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word(用来改变对象的lock record编码)和对象引用reference (表示指向哪个对象)

image-20221122213303718

  1. 让锁记录中的Object reference指向对象,并且尝试用CAS(compare and sweep)替换Object对象的Mark Word(表示加锁) , 将对象的Mark Word更新为指向Lock Record的指针,并将Mark Word 的值存入锁记录中 (等同于将Lock Record里的owner指针指向对象的Mark Word。)

image-20221122213314180

  1. 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态01,如下所示

image-20221122213324099

  1. 如果

    cas失败

    ,有两种情况

    1. 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
    2. 如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record 作为重入的计数

且此时新的一条Lock Record中,对象的MarkWord为null(相当于被前一个抢了)

image-20221122213334482

  1. 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

image-20221122213343248

  1. 当线程退出synchronized代码块的时候,如果获取的锁记录取值

    不为 null

    ,那么**使用cas将Mark Word的值恢复给对象 **

    1. 成功则解锁成功
    2. 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

总结

  • 加锁和解锁都是用CAS来交换Lock Record

🎯锁膨胀

如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image-20221122213353519

  1. 这时 Thread-1 加轻量级锁

    失败

    ,进入

    锁膨胀

    流程

    1. 即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor 的EntryList 变成BLOCKED阻塞状态

image-20221122213405767

  1. 当Thread-0 推出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程

总流程

image-20221122213425319

🎯自旋优化

为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

image-20221122213443486

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁

  1. 自旋重试成功的情况

image-20221122213457778

  1. 自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁

image-20221122213512624

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能

自适应自旋锁

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中 另有三种常见的锁形式: TicketLock、CLHlock和MCSlock

🎯偏向锁

在轻量级的锁中,我们可以发现,如果同一个线程对同一2对象进行重入锁时,也需要执行CAS操作,这是有点耗时的,所以java6开始引入了偏向锁,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS来加锁和解锁了

image-20221122213523868

偏向状态

image-20221122213533117

一个对象的创建过程
  1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.
  2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟
  3. 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

加上虚拟机参数-XX:BiasedLockingStartupDelay=0进行测试

public static void main(String[] args) throws InterruptedException {
Test1 t = new Test1();
    //加锁前
test.parseObjectHeader(getObjectHeader(t))//加锁后
synchronized (t){
test.parseObjectHeader(getObjectHeader(t));
}
    //释放锁后
test.parseObjectHeader(getObjectHeader(t));
} 

//输出结果如下,三次输出的状态码都为101
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
    
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
    
biasedLockFlag (1bit): 1
LockFlag (2bit): 01 

禁用偏向锁

image-20221122213550050

测试禁用:如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的。在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized状态变回001

  1. 测试代码:虚拟机参数-XX:-UseBiasedLocking
  2. 输出结果如下,最开始状态为001,然后加轻量级锁变成00,最后恢复成001
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
LockFlag (2bit): 01 

撤销偏向锁-hashcode方法

测试 hashCode:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存**hashcode**的值了 而轻量级锁存在lockRecord,重量级锁存在monitor

  1. 测试代码如下,使用虚拟机参数-XX:BiasedLockingStartupDelay=0 ,确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。
public static void main(String[] args) throws InterruptedException {
Test1 t = new Test1();
    //撤销偏向锁
t.hashCode();
test.parseObjectHeader(getObjectHeader(t));
synchronized (t){
test.parseObjectHeader(getObjectHeader(t));
}
test.parseObjectHeader(getObjectHeader(t));
} 

输出结果
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
    
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
    
LockFlag (2bit): 01 

撤销偏向锁-其它线程使用对象

这里我们演示的是偏向锁撤销变成轻量级锁的过程,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争,我们使用waitnotify 来辅助实现

  1. 代码,虚拟机参数-XX:BiasedLockingStartupDelay=0确保我们的程序最开始使用了偏向锁!
  2. 输出结果,最开始使用的是偏向锁,但是第二个线程尝试获取对象锁时,发现本来对象偏向的是线程一,那么偏向锁就会失效,加的就是轻量级锁
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
LockFlag (2bit): 01 

撤销偏向锁 - 调用 wait/notify

会使对象的锁变成重量级锁,因为wait/notify方法只有重量级锁才支持

批量重偏向

如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,可这和我们之前做的实验矛盾了呀,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。

🎯乐观锁VS悲观锁

乐观锁(无锁)

CAS

优点

  • 不会出现阻塞,所有线程都处于竞争状态,适用于线程较小的情况

缺点

  • 当线程较多的时候,会不断自旋浪费cpu资源

多读用乐观锁(冲突少)

多写用悲观锁(冲突多)

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

🎯公平锁VS非公平锁

公平锁

公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁

非公平锁的优点是可以减少唤起线程的开销(比如新的线程D进来的时候刚好前边的线程A释放了锁,那么D可以直接获取锁,无需进入阻塞队列),整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

实现

ReentrantLock提供了公平和非公平锁的实现。· 公平锁:ReentrantLockpairLock =new ReentrantLock(true)。· 非公平锁:ReentrantLockpairLock =new ReentrantLock(false)。

  • 如果构造函数不传递参数,则默认是非公平锁

源码比较

image-20221122213623286

通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。

image-20221122213637480

再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

🎯可重入锁vs不可重入锁

不可重入锁可能会导致死锁问题

首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁直接去获取并尝试更新当前status的值如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

image-20221122213648504

🎯锁消除和锁粗化

blog.csdn.net/qq_26222859…open in new window

锁消除

锁消除是发生在编译器级别的一种锁优化方式。 有时候我们写的代码完全不需要加锁,却执行了加锁操作。

锁消除是Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

比如,StringBuffer类的append操作:

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

从源码中可以看出,append方法用了synchronized关键词,它是线程安全的。但我们可能仅在线程内部把StringBuffer当作局部变量使用,比如:

    public static String test(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1);
        sb.append(str2);
        return sb.toString();
    }
}

此时不同线程调用该方法,都会创建不同的stringbuffer对象,并不会出现锁竞争等同步问题,所以此时编译器会做优化,去除不可能存在共享资源竞争的锁,这便是锁消除。

锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

锁粗化

public void doSomethingMethod(){
    synchronized(lock){
        //do some thing
    }
	//两个加锁过程中间,还有一些代码,但执行的速度很快
    
    synchronized(lock){
        //do other thing
    }
}

这两块需要同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,合并后的代码如下:

public void doSomethingMethod(){
    //进行锁粗化:整合成一次锁请求、同步、释放
    synchronized(lock){
        //do some thing
        //做其它不需要同步但能很快执行完的工作
        //do other thing
    }
}

🍿🍿🍿手撕面答环节 -- 这是一条分割线

🍔synchronized怎么保证可见性?

  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  • 线程加锁后,其它线程无法获取主内存中的共享变量。
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中

🍔synchronized怎么保证有序性?

synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。

因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。

所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

🍔synchronized怎么实现可重入的呢?

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。

之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

🍔锁升级?synchronized优化了解吗?

Java对象头里,有一块结构,叫Mark Word标记字段,这块结构会随着锁的状态变化而变化。

64 位虚拟机 Mark Word 是 64bit,我们来看看它的状态变化:

image-20221122213710750

Mark Word存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch) 等。

  • 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。
  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
  • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

🍔升级的具体过程

首先是无锁,没有竞争的情况

偏向锁

再是偏向锁,判断是否可以偏向,查看线程ID是否为当前线程,是的话则直接执行,无需CAS

不是则CAS争夺锁,若成功则设置线程ID为自己 失败,则升级为轻量级锁

偏向锁的撤销

  1. 偏向锁不会主动释放(撤销),只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持有该偏向锁的线程栈状态,因此要等到safepoint时执行,此时持有该偏向锁的线程(T)有‘2’,‘3’两种情况;
  2. 撤销----T线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态----该状态达到阈值20则执行批量重偏向
  3. 升级----T线程还在同步代码块中,则将T线程的偏向锁升级为轻量级锁,当前线程执行轻量级锁状态下的锁获取步骤----该状态达到阈值40则执行批量撤销

轻量级锁

  1. 进行加锁操作时,jvm会判断是否已经时重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中
  2. 复制成功之后,jvm使用CAS操作将对象头MarkWord更新为指向锁记录的指针,并将锁记录里的owner指针指向对象头的MarkWord。如果成功,则执行‘3’,否则执行‘4’
  3. 更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态
  4. 更新失败,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行‘5’,否则执行‘6’
  5. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word)为null,并指向Mark Word的锁对象,起到一个重入计数器的作用。
  6. 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁

升级过程:

image-20221122213728492

本篇属于是冷面大翻炒了,如有错误的地方还请指正😪

Loading...