深入JVM(八)線程安全與鎖優化

併發處理的廣泛應用是使得Amdahl定律代替摩爾定律成爲計算機性能發展源動力的根本原因,也是人類“壓榨”計算機運算能力的最有力武器。

這幾天比較低沉。無論是天氣還是心情。不過今天在睡了一整天之後總算是“活”過來了。而且心態也調整過來了,決定把之前寫的這個深入JVM系列寫完。其實也不過剩下最後一章,就是本文的線程安全與鎖優化。這本書其實讀的沒有多精細,通篇讀下來還是有一部分是沒有理解的。然後我着重記憶的也都是考點,或者說經常提到或者用到的。我經常說自己是考試前夕的學生。頭懸梁錐刺股爲的也不過是及格。有時候挺討厭這樣的自己的。閒話少敘,看文章內容吧。

概述

書中概述的內容較多。從面向過程的編程思想到面向對象的編程思想。雖然我們的整體變成已經進步了很多。但是整體的思路是沒有變的。這句話我在以前也提過:先實現,再優化。
書中原文:

有時候,良好的設計原則不得不向現實做出一些讓步,我們必須讓程序在計算機正確無誤地運行,然後再考慮如何將代碼組織得更好,讓程序運行得更快。對於這部分的主題 “高效併發” 來將,首先需要保證併發的正確性,然後在此基礎上實現高效。本章先從如何保證併發的正確性和如何實現線程安全講起。

線程安全

“線程安全”這個名稱,很多人都會聽說過,甚至在戴拿編寫和走查的時候可能還會經常掛在嘴邊。但是如何找到一個不太拗口的概念來定義線程安全卻不是一個容易的事情。
網上的定義“如果一個對象可以安全的被多個線程同時使用,那麼他就是線程安全的”。在書中還有Brian Goetz對線程安全的定義:“當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調工作。調用這個對象的行爲都可以獲得正確的結果,那這個對象是線程安全的”。
其實總而言之,就是使用這個對象不用考慮多線程問題,更沒必要採取措施保證多線程的調用。這個對象就是線程安全的。
java語言中的線程安全
我們按照線程安全的“安全程度”由強到弱排序,分成五類:不可變,絕對線程安全,相對線程安全,線程兼容和線程對立。

  • 不可變: 在java語言中不可變的對象一定是線程安全的。無論是對象的方法實現還是方法的調用者,都不需要採取任何的線程安全保障措施。 java語言中,共享數據是一個基本數據類型,只要在定義時加上final關鍵字就可以保證它是不可變的。如果共享數據是一個對象,我們要保證對象的行爲不會對其狀態產生任何影響。(可以想想string類型。調用它的substring(),replace()等都不影響它本來的值)
    保證對象行爲不影響自己狀態的途徑有好多。最簡單的就是把對象的屬性都設置爲final(書中說的是把對象的帶有狀態的變量都聲明爲final。這樣在構造函數結束以後,他就是不可變的。我理解就是所有屬性都是final。如果我說錯了歡迎大家指出來)。
  • 絕對線程安全:絕對線程安全就能滿足上面我們對線程安全的定義。其實這個定義很嚴格的。在java API中很多標註自己是線程安全的類其實都不是絕對的線程安全。
    我們都知道java.util.Vector是一個線程安全的容器。因爲它的add(),get(),size()這類方法都是被synchronized修飾的,雖然這樣效率很低,但是確實是安全的。但是即使他的所有的方法都是同步的,也不意味着調用它的時候永遠都不需要同步手段了。
package demo;

import java.util.Vector;

public class VectorDemo {
    
    //首先創建一個vector的對象。
    private static Vector<Integer> vector = new Vector<Integer>();
     
    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });
            
            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println(vector.get(i));
                    }
                }
            });
            
            removeThread.start();
            printThread.start();
            
            //這個書上說線程過多會操作系統假死。
            while (Thread.activeCount() > 20);
        }
    }
}

講一下上面的代碼,我跑了不到五分鐘,然後報錯java.lang.ArrayIndexOutOfBoundsException。集合下標越界。其實就是刪除和打印最後衝突了。書中說的解決辦法就是把線程中的for循環前面加上線程鎖。改成如下這樣

package demo;

import java.util.Vector;

public class VectorDemo {
    
    //首先創建一個vector的對象。
    private static Vector<Integer> vector = new Vector<Integer>();
     
    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized(vector) {
                        for (int i = 0; i < vector.size(); i++) {
                            vector.remove(i);
                        }
                    }
                }
            });
            
            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized(vector) {
                        for (int i = 0; i < vector.size(); i++) {
                            System.out.println(vector.get(i));
                        }
                    }
                }
            });
            
            removeThread.start();
            printThread.start();
            
            //這個書上說線程過多會操作系統假死。
            while (Thread.activeCount() > 20);
        }
    }
}
  • 相對線程安全:相對線程安全才是我們通常意義上所講的線程安全。它需要保證對這個對象的單獨操作是線程安全的,我們不需要做額外的保證措施。但是對一些特定順序的連續調用,需要在調用段使用額外的同步手段來保證調用的正確性。上面vector的代碼就是這種。
  • 線程兼容:線程兼容值對象本身不是線程安全的,但是可以通過在調用端正確的使用同步手段來保證對象在併發環境中可以安全的使用。我們平常說的一個類不是線程安全的,絕大多數就是這種情況。比如ArrayList和HashMap。
  • 線程對立:指無論是夠採用同步措施,都無法在多線程環境中併發使用的代碼。
    比如Thread的suspend()和resume()方法,同時使用很容易產生死鎖。
    線程安全的實現方法
    互斥同步:互斥同步是一種常見的併發正確性保障手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一個線程使用。而互斥則是實現同步的一種手段。在這四個字中,互斥是因,同步是果。互斥是手段,同步是目的。
    java語言中最基本的互斥同步手段就是synchronized。
    synchronized有兩點需要注意:
    1. synchronized同步塊對同一個線程可重複入。
    2. synchronized同步塊在已進入的線程執行完畢之前會阻塞其他的線程。
      然後因爲線程阻塞或喚醒一個線程很消耗性能,所以 synchronized在java中是一個重量級的操作。

除了 synchronized以外,還可以用java.util.concurrent包中的重入鎖實現同步,基本用法上,ReentrantLock和 synchronized很相似。都具備同一個線程可重入。並且相比 synchronized,ReentrantLock增加了一些高級功能。主要有:

  1. 等待可中斷
    當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改爲處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
  2. 可實現公平鎖
    多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。
    synchronized 中的鎖是非公平的,ReentrantLock 默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。
  3. 鎖可以綁定多個條件
    一個 ReentrantLock 對象可以同時綁定多個 Condition 對象,而在 synchronized 中,鎖對象的 wait() 和 notify() 或 notifyAll() 方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外添加一個鎖,而 ReentrantLock 則無須這樣做,只需要多次調用 newCondition() 方法即可。
    (我個人感覺就是ReentrantLock 比較靈活,可中斷,可排隊,可有多個鎖條件)
    然後書中還有兩個鎖的性能對比。但是是jdk1.5和1.6的。然後書中也說了synchronized在不斷優化。1.6的時候兩者性能就持平了。我覺得目前的開發都是1.6以上,所以就不額外說這個了。
  • 非阻塞同步:互斥同步最主要的問題就是線程阻塞和喚醒帶來的性能問題。因此這個也叫做阻塞同步。從處理問題的方式來說,互斥同步屬於悲觀鎖。而接下來要說的則屬於樂觀鎖。
    通俗地說,就是先進行操作,如果沒有其他線程爭用共享數據,那操作就成功了;如果共享數據有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,知道成功爲止),這種樂觀的併發策略的許多實現都不需要把線程掛起,因此這種同步操作稱爲非阻塞同步。
    書中用到了以前volatile的例子(我再次貼出來防止大家忘記了。)。
package demo;

public class VolatileDemo {

    private static volatile int num = 0;

    public static void add() {
        num++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        for (Thread thread : threads) {
            thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        add();
                    }
                }
            });         
            thread.start();
        }
        
        while (Thread.activeCount()>1)      
            Thread.yield();
        System.out.println(num);
    }

}

當時運行的結果怎麼都不是20w。因爲num++這個操作不是原子性的。在jvm運行時會拆成三個指令。而我們如果想保證得到的是20w則要保證自增的原子性。用num.incrementAndGet()代替num++。
下面是incrementAndGet() 方法的 JDK 源碼:

    public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

incrementAndGet() 方法在一個無限循環中,不斷嘗試將一個比當前值大 1 的新值賦給自己。如果失敗了,那說明在執行 “獲取-設置” 操作的時候值已經有了修改,於是再次循環進行下一次操作,直到設置成功爲止。其實這個方法也有一個問題。一個變量初次讀取是A,檢查賦值的時候也是A。但是能確定它的值沒被改過?

  • 無同步方案:有一些類不需要同步就可以保證線程安全。
    簡單的介紹兩個類:
  1. 可重入代碼:也叫純代碼。就是任何時刻中斷執行別的代碼在控制權回來之後還可以正確運行。所有可重入代碼都是線程安全的。但是並非所有線程安全的代碼都是可重入的。
    可重入代碼有一些共同的特徵,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。我們可以通過一個簡單的原則來判斷代碼是否具備可重入性:如果一個方法,它的返回結果是可以預測的,只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。
  2. 線程本地存儲:如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
鎖優化
  • 自旋鎖與自適應自旋
    互斥同步最大的消耗是線程阻塞和喚醒。同時,虛擬機的開發團隊也注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得。
    如果物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程 “稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,我們只需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。
    自適應的自旋鎖。意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。這個具體的時間算法有點複雜,但是都由虛擬機實現,所有我這裏就不多說了。畢竟我自己看了幾遍都懵。這裏只是簡單的瞭解。
  • 鎖消除
    鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。這個其實我感覺是虛擬機的優化,和我們本身的關係。。就跟語法糖似的,知道不知道我們編寫代碼的時候都是差不多沒影響的。
  • 鎖粗化
    原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變小,如果存在鎖競爭,那等待鎖的線程也能儘快拿到鎖。
    大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體中,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。如果虛擬機探測到這種情況,會把加鎖同步的範圍擴展(粗化)到整個操作序列的外部。
  • 偏向鎖
    其實這個概念挺好玩的。
    偏向鎖的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是這個鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。
    打個比方,你父母就一個孩子,那麼什麼都是你的,眼裏心裏都是你。但是如果沒有二胎會一直這麼下去。但是假如有了二胎,這種偏心立刻沒了!這個鎖也是這樣。
    當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲 “01”,即偏向模式。持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行如何同步操作。當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向後恢復到未鎖定或輕量級鎖定的狀態。(ps:這裏有個輕量級鎖我沒寫。因爲看了半天還沒太理解。等我理解了再補充上來)

然後到此,深入理解java虛擬機這本書就看完了。其實心情很複雜,有着看完一本書的驕傲和自豪,也有着遺憾和可惜。我一直講敲代碼是樂趣。學習一些知識也是。但是如此的填鴨式學習,死記硬背加上刪刪減減,我自己都覺得是對知識的不尊重。可是然後呢?聊一下題外話,最近的面試遇到過各種各樣的問題,簡單的,基礎的,重要的,核心的,奇葩的,超預計的。也心裏不平衡過,覺得問的什麼狗屁問題!也自卑過,驚訝於自己很多基礎都不紮實,應該會的問題都沒說清。不過學海無涯。我覺得應該堅持一下自己的一直以來的態度:多學學多看看,總不會有壞處的。

最後~~~~全文手打不易。如果你覺得稍微幫到了你一點點,請點個喜歡點個關注。有不同意見或者問題的歡迎評論或者私信。祝大家工作生活都順順利利吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章