并發(fā)問題是怎樣造成的

我們的程序要運行,離不開CPU、內(nèi)存、IO設(shè)備,但是他們?nèi)咧g的執(zhí)行速度是有差異的。

CPU 的執(zhí)行速度最快,內(nèi)存的速度次之,IO設(shè)備的速度最慢。

為什么會有內(nèi)存緩存

CPU 執(zhí)行一條指令非???,但是他從內(nèi)存中讀取某個數(shù)據(jù)時,就需要等待很長的時間,為了彌補(bǔ)速度上的巨大差異,讓 CPU 不被內(nèi)存拖垮,所以在 CPU 上增加了緩存。

當(dāng) CPU 請求內(nèi)存中的數(shù)據(jù)時,會先查看緩存中是否有該數(shù)據(jù),如果存在則直接返回該數(shù)據(jù);如果不存在,則要先把內(nèi)存中的數(shù)據(jù)載入緩存,然后再返回給 CPU。

所以我們的程序在執(zhí)行時,往往就需要將數(shù)據(jù)從內(nèi)存中讀取出來載入到緩存中,然后進(jìn)行處理,處理完成之后再將數(shù)據(jù)回寫到內(nèi)存中去。

除此以外,現(xiàn)代的計算機(jī)都是多CPU、多核的,程序也不再只運行在單一線程中,而是有多個線程在運行。

每個線程都會維護(hù)一份自己的內(nèi)存副本,也就是 CPU 緩存,所以線程之間一定會存在數(shù)據(jù)一致性的問題。

一般來說,導(dǎo)致并發(fā)問題的根源不外乎以下這幾個原因:

可見性:一個線程對共享變量的修改,另一個線程是否可見?

原子性:一個或多個操作在 CPU 執(zhí)行的過程中是否會被中斷?

有序性:程序編譯后的指令是否會按照代碼原本的順序執(zhí)行?

遺憾的是,以上三個問題的答案都是不確定的,正因為這些不確定所以才會存在并發(fā)下的各種問題。

什么是可見性

如果我們的程序是在單個 CPU 上執(zhí)行的,那么對于一個變量的原子性操作,無論如何都是不會出現(xiàn)問題的,不管是由一個線程還是多個線程來操作該變量,對結(jié)果都不會造成影響,因為內(nèi)存的副本只有一個。

single-cpu.jpg

在單個 CPU 上操作雖然不會有問題,但是要強(qiáng)調(diào)一點,就是這個操作必須是原子性的。

比如線程A 設(shè)置變量 V 的值為10,那線程B獲取到該變量的值就是10,不會出現(xiàn)問題。

但是我們的程序是不可能只在單個 CPU 上運行的,而是要在多個 CPU 上運行的,在多個 CPU 上執(zhí)行時,就會出現(xiàn)問題。

multi-cpus.jpg

如線程A 在CPU1 中對變量 V 設(shè)置了一個新的值,但是線程B是在 CPU2 中,而 CPU1 對緩存進(jìn)行了修改,并不會通知到 CPU2,所以這時線程B 拿到的變量 V 的值還是原來的老的值,也就是臟數(shù)據(jù)。

所以這就是導(dǎo)致并發(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 標(biāo)志位。

啟動完兩個線程之后,打印出如下結(jié)果:

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 線程已經(jīng)將 counter 的值緩存在本地的內(nèi)存副本中了, Writer 線程再怎么修改 counter 的值, Reader 線程也不會知道的,所以說 Writer 線程對于 counter 的修改,對 Reader 線程是不可見的。

同樣的, Reader 線程啟動后,讀取到 stop 變量的值為 false,在后續(xù) Writer 線程將 stop 的值更新為 true 之后, Reader 線程也不會感知到,所以該程序會一直運行下去,因為 Reader 線程中的 stop 狀態(tài)永遠(yuǎn)是 false。

如果我們將 Writer 線程中的休眠1s的代碼注釋掉,那么 Reader 線程可能會讀取到 stop 為 true。

為了解決這個問題,Java 給我們提供了一個 volatile 關(guān)鍵字,用來保證可見性。

當(dāng)一個變量被 volatile 修飾后,表示著線程本地內(nèi)存無效,當(dāng)一個線程修改共享變量后他會立即被更新到主內(nèi)存中,當(dāng)其他線程讀取共享變量時,它會直接從主內(nèi)存中讀取。

將上述的代碼中 counter 改為如下所示:

private static volatile int counter;

即可返回正確的結(jié)果,Writer 線程每次對 counter 所做的修改,Reader 線程都能感知到,也就是說 Writer 對變量 counter 做的修改,對 Reader 線程是可見的。

除了 volatile 可以保證可見性之外,synchronized 關(guān)鍵字和 Lock 都能保證可見性,但是和 volatile 比起來,加鎖的方式都太重了,涉及到線程的阻塞與喚醒。

為什么會有線程切換

我們的程序都是由非常多的線程來協(xié)作執(zhí)行的,而具體的執(zhí)行都是給 CPU 下達(dá)指令,讓 CPU 去執(zhí)行的。

那么每個線程該怎么使喚 CPU 讓他為自己干活呢?CPU 又是怎樣接受和處理這么多線程下發(fā)給自己的指令的呢?

由于 CPU 的執(zhí)行非???,而線程下發(fā)給他的任務(wù)有可能很快就執(zhí)行完了,也可能由于其他的原因?qū)е乱獔?zhí)行很久。

如果一個任務(wù)執(zhí)行的時間很久,是否需要一直占著 CPU 資源呢?

那 CPU 肯定不會同意的,CPU 為了更高效的處理各種任務(wù),會為每個線程分配一段差不多長的時間用來執(zhí)行他們的任務(wù),當(dāng)時間用完了之后,就去執(zhí)行其他線程的任務(wù)了,這個時間就稱為 “時間片” ,執(zhí)行不同的任務(wù)就是線程之間的切換了。

什么是原子性

雖然 CPU 通過時間片和線程切換,提高了程序運行的效率,但是說到線程切換,就可能導(dǎo)致另一種問題。

那么線程切換會在什么時候發(fā)生呢,在 CPU 指令執(zhí)行完成之后的任何時間點都可能發(fā)生線程切換。

所以對于非原子操作就可能,操作執(zhí)行了一半,發(fā)生了線程切換,另外的操作沒來得及執(zhí)行,要等到下一個線程切換時,輪到自己占有 CPU 時,才能完成剩下的操作。

但是這樣明顯是有問題的,你執(zhí)行了一半的操作,CPU 到別的地方轉(zhuǎn)了一圈回來之后,你原本的操作結(jié)果很可能就不對了,為什么會不對呢,因為你在等待 CPU 的這段時間內(nèi),很可能有別的線程也執(zhí)行了和你相同的事。

我們知道數(shù)據(jù)庫事務(wù)中也有原子性的概念,他主要說的是事務(wù)中的多個操作,要么全部執(zhí)行,要么全部不執(zhí)行。

但是 Java 中的原子性,并不能保證要么全部執(zhí)行,要么全部不執(zhí)行,反而是很可能多個操作只執(zhí)行了一部分。

說了這么多的 “操作”,Java 中的一條語句難道不就是一條 “操作” 嗎?

Java 中的一條語句還真不一定是一條 “操作”,這里說的 “操作” 是對 CPU 而言的,指的是一條指令。

而我們 Java 中的一條語句可能由一條指令組成,也可能由多條指令組成,操作系統(tǒng)只能保證一條指令的原子性,也就是要么該條指令執(zhí)行,要么該條指令不執(zhí)行,但是并不能保證多條指令的原子性。

所以說雖然線程切換解決了性能問題,但是卻帶來了原子性的問題。

Java 中的自增運算是一個典型的非原子性的操作,為什么這么說呢?

自增運算看似是一條語句,但是實際上需要三條 CPU 指令構(gòu)成,分別是:取值,值加1,回寫值。

self-increase.jpg

假設(shè)我們有一個變量 V,初始值是0,當(dāng)兩個線程都對變量 V 執(zhí)行自增操作,正常情況下,我們期望的結(jié)果是最終變量 V 的值是2,但是很可能由于縣城切換導(dǎo)致,最終被更新到內(nèi)存中的變量的值是1。

線程 A 從內(nèi)存中獲取到變量 V 的值為0,然后還沒來得及執(zhí)行后續(xù)的指令,就發(fā)生了線程切換,線程 B 這時從內(nèi)存中獲取到變量 V 的值也為 0,然后執(zhí)行了后續(xù)的指令,將值加1并把值回寫到了內(nèi)存中,這時內(nèi)存中的變量 V 的值為1。

然后又發(fā)生了線程切換,線程 A 重新獲得了 CPU 資源,繼續(xù)執(zhí)行未完成的指令,最終的也將變量 V 的值更新為1,然后寫入到了內(nèi)存中。

整個過程由于發(fā)生了線程切換,導(dǎo)致非原子性的操作的結(jié)果出現(xiàn)了問題,事實上只要線程 A 在執(zhí)行玩第一步或者第二步指令之后發(fā)生了線程切換,都會導(dǎo)致問題的發(fā)生。

而當(dāng)線程 A 在執(zhí)行完了第三步指令之后,再發(fā)生線程切換的話,則不會出現(xiàn)問題,原因是第三步指令執(zhí)行完之后,內(nèi)存中的變量值已經(jīng)更新為最新值了,即便發(fā)生了線程切換,其他線程也會從內(nèi)存中獲取到最新的值。當(dāng)然啦,假如第三步指令都執(zhí)行完了,那整個過程就相當(dāng)于是一個原子性的過程了,那就不存在由于線程切換而導(dǎo)致的問題了。

一個非原子性的示例

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 來設(shè)置某個變量的值時,會先檢查該變量內(nèi)存中的值是否與當(dāng)前期望的值一致,如果發(fā)現(xiàn)不一致則會重新獲取內(nèi)存中的最新值,直到內(nèi)存中的值與當(dāng)前期望的值一致時,才將最新的值更新到內(nèi)存中去,所以整個過程是原子性的。

復(fù)合原子操作是不是原子性的

現(xiàn)在我們知道了一個操作必須是原子性的才能保證在并發(fā)的情況下不出問題,具體可以使用原子類 Atomic* 來代替原始的變量。

但是 Atomic* 能否保證永遠(yuǎn)不出問題呢?

答案是不會,只要使用的不正確,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) {
        // 獲取當(dāng)前節(jié)點的索引,并將索引加1
        int value = nodes[nodeIndex.getAndIncrement()];
        // 如果索引值等于節(jié)點的長度了,則設(shè)置為0
        nodeIndex.compareAndSet(nodes.length, 0);
        System.out.println("Thread=" + Thread.currentThread().getName() + " current node value=" + value);
    }
}

上述代碼是模擬輪詢獲取可用節(jié)點的功能,假設(shè)有兩個節(jié)點,我們希望在多線程下能夠交替返回每一個節(jié)點給調(diào)用方,這樣可以做到負(fù)載均衡。

但是上述代碼無法做到交替返回,原因是 getAndIncreament() 和 compareAndSet() 方法雖然都是原子操作,但是他們放在一起作為一個復(fù)合操作就不是原子的了。

為什么會有重排序

編譯器或運行時環(huán)境為了優(yōu)化程序性能,通常會對指令進(jìn)行重新排序,所以重排序分兩種,分別是編譯期重排序和運行期重排序。

對于我們程序員來說,不要假設(shè)指令執(zhí)行的順序,因為我們無法預(yù)知不同線程之間的指令會以何種順序執(zhí)行。

java 會為了提升程序的性能,將指令進(jìn)行重排,這又是一種導(dǎo)致并發(fā)環(huán)境下可能出錯的情況。

什么是有序性

在程序執(zhí)行過程中,按照代碼的順序先后執(zhí)行,這就是有序性,但是通過上面的介紹我們知道,不采取措施的話有序性是無法保證的。

因為我們寫的代碼,在編譯期就已經(jīng)發(fā)生了變化,而在最終執(zhí)行時也可能發(fā)生變化,如果我們進(jìn)行干涉的話,執(zhí)行的結(jié)果很可能會發(fā)生不可預(yù)知的變化。

一個有序性的示例

一個最經(jīng)典的有序性的問題就是,獲取單例對象時,通過雙重檢查來保證對象只創(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)問題,但正是由于重排序的原因?qū)е路祷氐膯卫龑ο罂赡艹霈F(xiàn)問題。

get-instance.jpg

線程A來獲取單例對象,這時發(fā)現(xiàn)instance==null,所以就進(jìn)入了加鎖創(chuàng)建單例對象的代碼塊。

本來正常情況下,創(chuàng)建了一個對象然后返回就可以了,但是因為重排序的原因,創(chuàng)建對象的過程被重排序了:

resort.jpg

正常應(yīng)該是先初始化對象,然后再將分配好的內(nèi)存指向該對象,但是重排序后的結(jié)果變成了,先將分配好的內(nèi)存指向了對象,然后再初始化對象。

問題就出在這里,當(dāng)將分配好的內(nèi)存指向該對象后,如果發(fā)生了線程切換,線程B來獲取單例對象時,發(fā)現(xiàn)單例對象已經(jīng)不為空了,所以直接就拿該對象去操作了,但是該對象并沒有進(jìn)行過初始化,所以線程B后續(xù)再執(zhí)行時就會出現(xiàn)空指針的問題。

為了解決重排序的問題,需要我們寫代碼時進(jìn)行人為干預(yù),具體怎么干預(yù)呢?那就是通過 volatile 關(guān)鍵字,可是上面我們剛說了 volatile 是解決可見性的問題的啊。

沒錯 volatile 除了可以解決可見性問題,也可以解決有序性的問題,通過 volatile 修飾的變量,編譯器和運行時環(huán)境不會對他進(jìn)行指令重排。

并發(fā)問題是怎樣造成的

通過上面的分析,我們知道了造成并發(fā)問題的原因了,這些都是操作系統(tǒng)或者編譯期為了提升性能而做了一些努力,但是為了享受到這些性能上的優(yōu)勢,我們就得付出更多的代價來寫出復(fù)雜的代碼。

換句話說,硬件上為了最求卓越的性能,而忽略了軟件實現(xiàn)上的復(fù)雜度,相當(dāng)于硬件工程師給軟件工程師挖了一個坑。

CPU上的高速緩存造成了多線程下共享變量的可見性問題,可以通過 volatile 或加鎖的方式來解決。

線程切換造成了多線程下原子性的問題,可以通過原子類或加鎖的方式來解決。

編譯器或者運行環(huán)境為了優(yōu)化程序性能造成了有序性的問題,可以通過 volatile 禁止指令重排。

逅弈逐碼,專注于原創(chuàng)分享,用通俗易懂的圖文描述源碼及原理
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容