天天看點

java面試題總結--多線程

一、建立多線程的方式

1.繼承Thread類

Thread類本質上是實作了Runnable接口的一個執行個體,代表一個線程的執行個體。啟動線程的唯一方法就是通過Thread類的start()執行個體方法。start()方法是一個native方法,它将啟動一個新線程,并執行run()方法。

public class MyThread extends Thread { 
	public void run() { 
		System.out.println("MyThread.run()"); 
		} 
} 
MyThread myThread1 = new MyThread(); 
myThread1.start();
           

2.實作Runnable接口

如果自己寫的類以及extends另一個類,就無法直接extends Thread類,此時,可以實作Runnable接口

public class MyThread extends OtherClas implements Runnable{
	public void run(){
		Systemt.out.println("MyThread.run()")
	}
}
           

3.ExecutorService、Callable、Futura有傳回值線程

有傳回值的任務必須實作Callable接口,類似的,無傳回值的任務必須Runnable接口。執行Callable任務後,可以擷取一個Future的對象,在該對象上調用get就可以擷取到Callable任務傳回的Object了,再結合線程池接口ExecutorService就可以實作傳說中有傳回結果的多線程了。

//建立一個線程池 
ExecutorService pool = Executors.newFixedThreadPool(taskSize); 
// 建立多個有傳回值的任務 
List<Future> list = new ArrayList<Future>(); 
for (int i = 0; i < taskSize; i++) { 
	Callable c = new MyCallable(i + " "); 
	// 執行任務并擷取Future對象 
	Future f = pool.submit(c); 
	list.add(f); 
} 
// 關閉線程池 
pool.shutdown(); 
// 擷取所有并發任務的運作結果 
for (Future f : list) { 
	// 從Future對象上擷取任務的傳回值,并輸出到控制台 
	System.out.println("res:" + f.get().toString()); 
}
           
// 建立線程池 
ExecutorService threadPool = Executors.newFixedThreadPool(10);
 while(true) { 
 	threadPool.execute(new Runnable() { 
 	// 送出多個線程任務,并執行 
 		@Override 
 		public void run() { 
 			System.out.println(Thread.currentThread().getName() + " is running .."); 
 			try { 
 				Thread.sleep(3000); 
 			} catch (InterruptedException e) {
 				 e.printStackTrace(); 
 			} 
 		} 
 	}); 
 } 
           

二、四種線程池說明

Java裡線程池的頂級接口是Executor,但是嚴格意義上講Executor并不是一個線程池,而隻是一個執行線程池的執行工具。真正意義的線程池接口是ExecutorService。

java面試題總結--多線程

1.newCachedThreadPool

建立一個可緩存的線程池. 如果線程池的大小超過了處理任務所需要的線程,那麼就會回收部分空閑(60秒不執行任務)的線程。當任務數增加時,此線程池又可以智能的添加新線程來處理任務。此線程池不會對錢程池大小做限制,線程池大小完全依賴于作業系統(或者說JVM) 能夠建立的最大線程大小。

2.newFixedThreadPool

建立固定大小的線程池,每次送出一個任務就建立一個線程,直到線程達到線程池的最大大小。線程池的大小一旦達到是大值就會保持不變。如果某個線程因為執行異常而結束,那麼線程池會補充一個新線程。

3.newSingleThreadExecutor

建立一個單線程的線程池。這個線程池隻有一個線程在工作,也就是相當于單線程串行執行所有任務。如果這個唯一的線程因為異常結束,那麼會有一個新的線程替代它。此線程池保證所有任務的執行順序按照任務的送出順序執行。

4.newScheduledThreadPool

建立一個大小無限的線程池。此線程池支援定時以及周期性執行任務的需求。

ScheduledExecutorService scheduledThreadPool= Executors.newScheduledThreadPool(3); scheduledThreadPool.schedule(newRunnable(){ 
	@Override 
	public void run() { 
		System.out.println("延遲三秒"); 
	} 
}, 3, TimeUnit.SECONDS); 
scheduledThreadPool.scheduleAtFixedRate(newRunnable(){ 
	@Override 
	public void run() {
		System.out.println("延遲1秒後每三秒執行一次"); 
	} 
},1,3,TimeUnit.SECONDS);
           

三、線程的生命周期(狀态)

java面試題總結--多線程

如上圖,當線程被建立并啟動以後,它既不是一啟動就進入了執行狀态,也不是一直處于執行狀态。線上程的生命周期中,它要經過建立(New)、就緒(Runnable)、運作(Running)、阻塞(Blocked)和死亡(Dead)5種狀态。尤其是當線程啟動以後,它不可能一直"霸占"着CPU獨自運作,是以CPU需要在多條線程之間切換,于是線程狀态也會多次在運作、阻塞之間切換。

1.建立狀态(NEW)

當程式使用new關鍵字建立了一個線程之後,該線程就處于建立狀态,此時僅由JVM為其配置設定記憶體,并初始化其成員變量的值

2.就緒狀态(RUNNABLE)

當線程對象調用了start()方法之後,該線程處于就緒狀态。Java虛拟機會為其建立方法調用棧和程式計數器,等待排程運作。

3.運作狀态(RUNNING)

如果處于就緒狀态的線程獲得了CPU,開始執行run()方法的線程執行體,則該線程處于運作狀态。

4.阻塞狀态(BLOCKED)

阻塞狀态是指線程因為某種原因放棄了cpu 使用權,也即讓出了cpu timeslice,暫時停止運作。直到線程進入可運作(runnable)狀态,才有機會再次獲得cpu timeslice 轉到運作(running)狀态。阻塞的情況分三種:

等待阻塞(o.wait->等待對列): 運作(running)的線程執行o.wait()方法,JVM會把該線程放入等待隊列(waitting queue)中。 同步阻塞(lock->鎖池): 運作(running)的線程在擷取對象的同步鎖時,若該同步鎖被别的線程占用,則JVM會把該線程放入鎖池(lock pool)中。

其他阻塞(sleep/join) : 運作(running)的線程執行Thread.sleep(long ms)或t.join()方法,或者發出了I/O請求時,JVM會把該線程置為阻塞狀态。當sleep()狀态逾時、join()等待線程終止或者逾時、或者I/O處理完畢時,線程重新轉入可運作(runnable)狀态。

5.死亡狀态

線程會以下面三種方式結束,結束後就是死亡狀态。

正常結束: run()或call()方法執行完成,線程正常結束。

異常結束 : 線程抛出一個未捕獲的Exception或Error。

調用stop : 直接調用該線程的stop()方法來結束該線程—該方法通常容易導緻死鎖,不推薦使用。

四、終止線程的四種方式

1.正常運作結束

程式運作結束,線程自動結束。

2.使用退出标志退出線程

一般run()方法執行完,線程就會正常結束,然而,常常有些線程是伺服線程。它們需要長時間的運作,隻有在外部某些條件滿足的情況下,才能關閉這些線程。使用一個變量來控制循環,例如:最直接的方法就是設一個boolean類型的标志,并通過設定這個标志為true或false來控制while循環是否退出,代碼示例:

public class ThreadSafe extends Thread { 
	public volatile boolean exit = false; 
	public void run() { 
		while (!exit){ 
			//do something 
		} 
	} 
}
           

定義了一個退出标志exit,當exit為true時,while循環退出,exit的預設值為false.在定義exit時,使用了一個Java關鍵字volatile,這個關鍵字的目的是使exit同步,也就是說在同一時刻隻能由一個線程來修改exit的值。

3.Interrupt方法結束線程

使用interrupt()方法來中斷線程有兩種情況:

線程處于阻塞狀态: 如使用了sleep,同步鎖的wait,socket中的receiver,accept等方法時,會使線程處于阻塞狀态。當調用線程的interrupt()方法時,會抛出InterruptException異常。阻塞中的那個方法抛出這個異常,通過代碼捕獲該異常,然後break跳出循環狀态,進而讓我們有機會結束這個線程的執行。通常很多人認為隻要調用interrupt方法線程就會結束,實際上是錯的, 一定要先捕獲InterruptedException異常之後通過break來跳出循環,才能正常結束run方法。

線程未處于阻塞狀态: 使用isInterrupted()判斷線程的中斷标志來退出循環。當使用interrupt()方法時,中斷标志就會置true,和使用自定義的标志來控制循環是一樣的道理。

public class ThreadSafe extends Thread {
	public void run() {
		while (!isInterrupted()){ //非阻塞過程中通過判斷中斷标志來退出
			try{
				Thread.sleep(5*1000);//阻塞過程捕獲中斷異常來退出
			}catch(InterruptedException e){
				e.printStackTrace();
				break;//捕獲到異常之後,執行break跳出循環
			}
		}
	}
}
           

4.stop方法終止線程(線程不安全)

程式中可以直接使用thread.stop()來強行終止線程,但是stop方法是很危險的,就象突然關閉計算機電源,而不是按正常程式關機一樣,可能會産生不可預料的結果,不安全主要是:thread.stop()調用之後,建立子線程的線程就會抛出ThreadDeatherror的錯誤,并且會釋放子線程所持有的所有鎖。一般任何進行加鎖的代碼塊,都是為了保護資料的一緻性,如果在調用thread.stop()後導緻了該線程所持有的所有鎖的突然釋放(不可控制),那麼被保護資料就有可能呈現不一緻性,其他線程在使用這些被破壞的資料時,有可能導緻一些很奇怪的應用程式錯誤。是以,并不推薦使用stop方法來終止線程。

五、鎖的種類

1.公平鎖/非公平鎖

公平鎖是指多個線程按照申請鎖的順序來擷取鎖。

非公平鎖是指多個線程擷取鎖的順序并不是按照申請鎖的順序,有可能後申請的線程比先申請的線程優先擷取鎖。有可能,會造成優先級反轉或者饑餓現象。

對于Java ReentrantLock而言,通過構造函數指定該鎖是否是公平鎖,預設是非公平鎖。非公平鎖的優點在于吞吐量比公平鎖大。

對于synchronized而言,也是一種非公平鎖。由于其并不像ReentrantLock是通過AQS的來實作線程排程,是以并沒有任何辦法使其變成公平鎖。

2.可重入鎖

可重入鎖又名遞歸鎖,是指在同一個線程在外層方法擷取鎖的時候,在進入内層方法會自動擷取鎖。對于Java ReentrantLock而言, 其名字是Reentrant Lock即是重新進入鎖。對于synchronized而言,也是一個可重入鎖。可重入鎖的一個好處是可一定程度避免死鎖。

synchronized void setA() throws Exception{
    Thread.sleep(1000);
    setB();
}
synchronized void setB() throws Exception{
    Thread.sleep(1000);
}
           

上面的代碼就是一個可重入鎖的一個特點,如果不是可重入鎖的話,setB可能不會被目前線程執行,可能造成死鎖。

3. 獨享鎖/共享鎖

獨享鎖是指該鎖一次隻能被一個線程所持有;共享鎖是指該鎖可被多個線程所持有。

對于Java ReentrantLock而言,其是獨享鎖。但是對于Lock的另一個實作類ReadWriteLock,其讀鎖是共享鎖,其寫鎖是獨享鎖。讀鎖的共享鎖可保證并發讀是非常高效的,讀寫、寫讀 、寫寫的過程是互斥的。獨享鎖與共享鎖也是通過AQS來實作的,通過實作不同的方法,來實作獨享或者共享。對于synchronized而言,當然是獨享鎖。

4.互斥鎖/讀寫鎖

上面說到的獨享鎖/共享鎖就是一種廣義的說法,互斥鎖/讀寫鎖就是具體的實作。互斥鎖在Java中的具體實作就是ReentrantLock;讀寫鎖在Java中的具體實作就是ReadWriteLock。

5.樂觀鎖/悲觀鎖

樂觀鎖與悲觀鎖不是指具體的什麼類型的鎖,而是指看待并發同步的角度。

悲觀鎖: 總是假設最壞的情況,每次去拿資料的時候都認為别人會修改,是以每次在拿資料的時候都會上鎖,這樣别人想拿這個資料就會阻塞直到它拿到鎖。比如Java裡面的同步原語synchronized關鍵字的實作就是悲觀鎖。

樂觀鎖: 顧名思義,就是很樂觀,每次去拿資料的時候都認為别人不會修改,是以不會上鎖,但是在更新的時候會判斷一下在此期間别人有沒有去更新這個資料,可以使用版本号等機制。樂觀鎖适用于多讀的應用類型,這樣可以提高吞吐量,在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實作方式CAS(Compare and Swap 比較并交換)實作的

6.分段鎖

分段鎖其實是一種鎖的設計,并不是具體的一種鎖,對于ConcurrentHashMap而言,其并發的實作就是通過分段鎖的形式來實作高效的并發操作,ConcurrentHashMap中的分段鎖稱為Segment,它即類似于HashMap(JDK7與JDK8中HashMap的實作)的結構,即内部擁有一個Entry數組,數組中的每個元素又是一個連結清單;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。當需要put元素的時候,并不是對整個HashMap進行加鎖,而是先通過hashcode來知道他要放在那一個分段中,然後對這個分段進行加鎖,是以當多線程put的時候,隻要不是放在一個分段中,就實作了真正的并行的插入。但是,在統計size的時候,可就是擷取HashMap全局資訊的時候,就需要擷取所有的分段鎖才能統計。

分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。

7.偏向鎖/輕量級鎖/重量級鎖

這三種鎖是指鎖的狀态,并且是針對synchronized。在Java 5通過引入鎖更新的機制來實作高效synchronized。這三種鎖的狀态是通過對象螢幕在對象頭中的字段來表明的。

偏向鎖是指一段同步代碼一直被一個線程所通路,那麼該線程會自動擷取鎖。降低擷取鎖的代價。

輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所通路,偏向鎖就會更新為輕量級鎖,其他線程會通過自旋的形式嘗試擷取鎖,不會阻塞,提高性能。

重量級鎖是指當鎖為輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有擷取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。

8.自旋鎖

在Java中,自旋鎖是指嘗試擷取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試擷取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。

六、CAS和AQS

1.CAS的概念和特性

CAS(Compare And Swap/Set)比較并交換,CAS算法的過程是這樣:它包含3個參數(V,E,N)。V表示要更新的變量(記憶體值),E表示預期值(舊的),N表示新值。當且僅當V值等于E值時,才會将V的值設為N,如果V值和E值不同,則說明已經有其他線程做了更新,則目前線程什麼都不做。最後,CAS傳回目前V的真實值。

CAS操作是抱着樂觀的态度進行的(樂觀鎖),它總是認為自己可以成功完成操作。當多個線程同時使用CAS操作一個變量時,隻有一個會勝出,并成功更新,其餘均會失敗。失敗的線程不會被挂起,僅是被告知失敗,并且允許再次嘗試,當然也允許失敗的線程放棄操作。基于這樣的原理,CAS操作即使沒有鎖,也可以發現其他線程對目前線程的幹擾,并進行恰當的處理。

2.原子包 java.util.concurrent.atomic(鎖自旋)

JDK1.5的原子包:java.util.concurrent.atomic這個包裡面提供了一組原子類。其基本的特性就是在多線程環境下,當有多個線程同時執行這些類的執行個體包含的方法時,具有排他性,即當某個線程進入方法,執行其中的指令時,不會被其他線程打斷,而别的線程就像自旋鎖一樣,一直等到該方法執行完成,才由JVM從等待隊列中選擇一個另一個線程進入,這隻是一種邏輯上的了解。 相對于對于synchronized這種阻塞算法,CAS是非阻塞算法的一種常見實作。由于一般CPU切換時間比CPU指令集操作更加長, 是以J.U.C在性能上有了很大的提升。如下代碼:

public class AtomicInteger extends Number implements java.io.Serializable { 
	private volatile int value; 
	public final int get() { 
		return value; 
	} 
	public final int getAndIncrement() { 
		for (;;) { 
			//CAS自旋,一直嘗試,直達成功 
			int current = get(); 
			int next = current + 1; 
			if (compareAndSet(current, next)) 
				return current; 
		} 
	} 
	public final boolean compareAndSet(int expect, int update) { 
		return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 
	} 
}
           

getAndIncrement采用了CAS操作,每次從記憶體中讀取資料然後将此資料和+1後的結果進行CAS操作,如果成功就傳回結果,否則重試直到成功為止。而compareAndSet利用JNI來完成CPU指令的操作。

cmpxchg
/*
	accoumulator = AL,AX,or EAX,depending on whether a byte, word, 
	or dubleword comparison is being  performed
*/
if(accounmulator == Destination){//更新的變量和舊得變量預期值是否相等
	ZF = 1;	//設定跳轉辨別
	Destination = Source;//原始值設定到目标裡面去
}else{
	ZF = 0;//不設定值
	accoumulator = Destination;
}
           

3.ABA問題

CAS會導緻“ABA問題”。CAS算法實作一個重要前提需要取出記憶體中某時刻的資料,而在下時刻比較并替換,那麼在這個時間差類會導緻資料的變化。 比如說一個線程one從記憶體位置V中取出A,這時候另一個線程two也從記憶體中取出A,并且two進行了一些操作變成了B,然後two又将V位置的資料變成A,這時候線程one進行CAS操作發現記憶體中仍然是A,然後one操作成功。盡管線程one的CAS操作成功,但是不代表這個過程就是沒有問題的。 部分樂觀鎖的實作是通過版本号(version)的方式來解決ABA問題,樂觀鎖每次在執行資料的修改操作時,都會帶上一個版本号,一旦版本号和資料的版本号一緻就可以執行修改操作并對版本号執行+1操作,否則就執行失敗。因為每次操作的版本号都會随之增加,是以不會出現ABA問題,因為版本号隻會增加不會減少。

七、線程的上下文切換

1.上下文切換的概念:

在多任務處理系統中,作業數通常大于CPU數。為了讓使用者覺得這些任務在同時進行,CPU給每個任務配置設定一定時間,把目前任務狀态儲存下來,目前運作任務轉為就緒(或者挂起、删除)狀态,另一個被標明的就緒任務成為目前任務。之後CPU可以回過頭再處理之前被挂起任務。上下文切換就是這樣一個過程,它允許CPU記錄并恢複各種正在運作程式的狀态,使它能夠完成切換操作。在這個過程中,CPU會停止處理目前運作的程式,并儲存目前程式運作的具體位置以便之後繼續運作。

2.上下文切換的步驟

在切換過程中,正在執行的程序的狀态必須以某種方式存儲起來,這樣在未來才能被恢複。這裡說的程序狀态包括該程序正在使用的所有寄存器(尤其是程式計數器),和一些必要的作業系統資料。儲存程序狀态的資料結構叫做“程序控制塊”(PCB,process control block);

PCB通常是系統記憶體占用區中的一個連續存區,它存放着作業系統用于描述程序情況及控制程序運作所需的全部資訊,它使一個在多道程式環境下不能獨立運作的程式成為一個能獨立運作的基本機關或一個能與其他程序并發執行的程序。

上下文切換的具體步驟是(假設目前程序是程序A,要切換到的下一個程序是程序B):

  1. 儲存程序A的狀态(寄存器和作業系統資料);
  2. 更新PCB中的資訊,對程序A的“運作态”做出相應更改;
  3. 将程序A的PCB放入相關狀态的隊列;
  4. 将程序B的PCB資訊改為“運作态”,并執行程序B;
  5. B執行完後,從隊列中取出程序A的PCB,恢複程序A被切換時的上下文,繼續執行A。

線程分為使用者級線程和核心級線程。同一程序中的使用者級線程切換的時候,隻需要儲存使用者寄存器的内容,程式計數器,棧指針,不需要模式切換。但是這樣會導緻線程阻塞和無法利用多處理器。而同一程序中的核心級線程切換的時候,就克服了這兩個缺點,但是除了儲存上下文,還要進行模式切換。

線程切換和程序切換的步驟也不同。程序的上下文切換分為兩步:1.切換頁目錄以使用新的位址空間;2.切換核心棧和硬體上下文。對于linux來說,線程和程序的最大差別就在于位址空間。對于線程切換,第1步是不需要做的,第2是程序和線程切換都要做的。是以明顯是程序切換代價大。線程上下文切換和程序上下文切換一個最主要的差別是線程的切換虛拟記憶體空間依然是相同的,但是程序切換是不同的。這兩種上下文切換的處理都是通過作業系統核心來完成的。核心的這種切換過程伴随的最顯著的性能損耗是将寄存器中的内容切換出。

3.引起上下文切換的原因

  1. 目前執行任務的時間片用完之後,系統CPU正常排程下一個任務;
  2. 目前執行任務碰到IO阻塞,排程器将此任務挂起,繼續下一任務;
  3. 多個任務搶占鎖資源,目前任務沒有搶到鎖資源,被排程器挂起,繼續下一任務;
  4. 使用者代碼挂起目前任務,讓出CPU時間;
  5. 硬體中斷;