併發程式設計之ThreadLocal、Volatile、synchronized、Atomic關鍵字

逐夢小生發表於2020-07-09

前言

對於ThreadLocal、Volatile、synchronized、Atomic這四個關鍵字,我想一提及到大家肯定都想到的是解決在多執行緒併發環境下資源的共享問題,但是要細說每一個的特點、區別、應用場景、內部實現等,卻可能模糊不清,說不出個所以然來,所以,本文就對這幾個關鍵字做一些作用、特點、實現上的講解。

1、Atomic

作用:

對於原子操作類,Java的concurrent併發包中主要為我們提供了這麼幾個常用的:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference。
對於原子操作類,最大的特點是在多執行緒併發操作同一個資源的情況下,使用Lock-Free演算法來替代鎖,這樣開銷小、速度快,對於原子操作類是採用原子操作指令實現的,從而可以保證操作的原子性。

什麼是原子性?

比如一個操作i++;實際上這是三個原子操作,先把i的值讀取、然後修改(+1)、最後寫入給i。所以使用Atomic原子類運算元,比如:i++;那麼它會在這步操作都完成情況下才允許其它執行緒再對它進行操作,而這個實現則是通過Lock-Free+原子操作指令來確定的.

如:AtomicInteger類中:

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

而關於Lock-Free演算法,則是一種新的策略替代鎖來保證資源在併發時的完整性的,Lock-Free的實現有三步:

  • 1、迴圈(for(;;)、while)
  • 2、CAS(CompareAndSet)
  • 3、回退(return、break)

用法
比如在多個執行緒操作一個count變數的情況下,則可以把count定義為AtomicInteger,如下:

public class Counter {  

    private AtomicInteger count = new AtomicInteger(); 
    
    public int getCount() {   
        return count.get();    
    }    
    
    public void increment() {  
        count.incrementAndGet();   
    }
  }

在每個執行緒中通過increment()來對count進行計數增加的操作,或者其它一些操作。這樣每個執行緒訪問到的將是安全、完整的count。

內部實現

採用Lock-Free演算法替代鎖+原子操作指令實現併發情況下資源的安全、完整、一致性;

2、Volatile

作用

Volatile可以看做是一個輕量級的synchronized,它可以在多執行緒併發的情況下保證變數的“可見性”;

什麼是可見性?

就是在一個執行緒的工作記憶體中修改了該變數的值,該變數的值立即能回顯到主記憶體中,從而保證所有的執行緒看到這個變數的值是一致的。所以在處理同步問題上它大顯作用,而且它的開銷比synchronized小、使用成本更低。

舉個例子:在寫單例模式中,除了用靜態內部類外,還有一種寫法也非常受歡迎,就是Volatile+DCL:

public class Singleton { 

 private static volatile Singleton instance;  
  
 private Singleton() {  
 }  
  
 public static Singleton getInstance() {  
     if (instance == null) {  
        synchronized (Singleton.class) {  
         if (instance == null) {  
            instance = new Singleton();  
         }  
       }  
     }  
    return instance;  
    }  
}

這樣單例不管在哪個執行緒中建立的,所有執行緒都是共享這個單例的。

雖說這個Volatile關鍵字可以解決多執行緒環境下的同步問題,不過這也是相對的,因為它不具有操作的原子性,也就是它不適合在對該變數的寫操作依賴於變數本身自己。舉個最簡單的栗子:在進行計數操作時count++,實際是count=count+1;,count最終的值依賴於它本身的值。所以使用volatile修飾的變數在進行這麼一系列的操作的時候,就有併發的問題;

舉個例子:

  • 因為它不具有操作的原子性,有可能1號執行緒在即將進行寫操作時count值為4;而2號執行緒就恰好獲取了寫操作之前的值4,所以1號執行緒在完成它的寫操作後count值就為5了,而在2號執行緒中count的值還為4,即使2號執行緒已經完成了寫操作count還是為5,而我們期望的是count最終為6,所以這樣就有併發的問題。
  • 而如果count換成這樣:count=num+1;假設num是同步的,那麼這樣count就沒有併發的問題的,只要最終的值不依賴自己本身。

用法

因為volatile不具有操作的原子性,所以如果用volatile修飾的變數在進行依賴於它自身的操作時,就有併發問題,如:count,像下面這樣寫在併發環境中是達不到任何效果的:
public class Counter {  
 private volatile int count;  

 public int getCount(){  
 return count;  
 }  
 public void increment(){  
 count++;  
 }  
}

而要想count能在併發環境中保持資料的一致性,則可以在increment()中加synchronized同步鎖修飾,改進後的為:

public class Counter {  
 private volatile int count;  
  
 public int getCount(){  
 return count;  
 }  
 public synchronized void increment(){  
 count++;  
 }  
}

3、synchronized

作用

synchronized叫做同步鎖,是Lock的一個簡化版本,由於是簡化版本,那麼效能肯定是不如Lock的,不過它操作起來方便,只需要在一個方法或把需要同步的程式碼塊包裝在它內部,那麼這段程式碼就是同步的了,所有執行緒對這塊區域的程式碼訪問必須先持有鎖才能進入,否則則攔截在外面等待正在持有鎖的執行緒處理完畢再獲取鎖進入,正因為它基於這種阻塞的策略,所以它的效能不太好,但是由於操作上的優勢,只需要簡單的宣告一下即可,而且被它宣告的程式碼塊也是具有操作的原子性。

用法

 
 public synchronized void increment(){  
    count++;  
 }  
  
 public void increment(){  
     //同步程式碼塊
     synchronized (Counte.class){  
         count++;  
     }  
 }

內部實現
重入鎖ReentrantLock+一個Condition,所以說是Lock的簡化版本,因為一個Lock往往可以對應多個Condition;

4、ThreadLocal

作用

  • 關於ThreadLocal,這個類的出現並不是用來解決在多執行緒併發環境下資源的共享問題的,它和其它三個關鍵字不一樣,其它三個關鍵字都是從執行緒外來保證變數的一致性,這樣使得多個執行緒訪問的變數具有一致性,可以更好的體現出資源的共享。
  • 而ThreadLocal的設計,並不是解決資源共享的問題,而是用來提供執行緒內的區域性變數,這樣每個執行緒都自己管理自己的區域性變數,別的執行緒操作的資料不會對我產生影響,互不影響,所以不存在解決資源共享這麼一說,如果是解決資源共享,那麼其它執行緒操作的結果必然我需要獲取到,而ThreadLocal則是自己管理自己的,相當於封裝在Thread內部了,供執行緒自己管理。

用法

一般使用ThreadLocal,官方建議我們定義為private static ,至於為什麼要定義成靜態的,這和記憶體洩露有關,後面再講。
它有三個暴露的方法,set、get、remove。
public class ThreadLocalDemo {  
 
 private static ThreadLocal<String> threadLocal = new ThreadLocal<String>(){  
     @Override  
     protected String initialValue() {  
         return "hello";  
     }  
 };  
 
 static class MyRunnable implements Runnable{  
     private int num;  
     
     public MyRunnable(int num){  
         this.num = num;  
     }  
     @Override  
     public void run() {  
         threadLocal.set(String.valueOf(num));  
         System.out.println("threadLocalValue:"+threadLocal.get()); 
         //手動移除
         threadLocal.remove();
     }  
 }  
  
 public static void main(String[] args){  
     new Thread(new MyRunnable(1)).start();  
     new Thread(new MyRunnable(2)).start();  
     new Thread(new MyRunnable(3)).start();  
 }  
 
}

執行結果如下,這些ThreadLocal變數屬於執行緒內部管理的,互不影響:

threadLocalValue:1  
threadLocalValue:2  
threadLocalValue:3

對於get方法,在ThreadLocal沒有set值得情況下,預設返回null,所有如果要有一個初始值我們可以重寫initialValue()方法,在沒有set值得情況下呼叫get則返回初始值。

值得注意的一點:ThreadLocal線上程使用完畢後,我們應該手動呼叫remove方法,移除它內部的值,這樣可以防止記憶體洩露,當然還有設為static。

內部實現

ThreadLocal內部有一個靜態類ThreadLocalMap,使用到ThreadLocal的執行緒會與ThreadLocalMap繫結,維護著這個Map物件,而這個ThreadLocalMap的作用是對映當前ThreadLocal對應的值,它key為當前ThreadLocal的弱引用:WeakReference

記憶體洩露問題

對於ThreadLocal,一直涉及到記憶體的洩露問題,即當該執行緒不需要再操作某個ThreadLocal內的值時,應該手動的remove掉,為什麼呢?我們來看看ThreadLocal與Thread的聯絡圖:

其中虛線表示弱引用,從該圖可以看出,一個Thread維持著一個ThreadLocalMap物件,而該Map物件的key又由提供該value的ThreadLocal物件弱引用提供,所以這就有這種情況:

如果ThreadLocal不設為static的,由於Thread的生命週期不可預知,這就導致了當系統gc時將會回收它,而ThreadLocal物件被回收了,此時它對應key必定為null,這就導致了該key對應得value拿不出來了,而value之前被Thread所引用,所以就存在key為null、value存在強引用導致這個Entry回收不了,從而導致記憶體洩露。

所以避免記憶體洩露的方法,是對於ThreadLocal要設為static靜態的,除了這個,還必須線上程不使用它的值是手動remove掉該ThreadLocal的值,這樣Entry就能夠在系統gc的時候正常回收,而關於ThreadLocalMap的回收,會在當前Thread銷燬之後進行回收。

總結

關於Volatile關鍵字具有可見性,但不具有操作的原子性,而synchronized比volatile對資源的消耗稍微大點,但可以保證變數操作的原子性,保證變數的一致性,最佳實踐則是二者結合一起使用。

  • 1、對於synchronized的出現,是解決多執行緒資源共享的問題,同步機制採用了“以時間換空間”的方式:訪問序列化,物件共享化。同步機制是提供一份變數,讓所有執行緒都可以訪問。
  • 2、對於Atomic的出現,是通過原子操作指令+Lock-Free完成,從而實現非阻塞式的併發問題。
  • 3、對於Volatile,為多執行緒資源共享問題解決了部分需求,在非依賴自身的操作的情況下,對變數的改變將對任何執行緒可見。
  • 4、對於ThreadLocal的出現,並不是解決多執行緒資源共享的問題,而是用來提供執行緒內的區域性變數,省去引數傳遞這個不必要的麻煩,ThreadLocal採用了“以空間換時間”的方式:訪問並行化,物件獨享化。ThreadLocal是為每一個執行緒都提供了一份獨有的變數,各個執行緒互不影響。

相關文章