Java 執行緒安全問題的本質

zhao發表於2020-12-09

原創宣告:作者:Arnold.zhao 部落格園地址:https://www.cnblogs.com/zh94

目錄:

執行緒安全問題的本質

出現執行緒安全的問題本質是因為:

主記憶體和工作記憶體資料不一致性以及編譯器重排序導致。

所以理解上述兩個問題的核心,對認知多執行緒的問題則具有很高的意義;

簡單理解CPU

CPU除了控制器、運算器等器件還有一個重要的部件就是暫存器。其中暫存器的作用就是進行資料的臨時儲存。

CPU的運算速度是非常快的,為了效能CPU在內部開闢一小塊臨時儲存區域,並在進行運算時先將資料從記憶體複製到這一小塊臨時儲存區域中,運算時就在這一小快臨時儲存區域內進行。我們稱這一小塊臨時儲存區域為暫存器。

CPU讀取指令是往記憶體裡面去讀取的,讀一條指令放到CPU中,CPU去執行,對記憶體的讀取速度比較慢,所以從記憶體讀取的速度去決定了這個CPU的執行速度的。所以無論我們的CPU怎麼去升級,但是如果這方面速度沒有解決的話,其的效能也不會得到多大的提升。

為了彌補這個缺陷,所以新增了快取記憶體的機制,如ARM A11的處理器,它的1級快取中的容量是64KB,2級快取中的容量是8M,
通過增加cpu快取記憶體的機制,以此彌補伺服器記憶體讀寫速度的效率問題;

JVM虛擬機器類比於作業系統

JVM虛擬計算機平臺就類似於一個作業系統的角色,所以在具體實現上JVM虛擬機器也的確是借鑑了很多作業系統的特點;

JAVA中執行緒的工作空間(working memory)就是CPU的暫存器和快取記憶體的抽象描述,cpu在計算的時候,並不總是從記憶體讀取資料,它的資料讀取順序優先順序 是:暫存器-快取記憶體-記憶體;
而在JAVA的記憶體模型中也是同等的,Java記憶體模型中規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體(類似於CPU的快取記憶體),執行緒的工作記憶體中儲存了該執行緒使用到的變數到主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數,操作完成後再將變數寫回主記憶體。不同執行緒之間無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要在主記憶體來完成。基本關係如下圖:

注意:這裡的Java記憶體模型,主記憶體、工作記憶體與Java記憶體區域模型的Java堆、棧、方法區不是同一層次記憶體劃分,這兩者基本上沒有關係。

重排序

在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排序。一般重排序可以分為如下三種:

舉例如下:

public class Singleton {
    public static  Singleton singleton;

    /**
     * 建構函式私有,禁止外部例項化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

如上,一個簡單的單例模式,按照物件的構造過程,例項化一個物件1、可以分為三個步驟(指令):
1、 分配記憶體空間。
2、 初始化物件。
3、 將記憶體空間的地址賦值給對應的引用。
但是由於作業系統可以對指令進行重排序,所以上面的過程也可能變為如下的過程:
1、 分配記憶體空間。
2、 將記憶體空間的地址賦值給對應的引用。
3、 初始化物件 。

所以,如果出現併發訪問getInstance()方法時,則可能會出現,執行緒二判斷singleton是否為空,此時由於當前該singleton已經分配了記憶體地址,但其實並沒有初始化物件,則會導致return 一個未初始化的物件引用暴露出來,以此可能會出現一些不可預料的程式碼異常;

當然,指令重排序的問題並非每次都會進行,在某些特殊的場景下,編譯器和處理器是不會進行重排序的,但上述的舉例場景則是大概率會出現指令重排序問題(關於指令重排序的概念後續給出詳細的地址)

原創宣告:作者:Arnold.zhao 部落格園地址:https://www.cnblogs.com/zh94

彙總

所以,如上可知,多執行緒在執行過程中,資料的不可見性,原子性,以及重排序所引起的指令有序性 三個問題基本是多執行緒併發問題的三個重要特性,也就是我們常說的:

併發的三大特性:原子性,有序性,可見性;

原子性:程式碼操作是否是原子操作(如:i++ 看似一個程式碼片段,實際的執行中將會分為三步執行,則必然是非原子化的操作,在多執行緒的場景中則會出現異常)
有序性:CPU執行程式碼指令時的有序性;
可見性:由於工作執行緒的記憶體與主記憶體的資料不同步,而導致的資料可見性問題;

一些解釋

但是,問題就真的有那麼複雜嗎?如果按照上面所說的問題,i++是非原子操作,就會出現併發異常的問題,new Object() 就會出現重排序的併發問題,那麼Java開發還能做嗎。。我隨便寫個方法程式碼,豈不是就會出現併發問題?但是為什麼我開發了這麼久的程式碼,也沒有出現過方法併發導致的異常問題啊?
燒的麻袋;
這裡就要說明另外一個問題,JVM的執行緒棧,JVM執行緒棧中是執行緒獨有的記憶體空間(如:程式計數器以執行緒棧幀)而執行緒棧幀中的區域性變數表則用來儲存當前所執行方法的基本資料型別(包含 reference, returnAddress等),所以當方法在被執行緒執行的過程中,相關的物件引用資訊,以及基本型別的資料都是執行緒獨有的,並不會出現多個執行緒訪問時的併發問題,也就是簡單來說:一個方法內的變數定義以及方法內的業務程式碼,是不會出現併發問題的。多個執行緒並不會共享一個方法內的變數資料,而是每個方法內的定義都屬於當前該執行執行緒的獨有棧空間中。(所以通過Java執行緒棧的這一獨特特性自然當中則為我們省了很多事項;)

但是由於我們的執行緒的資料操作不可能每次都去訪問主存中的資料,對於執行緒所使用到的變數需要copy至執行緒記憶體中以增加我們的執行速度,所以就引出了我們上述所提到的併發問題的本質問題,執行緒工作空間和主記憶體的資料不同步而導致的資料共享時的可見性問題;

如:此時定義一個簡單的類

class Person{
    int a = 1;
    int b = 2;

    public void change() {
        a = 3;
        b = a;
    }

    public void print() {
        String result = "b=" + b + ";a=" + a;
        System.out.println(result);
    }
    
    public static void main(String[] args) {
        while (true) {
            final Person test = new Person();
            new Thread(() -> {
                Thread.sleep(10);
                test.change();
            }).start();

            new Thread(() -> {
                Thread.sleep(10);
                test.print();
            }).start();
        }
    }
}

如上,假設此時多個執行緒同時訪問change()以及print() 方法,則可能會出現print所輸出的結果是:b=2;a=1或者b=3;a=3;這兩種都是正常現象,但還有可能是會輸出結果是:b=2;a=3以及b=3;a=1;

Person類所定義的變數a和b,按照JVM記憶體區域劃分,在物件例項化後則都是儲存到資料堆中;
按照我們上述關於執行緒工作記憶體的解釋來看,此時執行緒在執行change()方法和print()方法時,由於兩個方法都有關於外部變數的引用,所以需要copy主記憶體中的這兩個變數副本到對應的執行緒工作記憶體中進行操作,執行完以後再同步至主記憶體中。

此時在A執行緒執行完change()方法後,a=3,b=3;但此時a=3在執行完成後還沒有同步到主記憶體,但b=3此時已經提供至主記憶體了,那麼此時B執行緒執行print()資料輸出後,則得到的是結果是:b=3;a=1;同理也可以得到b=2;a=3的可能性結果;所以此處則由於執行緒共享變數的可見性問題,而導致了上述的問題;

正是由於存在上述所提到的執行緒併發所可能引起的種種問題,所以JDK則也有了後續的一系列多執行緒玩法:ThreadLocal,CountDownLatch,ReentrantLock,Unsafe,synchronized,volatile,Executor,Future 這些供開發者在開發程式時用來對多執行緒保駕護航的助手類,以及JDK已經自身開發好的支援執行緒安全的一些工具類,StringBuffer,CopyOnWriteArrayList, ConcurrentHashMap,AtomicInteger等,供開發者開箱即用;後續針對這些JDK自身所提供的一些類的玩法會做進一步說明,順便系統整理下腦中的資訊,形成有效的知識結構;End;

參考連結

寫到這裡可能你依然會對執行緒工作記憶體和主記憶體的同步機制比較感興趣,則可以參考這裡:

Java記憶體模型總結

如果對上述所提到的執行緒棧的區域性變數表等概念依然不是很清晰,則可以參考這裡:

JVM虛擬機器的記憶體區域分配

原創宣告:作者:Arnold.zhao 部落格園地址:https://www.cnblogs.com/zh94

相關文章