前言
目前CPU的運算速度已經達到了百億次每秒,所以為了提高生產率和高效地完成任務,基本上都采用多線程和并發的運作方式。
并發(Concurrency):是指在某個時間段內,多任務交替處理的能力。CPU把可執行時間均勻地分成若干份,每個進程執行一段時間后,記錄當前的工作狀態,
釋放相關的執行資源并進入等待狀態,讓其他線程搶占CPU資源。
并行(Parallelism):是指同時處理多任務的能力
在并發環境下,由于程序的封閉性被打破,出現了一下特點:
1、并發程序之間有相互制約的關系。直接制約體現在一個程序需要另一個程序的計算結果;間接體現為多個程序競爭共享資源,如處理器、緩沖區等。
2、并發程序的執行過程是斷斷續續的。程序需要記憶現場指令及執行點
3、當并發數設置合理并且CPU擁有足夠的處理能力時,并發會提高程序的運行效率。
線程安全
線程是CPU調度和分派的基本單位,為了更充分地利用CPU資源,一般都會使用多線程進行處理。多線程的作用是提高任務的平均執行速度,但是會導致程序可理解性變差,編程難度加大。
線程安全的定義:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那這個對象是線程安全的。
線程可以擁有自己的操作棧、程序計數器、局部變量表等資源,它與同進程內的其他線程共享該進程的所有資源。線程在生命周期內存在多種狀態。有NEW(新建狀態)、
RUNNABLE(就緒狀態)、RUNNING(運行狀態)、BLOCKED(阻塞)狀態、DEAD(終止狀態)五種狀態。
1、NEW,即新建狀態,是線程被創建且未啟動的狀態。創建線程的方式有三種,第一種是繼承自Thread類,第二種是實現Runnable接口。第三種是實現Callable接口。
推薦使用實現Runnable接口的方式,因為繼承Thread類往往不符合里氏替換原則(任何父類出現的地方都可以用子類替換,子類不要重寫重載父類的方法)。
Callable與Runnable有兩點不同:
1):Callable可以通過call()獲得返回值。
2):call()方法可以拋出異常。而Runnable只有通過setDefaultUncaughtExceptionHandler()的方式才能在主線程中捕捉到子線程異常。
2、RUNNABLE,即就緒狀態,是調用start()方法后運行之前的狀態。需要注意的是線程的start()不能被多次調用,否則會拋出IllegalStateException異常
3、RUNNING,即運行狀態,是run()正在執行時線程的狀態。線程可能會由于某些因素而退出RUNNING,如時間、異常、鎖、調度等
4、BLOCKED,即阻塞狀態,進入此狀態,有以下幾種情況
同步阻塞:鎖被其他線程占用
異步阻塞:調用Thread的某些方法,主動讓出CPU執行權,比如sleep()、join()等
等待阻塞:執行了await()
5、DEAD,即終止狀態,是run()方法執行結束,或因異常退出后的狀態,此狀態不可逆轉。
線程安全的核心理念就是“要么只讀,要么加鎖”
線程安全問題只有在多線程環境下才出現,單線程串行執行不存在此問題。保證高并發場景下的線程安全,可以從以下維度考量:
1、數據單線程內可見:單線程總是安全的。通過限制數據只在單線程內可見,可以避免數據被其他線程篡改。最典型的就是線程局部變量,它存儲在獨立的
虛擬機棧幀的局部變量表中,與其他線程毫無瓜葛。ThreadLocal就是采用這種方式來實現線程安全的。
2、只讀對象:只讀對象總是線程安全的。它的特性是允許復制、拒絕寫入。最典型的只讀對象有String,Integer等。一個對象想要拒絕任何寫入,必須滿足以下條件:
1):使用final關鍵字修飾類。避免被繼承,如String,調用其的方法不會影響其原來的值,只會返回一個新構造的字符串對象
2):使用private final 關鍵字避免屬性被中途修改
3):沒有任何更新方法
4):返回值不能可變對象為引用
3、線程安全類:某些線程安全類內部有非常明確的線程安全機制。比如StringBuffer就是一個線程安全類,其內部采用sychronized關鍵字來修飾相關方法
4、同步與鎖機制:如果想要對某個對象進行并發更新操作,但又不屬于上述三類,需要開發工程師在代碼中實現安全的同步機制。
合理利用好JDK提供的并發包(java.util.concurrent),并發包主要分為以下幾個類族:
1):線程同步類,這些類使得線程間的協調更加容易,支持了更加豐富的線程協調場景,逐步淘汰了使用Object類的wait和notify進行同步的方式,主要代表為
CountDownLatch、Semaphore、CycleBarrier等
2):并發集合類,如ConcurrentHashMap,它不斷優化,從剛開始的鎖分段到后來的CAS,不斷地提升并發性能。其他還有BlockingQueue、CopyOnWriteArrayList等
3):線程管理類,如使用Executors靜態工廠或者使用ThreadPoolExecutor來創建線程池等,另外,通過ScheduledExecutorService來執行定時任務
4):鎖相關類。鎖以Lock為核心,最有名的是ReentrantLock。
線程安全的實現方法
1、互斥同步
同步是指在多個線程并發訪問共享數據時,保證共享數據在同一時刻只被一個(或者是一些,使用信號量的時候)線程使用。而互斥是實現同步的一種手段,臨界區、互斥量和信號量都是主要的互斥方式。
synchronized:
在Java語言中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之后,會在同步塊的前后分別形成monitorenter和moniterexit這兩個字節碼指令,這兩個字節碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。如果Java程序中的synchronized明確指定了對象參數,那就是這個對象的reference;如果沒有明確指定,那就根據synchronized修飾的是實例方法還是類方法,去取對應的對象實例或者Class對象來作為鎖對象。
在執行monitorenter指令時,首先要嘗試獲取對象的鎖。如果這個對象沒被鎖定(monitor為0),或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將計數器減1,當計數器為0時,鎖就被釋放。如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。
synchronized同步塊對同一條線程來說是可重入的,不會出現自己把自己鎖死的問題。其次,同步塊在已進入的線程執行完之前,會阻塞后面其他線程的進入。Java中的線程是映射到操作系統的原生線程之上的,如果要阻塞或喚醒一個線程,都需要操作系統來幫忙完成,這就需要從用戶態轉換到核心態之中,因此狀態轉換需要耗費很多的處理器時間。
對于簡單的同步代碼塊,狀態轉換的操作有可能比用戶代碼執行的時間還要長。所以synchronized是Java語言中的一個重量級的操作。同時虛擬機本身也做了一些優化,譬如在通知操作系統阻塞線程之前加入一段自旋等待的過程,避免頻繁地切入到核心態中。
Lock:
相比synchronized,ReentrantLock增加了一些高級功能,只要有以下3項:等待可中斷、可實現公平鎖、以及鎖可以綁定多個條件。
等待可中斷:是指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他的事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
公平鎖:是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。
synchronized中的鎖是非公平的,ReentrantLock默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。
鎖綁定多個條件:是指一個ReentrantLock對象可以同時綁定多個Condition對象,而在synchronized中,鎖對象的wait()和notify()或notifyAll()方法可以實現一個隱含的條件,如果要和多于一個的條件關聯的時候,就不得不額外地添加一個鎖,而ReentrantLock則無需這么做,只需要多次調用newCondition()方法即可。
2、非阻塞同步
互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也成為阻塞同步。從處理問題的方式上說,互斥同步屬于一種悲觀的并發策略,總是認
為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題,無論共享數據是否真的會出現競爭,它都要進行加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被
阻塞的線程需要喚醒等操作。
隨著硬件指令集的發展,我們可以選擇:基于沖突檢測的樂觀并發策略,通俗的說,就是先進行操作,如果沒有其他線程爭用共享數據,那操作就成功了;如果共享數據
有爭用,產生了沖突,那就再采用其他的補償措施(常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的并發策略的許多實現都不需要把線程掛起,因此這種同步
操作稱為非阻塞同步。
為什么使用樂觀并發策略需要”硬件指令集的發展“才能進行呢?因為我們需要操作和沖突檢測這兩個步驟具備原子性,靠什么來保證呢?如果這里使用互斥同步來保證就
失去意義了,所以我們只能靠硬件來完成這件事情,硬件保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類指令常用的有:
1)、測試并設置(Test-and-Set)
2)、獲取并增加(Fetch-and-Increment)
3)、交換(Swap)
4)、比較并交換(Compare-and-Swap,CAS)
5)、加載鏈接/條件存儲(Load_Linked/Store-Conditional,LL/SC)
其中后面的兩條是現代處理器新增的。
CAS指令需要3個操作數,分別是內存位置(在Java中可以理解為變量的內存地址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,且上面的處理過程是一個原子操作。
不過CAS有個邏輯漏洞:如果一個變量V初次讀取的時候是A值,并且在準備賦值的時候檢查到它仍為A值,那我們就能說它的值沒有被其他線程改變過了嗎?如果在這段期間它的值曾經被改成了B,后來又被改為A,那CAS操作就會誤認為它從來沒有改變過。這個漏洞稱為CAS操作的ABA問題。java.unit.concurrent包為了解決這個問題,提供了一個帶有標記的原子引用類”AtomicStampReference“,它可以通過控制變量值的版本來保證CAS的正確性。不過這個類目前來說比較雞肋,大部分情況下ABA問題不會影響程序并發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。
3、無同步方案
要保證線程安全,并不是一定要進行同步,兩者沒有因果關系。同步只是保證共享數據爭用時的正確性手段,如果一個方法本來就不涉及共享數據,那它自然就無需任何同步措施去保證正確性,因此會有一些代碼天生就是線程安全的,比如:
可重入代碼(Reentrant Code):這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回后,原來的程序不會出現任何錯誤。...
線程本地存儲(Thread Local Storage):如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見范圍控制在同一個線程之內,這樣,無需同步也能保證線程之間不出現數據爭用的問題。如ThreadLocal類可以實現線程本地存儲的功能。每個線程的Thread對象中都有一個ThreadLocalMap對象,這個對象存儲了一組以ThreadLocal.threadLocalHashCode為鍵,以本地線程變量為值的K-V鍵值對,ThreadLocal對象就是當前線程的ThreadLocalMap的訪問入口,每一個ThreadLocal對象都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以在線程K-V值對中找回對應的本地線程變量。
什么是鎖?
單機單線程時代,沒有鎖的概念。自動出現了資源競爭,人們才意識到需要對部分執行現場進行加鎖,表明自己短暫擁有。計算機中的鎖也從最開始的悲觀鎖,發展到后來的樂觀鎖、偏向鎖、分段鎖等。鎖主要提供了兩種特性:互斥性和不可見性。
1、用并發包中的鎖類
Lock是頂層接口,它的實現邏輯并未用到synchronized,而是利用了volatile的可見性。ReentrantLock對了Lock接口的實現主要依賴了Sync,而Sync繼承了
AbstractQueuedSynchronizer(AQS),在AQS中,定義了一個volatile int state 變量作為共享資源。如果線程獲取此共享資源失敗,則進入同步FIFO隊列中等待;
如果成功獲取資源就執行臨界區代碼。執行完釋放資源時,會通知同步隊列中的等待線程來獲取資源后出對并執行。
ReentrantLock的lock()方法默認執行的是NonfairSync中的lock()實現,利用Unsafe類的CAS;期望state值為0時將其值設為1,返回是否成功
因此ReentrantLock的lock()方法只有在state為0時才能獲得鎖,并將state設為1。這樣其他線程就無法獲取鎖,只能等待。
由于ReentrantLock是可重入鎖,即在獲得鎖的情況下,可以再次獲得鎖。并且線程可以進入任何一個它已經擁有的鎖所同步著的代碼塊。若在沒有釋放鎖的情況下,再次獲得鎖,則state加1,在釋放資源時,state減1,因此Lock獲取多少次鎖就要釋放多少次鎖,直到state為0。
2、利用同步代碼塊
同步代碼塊一般使用Java的sychronized關鍵字來實現,有兩種方式對方法進行加鎖操作:
1):第一,在方法簽名處加synchronized關鍵字
2):第二,使用synchronized(對象或類)進行同步
這里的原則是鎖的范圍盡可能小,鎖的時間盡可能短,即能鎖對象,就不要鎖類,能鎖代碼塊,就不要鎖方法。
synchronized鎖特性由JVM負責實現。在JDK的不斷優化迭代中,synchronized鎖的性能得到極大提升,特別是偏向鎖的實現,使得synchronized已經不是昔日那個低性能且笨重的鎖了。
JVM底層是通過監視鎖來實現synchronized同步的。監視鎖即monitor,是每個對象與生俱來的一個隱藏字段。使用synchronized時,JVM會根據synchronized的當前使用環境,找到對應的monitor,再根據monitor的狀態進行加、解鎖的判斷(使用monitorenter和monitorexit指令實現)。例如:線程在進入同步方法或者代碼塊時,會獲取該方法或代碼塊所屬對象的monitor(在Java對象頭中),進行加鎖判斷。如果成功加鎖就成為該moniter的唯一持有者。monitor在被釋放前,不能被其他線程獲取。
從字節碼看synchronized鎖的具體實現:
同步方法的方法元信息中會使用ACC_SYNCHRONIZED標識該方法是一個同步方法。同步代碼塊中會使用monitorenter及monitorexit兩個字節碼指令獲取和釋放monitor。
如果使用monitorenter進入時monitor為0,表示該線程可以持有monitor后續代碼,并將monitor加1;如果當前線程已經持有了monitor,那么monitor繼續加1(可重入);
如果monitor非0,其他線程就會進入阻塞狀態(和Lock的state類似)。
JVM對synchronized的優化主要在于對monitor的加鎖、解鎖上。JDK6后不斷優化使得synchronized提供三種鎖的實現,包括偏向鎖、輕量級鎖、重量級鎖,還提供自動的升級和降級機制。JVM就是利用CAS在對象頭上設置線程ID,表示這個對象偏向于當前線程,這就是偏向鎖。
偏向鎖是為了在資源沒有被多線程競爭的情況下盡量減少鎖帶來的性能開銷。在鎖的對象頭中有一個ThreadId字段,當第一個線程訪問鎖時,如果該鎖沒有被其他線程訪問過,即ThreadId字段為空,那么JVM讓其持有偏向鎖,并將ThreadId字段設置為該線程的ID。當下一次獲取鎖時,會判斷當前線程的ID是否與鎖對象的ThreadId一致,如果一致,那么該線程不會再重復獲取鎖,從而提高了程序的運行效率。如果出現鎖的競爭情況,那么偏向鎖會被撤銷并升級為輕量級鎖。如果資源的競爭非常激烈,會升級為重量級鎖。
偏向鎖可以降低競爭開銷,它不是互斥鎖,不存在線程競爭情況,省去了再次判斷的步驟,提升了性能。
鎖優化
自旋鎖和自適應鎖:
互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要從用戶態轉到核心態中去完成。這些操作給操作系統的并發性能帶來了很大的壓力。同時,在很多應用上,共享數據的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復線程并不值得。如果物理機器上有一個以上的處理器,能讓兩個或以上的線程同時并行執行,我們就可以讓后面請求鎖的那個線程”稍等一下“,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖,為了讓線程等待,我們只需要讓線程執行一個忙循環,即自旋,這項技術就是所謂的自旋鎖。
自旋鎖在1.6之后默認開啟,自旋等待不能代替阻塞,雖然避免了線程切換的開銷(掛起喚醒,用戶態轉核心態),但是還是會占用處理器的時間,因此如果鎖被占用的時間很短,那么自旋等待的效果就會非常好,如果鎖占用的時間很長,那么自旋的線程只會白白消耗處理器資源,帶來性能浪費。因此自旋等待的時間要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,那就應當用傳統的方式去掛起線程了。自旋的次數默認是10次。
1.6引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
鎖消除:
鎖消除是指在虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源于逃逸分析的數據支持,如果判斷在一段代碼中,堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上數據對待,認為它們是線程私有的,同步加鎖就無需進行。
鎖粗化:
原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用范圍限制得盡量小——只在共享數據的實際作用域中才進行同步,這樣是為了使得需要同步的操作數量盡可能變小(減少鎖時間),如果存在鎖競爭,那等待鎖的線程也能盡快拿到鎖。但是如果一系列的連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能消耗。如StringBuffer類的append()方法就是這種情況,每個append()方法都對同一個對象加鎖,且append()可能連續出現多次。
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把鎖同步的范圍擴展(粗化)到整個操作序列的外部,如多個append()的話就會擴展到第一個append()操作之前直至最后一個append()操作之后,這樣只需要加鎖一次就可以了。
輕量級鎖:
1.6引入的新型鎖機制,輕量級是相對使用操作系統互斥量來實現的傳統鎖而言的,傳統的鎖機制就稱為重量級鎖。輕量級鎖并不能替代重量級鎖,它的本意是在沒有多線
程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。
要理解輕量級鎖以及偏向鎖的原理和運作過程,必須了解JVM的對象(對象頭部分)的內部布局。HotSpot JVM的對象頭(Object Header)分為兩部分信息,第一部分用來存儲對象自身的運行時數據,如哈希碼(hashCode)、GC分代年齡、鎖標志位等。官方稱為Mark Word,它是實現輕量級鎖和偏向鎖的關鍵。另外一部分用于存儲指向方法區對象類型的指針,如果是數組的話,還會有一個額外的部分用于存儲數組的長度。
Mark Word對象頭在不同狀態下的標識位存儲內容如下:
輕量級鎖的執行過程:在代碼進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標識位為01的時候),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word拷貝,加了一個前綴Displaced,即Displaced Mark Word。然后虛擬機將使用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針。如果這個更新動作成功了,那么這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖標識位修改為00,即表示此對象處于輕量級鎖定狀態。如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶占了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標志的狀態值變為10,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待的線程也要進入阻塞狀態。
可以看到輕量級鎖的加鎖過程是通過CAS來實現的,同樣,解鎖過程也是通過CAS操作來進行的,如果對象的Mark Word仍然指向著線程的鎖記錄,那就用CAS操作把對象當前的Mark Word和線程中復制的Displaced Mark Word替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他線程嘗試獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖能提升程序同步性能的依據是“對于絕大部分的鎖,在整個同步周期內都是不存在競爭的”,這是一個經驗數據。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,如果存在鎖競爭,那么除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。
偏向鎖:
1.6引入的一項鎖優化,目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭情況下把整個同步都消除掉,并且連CAS操作都不做了。
偏向鎖的“偏”,就是偏心的“偏”,它的意思是這個鎖會偏向于第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。
如果虛擬機開啟了偏向鎖,1.6默認開啟,那么當鎖對象第一次被線程獲取的時候,虛擬機會把對象頭中的標志為設為01,即偏向模式。同時使用CAS操作把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中,如果CAS操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作。當有另外一個線程去嘗試獲取這個鎖時,偏向模式宣告結束。根據鎖對象目前是否出于被鎖定的狀態,撤銷偏向(Revoke Bias)后恢復到未鎖定(標志位為01)或輕量級鎖定(標志位為00)的狀態,后續的同步操作就按輕量級鎖的過程來執行。
偏向鎖可以提高帶有同步但無競爭的程序性能。但是它并不一定總是對程序有利,如果程序中大多數的鎖總是被多個不同的線程訪問,那么偏向模式就是多余的。
線程同步:
即當有一個線程在對內存進行操作時,其他線程都不可以對這個內存進行操作,一直等待直到該線程完成操作,其他線程才能對該內存進行操作。
在多個線程對同一變量進行寫操作時,如果操作沒有原子性,就可能產生臟數據。所謂原子性,是指不可分割的一系列操作指令,在執行完畢前不能被任何其他操作中斷,那么全部執行,要么全部不執行。如果每個線程對共享變量的修改都是原子操作,就不存在線程同步問題。
i++操作就不具備原子性,它需要分成三部ILOAD-->IINC-->ISTORE。
CAS(Compare And Swap)操作具備原子性
實現線程同步的方式有很多,比如同步方法、鎖、阻塞隊列等。
Volatile
happen-before:先從happen-before了解線程操作的內存可見性。把happen before定義為方法hb(a,b)表示a happen before b。如果hb(a,b)且hb(b,c),那么能夠推導出hb(a,c)。
即如果a在b之前發生,那么a對內存的操作b是可見的,b之后的操作c也是可見的。
指令優化:計算機并不會根據代碼順序按部就班地執行相關指令。CPU處理信息時會進行指令優化,分析哪些取數據可以合并進行,哪些存數據動作可以合并進行。CPU拜訪
一次遙遠的內存,一定會到處看看,是否可以存取合并,以提高執行效率。
happen-before是時鐘順序的先后,并不能保證線程交互的可見性。那什么是可見性呢?可見性是指某線程修改共享變量的指令對其他線程來說都是可見的,它反應的
是指令執行的實時透明度。先從Java內存模型說起:每個線程都有獨占的內存區域,如操作棧,本地變量表等。線程本地內存保存了引用變量在堆內存中的副本。線程對
變量的所有操作都在本地內存區域中進行,執行結束后再同步到堆內存(主內存)中去。在這個操作過程中,該線程對副本的操作,對于其他線程都是不可見的。
volatile的英文本義是揮發、不穩定的,延伸意義為敏感的。當使用volatile修飾變量時,意味著任何對此變量的操作都會在主內存中進行,不會產生副本,以保證共享
變量的可見性,局部阻止了指令重排的發生。它只是輕量級的線程操作可見方式,并非同步方式,如果是多寫場景,一定會產生線程安全問題。如果是一寫多讀的并發場景,
使用volatile修飾變量則非常合適。volatile一寫多讀最典型的應用是CopyOnWriteArrayList,它在修改數據時會把整個集合的數據全部復制出來,對寫操作加鎖,修改完成
后,再用setArray()把array指向新的集合。使用volatile可以使線程盡快地感知array的修改,不進行指令重排,操作后即對其他線程可見。
源碼如下:
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
/** The array, accessed only via getArray/setArray. */ 真正存儲元素的數組
private transient volatile Object[] array;
final void setArray(Object[] a) {
array = a;
}
}
在實際的業務中,如果不確定共享變量是否會被多個線程并發寫,保險的做法是使用同步代碼塊來實現線程同步。另外,因為所有的操作都需要同步給內存變量
所以volatile一定會使線程的執行速度變量,故要慎重定義和使用volatile屬性。
信號量同步
信號量同步是指在不同的線程之間,通過傳遞同步信號量來協調線程執行的先后次序。基于時間維度的CountDownLatch和基于信號維度的Semaphore。
CountDownLatch
public class CountDownLatch {
/**
* Synchronization control For CountDownLatch.
* Uses AQS state to represent count.
*/
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
private final Sync sync;
...
}
可以看到其和ReentrantLock類似,都是依賴AQS中的可見性變量state。
CountDownLatch:倒數計數器,比如日常開發中經常會遇到需要在主線程中開啟多線程去并行執行任務,并且主線程需要等待所有子線程執行完畢后再進行匯總的場景,它的內部提供了一個計數器,再構造閉鎖時必須指定計數器的初始值(state),且計數器的初始值必須大于0。另外它還提供了一個countDown方法來操作計數器的值,(在子線程中)每調用一次countDown方法計數器會減1,直到計數器的值減為0(類似于獲取到了鎖),所有因調用await方法而阻塞的線程都會被喚醒。
Semaphore:CountDownLatch是基于計數的同步類。在實際編碼中,可能需要處理基于空閑信號的同步情況。
public class Semaphore implements java.io.Serializable {
private static final long serialVersionUID = -3222578661600680210L;
/** All mechanics via AbstractQueuedSynchronizer subclass */
private final Sync sync;
/**
* Synchronization implementation for semaphore. Uses AQS state
* to represent permits. Subclassed into fair and nonfair
* versions.
*/
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 1192457210091910933L;
Sync(int permits) {
setState(permits);
}
final int getPermits() {
return getState();
}
...
}
// 默認使用非公平鎖
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -2694183684443567898L;
NonfairSync(int permits) {
super(permits);
}
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}
// 構造方法
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
...
}
使用Semaphore的構造方法指定同時處理的線程的數量,只有在調用Semaphore的acquire()成功后,才可以往下執行,完成后執行release()釋放持有的信號量,下一個線程就可以馬上獲取這個空閑信號量進入執行。
Semaphore的release()和CountDownLatch的countDown方法相同。
acquire()方法在直到有一個信號量空閑時,才會執行后續的代碼,否則,將一直阻塞。可以理解為Semaphore允許有創建對象時在構造中指定的鎖的數量,當鎖有空閑時,線程就可以拿到鎖,否則將一直等待。拿到鎖的線程執行完畢后釋放鎖。
countDown和release都是使state減1。
線程池:
線程池的好處:線程使應用能更加充分利用CPU、內存、網絡、IO等系統資源。線程的創建需要開辟虛擬機棧、本地方法棧、程序計數器等線程私有的內存空間。
在線程銷毀時需要回收這些系統資源。因此頻繁的創建和銷毀線程會浪費大量的系統資源,增加并發編程風險。另外,在服務器負載過大的時候,如何讓新的線程等待或者
友好地拒絕服務?這些都是線程本身無法解決的。所以需要通過線程池協調多個線程,并實現類似主次線程隔離、定時執行、周期執行等任務。線程池的作用包括:
1):利用線程池管理并復用線程、控制最大并發數等
2):實現任務線程隊列緩存策略和拒絕機制
3):實現某些與時間相關的功能,如定時執行、周期執行
4):隔離線程環境。通過配置兩個或多個線程池,將一臺服務器上較慢的服務和其他服務隔離開,避免各服務線程相互影響。
參數說明:
1、corePoolSize 表示常駐核心線程數,如果大于0,則即使執行完任務,線程也不會被銷毀。因此這個值的設置非常關鍵,設置過小會導致線程
頻繁地創建和銷毀,設置過大會造成浪費資源
2、maximumPoolSize 表示線程池能夠容納的最大線程數。必須大于或者等于1。如果待執行的線程數大于此值,需要緩存在隊列中等待
3、keepAliveTime 表示線程池中的線程空閑時間,當空閑時間達到keepAliveTime值時,線程會被銷毀,避免浪費內存和句柄資源。在默認情況下,當線程池
中的線程數大于corePoolSize時,keepAliveTime才起作用,達到空閑時間的線程,直到只剩下corePoolSize個線程為止。但是當ThreadPoolExecutor的
allowCoreThreadTimeOut設置為true時(默認false),核心線程超時后也會被回收。(一般設置60s)
4、TimeUnit 表示時間單位,keepAliveTime的時間單位通常是TimeUnit.SECONDS
5、workQueue 表示緩存隊列。
6、threadFactory 表示線程工廠。它用來生產一組相同任務的線程。線程池的命名是通過給threadFactory增加組名前綴來實現的。在用jstack分析時,就可以知道
線程任務是由哪個線程工廠產生的。
7、handler 表示執行拒絕策略的對象。當超過workQueue的緩存上限的時候,就可以通過該策略處理請求,這是一種簡單的限流保護。友好的拒絕策略可以是如下
三種:
1):保存到數據庫進行削峰填谷。在空閑時再取出來執行
2):轉向某個提示頁面
3):打印日志
總結使用線程池需要注意以下幾點:
1、合理設置各類參數,應根據實際業務場景來設置合理的工作線程數
2、線程資源必須通過線程池提供,不允許在應用中自行顯式創建線程
3、創建線程或線程池請指定有意義的線程名稱,方便出錯時回溯
4、線程池不允許使用Executors,而是通過ThreadPoolExecutor的方式來創建,這樣的處理方式能更加明確線程池的運行規則,規避資源耗盡的風險。
如創建線程池例子:
/**
* 創建一個用于發送郵件的線程池,核心線程數為1,最大線程數為5,線程空閑時間為60s,拒絕策略為打印日志
*/
private static final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(50), new CustomThreadFactory("redeemSendMail"), new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 只打印日志,什么都不做
LOGGER.error("Task{},rejected from{}", r.toString(), executor.toString());
}
});
ThreadLocal:
ThreadLocal是每一個線程私有的,每一個線程都有獨立的變量副本,其他線程不能訪問,所以不存在線程安全問題。也不會影響程序的性能。ThreadLocal對象通常都是
由private static修飾的,因為都需要復制進入本地線程,所以非static意義不大。但是ThreadLocal無法解決共享對象的更新問題。
ThreadLocal有個靜態內部類ThreadLocalMap,而ThreadLocalMap還有一個靜態內部類Entry【 static class Entry extends WeakReference<ThreadLocal> 】,ThreadLocal的ThreadLocalMap屬性的賦值是在createMap方法中進行的。ThreadLocal和ThreadLocalMap有三組對應的方法:get、set和remove;在ThreadLocal中只對它們做校驗和判斷,最終的實現會落在ThreadLocalMap上。Entry繼承自WeakReference,沒有方法,只有一個value屬性【Object value;】
ThreadLocal可用來為每個請求存儲上下文,如session;
ThreadLocal的副作用:
為了使線程安全的共享某個變量,JDK提供了ThreadLocal,但是ThreadLocal有一定的副作用,主要問題是會產生臟數據和內存泄漏。這兩個問題通常是在線程池中的線程
使用ThreadLocal引發的,因為線程池有線程復用和內存常駐兩個特點。
1、臟數據,線程復用會產生臟數據。由于線程池會重用Thread對象,那么與Thread綁定的類的靜態屬性ThreadLocal變量也會被重用。如果在實現的線程run()方法體中沒有顯式地調用remove清理線程相關的ThreadLocal信息,那么倘若下一個線程不調用set()設置初始值,就可能get()到重用的線程信息。包括ThreadLocal所關聯的線程對象的value值。
2、內存泄漏,在源碼注釋中提示使用static關鍵字來修飾ThreadLocal(放在常量池中了)。在此場景下,寄希望于ThreadLocal對象失去引用后,觸發弱引用機制來
回收Entry的Value就不現實了。
所以,解決上面兩個問題的方法就是在每次用完ThreadLocal時,必須要及時調用remove()方法清理。
文章來源于網絡。
感謝大家閱讀,歡迎大家私信討論。給大家推薦一個Java技術交流群:473984645里面會分享一些資深架構師錄制的視頻資料:有Spring,MyBatis,Netty源碼分析,高并發、高性能、分布式、微服務架構的原理,JVM性能優化、分布式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多!
推薦大家閱讀:
Java高級架構學習資料分享+架構師成長之路?
個人整理了更多資料以PDF文件的形式分享給大家,需要查閱的程序員朋友可以來免費領取。還有我的學習筆記PDF文件也免費分享給有需要朋友!