複習:
先來簡單的複習一遍以前寫過的東西,上次我們說了記憶體一緻性協定M(修改)E(獨占)S(共享)I(失效)四種狀态,還有我們并發程式設計的三大特性原子性、一緻性和可見性。再就是簡單的提到了我們的volatile關鍵字,他可以保證我們的可見性,也就是說被volatile關鍵字修飾的變量如果産生了變化,可以馬上刷到主存當中去。我們接下來看一下我們這次部落格的内容吧。
線程:
何為線程呢?這也是我們面試當中經常問到的。按照官方的說法是:現代作業系統在運作一個程式時,會為其建立一個程序。例如,啟動一個Java程式,操作 系統就會建立一個Java程序。現代作業系統排程CPU的最小單元是線程。比如我們啟動QQ,就是我們啟動了一個程序,我們發起了QQ語音,這個動作就是一個線程。
在這裡多提一句的就是線程分為核心級線程和使用者級線程,我們在java虛拟機内的線程一般都為使用者級線程,也就是由我們的jvm虛拟機來調用我們的CPU來申請時間片來完成我們的線程操作的。而我們的核心級線程是由我們的系統來排程CPU來完成的,為了保證安全性,一般的線程都是由虛拟機來控制的。
使用者線程:指不需要核心支援而在使用者程式中實作的線程,其不依賴于作業系統核心,應用程序利用線程庫提供建立、同步、排程和管理線程的函數來控制使用者線程。另外,使用者線程是由應用程序利用線程庫建立和管理,不依賴于作業系統核心。不需要使用者态/核心态切換,速度快。作業系統核心不知道多線程的存在,是以一個線程阻塞将使得整個程序(包括它的所有線程)阻塞。由于這裡的處理器時間片配置設定是以程序為基本機關,是以每個線程執行的時間相對減少。
核心線程: 線程的所有管理操作都是由作業系統核心完成的。核心儲存線程的狀态和上下文資訊,當一個線程執行了引起阻塞的系統調用時,核心可以排程該程序的其他線程執行。在多處理器系統上,核心可以分派屬于同一程序的多個線程在多個處理器上運作,提高程序執行的并行度。由于需要核心完成線程的建立、排程和管理,是以和使用者級線程相比這些操作要慢得多,但是仍然比程序的建立和管理操作要快。大多數市場上的作業系統,如Windows,Linux等都支援核心級線程。
使用者級線程就是我們常說的ULT,核心級線程就是我們說的KLT。線程從使用者态切換到核心态時會消耗很大的性能和時間,後面說sychronized鎖的膨脹更新會說到這個過程。
上下文切換:
上面我們說過,線程是由我們的虛拟機去CPU來申請時間片來完成我們的操作的,但是不一定馬上執行完成,這時就産生了上下文切換。大緻就是這樣的:

線程A沒有運作完成,但是時間片已經結束了,我們需要挂起我們的線程A,CPU該去執行線程B了,運作完線程B,才能繼續運作我們的線程A,這時就涉及到一個上下文的切換,我們把這個暫時挂起到再次運作的過程,可以了解為上下文切換(最簡單的了解方式)。
可見性:
用volatile關鍵字修飾過的變量,可以保證可見性,也就是volatile變量被修改了,會立即刷到主記憶體内,讓其他線程感覺到變量已經修改,我們來看一個事例
public class VolatileVisibilitySample {
private volatile boolean initFlag = false;
public void refresh(){
this.initFlag = true;
String threadname = Thread.currentThread().getName();
System.out.println("線程:"+threadname+":修改共享變量initFlag");
}
public void load(){
String threadname = Thread.currentThread().getName();
int i = 0;
while (!initFlag){
}
System.out.println("線程:"+threadname+"目前線程嗅探到initFlag的狀态的改變"+i);
}
public static void main(String[] args){
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(()->{
sample.refresh();
},"threadA");
Thread threadB = new Thread(()->{
sample.load();
},"threadB");
threadB.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
複制
我們想建立一個全局的由volatile修飾的boolean變量,refresh方法是修改我們的全局變量,load方法是無限循環去檢查我們全局volatile修飾過的變量,我們開啟兩個線程,開始運作,我們會看到如下結果。
也就是說,我們的變量被修改以後,我們的另外一個線程會感覺到我們的變量已經發生了改變,也就是我們的可行性,立即刷回主記憶體。
有序性:
說到有序性,不得不提到幾個知識點,指令重排,as-if-serial語義和happens-before 原則。
指令重排:java語言規範規定JVM線程内部維持順序化語義。即隻要程式的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與代碼順序不一緻,此過程叫指令的重排序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)适當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。
指令重排一般發生在class翻譯為位元組碼檔案和位元組碼檔案被CPU執行這兩個階段。
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。 為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因 為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關系,這些操作就可能被 編譯器和處理器重排序。
happens-before 原則内容如下
1. 程式順序原則,即在一個線程内必須保證語義串行性,也就是說按照代碼順序執行。
2. 鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。
3. volatile規則 volatile變量的寫,先發生于讀,這保證了volatile變量的可見性,簡單的了解就是,volatile變量在每次被線程通路時,都強迫從主記憶體中讀該變量的值,而當該變量發生變化時,又會強迫将最新的值重新整理到主記憶體,任何時刻,不同的線程總是能夠看到該變量的最新值。
4. 線程啟動規則 線程的start()方法先于它的每一個動作,即如果線程A在執行線程B的start方法之前修改了共享變量的值,那麼當線程B執行start方法時,線程A對共享變量的修改對線程B可見
5. 傳遞性A先于B ,B先于C,那麼A必然先于C
6. 線程終止規則 線程的所有操作先于線程的終結,Thread.join()方法的作用是等待目前執行的線程終止。假設線上程B終止之前,修改了共享變量,線程A從線程B的join方法成功傳回後,線程B對共享變量的修改将對線程A可見。
7. 線程中斷規則 對線程 interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。
8. 對象終結規則 對象的構造函數執行,結束先于finalize()方法。
上一段代碼看看指令重排的問題。
public class VolatileReOrderSample {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
複制
我們來分析一下上面的代碼
情況1:假設我們的線程1開始執行,線程2還沒開始,這時a = 1 ,x = b = 0,因為b的初始值是0,然後開始執行線程2,b = 1,y = a = 1,得到結論x = 0 ,y = 1.
情況2:假設線程1開始執行,将a指派為1,開始執行線程2,b指派為1,并且y = a = 1,這時繼續運作線程1,x = b = 1,得到結論 x = 1,y = 1.
情況3:線程2優先執行,這時b = 1,y = a = 0,然後運作線程1,a = 1,x = b = 1,得到結論 x = 1,y = 0。
不管怎麼誰先誰後,我們都是隻有這三種答案,不會産生x = 0且y = 0的情況,我們在下面寫出來了x = 0 且 y = 0 跳出循環。我們來測試一下。
運作到第72874次結果了0,0的情況産生了,也就是說,我們t1中的a = 1;x = b;和t2中的b = 1;y = a;代碼發生了改變,隻有變為
Thread t1 = new Thread(new Runnable() {
public void run() {
x = b;
a = 1;
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
y = a;
b = 1;
}
});
複制
這種情況才可以産生0,0的情況,我們可以把代碼改為
private static volatile int a = 0, b = 0;
複制
繼續來測試,我們發現無論我們運作多久都不會發生我們的指令重排現象,也就是說我們volatile關鍵字可以保證我們的有序性
至少我這裡570萬次還沒有發生0,0的情況。
就是我上次部落格給予的表格
Required barriers | 2nd operation | |||
---|---|---|---|---|
1st operation | Normal Load | Normal Store | Volatile Load | Volatile Store |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store | StoreLoad | StoreStore |
我們來分析一下代碼
線程1的。
public void run() {
a = 1;
x = b;
}
複制
a = 1;是将a這個變量指派為1,因為a被volatile修飾過了,我們成為volatile寫,就是對應表格的Volatile Store,接下來我們來看第二步,x = b,字面意思是将b的值指派給x,但是這步操作不是一個原子操作,其中包含了兩個步驟,先取得變量b,被volatile修飾過,就成為volatile load,然後将b的值賦給x,x沒有被volatile修飾,成為普通寫。也就是說,這兩行代碼做了三個動作,分别是Volatile Store,volatile load和Store寫讀寫,查表格我們看到volatile修飾的變量Volatile Store,volatile load之間是給予了StoreLoad這樣的屏障,是不允許指令重排的,是以達到了有序性的目的。
擴充:
我們再來看一個方法,不用volatile修飾也可以防止指令重排,因為上面我們說過,volatile可以保證有序性,就是增加記憶體屏障,防止了指令重排,我們可以采用手動加屏障的方式也可以阻止指令重排。我們來看一下事例。
public class VolatileReOrderSample {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (;;){
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(new Runnable() {
public void run() {
a = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
b = 1;
UnsafeInstance.reflectGetUnsafe().storeFence();
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
複制
storeFence就是一個有java底層來提供的記憶體屏障,有興趣的可以自己去看一下unsafe類,一共有三個屏障
UnsafeInstance.reflectGetUnsafe().storeFence();//寫屏障
UnsafeInstance.reflectGetUnsafe().loadFence();//讀屏障
UnsafeInstance.reflectGetUnsafe().fullFence();//讀寫屏障
複制
通過unsafe的反射來調用,涉及安全問題,jvm是不允許直接調用的。手寫單例模式時在超高并發記得加volatile修飾,不然産生指令重排,會造成空對象的行為。後面我會科普這個玩意。