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