網際網路校招面試必備——Java多執行緒 | 掘金技術徵文

wwwxmu發表於2018-09-18

本文首發於我的個人部落格:尾尾部落

本文是我刷了幾十篇一線網際網路校招java後端開發崗位的面經後總結的多執行緒相關題目,雖然有點小長,但是面試前看一看,相信能幫你輕鬆啃下多執行緒這塊大骨頭。

什麼是程式,什麼是執行緒?為什麼需要多執行緒程式設計?

程式是執行著的應用程式,而執行緒是程式內部的一個執行序列。一個程式可以有多個執行緒。執行緒又叫做輕量級程式。 程式是具有一定獨立功能的程式關於某個資料集合上的一次執行活動,是作業系統進行資源分配和排程的一個獨立單位;執行緒是程式的一個實體,是 CPU 排程和分派的基本單位,是比程式更小的能獨立執行的基本單位。執行緒的劃分尺度小於程式,這使得多執行緒程式的併發性高;程式在執行時通常擁有獨立的記憶體單元,而執行緒之間可以共享記憶體。使用多執行緒的程式設計通常能夠帶來更好的效能和使用者體驗,但是多執行緒的程式對於其他程式是不友好的,因為它佔用了更多的 CPU 資源。

程式間的通訊方式

  • 管道( pipe ):管道是一種半雙工的通訊方式,資料只能單向流動,而且只能在具有親緣關係的程式間使用。程式的親緣關係通常是指父子程式關係。
  • 有名管道 (namedpipe) : 有名管道也是半雙工的通訊方式,但是它允許無親緣關係程式間的通訊。
  • 訊號量(semophore ) : 訊號量是一個計數器,可以用來控制多個程式對共享資源的訪問。它常作為一種鎖機制,防止某程式正在訪問共享資源時,其他程式也訪問該資源。因此,主要作為程式間以及同一程式內不同執行緒之間的同步手段。
  • 訊息佇列( messagequeue ) : 訊息佇列是由訊息的連結串列,存放在核心中並由訊息佇列識別符號標識。訊息佇列克服了訊號傳遞資訊少、管道只能承載無格式位元組流以及緩衝區大小受限等缺點。
  • 訊號 (sinal) : 訊號是一種比較複雜的通訊方式,用於通知接收程式某個事件已經發生。
  • 共享記憶體(shared memory ) :共享記憶體就是對映一段能被其他程式所訪問的記憶體,這段共享記憶體由一個程式建立,但多個程式都可以訪問。共享記憶體是最快的 IPC 方式,它是針對其他程式間通訊方式執行效率低而專門設計的。它往往與其他通訊機制,如訊號兩,配合使用,來實現程式間的同步和通訊。
  • 套接字(socket ) : 套解口也是一種程式間通訊機制,與其他通訊機制不同的是,它可用於不同及其間的程式通訊。

執行緒間的通訊方式

  • 鎖機制:包括互斥鎖、條件變數、讀寫鎖
    • 互斥鎖提供了以排他方式防止資料結構被併發修改的方法。
    • 讀寫鎖允許多個執行緒同時讀共享資料,而對寫操作是互斥的。
    • 條件變數可以以原子的方式阻塞程式,直到某個特定條件為真為止。對條件的測試是在互斥鎖的保護下進行的。條件變數始終與互斥鎖一起使用。
  • 訊號量機制(Semaphore):包括無名執行緒訊號量和命名執行緒訊號量
  • 訊號機制(Signal):類似程式間的訊號處理

執行緒間的通訊目的主要是用於執行緒同步,所以執行緒沒有像程式通訊中的用於資料交換的通訊機制。

實現多執行緒的三種方法

  • 繼承Thread類,重寫父類run()方法
public class thread1 extends Thread {
        public void run() {
                for (int i = 0; i < 10000; i++) {
                        System.out.println("我是執行緒"+this.getId());
                }
        }
        public static void main(String[] args) {
                thread1 th1 = new thread1();
                thread1 th2 = new thread1();
                th1.start();
                th2.start();
        }
}
複製程式碼
  • 實現runnable介面
public class thread2 implements Runnable {
        public String ThreadName;
        public thread2(String tName){
                ThreadName = tName;
        }
        public void run() {
                for (int i = 0; i < 10000; i++) {
                        System.out.println(ThreadName);
                }
        }
        public static void main(String[] args) {
                // 建立一個Runnable介面實現類的物件
                thread2 th1 = new thread2("執行緒A:");
                thread2 th2 = new thread2("執行緒B:");
                // 將此物件作為形參傳遞給Thread類的構造器中,建立Thread類的物件,此物件即為一個執行緒
                Thread myth1 = new Thread(th1);
                Thread myth2 = new Thread(th2);
                // 呼叫start()方法,啟動執行緒並執行run()方法
                myth1.start();
                myth2.start();
        }
}
複製程式碼
  • 通過Callable和Future建立執行緒
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
 
public class CallableThreadTest implements Callable<Integer>
{
	@Override
	public Integer call() throws Exception{
		int i = 0;
		for(;i<100;i++){
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
		return i;
	}
	
	public static void main(String[] args){
		CallableThreadTest ctt = new CallableThreadTest();
		FutureTask<Integer> ft = new FutureTask<>(ctt);
		for(int i = 0;i < 100;i++){
			System.out.println(Thread.currentThread().getName()+" 的迴圈變數i的值"+i);
			if(i==20){
				new Thread(ft,"有返回值的執行緒").start();
			}
		}
		try{
			System.out.println("子執行緒的返回值:"+ft.get());
		} catch (InterruptedException e){
			e.printStackTrace();
		} catch (ExecutionException e){
			e.printStackTrace();
		}
	}
}
複製程式碼

三種建立多執行緒方法的對比

1、採用實現Runnable、Callable介面的方式建立多執行緒時,執行緒類只是實現了Runnable介面或Callable介面,還可以繼承其他類。缺點是程式設計稍微複雜,如果要訪問當前執行緒,則必須使用Thread.currentThread()方法。 2、使用繼承Thread類的方式建立多執行緒時,編寫簡單,如果需要訪問當前執行緒,則無需使用Thread.currentThread()方法,直接使用this即可獲得當前執行緒。缺點是執行緒類已經繼承了Thread類,所以不能再繼承其他父類。 3、Runnable和Callable的區別 (1) Callable規定重寫call(),Runnable重寫run()。 (2) Callable的任務執行後可返回值,而Runnable的任務是不能返回值的。 (3) call方法可以丟擲異常,run方法不可以。 (4) 執行Callable任務可以拿到一個Future物件,表示非同步計算的結果。它提供了檢查計算是否完成的方法,以等待計算的完成,並檢索計算的結果。通過Future物件可以瞭解任務執行情況,可取消任務的執行,還可獲取執行結果。

執行緒狀態

網際網路校招面試必備——Java多執行緒 | 掘金技術徵文

  • 新建狀態:新建執行緒物件,並沒有呼叫start()方法之前
  • 就緒狀態:呼叫start()方法之後執行緒就進入就緒狀態,但是並不是說只要呼叫start()方法執行緒就馬上變為當前執行緒,在變為當前執行緒之前都是為就緒狀態。值得一提的是,執行緒在睡眠和掛起中恢復的時候也會進入就緒狀態。
  • 執行狀態:執行緒被設定為當前執行緒,獲得CPU後,開始執行run()方法,就是執行緒進入執行狀態。
  • 阻塞狀態:處於執行的狀態的執行緒,除非執行時間非常非常非常短,否則它會因為系統對資源的排程而被中斷進入阻塞狀態。比如說呼叫sleep()方法後執行緒就進入阻塞狀態。
  • 死亡狀態:處於執行狀態的執行緒,當它主動或者被動結束,執行緒就處於死亡狀態。結束的形式,通常有以下幾種:1. 執行緒執行完成,執行緒正常結束;2. 執行緒執行過程中出現異常或者錯誤,被動結束;3. 執行緒主動呼叫stop方法結束執行緒。

執行緒控制

  • join():等待。讓一個執行緒等待另一個執行緒完成才繼續執行。如A執行緒執行緒執行體中呼叫B執行緒的join()方法,則A執行緒被阻塞,知道B執行緒執行完為止,A才能得以繼續執行。
  • sleep():睡眠。讓當前的正在執行的執行緒暫停指定的時間,並進入阻塞狀態。
  • yield():執行緒讓步。將執行緒從執行狀態轉換為就緒狀態。當某個執行緒呼叫 yiled() 方法從執行狀態轉換到就緒狀態後,CPU 會從就緒狀態執行緒佇列中只會選擇與該執行緒優先順序相同或優先順序更高的執行緒去執行。
  • setPriority():改變執行緒的優先順序。每個執行緒在執行時都具有一定的優先順序,優先順序高的執行緒具有較多的執行機會。每個執行緒預設的優先順序都與建立它的執行緒的優先順序相同。main執行緒預設具有普通優先順序。引數priorityLevel範圍在1-10之間,常用的有如下三個靜態常量值:MAX_PRIORITY:10;MIN_PRIORITY:1;NORM_PRIORITY:5。

PS: 具有較高執行緒優先順序的執行緒物件僅表示此執行緒具有較多的執行機會,而非優先執行。

  • setDaemon(true):設定為後臺執行緒。後臺執行緒主要是為其他執行緒(相對可以稱之為前臺執行緒)提供服務,或“守護執行緒”。如JVM中的垃圾回收執行緒。當所有的前臺執行緒都進入死亡狀態時,後臺執行緒會自動死亡。

sleep() 和 yield() 兩者的區別: ① sleep()方法會給其他執行緒執行的機會,不考慮其他執行緒的優先順序,因此會給較低優先順序執行緒一個執行的機會。yield()方法只會給相同優先順序或者更高優先順序的執行緒一個執行的機會。 ② 當執行緒執行了 sleep(long millis) 方法,將轉到阻塞狀態,引數millis指定睡眠時間。當執行緒執行了yield()方法,將轉到就緒狀態。 ③ sleep() 方法宣告丟擲InterruptedException異常,而 yield() 方法沒有宣告丟擲任何異常。

wait、notify、notifyAll的區別

wait、notify、notifyAll是java同步機制中重要的組成部分,結合synchronized關鍵字使用,可以建立很多優秀的同步模型。這3個方法並不是Thread類或者是Runnable介面的方法,而是Object類的3個本地方法。 呼叫一個Object的wait與notify/notifyAll的時候,必須保證呼叫程式碼對該Object是同步的,也就是說必須在作用等同於synchronized(obj){......}的內部才能夠去呼叫obj的wait與notify/notifyAll三個方法,否則就會報錯:java.lang.IllegalMonitorStateException:current thread not owner

先說兩個概念:鎖池和等待池 鎖池:假設執行緒A已經擁有了某個物件(注意:不是類)的鎖,而其它的執行緒想要呼叫這個物件的某個synchronized方法(或者synchronized塊),由於這些執行緒在進入物件的synchronized方法之前必須先獲得該物件的鎖的擁有權,但是該物件的鎖目前正被執行緒A擁有,所以這些執行緒就進入了該物件的鎖池中。 等待池:假設一個執行緒A呼叫了某個物件的wait()方法,執行緒A就會釋放該物件的鎖後,進入到了該物件的等待池中 @知乎--文龍

  • 如果執行緒呼叫了物件的 wait()方法,那麼執行緒便會處於該物件的等待池中,等待池中的執行緒不會去競爭該物件的鎖。
  • 當有執行緒呼叫了物件的 notifyAll()方法(喚醒所有 wait 執行緒)或 notify()方法(只隨機喚醒一個 wait 執行緒),被喚醒的的執行緒便會進入該物件的鎖池中,鎖池中的執行緒會去競爭該物件鎖。也就是說,呼叫了notify後只要一個執行緒會由等待池進入鎖池,而notifyAll會將該物件等待池內的所有執行緒移動到鎖池中,等待鎖競爭
  • 優先順序高的執行緒競爭到物件鎖的概率大,假若某執行緒沒有競爭到該物件鎖,它還會留在鎖池中,唯有執行緒再次呼叫 wait()方法,它才會重新回到等待池中。而競爭到物件鎖的執行緒則繼續往下執行,直到執行完了 synchronized 程式碼塊,它會釋放掉該物件鎖,這時鎖池中的執行緒會繼續競爭該物件鎖。

小結

  • wait:執行緒自動釋放其佔有的物件鎖,並等待notify
  • notify:喚醒一個正在wait當前物件鎖的執行緒,並讓它拿到物件鎖
  • notifyAll:喚醒所有正在wait當前物件鎖的執行緒 notify和notifyAll的最主要的區別是:notify只是喚醒一個正在wait當前物件鎖的執行緒,而notifyAll喚醒所有。值得注意的是:notify是本地方法,具體喚醒哪一個執行緒由虛擬機器控制;notifyAll後並不是所有的執行緒都能馬上往下執行,它們只是跳出了wait狀態,接下來它們還會是競爭物件鎖。

sleep() 和 wait() 有什麼區別?

sleep()方法是執行緒類(Thread)的靜態方法,導致此執行緒暫停執行指定時間,將執行機會給其他執行緒,但是監控狀態依然保持,到時後會自動恢復(執行緒回到就緒(ready)狀態),因為呼叫 sleep 不會釋放物件鎖。wait() 是 Object 類的方法,對此物件呼叫 wait()方法導致本執行緒放棄物件鎖(執行緒暫停執行),進入等待此物件的等待鎖定池,只有針對此物件發出 notify 方法(或 notifyAll)後本執行緒才進入物件鎖定池準備獲得物件鎖進入就緒狀態。

鎖型別

  • 可重入鎖:廣義上的可重入鎖指的是可重複可遞迴呼叫的鎖,在外層使用鎖之後,在內層仍然可以使用,並且不發生死鎖(前提得是同一個物件或者class),這樣的鎖就叫做可重入鎖。即在執行物件中所有同步方法不用再次獲得鎖。ReentrantLock和synchronized都是可重入鎖。舉個簡單的例子,當一個執行緒執行到某個synchronized方法時,比如說method1,而在method1中會呼叫另外一個synchronized方法method2,此時執行緒不必重新去申請鎖,而是可以直接執行方法method2。
  • 可中斷鎖:在等待獲取鎖過程中可中斷。synchronized就不是可中斷鎖,而Lock是可中斷鎖。
  • 公平鎖: 按等待獲取鎖的執行緒的等待時間進行獲取,等待時間長的具有優先獲取鎖權利。非公平鎖即無法保證鎖的獲取是按照請求鎖的順序進行的,這樣就可能導致某個或者一些執行緒永遠獲取不到鎖。synchronized是非公平鎖,它無法保證等待的執行緒獲取鎖的順序。對於ReentrantLock和ReentrantReadWriteLock,預設情況下是非公平鎖,但是可以設定為公平鎖。
  • 讀寫鎖:對資源讀取和寫入的時候拆分為2部分處理,一個讀鎖和一個寫鎖。讀的時候可以多執行緒一起讀,寫的時候必須同步地寫。ReadWriteLock就是讀寫鎖,它是一個介面,ReentrantReadWriteLock實現了這個介面。可以通過readLock()獲取讀鎖,通過writeLock()獲取寫鎖。

什麼是樂觀鎖和悲觀鎖

(1)樂觀鎖:很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會去判斷在此期間有沒有人去更新這個資料(可以使用版本號等機制)。如果因為衝突失敗就重試。樂觀鎖適用於寫比較少的情況下,即衝突比較少發生,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。像資料庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。 (2)悲觀鎖:總是假設最壞的情況,每次去拿資料的時候都認為別人會修改,因此每次拿資料的時候都會上鎖,這樣別人想拿這個資料就會阻塞直到它拿到鎖,效率比較低。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。再比如Java裡面的同步原語synchronized關鍵字的實現也是悲觀鎖。

樂觀鎖的實現方式(CAS)

樂觀鎖的實現主要就兩個步驟:衝突檢測和資料更新。其實現方式有一種比較典型的就是 Compare and Swap ( CAS )。 CAS:CAS是樂觀鎖技術,當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其它執行緒都失敗,失敗的執行緒並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。 CAS 操作中包含三個運算元 —— 需要讀寫的記憶體位置(V)、進行比較的預期原值(A)和擬寫入的新值(B)。如果記憶體位置V的值與預期原值A相匹配,那麼處理器會自動將該位置值更新為新值B。否則處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。(在 CAS 的一些特殊情況下將僅返回 CAS 是否成功,而不提取當前值。)CAS 有效地說明了“ 我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。 ”這其實和樂觀鎖的衝突檢查+資料更新的原理是一樣的。

樂觀鎖是一種思想,CAS是這種思想的一種實現方式。

CAS的缺點

  1. ABA問題

如果記憶體地址V初次讀取的值是A,並且在準備賦值的時候檢查到它的值仍然為A,那我們就能說它的值沒有被其他執行緒改變過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA”問題。ava併發包為了解決這個問題,提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變數值的版本來保證CAS的正確性。因此,在使用CAS前要考慮清楚“ABA”問題是否會影響程式併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

  1. 迴圈時間長開銷很大

自旋CAS(不成功,就一直迴圈執行,直到成功)如果長時間不成功,會給CPU帶來非常大的執行開銷。

  1. 只能保證一個共享變數的原子操作。

當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。

實現一個死鎖

什麼是死鎖:兩個程式都在等待對方執行完畢才能繼續往下執行的時候就發生了死鎖。結果就是兩個程式都陷入了無限的等待中。 產生死鎖的四個必要條件: 互斥條件:一個資源每次只能被一個程式使用。 請求與保持條件:一個程式因請求資源而阻塞時,對已獲得的資源保持不放。 不剝奪條件:程式已獲得的資源,在末使用完之前,不能強行剝奪。 迴圈等待條件:若干程式之間形成一種頭尾相接的迴圈等待資源關係。 這四個條件是死鎖的必要條件,只要系統發生死鎖,這些條件必然成立,而只要上述條件之一不滿足,就不會發生死鎖。 考慮如下情形: (1)執行緒A當前持有互斥所鎖lock1,執行緒B當前持有互斥鎖lock2。 (2)執行緒A試圖獲取lock2,因為執行緒B正持有lock2,因此執行緒A會阻塞等待執行緒B對lock2釋放。 (3)如果此時執行緒B也在試圖獲取lock1,同理執行緒也會阻塞。 (4)兩者都在等待對方所持有但是雙方都不釋放的鎖,這時便會一直阻塞形成死鎖。 死鎖的解決方法: a 撤消陷於死鎖的全部程式; b 逐個撤消陷於死鎖的程式,直到死鎖不存在; c 從陷於死鎖的程式中逐個強迫放棄所佔用的資源,直至死鎖消失。 d 從另外一些程式那裡強行剝奪足夠數量的資源分配給死鎖程式,以解除死鎖狀態

如何確保 N 個執行緒可以訪問 N 個資源同時又不導致死鎖?

使用多執行緒的時候,一種非常簡單的避免死鎖的方式就是:指定獲取鎖的順序,並強制執行緒按照指定的順序獲取鎖。因此,如果所有的執行緒都是以同樣的順序加鎖和釋放鎖,就不會出現死鎖了

volatile關鍵字

  對於過可見性、有序性及原子性問題,通常情況下我們可以通過Synchronized關鍵字來解決這些個問題,不過如果對Synchronized原理有了解的話,應該知道Synchronized是一個比較重量級的操作,對系統的效能有比較大的影響,所以,如果有其他解決方案,我們通常都避免使用Synchronized來解決問題。而volatile關鍵字就是Java中提供的另一種解決可見性和有序性問題的方案。對於原子性,需要強調一點,也是大家容易誤解的一點:對volatile變數的單次讀/寫操作可以保證原子性的,如long和double型別變數,但是並不能保證i++這種操作的原子性,因為本質上i++是讀、寫兩次操作。

  • 防止重排序

問題:作業系統可以對指令進行重排序,多執行緒環境下就可能將一個未初始化的物件引用暴露出來,從而導致不可預料的結果 解決原理:volatile關鍵字通過提供“記憶體屏障”的方式來防止指令被重排序,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。 1、在每個volatile寫操作前插入StoreStore屏障,在寫操作後插入StoreLoad屏障。 2、在每個volatile讀操作前插入LoadLoad屏障,在讀操作後插入LoadStore屏障。

  • 實現可見性

問題:可見性問題主要指一個執行緒修改了共享變數值,而另一個執行緒卻看不到 解決原理:(1)修改volatile變數時會強制將修改後的值重新整理的主記憶體中。 (2)修改volatile變數後會導致其他執行緒工作記憶體中對應的變數值失效。因此,再讀取該變數值的時候就需要重新從讀取主記憶體中的值。

  • 注:volatile並不保證變數更新的原子性

volatile使用建議

相對於synchronized塊的程式碼鎖,volatile應該是提供了一個輕量級的針對共享變數的鎖,當我們在多個執行緒間使用共享變數進行通訊的時候需要考慮將共享變數用volatile來修飾。 volatile是一種稍弱的同步機制,在訪問volatile變數時不會執行加鎖操作,也就不會執行執行緒阻塞,因此volatile變數是一種比synchronized關鍵字更輕量級的同步機制。 使用建議:在兩個或者更多的執行緒需要訪問的成員變數上使用volatile。當要訪問的變數已在synchronized程式碼塊中,或者為常量時,沒必要使用volatile。 由於使用volatile遮蔽掉了JVM中必要的程式碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。

volatile和synchronized區別

1、volatile不會進行加鎖操作: volatile變數是一種稍弱的同步機制在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比synchronized關鍵字更輕量級的同步機制。 2、volatile變數作用類似於同步變數讀寫操作: 從記憶體可見性的角度看,寫入volatile變數相當於退出同步程式碼塊,而讀取volatile變數相當於進入同步程式碼塊。 3、volatile不如synchronized安全: 在程式碼中如果過度依賴volatile變數來控制狀態的可見性,通常會比使用鎖的程式碼更脆弱,也更難以理解。僅當volatile變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它。一般來說,用同步機制會更安全些。 4、volatile無法同時保證記憶體可見性和原子性: 加鎖機制(即同步機制)既可以確保可見性又可以確保原子性,而volatile變數只能確保可見性,原因是宣告為volatile的簡單變數如果當前值與該變數以前的值相關,那麼volatile關鍵字不起作用,也就是說如下的表示式都不是原子操作:“count++”、“count = count+1”。

當且僅當滿足以下所有條件時,才應該使用volatile變數: 1、對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。 2、該變數沒有包含在具有其他變數的不變式中。 總結:在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在、jdK1.5之後,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的效能明顯有了很大的提升。

synchronized

synchronized可以保證方法或者程式碼塊在執行時,同一時刻只有一個方法可以進入到臨界區,同時它還可以保證共享變數的記憶體可見性。Synchronized主要有以下三個作用:保證互斥性、保證可見性、保證順序性。

synchronized的三種應用方式

  • 修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖。實現原理:指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先持有monitor(虛擬機器規範中用的是管程一詞), 然後再執行方法,最後再方法完成(無論是正常完成還是非正常完成)時釋放monitor。

    public synchronized void increase(){
        i++;
    }
    複製程式碼
  • 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖

    public static synchronized void increase(){
        i++;
    }
    複製程式碼
  • 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。實現原理:使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步程式碼塊的開始位置,monitorexit指令則指明同步程式碼塊的結束位置。

    static AccountingSync instance=new AccountingSync();
    synchronized(instance){
        for(int j=0;j<1000000;j++){
            i++;
        }
    }
    複製程式碼

Lock

Lock是一個介面,它的的實現類提供了比synchronized更廣泛意義上鎖操作,它允許使用者更靈活的程式碼結構,更多的不同特效。Lock的實現類主要有ReentrantLock和ReentrantReadWriteLock。

Lock lock=new ReentrantLock();
lock.lock();
try{
    // do something
    // 如果有return要寫在try塊中
}finally{
    lock.unlock();
}
複製程式碼

Lock介面中獲取鎖的方法

  • void lock():lock()方法是平常使用得最多的一個方法,就是用來獲取鎖。如果鎖已被其他執行緒獲取,則進行等待。在發生異常時,它不會自動釋放鎖,要記得在finally塊中釋放鎖,以保證鎖一定被被釋放,防止死鎖的發生。
  • void lockInterruptibly():可以響應中斷,當通過這個方法去獲取鎖時,如果執行緒 正在等待獲取鎖,則這個執行緒能夠響應中斷,即中斷執行緒的等待狀態。
  • boolean tryLock():有返回值的,它表示用來嘗試獲取鎖,如果獲取成功,則返回true;如果獲取失敗(即鎖已被其他執行緒獲取),則返回false。
  • boolean tryLock(long time, TimeUnit unit):和tryLock()方法是類似的,只不過區別在於這個方法在拿不到鎖時會等待一定的時間,在時間期限之內如果還拿不到鎖,就返回false,同時可以響應中斷。

Condition類

Condition是Java提供來實現等待/通知的類,Condition類還提供比wait/notify更豐富的功能,Condition物件是由lock物件所建立的。但是同一個鎖可以建立多個Condition的物件,即建立多個物件監視器。這樣的好處就是可以指定喚醒執行緒。notify喚醒的執行緒是隨機喚醒一個。 Condition 將 Object 監視器方法(wait、notify 和 notifyAll)分解成截然不同的物件,以便通過將這些物件與任意 Lock 實現組合使用,為每個物件提供多個等待 set (wait-set)。 其中,Lock 替代了 synchronized 方法和語句的使用,Condition 替代了 Object 監視器方法的使用。 在Condition中,用await()替換wait(),用signal()替換notify(),用signalAll()替換notifyAll(),傳統執行緒的通訊方式,Condition都可以實現,這裡注意,Condition是被繫結到Lock上的,要建立一個Lock的Condition必須用newCondition()方法。

Condition與Object中的wait, notify, notifyAll區別

1.Condition中的await()方法相當於Object的wait()方法,Condition中的signal()方法相當於Object的notify()方法,Condition中的signalAll()相當於Object的notifyAll()方法。 不同的是,Object中的這些方法是和同步鎖捆綁使用的;而Condition是需要與互斥鎖/共享鎖捆綁使用的。 2.Condition它更強大的地方在於:能夠更加精細的控制多執行緒的休眠與喚醒。對於同一個鎖,我們可以建立多個Condition,在不同的情況下使用不同的Condition。 例如,假如多執行緒讀/寫同一個緩衝區:當向緩衝區中寫入資料之後,喚醒"讀執行緒";當從緩衝區讀出資料之後,喚醒"寫執行緒";並且當緩衝區滿的時候,"寫執行緒"需要等待;當緩衝區為空時,"讀執行緒"需要等待。 如果採用Object類中的wait(),notify(),notifyAll()實現該緩衝區,當向緩衝區寫入資料之後需要喚醒"讀執行緒"時,不可能通過notify()或notifyAll()明確的指定喚醒"讀執行緒",而只能通過notifyAll喚醒所有執行緒(但是notifyAll無法區分喚醒的執行緒是讀執行緒,還是寫執行緒)。 但是,通過Condition,就能明確的指定喚醒讀執行緒。

synchronized和lock的區別

synchronized Lock
存在層次 Java的關鍵字 是一個介面
鎖的釋放 1、以獲取鎖的執行緒執行完同步程式碼,釋放鎖 2、執行緒執行發生異常,jvm會讓執行緒釋放鎖 在finally中必須釋放鎖,不然容易造成執行緒死鎖
鎖的獲取 假設A執行緒獲得鎖,B執行緒等待。如果A執行緒阻塞,B執行緒會一直等待 Lock可以讓等待鎖的執行緒響應中斷
鎖狀態 無法判斷 可以判斷有沒有成功獲取鎖
鎖型別 可重入 不可中斷 非公平 可重入 可中斷 公平/非公平

效能方面,JDK1.5中,synchronized是效能低效的。因為這是一個重量級操作,它對效能最大的影響是阻塞的是實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發性帶來了很大的壓力。相比之下使用Java提供的Lock物件,效能更高一些。多執行緒環境下,synchronized的吞吐量下降的非常嚴重,而ReentrankLock則能基本保持在同一個比較穩定的水平上。

到了JDK1.6,synchronize加入了很多優化措施,有自適應自旋,鎖消除,鎖粗化,輕量級鎖,偏向鎖等等。導致在JDK1.6上synchronize的效能並不比Lock差。官方也表示,他們也更支援synchronize,在未來的版本中還有優化餘地,所以還是提倡在synchronized能實現需求的情況下,優先考慮使用synchronized來進行同步。

鎖的狀態

Java SE1.6為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖”,所以在Java SE1.6裡鎖一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態,它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。

偏向鎖

在沒有實際競爭的情況下,還能夠針對部分場景繼續優化。如果不僅僅沒有實際競爭,自始至終,使用鎖的執行緒都只有一個,那麼,維護輕量級鎖都是浪費的。偏向鎖的目標是,減少無競爭且只有一個執行緒使用鎖的情況下,使用輕量級鎖產生的效能消耗。輕量級鎖每次申請、釋放鎖都至少需要一次CAS,但偏向鎖只有初始化時需要一次CAS。 “偏向”的意思是,偏向鎖假定將來只有第一個申請鎖的執行緒會使用鎖(不會有任何執行緒再來申請鎖),因此,只需要在Mark Word中CAS記錄owner(本質上也是更新,但初始值為空),如果記錄成功,則偏向鎖獲取成功,記錄鎖狀態為偏向鎖,以後當前執行緒等於owner就可以零成本的直接獲得鎖;否則,說明有其他執行緒競爭,膨脹為輕量級鎖。 偏向鎖無法使用自旋鎖優化,因為一旦有其他執行緒申請鎖,就破壞了偏向鎖的假定。

輕量級鎖

輕量級鎖是由偏向所升級來的,偏向鎖執行在一個執行緒進入同步塊的情況下,當第二個執行緒加入鎖爭用的時候,偏向鎖就會升級為輕量級鎖。輕量級鎖是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用產生的效能消耗。輕量級鎖所適應的場景是執行緒交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。 使用輕量級鎖時,不需要申請互斥量,僅僅將Mark Word中的部分位元組CAS更新指向執行緒棧中的Lock Record,如果更新成功,則輕量級鎖獲取成功,記錄鎖狀態為輕量級鎖;否則,說明已經有執行緒獲得了輕量級鎖,目前發生了鎖競爭(不適合繼續使用輕量級鎖),接下來膨脹為重量級鎖。

重量級鎖

重量鎖在JVM中又叫物件監視器(Monitor),它很像C中的Mutex,除了具備Mutex(0|1)互斥的功能,它還負責實現了Semaphore(訊號量)的功能,也就是說它至少包含一個競爭鎖的佇列,和一個訊號阻塞佇列(wait佇列),前者負責做互斥,後一個用於做執行緒同步。

自旋鎖

自旋鎖原理非常簡單,如果持有鎖的執行緒能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做核心態和使用者態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的執行緒釋放鎖後即可立即獲取鎖,這樣就避免使用者執行緒和核心的切換的消耗。 但是執行緒自旋是需要消耗cup的,說白了就是讓cup在做無用功,如果一直獲取不到鎖,那執行緒也不能一直佔用cup自旋做無用功,所以需要設定一個自旋等待的最大時間。 如果持有鎖的執行緒執行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的執行緒在最大等待時間內還是獲取不到鎖,這時爭用執行緒會停止自旋進入阻塞狀態。

自適應自旋鎖

自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定:

  • 如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個迴圈。
  • 相反的,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能減少自旋時間甚至省略自旋過程,以避免浪費處理器資源。

自適應自旋解決的是“鎖競爭時間不確定”的問題。JVM很難感知到確切的鎖競爭時間,而交給使用者分析就違反了JVM的設計初衷。自適應自旋假定不同執行緒持有同一個鎖物件的時間基本相當,競爭程度趨於穩定,因此,可以根據上一次自旋的時間與結果調整下一次自旋的時間。

偏向鎖、輕量級鎖、重量級鎖適用於不同的併發場景

偏向鎖:無實際競爭,且將來只有第一個申請鎖的執行緒會使用鎖。 輕量級鎖:無實際競爭,多個執行緒交替使用鎖;允許短時間的鎖競爭。 重量級鎖:有實際競爭,且鎖競爭時間長。 另外,如果鎖競爭時間短,可以使用自旋鎖進一步優化輕量級鎖、重量級鎖的效能,減少執行緒切換。 如果鎖競爭程度逐漸提高(緩慢),那麼從偏向鎖逐步膨脹到重量鎖,能夠提高系統的整體效能。

鎖膨脹的過程:只有一個執行緒進入臨界區(偏向鎖),多個執行緒交替進入臨界區(輕量級鎖),多執行緒同時進入臨界區(重量級鎖)。

AQS

AQS即是AbstractQueuedSynchronizer,一個用來構建鎖和同步工具的框架,包括常用的ReentrantLock、CountDownLatch、Semaphore等。 AbstractQueuedSynchronizer是一個抽象類,主要是維護了一個int型別的state屬性和一個非阻塞、先進先出的執行緒等待佇列;其中state是用volatile修飾的,保證執行緒之間的可見性,佇列的入隊和出對操作都是無鎖操作,基於自旋鎖和CAS實現;另外AQS分為兩種模式:獨佔模式和共享模式,像ReentrantLock是基於獨佔模式模式實現的,CountDownLatch、CyclicBarrier等是基於共享模式。

執行緒池

如果併發的執行緒數量很多,並且每個執行緒都是執行一個時間很短的任務就結束了,這樣頻繁建立執行緒就會大大降低系統的效率,因為頻繁建立執行緒和銷燬執行緒需要時間。 執行緒池的產生和資料庫的連線池類似,系統啟動一個執行緒的代價是比較高昂的,如果在程式啟動的時候就初始化一定數量的執行緒,放入執行緒池中,在需要是使用時從池子中去,用完再放回池子裡,這樣能大大的提高程式效能,再者,執行緒池的一些初始化配置,也可以有效的控制系統併發的數量,防止因為消耗過多的記憶體,而把伺服器累趴下。

通過Executors工具類可以建立各種型別的執行緒池,如下為常見的四種:

  • newCachedThreadPool :大小不受限,當執行緒釋放時,可重用該執行緒;
  • newFixedThreadPool :大小固定,無可用執行緒時,任務需等待,直到有可用執行緒;
  • newSingleThreadExecutor :建立一個單執行緒,任務會按順序依次執行;
  • newScheduledThreadPool:建立一個定長執行緒池,支援定時及週期性任務執行

使用執行緒池的好處

  • 減少了建立和銷燬執行緒的次數,每個工作執行緒都可以被重複利用,可執行多個任務。
  • 運用執行緒池能有效的控制執行緒最大併發數,可以根據系統的承受能力,調整執行緒池中工作線執行緒的數目,防止因為消耗過多的記憶體,而把伺服器累趴下(每個執行緒需要大約1MB記憶體,執行緒開的越多,消耗的記憶體也就越大,最後當機)。
  • 對執行緒進行一些簡單的管理,比如:延時執行、定時迴圈執行的策略等,運用執行緒池都能進行很好的實現

執行緒池都有哪幾種工作佇列

1、ArrayBlockingQueue 是一個基於陣列結構的有界阻塞佇列,此佇列按 FIFO(先進先出)原則對元素進行排序。 2、LinkedBlockingQueue 一個基於連結串列結構的阻塞佇列,此佇列按FIFO (先進先出) 排序元素,吞吐量通常要高於ArrayBlockingQueue。靜態工廠方法Executors.newFixedThreadPool()使用了這個佇列 3、SynchronousQueue 一個不儲存元素的阻塞佇列。每個插入操作必須等到另一個執行緒呼叫移除操作,否則插入操作一直處於阻塞狀態,吞吐量通常要高於LinkedBlockingQueue,靜態工廠方法Executors.newCachedThreadPool使用了這個佇列。 4、PriorityBlockingQueue 一個具有優先順序的無限阻塞佇列。

參考

Java 多執行緒

Java併發:volatile記憶體可見性和指令重排

併發程式設計的鎖機制:synchronized和lock

淺談偏向鎖、輕量級鎖、重量級鎖

獲取最新資訊,請關注微信公眾號:南強說晚安

網際網路校招面試必備——Java多執行緒 | 掘金技術徵文

我在參加掘金技術徵文 徵文活動地址

網際網路校招面試必備——Java多執行緒 | 掘金技術徵文

相關文章