深入淺出併發程式設計底層原理
1.Java記憶體模型——底層原理
1.1 什麼是底層原理
Java程式編譯到執行需要經過將.java字尾的檔案通過javac命令編譯成.class檔案(此時與平臺無關),然後將對應的.class檔案轉化成機器碼並執行,但是由於不同平臺的JVM會帶來不同的“翻譯”,所以我們在Java層寫的各種Lock,其實最終依賴的是JVM的具體實現和CPU指令,才能幫助我們達到執行緒安全的效果。
2 三兄弟:JVM記憶體結構、Java記憶體模型、Java物件模型
2.1 JVM記憶體結構,和Java虛擬機器器的執行時資料區有關
- 堆:堆是記憶體結構中最大的一塊區域,執行緒共享並且動態分配記憶體,當建立了一個物件就會在堆上分配記憶體,當堆滿了之後會觸發GC進行垃圾回收。
- 方法區:方法區是執行緒共享的,方法區用於儲存類資訊、常量以及靜態變數。
- Java棧(虛擬機器器棧):虛擬機器器棧是執行緒私有的,它的記憶體是不可變的,也就是說在編譯時就已經確定了的,虛擬機器器棧用於儲存區域性變量表、運算元棧、動態連結和方法出口。
- 本地方法棧:本地方法棧的作用於虛擬機器器棧類似,區別在於一個服務於Java方法一個服務於native方法
- 程式計數器:程式計數器是執行緒私有的,它佔用的空間非常小也是唯一一個不存在OOM問題的區域,主要用於記錄程式執行的行號數。
2.2 Java記憶體模型,和Java的併發程式設計有關
下面介紹
2.3 Java物件模型,和Java物件在虛擬機器器中的表現形式 有關
Java物件模型是Java物件自身的儲存模型,JVM會給一個類建立一個instanceKlass,儲存在方法區中,用來在JVM層表示該Java類。
在使用new指令建立一個物件的時候,JVM會建立一個instanceOopDesc物件,這個物件中包含了物件頭以及例項資料
3.JMM(Java Memory Model)
3.1 為什麼需要JMM
C/C++語言它們不存在記憶體模型的概念,它們依賴於處理器,不同的處理器處理的結果不同,也就無法保證併發安全。所以此時需要一個標準,讓多執行緒的執行結果可預期。
3.2 什麼是JMM
JMM是一組規範,要求JVM依照規範來實現,從而讓我們更好的開發多執行緒程式
。如果沒有了JMM規範,那麼不同的虛擬機器器可能會進行不同的重排序,這樣就會導致不同的虛擬機器器上執行的結果不同,這也就引法了問題。
JMM除了是規範還是工具類和關鍵字的原理,我們常見的
volatile
、synchronized
以及Lock
等的原理都是JMM。如果沒有JMM,那就需要我們自己指定什麼時候需要記憶體柵欄(工作記憶體與主記憶體之間的拷貝同步)等,這樣就很麻煩,因為有了JMM,所以我們只需要使用關鍵字就可以開發併發程式了。
4.JMM之重排序
第一種執行情況
/**
* 演示重排序的現象
* “直到達到某個條件才停止”,測試小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
one.start();
two.start();
one.join();
two.join();
System.out.println("x = " + x + ",y = " + y);
}
}
複製程式碼
第二種執行情況
/**
* 演示重排序的現象
* “直到達到某個條件才停止”,測試小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0,b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
two.start();
one.start();
one.join();
two.join();
System.out.println("x = " + x + ",y = " + y);
}
}
複製程式碼
第三種執行情況
/**
* 演示重排序的現象
* “直到達到某個條件才停止”,測試小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0,b = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await(); //進行等待
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await(); //進行等待
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
latch.countDown(); //統一開始執行
one.join();
two.join();
System.out.println("x = " + x + ",y = " + y);
}
}
複製程式碼
對第三種情況的優化
/**
* 演示重排序的現象
* “直到達到某個條件才停止”,測試小概率事件
*/
public class OutOfOrderExecution {
private static int x = 0,b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0; //計數
for (; ; ) {
i++;
x = 0; //清零操作
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
one.start();
two.start();
latch.countDown();
one.join();
two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 1 && y == 1) {
System.out.println(result);
return; //滿足條件後退出迴圈
} else {
System.out.println(result);
}
}
}
}
複製程式碼
總結:程式碼的執行順序決定了執行的結果
4.1 眼見為實的重排序
只需將上面的結束條件改為x == 0 && y == 0
即可
- 為什麼會出現x=0,y=0 ?
出現這種情況是因為重排序發生了,程式碼的執行順序有可能為
y = a;
a = 1;
x = b;
b = 1;
複製程式碼
- 什麼是重排序
執行緒1中程式碼的執行順序與Java程式碼不一致,程式碼的執行順序並不是按照指令執行的,它們的執行順序被改變了,這就是重排序。
4.2 重排序的好處與發生的時機
對比下圖可以發現如果進行重排序可以減少關於變數a
的執行指令,如果在程式中個存在大量的類似情況,也就提高了處理速度。
4.3 重排序的3種情況
- 編譯器優化:包括JVM,JIT編譯器等
比如存在變數a和b,如果將對a的操作連續執行效率更高的話,就可能發生重排序來提高執行效率。
- CPU指令重排:就算編譯器不發生重排,CPU也可能對指令進行重排
CPU重排和編譯器重排類似,就算編譯器不重排CPU也會進行重排,它們都是打亂執行順序達到優化的目的。
- 記憶體的“重排序”:執行緒A的修改執行緒B卻看不到,引出可見性問題
記憶體中的重排序並非真正的重排序,因為記憶體中有快取的存在,在JMM中表現為本地記憶體和主記憶體,如果執行緒1修改了變數a的值還沒有來得及寫入到主存,此時執行緒2由於可見性的原因無法知道執行緒1對變數進行了修改,所以會使程式表現出亂序行為。
5.JMM之可見性
5.1 什麼是可見性
演示程式碼當一個執行緒執行寫操作時,另外一個執行緒無法看見此時被更改的值。就像下圖所示當執行緒1從主存中讀取變數x,並將x的值設定為1,但是此時執行緒1並沒有將x的值寫回主存,所以執行緒2就無法得知x的值已經改變了。
/**
* 演示可見性帶來的問題
*/
public class FieldVisibility {
int a = 1;
int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
System.out.println("b = " + b + "; a = " + a);
}
private void change() {
a = 3;
b = a;
}
}
複製程式碼
四種情況
a = 3; b = 3;
a = 1; b = 2;
a = 3; b = 2;
a = 1; b = 3; //發生可見性問題
複製程式碼
5.2 解決可見性問題——使用volatile
/**
* 演示可見性帶來的問題
*/
public class FieldVisibility {
//解決可見性問題
volatile int a = 1;
volatile int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
System.out.println("b = " + b + "; a = " + a);
}
private void change() {
a = 3;
b = a;
}
}
複製程式碼
volatile怎麼解決可見性問題
當執行緒1讀取到x並將值更新為1後會刷回主存,當執行緒2再次讀取x時就會從主存中載入,這樣就不會引發可見性的問題。
5.3 為什麼會出現可見性問題
引發可見性問題的原因是因為讀取資料時需要一層一層對資料進行快取,如果直接從RAM中讀取資料的話,這樣會大量降低讀取速度,這也是需要JMM的原因。5.4 JMM的抽象——主記憶體和本地記憶體
Java作為高階語言,遮蔽了這些底層細節,用JMM定義了這些讀寫記憶體資料的規範,雖然我們不再需要關心一級快取和二級快取的問題,但是JMM抽象了主記憶體和本地記憶體的概念。
這裡說的本地記憶體並不是真正的為每個執行緒分配一塊記憶體,而是JMM的抽象,是對暫存器、一級快取、二級快取的抽象。
主記憶體和本地記憶體的關係
JMM有以下規定
- 所有的變數都儲存在主記憶體中,每個執行緒中有獨立的工作記憶體,工作記憶體中的變數是主記憶體中的拷貝。
- 執行緒不能直接操作主記憶體中的變數,只能通過自己的工作記憶體讀取主記憶體中的變數再寫回去。
- 主記憶體是執行緒共享的,但是工作記憶體不是,如果執行緒之間通訊必須通過主記憶體進行中轉。
總結:執行緒操作資料必須從主記憶體中讀取資料,然後在自己的工作記憶體中進行操作,操作完成後再寫回主記憶體,因為讀寫需要時間所以就會引發可見性的問題
6.Happens-Before規則有哪些?
- 單執行緒原則
在單執行緒情況下,後面的語句一定能看到前面的語句做了什麼
- 鎖操作(synchronized和Lock)
加鎖之後能看到解鎖之前的全部操作
- volatile變數
被volatile
修飾的變數只要執行了寫操作,就一定會被讀取到
- 執行緒啟動
呼叫start()
方法可以讓子執行緒中所有語句看到啟動之前的結果
- 執行緒join
join()
後的語句能看到等待之前的所有操作
- 傳遞性
比如第一行程式碼執行後第二行會看到,第二行執行後第三行會看到,從中可以推斷出第一行程式碼執行完第三行就會看到。
- 中斷
如果一個執行緒被interrupt()
時,那麼isInterrupt()
或者InterruptException
一定能看到。
- 構造方法
物件構造方法的最後一行語句happens-before於finalize()
的第一行語句
- 工具類的Happens-Before原則
- 執行緒安全的容器get一定能看到在此之前的put操作
- CountDownLatch
- CyclicBarrier
- Semaphore
- Future
- 執行緒池
7.volatile關鍵字
7.1 什麼是volatile
volatile
是一種同步機制,相對synchronized
和Lock
更輕量,不會帶來上下文切換等重大開銷。如果一個變數被volatile
修飾,那麼JVM就知道這個變數可能會被併發修改。雖然volatile
的開銷小,但是它的能力也小,相對於synchronized
來說volatile
無法保證原子性。
7.2 volatile的使用場景
- 不適用於組合操作
/**
* 不適用volatile的場景
*/
public class NoVolatile implements Runnable {
volatile int a;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
realA.incrementAndGet();
}
}
public static void main(String[] args) throws InterruptedException {
NoVolatile noVolatile = new NoVolatile();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.a);
System.out.println(noVolatile.realA.get());
}
}
複製程式碼
- 適用於直接賦值操作
/**
* volatile的適用場景
*/
public class UseVolatile implements Runnable {
volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
setDone();
realA.incrementAndGet();
}
}
private void setDone() {
done = true;
}
public static void main(String[] args) throws InterruptedException {
UseVolatile noVolatile = new UseVolatile();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.done);
System.out.println(noVolatile.realA.get());
}
}
複製程式碼
注意:賦值操作本來是原子操作,所以對volatile
修飾的變數進行賦值可以保證執行緒安全,但是如果不是直接賦值則無法保證,請看下面的例子
/**
* 不適用volatile的場景
*/
public class NoVolatile2 implements Runnable {
volatile boolean done = false;
AtomicInteger realA = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
flipDone();
realA.incrementAndGet();
}
}
private void flipDone() {
done = !done;
}
public static void main(String[] args) throws InterruptedException {
NoVolatile2 noVolatile = new NoVolatile2();
Thread thread1 = new Thread(noVolatile);
Thread thread2 = new Thread(noVolatile);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(noVolatile.done);
System.out.println(noVolatile.realA.get());
}
}
複製程式碼
上來初始值為false
,所以執行偶數次結果應該為false
,可是執行了20000次之後結果卻是true
,從中便可以看出volatile
在此情況下不適用
- 使用情況2:作為重新整理變數前的觸發器 這裡可以使用前面的例子
/**
* 演示可見性帶來的問題
*/
public class FieldVisibility {
int a = 1;
int abc = 1;
int abcd = 1;
volatile int b = 2;
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
private void print() {
if (b == 0) {
System.out.println("b = " + b + "; a = " + a);
}
}
private void change() {
abc = 7;
abcd = 70;
a = 3;
b = 0;
}
}
複製程式碼
在這裡b==0
作為觸發的條件,因為在change()
方法中最後一句將b設定為0,所以依照happens-before原則在b=0以前的操作都是可見的,從而達到了觸發器的作用
7.3 volatile的兩點作用
- 可見性:讀一個
volatile
修飾的變數需要使本地快取失效,然後從主存中讀取新值,寫一個volatile
變數後會立即刷回主存 - 禁止指令重排優化:解決單例雙重鎖亂序問題
7.4 volatile和synchronized的關係
volatile
是輕量級的synchronized
,當在多執行緒環境下只做賦值操作時可以使用volatile
代替synchronized
,因為賦值操作自身保證原子性,而使用volatile
又能保證可見性,所以可以實現執行緒安全。
7.5 volatile總結
- voaltile修飾符適用於以下場景:當某個屬性被執行緒共享,其中有一個執行緒修改了此屬性,其他執行緒可以立即得到修改後的值,比如
boolean flag;
,或者作為觸發器實現輕量級同步。 - volatile的讀寫都輸無鎖操作,它不能替代
synchronized
是因為它無法提供原子性和互斥性,因為無鎖,它也不會在獲取鎖和釋放鎖上有開銷,所以說它是低成本的。 - volatile只能用於修飾某個屬性,被volatile修飾的屬性不會被重排序。
- volatile提供可見性,任何執行緒對其進行修改後立刻就會對其他執行緒可見,volatile屬性不會被執行緒執行緒快取,必須從主存中讀取。
- volatile提供了happens-before保證
- volatile可以保證long和double的賦值操作都是原子的
7.6 能保證可見性的措施
除了volatile可以保證可見性之外,synchronized、Lock、併發集合、Thread.join()和Thread。start()都可以保證可見性(具體看happens-before原則)。
7.7 對synchronized理解的昇華
- synchronized不僅可以保證原子性,還能保證可見性
- synchronized不僅讓被保護的程式碼安全,而且還“近朱者赤”(具體看happens-before原則)
8.JMM之原子性
8.1 什麼是原子性
一系列操作要麼全部成功,要麼全部失敗,不會出現只執行一半的情況,是不可分割的。
8.2 Java中原子操作有哪些
- 除了long和double之外的基本型別的賦值操作。
- 所有引用的賦值操作
- java.concurrent.util.Atomic.*包中所有類的原子操作。
8.3 long和double的原子性
對於64位值的寫入,可以分為兩個32位操作進行寫入,所以可能會導致64位的值發生錯亂,針對這種情況可以新增volatile進行解決。在32位的JVM上它們不是原子的,而在64位的JVM上卻是原子的。
8.4 原子操作+原子操作 != 原子操作
簡單的把原子操作組合在一起,並不能保證整體依然具有原子性,比如說去銀行取兩次錢,這兩次取錢都是原子操作,但是中途銀行卡可能會被女朋友借走,這樣就造成了兩次取錢的中斷。
9.JMM應用例項:單例模式的8種寫法、單例和併發的關係
9.1 單例模式的作用
- 節省記憶體和計算
- 保證結果正確
- 方便管理
9.2 單例模式的適用場景
- 無狀態的工具類:比如日誌工具類,不管在哪裡使用,我們需要的知識讓它幫我們記錄日誌資訊,除此之外,並不需要在它的例項物件上儲存狀態,這時候我們就只需要一個例項物件即可。
- 全域性資訊類:比如在統計網站訪問次數時,我們不希望一些結果記錄在物件A上,一些記錄在物件B上,此時可以建立一個單例的類進行計算。
9.3 單例模式的實現
- 餓漢式(靜態常量,可用)
/**
* 餓漢式(靜態常量)(可用)
*/
public class Singleton1 {
private static final Singleton1 INSTANCE = new Singleton1();
private Singleton1(){
}
public Singleton1 getInstance(){
return INSTANCE;
}
}
複製程式碼
- 餓漢式(靜態程式碼塊,可用)
/**
* 餓漢式(靜態程式碼塊) (可用)
*/
public class Singleton2 {
private static Singleton2 INSTANCE;
static {
INSTANCE = new Singleton2();
}
private Singleton2(){}
public static Singleton2 getInstance(){
return INSTANCE;
}
}
複製程式碼
- 懶漢式(執行緒不安全,不可用)
/**
* 懶漢式(執行緒不安全) (不可用)
*/
public class Singleton3 {
private static Singleton3 INSTANCE;
private Singleton3(){}
public static Singleton3 getInstance(){
if (INSTANCE == null){
INSTANCE = new Singleton3();
}
return INSTANCE;
}
}
複製程式碼
因為這樣寫在多執行緒情況下有可能執行緒1進入了if (INSTANCE == null)
但還沒來得及建立,此時執行緒2進入if (INSTANCE == null)
,這樣就造成了重複的建立,破壞了單例。
- 懶漢式(執行緒安全,同步方法,不推薦用)
/**
* 懶漢式(執行緒安全,同步方法) (不推薦用)
*/
public class Singleton4 {
private static Singleton4 INSTANCE;
private Singleton4(){}
public synchronized static Singleton4 getInstance(){
if (INSTANCE == null){
INSTANCE = new Singleton4();
}
return INSTANCE;
}
}
複製程式碼
因為添加了synchronized
關鍵字,所以可以保證同一時刻只有一個執行緒能進入方法也就保證了執行緒安全。但是由於添加了synchronized
也會對效能產生影響
- 懶漢式(執行緒不安全,同步程式碼塊,不可用)
/**
* 懶漢式(執行緒不安全,同步程式碼塊) (不可用)
*/
public class Singleton5 {
private static Singleton5 INSTANCE;
private Singleton5() {
}
public static Singleton5 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton5.class) {
INSTANCE = new Singleton5();
}
}
return INSTANCE;
}
}
複製程式碼
這樣寫看似可行,可是實際上卻不可以。因為只要INSTANCE
為空就會進入判斷,無論裡面加不加同步早晚都會再次建立,所以這樣會導致例項被多次建立
- 雙重檢查
/**
* 雙重檢查(推薦面試使用)
*/
public class Singleton6 {
private volatile static Singleton6 INSTANCE;
private Singleton6() {
}
public static Singleton6 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton6.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton6();
}
}
}
return INSTANCE;
}
}
複製程式碼
優點:執行緒安全,延遲載入,效率高
為什麼要double-check?單check行不行?
因為如果不進行第二次檢查無論添不新增同步都會對例項進行建立,這樣就會建立多個例項,是執行緒不安全的
如果把synchronized
新增在方法上可以嗎?
如果新增在方法上是可以的,但是這樣會造成效能問題
為什麼一定要加volatile
因為新建物件不是原子操作,它需要經過建立空物件、呼叫構造方法、將地址分配給引用這三個步驟,這樣可能會進行重排序,所以就可能出現空指標異常,針對這個問題可以新增volatile
關鍵字來解決
- 靜態內部類(推薦用)
/**
* 靜態內部類式,可用
*/
public class Singleton7 {
private Singleton7() {
}
private static class InnerClass{
//不會對內部靜態例項進行初始化
private static Singleton7 INSTANCE = new Singleton7();
}
public static Singleton7 getInstance(){
return InnerClass.INSTANCE;
}
}
複製程式碼
靜態內部類方式是一種“懶漢”的方式,在最初對類載入時不會載入內部類的靜態例項
- 列舉單例
/**
* 列舉單例
*/
public enum Singleton8 {
INSTANCE;
}
複製程式碼
9.4 對比單例模式實現方案
- 餓漢:簡單,但是沒有lazy loading
- 懶漢:有執行緒安全問題
- 靜態內部類:避免了執行緒安全問題和資源浪費的問題,但是會增加程式設計的複雜性
- 雙重檢查:與JMM相關
- 列舉:寫法簡單、先天執行緒安全、避免反序列化破壞單例