多线程:线程安全?如何实现?
多個線程不管以何種方式訪問某個類,并且在主調代碼中不需要進行同步,都能表現正確的行為。
線程安全有以下幾種實現方式:
1)不可變
為什么是不可變?
不可變(Immutable)的對象一定是線程安全的,不需要再采取任何的線程安全保障措施。只要一個不可變的對象被正確地構建出來,永遠也不會看到它在多個線程之中處于不一致的狀態。多線程環境下,應當盡量使對象成為不可變,來滿足線程安全。
有哪些不可變的類型呢?
- final 關鍵字修飾的基本數據類型
- String
- 枚舉類型
- Number 部分子類,如 Long 和 Double 等數值包裝類型,BigInteger 和 BigDecimal 等大數據類型。但同為 Number 的原子類 AtomicInteger 和 AtomicLong 則是可變的。
對于集合類型,可以使用 Collections.unmodifiableXXX() 方法來獲取一個不可變的集合。
public class ImmutableExample {public static void main(String[] args) {Map<String, Integer> map = new HashMap<>();Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);unmodifiableMap.put("a", 1);} } Exception in thread "main" java.lang.UnsupportedOperationExceptionat java.util.Collections$UnmodifiableMap.put(Collections.java:1457)at ImmutableExample.main(ImmutableExample.java:9)Collections.unmodifiableXXX() 先對原始的集合進行拷貝,需要對集合進行修改的方法都直接拋出異常。
public V put(K key, V value) {throw new UnsupportedOperationException(); }2)互斥同步
synchronized 和 ReentrantLock。本博客多線程板塊前面有講,請查閱。
3)非阻塞同步
互斥同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱為阻塞同步。
互斥同步屬于一種悲觀的并發策略,總是認為只要不去做正確的同步措施,那就肯定會出現問題。無論共享數據是否真的會出現競爭,它都要進行加鎖(這里討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。
1. CAS
隨著硬件指令集的發展,我們可以使用基于沖突檢測的樂觀并發策略:先進行操作,如果沒有其它線程爭用共享數據,那操作就成功了,否則采取補償措施(不斷地重試,直到成功為止)。這種樂觀的并發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱為非阻塞同步。
樂觀鎖需要操作和沖突檢測這兩個步驟具備原子性,這里就不能再使用互斥同步來保證了,只能靠硬件來完成。硬件支持的原子性操作最典型的是:比較并交換(Compare-and-Swap,CAS)。CAS 指令需要有 3 個操作數,分別是內存地址 V、舊的預期值 A 和新值 B。當執行操作時,只有當 V 的值等于 A,才將 V 的值更新為 B。
CAS會帶來ABA問題。
什么是ABA問題?如何解決?
?
如果一個變量初次讀取的時候是 A 值,它的值被改成了 B,后來又被改回為 A,那 CAS 操作就會誤認為它從來沒有被改變過。
J.U.C 包提供了一個帶有標記的原子引用類 AtomicStampedReference?(相當于一個計數器)來解決這個問題,它可以通過控制變量值的版本來保證 CAS 的正確性。
大部分情況下 ABA 問題不會影響程序并發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
?
2. AtomicInteger(保證原子性)
J.U.C 包里面的整數原子類 AtomicInteger 的方法調用了 Unsafe 類的 CAS 操作。
以下代碼使用了 AtomicInteger 執行了自增的操作。
private AtomicInteger cnt = new AtomicInteger();public void add() {cnt.incrementAndGet(); }以下代碼是 incrementAndGet() 的源碼,它調用了 Unsafe 的 getAndAddInt() 。
public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }以下代碼是 getAndAddInt() 源碼,var1 指示對象內存地址,var2 指示該字段相對對象內存地址的偏移,var4 指示操作需要加的數值,這里為 1。通過 getIntVolatile(var1, var2) 得到舊的預期值,通過調用 compareAndSwapInt() 來進行 CAS 比較,如果該字段內存地址中的值等于 var5,那么就更新內存地址為 var1+var2 的變量為 var5+var4。
可以看到 getAndAddInt() 在一個循環中進行,發生沖突的做法是不斷的進行重試。
public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5; }4)無同步方案
要保證線程安全,并不是一定就要進行同步。如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性。
1. 棧封閉
多個線程訪問同一個方法的局部變量時,不會出現線程安全問題,因為局部變量存儲在虛擬機棧中,屬于線程私有的。
可能會嚴重影響程序的執行效率。例如如果每次都在具體方法中頻繁的開啟數據庫連接和關閉的操作,會嚴重影響程序的執行效率,還可能導致對服務器的壓力過大!
public class StackClosedExample {public void add100() {int cnt = 0;for (int i = 0; i < 100; i++) {cnt++;}System.out.println(cnt);} } public static void main(String[] args) {StackClosedExample example = new StackClosedExample();ExecutorService executorService = Executors.newCachedThreadPool();executorService.execute(() -> example.add100());executorService.execute(() -> example.add100());executorService.shutdown(); } 100 1002. 線程本地存儲(Thread Local Storage)
如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見范圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
符合這種特點的應用并不少見,大部分使用消費隊列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程盡量在一個線程中消費完。其中最重要的一個應用實例就是經典 Web 交互模型中的“一個請求對應一個服務器線程”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用線程本地存儲來解決線程安全問題。
可以使用 java.lang.ThreadLocal 類來實現線程本地存儲功能。
對于以下代碼,thread1 中設置 threadLocal 為 1,而 thread2 設置 threadLocal 為 2。過了一段時間之后,thread1 讀取 threadLocal 依然是 1,不受 thread2 的影響。
public class ThreadLocalExample {public static void main(String[] args) {ThreadLocal threadLocal = new ThreadLocal();Thread thread1 = new Thread(() -> {threadLocal.set(1);try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(threadLocal.get());threadLocal.remove();});Thread thread2 = new Thread(() -> {threadLocal.set(2);threadLocal.remove();});thread1.start();thread2.start();} } 1為了理解 ThreadLocal,先看以下代碼:
public class ThreadLocalExample1 {public static void main(String[] args) {ThreadLocal threadLocal1 = new ThreadLocal();ThreadLocal threadLocal2 = new ThreadLocal();Thread thread1 = new Thread(() -> {threadLocal1.set(1);threadLocal2.set(1);});Thread thread2 = new Thread(() -> {threadLocal1.set(2);threadLocal2.set(2);});thread1.start();thread2.start();} }它所對應的底層結構圖為:
?
每個 Thread 都有一個 ThreadLocal.ThreadLocalMap 對象。
/* ThreadLocal values pertaining to this thread. This map is maintained* by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null;當調用一個 ThreadLocal 的 set(T value) 方法時,先得到當前線程的 ThreadLocalMap 對象,然后將 ThreadLocal->value 鍵值對插入到該 Map 中。
public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value); }get() 方法類似。
public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue(); }ThreadLocal 從理論上講并不是用來解決多線程并發問題的,因為根本不存在多線程競爭。
在一些場景 (尤其是使用線程池) 下,由于 ThreadLocal.ThreadLocalMap 的底層數據結構導致 ThreadLocal 有內存泄漏的情況,應該盡可能在每次使用 ThreadLocal 后手動調用 remove(),以避免出現 ThreadLocal 經典的內存泄漏甚至是造成自身業務混亂的風險。
3. 可重入代碼(Reentrant Code)
這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回后,原來的程序不會出現任何錯誤。
可重入代碼有一些共同的特征,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。
?
總結
以上是生活随笔為你收集整理的多线程:线程安全?如何实现?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 多线程:一些好的编程建议
- 下一篇: 多线程:Vector是线程安全的吗