作者:追夢
借用 Java 并發(fā)編程實(shí)踐中的話;編寫正確的程序并不容易,而編寫正常的并發(fā)程序就更難了;相比于順序執(zhí)行的情況,多線程的線程安全問題是微妙而且出乎意料的,因?yàn)樵跊]有進(jìn)行適當(dāng)同步的情況下多線程中各個(gè)操作的順序是不可預(yù)期的。
并發(fā)編程相比 Java 中其他知識點(diǎn)學(xué)習(xí)起來門檻相對較高,學(xué)習(xí)起來比較費(fèi)勁,從而導(dǎo)致很多人望而卻步;而無論是職場面試和高并發(fā)高流量的系統(tǒng)的實(shí)現(xiàn)卻都還離不開并發(fā)編程,從而導(dǎo)致能夠真正掌握并發(fā)編程的人才成為市場比較迫切需求的。
本文作為 Java 并發(fā)編程之美系列的并發(fā)編程必備基礎(chǔ)晉級篇,通過通俗易懂的方式來和大家聊聊多線程并發(fā)編程中涉及到的高級基礎(chǔ)知識(建議先閱讀《Java 并發(fā)編程之美:線程相關(guān)的基礎(chǔ)知識》),具體內(nèi)容如下:
- 什么是多線程并發(fā)和并行。
- 什么是線程安全問題。
- 什么是共享變量的內(nèi)存可見性問題。
- 什么是 Java 中原子性操作。
- 什么是 Java 中的 CAS 操作,AtomicLong 實(shí)現(xiàn)原理
- 什么是 Java 指令重排序。
- Java 中 Synchronized 關(guān)鍵字的內(nèi)存語義是什么。
- Java 中 Volatile 關(guān)鍵字的內(nèi)存語義是什么。
- 什么是偽共享,為何會出現(xiàn),以及如何避免。
- 什么是可重入鎖、樂觀鎖、悲觀鎖、公平鎖、非公平鎖、獨(dú)占鎖、共享鎖。
多線程并發(fā)與并行
首先要澄清并發(fā)和并行的概念,并發(fā)是指同一個(gè)時(shí)間段內(nèi)多個(gè)任務(wù)同時(shí)都在執(zhí)行,并且都沒有執(zhí)行結(jié)束;而并行是說在單位時(shí)間內(nèi)多個(gè)任務(wù)同時(shí)在執(zhí)行;并發(fā)任務(wù)強(qiáng)調(diào)在一個(gè)時(shí)間段內(nèi)同時(shí)執(zhí)行,而一個(gè)時(shí)間段有多個(gè)單位時(shí)間累積而成,所以說并發(fā)的多個(gè)任務(wù)在單位時(shí)間內(nèi)不一定同時(shí)在執(zhí)行。
在單個(gè) CPU 的時(shí)代多個(gè)任務(wù)同時(shí)運(yùn)行都是并發(fā),這是因?yàn)?CPU 同時(shí)只能執(zhí)行一個(gè)任務(wù),單個(gè) CPU 時(shí)代多任務(wù)是共享一個(gè) CPU 的,當(dāng)一個(gè)任務(wù)占用 CPU 運(yùn)行時(shí)候,其它任務(wù)就會被掛起,當(dāng)占用 CPU 的任務(wù)時(shí)間片用完后,會把 CPU 讓給其它任務(wù)來使用,所以在單 CPU 時(shí)代多線程編程的意義不大,并且線程間頻繁的上下文切換還會帶來開銷。
如下圖單個(gè) CPU 上運(yùn)行兩個(gè)線程,可知線程 A 和 B 是輪流使用 CPU 進(jìn)行任務(wù)處理的,也就是同時(shí) CPU 只在執(zhí)行一個(gè)線程上面的任務(wù),當(dāng)前線程 A 的時(shí)間片用完后會進(jìn)行線程上下文切換,也就是保存當(dāng)前線程的執(zhí)行線程,然后切換線程 B 占用 CPU 運(yùn)行任務(wù)。
如下圖雙 CPU 時(shí)候,線程 A 和線程 B 在自己的 CPU 上執(zhí)行任務(wù),實(shí)現(xiàn)了真正的并行運(yùn)行。
而在多線程編程實(shí)踐中線程的個(gè)數(shù)往往多于 CPU 的個(gè)數(shù),所以平時(shí)都是稱多線程并發(fā)編程而不是多線程并行編程。
線程安全問題
談到線程安全問題不得不先說說什么是共享資源,所謂共享資源是說多個(gè)線程都可以去訪問的資源。
線程安全問題是指當(dāng)多個(gè)線程同時(shí)讀寫一個(gè)共享資源并且沒有任何同步措施的時(shí)候,導(dǎo)致臟數(shù)據(jù)或者其它不可預(yù)見的結(jié)果的問題。
如上圖,線程 A 和線程 B 可以同時(shí)去操作主內(nèi)存中的共享變量,是不是說多個(gè)線程共享了資源,都會產(chǎn)生線程安全問題呢?答案是否定的,如果多個(gè)線程都是只讀取共享資源,而不去修改,那么就不會存在線程安全問題。
只有當(dāng)至少一個(gè)線程修改共享資源時(shí)候才會存在線程安全問題。最典型的就是計(jì)數(shù)器類的實(shí)現(xiàn),計(jì)數(shù) count 本身是一個(gè)共享變量,多個(gè)線程可以對其進(jìn)行增加一,如果不使用同步的話,由于遞增操作是獲取 -> 加1 -> 保存三步操作,所以可能導(dǎo)致導(dǎo)致計(jì)數(shù)不準(zhǔn)確,如下表:
假如當(dāng)前 count=0,t1 時(shí)刻線程 A 讀取了 count 值到本地變量 countA。
然后 t2 時(shí)刻遞增 countA 值為1,同時(shí)線程 B 讀取 count 的值0放到本地變量 countB 值為0(因?yàn)?countA 還沒有寫入主內(nèi)存)。
t3 時(shí)刻線程 A 才把 countA 為1的值寫入主內(nèi)存,至此線程 A 一次計(jì)數(shù)完畢,同時(shí)線程 B 遞增 CountB 值為1。
t4 時(shí)刻線程 B 把 countB 值1寫入內(nèi)存,至此線程 B 一次計(jì)數(shù)完畢。
先不考慮內(nèi)存可見性問題,明明是兩次計(jì)數(shù)哇,為啥最后結(jié)果還是1而不是2呢?其實(shí)這就是共享變量的線程安全問題。那么如何解決?這就需要在線程訪問共享變量時(shí)候進(jìn)行適當(dāng)?shù)耐剑琂ava 中首屈一指的是使用關(guān)鍵字 Synchronized 進(jìn)行同步,這個(gè)下面會有具體介紹。
共享變量的內(nèi)存可見性問題
要談內(nèi)存可見性首先需要介紹下 Java 中多線程下處理共享變量時(shí)候的內(nèi)存模型。
如上圖,Jav a內(nèi)存模型規(guī)定了所有的變量都存放在主內(nèi)存中,當(dāng)線程使用變量時(shí)候都是把主內(nèi)存里面的變量拷貝到了自己的工作空間或者叫做工作內(nèi)存。
Java 內(nèi)存模型是個(gè)抽象的概念,那么在實(shí)際實(shí)現(xiàn)中什么是線程的工作內(nèi)存呢?
如上圖是雙核 CPU 系統(tǒng)架構(gòu),每核有自己的控制器和運(yùn)算器,其中控制器包含一組寄存器和操作控制器,運(yùn)算器執(zhí)行算術(shù)邏輯運(yùn)算,并且有自己的一級緩存,并且有些架構(gòu)里面雙核還有個(gè)共享的二級緩存。
那么 對應(yīng) Java 內(nèi)存模型里面的工作內(nèi)存,在實(shí)現(xiàn)上這里是指 L1 或者 L2 緩存或者 CPU 的寄存器。
假如線程 A 和 B 同時(shí)去處理一個(gè)共享變量,會出現(xiàn)什么情況呢?
使用上圖 CPU 架構(gòu),假設(shè)線程 A和 B 使用不同 CPU 進(jìn)行去修改共享變量 X,假設(shè) X 的初始化為0,并且當(dāng)前兩級 Cache 都為空的情況,具體看下面分析:
- 假設(shè)線程 A 首先獲取共享變量 X 的值,由于兩級 Cache 都沒有命中,所以到主內(nèi)存加載了 X=0,然后會把 X=0 的值緩存到兩級緩存,假設(shè)線程 A 修改 X 的值為1,然后寫入到兩級 Cache,并且刷新到主內(nèi)存(注:如果沒刷新會主內(nèi)存也會存在內(nèi)存不可見問題)。這時(shí)候線程 A 所在的 CPU 的兩級 Cache 內(nèi)和主內(nèi)存里面 X 的值都是1;
- 然后假設(shè)線程 B 這時(shí)候獲取 X 的值,首先一級緩存沒有命中,然后看二級緩存,二級緩存命中了,所以返回 X=1;然后線程 B 修改 X 的值為2;然后存放到線程2所在的一級 Cache 和共享二級 Cache,最后更新主內(nèi)存值為2;
- 然后假設(shè)線程 A 這次又需要修改 X 的值,獲取時(shí)候一級緩存命中獲取 X=1,到這里問題就出現(xiàn)了,明明線程 B 已經(jīng)把 X 的值修改為了2,為啥線程 A 獲取的還是1呢?這就是共享變量的內(nèi)存不可見問題,也就是線程 B 寫入的值對線程 A 不可見。
那么對于共享變量內(nèi)存不可見問題如何解決呢?Java 中首屈一指的 Synchronized 和 Volatile 關(guān)鍵字就可以解決這個(gè)問題,下面會有講解。
Java 中 Synchronized 關(guān)鍵字
Synchronized 塊是 Java 提供的一種原子性內(nèi)置鎖,Java 中每個(gè)對象都可以當(dāng)做一個(gè)同步鎖的功能來使用,這些 Java 內(nèi)置的使用者看不到的鎖被稱為內(nèi)部鎖,也叫做監(jiān)視器鎖。
線程在進(jìn)入 Synchronized 代碼塊前會自動(dòng)嘗試獲取內(nèi)部鎖,如果這時(shí)候內(nèi)部鎖沒有被其他線程占有,則當(dāng)前線程就獲取到了內(nèi)部鎖,這時(shí)候其它企圖訪問該代碼塊的線程會被阻塞掛起。
拿到內(nèi)部鎖的線程會在正常退出同步代碼塊或者異常拋出后或者同步塊內(nèi)調(diào)用了該內(nèi)置鎖資源的 wait 系列方法時(shí)候釋放該內(nèi)置鎖;內(nèi)置鎖是排它鎖,也就是當(dāng)一個(gè)線程獲取這個(gè)鎖后,其它線程必須等待該線程釋放鎖才能獲取該鎖。
上一節(jié)講了多線程并發(fā)修改共享變量時(shí)候會存在內(nèi)存不可見的問題,究其原因是因?yàn)?Java 內(nèi)存模型中線程操作共享變量時(shí)候會從自己的工作內(nèi)存中獲取而不是從主內(nèi)存獲取或者線程寫入到本地內(nèi)存的變量沒有被刷新會主內(nèi)存。
下面講解下 Synchronized 的一個(gè)內(nèi)存語義,這個(gè)內(nèi)存語義就可以解決共享變量內(nèi)存不可見性問題。
線程進(jìn)入 Synchronized 塊的語義是會把在 Synchronized 塊內(nèi)使用到的變量從線程的工作內(nèi)存中清除,在 Synchronized 塊內(nèi)使用該變量時(shí)候就不會從線程的工作內(nèi)存中獲取了,而是直接從主內(nèi)存中獲取;退出 Synchronized 塊的內(nèi)存語義是會把 Synchronized 塊內(nèi)對共享變量的修改刷新到主內(nèi)存。對應(yīng)上面一節(jié)講解的假如線程在 Synchronized 塊內(nèi)獲取變量 X 的值,那么線程首先會清空所在的 CPU 的緩存,然后從主內(nèi)存獲取變量 X 的值;當(dāng)線程修改了變量的值后會把修改的值刷新回主內(nèi)存。
其實(shí)這也是加鎖和釋放鎖的語義,當(dāng)獲取鎖后會清空本地內(nèi)存中后面將會用到的共享變量,在使用這些共享變量的時(shí)候會從主內(nèi)存進(jìn)行加載;在釋放鎖時(shí)候會刷新本地內(nèi)存中修改的共享變量到主內(nèi)存。
除了可以解決共享變量內(nèi)存可見性問題外,Synchronized 經(jīng)常被用來實(shí)現(xiàn)原子性操作,另外注意,Synchronized 關(guān)鍵字會引起線程上下文切換和線程調(diào)度的開銷。
Java 中 Volatile 關(guān)鍵字
上面介紹了使用鎖的方式可以解決共享變量內(nèi)存可見性問題,但是使用鎖太重,因?yàn)樗鼤鹁€程上下文的切換開銷,對于解決內(nèi)存可見性問題,Java 還提供了一種弱形式的同步,也就是使用了 volatile 關(guān)鍵字。
一旦一個(gè)變量被 volatile 修飾了,當(dāng)線程獲取這個(gè)變量值的時(shí)候會首先清空線程工作內(nèi)存中該變量的值,然后從主內(nèi)存獲取該變量的值;當(dāng)線程寫入被 volatile 修飾的變量的值的時(shí)候,首先會把修改后的值寫入工作內(nèi)存,然后會刷新到主內(nèi)存。這就保證了對一個(gè)變量的更新對其它線程馬上可見。
下面看一個(gè)使用 volatile 關(guān)鍵字解決內(nèi)存不可見性的一個(gè)例子,如下代碼的共享變量 value 是線程不安全的,因?yàn)樗鼪]有進(jìn)行適當(dāng)同步措施。
public class ThreadNotSafeInteger {
private int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
首先看下使用 synchronized 關(guān)鍵字進(jìn)行同步方式如下:
public class ThreadSafeInteger {
private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
然后看下使用 volatile 進(jìn)行同步如下:
public class ThreadSafeInteger {
private volatile int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
這里使用 synchronized 和使用 volatile 是等價(jià)的,都解決了共享變量 value 的內(nèi)存不可見性問題;但是前者是獨(dú)占鎖,同時(shí)只能有一個(gè)線程調(diào)用 get() 方法,其它調(diào)用線程會被阻塞;并且會存在線程上下文切換和線程重新調(diào)度的開銷;而后者是非阻塞算法,不會造成線程上下文切換的開銷。
這里使用 synchronized 和使用 volatile 是等價(jià)的,但是并不是所有情況下都是等價(jià)的,這是因?yàn)?volatile 雖然提供了可見性保證,但是并沒有保證操作的原子性。
那么一般什么時(shí)候才使用 volatile 關(guān)鍵字修飾變量呢?
- 當(dāng)寫入變量值時(shí)候不依賴變量的當(dāng)前值。因?yàn)槿绻蕾嚠?dāng)前值則是獲取 -> 計(jì)算 -> 寫入操作,而這三步操作不是原子性的,而 volatile 不保證原子性。
- 讀寫變量值時(shí)候沒有進(jìn)行加鎖。因?yàn)榧渔i本身已經(jīng)保證了內(nèi)存可見性,這時(shí)候不需要把變量聲明為 volatile。
另外變量被聲明為 volatile 還可以避免重排序的發(fā)生,這個(gè)后面會講到。
Java 中原子性操作
所謂原子性操作是指當(dāng)執(zhí)行一系列操作時(shí)候,這些操作那么全部被執(zhí)行,那么全部不被執(zhí)行,不存在只執(zhí)行其中一部分的情況。
在設(shè)計(jì)計(jì)數(shù)器時(shí)候一般都是先讀取當(dāng)前值,然后+1,然后更新,這個(gè)過程是讀 -> 改 -> 寫的過程,如果不能保證這個(gè)過程是原子性,那么就會出現(xiàn)線程安全問題。如下代碼是線程不安全的,因?yàn)椴荒鼙WC ++value
是原子性操作。
public class ThreadNotSafeCount {
private Long value;
public Long getCount() {
return value;
}
public void inc() {
++value;
}
}
通過使用 Javap -c
查看匯編代碼如下:
public void inc();
Code:
0: aload_0
1: dup
2: getfield #2 // Field value:J
5: lconst_1
6: ladd
7: putfield #2 // Field value:J
10: return
可知簡單的 ++value
有 2,5,6,7 組成,其中2是獲取當(dāng)前 value 的值并放入棧頂,5是把常量1放入棧頂,6是把當(dāng)前棧頂中2個(gè)值相加并把結(jié)果放入棧頂,7則是把棧頂結(jié)果賦值會 value 變量,可知 Java 中簡單的一句 ++value 轉(zhuǎn)換為匯編后就不具有原子性了。
那么如何才能保證多個(gè)操作完成原子性呢,最簡單的是使用 Synchronized 進(jìn)行同步,修改代碼如下:
public class ThreadSafeCount {
private Long value;
public synchronized Long getCount() {
return value;
}
public synchronized void inc() {
++value;
}
}
使用 Synchronized 的確可以實(shí)現(xiàn)線程安全,即實(shí)現(xiàn)內(nèi)存可見性和同步,但是 Synchronized 是獨(dú)占鎖,同時(shí)只有一個(gè)線程可以調(diào)用 getCount 方法,其他沒有獲取內(nèi)部鎖的線程會被阻塞掉;而這里 getCount 方法只是讀操作,多個(gè)線程同時(shí)調(diào)用不會存在線程安全問題,但是加了關(guān)鍵字 Synchronized 后同時(shí)就只能有一個(gè)線程可以調(diào)用了,這顯然大大降低了并發(fā)性。
也許你會問既然是只讀操作那么為何不去掉 getCount 方法上的 Synchronized 關(guān)鍵字呢?其實(shí)是不能去掉的,別忘了這里要靠 Synchronized 的內(nèi)存語義來實(shí)現(xiàn) value 的內(nèi)存可見性。
那么有沒有更好的實(shí)現(xiàn)呢?答案是肯定的,下面會講到的內(nèi)部使用非阻塞 CAS 算法實(shí)現(xiàn)的原子性操作類 AtomicLong 就是不錯(cuò)選擇。
Java 中的 CAS 操作和 AtomicLong 實(shí)現(xiàn)原理
CAS 來源
在 Java 中鎖在并發(fā)處理中占據(jù)了一席之地,但是使用鎖不好的地方是當(dāng)一個(gè)線程沒有獲取到鎖后會被阻塞掛起,這會導(dǎo)致線程上下文的切換和重新調(diào)度的開銷。
Java 中提供了非阻塞的 volatile 關(guān)鍵字來解決共享變量的可見性問題,這在一定程度上彌補(bǔ)了鎖所在帶來的開銷,但是 volatile 只能保證共享變量的可見性問題,但是還是不能解決例如讀 -> 改 -> 寫等的原子性問題。
CAS 即 Compare And Swap,是 JDK 提供的非阻塞原子性操作,它通過硬件保證了比較-更新操作的原子性,JDK 里面的 Unsafe 類提供了一些列的 compareAndSwap*
方法,下面以 compareAndSwapLong 為例進(jìn)行簡單介紹。
- boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update)方法。
compareAndSwap 的意思也就是比較并交換,CAS 有四個(gè)操作數(shù)分別為:對象內(nèi)存位置,對象中的變量的偏移量,變量預(yù)期值 expect,新的值 update。
操作含義是如果對象 obj 中內(nèi)存偏移量為 valueOffset 位置的變量值為 expect 則使用新的值 update 替換舊的值 expect。這個(gè)是處理器提供的一個(gè)原子性指令。
AtomicLong 的原理
并發(fā)包中原子性操作類都有 AtomicInteger,AtomicLong,AtomicBoolean,原理類似,本節(jié)講解下 AtomicLong 類。AtomicLong 是原子性遞增或者遞減類,其內(nèi)部使用 Unsafe 來實(shí)現(xiàn),下面看下代碼:
public class AtomicLong extends Number implements java.io.Serializable {
private static final long serialVersionUID = 1927816293512124184L;
// (1)獲取Unsafe實(shí)例
private static final Unsafe unsafe = Unsafe.getUnsafe();
//(2)存放變量value的偏移量
private static final long valueOffset;
//(3)判斷JVM是否支持Long類型無鎖CAS
static final boolean VM_SUPPORTS_LONG_CAS = VMSupportsCS8();
private static native boolean VMSupportsCS8();
static {
try {
//(4)獲取value在AtomicLong中偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicLong.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
//(5)實(shí)際變量值
private volatile long value;
public AtomicLong(long initialValue) {
value = initialValue;
}
....
}
- 代碼(1)創(chuàng)建了通過 Unsafe.getUnsafe()方式獲取到 Unsafe 類實(shí)例,這里你可能會疑問為何這里能通過 Unsafe.getUnsafe() 方式獲取到 Unsafe 類實(shí)例?其實(shí)這是因?yàn)?AtomicLong 類也是在 rt.jar 包里面,AtomicLong 類的加載就是通過 BootStarp 類加載器進(jìn)行加載的(關(guān)于 Unsafe 后面高級篇會具體講解,這里先了解)
- 代碼(5)中 value 聲明為 volatile 是為了多線程下保證內(nèi)存可見性,value 是具體存放計(jì)數(shù)的變量。
- 代碼(2)(4)獲取 value 變量在 AtomicLong 類中偏移量。
下面重點(diǎn)看下 AtomicLong 中主要函數(shù):
- 遞增和遞減操作代碼。
//(6)調(diào)用unsafe方法,原子性設(shè)置value值為原始值+1,返回值為遞增后的值
public final long incrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
}
//(7)調(diào)用unsafe方法,原子性設(shè)置value值為原始值-1,返回值為遞減之后的值
public final long decrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, -1L) - 1L;
}
//(8)調(diào)用unsafe方法,原子性設(shè)置value值為原始值+1,返回值為原始值
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
//(9)調(diào)用unsafe方法,原子性設(shè)置value值為原始值-1,返回值為原始值
public final long getAndDecrement() {
return unsafe.getAndAddLong(this, valueOffset, -1L);
}
如上代碼內(nèi)部都是調(diào)用 Unsafe 的 getAndAddLong 方法實(shí)現(xiàn),這個(gè)函數(shù)是個(gè)原子性操作,這里第一個(gè)參數(shù)是 AtomicLong 實(shí)例的引用,第二個(gè)參數(shù)是 value 變量在 AtomicLong 中的偏移值,第三個(gè)參數(shù)是要設(shè)置第二個(gè)變量的值。
其中 getAndIncrement 方法在 JDK 7 的實(shí)現(xiàn)邏輯為:
public final long getAndIncrement() {
while (true) {
long current = get();
long next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
如上代碼可知每個(gè)線程是先拿到變量的當(dāng)前值(由于是 value 是 volatile 變量所以這里拿到的是最新的值),然后在工作內(nèi)存對其進(jìn)行增一操作,然后使用 CAS 修改變量的值,如果設(shè)置失敗,則循環(huán)繼續(xù)嘗試,直到設(shè)置成功。
而 JDK 8 邏輯為:
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
其中JDK8中unsafe.getAndAddLong代碼為:
public final long getAndAddLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
{
l = getLongVolatile(paramObject, paramLong1);
} while (!compareAndSwapLong(paramObject, paramLong1, l, l + paramLong2));
return l;
}
可知 JDK 7 的 AtomicLong 中的循環(huán)邏輯已經(jīng)被 JDK 8 的原子操作類 UNsafe 內(nèi)置了,之所以內(nèi)置應(yīng)該是考慮到這種函數(shù)會在其它地方也會用到,內(nèi)置可以提高復(fù)用性。
- boolean compareAndSet(long expect, long update)方法
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
如上代碼可知道內(nèi)部還是調(diào)用了 unsafe.compareAndSwapLong 方法。如果原子變量中 value 的值等于 expect 則使用 update 值更新該值并返回 true,否者返回 false。
下面通過一個(gè)多線程使用 AtomicLong 統(tǒng)計(jì)0的個(gè)數(shù)的例子來加深對 AtomicLong 的理解:
/**
統(tǒng)計(jì)0的個(gè)數(shù)
*/
public class Atomic
{
//(10)創(chuàng)建Long型原子計(jì)數(shù)器
private static AtomicLong atomicLong = new AtomicLong();
//(11)創(chuàng)建數(shù)據(jù)源
private static Integer[] arrayOne = new Integer[]{0,1,2,3,0,5,6,0,56,0};
private static Integer[] arrayTwo = new Integer[]{10,1,2,3,0,5,6,0,56,0};
public static void main( String[] args ) throws InterruptedException
{
//(12)線程one統(tǒng)計(jì)數(shù)組arrayOne中0的個(gè)數(shù)
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
int size = arrayOne.length;
for(int i=0;i<size;++i){
if(arrayOne[i].intValue() == 0){
atomicLong.incrementAndGet();
}
}
}
});
//(13)線程two統(tǒng)計(jì)數(shù)組arrayTwo中0的個(gè)數(shù)
Thread threadTwo = new Thread(new Runnable() {
@Override
public void run() {
int size = arrayTwo.length;
for(int i=0;i<size;++i){
if(arrayTwo[i].intValue() == 0){
atomicLong.incrementAndGet();
}
}
}
});
//(14)啟動(dòng)子線程
threadOne.start();
threadTwo.start();
//(15)等待線程執(zhí)行完畢
threadOne.join();
threadTwo.join();
System.out.println("count 0:" + atomicLong.get());
}
}
輸出結(jié)果:count 0:7。
如上代碼兩個(gè)線程各自統(tǒng)計(jì)自己所在數(shù)據(jù)中0的個(gè)數(shù),每當(dāng)找到一個(gè)0就會調(diào)用 AtomicLong 的原子性遞增方法。
注:在沒有原子類的情況下例如最開始一節(jié)中自己做計(jì)數(shù)器的話,需要使用一定的同步措施,比如使用 Synchronized 關(guān)鍵字等,但是這些都是阻塞算法,對性能有一定損耗,而本節(jié)介紹的這些原子操作類都是使用 CAS 非阻塞算法,性能會更好。但是在高并發(fā)情況下 AtomicLong 還是會存在性能問題,后期高級篇會講到 JDK 8 中提供了一個(gè)在高并發(fā)下性能更好的 LongAdder 類。
偽共享
什么是偽共享
計(jì)算機(jī)系統(tǒng)中為了解決主內(nèi)存與 CPU 運(yùn)行速度的差距,在 CPU 與主內(nèi)存之間添加了一級或者多級高速緩沖存儲器(Cache),這個(gè) Cache 一般是集成到 CPU 內(nèi)部的,所以也叫 CPU Cache,如下圖是兩級 Cache 結(jié)構(gòu):
Cache 內(nèi)部是按行存儲的,其中每一行稱為一個(gè) Cache 行,Cache 行是 Cache 與主內(nèi)存進(jìn)行數(shù)據(jù)交換的單位,Cache 行的大小一般為2的冪次數(shù)字節(jié)。
當(dāng) CPU 訪問某一個(gè)變量時(shí)候,首先會去看 CPU Cache 內(nèi)是否有該變量,如果有則直接從中獲取,否者就去主內(nèi)存里面獲取該變量,然后把該變量所在內(nèi)存區(qū)域的一個(gè) Cache 行大小的內(nèi)存拷貝到 Cache(Cache 行是 Cache 與主內(nèi)存進(jìn)行數(shù)據(jù)交換的單位)。
由于存放到 Cache 行的的是內(nèi)存塊而不是單個(gè)變量,所以可能會把多個(gè)變量存放到了一個(gè) Cache 行。當(dāng)多個(gè)線程同時(shí)修改一個(gè)緩存行里面的多個(gè)變量時(shí)候,由于同時(shí)只能有一個(gè)線程操作緩存行,所以相比每個(gè)變量放到一個(gè)緩存行性能會有所下降,這就是偽共享。
如上圖變量 x,y 同時(shí)被放到了 CPU 的一級和二級緩存,當(dāng)線程1使用 CPU 1對變量 x 進(jìn)行更新時(shí)候,首先會修改 CPU 1 的一級緩存變量 x 所在緩存行,這時(shí)候緩存一致性協(xié)議會導(dǎo)致 CPU 2 中變量 x 對應(yīng)的緩存行失效。
那么線程2寫入變量 x 的時(shí)候就只能去二級緩存去查找,這就破壞了一級緩存,而一級緩存比二級緩存更快,這里也說明了多個(gè)線程不可能同時(shí)去修改自己所使用的 CPU 中緩存行中相同緩存行里面的變量。更壞的情況下如果 CPU 只有一級緩存,那么會導(dǎo)致頻繁的直接訪問主內(nèi)存。
為何會出現(xiàn)偽共享
偽共享的產(chǎn)生是因?yàn)槎鄠€(gè)變量被放入了一個(gè)緩存行,并且多個(gè)線程同時(shí)去寫入緩存行中不同變量。那么為何多個(gè)變量會被放入一個(gè)緩存行那。其實(shí)是因?yàn)?Cache 與內(nèi)存交換數(shù)據(jù)的單位就是 Cache 行,當(dāng) CPU 要訪問的變量沒有在 Cache 命中時(shí)候,根據(jù)程序運(yùn)行的局部性原理會把該變量在內(nèi)存中大小為 Cache 行的內(nèi)存放如緩存行。
long a;
long b;
long c;
long d;
如上代碼,聲明了四個(gè) long 變量,假設(shè) Cache 行的大小為32個(gè)字節(jié),那么當(dāng) CPU 訪問變量 a 時(shí)候發(fā)現(xiàn)該變量沒有在 Cache 命中,那么就會去主內(nèi)存把變量 a 以及內(nèi)存地址附近的 b、c、d 放入緩存行。
也就是地址連續(xù)的多個(gè)變量才有可能會被放到一個(gè)緩存行中,當(dāng)創(chuàng)建數(shù)組時(shí)候,數(shù)組里面的多個(gè)元素就會被放入到同一個(gè)緩存行。那么單線程下多個(gè)變量放入緩存行對性能有影響?其實(shí)正常情況下單線程訪問時(shí)候由于數(shù)組元素被放入到了一個(gè)或者多個(gè) Cache 行對代碼執(zhí)行是有利的,因?yàn)閿?shù)據(jù)都在緩存中,代碼執(zhí)行會更快,可以對比下面代碼執(zhí)行:
代碼(1):
public class TestForContent {
static final int LINE_NUM = 1024;
static final int COLUM_NUM = 1024;
public static void main(String[] args) {
long [][] array = new long[LINE_NUM][COLUM_NUM];
long startTime = System.currentTimeMillis();
for(int i =0;i<LINE_NUM;++i){
for(int j=0;j<COLUM_NUM;++j){
array[i][j] = i*2+j;
}
}
long endTime = System.currentTimeMillis();
long cacheTime = endTime - startTime;
System.out.println("cache time:" + cacheTime);
}
}
代碼(2):
public class TestForContent2 {
static final int LINE_NUM = 1024;
static final int COLUM_NUM = 1024;
public static void main(String[] args) {
long [][] array = new long[LINE_NUM][COLUM_NUM];
long startTime = System.currentTimeMillis();
for(int i =0;i<COLUM_NUM;++i){
for(int j=0;j<LINE_NUM;++j){
array[j][i] = i*2+j;
}
}
long endTime = System.currentTimeMillis();
System.out.println("no cache time:" + (endTime - startTime));
}
}
我 Mac 電腦上執(zhí)行代碼(1)多次耗時(shí)均在10ms一下,執(zhí)行代碼(2)多次耗時(shí)均在10ms以上。
總的來說代碼(1)比代碼(2)執(zhí)行的快,這是因?yàn)閿?shù)組內(nèi)數(shù)組元素之間內(nèi)存地址是連續(xù)的,當(dāng)訪問數(shù)組第一個(gè)元素時(shí)候,會把第一個(gè)元素后續(xù)若干元素一塊放入到 Cache 行,這樣順序訪問數(shù)組元素時(shí)候會在 Cache 中直接命中,就不會去主內(nèi)存讀取,后續(xù)訪問也是這樣。
總結(jié)下也就是當(dāng)順序訪問數(shù)組里面元素時(shí)候,如果當(dāng)前元素在 Cache 沒有命中,那么會從主內(nèi)存一下子讀取后續(xù)若干個(gè)元素到 Cache,也就是一次訪問內(nèi)存可以讓后面多次直接在 Cache 命中。而代碼(2)是跳躍式訪問數(shù)組元素的,而不是順序的,這破壞了程序訪問的局部性原理,并且 Cache是有容量控制的,Cache 滿了會根據(jù)一定淘汰算法替換 Cache 行,會導(dǎo)致從內(nèi)存置換過來的 Cache 行的元素還沒等到讀取就被替換掉了。
所以單個(gè)線程下順序修改一個(gè) Cache 行中的多個(gè)變量,是充分利用了程序運(yùn)行局部性原理,會加速程序的運(yùn)行,而多線程下并發(fā)修改一個(gè) Cache 行中的多個(gè)變量而就會進(jìn)行競爭 Cache 行,降低程序運(yùn)行性能。
如何避免偽共享
JDK 8 之前一般都是通過字節(jié)填充的方式來避免,也就是創(chuàng)建一個(gè)變量的時(shí)候使用填充字段填充該變量所在的緩存行,這樣就避免了多個(gè)變量存在同一個(gè)緩存行,如下代碼:
public final static class FilledLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
假如 Cache 行為64個(gè)字節(jié),那么我們在 FilledLong 類里面填充了6個(gè) long 類型變量,每個(gè) long 類型占用8個(gè)字節(jié),加上 value 變量的8個(gè)字節(jié)總共56個(gè)字節(jié),另外這里 FilledLong 是一個(gè)類對象,而類對象的字節(jié)碼的對象頭占用了8個(gè)字節(jié),所以當(dāng) new 一個(gè) FilledLong 對象時(shí)候?qū)嶋H會占用64個(gè)字節(jié)的內(nèi)存,這個(gè)正好可以放入 Cache 的一個(gè)行。
在 JDK 8 中提供了一個(gè) sun.misc.Contended 注解,用來解決偽共享問題,上面代碼可以修改為如下:
@sun.misc.Contended
public final static class FilledLong {
public volatile long value = 0L;
}
上面是修飾類的,當(dāng)然也可以修飾變量,比如 Thread 類中的使用:
/** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
Thread 類里面這三個(gè)變量是在 ThreadLocalRandom(Chat:《Java 并發(fā)編程之美:并發(fā)編程高級篇之一》中對其進(jìn)行了講解)中為了實(shí)現(xiàn)高并發(fā)下高性能生成隨機(jī)數(shù)時(shí)候使用的,這三個(gè)變量默認(rèn)是初始化為0。
需要注意的是默認(rèn)情況下 @Contended 注解只用到 Java 核心類,比如 rt 包下的類,如果需要在用戶 classpath 下的類使用這個(gè)注解需要添加 JVM 參數(shù):-XX:-RestrictContended
,另外默認(rèn)填充的寬度為128,如果你想要自定義寬度可以設(shè)置 -XX:ContendedPaddingWidth
參數(shù)。
注:本節(jié)講述了偽共享如何產(chǎn)生,以及如何避免,并證明多線程下訪問同一個(gè) Cache 行的多個(gè)的變量時(shí)候才會出現(xiàn)偽共享,當(dāng)單個(gè)線程訪問一個(gè) Cache 行里面的多個(gè)變量時(shí)候反而對程序運(yùn)行起到加速作用。這里為后面高級篇講解 LongAdder 的實(shí)現(xiàn)提供了基礎(chǔ)。
Java 中的指令重排序
Java 內(nèi)存模型允許編譯器和處理器對指令進(jìn)行重排序以提高運(yùn)行性能,并且重排序只會對不存在數(shù)據(jù)依賴性的指令進(jìn)行重排序;在單線程下重排序可以保證最終執(zhí)行的結(jié)果是與程序順序執(zhí)行的結(jié)果一致,但是在多線程下就會存在問題。
下面看一個(gè)例子
int a = 1;//(1)
int b = 2;//(2)
int c= a + b;//(3)
如上代碼變量 c 的值依賴 a 和 b 的值,所以重排序后能夠保證(3)的操作在(2)(1)之后,但是(1)(2)誰先執(zhí)行就不一定了,這在單線程下不會存在問題,因?yàn)椴⒉挥绊懽罱K結(jié)果。
下面看一個(gè)多線程的例子:
public static class ReadThread extends Thread {
public void run() {
while(!Thread.currentThread().isInterrupted()){
if(ready){//(1)
System.out.println(num+num);//(2)
}
System.out.println("read thread....");
}
}
}
public static class Writethread extends Thread {
public void run() {
num = 2;//(3)
ready = true;//(4)
System.out.println("writeThread set over...");
}
}
private static int num =0;
private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
ReadThread rt = new ReadThread();
rt.start();
Writethread wt = new Writethread();
wt.start();
Thread.sleep(10);
rt.interrupt();
System.out.println("main exit");
}
首先這段代碼里面的變量沒有聲明為 volatile 也沒有使用任何同步措施,所以多線程下存在共享變量內(nèi)存可見性問題,這里先不談內(nèi)存可見性問題,因?yàn)橥ㄟ^把變量聲明為 volatile 本身就可以避免指令重排序問題。
這里先看看指令重排序會造成什么影響,如上代碼不考慮內(nèi)存可見性問題的情況下 程序一定會輸出4?答案是不一定,由于代碼(1)(2)(3)(4)之間不存在依賴,所以寫線程的代碼(3)(4)可能被重排序?yàn)橄葓?zhí)行(4)在執(zhí)行(3),那么執(zhí)行(4)后,讀線程可能已經(jīng)執(zhí)行了(1)操作,并且在(3)執(zhí)行前開始執(zhí)行(2)操作,這時(shí)候打印結(jié)果為0而不是4。
這就是重排序在多線程下導(dǎo)致程序執(zhí)行結(jié)果不是我們想要的了,這里使用 volatile 修飾 ready 可以避免重排序和內(nèi)存可見性問題。
當(dāng)寫 volatile 變量時(shí)候,可以確保 volatile 寫之前的操作不會被編譯器重排序到 volatile 寫之后。
當(dāng)讀 volatile 讀變量時(shí)候,可以確保 volatile 讀之后的操作不會被編譯器重排序到 volatile 讀之前。
鎖的概述
樂觀鎖與悲觀鎖
樂觀鎖和悲觀鎖是在數(shù)據(jù)庫中使用的名詞,本節(jié)這里也提下。
悲觀鎖
悲觀鎖指對數(shù)據(jù)被外界修改持保守態(tài)度,在整個(gè)數(shù)據(jù)處理過程中,將數(shù)據(jù)處于鎖定狀態(tài)。悲觀鎖的實(shí)現(xiàn),往往依靠數(shù)據(jù)庫提供的鎖機(jī)制,數(shù)據(jù)庫中實(shí)現(xiàn)是對數(shù)據(jù)記錄操作前給記錄加排它鎖。如果獲取鎖失敗,則說明數(shù)據(jù)正在被其它線程修改,則等待或者拋出異常。如果加鎖成功,則獲取記錄,對其修改,然后事務(wù)提交后釋放排它鎖。
使用悲觀鎖的一個(gè)常用的例子: select * from 表 where .. for update;
。
樂觀鎖
樂觀鎖是相對悲觀鎖來說的,它認(rèn)為數(shù)據(jù)一般情況下不會造成沖突,所以在訪問記錄前不會加排它鎖,而是在數(shù)據(jù)進(jìn)行提交更新的時(shí)候,才會正式對數(shù)據(jù)的沖突與否進(jìn)行檢測。具體說是根據(jù) update 返回的行數(shù)讓用戶決定如何去做。
例如: update 表 set comment='***',status='operator',version=version+1 where version = 1 and id = 1;
樂觀鎖并不會使用數(shù)據(jù)庫提供的鎖機(jī)制,一般在表添加 version 字段或者使用業(yè)務(wù)狀態(tài)來做。樂觀鎖直到提交的時(shí)候才去鎖定,所以不會產(chǎn)生任何鎖和死鎖。
公平鎖與非公平鎖
根據(jù)線程獲取鎖的搶占機(jī)制鎖可以分為公平鎖和非公平鎖,公平鎖表示線程獲取鎖的順序是按照線程請求鎖的時(shí)間長短來分決定的的,也就是最早獲取鎖的線程將最早獲取到鎖,也就是先來先得的 FIFO 順序。而非公平鎖則運(yùn)行時(shí)候闖入,也就是先來不一定先得。
ReentrantLock 提供了公平和非公平鎖的實(shí)現(xiàn):
- 公平鎖:
ReentrantLock pairLock = new ReentrantLock(true);
- 非公平鎖:
ReentrantLock pairLock = new ReentrantLock(false);
如果構(gòu)造函數(shù)不傳遞參數(shù),則默認(rèn)是非公平鎖。
具體來說假設(shè)線程 A 已經(jīng)持有了鎖,這時(shí)候線程 B 請求該鎖將會被掛起,當(dāng)線程 A 釋放鎖后,假如當(dāng)前有線程 C 也需要獲取該鎖,如果采用非公平鎖方式,則根據(jù)線程調(diào)度策略線程 B 和 C 兩者之一可能獲取鎖,這時(shí)候不需要任何其它干涉,如果使用公平鎖則需要把 C 掛起,讓 B 獲取當(dāng)前鎖。
在沒有公平性需求的前提下盡量使用非公平鎖,因?yàn)楣芥i會帶來性能開銷。
獨(dú)占鎖與共享鎖
根據(jù)鎖只能被單個(gè)線程持有還是能被多個(gè)線程共同持有,鎖分為獨(dú)占鎖和共享鎖。
獨(dú)占鎖保證任何時(shí)候都只有一個(gè)線程能得到鎖,ReentrantLock 就是以獨(dú)占方式實(shí)現(xiàn)的。共享鎖則同時(shí)有多個(gè)線程可以持有,例如 ReadWriteLock 讀寫鎖,它允許一個(gè)資源可以被多線程同時(shí)進(jìn)行讀操作。
獨(dú)占鎖是一種悲觀鎖,每次訪問資源都先加上互斥鎖,這限制了并發(fā)性,因?yàn)樽x操作并不會影響數(shù)據(jù)一致性,而獨(dú)占鎖只允許同時(shí)一個(gè)線程讀取數(shù)據(jù),其它線程必須等待當(dāng)前線程釋放鎖才能進(jìn)行讀取。
共享鎖則是一種樂觀鎖,它放寬了加鎖的條件,允許多個(gè)線程同時(shí)進(jìn)行讀操作。
什么是可重入鎖
當(dāng)一個(gè)線程要獲取一個(gè)被其它線程持有的獨(dú)占鎖時(shí)候,該線程會被阻塞,那么當(dāng)一個(gè)線程再次獲取它自己已經(jīng)獲取的鎖時(shí)候是否會被阻塞那?如果不被阻塞,那么我們說該鎖是可重入的,也就是只要該線程獲取了該鎖,那么可以無限制次數(shù)(高級篇我們會知道嚴(yán)格來說是有限次數(shù))進(jìn)入被該鎖鎖住的代碼。
下面看一個(gè)例子看看什么情況下會用可重入鎖。
public class Hello{
public Synchronized void helloA(){
System.out.println("hello");
}
public Synchronized void helloB(){
System.out.println("hello B");
helloA();
}
}
如上面代碼當(dāng)調(diào)用 helloB 函數(shù)前會先獲取內(nèi)置鎖,然后打印輸出,然后調(diào)用 helloA 方法,調(diào)用前會先去獲取內(nèi)置鎖,如果內(nèi)置鎖不是可重入的那么該調(diào)用就會導(dǎo)致死鎖了,因?yàn)榫€程持有并等待了鎖導(dǎo)致調(diào)用 helloA 時(shí)候永遠(yuǎn)不會獲取到鎖。
實(shí)際上 synchronized 內(nèi)部鎖是可重入鎖,可重入鎖的原理是在鎖內(nèi)部維護(hù)了一個(gè)線程標(biāo)示,用來標(biāo)示該鎖目前被那個(gè)線程占用,然后關(guān)聯(lián)一個(gè)計(jì)數(shù)器。一開始計(jì)數(shù)器值為0,說明該鎖沒有被任何線程占用,當(dāng)一個(gè)線程獲取了該鎖,計(jì)數(shù)器會變成1,其它線程在獲取該鎖時(shí)候發(fā)現(xiàn)鎖的所有者不是自己就會被阻塞掛起。
但是當(dāng)獲取該鎖的線程再次獲取鎖時(shí)候發(fā)現(xiàn)鎖擁有者是自己,就會把計(jì)數(shù)器值+1, 當(dāng)釋放鎖后計(jì)數(shù)器會-1,當(dāng)計(jì)數(shù)器為0時(shí)候,鎖里面的線程標(biāo)示重置為 null,這時(shí)候阻塞的線程會獲取被喚醒來競爭獲取該鎖。
總結(jié)
本章主要介紹了并發(fā)編程的基礎(chǔ)知識,為后面高級篇講解并發(fā)包源碼提供了基礎(chǔ),通過圖形結(jié)合講述了為什么要使用多線程編程,多線程編程存在的線程安全問題,以及什么是內(nèi)存可見性問題。然后講解了 synchronized 和 volatile 關(guān)鍵字,并且強(qiáng)調(diào)了前者既保證了內(nèi)存可見性同時(shí)也保證了原子性,而后者則主要做到了內(nèi)存可見性,但是它們的內(nèi)存語義還是很相似的,最后講解的什么是 CAS 和線程間同步以及各種鎖的概念,都為后面講解 JUC 包源碼奠定了基礎(chǔ)。