(2021最新版)Java後端面試題|Java多線程與併發

前言

很多朋友問,如何短時間突擊 Java 通過面試?

面試前還是很有必要針對性的刷一些題,很多朋友的實戰能力很強,但是理論比較薄弱,面試前不做準備是很喫虧的。這裏整理了很多面試常考的一些面試題,希望能幫助到你面試前的複習並且找到一個好的工作,也節省你在網上搜索資料的時間來學習。

整理的這些Java面試題,包括Java基礎、Java多線程與併發編程、spring、spring mvc、spring boot、mybatis。MySQL、Redis、消息中間件MQ、分佈式與微服務。持續更新中…

完整版Java面試題地址:105道Java面試題總結|含答案解析

內容 地址
Java基礎 https://my.oschina.net/u/4361958/blog/5011060
多線程與併發 本文
Spring 未更新
Spring MVC、Spring Boot 未更新
MyBatis 未更新
MySQL 未更新
Redis 未更新
分佈式與微服務 未更新
MQ 未更新

1、線程的生命週期?線程有幾種狀態

線程通常有五種狀態,創建,就緒,運行、阻塞和死亡狀態

(1) 新建狀態(New):新創建了一個線程對象。

(2) 就緒狀態(Runnable):線程對象創建後,其他線程調用了該對象的start方法。該狀態的線程位於可運行線程池中,變得可運行,等待獲取CPU的使用權。

(3) 運行狀態(Running):就緒狀態的線程獲取了CPU,執行程序代碼。

(4) 阻塞狀態(Blocked):阻塞狀態是線程因爲某種原因放棄CPU使用權,暫時停止運行。直到線程進 入就緒狀態,纔有機會轉到運行狀態。

(5) 死亡狀態(Dead):線程執行完了或者因異常退出了run方法,該線程結束生命週期。

阻塞的情況又分爲三種:

等待阻塞:運行的線程執行wait方法,該線程會釋放佔用的所有資源,JVM會把該線程放入“等待池”中。進入這個狀態後,是不能自動喚醒的,必須依靠其他線程調用notify或notifyAll方法才能被喚醒,wait是object類的方法

同步阻塞:運行的線程在獲取對象的同步鎖時,若該同步鎖被別的線程佔用,則JVM會把該線程放入“鎖池”中。

其他阻塞:運行的線程執行sleep或join方法,或者發出了I/O請求時,JVM會把該線程置爲阻塞狀態。當sleep狀態超時、join等待線程終止或者超時、或者I/O處理完畢時,線程重新轉入就緒狀態。sleep是Thread類的方法

2、sleep()、wait()、join()、yield()的區別

(1)鎖池

所有需要競爭同步鎖的線程都會放在鎖池當中,比如當前對象的鎖已經被其中一個線程得到,則其他線程需要在這個鎖池進行等待,當前面的線程釋放同步鎖後鎖池中的線程去競爭同步鎖,當某個線程得到後會進入就緒隊列進行等待cpu資源分配。

(2)等待池

當我們調用wait()方法後,線程會放到等待池當中,等待池的線程是不會去競爭同步鎖。只有調用了notify()或notifyAll()後等待池的線程纔會開始去競爭鎖,notify()是隨機從等待池選出一個線程放到鎖池,而notifyAll()是將等待池的所有線程放到鎖池當中

1)sleep 是 Thread 類的靜態本地方法,wait 則是 Object 類的本地方法。

2)sleep方法不會釋放lock,但是wait會釋放,而且會加入到等待隊列中。

sleep就是把cpu的執行資格和執行權釋放出去,不再運行此線程,當定時時間結束再取回cpu資源,參與cpu的調度,獲取到cpu資源後就可以繼續運行了。而如果sleep時該線程有鎖,那麼sleep不會釋放這個鎖,而是把鎖帶着進入了凍結狀態,也就是說其他需要這個鎖的線程根本不可能獲取到這個鎖。也就是說無法執行程序。如果在睡眠期間其他線程調用了這個線程的interrupt方法,那麼這個線程也會拋出interruptexception異常返回,這點和wait是一樣的。

3)sleep方法不依賴於同步器synchronized,但是wait需要依賴synchronized關鍵字。

4)sleep不需要被喚醒(休眠之後推出阻塞),但是wait需要(不指定時間需要被別人中斷)。

5)sleep 一般用於當前線程休眠,或者輪循暫停操作,wait 則多用於多線程之間的通信。

6)sleep 會讓出 CPU 執行時間且強制上下文切換,而 wait 則不一定,wait 後可能還是有機會重新競 爭到鎖繼續執行的。

yield()執行後線程直接進入就緒狀態,馬上釋放了cpu的執行權,但是依然保留了cpu的執行資格,所以有可能cpu下次進行線程調度還會讓這個線程獲取到執行權繼續執行

join()執行後線程進入阻塞狀態,例如在線程B中調用線程A的join(),那線程B會進入到阻塞隊列,直到線程A結束或中斷線程

public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(new Runnable() {
		@Override
		public void run() {
			try {
				Thread.sleep(3000);
			}
			catch (InterruptedException e) {
				e.printStackTrace();
			}
			System.out.println("22222222");
		}
	}
	);
	t1.start();
	t1.join();
	// 這行代碼必須要等t1全部執行完畢,纔會執行
	System.out.println("1111");
}
22222222
1111

3、對線程安全的理解

不是線程安全、應該是內存安全,堆是共享內存,可以被所有線程訪問

當多個線程訪問一個對象時,如果不用進行額外的同步控制或其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,我們就說這個對象是線程安全的

堆是進程和線程共有的空間,分全局堆和局部堆。全局堆就是所有沒有分配的空間,局部堆就是用戶分配的空間。堆在操作系統對進程初始化的時候分配,運行過程中也可以向系統要額外的堆,但是用完了要還給操作系統,要不然就是內存泄漏。

在Java中,堆是Java虛擬機所管理的內存中最大的一塊,是所有線程共享的一塊內存區域,在虛擬機啓動時創建。堆所存在的內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這裏分配內存。

棧是每個線程獨有的,保存其運行狀態和局部自動變量的。棧在線程開始的時候初始化,每個線程的棧互相獨立,因此,棧是線程安全的。操作系統在切換線程的時候會自動切換棧。棧空間不需要在高級語言裏面顯式的分配和釋放。

目前主流操作系統都是多任務的,即多個進程同時運行。爲了保證安全,每個進程只能訪問分配給自己的內存空間,而不能訪問別的進程的,這是由操作系統保障的。

在每個進程的內存空間中都會有一塊特殊的公共區域,通常稱爲堆(內存)。進程內的所有線程都可以訪問到該區域,這就是造成問題的潛在原因。

4、Thread、Runable的區別

Thread和Runnable的實質是繼承關係,沒有可比性。無論使用Runnable還是Thread,都會newThread,然後執行run方法。用法上,如果有複雜的線程操作需求,那就選擇繼承Thread,如果只是簡單的執行一個任務,那就實現runnable。

//會賣出多一倍的票
public class Test {
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		new MyThread().start();
		new MyThread().start();
	}
	static class MyThread extends Thread{
		private int ticket = 5;
		public void run(){
			while(true){
				System.out.println("Thread ticket = " + ticket--);
				if(ticket < 0){
					break;
				}
			}
		}
	}
}
//正常賣出
public class Test2 {
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyThread2 mt=new MyThread2();
		new Thread(mt).start();
		new Thread(mt).start();
	}
	static class MyThread2 implements Runnable{
		private int ticket = 5;
		public void run(){
			while(true){
				System.out.println("Runnable ticket = " + ticket--);
				if(ticket < 0){
					break;
				}
			}
		}
	}
}

原因是:MyThread創建了兩個實例,自然會賣出兩倍,屬於用法錯誤

5、對守護線程的理解

守護線程:

爲所有非守護線程提供服務的線程;任何一個守護線程都是整個JVM中所有非守護線程的保姆;

守護線程類似於整個進程的一個默默無聞的小嘍嘍;它的生死無關重要,它卻依賴整個進程而運行;哪天其他線程結束了,沒有要執行的了,程序就結束了,理都沒理守護線程,就把它中斷了;

注意: 由於守護線程的終止是自身無法控制的,因此千萬不要把IO、File等重要操作邏輯分配給它;因 爲它不靠譜;

守護線程的作用是什麼?

舉例, GC垃圾回收線程:就是一個經典的守護線程,當我們的程序中不再有任何運行的Thread,程序就不會再產生垃圾,垃圾回收器也就無事可做,所以當垃圾回收線程是JVM上僅剩的線程時,垃圾回收線程會自動離開。它始終在低級別的狀態中運行,用於實時監控和管理系統中的可回收資源。

應用場景:(1)來爲其它線程提供服務支持的情況;(2) 或者在任何情況下,程序結束時,這個線程必須正常且立刻關閉,就可以作爲守護線程來使用;反之,如果一個正在執行某個操作的線程必須要正確地關閉掉否則就會出現不好的後果的話,那麼這個線程就不能是守護線程,而是用戶線程。通常都是些關鍵的事務,比方說,數據庫錄入或者更新,這些操作都是不能中斷的。

thread.setDaemon(true)必須在thread.start()之前設置,否則會跑出一個 IllegalThreadStateException異常。你不能把正在運行的常規線程設置爲守護線程。

在Daemon線程中產生的新線程也是Daemon的。

守護線程不能用於去訪問固有資源,比如讀寫操作或者計算邏輯。因爲它會在任何時候甚至在一個操作的中間發生中斷。

Java自帶的多線程框架,比如ExecutorService,會將守護線程轉換爲用戶線程,所以如果要使用後臺線程就不能用Java的線程池。

6、ThreadLocal的原理和使用場景

每一個 Thread 對象均含有一個 ThreadLocalMap 類型的成員變量 threadLocals ,它存儲本線程中所有ThreadLocal對象及其對應的值ThreadLocalMap 由一個個 Entry 對象構成

Entry 繼承自 WeakReference<ThreadLocal<?>> ,一個 Entry 由 ThreadLocal 對象和 Object 構 成。由此可見, Entry 的key是ThreadLocal對象,並且是一個弱引用。當沒指向key的強引用後,該key就會被垃圾收集器回收

當執行set方法時,ThreadLocal首先會獲取當前線程對象,然後獲取當前線程的ThreadLocalMap對象。再以當前ThreadLocal對象爲key,將值存儲進ThreadLocalMap對象中。

get方法執行過程類似。ThreadLocal首先會獲取當前線程對象,然後獲取當前線程的ThreadLocalMap對象。再以當前ThreadLocal對象爲key,獲取對應的value。

由於每一條線程均含有各自私有的ThreadLocalMap容器,這些容器相互獨立互不影響,因此不會存在 線程安全性問題,從而也無需使用同步機制來保證多條線程訪問容器的互斥性。

使用場景:

1、在進行對象跨層傳遞的時候,使用ThreadLocal可以避免多次傳遞,打破層次間的約束。

2、線程間數據隔離

3、進行事務操作,用於存儲線程事務信息。

4、數據庫連接,Session會話管理。

Spring框架在事務開始時會給當前線程綁定一個Jdbc Connection,在整個事務過程都是使用該線程綁定的connection來執行數據庫操作,實現了事務的隔離性。Spring框架裏面就是用的ThreadLocal來實現這種隔離

7、ThreadLocal內存泄露原因,如何避免

內存泄露爲程序在申請內存後,無法釋放已申請的內存空間,一次內存泄露危害可以忽略,但內存泄露堆積後果很嚴重,無論多少內存,遲早會被佔光,不再會被使用的對象或者變量佔用的內存不能被回收,就是內存泄露。

強引用:

使用最普遍的引用(new),一個對象具有強引用,不會被垃圾回收器回收。當內存空間不足,Java虛擬機寧願拋出OutOfMemoryError錯誤,使程序異常終止,也不回收這種對象。

如果想取消強引用和某個對象之間的關聯,可以顯式地將引用賦值爲null,這樣可以使JVM在合適的時 間就會回收該對象。

弱引用:

JVM進行垃圾回收時,無論內存是否充足,都會回收被弱引用關聯的對象。在java中,用java.lang.ref.WeakReference類來表示。可以在緩存中使用弱引用。

ThreadLocal的實現原理,每一個Thread維護一個ThreadLocalMap,key爲使用弱引用的ThreadLocal實例,value爲線程變量的副本

hreadLocalMap使用ThreadLocal的弱引用作爲key,如果一個ThreadLocal不存在外部強引用時,Key(ThreadLocal)勢必會被GC回收,這樣就會導致ThreadLocalMap中key爲null, 而value還存在着強引用,只有thead線程退出以後,value的強引用鏈條纔會斷掉,但如果當前線程再遲遲不結束的話,這些key爲null的Entry的value就會一直存在一條強引用鏈(紅色鏈條)

key 使用強引用

當hreadLocalMap的key爲強引用回收ThreadLocal時,因爲ThreadLocalMap還持有ThreadLocal的強引用,如果沒有手動刪除,ThreadLocal不會被回收,導致Entry內存泄漏。

key 使用弱引用

當ThreadLocalMap的key爲弱引用回收ThreadLocal時,由於ThreadLocalMap持有ThreadLocal的弱引用,即使沒有手動刪除,ThreadLocal也會被回收。當key爲null,在下一次ThreadLocalMap調用set(),get(),remove()方法的時候會被清除value值。

因此,ThreadLocal內存泄漏的根源是:由於ThreadLocalMap的生命週期跟Thread一樣長,如果沒有手動刪除對應key就會導致內存泄漏,而不是因爲弱引用。

ThreadLocal正確的使用方法

(1)每次使用完ThreadLocal都調用它的remove()方法清除數據

(2)將ThreadLocal變量定義成private static,這樣就一直存在ThreadLocal的強引用,也就能保證任何時候都能通過ThreadLocal的弱引用訪問到Entry的value值,進而清除掉 。

8、併發、並行、串行的區別

串行在時間上不可能發生重疊,前一個任務沒搞定,下一個任務就只能等着

並行在時間上是重疊的,兩個任務在同一時刻互不干擾的同時執行。

併發允許兩個任務彼此干擾。統一時間點、只有一個任務運行,交替執行

9、併發的三大特性

原子性

原子性是指在一個操作中cpu不可以在中途暫停然後再調度,即不被中斷操作,要不全部執行完成,要不都不執行。就好比轉賬,從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。2個操作必須全部完成。

private long count = 0;
public void calc() {
	count++;
}

(1) 將 count 從主存讀到工作內存中的副本中

(2) +1的運算

(3) 將結果寫入工作內存

(4) 將工作內存的值刷回主存(什麼時候刷入由操作系統決定,不確定的)

那程序中原子性指的是最小的操作單元,比如自增操作,它本身其實並不是原子性操作,分了3步的,包括讀取變量的原始值、進行加1操作、寫入工作內存。所以在多線程中,有可能一個線程還沒自增完,可能才執行到第二部,另一個線程就已經讀取了值,導致結果錯誤。那如果我們能保證自增操作是一個原子性的操作,那麼就能保證其他線程讀取到的一定是自增後的數據。

關鍵字:synchronized

可見性

當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

若兩個線程在不同的cpu,那麼線程1改變了i的值還沒刷新到主存,線程2又使用了i,那麼這個i值肯定還是之前的,線程1對變量的修改線程沒看到這就是可見性問題。

//線程1
Boolean stop = false;
while(!stop){
	doSomething();
}
//線程2
stop = true;

如果線程2改變了stop的值,線程1一定會停止嗎?不一定。當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

關鍵字:volatile、synchronized、final

有序性

虛擬機在進行代碼編譯時,對於那些改變順序之後不會對最終結果造成影響的代碼,虛擬機不一定會按照我們寫的代碼的順序來執行,有可能將他們重排序。實際上,對於有些代碼進行重排序之後,雖然對變量的值沒有造成影響,但有可能會出現線程安全問題。

int a = 0;
bool flag = false;
public void write() {
	a = 2;
	//1
	flag = true;
	//2
}
public void multiply() {
	if (flag) {
		//3
		int ret = a * a;
		//4
	}
}

write方法裏的1和2做了重排序,線程1先對flag賦值爲true,隨後執行到線程2,ret直接計算出結果,再到線程1,這時候a才賦值爲2,很明顯遲了一步

關鍵字:volatile、synchronized

volatile本身就包含了禁止指令重排序的語義,而synchronized關鍵字是由“一個變量在同一時刻只允許一條線程對其進行lock操作”這條規則明確的。

synchronized關鍵字同時滿足以上三種特性,但是volatile關鍵字不滿足原子性。

在某些情況下,volatile的同步機制的性能確實要優於鎖(使用synchronized關鍵字或java.util.concurrent包裏面的鎖),因爲volatile的總開銷要比鎖低。

我們判斷使用volatile還是加鎖的唯一依據就是volatile的語義能否滿足使用的場景(原子性)

10、volatile

(1) 保證被volatile修飾的共享變量對所有線程總是可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。

//線程1
Boolean stop = false;
while(!stop){
	doSomething();
}
//線程2
stop = true;

如果線程2改變了stop的值,線程1一定會停止嗎?不一定。當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

2. 禁止指令重排序優化。

int a = 0;
bool flag = false;
public void write() {
	a = 2;
	//1
	flag = true;
	//2
}
public void multiply() {
	if (flag) {
		//3
		int ret = a * a;
		//4
	}
}

write方法裏的1和2做了重排序,線程1先對flag賦值爲true,隨後執行到線程2,ret直接計算出結果,再到線程1,這時候a才賦值爲2,很明顯遲了一步。

但是用volatile修飾之後就變得不一樣了

第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

第二:使用volatile關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);

第三:由於線程1的工作內存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主 存讀取。

inc++; 其實是兩個步驟,先加加,然後再賦值。不是原子性操作,所以volatile不能保證線程安全。

11、爲什麼用線程池?解釋下線程池參數?

1、降低資源消耗;提高線程利用率,降低創建和銷燬線程的消耗。

2、提高響應速度;任務來了,直接有線程可用可執行,而不是先創建線程,再執行。

3、提高線程的可管理性;線程是稀缺資源,使用線程池可以統一分配調優監控。

corePoolSize 代表核心線程數,也就是正常情況下創建工作的線程數,這些線程創建後並不會消除,而是一種常駐線程

maxinumPoolSize 代表的是最大線程數,它與核心線程數相對應,表示最大允許被創建的線程 數,比如當前任務較多,將核心線程數都用完了,還無法滿足需求時,此時就會創建新的線程,但是線程池內線程總數不會超過最大線程數

keepAliveTime 、 unit 表示超出核心線程數之外的線程的空閒存活時間,也就是核心線程不會 消除,但是超出核心線程數的部分線程如果空閒一定的時間則會被消除,我們可以通過 setKeepAliveTime 來設置空閒時間

workQueue 用來存放待執行的任務,假設我們現在覈心線程都已被使用,還有任務進來則全部放入隊列,直到整個隊列被放滿但任務還再持續進入則會開始創建新的線程

ThreadFactory 實際上是一個線程工廠,用來生產線程執行任務。我們可以選擇使用默認的創建 工廠,產生的線程都在同一個組內,擁有相同的優先級,且都不是守護線程。當然我們也可以選擇自定義線程工廠,一般我們會根據業務來制定不同的線程工廠

Handler 任務拒絕策略,有兩種情況,第一種是當我們調用 shutdown 等方法關閉線程池後,這 時候即使線程池內部還有沒執行完的任務正在執行,但是由於線程池已經關閉,我們再繼續想線程池提交任務就會遭到拒絕。另一種情況就是當達到最大線程數,線程池已經沒有能力繼續處理新提交的任務時,這是也就拒絕

12、簡述線程池處理流程

13、線程池中阻塞隊列的作用?爲什麼是先添加列隊而不是先創建最大線程?

1、一般的隊列只能保證作爲一個有限長度的緩衝區,如果超出了緩衝長度,就無法保留當前的任務 了,阻塞隊列通過阻塞可以保留住當前想要繼續入隊的任務。

阻塞隊列可以保證任務隊列中沒有任務時阻塞獲取任務的線程,使得線程進入wait狀態,釋放cpu資 源。

阻塞隊列自帶阻塞和喚醒的功能,不需要額外處理,無任務執行時,線程池利用阻塞隊列的take方法掛起,從而維持核心線程的存活、不至於一直佔用cpu資源

2、在創建新線程的時候,是要獲取全局鎖的,這個時候其它的就得阻塞,影響了整體效率。

就好比一個企業裏面有10個(core)正式工的名額,最多招10個正式工,要是任務超過正式工人數(task > core)的情況下,工廠領導(線程池)不是首先擴招工人,還是這10人,但是任務可以稍微積壓一下,即先放到隊列去(代價低)。10個正式工慢慢幹,遲早會幹完的,要是任務還在繼續增加,超過正式工的加班忍耐極限了(隊列滿了),就的招外包幫忙了(注意是臨時工)要是正式工加上外包還是不能完成任務,那新來的任務就會被領導拒絕了(線程池的拒絕策略)。

14、線程池中線程複用原理*

線程池將線程和任務進行解耦,線程是線程,任務是任務,擺脫了之前通過 Thread 創建線程時的 一個線程必須對應一個任務的限制。

在線程池中,同一個線程可以從阻塞隊列中不斷獲取新任務來執行,其核心原理在於線程池對Thread 進行了封裝,並不是每次執行任務都會調用 Thread.start() 來創建新線程,而是讓每個線程去執行一個“循環任務”,在這個“循環任務”中不停檢查是否有任務需要被執行,如果有則直接執行,也就是調用任務中的 run 方法,將 run 方法當成一個普通的方法執行,通過這種方式只使用固定的線程就將所有任務的 run 方法串聯起來。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章