Java虚拟机的锁优化策略

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

【1】自旋锁

当一个线程请求一把锁的时候,如果锁被其他线程占用,该线程并不会立即进入阻塞状态,而是循环请求一段时间–该过程被称之为自旋。如果一段时间内还没有获取,则造成锁升级。

① 背景
互斥同步对性能最大的影响的阻塞,挂起和恢复线程都需要转入内核态中完成。并且通常情况下,共享数据的锁定状态只持续很短的一段时间,为了这很短的一段时间进行上下文切换并不值得。

② 原理

当一条线程需要请求一把已经被占用的锁时,并不会进入阻塞状态,而是继续持有CPU执行权等待一段时间,该过程称为“自旋”。

③ 优点

由于自旋等待锁的过程,线程并不会引起上下文切换,因此比较高效。

④ 缺点

自旋等待过程线程一直占用CPU执行权但不处理任何任务,因此若该过程过长,那就会造成CPU资源的浪费。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

⑤ 自适应自旋

自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

【2】锁清除

编译器会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。也就是没有必要使用锁的地方强加了锁,会被编译器清除。锁消除的依据是逃逸分析的数据支持。

如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
	Vector<String> vector  = new Vector<String>();
	for(int i=0;i<10;i++){
		vector.add(i+"");
	}
	System.out.println(vector);
}

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

【3】锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

若有一系列的操作,反复地对同一把锁进行上锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作,减少加锁/解锁的次数。

【4】轻量级锁

① 本质

使用CAS取代互斥同步。CAS是什么?互斥同步又是什么?

所谓互斥,就是不同线程通过竞争进入临界区(共享的数据和硬件资源),为了防止访问冲突,在有限的时间内只允许其中之一独占性的使用共享资源。如不允许同时写。

同步关系则是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。如先生产后使用。

与锁相比,使用比较交换(CAS)会使程序看起来更加复杂一些,但由于其非阻塞性,它对死锁免疫。更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程频繁调度带来的开销,因此,它是比基于锁的优势的方式拥有更优越的性能。

CAS算法的过程是这样:它包含三个参数CAS(V,E,N)。V表示要更新的值(即旧值),E表示预期值,N表示新值。仅当V等于E时,才会将V的值设为N。如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作一个变量时,只有一个会胜出,并更新成功,其余均会失败。失败的线程不会被挂起,仅是被告知失败 ,并且允许再次尝试,当然也允许失败的线程放弃操作。

基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰恰当的处理。

CAS详解参考:CAS算法详解

② 背景

轻量级锁是相对于重量级锁而言的,而重量级锁就是传统的锁,比如synchronized。引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下。

获取锁

  • 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);

  • JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);

  • 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁,轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  • 取出在获取轻量级锁保存在Displaced Mark Word中的数据;

  • 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);

  • 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

③ 轻量级锁与重量级锁的比较

重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全。

而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁。这样能减少互斥同步所使用的互斥量带来的性能开销。

④ 实现原理

对象头称为Mark Word,虚拟机为了节约对象的存储空间,对象处于不同的状态下,Mark Word中存储的信息也有所不同。Mark Word中有个标志位用来表示当前对象所处的状态。

当线程请求锁时,若该锁对象的Mark Word中标志位为01(未锁定状态),则在该线程的栈帧中创建一块名为“锁记录”的空间,然后将锁对象的Mark Word拷贝至该空间。最后通过CAS操作将锁对象的Mark Word指向该锁记录。

若CAS操作成功,则轻量级锁的上锁过程成功。若CAS操作失败,再判断当前线程是否已经持有了该轻量级锁。若已经持有,则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁。

《Java虚拟机的锁优化策略》

参考博文:
一文读懂Synchronized的实现原理
细探究,Java对象创建的奥秘
一个Java对象到底占用多大内存

⑤ 前提

轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量外,还额外发生了CAS操作,因此更慢!

【5】偏向锁

① 作用

偏向锁所应对的场景则更为乐观:至始至终只有一个线程请求某把锁。偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下。

获取锁

  • 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;

  • 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);

  • 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);

  • 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。

  • 执行同步代码块

释放锁,偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点上没有正在执行的代码)。其步骤如下:

  • 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;如果是则执行步骤(2),否则不用撤销。

  • 撤销偏向锁,恢复到无锁状态(01)或者升级为轻量级锁的状态;

《Java虚拟机的锁优化策略》

② 与轻量级锁的区别

轻量级锁是在无竞争的情况下使用CAS操作来代替互斥量的使用,从而实现同步。而偏向锁是在无竞争的情况下完全取消同步。

③ 与轻量级锁的相同点

它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。

④ 原理

当线程请求到锁对象后,将锁对象的状态标志位改为01,即偏向模式。然后使用CAS操作将线程的ID记录在锁对象的Mark Word中。以后该线程可以直接进入同步块,连CAS操作都不需要。但是一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。

⑤ 优点

偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。

偏向锁可以通过虚拟机的参数来控制它是否开启。·

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

那么锁是什么?锁在哪里?参考专题相关文章:

一文读懂Synchronized的实现原理
从内存可见性看Volatile、原子变量和CAS算法
多线程并发之CountDownLatch(闭锁)使用详解
多线程并发之显示锁Lock与其通信方式Condition源码解读
多线程并发之读写锁(ReentranReadWriteLock&ReadWriteLock)使用详解
多线程并发之线程池Executor与Fork/Join框架
多线程并发之JUC 中的 Atomic 原子类总结
多线程并发之volatile的底层实现原理
多线程并发之Semaphore(信号量)使用详解

    原文作者:java锁
    原文地址: https://blog.csdn.net/J080624/article/details/82463399
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞