【精心總結】java內存模型和多線程必會知識

內存模型

(1)java內存模型到底是個啥子東西?

java內存模型是java虛擬機規範定義的一種特定模型,用以屏蔽不同硬件和操作系統的內存訪問差異,讓java在不同平臺中能達到一致的內存訪問效果,是在特定的協議下對特定的內存或高速緩存進行讀寫訪問的抽象。我來簡單的總結成一句話就是:java內存模型是java定義的對計算機內存資源(包含寄存器、高速緩存、主存等)的讀寫方法和規則。 注意上面定義是我個人的理解。
隨着我們計算機技術的不斷髮展,計算機的運算能力越來越強,cpu和存儲及通信子系統的速度差距越來越大,爲了避免將大量寶貴的計算資源浪費在數據庫查詢、網絡通信等IO操作上,現在多線程開發已經成了我們必需的技能。而多線程開發面臨的最大問題就是數據一致性問題,線程之間如何讀到各自的數據?線程之間如何進行交互?這些都是很重要的問題。另外編譯器在編譯程序時會自動對程序進行重排序,cpu在執行指令時也會通過指令亂序的方式來提高執行效率,高速緩存也會導致變量提交到內存的順序發生變化,同時不同處理器高速緩存中的數據互相不可見,這些都導致從一個線程看另一個線程,另一個線程的內存操作似乎在亂序執行。
爲了解決這些問題,java內存模型規定了一組最小保證,這組保證規定了對變量的寫入操作在何時對其他線程可見,同時也會保證在單線程環境中程序的執行結果與在嚴格串行環境中執行的結果相同(在本線程中好像順序執行一樣)。

(2)主內存和工作內存的規定

jvm虛擬機的主要目標是定義共享變量的訪問規則,java內存模型在設計時爲了保證性能在可預測性和易開發性間進行了平衡,它並沒有限制編譯器的重排序優化,也沒有限制執行引擎使用處理器的寄存器和緩存與主存進行交互,在跨線程的共享數據處理中,我們仍然需要使用合適的同步操作訪問共享數據。
在java內存模型中定義了“主內存”和“工作內存”兩個概念,我們可以將主內存類比爲我們計算中的內存,將工作內存類比爲我們cpu中的高速緩存和寄存器,但是實際上他們並不是等價關係。java內存模型規定:所有變量都儲存在主內存中,線程對變量的所有操作都必須在工作線程中,每個線程都有自己的工作內存,他們之間互相無法訪問,線程間的交互需要通過主內存。
在主內存和工作內存的基礎上,java內存模型定義了8個最基本的原子操作,用以處理主內存和工作內存的交互。

  1. lock:鎖定內存變量
  2. unlock:解鎖內存變量
  3. read:讀取主內存內的變量
  4. load:將read讀取的變量寫入到工作內存中
  5. use:從工作內存中讀取變量到執行引擎
  6. assign:將執行引擎的變量數據寫到工作內存中
  7. store:讀取工作內存的變量
  8. write:將工作內存中的變量寫入到主內存中
    在這裏插入圖片描述
(3)volatile的語義

在日常的開發中我們經常能聽大家談論volatile關鍵字,但實際上具體是如何實現的大部分人都不清楚,實際上原理並不複雜。volatile具備兩個關鍵的特性,一個是保證變量對所有線程的可見性,另一個是禁止指令重排序(包括cpu層面的指令亂序)。volatile抽象邏輯上通過“內存柵欄”實現,其使用的“柵欄”如下所示:

每個volatile寫操作前會插入StoreStore柵欄,寫操作後會插入StoreLoad柵欄
每個volatile讀操作前會插入LoadLoad柵欄,讀操作後會插入LoadStore柵欄

在字節碼層面,volatile通過lock指令實現,在volatile變量寫操作後會有一個lock addl ¥0x0, (%esp) 的命令,這個命令會將變量數據立即刷到主內存中,並利用cpu總線嗅探機制使其他線程高速緩存內的cacheline失效(cacheline是cpu高速緩存cache的基本讀寫單位),使用時必須重新到主內存Memory讀取。同時因爲需要立刻刷數據到內存中,那麼volatile變量操作前的所有操作都需要完全執行完成,這樣進而也保證了volatile變量寫操作前後不會出現重排序。通常volatile變量的讀寫效率和普通變量沒有多大差別,但在volatile變量併發訪問衝突非常頻繁的情況下可能造成性能的下降,具體的例子及解決方案可以百度“僞共享”問題。

(4)槓槓的先行發生原則

java內存模型主要是通過各種操作的定義實現的,包括內存變量的讀寫操作、監視器鎖定釋放、線程關閉啓動等等。java內存模型爲所有的這些操作定義了一套偏序關係,我們稱之爲先行發生規則(happens-before)。線程A要看到線程B的結果,那麼線程A和線程B必須滿足happens-before原則,如果不滿足就可能會出現重排序。下面是具體的規則:

  • 程序順序規則:在同一個線程內,按照代碼書寫順序,寫在前面的代碼一定先於後面的代碼執行。這種單線程代碼有序是jvm通過內存柵欄幫我們實現的。
  • 管程鎖定規則:同一個鎖,unlock一定發生在lock前
  • volatile規則:volatile的寫操作先行發生於讀操作
  • 線程啓動規則:線程start方法先行於線程內所有操作
  • 線程關閉規則:線程所有操作先行發生於線程關閉操作
  • 對象終結規則:對象構造操作先行發生於它的finalize()方法
  • 傳遞性:如果A先行發生於B,B現行發生於C,那麼A先行發行於C。

注意先行發生並不代表時間上的先後! 舉兩個小🌰
(1)函數A進行set操作,函數B進行get操作,即時時間上A先執行B後執行,B也不不一定能讀到A set的值,很可能剛好函數A指令還沒執行好,線程的時間片就沒了,然後B函數獲得了cpu時間片並執行完成,這時B函數根本讀不到A設置的值。
(2)另外即使是A操作先行發生於B操作,那麼A操作也不一定在時間上先於B操作執行,假如AB間沒有依賴關係,那麼很可能在時間上B先執行,因爲jvm只會幫我們保證最終的執行結果與嚴格順序執行的結果相同,不存在依賴關係的變量或操作間仍可能重排序優化。

線程安全

(1)線程安全的定義

在併發開發中,我們首先需要保證併發的正確性,然後在此基礎上實現高效代碼的開發。在日常開發中,我們通常會將能夠安全的被多個線程使用的對象稱爲線程安全對象,但這樣說可能仍不夠嚴謹,我們可以借用《java併發編程實戰》中的定義:當多個線程訪問一個對象時,如果不用考慮線程在運行時環境的調度和交替執行,也不需要額外的同步和調用方的操作協調,直接使用這個對象都能獲得正確的結果,這個對象就是線程安全的。

(2)Java中的線程安全級別

通常在java中我們可以將java按安全性強弱分爲幾個級別:不可變、絕對線程安全、相對線程安全、線程兼容、線程對立,接下來我們分別簡單的介紹下。

  • 不可變
    在java中不可變對象一定是線程安全的,線程安全是不可變對象的固有屬性之一,它們的不變條件是由構造函數創建的。我們需要注意的是java中目前沒有不可變對象的明確定義,一般情況下如果對象的所有狀態變量都是不可變的,那麼對象就是不可變對像(即使對象的所有域都爲final類型,對象也不一定是不可變的,因爲有些域是引用類型。當final修飾的引用類型時,只能保證引用地址是不變的,實際指向的對象仍然可能發生狀態變更)。
    另外經常會看見某些同學喜歡用final修飾局部變量,其實沒啥卵用。因爲class文件在設計時,對於局部變量和字段(實例變量、類變量)是區別對待的。字段在class中有access_flags屬性用來記錄字段的修飾符,例如final、static、private等。而局部變量是沒有這個屬性信息的,使不使用final修飾局部變量,在經過javac的編譯後生成的class文件是一模一樣的。
  • 絕對線程安全、相對線程安全
    絕對線程安全的實現通常需要付出非常大的代價,我們平時開發中聲明爲線程安全的類也並不是絕對線程安全的,而實際上指的是相對線程安全。
  • 線程兼容
    實際上我們通常說的線程不安全對象,例如HashMap、ArrayList等,其實在java中都是被定義爲線程兼容類型,指我們可以通過額外的同步操作保證線程安全。
  • 線程對立
    指無論調用是否採用同步措施,都無法併發使用,比如舊版本中的suspend()和 resume()方法。
(3)保障線程安全的一些措施
  • 阻塞同步:
    synchronized的語義
    在併發編程中,我們最常用的同步手段就是synchronized,synchronized是java提供的可以保證原子性的內置鎖,是具有排他性的可重入鎖,同一個線程可以多次使用已經獲得的synchronized鎖。
    synchronized的底層實現依賴於jvm用C++實現的管程(ObjectMonitor),管程是一種類似於信號量的程序結構,它封裝了同步操作並對進程隱蔽了同步細節。其整體實現邏輯和ReentrantLock很相似,大致的結構原理可以參見:Synchronized之管程
    我們在使用synchronized時通常有兩種方式:1.修飾方法、2.修飾代碼塊,其實兩者差別不大,本質上都是同步代碼塊。在虛擬機層面上,當用synchronized修飾方法時,class文件中會在方法表中爲相應方法增加ACC_SYNCHRONIZED訪問標誌,用以標識該方法爲同步方法。而當用synchronized修飾代碼塊時,會在相應代碼段字節碼的前後分別插入monitorenter和monitorexit字節碼指令,用以表示該段代碼需要同步。
    當線程執行到相應的方法或代碼段時,需要先獲取對象的鎖。如果對象沒有被鎖定或當前線程已經擁有了該鎖,則將鎖計數器的值加1然後執行代碼,相應的在退出方法或代碼段時,需將計數器的值減1。而如果獲取鎖失敗,則當前線程就需要阻塞等待,直到鎖被釋放。
    由於java線程是通過映射到內核線程實現的,線程的掛起和喚醒都需要操作系統的幫助,需要從用戶態切換到內核態,需要耗費很多cpu資源,所以synchronized相對而言是一種比較重的鎖(不過隨着不斷優化,jvm通過自適應自旋、鎖消除/鎖粗化、鎖升級等逐漸讓synchronized顯得沒那麼重了),需要在合適的場景恰當的使用。
    個人經驗之談:在使用synchronized時,我們可以把synchronized修飾的方法或代碼段想象成一段不可以併發訪問的臨界區資源,這種資源必須獨佔使用。而如何實現獨佔訪問呢?我們可以想象每個對象都有把獨佔鎖,我們需要藉助某個對象的獨佔鎖來訪問這種臨界區資源,而同一個鎖某個時刻只能被一個線程所獲取,其他線程都得等待鎖的釋放。用synchronized修飾的實例方法(public synchronized void method())默認使用當前對象(this)的鎖,而用synchronized修飾的靜態方法(public synchronized static void method())默認使用當前對象對應的Class對象鎖,他們分別對應於synchronized修飾代碼塊中的synchronized(object)和synchronized(Object.class)。Class對象存在於方法區中,具有全局唯一性,在一個jvm實例中一個Class對象只有一把鎖,所有使用該Class對象作爲鎖的靜態方法或代碼塊,執行前都必須先獲得該Class對象鎖。而同一個Class可以有很多實例對象,每個實例對象都有一個自己的鎖,使用實例對象A鎖的線程和使用實例對象B鎖的線程間不存在競爭關係。
    除了synchronized外,DougLea也幫我們實現了ReentrantLock,它和synchronized功能和實現邏輯都基本相似,不過提供更多個性化的功能,大家有時間可以學習下。
  • 非阻塞同步:
    從概念模型角度出發,我們可以認爲阻塞同步是一種悲觀的併發策略,無論是否存在併發競爭,都需要先加鎖後進行操作。而非阻塞同步是一種基於衝突檢測的樂觀併發測策略,它會先執行相關操作,在提交階段才進行衝突檢測,如果存在衝突再進行相應補償,這種併發策略大部分時候不需要將線程掛起。
    最經典的非阻塞同步就是CAS,就像它的名字compare and swap一樣,在提交數據前它會比較數據版本是否正確,以決定是否提交數據。我們java API中有大量的CAS應用,譬如AQS:它會維護一個volatile state變量和一個雙向鏈表,通過CAS操作state變量,然後根據state變量的值決定線程是掛起放入雙向鏈表中還是獲得執行權。
  • 無同步方案:
    除了上面兩種同步方案外,我們還有很多代碼是無狀態的、本身並不需要同步的,我一般稱這種代碼爲純代碼。這種代碼無任何狀態,它們不依賴堆中數據和公用的系統資源,也是線程安全的。
    另外如果如果我們能將共享數據的可見範圍限制在同一個線程範圍內,那麼無需同步也能保證線程間不出現數據爭用問題。我們可以通過java.lang.ThreadLocal類來實現線程本地存儲的功能。
    ThreadLocal實現介紹
    ThreadLocal的實現並不算複雜,首先每個線程Thread對象都維護了一個ThreadLocalMap,這個Map是由ThreadLocal類實現的一個使用線性探測的自定義Map,Map的key是ThreadLocal對象的引用,而value就是我們需要存儲的本地線程變量。
    如下所示當我們使用ThreadLocal時,需先new一個ThreadLocal對象threadLocalA,當使用threadLocalA保存本地線程變量(“東哥真帥!”)時,會先獲取當前Thread對象中的ThreadLocalMap,然後將對象threadLocalA的引用作爲key,本地線程變量(“東哥真帥!”)作爲value存進ThreadLocalMap中。
   // ThreadLocal使用方式
   ThreadLocal<String> threadLocalA = new ThreadLocal<>();
   threadLocal.set("東哥真帥!");
   
   // ThreadLocal.set源碼
   public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    
    
   // ThreadLocalMap.set部分源碼
   private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ........
            }
        }
    ........
    
 
    // ThreadLocalMap類部分源碼
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
            ........
        }
    ........

值得注意的是ThreadLocalMap並沒有使用拉鍊法,而是使用了線性探測法,並且爲了提高key的離散度/減少key衝突,沒有使用對象自身的HashCode,而是使用了自定義的threadLocalHashCode。
另外還有一點非常重要:ThreadLocalMap的key被封裝成了弱引用。當ThreadLocal對象threadLocalA沒有其他強引用時,在下次GC來臨時threadLocalA就會被回收,同時ThreadLocalMap相應槽位的key值會變爲null,ThreadLocalMap在每次進行get/set操作時都會主動的去清空key爲null的鍵值對。ThreadLocal的這種設計主要是爲了防止出現內存泄露。假如key爲強引用,那麼當threadLocalA使用完後,ThreadLocalMap仍持有threadLocalA的強引用,將會導致threadLocalA無法回收。
在這裏插入圖片描述
順便提下java中的幾種引用類型,主要有強引用、軟引用、弱引用、虛引用。相關的知識可以參見:Java 的強引用、弱引用、軟引用、虛引用

  • 強引用(StrongReference):強引用就是平時我們new出來的對象引用,當對象生命週期結束時纔會被回收。
  • 軟引用(SoftReference):軟引用在內存空間不足時會被GC回收,內存充足時不會被回收。
  • 弱引用(WeakReference):弱引用只能活到下次GC到來。
  • 虛引用(PhantomReference):虛引用就相當於沒有引用,但虛引用會綁定一個ReferenceQueue引用隊列,當對象被回收時相關聯的虛引用就會被放入ReferenceQueue引用隊列中,可以用來釋放特定的資源。比如我們可以把jdbcConnection封裝成虛引用,同時虛引用中記錄jdbcConnection使用的堆外內存數據。當jdbcConnection被回收時,我們就可以在ReferenceQueue引用隊列通過虛引用去主動釋放堆外內存數據。
(4)鎖競爭優化方案

在併發程序中,對伸縮性的最主要威脅就是獨佔方式的資源鎖。在獨佔鎖上發生競爭將導致線程操作串行化和大量上線文切換,所以儘量降低和減少鎖的競爭可以提升性能以及提高程序的可伸縮性。影響鎖競爭的兩個最重要的因素是:1.鎖的請求頻率,2.鎖的持有時間。接下來介紹幾種減少鎖競爭的方案。

  1. 縮小鎖的範圍(快進快出)
    將一些與鎖無關的代碼移除同步代碼塊,尤其是時間開銷比較大的操作,可以有效的縮短鎖的持有時間,進而降低鎖競爭。
  2. 鎖分解
    如果一個鎖用來保護多個相互獨立的狀態變量,那麼可以將這個鎖分解爲多個鎖,每個鎖只保護一個變量,這樣就能夠降低每個鎖的請求頻率。進而提高程序的可伸縮性。
    舉個阻塞隊列的例子:大部分的阻塞隊列都會有個隊列,生產者線程池不停生產數據到隊列中,當隊列滿了就阻塞生產者線程,而生產者線程池不停消費隊列中的數據,當隊列空了就阻塞消費者線程,這時我們可以使用一個全局的ReetrantLock.Condition用於阻塞生產者線程或者消費者線程,但更好的方案是使用兩個ReetrantLock.Condition分別負責阻塞生產者線程和消費者線程,這實際上就是一種鎖分解。如下爲LinkedBlockingQueue的部分代碼,其中notEmpty和notFull兩個條件鎖的使用實際上體現的就是鎖分解的思想。
    private final ReentrantLock takeLock = new ReentrantLock();
    private final Condition notEmpty = takeLock.newCondition();

    private final ReentrantLock putLock = new ReentrantLock();
    private final Condition notFull = putLock.newCondition();

    public boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException {

        if (e == null) throw new NullPointerException();
        long nanos = unit.toNanos(timeout);
        int c = -1;
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                if (nanos <= 0)
                    return false;
                nanos = notFull.awaitNanos(nanos);
            }
            enqueue(new Node<E>(e));
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
        return true;
    }
  1. 鎖分段
    鎖分解是利用系統內相互獨立的狀態變量來進行鎖的拆分,但大部分系統中相互獨立的狀態變量並不多,當鎖的競爭非常激烈時,這種拆分的性能提升是有限的。在某些情況下,我們可以對系統內一組獨立對象上的鎖進行拆分,這種拆分的方式被稱爲鎖分段。一個最經典的例子就是舊版的ConcurrentHashMap,在舊版ConcurrentHashMap的實現中使用了包含16個鎖的數組,每個鎖保護散列桶的16分之1,這其實體現的就是鎖分段的思想。新版ConcurrentHashMap分的就更細了,每個桶都有一個鎖,具體的細節大家有時間可以去學習下。
    舊版的ConcurrentHashMap
    大家有沒有感覺到,鎖分解有點類似於分庫,是水平的,而鎖分段有點像分表,是垂直的,看來優秀的設計思想都很相似。
  2. 避免使用獨佔鎖
    在業務允許的情況下,我們也可以通過避免使用獨佔鎖來降低鎖的競爭。例如,在讀取操作比較多的時候,我們可以用ReadWriteLock來代替ReetrantLock,這樣能提供更高的併發性和性能。對於一些訪問頻率非常高的熱點變量數據,我們可以使用原子變量來操作,也可以用volatile+CAS來代替,這些都能夠有效的提升系統性能。

繼續加油!繼續努力!不斷成長!不斷進步!
在這裏插入圖片描述

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