Java并發(fā)十二連招,你能接住嗎?
本文轉載自微信公眾號「sowhat1412」,可以通過以下二維碼關注。轉載本文請聯(lián)系sowhat1412公眾號。
1、HashMap
面試第一題必問的 HashMap,挺考驗Javaer的基礎功底的,別問為啥放在這,因為重要!HashMap具有如下特性:
- HashMap 的存取是沒有順序的。
- KV 均允許為 NULL。
- 多線程情況下該類安全,可以考慮用 HashTable。
- JDk8底層是數組 + 鏈表 + 紅黑樹,JDK7底層是數組 + 鏈表。
- 初始容量和裝載因子是決定整個類性能的關鍵點,輕易不要動。
- HashMap是懶漢式創(chuàng)建的,只有在你put數據時候才會 build。
- 單向鏈表轉換為紅黑樹的時候會先變化為雙向鏈表最終轉換為紅黑樹,切記雙向鏈表跟紅黑樹是共存的。
- 對于傳入的兩個key,會強制性的判別出個高低,目的是為了決定向左還是向右放置數據。
- 鏈表轉紅黑樹后會努力將紅黑樹的root節(jié)點和鏈表的頭節(jié)點 跟table[i]節(jié)點融合成一個。
- 在刪除的時候是先判斷刪除節(jié)點紅黑樹個數是否需要轉鏈表,不轉鏈表就跟RBT類似,找個合適的節(jié)點來填充已刪除的節(jié)點。
- 紅黑樹的root節(jié)點不一定跟table[i]也就是鏈表的頭節(jié)點是同一個,三者同步是靠MoveRootToFront實現的。而HashIterator.remove()會在調用removeNode的時候movable=false。
常見HashMap考點:
- HashMap原理,內部數據結構。
- HashMap中的put、get、remove大致過程。
- HashMap中 hash函數實現。
- HashMap如何擴容。
- HashMap幾個重要參數為什么這樣設定。
- HashMap為什么線程不安全,如何替換。
- HashMap在JDK7跟JDK8中的區(qū)別。
- HashMap中鏈表跟紅黑樹切換思路。
- JDK7中 HashMap環(huán)產生原理。
2、ConcurrentHashMap
ConcurrentHashMap 是多線程模式下常用的并發(fā)容器,它的實現在JDK7跟JDK8區(qū)別挺大的。
2.1 JDK7
JDK7中的 ConcurrentHashMap 使用 Segment + HashEntry 分段鎖實現并發(fā),它的缺點是并發(fā)程度是由Segment 數組個數來決定的,并發(fā)度一旦初始化無法擴容,擴容的話只是HashEntry的擴容。
Segment 繼承自 ReentrantLock,在此扮演鎖的角色??梢岳斫鉃槲覀兊拿總€Segment都是實現了Lock功能的HashMap。如果我們同時有多個Segment形成了Segment數組那我們就可以實現并發(fā)咯。
大致的put流程如下:
1.ConcurrentHashMap底層大致實現?
ConcurrentHashMap允許多個修改操作并發(fā)進行,其關鍵在于使用了鎖分離技術。它使用了多個鎖來控制對hash表的不同部分進行的修改。內部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的HashTable,只要多個修改操作發(fā)生在不同的段上就可以并發(fā)進行。
2.ConcurrentHashMap在并發(fā)下的情況下如何保證取得的元素是最新的?
用于存儲鍵值對數據的HashEntry,在設計上它的成員變量value跟next都是volatile類型的,這樣就保證別的線程對value值的修改,get方法可以馬上看到,并且get的時候是不用加鎖的。
3.ConcurrentHashMap的弱一致性體現在clear和get方法,原因在于沒有加鎖。
比如迭代器在遍歷數據的時候是一個Segment一個Segment去遍歷的,如果在遍歷完一個Segment時正好有一個線程在剛遍歷完的Segment上插入數據,就會體現出不一致性。clear也是一樣。get方法和containsKey方法都是遍歷對應索引位上所有節(jié)點,都是不加鎖來判斷的,如果是修改性質的因為可見性的存在可以直接獲得最新值,不過如果是新添加值則無法保持一致性。
4.size 統(tǒng)計個數不準確
size方法比較有趣,先無鎖的統(tǒng)計所有的數據量看下前后兩次是否數據一樣,如果一樣則返回數據,如果不一樣則要把全部的segment進行加鎖,統(tǒng)計,解鎖。并且size方法只是返回一個統(tǒng)計性的數字。
2.2 JDK8
ConcurrentHashMap 在JDK8中拋棄了分段鎖,轉為用 CAS + synchronized,同時將HashEntry改為Node,還加入了紅黑樹的實現,主要還是看put的流程(如果看了擴容這塊,絕對可以好好吹逼一番)。
ConcurrentHashMap 是如果來做到高效并發(fā)安全?
1.讀操作
get方法中根本沒有使用同步機制,也沒有使用unsafe方法,所以讀操作是支持并發(fā)操作的。
2.寫操作
基本思路跟HashMap的寫操作類似,只不過用到了CAS + syn 實現加鎖,同時還涉及到擴容的操作。JDK8中鎖已經細化到 table[i] 了,數組位置不同可并發(fā),位置相同則去幫忙擴容。
3.同步處理主要是通過syn和unsafe的硬件級別原子性這兩種方式完成
當我們對某個table[i]操作時候是用syn加鎖的。
取數據的時候用的是unsafe硬件級別指令,直接獲取指定內存的最新數據。
3 、并發(fā)基礎知識
并發(fā)編程的出發(fā)點:充分利用CPU計算資源,多線程并不是一定比單線程快,要不為什么Redis6.0版本的核心操作指令仍然是單線程呢?對吧!
多線程跟單線程的性能都要具體任務具體分析,talk is cheap, show me the picture。
3.1 進程跟線程
進程:
進程是操作系統(tǒng)調用的最小單位,是系統(tǒng)進行資源分配和調度的獨立單位。
線程:
因為進程的創(chuàng)建、銷毀、切換產生大量的時間和空間的開銷,進程的數量不能太多,而線程是比進程更小的能獨立運行的基本單位,他是進程的一個實體,是CPU調度的最小單位。線程可以減少程序并發(fā)執(zhí)行時的時間和空間開銷,使得操作系統(tǒng)具有更好的并發(fā)性。
線程基本不擁有系統(tǒng)資源,只有一些運行時必不可少的資源,比如程序計數器、寄存器和棧,進程則占有堆、棧。線程,Java默認有兩個線程 main 跟GC。Java是沒有權限開線程的,無法操作硬件,都是調用的 native 的 start0 方法 由 C++ 實現
3.2 并行跟并發(fā)
并發(fā):
concurrency : 多線程操作同一個資源,單核CPU極速的切換運行多個任務
并行:
parallelism :多個CPU同時使用,CPU多核 真正的同時執(zhí)行
3.3 線程幾個狀態(tài)
Java中線程的狀態(tài)分為6種:
1.初始(New):
新創(chuàng)建了一個線程對象,但還沒有調用start()方法。
2.可運行(Runnable):
調用線程的start()方法,此線程進入就緒狀態(tài)。就緒狀態(tài)只是說你資格運行,調度程序沒有給你CPU資源,你就永遠是就緒狀態(tài)。
當前線程sleep()方法結束,其他線程join()結束,等待用戶輸入完畢,某個線程拿到對象鎖,這些線程也將進入就緒狀態(tài)。
當前線程時間片用完了,調用當前線程的yield()方法,當前線程進入就緒狀態(tài)。
鎖池里的線程拿到對象鎖后,進入就緒狀態(tài)。
3.運行中(Running)
就緒狀態(tài)的線程在獲得CPU時間片后變?yōu)檫\行中狀態(tài)(running)。這也是線程進入運行狀態(tài)的唯一的一種方式。
4.阻塞(Blocked):
阻塞狀態(tài)是線程阻塞在進入synchronized關鍵字修飾的方法或代碼塊(獲取鎖)時的狀態(tài)。
5.等待(Waiting) 跟 超時等待(Timed_Waiting):
處于這種狀態(tài)的線程不會被分配CPU執(zhí)行時間,它們要等待被顯式地喚醒(通知或中斷),否則會處于無限期等待的狀態(tài)。
處于這種狀態(tài)的線程不會被分配CPU執(zhí)行時間,不過無須無限期等待被其他線程顯示地喚醒,在達到一定時間后它們會自動喚醒。
6.終止(Terminated):
當線程正常運行結束或者被異常中斷后就會被終止。線程一旦終止了,就不能復生。
PS:
調用 obj.wait 的線程需要先獲取 obj 的 monitor,wait會釋放 obj 的 monitor 并進入等待態(tài)。所以 wait()/notify() 都要與 synchronized 聯(lián)用。
其實線程從阻塞/等待狀態(tài) 到 可運行狀態(tài)都涉及到同步隊列跟等待隊列的,這點在 AQS 有講。
3.4. 阻塞與等待的區(qū)別
阻塞:
當一個線程試圖獲取對象鎖(非JUC庫中的鎖,即synchronized),而該鎖被其他線程持有,則該線程進入阻塞狀態(tài)。它的特點是使用簡單,由JVM調度器來決定喚醒自己,而不需要由另一個線程來顯式喚醒自己,不響應中斷。
等待:
當一個線程等待另一個線程通知調度器一個條件時,該線程進入等待狀態(tài)。它的特點是需要等待另一個線程顯式地喚醒自己,實現靈活,語義更豐富,可響應中斷。例如調用:Object.wait()、**Thread.join()**以及等待 Lock 或 Condition。
雖然 synchronized 和 JUC 里的 Lock 都實現鎖的功能,但線程進入的狀態(tài)是不一樣的。synchronized 會讓線程進入阻塞態(tài),而 JUC 里的 Lock是用park()/unpark() 來實現阻塞/喚醒 的,會讓線程進入等待狀態(tài)。雖然等鎖時進入的狀態(tài)不一樣,但被喚醒后又都進入Runnable狀態(tài),從行為效果來看又是一樣的。
3.5 yield 跟 sleep 區(qū)別
- yield 跟 sleep 都能暫停當前線程,都不會釋放鎖資源,sleep 可以指定具體休眠的時間,而 yield 則依賴 CPU 的時間片劃分。
- sleep方法給其他線程運行機會時不考慮線程的優(yōu)先級,因此會給低優(yōu)先級的線程以運行的機會。yield方法只會給相同優(yōu)先級或更高優(yōu)先級的線程以運行的機會。
- 調用 sleep 方法使線程進入等待狀態(tài),等待休眠時間達到,而調用我們的 yield方法,線程會進入就緒狀態(tài),也就是sleep需要等待設置的時間后才會進行就緒狀態(tài),而yield會立即進入就緒狀態(tài)。
- sleep方法聲明會拋出 InterruptedException,而 yield 方法沒有聲明任何異常
- yield 不能被中斷,而 sleep 則可以接受中斷。
- sleep方法比yield方法具有更好的移植性(跟操作系統(tǒng)CPU調度相關)
3.6 wait 跟 sleep 區(qū)別
1.來源不同
wait 來自Object,sleep 來自 Thread
2.是否釋放鎖
wait 釋放鎖,sleep 不釋放
3.使用范圍
wait 必須在同步代碼塊中,sleep 可以任意使用
4.捕捉異常
wait 不需要捕獲異常,sleep 需捕獲異常
3.7 多線程實現方式
- 繼承 Thread,實現run方法
- 實現 Runnable接口中的run方法,然后用Thread包裝下。Thread 是線程對象,Runnable 是任務,線程啟動的時候一定是對象。
- 實現 Callable接口,FutureTask 包裝實現接口,Thread 包裝 FutureTask。Callable 與Runnable 的區(qū)別在于Callable的call方法有返回值,可以拋出異常,Callable有緩存。
- 通過線程池調用實現。
- 通過Spring的注解 @Async 實現。
3.8 死鎖
死鎖是指兩個或兩個以上的線程互相持有對方所需要的資源,由于某些鎖的特性,比如syn使用下,一個線程持有一個資源,或者說獲得一個鎖,在該線程釋放這個鎖之前,其它線程是獲取不到這個鎖的,而且會一直死等下去,因此這便造成了死鎖。
面試官:你給我解釋下死鎖是什么,解釋好了我就錄用你。
應聘者:先發(fā)Offer,發(fā)了Offer我給你解釋什么是死鎖。
產生條件:
互斥條件:一個資源,或者說一個鎖只能被一個線程所占用,當一個線程首先獲取到這個鎖之后,在該線程釋放這個鎖之前,其它線程均是無法獲取到這個鎖的。
占有且等待:一個線程已經獲取到一個鎖,再獲取另一個鎖的過程中,即使獲取不到也不會釋放已經獲得的鎖。
不可剝奪條件:任何一個線程都無法強制獲取別的線程已經占有的鎖
循環(huán)等待條件:線程A拿著線程B的鎖,線程B拿著線程A的鎖。。
檢查:
1、jps -l 定位進程號
2、jstack 進程號找到死鎖問題
避免:
加鎖順序:線程按照相同的順序加鎖。
限時加鎖:線程獲取鎖的過程中限制一定的時間,如果給定時間內獲取不到,就算了,這需要用到Lock的一些API。
4、JMM
4.1 JMM由來
隨著CPU、內存、磁盤的高速發(fā)展,它們的訪問速度差別很大。為了提速就引入了L1、L2、L3三級緩存。以后程序運行獲取數據就是如下的步驟了。
這樣雖然提速了但是會導致緩存一致性問題跟內存可見性問題。同時編譯器跟CPU為了加速也引入了指令重排。指令重排的大致意思就是你寫的代碼運行運算結果會按照你看到的邏輯思維去運行,但是在JVM內部系統(tǒng)是智能化的會進行加速排序的。
1、編譯器優(yōu)化的重排序:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執(zhí)行順序。
2、指令級并行的重排序:現代處理器采用了指令級并行技術在不影響數據依賴性前提下重排。
3、內存系統(tǒng)的重排序:處理器使用緩存和讀/寫緩沖區(qū) 進程重排。
指令重排這種機制會導致有序性問題,而在并發(fā)編程時經常會涉及到線程之間的通信跟同步問題,一般說是可見性、原子性、有序性。這三個問題對應的底層就是 緩存一致性、內存可見性、有序性。
原子性:原子性就是指該操作是不可再分的。不論是多核還是單核,具有原子性的量,同一時刻只能有一個線程來對它進行操作。在整個操作過程中不會被線程調度器中斷的操作,都可認為是原子性。比如 a = 1。
可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。Java保證可見性可以認為通過volatile、synchronized、final來實現。
有序性:程序執(zhí)行的順序按照代碼的先后順序執(zhí)行,Java通過volatile、synchronized來保證。
為了保證共享內存的正確性(可見性、有序性、原子性),內存模型定義了共享內存模式下多線程程序讀寫操作行為的規(guī)范,既JMM模型,注意JMM只是一個約定概念,是用來保證效果一致的機制跟規(guī)范。它作用于工作內存和主存之間數據同步過程,規(guī)定了如何做數據同步以及什么時候做數據同步。
在JMM中,有兩條規(guī)定:
線程對共享變量的所有操作都必須在自己的工作內存中進行,不能直接從主內存中讀寫。
不同線程之間無法訪問其他線程工作內存中的變量,線程間變量值的傳遞需要通過主內存來完成。
共享變量要實現可見性,必須經過如下兩個步驟:
把本地內存1中更新過的共享變量刷新到主內存中。
把主內存中最新的共享變量的值更新到本地內存2中。
同時人們提出了內存屏障、happen-before、af-if-serial這三種概念來保證系統(tǒng)的可見性、原子性、有序性。
4.2 內存屏障
內存屏障 (Memory Barrier) 是一種CPU指令,用于控制特定條件下的重排序和內存可見性問題。Java編譯器也會根據內存屏障的規(guī)則禁止重排序。Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按我們預想的流程去執(zhí)行。具有如下功能:
保證特定操作的執(zhí)行順序。
影響某些數據(或則是某條指令的執(zhí)行結果)的內存可見性。
在 volatile 中就用到了內存屏障,volatile部分已詳細講述。
4.3 happen-before
因為有指令重排的存在會導致難以理解CPU內部運行規(guī)則,JDK用 happens-before 的概念來闡述操作之間的內存可見性。在JMM 中如果一個操作執(zhí)行的結果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關系 。其中CPU的happens-before無需任何同步手段就可以保證的。
- 程序順序規(guī)則:一個線程中的每個操作,happens-before于該線程中的任意后續(xù)操作。
- 監(jiān)視器鎖規(guī)則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
- volatile變量規(guī)則:對一個volatile域的寫,happens-before于任意后續(xù)對這個volatile域的讀。
- 傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()規(guī)則:如果線程A執(zhí)行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
- join()規(guī)則:如果線程A執(zhí)行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
- 線程中斷規(guī)則:對線程interrupt方法的調用happens-before于被中斷線程的代碼檢測到中斷事件的發(fā)生。
4.4 af-if-serial
af-if-serial 的含義是不管怎么重排序(編譯器和處理器為了提高并行度),單線程環(huán)境下程序的執(zhí)行結果不能被改變且必須正確。該語義使單線程環(huán)境下程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。
5、volatile
volatile 關鍵字的引入可以保證變量的可見性,但是無法保證變量的原子性,比如 a++這樣的是無法保證的。這里其實涉及到JMM 的知識點,Java多線程交互是通過共享內存的方式實現的。當我們讀寫volatile變量時具有如下規(guī)則:
當寫一個volatile變量時,JMM會把該線程對應的本地中的共享變量值刷新到主內存。
當讀一個volatile變量時,JMM會把該線程對應的本地內存置為無效。線程接下來將從主內存中讀取共享變量。
volatile就會用到上面說到的內存屏障,目前有四種內存屏障:
- StoreStore屏障,保證普通寫不和volatile寫發(fā)生重排序
- StoreLoad屏障,保證volatile寫與后面可能的volatile讀寫不發(fā)生重排序
- LoadLoad屏障,禁止volatile讀與后面的普通讀重排序
- LoadStore屏障,禁止volatile讀和后面的普通寫重排序
volatile原理:用volatile變量修飾的共享變量進行寫操作的時候會使用CPU提供的Lock前綴指令,在CPU級別的功能如下:
將當前處理器緩存行的數據寫回到 系統(tǒng)內存。
這個寫回內存的操作會告知在其他CPU你們拿到的變量是無效的下一次使用時候要重新共享內存拿。
6、單例模式 DCL + volatile
6.1 標準單例模式
高頻考點單例模式:就是將類的構造函數進行private化,然后只留出一個靜態(tài)的Instance 函數供外部調用者調用。單例模式一般標準寫法是 DCL + volatile:
- public class SingleDcl {
- private volatile static SingleDcl singleDcl; //保證可見性
- private SingleDcl(){
- }
- public static SingleDcl getInstance(){
- // 放置進入加鎖代碼,先判斷下是否已經初始化好了
- if(singleDcl == null) {
- // 類鎖 可能會出現 AB線程都在這卡著,A獲得鎖,B等待獲得鎖。
- synchronized (SingleDcl.class) {
- if(singleDcl == null) {
- // 如果A線程初始化好了,然后通過vloatile 將變量復雜給住線程。
- // 如果此時沒有singleDel === null,判斷 B進程 進來后還會再次執(zhí)行 new 語句
- singleDcl = new SingleDcl();
- }
- }
- }
- return singleDcl;
- }
- }
6.2 為什么用Volatile修飾
不用Volatile則代碼運行時可能存在指令重排,會導致線程一在運行時執(zhí)行順序是 1-->2--> 4 就賦值給instance變量了,然后接下來再執(zhí)行構造方法初始化。問題是如果構造方法初始化執(zhí)行沒完成前 線程二進入發(fā)現instance != null,直接給線程二個半成品,加入volatile后底層會使用內存屏障強制按照你以為的執(zhí)行。
單例模式幾乎是面試必考點,,一般有如下特性:
懶漢式:在需要用到對象時才實例化對象,正確的實現方式是 Double Check + Lock + volatile,解決了并發(fā)安全和性能低下問題,對內存要求非常高,那么使用懶漢式寫法。
餓漢式:在類加載時已經創(chuàng)建好該單例對象,在獲取單例對象時直接返回對象即可,對內存要求不高使用餓漢式寫法,因為簡單不易出錯,且沒有任何并發(fā)安全和性能問題。
枚舉式:Effective Java 這本書也列舉了使用枚舉,其代碼精簡,沒有線程安全問題,且 Enum 類內部防止反射和反序列化時破壞單例。
7、線程池
7.1 五分鐘了解線程池
老王是個深耕在帝都的一線碼農,辛苦一年掙了點錢,想把錢存儲到銀行卡里,拿錢去銀行辦理遇到了如下的遭遇
老王銀行門口取號后發(fā)現有柜臺營業(yè)ing 但是沒人辦理業(yè)務就直接辦理了。
老王取號后發(fā)現柜臺上都有人在辦理,等待席有空地,去坐著等辦理去了。
老王取號后發(fā)現柜臺都有人辦理,等待席也人坐滿了,這個時候銀行經理看到老王是老實人本著關愛老實人的態(tài)度,新開一個臨時窗口給他辦理了。
老王取號后發(fā)現柜臺都滿了,等待座位席也滿了,臨時窗口也人滿了。這個時候銀行經理給出了若干解決策略。
- 直接告知人太多不給你辦理了。
- 采用冷暴力模式,也不給不辦理也不讓他走。
- 經理讓老王取嘗試跟座位席中最前面的人聊一聊看是否可以加塞,可以就辦理,不可以還是被踢走。
- 經理直接跟老王說誰讓你來的你找誰去我這辦理不了。
上面的這個流程幾乎就跟JDK線程池的大致流程類似,其中7大參數:
- 營業(yè)中的3個窗口對應核心線程池數:corePoolSize
- 銀行總的營業(yè)窗口數對應:maximumPoolSize
- 打開的臨時窗口在多少時間內無人辦理則關閉對應:keepAliveTime
- 臨時窗口存貨時間單位:TimeUnit
- 銀行里的等待座椅就是等待隊列:BlockingQueue
- threadFactory 該參數在JDK中是 線程工廠,用來創(chuàng)建線程對象,一般不會動。
- 無法辦理的時候銀行給出的解決方法對應:RejectedExecutionHandler
當線程池的任務緩存隊列已滿并且線程池中的線程數目達到maximumPoolSize,如果還有任務到來就會采取任務拒絕策略,一般有四大拒絕策略:
- ThreadPoolExecutor.AbortPolicy :丟棄任務,并拋出 RejectedExecutionException 異常。
- ThreadPoolExecutor.CallerRunsPolicy:該任務被線程池拒絕,由調用 execute方法的線程執(zhí)行該任務。
- ThreadPoolExecutor.DiscardOldestPolicy :拋棄隊列最前面的任務,然后重新嘗試執(zhí)行任務。
- ThreadPoolExecutor.DiscardPolicy:丟棄任務,也不拋出異常。
7.2 正確創(chuàng)建方式
使用Executors創(chuàng)建線程池可能會導致OOM。原因在于線程池中的BlockingQueue主要有兩種實現,分別是ArrayBlockingQueue 和 LinkedBlockingQueue。
- ArrayBlockingQueue 是一個用數組實現的有界阻塞隊列,必須設置容量。
- LinkedBlockingQueue 是一個用鏈表實現的有界阻塞隊列,容量可以選擇進行設置,不設置的話,將是一個無邊界的阻塞隊列,最大長度為Integer.MAX_VALUE,極易容易導致線程池OOM。
正確創(chuàng)建線程池的方式就是自己直接調用ThreadPoolExecutor的構造函數來自己創(chuàng)建線程池。在創(chuàng)建的同時,給BlockQueue指定容量就可以了。
- private static ExecutorService executor = new ThreadPoolExecutor(10, 10,
- 60L, TimeUnit.SECONDS,
- new ArrayBlockingQueue(10));
7.3 常見線程池
羅列幾種常見的線程池創(chuàng)建方式。
1.Executors.newFixedThreadPool
定長的線程池,有核心線程,核心線程的即為最大的線程數量,沒有非核心線程。 使用的無界的等待隊列是LinkedBlockingQueue。使用時候小心堵滿等待隊列。
2.Executors.newSingleThreadExecutor
創(chuàng)建單個線程數的線程池,它可以保證先進先出的執(zhí)行順序
3.Executors.newCachedThreadPool
創(chuàng)建一個可緩存線程池,如果線程池長度超過處理需要,可靈活回收空閑線程,若無可回收,則新建線程。
4.Executors.newScheduledThreadPool
創(chuàng)建一個定長的線程池,而且支持定時的以及周期性的任務執(zhí)行,支持定時及周期性任務執(zhí)行
5.ThreadPoolExecutor
最原始跟常見的創(chuàng)建線程池的方式,它包含了 7 個參數、4種拒絕策略 可用。
7.4 線程池核心點
線程池 在工作中常用,面試也是必考點。關于線程池的細節(jié)跟使用在以前舉例過一個 銀行排隊 辦業(yè)務的例子了。線程池一般主要也無非就是下面幾個考點了:
- 為什么用線程池。
- 線程池的作用。
- 7大重要參數。
- 4大拒絕策略。
- 常見線程池任務隊列,如何理解有界跟無界。
- 常用的線程池模版。
- 如何分配線程池個數,IO密集型 還是 CPU密集型。
- 設定一個線程池優(yōu)先級隊列,Runable 類要實現可對比功能,任務隊列使用優(yōu)先級隊列。
8、ThreadLocal
ThreadLocal 可以簡單理解為線程本地變量,相比于 synchronized 是用空間來換時間的思想。他會在每個線程都創(chuàng)建一個副本,在線程之間通過訪問內部副本變量的形式做到了線程之間互相隔離。這里用到了 弱引用 知識點:
如果一個對象只具有弱引用,那么GC回收器在掃描到該對象時,無論內存充足與否,都會回收該對象的內存。
8.1 核心點
每個Thread內部都維護一個ThreadLocalMap字典數據結構,字典的Key值是ThreadLocal,那么當某個ThreadLocal對象不再使用(沒有其它地方再引用)時,每個已經關聯(lián)了此ThreadLocal的線程怎么在其內部的ThreadLocalMap里做清除此資源呢?JDK中的ThreadLocalMap沒有繼承java.util.Map類,而是自己實現了一套專門用來定時清理無效資源的字典結構。其內部存儲實體結構Entry
接著分析底層代碼會發(fā)現在調用ThreadLocal.get() 或者 ThreadLocal.set() 都會 定期回收無效的Entry 操作。
9、CAS
Compare And Swap:比較并交換,主要是通過處理器的指令來保證操作的原子性,它包含三個操作數:
V:變量內存地址
A:舊的預期值
B:準備設置的新值
當執(zhí)行CAS指令時,只有當 V 對應的值等于 A 時才會用 B 去更新V的值,否則就不會執(zhí)行更新操作。CAS可能會帶來ABA問題、循環(huán)開銷過大問題、一個共享變量原子性操作的局限性。如何解決以前寫過,在此不再重復。
10、Synchronized
10.1 Synchronized 講解
Synchronized 是 JDK自帶的線程安全關鍵字,該關鍵字可以修飾實例方法、靜態(tài)方法、代碼塊三部分。該關鍵字可以保證互斥性、可見性、有序性(不解決重排)但保證有序性。
Syn的底層其實是C++代碼寫的,JDK6前是重量級鎖,調用的時候涉及到用戶態(tài)跟內核態(tài)的切換,挺耗時的。JDK6之前 Doug Lea寫出了JUC包,可以方便的讓用于在用戶態(tài)實現鎖的使用,Syn的開發(fā)者被激發(fā)了斗志所以在JDK6后對Syn進行了各種性能升級。
10.2 Synchronized 底層
Syn里涉及到了 對象頭 包含對象頭、填充數據、實例變量。這里可以看一個美團面試題:
問題一:new Object()占多少字節(jié)
- markword 8字節(jié) + classpointer 4字節(jié)(默認用calssPointer壓縮) + padding 4字節(jié) = 16字節(jié)
- 如果沒開啟classpointer壓縮:markword 8字節(jié) + classpointer 8字節(jié) = 16字節(jié)
問題二:User (int id,String name) User u = new User(1,"李四")
markword 8字節(jié) + 開啟classPointer壓縮后classpointer 4字節(jié) + instance data int 4字節(jié) + 開啟普通對象指針壓縮后String4字節(jié) + padding 4 = 24字節(jié)
10.3 Synchronized 鎖升級
synchronized 鎖在JDK6以后有四種狀態(tài),無鎖、偏向鎖、輕量級鎖、重量級鎖。這幾個狀態(tài)會隨著競爭狀態(tài)逐漸升級,鎖可以升級但不能降級,但是偏向鎖狀態(tài)可以被重置為無鎖狀態(tài)。大致升級過程如下
鎖對比:
鎖狀態(tài) | 優(yōu)點 | 缺點 | 適用場景 |
---|---|---|---|
偏向鎖 | 加鎖解鎖無需額外消耗,跟非同步方法時間相差納秒級別 | 如果競爭線程多,會帶來額外的鎖撤銷的消耗 | 基本沒有其他線程競爭的同步場景 |
輕量級鎖 | 競爭的線程不會阻塞而是在自旋,可提高程序響應速度 | 如果一直無法獲得會自旋消耗CPU | 少量線程競爭,持有鎖時間不長,追求響應速度 |
重量級鎖 | 線程競爭不會導致CPU自旋跟消耗CPU資源 | 線程阻塞,響應時間長 | 很多線程競爭鎖,切鎖持有時間長,追求吞吐量時候 |
10.4 Synchronized 無法禁止指令重排,卻能保證有序性
指令重排是程序運行時 解釋器 跟 CPU 自帶的加速手段,可能導致語句執(zhí)行順序跟預想不一樣情況,但是無論如何重排 也必須遵循 as-if-serial。
避免重排的最簡單方法就是禁止處理器優(yōu)化跟指令重排,比如volatile中用內存屏障實現,syn是關鍵字級別的排他且可重入鎖,當某個線程執(zhí)行到一段被syn修飾的代碼之前,會先進行加鎖,執(zhí)行完之后再進行解鎖。
當某段代碼被syn加鎖后跟解鎖前,其他線程是無法再次獲得鎖的,只有這條加鎖線程可以重復獲得該鎖。所以代碼在執(zhí)行的時候是單線程執(zhí)行的,這就滿足了as-if-serial語義,正是因為有了as-if-serial語義保證,單線程的有序性就天然存在了。
10.5 wait 虛假喚醒
虛假喚醒定義:
當一個條件滿足時,很多線程都被喚醒了,但只有其中部分是有用的喚醒,其它的喚醒是不對的,
比如說買賣貨物,如果商品本來沒有貨物,所有消費者線程都在wait狀態(tài)卡頓呢。這時突然生產者進了一件商品,喚醒了所有掛起的消費者。可能導致所有的消費者都繼續(xù)執(zhí)行wait下面的代碼,出現錯誤調用。
虛假喚醒原因:
因為 if 只會執(zhí)行一次,執(zhí)行完會接著向下執(zhí)行 if 下面的。而 while 不會,直到條件滿足才會向下執(zhí)行 while下面的。
虛假喚醒 解決辦法:
在調用 wait 的時候要用 while 不能用 if。
10.6 notify()底層
1.為何wait跟notify必須要加synchronized鎖
synchronized 代碼塊通過 javap 生成的字節(jié)碼中包含monitorenter 和 monitorexit 指令線程,執(zhí)行 monitorenter 指令可以獲取對象的 monitor,而wait 方法通過調用 native 方法 wait(0) 實現,該注釋說:The current thread must own this object's monitor。
2.notify 執(zhí)行后立馬喚醒線程嗎?
notify/notifyAll 調用時并不會真正釋放對象鎖,只是把等待中的線程喚醒然后放入到對象的鎖池中,但是鎖池中的所有線程都不會立馬運行,只有擁有鎖的線程運行完代碼塊釋放鎖,別的線程拿到鎖才可以運行。
- public void test()
- {
- Object object = new Object();
- synchronized (object){
- object.notifyAll();
- while (true){
- // TODO 死循環(huán)會導致 無法釋放鎖。
- }
- }
- }
11、AQS
11.1 高頻考點線程交替打印
目標是實現兩個線程交替打印,實現字母在前數字在后。你可以用信號量、Synchronized關鍵字跟Lock實現,這里用 ReentrantLock簡單實現:
- import java.util.concurrent.CountDownLatch;
- import java.util.concurrent.locks.Condition;
- import java.util.concurrent.locks.Lock;
- import java.util.concurrent.locks.ReentrantLock;
- public class Main {
- private static Lock lock = new ReentrantLock();
- private static Condition c1 = lock.newCondition();
- private static Condition c2 = lock.newCondition();
- private static CountDownLatch count = new CountDownLatch(1);
- public static void main(String[] args) {
- String c = "ABCDEFGHI";
- char[] ca = c.toCharArray();
- String n = "123456789";
- char[] na = n.toCharArray();
- Thread t1 = new Thread(() -> {
- try {
- lock.lock();
- count.countDown();
- for(char caa : ca) {
- c1.signal();
- System.out.print(caa);
- c2.await();
- }
- c1.signal();
- } catch (InterruptedException e) {
- e.printStackTrace();
- } finally {
- lock.unlock();
- }
- });
- Thread t2 = new Thread(() -> {
- try {
- count.await();
- lock.lock();
- for(char naa : na) {
- c2.signal();
- System.out.print(naa);
- c1.await();
- }
- c2.signal();
- } catch (InterruptedException e) {
- e.printStackTrace();
- } finally {
- lock.unlock();
- }
- });
- t1.start();
- t2.start();
- }
- }
11.2 AQS底層
上題我們用到了ReentrantLock、Condition ,但是它們的底層是如何實現的呢?其實他們是基于AQS的 同步隊列 跟 等待隊列 實現的!
11.2.1 AQS 同步隊列
學AQS 前 CAS + 自旋 + LockSupport + 模板模式 必須會,目的是方便理解源碼,感覺比 Synchronized 簡單,因為是單純的 Java 代碼。個人理解AQS具有如下幾個特點:
- 在AQS 同步隊列中 -1 表示線程在睡眠狀態(tài)
- 當前Node節(jié)點線程會把前一個Node.ws = -1。當前節(jié)點把前面節(jié)點ws設置為-1,你可以理解為:你自己能知道自己睡著了嗎?只能是別人看到了發(fā)現你睡眠了!
- 持有鎖的線程永遠不在隊列中。
- 在AQS隊列中第二個才是最先排隊的線程。
- 如果是交替型任務或者單線程任務,即使用了Lock也不會涉及到AQS 隊列。
- 不到萬不得已不要輕易park線程,很耗時的!所以排隊的頭線程會自旋的嘗試幾個獲取鎖。
- 并不是說 CAS 一定比SYN好,如果高并發(fā)執(zhí)行時間久 ,用SYN好, 因為SYN底層用了wait() 阻塞后是不消耗CPU資源的。如果鎖競爭不激烈說明自旋不嚴重 此時用CAS。
- 在AQS中也要盡可能避免調用CLH隊列,因為CLH可能會調用到park,相對來耗時。
ReentrantLock底層:
11.2.2 AQS 等待隊列
當我們調用 Condition 里的 await 跟 signal 時候底層其實是這樣走的。
12、線程思考
12.1. 變量建議使用棧封閉
所有的變量都是在方法內部聲明的,這些變量都處于棧封閉狀態(tài)。方法調用的時候會有一個棧楨,這是一個獨立的空間。在這個獨立空間創(chuàng)建跟使用則絕對是安全的,但是注意不要返回該變量哦!
12.2. 防止線程饑餓
優(yōu)先級低的線程總是得不到執(zhí)行機會,一般要保證資源充足、公平的分配資源、防止持有鎖的線程長時間執(zhí)行。
12.3 開發(fā)步驟
多線程編程不要為了用而用,引入多線程后會引入額外的開銷。量應用程序性能一般:服務時間、延遲時間、吞吐量、可伸縮性。做應用的時候可以一般按照如下步驟:
- 先確保保證程序的正確性跟健壯性,確實達不到性能要求再想如何提速。
- 一定要以測試為基準。
- 一個程序中串行的部分永遠是有的.
- 裝逼利器:阿姆達爾定律 S=1/(1-a+a/n)
阿姆達爾定律中 a為并行計算部分所占比例,n為并行處理結點個數:
- 當1-a=0時,(即沒有串行,只有并行)最大加速比s=n;
- 當a=0時(即只有串行,沒有并行),最小加速比s=1;
- 當n無窮大時,極限加速比s→ 1/(1-a),這就是加速比的上限。例如,若串行代碼占整個代碼的25%,則并行處理的總體性能不可能超過4。
12.4 影響性能因素
- 縮小鎖的范圍,能鎖方法塊盡量不要鎖函數
- 減少鎖的粒度跟鎖分段,比如ConcurrentHashMap的實現。
- 讀多寫少時候用讀寫鎖,可提高十倍性能。
- 用CAS操作來替換重型鎖。
- 盡量用JDK自帶的常見并發(fā)容器,底層已經足夠優(yōu)化了。