1、線程安全
多個線程對同一個共享變量進行讀寫操作時可能產生不可預見的結果,這就是線程安全問題。
線程安全的核心點就是共享變量,只有在共享變量的情況下才會有線程安全問題。這里說的共享變量,是指多個線程都能訪問的變量,一般包括成員變量和靜態變量,方法內定義的局部變量不屬於共享變量的范圍。
線程安全問題示例:
import lombok.extern.slf4j.Slf4j;
/**
* @Author FengJian
* @Date 2021/1/27 10:59
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count++;
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count--;
}
}
};
t1.start();
t2.start();
/**
* join方法:使main線程與t1、t2線程同步執行,即t1、t2線程都執行完,main線程才會繼續執行(但t1、t2之間依然是並行執行的)
* 主要是為了等待兩個線程執行完后,在main線程打印count的值
*/
t1.join();
t2.join();
log.debug("count的值為:{}",count);
}
}
運行上述代碼三次的結果如下:
[main] DEBUG c.ThreadSafeTest - count的值為:-904
[main] DEBUG c.ThreadSafeTest - count的值為:-2206
[main] DEBUG c.ThreadSafeTest - count的值為:73
在上述代碼中,線程t1中count進行5000次自增操作,而線程t2中count則進行5000次自減操作。在兩個線程都運行結束后,按照預期結果,count的值應為0。但由打印結果可知,count的值並不為0,且每次運行的結果都不一樣。這就是多線程對共享變量進行操作出現的不可預見的結果,即常說的線程安全問題。
而線程安全,則指的是在多線程環境下,程序可以始終執行正確的行為,符合預期的邏輯。具體到上述代碼,就是不論執行多少次,在t1、t2線程執行完畢后,count的值都應該始終符合預期的結果0。上述代碼明顯是線程不安全的。
2、出現線程安全的原因
線程安全是使用多線程必定會面臨的問題,導致線程不安全的主要原因有以下三點:
①原子性:一個或者多個操作在 CPU 執行的過程中被中斷
②可見性:一個線程對共享變量的修改,另外一個線程不能立刻看到
③有序性:序執行的順序沒有按照代碼的先后順序執行
2.1、原子性
2.1.1 什么是原子性問題
原子性問題,其實說的是原子性操作。即一個或多個操作,應該是一個不可分的整體,這些操作要么全部執行並且不被打斷,要么就都不執行。
以上述代碼中的count的自增(count++)和自減(count--)為例。
count++和count--看似只有一行代碼,但實際上這一行代碼在編譯后的字節碼指令以及在JVM執行的對應操作如下:
count++:
getstatic count //獲取靜態變量count的值
iconst_1 //准備常量1
iadd //自增
putstatic count //將修改后的值存入靜態變量count
count--:
getstatic count //獲取靜態變量count的值
iconst_1 //准備常量1
isub //自減
putstatic count //將修改后的值存入靜態變量count
由此可知,count自增或自減的操作,並不是一個原子操作,即中間過程是有可能被打斷的。
count自增自減操作需要四個步驟(指令)才能完成,這意味着如果這執行這四個步驟的某一步時,線程發生了上下文切換,那么自增自減操作將被打斷暫停。
如果使用單線程來執行自增自減操作,這實際上並無問題:

上圖為單線程執行count自增自減的一次過程,可以看出在沒有線程上下文切換的情況下,即使自增自減不是原子操作,count的最后結果都會是0。
但在多線程環境下,就會出現問題了:

可以看到由於自增自減不是原子操作,因此在線程t1執行自增過程中,如果進行上下文切換,則將導致線程t1還沒來得及把count = 1 寫入主存,count的值就被t2線程讀取,所以在最后,線程t2自減得出的值-1寫入主存后,會被線程t1覆蓋,變為1。
這結果明顯是不符合我們的預期的,實際上,上述圖片展示的只是一種可能的結果。還有可能是t2寫入count的步驟是最后執行的,那么最后count的值將為-1。
這就是由於非原子操作帶來的多線程訪問共享變量出現不符合預期的結果,即由於原子性帶來的線程安全問題。
上面示例中兩個線程t1、t2分別執行count++和count--出現的問題,就是由於原子性帶來的線程安全問題。
2.1.2、原子性問題解決辦法
解決辦法就是將count++和count--的操作變為原子操作,Java中的實現方法是:
①上鎖:使用synchronized
只需要創建一個對象作為鎖,並在訪問count時用synchronized進行加鎖即可。
static int count = 0;
static Object lock = new Object(); //鎖對象
synchronized(lock){
count++;
}
synchronized(lock){
count--;
}
上鎖后,執行自增自減的示意圖如下:

由於鎖的存在,則保證了不持有鎖的t2線程會被阻塞,直到t1線程執行自增完畢,並釋放鎖。在這一過程中,雖然依舊存在線程的上下文切換,但是t2線程是無法對共享變量count進行操作的,因此保證了t1線程中count++操作的原子性。
因此使用synchronized鎖可以解決原子性帶來的線程安全問題。
②、循環CAS操作
其基本思路就是循環進行CAS操作(compare and swap,比較並交換)。即對共享變量進行計算前,線程會先將該共享變量保存一份舊值a,計算完畢后得出結果值b。在將b從線程的本地內存刷新回主內存前,會先比較主內存中的值是否和a一致。如果一致,則將b刷新回主內存。若不一致,則一直循環比較,直到主內存中的值與a一致,才把共享變量的值設為b,操作才結束。
在Java中,使用CAS操作保證原子性的具體實現就是Lock和原子類(AtomicInteger)。它們都是通過使用unsafe的compareAndSwap方法實現CAS操作保證原子性的。
Lock的使用:
static int count = 0;
static Lock lock = new Lock (); //鎖對象
lock.lock(); //加鎖
count++;
lock.unlock(); //解鎖
lock.lock(); //加鎖
count--;
lock.unlock(); //解鎖
原子類的使用:
static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); //自增
count.decrementAndGet(); //自減
以上都是Java中可以保證原子操作的具體方法,它們各有優缺點,要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的線程安全問題。
2.2、可見性
2.2.1、什么是可見性問題
可見性實際上指的是內存可見性問題。總的來說就是一個線程對共享變量的修改,另外一個線程不能立刻看到,從而產生的線程安全問題。
在上一篇筆記【JAVA並發第三篇】線程間通信 中的通過共享內存進行通信實際上講的就是內存可見性問題。這里再從線程安全的角度講述一遍。
我們知道,CPU要從內存中讀取出數據來進行計算,但實際上CPU並不總是直接從內存中讀取數據。由於CPU和內存間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機制,如圖

上圖所示是一個雙核心的CPU系統架構,每個核心都有自己的控制器和運算器,也都有自己的一級緩存,還有可能有所有CPU核心共享的二級緩存,每個核心都可以獨立運行線程。
因此,CPU讀取數據的順序是:寄存器-高速緩存-主存。主存中的部分數據,會先拷貝一份放到cache中,當CPU計算時,會直接從cache中讀取數據,計算完畢后再將計算結果放置到cache中,最后在主存中刷新計算結果。所以每個CPU都會擁有一份拷貝。
以上只是CPU訪問內存,進行計算的基本方式。實際上,不同的硬件,訪問過程會存在不同程度的差異。比如,不同的計算機,CPU和主存間可能會存在三級緩存、四級緩存、五級緩存等等的情況。
為了屏蔽掉各種硬件和操作系統的內存訪問差異,實現讓 Java 程序在各種平台下都能達到一致的內存訪問效果,定義了Java的內存模型(Java Memory Model,JMM)。
JMM 的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到主存和從主存中取出變量這樣的底層細節。這里的變量指的是能夠被多個線程共享的變量,它包括了實例字段、靜態字段和構成數組對象的元素,方法內的局部變量和方法的參數為線程私有,不受JMM的影響。
Java的內存模型如下:

Java內存模型中的本地內存,對應的就是CPU結構圖中的cache1或者cache2。它實際上並不真實存在,其包含了緩存、寫緩沖區、寄存器以及其他的硬件和編譯器的優化。
JMM規定:將所有共享變量放到主內存中,當線程使用變量時,會把其中的變量復制到自己的本地內存,線程讀寫時操作的是本地內存中的變量副本。一個線程不能訪問其他線程的本地內存。
這樣的情況下,如果有一個變量i在線程A、B的本地內存中都有一份副本。此時,若線程A想修改i的值,在線程A將修改后的值放入到本地內存,但又未刷新回主內存時,如果線程B讀取變量i的值,則讀到的是未修改時的值,這就造成了讀寫共享變量出現不可預期的結果,產生線程安全問題。
有代碼如下:
/**
* @Author FengJian
* @Date 2021/2/21 23:47
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafe02 {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread My_Thread = new Thread(new Runnable() {
@Override
public void run() {
while (run) {
}
}
}, "My_Thread");
My_Thread.start(); //啟動My_Thread線程
log.debug(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
Thread.sleep(1000); //主線程休眠1s
run = false; //改變My_Thread線程運行條件
log.debug(Thread.currentThread().getName()+"正在運行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
}
}
從運行結果發現,即使在主線程中修改了共享變量run的值,My_Thread線程依然在循環並不會停止:

其原因就是main線程對共享變量run的修改,另外一個線程My_Thread並不能立刻看到:

這就是由於內存可見性帶來的多線程訪問共享變量出現不符合預期的結果,即由於可見性帶來的線程安全問題。
2.2.2、可見性問題解決辦法
解決辦法就是保證共享變量的可見性,具體實現就是任何對共享變量的訪問都要從共享內存(主內存)中獲取。在Java中的實現方法是:
①加鎖,synchronized和Lock都可以保證
線程在加鎖時,會清空本地內存中共享變量的值,共享變量的使用需要從主內存中重新獲取。而在釋放鎖資源時,則必須先把此共享變量同步回主內存中。
由於鎖的存在,未持有鎖的線程並不能操作共享變量,而當阻塞的線程獲得鎖時,主內存中共享變量的值已經刷新過了,因此線程修改共享變量對其他線程是可見的。這保證了共享變量的可見性,可以解決內存可見性產生的線程安全問題。
②使用volatile修飾共享變量
當一個變量被聲明為volitale時,線程在寫入變量時,不會把值緩存本地內存,而是會立即把值刷新回主存,而當要讀取該共享變量時,線程則會先清空本地內存中的副本值,從主存中重新獲取。這些也都保證了內存的可見性。
優先使用volatile關鍵字來解決可見性問題,加鎖消耗的資源更多。
2.3、有序性
2.3.1、什么是有序性問題
有序性,實際上是指令的重排序問題。
我們知道,CPU的執行速度是比內存要快出很多個數量級的。CPU為了執行效率,會把CPU指令進行重新排序。即我們編寫的Java代碼並不一定按照順序一行一行的往下執行,處理器會根據需要重新排序這些指令,稱為指令並行重排序。
同時,JIT編譯器也會在代碼編譯的時候對代碼進行重新整理,最大限度的去優化代碼的執行效率,稱為編譯器的重排序。
而又由於處理器與主存之間會使用緩存和讀/寫緩沖機制,因此從主存加載和存儲操作也有可能是經過指令重排序的,稱為內存系統重排序。
綜上所述,在執行程序時,為了提高性能,編譯器和處理器常常會對指令進行重排序,再加上主內存和處理器的緩存,Java源碼經過層層的重排序,最后才得出最終結果。

由圖可知,從Java源碼到最后的執行指令,會經歷3種重排序的優化。若有ava代碼如下:
int a = 2; //A
int b = 3; //B
int c = a*b; //C
經過上述3種重排序后,語句A和語句B的執行順序是可能互換的,並且這種互換並不影響代碼的正確性。但是我們發現語句C則不能和A、B互換,否則得出的結果將不正確,因為他們之間存在着數據依賴關系,即語句C的數據依賴A和B得出。
由此,我們可以發現,以上3種指令的重排序並不能隨意排序,他們需要遵守一定的規則,以保證程序的正確性。
①as-if-serial語義
as-if-serial語義是指:不管怎么樣重排序,單線程程序的執行結果都不能被改變。即不會對存在數據依賴關系的操作進行重排序。
編譯器、處理器進行指令重排序優化時都必須遵守as-if-serial語義。即在單線程的情況下,指令重排序只能對不影響處理結果的部分進行重排序。
以上述語句A、B、C為例,存在數據依賴關系的語句C和A或B不能被重排序:

as-if-serial語義把單線程程序保護起來了,遵守該語義的編譯器、處理器等使我們編寫單線程有一個錯覺:單線程程序是按照源代碼的順序來執行的。實際上在由於as-if-serial語義的存在,我們編寫單線程時,完全可以認為源代碼是按照順序執行的,因為即使代碼被進行了重排序,其結果也不會改變,同時單線程中也無需擔心內存可見性問題。
as-if-serial語義的核心思想是:不會對存在數據依賴關系的操作進行重排序。
那么數據依賴類型有哪些呢?如下表所示:
| 類型 | 示例 | 說明 |
|---|---|---|
| 寫后讀 | a = 1; b = a | 寫一個變量后再讀該變量 |
| 寫后寫 | a = 1; a = 2 | 寫一個變量后再寫該變量 |
| 讀后寫 | a = b; b = 2 | 讀一個變量后再寫該變量 |
以上三種依賴關系,一旦重排序兩個操作的執行順序,其結果就會改變,所以依照as-if-serial語義,Java在單線程的情況下不會對這三種依賴關系進行重排序(多線程情況不符合此情況)。
as-if-serial語義是基於數據依賴關系的,但它無法保證多線程環境下,重排序之后程序執行結果的正確性。
有代碼如下:
/**
* @Author FengJian
* @Date 2021/2/24 16:44
* @Version 1.0
*/
@Slf4j(topic = "c.HappensBeforeTest")
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
關於上述代碼,我們先忽略內存可見性的問題(即線程t2修改了a和finish,但t1可能看不到的緩存問題)。在此前提下如果成功打印a*a的值,那么結果應該為4。
但實際上a*a打印的結果還可能為0,這是由於指令重排序的存在導致的。
在線程t2中,由於a = 2;和finish = true;沒有數據依賴關系,依照as-if-serial語義,可以對這兩條語句進行重排序,因此會出現finish = true;的指令比a = 2;先執行的情況。
如果在先執行finish = true;,而a = 2;沒有執行時發生線程上下文切換,輪到線程t1執行,那么t1線程中的if語句條件為真,而a的值依然為初始值0,則a*a的結果為0。

可以看出,即使在假設沒有內存可見性問題的前提下,上述代碼的結果也是不可預期的,因此上述代碼也是線程不安全的,其原因就是重排序破壞了多線程程序的語義。
②happens-before規則
既然是重排序出現問題,那么解決思路就是禁止重排序。但是也要注意不能全部禁用重排序,重排序的目的是為了提升執行效率,如果全部禁用那么Java程序的性能將會很差。所以,應該做到的是部分禁用,Java的內存模型提供了一個可用於多線程環境,也適用於單線程環境的規則:happens-before規則。
happens-before規則的定義如下:A happens-before B,那么操作A的執行結果對操作B是可見的,且操作A的執行順序排在操作B之前。這里的操作A和操作B可以在同一個線程中,也可以在不同線程中。
注意:執行順序只是happens-before向開發人員做的保證,實際上在處理器和編譯器上執行時並不一定按照操作A排在操作B之前執行。
如果重排序之后,依然可以保證與先A后B的執行結果一樣,那么進行重排序也是可以的。也就是說,符合happens-before的操作,只要不改變執行結果,處理器和編譯器怎么優化(重排序)都行。
只是我們開發人員可以直接認為操作A的執行順序排在操作B之前。
happens-before保證操作A的執行結果對B可見,依靠這個原則,可以解決多線程環境下內存可見性和有序性問題。
回到代碼:
/**線程t1**/
if(finish){
a*a;
}
/**線程t2**/
a = 2;
finish = true;
一共有四個操作a = 2;、finish = true;、if(finish)、a*a;,想要上述代碼達到線程安全(即打印都正確輸出4),只需要:

即在t2線程計算a*a;和if(finish);之前,需要知道t1線程中a = 2;和finish = true;(t2線程對t1線程的結果可見)。
要達到這一目的,就需要上圖中,①和②所示的happens-before關系。
那要如何達到呢?這就需要了解happens-before的六大具體規則了(兩個操作,只需要符合其中任何一條就可以認為是happens-before關系):
- ①程序順序規則:一個線程中的每個操作,按照程序順序,前面的操作 happens-before 於該線程中的任意后續操作。
以上述代碼為例:
/**線程t2**/
a = 2; //操作1
finish = true; //操作2
/**線程t1**/
if(finish ); //操作3
a*a; //操作4
操作1 happens-before 操作2
操作3 happens-before 操作4
- ②監視器鎖規則:對一個鎖的解鎖,happens-before於隨后對這個鎖的加鎖。
synchronized (lock) { //加鎖
// x是共享變量,初始值=10
if (x < 12) {
x = 12;
}
} //解鎖
若有兩個線程A、B,先后執行這段代碼。則線程A執行完畢后X = 12並釋放鎖。而線程B獲得鎖后,進入代碼塊,在if中取X值判斷是否小於12。
此時 線程A中X=12的操作 happens-before 線程B中取X值判斷的操作(即線程B能看到線程A中執行的X=12的結果)
- ③volatile變量規則:對一個volatile域的寫,happens-before於任意后續對這個volatile域的讀。
volatile int x = 10;
/**線程t1**/
x = 11; //操作1
/**線程t2**/
int y = x; //操作2
操作1 happens-before 操作2
-
④傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
-
⑤start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before於線程B中的任意操作。
-
⑥join()規則:如果線程A執行操作ThreadB.join()並成功返回,那么線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。
以上就是happens-before的六大常用規則(全部有八種,但后面兩種應該很少用到)
2.3.2、有序性問題解決辦法
解決有序性問題,實際上就是要運用以上提到的兩種規則,as-if-serial語義解決了單線程程序的有序性問題,而happens-before關系則能解決多線程程序的有序性問題。
再回顧一下原始代碼,這是一段存在有序性問題線程不安全的代碼,我們要利用happens-before關系解決有序性問題:
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
提取一下關鍵的操作,如下嗷:
/**線程t1**/
if(finish){
a*a;
}
/**線程t2**/
a = 2;
finish = true;
我們的目標是運用happens-before的六大常用規則達到如下圖的happens-before關系,以實現上訴代碼的線程安全

解決辦法如下:
①、方法一:運用volatile修飾變量
使用到happens-before規則中的程序順序規則、volatile變量規則和傳遞性。
首先,按照程序順序規則,可以知道如下的happens-before關系:
| 線程t1 | 線程t2 |
|---|---|
| if(finish) happens-before a*a; | a = 2; happens-before finish = true; |
這由線程中的代碼很容易就能得出。接下來運用volatile變量規則,需要用volatile修飾一個變量,我們選變量finish。即初始化時代碼改為為volatile static boolean finish = false;。
那么根據volatile變量規則,可知對finish的寫要happens-before於對finish的讀。
因此給finish加上volatile關鍵字后,就可以達到如下效果:

volatile關鍵字不僅可以保證內存可見性問題,同時依照happens-before的volatile變量規則,對於volatile修飾的變量,要保證對該變量寫的結果要對讀的操作可見,因此volatile禁止對有讀寫操作的volatile修飾的變量進行重排序。
也就是說,volatile關鍵字不僅可以解決可見性問題,還可以解決有序性問題。
最后,通過傳遞性。可知:

可知,圖示的三和五,就是我們的目標。到此,我們利用happens-before關系保證了代碼的可見性和有序性問題。
雖然分析的過程比較長,但是在原代碼中,我們實際上只改動了一行代碼。即將static boolean finish = false;改為volatile static boolean finish = false;而已,就可以使我們的代碼改變線程安全的。
這就是運用volatile修飾變量來解決線程安全的辦法。volatile直接通過禁止相關的重排序來達到有序性的目的。
②、方法二:加鎖,synchronized
這個應該比較容易理解,對相關代碼加鎖后,同一時刻就只有一個線程在執行,也就相當於對相關變量的操作,是保證有序的。
不過synchronized並不像volatile一樣禁止指令重排序,實際上synchronized塊內部的代碼指令依然是可以進行重排序優化的。
3、小結
- 多個線程對同一個共享變量進行讀寫操作時就可能產生不可預見的結果,就是線程安全問題。其重點是多線程對共享變量進行讀和寫,如果只有讀,並不會有線程安全問題。
- 線程安全的原因有:①線程切換帶來的原子性問題②緩存帶來的可見性問題③指令重排序帶來的原子性問題。
- 線程安全的解決辦法:①對於原子性問題,使用鎖synchronized和Lock、或者使用原子類(AtomicInteger等)②對於可見性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字③對於有序性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字
由於能力有限,可能存在錯誤,感謝並懇請老鐵們指出。以上內容為本人在學習過程中所做的筆記。參考的書籍、文章或博客如下:
[1]方騰飛,魏鵬,程曉明. Java並發編程的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java並發編程之美[M].電子工業出版社.
[3]mg驛站. 多線程篇-線程安全-原子性、可見性、有序性解析.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java並發的原子性、可見性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程序員七哥.happens-before是什么?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344
