我們的程序要運行,離不開CPU列林、內存瑞你、IO設備,但是他們三者之間的執(zhí)行速度是有差異的希痴。
CPU 的執(zhí)行速度最快者甲,內存的速度次之,IO設備的速度最慢砌创。
為什么會有內存緩存
CPU 執(zhí)行一條指令非陈哺祝快,但是他從內存中讀取某個數(shù)據(jù)時嫩实,就需要等待很長的時間寇钉,為了彌補速度上的巨大差異,讓 CPU 不被內存拖垮舶赔,所以在 CPU 上增加了緩存。
當 CPU 請求內存中的數(shù)據(jù)時谦秧,會先查看緩存中是否有該數(shù)據(jù)竟纳,如果存在則直接返回該數(shù)據(jù)撵溃;如果不存在,則要先把內存中的數(shù)據(jù)載入緩存锥累,然后再返回給 CPU缘挑。
所以我們的程序在執(zhí)行時,往往就需要將數(shù)據(jù)從內存中讀取出來載入到緩存中桶略,然后進行處理语淘,處理完成之后再將數(shù)據(jù)回寫到內存中去。
除此以外际歼,現(xiàn)代的計算機都是多CPU惶翻、多核的,程序也不再只運行在單一線程中鹅心,而是有多個線程在運行吕粗。
每個線程都會維護一份自己的內存副本,也就是 CPU 緩存旭愧,所以線程之間一定會存在數(shù)據(jù)一致性的問題颅筋。
一般來說,導致并發(fā)問題的根源不外乎以下這幾個原因:
可見性:一個線程對共享變量的修改输枯,另一個線程是否可見议泵?
原子性:一個或多個操作在 CPU 執(zhí)行的過程中是否會被中斷?
有序性:程序編譯后的指令是否會按照代碼原本的順序執(zhí)行桃熄?
遺憾的是先口,以上三個問題的答案都是不確定的,正因為這些不確定所以才會存在并發(fā)下的各種問題蜻拨。
什么是可見性
如果我們的程序是在單個 CPU 上執(zhí)行的池充,那么對于一個變量的原子性操作,無論如何都是不會出現(xiàn)問題的缎讼,不管是由一個線程還是多個線程來操作該變量收夸,對結果都不會造成影響,因為內存的副本只有一個血崭。
在單個 CPU 上操作雖然不會有問題卧惜,但是要強調一點,就是這個操作必須是原子性的夹纫。
比如線程A 設置變量 V 的值為10咽瓷,那線程B獲取到該變量的值就是10,不會出現(xiàn)問題舰讹。
但是我們的程序是不可能只在單個 CPU 上運行的茅姜,而是要在多個 CPU 上運行的,在多個 CPU 上執(zhí)行時月匣,就會出現(xiàn)問題钻洒。
如線程A 在CPU1 中對變量 V 設置了一個新的值奋姿,但是線程B是在 CPU2 中,而 CPU1 對緩存進行了修改素标,并不會通知到 CPU2称诗,所以這時線程B 拿到的變量 V 的值還是原來的老的值,也就是臟數(shù)據(jù)头遭。
所以這就是導致并發(fā)問題的第一個原因寓免,在一個線程中對共享變量的更改,對其他的線程是不可見的计维。
一個不可見性的示例
private static int counter;
private static boolean stop;
private static class Reader implements Runnable {
private int newestCounter;
@Override
public void run() {
while (!stop) {
if (newestCounter != counter) {
newestCounter = counter;
System.out.println("Reader has read a new value=" + newestCounter);
}
}
System.out.println("Reader stopped at:" + System.currentTimeMillis());
}
}
private static class Writer implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
counter = i;
System.out.println("writer has write a new value to counter=" + counter);
// 等待 Reader 去讀取 counter 的變化
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
stop = true;
System.out.println("Writer set stop at:" + System.currentTimeMillis());
}
}
有兩個線程袜香,一個 Reader 線程,一個 Writer 線程享潜,并且有兩個共享變量:counter 和 stop 標志位困鸥。
啟動完兩個線程之后,打印出如下結果:
writer has write a new value to counter=1
Reader has read a new value=1
writer has write a new value to counter=2
writer has write a new value to counter=3
writer has write a new value to counter=4
writer has write a new value to counter=5
Writer set stop at:1553871839283
Writer 線程每隔一秒更新一次 counter 的值剑按, Reader 線程只讀取到第一次 counter 的變化后的值疾就,后面的值變更,都沒有讀取到艺蝴,因為此時 Reader 線程已經將 counter 的值緩存在本地的內存副本中了猬腰, Writer 線程再怎么修改 counter 的值, Reader 線程也不會知道的猜敢,所以說 Writer 線程對于 counter 的修改姑荷,對 Reader 線程是不可見的。
同樣的缩擂, Reader 線程啟動后鼠冕,讀取到 stop 變量的值為 false,在后續(xù) Writer 線程將 stop 的值更新為 true 之后胯盯, Reader 線程也不會感知到懈费,所以該程序會一直運行下去,因為 Reader 線程中的 stop 狀態(tài)永遠是 false博脑。
如果我們將 Writer 線程中的休眠1s的代碼注釋掉憎乙,那么 Reader 線程可能會讀取到 stop 為 true。
為了解決這個問題叉趣,Java 給我們提供了一個 volatile 關鍵字泞边,用來保證可見性。
當一個變量被 volatile 修飾后疗杉,表示著線程本地內存無效阵谚,當一個線程修改共享變量后他會立即被更新到主內存中,當其他線程讀取共享變量時,它會直接從主內存中讀取梢什。
將上述的代碼中 counter 改為如下所示:
private static volatile int counter;
即可返回正確的結果闻牡,Writer 線程每次對 counter 所做的修改,Reader 線程都能感知到绳矩,也就是說 Writer 對變量 counter 做的修改,對 Reader 線程是可見的玖翅。
除了 volatile 可以保證可見性之外翼馆,synchronized 關鍵字和 Lock 都能保證可見性,但是和 volatile 比起來金度,加鎖的方式都太重了应媚,涉及到線程的阻塞與喚醒。
為什么會有線程切換
我們的程序都是由非常多的線程來協(xié)作執(zhí)行的猜极,而具體的執(zhí)行都是給 CPU 下達指令中姜,讓 CPU 去執(zhí)行的。
那么每個線程該怎么使喚 CPU 讓他為自己干活呢跟伏?CPU 又是怎樣接受和處理這么多線程下發(fā)給自己的指令的呢丢胚?
由于 CPU 的執(zhí)行非常快受扳,而線程下發(fā)給他的任務有可能很快就執(zhí)行完了携龟,也可能由于其他的原因導致要執(zhí)行很久。
如果一個任務執(zhí)行的時間很久勘高,是否需要一直占著 CPU 資源呢峡蟋?
那 CPU 肯定不會同意的,CPU 為了更高效的處理各種任務华望,會為每個線程分配一段差不多長的時間用來執(zhí)行他們的任務蕊蝗,當時間用完了之后,就去執(zhí)行其他線程的任務了赖舟,這個時間就稱為 “時間片” 蓬戚,執(zhí)行不同的任務就是線程之間的切換了。
什么是原子性
雖然 CPU 通過時間片和線程切換,提高了程序運行的效率篙耗,但是說到線程切換恃鞋,就可能導致另一種問題。
那么線程切換會在什么時候發(fā)生呢痛单,在 CPU 指令執(zhí)行完成之后的任何時間點都可能發(fā)生線程切換。
所以對于非原子操作就可能劲腿,操作執(zhí)行了一半旭绒,發(fā)生了線程切換,另外的操作沒來得及執(zhí)行,要等到下一個線程切換時挥吵,輪到自己占有 CPU 時重父,才能完成剩下的操作。
但是這樣明顯是有問題的忽匈,你執(zhí)行了一半的操作房午,CPU 到別的地方轉了一圈回來之后,你原本的操作結果很可能就不對了丹允,為什么會不對呢郭厌,因為你在等待 CPU 的這段時間內,很可能有別的線程也執(zhí)行了和你相同的事雕蔽。
我們知道數(shù)據(jù)庫事務中也有原子性的概念折柠,他主要說的是事務中的多個操作,要么全部執(zhí)行批狐,要么全部不執(zhí)行扇售。
但是 Java 中的原子性,并不能保證要么全部執(zhí)行嚣艇,要么全部不執(zhí)行承冰,反而是很可能多個操作只執(zhí)行了一部分。
說了這么多的 “操作”髓废,Java 中的一條語句難道不就是一條 “操作” 嗎巷懈?
Java 中的一條語句還真不一定是一條 “操作”,這里說的 “操作” 是對 CPU 而言的慌洪,指的是一條指令顶燕。
而我們 Java 中的一條語句可能由一條指令組成,也可能由多條指令組成冈爹,操作系統(tǒng)只能保證一條指令的原子性涌攻,也就是要么該條指令執(zhí)行,要么該條指令不執(zhí)行频伤,但是并不能保證多條指令的原子性恳谎。
所以說雖然線程切換解決了性能問題,但是卻帶來了原子性的問題憋肖。
Java 中的自增運算是一個典型的非原子性的操作因痛,為什么這么說呢?
自增運算看似是一條語句岸更,但是實際上需要三條 CPU 指令構成鸵膏,分別是:取值,值加1怎炊,回寫值谭企。
假設我們有一個變量 V廓译,初始值是0,當兩個線程都對變量 V 執(zhí)行自增操作债查,正常情況下非区,我們期望的結果是最終變量 V 的值是2,但是很可能由于縣城切換導致盹廷,最終被更新到內存中的變量的值是1征绸。
線程 A 從內存中獲取到變量 V 的值為0,然后還沒來得及執(zhí)行后續(xù)的指令俄占,就發(fā)生了線程切換歹垫,線程 B 這時從內存中獲取到變量 V 的值也為 0,然后執(zhí)行了后續(xù)的指令颠放,將值加1并把值回寫到了內存中,這時內存中的變量 V 的值為1吭敢。
然后又發(fā)生了線程切換碰凶,線程 A 重新獲得了 CPU 資源,繼續(xù)執(zhí)行未完成的指令鹿驼,最終的也將變量 V 的值更新為1欲低,然后寫入到了內存中。
整個過程由于發(fā)生了線程切換畜晰,導致非原子性的操作的結果出現(xiàn)了問題砾莱,事實上只要線程 A 在執(zhí)行玩第一步或者第二步指令之后發(fā)生了線程切換,都會導致問題的發(fā)生凄鼻。
而當線程 A 在執(zhí)行完了第三步指令之后腊瑟,再發(fā)生線程切換的話,則不會出現(xiàn)問題块蚌,原因是第三步指令執(zhí)行完之后闰非,內存中的變量值已經更新為最新值了,即便發(fā)生了線程切換峭范,其他線程也會從內存中獲取到最新的值财松。當然啦,假如第三步指令都執(zhí)行完了纱控,那整個過程就相當于是一個原子性的過程了辆毡,那就不存在由于線程切換而導致的問題了。
一個非原子性的示例
private int increment = 10000;
private int unsafeCounter = 0;
private void unsafeIncrease() {
int idx = 0;
while (idx++ < increment) {
unsafeCounter++;
}
}
// 多個線程執(zhí)行不安全的非原子性操作
Runnable runnable = new Runnable() {
@Override
public void run() {
unsafeIncrease();
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("unsafeCounter=" + unsafeCounter);
執(zhí)行上述代碼之后甜害,你會發(fā)現(xiàn)舶掖,unsafeCounter 的值是一個1000~2000之間的數(shù)字。
一個原子性的示例
private int increment = 10000;
private AtomicInteger safeCounter = new AtomicInteger(0);
private void safeIncrease() {
int idx = 0;
while (idx++ < increment) {
safeCounter.incrementAndGet();
}
}
// 多個線程執(zhí)行安全的原子性操作
Runnable runnable = new Runnable() {
@Override
public void run() {
safeIncrease();
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("safeCounter=" + safeCounter);
執(zhí)行上述代碼之后唾那,你會發(fā)現(xiàn)访锻,safeCounter 的值確實是2000褪尝。
為什么使用 AtomicInteger 就能保證原子性呢,這些 Atomic* 開頭的類都是為了解決原子性的問題而存在的期犬,為什么他們就能保證原子性呢河哑,原因是他們底層是通過 CAS 實現(xiàn)的。
通過 CAS 來設置某個變量的值時龟虎,會先檢查該變量內存中的值是否與當前期望的值一致璃谨,如果發(fā)現(xiàn)不一致則會重新獲取內存中的最新值,直到內存中的值與當前期望的值一致時鲤妥,才將最新的值更新到內存中去佳吞,所以整個過程是原子性的。
復合原子操作是不是原子性的
現(xiàn)在我們知道了一個操作必須是原子性的才能保證在并發(fā)的情況下不出問題棉安,具體可以使用原子類 Atomic* 來代替原始的變量底扳。
但是 Atomic* 能否保證永遠不出問題呢?
答案是不會贡耽,只要使用的不正確衷模,Atomic* 也會出現(xiàn)問題,例如下面的代碼:
private int[] nodes = new int[]{1, 2};
private AtomicInteger nodeIndex = new AtomicInteger(0);
private void unsafeAtomic() {
int i = 0;
while (i++ < 100) {
// 獲取當前節(jié)點的索引蒲赂,并將索引加1
int value = nodes[nodeIndex.getAndIncrement()];
// 如果索引值等于節(jié)點的長度了阱冶,則設置為0
nodeIndex.compareAndSet(nodes.length, 0);
System.out.println("Thread=" + Thread.currentThread().getName() + " current node value=" + value);
}
}
上述代碼是模擬輪詢獲取可用節(jié)點的功能,假設有兩個節(jié)點滥嘴,我們希望在多線程下能夠交替返回每一個節(jié)點給調用方木蹬,這樣可以做到負載均衡。
但是上述代碼無法做到交替返回若皱,原因是 getAndIncreament() 和 compareAndSet() 方法雖然都是原子操作镊叁,但是他們放在一起作為一個復合操作就不是原子的了。
為什么會有重排序
編譯器或運行時環(huán)境為了優(yōu)化程序性能走触,通常會對指令進行重新排序意系,所以重排序分兩種,分別是編譯期重排序和運行期重排序饺汹。
對于我們程序員來說蛔添,不要假設指令執(zhí)行的順序,因為我們無法預知不同線程之間的指令會以何種順序執(zhí)行兜辞。
java 會為了提升程序的性能迎瞧,將指令進行重排,這又是一種導致并發(fā)環(huán)境下可能出錯的情況逸吵。
什么是有序性
在程序執(zhí)行過程中凶硅,按照代碼的順序先后執(zhí)行,這就是有序性扫皱,但是通過上面的介紹我們知道足绅,不采取措施的話有序性是無法保證的捷绑。
因為我們寫的代碼,在編譯期就已經發(fā)生了變化氢妈,而在最終執(zhí)行時也可能發(fā)生變化粹污,如果我們進行干涉的話,執(zhí)行的結果很可能會發(fā)生不可預知的變化首量。
一個有序性的示例
一個最經典的有序性的問題就是壮吩,獲取單例對象時,通過雙重檢查來保證對象只創(chuàng)建了一次加缘,具體代碼如下:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上述的代碼乍看上去是沒有問題的鸭叙,如果不是指令重排序的話,也確實不會出現(xiàn)問題拣宏,但正是由于重排序的原因導致返回的單例對象可能出現(xiàn)問題沈贝。
線程A來獲取單例對象,這時發(fā)現(xiàn)instance==null勋乾,所以就進入了加鎖創(chuàng)建單例對象的代碼塊缀程。
本來正常情況下,創(chuàng)建了一個對象然后返回就可以了市俊,但是因為重排序的原因,創(chuàng)建對象的過程被重排序了:
正常應該是先初始化對象滤奈,然后再將分配好的內存指向該對象摆昧,但是重排序后的結果變成了,先將分配好的內存指向了對象蜒程,然后再初始化對象绅你。
問題就出在這里,當將分配好的內存指向該對象后昭躺,如果發(fā)生了線程切換忌锯,線程B來獲取單例對象時,發(fā)現(xiàn)單例對象已經不為空了领炫,所以直接就拿該對象去操作了偶垮,但是該對象并沒有進行過初始化,所以線程B后續(xù)再執(zhí)行時就會出現(xiàn)空指針的問題帝洪。
為了解決重排序的問題似舵,需要我們寫代碼時進行人為干預,具體怎么干預呢葱峡?那就是通過 volatile 關鍵字砚哗,可是上面我們剛說了 volatile 是解決可見性的問題的啊。
沒錯 volatile 除了可以解決可見性問題砰奕,也可以解決有序性的問題蛛芥,通過 volatile 修飾的變量提鸟,編譯器和運行時環(huán)境不會對他進行指令重排。
并發(fā)問題是怎樣造成的
通過上面的分析仅淑,我們知道了造成并發(fā)問題的原因了称勋,這些都是操作系統(tǒng)或者編譯期為了提升性能而做了一些努力,但是為了享受到這些性能上的優(yōu)勢漓糙,我們就得付出更多的代價來寫出復雜的代碼铣缠。
換句話說,硬件上為了最求卓越的性能昆禽,而忽略了軟件實現(xiàn)上的復雜度蝗蛙,相當于硬件工程師給軟件工程師挖了一個坑。
CPU上的高速緩存造成了多線程下共享變量的可見性問題醉鳖,可以通過 volatile 或加鎖的方式來解決捡硅。
線程切換造成了多線程下原子性的問題,可以通過原子類或加鎖的方式來解決盗棵。
編譯器或者運行環(huán)境為了優(yōu)化程序性能造成了有序性的問題壮韭,可以通過 volatile 禁止指令重排。