不少程序員應該對併發一詞並不陌生,併發如同一把雙刃劍,若是使用得當,能夠幫助咱們更好的壓榨硬件的性能,反之,也會產生一些難以排查的問題。這裏,先簡單介紹下併發的幾個基本概念。java
進程:進程是操做系統進行資源分配和調度的基本單位。程序員
線程:線程是操做系統可以進行運算調度的最小單位,它被包含在進程之中,是進程中的實際運做單位。數據庫
上面是百度百科對進程和線程的解釋,可能有點抽象,這裏筆者再根據本身的理解解釋下進程和線程的概念和區別:當咱們打開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內核資源。bash
區別:從上面的例子,咱們能夠知道併發和並行的區別,併發是指在一段時間內,多個任務交替執行,並行是同一時間內,多個任務能夠同時執行。微信
至此,咱們已經瞭解了併發的幾個基本概念。而併發的本質是要解決:可見性、原子性、有序性這三個問題。多線程
當多個線程同時訪問同一個變量,一個線程修改了這個變量的值,其餘線程要能馬上看到修改的結果。架構
咱們來看下面這段代碼,首先咱們聲明瞭一個靜態變量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內核,L一、L二、L3是CPU的高速緩存,當CPU須要對數值進行運算時,會先把內存的數據加載到高速緩存再進行運算。假設線程A跑在Core1,線程B跑在Core2,無論是讀取flag仍是修改flag,線程A和B都須要從主存將flag加載到高速緩存(L一、L二、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跳出循環,但三者的原理是不同的:
至此,咱們瞭解了線程可見性,以及保證可見性的方法。固然,在上面幾種保證可見性的方法中,最優雅的仍是使用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
爲了提升執行程序的性能,編譯器和處理器可能會對咱們編寫的程序作一些優化,執行程序的順序不必定是按照咱們代碼編寫的順序,即指令重排序。編譯器和處理器只要保證程序在單線程狀況下,指令重排序的執行結果和按照咱們代碼順序所執行出來的結果同樣便可。
咱們看下面的兩行代碼,思考一下若是對調這兩行代碼會不會有什麼問題?這兩行代碼那一行須要執行的指令更少?
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禁止指令重排序,循序漸進的分配內存,建立對象,再將引用指向對象。