一、出現線程安全的原因
1.【根本原因】線程的調度執行時隨機的(搶占式執行)->罪魁禍首
2.多個線程同時修改同一個變量
如果是一個線程修改一個變量 或者 多個線程讀取同一個變量 或者 多個線程修改不同變量 這些都沒事。
3.修改操作不是原子的!
像count++ 這樣的修改,就是不是原子的修改。
但是像 = 這樣的修改,就是屬于原子的。(在Java針對內置類型進行 = ,或者針對引用 = 都是原子的)(*但是,在C++中就不一定了,還是不是原子的,就得具體問題具體分析了)
后序判定某個代碼是否是線程安全的,需要結合這幾點一起來判定。
*如果將全局變量count放入到main方法當中,就會出現報錯。
把變量改成局部變量,編譯直接過不了了:lambda表達式要想正確捕獲變量,要求是final或者事實final(雖然沒有加final關鍵字,但是代碼中確實也沒人修改)
寫成成員變量在lambda中確實能夠使用,此時不是走“變量捕獲”語法,而是“內部類訪問外部類成員”,本身就ok。
lambda本質上就是匿名 內部類(函數式接口)
內部類訪問外部類成員,本來就可以實現。
*擴展String?
String是不可變對象,new好一個String對象本身就是不能修改的。設計成不可變對象,其中有一個理由,就是不可變對象天然就是線程安全的。
不可變對象,方便放到常量池中進行緩存。不可變對象,hasCode是固定值,也方便和哈希表進行結合,作為hash的Key
StringBuffer本身確實修改了,但是又通過其他路徑(例如枷鎖)解決線程安全問題。
StringBuilder是徹底的線程不安全。
*什么是原子的?一個事物是原子的,說明他就是不可拆分的最小單位。SQL中,事務就是把多個SQL打包成一個整體,執行的時候,要么全都執行完,要么一個都不執行。就不會出現執行一半的情況,這就成為原子性。
此處談到的原子也是類似的含義,CPU執行指令的角度,如果是一條指令,對于CPU來說,要么就是執行完,要么就是不執行,不會出現“一個指令執行一般”這樣的情況。CPU執行一條指令,這個行為就是原子的。
像count++這樣的指令,對應到多條CPU指令,CPU執行過程中就可能執行一半,就調度走執行別人的指令了(這就不是原子的)
=這樣的操作,也是對應到一條CPU指令(類似于MOVE)
那么如何將這些操作變為線程安全的呢?
核心思路:把修改操作變成原子的。
通過鎖來實現。
關鍵字:synchronized通過這個關鍵字來實現使用鎖。
對于鎖這樣的概念,涉及到兩個核心操作:
1.加鎖
2.解鎖
Java就通過這一個關鍵字來表示這兩種操作,進來就是加鎖,出去就是解鎖。sychronized()的()里面填寫的是“鎖對象”,真正用來枷鎖的鎖是誰?——>在Java中,任何一個對象都可以用來作為鎖對象(引用類型,不能是內置類型)
加鎖,就是把若干個操作“打包成一個原子”。不是說把這count++的三個指令變成一個指令了。也不是說,這三個指令就必須要一口氣在cpu上執行完,不會觸發調度。加鎖會影響到其他的加鎖線程,而且是加同一個鎖的線程。
當兩個線程,嘗試競爭同一把鎖,才會產生“阻塞”,如果是競爭不同的鎖,就沒有影響。
sychronized(鎖對象),看鎖對象是不是同一個對象。
鎖競爭(Lock Contention)是指多個線程試圖同時訪問同一個臨界區(即需要互斥訪問的代碼區域),因此它們之間產生了競爭。在任意時刻,只能有一個線程持有鎖并進入臨界區,其他試圖進入臨界區的線程必須等待。
如果是兩個線程,一個加鎖了另一個么有加鎖,這樣就不會產生阻塞。兩個線程都加鎖了,而且是同一個對象,才會產生鎖競爭。
此處的加鎖和解鎖,也可以視為兩個cpu指令。這兩個操作使得這兩個線程各自循環執行5w次。
整個程序按照如圖所示的流程進行:
本來load add save 在兩個線程中是穿插執行的,但是在引入鎖之后,就變成了“串行執行”,不再穿插,最后輸出結果也自然是1w次。
*要是兩個鎖對象不一樣:不一樣就不會產生阻塞,程序的執行也就不會出現上述的“禁止插隊”這樣的行為。
這里又一系列很復雜的邏輯
這里也有一系列很復雜的邏輯
日常工作中,一般都是讓加鎖范圍盡量的小
這樣的話,可以并發執行的邏輯就更多,此時外部的邏輯通常是更復雜的。
此時這張圖中,只有count++是串行的。
引入多線程,就是為了并發執行,就是為了充分利用cpu多核心資源。
多進程編程 和 多線程編程 就是在利用多核心的編程手法。
t1如果加上鎖,t1就會不停地執行循環,直到把5w次都執行完,才會去釋放鎖。
t2只能阻塞等待,一直等到t1釋放鎖(t1的5w次循環都執行完了),t2才能繼續執行
此時,這兩個線程的循環時完全串行的,(也就是和一個線程執行是類似的了),這種寫法,兩個代碼是完全串行化。
此時,并沒有把多核心利用起來。
這種寫法,在當前代碼下,執行速度反而更快,主要是因為當前的任務很簡單。
*這種情況下,t1和t2誰先拿到鎖?
結論沒有唯一性,假設t1先拿到鎖,當t1循環完畢一次,下一次加鎖可能是t1繼續加上,也可能是t2加上(這里體現了隨機調度)。類似于“數據庫隔離級別”,隔離級別越高,并發程度越低,執行速度越慢。
synchronized使用方法
1.synchronized(鎖對象){}
基礎使用,常見使用。不是禁止調度,而是禁止其他線程插隊。
2.修飾一個普通方法,就可以省略鎖對象:
*此時,相當于針對this加鎖,不是沒有鎖對象,而是把鎖對象給省略掉了
等價于
上面這兩種寫法,實際上沒有任何區別。鎖對象,是啥對象不重要,重要的是,兩個線程是否是針對同一個對象加鎖。
3.synchronized修飾靜態方法
此時認為synchronized是針對類對象進行加鎖的
static修飾的方法,也叫做“類方法”,不是針對“實例”的方法,而是針對類的。在這個方法里,沒有this。
Counter.class——>反射 程序運行時,能夠拿到類/對象的一些相關屬性。(這個類有哪些成員,都叫啥名字,都是啥類型,都是private/public,有啥方法,都叫啥名,參數列表是啥,是private/public,這個類繼承的父類是誰,上實現了哪些interface.......)
通過類對象拿到上述信息。(*不考慮運行,看下代碼就行,強調“運行時”的意思就是,不讓你看代碼,也能夠獲取到這里的信息)
.java編譯生成.class?
.class被jvm加載到內存中,就得到了對應的“類對象”。
synchronized的特性
1.互斥性(前文已經提及)
2.可重入
如果一個線程,針對一把鎖,連續加鎖兩次會發生死鎖(deadlock)。(如圖所示)
分析:初始情況下,假定locker是未加鎖的狀態,此時的synchronized就會加鎖成功,繼續向下執行。
第二次加鎖的時候,這個鎖已經是“被加鎖”的狀態了,如果這個鎖已經被加鎖了,嘗試加鎖操作,就會觸發鎖沖突/鎖競爭,此時該線程就會阻塞等待,一直等到鎖被釋放。
走到第二個加鎖的位置,觸發阻塞,如何解除阻塞?得先釋放第一個鎖,如何釋放第一個鎖,得先把第二個鎖加上,往下繼續走。
BLOCKED狀態不是“死鎖”,而是因為鎖產生的阻塞,這里所說的死鎖指的是這個鎖再也解不開了。
有的時候,死鎖的現象不是特別明顯,稍有不慎就會發生死鎖現象。
但是,java中引入了可重入機制,有效地避免了上述的死鎖情況。(注意,死鎖有很多種體現形式,可重入只是能解決一個線程一把鎖,加鎖兩次的情況,解決不了其他情況)同一個線程,針對同一把鎖,連續加鎖多次,不會觸發死鎖,此時這個鎖就可以稱為“可重入鎖”。