天天看點

五分鐘帶你玩轉多線程(五)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()函數為例。

1. private Entry getEntry(ThreadLocal<?> key) {
2. int i = key.threadLocalHashCode & (table.length - 1);
3.             Entry e = table[i];
4. if (e != null && e.get() == key)
5. return e;
6. else
7. return getEntryAfterMiss(key, i, e);
8. }
9. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
10.             Entry[] tab = table;
11. int len = tab.length;
12. 
13. while (e != null) {
14.                 ThreadLocal<?> k = e.get();
15. if (k == key)
16. return e;
17. if (k == null)
18.                     expungeStaleEntry(i);
19. else
20.                     i = nextIndex(i, len);
21.                 e = tab[i];
22.             }
23. }
24.      

要注意的是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。

總結:

1.threadLocal是用于解決多線程共享類的成員變量,原理:在每個線程中都存有一個本地ThreadMap,相當于存了一個對象的副本,key為threadlocal對象本身,value為需要存儲的對象值,這樣各個線程之間對于某個成員變量都有自己的副本,不會沖突。用空間去換時間

2.使用volatile關鍵字的時候,該變量一旦被修改,會立即寫入到主存中,同時會讓其他線程的工作記憶體中的緩存失效,這樣,其他線程在通路該變量的時候會重新從主存中讀取可以獲得該變量最新的資料,進而保證的變量的可見性。而volatile首先保證前面的任務都完成,保證後面的任務在現有任務之後。

3. volatile隻能用于原子性的操作。如:i++,i=x;這種都不屬于原子性的操作,i++有三個步驟,先讀取記憶體中i的值,然後執行i+1操作,然後把結果寫回i,這樣的操作不屬于原子性的。