淺談自旋鎖和 JVM 對鎖的最佳化

PHP定製開發發表於2022-09-22

背景

先上圖

淺談自旋鎖和 JVM 對鎖的最佳化

由此可見,非自旋鎖如果拿不到鎖會把執行緒阻塞,直到被喚醒;自旋鎖拿不到鎖會一直嘗試

為什麼要這樣?

好處

阻塞和喚醒執行緒都是需要高昂的開銷的,如果同步程式碼塊中的內容不復雜,那麼可能轉換執行緒帶來的開銷比實際業務程式碼執行的開銷還要大。

在很多場景下,可能我們的同步程式碼塊的內容並不多,所以需要的執行時間也很短,如果我們僅僅為了這點時間就去切換執行緒狀態,那麼其實不如讓執行緒不切換狀態,而是讓它自旋地嘗試獲取鎖,等待其他執行緒釋放鎖,有時我只需要稍等一下, 就可以避免上下文切換等開銷,提高了效率。

用一句話總結自旋鎖的好處,那就是自旋鎖用迴圈去不停地嘗試獲取鎖,讓執行緒始終處於 Runnable 狀態,節省了執行緒狀態切換帶來的開銷。

AtomicLong 的實現

getAndIncrement 方法

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}複製程式碼
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);
        //如果修改過程中遇到其他執行緒競爭導致沒修改成功,死迴圈,直到修改成功為止
    } while (!compareAndSwapLong(o, offset, v, v + delta));
    return v;
}複製程式碼

實驗

package com.reflect;import java.util.concurrent.atomic.AtomicReference;class ReentrantSpinLock {
    private AtomicReference<Thread> owner = new AtomicReference<>();
    private int count = 0;
    public void lock() {
        Thread t = Thread.currentThread();
        if (t == owner.get()) {
            ++count;
            return;
        }
        while (!owner.compareAndSet(null, t)) {
            System.out.println("自旋了");
        }
    }
    public void unlock() {
        Thread t = Thread.currentThread();
        if (t == owner.get()) {
            if (count > 0) {
                --count;
            } else {
                owner.set(null);
            }
        }
    }
    public static void main(String[] args) {
        ReentrantSpinLock spinLock = new ReentrantSpinLock();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "開始嘗試獲 取自旋鎖");
                spinLock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + "獲取到 了自旋鎖");
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    spinLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "釋放了 了自旋鎖");
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}複製程式碼

很多 "自旋了",說明自旋期間 CPU 依然在不停運轉

缺點

雖然避免了執行緒切換的開銷,但是在避免執行緒切換開銷的同時帶來新的開銷:不停嘗試獲取鎖,如果這個鎖一直不能被釋放那麼這種嘗試知識無用的嘗試,浪費處理器資源, 就是說一開始自旋鎖開銷低於執行緒切換,但是隨著時間增加,這種開銷後期甚至超過執行緒切換的開銷,得不償失

適用場景

  • 併發不是特別高的場景
  • 臨界區比較短小的情況,利用避免執行緒切換提高效率

如果臨界區很大,執行緒拿到鎖很久才釋放,那自旋會一直佔用 CPU 但無法拿到鎖,浪費資源

JVM 對鎖做了哪些最佳化?

相比於 JDK 1.5,在 JDK 1.6 中 HotSopt 虛擬機器對 synchronized 內建鎖的效能進行了很多最佳化,包括 自適應的自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖等。有了這些最佳化措施後,synchronized 鎖的效能得到了大幅提高,下面我們分別介紹這些具體的最佳化。

自適應的自旋鎖

在 JDK 1.6 中引入了自適應的自旋鎖來解決長時間自旋的問題。自適應意味著自旋的時間不再固定,而是會根據最近自旋嘗試的成功率、失敗率,以及當前鎖的擁有者的狀態等多種因素來共同決定。自旋的持續時間是變化的,自旋鎖變 “聰明” 了。比如,如果最近嘗試自旋獲取某一把鎖成功了,那麼下一次可能還會繼續使用自旋,並且允許自旋更長的時間;但是如果最近自旋獲取某一把鎖失敗了,那麼可能會省略掉自旋的過程,以便減少無用的自旋,提高效率。

鎖消除

public class Person {
    private String name;
    private int age;
    public Person(String personName, int personAge) {
        name = personName;
        age = personAge;
    }
    public Person(Person p) {
        this(p.getName(), p.getAge());
    }
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
}class Employee {
    private Person person;
    public Person getPerson() {
        return new Person(person);
    }
    public void printEmployeeDetail(Employee emp) {
        Person person = emp.getPerson();
        System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
    }
}複製程式碼

在這段程式碼中,我們看到下方的 Employee 類中的 getPerson () 方法,這個方法中使用了類裡面的 person 物件,並且新建一個和它屬性完全相同的新的 person 物件,目的是防止方法呼叫者修改原來的 person 物件。但是在這個例子中,其實是沒有任何必要新建物件的,因為我們的 printEmployeeDetail () 方法沒有對這個物件做出任何的修改,僅僅是列印,既然如此,我們其實可以直接列印最開始的 person 物件,而無須新建一個新的。

如果編譯器可以確定最開始的 person 物件不會被修改的話,它可能會最佳化並且消除這個新建 person 的過程。根據這樣的思想,接下來我們就來舉一個鎖消除的例子,,經過逃逸分析之後,如果發現某些物件不可能被其他執行緒訪問到,那麼就可以把它們當成棧上資料,棧上資料由於只有本執行緒可以訪問,自然是執行緒安全的,也就無需加鎖,所以會把這樣的鎖給自動去除掉。

例如,我們的 StringBuffffer 的 append 方法如下所示:

@Overridepublic synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}複製程式碼

從程式碼中可以看出,這個方法是被 synchronized 修飾的同步方法,因為它可能會被多個執行緒同時使用。

但是在大多數情況下,它只會在一個執行緒內被使用,如果編譯器能確定這個 StringBuffffer 物件只會在一個執行緒內被使用,就代表肯定是執行緒安全的,那麼我們的編譯器便會做出最佳化,把對應的 synchronized 給消除,省去加鎖和解鎖的操作,以便增加整體的效率。

鎖粗化

釋放了鎖,緊接著什麼都沒做,又重新獲取鎖

public void lockCoarsening() { 
    synchronized (this) { 
    } 
    synchronized (this) { 
    } 
    synchronized (this) { 
    } 
}複製程式碼

那麼其實這種釋放和重新獲取鎖是完全沒有必要的,如果我們把同步區域擴大,也就是隻在最開始加一次鎖,並且在最後直接解鎖,那麼就可以把中間這些無意義的解鎖和加鎖的過程消除,相當於是把幾個 synchronized 塊合併為一個較大的同步塊。這樣做的好處在於線上程執行這些程式碼時,就無須頻繁申請與釋放鎖了,這樣就減少了效能開銷。

不過,我們這樣做也有一個 副作用,那就是我們會讓同步區域變大。如果在迴圈中我們也這樣做,如程式碼所示:

for (int i = 0; i < 1000; i++) { 
    synchronized (this) { 
    } 
}複製程式碼

也就是我們在第一次迴圈的開始,就開始擴大同步區域並持有鎖,直到最後一次迴圈結束,才結束同步程式碼塊釋放鎖的話,這就會導致其他執行緒長時間無法獲得鎖。所以,這裡的鎖粗化不適用於迴圈的場景,僅適用於非迴圈的場景。

鎖粗化功能是預設開啟的,用 -XX:-EliminateLocks 可以關閉該功能

偏向鎖 / 輕量級鎖 / 重量級鎖

這三種鎖是特指 synchronized 鎖的狀態,透過在物件頭中的 mark word 來表明鎖的狀態

  • 偏向鎖

對於偏向鎖而言,它的思想是如果自始至終,對於這把鎖都不存在競爭,那麼其實就沒必要上鎖,只要打個標記就行了。 一個物件在被初始化後,如果還沒有任何執行緒來獲取它的鎖時,它就是可偏向的,當有第一個執行緒來訪問它嘗試獲取鎖的時候,它就記錄下來這個執行緒,如果後面嘗試獲取鎖的執行緒正是這個偏向鎖的擁有者,就可以直接獲取鎖,開銷很小。

  • 輕量級鎖

JVM 的開發者發現在很多情況下,synchronized 中的程式碼塊是被多個執行緒交替執行的,也就是說,並不存在實際的競爭,或者是隻有短時間的鎖競爭,用 CAS 就可以解決。這種情況下,重量級鎖是沒必要的。輕量級鎖指當鎖原來是偏向鎖的時候,被另一個執行緒所訪問,說明存在競爭,那麼偏向鎖就會升級為輕量級鎖,執行緒會透過自旋的方式嘗試獲取鎖,不會阻塞

  • 重量級鎖

這種鎖利用作業系統的同步機制實現,所以開銷比較大。當多個執行緒直接有實際競爭,並且鎖競爭時間比較長的時候,此時偏向鎖和輕量級鎖都不能滿足需求,鎖就會膨脹為重量級鎖。重量級鎖會讓其他申請卻拿不到鎖的執行緒進入阻塞狀態。

鎖升級

偏向鎖效能最好,避免了 CAS 操作。而輕量級鎖利用自旋和 CAS 避免了重量級鎖帶來的執行緒阻塞和喚醒,效能中等。重量級鎖則會把獲取不到鎖的執行緒阻塞,效能最差。

淺談自旋鎖和 JVM 對鎖的最佳化

JVM 預設會優先使用偏向鎖,如果有必要的話才逐步升級,這大幅提高了鎖的效能


完整附件: 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70021881/viewspace-2915850/,如需轉載,請註明出處,否則將追究法律責任。

相關文章