【面试必备】深入浅出Java锁优化(偏向锁,轻量级锁,锁消除,锁粗化,自旋锁)

目录

一、锁膨胀

1.1 偏向锁(Biased Locking)

1.2 轻量级锁

为什么会尝试CAS不成功以及什么情况下会不成功?

1.3 重量级锁

三种锁各自的优缺点和适用场景

二、锁消除(Lock Elision)

三、锁粗化(Lock Coarsening)

对于锁粗化的的理解

四、自旋锁与自适应自旋锁


高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花 费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行 效率。下面我们以synchronixed关键字为例,讲一讲JVM对锁的优化

一、锁膨胀

在JDK6对锁进行优化之后,锁的状态就有四种,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。也就是说对象对应的锁是会根据当前线程申请,抢占锁的情况自行改变锁的类型。

1.1 偏向锁Biased Locking

一句话总结它的作用:减少同一线程获取锁的代价在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,使用偏向锁也就去掉了这一部分的负担,也取消掉了加锁和解锁的过程消耗

核心思想:

引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉

如果该锁第一次被一个线程持有,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位是否为偏向锁以及当前线程ID是否等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作,减少不必要的CAS操作。申请获取偏向锁的时间非常短,这种锁在竞争不激烈的时候比较适用。如果程序中大多数的锁都总是被多个不同的线程访 问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

原理:

1)线程申请锁的时候首先都会检测Mark Word是否为可偏向状态即是否为偏向锁1,锁标识位为01;因为当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式,表示对象处于可偏向的状态,并且ThreadId为0,这该对象是biasable&unbiased状态

如果当前对象处于可偏向状态,则测试线程ID是否为当前线程ID如果是,则执行步骤5);否则执行步骤2),尝试获取偏向锁。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,由于锁竞争应该直接进入步骤4)

2)若当前对象的Mark Word中指向的持有锁的线程ID不是该线程ID,则该线程就尝试用CAS操作将自己的ThreadID放置到Mark Word中相应的位置,如果CAS操作成功,说明该线程成功获取偏向锁,进入到步骤3),否则进入步骤4)

3)进入到这一步代表当前没有锁竞争,此时ThreadID已经不为0了,而是持有锁的线程ID。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等),只需要检查Mark Word的锁标记位是否为偏向锁以及当前线程ID是否等于Mark Word的ThreadID,如果都满足则进入步骤5执行同步代码块。

4)当线程执行CAS失败,表示另一个线程当前正在竞争该对象上的锁。当到达全局安全点时(cpu没有正在执行的字节,即获得偏向锁的线程当前没有执行,这个时间点是上没有正在执行的代码,注意当前持有偏向锁的线程不执行并不一定就是它的操作已经执行完成,要释放锁了)之前持有偏向锁的线程将被暂停,撤销偏向(偏向位置0)

然后判断锁对象是否还处于被锁定状态,如果没有被锁定说明当前资源没有被线程使用,则恢复到无锁状态(01),以允许其余线程竞争。如果处于被锁定状态说明当前资源正在被线程使用,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址Lock Record的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;后续的同步操作就按照轻量级锁那样去执行。同时被撤销偏向锁的线程继续往下执行。

注意:此处将 当前线程挂起再恢复的过程 中并没有发生锁的转移 ,锁仍然在当前线程手中,只是穿插了个 “将对象头中的线程 ID 变更为指向锁记录地址的指针” 这么个事(将偏向锁转换成轻量级锁)。

5)执行同步代码块;

 

 

1.2 轻量级锁

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。(意思就是线程之间获取锁是没有争抢的,线程A持有了资源X的锁,当时用完资源X之后,A线程释放掉资源X的锁,当线程B也想使用资源X去申请它的锁的时候,就再次申请获取资源X的锁,两个线程之间没有发成争抢,也就没有必要使用以前的互斥量还要休眠进程白白降低效率)

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢

核心思想:

如果说偏向锁是只允许一个线程获得锁,那么轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况。轻量级锁的加锁和解锁都是通过CAS操作是现实

原理:

1)线程1在执行同步代码块之前,如果此同步对象没有被锁定(锁标志位为“01”状态),JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间用来存储锁记录,然后再把对象头中的MarkWord复制到该锁记录中,官方称之为Displaced Mark Word。然后线程尝试使用CAS将对象头中的MarkWord 替换为指向锁记录的指针(锁状态为轻量级锁的Mark Word中存储的就是指向持有锁的线程的所记录的指针,这个操作详细就是使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word)。如果成功,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的 最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。进入步骤3)。如果该操作失败,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入步骤5执行同步块就可以了,否则就说明这个锁对象已经被其他线程抢占了,执行步骤2)

 

 

2)如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,但是对锁进行了优化,线程会先进行一段时间的自旋状态(轮询申请锁),先并不会进入阻塞状态,如果在自旋期间成功获得锁,则进入步骤3)。如果自旋结束也没有获得锁,则膨胀成为重量级锁,并把锁标志位变为10,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针(Mark Word中重量级锁状态就存储指向重量级锁的指针),所有等待该锁的线程也必须进入阻塞状态,进入步骤3)

3)锁的持有线程执行同步代码,执行完之后如果对象的 Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word用线程中复制的Displaced Mark Word替换回来(也就是执行了compare and swap 比较然后交换操作),即CAS替换Mark Word释放锁,如果CAS执行成功,那整个同步过程就顺利完成了,则流程结束;CAS执行失败则进行步骤4)

4)CAS执行失败说明期间有线程尝试获得锁并自旋失败,轻量级锁升级为了重量级锁,此时释放锁之后,还要唤醒等待的线程

5)执行同步代码块;

 

为什么会尝试CAS不成功以及什么情况下会不成功?

CAS本身是不带锁机制的,其是通过比较而来。假设如下场景:线程A和线程B都在对象头里的锁标识为无锁状态进入,那么如线程A先更新对象头为其锁记录指针成功之后,线程B再用CAS去更新,就会发现此时的对象头已经不是其操作前的对象HashCode了,所以CAS会失败。也就是说,只有两个线程并发申请锁的时候会发生CAS失败。

然后线程B进行CAS自旋,等待对象头的锁标识重新变回无锁状态或对象头内容等于对象HashCode(因为这是线程B做CAS操作前的值),这也就意味着线程A执行结束(参见后面轻量级锁的撤销,只有线程A执行完毕撤销锁了才会重置对象头),此时线程B的CAS操作终于成功了,于是线程B获得了锁以及执行同步代码的权限。如果线程A的执行时间较长,线程B经过若干次CAS时钟没有成功,则锁膨胀为重量级锁,即线程B被挂起阻塞、等待重新调度。

 

1.3 重量级锁

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。

原理:

Synchronized是通过对象内部的监视器锁(Monitor来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。实现Mutex Lock又需要进行两个线程之间的切换,而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。它通过操作系统的互斥量和线程的阻塞和唤醒来实现锁机制。

重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景

 

三种索各自的优缺点和适用场景:

 

二、锁消除(Lock Elision

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁锁消除可以节省毫无意义的请求锁的时间。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有局部变量,不存在所得竞争关系。

原理:

“锁消除”,是JIT编译器对内部锁的具体实现所做的一种优化。锁消除是借助逃逸分析实现的。

在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

如以下代码:

public void f() {
	Object hollis = new Object();
	synchronized(hollis) {
		System.out.println(hollis);
	}
}

 代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:

public void f() {
    Object hollis = new Object();
	System.out.println(hollis);
}

 这里,可能有读者会质疑了,代码是程序员自己写的,程序员难道没有能力判断要不要加锁吗?就像以上代码,完全没必要加锁,有经验的开发者一眼就能看的出来的。其实道理是这样,但是还是有可能有疏忽,虽然没有显示使用锁,但是在使用一些JDK的内置API时,StringBufferVectorHashTable等,他们的方法很多都被进行了加锁处理,会存在隐形的加锁操作。比如我们经常在代码中使用StringBuffer作为局部变量,而StringBuffer中的append是线程安全的,有synchronized修饰的,这种情况开发者可能会忽略。再比如说Vectoradd()方法:

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

 在运行这段代码时,vector是这段代码的局部变量,整个生命周期都是跟随vectorTest()方法得,并没有出现逃逸现象,那么vector源码中对add方法进行的加锁操作也就失去了意义,所以JVM检测到变量vector没有逃逸出方法vectorTest()后,JVM就vector内部的加锁操作消除。这时候,JIT就可以帮忙优化,进行锁消除。

总之,在使用synchronized的时候,如果JIT经过逃逸分析之后发现同步块中使用的锁对象并没有逃逸出去,不可能被其他线程所使用,并无线程安全问题的话,就会做锁消除

 

三、锁粗化Lock Coarsening

锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。

 

对于锁粗化的的理解:

很多人都知道,在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。

这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,这其实是很有道理的

还是我们去银行柜台办业务,最高效的方式是你坐在柜台前面的时候,只办和银行相关的事情。如果这个时候,你拿出手机,接打几个电话,问朋友要往哪个账户里面打钱,这就很浪费时间了。最好的做法肯定是提前准备好相关资料,在办理业务时直接办理就好了。

加锁也一样,把无关的准备工作放到锁外面,锁内部只处理和并发相关的内容。这样有助于提高效率。

那么,这和锁粗化有什么关系呢?可以说,大部分情况下,减小锁的粒度是很正确的做法,只有一种特殊的情况下,会发生一种叫做锁粗化的优化

就像你去银行办业务,你为了减少每次办理业务的时间,你把要办的五个业务分成五次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号、排队、被唤醒的时间。

如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。

当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。

如以下代码:

for(int i=0;i<100000;i++){  
	synchronized(this){  
	    do();  
    }
}  

会被粗化成:

synchronized(this){  
	for(int i=0;i<100000;i++){  
	    do();  
    }
}  

 这其实和我们要求的减小锁粒度并不冲突。减小锁粒度强调的是不要在银行柜台前做准备工作以及和办理业务无关的事情。而锁粗化建议的是,同一个人,要办理多个业务的时候,可以在同一个窗口一次性办完,而不是多次取号多次办理

 

四、自旋锁与自适应自旋锁

自旋锁在 JDK1.4.2 实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。另外,在 JDK1.6 中引入了自适应的自旋锁。

之前如果线程尝试获得锁失败,就会进入到阻塞状态,线程进入到阻塞状态是需要操作系统来讲线程进行挂起,挂起和唤醒都是一个消耗时间和资源的操作,所以为了避免这种情况,就出现了自旋锁的概念。

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要操作系统从用户态转入内核态中完成(用户态转换到内核态会耗费时间)。

 

自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来更改

自适应自旋锁(Adaptive Locking)这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。那它如何进行适应性自旋呢?

  • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
  • 反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源

自旋锁阻塞锁最大的区别就是,到底要不要放弃处理器的执行时间。对于阻塞锁和自旋锁来说,都是要等待获得共享资源。但是阻塞锁是放弃了CPU时间,进入了等待区,等待被唤醒。而自旋锁是一直“自旋”在那里,时刻的检查共享资源是否可以被访问。


其他相关文章:【并发编程】synchronized关键字最全详解,看这一篇就够了
                        【并发基础】CAS(Compare And Swap)操作的底层原理以及应用详解
                        【并发编程】Java中的锁有哪些?各自都有什么样的特性?
                        【编译器优化技术】逃逸
                        【并发编程】volatile关键字最全详解,看这一篇就够了


参考资料:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)- 周志明

发布了54 篇原创文章 · 获赞 47 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/cy973071263/article/details/104546954