文章目錄
- 4. 多線程
- 4.1 創建線程有哪幾種方式?
- 4.2 說說Thread類的常用方法
- 4.3 run()和start()有什么區別?
- 4.4 線程是否可以重復啟動,會有什么后果?
- 4.5 介紹一下線程的生命周期
- 4.6 如何實現線程同步?
- 4.7 說一說Java多線程之間的通信方式
- 4.8 說一說Java同步機制中的wait和notify
- 4.9 說一說sleep()和wait()的區別
- 4.10 說一說notify()、notifyAll()的區別
- 4.11 如何實現子線程先執行,主線程再執行?
- 4.12 阻塞線程的方式有哪些?
- 4.13 說一說synchronized與Lock的區別
- 4.14 說一說synchronized的底層實現原理
- 4.15 synchronized可以修飾靜態方法和靜態代碼塊嗎?
- 4.16 談談ReentrantLock的實現原理
- 4.17 如果不使用synchronized和Lock,如何保證線程安全?
- 4.18 說一說Java中樂觀鎖和悲觀鎖的區別
- 4.19 公平鎖與非公平鎖是怎么實現的?
- 4.20 了解Java中的鎖升級嗎?
- 4.21 如何實現互斥鎖(mutex)?
- 4.22 分段鎖是怎么實現的?
- 4.23 說說你對讀寫鎖的了解
- 4.24 volatile關鍵字有什么用?
- 4.25 談談volatile的實現原理
- 4.26 說說你對JUC的了解
- 4.27 說說你對AQS的理解
- 4.28 LongAdder解決了什么問題,它是如何實現的?
- 4.29 介紹下ThreadLocal和它的應用場景
- 4.30 請介紹ThreadLocal的實現原理,它是怎么處理hash沖突的?
- 4.31 介紹一下線程池
- 4.32 介紹一下線程池的工作流程
- 4.33 線程池都有哪些狀態?
- 4.34 談談線程池的拒絕策略
- 4.35 線程池的隊列大小你通常怎么設置?
- 4.36 線程池有哪些參數,各個參數的作用是什么?
- 4.36 線程池有哪些參數,各個參數的作用是什么?
4. 多線程
4.1 創建線程有哪幾種方式?
參考答案
創建線程有三種方式,分別是繼承Thread類、實現Runnable接口、實現Callable接口。
通過繼承Thread類來創建并啟動線程的步驟如下:
- 定義Thread類的子類,并重寫該類的run()方法,該run()方法將作為線程執行體。
- 創建Thread子類的實例,即創建了線程對象。
- 調用線程對象的start()方法來啟動該線程。
通過實現Runnable接口來創建并啟動線程的步驟如下:
- 定義Runnable接口的實現類,并實現該接口的run()方法,該run()方法將作為線程執行體。
- 創建Runnable實現類的實例,并將其作為Thread的target來創建Thread對象,Thread對象為線程對象。
- 調用線程對象的start()方法來啟動該線程。
通過實現Callable接口來創建并啟動線程的步驟如下:
- 創建Callable接口的實現類,并實現call()方法,該call()方法將作為線程執行體,且該call()方法有返回值。然后再創建Callable實現類的實例。
- 使用FutureTask類來包裝Callable對象,該FutureTask對象封裝了該Callable對象的call()方法的返回值。
- 使用FutureTask對象作為Thread對象的target創建并啟動新線程。
- 調用FutureTask對象的get()方法來獲得子線程執行結束后的返回值。
擴展閱讀
通過繼承Thread類、實現Runnable接口、實現Callable接口都可以實現多線程,不過實現Runnable接口與實現Callable接口的方式基本相同,只是Callable接口里定義的方法有返回值,可以聲明拋出異常而已。因此可以將實現Runnable接口和實現Callable接口歸為一種方式。
采用實現Runnable、Callable接口的方式創建多線程的優缺點:
- 線程類只是實現了Runnable接口或Callable接口,還可以繼承其他類。
- 在這種方式下,多個線程可以共享同一個target對象,所以非常適合多個相同線程來處理同一份資源的情況,從而可以將CPU、代碼和數據分開,形成清晰的模型,較好地體現了面向對象的思想。
- 劣勢是,編程稍稍復雜,如果需要訪問當前線程,則必須使用Thread.currentThread()方法。
采用繼承Thread類的方式創建多線程的優缺點:
- 劣勢是,因為線程類已經繼承了Thread類,所以不能再繼承其他父類。
- 優勢是,編寫簡單,如果需要訪問當前線程,則無須使用Thread.currentThread()方法,直接使用this即可獲得當前線程。
鑒于上面分析,因此一般推薦采用實現Runnable接口、Callable接口的方式來創建多線程。
4.2 說說Thread類的常用方法
參考答案
Thread類常用構造方法:
- Thread()
- Thread(String name)
- Thread(Runnable target)
- Thread(Runnable target, String name)
其中,參數 name為線程名,參數 target為包含線程體的目標對象。
Thread類常用靜態方法:
- currentThread():返回當前正在執行的線程;
- interrupted():返回當前執行的線程是否已經被中斷;
- sleep(long millis):使當前執行的線程睡眠多少毫秒數;
- yield():使當前執行的線程自愿暫時放棄對處理器的使用權并允許其他線程執行;
Thread類常用實例方法:
- getId():返回該線程的id;
- getName():返回該線程的名字;
- getPriority():返回該線程的優先級;
- interrupt():使該線程中斷;
- isInterrupted():返回該線程是否被中斷;
- isAlive():返回該線程是否處于活動狀態;
- isDaemon():返回該線程是否是守護線程;
- setDaemon(boolean on):將該線程標記為守護線程或用戶線程,如果不標記默認是非守護線程;
- setName(String name):設置該線程的名字;
- setPriority(int newPriority):改變該線程的優先級;
- join():等待該線程終止;
- join(long millis):等待該線程終止,至多等待多少毫秒數。
4.3 run()和start()有什么區別?
參考答案
run()方法被稱為線程執行體,它的方法體代表了線程需要完成的任務,而start()方法用來啟動線程。
調用start()方法啟動線程時,系統會把該run()方法當成線程執行體來處理。但如果直接調用線程對象的run()方法,則run()方法立即就會被執行,而且在run()方法返回之前其他線程無法并發執行。也就是說,如果直接調用線程對象的run()方法,系統把線程對象當成一個普通對象,而run()方法也是一個普通方法,而不是線程執行體。
4.4 線程是否可以重復啟動,會有什么后果?
參考答案
只能對處于新建狀態的線程調用start()方法,否則將引發IllegalThreadStateException異常。
擴展閱讀
當程序使用new關鍵字創建了一個線程之后,該線程就處于新建狀態,此時它和其他的Java對象一樣,僅僅由Java虛擬機為其分配內存,并初始化其成員變量的值。此時的線程對象沒有表現出任何線程的動態特征,程序也不會執行線程的線程執行體。
當線程對象調用了start()方法之后,該線程處于就緒狀態,Java虛擬機會為其創建方法調用棧和程序計數器,處于這個狀態中的線程并沒有開始運行,只是表示該線程可以運行了。至于該線程何時開始運行,取決于JVM里線程調度器的調度。
4.5 介紹一下線程的生命周期
參考答案
在線程的生命周期中,它要經過新建(New)、就緒(Ready)、運行(Running)、阻塞(Blocked)和死亡(Dead)5種狀態。尤其是當線程啟動以后,它不可能一直“霸占”著CPU獨自運行,所以CPU需要在多條線程之間切換,于是線程狀態也會多次在運行、就緒之間切換。
當程序使用new關鍵字創建了一個線程之后,該線程就處于新建狀態,此時它和其他的Java對象一樣,僅僅由Java虛擬機為其分配內存,并初始化其成員變量的值。此時的線程對象沒有表現出任何線程的動態特征,程序也不會執行線程的線程執行體。
當線程對象調用了start()方法之后,該線程處于就緒狀態,Java虛擬機會為其創建方法調用棧和程序計數器,處于這個狀態中的線程并沒有開始運行,只是表示該線程可以運行了。至于該線程何時開始運行,取決于JVM里線程調度器的調度。
如果處于就緒狀態的線程獲得了CPU,開始執行run()方法的線程執行體,則該線程處于運行狀態,如果計算機只有一個CPU,那么在任何時刻只有一個線程處于運行狀態。當然,在一個多處理器的機器上,將會有多個線程并行執行;當線程數大于處理器數時,依然會存在多個線程在同一個CPU上輪換的現象。
當一個線程開始運行后,它不可能一直處于運行狀態,線程在運行過程中需要被中斷,目的是使其他線程獲得執行的機會,線程調度的細節取決于底層平臺所采用的策略。對于采用搶占式策略的系統而言,系統會給每個可執行的線程一個小時間段來處理任務。當該時間段用完后,系統就會剝奪該線程所占用的資源,讓其他線程獲得執行的機會。當發生如下情況時,線程將會進入阻塞狀態:
- 線程調用sleep()方法主動放棄所占用的處理器資源。
- 線程調用了一個阻塞式IO方法,在該方法返回之前,該線程被阻塞。
- 線程試圖獲得一個同步監視器,但該同步監視器正被其他線程所持有。
- 線程在等待某個通知(notify)。
- 程序調用了線程的suspend()方法將該線程掛起。但這個方法容易導致死鎖,所以應該盡量避免使用該方法。
針對上面幾種情況,當發生如下特定的情況時可以解除上面的阻塞,讓該線程重新進入就緒狀態:
- 調用sleep()方法的線程經過了指定時間。
- 線程調用的阻塞式IO方法已經返回。
- 線程成功地獲得了試圖取得的同步監視器。
- 線程正在等待某個通知時,其他線程發出了一個通知。
- 處于掛起狀態的線程被調用了resume()恢復方法。
線程會以如下三種方式結束,結束后就處于死亡狀態:
- run()或call()方法執行完成,線程正常結束。
- 線程拋出一個未捕獲的Exception或Error。
- 直接調用該線程的stop()方法來結束該線程,該方法容易導致死鎖,通常不推薦使用。
擴展閱讀
線程5種狀態的轉換關系,如下圖所示:
4.6 如何實現線程同步?
參考答案
-
同步方法
即有synchronized關鍵字修飾的方法,由于java的每個對象都有一個內置鎖,當用此關鍵字修飾方法時, 內置鎖會保護整個方法。在調用該方法前,需要獲得內置鎖,否則就處于阻塞狀態。需要注意, synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類。
-
同步代碼塊
即有synchronized關鍵字修飾的語句塊,被該關鍵字修飾的語句塊會自動被加上內置鎖,從而實現同步。需值得注意的是,同步是一種高開銷的操作,因此應該盡量減少同步的內容。通常沒有必要同步整個方法,使用synchronized代碼塊同步關鍵代碼即可。
-
ReentrantLock
Java 5新增了一個java.util.concurrent包來支持同步,其中ReentrantLock類是可重入、互斥、實現了Lock接口的鎖,它與使用synchronized方法和快具有相同的基本行為和語義,并且擴展了其能力。需要注意的是,ReentrantLock還有一個可以創建公平鎖的構造方法,但由于能大幅度降低程序運行效率,因此不推薦使用。
-
volatile
volatile關鍵字為域變量的訪問提供了一種免鎖機制,使用volatile修飾域相當于告訴虛擬機該域可能會被其他線程更新,因此每次使用該域就要重新計算,而不是使用寄存器中的值。需要注意的是,volatile不會提供任何原子操作,它也不能用來修飾final類型的變量。
-
原子變量
在java的util.concurrent.atomic包中提供了創建了原子類型變量的工具類,使用該類可以簡化線程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在應用程序中(如以原子方式增加的計數器),但不能用于替換Integer。可擴展Number,允許那些處理機遇數字類的工具和實用工具進行統一訪問。
4.7 說一說Java多線程之間的通信方式
參考答案
在Java中線程通信主要有以下三種方式:
-
wait()、notify()、notifyAll()
如果線程之間采用synchronized來保證線程安全,則可以利用wait()、notify()、notifyAll()來實現線程通信。這三個方法都不是Thread類中所聲明的方法,而是Object類中聲明的方法。原因是每個對象都擁有鎖,所以讓當前線程等待某個對象的鎖,當然應該通過這個對象來操作。并且因為當前線程可能會等待多個線程的鎖,如果通過線程來操作,就非常復雜了。另外,這三個方法都是本地方法,并且被final修飾,無法被重寫。
wait()方法可以讓當前線程釋放對象鎖并進入阻塞狀態。notify()方法用于喚醒一個正在等待相應對象鎖的線程,使其進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。notifyAll()用于喚醒所有正在等待相應對象鎖的線程,使它們進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。
每個鎖對象都有兩個隊列,一個是就緒隊列,一個是阻塞隊列。就緒隊列存儲了已就緒(將要競爭鎖)的線程,阻塞隊列存儲了被阻塞的線程。當一個阻塞線程被喚醒后,才會進入就緒隊列,進而等待CPU的調度。反之,當一個線程被wait后,就會進入阻塞隊列,等待被喚醒。
-
await()、signal()、signalAll()
如果線程之間采用Lock來保證線程安全,則可以利用await()、signal()、signalAll()來實現線程通信。這三個方法都是Condition接口中的方法,該接口是在Java 1.5中出現的,它用來替代傳統的wait+notify實現線程間的協作,它的使用依賴于 Lock。相比使用wait+notify,使用Condition的await+signal這種方式能夠更加安全和高效地實現線程間協作。
Condition依賴于Lock接口,生成一個Condition的基本代碼是lock.newCondition() 。 必須要注意的是,Condition 的 await()/signal()/signalAll() 使用都必須在lock保護之內,也就是說,必須在lock.lock()和lock.unlock之間才可以使用。事實上,await()/signal()/signalAll() 與 wait()/notify()/notifyAll()有著天然的對應關系。即:Conditon中的await()對應Object的wait(),Condition中的signal()對應Object的notify(),Condition中的signalAll()對應Object的notifyAll()。
-
BlockingQueue
Java 5提供了一個BlockingQueue接口,雖然BlockingQueue也是Queue的子接口,但它的主要用途并不是作為容器,而是作為線程通信的工具。BlockingQueue具有一個特征:當生產者線程試圖向BlockingQueue中放入元素時,如果該隊列已滿,則該線程被阻塞;當消費者線程試圖從BlockingQueue中取出元素時,如果該隊列已空,則該線程被阻塞。
程序的兩個線程通過交替向BlockingQueue中放入元素、取出元素,即可很好地控制線程的通信。線程之間需要通信,最經典的場景就是生產者與消費者模型,而BlockingQueue就是針對該模型提供的解決方案。
4.8 說一說Java同步機制中的wait和notify
參考答案
wait()、notify()、notifyAll()用來實現線程之間的通信,這三個方法都不是Thread類中所聲明的方法,而是Object類中聲明的方法。原因是每個對象都擁有鎖,所以讓當前線程等待某個對象的鎖,當然應該通過這個對象來操作。并且因為當前線程可能會等待多個線程的鎖,如果通過線程來操作,就非常復雜了。另外,這三個方法都是本地方法,并且被final修飾,無法被重寫,并且只有采用synchronized實現線程同步時才能使用這三個方法。
wait()方法可以讓當前線程釋放對象鎖并進入阻塞狀態。notify()方法用于喚醒一個正在等待相應對象鎖的線程,使其進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。notifyAll()方法用于喚醒所有正在等待相應對象鎖的線程,使它們進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。
每個鎖對象都有兩個隊列,一個是就緒隊列,一個是阻塞隊列。就緒隊列存儲了已就緒(將要競爭鎖)的線程,阻塞隊列存儲了被阻塞的線程。當一個阻塞線程被喚醒后,才會進入就緒隊列,進而等待CPU的調度。反之,當一個線程被wait后,就會進入阻塞隊列,等待被喚醒。
4.9 說一說sleep()和wait()的區別
參考答案
- sleep()是Thread類中的靜態方法,而wait()是Object類中的成員方法;
- sleep()可以在任何地方使用,而wait()只能在同步方法或同步代碼塊中使用;
- sleep()不會釋放鎖,而wait()會釋放鎖,并需要通過notify()/notifyAll()重新獲取鎖。
4.10 說一說notify()、notifyAll()的區別
參考答案
-
notify()
用于喚醒一個正在等待相應對象鎖的線程,使其進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。
-
notifyAll()
用于喚醒所有正在等待相應對象鎖的線程,使它們進入就緒隊列,以便在當前線程釋放鎖后競爭鎖,進而得到CPU的執行。
4.11 如何實現子線程先執行,主線程再執行?
參考答案
啟動子線程后,立即調用該線程的join()方法,則主線程必須等待子線程執行完成后再執行。
擴展閱讀
Thread類提供了讓一個線程等待另一個線程完成的方法——join()方法。當在某個程序執行流中調用其他線程的join()方法時,調用線程將被阻塞,直到被join()方法加入的join線程執行完為止。
join()方法通常由使用線程的程序調用,以將大問題劃分成許多小問題,每個小問題分配一個線程。當所有的小問題都得到處理后,再調用主線程來進一步操作。
4.12 阻塞線程的方式有哪些?
參考答案
當發生如下情況時,線程將會進入阻塞狀態:
- 線程調用sleep()方法主動放棄所占用的處理器資源;
- 線程調用了一個阻塞式IO方法,在該方法返回之前,該線程被阻塞;
- 線程試圖獲得一個同步監視器,但該同步監視器正被其他線程所持有;
- 線程在等待某個通知(notify);
- 程序調用了線程的suspend()方法將該線程掛起,但這個方法容易導致死鎖,所以應該盡量避免使用該方法。
4.13 說一說synchronized與Lock的區別
參考答案
- synchronized是Java關鍵字,在JVM層面實現加鎖和解鎖;Lock是一個接口,在代碼層面實現加鎖和解鎖。
- synchronized可以用在代碼塊上、方法上;Lock只能寫在代碼里。
- synchronized在代碼執行完或出現異常時自動釋放鎖;Lock不會自動釋放鎖,需要在finally中顯示釋放鎖。
- synchronized會導致線程拿不到鎖一直等待;Lock可以設置獲取鎖失敗的超時時間。
- synchronized無法得知是否獲取鎖成功;Lock則可以通過tryLock得知加鎖是否成功。
- synchronized鎖可重入、不可中斷、非公平;Lock鎖可重入、可中斷、可公平/不公平,并可以細分讀寫鎖以提高效率。
4.14 說一說synchronized的底層實現原理
參考答案
一、以下列代碼為例,說明同步代碼塊的底層實現原理:
public class SynchronizedDemo {public void method() {synchronized (this) {System.out.println("Method 1 start");}}
}
查看反編譯后結果,如下圖:
可見,synchronized作用在代碼塊時,它的底層是通過monitorenter、monitorexit指令來實現的。
-
monitorenter:
每個對象都是一個監視器鎖(monitor),當monitor被占用時就會處于鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:
如果monitor的進入數為0,則該線程進入monitor,然后將進入數設置為1,該線程即為monitor的所有者。如果線程已經占有該monitor,只是重新進入,則進入monitor的進入數加1。如果其他線程已經占用了monitor,則該線程進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。
-
monitorexit:
執行monitorexit的線程必須是objectref所對應的monitor持有者。指令執行時,monitor的進入數減1,如果減1后進入數為0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個monitor的所有權。
monitorexit指令出現了兩次,第1次為同步正常退出釋放鎖,第2次為發生異步退出釋放鎖。
二、以下列代碼為例,說明同步方法的底層實現原理:
public class SynchronizedMethod {public synchronized void method() {System.out.println("Hello World!");}
}
查看反編譯后結果,如下圖:
從反編譯的結果來看,方法的同步并沒有通過 monitorenter 和 monitorexit 指令來完成,不過相對于普通方法,其常量池中多了 ACC_SYNCHRONIZED 標示符。JVM就是根據該標示符來實現方法的同步的:
當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標志是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之后才能執行方法體,方法執行完后再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。
三、總結
兩種同步方式本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過字節碼來完成。兩個指令的執行是JVM通過調用操作系統的互斥原語mutex來實現,被阻塞的線程會被掛起、等待重新調度,會導致“用戶態和內核態”兩個態之間來回切換,對性能有較大影響。
4.15 synchronized可以修飾靜態方法和靜態代碼塊嗎?
參考答案
synchronized可以修飾靜態方法,但不能修飾靜態代碼塊。
當修飾靜態方法時,監視器鎖(monitor)便是對象的Class實例,因為Class數據存在于永久代,因此靜態方法鎖相當于該類的一個全局鎖。
4.16 談談ReentrantLock的實現原理
參考答案
ReentrantLock
是基于AQS
實現的,AQS
即AbstractQueuedSynchronizer
的縮寫,這個是個內部實現了兩個隊列的抽象類,分別是同步隊列和條件隊列。其中同步隊列是一個雙向鏈表,里面儲存的是處于等待狀態的線程,正在排隊等待喚醒去獲取鎖,而條件隊列是一個單向鏈表,里面儲存的也是處于等待狀態的線程,只不過這些線程喚醒的結果是加入到了同步隊列的隊尾,AQS
所做的就是管理這兩個隊列里面線程之間的等待狀態-喚醒的工作。
在同步隊列中,還存在2
中模式,分別是獨占模式和共享模式,這兩種模式的區別就在于AQS
在喚醒線程節點的時候是不是傳遞喚醒,這兩種模式分別對應獨占鎖和共享鎖。
AQS
是一個抽象類,所以不能直接實例化,當我們需要實現一個自定義鎖的時候可以去繼承AQS
然后重寫獲取鎖的方式和釋放鎖的方式還有管理state,而ReentrantLock
就是通過重寫了AQS
的tryAcquire
和tryRelease
方法實現的lock
和unlock
。
ReentrantLock
結構如下圖所示:
首先ReentrantLock
實現了 Lock
接口,然后有3
個內部類,其中Sync
內部類繼承自AQS
,另外的兩個內部類繼承自Sync
,這兩個類分別是用來公平鎖和非公平鎖的。通過Sync
重寫的方法tryAcquire
、tryRelease
可以知道,ReentrantLock
實現的是AQS
的獨占模式,也就是獨占鎖,這個鎖是悲觀鎖。
4.17 如果不使用synchronized和Lock,如何保證線程安全?
參考答案
-
volatile
volatile關鍵字為域變量的訪問提供了一種免鎖機制,使用volatile修飾域相當于告訴虛擬機該域可能會被其他線程更新,因此每次使用該域就要重新計算,而不是使用寄存器中的值。需要注意的是,volatile不會提供任何原子操作,它也不能用來修飾final類型的變量。
-
原子變量
在java的util.concurrent.atomic包中提供了創建了原子類型變量的工具類,使用該類可以簡化線程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在應用程序中(如以原子方式增加的計數器),但不能用于替換Integer。可擴展Number,允許那些處理機遇數字類的工具和實用工具進行統一訪問。
-
本地存儲
可以通過ThreadLocal類來實現線程本地存儲的功能。每一個線程的Thread對象中都有一個ThreadLocalMap對象,這個對象存儲了一組以ThreadLocal.threadLocalHashCode為鍵,以本地線程變量為值的K-V值對,ThreadLocal對象就是當前線程的ThreadLocalMap的訪問入口,每一個ThreadLocal對象都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以在線程K-V值對中找回對應的本地線程變量。
-
不可變的
只要一個不可變的對象被正確地構建出來,那其外部的可見狀態永遠都不會改變,永遠都不會看到它在多個線程之中處于不一致的狀態,“不可變”帶來的安全性是最直接、最純粹的。Java語言中,如果多線程共享的數據是一個基本數據類型,那么只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。如果共享數據是一個對象,由于Java語言目前暫時還沒有提供值類型的支持,那就需要對象自行保證其行為不會對其狀態產生任何影響才行。String類是一個典型的不可變類,可以參考它設計一個不可變類。
4.18 說一說Java中樂觀鎖和悲觀鎖的區別
參考答案
悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖。Java中悲觀鎖是通過synchronized關鍵字或Lock接口來實現的。
樂觀鎖:顧名思義,就是很樂觀,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。樂觀鎖適用于多讀的應用類型,這樣可以提高吞吐量。在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相對于對于 synchronized 這種阻塞算法,CAS是非阻塞算法的一種常見實現。所以J.U.C在性能上有了很大的提升。
4.19 公平鎖與非公平鎖是怎么實現的?
參考答案
在Java中實現鎖的方式有兩種,一種是使用Java自帶的關鍵字synchronized對相應的類或者方法以及代碼塊進行加鎖,另一種是ReentrantLock,前者只能是非公平鎖,而后者是默認非公平但可實現公平的一把鎖。
ReentrantLock是基于其內部類FairSync(公平鎖)和NonFairSync(非公平鎖)實現的,并且它的實現依賴于Java同步器框架AbstractQueuedSynchronizer(AQS),AQS使用一個整形的volatile變量state來維護同步狀態,這個volatile變量是實現ReentrantLock的關鍵。我們來看一下ReentrantLock的類圖:
ReentrantLock 的公平鎖和非公平鎖都委托了 AbstractQueuedSynchronizer#acquire
去請求獲取。
public` `final` `void` `acquire(``int` `arg) {`` ``if` `(!tryAcquire(arg) &&`` ``acquireQueued(addWaiter(Node.EXCLUSIVE), arg))`` ``selfInterrupt();``}
- tryAcquire 是一個抽象方法,是公平與非公平的實現原理所在。
- addWaiter 是將當前線程結點加入等待隊列之中。公平鎖在鎖釋放后會嚴格按照等到隊列去取后續值,而非公平鎖在對于新晉線程有很大優勢。
- acquireQueued 在多次循環中嘗試獲取到鎖或者將當前線程阻塞。
- selfInterrupt 如果線程在阻塞期間發生了中斷,調用 Thread.currentThread().interrupt() 中斷當前線程。
公平鎖和非公平鎖在說的獲取上都使用到了 volatile 關鍵字修飾的state字段, 這是保證多線程環境下鎖的獲取與否的核心。但是當并發情況下多個線程都讀取到 state == 0
時,則必須用到CAS技術,一門CPU的原子鎖技術,可通過CPU對共享變量加鎖的形式,實現數據變更的原子操作。volatile 和 CAS的結合是并發搶占的關鍵。
-
公平鎖FairSync
公平鎖的實現機理在于每次有線程來搶占鎖的時候,都會檢查一遍有沒有等待隊列,如果有, 當前線程會執行如下步驟:
if
(!hasQueuedPredecessors() && compareAndSetState(``0``, acquires)) { `` ``setExclusiveOwnerThread(current);`` ``return
true``;``}
其中hasQueuedPredecessors是用于檢查是否有等待隊列的:
public
final
boolean
hasQueuedPredecessors() {`` ``Node t = tail; ``// Read fields in reverse initialization order`` ``Node h = head;`` ``Node s;`` ``return
h != t &&`` ``((s = h.next) == ``null
|| s.thread != Thread.currentThread());``}
-
非公平鎖NonfairSync
非公平鎖在實現的時候多次強調隨機搶占:
if
(c == ``0``) {`` ``if
(compareAndSetState(``0``, acquires)) {`` ``setExclusiveOwnerThread(current);`` ``return
true``;`` ``}``}
與公平鎖的區別在于新晉獲取鎖的進程會有多次機會去搶占鎖,被加入了等待隊列后則跟公平鎖沒有區別。
4.20 了解Java中的鎖升級嗎?
參考答案
JDK 1.6之前,synchronized 還是一個重量級鎖,是一個效率比較低下的鎖。但是在JDK 1.6后,JVM為了提高鎖的獲取與釋放效率對synchronized 進行了優化,引入了偏向鎖和輕量級鎖 ,從此以后鎖的狀態就有了四種:無鎖、偏向鎖、輕量級鎖、重量級鎖。并且四種狀態會隨著競爭的情況逐漸升級,而且是不可逆的過程,即不可降級,這四種鎖的級別由低到高依次是:無鎖、偏向鎖,輕量級鎖,重量級鎖。如下圖所示:
-
無鎖
無鎖是指沒有對資源進行鎖定,所有的線程都能訪問并修改同一個資源,但同時只有一個線程能修改成功。無鎖的特點是修改操作會在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有沖突就修改成功并退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。
-
偏向鎖
初次執行到synchronized代碼塊的時候,鎖對象變成偏向鎖(通過CAS修改對象頭里的鎖標志位),字面意思是“偏向于第一個獲得它的線程”的鎖。執行完同步代碼塊后,線程并不會主動釋放偏向鎖。當第二次到達同步代碼塊時,線程會判斷此時持有鎖的線程是否就是自己(持有鎖的線程ID也在對象頭里),如果是則正常往下執行。由于之前沒有釋放鎖,這里也就不需要重新加鎖。如果自始至終使用鎖的線程只有一個,很明顯偏向鎖幾乎沒有額外開銷,性能極高。
偏向鎖是指當一段同步代碼一直被同一個線程所訪問時,即不存在多個線程的競爭時,那么該線程在后續訪問時便會自動獲得鎖,從而降低獲取鎖帶來的消耗,即提高性能。
當一個線程訪問同步代碼塊并獲取鎖時,會在 Mark Word 里存儲鎖偏向的線程 ID。在線程進入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 里是否存儲著指向當前線程的偏向鎖。輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 ThreadID 的時候依賴一次 CAS 原子指令即可。
偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程是不會主動釋放偏向鎖的。關于偏向鎖的撤銷,需要等待全局安全點,即在某個時間點上沒有字節碼正在執行時,它會先暫停擁有偏向鎖的線程,然后判斷鎖對象是否處于被鎖定狀態。如果線程不處于活動狀態,則將對象頭設置成無鎖狀態,并撤銷偏向鎖,恢復到無鎖(標志位為01)或輕量級鎖(標志位為00)的狀態。
-
輕量級鎖
輕量級鎖是指當鎖是偏向鎖的時候,卻被另外的線程所訪問,此時偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,線程不會阻塞,從而提高性能。
輕量級鎖的獲取主要由兩種情況:
- 當關閉偏向鎖功能時;
- 由于多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖。
一旦有第二個線程加入鎖競爭,偏向鎖就升級為輕量級鎖(自旋鎖)。這里要明確一下什么是鎖競爭:如果多個線程輪流獲取一個鎖,但是每次獲取鎖的時候都很順利,沒有發生阻塞,那么就不存在鎖競爭。只有當某線程嘗試獲取鎖的時候,發現該鎖已經被占用,只能等待其釋放,這才發生了鎖競爭。
在輕量級鎖狀態下繼續鎖競爭,沒有搶到鎖的線程將自旋,即不停地循環判斷鎖是否能夠被成功獲取。獲取鎖的操作,其實就是通過CAS修改對象頭里的鎖標志位。先比較當前鎖標志位是否為“釋放”,如果是則將其設置為“鎖定”,比較并設置是原子性發生的。這就算搶到鎖了,然后線程將當前鎖的持有者信息修改為自己。
長時間的自旋操作是非常消耗資源的,一個線程持有鎖,其他線程就只能在原地空耗CPU,執行不了任何有效的任務,這種現象叫做忙等(busy-waiting)。如果多個線程用一個鎖,但是沒有發生鎖競爭,或者發生了很輕微的鎖競爭,那么synchronized就用輕量級鎖,允許短時間的忙等現象。這是一種折衷的想法,短時間的忙等,換取線程在用戶態和內核態之間切換的開銷。
-
重量級鎖
重量級鎖顯然,此忙等是有限度的(有個計數器記錄自旋次數,默認允許循環10次,可以通過虛擬機參數更改)。如果鎖競爭情況嚴重,某個達到最大自旋次數的線程,會將輕量級鎖升級為重量級鎖(依然是CAS修改鎖標志位,但不修改持有鎖的線程ID)。當后續線程嘗試獲取鎖時,發現被占用的鎖是重量級鎖,則直接將自己掛起(而不是忙等),等待將來被喚醒。
重量級鎖是指當有一個線程獲取鎖之后,其余所有等待獲取該鎖的線程都會處于阻塞狀態。簡言之,就是所有的控制權都交給了操作系統,由操作系統來負責線程間的調度和線程的狀態變更。而這樣會出現頻繁地對線程運行狀態的切換,線程的掛起和喚醒,從而消耗大量的系統資。
擴展閱讀
synchronized 用的鎖是存在Java對象頭里的,那么什么是對象頭呢?我們以 Hotspot 虛擬機為例進行說明,Hopspot 對象頭主要包括兩部分數據:Mark Word(標記字段) 和 Klass Pointer(類型指針)。
- Mark Word:默認存儲對象的HashCode,分代年齡和鎖標志位信息。這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲盡量多的數據。它會根據對象的狀態復用自己的存儲空間,也就是在運行期間Mark Word里存儲的數據會隨著鎖標志位的變化而變化。
- Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
那么,synchronized 具體是存在對象頭哪里呢?答案是:存在鎖對象的對象頭的Mark Word中,那么MarkWord在對象頭中到底長什么樣,它到底存儲了什么呢?
在64位的虛擬機中:
在32位的虛擬機中:
下面我們以 32位虛擬機為例,來看一下其 Mark Word 的字節具體是如何分配的:
- 無鎖 :對象頭開辟 25bit 的空間用來存儲對象的 hashcode ,4bit 用于存放對象分代年齡,1bit 用來存放是否偏向鎖的標識位,2bit 用來存放鎖標識位為01。
- 偏向鎖: 在偏向鎖中劃分更細,還是開辟 25bit 的空間,其中23bit 用來存放線程ID,2bit 用來存放 Epoch,4bit 存放對象分代年齡,1bit 存放是否偏向鎖標識, 0表示無鎖,1表示偏向鎖,鎖的標識位還是01。
- 輕量級鎖:在輕量級鎖中直接開辟 30bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標志位,其標志位為00。
- 重量級鎖: 在重量級鎖中和輕量級鎖一樣,30bit 的空間用來存放指向重量級鎖的指針,2bit 存放鎖的標識位,為11。
- GC標記: 開辟30bit 的內存空間卻沒有占用,2bit 空間存放鎖標志位為11。
其中無鎖和偏向鎖的鎖標志位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。關于內存的分配,我們可以在git中openJDK中 markOop.hpp 可以看出:
public:// Constantsenum { age_bits = 4,lock_bits = 2,biased_lock_bits = 1,max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,cms_bits = LP64_ONLY(1) NOT_LP64(0),epoch_bits = 2};
- age_bits: 就是我們說的分代回收的標識,占用4字節。
- lock_bits: 是鎖的標志位,占用2個字節。
- biased_lock_bits: 是是否偏向鎖的標識,占用1個字節。
- max_hash_bits: 是針對無鎖計算的hashcode 占用字節數量,如果是32位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節未使用,所以64位的 hashcode 占用 31 byte。
- hash_bits: 是針對 64 位虛擬機來說,如果最大字節數大于 31,則取31,否則取真實的字節數。
- cms_bits: 不是64位虛擬機就占用 0 byte,是64位就占用 1byte。
- epoch_bits: 就是 epoch 所占用的字節大小,2字節。
4.21 如何實現互斥鎖(mutex)?
參考答案
在Java里面,最基本的互斥同步手段就是synchronized關鍵字,這是一種塊結構(Block Structured)的同步語法。synchronized關鍵字經過Javac編譯之后,會在同步塊的前后分別形成monitorenter和monitorexit這兩個字節碼指令。這兩個字節碼指令都需要一個reference類型的參數來指明要鎖定和解鎖的對象。如果Java源碼中的synchronized明確指定了對象參數,那就以這個對象的引用作為reference。如果沒有明確指定,那將根據synchronized修飾的方法類型(如實例方法或類方法),來決定是取代碼所在的對象實例還是取類型對應的Class對象來作為線程要持有的鎖。
自JDK 5起,Java類庫中新提供了java.util.concurrent包(J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一種全新的互斥同步手段。基于Lock接口,用戶能夠以非塊結構(Non-Block Structured)來實現互斥同步,從而擺脫了語言特性的束縛,改為在類庫層面去實現同步,這也為日后擴展出不同調度算法、不同特征、不同性能、不同語義的各種鎖提供了廣闊的空間。
4.22 分段鎖是怎么實現的?
參考答案
在并發程序中,串行操作是會降低可伸縮性,并且上下文切換也會減低性能。在鎖上發生競爭時將通水導致這兩種問題,使用獨占鎖時保護受限資源的時候,基本上是采用串行方式—-每次只能有一個線程能訪問它。所以對于可伸縮性來說最大的威脅就是獨占鎖。
我們一般有三種方式降低鎖的競爭程度:
- 減少鎖的持有時間;
- 降低鎖的請求頻率;
- 使用帶有協調機制的獨占鎖,這些機制允許更高的并發性。
在某些情況下我們可以將鎖分解技術進一步擴展為一組獨立對象上的鎖進行分解,這稱為分段鎖。其實說的簡單一點就是:容器里有多把鎖,每一把鎖用于鎖容器其中一部分數據,那么當多線程訪問容器里不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高并發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然后給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
如下圖,ConcurrentHashMap使用Segment數據結構,將數據分成一段一段的存儲,然后給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問,能夠實現真正的并發訪問。所以說,ConcurrentHashMap在并發情況下,不僅保證了線程安全,而且提高了性能。
4.23 說說你對讀寫鎖的了解
參考答案
與傳統鎖不同的是讀寫鎖的規則是可以共享讀,但只能一個寫,總結起來為:讀讀不互斥、讀寫互斥、寫寫互斥,而一般的獨占鎖是:讀讀互斥、讀寫互斥、寫寫互斥,而場景中往往讀遠遠大于寫,讀寫鎖就是為了這種優化而創建出來的一種機制。
注意是讀遠遠大于寫,一般情況下獨占鎖的效率低來源于高并發下對臨界區的激烈競爭導致線程上下文切換。因此當并發不是很高的情況下,讀寫鎖由于需要額外維護讀鎖的狀態,可能還不如獨占鎖的效率高。因此需要根據實際情況選擇使用。
在Java中ReadWriteLock
的主要實現為ReentrantReadWriteLock
,其提供了以下特性:
- 公平性選擇:支持公平與非公平(默認)的鎖獲取方式,吞吐量非公平優先于公平。
- 可重入:讀線程獲取讀鎖之后可以再次獲取讀鎖,寫線程獲取寫鎖之后可以再次獲取寫鎖。
- 可降級:寫線程獲取寫鎖之后,其還可以再次獲取讀鎖,然后釋放掉寫鎖,那么此時該線程是讀鎖狀態,也就是降級操作。
4.24 volatile關鍵字有什么用?
參考答案
當一個變量被定義成volatile之后,它將具備兩項特性:
-
保證可見性
當寫一個volatile變量時,JMM會把該線程本地內存中的變量強制刷新到主內存中去,這個寫會操作會導致其他線程中的volatile變量緩存無效。
-
禁止指令重排
使用volatile關鍵字修飾共享變量可以禁止指令重排序,volatile禁止指令重排序有一些規則:
- 當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見,在其后面的操作肯定還沒有進行;
- 在進行指令優化時,不能將對volatile變量訪問的語句放在其后面執行,也不能把volatile變量后面的語句放到其前面執行。
即執行到volatile變量時,其前面的所有語句都執行完,后面所有語句都未執行。且前面語句的結果對volatile變量及其后面語句可見。
注意,雖然volatile能夠保證可見性,但它不能保證原子性。volatile變量在各個線程的工作內存中是不存在一致性問題的,但是Java里面的運算操作符并非原子操作,這導致volatile變量的運算在并發下一樣是不安全的。
4.25 談談volatile的實現原理
參考答案
volatile可以保證線程可見性且提供了一定的有序性,但是無法保證原子性。在JVM底層volatile是采用“內存屏障”來實現的。觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的匯編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令,lock前綴指令實際上相當于一個內存屏障,內存屏障會提供3個功能:
- 它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
- 它會強制將對緩存的修改操作立即寫入主存;
- 如果是寫操作,它會導致其他CPU中對應的緩存行無效。
4.26 說說你對JUC的了解
參考答案
JUC是java.util.concurrent的縮寫,該包參考自EDU.oswego.cs.dl.util.concurrent,是JSR 166標準規范的一個實現。JSR 166是一個關于Java并發編程的規范提案,在JDK中該規范由java.util.concurrent包實現。即JUC是Java提供的并發包,其中包含了一些并發編程用到的基礎組件。
JUC這個包下的類基本上包含了我們在并發編程時用到的一些工具,大致可以分為以下幾類:
-
原子更新
Java從JDK1.5開始提供了java.util.concurrent.atomic包,方便程序員在多線程環 境下,無鎖的進行原子操作。在Atomic包里一共有12個類,四種原子更新方式,分別是原子更新基本類型,原子更新 數組,原子更新引用和原子更新字段。
-
鎖和條件變量
java.util.concurrent.locks包下包含了同步器的框架 AbstractQueuedSynchronizer,基于AQS構建的Lock以及與Lock配合可以實現等待/通知模式的Condition。JUC 下的大多數工具類用到了Lock和Condition來實現并發。
-
線程池
涉及到的類比如:Executor、Executors、ThreadPoolExector、 AbstractExecutorService、Future、Callable、ScheduledThreadPoolExecutor等等。
-
阻塞隊列
涉及到的類比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、LinkedBlockingDeque等等。
-
并發容器
涉及到的類比如:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、CopyOnWriteArraySet等等。
-
同步器
剩下的是一些在并發編程中時常會用到的工具類,主要用來協助線程同步。比如:CountDownLatch、CyclicBarrier、Exchanger、Semaphore、FutureTask等等。
4.27 說說你對AQS的理解
參考答案
抽象隊列同步器AbstractQueuedSynchronizer (以下都簡稱AQS),是用來構建鎖或者其他同步組件的骨架類,減少了各功能組件實現的代碼量,也解決了在實現同步器時涉及的大量細節問題,例如等待線程采用FIFO隊列操作的順序。在不同的同步器中還可以定義一些靈活的標準來判斷某個線程是應該通過還是等待。
AQS采用模板方法模式,在內部維護了n多的模板的方法的基礎上,子類只需要實現特定的幾個方法(不是抽象方法!不是抽象方法!不是抽象方法!),就可以實現子類自己的需求。
基于AQS實現的組件,諸如:
- ReentrantLock 可重入鎖(支持公平和非公平的方式獲取鎖);
- Semaphore 計數信號量;
- ReentrantReadWriteLock 讀寫鎖。
擴展閱讀
AQS內部維護了一個int成員變量來表示同步狀態,通過內置的FIFO(first-in-first-out)同步隊列來控制獲取共享資源的線程。
我們可以猜測出,AQS其實主要做了這么幾件事情:
- 同步狀態(state)的維護管理;
- 等待隊列的維護管理;
- 線程的阻塞與喚醒。
通過AQS內部維護的int型的state,可以用于表示任意狀態!
- ReentrantLock用它來表示鎖的持有者線程已經重復獲取該鎖的次數,而對于非鎖的持有者線程來說,如果state大于0,意味著無法獲取該鎖,將該線程包裝為Node,加入到同步等待隊列里。
- Semaphore用它來表示剩余的許可數量,當許可數量為0時,對未獲取到許可但正在努力嘗試獲取許可的線程來說,會進入同步等待隊列,阻塞,直到一些線程釋放掉持有的許可(state+1),然后爭用釋放掉的許可。
- FutureTask用它來表示任務的狀態(未開始、運行中、完成、取消)。
- ReentrantReadWriteLock在使用時,稍微有些不同,int型state用二進制表示是32位,前16位(高位)表示為讀鎖,后面的16位(低位)表示為寫鎖。
- CountDownLatch使用state表示計數次數,state大于0,表示需要加入到同步等待隊列并阻塞,直到state等于0,才會逐一喚醒等待隊列里的線程。
AQS通過內置的FIFO(first-in-first-out)同步隊列來控制獲取共享資源的線程。CLH隊列是FIFO的雙端雙向隊列,AQS的同步機制就是依靠這個CLH隊列完成的。隊列的每個節點,都有前驅節點指針和后繼節點指針。如下圖:
4.28 LongAdder解決了什么問題,它是如何實現的?
參考答案
高并發下計數,一般最先想到的應該是AtomicLong/AtomicInt,AtmoicXXX使用硬件級別的指令 CAS 來更新計數器的值,這樣可以避免加鎖,機器直接支持的指令,效率也很高。但是AtomicXXX中的 CAS 操作在出現線程競爭時,失敗的線程會白白地循環一次,在并發很大的情況下,因為每次CAS都只有一個線程能成功,競爭失敗的線程會非常多。失敗次數越多,循環次數就越多,很多線程的CAS操作越來越接近 自旋鎖(spin lock)。計數操作本來是一個很簡單的操作,實際需要耗費的cpu時間應該是越少越好,AtomicXXX在高并發計數時,大量的cpu時間都浪費會在 自旋 上了,這很浪費,也降低了實際的計數效率。
LongAdder是jdk8新增的用于并發環境的計數器,目的是為了在高并發情況下,代替AtomicLong/AtomicInt,成為一個用于高并發情況下的高效的通用計數器。說LongAdder比在高并發時比AtomicLong更高效,這么說有什么依據呢?LongAdder是根據鎖分段來實現的,它里面維護一組按需分配的計數單元,并發計數時,不同的線程可以在不同的計數單元上進行計數,這樣減少了線程競爭,提高了并發效率。本質上是用空間換時間的思想,不過在實際高并發情況中消耗的空間可以忽略不計。
現在,在處理高并發計數時,應該優先使用LongAdder,而不是繼續使用AtomicLong。當然,線程競爭很低的情況下進行計數,使用Atomic還是更簡單更直接,并且效率稍微高一些。其他情況,比如序號生成,這種情況下需要準確的數值,全局唯一的AtomicLong才是正確的選擇,此時不應該使用LongAdder。
4.29 介紹下ThreadLocal和它的應用場景
參考答案
ThreadLocal顧名思義是線程私有的局部變量存儲容器,可以理解成每個線程都有自己專屬的存儲容器,它用來存儲線程私有變量,其實它只是一個外殼,內部真正存取是一個Map。每個線程可以通過set()
和get()
存取變量,多線程間無法訪問各自的局部變量,相當于在每個線程間建立了一個隔板。只要線程處于活動狀態,它所對應的ThreadLocal實例就是可訪問的,線程被終止后,它的所有實例將被垃圾收集。總之記住一句話:ThreadLocal存儲的變量屬于當前線程。
ThreadLocal經典的使用場景是為每個線程分配一個 JDBC 連接 Connection,這樣就可以保證每個線程的都在各自的 Connection 上進行數據庫的操作,不會出現 A 線程關了 B線程正在使用的 Connection。 另外ThreadLocal還經常用于管理Session會話,將Session保存在ThreadLocal中,使線程處理多次處理會話時始終是同一個Session。
4.30 請介紹ThreadLocal的實現原理,它是怎么處理hash沖突的?
參考答案
Thread類中有個變量threadLocals,它的類型為ThreadLocal中的一個內部類ThreadLocalMap,這個類沒有實現map接口,就是一個普通的Java類,但是實現的類似map的功能。每個線程都有自己的一個map,map是一個數組的數據結構存儲數據,每個元素是一個Entry,entry的key是ThreadLocal的引用,也就是當前變量的副本,value就是set的值。代碼如下所示:
public class Thread implements Runnable {/* ThreadLocal values pertaining to this thread. This map is maintained* by the ThreadLocal class. */ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap是ThreadLocal的內部類,每個數據用Entry保存,其中的Entry繼承與WeakReference,用一個鍵值對存儲,鍵為ThreadLocal的引用。為什么是WeakReference呢?如果是強引用,即使把ThreadLocal設置為null,GC也不會回收,因為ThreadLocalMap對它有強引用。代碼如下所示:
static class Entry extends WeakReference<ThreadLocal<?>> {/** The value associated with this ThreadLocal. */Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}
}
ThreadLocal中的set方法的實現邏輯,先獲取當前線程,取出當前線程的ThreadLocalMap,如果不存在就會創建一個ThreadLocalMap,如果存在就會把當前的threadlocal的引用作為鍵,傳入的參數作為值存入map中。代碼如下所示:
public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {map.set(this, value);} else {createMap(t, value);}
}
ThreadLocal中get方法的實現邏輯,獲取當前線程,取出當前線程的ThreadLocalMap,用當前的threadlocak作為key在ThreadLocalMap查找,如果存在不為空的Entry,就返回Entry中的value,否則就會執行初始化并返回默認的值。代碼如下所示:
public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue();
}
ThreadLocal中remove方法的實現邏輯,還是先獲取當前線程的ThreadLocalMap變量,如果存在就調用ThreadLocalMap的remove方法。ThreadLocalMap的存儲就是數組的實現,因此需要確定元素的位置,找到Entry,把entry的鍵值對都設為null,最后也Entry也設置為null。其實這其中會有哈希沖突,具體見下文。代碼如下所示:
public void remove() {ThreadLocalMap m = getMap(Thread.currentThread());if (m != null) {m.remove(this);}
}
ThreadLocal中的hash code非常簡單,就是調用AtomicInteger的getAndAdd方法,參數是個固定值0x61c88647
。上面說過ThreadLocalMap的結構非常簡單只用一個數組存儲,并沒有鏈表結構,當出現Hash沖突時采用線性查找的方式,所謂線性查找,就是根據初始key的hashcode值確定元素在table數組中的位置,如果發現這個位置上已經有其他key值的元素被占用,則利用固定的算法尋找一定步長的下個位置,依次判斷,直至找到能夠存放的位置。如果產生多次hash沖突,處理起來就沒有HashMap的效率高,為了避免哈希沖突,使用盡量少的threadlocal變量。
4.31 介紹一下線程池
參考答案
系統啟動一個新線程的成本是比較高的,因為它涉及與操作系統交互。在這種情形下,使用線程池可以很好地提高性能,尤其是當程序中需要創建大量生存期很短暫的線程時,更應該考慮使用線程池。
與數據庫連接池類似的是,線程池在系統啟動時即創建大量空閑的線程,程序將一個Runnable對象或Callable對象傳給線程池,線程池就會啟動一個空閑的線程來執行它們的run()或call()方法,當run()或call()方法執行結束后,該線程并不會死亡,而是再次返回線程池中成為空閑狀態,等待執行下一個Runnable對象的run()或call()方法。
從Java 5開始,Java內建支持線程池。Java 5新增了一個Executors工廠類來產生線程池,該工廠類包含如下幾個靜態工廠方法來創建線程池。創建出來的線程池,都是通過ThreadPoolExecutor類來實現的。
- newCachedThreadPool():創建一個具有緩存功能的線程池,系統根據需要創建線程,這些線程將會被緩存在線程池中。
- newFixedThreadPool(int nThreads):創建一個可重用的、具有固定線程數的線程池。
- newSingleThreadExecutor():創建一個只有單線程的線程池,它相當于調用newFixedThread Pool()方法時傳入參數為1。
- newScheduledThreadPool(int corePoolSize):創建具有指定線程數的線程池,它可以在指定延遲后執行線程任務。corePoolSize指池中所保存的線程數,即使線程是空閑的也被保存在線程池內。
- newSingleThreadScheduledExecutor():創建只有一個線程的線程池,它可以在指定延遲后執行線程任務。
- ExecutorService newWorkStealingPool(int parallelism):創建持有足夠的線程的線程池來支持給定的并行級別,該方法還會使用多個隊列來減少競爭。
- ExecutorService newWorkStealingPool():該方法是前一個方法的簡化版本。如果當前機器有4個CPU,則目標并行級別被設置為4,也就是相當于為前一個方法傳入4作為參數。
4.32 介紹一下線程池的工作流程
參考答案
線程池的工作流程如下圖所示:
- 判斷核心線程池是否已滿,沒滿則創建一個新的工作線程來執行任務。
- 判斷任務隊列是否已滿,沒滿則將新提交的任務添加在工作隊列。
- 判斷整個線程池是否已滿,沒滿則創建一個新的工作線程來執行任務,已滿則執行飽和(拒絕)策略。
4.33 線程池都有哪些狀態?
參考答案
線程池一共有五種狀態, 分別是:
- RUNNING :能接受新提交的任務,并且也能處理阻塞隊列中的任務。
- SHUTDOWN:關閉狀態,不再接受新提交的任務,但卻可以繼續處理阻塞隊列中已保存的任務。在線程池處于 RUNNING 狀態時,調用 shutdown()方法會使線程池進入到該狀態。
- STOP:不能接受新任務,也不處理隊列中的任務,會中斷正在處理任務的線程。在線程池處于 RUNNING 或 SHUTDOWN 狀態時,調用 shutdownNow() 方法會使線程池進入到該狀態。
- TIDYING:如果所有的任務都已終止了,workerCount (有效線程數) 為0,線程池進入該狀態后會調用 terminated() 方法進入TERMINATED 狀態。
- TERMINATED:在terminated() 方法執行完后進入該狀態,默認terminated()方法中什么也沒有做。進入TERMINATED的條件如下:
- 線程池不是RUNNING狀態;
- 線程池狀態不是TIDYING狀態或TERMINATED狀態;
- 如果線程池狀態是SHUTDOWN并且workerQueue為空;
- workerCount為0;
- 設置TIDYING狀態成功。
下圖為線程池的狀態轉換過程:
4.34 談談線程池的拒絕策略
參考答案
當線程池的任務緩存隊列已滿并且線程池中的線程數目達到maximumPoolSize,如果還有任務到來就會采取任務拒絕策略,通常有以下四種策略:
- AbortPolicy:丟棄任務并拋出RejectedExecutionException異常。
- DiscardPolicy:也是丟棄任務,但是不拋出異常。
- DiscardOldestPolicy:丟棄隊列最前面的任務,然后重新嘗試執行任務(重復該過程)。
- CallerRunsPolicy:由調用線程處理該任務。
4.35 線程池的隊列大小你通常怎么設置?
參考答案
-
CPU密集型任務
盡量使用較小的線程池,一般為CPU核心數+1。 因為CPU密集型任務使得CPU使用率很高,若開過多的線程數,會造成CPU過度切換。
-
IO密集型任務
可以使用稍大的線程池,一般為2*CPU核心數。 IO密集型任務CPU使用率并不高,因此可以讓CPU在等待IO的時候有其他線程去處理別的任務,充分利用CPU時間。
-
混合型任務
可以將任務分成IO密集型和CPU密集型任務,然后分別用不同的線程池去處理。 只要分完之后兩個任務的執行時間相差不大,那么就會比串行執行來的高效。因為如果劃分之后兩個任務執行時間有數據級的差距,那么拆分沒有意義。因為先執行完的任務就要等后執行完的任務,最終的時間仍然取決于后執行完的任務,而且還要加上任務拆分與合并的開銷,得不償失。
4.36 線程池有哪些參數,各個參數的作用是什么?
參考答案
線程池主要有如下6個參數:
-
corePoolSize(核心工作線程數):當向線程池提交一個任務時,若線程池已創建的線程數小于corePoolSize,即便此時存在空閑線程,也會通過創建一個新線程來執行該任務,直到已創建的線程數大于或等于corePoolSize時。
-
maximumPoolSize(最大線程數):線程池所允許的最大線程個數。當隊列滿了,且已創建的線程數小于maximumPoolSize,則線程池會創建新的線程來執行任務。另外,對于無界隊列,可忽略該參數。
-
keepAliveTime(多余線程存活時間):當線程池中線程數大于核心線程數時,線程的空閑時間如果超過線程存活時間,那么這個線程就會被銷毀,直到線程池中的線程數小于等于核心線程數。
-
workQueue(隊列):用于傳輸和保存等待執行任務的阻塞隊列。
-
threadFactory(線程創建工廠):用于創建新線程。threadFactory創建的線程也是采用new Thread()方式,threadFactory創建的線程名都具有統一的風格:pool-m-thread-n(m為線程池的編號,n為線程池內的線程編號)。
數。 IO密集型任務CPU使用率并不高,因此可以讓CPU在等待IO的時候有其他線程去處理別的任務,充分利用CPU時間。 -
混合型任務
可以將任務分成IO密集型和CPU密集型任務,然后分別用不同的線程池去處理。 只要分完之后兩個任務的執行時間相差不大,那么就會比串行執行來的高效。因為如果劃分之后兩個任務執行時間有數據級的差距,那么拆分沒有意義。因為先執行完的任務就要等后執行完的任務,最終的時間仍然取決于后執行完的任務,而且還要加上任務拆分與合并的開銷,得不償失。
4.36 線程池有哪些參數,各個參數的作用是什么?
參考答案
線程池主要有如下6個參數:
- corePoolSize(核心工作線程數):當向線程池提交一個任務時,若線程池已創建的線程數小于corePoolSize,即便此時存在空閑線程,也會通過創建一個新線程來執行該任務,直到已創建的線程數大于或等于corePoolSize時。
- maximumPoolSize(最大線程數):線程池所允許的最大線程個數。當隊列滿了,且已創建的線程數小于maximumPoolSize,則線程池會創建新的線程來執行任務。另外,對于無界隊列,可忽略該參數。
- keepAliveTime(多余線程存活時間):當線程池中線程數大于核心線程數時,線程的空閑時間如果超過線程存活時間,那么這個線程就會被銷毀,直到線程池中的線程數小于等于核心線程數。
- workQueue(隊列):用于傳輸和保存等待執行任務的阻塞隊列。
- threadFactory(線程創建工廠):用于創建新線程。threadFactory創建的線程也是采用new Thread()方式,threadFactory創建的線程名都具有統一的風格:pool-m-thread-n(m為線程池的編號,n為線程池內的線程編號)。
- handler(拒絕策略):當線程池和隊列都滿了,再加入線程會執行此策略。