學(xué)妹問(wèn)我,并發(fā)問(wèn)題的根源到底是什么?
并發(fā)編程是 java 高級(jí)程序員的必備的基礎(chǔ)技能之一。但是想要寫(xiě)好并發(fā)程序并非易事。
那究竟是什么原因?qū)е麓蟀训?ldquo;格子衫”朋友無(wú)法寫(xiě)出優(yōu)質(zhì)和性能穩(wěn)定的并發(fā)程序呢?根本原因就是大家對(duì)并發(fā)編程的核心理論的模糊和不理解。想要運(yùn)用好一項(xiàng)技術(shù)。理論知識(shí)和核心概念是一定要理解透徹的。
今天我們就來(lái)一起看下并發(fā)編程三大核心基礎(chǔ)理論:原子性、可見(jiàn)性、有序性
1、原子性
先來(lái)看下什么叫原子性
第一種理解:原子(atomic)本意是“不能被進(jìn)一步分割的最小粒子”,而原子操作(atomic operation)意 為“不可被中斷的一個(gè)或一系列操作”
第二種理解:原子性,即一個(gè)操作或多個(gè)操作,要么全部執(zhí)行并且在執(zhí)行的過(guò)程中不被打斷,要么全部不執(zhí)行。(提供了互斥訪問(wèn),在同一時(shí)刻只有一個(gè)線程進(jìn)行訪問(wèn))
原子,在物理學(xué)中定義是組成物體的不可分割的最小的單位。在 java 并發(fā)編程中我們可以將其理解為:一組要么成功要么失敗的操作。
1.1、原子性問(wèn)題的產(chǎn)生的原因
原子性問(wèn)題產(chǎn)生的根本原因是什么?我們只要知道了癥狀才能準(zhǔn)確的對(duì)癥下藥,本小節(jié),我們就來(lái)一起探討下原子性問(wèn)題的由來(lái)。
我們都知道,程序在執(zhí)行的時(shí)候,一定是以線程為單位在執(zhí)行的,因?yàn)榫€程是 CPU 進(jìn)行任務(wù)調(diào)度的基本單位。
電腦的 CPU 會(huì)根據(jù)不同的任務(wù)調(diào)度算法去執(zhí)行線程的調(diào)度,將時(shí)間分片并派分給各個(gè)線程。
當(dāng)某個(gè)線程獲得CPU的時(shí)間片之后就獲取了CPU的執(zhí)行權(quán),就可以執(zhí)行任務(wù),當(dāng)時(shí)間片耗盡之后,就會(huì)失去CPU使用權(quán)。
進(jìn)而本任務(wù)會(huì)暫時(shí)的停止執(zhí)行。多線程場(chǎng)景下,由于時(shí)間片在線程間輪換,就會(huì)發(fā)生原子性問(wèn)題。
看完理論似乎并不能直觀的理解原子性問(wèn)題。下面我們就通過(guò)代碼的方式來(lái)具體闡述下原子性問(wèn)題的產(chǎn)生原因。
1.2、案例分析
我們以常見(jiàn)的 i++ 為例,這是一個(gè)老生常談的原子性問(wèn)題了,先來(lái)看下代碼
- public class AtomicDemo {
- private int count = 0;
- public void add() {
- count++;
- }
- public int get() {
- return count;
- }
- public static void main(String[] args) throws InterruptedException {
- CountDownLatch countDownLatch = new CountDownLatch(100);
- AtomicDemo atomicDemo = new AtomicDemo();
- IntStream.rangeClosed(0, 100).forEach(item -> {
- new Thread(() -> {
- IntStream.rangeClosed(1, 100).forEach(i -> {
- atomicDemo.add();
- });
- }).start();
- countDownLatch.countDown();
- });
- countDownLatch.await();
- System.out.println(atomicDemo.get());
- }
- }
上面 代碼的作用是將初始值為0的 count 變量,通過(guò)100線程每個(gè)線程累加100次的方式來(lái)累加。想要得到一個(gè)結(jié)果為 10000 的值。但是實(shí)際上結(jié)果很難達(dá)到10000。
產(chǎn)生這個(gè)問(wèn)題的原因:
count++ 的執(zhí)行實(shí)際上這個(gè)操作不是原子性的,因?yàn)?count++ 會(huì)被拆分成以下三個(gè)步驟執(zhí)行(這樣的步驟不是虛擬的,而是真實(shí)情況就是這么執(zhí)行的)
第一步:讀取 count 的值;
第二步:計(jì)算 +1 的結(jié)果;
第三步:將 +1 的結(jié)果賦值給 count變量
那問(wèn)題又來(lái)了。分三步又咋樣?讓他執(zhí)行完不就行了?
理論上是這樣子的,大家都很友好,你執(zhí)行完我執(zhí)行,我執(zhí)行完你繼續(xù)。你想象的可能是這樣的”烏托邦圖“
image-20210430131612018
但是實(shí)際上這些線程已經(jīng)”黑化”了。他們絕不可能互相謙讓。CPU或者是程序的世界觀里面。大家做任何事情都是在”爭(zhēng)搶“。我們來(lái)看下面這張圖:
上圖詳細(xì)分析:
第一步:A線程從主內(nèi)存中讀取 count 的值 0;
第二步:A線程開(kāi)始對(duì) count 值進(jìn)行累加;
第三步:B線程從主內(nèi)存中讀取 count 的值 0(PS:具體第三步從哪里開(kāi)始都不是重點(diǎn),重點(diǎn)是:A線程將 count 值寫(xiě)入主內(nèi)存之前 B 線程就開(kāi)始讀取 count 并執(zhí)行。此時(shí) B線程 讀取到的 count 值依舊是還未被操作過(guò)的原始值);
第四步:(PS:到這里其實(shí)已經(jīng)不重要了。因?yàn)椴还?A線程和B線程現(xiàn)在怎么操作。結(jié)果已經(jīng)不可逆轉(zhuǎn),已經(jīng)錯(cuò)了)B線程開(kāi)始對(duì) count 值進(jìn)行累加;
第五步:A 線程將累加后的結(jié)果賦值給 count 結(jié)果為 1;
第六步:B 線程將累加后的結(jié)果賦值給 count 結(jié)果為 1;
第七步:A 線程將結(jié)果 count =1 刷回到主內(nèi)存;
第八步:B 線程將結(jié)果 count =1 刷回到主內(nèi)存;
相信大家此時(shí)已經(jīng)非常清晰地分析出了原子性產(chǎn)生的根本原因了。
至于解決方案可以通過(guò)鎖或者是 CAS 的方式。具體方案就不再這里贅述了。
2、可見(jiàn)性
萬(wàn)丈高樓平地起,再?gòu)?fù)雜的技術(shù)我們也需要從基本的概念看起來(lái):
可見(jiàn)性:一個(gè)線程對(duì)共享變量的修改,另外一個(gè)線程能夠立刻看到,我們稱為可見(jiàn)性。
2.1、可見(jiàn)性問(wèn)題產(chǎn)生的原因
在很多年前,那個(gè)嫁妝只需要一個(gè)手電筒的年代你或許還不會(huì)出現(xiàn)可見(jiàn)性這樣的問(wèn)題,因?yàn)榇蠹叶际菃魏颂幚砥鳎淮嬖诓l(fā)的情況。
而對(duì)于現(xiàn)在“視金錢(qián)如糞土”的年代。多核處理器已經(jīng)是現(xiàn)代超級(jí)計(jì)算機(jī)的基礎(chǔ)硬件。高速的CPU處理器和緩慢的內(nèi)存之前數(shù)據(jù)的通信成了矛盾。
所以為了解決和緩和這樣的情況,每個(gè)CPU和線程都有自己的本地緩存,所謂本地緩存即該緩存僅僅對(duì)它所在的處理器可見(jiàn),CPU緩存與內(nèi)存的數(shù)據(jù)不容易保證一致。
為了避免這種因?yàn)閷?xiě)數(shù)據(jù)速度不一致而導(dǎo)致 CPU 的性能浪費(fèi)的情況,處理器通過(guò)使用寫(xiě)緩沖區(qū)來(lái)臨時(shí)保存待寫(xiě)入主內(nèi)存的數(shù)據(jù)。寫(xiě)緩沖區(qū)合并對(duì)同一內(nèi)存地址的多次寫(xiě),并以批處理的方式刷新,也就是說(shuō)寫(xiě)緩沖區(qū)不會(huì)立即將數(shù)據(jù)刷新到主內(nèi)存中。
緩存不能及時(shí)刷新到主內(nèi)存就是導(dǎo)致可見(jiàn)性問(wèn)題產(chǎn)生的根本原因。
2.2、案例分析
- public class AtomicDemo {
- private int count = 0;
- public void add() {
- count++;
- }
- public int get() {
- return count;
- }
- public static void main(String[] args) throws InterruptedException {
- CountDownLatch countDownLatch = new CountDownLatch(100);
- AtomicDemo atomicDemo = new AtomicDemo();
- IntStream.rangeClosed(0, 100).forEach(item -> {
- new Thread(() -> {
- IntStream.rangeClosed(1, 100).forEach(i -> {
- atomicDemo.add();
- });
- }).start();
- countDownLatch.countDown();
- });
- countDownLatch.await();
- System.out.println(atomicDemo.get());
- }
- }
“what * *”,怎么和上面代碼一樣。。。結(jié)果就不截圖了,必然不是10000。
我們來(lái)看下執(zhí)行的流程圖(PS:不要糾結(jié)于為什么和上面的不一樣,特定問(wèn)題特定分析。在闡述一種問(wèn)題的時(shí)候,一定會(huì)在某些層面上屏蔽另外一種問(wèn)題的干擾)
假設(shè) A 線程和 B 線程同時(shí)開(kāi)始執(zhí)行,首先 A 線程和 B 線程會(huì)將主內(nèi)存中的 count 的值加載/緩存到自己的本地內(nèi)存中。然后會(huì)讀取各自的內(nèi)存中的值去執(zhí)行操作,也就是說(shuō)此時(shí) A 線程和 B 線程就好像是兩個(gè)世界的人,彼此不會(huì)產(chǎn)生任何關(guān)聯(lián)。
操作完之后 A 線程將結(jié)果寫(xiě)回到自己的本地內(nèi)存中,同樣 B 線程將結(jié)果寫(xiě)回到自己的本地內(nèi)存中。然后回來(lái)某個(gè)時(shí)機(jī)各自將結(jié)果刷回到主內(nèi)存。那最終必然是一方的數(shù)據(jù)被另一方覆蓋。這就是緩存的可見(jiàn)性問(wèn)題。
3、有序性
不積跬步無(wú)以至千里,我們還是先來(lái)看概念
有序性:程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
這有啥的,程序老老實(shí)實(shí)按照程序員寫(xiě)的代碼執(zhí)行就完事了,這還會(huì)有什么問(wèn)題嗎?
3.1、有序性問(wèn)題產(chǎn)生的原因
實(shí)際上編譯器為了提高程序執(zhí)行的性能。會(huì)改變我們代碼的執(zhí)行順序的。即你寫(xiě)在前面的代碼不一定是先被執(zhí)行完的。
例如:int a = 1;int b =4;從表面和常規(guī)角度來(lái)看,程序的執(zhí)行應(yīng)該是先初始化 a ,然后初始化 b 。但是實(shí)際上非常有可能是先初始化 b,然后初始化 a。因?yàn)樵诰幾g器看了來(lái),先初始化誰(shuí)對(duì)這兩個(gè)變量不會(huì)有任何影響。即這兩個(gè)變量之間沒(méi)有任何的數(shù)據(jù)依賴。
指令重排序有三種類(lèi)型,分別為:
① 編譯器優(yōu)化的重排序。編譯器在不改變單線程程序語(yǔ)義的前提下,可以重新安排語(yǔ)句的執(zhí)行順序。
② 指令級(jí)并行的重排序?,F(xiàn)代處理器采用了指令級(jí)并行技術(shù)(Instruction-Level Parallelism,ILP)來(lái)將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性,處理器可以改變語(yǔ)句對(duì)應(yīng) 機(jī)器指令的執(zhí)行順序。
③ 內(nèi)存系統(tǒng)的重排序。由于處理器使用緩存和讀/寫(xiě)緩沖區(qū),這使得加載和存儲(chǔ)操作看上 去可能是在亂序執(zhí)行。
3.2、案例分析
有序性的案例最常見(jiàn)的就是 DCL了(double check lock)就是單例模式中的雙重檢查鎖功能。先來(lái)看下代碼
- public class SingletonDclDemo {
- private SingletonDclDemo(){}
- private static SingletonDclDemo instance;
- public static SingletonDclDemo getInstance(){
- if (Objects.isNull(instance)) {
- synchronized (SingletonDclDemo.class) {
- if (Objects.isNull(instance)) {
- instance = new SingletonDclDemo();
- }
- }
- }
- return instance;
- }
- public static void main(String[] args) {
- IntStream.rangeClosed(0,100).forEach(item->{
- new Thread(SingletonDclDemo::getInstance).start();
- });
- }
- }
這個(gè)代碼還是比較簡(jiǎn)單的。
在獲取對(duì)象實(shí)例的方法中,程序首先判斷 instance 對(duì)象是否為空,如果為空,則鎖定SingletonDclDemo.class 并再次檢查instance是否為空,如果還為空則創(chuàng)建 Singleton的一個(gè)實(shí)例??此坪芡昝溃缺WC了線程完全的初始化單例,又經(jīng)過(guò)判斷 instance 為 null 時(shí)再用 synchronized 同步加鎖。但是還有問(wèn)題!
instance = new SingletonDclDemo(); 創(chuàng)建對(duì)象的代碼,分為三步:① 分配內(nèi)存空間;② 初始化對(duì)象SingletonDclDemo;③ 將內(nèi)存空間的地址賦值給instance;
但是這三步經(jīng)過(guò)重排之后:① 分配內(nèi)存空間 ② 將內(nèi)存空間的地址賦值給instance ③ 初始化對(duì)象SingletonDclDemo
會(huì)導(dǎo)致什么結(jié)果呢?
線程 A 先執(zhí)行 getInstance() 方法,當(dāng)執(zhí)行完指令②時(shí)恰好發(fā)生了線程切換,切換到了線程B上;如果此時(shí)線程B也執(zhí)行 getInstance() 方法,那么線程B在執(zhí)行第一個(gè)判斷時(shí)會(huì)發(fā)現(xiàn)instance!=null,所以直接返回instance,而此時(shí)的instance是沒(méi)有初始化過(guò)的,如果我們這個(gè)時(shí)候訪問(wèn)instance的成員變量就可能觸發(fā)空指針異常。
繼續(xù)來(lái)張圖來(lái)更直觀的理解下:
具體的執(zhí)行流程在上面已經(jīng)分析了。相信這張圖片一定能讓你徹底理解。
4、本文小結(jié)
并發(fā)編程的學(xué)習(xí)和使用并非一朝一夕的事情,也并非會(huì)幾個(gè)理論就能寫(xiě)好優(yōu)質(zhì)的并發(fā)程序。這需要長(zhǎng)時(shí)間的實(shí)踐和總結(jié)。好的代碼很少是寫(xiě)出來(lái)的,都是迭代和優(yōu)化的。