偏向锁、轻量级锁、重量级锁区别与联系

今天总结了锁升级(偏向锁、轻量级锁、重量级锁)和锁优化下面开始总结。

其实这些内容都是JVM对锁进行的一些优化,为什么分开讲,原因是锁升级比较重要,也比较难。

一、锁升级

    在1.6之前java中不存在只存在重量级锁,这种锁直接对接底层操作系统中的互斥量(mutex),这种同步成本非常高,包括操作系统调用引起的内核态与用户态之间的切换。线程阻塞造成的线程切换等。因此在jdk 1.6中将锁分为四种状态:由低到高分别为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。

    1. 偏向锁。什么是偏向锁呢?为什么要引入偏向锁呢?

            偏向锁是如果一个线程获取到了偏向锁,在没有其他线程竞争的情况下,如果下次再执行该同步块时则只需要简单判断当前偏向锁所偏向的对象是否是当前线程,如果是则不需要再进行任何获取锁与释放锁的过程,直接执行同步块。至于为什么引入偏向锁,是因为经过JVM的开发人员大量的研究发现大多数时候都是不存在锁竞争的,通常都是一个线程在使用锁的时候没有其他线程来竞争,然而每次都要进行加锁和解锁就会额外增加一些没有必要的资源浪费。为了降低这些浪费,JVM引入了偏向锁。

        a) 偏向锁的获取以及升级过程如下:

            当一个线程在执行同步块时,它会先获取该对象头的MarkWord,通过MarkWord来判断当前虚拟机是否支持偏向锁(因为偏向锁是可以手动关闭的),如果不支持则直接进入轻量级锁获取过程。如果支持,则判断当前MarkWord中存储的ThreadID是否指向当前线程,如果指向当前线程,则直接开始执行同步块。如果没有指向当前线程,则通过CAS将对象头的MarkWord中相应位置替换为当前线程ID表示当前线程获取到了偏向锁,如果CAS成功,同时将偏向锁置为1,执行同步块;若CAS失败,则表示存在多个线程竞争,当达到全局安全点(safepoint)的时候,暂停获得偏向锁的线程,撤销偏向锁(将偏向锁置为0,并且将ThreadID置为空),然后将锁升级为轻量级锁,之后恢复刚暂停的线程,则刚刚CAS失败的线程通过自旋的方式等待轻量级锁被释放。

              偏向锁适用于没有线程竞争的同步场所。

               但它并不一定总是对程序有利,如果程序中大多数锁都存在竞争,那么偏向锁模式就显得赘余。因此偏向锁可以通过一些虚拟机参数进行手动关闭的。

        2. 轻量级锁。什么是轻量级锁?为什么引入轻量级锁?

            轻量级锁是当一个线程获取到该锁后,另一个线程也来获取该锁,这个线程并不会被直接阻塞,而是通过自旋来等待该锁被释放,所谓的自旋就是让线程执行一段无意义的循环。当然如果该循环长时间执行也会带来非常大的资源浪费。因此这段自旋通常都是规定次数的,比如自旋100次啊等等,但是如果在第101次锁释放了呢,岂不是很可惜,因此在JDK1.6中JVM加入了自适应自旋,通过之前获取锁所等待的时间来增加或者减少循环次数。那么如果直到自旋结束该锁还未被释放,那么此时轻量级锁膨胀为重量级锁,将后面的线程全部阻塞,还有一种情况,如果线程2正在自旋等待线程1释放锁,此时线程3也来竞争锁,那么这时该轻量级锁膨胀为重量级锁将等待线程全部阻塞。为什么会引入轻量级锁呢?原因是轻量级锁主要考虑到竞争线程并不多,并且持有对象锁的线程执行的时间也不长的这种情况,在未引入轻量级锁之前,如果一个线程刚刚被阻塞,这个锁就被其他线程释放,如果这种情况频繁发生,那么会因为频繁的阻塞以及唤醒线程给带来不必要的资源浪费。而在引入轻量级锁之后,在线程获取锁失败的情况下,线程并不会立即被阻塞,而是通过一段自旋的过程,来等待获取锁,因此就避免了频繁的阻塞与唤醒操作带来的资源浪费。

            a) 轻量级锁的加锁、释放、以及膨胀过程?

                现在线程1要访问同步块,在线程1访问同步块之前,JVM会在当前线程的栈帧中创建一个用于存储锁记录的空间(官方称为 Displaced Mark Word),并且将对象头中的MarkWord复制到该锁记录中,并且将该对象的地址存储在锁记录中的owner字段中。然后线程1尝试通过CAS将对象头中的MarkWord对应位置替换为当前栈帧中锁记录的地址,如果CAS成功,则当前线程获取锁成功,开始执行同步代码。如果CAS失败,则进入自旋状态尝试获取该锁,如果直到自旋结束都没有获取成功,则该轻量级锁膨胀为重量级锁,并且阻塞后面的其他竞争该锁的线程。当获取锁的线程执行完毕,此时释放锁通过CAS将对象头中的信息重新替换回去,如果CAS成功则线程成功释放锁,如果CAS失败则说明存在其他线程竞争此时锁已经膨胀为重量级锁,此时释放锁并且唤醒被阻塞的线程。

            轻量级锁适用的场景为:少量线程竞争锁对象,且线程持有锁的时间不长,追求相应速度的场景。

           但是如果存在大量的锁竞争,轻量级锁的效率会比传统重量级锁会更慢,因为最终都是进入阻塞状态,但轻量级锁还额外进行了CAS自旋操作。

        3. 重量级锁。

            重量级锁是如果多个线程同时竞争锁,只会有一个线程得到这把锁,其他线程获取锁失败不会和轻量级锁进行自旋等待锁被释放,而是直接阻塞没有获取成功的线程。重量级锁的实现与对象内部的monitor监视器息息相关。monitor在虚拟机中实际实现是ObjectMonitor。通过JVM顶级基类oopDesc类中的一个成员oopMark类型的子对象去调monitor()方法来获取到ObjectMonitor对象,而重量级锁就是通过获取ObjectMonitor这把监视器锁来实现的具体实现细节请参考我上一篇博客:synchronized的实现原理

二、锁优化

   1. 自旋锁。

     下面介绍为什么引入自旋锁,原因是这样的,Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,这样是很浪费效率的,因此在JDK1.6中对锁进行了一系列优化其中就包括自旋锁,比如存在这样一个情况当一个线程刚刚进入阻塞状态,这个锁就被其他线程释放了,对于这个线程来说得被唤醒,又从内核态转换到用户态,为了解决这种情况,于是就引入了自旋锁,自旋锁是这样的,在一个线程获取锁失败,它并不会立即阻塞线程,而是通过一段无意义的循环,进行尝试过去锁状态。当然如果长时间进行这样无意义的循环对于CPU的浪费也是非常巨大的,因此JVM对于自旋是有次数规定的。比如循环100次啊等等。可是有存在这样一种情况,如果100次还是没有获取到锁,当前线程被阻塞,可是就在101次的时候这把锁被释放了,此时是不是很可惜呀!

      但是没关系,为了解决这种问题JVM团队又引入了自适应自旋,自适应自旋是这样的,此时获取这把锁的自旋此时就不是固定的被写死的,而是一种动态的,它可以通过之前这把锁的获得情况来自动的选择增加自旋此处或者减少自旋次数,如果之前有成功获取这把锁的线程,那么JVM会认为这把锁是能够被获取的,此时会自适应的增加一些自旋次数,当然如果之前没有一个线程成功获取这把锁,JVM为了避免无意义的循环带来的资源浪费,会选择减少自旋次数,或者说不去自旋,而直接阻塞。

    2. 锁粗化

        在java中编写代码时总会认为锁粒度小会在效率上有所提升,是不是和我们现在说的锁粗化相违背呢?其实不然,任何事情都不能太过于绝对,就比如如果锁太过于细化(也就是说没有必要的细化)也会使程序效率大大折扣,比如如果一系列的连续操作都对一个对象进行加锁或者解锁操作,甚至加锁操作是出现在循环体中,那么即使没有线程竞争这些频繁的加锁和解锁操作也会导致不必要的性能损失。比如下面代码:

public String getString(String s1,String s2){
 StringBuffer sb=new StringBuffer();
  sb.append(s1);
  sb.append(s2);
 return sb.toString();
}
比如上面这段代码,都知道StringBuffer类的append方法是synchronized关键字修饰的,那么每次循环体执行都要进行加锁与解锁操作,这样无疑会带来很大的性能损失,因此JVM会将当前加在append方法上的锁的范围进行粗化,粗化到第一个append方法之前到第二个append方法之后。这样就像两次加锁,减少为一次加锁,无疑是提高了效率。

    3. 锁消除

       什么是锁消除,是指在虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检查到不存在数据共享的锁进行消除的操作,锁消除的主要判定依据来源于逃逸分析。什么是逃逸分析呢?逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用者参数传递到其他方法中,称为方法逃逸,其中甚至还有可能被其他线程访问到。譬如赋值给类变量或可以在其他线程中访问到的变量,称为线程逃逸。例如我们在判断一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据来对待,认为它们是线程私有的,同步加锁就无需进行,从而达到锁消除。比如下面代码:

public String getString(){
    StringBuffer sb=new StringBuffer();
    for(int i=0;i<10;i++){
        sb.append(i);
    }
    return sb.toString();
}
比如上面这段代码虽然表面看起来没有加锁,但是StringBuffer的append方法是一个synchronized方法也就是说每个append都是要进行加锁和释放锁的。但通过观察上面代码,方法中所有用到的变量都是方法中的局部变量,这个方法中的所有对象都无法逃逸出这个方法之外。因此其他线程无法访问到它,也就不存在数据争用问题,因此此时JVM就会将这个方法中的锁进行消除。

发布了22 篇原创文章 · 获赞 25 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/chmodzora/article/details/104577873