線程是程式執行的最小單元,多線程是指程式同一時間可以有多個執行單元運行(這個與你的CPU核心有關)。 在java中開啟一個新線程非常簡單,創建一個Thread對象,然後調用它的start方法,一個新線程就開啟了。 那麼執行代碼放在那裡呢?有兩種方式:1. 創建Thread對象時,覆寫它的run方法, ...
線程是程式執行的最小單元,多線程是指程式同一時間可以有多個執行單元運行(這個與你的CPU核心有關)。
在java中開啟一個新線程非常簡單,創建一個Thread對象,然後調用它的start方法,一個新線程就開啟了。
那麼執行代碼放在那裡呢?有兩種方式:1. 創建Thread對象時,覆寫它的run方法,把執行代碼放在run方法里。2. 創建Thread對象時,給它傳遞一個Runnable對象,把執行代碼放在Runnable對象的run方法里。
如果多線程操作的是不同資源,線程之間不會相互影響,不會產生任何問題。但是如果多線程操作相同資源(共用變數),就會產生多線程衝突,要知道這些衝突產生的原因,就要先瞭解java記憶體模型(簡稱JMM)。
一. java記憶體模型(JMM)
1.1 java記憶體模型(JMM)介紹
java記憶體模型決定一個線程對共用變數的寫入何時對另一個線程可見。從抽樣的角度來說:線程之間的共用變數存儲在主記憶體(main memory)中,每個線程都有一個私有的本地記憶體(local memory),本地記憶體中存儲了該線程以讀/寫共用變數的副本。
存在兩種記憶體:主記憶體和線程本地記憶體,線程開始時,會複製一份共用變數的副本放在本地記憶體中。
線程對共用變數操作其實都是操作線程本地記憶體中的副本變數,當副本變數發生改變時,線程會將它刷新到主記憶體中(並不一定立即刷新,何時刷新由線程自己控制)。
當主記憶體中變數發生改變,就會通知發出信號通知其他線程將該變數的緩存行置為無效狀態,因此當其他線程從本地記憶體讀取這個變數時,發現這個變數已經無效了,那麼它就會從記憶體重新讀取。
1.2 可見性
從上面的介紹中,我們看出多線程操作共用變數,會產生一個問題,那就是可見性問題: 即一個線程對共用變數修改,對另一個線程來說並不是立即可見的。
classData{inta =0;intb =0;intx =0;inty =0;// a線程執行publicvoidthreadA(){ a =1; x = b; }// b線程執行publicvoidthreadB(){ b =2; y = a; }}
如果有兩個線程同時分別執行了threadA和threadB方法。可能會出現x==y==0這個情況(當然這個情況比較少的出現)。
因為a和b被賦值後,還沒有刷新到主記憶體中,就執行x = b和y = a的語句,這個時候線程並不知道a和b還已經被修改了,依然是原來的值0。
1.3 有序性
為了提高程式執行性能,Java記憶體模型允許編譯器和處理器對指令進行重排序。重排序過程不會影響到單線程程式的執行,卻會影響到多線程併發執行的正確性。
classReorder{intx =0;booleanflag =false;publicvoidwriter(){ x =1; flag =true; }publicvoidreader(){if(flag) {inta = x * x; ... } }}
例如上例中,我們使用flag變數,標誌x變數已經被賦值了。但是這兩個語句之間沒有數據依賴,所以它們可能會被重排序,即flag = true語句會在x = 1語句之前,那麼這麼更改會不會產生問題呢?
在單線程模式下,不會有任何問題,因為writer方法是一個整體,只有等writer方法執行完畢,其他方法才能執行,所以flag = true語句和x = 1語句順序改變沒有任何影響。
在多線程模式下,就可能會產生問題,因為writer方法還沒有執行完畢,reader方法就被另一線程調用了,這個時候如果flag = true語句和x = 1語句順序改變,就有可能產生flag為true,但是x還沒有賦值情況,與程式意圖產生不一樣,就會產生意想不到的問題。
1.4 原子性
在Java中,對基本數據類型的變數的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。
x =1;// 原子性y = x;// 不是原子性x = x +1;// 不是原子性x++;// 不是原子性System.out.println(x);// 原子性
公式2:有兩個原子性操作,讀取x的值,賦值給y。公式3:也是三個原子性操作,讀取x的值,加1,賦值給x。公式4:和公式3一樣。
所以對於原子性操作就兩種:1. 將基本數據類型常量賦值給變數。2. 讀取基本數據類型的變數值。任何計算操作都不是原子的。
1.5 小結
多線程操作共用變數,會產生上面三個問題,可見性、有序性和原子性。
可見性: 一個線程改變共用變數,可能並沒有立即刷新到主記憶體,這個時候另一個線程讀取共用變數,就是改變之前的值。所以這個共用變數的改變對其他線程並不是可見的。
有序性: 編譯器和處理器會對指令進行重排序,語句的順序發生改變,這樣在多線程的情況下,可能出現奇怪的異常。
原子性: 只有對基本數據類型的變數的讀取和賦值操作是原子性操作。
要解決這三個問題有兩種方式:
volatile關鍵字:它只能解決兩個問題可見性和有序性問題,但是如果volatile修飾基本數據類型變數,而且這個變數只做讀取和賦值操作,那麼也沒有原子性問題了。比如說用它來修飾boolean的變數。
加鎖:可以保證同一時間只有同一線程操作共用變數,當前線程操作共用變數時,共用變數不會被別的線程修改,所以可見性、有序性和原子性問題都得到解決。分為synchronized同步鎖和JUC框架下的Lock鎖。
二. volatile關鍵字
volatile關鍵字作用
1.可見性: 對一個volatile變數的讀取,總是能看到(任意線程)對這個volatile變數最後的寫入。
有序性: 禁止指令重排序,即在程式中在volatile變數進行操作時,在其之前的操作肯定已經全部執行了,而且結果已經對後面的操作可見,在其之後的操作肯定還沒有執行。
這個的具體解釋,大家請看《深入理解Java記憶體模型》裡面關於happens-before規則的講解。
classVolatileFeaturesExample{//使用volatile聲明一個基本數據類型變數vlvolatilelongvl =0L;//對於單個volatile基本數據類型變數賦值publicvoidset(longl){ vl = l; }//對於單個volatile基本數據類型變數的複合操作publicvoidgetAndIncrement(){ vl++; }//對於單個volatile基本數據類型變數讀取publiclongget(){returnvl; }}classVolatileFeaturesExample{//聲明一個基本數據類型變數vllongvl =0L;// 相當於加了同步鎖publicsynchronizedvoidset(longl){ vl = l; }// 普通方法publicvoidgetAndIncrement(){longtemp = get(); temp +=1L; set(temp); }// 相當於加了同步鎖publicsynchronizedlongget(){returnvl; }}
如果volatile修飾基本數據類型變數,而且只對這個變數做讀取和賦值操作,那麼就相當於加了同步鎖。
三. synchronized同步鎖
synchronized同步鎖作用是訪問被鎖住的資源時,只要獲取鎖的線程才能操作被鎖住的資源,其他線程必須阻塞等待。
所以一個線程來說,可以阻塞等待,可以運行,那麼線程到底有哪些狀態呢?
3.1 線程狀態
狀態轉換圖
線程分為5種狀態:
新建狀態(New):創建一個Thread對象,那麼該thread對象就是新建狀態。
可運行狀態(Runnable):表示該thread線程隨時都可以運行,只要獲取CPU的執行權。
註: 該狀態可以由新建狀態轉換而來(通過調用thread的start方法),也可以由阻塞狀態轉換而來
運行狀態(Running):表示該線程正在運行,註意運行狀態只能從可運行狀態到達。
阻塞狀態(Blocked):表示該線程當前停止運行,主要分為三種情況:
1). 同步阻塞狀態:線程獲取同步鎖失敗,就會進入同步阻塞狀態。
2). 等待阻塞狀態:線程調用wait方法,進入該狀態。註:join方法本質也是通過wait方法實現的。
3). 其他阻塞狀態:通過Thread.sleep方法讓線程睡眠,開啟IO流讓線程等待阻塞。
死亡狀態(Dead):當thread的run方法運行完畢,那麼線程就進入死亡狀態。該狀態不能再轉換成其他狀態。
3.2 synchronized同步方法或者同步塊
synchronized同步方法或者同步塊具體是怎樣操作的呢?
相當於有一個大房間,房間門上有一把鎖lock,房間裡面存放的是所有與這把鎖lock關聯的同步方法或者同步塊。
當某一個線程要執行這把鎖lock的一個同步方法或者同步塊時,它就來到房間門前,如果發現鎖lock還在,那麼它就拿著鎖進入房間,並將房間鎖上,它可以執行房間中任何一個同步方法或者同步塊。
這時又有另一個線程要執行這把鎖lock的一個同步方法或者同步塊時,它就來到房間門前,發現鎖lock沒有了,就只能在門外等待,此時該線程就在synchronized同步阻塞線程池中。
等到拿到鎖lock的線程,同步方法或者同步塊代碼執行完畢,它就會從房間中退出來,將鎖放到門上。
這時在門外等待的線程就爭奪這把鎖lock,拿到鎖的線程就可以進入房間,其他線程則又要繼續等待。
註:synchronized 鎖是鎖住所有與這個鎖關聯的同步方法或者同步塊。
synchronized的同步鎖到底是什麼呢?
其實就是java對象,在Java中,每一個對象都擁有一個鎖標記(monitor),也稱為監視器,多線程同時訪問某個對象時,線程只有獲取了該對象的鎖才能訪問。
3.3 wait與notify、notifyAll
這三個方法主要用於實現線程之間相互等待的問題。
調用對象lock的wait方法,會讓當前線程進行等待,即將當前線程放入對象lock的線程等待池中。調用對象lock的notify方法會從線程等待池中隨機喚醒一個線程,notifyAll方法會喚醒所有線程。
註:對象lock的wait與notify、notifyAll方法調用必須放在以對象lock為鎖的同步方法或者同步塊中,否則會拋出IllegalMonitorStateException異常。
wait與notify、notifyAll具體是怎麼操作的呢?
前面過程與synchronized中介紹的一樣,當調用鎖lock的wait方法時,該線程(即當前線程)退出房間,歸還鎖lock,但並不是進入synchronized同步阻塞線程池中,而是進入鎖lock的線程等待池中。
這時另一個線程拿到鎖lock進行房間,如果它執行了鎖lock的notify方法,那麼就會從鎖lock的線程等待池中隨機喚醒一個線程,將它放入synchronized同步阻塞線程池中(記住只有拿到鎖lock的線程才能進行房間)。調用鎖lock的notifyAll方法,即喚醒線程等待池所有線程。
註:當被wait阻塞的線程再次進入synchronized同步代碼塊時,會從wait方法調用之後的地方繼續執行。
在鎖lock的線程等待池中的線程,只有四種方式喚醒:
通過notify()喚醒
通過notifyAll()喚醒
通過interrupt()中斷喚醒
如果是通過調用wait(long timeout)進入等待狀態的線程,當時間超時的時候,也會被喚醒。
註意wait、notify和notifyAll方法必須先獲取鎖才能調用,否則拋出IllegalMonitorStateException異常。而只有synchronized模塊才能讓當前線程獲取鎖,所以wait方法只能在synchronized模塊中執行。
四. 其他重要方法
4.1 join方法
讓當前線程等待另一個線程執行完成後,才繼續執行。
publicfinalvoidjoin()throwsInterruptedException {join(0); }publicfinalsynchronizedvoidjoin(longmillis)throwsInterruptedException {// 獲取當前系統毫秒數longbase = System.currentTimeMillis();longnow =0;// millis小於0,拋出異常if(millis <0) {thrownewIllegalArgumentException("timeout value is negative"); }if(millis ==0) {// 通過isAlive判斷當前線程是否存活while(isAlive()) {// wait(0)表示當前線程無限等待wait(0); } }else{// 通過isAlive判斷當前線程是否存活while(isAlive()) {longdelay = millis - now;if(delay <=0) {break; }// 當前線程等待delay毫秒,超過時間,當前線程就被喚醒wait(delay); now = System.currentTimeMillis() - base; } } }
join方法是Thread中的方法,synchronized方法同步的鎖對象就是Thread對象,通過調用Thread對象的wait方法,讓當前線程等待
註意:這裡是讓當前線程等待,即當前調用join方法的線程,而不是Thread對象的線程。那麼當前線程什麼時候會被喚醒呢?
當Thread對象線程執行完畢,進入死亡狀態時,會調用Thread對象的notifyAll方法,來喚醒Thread對象的線程等待池中所有線程。
示例:
publicstaticvoidjoinTest(){ Thread thread =newThread(newRunnable() { @Overridepublicvoidrun(){for(inti =0; i <10; i++) {try{ Thread.sleep(100); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+": i==="+i); } } },"t1"); thread.start();try{ thread.join(); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+": end"); }
4.2 sleep方法
只是讓當前線程等待一定的時間,才繼續執行。
4.3 yield方法
將當前線程狀態從運行狀態轉成可運行狀態,如果再獲取CPU執行權,就繼續執行。
4.4 interrupt方法
中斷線程,它會中斷處於阻塞狀態下的線程,但是對於運行狀態下的線程不起任何作用。
示例:
publicstaticvoidinterruptTest(){// 處於阻塞狀態下的線程Thread thread =newThread(newRunnable() { @Overridepublicvoidrun(){try{ System.out.println(Thread.currentThread().getName()+" 開始"); Thread.sleep(1000); System.out.println(Thread.currentThread().getName()+" 結束"); }catch(InterruptedException e) { System.out.println(Thread.currentThread().getName()+" 產生異常"); } } },"t1"); thread.start();// 處於運行狀態下的線程Thread thread1 =newThread(newRunnable() { @Overridepublicvoidrun(){ System.out.println(Thread.currentThread().getName()+" 開始");inti =0;while(i < Integer.MAX_VALUE -10) { i = i +1;for(intj =0; j < i; j++); } System.out.println(Thread.currentThread().getName()+" i=="+i); System.out.println(Thread.currentThread().getName()+" 結束"); } },"t2"); thread1.start();try{ Thread.sleep(10); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+" 進行中斷"); thread.interrupt(); thread1.interrupt(); }
4.5 isInterrupted方法
返回這個線程是否被中斷。註意當調用線程的interrupt方法後,該線程的isInterrupted的方法就會返回true。如果異常被處理了,又會將該標誌位置位false,即isInterrupted的方法返回false。
4.6 線程優先順序以及守護線程
在java中線程優先順序範圍是1~10,預設的優先順序是5。
在java中線程分為用戶線程和守護線程,isDaemon返回是true,表示它是守護線程。當所有的用戶線程執行完畢後,java虛擬機就會退出,不管是否還有守護線程未執行完畢。
當創建一個新線程時,這個新線程的優先順序等於創建它線程的優先順序,且只有當創建它線程是守護線程時,新線程才是守護線程。
當然也可以通過setPriority方法修改線程的優先順序,已經setDaemon方法設置線程是否為守護線程。
五. 實例講解
5.1 不加任何同步鎖
importjava.util.Collections;importjava.util.List;importjava.util.concurrent.CopyOnWriteArrayList;importjava.util.concurrent.CountDownLatch;classData {intnum;publicData(intnum){this.num = num; }publicintgetAndDecrement(){returnnum--; }}classMyRun implements Runnable {privateData data;// 用來記錄所有賣出票的編號privateListlist;privateCountDownLatch latch;publicMyRun(Data data, Listlist, CountDownLatch latch){this.data = data;this.list=list;this.latch = latch; } @Overridepublicvoidrun(){try{ action(); } finally {// 釋放latch共用鎖latch.countDown(); } }// 進行買票操作,註意這裡沒有使用data.num>0作為判斷條件,直到賣完線程退出。// 那麼做會導致這兩處使用了共用變數data.num,那麼做多線程同步時,就要考慮更多條件。// 這裡只for迴圈了5次,表示每個線程只賣5張票,並將所有賣出去編號存入list集合中。publicvoidaction(){for(inti =0; i <5; i++) {try{ Thread.sleep(10); }catch(InterruptedException e) { e.printStackTrace(); }intnewNum = data.getAndDecrement(); System.out.println("線程"+Thread.currentThread().getName()+" num=="+newNum);list.add(newNum); } }}publicclassThreadTest {publicstaticvoidstartThread(Data data, String name, Listlist,CountDownLatch latch){ Thread t =newThread(newMyRun(data,list, latch), name); t.start(); }publicstaticvoidmain(String[] args){// 使用CountDownLatch來讓主線程等待子線程都執行完畢時,才結束CountDownLatch latch =newCountDownLatch(6);longstart = System.currentTimeMillis();// 這裡用併發list集合Listlist=newCopyOnWriteArrayList(); Data data =newData(30); startThread(data,"t1",list, latch); startThread(data,"t2",list, latch); startThread(data,"t3",list, latch); startThread(data,"t4",list, latch); startThread(data,"t5",list, latch); startThread(data,"t6",list, latch);try{ latch.await(); }catch(InterruptedException e) { e.printStackTrace(); }// 處理一下list集合,進行排序和翻轉Collections.sort(list); Collections.reverse(list); System.out.println(list);longtime = System.currentTimeMillis() - start;// 輸出一共花費的時間System.out.println("\n主線程結束 time=="+time); }}
輸出的結果是
線程t2num==29線程t6num==27線程t5num==28線程t4num==28線程t1num==30線程t3num==30線程t2num==26線程t4num==24線程t6num==25線程t5num==23線程t1num==22線程t3num==21線程t4num==20線程t6num==19線程t5num==18線程t2num==17線程t1num==16線程t3num==15線程t4num==14線程t5num==12線程t6num==13線程t1num==9線程t3num==10線程t2num==11線程t1num==8線程t6num==5線程t2num==7線程t5num==3線程t3num==4線程t4num==6[30,30,29,28,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3]主線程結束 time==62
從結果中發現問題,出現了重覆票,所以30張票沒有被賣完。最主要的原因就是Data類的getAndDecrement方法操作不是多線程安全的。
首先它不能保證原子性,分為三個操作,先讀取num的值,然後num自減,在返回自減前的值。
因為num不是volatile關鍵字修飾的,它也不能保證可見性和有序性。
所以只要保證getAndDecrement方法多線程安全,那麼就可以解決上面出現的問題。那麼保證getAndDecrement方法多線程安全呢?最簡單的方式就是在getAndDecrement方法前加synchronized關鍵字。
這是synchronized關鍵鎖就是這個data對象實例,所以保證了多線程調用getAndDecrement方法時,只有一個線程能調用,等待調用完成,其他線程才能調用getAndDecrement方法。
因為同一時間只有一個線程調用getAndDecrement方法,所以它在做num--操作時,不用擔心num變數會發生改變。所以原子性、可見性和有序性都可以得到保證。
5.2 使用最小同步鎖
classData{intnum; public Data(intnum) {this.num=num; }// 將getAndDecrement方法加了同步鎖public synchronizedintgetAndDecrement() {returnnum--; }}
輸出結果
線程t1num==30線程t2num==29線程t6num==28線程t4num==26線程t3num==27線程t5num==25線程t6num==22線程t2num==21線程t3num==23線程t1num==24線程t4num==20線程t5num==19線程t2num==18線程t3num==17線程t5num==13線程t4num==14線程t6num==16線程t1num==15線程t2num==12線程t4num==9線程t1num==7線程t5num==10線程t3num==11線程t6num==8線程t4num==6線程t2num==3線程t1num==2線程t3num==4線程t5num==5線程t6num==1[30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]主線程結束 time==61
我們只是將Data的getAndDecrement方法加了同步鎖,發現解決了多線程併發問題。主要是因為我們只在一處使用了共用變數num,所以只需要將這處加同步就行了。而且你會發現最後花費的總時間與沒加同步鎖時幾乎一樣,那麼因為我們同步代碼足夠小。
相反地,我們加地同步鎖不合理,可能也能實現多線程安全,但是耗時就會大大增加。
5.3 不合理地使用同步鎖
@Overridepublicvoidrun(){try{synchronized(data){ action(); } }finally{// 釋放latch共用鎖latch.countDown(); } }
輸入結果:
線程t1num==30線程t1num==29線程t1num==28線程t1num==27線程t1num==26線程t6num==25線程t6num==24線程t6num==23線程t6num==22線程t6num==21線程t5num==20線程t5num==19線程t5num==18線程t5num==17線程t5num==16線程t4num==15線程t4num==14線程t4num==13線程t4num==12線程t4num==11線程t3num==10線程t3num==9線程t3num==8線程t3num==7線程t3num==6線程t2num==5線程t2num==4線程t2num==3線程t2num==2線程t2num==1[30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]主線程結束 time==342
在這裡我們將整個action方法,放入同步代碼塊中,也可以解決多線程衝突問題,但是所耗費的時間是在getAndDecrement方法上加同步鎖時間的幾倍。
所以我們在加同步鎖的時候,那些需要同步,就是看那些地方使用了共用變數。比如這裡只在getAndDecrement方法中使用了同步變數,所以只要給它加鎖就行了。
但是如果在action方法中,使用data.num>0來作為迴圈條件,那麼在加同步鎖時,就必須將整個action方法放在同步模塊中,因為我們必須保證,在data.num>0判斷到getAndDecrement方法調用這些代碼都是在同步模塊中,不然就會產生多線程衝突問題。
福利:
想要瞭解更多多線程知識點的,可以關註我一下,我後續也會整理更多關於多線程這一塊的知識點分享出來,另外順便給大家推薦一個交流學習群:650385180,裡面會分享一些資深架構師錄製的視頻錄像:有Spring,MyBatis,Netty源碼分析,高併發、高性能、分散式、多線程、微服務架構的原理,JVM性能優化這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多。