其他分享
首页 > 其他分享> > JVM之锁的理解

JVM之锁的理解

作者:互联网

1. 初识锁

1.1 锁的认知

       说起锁给人的第一反应就是各种门上的锁、车锁等等物理存在的可见的实物锁,功能就是为了保护人身财产乃至生命的安全的。今天所说的锁也是类似功能,但是是我们不可见的是java虚拟机内部的锁,后端开发都知道锁是多线程开发过程中必不可少的工具之一,它的基本作用是保护临界区资源不会被多线程同时访问而造成破坏,如果多线程访问临界区资源造成资源结果不一致从而导致系统运行最终结果出现错误。如果使用锁进行对该资源进行锁定,让多线程排队去访问使用资源,这样就保证了多线程访问资源对象的数据一致性,从而也就是达到了资源被多线程操作的线程安全目的。

1.2 线程安全简述

       通过锁可以实现线程安全,简单来说线程安全就是在多线程环境下,无论多少个线程如何访问目标对象(临界资源),目标对象的数值或者状态始终是保持一致的,线程的行为结果也总是预期正确的。日常开发过程像我们常用的 ArrayListHashMap等在多线程操作过程中会出现线程不安全的问题,简单方案就是使用VectorHashTable进行替换,因为这些对象的一些方法是由synchronized关键字修饰的,synchronized关键字保证了每次只能有一个线程可以访问对象实例,确保了多线程环境中对象内部数据的一致性。

    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

那么synchronized关键字是如何保证每次只能有一个线程可以访问对象实例呢,那就需要理解认识下对象头概念。

1.3 对象头和锁

在java虚拟机的实现中每个对象都有一个对象头,主要用来保存对象的系统信息。对象头中有一个被称为Mark Word的部分,它是实现锁的关键。在64位操作系统中Mark Word是一个占位64位的数据,这个数据区里面可以存放对象的哈希值、对象的年龄、锁的指针等信息。一个对象是否占用锁,占用哪个锁,就记录在Mark Word 中。对象头中信息概念如下:

// 32位系统为例 25位比特表示对象哈希值 4位比特表示对象年龄 1位比特表示是否位偏向锁 2位比特表示锁的信息
hash : 25 -------> | age : 4  biased_lock:1  lock:2

// 对于偏向锁的对象 年龄后的1位比特固定为1表示偏向锁 最后01表示可偏向未锁定
[JavaThread*  | epoch  |  age |  1  |  01 ]


// 对象处于轻量级锁定时 00表示轻量级锁
[ptr    |  00 ]  locked

// 对象处于重量级锁定时
[ptr    |  10 ]  monitor

// 对象处于普通的未锁定时
[header |  0  |  01] unlocked

2. 锁在JAVA 虚拟机的实现和优化

       了解完对象头基本概念之后,就可以再深入一点到虚拟机内部,探索虚拟机对锁的实现方式。多线程中的资源竞争是不可避免的,也是一种常态,如何使用更高效的处理多线程的竞争是Java虚拟机的一项重要使命。如果将所有的线程竞争都交由操作系统处理,那么并发性能将变的非常低下,为此虚拟机在操作系统层面挂起线程之前,会尽一切可能在虚拟机层面上解决竞争关系,尽可能避免真实的竞争发生,同时也会试图消除不必要的竞争。下面将介绍实现手段的方案,包括偏向锁、轻量级锁、自旋锁、锁消除、锁膨胀等。

2.1 偏向锁

       偏向锁是JDK1.6提出的一种锁的优化方式。其核心思想是:如果程序没有竞争,则取消之前已经取得锁的线程同步操作。也就是说若锁被线程获取后,便进入了偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作(因为当对象处于偏向模式时对象头会记录获得锁的线程),从而节省了操作时间。如果在此之前有其他线程进行了锁的请求,则锁退出偏向模式。在JVM中使用 -XX:+UseBiasedLocking 可以设置启用偏向锁。

      但是偏向锁在少竞争的情况下,对系统性能有一定帮助。在锁竞争激烈的场合没有太强的优化效果。因为大量的竞争会导致持有锁的线程不停的切换,锁也很难一直保持在偏向模式,这时使用偏向锁不仅提升不了性能,大概率会降低系统性能。因此在竞争激烈的场景中可以尝试使用-XX:-UseBiasedLocking 参数禁用偏向锁。

2.2 轻量级锁

       如果偏向锁失败,Java 虚拟机会让线程申请轻量级锁。轻量级锁在虚拟机内部使用一个称为BasicObjectLock的对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的对象指针组成,BasicObjectLock对象放置在Java栈的栈帧中。在BasicLock对象内部还维护着displaced_header字段,它用于备份对象头的Mark Word

       首先BasicLock通过set_displaced_header()方法备份了原对象的Mark Word。然后使用CAS操作尝试将BasicLock的地址复制到对象头的Mark Word。如果复制成功那么枷锁成功,否则加锁失败。如果加锁失败,那么轻量级锁就有可能被膨胀成重量级锁。

2.3 锁膨胀

       当轻量级锁失败,虚拟机就会使用重量级锁。具体体现在上方代码块[ptr  | 10] monitor, 末尾2比特标记位为10,整个Mark Word表示指向monitor对象的指针,在轻量级锁处理失败后,虚拟机会执行以下操作:

lock -> set_displaced_header(markOopDesc::unused_mark());
ObjectSynchronizer::inflate(THREAD, obj()) -> enter(THREAD);

       第一步时废弃前面的BasicLock备份的对象头信息,第二步则正式启用重量级锁。启用过程分两步:首先通过inflate() 方法进行锁膨胀,其目的时获取对象的ObjectMonitor;然后使用enter() 方法尝试进入该锁。在enter() 方法调用中,线程很可能会在操作系统层面被挂起。如果这样线程间切换频繁度的成本就会比较高。

2.4 自旋锁

       上面提到锁膨胀以后进入enter()方法,线程很可能会在操作系统层面被挂起。这样线程上下文切换的性能损失就比较大。因此锁膨胀之后,虚拟机会做最后的争取,希望线程可以尽快进入临界区而避免被操作系统挂起,一种较为有效的手段就是使用自旋锁。

       自旋锁可以使线程在没有获取到锁时,不被挂起,而是去执行一个空循环(所谓的自旋),在若干个空循环后,线程如果可以获取到锁,则继续执行,若线程依然不能获得锁才会被挂起。使用自旋锁后,线程挂起的几率相对减少,线程执行的连贯性相对加强。因此对于一些锁竞争不是很激烈的锁占用时间很短的并发线程,具有一定积极意义,但是对于锁竞争激烈的、单线程锁占用时间较长的并发程序,自旋锁在自旋等待以后往往依然无法获取对应的锁,不仅仅浪费了CPU时间,最终还是避免不了被挂起的操作反而浪费了系统资源。

       JDK1.6的时候虚拟机提供了-XX:+UseSpining 参数来开启自旋锁,使用-XX:PreBlockSpin参数来设置自旋锁的等待次数,而在1.7之后自旋锁的参数被取消,全部有虚拟机自行调整。

2.5 锁消除

       锁消除时Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,这样就节省了毫无意义的请求锁时间。是不是在想不可能存在的竞争为什么还要加锁呢?因为在Java开发过程中,开发人员使用一些JDK内置的API,比如StringBuffer、Vector等。这些常用的工具类很可能在完全没有多线程竞争的场合被使用,这种情况下这些工具类的内部同步方法是不必要的。虚拟机可以在运行时,基于逃逸分析技术,捕捉到这些不可能存在竞争却有申请锁的代码段,消除这些不必要的锁以提高系统性能。

        逃逸分析和锁消除分别可以使用参数 -XX:+DoEscapeAnalysis 和 -XX:+EliminateLocks 开启锁消除必须工作在 -server模式下。

-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks -XCOMP -XX:-BackgroundCompilateion

3. 锁在应用层面的优化思路方案

3.1 减少锁的持有时间

       对于使用锁进行并发控制的应用程序而言,在锁的竞争过程中,单线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间很长,那么相对锁的竞争程度也就很激烈。因此在开发过程中应尽可能的减少对某个锁的占有时间,以减少线程间互斥的可能性。某一个方法内部有同步操作,那么梳理业务逻辑做到不是竞争条件的逻辑不要包含在同步方法内部,这样做的在持有锁的过程中存在不必要的同步加锁的逻辑就会排除在外,从而减少锁的持有时间。

3.2 减小锁的粒度

       减小锁的粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。对一个普通的集合对象的多线程同步来说,最常用的方式就是对 get()\put()\add()方法进行同步操作。每当这项操作的时候总是获得集合对象的锁,而在高并发时激烈的锁竞争会影响系统的吞吐量。而ConcurrentHashMap 类很好的使用了拆分锁对象的方式提高了集合对象的吞吐量。ConcurrentHashMap将真个map分成若干个Segment,每个Segment都是一个子HashMap。如果需要在ConcurrentHashMap中增加一个新的表项时,并不是将整个ConcurrentHashMap 加锁,二十首先根据hashCode得到该表项被存放的那个Segment中,然后对该Segment进行加锁并完成put()操作,多线程环境中ConcurrentHashMap默认并发度为16,也就是同时满足16个线程同时进行put()操作。

3.3 锁分离

       锁分离是减小锁粒度的一个特例,它依据应用程序的功能特点,将一个独占锁分成多个锁。特例redis支持的读写锁,这样在操作过程中对有些资源可能仅仅是读取查询,可以使用读锁非独占可重入,写锁就是独占的。

3.4 锁粗化

       通常情况下为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样等待在这个锁上的其他线程才能尽早的获取资源执行任务。但是凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源反而不利于性能的优化。为此虚拟机在遇到一连串的对同一锁不断的进行请求和释放的操作时,便会把所有的操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作也叫做锁的粗化。

今天就整到这欢迎大家指正和建议。

如有披露或问题欢迎留言或者入群探讨

标签:之锁,对象,虚拟机,竞争,理解,线程,JVM,多线程,偏向
来源: https://blog.csdn.net/qq_39470733/article/details/115478207