【Java并發(fā)003】原理層面:Java并發(fā)三特性全解析

一、前言

不管什么語言,并發(fā)的編程都是在高級的部分,因為并發(fā)的涉及的知識太廣,不單單是操作系統(tǒng)的知識,還有計算機(jī)的組成的知識等等。說到底,這些年硬件的不斷的發(fā)展,但是一直有一個核心的矛盾在:CPU、內(nèi)存、I/O設(shè)備的三者的速度的差異。這就是所有的并發(fā)的源頭。

CPU與內(nèi)存:緩存,CPU增加了緩存,以均衡與內(nèi)存的差異;(但是帶來可見性問題)
CPU與IO設(shè)備:進(jìn)程、線程,操作系統(tǒng)增加了進(jìn)程、線程,以分時復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;(但是帶來原子性問題)
編譯程序指令重排序,編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理的利用。(但是帶來有序性問題)

二、三大源頭

2.1 緩存導(dǎo)致可見性問題

2.1.1 理論:從單核CPU到多核CPU

單核:所有的線程都是在一顆CPU上執(zhí)行,CPU緩存與內(nèi)存的數(shù)據(jù)一致性容易解決。因為所有線程都是同一個CPU的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的;

多核:每顆CPU都有自己的緩存,這時CPU緩存與內(nèi)存的數(shù)據(jù)一致性就沒有那么容易解決了,當(dāng)多個線程在不同的CPU上執(zhí)行時,這些線程操作的是不同的CPU緩存(CPU的解決的方案:MESI協(xié)議)。

單核CPU與多核CPU對比如下圖:

在這里插入圖片描述

金手指:一句話小結(jié)緩存在多線程下的可見性問題
1、單核cpu,只有一個cpu,所以只有一個緩存,所有的內(nèi)存都是使用這個緩存,一旦緩存內(nèi)存修改,所有cpu都可見,沒有可見性問題
2、多核cpu,有多個cpu,每一個cpu中一個緩存,所以n個cpu就有n個緩存,n個緩存與同一個主存交互數(shù)據(jù),主存中的數(shù)據(jù)對于所有緩存可見,但是不同緩存之間的數(shù)據(jù)是不可見的
單線程下,緩存從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,沒有問題
多線程下,兩個線程使用兩個不同的cpu,兩個cpu緩存都從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,這個過程中,每一個線程都是基于自己CPU緩存里的count值來計算,但是自己的緩存數(shù)據(jù)的修改只有自己可見,造成可見性問題
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存不可見,所以造成可見性問題。

2.1.2 實踐:多線程可見性問題

測試一下可見性,書寫以下的代碼:

public class Test {
    private static long count = 0;   // 類變量count

    // 添加一萬
    private void add10K() {
        int idx = 0;
        // 當(dāng)小于10000的時候不斷加
        while (idx++ < 10000) {
            count += 1;
        }
    }

    public static long calc() throws InterruptedException {
        final Test test = new Test();
        // 創(chuàng)建兩個線程,這兩個線程執(zhí)行 add() 操作
        Thread th1 = new Thread(test::add10K);
        Thread th2 = new Thread(test::add10K);
        // 啟動兩個線程
        th1.start();
        th2.start();
        th1.join();   // 暫停主線程,加入thread1,thread1執(zhí)行完之后執(zhí)行main
        th2.join();   // 暫停主線程,加入thread2,thread2執(zhí)行完之后執(zhí)行main
        return count;    // 返回類變量count
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calc());  // 執(zhí)行,返回count
    }
}

運(yùn)行結(jié)果如下:可以見得不是20000的值。

11022

原因:原子性無法保證,線程不安全,count += 1; 不是原子操作,是三步操作,可以被打斷。
其他的,可見性和有序性都無法保證(即使用volatile修飾保證可見性和有序性也沒用,原子性無法保證)
解決方法:同步阻塞保證原子性(synchronized)和非同步阻塞保證原子性(cas)
synchronized == for + if(cas),注意cas有一個ABA問題,雖然這里累加不涉及

我們假設(shè)線程A和線程B同時開始執(zhí)行,那么第一次都會將count=0讀到各自的CPU緩存里,執(zhí)行完count+=1之后,各自CPU緩存里面的值都是1,同時寫入內(nèi)存后,我們后發(fā)現(xiàn)現(xiàn)在內(nèi)存中是1,而不是我們期望的2。之后由于各自的CPU緩存里都有了count的值,兩個線程都是基于CPU緩存里的count值來計算,所以導(dǎo)致最終count的值都是小于20000的。這就是緩存的可見性的問題。

總結(jié):一個線程對共享變量的修改,另外一個線程能夠立即看到,我們稱為可見性.

2.2 線程切換帶來的原子性問題

原子性定義:我們把一個或者多個操作在CPU執(zhí)行過程中不被中斷的特性稱為原子性。

  1. 時間片的引入,CPU分片執(zhí)行:為了提供CPU的利用率,操作系統(tǒng)允許某個進(jìn)程分時使用CPU,當(dāng)一個進(jìn)程執(zhí)行IO操作時候(即count+=1 讀盤、操作、寫盤三操作中的讀盤/寫盤),這個時候這個進(jìn)程將自己標(biāo)記為休眠的狀態(tài),并且讓出CPU使用權(quán),讓給其他線程執(zhí)行,這樣CPU的使用率就會上來。另一方面,如果這個時候有一個進(jìn)程要進(jìn)行IO操作,這個時候會發(fā)現(xiàn)已經(jīng)有進(jìn)程在IO操作,這個時候新來的進(jìn)程就會等待。當(dāng)上個IO進(jìn)程結(jié)束的時候,會再進(jìn)行這個新來的IO進(jìn)程,這樣IO的使用率也上去了。

  2. 高級語言中的語句往往不是原子性的:所有的高級的語言一條語句往往需要CPU中的多條指令。例如count+=1,至少需要三條CPU指令:
    指令1:首先,需要把變量count從內(nèi)存加載到CPU的寄存器;
    指令2:之后,在寄存器中執(zhí)行+1操作;
    指令3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是CPU緩存而不是內(nèi)存)

  3. 操作系統(tǒng)任務(wù)切換,可以發(fā)生在任何一條CPU指令執(zhí)行完。注意,是CPU指令,而不是高級語言里的一條語言,所以,高級語言中的一條語句是可以被中途打斷的。

在這里插入圖片描述

上面說的是,讀磁盤-計算-寫磁盤,這里演示的是 讀內(nèi)存-計算-寫內(nèi)存,實際上是一樣的。

金手指:一句話解析分時時間片在多線程下的原子性問題
1、如果操作系統(tǒng)不支持分時使用CPU,即必須一個進(jìn)程執(zhí)行完成之后之后,再執(zhí)行下一個進(jìn)程,每一個進(jìn)程的執(zhí)行都不會被打斷,每一個進(jìn)程的執(zhí)行都是原子的,不存在原子性問題。
2、如果操作系統(tǒng)支持分時使用CPU,一個進(jìn)程執(zhí)行讀寫磁盤或者讀寫內(nèi)存的時候,會把CPU讓出來,讓給其他進(jìn)程使用,如果獲得CPU使用權(quán)的進(jìn)程和當(dāng)前進(jìn)程修改同一變量,造成安全問題,該問題歸根結(jié)底是因為打斷了對一個變量讀寫的原子操作。
從 CPU-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存訪問主內(nèi)存的操作無法原子化,所以造成原子性問題。

2.3 編譯優(yōu)化帶來的有序性問題

2.3.1 第一,經(jīng)典案例:雙重檢查創(chuàng)建單例對象

public class Singleton {
    private Singleton(){}
    private static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

注意:這里的懶漢式單例沒有使用volatile關(guān)鍵字修飾instance變量禁止重排序,所有即使使用雙層if判斷,也是線程不安全的。

2.3.2 第二,我們認(rèn)為的new操作:instance = new Singleton();

instance = new Singleton();

第一步,分配一塊內(nèi)存M;
第二步,在內(nèi)存M上初始化Singleton對象,得到M的地址;
第三步,然后M的地址返回給instance變量。 // 最后賦值給instance變量

如果是這樣執(zhí)行,無論單線程還是多線程都不會有問題。
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全

2.3.3 第三,實際優(yōu)化后的執(zhí)行路徑:instance = new Singleton();

instance = new Singleton();

第一步,分配一塊內(nèi)存M;
第二步,將M的地址賦值給instance變量;// 解釋:這個時候Singleton對象還沒有初始化,M地址為&M,但是注意,這里完成對instance變量賦值操作后,線程A就會釋放同步鎖,線程B就可以進(jìn)來了,這就是指令重排序造成的懶漢式單例雙重檢查不安全的地方
第三步,最后在內(nèi)存M上初始化Singleton對象。// 解釋:這個時候再來初始化,但是內(nèi)存M的地址沒有給instance變量

如下,由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這是instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯

在這里插入圖片描述

金手指:一句話小結(jié)指令重排序在多線程下的有序性問題(懶漢式單例的雙層if判斷為例)
1、不使用指令重排序,單線程和多線程下沒有線程安全問題
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全
2、使用指令重排序,單線程下沒問題,多線程下線程安全問題
由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,因為完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這時instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的執(zhí)行引擎 指令重排序,所以造成有序性問題。

三、Java中如何解決可見性和有序性問題?(JMM:Java內(nèi)存模型)

3.1 可見性和有序性

根本的原因:緩存導(dǎo)致可見性,編譯優(yōu)化導(dǎo)致有序性。

解決辦法:禁用緩存和編譯優(yōu)化。如果全部的禁用緩存和編譯優(yōu)化,那我們的程序的性能會很差,所以我們的操作是按需禁用緩存以及編譯優(yōu)化。

3.2 JMM處理可見性和有序性問題

JMM(Java內(nèi)存模型)解決上面的問題主要是volatile、synchronized和final三個關(guān)鍵字,以及八項Happens-Before規(guī)則。

3.2.1 volatile:保證可見性

保證可見性不被破壞,就要禁用緩存,強(qiáng)制cpu運(yùn)算器需要數(shù)據(jù)的時候直接從主存中拿,因為主存只有一塊,對所有的線程都是可見的,從主存中拿就可以保證數(shù)據(jù)修改對所有線程可見,就解決了可見性問題。但是,不能對所有的變量都禁用緩存,會效率低下。

Java規(guī)定,對于使用volatile關(guān)鍵字修飾的變量,cpu對其讀寫,強(qiáng)制在主存中操作。但是,哪些變量需要使用volatile關(guān)鍵字修飾呢?這就是Java程序員的工作了,其實就是多線程臨界區(qū)中使用的那些變量,一般只有一個或幾個。

volatile保證可見性:告訴編譯器,對這個變量的讀寫,不能使用CPU緩存,必須從內(nèi)存中讀取和寫入。

public class VolatileExample {
    int x = 0;
    volatile boolean v = false;   // 使用volatile修飾,保證可見性,從主存中讀寫

    public void writer() {
        x = 42;
        v = true;   // 寫到主存中
    }

    public void reader() {
        if (v == true) {
            //x為多少?
            System.out.println(x);  
        }
    }
}

在JDK1.5之前可能會出現(xiàn)x=0的情況,因為變量x可能被CPU緩存而導(dǎo)致可見性問題。1.5之后對volatile語義進(jìn)行了增強(qiáng),就是利用Happens-Before規(guī)則。

3.2.2 Happens-Before規(guī)則(JVM中已經(jīng)定義的有序性:前面一個操作的結(jié)果對后續(xù)操作是可見的)

  1. 程序次序規(guī)則:在一個線程中,按照程序順序,前面的操作Happens-Before于后續(xù)的任何操作。(解釋:同一個線程中,或單線程下,有序性不變,執(zhí)行順序就是編寫順序)

  2. volatile變量規(guī)則:對一個volatile編程的寫操作,Happens-Before于后續(xù)對這個volatile變量的讀操作。(解釋:volatile強(qiáng)制保證有序性,作為happen-before 8條中的一條)

  3. 傳遞規(guī)則:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。(解釋:沒啥好說的)

  4. 鎖定規(guī)則:對一個鎖的解鎖Happens-Before與后續(xù)對這個鎖的加鎖。(解釋:上一個先解鎖,下一個才能加鎖,為保證一定解鎖,解鎖一般放在finally里面)

  5. 線程啟動規(guī)則:如果線程A調(diào)用線程B的start()方法(即在線程A中啟動線程B),那么該start()操作Happens-Before線程B中的任意操作。(解釋:start()在啟動線程之前)

  6. 線程終結(jié)規(guī)則:如果在線程A中,調(diào)用線程B的join()并成功返回,那么線程B中的任意操作Happens-Before于該join()操作的返回。(解釋:join()返回在所有操作之后)

  7. 線程中斷規(guī)則:對線程interrupt()方法的調(diào)用Happens-Before被中斷線程的代碼檢測到中斷事件的發(fā)生(解釋:interrupt()早于中斷線程)

  8. 對象終結(jié)規(guī)則:一個對象的初始化完成Happens-Before他的finalize()方法的開始。(解釋:finalize()一定在結(jié)束對象之前)

3.2.3 final關(guān)鍵字(不可變)

JVM中五種線程安全級別:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立,不可變是最高級別的線程安全,和常量一樣,使用final修飾變量就是達(dá)到不可變
final修飾變量是,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優(yōu)化。

逸出問題:

final int x;
// 錯誤的構(gòu)造函數(shù)
public FinalFieldExample() {
    x = 3;
    y = 4;
    // 此處就是講 this 逸出,
    global.obj = this;
}

在1.5以后Java內(nèi)存模型對final類型變量的重排進(jìn)行了約束。現(xiàn)在只要我們能夠提供正確函數(shù)沒有‘逸出’,就不會出問題了。

四、Java中如何解決原子性問題(互斥鎖)

4.1 保證原子性(原子性兩點:保證加鎖才進(jìn)入臨界區(qū)、保證走完臨界區(qū)代碼解鎖)

1、概念:一個或者多個操作在CPU執(zhí)行過程不被中斷的特性,稱為原子性。

2、導(dǎo)致的原因:線程切換。

3、操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換。
(1)當(dāng)在32位CPU上執(zhí)行l(wèi)ong型變量的寫操作會被拆分成兩次操作。寫高32位和寫低32位。
(2)單核:在單核CPU下,同一時刻只有一個線程執(zhí)行,禁止CPU中斷,意味著操作不會重新調(diào)度線程,也就是禁止了線程切換,獲得CPU使用權(quán)的線程就可以不間斷地執(zhí)行,所以兩次寫操作一定是:要么都被執(zhí)行,要么都沒有被執(zhí)行,具有原子性。
(3)多核:同一時刻,有可能有兩個線程同時執(zhí)行,一個線程執(zhí)行在CPU-1上,一個線程執(zhí)行在CPU-2上,此時禁止CPU中斷,只能保證CPU上的線程連續(xù)執(zhí)行,并不能保證同一時刻只有一個線程執(zhí)行。

4、解決辦法:互斥鎖

金手指:
原因:造成操作的原子性被破壞的原因是線程切換
解決方式:禁止CPU中斷(操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換)
Java禁止CPU中斷的兩種方式:同步阻塞synchronized和非同步阻塞CAS

同步阻塞synchronized和非同步阻塞CAS是如何保證原子性的?

  1. 保證加鎖才進(jìn)入臨界區(qū)
    解釋:一定要線程安全才能進(jìn)來 cas和synchronized都可以保證,cas一定要current==real才能進(jìn)來,synchronized一定要獲取到同步鎖才能進(jìn)來
  2. 保證走完臨界區(qū)代碼解鎖
    解釋:里面的不出去,外面的不能進(jìn)來,cas這里return next;之前都沒有改變數(shù)據(jù),所以里面的沒出去,外面的不能進(jìn)來;synchronized也可以保證這一點,里面的不執(zhí)行完不釋放鎖,外面的進(jìn)不來

4.2 鎖模型理論,解決原子性問題

4.2.1 簡易的鎖模型

所有的原子性(synchronzied lock cas)都是保證這兩點:
第一,保證加鎖才進(jìn)入臨界區(qū)
第二,保證走完臨界區(qū)代碼解鎖

在這里插入圖片描述

對于上圖鎖模型的解釋:

第一,保證加鎖才進(jìn)入臨界區(qū):線程進(jìn)入臨界區(qū)之前,首先嘗試加鎖lock(),如果成功,則進(jìn)入臨界區(qū),此時我們稱這個線1程持有鎖;否則就等待,直到持有鎖的線程解鎖;

第二,保證走完臨界區(qū)代碼解鎖:持有鎖的線程執(zhí)行完臨界區(qū)的代碼后,執(zhí)行解鎖unlock()

4.2.2 改進(jìn)后的鎖模型(添加鎖對象和受保護(hù)資源)

在這里插入圖片描述

對于上圖改進(jìn)后的鎖模型的解釋:

第一,在臨界代碼中,增加了受保護(hù)的資源R(解釋:R 表示Resource);
第二,為了保護(hù)資源R就得為它創(chuàng)建一把鎖LR(解釋:LR 表示 Lock for Resource);
第三,對于新創(chuàng)建的鎖LR,需要在進(jìn)出臨界區(qū)時添上加鎖和解鎖操作,即lock(LR)操作和unlock(LR)操作。

4.3 實踐鎖模型,synchronized五種用法

public class X {
    //修飾非靜態(tài)方法
    synchronized void foo() {
        //臨界區(qū)
    }

    //修飾靜態(tài)方法
    synchronized static void bar() {
        //臨界區(qū)
    }

    //修飾代碼塊
    Object obj = new Object();

    void baz() {
        synchronized (obj) {
            //臨界區(qū)
        }
    }
}

synchronized修飾靜態(tài)方法相當(dāng)于

class X {
    //修飾靜態(tài)方法
    synchronized(X.class) static void bar() {
        //臨界區(qū)
    }
}

synchronized修飾非靜態(tài)方法相當(dāng)于

class X {
    //修飾靜態(tài)方法
    synchronized(this) void bar() {
        //臨界區(qū)
    }
}

小結(jié):synchronized可以修飾靜態(tài)方法或靜態(tài)代碼塊,同步鎖是所在類的.class字節(jié)碼對象;
synchronized可以修飾非靜態(tài)方法或非靜態(tài)代碼塊,同步鎖是所在類的當(dāng)前對象this;
synchronized可以修飾非靜態(tài)代碼塊,同步鎖是任意Object對象;

注意,方法上鎖,要么this要么字節(jié)碼對象,不能Object對象就好。

一共五種,小結(jié)如下表格:

image.png

4.4 附加1:用synchronized解決count+1問題(鎖:資源從1:1到N:1)

4.4.1 鎖和受保護(hù)資源的關(guān)系是1:1關(guān)系(使用層面,不重要,兩個鎖保護(hù)一個資源,兩個臨界區(qū)沒有互斥關(guān)系)

class SafeCalc {
    long value = 0L;

    synchronized long get() {
        return value;      // 獲取臨界資源用synchronized包裹一次,讀內(nèi)存變量是一步操作,但是這里是long,long和double是64位,每次讀32位,所有就不是原子操作了,所以要用synchronized包裹一次,防止被另一個線程調(diào)用addOne()打斷
    }

    synchronized void addOne() {
        value += 1;     // value=value+1,三步操作,一定要用synchronized,防止被另一個線程調(diào)用get()打斷
    }
 }

大體的模型如下:

在這里插入圖片描述

線程并發(fā)兩要素:多線程,同步鎖,這里一個鎖鎖住一個變量value,起到原子化的作用,所以沒有并發(fā)安全問題。

4.4.2 鎖和受保護(hù)資源的關(guān)系是N:1關(guān)系(使用層面,不重要,兩個鎖保護(hù)一個資源,兩個臨界區(qū)沒有互斥關(guān)系)

受保護(hù)資源和鎖之前的關(guān)聯(lián)關(guān)系是N:1的關(guān)系。

重寫修改上面的代碼

class SafeCalc {
    long value = 0L;

    synchronized long get() {
        return value;
    }

    synchronized static void addOne() {
        value += 1;
    }
}

大體的模型如下:

在這里插入圖片描述

注意:這個時候的問題就是兩個鎖保護(hù)一個資源。因此這兩個臨界區(qū)沒有互斥關(guān)系,臨界區(qū)addOne()對value的修改對臨界區(qū)get()也沒有可見性保證,這就導(dǎo)致并發(fā)問題

線程并發(fā)兩要素:多線程,同步鎖,這里兩個鎖鎖住一個變量value,沒有起到隔離作用,所以有并發(fā)安全問題。

4.4.3 鎖:資源=1:N,用一把鎖保護(hù)多個資源(使用層面,但是使用得少,屬于同步鎖對象高級用法,面試有區(qū)分度,重要)

4.4.3.1 一把鎖保護(hù)多個資源:保護(hù)沒有關(guān)聯(lián)關(guān)系的多個資源

賬號余額和賬號密碼沒有關(guān)聯(lián)關(guān)系

class Account {
    // 鎖:保護(hù)賬戶余額
    private final Object balLock = new Object();
    // 賬戶余額
    private Integer balance;
    // 鎖:保護(hù)賬戶密碼
    private final Object pwLock = new Object();
    // 賬戶密碼
    private String password;

    // 取款
    void withdraw(Integer amt) {
        synchronized (balLock) {
            if (this.balance > amt) {
                this.balance -= amt;
            }
        }
    }

    // 查看余額
    Integer getBalance() {
        synchronized (balLock) {
            return balance;
        }
    }

    // 更改密碼
    void updatePassword(String pw) {
        synchronized (pwLock) {
            this.password = pw;
        }
    }

    // 查看密碼
    String getPassword() {
        synchronized (pwLock) {
            return password;
        }
    }
}

4.4.3.2 造成問題:一把鎖保護(hù)多個資源:保護(hù)有關(guān)聯(lián)關(guān)系的多個資源(銀行轉(zhuǎn)賬的問題)

class Account {
    private int balance;

    // 轉(zhuǎn)賬
    synchronized void transfer(Account target, int amt) {
        if (this.balance > amt) {
            this.balance -= amt;
            target.balance += amt;
        }
    }
}

大體的模型如下:

在這里插入圖片描述

上面的代碼的問題出在this這把鎖上,this這把鎖可以保護(hù)自己余額this.balance,卻保護(hù)不了別人的余額target.balance,就像你不能用自家的鎖來保護(hù)別人家的資產(chǎn),也不能用自己的票保護(hù)別人的座位一樣。

4.4.3.3 解決方式:一把鎖保護(hù)有關(guān)聯(lián)關(guān)系的多個資源(銀行轉(zhuǎn)賬的問題)

再次修改上面的代碼(下面的代碼存在串行化的操作)

class Account {
    private int balance;

    // 轉(zhuǎn)賬
    synchronized void transfer(Account target, int amt) {
        synchronized(Account.class){ 
             if (this.balance > amt) {
                 this.balance -= amt;
                 target.balance += amt;
            }
        }
    }
}

總結(jié):首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。
原子性的本質(zhì):操作的中間狀態(tài)對外不可見。

金手指:如果使用一把鎖保護(hù)多個資源?
首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。

4.5 附加2:死鎖問題

4.5.1 死鎖的引入:銀行轉(zhuǎn)賬的問題

對于上面,使用synchronize加一個this鎖,可以保護(hù)this.balance但是無法保護(hù)target.balance,所以內(nèi)部加一個synchronized(Xxx.class)靜態(tài)鎖,解決了這個問題,但是在現(xiàn)實生活中,沒有辦法鎖住整個字節(jié)碼對象。

在現(xiàn)實生活中,假設(shè)銀行在給我能做轉(zhuǎn)賬時,要去文件架上把轉(zhuǎn)出賬本和轉(zhuǎn)入賬本都拿到手,然后做轉(zhuǎn)賬。會出現(xiàn)三種情況

(1)文件架上恰好有轉(zhuǎn)出賬本和轉(zhuǎn)入賬本,那就同時拿走;

(2)如果文件加上只有轉(zhuǎn)出和轉(zhuǎn)入賬本之一,那這個柜員就先把文件架上有賬本拿到手,同時等著其他柜員把另一個賬本送回來;

(3)轉(zhuǎn)出賬本和轉(zhuǎn)入賬本都沒有,那這個柜員就等著兩個賬本都被送回來。

即現(xiàn)實生活中使用兩個鎖對象synchronized(this)和synchronized(target)來加鎖兩個資源this.balance和target.balance,不能使用synchronized(Xxx.class),這種靜態(tài)鎖只有理論意義,在現(xiàn)實中模擬不出來,真正模擬出來就是同時鎖住兩個賬本,但現(xiàn)實中一般不這么做。

于是我們將代碼改成如下:

class Account {
    private int balance;

    // 轉(zhuǎn)賬
    synchronized void transfer(Account target, int amt) {
        //鎖定轉(zhuǎn)出賬戶
        synchronized(this){
            //鎖定轉(zhuǎn)入賬戶
            synchronized(target){
                 if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

這樣的代碼會發(fā)生新的問題,就是死鎖的問題

大體模型為:


在這里插入圖片描述

4.5.2 死鎖:銀行轉(zhuǎn)賬的問題,兩次加鎖產(chǎn)生死鎖

金手指1:兩次加鎖給死鎖創(chuàng)造了條件
代碼中兩次加鎖就會給死鎖創(chuàng)造條件,代碼中避免這樣寫,如果真的這樣寫了,處理處理呢?

概念:一組互相競爭資源的線程互相等待,導(dǎo)致“永久”阻塞的現(xiàn)象。

class Account {
    private int balance;

    // 轉(zhuǎn)賬
    synchronized void transfer(Account target, int amt) {
        //鎖定轉(zhuǎn)出賬戶
        synchronized(this){//①
            //鎖定轉(zhuǎn)入賬戶
            synchronized(target){//②
                 if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

如何發(fā)生死鎖(上面的代碼)
(1)假設(shè)線程T1執(zhí)行賬戶A轉(zhuǎn)賬賬戶B的操作,賬號A.transfer(賬戶B);
(2)同時線程T2執(zhí)行賬戶B轉(zhuǎn)賬賬戶A的操作,賬戶B.transfer(賬戶A);
(3)當(dāng)T1和T2同時執(zhí)行完①處的代碼時,T1獲得了賬戶A的鎖(對于T1,this是賬戶A)而T2獲得了賬戶B的鎖(對于T2,this是賬戶B)
(4)之后T1和T2在執(zhí)行完②處的代碼時,T1試圖獲取賬戶B的鎖時,發(fā)現(xiàn)賬戶B已經(jīng)被鎖定(被T2鎖定),所以T1開始等待
(5)T2則試圖獲取賬戶A的鎖時,發(fā)現(xiàn)賬戶A已經(jīng)被鎖定(被T1鎖定),所以T2也開始等待

死鎖的產(chǎn)生條件
(1)互斥,共享資源X和Y只能被一個線程占用;
(2)占有且等待,線程T1已經(jīng)取得共享資源X,在等待共享資源Y的時候,不釋放共享資源X
(3)不可搶占,其他線程不能強(qiáng)行搶占線程T1占有的資源
(4)循環(huán)等待,線程T1等待線程T2占有的資源,線程T2等待線程T1占有的資源,就是循環(huán)等待。

4.5.3 解決方式:死鎖解決辦法(破壞四個條件的其中之一)

4.5.3.1 最常見:破壞占有且等待條件(一次性申請所有資源)

class Allocator {
    private List<Object> als = new ArrayList<>();    // 僅僅一個局部變量而已,沒有用
    // 一次性申請所有資源  synchronized加鎖,原子化 als.add(from); als.add(to);
    synchronized boolean apply(Object from, Object to){
        if(als.contains(from) || als.contains(to)){
            return false;
        } else {
            als.add(from);
            als.add(to);
        }
        return true;
    }
    // 一次性歸還所有資源  synchronized解鎖,原子化 als.remove(from); als.remove(to);
    synchronized void free(Object from, Object to){
        als.remove(from);
        als.remove(to);
    }
}
class Account {
    // actr 應(yīng)該為單例
    private Allocator actr;
    private int balance;
    // 轉(zhuǎn)賬
    void transfer(Account target, int amt){
        // 一次性申請轉(zhuǎn)出賬戶this和轉(zhuǎn)入賬戶target,直到成功 
        while(!actr.apply(this, target));      
        try{
            // 鎖定轉(zhuǎn)出賬戶
            synchronized(this){
                // 鎖定轉(zhuǎn)入賬戶
                synchronized(target){
                    if (this.balance > amt){
                        this.balance -= amt;
                        target.balance += amt;
                    }
                }
            }
        } finally {
            actr.free(this, target)
        }
    }
}

解釋上面的代碼:

  1. 從Account類來看:會造成死鎖的代碼(synchronized(this) synchronized(target) )放在了while(!actr.apply(this, target)); 和 actr.free(this, target);中間,包裹一起來。
    while(!actr.apply(this, target));加鎖,使用synchronized保證只有一個線程進(jìn)來,在該線程執(zhí)行完成之前,其他的線程都不能進(jìn)來,然后執(zhí)行會發(fā)生死鎖的代碼(只有一個線程就不會死鎖了),執(zhí)行完成后解鎖。

  2. 從Allocator類來看:同時申請資源apply()和同時釋放資源free()。賬戶Account類里面持有一個Alloctor的單例(必須是單例,只能由一個人分配資源)。當(dāng)賬戶Account在執(zhí)行轉(zhuǎn)賬的時候,首先向Allocator同時申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源,成功后再鎖定這兩個資源,當(dāng)轉(zhuǎn)賬執(zhí)行完,釋放鎖之后,我們需要通知Allocator同時釋放轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源。

問題:自旋帶來的cpu消耗
一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
while(!actr.apply(this, target));
如果上面的代碼的執(zhí)行時間非常短,這個方案是可行的,如果操作耗時長,這樣CPU的空轉(zhuǎn)的時間會大大的提升。

解決辦法(等待-通知機(jī)制)
如果線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同在文件架上)不滿足,則線程阻塞自己,進(jìn)入等待狀態(tài);當(dāng)線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同時在文件架上)滿足后,通知等待的線程重新執(zhí)行。
一個完整的等待-通知機(jī)制:
線程首先獲取互斥鎖,當(dāng)線程要求的條件不滿足時,釋放互斥鎖,進(jìn)入等待狀態(tài);當(dāng)要求的條件滿足時,通知等待的線程,重新獲取互斥鎖。

4.5.3.2 破壞不可搶占條件

能夠主動釋放它占有的資源。synchronized不支持,后面會說JUC下面的包會支持

4.5.3.3 破壞循環(huán)等待條件,按序申請資源

破壞這個條件,需要對資源進(jìn)行排序,然后按序申請資源

class Account {
    private int id;
    private int balance;
    // 轉(zhuǎn)賬
    void transfer(Account target, int amt){
        Account left = this; //①   
        // 就是對申請資源定義順序,任何線程都按這個順序來,如下:
        
        // 設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,
        // 符合條件就交換,這里不符合條件
        // 最后先加鎖left A線程,再加鎖right B線程
        
        // 設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,
        // 符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right
        // 最后先加鎖left A線程,再加鎖right B線程
        
        Account right = target; //②
        if (this.id > target.id) { //③
            left = target; //④   
            right = this; //⑤
        } //⑥
        // 鎖定序號小的賬戶
        synchronized(left){
            // 鎖定序號大的賬戶
            synchronized(right){
                if (this.balance > amt){
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

將原來的

     synchronized(this){
           synchronized(target){

修改為

     synchronized(left){
            synchronized(right){

如何來按照順序申請資源,即對申請資源定義順序,任何線程都按這個順序來,如下:
第一,設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,符合條件就交換,這里不符合條件,最后先加鎖left A線程,再加鎖right B線程;
第二,設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right,最后先加鎖left A線程,再加鎖right B線程。

4.6 附加3:線程通信,synchronized等待隊列:wait()、notify()、notifyAll()

當(dāng)一個線程進(jìn)入臨界區(qū)后,由于某些條件不滿足(解釋:就是我們的標(biāo)志位不滿足),需要進(jìn)入等待狀態(tài)。注意,這個等待隊列和互斥鎖是一對一的關(guān)系,每個互斥鎖都有自己獨立的等待隊列。

第一,當(dāng)調(diào)用wait()方法后,當(dāng)前線程就會被阻塞,并且當(dāng)前線程會進(jìn)入到右邊的等待隊列中,這個等待隊列也是互斥鎖的等待隊列。線程在進(jìn)入等待隊列的同時,會釋放持有的互斥鎖,線程釋放后,其他線程就會有機(jī)會獲得鎖,并進(jìn)入臨界區(qū)。

第二,當(dāng)條件滿足時調(diào)用notify(),會通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經(jīng)滿足過,將該線程出等待隊列。

注意:因為notify()只能保證在通知時間點,條件是滿足的。但是,被通知線程的執(zhí)行時間和通知的時間點基本不會重合,所以當(dāng)被通知的線程執(zhí)行的時候,很可能條件已經(jīng)不滿足了(就是被其他同類競爭者線程插隊了),被通知的線程要重新執(zhí)行,仍然需要獲取到互斥鎖(因為插入到等待隊列的時候已經(jīng)釋放同步鎖了),notify()僅僅幫助這個線程從等待隊列中逃離了而已。

大體模型如下:

在這里插入圖片描述

即:wait()將一個線程進(jìn)入等待隊列,阻塞,不參與同步鎖的競爭;
notify()將一個線程退出等待隊列,喚醒,重新參與同步鎖的競爭。

重寫剛才的例子

class Allocator {
    private List<Object> als;
    // 一次性申請所有資源
    synchronized void apply(Object from, Object to){
        // 經(jīng)典寫法
        while(als.contains(from) ||als.contains(to)){
            try{
                wait();   // 這里wait()
            }catch(Exception e){
            }
        }
        als.add(from);
        als.add(to);
    }
    // 歸還資源
    synchronized void free(Object from, Object to){
        als.remove(from);
        als.remove(to);
        notifyAll();   // 這里多加一個notifyAll()
    }
}

金手指:

 // 一次性申請所有資源  synchronized加鎖
    synchronized boolean apply(Object from, Object to){
        if(als.contains(from) || als.contains(to)){
            return false;
        } else {
            als.add(from);
            als.add(to);
        }
        return true;
    }
    // 一次性歸還所有資源  synchronized解鎖
    synchronized void free(Object from, Object to){
        als.remove(from);
        als.remove(to);
    }

tip1:lock有公平鎖和非公平鎖,synchronized只有非公平鎖,lock實現(xiàn)公平加鎖的底層是同步隊列AQS,不是非循環(huán)單鏈表等待隊列,等待隊列synchronized也有。
tip2:
在lock中,同步隊列FIFO保證加鎖公平,等待隊列FIFO保證阻塞喚醒公平;
在synchronized中,加鎖解鎖是搶占式非公平的,但是等待隊列FIFO保證喚醒阻塞是公平,但是喚醒不保證可以公平地競爭到同步鎖。

五、總結(jié)(宏觀的角度)

5.1 存在共享數(shù)據(jù)并且該數(shù)據(jù)會發(fā)生變化,通俗地講就是有多個線程同時讀寫同一個數(shù)據(jù)。

5.2 并發(fā)的問題主要是三個方面:安全性問題、活躍性問題、性能問題

5.2.1 安全性問題

1、本質(zhì)的問題:程序按照我們期望的執(zhí)行。

2、數(shù)據(jù)競爭:當(dāng)多個線程同時訪問同一數(shù)據(jù),并且至少有一個線程會寫這個數(shù)據(jù)的時候,如果我們不采取防護(hù)措施,那么就會導(dǎo)致并發(fā)的Bug。

3、競態(tài)條件:程序的執(zhí)行結(jié)果依賴線程執(zhí)行的順序。

4、上面的兩種問題,都可以通過互斥這個技術(shù)方案,而實現(xiàn)互斥的方案有很多,CPU提供了相關(guān)的互斥指令,操作系統(tǒng)、編程語言也會提供相關(guān)的API鎖。

5.2.2 活躍性問題

1、死鎖:線程會互相等待,而且會一直等待下去,在技術(shù)上表現(xiàn)就是線程永久的阻塞

2、活鎖:線程沒有阻塞,但仍然會存在執(zhí)行不下去的情況。(嘗試等待一個隨機(jī)時間就可以了)

3、 饑餓:線程因無法訪問所需資源而無法執(zhí)行下去的情況。(不患寡,而患不均)(解決辦法:保證資源充足,公平地分配資源,避免持有鎖的線程長時間執(zhí)行。)

5.2.3 性能問題

1、盡量使用無鎖 算法和數(shù)據(jù)結(jié)構(gòu)

2、減少鎖持有的時間

3、衡量標(biāo)準(zhǔn)
(1)吞吐量:單位時間內(nèi)能處理的請求數(shù)量
(2)延遲:從發(fā)出請求到收到響應(yīng)的時間
(3)并發(fā)量:同時處理的請求數(shù)量,隨著并發(fā)量的增加、延遲也會增加。)

六、面試金手指(原理層面:談一談你對并發(fā)的了解)

6.1 優(yōu)化性能造成線程三個特性的破壞

CPU與內(nèi)存:緩存,CPU增加了緩存,以均衡與內(nèi)存的差異;(但是帶來可見性問題)
CPU與IO設(shè)備:進(jìn)程、線程,操作系統(tǒng)增加了進(jìn)程、線程,以分時復(fù)用CPU,進(jìn)而均衡CPU與I/O設(shè)備的速度差異;(但是帶來原子性問題)
編譯程序指令重排序,編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理的利用。(但是帶來有序性問題)

6.2 解釋緩存是如何造成可見性問題的,cpu分時時間片是如何造成原子性問題的,指令重排序是如何造成有序性問題的

金手指:一句話小結(jié)緩存在多線程下的可見性問題
1、單核cpu,只有一個cpu,所以只有一個緩存,所有的內(nèi)存都是使用這個緩存,一旦緩存內(nèi)存修改,所有cpu都可見,沒有可見性問題
2、多核cpu,有多個cpu,每一個cpu中一個緩存,所以n個cpu就有n個緩存,n個緩存與同一個主存交互數(shù)據(jù),主存中的數(shù)據(jù)對于所有緩存可見,但是不同緩存之間的數(shù)據(jù)是不可見的
單線程下,緩存從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,沒有問題
多線程下,兩個線程使用兩個不同的cpu,兩個cpu緩存都從主存中拿數(shù)據(jù),運(yùn)算,然后再存入主存,這個過程中,每一個線程都是基于自己CPU緩存里的count值來計算,但是自己的緩存數(shù)據(jù)的修改只有自己可見,造成可見性問題
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存不可見,所以造成可見性問題。

金手指:一句話解析分時時間片在多線程下的原子性問題
1、如果操作系統(tǒng)不支持分時使用CPU,即必須一個進(jìn)程執(zhí)行完成之后之后,再執(zhí)行下一個進(jìn)程,每一個進(jìn)程的執(zhí)行都不會被打斷,每一個進(jìn)程的執(zhí)行都是原子的,不存在原子性問題。
2、如果操作系統(tǒng)支持分時使用CPU,一個進(jìn)程執(zhí)行讀寫磁盤或者讀寫內(nèi)存的時候,會把CPU讓出來,讓給其他進(jìn)程使用,如果獲得CPU使用權(quán)的進(jìn)程和當(dāng)前進(jìn)程修改同一變量,造成安全問題,該問題歸根結(jié)底是因為打斷了對一個變量讀寫的原子操作。
從 CPU-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的工作內(nèi)存訪問主內(nèi)存的操作無法原子化,所以造成原子性問題。

金手指:一句話小結(jié)指令重排序在多線程下的有序性問題(懶漢式單例的雙層if判斷為例)
1、不使用指令重排序,單線程和多線程下沒有線程安全問題
單線程不必多說,多線程下也是安全的(因為一定要第三步賦值給instance變量)
舉例:線程A執(zhí)行到第二步,假設(shè)被線程B線程切換,當(dāng)然不會被切換,因為我們使用synchronized ,在instance沒有完成賦值操作之前,線程A不會釋放同步鎖,所以線程B根本進(jìn)不來。
所以,按照這個三步驟,可以保證多線程安全
2、使用指令重排序,單線程下沒問題,多線程下線程安全問題
由于instance = new Singleton();底層對應(yīng)的指令重排序,所以線程A第二步就是instance=&M,因為完成了對instance對象的修改,就會釋放同步鎖,就會被切換到線程B,這時instance==&M,因為instance已經(jīng)被賦值修改,不再是null,所以線程B不會再執(zhí)行自己的instance=new Singleton三步驟了,但是線程B看到M內(nèi)存中沒有初始化的Singleton對象,出錯
從 CPU-緩存-主內(nèi)存 到 執(zhí)行引擎-工作內(nèi)存-主內(nèi)存,
對于JMM,就是各個線程之間的執(zhí)行引擎 指令重排序,所以造成有序性問題。

6.3 Java處理可見性和有序性問題(原因、解決方式、volatile、happen-before、final、synchronized)

有序性根本原因:緩存導(dǎo)致可見性,編譯優(yōu)化導(dǎo)致有序性。
有序性解決辦法:禁用緩存和編譯優(yōu)化。如果全部的禁用緩存和編譯優(yōu)化,那我們的程序的性能會很差。我們需要的是按需禁用緩存以及編譯優(yōu)化。

保證可見性不被破壞,就要禁用緩存,強(qiáng)制cpu運(yùn)算器需要數(shù)據(jù)的時候直接從主存中拿,因為主存只有一塊,對所有的線程都是可見的,從主存中拿就可以保證數(shù)據(jù)修改對所有線程可見,就解決了可見性問題。但是,不能對所有的變量都禁用緩存,會效率低下。

Java規(guī)定,對于使用volatile關(guān)鍵字修飾的變量,cpu對其讀寫,強(qiáng)制在主存中操作。但是,哪些變量需要使用volatile關(guān)鍵字修飾呢?這就是Java程序員的工作了,其實就是多線程臨界區(qū)中使用的那些變量,一般只有一個或幾個。

volatile保證可見性:告訴編譯器,對這個變量的讀寫,不能使用CPU緩存,必須從內(nèi)存中讀取和寫入。

  1. 程序次序規(guī)則:在一個線程中,按照程序順序,前面的操作Happens-Before于后續(xù)的任何操作。(解釋:同一個線程中,或單線程下,有序性不變,執(zhí)行順序就是編寫順序)

  2. volatile變量規(guī)則:對一個volatile編程的寫操作,Happens-Before于后續(xù)對這個volatile變量的讀操作。(解釋:volatile強(qiáng)制保證有序性,作為happen-before 8條中的一條)

  3. 傳遞規(guī)則:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。(解釋:沒啥好說的)

  4. 鎖定規(guī)則:對一個鎖的解鎖Happens-Before與后續(xù)對這個鎖的加鎖。(解釋:上一個先解鎖,下一個才能加鎖,為保證一定解鎖,解鎖一般放在finally里面)

  5. 線程啟動規(guī)則:如果線程A調(diào)用線程B的start()方法(即在線程A中啟動線程B),那么該start()操作Happens-Before線程B中的任意操作。(解釋:start()在啟動線程之前)

  6. 線程終結(jié)規(guī)則:如果在線程A中,調(diào)用線程B的join()并成功返回,那么線程B中的任意操作Happens-Before于該join()操作的返回。(解釋:join()返回在所有操作之后)

  7. 線程中斷規(guī)則:對線程interrupt()方法的調(diào)用Happens-Before被中斷線程的代碼檢測到中斷事件的發(fā)生(解釋:interrupt()早于中斷線程)

  8. 對象終結(jié)規(guī)則:一個對象的初始化完成Happens-Before他的finalize()方法的開始。(解釋:finalize()一定在結(jié)束對象之前)

JVM中五種線程安全級別:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立,不可變是最高級別的線程安全,和常量一樣,使用final修飾變量就是達(dá)到不可變
final修飾變量是,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優(yōu)化。

附加:synchronized同時保證原子性、有序性、可見性

6.4 Java處理原子性問題(原因、解決方式、兩種方式和保證原子性的原因)

原因:造成操作的原子性被破壞的原因是線程切換

解決方式:禁止CPU中斷(操作系統(tǒng)做線程切換是依賴CPU中斷的,所以禁止CPU發(fā)生中斷能夠禁止線程切換)

Java禁止CPU中斷的兩種方式:同步阻塞synchronized和非同步阻塞CAS

保證原子性的原因:同步阻塞synchronized和非同步阻塞CAS是如何保證原子性的?

  1. 保證加鎖才進(jìn)入臨界區(qū)
    解釋:一定要線程安全才能進(jìn)來 cas和synchronized都可以保證,cas一定要current==real才能進(jìn)來,synchronized一定要獲取到同步鎖才能進(jìn)來
  2. 保證走完臨界區(qū)代碼解鎖
    解釋:里面的不出去,外面的不能進(jìn)來,cas這里return next;之前都沒有改變數(shù)據(jù),所以里面的沒出去,外面的不能進(jìn)來;synchronized也可以保證這一點,里面的不執(zhí)行完不釋放鎖,外面的進(jìn)不來

6.5 附加1:鎖與資源三種對應(yīng)關(guān)系(鎖:資源=1:1 N:1 1:N)

金手指:如果使用一把鎖保護(hù)多個資源?
首先要分析多個資源之間的關(guān)系。
如果資源資源之間沒有關(guān)系,很好處理,每個資源一把鎖就可以了。
如果資源之間有關(guān)聯(lián)關(guān)系,就要選擇一個粒度更大的鎖,這個鎖應(yīng)該能夠覆蓋所有相關(guān)的資源。

6.6 附加2:死鎖問題(四個條件和三個解決方式)

金手指1:兩次加鎖給死鎖創(chuàng)造了條件
代碼中兩次加鎖就會給死鎖創(chuàng)造條件,代碼中避免這樣寫,如果真的這樣寫了,處理處理呢?
三種方式:破壞請求與等待條件(最常用:一次性申請資源)、破壞不可搶占條件(synchronized不支持,不能搶其他人手里的資源)、破壞循環(huán)等待條件(有序申請資源)
其中,互斥條件無法被破壞 (共享資源X和Y只能被一個線程占用,無法改變)

一次性申請資源:

  1. 從Account類來看:會造成死鎖的代碼(synchronized(this) synchronized(target) )放在了while(!actr.apply(this, target)); 和 actr.free(this, target);中間,包裹一起來。
    while(!actr.apply(this, target));加鎖,使用synchronized保證只有一個線程進(jìn)來,在該線程執(zhí)行完成之前,其他的線程都不能進(jìn)來,然后執(zhí)行會發(fā)生死鎖的代碼(只有一個線程就不會死鎖了),執(zhí)行完成后解鎖。

  2. 從Allocator類來看:同時申請資源apply()和同時釋放資源free()。賬戶Account類里面持有一個Alloctor的單例(必須是單例,只能由一個人分配資源)。當(dāng)賬戶Account在執(zhí)行轉(zhuǎn)賬的時候,首先向Allocator同時申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源,成功后再鎖定這兩個資源,當(dāng)轉(zhuǎn)賬執(zhí)行完,釋放鎖之后,我們需要通知Allocator同時釋放轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶這兩個資源。

問題:自旋帶來的cpu消耗
一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
while(!actr.apply(this, target));
如果上面的代碼的執(zhí)行時間非常短,這個方案是可行的,如果操作耗時長,這樣CPU的空轉(zhuǎn)的時間會大大的提升。

解決辦法(等待-通知機(jī)制)
如果線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同在文件架上)不滿足,則線程阻塞自己,進(jìn)入等待狀態(tài);當(dāng)線程要求的條件(轉(zhuǎn)出賬本和轉(zhuǎn)入賬本同時在文件架上)滿足后,通知等待的線程重新執(zhí)行。
一個完整的等待-通知機(jī)制:
線程首先獲取互斥鎖,當(dāng)線程要求的條件不滿足時,釋放互斥鎖,進(jìn)入等待狀態(tài);當(dāng)要求的條件滿足時,通知等待的線程,重新獲取互斥鎖。

破壞循環(huán)等待條件,自定義申請資源順序,任何線程都按這個來

將原來的

     synchronized(this){
           synchronized(target){

修改為

     synchronized(left){
            synchronized(right){

如何來按照順序申請資源,即對申請資源定義順序,任何線程都按這個順序來,如下:
第一,設(shè)A線程id<B線程id,A線程進(jìn)來,A線程為left,B線程為right,符合條件就交換,這里不符合條件,最后先加鎖left A線程,再加鎖right B線程;
第二,設(shè)A線程id<B線程id,B線程進(jìn)來,B線程為left,A線程為right,符合條件就交換:這里A線程設(shè)置為left,B線程設(shè)置為right,最后先加鎖left A線程,再加鎖right B線程。

6.7 附加3:線程通信,synchronized等待隊列:wait()、notify()、notifyAll()

  1. 辨析wait() notify()的底層作用
    wait()將一個線程進(jìn)入等待隊列,阻塞,不參與同步鎖的競爭;
    notify()將一個線程退出等待隊列,喚醒,重新參與同步鎖的競爭。
  1. 辨析lock synchronized的底層區(qū)別
    (1)lock有公平鎖和非公平鎖,synchronized只有非公平鎖,lock實現(xiàn)公平加鎖的底層是同步隊列AQS,不是非循環(huán)單鏈表等待隊列,等待隊列synchronized也有。
    (2)在lock中,同步隊列FIFO保證加鎖公平,等待隊列FIFO保證阻塞喚醒公平;
    在synchronized中,加鎖解鎖是搶占式非公平的,但是等待隊列FIFO保證喚醒阻塞是公平,但是喚醒不保證可以公平地競爭到同步鎖。

6.8 并發(fā)三個問題

安全性問題
1、本質(zhì)的問題:程序按照我們期望的執(zhí)行。
2、數(shù)據(jù)競爭:當(dāng)多個線程同時訪問同一數(shù)據(jù),并且至少有一個線程會寫這個數(shù)據(jù)的時候,如果我們不采取防護(hù)措施,那么就會導(dǎo)致并發(fā)的Bug。
3、競態(tài)條件:程序的執(zhí)行結(jié)果依賴線程執(zhí)行的順序。
4、上面的兩種問題,都可以通過互斥這個技術(shù)方案,而實現(xiàn)互斥的方案有很多,CPU提供了相關(guān)的互斥指令,操作系統(tǒng)、編程語言也會提供相關(guān)的API鎖。

活躍性問題
1、死鎖:線程會互相等待,而且會一直等待下去,在技術(shù)上表現(xiàn)就是線程永久的阻塞
2、活鎖:線程沒有阻塞,但仍然會存在執(zhí)行不下去的情況。(嘗試等待一個隨機(jī)時間就可以了)
3、 饑餓:線程因無法訪問所需資源而無法執(zhí)行下去的情況。(不患寡,而患不均)(解決辦法:保證資源充足,公平地分配資源,避免持有鎖的線程長時間執(zhí)行。)

性能問題
1、盡量使用無鎖 算法和數(shù)據(jù)結(jié)構(gòu)
2、減少鎖持有的時間
3、衡量標(biāo)準(zhǔn)
(1)吞吐量:單位時間內(nèi)能處理的請求數(shù)量
(2)延遲:從發(fā)出請求到收到響應(yīng)的時間
(3)并發(fā)量:同時處理的請求數(shù)量,隨著并發(fā)量的增加、延遲也會增加。)

七、小結(jié)

原理層面:Java并發(fā)的硬件底層支持,完成了。

天天打碼,天天進(jìn)步!!!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。