多個線程對同一個共享變量進(jìn)行讀寫操作時可能產(chǎn)生不可預(yù)見的結(jié)果,這就是線程安全問題。
線程安全的核心點就是共享變量,只有在共享變量的情況下才會有線程安全問題。這里說的共享變量,是指多個線程都能訪問的變量,一般包括成員變量和靜態(tài)變量,方法內(nèi)定義的局部變量不屬于共享變量的范圍。
線程安全問題示例:
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線程同步執(zhí)行,即t1、t2線程都執(zhí)行完,main線程才會繼續(xù)執(zhí)行(但t1、t2之間依然是并行執(zhí)行的)
* 主要是為了等待兩個線程執(zhí)行完后,在main線程打印count的值
*/
t1.join();
t2.join();
log.debug("count的值為:{}",count);
}
}
運行上述代碼三次的結(jié)果如下:
[main] DEBUG c.ThreadSafeTest - count的值為:-904
[main] DEBUG c.ThreadSafeTest - count的值為:-2206
[main] DEBUG c.ThreadSafeTest - count的值為:73
在上述代碼中,線程t1中count進(jìn)行5000次自增操作,而線程t2中count則進(jìn)行5000次自減操作。在兩個線程都運行結(jié)束后,按照預(yù)期結(jié)果,count的值應(yīng)為0。但由打印結(jié)果可知,count的值并不為0,且每次運行的結(jié)果都不一樣。這就是多線程對共享變量進(jìn)行操作出現(xiàn)的不可預(yù)見的結(jié)果,即常說的線程安全問題。
而線程安全,則指的是在多線程環(huán)境下,程序可以始終執(zhí)行正確的行為,符合預(yù)期的邏輯。具體到上述代碼,就是不論執(zhí)行多少次,在t1、t2線程執(zhí)行完畢后,count的值都應(yīng)該始終符合預(yù)期的結(jié)果0。上述代碼明顯是線程不安全的。
線程安全是使用多線程必定會面臨的問題,導(dǎo)致線程不安全的主要原因有以下三點:
①原子性:一個或者多個操作在 CPU 執(zhí)行的過程中被中斷
②可見性:一個線程對共享變量的修改,另外一個線程不能立刻看到
③有序性:序執(zhí)行的順序沒有按照代碼的先后順序執(zhí)行
原子性問題,其實說的是原子性操作。即一個或多個操作,應(yīng)該是一個不可分的整體,這些操作要么全部執(zhí)行并且不被打斷,要么就都不執(zhí)行。
以上述代碼中的count的自增(count++
)和自減(count--
)為例。
count++
和count--
看似只有一行代碼,但實際上這一行代碼在編譯后的字節(jié)碼指令以及在JVM執(zhí)行的對應(yīng)操作如下:
count++:
getstatic count //獲取靜態(tài)變量count的值
iconst_1 //準(zhǔn)備常量1
iadd //自增
putstatic count //將修改后的值存入靜態(tài)變量count
count--:
getstatic count //獲取靜態(tài)變量count的值
iconst_1 //準(zhǔn)備常量1
isub //自減
putstatic count //將修改后的值存入靜態(tài)變量count
由此可知,count自增或自減的操作,并不是一個原子操作,即中間過程是有可能被打斷的。
count自增自減操作需要四個步驟(指令)才能完成,這意味著如果這執(zhí)行這四個步驟的某一步時,線程發(fā)生了上下文切換,那么自增自減操作將被打斷暫停。
如果使用單線程來執(zhí)行自增自減操作,這實際上并無問題:
上圖為單線程執(zhí)行count自增自減的一次過程,可以看出在沒有線程上下文切換的情況下,即使自增自減不是原子操作,count的最后結(jié)果都會是0。
但在多線程環(huán)境下,就會出現(xiàn)問題了:
這結(jié)果明顯是不符合我們的預(yù)期的,實際上,上述圖片展示的只是一種可能的結(jié)果。還有可能是t2寫入count的步驟是最后執(zhí)行的,那么最后count的值將為-1。
這就是由于非原子操作帶來的多線程訪問共享變量出現(xiàn)不符合預(yù)期的結(jié)果,即由于原子性帶來的線程安全問題。
上面示例中兩個線程t1、t2分別執(zhí)行count++和count--出現(xiàn)的問題,就是由于原子性帶來的線程安全問題。
解決辦法就是將count++和count--的操作變?yōu)樵硬僮?,Java中的實現(xiàn)方法是:
①上鎖:使用synchronized
只需要創(chuàng)建一個對象作為鎖,并在訪問count時用synchronized進(jìn)行加鎖即可。
static int count = 0;
static Object lock = new Object(); //鎖對象
synchronized(lock){
count++;
}
synchronized(lock){
count--;
}
上鎖后,執(zhí)行自增自減的示意圖如下:
因此使用synchronized鎖可以解決原子性帶來的線程安全問題。
②、循環(huán)CAS操作
其基本思路就是循環(huán)進(jìn)行CAS操作(compare and swap,比較并交換)。即對共享變量進(jìn)行計算前,線程會先將該共享變量保存一份舊值a,計算完畢后得出結(jié)果值b。在將b從線程的本地內(nèi)存刷新回主內(nèi)存前,會先比較主內(nèi)存中的值是否和a一致。如果一致,則將b刷新回主內(nèi)存。若不一致,則一直循環(huán)比較,直到主內(nèi)存中的值與a一致,才把共享變量的值設(shè)為b,操作才結(jié)束。
在Java中,使用CAS操作保證原子性的具體實現(xiàn)就是Lock和原子類(AtomicInteger)。它們都是通過使用unsafe的compareAndSwap方法實現(xiàn)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中可以保證原子操作的具體方法,它們各有優(yōu)缺點,要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的線程安全問題。
可見性實際上指的是內(nèi)存可見性問題??偟膩碚f就是一個線程對共享變量的修改,另外一個線程不能立刻看到,從而產(chǎn)生的線程安全問題。
在上一篇筆記【JAVA并發(fā)第三篇】線程間通信 中的通過共享內(nèi)存進(jìn)行通信實際上講的就是內(nèi)存可見性問題。這里再從線程安全的角度講述一遍。
我們知道,CPU要從內(nèi)存中讀取出數(shù)據(jù)來進(jìn)行計算,但實際上CPU并不總是直接從內(nèi)存中讀取數(shù)據(jù)。由于CPU和內(nèi)存間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機(jī)制,如圖
因此,CPU讀取數(shù)據(jù)的順序是:寄存器-高速緩存-主存。主存中的部分?jǐn)?shù)據(jù),會先拷貝一份放到cache中,當(dāng)CPU計算時,會直接從cache中讀取數(shù)據(jù),計算完畢后再將計算結(jié)果放置到cache中,最后在主存中刷新計算結(jié)果。所以每個CPU都會擁有一份拷貝。
以上只是CPU訪問內(nèi)存,進(jìn)行計算的基本方式。實際上,不同的硬件,訪問過程會存在不同程度的差異。比如,不同的計算機(jī),CPU和主存間可能會存在三級緩存、四級緩存、五級緩存等等的情況。
為了屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,實現(xiàn)讓 Java 程序在各種平臺下都能達(dá)到一致的內(nèi)存訪問效果,定義了Java的內(nèi)存模型(Java Memory Model,JMM)。
JMM 的主要目標(biāo)是定義程序中各個變量的訪問規(guī)則,即在虛擬機(jī)中將變量存儲到主存和從主存中取出變量這樣的底層細(xì)節(jié)。這里的變量指的是能夠被多個線程共享的變量,它包括了實例字段、靜態(tài)字段和構(gòu)成數(shù)組對象的元素,方法內(nèi)的局部變量和方法的參數(shù)為線程私有,不受JMM的影響。
Java的內(nèi)存模型如下:
JMM規(guī)定:將所有共享變量放到主內(nèi)存中,當(dāng)線程使用變量時,會把其中的變量復(fù)制到自己的本地內(nèi)存,線程讀寫時操作的是本地內(nèi)存中的變量副本。一個線程不能訪問其他線程的本地內(nèi)存。
這樣的情況下,如果有一個變量i在線程A、B的本地內(nèi)存中都有一份副本。此時,若線程A想修改i的值,在線程A將修改后的值放入到本地內(nèi)存,但又未刷新回主內(nèi)存時,如果線程B讀取變量i的值,則讀到的是未修改時的值,這就造成了讀寫共享變量出現(xiàn)不可預(yù)期的結(jié)果,產(chǎn)生線程安全問題。
有代碼如下:
/**
* @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);
}
}
從運行結(jié)果發(fā)現(xiàn),即使在主線程中修改了共享變量run的值,My_Thread線程依然在循環(huán)并不會停止:
解決辦法就是保證共享變量的可見性,具體實現(xiàn)就是任何對共享變量的訪問都要從共享內(nèi)存(主內(nèi)存)中獲取。在Java中的實現(xiàn)方法是:
①加鎖,synchronized和Lock都可以保證
線程在加鎖時,會清空本地內(nèi)存中共享變量的值,共享變量的使用需要從主內(nèi)存中重新獲取。而在釋放鎖資源時,則必須先把此共享變量同步回主內(nèi)存中。
由于鎖的存在,未持有鎖的線程并不能操作共享變量,而當(dāng)阻塞的線程獲得鎖時,主內(nèi)存中共享變量的值已經(jīng)刷新過了,因此線程修改共享變量對其他線程是可見的。這保證了共享變量的可見性,可以解決內(nèi)存可見性產(chǎn)生的線程安全問題。
②使用volatile修飾共享變量
當(dāng)一個變量被聲明為volitale時,線程在寫入變量時,不會把值緩存本地內(nèi)存,而是會立即把值刷新回主存,而當(dāng)要讀取該共享變量時,線程則會先清空本地內(nèi)存中的副本值,從主存中重新獲取。這些也都保證了內(nèi)存的可見性。
優(yōu)先使用volatile關(guān)鍵字來解決可見性問題,加鎖消耗的資源更多。
有序性,實際上是指令的重排序問題。
我們知道,CPU的執(zhí)行速度是比內(nèi)存要快出很多個數(shù)量級的。CPU為了執(zhí)行效率,會把CPU指令進(jìn)行重新排序。即我們編寫的Java代碼并不一定按照順序一行一行的往下執(zhí)行,處理器會根據(jù)需要重新排序這些指令,稱為指令并行重排序。
同時,JIT編譯器也會在代碼編譯的時候?qū)Υa進(jìn)行重新整理,最大限度的去優(yōu)化代碼的執(zhí)行效率,稱為編譯器的重排序。
而又由于處理器與主存之間會使用緩存和讀/寫緩沖機(jī)制,因此從主存加載和存儲操作也有可能是經(jīng)過指令重排序的,稱為內(nèi)存系統(tǒng)重排序。
綜上所述,在執(zhí)行程序時,為了提高性能,編譯器和處理器常常會對指令進(jìn)行重排序,再加上主內(nèi)存和處理器的緩存,Java源碼經(jīng)過層層的重排序,最后才得出最終結(jié)果。
由圖可知,從Java源碼到最后的執(zhí)行指令,會經(jīng)歷3種重排序的優(yōu)化。若有ava代碼如下:
int a = 2; //A
int b = 3; //B
int c = a*b; //C
經(jīng)過上述3種重排序后,語句A和語句B的執(zhí)行順序是可能互換的,并且這種互換并不影響代碼的正確性。但是我們發(fā)現(xiàn)語句C則不能和A、B互換,否則得出的結(jié)果將不正確,因為他們之間存在著數(shù)據(jù)依賴關(guān)系,即語句C的數(shù)據(jù)依賴A和B得出。
由此,我們可以發(fā)現(xiàn),以上3種指令的重排序并不能隨意排序,他們需要遵守一定的規(guī)則,以保證程序的正確性。
①as-if-serial語義
as-if-serial語義是指:不管怎么樣重排序,單線程程序的執(zhí)行結(jié)果都不能被改變。即不會對存在數(shù)據(jù)依賴關(guān)系的操作進(jìn)行重排序。
編譯器、處理器進(jìn)行指令重排序優(yōu)化時都必須遵守as-if-serial語義。即在單線程的情況下,指令重排序只能對不影響處理結(jié)果的部分進(jìn)行重排序。
以上述語句A、B、C為例,存在數(shù)據(jù)依賴關(guān)系的語句C和A或B不能被重排序:
as-if-serial語義的核心思想是:不會對存在數(shù)據(jù)依賴關(guān)系的操作進(jìn)行重排序。
那么數(shù)據(jù)依賴類型有哪些呢?如下表所示:
類型 | 示例 | 說明 |
---|---|---|
寫后讀 | a = 1; b = a | 寫一個變量后再讀該變量 |
寫后寫 | a = 1; a = 2 | 寫一個變量后再寫該變量 |
讀后寫 | a = b; b = 2 | 讀一個變量后再寫該變量 |
以上三種依賴關(guān)系,一旦重排序兩個操作的執(zhí)行順序,其結(jié)果就會改變,所以依照as-if-serial語義,Java在單線程的情況下不會對這三種依賴關(guān)系進(jìn)行重排序(多線程情況不符合此情況)。
as-if-serial語義是基于數(shù)據(jù)依賴關(guān)系的,但它無法保證多線程環(huán)境下,重排序之后程序執(zhí)行結(jié)果的正確性。
有代碼如下:
/**
* @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();
}
}
關(guān)于上述代碼,我們先忽略內(nèi)存可見性的問題(即線程t2修改了a和finish,但t1可能看不到的緩存問題)。在此前提下如果成功打印a*a的值,那么結(jié)果應(yīng)該為4。
但實際上a*a打印的結(jié)果還可能為0,這是由于指令重排序的存在導(dǎo)致的。
在線程t2中,由于a = 2;
和finish = true;
沒有數(shù)據(jù)依賴關(guān)系,依照as-if-serial語義,可以對這兩條語句進(jìn)行重排序,因此會出現(xiàn)finish = true;
的指令比a = 2;
先執(zhí)行的情況。
如果在先執(zhí)行finish = true;
,而a = 2;
沒有執(zhí)行時發(fā)生線程上下文切換,輪到線程t1執(zhí)行,那么t1線程中的if語句條件為真,而a的值依然為初始值0,則a*a的結(jié)果為0。
可以看出,即使在假設(shè)沒有內(nèi)存可見性問題的前提下,上述代碼的結(jié)果也是不可預(yù)期的,因此上述代碼也是線程不安全的,其原因就是重排序破壞了多線程程序的語義。
②happens-before規(guī)則
既然是重排序出現(xiàn)問題,那么解決思路就是禁止重排序。但是也要注意不能全部禁用重排序,重排序的目的是為了提升執(zhí)行效率,如果全部禁用那么Java程序的性能將會很差。所以,應(yīng)該做到的是部分禁用,Java的內(nèi)存模型提供了一個可用于多線程環(huán)境,也適用于單線程環(huán)境的規(guī)則:happens-before規(guī)則。
happens-before規(guī)則的定義如下:A happens-before B,那么操作A的執(zhí)行結(jié)果對操作B是可見的,且操作A的執(zhí)行順序排在操作B之前。這里的操作A和操作B可以在同一個線程中,也可以在不同線程中。
注意:執(zhí)行順序只是happens-before向開發(fā)人員做的保證,實際上在處理器和編譯器上執(zhí)行時并不一定按照操作A排在操作B之前執(zhí)行。
如果重排序之后,依然可以保證與先A后B的執(zhí)行結(jié)果一樣,那么進(jìn)行重排序也是可以的。也就是說,符合happens-before的操作,只要不改變執(zhí)行結(jié)果,處理器和編譯器怎么優(yōu)化(重排序)都行。
只是我們開發(fā)人員可以直接認(rèn)為操作A的執(zhí)行順序排在操作B之前。
happens-before保證操作A的執(zhí)行結(jié)果對B可見,依靠這個原則,可以解決多線程環(huán)境下內(nèi)存可見性和有序性問題。
回到代碼:
/**線程t1**/
if(finish){
a*a;
}
/**線程t2**/
a = 2;
finish = true;
一共有四個操作a = 2;
、finish = true;
、if(finish)
、a*a;
,想要上述代碼達(dá)到線程安全(即打印都正確輸出4),只需要:
a*a;
和if(finish);
之前,需要知道t1線程中a = 2;
和finish = true;
(t2線程對t1線程的結(jié)果可見)。
要達(dá)到這一目的,就需要上圖中,①和②所示的happens-before關(guān)系。
那要如何達(dá)到呢?這就需要了解happens-before的六大具體規(guī)則了(兩個操作,只需要符合其中任何一條就可以認(rèn)為是happens-before關(guān)系):
以上述代碼為例:
/**線程t2**/
a = 2; //操作1
finish = true; //操作2
/**線程t1**/
if(finish ); //操作3
a*a; //操作4
操作1 happens-before 操作2
操作3 happens-before 操作4
synchronized (lock) { //加鎖
// x是共享變量,初始值=10
if (x < 12) {
x = 12;
}
} //解鎖
若有兩個線程A、B,先后執(zhí)行這段代碼。則線程A執(zhí)行完畢后X = 12并釋放鎖。而線程B獲得鎖后,進(jìn)入代碼塊,在if中取X值判斷是否小于12。
此時 線程A中X=12的操作 happens-before 線程B中取X值判斷的操作(即線程B能看到線程A中執(zhí)行的X=12的結(jié)果)
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()規(guī)則:如果線程A執(zhí)行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
⑥join()規(guī)則:如果線程A執(zhí)行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
以上就是happens-before的六大常用規(guī)則(全部有八種,但后面兩種應(yīng)該很少用到)
解決有序性問題,實際上就是要運用以上提到的兩種規(guī)則,as-if-serial語義解決了單線程程序的有序性問題,而happens-before關(guān)系則能解決多線程程序的有序性問題。
再回顧一下原始代碼,這是一段存在有序性問題線程不安全的代碼,我們要利用happens-before關(guān)系解決有序性問題:
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();
}
}
提取一下關(guān)鍵的操作,如下嗷:
/**線程t1**/
if(finish){
a*a;
}
/**線程t2**/
a = 2;
finish = true;
我們的目標(biāo)是運用happens-before的六大常用規(guī)則達(dá)到如下圖的happens-before關(guān)系,以實現(xiàn)上訴代碼的線程安全
使用到happens-before規(guī)則中的程序順序規(guī)則、volatile變量規(guī)則和傳遞性。
首先,按照程序順序規(guī)則,可以知道如下的happens-before關(guān)系:
線程t1 | 線程t2 |
---|---|
if(finish) happens-before a*a; | a = 2; happens-before finish = true; |
這由線程中的代碼很容易就能得出。接下來運用volatile變量規(guī)則,需要用volatile修飾一個變量,我們選變量finish
。即初始化時代碼改為為volatile static boolean finish = false;
。
那么根據(jù)volatile變量規(guī)則,可知對finish
的寫要happens-before于對finish
的讀。
因此給finish
加上volatile關(guān)鍵字后,就可以達(dá)到如下效果:
也就是說,volatile關(guān)鍵字不僅可以解決可見性問題,還可以解決有序性問題。
最后,通過傳遞性??芍?/p>
可知,圖示的三和五,就是我們的目標(biāo)。到此,我們利用happens-before關(guān)系保證了代碼的可見性和有序性問題。
雖然分析的過程比較長,但是在原代碼中,我們實際上只改動了一行代碼。即將static boolean finish = false;
改為volatile static boolean finish = false;
而已,就可以使我們的代碼改變線程安全的。
這就是運用volatile修飾變量來解決線程安全的辦法。volatile直接通過禁止相關(guān)的重排序來達(dá)到有序性的目的。
②、方法二:加鎖,synchronized
這個應(yīng)該比較容易理解,對相關(guān)代碼加鎖后,同一時刻就只有一個線程在執(zhí)行,也就相當(dāng)于對相關(guān)變量的操作,是保證有序的。
不過synchronized并不像volatile一樣禁止指令重排序,實際上synchronized塊內(nèi)部的代碼指令依然是可以進(jìn)行重排序優(yōu)化的。
由于能力有限,可能存在錯誤,感謝并懇請老鐵們指出。以上內(nèi)容為本人在學(xué)習(xí)過程中所做的筆記。參考的書籍、文章或博客如下:
[1]方騰飛,魏鵬,程曉明. Java并發(fā)編程的藝術(shù)[M].機(jī)械工業(yè)出版社.
[2]霍陸續(xù),薛賓田. Java并發(fā)編程之美[M].電子工業(yè)出版社.
[3]mg驛站. 多線程篇-線程安全-原子性、可見性、有序性解析.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java并發(fā)的原子性、可見性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程序員七哥.happens-before是什么?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344