一、前言
不管什么語言,并發(fā)的編程都是在高級的部分,因為并發(fā)的涉及的知識太廣,不單單是操作系統(tǒng)的知識,還有計算機(jī)的組成的知識等等。說到底,這些年硬件的不斷的發(fā)展,但是一直有一個核心的矛盾在:CPU、內(nèi)存、I/O設(shè)備的三者的速度的差異。這就是所有的并發(fā)的源頭。
CPU與內(nèi)存:緩存,CPU增加了緩存,以均衡與內(nèi)存的差異;(但是帶來可見性問題)
CPU與IO設(shè)備:進(jìn)程、線程,操作系統(tǒng)增加了進(jìn)程、線程,以分時復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;(但是帶來原子性問題)
編譯程序指令重排序,編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理的利用。(但是帶來有序性問題)
二、三大源頭
2.1 緩存導(dǎo)致可見性問題
2.1.1 理論:從單核CPU到多核CPU
單核:所有的線程都是在一顆CPU上執(zhí)行,CPU緩存與內(nèi)存的數(shù)據(jù)一致性容易解決。因為所有線程都是同一個CPU的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的;
多核:每顆CPU都有自己的緩存,這時CPU緩存與內(nèi)存的數(shù)據(jù)一致性就沒有那么容易解決了,當(dāng)多個線程在不同的CPU上執(zhí)行時,這些線程操作的是不同的CPU緩存(CPU的解決的方案:MESI協(xié)議)。
單核CPU與多核CPU對比如下圖:
金手指:一句話小結(jié)緩存在多線程下的可見性問題
1、單核cpu,只有一個cpu,所以只有一個緩存,所有的內(nèi)存都是使用這個緩存,一旦緩存內(nèi)存修改,所有cpu都可見,沒有可見性問題
2、多核cpu,有多個cpu,每一個cpu中一個緩存,所以n個cpu就有n個緩存,n個緩存與同一個主存交互數(shù)據(jù),主存中的數(shù)據(jù)對于所有緩存可見,但是不同緩存之間的數(shù)據(jù)是不可見的
單線程下,緩存從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,沒有問題
多線程下,兩個線程使用兩個不同的cpu,兩個cpu緩存都從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,這個過程中,每一個線程都是基于自己CPU緩存里的count值來計算,但是自己的緩存數(shù)據(jù)的修改只有自己可見,造成可見性問題
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存不可見,所以造成可見性問題。
2.1.2 實踐:多線程可見性問題
測試一下可見性,書寫以下的代碼:
public class Test {
private static long count = 0; // 類變量count
// 添加一萬
private void add10K() {
int idx = 0;
// 當(dāng)小于10000的時候不斷加
while (idx++ < 10000) {
count += 1;
}
}
public static long calc() throws InterruptedException {
final Test test = new Test();
// 創(chuàng)建兩個線程,這兩個線程執(zhí)行 add() 操作
Thread th1 = new Thread(test::add10K);
Thread th2 = new Thread(test::add10K);
// 啟動兩個線程
th1.start();
th2.start();
th1.join(); // 暫停主線程,加入thread1,thread1執(zhí)行完之后執(zhí)行main
th2.join(); // 暫停主線程,加入thread2,thread2執(zhí)行完之后執(zhí)行main
return count; // 返回類變量count
}
public static void main(String[] args) throws InterruptedException {
System.out.println(calc()); // 執(zhí)行,返回count
}
}
運(yùn)行結(jié)果如下:可以見得不是20000的值。
11022
原因:原子性無法保證,線程不安全,count += 1; 不是原子操作,是三步操作,可以被打斷。
其他的,可見性和有序性都無法保證(即使用volatile修飾保證可見性和有序性也沒用,原子性無法保證)
解決方法:同步阻塞保證原子性(synchronized)和非同步阻塞保證原子性(cas)
synchronized == for + if(cas),注意cas有一個ABA問題,雖然這里累加不涉及
我們假設(shè)線程A和線程B同時開始執(zhí)行,那么第一次都會將count=0讀到各自的CPU緩存里,執(zhí)行完count+=1之后,各自CPU緩存里面的值都是1,同時寫入內(nèi)存后,我們后發(fā)現(xiàn)現(xiàn)在內(nèi)存中是1,而不是我們期望的2。之后由于各自的CPU緩存里都有了count的值,兩個線程都是基于CPU緩存里的count值來計算,所以導(dǎo)致最終count的值都是小于20000的。這就是緩存的可見性的問題。
總結(jié):一個線程對共享變量的修改,另外一個線程能夠立即看到,我們稱為可見性.
2.2 線程切換帶來的原子性問題
原子性定義:我們把一個或者多個操作在CPU執(zhí)行過程中不被中斷的特性稱為原子性。
時間片的引入,CPU分片執(zhí)行:為了提供CPU的利用率,操作系統(tǒng)允許某個進(jìn)程分時使用CPU,當(dāng)一個進(jìn)程執(zhí)行IO操作時候(即count+=1 讀盤、操作、寫盤三操作中的讀盤/寫盤),這個時候這個進(jìn)程將自己標(biāo)記為休眠的狀態(tài),并且讓出CPU使用權(quán),讓給其他線程執(zhí)行,這樣CPU的使用率就會上來。另一方面,如果這個時候有一個進(jìn)程要進(jìn)行IO操作,這個時候會發(fā)現(xiàn)已經(jīng)有進(jìn)程在IO操作,這個時候新來的進(jìn)程就會等待。當(dāng)上個IO進(jìn)程結(jié)束的時候,會再進(jìn)行這個新來的IO進(jìn)程,這樣IO的使用率也上去了。
高級語言中的語句往往不是原子性的:所有的高級的語言一條語句往往需要CPU中的多條指令。例如count+=1,至少需要三條CPU指令:
指令1:首先,需要把變量count從內(nèi)存加載到CPU的寄存器;
指令2:之后,在寄存器中執(zhí)行+1操作;
指令3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是CPU緩存而不是內(nèi)存)操作系統(tǒng)任務(wù)切換,可以發(fā)生在任何一條CPU指令執(zhí)行完。注意,是CPU指令,而不是高級語言里的一條語言,所以,高級語言中的一條語句是可以被中途打斷的。
上面說的是,讀磁盤-計算-寫磁盤,這里演示的是 讀內(nèi)存-計算-寫內(nèi)存,實際上是一樣的。
金手指:一句話解析分時時間片在多線程下的原子性問題
1、如果操作系統(tǒng)不支持分時使用CPU,即必須一個進(jìn)程執(zhí)行完成之后之后,再執(zhí)行下一個進(jìn)程,每一個進(jìn)程的執(zhí)行都不會被打斷,每一個進(jìn)程的執(zhí)行都是原子的,不存在原子性問題。
2、如果操作系統(tǒng)支持分時使用CPU,一個進(jìn)程執(zhí)行讀寫磁盤或者讀寫內(nèi)存的時候,會把CPU讓出來,讓給其他進(jìn)程使用,如果獲得CPU使用權(quán)的進(jìn)程和當(dāng)前進(jìn)程修改同一變量,造成安全問題,該問題歸根結(jié)底是因為打斷了對一個變量讀寫的原子操作。
從 CPU-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存訪問主內(nèi)存的操作無法原子化,所以造成原子性問題。
2.3 編譯優(yōu)化帶來的有序性問題
2.3.1 第一,經(jīng)典案例:雙重檢查創(chuàng)建單例對象
public class Singleton {
private Singleton(){}
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
注意:這里的懶漢式單例沒有使用volatile關(guān)鍵字修飾instance變量禁止重排序,所有即使使用雙層if判斷,也是線程不安全的。
2.3.2 第二,我們認(rèn)為的new操作:instance = new Singleton();
instance = new Singleton();
第一步,分配一塊內(nèi)存M;
第二步,在內(nèi)存M上初始化Singleton對象,得到M的地址;
第三步,然后M的地址返回給instance變量。 // 最后賦值給instance變量
如果是這樣執(zhí)行,無論單線程還是多線程都不會有問題。
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全
2.3.3 第三,實際優(yōu)化后的執(zhí)行路徑:instance = new Singleton();
instance = new Singleton();
第一步,分配一塊內(nèi)存M;
第二步,將M的地址賦值給instance變量;// 解釋:這個時候Singleton對象還沒有初始化,M地址為&M,但是注意,這里完成對instance變量賦值操作后,線程A就會釋放同步鎖,線程B就可以進(jìn)來了,這就是指令重排序造成的懶漢式單例雙重檢查不安全的地方
第三步,最后在內(nèi)存M上初始化Singleton對象。// 解釋:這個時候再來初始化,但是內(nèi)存M的地址沒有給instance變量
如下,由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這是instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯
金手指:一句話小結(jié)指令重排序在多線程下的有序性問題(懶漢式單例的雙層if判斷為例)
1、不使用指令重排序,單線程和多線程下沒有線程安全問題
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全
2、使用指令重排序,單線程下沒問題,多線程下線程安全問題
由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,因為完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這時instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的執(zhí)行引擎 指令重排序,所以造成有序性問題。
三、Java中如何解決可見性和有序性問題?(JMM:Java內(nèi)存模型)
3.1 可見性和有序性
根本的原因:緩存導(dǎo)致可見性,編譯優(yōu)化導(dǎo)致有序性。
解決辦法:禁用緩存和編譯優(yōu)化。如果全部的禁用緩存和編譯優(yōu)化,那我們的程序的性能會很差,所以我們的操作是按需禁用緩存以及編譯優(yōu)化。
3.2 JMM處理可見性和有序性問題
JMM(Java內(nèi)存模型)解決上面的問題主要是volatile、synchronized和final三個關(guān)鍵字,以及八項Happens-Before規(guī)則。
3.2.1 volatile:保證可見性
保證可見性不被破壞,就要禁用緩存,強(qiáng)制cpu運(yùn)算器需要數(shù)據(jù)的時候直接從主存中拿,因為主存只有一塊,對所有的線程都是可見的,從主存中拿就可以保證數(shù)據(jù)修改對所有線程可見,就解決了可見性問題。但是,不能對所有的變量都禁用緩存,會效率低下。
Java規(guī)定,對于使用volatile關(guān)鍵字修飾的變量,cpu對其讀寫,強(qiáng)制在主存中操作。但是,哪些變量需要使用volatile關(guān)鍵字修飾呢?這就是Java程序員的工作了,其實就是多線程臨界區(qū)中使用的那些變量,一般只有一個或幾個。
volatile保證可見性:告訴編譯器,對這個變量的讀寫,不能使用CPU緩存,必須從內(nèi)存中讀取和寫入。
public class VolatileExample {
int x = 0;
volatile boolean v = false; // 使用volatile修飾,保證可見性,從主存中讀寫
public void writer() {
x = 42;
v = true; // 寫到主存中
}
public void reader() {
if (v == true) {
//x為多少?
System.out.println(x);
}
}
}
在JDK1.5之前可能會出現(xiàn)x=0的情況,因為變量x可能被CPU緩存而導(dǎo)致可見性問題。1.5之后對volatile語義進(jìn)行了增強(qiáng),就是利用Happens-Before規(guī)則。
3.2.2 Happens-Before規(guī)則(JVM中已經(jīng)定義的有序性:前面一個操作的結(jié)果對后續(xù)操作是可見的)
程序次序規(guī)則:在一個線程中,按照程序順序,前面的操作Happens-Before于后續(xù)的任何操作。(解釋:同一個線程中,或單線程下,有序性不變,執(zhí)行順序就是編寫順序)
volatile變量規(guī)則:對一個volatile編程的寫操作,Happens-Before于后續(xù)對這個volatile變量的讀操作。(解釋:volatile強(qiáng)制保證有序性,作為happen-before 8條中的一條)
傳遞規(guī)則:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。(解釋:沒啥好說的)
鎖定規(guī)則:對一個鎖的解鎖Happens-Before與后續(xù)對這個鎖的加鎖。(解釋:上一個先解鎖,下一個才能加鎖,為保證一定解鎖,解鎖一般放在finally里面)
線程啟動規(guī)則:如果線程A調(diào)用線程B的start()方法(即在線程A中啟動線程B),那么該start()操作Happens-Before線程B中的任意操作。(解釋:start()在啟動線程之前)
線程終結(jié)規(guī)則:如果在線程A中,調(diào)用線程B的join()并成功返回,那么線程B中的任意操作Happens-Before于該join()操作的返回。(解釋:join()返回在所有操作之后)
線程中斷規(guī)則:對線程interrupt()方法的調(diào)用Happens-Before被中斷線程的代碼檢測到中斷事件的發(fā)生(解釋:interrupt()早于中斷線程)
對象終結(jié)規(guī)則:一個對象的初始化完成Happens-Before他的finalize()方法的開始。(解釋:finalize()一定在結(jié)束對象之前)
3.2.3 final關(guān)鍵字(不可變)
JVM中五種線程安全級別:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立,不可變是最高級別的線程安全,和常量一樣,使用final修飾變量就是達(dá)到不可變
final修飾變量是,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優(yōu)化。
逸出問題:
final int x;
// 錯誤的構(gòu)造函數(shù)
public FinalFieldExample() {
x = 3;
y = 4;
// 此處就是講 this 逸出,
global.obj = this;
}
在1.5以后Java內(nèi)存模型對final類型變量的重排進(jìn)行了約束。現(xiàn)在只要我們能夠提供正確函數(shù)沒有‘逸出’,就不會出問題了。
四、Java中如何解決原子性問題(互斥鎖)
4.1 保證原子性(原子性兩點:保證加鎖才進(jìn)入臨界區(qū)、保證走完臨界區(qū)代碼解鎖)
1、概念:一個或者多個操作在CPU執(zhí)行過程不被中斷的特性,稱為原子性。
2、導(dǎo)致的原因:線程切換。
3、操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換。
(1)當(dāng)在32位CPU上執(zhí)行l(wèi)ong型變量的寫操作會被拆分成兩次操作。寫高32位和寫低32位。
(2)單核:在單核CPU下,同一時刻只有一個線程執(zhí)行,禁止CPU中斷,意味著操作不會重新調(diào)度線程,也就是禁止了線程切換,獲得CPU使用權(quán)的線程就可以不間斷地執(zhí)行,所以兩次寫操作一定是:要么都被執(zhí)行,要么都沒有被執(zhí)行,具有原子性。
(3)多核:同一時刻,有可能有兩個線程同時執(zhí)行,一個線程執(zhí)行在CPU-1上,一個線程執(zhí)行在CPU-2上,此時禁止CPU中斷,只能保證CPU上的線程連續(xù)執(zhí)行,并不能保證同一時刻只有一個線程執(zhí)行。
4、解決辦法:互斥鎖
金手指:
原因:造成操作的原子性被破壞的原因是線程切換
解決方式:禁止CPU中斷(操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換)
Java禁止CPU中斷的兩種方式:同步阻塞synchronized和非同步阻塞CAS
同步阻塞synchronized和非同步阻塞CAS是如何保證原子性的?
- 保證加鎖才進(jìn)入臨界區(qū)
解釋:一定要線程安全才能進(jìn)來 cas和synchronized都可以保證,cas一定要current==real才能進(jìn)來,synchronized一定要獲取到同步鎖才能進(jìn)來 - 保證走完臨界區(qū)代碼解鎖
解釋:里面的不出去,外面的不能進(jìn)來,cas這里return next;之前都沒有改變數(shù)據(jù),所以里面的沒出去,外面的不能進(jìn)來;synchronized也可以保證這一點,里面的不執(zhí)行完不釋放鎖,外面的進(jìn)不來
4.2 鎖模型理論,解決原子性問題
4.2.1 簡易的鎖模型
所有的原子性(synchronzied lock cas)都是保證這兩點:
第一,保證加鎖才進(jìn)入臨界區(qū)
第二,保證走完臨界區(qū)代碼解鎖
對于上圖鎖模型的解釋:
第一,保證加鎖才進(jìn)入臨界區(qū):線程進(jìn)入臨界區(qū)之前,首先嘗試加鎖lock(),如果成功,則進(jìn)入臨界區(qū),此時我們稱這個線1程持有鎖;否則就等待,直到持有鎖的線程解鎖;
第二,保證走完臨界區(qū)代碼解鎖:持有鎖的線程執(zhí)行完臨界區(qū)的代碼后,執(zhí)行解鎖unlock()
4.2.2 改進(jìn)后的鎖模型(添加鎖對象和受保護(hù)資源)
對于上圖改進(jìn)后的鎖模型的解釋:
第一,在臨界代碼中,增加了受保護(hù)的資源R(解釋:R 表示Resource);
第二,為了保護(hù)資源R就得為它創(chuàng)建一把鎖LR(解釋:LR 表示 Lock for Resource);
第三,對于新創(chuàng)建的鎖LR,需要在進(jìn)出臨界區(qū)時添上加鎖和解鎖操作,即lock(LR)操作和unlock(LR)操作。
4.3 實踐鎖模型,synchronized五種用法
public class X {
//修飾非靜態(tài)方法
synchronized void foo() {
//臨界區(qū)
}
//修飾靜態(tài)方法
synchronized static void bar() {
//臨界區(qū)
}
//修飾代碼塊
Object obj = new Object();
void baz() {
synchronized (obj) {
//臨界區(qū)
}
}
}
synchronized修飾靜態(tài)方法相當(dāng)于
class X {
//修飾靜態(tài)方法
synchronized(X.class) static void bar() {
//臨界區(qū)
}
}
synchronized修飾非靜態(tài)方法相當(dāng)于
class X {
//修飾靜態(tài)方法
synchronized(this) void bar() {
//臨界區(qū)
}
}
小結(jié):synchronized可以修飾靜態(tài)方法或靜態(tài)代碼塊,同步鎖是所在類的.class字節(jié)碼對象;
synchronized可以修飾非靜態(tài)方法或非靜態(tài)代碼塊,同步鎖是所在類的當(dāng)前對象this;
synchronized可以修飾非靜態(tài)代碼塊,同步鎖是任意Object對象;
注意,方法上鎖,要么this要么字節(jié)碼對象,不能Object對象就好。
一共五種,小結(jié)如下表格:
4.4 附加1:用synchronized解決count+1問題(鎖:資源從1:1到N:1)
4.4.1 鎖和受保護(hù)資源的關(guān)系是1:1關(guān)系(使用層面,不重要,兩個鎖保護(hù)一個資源,兩個臨界區(qū)沒有互斥關(guān)系)
class SafeCalc {
long value = 0L;
synchronized long get() {
return value; // 獲取臨界資源用synchronized包裹一次,讀內(nèi)存變量是一步操作,但是這里是long,long和double是64位,每次讀32位,所有就不是原子操作了,所以要用synchronized包裹一次,防止被另一個線程調(diào)用addOne()打斷
}
synchronized void addOne() {
value += 1; // value=value+1,三步操作,一定要用synchronized,防止被另一個線程調(diào)用get()打斷
}
}
大體的模型如下:
線程并發(fā)兩要素:多線程,同步鎖,這里一個鎖鎖住一個變量value,起到原子化的作用,所以沒有并發(fā)安全問題。
4.4.2 鎖和受保護(hù)資源的關(guān)系是N:1關(guān)系(使用層面,不重要,兩個鎖保護(hù)一個資源,兩個臨界區(qū)沒有互斥關(guān)系)
受保護(hù)資源和鎖之前的關(guān)聯(lián)關(guān)系是N:1的關(guān)系。
重寫修改上面的代碼
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
大體的模型如下:
注意:這個時候的問題就是兩個鎖保護(hù)一個資源。因此這兩個臨界區(qū)沒有互斥關(guān)系,臨界區(qū)addOne()對value的修改對臨界區(qū)get()也沒有可見性保證,這就導(dǎo)致并發(fā)問題
線程并發(fā)兩要素:多線程,同步鎖,這里兩個鎖鎖住一個變量value,沒有起到隔離作用,所以有并發(fā)安全問題。
4.4.3 鎖:資源=1:N,用一把鎖保護(hù)多個資源(使用層面,但是使用得少,屬于同步鎖對象高級用法,面試有區(qū)分度,重要)
4.4.3.1 一把鎖保護(hù)多個資源:保護(hù)沒有關(guān)聯(lián)關(guān)系的多個資源
賬號余額和賬號密碼沒有關(guān)聯(lián)關(guān)系
class Account {
// 鎖:保護(hù)賬戶余額
private final Object balLock = new Object();
// 賬戶余額
private Integer balance;
// 鎖:保護(hù)賬戶密碼
private final Object pwLock = new Object();
// 賬戶密碼
private String password;
// 取款
void withdraw(Integer amt) {
synchronized (balLock) {
if (this.balance > amt) {
this.balance -= amt;
}
}
}
// 查看余額
Integer getBalance() {
synchronized (balLock) {
return balance;
}
}
// 更改密碼
void updatePassword(String pw) {
synchronized (pwLock) {
this.password = pw;
}
}
// 查看密碼
String getPassword() {
synchronized (pwLock) {
return password;
}
}
}
4.4.3.2 造成問題:一把鎖保護(hù)多個資源:保護(hù)有關(guān)聯(lián)關(guān)系的多個資源(銀行轉(zhuǎn)賬的問題)
class Account {
private int balance;
// 轉(zhuǎn)賬
synchronized void transfer(Account target, int amt) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
大體的模型如下:
上面的代碼的問題出在this這把鎖上,this這把鎖可以保護(hù)自己余額this.balance,卻保護(hù)不了別人的余額target.balance,就像你不能用自家的鎖來保護(hù)別人家的資產(chǎn),也不能用自己的票保護(hù)別人的座位一樣。
4.4.3.3 解決方式:一把鎖保護(hù)有關(guān)聯(lián)關(guān)系的多個資源(銀行轉(zhuǎn)賬的問題)
再次修改上面的代碼(下面的代碼存在串行化的操作)
class Account {
private int balance;
// 轉(zhuǎn)賬
synchronized void transfer(Account target, int amt) {
synchronized(Account.class){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
總結(jié):首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。
原子性的本質(zhì):操作的中間狀態(tài)對外不可見。
金手指:如果使用一把鎖保護(hù)多個資源?
首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。
4.5 附加2:死鎖問題
4.5.1 死鎖的引入:銀行轉(zhuǎn)賬的問題
對于上面,使用synchronize加一個this鎖,可以保護(hù)this.balance但是無法保護(hù)target.balance,所以內(nèi)部加一個synchronized(Xxx.class)靜態(tài)鎖,解決了這個問題,但是在現(xiàn)實生活中,沒有辦法鎖住整個字節(jié)碼對象。
在現(xiàn)實生活中,假設(shè)銀行在給我能做轉(zhuǎn)賬時,要去文件架上把轉(zhuǎn)出賬本和轉(zhuǎn)入賬本都拿到手,然后做轉(zhuǎn)賬。會出現(xiàn)三種情況
(1)文件架上恰好有轉(zhuǎn)出賬本和轉(zhuǎn)入賬本,那就同時拿走;
(2)如果文件加上只有轉(zhuǎn)出和轉(zhuǎn)入賬本之一,那這個柜員就先把文件架上有賬本拿到手,同時等著其他柜員把另一個賬本送回來;
(3)轉(zhuǎn)出賬本和轉(zhuǎn)入賬本都沒有,那這個柜員就等著兩個賬本都被送回來。
即現(xiàn)實生活中使用兩個鎖對象synchronized(this)和synchronized(target)來加鎖兩個資源this.balance和target.balance,不能使用synchronized(Xxx.class),這種靜態(tài)鎖只有理論意義,在現(xiàn)實中模擬不出來,真正模擬出來就是同時鎖住兩個賬本,但現(xiàn)實中一般不這么做。
于是我們將代碼改成如下:
class Account {
private int balance;
// 轉(zhuǎn)賬
synchronized void transfer(Account target, int amt) {
//鎖定轉(zhuǎn)出賬戶
synchronized(this){
//鎖定轉(zhuǎn)入賬戶
synchronized(target){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
這樣的代碼會發(fā)生新的問題,就是死鎖的問題
大體模型為:
4.5.2 死鎖:銀行轉(zhuǎn)賬的問題,兩次加鎖產(chǎn)生死鎖
金手指1:兩次加鎖給死鎖創(chuàng)造了條件
代碼中兩次加鎖就會給死鎖創(chuàng)造條件,代碼中避免這樣寫,如果真的這樣寫了,處理處理呢?
概念:一組互相競爭資源的線程互相等待,導(dǎo)致“永久”阻塞的現(xiàn)象。
class Account {
private int balance;
// 轉(zhuǎn)賬
synchronized void transfer(Account target, int amt) {
//鎖定轉(zhuǎn)出賬戶
synchronized(this){//①
//鎖定轉(zhuǎn)入賬戶
synchronized(target){//②
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
如何發(fā)生死鎖(上面的代碼)
(1)假設(shè)線程T1執(zhí)行賬戶A轉(zhuǎn)賬賬戶B的操作,賬號A.transfer(賬戶B);
(2)同時線程T2執(zhí)行賬戶B轉(zhuǎn)賬賬戶A的操作,賬戶B.transfer(賬戶A);
(3)當(dāng)T1和T2同時執(zhí)行完①處的代碼時,T1獲得了賬戶A的鎖(對于T1,this是賬戶A)而T2獲得了賬戶B的鎖(對于T2,this是賬戶B)
(4)之后T1和T2在執(zhí)行完②處的代碼時,T1試圖獲取賬戶B的鎖時,發(fā)現(xiàn)賬戶B已經(jīng)被鎖定(被T2鎖定),所以T1開始等待
(5)T2則試圖獲取賬戶A的鎖時,發(fā)現(xiàn)賬戶A已經(jīng)被鎖定(被T1鎖定),所以T2也開始等待
死鎖的產(chǎn)生條件
(1)互斥,共享資源X和Y只能被一個線程占用;
(2)占有且等待,線程T1已經(jīng)取得共享資源X,在等待共享資源Y的時候,不釋放共享資源X
(3)不可搶占,其他線程不能強(qiáng)行搶占線程T1占有的資源
(4)循環(huán)等待,線程T1等待線程T2占有的資源,線程T2等待線程T1占有的資源,就是循環(huán)等待。
4.5.3 解決方式:死鎖解決辦法(破壞四個條件的其中之一)
4.5.3.1 最常見:破壞占有且等待條件(一次性申請所有資源)
class Allocator {
private List<Object> als = new ArrayList<>(); // 僅僅一個局部變量而已,沒有用
// 一次性申請所有資源 synchronized加鎖,原子化 als.add(from); als.add(to);
synchronized boolean apply(Object from, Object to){
if(als.contains(from) || als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 一次性歸還所有資源 synchronized解鎖,原子化 als.remove(from); als.remove(to);
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr 應(yīng)該為單例
private Allocator actr;
private int balance;
// 轉(zhuǎn)賬
void transfer(Account target, int amt){
// 一次性申請轉(zhuǎn)出賬戶this和轉(zhuǎn)入賬戶target,直到成功
while(!actr.apply(this, target));
try{
// 鎖定轉(zhuǎn)出賬戶
synchronized(this){
// 鎖定轉(zhuǎn)入賬戶
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
解釋上面的代碼:
從Account類來看:會造成死鎖的代碼(synchronized(this) synchronized(target) )放在了while(!actr.apply(this, target)); 和 actr.free(this, target);中間,包裹一起來。
while(!actr.apply(this, target));加鎖,使用synchronized保證只有一個線程進(jìn)來,在該線程執(zhí)行完成之前,其他的線程都不能進(jìn)來,然后執(zhí)行會發(fā)生死鎖的代碼(只有一個線程就不會死鎖了),執(zhí)行完成后解鎖。從Allocator類來看:同時申請資源apply()和同時釋放資源free()。賬戶Account類里面持有一個Alloctor的單例(必須是單例,只能由一個人分配資源)。當(dāng)賬戶Account在執(zhí)行轉(zhuǎn)賬的時候,首先向Allocator同時申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源,成功后再鎖定這兩個資源,當(dāng)轉(zhuǎn)賬執(zhí)行完,釋放鎖之后,我們需要通知Allocator同時釋放轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源。
問題:自旋帶來的cpu消耗
一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
while(!actr.apply(this, target));
如果上面的代碼的執(zhí)行時間非常短,這個方案是可行的,如果操作耗時長,這樣CPU的空轉(zhuǎn)的時間會大大的提升。
解決辦法(等待-通知機(jī)制)
如果線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同在文件架上)不滿足,則線程阻塞自己,進(jìn)入等待狀態(tài);當(dāng)線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同時在文件架上)滿足后,通知等待的線程重新執(zhí)行。
一個完整的等待-通知機(jī)制:
線程首先獲取互斥鎖,當(dāng)線程要求的條件不滿足時,釋放互斥鎖,進(jìn)入等待狀態(tài);當(dāng)要求的條件滿足時,通知等待的線程,重新獲取互斥鎖。
4.5.3.2 破壞不可搶占條件
能夠主動釋放它占有的資源。synchronized不支持,后面會說JUC下面的包會支持
4.5.3.3 破壞循環(huán)等待條件,按序申請資源
破壞這個條件,需要對資源進(jìn)行排序,然后按序申請資源
class Account {
private int id;
private int balance;
// 轉(zhuǎn)賬
void transfer(Account target, int amt){
Account left = this; //①
// 就是對申請資源定義順序,任何線程都按這個順序來,如下:
// 設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,
// 符合條件就交換,這里不符合條件
// 最后先加鎖left A線程,再加鎖right B線程
// 設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,
// 符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right
// 最后先加鎖left A線程,再加鎖right B線程
Account right = target; //②
if (this.id > target.id) { //③
left = target; //④
right = this; //⑤
} //⑥
// 鎖定序號小的賬戶
synchronized(left){
// 鎖定序號大的賬戶
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
將原來的
synchronized(this){
synchronized(target){
修改為
synchronized(left){
synchronized(right){
如何來按照順序申請資源,即對申請資源定義順序,任何線程都按這個順序來,如下:
第一,設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,符合條件就交換,這里不符合條件,最后先加鎖left A線程,再加鎖right B線程;
第二,設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right,最后先加鎖left A線程,再加鎖right B線程。
4.6 附加3:線程通信,synchronized等待隊列:wait()、notify()、notifyAll()
當(dāng)一個線程進(jìn)入臨界區(qū)后,由于某些條件不滿足(解釋:就是我們的標(biāo)志位不滿足),需要進(jìn)入等待狀態(tài)。注意,這個等待隊列和互斥鎖是一對一的關(guān)系,每個互斥鎖都有自己獨立的等待隊列。
第一,當(dāng)調(diào)用wait()方法后,當(dāng)前線程就會被阻塞,并且當(dāng)前線程會進(jìn)入到右邊的等待隊列中,這個等待隊列也是互斥鎖的等待隊列。線程在進(jìn)入等待隊列的同時,會釋放持有的互斥鎖,線程釋放后,其他線程就會有機(jī)會獲得鎖,并進(jìn)入臨界區(qū)。
第二,當(dāng)條件滿足時調(diào)用notify(),會通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經(jīng)滿足過,將該線程出等待隊列。
注意:因為notify()只能保證在通知時間點,條件是滿足的。但是,被通知線程的執(zhí)行時間和通知的時間點基本不會重合,所以當(dāng)被通知的線程執(zhí)行的時候,很可能條件已經(jīng)不滿足了(就是被其他同類競爭者線程插隊了),被通知的線程要重新執(zhí)行,仍然需要獲取到互斥鎖(因為插入到等待隊列的時候已經(jīng)釋放同步鎖了),notify()僅僅幫助這個線程從等待隊列中逃離了而已。
大體模型如下:
即:wait()將一個線程進(jìn)入等待隊列,阻塞,不參與同步鎖的競爭;
notify()將一個線程退出等待隊列,喚醒,重新參與同步鎖的競爭。
重寫剛才的例子
class Allocator {
private List<Object> als;
// 一次性申請所有資源
synchronized void apply(Object from, Object to){
// 經(jīng)典寫法
while(als.contains(from) ||als.contains(to)){
try{
wait(); // 這里wait()
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 歸還資源
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll(); // 這里多加一個notifyAll()
}
}
金手指:
// 一次性申請所有資源 synchronized加鎖
synchronized boolean apply(Object from, Object to){
if(als.contains(from) || als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 一次性歸還所有資源 synchronized解鎖
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
}
tip1:lock有公平鎖和非公平鎖,synchronized只有非公平鎖,lock實現(xiàn)公平加鎖的底層是同步隊列AQS,不是非循環(huán)單鏈表等待隊列,等待隊列synchronized也有。
tip2:
在lock中,同步隊列FIFO保證加鎖公平,等待隊列FIFO保證阻塞喚醒公平;
在synchronized中,加鎖解鎖是搶占式非公平的,但是等待隊列FIFO保證喚醒阻塞是公平,但是喚醒不保證可以公平地競爭到同步鎖。
五、總結(jié)(宏觀的角度)
5.1 存在共享數(shù)據(jù)并且該數(shù)據(jù)會發(fā)生變化,通俗地講就是有多個線程同時讀寫同一個數(shù)據(jù)。
5.2 并發(fā)的問題主要是三個方面:安全性問題、活躍性問題、性能問題
5.2.1 安全性問題
1、本質(zhì)的問題:程序按照我們期望的執(zhí)行。
2、數(shù)據(jù)競爭:當(dāng)多個線程同時訪問同一數(shù)據(jù),并且至少有一個線程會寫這個數(shù)據(jù)的時候,如果我們不采取防護(hù)措施,那么就會導(dǎo)致并發(fā)的Bug。
3、競態(tài)條件:程序的執(zhí)行結(jié)果依賴線程執(zhí)行的順序。
4、上面的兩種問題,都可以通過互斥這個技術(shù)方案,而實現(xiàn)互斥的方案有很多,CPU提供了相關(guān)的互斥指令,操作系統(tǒng)、編程語言也會提供相關(guān)的API鎖。
5.2.2 活躍性問題
1、死鎖:線程會互相等待,而且會一直等待下去,在技術(shù)上表現(xiàn)就是線程永久的阻塞
2、活鎖:線程沒有阻塞,但仍然會存在執(zhí)行不下去的情況。(嘗試等待一個隨機(jī)時間就可以了)
3、 饑餓:線程因無法訪問所需資源而無法執(zhí)行下去的情況。(不患寡,而患不均)(解決辦法:保證資源充足,公平地分配資源,避免持有鎖的線程長時間執(zhí)行。)
5.2.3 性能問題
1、盡量使用無鎖 算法和數(shù)據(jù)結(jié)構(gòu)
2、減少鎖持有的時間
3、衡量標(biāo)準(zhǔn)
(1)吞吐量:單位時間內(nèi)能處理的請求數(shù)量
(2)延遲:從發(fā)出請求到收到響應(yīng)的時間
(3)并發(fā)量:同時處理的請求數(shù)量,隨著并發(fā)量的增加、延遲也會增加。)
六、面試金手指(原理層面:談一談你對并發(fā)的了解)
6.1 優(yōu)化性能造成線程三個特性的破壞
CPU與內(nèi)存:緩存,CPU增加了緩存,以均衡與內(nèi)存的差異;(但是帶來可見性問題)
CPU與IO設(shè)備:進(jìn)程、線程,操作系統(tǒng)增加了進(jìn)程、線程,以分時復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;(但是帶來原子性問題)
編譯程序指令重排序,編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理的利用。(但是帶來有序性問題)
6.2 解釋緩存是如何造成可見性問題的,cpu分時時間片是如何造成原子性問題的,指令重排序是如何造成有序性問題的
金手指:一句話小結(jié)緩存在多線程下的可見性問題
1、單核cpu,只有一個cpu,所以只有一個緩存,所有的內(nèi)存都是使用這個緩存,一旦緩存內(nèi)存修改,所有cpu都可見,沒有可見性問題
2、多核cpu,有多個cpu,每一個cpu中一個緩存,所以n個cpu就有n個緩存,n個緩存與同一個主存交互數(shù)據(jù),主存中的數(shù)據(jù)對于所有緩存可見,但是不同緩存之間的數(shù)據(jù)是不可見的
單線程下,緩存從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,沒有問題
多線程下,兩個線程使用兩個不同的cpu,兩個cpu緩存都從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,這個過程中,每一個線程都是基于自己CPU緩存里的count值來計算,但是自己的緩存數(shù)據(jù)的修改只有自己可見,造成可見性問題
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存不可見,所以造成可見性問題。
金手指:一句話解析分時時間片在多線程下的原子性問題
1、如果操作系統(tǒng)不支持分時使用CPU,即必須一個進(jìn)程執(zhí)行完成之后之后,再執(zhí)行下一個進(jìn)程,每一個進(jìn)程的執(zhí)行都不會被打斷,每一個進(jìn)程的執(zhí)行都是原子的,不存在原子性問題。
2、如果操作系統(tǒng)支持分時使用CPU,一個進(jìn)程執(zhí)行讀寫磁盤或者讀寫內(nèi)存的時候,會把CPU讓出來,讓給其他進(jìn)程使用,如果獲得CPU使用權(quán)的進(jìn)程和當(dāng)前進(jìn)程修改同一變量,造成安全問題,該問題歸根結(jié)底是因為打斷了對一個變量讀寫的原子操作。
從 CPU-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存訪問主內(nèi)存的操作無法原子化,所以造成原子性問題。
金手指:一句話小結(jié)指令重排序在多線程下的有序性問題(懶漢式單例的雙層if判斷為例)
1、不使用指令重排序,單線程和多線程下沒有線程安全問題
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全
2、使用指令重排序,單線程下沒問題,多線程下線程安全問題
由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,因為完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這時instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的執(zhí)行引擎 指令重排序,所以造成有序性問題。
6.3 Java處理可見性和有序性問題(原因、解決方式、volatile、happen-before、final、synchronized)
有序性根本原因:緩存導(dǎo)致可見性,編譯優(yōu)化導(dǎo)致有序性。
有序性解決辦法:禁用緩存和編譯優(yōu)化。如果全部的禁用緩存和編譯優(yōu)化,那我們的程序的性能會很差。我們需要的是按需禁用緩存以及編譯優(yōu)化。
保證可見性不被破壞,就要禁用緩存,強(qiáng)制cpu運(yùn)算器需要數(shù)據(jù)的時候直接從主存中拿,因為主存只有一塊,對所有的線程都是可見的,從主存中拿就可以保證數(shù)據(jù)修改對所有線程可見,就解決了可見性問題。但是,不能對所有的變量都禁用緩存,會效率低下。
Java規(guī)定,對于使用volatile關(guān)鍵字修飾的變量,cpu對其讀寫,強(qiáng)制在主存中操作。但是,哪些變量需要使用volatile關(guān)鍵字修飾呢?這就是Java程序員的工作了,其實就是多線程臨界區(qū)中使用的那些變量,一般只有一個或幾個。
volatile保證可見性:告訴編譯器,對這個變量的讀寫,不能使用CPU緩存,必須從內(nèi)存中讀取和寫入。
程序次序規(guī)則:在一個線程中,按照程序順序,前面的操作Happens-Before于后續(xù)的任何操作。(解釋:同一個線程中,或單線程下,有序性不變,執(zhí)行順序就是編寫順序)
volatile變量規(guī)則:對一個volatile編程的寫操作,Happens-Before于后續(xù)對這個volatile變量的讀操作。(解釋:volatile強(qiáng)制保證有序性,作為happen-before 8條中的一條)
傳遞規(guī)則:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。(解釋:沒啥好說的)
鎖定規(guī)則:對一個鎖的解鎖Happens-Before與后續(xù)對這個鎖的加鎖。(解釋:上一個先解鎖,下一個才能加鎖,為保證一定解鎖,解鎖一般放在finally里面)
線程啟動規(guī)則:如果線程A調(diào)用線程B的start()方法(即在線程A中啟動線程B),那么該start()操作Happens-Before線程B中的任意操作。(解釋:start()在啟動線程之前)
線程終結(jié)規(guī)則:如果在線程A中,調(diào)用線程B的join()并成功返回,那么線程B中的任意操作Happens-Before于該join()操作的返回。(解釋:join()返回在所有操作之后)
線程中斷規(guī)則:對線程interrupt()方法的調(diào)用Happens-Before被中斷線程的代碼檢測到中斷事件的發(fā)生(解釋:interrupt()早于中斷線程)
對象終結(jié)規(guī)則:一個對象的初始化完成Happens-Before他的finalize()方法的開始。(解釋:finalize()一定在結(jié)束對象之前)
JVM中五種線程安全級別:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立,不可變是最高級別的線程安全,和常量一樣,使用final修飾變量就是達(dá)到不可變
final修飾變量是,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優(yōu)化。
附加:synchronized同時保證原子性、有序性、可見性
6.4 Java處理原子性問題(原因、解決方式、兩種方式和保證原子性的原因)
原因:造成操作的原子性被破壞的原因是線程切換
解決方式:禁止CPU中斷(操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換)
Java禁止CPU中斷的兩種方式:同步阻塞synchronized和非同步阻塞CAS
保證原子性的原因:同步阻塞synchronized和非同步阻塞CAS是如何保證原子性的?
- 保證加鎖才進(jìn)入臨界區(qū)
解釋:一定要線程安全才能進(jìn)來 cas和synchronized都可以保證,cas一定要current==real才能進(jìn)來,synchronized一定要獲取到同步鎖才能進(jìn)來 - 保證走完臨界區(qū)代碼解鎖
解釋:里面的不出去,外面的不能進(jìn)來,cas這里return next;之前都沒有改變數(shù)據(jù),所以里面的沒出去,外面的不能進(jìn)來;synchronized也可以保證這一點,里面的不執(zhí)行完不釋放鎖,外面的進(jìn)不來
6.5 附加1:鎖與資源三種對應(yīng)關(guān)系(鎖:資源=1:1 N:1 1:N)
金手指:如果使用一把鎖保護(hù)多個資源?
首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。
6.6 附加2:死鎖問題(四個條件和三個解決方式)
金手指1:兩次加鎖給死鎖創(chuàng)造了條件
代碼中兩次加鎖就會給死鎖創(chuàng)造條件,代碼中避免這樣寫,如果真的這樣寫了,處理處理呢?
三種方式:破壞請求與等待條件(最常用:一次性申請資源)、破壞不可搶占條件(synchronized不支持,不能搶其他人手里的資源)、破壞循環(huán)等待條件(有序申請資源)
其中,互斥條件無法被破壞 (共享資源X和Y只能被一個線程占用,無法改變)
一次性申請資源:
從Account類來看:會造成死鎖的代碼(synchronized(this) synchronized(target) )放在了while(!actr.apply(this, target)); 和 actr.free(this, target);中間,包裹一起來。
while(!actr.apply(this, target));加鎖,使用synchronized保證只有一個線程進(jìn)來,在該線程執(zhí)行完成之前,其他的線程都不能進(jìn)來,然后執(zhí)行會發(fā)生死鎖的代碼(只有一個線程就不會死鎖了),執(zhí)行完成后解鎖。從Allocator類來看:同時申請資源apply()和同時釋放資源free()。賬戶Account類里面持有一個Alloctor的單例(必須是單例,只能由一個人分配資源)。當(dāng)賬戶Account在執(zhí)行轉(zhuǎn)賬的時候,首先向Allocator同時申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源,成功后再鎖定這兩個資源,當(dāng)轉(zhuǎn)賬執(zhí)行完,釋放鎖之后,我們需要通知Allocator同時釋放轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源。
問題:自旋帶來的cpu消耗
一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
while(!actr.apply(this, target));
如果上面的代碼的執(zhí)行時間非常短,這個方案是可行的,如果操作耗時長,這樣CPU的空轉(zhuǎn)的時間會大大的提升。
解決辦法(等待-通知機(jī)制)
如果線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同在文件架上)不滿足,則線程阻塞自己,進(jìn)入等待狀態(tài);當(dāng)線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同時在文件架上)滿足后,通知等待的線程重新執(zhí)行。
一個完整的等待-通知機(jī)制:
線程首先獲取互斥鎖,當(dāng)線程要求的條件不滿足時,釋放互斥鎖,進(jìn)入等待狀態(tài);當(dāng)要求的條件滿足時,通知等待的線程,重新獲取互斥鎖。
破壞循環(huán)等待條件,自定義申請資源順序,任何線程都按這個來
將原來的
synchronized(this){
synchronized(target){
修改為
synchronized(left){
synchronized(right){
如何來按照順序申請資源,即對申請資源定義順序,任何線程都按這個順序來,如下:
第一,設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,符合條件就交換,這里不符合條件,最后先加鎖left A線程,再加鎖right B線程;
第二,設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right,最后先加鎖left A線程,再加鎖right B線程。
6.7 附加3:線程通信,synchronized等待隊列:wait()、notify()、notifyAll()
- 辨析wait() notify()的底層作用
wait()將一個線程進(jìn)入等待隊列,阻塞,不參與同步鎖的競爭;
notify()將一個線程退出等待隊列,喚醒,重新參與同步鎖的競爭。
- 辨析lock synchronized的底層區(qū)別
(1)lock有公平鎖和非公平鎖,synchronized只有非公平鎖,lock實現(xiàn)公平加鎖的底層是同步隊列AQS,不是非循環(huán)單鏈表等待隊列,等待隊列synchronized也有。
(2)在lock中,同步隊列FIFO保證加鎖公平,等待隊列FIFO保證阻塞喚醒公平;
在synchronized中,加鎖解鎖是搶占式非公平的,但是等待隊列FIFO保證喚醒阻塞是公平,但是喚醒不保證可以公平地競爭到同步鎖。
6.8 并發(fā)三個問題
安全性問題
1、本質(zhì)的問題:程序按照我們期望的執(zhí)行。
2、數(shù)據(jù)競爭:當(dāng)多個線程同時訪問同一數(shù)據(jù),并且至少有一個線程會寫這個數(shù)據(jù)的時候,如果我們不采取防護(hù)措施,那么就會導(dǎo)致并發(fā)的Bug。
3、競態(tài)條件:程序的執(zhí)行結(jié)果依賴線程執(zhí)行的順序。
4、上面的兩種問題,都可以通過互斥這個技術(shù)方案,而實現(xiàn)互斥的方案有很多,CPU提供了相關(guān)的互斥指令,操作系統(tǒng)、編程語言也會提供相關(guān)的API鎖。
活躍性問題
1、死鎖:線程會互相等待,而且會一直等待下去,在技術(shù)上表現(xiàn)就是線程永久的阻塞
2、活鎖:線程沒有阻塞,但仍然會存在執(zhí)行不下去的情況。(嘗試等待一個隨機(jī)時間就可以了)
3、 饑餓:線程因無法訪問所需資源而無法執(zhí)行下去的情況。(不患寡,而患不均)(解決辦法:保證資源充足,公平地分配資源,避免持有鎖的線程長時間執(zhí)行。)
性能問題
1、盡量使用無鎖 算法和數(shù)據(jù)結(jié)構(gòu)
2、減少鎖持有的時間
3、衡量標(biāo)準(zhǔn)
(1)吞吐量:單位時間內(nèi)能處理的請求數(shù)量
(2)延遲:從發(fā)出請求到收到響應(yīng)的時間
(3)并發(fā)量:同時處理的請求數(shù)量,隨著并發(fā)量的增加、延遲也會增加。)
七、小結(jié)
原理層面:Java并發(fā)的硬件底層支持,完成了。
天天打碼,天天進(jìn)步!!!