並發
很多程序員應該對並發一詞並不陌生,並發如同一把雙刃劍,如果使用得當,可以幫助我們更好的壓榨硬件的性能,反之,也會產生一些難以排查的問題。這里,先簡單介紹下並發的幾個基本概念。
進程與線程
進程:進程是操作系統進行資源分配和調度的基本單位。
線程:線程是操作系統能夠進行運算調度的最小單位,它被包含在進程之中,是進程中的實際運作單位。
上面是百度百科對進程和線程的解釋,可能有點抽象,這里筆者再根據自己的理解解釋下進程和線程的概念和區別:當我們打開QQ、微信、網易雲音樂,這時我們啟動了三個進程,操作系統會分別對這三個進程分配資源,操作系統會分配什么資源給這三個進程呢?首先是內存資源,這三個進程都有各自的內存進行數據的存取,QQ和微信分別有各自的內存資源來保存我們的用戶數據、聊天數據。其次,當我們需要用QQ或者微信聊天時,操作系統只會把鍵盤資源分配給QQ或者微信其中一個進程,當我們輸入文字,只會出現在QQ或者微信其中一個的聊天窗。下面我們再來說說線程,我們用網易雲音樂,可以同時下載音樂和播放音樂,兩者互不影響,這是因為在網易雲音樂這個進程里,同時有兩個線程,一個線程播放音樂,一個線程下載音樂,利用多線程,可以使一個進程在一段時間內同時執行兩個任務。
並發與並行
並發:在單核單CPU架構中,只會出現並發,不會出現並行。比如在一個電商系統中,用戶A正在下單,用戶B正在改名,因此分別有線程A和線程B兩個線程在CPU上交替執行,互相競爭CPU資源。假設下單操作需要執行100個指令,改名操作需要執行60個指令,單核單CPU的架構可能先在線程A中執行80個指令,然后將CPU時間片讓給線程B,線程B在執行50個指令后,CPU重新把時間片讓給線程A執行剩余的20個指令,再執行線程B剩余的10個指令,最后線程A和線程B都執行完畢。
並行:只要是多核CPU,不管是單CPU還是多CPU,都有可能出現並行。還是以上面的電商系統為例,用戶A和用戶B的線程可以同時跑在同CPU或者不同CPU的不同的處理器上,這時候就能做到線程A和線程B同時執行,互不競爭CPU處理器資源。
區別:從上面的例子,我們可以知道並發和並行的區別,並發是指在一段時間內,多個任務交替執行,並行是同一時間內,多個任務可以同時執行。
並發編程的本質
至此,我們已經了解了並發的幾個基本概念。而並發的本質是要解決:可見性、原子性、有序性這三個問題。
可見性
當多個線程同時訪問同一個變量,一個線程修改了這個變量的值,其他線程要能立刻看到修改的結果。
我們來看下面這段代碼,首先我們聲明了一個靜態變量flag,默認為true,線程A只要檢查到flag為true時,就循環下去,主線程啟動線程A后休眠2000毫秒,再啟動線程B修改flag的值為false。按理來說,在flag被線程B修改為false之后,線程A應該退出循環。然而,如果我們運行下面的代碼,會發現程序並不會終止。程序之所以不會終止的原因,是因為線程A無法跳出循環,即便我們用線程B把flag改為false,但線程B修改的行為,對線程A是無感知的,即線程A並不知道此時flag已經被其他線程修改為false,線程A仍舊以為flag為true,所以無法跳出循環。
public class VisibilityTest { private static boolean flag = true;//靜態變量 public static void main(String[] args) { new Thread(() -> { int i = 0; while (flag) {//如果靜態變量為flag則循環下去 i++; } System.out.println("i=" + i); }, "Thread-A").start(); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(() -> flag = false, "Thread-B").start(); } }
之所以線程A無法感知線程B修改flag變量的值,是因為在線程A啟動的時候,會拷貝一份flag的副本,我們將副本命名為flag’,當線程A需要flag的值時,會去訪問flag’,並不會去訪問flag最新的值。那么,線程A又為什么要拷貝一份flag的值呢?為什么不直接去訪問flag呢?這里就要談到CPU緩存架構和JMM模型(Java線程內存模型)。
下圖是一個雙核雙CPU的架構,Core是CPU內核,L1、L2、L3是CPU的高速緩存,當CPU需要對數值進行運算時,會先把內存的數據加載到高速緩存再進行運算。假設線程A跑在Core1,線程B跑在Core2,不管是讀取flag還是修改flag,線程A和B都需要從主存將flag加載到高速緩存(L1、L2、L3)。因此,高速緩存有兩份flag的拷貝:flag(A)和flag(B),分別用於線程A和線程B,要注意一點的是,即便flag(A)和flag(B)都是主存flag的拷貝,但線程A對flag(A)讀取或者修改對線程B是不可見的,同理線程B對flag(B)的讀取修改對線程A也是不可見的。在我們上面的代碼中,線程B在修改緩存的flag(B)之后,會把flag(B)最新的值同步回主存的flag,但線程A並不知道主存的flag已更新,它仍舊用緩存中flag(A)的值,所以無法跳出循環。
CPU緩存結構
Java的線程內存模型則參考了CPU的結構,在Java中,每個線程都有自己單獨的本地內存用來存儲數據,主存的共享變量也會被拷貝到本地內存成為副本,線程如果要使用共享變量,不會從主存讀取或者修改,而是讀取修改本地內存的副本。這也是代碼VisibilityTest中,線程B在修改flag變量后,線程A無法跳出循環的原因。
Java線程內存模型
那么,如果我們業務中存在多線程訪問修改同一變量,而且要求其他線程能看到變量最新修改的值該怎么辦呢?Java提供了volatile關鍵字,來保證變量的可見性:
private static volatile boolean flag = true;
如果我們給flag加上volatile,線程B在修改flag的值之后,線程A就能及時獲取到flag最新的值,就會跳出循環。那么,除了volatile關鍵字,還有其他的辦法來保證可見性嗎?有三種方式:synchronized、休眠和緩存失效。
public class VisibilityTest2 { private static boolean flag = true;//靜態變量 public static void main(String[] args) { new Thread(() -> { int i = 0; while (flag) {//如果靜態變量為flag則循環下去 i++; //System.out.println("i=" + i);//<1>調用println()方法時會進入synchronized同步代碼塊,synchronized可以保證共享變量的可見性 // try { // Thread.sleep(100);//<2>休眠也可以保證貢獻變量的可見性 // } catch (InterruptedException e) { // e.printStackTrace(); // } //shortWait(100000);//<3>模擬休眠100000納秒,緩存失效 } System.out.println("i=" + i); }, "Thread-A").start(); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(() -> flag = false, "Thread-B").start(); } public static void shortWait(long interval) { long start = System.nanoTime(); long end; do { end = System.nanoTime(); } while (start + interval >= end); } }
VisibilityTest2中<1>、<2>、<3>處的代碼都會可以讓線程A跳出循環,但三者的原理是不一樣的:
- <1>調用標准輸出流的println()方法,這個方法里有synchronized關鍵字,這個關鍵字可以保證本地內存對共享變量的可見性。
- <2>Thread.sleep()和Thread.yield()會讓出CPU時間片,當休眠結束或者重新得到CPU時間片時,線程會去加載主存最新的共享變量。
- <3>我們調用shortWait(long interval)等待100000納秒,由於本地內存的副本太久沒有使用,線程判斷副本過期,重新去主存加載,這里需要注意一點是,如果我們把等待時間設為10或者100納秒,那么結束等待時線程又會去使用flag副本,由於等待時間不是很長,不會將副本設置為已過期,也就不會跳出循環。
至此,我們了解了線程可見性,以及保證可見性的方法。當然,在上面幾種保證可見性的方法中,最優雅的還是使用volatile關鍵字,其他保證可見性的方式都不是那么優雅,或者說是不可控的。
原子性
即一個操作或者多個操作,要么全部執行並且執行的過程不被任何因素打斷,要么就都不執行。原子性就像數據庫里面的事務一樣,要嘛全部執行成功,如果在執行過程中出現失敗,則整體操作回滾。
我們來看下面的例子,在AtomicityTest中聲明兩個int類型的靜態變量a和b,然后我們啟動10個線程,每個線程對a和b循環1000次加1的操作,如果我們多次執行下面這段代碼,會發現大部分情況下a和b最后的值都不是10000,甚至a和b的值也不相等,那么是為什么呢?
public class AtomicityTest { private static volatile int a, b; public static void main(String[] args) { Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 1000; j++) { a++; b++; } }); } for (Thread thread : threads) { thread.start(); } for (Thread thread : threads) { try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("a=" + a + " b=" + b); } }
運行結果:
a=9835 b=9999
我們來思考下,為什么a和b都不等於10000呢?靜態變量a和b我們都用關鍵字volatile標記,所以一定能保證如果a和b的值被一個線程修改,其他線程能馬上感知到。之所以出現a和b的結果都不是10000,是因為a++這個操作,並不是原子性,在一個線程執行a++這個操作時,可能被其他線程干擾。
我們可以來拆解下a++這個操作分哪幾個步驟:
1.讀取a的值 2.對a加1 3.將+1的結果賦值給a
我們假設線程1在執行a++操作的時,讀取到a的數值為100,線程1執行完a++的第二個步驟,得出+1的結果是101,還未執行第三個步驟進行復制,此時線程2搶占了CPU時間片,線程1休眠,線程2讀取到a的數值也是100,並且線程2完整的執行兩次a++的所有步驟,此時a的數值為102,之后線程2休眠,線程1搶占到CPU時間片,便將之前+1的結果101賦值給a。這就是筆者所說,a++這個操作並非原子性,且被其他線程干擾,同理我們也就知道為何b的結果不是10000,而且a和b的結果還不相等。
要解決原子性問題也有很多種方式,針對AtomicityTest的代碼,最簡單的方式就是用synchronized加上一把同步鎖:
public static void main(String[] args) { Thread[] threads = new Thread[10]; Object lock = new Object(); for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { synchronized (lock) { for (int j = 0; j < 1000; j++) { a++; b++; } } }); } …… }
運行上面的代碼,a和b的結果都是10000。利用synchronized (lock)可以保證同一個時刻,最多只有一個線程訪問同步代碼塊,其他線程如果要訪問時只能陷入阻塞。這樣也就能保證a++和b++的原子性。
Java每個對象的底層維護着一個鎖記錄,當一個對象時某個同步代碼塊的鎖時,如果有線程進入同步代碼塊,對象的鎖記錄+1,線程離開同步代碼塊,則鎖記錄-1。如果鎖記錄>1,則代表當前線程重入鎖,比如下面的代碼,即方法A和方法B都有lock對象的同步代碼塊,當線程進入methodA的lock同步代碼塊,鎖記錄+1,調用methodB時執行到lock的同步代碼塊時,鎖記錄再次+1為2,當執行完methodB的同步代碼塊,lock的鎖記錄-1為1,最后執行完methodA的lock同步代碼塊,鎖記錄-1變為0,其他線程則可以競爭lock的鎖權限,執行methodA或者methodB的同步代碼塊。
public void methodA() { synchronized (lock) { //... methodB(); } } public void methodB() { synchronized (lock) { //... } }
我們來看看下面四個操作哪幾個是原子性哪幾個不是:
i = 0; //1 j = i ; //2 i++; //3 i = j + 1; //4
- i=0:是原子性,在Java中對基本數據類型變量的賦值操作是原子性操作。
- j=i:不是原子性,首先要讀取i的值,再將i的值賦值給變量j。
- i++:不是原子性,操作步驟見上。
- i=j+1:不是原子性,原因同i++一樣。
有序性
為了提高執行程序的性能,編譯器和處理器可能會對我們編寫的程序做一些優化,執行程序的順序不一定是按照我們代碼編寫的順序,即指令重排序。編譯器和處理器只要保證程序在單線程情況下,指令重排序的執行結果和按照我們代碼順序所執行出來的結果一樣即可。
我們看下面的兩行代碼,思考一下如果對調這兩行代碼會不會有什么問題?這兩行代碼那一行需要執行的指令更少?
int j = a;//<1> int i = 1;//<2>
首先我們來解決第一個問題,<1>和<2>這兩行代碼即便我們程序對調也不會有問題,畢竟代碼<1>用到的變量和代碼<2>沒有交集,所以這兩行代碼是可以互換位置的。其次,我們來考慮<1>和<2>哪一行執行的指令更少,通過之前的學習,我們知道<2>是一個原子操作,而<1>需要讀值再賦值,不是原子操作,執行代碼<2>所需指令比<1>更少,所以編譯器就可以做一個優化,把代碼<2>和代碼<1>的位置互換,優先執行指令少且變動順序不會影響結果的代碼,再執行指令多的代碼。
下面的代碼[1]和代碼[2]是兩個獨立的代碼塊,但這兩個獨立的代碼塊最終結果又都是一樣,即:i=2,j=3,那么哪一個代碼塊執行效率更高?
//[1] int i = 1;//<1> int j = 3;//<2> int i = i+1;//<3> //[2] int i = 1;//<4> int i = i+1;//<5> int j = 3;//<6>
為了思考代碼塊[1]和代碼塊[2]哪一個執行效率更高,我們模擬下CPU的執行邏輯。首先是代碼塊[1]:CPU在執行完<1>和<2>兩個賦值操作后,即將執行i=i+1,這時候i的值可能已經不在CPU的高速緩存里,CPU需要去主存加載i的值進行運算和賦值。再來是代碼塊[2]:CPU執行完<4>的賦值操作,此時i還在高速緩存,CPU直接從高速緩存讀取i的值加1再賦值給i,最后再執行代碼<6>的賦值操作。
到這里,我想大家應該都明白哪個代碼塊效率更高,顯而易見,代碼塊[2]的效率會更高,因為它不用面臨變量i從高速緩存中淘汰,后續對i進行+1操作時又需要去主存加載變量i。而代碼塊[1]在執行完i的賦值操作后,又執行了其他指令,這時候可能出現高速緩存無法容納變量i而將i淘汰,后續需要對i進行操作需要去主存加載i。
根據上面我們所了解的,指令重排序確實會提高程序的性能,但指令重排序只保證單線程情況下,重排序的執行結果和未排序的執行結果是一樣的,如果是多線程的情況下,指令重排序會給我們帶來意想不到的結果。
在下面的代碼中,我們聲明4個int類型的靜態變量:a,b,x,y,主方法有一個循環,每次循環都會將這四個靜態變量賦值為0,之后開啟兩個線程,在線程1中獎a賦值為1,b的值賦值給x,線程2中將b賦值為1,a的值賦值給y。等到兩個線程執行完畢后,如果x和y都為0,則跳出循環。
public class ReOrderTest { private static int x = 0, y = 0; private static int a = 0, b = 0; public static void main(String[] args) { int i = 0; while (true) { i++; x = 0; y = 0; a = 0; b = 0; Thread thread1 = new Thread(() -> { a = 1;//<1> x = b;//<2> }); Thread thread2 = new Thread(() -> { b = 1;//<3> y = a;//<4> }); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("第" + i + "次:x=" + x + " y=" + y + ""); if (x == 0 && y == 0) { break; } } } }
運行結果:
第1次:x=0 y=1 …… 第86586次:x=0 y=1 第86587次:x=0 y=0
運行上面的程序,我們會發現程序終究會跳出循環,按理來說,我們在線程1給a賦值,在線程2將a的值賦予給y,線程2又對b賦值,在線程1將b的值賦值給x,兩個線程執行結束后,x和y本來應該都不為0,那為什么會出現x和y同時為0跳出循環的情況?可能有人想到線程的可見性,誠然有可能出現:線程1和線程2同時將這四個靜態變量的值拷貝到本地內存,即便線程1對a賦值,線程2對b賦值,但線程1看不到線程2對b的修改,將b在本地內存的拷貝賦值給x,同理線程2將a在本地內存的拷貝賦值給y,因此x和y同時為0,跳出循環。但這里還要考慮到一個重排序的情況,線程1的<1>、<2>代碼是可以互換位置的,同理還有線程2的<3>、<4>。考慮下線程1執行重排序后,執行順序是<2>、<1>,而線程2執行順序是<4>、<3>,即代碼順序變為:
//線程1 x = b;//<2> a = 1;//<1> //線程2 y = a;//<4> b = 1;//<3>
線程1執行<2>之后,線程2又執行了<4>,之后兩個線程即便對a和b賦值,但對x和y來說為時已晚,x和y已經具備跳出循環的條件了。那么,有沒有辦法解決這個問題呢?這里又要請出我們的關鍵字volatile了,volatile除了保證可見性,還能保證有序性。只要將ReOrderTest 的四個靜態變量標記上volatile,就可以禁止指令重排序。
private static volatile int x = 0, y = 0; private static volatile int a = 0, b = 0;
volatile之所以可以防止指令重排序,是因為它會在使用倒volatile變量的地方生成一道“柵欄”,“柵欄”的前后指令都不能更換順序,比如上述四個靜態變量標記上volatile關鍵字后,線程1執行代碼的順序如下:
a = 1; //---柵欄--- x = b; //---柵欄---
變量a的后面會生成一道“柵欄”,編譯器和處理器會檢測到這道“柵欄”,即便我們的指令在單線程下有優化空間,volatile也能保證處理器執行指令的順序是按照我們代碼所編寫的順序。
另外,筆者之前有提過,執行a=1的執行比x=b的指令更少,處理器應該要優先執行a=1再執行x=b,但實際上Java虛擬機在執行指令的時候情況是不一定的,也有可能優先執行x=b再執行a=1,也就是說JVM虛擬機執行指令的順序,可能會按照我們編寫代碼的順序,也可能會將我們的代碼調整順序后再執行,即便是同一段代碼循環執行兩次,前后兩次的指令順序,有可能是按我們代碼所編寫的順序,也有可能不是。
下面的代碼是用於獲取單例對象的代碼,通過SingleFactory.getInstance()方法我們可以獲取到singleFactory對象,在這個方法中,如果singleFactory不為空,則直接返回,如果為空,則進入if分支,在if分支中還有個同步代碼塊,同步代碼塊里會再判斷一次singleFactory是否為null,避免多線程調用SingleFactory.getInstance(),由於可見性原因,生成多個SingleFactory對象,所以synchronized已經保證了我們的可見性,第一個進入synchronized代碼塊中的線程,singleFactory一定為null,所以會去初始化對象,而其他同樣需要singleFactory對象的線程,會先阻塞在同步代碼塊之外,等到第一個線程初始化好singleFactory后離開同步代碼塊,其他線程進入時singleFactory已經不為null了。但我們注意到一點,為什么synchronized已經保證了可見性,singleFactory這個靜態變量還要用volatile關鍵字來標記呢?
public class SingleFactory { private static volatile SingleFactory singleFactory; private SingleFactory() { } public static SingleFactory getInstance() { if (singleFactory == null) { synchronized (SingleFactory.class) { if (singleFactory == null) { singleFactory = new SingleFactory(); } } } return singleFactory; } }
誠然,volatile和synchronized都能保證可見性,但這里的volatile不是用來保證可見性的,而是禁止指令重排序的。我們來思考一個問題:JVM會如何執行singleFactory = new SingleFactory()這段代碼?正常應該會先在堆上分配一塊內存,在內存上創建一個SingleFactory對象,最后把singleFactory這個引用指向堆上的SingleFactory對象是不是?但如果一個對象的構建及其復雜,JVM可能會把創建對象的指令優化成先開辟一塊內存,將singleFactory的引用指向這塊內存,然后再創建這個對象。如果執行的順序是先開辟內存,再指向內存,最后在內存上創建對象,那么其他線程在調用SingleFactory.getInstance()時,即便對象還沒創建好,但singleFactory引用已經不為null了,這個時候如果將singleFactory引用返回並調用其堆上的方法是非常危險的,所以這里需要用volatile禁止指令重排序,並不是為了volatile的可見性,而是讓volatile禁止指令重排序,按部就班的分配內存,創建對象,再將引用指向對象。