[並發編程的藝術] 02-Java並發機制的底層實現原理
Java代碼在編譯後會變成Java字節碼,字節碼被類加載起加載到JVM裏,JVM執行字節碼,最終需要轉化為匯編指令在CPU上執行, Java中所使用的並發機制依賴於JVM的實現和CPU的指令.
一、volatile的應用
在多處理器開發中保證共享變量的 "可見性", 可見性的意思是: 當一個線程修改一個共享變量時,另外一個線程能夠讀到這個修改的值. 如果 volatile變量修飾符使用恰當的話, 它比synchronized的使用和執行成本更低, 因為它不會引起線程上下文的切換和調度.
volatile的定義與使用
Java編程語言允許線程訪問共享變量, 為了確保共享變量能被準確和一致的更新, 線程應該確保通過排他鎖單獨獲得這個變量. Java語言提供了volatile, 在某些情況下比鎖要更加方便. 如果一個字段被聲明為 volatile, Java線程內存模型確保所有線程看到這個變量的值是一致的
為了提高處理速度,處理器不直接和內存通信,而是先將系統內存的數據讀取到內部緩存再進行操作, 但操作完不知道何時寫回到內存. 如果對聲明了volatile的變量進行寫操作, JVM就會向處理器發送一條Lock前綴的指令, 將這個變量所在緩存行的數據寫回到內存. 但是, 就算寫回到內存,如果其它處理器緩存的值還是舊的,再執行計算操作就會有問題. 所以在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議, 每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改, 就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀取到處理器緩存裏.
Lock前綴的指令在多核處理器下會引發兩件事情:
a) 將當前處理器緩存行的數據寫回到系統內存
b) 這個寫回內存的操作會使在其它CPU裏緩存了該內存地址的數據無效.
使用較多的場景
a) 作為線程開關
b) 在懶漢式單例設計模式中,修飾對象實例, 禁止指令重排
作為線程開關示例:
public class Test3 implements Runnable{ private static volatile boolean flag = true; @OverrideView Codepublic void run() { while (flag) { System.out.println(Thread.currentThread().getName()); } } public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new Test3()); thread.start(); TimeUnit.SECONDS.sleep(1); flag = false; } }
懶漢式單例模式示例:
public class LazySingleton { // private static LazySingleton lazySingleton = null; private static volatile LazySingleton lazySingleton = null; private LazySingleton(){} public static LazySingleton getInstance(){ /////////////////最簡單的寫法///////////////// // // 實例為空就實例化 // if (null == lazySingleton) { // lazySingleton = new LazySingleton(); // } // // // 否則直接返回 // return lazySingleton; /////////////////最簡單的寫法///////////////// // 這樣去實例化,結果也不是預期的,因為第一個線程進入代碼塊進行實例化之後,退出代碼塊,隨之切換到了其它線程,其它線程進入代碼塊 // 也會進行實例化 // if (null == lazySingleton) { // try { // TimeUnit.SECONDS.sleep(1); // } catch (InterruptedException e) { // e.printStackTrace(); // } // // synchronized (LazySingleton.class) { // lazySingleton = new LazySingleton(); // } // } // 使用雙重檢查保證單例的線程安全(此時也不是絕對的線程安全(指令重排序會導致不安全), // 要達到線程安全,還要給lazySingleton加上volatile關鍵字,禁止指令重排序 ) // // 第一個線程實例化後,離開代碼塊,此時即使第二個線程進入代碼塊,經過判斷會發現實例已經存在了,所以第二個線程不會去實例化對象了 if (null == lazySingleton) { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (LazySingleton.class) { if (null == lazySingleton) { lazySingleton = new LazySingleton(); } } } return lazySingleton; } }View Code
懶漢式單例模式測試:
/** * 測試懶漢式單例是否線程安全 * 如果按照最簡單的寫法,拿到的對象並不是相同的。 * 解決方法1:給getInstance方法加上synchronized,但是這樣會導致其它線程等待,消耗性能。 * 解決方法2:同步代碼塊 */ @Test public void f2() throws InterruptedException { for (int i = 0; i < 100; i++) { new Thread(() -> { System.out.println(LazySingleton.getInstance()); }).start(); } TimeUnit.SECONDS.sleep(2); }View Code
二、synchronized的應用
在多線程並發編程中,synchronized一直是元老級角色, 它能保證原子性,很多人都會稱呼它為 "重量級鎖", 隨著 Java SE 1.6對synchronized進行優化之後,有些情況下它就並不那麽重了, Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的性能消耗, 引入了偏向鎖和輕量級鎖以及鎖的存儲結構.
Java中每一個對象都可以作為鎖,具體表現為3種形式:
1) 對於普通同步方法,鎖是當前實例對象(會鎖住對象實例)
2) 對於靜態同步方法, 鎖是當前類的class對象(會鎖住整個類)
3) 對於同步方法塊, 鎖是synchronized括號裏配置的對象(會鎖住括號裏的對象)
當一個線程試圖訪問同步代碼塊時, 它必須得到鎖, 退出或拋出異常時,必須釋放鎖. 對於1、2、3 這三種情況的測試:
/** * 深入理解synchronized關鍵字 * 保證原子性和可見性操作 * 內置鎖 * 每個java對象都可以用作一個實現同步的鎖,這些鎖稱為內置鎖,線程進入同步代碼塊或方法塊時會自動獲得該鎖,在退出代碼塊/方法塊時會釋放該鎖 * 獲得內置鎖的唯一途徑就是進入這個鎖保護的同步代碼塊/方法 * 互斥鎖 * 內置鎖是一個互斥鎖,意味著最多只有一個線程能夠獲得該鎖,當線程A嘗試去獲得線程B持有的內置鎖時,線程A必須等待或者阻塞,直到線程B釋放這個 * 鎖,如果B線程不釋放這個鎖,那麽A線程將永遠等待下去。 * * 可修飾哪些地方 * 1、可修飾實例方法、靜態方法 * 實例方法:鎖住對象的實例 f1 兩個不同對象都鎖3秒,結果是幾乎同時運行結束,說明鎖的是各自的對象實例 * 靜態方法:鎖住整個類 f2 兩個對象調用m2不會同時結束,第一個線程結束3miao後第二個線程結束,說明整個類被鎖類 * 實際編程中盡量少用synchronized修飾靜態方法,因為它會導致整個類被鎖,所有線程串行執行 * 2、可修飾代碼塊 * 鎖住括號中的對象 m3中鎖的就是lock對象,因此f3中也是串行的效果, f4是並行效果 * * @Auther: [email protected] * @Date: 2019-03-01 21:18 */ public class Test2 { private Object lock = new Object(); public void m3(){ synchronized (lock) { try { TimeUnit.SECONDS.sleep(3); System.out.println(Thread.currentThread().getName()); } catch (InterruptedException e) { e.printStackTrace(); } } } // 修飾靜態方法 public synchronized static void m2(){ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } // 修飾實例方法 public synchronized void m1() { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } @Test public void f4() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread = new Thread(() -> { class1.m3(); }); Thread thread1 = new Thread(() -> { class2.m3(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f3() throws InterruptedException { Test2 class1 = new Test2(); Thread thread = new Thread(() -> { class1.m3(); }); Thread thread1 = new Thread(() -> { class1.m3(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f2() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread = new Thread(() -> { class1.m2(); }); Thread thread1 = new Thread(() -> { class2.m2(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f1() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread1 = new Thread(() -> { class1.m1(); }); Thread thread2 = new Thread(() -> { class2.m1(); }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } }View Code
鎖的升級與對比
Java SE 1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了 "偏量鎖" 和 "輕量級鎖", 在1.6中, 鎖一共有4中狀態, 級別從低到高依次是: 無鎖狀態、偏量鎖狀態、輕量級鎖狀態和重量級鎖狀態, 這幾個狀態會隨著競爭情況逐漸升級, 鎖可以升級但不能降級. 鎖的優缺點對比:
三、原子操作的實現原理
不可被中斷的一個或一系列操作稱為原子操作, 在Java中可以通過鎖和循環CAS的方式來實現原子操作.
CAS(Compare and Swap)比較並交換: CAS操作需要數據兩個數值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化在不交換.
在Java中通過鎖和循環CAS的方式來實現原子操作.
1) 使用循環CAS實現原子操作
public class Counter { private AtomicInteger atomicInteger = new AtomicInteger(); private int i = 0; public static void main(String[] args) { final Counter cas = new Counter(); List<Thread> ts = new ArrayList<>(600); long start = System.currentTimeMillis(); for (int j = 0; j < 100; j++) { Thread t = new Thread(() -> { for (int i = 0; i < 10000; i++) { cas.count(); cas.safeCount(); } }); ts.add(t); } for (Thread t : ts) { t.start(); } for (Thread t : ts) { try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(cas.i); System.out.println(cas.atomicInteger.get()); System.out.println(System.currentTimeMillis() - start); } // 使用cas實現線程安全的計數器 private void safeCount(){ for (; ; ) { int i = atomicInteger.get(); boolean suc = atomicInteger.compareAndSet(i, ++i); if (suc) break; } } // 非線程安全計數器 private void count(){ i++; } }View Code
JDK並發包裏提供了一些類來支持原子操作,如 AtomicBoolean(用原子方式來更新的boolean值), AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值), 這些原子包裝類還提供了有用的工具方法, 比如以原子的方式將當前值自增1或自減1.
2) CAS實現原子操作的三大問題
CAS雖然高效的解決了原子操作,但是CAS仍然存在三大問題,ABA問題、循環時間長開銷大、只能保證一個共享變量的原子操作.
a) ABA問題
因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A, 那麽使用CAS進行檢查時會發現它的值沒有發生變化,但實際上卻變化了, 解決思路就是使用版本號,在變量前面加上版本號,每次變量更新的時候把版本號加1,那麽 A->B->A就會變成 1A->2B->3A, JDK1.5開始提供了AtomicStampedReference來解決ABA問題, 這個類的 compareAndSet 方法的作用是首先檢查當前引用是否等於預期引用, 並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值.
public boolean compareAndSet( V expectedReference, //預期引用 V newReference, //更新後的引用 int expectedStamp, //預期標誌 int newStamp //更新後的標誌 )View Code
b) 循環時間長開銷大
自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷.
c) 只能保證一個共享變量的原子操作
當對一個共享變量進行操作時,可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以使用鎖. 還有一個取巧的辦法,就是把多個共享變量合並成一個共享變量進行操作. 比如又2個共享變量 i=2, j=a; 合並一下 ij=2a, 然後用CAS來操作ij, JDK1.5開始提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象裏來進行CAS操作.
3) 使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域. JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖, 除了偏向鎖,JVM實現鎖的方式都使用了循環CAS, 即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當他退出同步塊的時候使用循環CAS釋放鎖.
四、小結
本章學習了 volatile、synchronized和原子操作的實現原理,Java中大部分容器和框架都依賴於volatile和原子操作的實現原理, 了解這些原理對我們進行並發編程會更有幫助.
[並發編程的藝術] 02-Java並發機制的底層實現原理