線程安全:線程安全問題的發現與解決-CSDN博客
Java中所使用的并發機制依賴于JVM的實現和CPU的指令。 所以了解并掌握深入Java并發編程基礎的前提知識是熟悉JVM的實現了解CPU的指令。
1.volatile簡介
在多線程并發編程中,有兩個重要的關鍵字:synchronized和volatile,譯為
volatile是輕量級的synchronized,它在多線程開發中確保了共享內存變量的"可見性"。
什么叫做可見性?簡要的概述其實很簡單:
當一個線程修改一個共享變量的話,另一個線程能知道并讀到這個修改后的值。
如果volatile變量修飾符使用得當的話,會比synchronized的使用和執行成本更低,因為它不會引起
線程的上下文切換和調度。
1.1volatile的定義與使用
volatile的定義:Java語言允許線程共享變量,為了確保共享變量能被準確和一致的更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個字段被聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。
上面我們提到了,可見性,volatile,synchronized還有排他鎖這幾個新鮮的概念,我們來逐個討論一下,等到了最后volatile關鍵字也就理解的差不多了。
1.內存可見性
談到可見性,一般都是內存可見性,內存可見性的問題是由于編譯器優化導致的,
正如我們開頭展示的筆記那樣,一個Java文件想要被cpu所執行,需要經歷重重編譯,轉化等等
而在程序員這個圈子里,水平各個參差不齊,總的來說還是菜鳥更多,大佬更少,怎么樣在這種情況讓菜鳥也能寫出來優秀的代碼呢?大佬們就在編譯器上動了手腳,加入了優化機制這樣一來,即使初學者寫出的代碼不夠高效,編譯器也能在背后“兜底”,生成更高性能的執行代碼,從而實現“寫出來的代碼比人本身更聰明”的目標。編譯器編譯的時候自動分析代碼的邏輯,在保持代碼邏輯不變的前提下,自動修改代碼的內容,從而讓代碼變得更高效。
在這個案例中,我們希望通過線程2來控制線程1的循環條件,從而控制線程1的結束。
從線程1的循環中,我們可以看到,每次循環的條件,再假設線程2不能控制的前提下,都是為真的,編譯器就發現了
1. 這里的isRunning每次讀到的都是相同的值,僅僅1s足夠讓循環執行上萬次,重復無效的代碼了
2. 編譯器查看循環條件和循環體并沒有發現需要修改的地方
對于編譯器而言,它無法靜態分析出這個修改何時發生、是否會發生,甚至是否發生在同一個內存空間(因為線程間的可見性并不總是成立)
并且,這段代碼的執行,依靠了,讀內存操作,比較和跳轉操作
通過讀內存操作從內存中讀取isRunning的值到CPU寄存器,通過比較寄存器存放的值和true是否相同,如果相同就繼續執行否則使用跳轉語句到指定的位置。
通過優化后變為
-
從內存讀取變量值:通過“load”操作,將共享變量
isRunning
的值從主內存讀取到 CPU 寄存器 或說是線程工作內存中。 -
寄存器中進行比較:循環條件判斷時,CPU 不會每次都訪問主內存,而是直接比較寄存器中或者說是工作內存中的值是否為
true
。 -
分支跳轉指令執行控制流:
-
如果等于
true
,程序繼續執行循環體; -
如果等于
false
,程序跳轉到循環之后的位置,退出循環
-
也就是說,編譯器此處做了個大膽的決定,把訪問內存這步操作在第一次訪問后給優化掉了,后續的循環只需要從CPU寄存器或者緩存中讀取值即可!
此時如果t2線程即使修改了isRunning的值,t1線程也無法感知到了,t1已經被優化了并沒有從內存中讀取而是從(寄存器/緩存)工作內存中讀取了!
對于多線程中的內存可見性問題,其中一個關鍵原因就是編譯器為了優化性能而對代碼進行了重排和緩存。
比如,在一個循環中重復讀取某個變量的值,編譯器會認為:
“這個變量的值在循環體內沒有被修改,而且看上去始終相同,那我就沒必要每次都從內存中去讀了,直接緩存到寄存器里用就行。
小問題:如果我們此時將While循環中的空代碼塊加入Thread.sleep(1)后發現?
線程1居然神奇的受到了線程2輸入的非零數字的影響結束了循環。
難道說Thread.sleep()也能解決內存可見性的問題嗎?
我們知道,內存可見性的問題本質上是編譯器優化所帶來的,但是引入sleep后這個代碼中的 ,從內存讀取的操作并沒有被編譯器優化掉
代碼的指令大致有
1.從內存中讀取數據load
2.cmp通過比較來判斷循環條件是否為真
3.sleep方法(背后是很多多的指令)
哪怕是sleep(0)在這里也不會被優化掉
我們在循環體中做各種復雜的操作,都會引起上述的優化失效!
綜上內存可見性的問題,我們已經了解的差不多了,可以談一下volatile關鍵字了,
如果我們在代碼的isRunning變量加上了volatile關鍵字,就可以解決上述的問題!
?
有沒有覺得很神奇,僅僅只是加了個關鍵字就解決了,我們談論那么長時間的內存可見性問題?
總結成一句話來說:
volatile 保證可見性,靠的是底層 JIT 編譯器在寫操作中生成帶
lock
前綴的匯編指令,這個指令通過緩存一致性協議,確保變量修改對所有 CPU 可見。
2.synchronized簡介
請注意volatile關鍵字只能解決內存可見性的問題,對于,多個進程訪問修改同一個變量,而造成的線程安全問題是無能為力的只能依靠synchronized
2.1synchronized的定義和使用
在多線程并發編程中,synchronized真是一位遠古大能級別的角色,很多人會稱呼他為重量級鎖,
但是隨著JavaSE的各種優化,有些情況下,他就不是那么重了。
synchronized實現同步的基礎:Java中每一個對象都可以作為鎖。具體表現為以下三種形式:
1.對于普通同步方法,鎖是當前的實例對象
2.對于靜態同步方法,鎖是當前類的Class對象
3.對于同步方法塊,鎖是synchronized括號中配置的對象
當一個線程試圖訪問同步代碼塊時,他首先必須要先得到鎖,退出或者拋出異常時必須釋放鎖。
synchronized(obj){...}中的obj就是在同步代碼塊中用來加鎖的那種對象,JVM會對這個obj對象的監視器(monitor)進行加鎖和解鎖,從而實現線程之間的互斥。
注意:此處加鎖并不是禁止線程調度,而是防止其他線程插隊。
該鎖塊中一共有大概
count++ == >(count = count + 1)
load(從內存讀取變量
count
的當前值)add(對值進行+1的操作)
save(將新值寫回內存)
三個指令操作,執行上述這些操作指令的時候,是隨時會被其他線程插隊從cpu上調度走的,如果加了鎖就保證了操作的原子性,
因為此時如果其他線程嘗試加鎖操作,就會產生阻塞,從而避免執行上述指令時被插隊的問題。
(使用lock和unlock來代替synchronized的{ 和} )
?synchronized的要點
1.進入 { 就是加鎖,離開 } 就是解鎖?
2.加鎖操作是為了防止其他線程在本線程執行中插隊,而不影響本線程調度
3.鎖對象,兩個或者多個線程針對同一個對象加鎖才會有鎖競爭,鎖才會生效
對于下面的代碼是否存在線程安全問題?
對于兩個線程一個加鎖,一個沒有加鎖是會產生線程安全的問題的,
因為在一把鎖生效時,原子操作仍然會被打斷,另一個線程并沒有因為鎖而受到限制
對于下面兩種加鎖的方式,就涉及到鎖的粒度
t1線程:
對整個循環操做加鎖,鎖的粒度大,鎖內部代碼邏輯復雜
t2線程
每一次循環操作都會加鎖,加100次鎖,鎖的粒度小,鎖內部代碼邏輯少
由于synchronized的設計
在synchronized(){
}代碼塊中,
Java 中的
synchronized
關鍵字由 JVM 保證:無論同步代碼塊中是正常執行、return
提前返回,還是拋出異常(throw
)提前終止,都會自動執行解鎖(unlock)操作。
這一點是很多高級語言設計lock和unlock操作的不足之處
1.可重入鎖
對于下面的代碼,是否可以正常運行呢?
假設說不存在可重入鎖的概念,我們來分析
當線程2進入第一層鎖,此時已經加鎖成功,如果此時再對同一個對象加第二次鎖就會產生死鎖,因為第一次加鎖的解鎖操作需要等到第二次加鎖并解決成功,而第二次的加鎖操作又得等第一次解鎖,就死鎖了。
但是Java中存在可重入鎖的概念,十分簡單:
Java 中的 synchronized
是 可重入鎖(Reentrant Lock),其工作機制:
-
每個鎖記錄:
-
當前持有鎖的線程ID
-
當前線程對這把鎖的重入次數(計數器)
-
于是:
-
線程 T1 首次獲得鎖
obj
,線程ID 被記錄,重入次數為 1。 -
T1 再次進入
synchronized(obj)
,JVM 檢查:鎖的持有者仍是 T1,本線程重入,于是允許繼續進入,同時 重入計數 +1。 -
等兩個
synchronized
代碼塊都執行完后,T1 每退出一層,重入計數 -1,直到為 0 時,才真正釋放鎖。
避免了“自己鎖死自己”的問題,確保線程可以多次、安全地進入同一把鎖控制的臨界區。
那么鎖到底存在哪里呢?鎖里面會存儲什么信息呢?
這些就涉及到深入的理解了
synchronized的實現原理與應用&Java對象的內存布局_java synchronized原理java對象內存布局-CSDN博客
3.wait和notify簡介
3.1wait的定義和使用
首先需要清楚的是,wait和notify并不是Thread包括任何線程相關類的方法,而是Object基類的方法
在多線程的世界中,線程的調度是隨機的,雖然join方法可以簡單的控制線程的結束時間,
Thread.join()
是一個同步等待方法,可以讓主線程等待子線程執行完畢之后再繼續執行。
在main線程中調用t1,join()和t2.join(),main線程會等待t1和t2執行完畢,main才會執行完畢,而且t1和t2的執行完畢順序也不確定
學習過操作系統課程的一定見過一個很經典的操作,叫PV操作,里面的代碼都是手寫的,需要我們來分析,等到線程1完成了什么什么條件或者任務就會喚醒線程2的操作等等,但是PV操作和我們的wait和notify操作有著本質的區別
1.PV操作是操作系統底層的操作叫原語,基于“信號量(Semaphore)”,通過計數控制資源訪問
2.wait和notify方法是Java語言層面,基于“對象監視器(Monitor)”,通過條件變量進行線程協作
我們學到這里可以把PV操作暫時先忘掉了,雖然二者的很多用法相同,但是為了避免混淆還是不提及
程序中存在t1線程,t2線程
要求t1先執行某個邏輯A 然后t2再執行某個邏輯B
就比如我們生活的例子,只有A球員把球傳給B球員,B球員才能完成扣籃的操作
雖然wait方法任何對象都可以直接調用,如果我們直接調用的話會拋出以下異常:
1.在使用前wait也和sleep方法一樣需要拋出InterruptedException異常
2.運行后發現拋出了java.lang.IllegalMonitorStateException異常
翻譯一下就是非法的監視器狀態異常也就是說
你現在沒有處于這個對象的監視器鎖內部狀態,卻調用了必須在其中調用的方法。
JVM內部實現synchronized時,使用了形如monitor屬性作為變量/方法名,也被稱為“監視器鎖
wait()
必須在 synchronized(obj)
中使用,否則 JVM 會拋出 IllegalMonitorStateException
,因為你沒有持有該對象的 monitor 鎖。
就像你去面試一樣,你都沒有準備去面試呢,就在想以后薪資會給你開多少。
使用wait的時候如果沒有被notify就會一直阻塞
在synchronized代碼塊中一共有三個動作:
1.釋放掉當前鎖
2.等待其他線程通知,此時處于阻塞狀態
3.當通知到達后,從阻塞狀態到就緒狀態,并重新嘗試獲取到鎖
假如wait一直占著鎖,別的線程會一直等待鎖,造成死鎖
wait如果是無參版本的話,屬于是死等,而wait也存在有參數的版本,同sleep一樣,等待一定的時間就不會等待了,
同時notify也有一個notifyAll的版本會喚醒所有線程,而notify只是隨機喚醒,上述例子中只有兩個線程,所以一個wait一個喚醒,如果多個線程就不一定的。
sleep和wait的區別
sleep()
是讓線程“暫停”一會兒,wait()
是讓線程“等待”別人通知它繼續。
對比點 | wait() | sleep() |
---|---|---|
1. 設計目的 | 主要用于線程間通信與協作,通常與 notify() / notifyAll() 搭配使用。 | 主要用于讓線程休眠一段時間,是一種簡單的阻塞延遲機制。 |
2. 是否釋放鎖 | 釋放鎖。wait() 會釋放當前對象的監視器鎖(monitor)。 | 不釋放鎖。線程進入休眠時仍然持有鎖。 |
3. 是否需要在同步塊中調用 | 必須在 synchronized 塊中使用,否則拋出 IllegalMonitorStateException 。 | 不需要,可以在任何地方調用。 |
4. 是否可以被喚醒 | 可以被 notify() / notifyAll() 喚醒,也可以被 interrupt() 打斷。 | 只能被 interrupt() 打斷,無法被 notify() 喚醒。 |
5. 是否拋異常 | 需要處理 InterruptedException 。 | 也需要處理 InterruptedException 。 |
6. 喚醒后行為 | 通常被喚醒后繼續參與協作,如再次 wait() 或繼續執行臨界區。 | 被打斷或睡眠時間到后繼續執行,不涉及線程間協作。 |