本篇參考許多著名的書籍,形成讀書筆記,便於加深記憶。
前文傳送門:Java並發讀書筆記:JMM與重排序
導致線程不安全的原因
當一個變量被多個線程讀取,且至少被一個線程寫入時,如果讀寫操作不遵循happens-before
規則,那么就會存在數據競爭的隱患,如果不給予正確的同步手段,將會導致線程不安全。
什么是線程安全
Brian Goetz在《Java並發編程實戰》中是這樣定義的:
當多個線程訪問一個類時,如果不用考慮這些線程在運行時環境下的調度和交替執行,並且不需要額外的同步及在調用方代碼不必做其他的協調,這個類的行為仍然是正確的,那么這個類就是線程安全的。
周志明在《深入理解Java虛擬機》中提到:多個線程之間存在共享數據時,這些數據可以按照線程安全程度進行分類:
不可變
不可變的對象一定是線程安全的,只要一個不可變的對象被正確地構建出來,那么它在多個線程中的狀態就是一致的。例如用final關鍵字修飾對象:
- 修飾的是基本數據類型,final修飾不可變。
- 修飾的是一個對象,就需要保證其狀態不發生變化。
JavaAPI中符合不可變要求的類型:String類,枚舉類,數值包裝類型(如Double)和大數據類型(BigDecimal)。
絕對線程安全
即完全滿足上述對於線程安全定義的。
滿足該定義其實需要付出很多代價,Java中標注線程安全的類,實際上絕大多數都不是線程安全的(如Vector),因為它仍需要在調用端做好同步措施。Java中絕對線程安全的類:CopyOnWriteArrayList
、CopyOnWriteArraySet
。
相對線程安全
即我們通常所說的線程安全,Java中大部分的線程安全類都屬於該范疇,如Vector
,HashTable
,Collections
集合工具類的synchronizedCollection()
方法包裝的集合等等。就拿Vector舉例:如果有個線程在遍歷某個Vector、有個線程同時在add這個Vector,99%的情況下都會出現ConcurrentModificationException
,也就是fail-fast
機制。
線程兼容
對象本身並不是線程安全的,可以通過在調用段正確同步保證對象在並發環境下安全使用。如我們之前學的分別與Vector和HashTable對應的ArrayList
和HashMap
。
對象通過synchronized關鍵字修飾,達到同步效果,本身是安全的,但相對來說,效率會低很多。
線程對立
無論調用端是否采取同步措施,都無法正確地在多線程環境下執行。Java典型的線程對立:Thread類中的suspend()和resume()方法:如果兩個線程同時操控一個線程對象,一個嘗試掛起,一個嘗試恢復,將會存在死鎖風險,已經被棄用。
常見的對立:System.setIn()
,System.setOut()
和System.runFinalizersOnExit()
。
互斥同步實現線程安全
互斥同步也被稱做阻塞同步(因為互斥同步會因為線程阻塞和喚醒產生性能問題),它是實現線程安全的其中一種方法,還有一種是非阻塞同步,之后再做學習。
互斥同步:保證並發下,共享數據在同一時刻只被一個線程使用。
synchronized內置鎖
其中使用synchronized
關鍵字修飾方法或代碼塊是最基本的互斥同步手段。
synchronized
是Java提供的一種強制原子性的內置鎖機制,以synchronized
代碼塊的定義方式來說:
synchronized(lock){
//訪問或修改被鎖保護的共享狀態
}
它包含了兩部分:1、鎖對象的引用 2、鎖保護的代碼塊。
每個Java對象都可以作為用於同步的鎖對象,我們稱該類的鎖為監視器鎖(monitor locks),也被稱作內置鎖。
可以這樣理解:線程在進入synchronized之前需要獲得這個鎖對象,在線程正常結束或者拋出異常都會釋放這個鎖。
而這個鎖對象很好地完成了互斥,假設A持有鎖,這時如果B也想訪問這個鎖,B就會陷入阻塞。A釋放了鎖之后,B才可能停止阻塞。
鎖即對象
- 對於普通同步方法,鎖是當前實例對象(this)。
//普通同步方法
public synchronized void do(){}
- 對於靜態同步方法,鎖是當前的類的Class對象。
//靜態同步方法
public static synchronized void f(){}
- 對於同步方法塊,鎖的是括號里配置的對象。
//鎖對象為TestLock的類對象
synchronized (TestLock.class){
f();
}
明確:synchronized方法和代碼塊本質上沒啥不同,方法只是對跨越整個方法體的代碼塊的簡短描述,而這個鎖是方法所在對象本身(static修飾的方法,對象是當前類對象)。這個部分可以參考:Java並發之synchronized深度解析
是否要釋放鎖
釋放鎖的情況:
- 線程執行完畢。
- 遇到return、break終止。
- 拋出未處理的異常或錯誤。
- 調用了當前對象的wait()方法。
不釋放鎖的情況:
- 調用了Thread.sleep()和Thread.yield()暫停執行不會釋放鎖。
- 調用suspend()掛起線程,不會釋放鎖,已被棄用。
實現原理
通過對.class文件反編譯可以發現,同步方法通過ACC_SYNCHRONIZED
修飾,代碼塊同步使用monitorenter
和monitorexit
兩個指令實現。
雖然兩者實現細節不同,但其實本質上都是JVM基於進入和退出Monitor對象來實現同步,JVM的要求如下:
-
monitorenter
指令會在編譯后插入到同步代碼塊的開始位置,而monitorexit
則會插入到方法結束和異常處。 -
每個對象都有一個
monitor
與之關聯,且當一個monitor
被持有之后,他會處於鎖定狀態。 -
線程執行到
monitorenter
時,會嘗試獲取對象對應monitor
的所有權。 -
在獲取鎖時,如果對象沒被鎖定,或者當前線程已經擁有了該對象的鎖(可重進入,不會鎖死自己),將鎖計數器加一,執行
monitorexit
時,鎖計數器減一,計數為零則鎖釋放。 -
獲取對象鎖失敗,則當前線程陷入阻塞,直到對象鎖被另外一個線程釋放。
啥是重進入?
重進入意味着:任意線程在獲取到鎖之后能夠再次獲取該鎖而不會被鎖阻塞,synchronized
是隱式支持重進入的,因此不會出現鎖死自己的情況。
這就體現了鎖計數器的作用:獲得一次鎖加一,釋放一次鎖減一,無論獲得還是釋放多少次,只要計數為零,就意味着鎖被成功釋放。
ReentrantLock(重入鎖)
ReentrantLock
位於java.util.concurrent(J.U.C)
包下,是Lock接口的實現類。基本用法與synchronized
相似,都具備可重入互斥的特性,但擁有擴展的功能。
Lock接口的實現提供了比使用synchronized方法和代碼塊更廣泛的鎖操作。允許更靈活的結構,具有完全不同的屬性,並且可能支持多個關聯的Condition對象。
RenntrantLock官方推薦的基本寫法:
class X {
//定義鎖對象
private final ReentrantLock lock = new ReentrantLock();
// ...
//定義需要保證線程安全的方法
public void m() {
//加鎖
lock.lock();
try{
// 保證線程安全的代碼
}
// 使用finally塊保證釋放鎖
finally {
lock.unlock()
}
}
}
API層面的互斥鎖
ReentrantLock表現為API層面的互斥鎖,通過lock()
和unlock()
方法完成,是顯式的,而synchronized表現為原生語法層面的互斥鎖,是隱式的。
等待可中斷
當持有線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待或處理其他事情。
公平鎖
ReentrantLock鎖是公平鎖,即保證等待的多個線程按照申請鎖的時間順序依次獲得鎖,而synchronized是不公平鎖。
鎖綁定
一個ReentrantLock對象可以同時綁定多個Condition對象。
JDK1.6之前,ReentrantLock在性能方面是要領先於synchronized鎖的,但是JDK1.6版本實現了各種鎖優化技術,后續性能改進會更加偏向於原生的synchronized。
參考數據:《Java並發編程實戰》、《Java並發編程的藝術》、《深入理解Java虛擬機》