天天看點

java性能優化實戰:優化多線程鎖提高代碼性能

作者:一個即将退役的碼農

之前我們提到可以使用 ThreadLocal,來避免 SimpleDateFormat 在并發環境下引起的時間錯亂問題。其實還有一種解決方式,就是通過對parse 方法進行加鎖,也能保證日期處理類的正确運作,代碼如下圖:

java性能優化實戰:優化多線程鎖提高代碼性能

其實鎖對性能的影響,是非常大的。因為對資源加鎖以後,資源就被加鎖的線程獨占,其他的線程就隻能排隊等待這個鎖,此時程式由并行執行,變相地成了順序執行,執行速度自然就降低了。

下面是開啟了 50 個線程,使用 ThreadLocal 和同步鎖方式性能的一個對比。

Benchmark                                 Mode  Cnt     Score      Error   Units
SynchronizedNormalBenchmark.sync         thrpt   10  2554.628 ± 5098.059  ops/ms
SynchronizedNormalBenchmark.threadLocal  thrpt   10  3750.902 ±  103.528  ops/ms
========去掉業務影響========
Benchmark                                 Mode  Cnt        Score        Error   Units
SynchronizedNormalBenchmark.sync         thrpt   10    26905.514 ±   1688.600  ops/ms
SynchronizedNormalBenchmark.threadLocal  thrpt   10  7041876.244 ± 355598.686  ops/ms
           

可以看到,使用同步鎖的方式,性能是比較低的。如果去掉業務本身邏輯的影響(删掉執行邏輯),這個差異會更大。代碼執行的次數越多,鎖的累加影響越大,對鎖本身的速度優化,是非常重要的。

我們都知道,Java 中有兩種加鎖的方式:一種就是常見的synchronized 關鍵字,另外一種,就是使用 concurrent 包裡面的 Lock。針對這兩種鎖,JDK 自身做了很多的優化,它們的實作方式也是不同的。本課時将從這兩種鎖講起,看一下對鎖的一些優化方式。

synchronied

synchronized 關鍵字給代碼或者方法上鎖時,都有顯示或者隐藏的上鎖對象。當一個線程試圖通路同步代碼塊時,它首先必須得到鎖,而退出或抛出異常時必須釋放鎖。

  • 給普通方法加鎖時,上鎖的對象是 this;
  • 給靜态方法加鎖時,鎖的是 class 對象;
  • 給代碼塊加鎖,可以指定一個具體的對象作為鎖。

1.monitor 原理

在面試中,面試官很可能會問你:synchronized 在位元組碼中,是怎麼展現的呢? 參照下面的代碼,在指令行執行 javac,然後再執行 javap -v -p,就可以看到它具體的位元組碼。

可以看到,在位元組碼的展現上,它隻給方法加了一個 flag:ACC_SYNCHRONIZED。

synchronized void syncMethod() {
        System.out.println("syncMethod");
}
======位元組碼=====
synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4
         3: ldc           #5
         5: invokevirtual #6
         8: return
           

我們再來看下同步代碼塊的位元組碼。可以看到,位元組碼是通過 monitorenter 和monitorexit 兩個指令進行控制的。

void syncBlock(){
    synchronized (Test.class){
    }
}
======位元組碼======
void syncBlock();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2 
         2: dup
         3: astore_1
         4: monitorenter
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: return
      Exception table:
         from    to  target type
             5     7    10   any
            10    13    10   any
           

這兩者雖然顯示效果不同,但他們都是通過 monitor 來實作同步的。我們可以通過下面這張圖,來看一下 monitor 的原理。

注意了,下面是面試題目高發地。比如,你能描述一下 monitor 鎖的實作原理嗎?

java性能優化實戰:優化多線程鎖提高代碼性能

如上圖所示,我們可以把運作時的對象鎖抽象地分成三部分。其中,EntrySet 和 WaitSet 是兩個隊列,中間虛線部分是目前持有鎖的線程,我們可以想象一下線程的執行過程。

當第一個線程到來時,發現并沒有線程持有對象鎖,它會直接成為活動線程,進入 RUNNING 狀态。

接着又來了三個線程,要争搶對象鎖。此時,這三個線程發現鎖已經被占用了,就先進入 EntrySet 緩存起來,進入 BLOCKED 狀态。此時,從 jstack 指令,可以看到他們展示的資訊都是 waiting for monitor entry。

"http-nio-8084-exec-120" #143 daemon prio=5 os_prio=31 cpu=122.86ms elapsed=317.88s tid=0x00007fedd8381000 nid=0x1af03 waiting for monitor entry  [0x00007000150e1000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.io.BufferedInputStream.read([email protected]/BufferedInputStream.java:263)
    - waiting to lock <0x0000000782e1b590> (a java.io.BufferedInputStream)
    at org.apache.commons.httpclient.HttpParser.readRawLine(HttpParser.java:78)
    at org.apache.commons.httpclient.HttpParser.readLine(HttpParser.java:106)
    at org.apache.commons.httpclient.HttpConnection.readLine(HttpConnection.java:1116)
    at org.apache.commons.httpclient.HttpMethodBase.readStatusLine(HttpMethodBase.java:1973)
    at org.apache.commons.httpclient.HttpMethodBase.readResponse(HttpMethodBase.java:1735)
           

處于活動狀态的線程,執行完畢退出了;或者由于某種原因執行了 wait 方法,釋放了對象鎖,進入了 WaitSet 隊列,這就是在調用 wait 之前,需要先獲得對象鎖的原因。

就像下面的代碼:

synchronized (lock){
    try {
         lock.wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
           

此時,jstack 顯示的線程狀态是 WAITING 狀态,而原因是 in Object.wait()。

"wait-demo" #12 prio=5 os_prio=31 cpu=0.14ms elapsed=12.58s tid=0x00007fb66609e000 nid=0x6103 in Object.wait()  [0x000070000f2bd000]
   java.lang.Thread.State: WAITING (on object monitor)
    at java.lang.Object.wait([email protected]/Native Method)
    - waiting on <0x0000000787b48300> (a java.lang.Object)
    at java.lang.Object.wait([email protected]/Object.java:326)
    at WaitDemo.lambda$main$0(WaitDemo.java:7)
    - locked <0x0000000787b48300> (a java.lang.Object)
    at WaitDemo$Lambda$14/0x0000000800b44840.run(Unknown Source)
    at java.lang.Thread.run([email protected]/Thread.java:830)
           

發生了這兩種情況,都會造成對象鎖的釋放,進而導緻 EntrySet 裡的線程重新争搶對象鎖,成功搶到鎖的線程成為活動線程,這是一個循環的過程。

那 WaitSet 中的線程是如何再次被激活的呢?接下來,在某個地方,執行了鎖的 notify 或者 notifyAll 指令,會造成 WaitSet 中的線程,轉移到 EntrySet 中,重新進行鎖的争奪。

如此周而複始,線程就可按順序排隊執行。

2.分級鎖

在 JDK 1.8 中,synchronized 的速度已經有了顯著的提升,它都做了哪些優化呢?答案就是分級鎖。JVM 會根據使用情況,對 synchronized 的鎖,進行更新,它大體可以按照下面的路徑進行更新:偏向鎖 — 輕量級鎖 — 重量級鎖。

鎖隻能更新,不能降級,是以一旦更新為重量級鎖,就隻能依靠作業系統進行排程。

要想了解鎖更新的過程,需要先看一下對象在記憶體裡的結構。

java性能優化實戰:優化多線程鎖提高代碼性能

如上圖所示,對象分為 MarkWord、Class Pointer、Instance Data、Padding 四個部分。

和鎖更新關系最大的就是 MarkWord,它的長度是 24 位,我們着重介紹一下。它包含Thread ID(23bit)、Age(6bit)、Biased(1bit)、Tag(2bit) 四個部分,鎖更新就是靠判斷 Thread Id、Biased、Tag 等三個變量值來進行的。

  • 偏向鎖

在隻有一個線程使用了鎖的情況下,偏向鎖能夠保證更高的效率。

具體過程是這樣的:當第一個線程第一次通路同步塊時,會先檢測對象頭 Mark Word 中的标志位 Tag 是否為 01,以此判斷此時對象鎖是否處于無鎖狀态或者偏向鎖狀态(匿名偏向鎖)。

01 也是鎖預設的狀态,線程一旦擷取了這把鎖,就會把自己的線程 ID 寫到 MarkWord 中,在其他線程來擷取這把鎖之前,鎖都處于偏向鎖狀态。

當下一個線程參與到偏向鎖競争時,會先判斷 MarkWord 中儲存的線程 ID 是否與這個線程 ID 相等,如果不相等,會立即撤銷偏向鎖,更新為輕量級鎖。

  • 輕量級鎖

輕量級鎖的擷取是怎麼進行的呢?它們使用的是自旋方式。

參與競争的每個線程,會在自己的線程棧中生成一個 LockRecord ( LR ),然後每個線程通過 CAS(自旋)的方式,将鎖對象頭中的 MarkWord 設定為指向自己的 LR 的指針,哪個線程設定成功,就意味着哪個線程獲得鎖。

當鎖處于輕量級鎖的狀态時,就不能夠再通過簡單地對比 Tag 的值進行判斷,每次對鎖的擷取,都需要通過自旋。

當然,自旋也是面向不存在鎖競争的場景,比如一個線程運作完了,另外一個線程去擷取這把鎖;但如果自旋失敗達到一定的次數,鎖就會膨脹為重量級鎖。

  • 重量級鎖

重量級鎖,即我們對 synchronized 的直覺認識,這種情況下,線程會挂起,進入到作業系統核心态,等待作業系統的排程,然後再映射回使用者态。系統調用是昂貴的,是以重量級鎖的名稱由此而來。

如果系統的共享變量競争非常激烈,鎖會迅速膨脹到重量級鎖,這些優化就名存實亡。如果并發非常嚴重,可以通過參數 -XX:-UseBiasedLocking 禁用偏向鎖,理論上會有一些性能提升,但實際上并不确定。

Lock

在 concurrent 包裡,我們能夠發現 ReentrantLock 和 ReentrantReadWriteLock 兩個類。Reentrant 就是可重入的意思,它們和 synchronized 關鍵字一樣,都是可重入鎖。

這裡有必要解釋一下**“可重入”這個概念,這是一個面試高頻考點**。它的意思是,一個線程運作時,可以多次擷取同一個對象鎖,這是因為 Java 的鎖是基于線程的,而不是基于調用的。

比如下面這段代碼,由于方法 a、b、c 鎖的都是目前的 this,線程在調用 a 方法的時候,就不需要多次擷取對象鎖。

public synchronized void a(){
    b();
}
public synchronized void b(){
    c();
}
public synchronized void c(){
}
           

1.主要方法

Lock 是基于 AQS(AbstractQueuedSynchronizer)實作的,而 AQS 是基于 volitale 和 CAS 實作的(關于CAS,我們将在下一課時講解)。

Lock 與 synchronized 的使用方法不同,它需要手動加鎖,然後在 finally 中解鎖。Lock 接口比 synchronized 靈活性要高,我們來看一下幾個關鍵方法。

  • Lock: Lock 方法和 synchronized 沒什麼差別,如果擷取不到鎖,都會被阻塞;
  • tryLock: 此方法會嘗試擷取鎖,不管能不能擷取到鎖,都會立即傳回,不會阻塞,它是有傳回值的,擷取到鎖就會傳回 true;
  • tryLock(long time, TimeUnit unit): 與 tryLock 類似,但它在拿不到鎖的情況下,會等待一段時間,直到逾時;
  • LockInterruptibly: 與 Lock 類似,但是可以鎖等待,可以被中斷,中斷後傳回 InterruptedException;

一般情況下,使用 Lock 方法就可以;但如果業務請求要求響應及時,那使用帶逾時時間的tryLock是更好的選擇:我們的業務可以直接傳回失敗,而不用進行阻塞等待。tryLock 這種優化手段,采用降低請求成功率的方式,來保證服務的可用性,在高并發場景下常被高頻采用。

2.讀寫鎖

但對于有些業務來說,使用 Lock 這種粗粒度的鎖還是太慢了。比如,對于一個HashMap 來說,某個業務是讀多寫少的場景,這個時候,如果給讀操作,也加上和寫操作一樣的鎖的話,效率就會很慢。

ReentrantReadWriteLock 是一種讀寫分離的鎖,它允許多個讀線程同時進行,但讀和寫、寫和寫是互斥的。

使用方法如下所示,分别擷取讀寫鎖,對寫操作加寫鎖,對讀操作加讀鎖,并在 finally 裡釋放鎖即可。

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    Lock readLock = lock.readLock();
    Lock writeLock = lock.writeLock();

    public void put(K k, V v) {
        writeLock.lock();
        try {
            map.put(k, v);
        } finally {
            writeLock.unlock();
        }
    }
...
           

3.公平鎖與非公平鎖

  • 非公平鎖

我們平常用到的鎖,都是非公平鎖,可以回過頭來看一下 monitor 的原理。當持有鎖的線程釋放鎖的時候,EntrySet 裡的線程就會争搶這把鎖,這個争搶過程,是随機的,也就是說你并不知道哪個線程會擷取對象鎖,誰搶到了就算誰的。

這就有一定的機率會發生,某個線程總是搶不到鎖的情況。比如,某個線程通過 setPriority 設定得比較低的優先級,這個搶不到鎖的線程,就一直處于饑餓狀态,這就是線程饑餓的概念。

  • 公平鎖

而公平鎖通過把随機變成有序,可以解決這個問題,synchronized 沒有這個功能,在Lock 中可以通過構造參數設定成公平鎖,代碼如下:

public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
}
           

由于所有的線程都需要排隊,需要在多核的場景下維護一個同步隊列,在多個線程争搶鎖的時候,吞吐量就很低。

下面是 20 個并發之下,鎖的 JMH 測試結果,可以看到,非公平鎖比公平鎖的性能高出兩個數量級。

Benchmark                      Mode  Cnt      Score      Error   Units
FairVSNoFairBenchmark.fair    thrpt   10    186.144 ±   27.462  ops/ms
FairVSNoFairBenchmark.nofair  thrpt   10  35195.649 ± 6503.375  ops/ms
           

鎖的優化技巧

1.死鎖

我們可以先看一下鎖沖突最嚴重的一種情況:死鎖。下面這段示例代碼,兩個線程分别持有對方所需要的鎖,并進入了互相等待的狀态,那麼它們就進入了死鎖。

在面試中,經常會要求被面試者手寫下面這段代碼:

public class DeadLockDemo {
    public static void main(String[] args) {
        Object object1 = new Object();
        Object object2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (object1) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (object2) {
                }
            }
        }, "deadlock-demo-1");

        t1.start();
        Thread t2 = new Thread(() -> {
            synchronized (object2) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (object1) {
                }
            }
        }, "deadlock-demo-2");
        t2.start();
    }
}
           

代碼建立了兩把對象鎖,線程1 首先拿到了 object1 的對象鎖,200ms 後嘗試擷取 object2 的對象鎖。但這個時候,object2 的對象鎖已經被線程2 擷取了。這兩個線程進入了互相等待的狀态,産生了死鎖。

使用我們上面提到的,帶逾時時間的 tryLock 方法,有一方逾時讓步,可以一定程度上避免死鎖。

2.優化技巧

鎖的優化理論其實很簡單,那就是減少鎖的沖突。無論是鎖的讀寫分離,還是分段鎖,本質上都是為了避免多個線程同時擷取同一把鎖。

是以我們可以總結一下優化的一般思路:減少鎖的粒度、減少鎖持有的時間、鎖分級、鎖分離 、鎖消除、樂觀鎖、無鎖等。

java性能優化實戰:優化多線程鎖提高代碼性能
  • 減少鎖粒度

通過減小鎖的粒度,可以将沖突分散,減少沖突的可能,進而提高并發量。簡單來說,就是把資源進行抽象,針對每類資源使用單獨的鎖進行保護。

比如下面的代碼,由于 list 1 和 list 2 屬于兩類資源,就沒必要使用同一個對象鎖進行處理。

public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    public synchronized void addList1(String v){
        this.list1.add(v);
    }
    public synchronized void addList2(String v){
        this.list2.add(v);
    }
}
           

可以建立兩個不同的鎖,改善情況如下:

public class LockLessDemo {
    List<String> list1 = new ArrayList<>();
    List<String> list2 = new ArrayList<>();
    final Object lock1 = new Object();
    final Object lock2 = new Object();
    public void addList1(String v) {
        synchronized (lock1) {
            this.list1.add(v);
        }
    }
    public void addList2(String v) {
        synchronized (lock2) {
            this.list2.add(v);
        }
    }
}
           
  • 減少鎖持有時間

通過讓鎖資源盡快地釋放,減少鎖持有的時間,其他線程可更迅速地擷取鎖資源,進行其他業務的處理。

考慮到下面的代碼,由于 slowMethod 不在鎖的範圍内,占用的時間又比較長,可以把它移動到 Synchronized 代碼塊外面,加速鎖的釋放。

public class LockTimeDemo {
    List<String> list = new ArrayList<>();
    final Object lock = new Object();
    public void addList(String v) {
        synchronized (lock) {
            slowMethod();
            this.list.add(v);
        }
    }
    public void slowMethod(){
    }
}
           
  • 鎖分級

鎖分級,指的是我們文章開始講解的 Synchronied 鎖的鎖更新,屬于 JVM 的内部優化,它從偏向鎖開始,逐漸更新為輕量級鎖、重量級鎖,這個過程是不可逆的。

  • 鎖分離

我們在上面提到的讀寫鎖,就是鎖分離技術。這是因為,讀操作一般是不會對資源産生影響的,可以并發執行;寫操作和其他操作是互斥的,隻能排隊執行。是以讀寫鎖适合讀多寫少的場景。

  • 鎖消除

通過 JIT 編譯器,JVM 可以消除某些對象的加鎖操作。舉個例子,大家都知道StringBuffer 和 StringBuilder 都是做字元串拼接的,而且前者是線程安全的。

但其實,如果這兩個字元串拼接對象用在函數内,JVM 通過逃逸分析這個對象的作用範圍就是在本函數中,就會把鎖的影響給消除掉。

比如下面這段代碼,它和 StringBuilder 的效果是一樣的。

String m1(){
    StringBuffer sb = new StringBuffer();
    sb.append("");
    return sb.toString();
}
           

當然,對于讀多寫少的網際網路場景,最有效的做法,是使用樂觀鎖,甚至無鎖。

小結

Java 中有兩種加鎖方式:一種是使用 Synchronized 關鍵字,另外一種是 concurrent 包下面的 Lock。

我們詳細地了解了它們的一些特性,包括實作原理,其對比如下:

類别 Synchronized Lock
實作方式 monitor AQS
底層細節 JVM優化 Java API
分級鎖
功能特性 單一 豐富
鎖分離 讀寫鎖
鎖逾時 帶逾時時間的 tryLock
可中斷 lockInterruptibly

Lock 的功能是比 Synchronized 多的,能夠對線程行為進行更細粒度的控制。

但如果隻是用最簡單的鎖互斥功能,建議直接使用 Synchronized,有兩個原因:

  • Synchronized 的程式設計模型更加簡單,更易于使用
  • Synchronized 引入了偏向鎖,輕量級鎖等功能,能夠從 JVM 層進行優化,同時JIT 編譯器也會對它執行一些鎖消除動作。

我們還了解了公平鎖與非公平鎖,以及可重入鎖的概念,以及一些通用的優化技巧。有沖突,才會有優化空間,那麼無鎖隊列是怎麼回事呢?它又是怎麼實作的呢?後續我們會繼續講到。