实战JAVA虚拟机 JVM故障诊断与性能优化(八)

锁与并发

  Java虚拟机对多线程开发有着很好的支持,其中,一个重要的因素就是对“锁”的实现和优化。

  锁时多线程软件开发的必要条件之一,它的基本作用就是保护临界区资源不会被多个线程同时访问而受到破坏,通过锁,可以让多个线程排队,一个一个地进入临界区访问目标对象,使目标对象的状态总是保持一致,这也就是锁存在的价值。

对象头和锁

  在java虚拟机的实现中每个对象都有一个对象头,用于保存对象的系统信息。对象头中有一个称为Mark Word的部分,它是实现锁的关键,32位系统中占32位,64位系统中占64位。它是一个多功能的数据区,可以存放对象的哈希值、对象年龄、锁的指针等信息。一个对象是否占有锁,占有那个锁,就记录在Mark Word中。

  以32位为例:

      hash:25------------------------->| age:4  biased_lock:1  lock:2

  表示Mark Word 中,25位比特表示对象的哈希值,4位比特表示对象的年龄,1位比特表示是否持有偏向锁,2位比特表示锁的信息。

  对于偏向锁的对象,格式如下:

    [JavaThread* | epoch | age | 1 | 01]

    前23位表示持有偏向锁的线程,后续2位表示偏向锁的时间戳(epoch),4位比特表示对象的年龄,年龄后1位比特固定为1,最后2位为01表示可偏向/未锁定。

  当对象处于轻量级锁定时,其Mark Word如下(00 表示最后2位的值):

    [ ptr             | 01]  locked

       此时,它指向存放在获得锁的线程栈中的对象真实对象头。

    当对象处于重量级锁定时,其Mark Word如下:

    [ptr             | 10]  Monitor  

         此时,最后两位为10,整个Mark Word表示指向Monitor的指针。

   当对象处于普通的未锁定状态,其格式如下:

    [ptr           | 0 | 01]  unlocked

         前29位表示对象的哈希值、年龄等信息。倒数第3位为0,最后两位为01,表示未锁定。可以发现,最后两位的值和偏向状态时是一样的,此时,虚拟机正是通过倒数第3位比特来区分是否为偏向锁。

锁在java虚拟机中的实现和优化

  1、偏向锁

    偏向锁是jdk1.6提出的一种锁优化的方式。其核心思想是,若某一锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省时间。如果在此之间进行锁请求,则锁退出偏向模式。

    在jvm中使用-XX:+UseBiaseLocking可以设置启用偏向锁。

    当锁对象处于偏向模式时,对象头会记录获得锁的线程:

      [JavaThread* | epoch | age | 1 |  01]

    这样,当该线程再次尝试获取锁时,通过MarkWord线程信息就可以判断当前线程是否持有偏向锁。

    注意:偏向锁在锁竞争激烈的场合没有太强的优化效果,因为大量的竞争会导致持有锁的线程不停的切换,锁也很难一直保持在偏向模式,此时使用锁偏向不仅得不到性能优化,反而有降低系统性能。因此在激烈竞争的场合,

         可以尝试使用-XX:-UseBiaseLocking 参数禁用偏向锁。

  2、轻量级锁

    如果偏向锁失败,Java虚拟机会让线程重新申请轻量级锁。轻量级锁在虚拟机内部,使用一个称为BasicObjectLock的对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。BasicObjectLock对象

    放置在Java栈的栈帧中。BasicLock对象内部还维护着displaced_header 字段,他用于备份对象头部的Mark Word。

    当一个线程持有一个对象的锁时,对象头部Mark Word如下:

    [ptr    | 00 ] locked

      轻量级锁示意图:

      

     末尾两位比特为00,整个Mark Word为指向Basic Lock对象的指针。由于BisicObjectLock对象在线程栈中,因此该指针指向持有该锁的线程栈空间。但需要判断某一线程是否持有该对象锁时,也只需简单地判断对象头的指针

     是否在当前线程的地址范围内即可。同时,BasicLock对象的displaced_header字段,备份了原对象的Mark Word内容。BasicObjectLock对象的obj字段则指向该对象。

     首先,BasicLock通过set_diplaced_header()方法备份了原对象的Mark Word.接着,使用CAS操作,尝试将BasicLock的地址复制到对象头的Mark Word。如果复制成功,那么加锁成功,否则认为加锁失败。如果加锁失败,那么

     轻量级锁有可能膨胀为重量级锁。

   3.锁膨胀

    当轻量级锁失败,虚拟机就会使用重量级锁。Mark Word如下:

    [ptr    | 10 ]  monitor

    末尾的2比特标记位被置为10,。整个Mork Word表示指向monitor对象的指针。

    在轻量级锁失败后,虚拟机会执行以下操作:

       第一步:废弃前面BasicLock备份的对象头信息。

         第二步:正式启用重量级锁。分为两步:首先通过inflate()方法进行锁膨胀,其目的是获得对象的ObjectMoitor,然后使用enter()方法尝试进入该锁。

       在enter()方法调用中,线程很可能会在操作系统层面被挂起。如果这样,线程间切换和调度的成本就会比较高。

   4.自旋锁

    在锁膨胀后,进入ObjectMoniter的enter(),线程很可能会在操作系统层面被挂起,这样线程上下文切换的性能损失就比较大。因此,在锁膨胀之后,虚拟机会做最后的争取,希望线程可以尽快进入临界区

    而避免被操作系统挂起。自旋锁可以使线程在没有取得锁时,不被挂起,而转而去执行一个空循环(即所谓的自旋),在若干空循环后,线程如果可以获得锁,则继续执行。若线程依然不能获得锁,才会被挂起。

    jdk1.6,-XX:+UseSpinning参数来开启自旋锁,-XX:PreBlockSpin参数来设置自旋锁的等待次数。

    jdk1.7中,自旋锁的参数被取消,虚拟机不再支持由用户配置自旋锁。自旋锁总是执行,自旋次数也是由虚拟机自己设定。

  5、锁清楚

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

    虚拟机可以在运行时,基于逃逸分析技术,捕获到这些不可能存在竞争却有申请锁的代码块,并清除这些不必要的锁,从而提高系统性能。

    逃逸分析和锁清楚分别可以使用参数 -XX:DoEscapeAnalysis 和 -XX:+EliminateLocks 开启(锁清楚必须在-server模式下)

锁在应用层优化思路 

  1.减少锁持有时间

  2.减小锁的粒度

    技术典型的使用场景就是 ConcurrentHashMap 类的实现。对一个普通的集合对象的多线程同步来说,最常使用的方式就是对get()和add()方式进行同步。每当集合进行add()操作或者get()操作时,

    总是获得集合对象的锁,因此,事实上没有两个线程可以做到并发,任何线程在执行这些同步方法时,总要等待前一个线程执行完毕。在高并发时,激烈的锁竞争会影响系统的吞吐量。

    ConcurrenHashMap将整个HashMap分成若干段(Segment),每个段都是一个子HashMap,如果需要在 ConcurrentHashMap 新增一个表项,并不是将整个HashMap加锁,而是首先根据hashcode

    得到该表应该被存放到那个段中,然后对该段加锁,并完成put()操作。在多线程环境下中,如果多个线程同时进行put操作,只要被加入的表项不存在同一个段中,则线程间便可以做到真正的并行。

    默认情况下, ConcurrentHashMap 拥有16和段,因此,如果幸运的话,ConcurrentHashMap可以同时接受16个线程的同时插入。 

      

     减小锁的粒度会引入一个新的问题,当系统取得全局锁时,其消耗的资源会比较多,当试图访问 ConcurrentHashMap 的全局信息时,就会需要同时取得所有段的锁方能顺利实施,比如

    ConcurrentHashMap的size()方法,它返回有效表项的数量。事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方式。但不管怎么说,在高并发场合

    ConcurrentHashMap的size()的性能依然要差于同步的HashMap

  3、锁分离

    锁分离是减少锁粒度的一个特例,程序的特点是,将一个独占锁分成多个锁。一个典型的案例是 java.util.concurrent.LinkedBlockingQueue的实现。

    在实现中,take()函数和put()函数分别实现了从队列中取数据和往队列中添加数据的功能,虽然两个函数都对队列进行了修改操作。但由于 LinkedBlockingQueue 是基于链表的,因此,两个操作

    分别作用于队列的前端和尾端,

      

    如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()和put()操作就不可能真正并发,因此在JDK的实现中,并没有采用这样的方式,它分离了take()和put()操作。

    因此,定义了takeLock和putLock,它们分别在take()操作和put()操作中使用,所以,take()和put()函数就相互独立,不存在竞争关系。

  4、锁粗化

    虚拟机在遇到一连串连续地对同一锁不断进行请求和释放操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少锁的请求同步次数。

    例如:

          ----》    

        ---》  

   注意:性能优化就是运行时真实环境对各个资源点进行权衡这种的过程。锁粗话的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同。

      开发人员需要根据实际情况进行权衡。

  

原文地址:https://www.cnblogs.com/kaishi/p/7545037.html