首頁  >  文章  >  Java  >  Java虛擬機器四種最佳化對內部鎖的方式解析

Java虛擬機器四種最佳化對內部鎖的方式解析

黄舟
黄舟原創
2017-10-13 10:18:431252瀏覽

這篇文章主要介紹了淺談Java虛擬機對內部鎖的四種優化方式,小編覺得挺不錯的,現在分享給大家,也給大家做個參考。一起跟著小編過來看看吧

自Java 6/Java 7開始,Java虛擬機器對內部鎖的實作進行了一些最佳化。這些優化主要包括鎖定消除(Lock Elision)、鎖粗化(Lock Coarsening)、偏向鎖(Biased Locking)以及適應性鎖(Adaptive Locking)。這些最佳化僅在Java虛擬機器server模式下運作(即執行Java程式時我們可能需要在命令列中指定Java虛擬機器參數「-server」以開啟這些最佳化)。

1 鎖定消除

鎖定消除(Lock Elision)是JIT編譯器對內部鎖的具體實現所做的一種最佳化。

 

鎖定消除(Lock Elision)示意圖

在動態編譯同步區塊的時候,JIT編譯器可以藉助一種稱為逃逸分析(Escape Analysis)的技術來判斷同步區塊所使用的鎖定物件是否只能夠被一個執行緒存取而沒有被發佈到其他執行緒。如果同步區塊所使用的鎖定物件透過這種分析被證實只能夠被一個執行緒訪問,那麼JIT編譯器在編譯這個同步區塊的時候並不產生synchronized所表示的鎖的申請與釋放對應的機器碼,而僅產生原臨界區代碼對應的機器碼,這就造成了被動態編譯的字節碼就像是不包含monitorenter(申請鎖)和monitorexit(釋放鎖)這兩個字節碼指令一樣,即消除了鎖的使用。這種編譯器最佳化就被稱為鎖消除(Lock Elision),它使得特定情況下我們可以完全消除鎖的開銷。

Java標準庫中的有些類別(例如StringBuffer)雖然是線程安全的,但是在實際使用中我們往往不在多個線程間共享這些類別的實例。而這些類別在實現線程安全的時候往往借助於內部鎖。因此,這些類別是鎖消除優化的常見目標。

清單12-1  可進行鎖定消除最佳化的範例程式碼


#
public class LockElisionExample {

 public static String toJSON(ProductInfo productInfo) {
  StringBuffer sbf = new StringBuffer();
  sbf.append("{\"productID\":\"").append(productInfo.productID);
  sbf.append("\",\"categoryID\":\"").append(productInfo.categoryID);
  sbf.append("\",\"rank\":").append(productInfo.rank);
  sbf.append(",\"inventory\":").append(productInfo.inventory);
  sbf.append('}');

  return sbf.toString();
 }
}

在上面範例中,JIT編譯器在編譯toJSON方法的時候會將其呼叫的StringBuffer.append/toString方法內聯(Inline)到該方法之中,這相當於把StringBuffer.append/toString方法的方法體中的指令複製到toJSON方法體之中。這裡的StringBuffer實例sbf是一個局部變量,而該變數所引用的物件並沒有被發佈到其他線程,因此sbf引用的物件只能夠被sbf所在的方法(toJSON方法)的當前執行線程(一個線程)訪問。所以,JIT編譯器此時可以消除toJSON方法中從StringBuffer.append/toString方法的方法體複製的指令所使用的內部鎖定。在這個例子中,StringBuffer.append/toString方法本身所使用的鎖並不會被消除,因為系統中可能還有其他地方在使用StringBuffer,而這些程式碼可能會共用StringBuffer實例。

鎖定消除優化所依賴的逃逸分析技術自Java SE 6u23起預設是開啟的,但是鎖定消除優化是在Java 7開始引入的。

從上述例子可以看出,鎖定消除最佳化也可能需要以JIT編譯器的內聯最佳化為前提。而一個方法是否會被JIT編譯器內聯取決於該方法的熱度以及該方法對應的字節碼的尺寸(Bytecode Size)。因此,鎖消除最佳化能否被實施也取決於被呼叫的同步方法(或帶有同步區塊的方法)是否能夠被內聯。

鎖定消除優化告訴我們在該使用鎖的情況下必須使用鎖,而不必過度在意鎖的開銷。開發人員應該在程式碼的邏輯層面考慮是否需要加鎖,而至於程式碼運行層面上某個鎖是否真的有必要使用則由JIT編譯器來決定。鎖定消除最佳化並不表示開發人員在編寫程式碼的時候可以隨意使用內部鎖(在不需要加鎖的情況下加鎖),因為鎖定消除是JIT編譯器而不是javac所做的一種最佳化,而一段程式碼只有在執行的頻率夠大的情況下才有可能會被JIT編譯器最佳化。也就是說在JIT編譯器最佳化介入之前,只要原始碼中使用了內部鎖,那麼這個鎖的開銷就會存在。另外,JIT編譯器所執行的內嵌最佳化、逃逸分析以及鎖定消除最佳化本身都是有其開銷的。

在鎖消除的作用下,利用ThreadLocal將一個線程安全的物件(例如Random)作為一個線程特有物件來使用,不僅可以避免鎖的爭用,還可以徹底消除這些物件內部所使用的鎖的開銷。

2 鎖粗化

鎖定粗化(Lock Coarsening/Lock Merging)是JIT編譯器對內部鎖的具體實作所做的最佳化。

 

鎖定粗化(Lock Coarsening)示意圖

对于相邻的几个同步块,如果这些同步块使用的是同一个锁实例,那么JIT编译器会将这些同步块合并为一个大同步块,从而避免了一个线程反复申请、释放同一个锁所导致的开销。然而,锁粗化可能导致一个线程持续持有一个锁的时间变长,从而使得同步在该锁之上的其他线程在申请锁时的等待时间变长。例如上图中,第1个同步块结束和第2个同步块开始之间的时间间隙中,其他线程本来是有机会获得monitorX的,但是经过锁粗化之后由于临界区的长度变长,这些线程在申请monitorX时所需的等待时间也相应变长了。因此,锁粗化不会被应用到循环体内的相邻同步块。

相邻的两个同步块之间如果存在其他语句,也不一定就会阻碍JIT编译器执行锁粗化优化,这是因为JIT编译器可能在执行锁粗化优化前将这些语句挪到(即指令重排序)后一个同步块的临界区之中(当然,JIT编译器并不会将临界区内的代码挪到临界区之外)。

实际上,我们写的代码中可能很少会出现上图中那种连续的同步块。这种同一个锁实例引导的相邻同步块往往是JIT编译器编译之后形成的。

例如,在下面的例子中

清单12-2  可进行锁粗化优化的示例代码


public class LockCoarseningExample {
 private final Random rnd = new Random();

 public void simulate() {
  int iq1 = randomIQ();
  int iq2 = randomIQ();
  int iq3 = randomIQ();
  act(iq1, iq2, iq3);
 }

 private void act(int... n) {
  // ...
 }

 // 返回随机的智商值
 public int randomIQ() {
  // 人类智商的标准差是15,平均值是100
  return (int) Math.round(rnd.nextGaussian() * 15 + 100);
 }
 // ...
}

simulate方法连续调用randomIQ方法来生成3个符合正态分布(高斯分布)的随机智商(IQ)。在simulate方法被执行得足够频繁的情况下,JIT编译器可能对该方法执行一系优化:首先,JIT编译器可能将randomIQ方法内联(inline)到simulate方法中,这相当于把randomIQ方法体中的指令复制到simulate方法之中。在此基础上,randomIQ方法中的rnd.nextGaussian()调用也可能被内联,这相当于把Random.nextGaussian()方法体中的指令复制到simulate方法之中。Random.nextGaussian()是一个同步方法,由于Random实例rnd可能被多个线程共享(因为simulate方法可能被多个线程执行),因此JIT编译器无法对Random.nextGaussian()方法本身执行锁消除优化,这使得被内联到simulate方法中的Random.nextGaussian()方法体相当于一个由rnd引导的同步块。经过上述优化之后,JIT编译器便会发现simulate方法中存在3个相邻的由rnd(Random实例)引导的同步块,于是锁粗化优化便“粉墨登场”了。

锁粗化默认是开启的。如果要关闭这个特性,我们可以在Java程序的启动命令行中添加虚拟机参数“-XX:-EliminateLocks”(开启则可以使用虚拟机参数“-XX:+EliminateLocks”)。

3 偏向锁

偏向锁(Biased Locking)是Java虚拟机对锁的实现所做的一种优化。这种优化基于这样的观测结果(Observation):大多数锁并没有被争用(Contented),并且这些锁在其整个生命周期内至多只会被一个线程持有。然而,Java虚拟机在实现monitorenter字节码(申请锁)和monitorexit字节码(释放锁)时需要借助一个原子操作(CAS操作),这个操作代价相对来说比较昂贵。因此,Java虚拟机会为每个对象维护一个偏好(Bias),即一个对象对应的内部锁第1次被一个线程获得,那么这个线程就会被记录为该对象的偏好线程(Biased Thread)。这个线程后续无论是再次申请该锁还是释放该锁,都无须借助原先(指未实施偏向锁优化前)昂贵的原子操作,从而减少了锁的申请与释放的开销。

然而,一个锁没有被争用并不代表仅仅只有一个线程访问该锁,当一个对象的偏好线程以外的其他线程申请该对象的内部锁时,Java虚拟机需要收回(Revoke)该对象对原偏好线程的“偏好”并重新设置该对象的偏好线程。这个偏好收回和重新分配过程的代价也是比较昂贵的,因此如果程序运行过程中存在比较多的锁争用的情况,那么这种偏好收回和重新分配的代价便会被放大。有鉴于此,偏向锁优化只适合于存在相当大一部分锁并没有被争用的系统之中。如果系统中存在大量被争用的锁而没有被争用的锁仅占极小的部分,那么我们可以考虑关闭偏向锁优化。

偏向锁优化默认是开启的。要关闭偏向锁优化,我们可以在Java程序的启动命令行中添加虚拟机参数“-XX:-UseBiasedLocking”(开启偏向锁优化可以使用虚拟机参数“-XX:+UseBiasedLocking”)。

4 适应性锁

适应性锁(Adaptive Locking,也被称为 Adaptive Spinning )是JIT编译器对内部锁实现所做的一种优化。

存在锁争用的情况下,一个线程申请一个锁的时候如果这个锁恰好被其他线程持有,那么这个线程就需要等待该锁被其持有线程释放。实现这种等待的一种保守方法——将这个线程暂停(线程的生命周期状态变为非Runnable状态)。由于暂停线程会导致上下文切换,因此对于一个具体锁实例来说,这种实现策略比较适合于系统中绝大多数线程对该锁的持有时间较长的场景,这样才能够抵消上下文切换的开销。另外一种实现方法就是采用忙等(Busy Wait)。所谓忙等相当于如下代码所示的一个循环体为空的循环语句:


// 当锁被其他线程持有时一直循环 
while (lockIsHeldByOtherThread){}

可见,忙等是通过反复执行空操作(什么也不做)直到所需的条件成立为止而实现等待的。这种策略的好处是不会导致上下文切换,缺点是比较耗费处理器资源——如果所需的条件在相当长时间内未能成立,那么忙等的循环就会一直被执行。因此,对于一个具体的锁实例来说,忙等策略比较适合于绝大多数线程对该锁的持有时间较短的场景,这样能够避免过多的处理器时间开销。

事实上,Java虚拟机也不是非要在上述两种实现策略之中择其一 ——它可以综合使用上述两种策略。对于一个具体的锁实例,Java虚拟机会根据其运行过程中收集到的信息来判断这个锁是属于被线程持有时间“较长”的还是“较短”的。对于被线程持有时间“较长”的锁,Java虚拟机会选用暂停等待策略;而对于被线程持有时间“较短”的锁,Java虚拟机会选用忙等等待策略。Java虚拟机也可能先采用忙等等待策略,在忙等失败的情况下再采用暂停等待策略。Java虚拟机的这种优化就被称为适应性锁(Adaptive Locking),这种优化同样也需要JIT编译器介入。

适应性锁优化可以是以具体的一个锁实例为基础的。也就是说,Java虚拟机可能对一个锁实例采用忙等等待策略,而对另外一个锁实例采用暂停等待策略。

从适应性锁优化可以看出,内部锁的使用并不一定会导致上下文切换,这就是我们说锁与上下文切换时均说锁“可能”导致上下文切换的原因。

 

以上是Java虛擬機器四種最佳化對內部鎖的方式解析的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn