前言
上節(jié),我們對線程安全有了較全面的認(rèn)知.
我們知道, 線程之所以不安全, 主要是多線程下對可變的共享資源的爭用導(dǎo)致的.
衡量線程是否安全, 主要從三個特性入手
- 原子性
- 可見效
- 有序性
只要保證了這三個特性,我們就認(rèn)為線程是安全的, 多線程下執(zhí)行結(jié)果才會和單線程執(zhí)行結(jié)果統(tǒng)一起來.
本章,我們就來聊聊如何保證線程安全
的問題.
如何保證原子性
常用的保證Java操作原子性的工具是鎖和同步方法(或者同步代碼塊)
.
我們舉個例子:
public class Test {
private static int count = 0;
public static void addCount() {
count++;
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
addCount();
}
}
});
thread.start();
}
// 主線程睡眠1s,保證子線程都執(zhí)行完畢
Thread.sleep(1000);
System.out.println("count=" + count);
}
}
可以看出,
子線程計數(shù)器累加到1000
,
然后主線程創(chuàng)建了10
個子線程來跑,
所以,最終結(jié)果是應(yīng)該是10000
,
但是大家運(yùn)行代碼看看, 發(fā)現(xiàn)各種錯誤的輸出都有!
原因就是 “count++
”這個操作不是我們以為的原子操作
, 它其實(shí)是三步操作
從主存中讀取count的值,復(fù)制一份到CPU寄存器
CPU寄存器中,CPU執(zhí)行指令對 count 進(jìn)行加1 操作
把count重新刷新到主存
單線程當(dāng)然沒有問題, 但當(dāng)多線程時, 就會存在問題.
所以我們必須解決這個問題!
鎖
使用鎖, 可以保證
同一時間只有一個線程能拿到鎖,也就保證了同一時間只有一個線程能執(zhí)行申請鎖和釋放鎖之間的代碼.
使用方式
// 聲明一個鎖
private static ReentrantLock lock = new ReentrantLock();
public static void addCount() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
需要強(qiáng)調(diào)的是
try{
//加鎖代碼
}finally{
lock.unlock();
}
防止異常導(dǎo)致鎖一直無法釋放!
同步方法
與鎖類似的是同步方法或者同步代碼塊,
Java使用關(guān)鍵字synchronized
進(jìn)行同步.
需要注意的是, synchronized是有作用范圍的.
synchronized的作用范圍:
- 修飾非靜態(tài)方法(或成員變量),鎖的是this對象, 就是類的實(shí)例對象(即: 對象鎖)
public synchronized void addCount() {
count++;
}
- 修飾靜態(tài)方法(或成員變量), 鎖的是Class對象本身, 因?yàn)殪o態(tài)成員不專屬于任何一個實(shí)例對象 (即: 類鎖)
public static synchronized void addCount() {
count++;
}
- 修飾代碼塊時, 鎖住的是synchronized關(guān)鍵字后面括號內(nèi)的對象.
public class Test{
private Object object = new Object();
public void addCount() {
//此時,鎖住的是object對象變量
synchronized (object) {
count++;
}
//此時鎖住的是當(dāng)前實(shí)例對象
synchronized (this) {
count++;
}
//此時鎖住的是當(dāng)前Test類的class對象
synchronized (Test.class) {
count++;
}
}
}
無論使用鎖還是synchronized, 本質(zhì)都是一樣
通過鎖或同步來實(shí)現(xiàn)資源的排它性,
從而實(shí)際目標(biāo)代碼段同一時間只會被一個線程執(zhí)行,
進(jìn)而保證了目標(biāo)代碼段的原子性.
悲觀鎖和樂觀鎖
- 悲觀鎖
處理數(shù)據(jù)時,假設(shè)會有其他外部修改, 所以每次都會鎖住數(shù)據(jù), 防止外部的操作.
- 樂觀鎖
處理數(shù)據(jù)時, 不加鎖而是假設(shè)沒有沖突而去完成某項(xiàng)操作,如果因?yàn)闆_突失敗就重試,直到成功為止.
初一看, 大家可能會任務(wù)樂觀鎖好像比悲觀鎖性能高,其實(shí)也要看具體場景! 因?yàn)闃酚^鎖的重試機(jī)制, 所以當(dāng)并發(fā)量很高的時候, 重試的次數(shù)就會劇增, 此時, 顯然性能是不如悲觀鎖的!
顯而易見, 鎖或同步就是悲觀鎖
, 它們以"犧牲性能
"來保證原子性.
那么, 有沒有無需加鎖也能保證原子性
的方式呢?
CAS無鎖
CAS 是英文單詞 Compare And Swap 的縮寫,翻譯過來就是
比較并替換
.
CAS有3個操作數(shù),內(nèi)存值V, 舊的預(yù)期值A(chǔ),要修改的新值B.
當(dāng)且僅當(dāng)預(yù)期值A(chǔ)和內(nèi)存值V相同時, 將內(nèi)存值V修改為B,否則什么都不做.
我們舉個例子:
假設(shè) V = 10;
線程1想要使得V的值加1, 按CAS, 此時, A=10, B = 11;
線程2突然修改了V=11;
線程1發(fā)現(xiàn), (A=10) != (V=11), 所以, 不允許更新!
CAS是一種樂觀鎖的機(jī)制
,它不會阻塞任何線程. 所以在效率上,它會比 鎖和同步要高.
上文中我們說“count++
”自增操作不是原子的, 這導(dǎo)致了并發(fā)問題, 那么如何解決呢?
Java提供了并發(fā)原子類AtomicInteger
來解決自增操作原子性的問題,其底層就是使用了CAS原理
private static AtomicInteger count = new AtomicInteger();
public static void addCount() {
count.incrementAndGet();
}
CAS雖然在普通場景下優(yōu)于鎖和同步, 但是同時引入了一個“ABA”問題!
ABA問題:
我們還舉上一個例子:
假設(shè) V = 10;
線程1想要使得V的值加1, 按CAS, 此時, A=10, B = 11;
線程2突然修改了V=11;
線程3突然修改了V=10;
線程1發(fā)現(xiàn), (A=10) = (V=10), 所以, 允許更新!
雖然數(shù)字結(jié)果上沒有問題, 但是如果需要追溯過程就會存在漏洞!
因?yàn)镃AS把線程3修改的V=10,當(dāng)成了V的初始值10, 認(rèn)為它從未更改過!
針對ABA問題,雖然也能通過增加版本號等等來解決, 不過有句忠告:
使用CAS要考慮清楚“ABA”問題是否會影響程序并發(fā)的正確性,如果需要解決ABA問題,改用鎖或同步可能更高效
可重入鎖
介紹完以上知識,不知道大家關(guān)于“鎖”的使用,有沒有這樣的疑惑
A線程對某個對象加鎖后, 在A線程內(nèi)部如果再次要獲取同一個對象的鎖,會怎樣? 會不會死鎖?
針對這樣的問題, 提出了可重入鎖
這個東西!
所謂可重入鎖,指的是以線程為單位,當(dāng)一個線程獲取對象鎖之后,
這個線程可以再次獲取本對象上的鎖,而其他的線程是不可以的.
(同一個加鎖線程自己調(diào)用自己不會發(fā)生死鎖情況)
可重入鎖是為了防止死鎖
它的實(shí)現(xiàn)原理是
通過為每個鎖關(guān)聯(lián)一個請求計數(shù)和一個占有它的線程.
當(dāng)計數(shù)為 0 時,認(rèn)為鎖是未被占有的.
線程請求一個未被占有的鎖時, jvm 將記錄鎖的占有者,并且將請求計數(shù)器置為 1 .
如果同一個線程再次請求這個鎖,計數(shù)將遞增;
每次占用線程退出同步塊,計數(shù)器值將遞減.
直到計數(shù)器為0,鎖被釋放.
synchronized 和 ReentrantLock 都是可重入鎖
ReentrantLock 表現(xiàn)為 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成);
synchronized 表現(xiàn)為原生語法層面的互斥鎖.
如何保證可見性
Java提供了volatile
關(guān)鍵字來保證可見性.
當(dāng)使用volatile修飾某個變量時,
它會保證對該變量的修改會立即被更新到內(nèi)存中,
并且將其它線程緩存中對該變量的緩存設(shè)置成無效
因此其它線程需要讀取該值時必須從主內(nèi)存中讀取,
從而得到最新的值.
我們還舉介紹可見性時的例子,
private static volatile boolean isRuning = false;
如果用volatile來修飾isRuning,
再運(yùn)行你會發(fā)現(xiàn), 程序能得到預(yù)期結(jié)果了.
volatile適用于不需要保證原子性,但卻需要保證可見性的場景 一種典型的使用場景是用它修飾用于停止線程的狀態(tài)標(biāo)記
關(guān)于“不需要保證原子性
”這點(diǎn), 大家可以參考介紹“原子性
”的那個案例(多線程count++
),
將count定義為volatile修飾的變量
private static volatile int count = 0;
運(yùn)行你會發(fā)現(xiàn)最終結(jié)果并不是預(yù)期值, 原因就在于:
兩個線程A,B同時進(jìn)行count++,
count++是三步操作
從主存中讀取count的值,復(fù)制一份到CPU寄存器
CPU寄存器中,CPU執(zhí)行指令對 count 進(jìn)行加1 操作
把count重新刷新到主存
假設(shè)count = 1
A和B讀取count都是1,復(fù)制到各自的緩存中
假設(shè)A先執(zhí)行完了, 將count = 2回寫進(jìn)主存, 因?yàn)関olatile, 所以通知其它線程count值有更新.
B呢,此時正好執(zhí)行到最后一步,于是保存的是2,而不是我們認(rèn)為的3!
如何保證有序性
針對編譯器和處理器對指令進(jìn)行重新排序時,可能影響多線程程序并發(fā)執(zhí)行的正確性問題,
Java中可通過volatile關(guān)鍵字在一定程序上保證順序性, 另外還可以通過鎖和同步(synchronized)來保證順序性.
事實(shí)上, 鎖和synchronized即可以保證原子性,也可以保證可見性以及順序性.因?yàn)樗鼈兪峭ㄟ^保證同一時間只有一個線程執(zhí)行目標(biāo)代碼段來實(shí)現(xiàn)的.
鎖和synchronized可以“勝任”一切,為什么還需要volatile?
synchronized和鎖需要通過操作系統(tǒng)來仲裁誰獲得鎖,開銷比較高;
而volatile開銷小很多,
因此在只需要保證可見性的條件下,
使用volatile的性能要比使用鎖和synchronized高得多.
除了從應(yīng)用層面保證目標(biāo)代碼段執(zhí)行的順序性外,
JVM還通過被稱為happens-before
原則隱式地保證順序性.
兩個操作的執(zhí)行順序只要可以通過happens-before推導(dǎo)出來,
則JVM會保證其順序性,
反之JVM對其順序性不作任何保證,可對其進(jìn)行任意必要的重新排序以獲取高效率.
happens-before
在JMM(Java內(nèi)存模型)中,
如果一個操作的執(zhí)行結(jié)果需要對另一個操作可見,
那么這兩個操作之間必須要存在happens-before關(guān)系,
這兩個操作既可以在同一個線程,也可以在不同的兩個線程中.
我們需要關(guān)注的happens-before規(guī)則如下:
- 傳遞規(guī)則
如果操作A先行發(fā)生于操作B,而操作B又先行發(fā)生于操作C,則可以得出操作A先行發(fā)生于操作C
- 鎖定規(guī)則
一個unlock操作肯定會在后面對同一個鎖的lock操作前發(fā)生, 鎖只有被釋放了才會被再次獲取
- volatile變量規(guī)則
對一個volatile修飾的變量的寫操作先行發(fā)生于后面對這個變量的讀操作
- 程序次序規(guī)則
一個線程內(nèi),按照代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作
- 線程啟動規(guī)則
Thread對象的start()方法先發(fā)生于此線程的其它動作
- 線程終結(jié)原則
線程中所有的操作都先行發(fā)生于線程的終止檢測, 我們可以通過Thread.join()方法結(jié)束, Thread.isAlive()的返回值手段檢測到線程已經(jīng)終止執(zhí)行(所有終結(jié)的線程都不可再用)
- 線程中斷規(guī)則
對線程interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生
- 對象終結(jié)規(guī)則
一個對象的初始化完成先行發(fā)生于他的finalize()方法的開始
歡迎關(guān)注我
技術(shù)公眾號 “CTO技術(shù)”