volatile、ThreadLocal的使用場景和原理

併發編程中的三個概念

  • 原子性
    一個或多個操作。要麼全部執行完成並且執行過程不會被打斷,要麼不執行。最常見的例子:i++/i--操作。不是原子性操作,如果不做好同步性就容易造成線程安全問題。

  • 可見性
    多個線程訪問同一個變量,一個線程改變了這個變量的值,其他線程可以立即看到修改的值。可見性的問題,有兩種方式保證。一是volatile關鍵字,二是通過synchronized和lock。詳細在後面。

  • 有序性
    程序執行的順序按照代碼的先後順序執行。

要了解有序性需要了解一下指令重排序。處理器爲了提供運行效率,會將代碼優化,不保證各個語句的執行順序,但會保證執行結果跟代碼順序執行一致,其不影響單線程的執行結果,但會影響線程併發執行的正確性。指令重排序會考慮指令之間的數據依賴性,如果一個指令B必須用到指令A的結果,那麼處理器會保證A在B之前執行。

要保證併發程序正確的執行,必須要保證原子性、可見性及有序性。只要有一個沒有被保證,就可能導致程序運行不正確。

Java內存模型

Java內存模型規定:所有變量存在主內存,每個線程有自己的工作內存。線程對變量的操作必須在工作內存進行,而不能直接對主內存進行操作。並且每個線程不能訪問其他線程的工作內存。

JAVA語言本身提供的對原子性、可見性及有序性的保證:

  • 原子性:java中,對於引用變量,和大部分的原始數據類型的讀寫(除long 和 double外)操作都是原子的。這些操作不可被中斷,要麼執行,要麼不執行。對於所有被聲明爲volatile的變量的讀寫,都是原子的(除long和double外)

  • 可見性:java提供了volatile關鍵字來保證可見性。

    當一個共享變量被volatile修飾時,它會保證修改的值立即被更新到主內存。其他線程讀取時會從內存中讀到新值。普通的共享變量不能保證可見性,其被寫入內存的時機不確定。當其他線程去讀,可能讀到的是舊的值。

    另外通過synchronized和lock也可以保證可見性。它們能保證同一時刻只有一個線程獲取鎖然後執行同步代碼。並在釋放鎖之前對變量的修改刷新到住內存中。以此來保證可見性

  • 有序性:java內存模型中,允許編譯器和處理器 對指令進行重排序。其會影響多線程併發執行的正確性。在java裏可以通過volatile關鍵字,還有synchronized和lock來保證有序性。

    synchronized和lock保證每個時刻只有一個線程執行同步代碼,使得線程串行化執行同步代碼,保證了有序性。volatile如何保證的講解在後面。

volatile

一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾後,就具備了兩層語義:保證了不同線程對這個變量進行操作時的可見性和禁止了指令重排序。

關於volatile保證可見性的原因我們上面已經講過了,現在來看看volatile通過禁止指令重排序來保證一定的有序性的意思:

1、當程序執行到volatile變量的讀操作或寫操作時,在其之前的操作的更改肯定全部已經進行,且結果對後面的操作可見。其後面的操作肯定還沒有進行

2、在進行指令優化時,不能將在volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放在其前面執行。

volatile關鍵字的原理和實現機制:

在加入volatile關鍵字時,會多出一個lock前綴指令。lock前綴指令相當於一個內存屏障,其提供三個功能。
  1、它會強制將對緩存的修改操作立即寫入主內存。
  2、如果是寫操作,它會導致其他CPU中對應的緩存行無效
  3、它確保指定重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面。即在執行到內存屏障這句指令時,在它前面的操作已經全部完成。
  • volatile關鍵字能保證可見性和一定的有序性,那它能保證對變量的操作是原子性嗎?

答案是不能的。如常見的自增操作是不具備原子性的,它包括讀取變量的原始值,進行加一操作,寫入工作內存三個子操作。這就導致進行自增時可能發生子操作被分割執行。

如某個時刻變量i=10。
線程A對i進行自增操作,在讀取i的原始值後被阻塞,
然後線程B對i進行自增,去讀取i的原始值。
由於A沒有對i進行修改,所以B在主內存中讀取到的是原始值並進行加1。然後把11寫入主內存。然後A對i進行操作。由於已經讀取了i的值,此時A的工作內存中i的值還是10,A對i進行自增加一後,把11寫入主內存。兩個線程分別進行了一次自增操作,但是結果卻是11。
在這裏插入圖片描述

要注意的是:volatile無法保證對變量的任何操作都是原子性的。

使用volatile關鍵字時必須具備兩個條件:

1、對變量的寫操作不依賴於當前值。

2、該變量沒有包含在具有其他變量的不變式中。

即保證操作是原子性操作,才能保證使用volatile關鍵字的程序在併發時能夠正確執行。

ThreadLocal

首先ThreadLocal 是一個線程的局部變量(其實就是一個Map),ThreadLocal會爲每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立地改變自己的副本,將對象的可見範圍限制在同一個線程內,而不會影響其它線程所對應的副本。
這樣做其實就是以空間換時間的方式(與synchronized相反),以耗費內存爲代價,單大大減少了線程同步(如synchronized)所帶來性能消耗以及減少了線程併發控制的複雜度。

ThreadLoca類中提供了幾個常用方法

 public T get() { }---獲取ThreadLocal在當前線程中保存的變量副本
    public void set(T value) { }---設置當前線程中變量的副本
    public void remove() { }---移除當前線程中變量的副本
    protected T initialValue() { }---protected修飾的方法。
    ThreadLocal提供的只是一個淺拷貝,如果變量是一個引用類型,那麼就要重寫該函數來實現深拷貝。建議在使用ThreadLocal一開始時就重寫該函數

ThreadLocal的設計初衷就是爲了避免多個線程去併發訪問同一個對象,儘管它是線程安全的。因此如果用普遍的方法,通過一個全局的線程安全的map來存儲多個線程的變量副本就違背了ThreadLocal的本意。在每個Thread中存放與它關聯的ThreadLocalMap是完全符合其設計思想的。當想對線程局部變量進行操作時,只要把Thread作爲key來獲取Thread中的ThreadLocalMap即可。這種設計相比採用一個全局map的方法會佔用很多內存空間,但其不需要額外採取鎖等線程同步方法而節省了時間上的消耗。

Synchronized卻正好相反,它用於在多個線程間通信時能夠獲得數據共享。即Synchronized用於線程間的數據共享,而ThreadLocal則用於線程間的數據隔離。所以ThreadLocal並不能代替synchronized,Synchronized的功能範圍更廣(同步機制)。

  • ThreadLocal中的內存泄露問題:

如果ThreadLocal被設置爲null後,並且沒有任何強引用指向它,根據垃圾回收的可達性分析算法,ThreadLocal將被回收。這樣的話,ThreadLocalMap中就會含有key爲null的Entry,而且ThreadLocalMap是在Thread中的,只要線程遲遲不結束,這些無法訪問到的value就會形成內存泄露。爲了解決這個問題,ThreadLocalMap中的getEntry()、set()和remove()函數都會清理key爲null的Entry,以下面的getEntry()函數爲例。

private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
}

要注意的是ThreadLocalMap的key是一個弱引用。在這裏我們分析一下強引用key和弱引用key的差別

強引用key:ThreadLocal被設置爲null,由於ThreadLocalMap持有ThreadLocal的強引用,如果不手動刪除,那麼ThreadLocal將不會回收,產生內存泄漏。

弱引用key:ThreadLocal被設置爲null,由於ThreadLocalMap持有ThreadLocal的弱引用,即便不手動刪除,ThreadLocal仍會被回收,ThreadLocalMap在之後調用set()、getEntry()和remove()函數時會清除所有key爲null的Entry。

ThreadLocalMap僅僅含有這些被動措施來補救內存泄露問題,如果在之後沒有調用ThreadLocalMap的set()、getEntry()和remove()函數的話,那麼仍然會存在內存泄漏問題。在使用線程池的情況下,如果不及時進行清理,內存泄漏問題事小,甚至還會產生程序邏輯上的問題。所以,爲了安全地使用ThreadLocal,必須要像每次使用完鎖就解鎖一樣,在每次使用完ThreadLocal後都要調用remove()來清理無用的Entry。

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