1. 底層原理
1.1 JVM內存結構 VS JMM內存模型
- JVM內存結構和JVM的運行區域有關,包括堆、方法區、虛擬機棧、本地方法棧、程序計數器
- 堆:線程共享,new出來的實例對象;
- 虛擬機棧:線程私有,基本數據類型以及對象的引用地址;
- 方法區:線程共享,static靜態變量,類信息(方法代碼,變量名,方法名,訪問權限,返回值),常量,永久引用(static修飾的類);
- 本地方法棧:native方法;
- 程序計數器:程序的位置,行號數;
- JMM內存模型
- JMM是一種規範,防止在不同的虛擬機上運行結果不一樣,可以更方便地開發出多線程程序
- volatile、synchronized、lock等的原理都是JMM,如果沒有JMM,必須手動指定什麼時候同步
2. 重排序
public class ReOrder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
如上代碼所示,正常情況下,x,y有以下三種結果:
- t1執行完t2再執行:x=0,y=1
- t2執行完t1再執行:x=1,y=0
- t1和t2各執行一半:x=1,y=1
那麼會不會出現x=0,y=0的結果呢?只有x=b在a=1之前,或者y=a在b=1之前執行纔會發生這種情況,這種情況一旦發生,就說明發生了指令重排序。
- 重排序的定義:當指令的執行順序和Java代碼的執行順序不一樣,就說明發生了指令重排序。
- 重排序的好處:提升處理速度。
- 重排序發生的2種情況:
- 編譯器優化(JVM優化),尤其發生數據沒有依賴關係的情況,更有可能會發生指令重排序;
- CPU指令重排序:就算編譯器不重排序,CPU也可能會發生指令重排序;
2. 可見性
2.1 可見性問題演示
代碼演示:
public class Visibility {
private static int a = 1, b = 2;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 3;
b = a;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(b+ "," + a);
}
});
t1.start();
t2.start();
}
}
正常情況下,會發生一下三種情況:
- t2先執行:a = 1, b = 2
- t1先執行:a = 3, b = 3
- t1執行一半給t2執行:a = 3, b = 2
但是由於內存可見性問題,也可能出現第四種情況:a = 1, b = 3,爲什麼會發生這種情況?
由於t1線程執行a=3,b=a後,b被寫回到主內存,而a還沒來得及寫回到主內存,此時,t2已經在主內存中讀取了a和b的值, 就造成了a=1,b=3的情況。t2線程沒“看完整”t1線程的操作,只看到了b的賦值情況,而沒看到a的賦值情況。
當使用volatile關鍵字之後,a的值改變,立刻刷回到主內存,t2讀取到的一定是改變的值。
2.1 爲什麼發生可見性問題?
- 如圖所示,數據從主內存到CPU過程中有多層緩存,分別是L3、L2、L1、寄存器。由於多層緩存的存在,可以大幅提升CPU的處理效率;
- 每個核心將自己需要的數據讀到私有的緩存中,然後將修改後的值寫回到緩存中,最後等待刷到內存中,由於這個等待的過程,當核心1更新某共享數據後,核心2還沒有等到核心1將緩存刷回主內存就讀取數據了,導致髒數據;
2.2 JMM如何解決可見性問題?
-
JMM定義了一套讀寫規範,我們不用關心寄存器、一級緩存、二級緩存等,JMM抽象出主內存和本地內存的概念。
-
本地內存包括寄存器、一級緩存、二級緩存;
-
主內存包括三級緩存和內存;
-
主內存和本地內存的關係:
- 所有的變量都存儲在主內存中,同時每個線程都有自己的工作內存,工作內存中的變量是主內存中的拷貝;
- 線程不能直接讀寫主內存中的變量,而是隻能操作自己工作內存中的變量,然後同步到主內存中;
- 主內存是多個線程共享的,但是線程不共享工作內存,如果線程之間需要通信,必須藉助於主內存中轉完成
- 正是因爲需要主內存來交換才導致了可見性問題;
2.3 happens-before原則
- 什麼是happens-before:該原則是用來解決可見性問題的,在時間上,動作A發生在動作B之前,B保證能看見A;
- 另一種解釋:如果一個操作happens-before另一個操作,那麼我們說第一個操作對於第二個操作是可見的;
- 什麼不是happens-before:兩個線程沒有相互配合的機制,所以代碼A和B的執行結果不能保證總是被對象看到的,這就不具備happens-before;
- 只要符合了happens-before原則,就不會產生可見性問題;
- 符合happens-before原則的常見場景:
- 單線程原則:在一個線程之內,後面的語句一定能看到前面的語句做了什麼 ,因爲每個線程都有自己的工作內存,自己工作內存的變量都是可見的(如果數據之間沒有依賴,單線程下會發生指令重排序,但是不影響結果,所以單線程原則不影響重排序);
- 鎖操作(synchronized和lock):如果t1線程對a對象解鎖了,緊接着t2線程對a對象加鎖了,那麼t2線程能看到t1線程的所有操作,無論t1做了什麼修改,做了什麼邏輯,t2都可以看到,不會發生髒數據的情況;
- volatile:volatile修飾的變量發生的讀寫操作,當t1線程發生寫操作,t2線程進行讀操作時一定能看到這個寫操作;
- 線程啓動:子線程一定能看到主線程在執行start()之前的操作;
- 線程join:主線程join()後面的語句一定能看到子線程運行的所有的語句;
- 傳遞性:如果a happens-before b, b happens-before c,那麼a 一定happens-before c;
- 中斷:一個線程被其他線程中斷時,那麼檢測中斷的線程一定能看到並拋出異常;
- 符合happens-before原則的工具類:ConcurrentHashMap、CountDownLatch、線程池、FutureTask、CyclicBarrier;
- 輕量級同步:給b加了volatile,不僅b被影響,還可以實現輕量級的同步,b = a 之前的代碼對讀取打印b後的代碼可見,所以在寫入線程裏對a的賦值,一定會對讀取線程可見,所以這裏的a即使不加volatile,只要b讀取到是3,就可以保證a讀取到的都是3而不可能是1,所以只給b加volatile,b賦值操作執行之前的其他變量的賦值操作也具有可見性。
2.4 volatile關鍵字詳解
- 定義:volatile是一種同步機制,比synchronized或Lock鎖等更輕量,因爲volatile僅僅是控制把緩存中的數據立刻刷回到主內存中,不會被線程緩存,不會給對象上鎖,所以不會發生上下文切換等開銷很大的行爲;
- 如果一個變量被volatile修飾,那麼JVM就知道這個變量有併發可能,就會禁止重排序;
- volatile無法保證原子性,且只能作用於屬性,讀寫操作都是無鎖的,不能替代synchronized,場景有限;
- 不適用場景:a++
- 適用場景1:boolean flag(作爲一個標記位),如果一個共享變量自始至終只被各個線程賦值,而沒有其他的操作(修改,取反,對比),就可以用volatile來代替synchronized,因爲賦值本身是有原子性的,而volatile又保證了可見性,所以就足以保證線程安全。
- 適用場景2:作爲刷新之前變量的觸發器,只要volatile變量被賦值,那麼在其執行之前的賦值操作都可見;
- volatile的兩點作用:
- 可見性:讀一個volatile變量之前,需要先使相應的本地緩存失效,這樣就必須到主內存讀取最新值,寫一個volatile屬性會立即刷入到主內存。
- 禁止指令重排序優化:解決單例雙重鎖亂序問題
- 保證可見性的措施:synchronized、Lock、併發集合、join、start都保證可見性;
- synchronized可見性:不僅保證了原子性,還保證了可見性;凡是被synchronized修飾的代碼,上一個線程的操作可以被下一個線程所看到;
- synchronized近朱者赤:在單個線程中synchronized修飾的代碼塊,在其之前的賦值操作也是對另一個線程可見的;
3. 原子性
3.1 什麼是原子性?
- 定義:一系列的操作要麼全部都成功,要麼全部都不成功,是不可分割的。
- i++不是原子性的。
- 用synchronized實現原子性:保證同一時刻只有一個線程執行這段代碼
- 原子操作 + 原子操作 != 原子操作。
- Java中的原子操作有哪些:
- 除了long和double外的基本數據類型的賦值操作,在32位的JVM上,long和double的操作不是原子性的,在64位上是原子性的,在商用JVM中不會出現這種問題;
- 所有引用的賦值操作;
- Atomic包中的所有類的原子操作;
3.2 synchronized關鍵字詳解
3.2.1 synchronized基本用法
- 定義:如果一個對象對多個線程可見,synchronized能夠保證在同一時刻最多隻有一個線程操作這個對象,以達到保證併發安全的效果。
- 作用:保證可見性和原子性,可以避免線程安全問題:運行結果錯誤
- 兩種使用方法:
-
對象鎖:
-
方法鎖,默認鎖對象爲this當前實例對象
public class ObjectLock3 implements Runnable { @Override public void run() { method(); } public synchronized void method() { System.out.println(Thread.currentThread().getName() + "進入同步方法"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { ObjectLock3 objectLock3 = new ObjectLock3(); Thread t1 = new Thread(objectLock3); Thread t2 = new Thread(objectLock3); t1.start(); t2.start(); } }
-
同步代碼塊鎖,自己指定鎖對象
public class ObjectLock1 implements Runnable { @Override public void run() { synchronized (this) { System.out.println(Thread.currentThread().getName() + "進入同步代碼塊"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代碼塊"); } } public static void main(String[] args) { ObjectLock1 objectLock1 = new ObjectLock1(); new Thread(objectLock1).start(); new Thread(objectLock1).start(); } } Thread-0進入同步代碼塊 Thread-0退出同步代碼塊 Thread-1進入同步代碼塊 Thread-1退出同步代碼塊
public class ObjectLock2 implements Runnable { private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); @Override public void run() { synchronized (lock1) { System.out.println(Thread.currentThread().getName() + "進入同步代碼塊1"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代碼塊1"); } synchronized (lock2) { System.out.println(Thread.currentThread().getName() + "進入同步代碼塊2"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代碼塊2"); } } public static void main(String[] args) { ObjectLock2 objectLock2 = new ObjectLock2(); new Thread(objectLock2).start(); new Thread(objectLock2).start(); } } Thread-0進入同步代碼塊1 Thread-0退出同步代碼塊1 Thread-0進入同步代碼塊2 Thread-1進入同步代碼塊1 Thread-1退出同步代碼塊1 Thread-0退出同步代碼塊2 Thread-1進入同步代碼塊2 Thread-1退出同步代碼塊2
-
-
類鎖:
-
靜態方法鎖,synchronized加在static方法上,鎖對象爲當前類
public class ObjectStaticLock1 implements Runnable { @Override public void run() { method(); } public static synchronized void method() { System.out.println(Thread.currentThread().getName() + "進入到同步靜態方法中"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步靜態方法"); } public static void main(String[] args) { ObjectStaticLock1 objectStaticLock1 = new ObjectStaticLock1(); ObjectStaticLock1 objectStaticLock2 = new ObjectStaticLock1(); Thread t1 = new Thread(objectStaticLock1); Thread t2 = new Thread(objectStaticLock2); t1.start(); t2.start(); } }
-
同步代碼塊鎖,synchronized(*.class)代碼塊,指定鎖對象爲class對象,所謂的類鎖,不過是Class對象的鎖而已
public class ObjectStaticLock2 implements Runnable { @Override public void run() { synchronized (ObjectStaticLock2.class) { System.out.println(Thread.currentThread().getName() + "進入到同步代碼塊"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代碼塊"); } } public static void main(String[] args) { ObjectStaticLock2 objectStaticLock1 = new ObjectStaticLock2(); ObjectStaticLock2 objectStaticLock2 = new ObjectStaticLock2(); Thread t1 = new Thread(objectStaticLock1); Thread t2 = new Thread(objectStaticLock2); t1.start(); t2.start(); } }
-
-
3.2.2 多線程訪問同步方法的7種情況
- 兩個線程同時訪問一個對象的同步方法:會發生同步,鎖對象都爲同一個實例對象;
- 兩個線程同時訪問兩個對象的同步方法:互不影響,鎖對象不同;
- 兩個線程訪問的是synchronized的靜態方法:會發生同步,鎖對象都爲Class對象,Class對象只有一個;
- 同時訪問同步方法和非同步方法:非同步方法不受影響,不發生同步;
- 訪問同一個對象不同的普通同步方法:會發生同步,鎖對象默認爲同一個實例對象;
- 同時訪問靜態synchronized和非靜態synchronized方法:互不影響,靜態syn方法的鎖對象爲Class對象,非靜態syn方法的鎖對象爲一個實例對象this,實例對象和Class對象不是同一個對象,實例對象在堆中,Class對象在方法區中;
- 方法拋出異常後,會釋放鎖;
總結:- 一把鎖只能同時被一個線程獲取,沒拿到鎖的線程必須等待,如1、5;
- 每個實例都有自己的一把鎖,不同實例互不影響,當使用Class對象以及synchonized修飾的static方法的時候,所有對象共用同一把類鎖,對應2、3、4、5;
- 遇到異常,會釋放鎖,對應7;
3.2.3 synchronized關鍵字的性質
3.2.3.1 可重入
- 定義:一個線程已經獲取到鎖,想再次獲取到這把鎖時不需要釋放,直接可以用;
- 什麼是不可重入:一個線程獲取到鎖之後,想再次使用這個鎖,必須釋放鎖之後還其他線程競爭;
- 好處:避免死鎖:假如一個類有兩個synchronized方法,當一個線程執行了方法1獲得了默認的this對象鎖,這個時候要執行方法2,如果synchronized不具備可重入性,那麼這個線程就無法獲取到訪問方法2的鎖,又無法釋放鎖,就造成了死鎖。
- 粒度:線程範圍,在一個線程中,只要這個線程拿到了這把鎖,在這個線程內部就可以一直使用
- 同一個方法是可重入的;
- 可重入不要求是同一個方法;
- 可重入不要求是同一個類中;
3.2.3.2 不可中斷
一旦這個鎖已經被別的線程獲得了,如果本線程還想獲得,該線程只能等待或阻塞,直到別的線程釋放這個鎖。如果別的線程永遠不釋放鎖,那麼本線程則永遠等待下去。
相比之下,Lock類,擁有可以中斷的能力:
- 如果等的時間過長,可以中斷現在已經獲取的鎖的線程的執行;
- 如果等待時間過長,也可以退出。
3.2.4 synchronized原理
3.2.4.1 加鎖和釋放鎖原理
- 每個一個對象都有一個內置的monitor鎖,這個鎖存儲在對象頭中的,鎖的獲取和釋放實際上需要執行兩個指令:monitorenter和monitorexit,當線程執行到monitorenter的時候會嘗試獲取這個鎖;
- 反編譯:先javac demo.java,然後javap -verbose demo.class文件;
- monitorenter和monitorexit在執行的時候會讓對象鎖的計數+1或-1;
- 獲取鎖的過程:首先一個線程要獲取一個對象鎖的時候會查看這個monitor鎖的計數器如果爲0,那麼就給他+1,這樣別的線程就進不來了,如果一個線程有了這把鎖,又重入了,在計數器再+1;如果monitor被其他線程持有了,直到計數器=0,纔會獲取這個鎖。
- 釋放鎖的過程:將monitor的計數器-1,直到=0,表示不再擁有所有權了,如果不是0,說明剛纔是可重入進來的
3.2.4.1 可重入原理
一個線程拿到一把鎖之後,還想再次進入由這把鎖所控制的方法,則可以再次進入,原理是用了monitor鎖的計數器。
- JVM負責跟蹤被加鎖的次數
- 線程第一次給對象加鎖的時候,計數+1.每當這個相同的線程再次獲取該對象鎖的時候,計數器會遞增;
- 每當任務離開的時候,計數遞減,當計數爲0的時候,鎖被完全釋放;
3.2.4.1 可見性原理
線程A和線程B通信:
- 本地內存A把修改後的內容放到主內存中;
- 本地內存B從主內存從讀取修改後的內容;
synchnized修飾的代碼塊對對象的任何修改,在釋放鎖之前都要將修改的內容先寫回到主內存中,所以從主內存中讀取的內容都是最新的。
3.2.5 synchronized的缺陷
- 效率低:鎖的釋放情況少(只有代碼執行完和拋異常)、試圖獲得鎖時候不能設定超時、不能中斷一個正在試圖獲得鎖的線程
- 不夠靈活:加鎖和釋放的時機單一,每個鎖僅僅有單一的條件,可能是不夠的。讀寫鎖更靈活。
- 無法知道是否成功獲取到鎖,沒法去嘗試獲取,去判斷。Lock是可以通過tryLock方法嘗試獲取,返回true代表成功加鎖。