天天看點

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

并發程式設計

  • 并發程式設計
    • 線程發展史
      • 1、程序
      • 2、線程
      • 3、協程
      • 5、面試
    • 線程基礎知識
      • 1、線程的建立
      • 2、線程的狀态
      • 3、線程中斷/打斷
        • 1、線程中斷的核心API
        • 2、interrupt 遇到 wait,join,sleep
        • 3、interrupt 遇到 synchronized/lock
      • 4、線程退出
      • 5、面試
    • 線程三大特性
      • 1.1、CPU緩存導緻可見性問題
      • 1.2、線程切換導緻原子性問題
      • 1.3、性能優化導緻有序性問題
      • 2、JMM(Java Memory Model)
        • 2.1、主記憶體與工作記憶體
        • 2.2、JMM解決什麼問題?
        • 2.3、JMM記憶體互動
        • 2.4、Happens-Before
    • 3、可見性解決方案
      • 3.1、volatile
      • 3.2、synchronized
      • 3.3、final
    • 4、有序性解決方案
      • 4.1、volatile
      • 4.2、記憶體屏障
    • 5、原子性解決方案
      • 5.1、synchronized
      • 5.2、解決 i+=1 問題
      • 5.3、鎖和資源的關系
      • 5.4、死鎖
      • 5.5、如何預防死鎖
        • 破壞保持和等待
        • 破壞不可被剝奪/搶占
        • 破壞循環等待
    • 6、安全,活躍,性能問題
    • 7、面試
    • CAS
    • synchronized
      • 0、Linux核心同步機制
      • 1、synchronized多層面解讀
        • 1.1、源碼層面
        • 1.2、位元組碼層面
        • 1.3、jvm層面
      • 2、markword
      • 3、鎖更新
      • 4、鎖消除,鎖粗化
      • 5、底層彙編
    • Unsafe

并發程式設計

線程發展史

1、程序

對于線程的發展問題,我們首先要從作業系統講起,因為作業系統的發展

帶來了軟體層面的變革。 從多線程的發展來看,可以作業系統的發展分為三個

曆史階段:

  • 真空管和穿孔卡片(單程序人工切換)

程式員首先把程式寫到紙上,操作員将紙片上的程式輸入到計算機上,計算機運作完目前的任務以後,把計算結果從列印機上進行輸出,操作員把列印出來的結果交給程式員。然後,操作員再繼續另一個任務重複上述的步驟

弊端:操作員來回排程資源,計算機存在大量空閑狀态,資源浪費!!!

  • 半導體和批處理系統(多程序批處理)

操作員收集全部的作業,把它們讀取到錄音帶上,把錄音帶輸入到計算機,計算機通過讀取錄音帶的指令來進行運算,最後把結果輸出錄音帶上,好處在于,計算機會一直處于運算狀态,合理的利用了計算機資源

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

弊端:批處理作業系統雖然能夠解決計算機的空閑問題,但是當某一個作業因為等待磁盤或者其他I/O操作而暫停,那CPU就隻能阻塞直到該I/O完成,對于CPU操作密集型的程式,I/O操作相對較少,是以浪費的時間也很少。但是對于I/O操作較多的場景來說,CPU的資源是屬于嚴重浪費的。

  • 內建電路和多道程式設計(多程序并行處理)

    把記憶體分為幾個部分,每一個部分放不同的程式。當一個程式需要等待I/O操作完成時。那麼CPU可以切換執行記憶體中的另外一個程式。如果記憶體中可以同時存放足夠多的程式,那CPU的使用率可以接近100%

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

在這個時候,引入了第一個概念- 程序 , 程序的本質是一個正在執行的程

序,程式運作時系統會建立一個程序,并且給每個程序配置設定獨立的記憶體位址空

間保證每個程序位址不會互相幹擾(程序是作業系統配置設定資源的最小機關)。

同時,在CPU對程序做時間片的切換時,保證程序切換過程中仍然要從進

程切換之前運作的位置出開始執行。是以程序通常還會包括程式計數器、堆棧

指針等

時間片:作業系統配置設定給每個正在運作的程序/線程 的一段CPU時間。

有了程序以後,可以讓作業系統從宏觀層面實作多應用并發。而并發的實作是通過CPU時間片不斷切換執行的。對于單核CPU來說,在任意一個時刻隻會有一個程序在被CPU排程

2、線程

有了程序之後,為什麼還要有線程呢?

一個應用程序中,也會存在多個同時執行的任務,如果其中一個任務被阻塞,将會引起不依賴該任務的任務也被阻塞。

舉個例子:我們在用word文檔編輯内容的時候,都會有一個自動儲存的功能,假設word的自動儲存因為磁盤問題導緻寫入較慢,勢必會影響到使用者的文檔編輯功能,直到磁盤寫入完成使用者才可編輯,這種體驗是很差的。我們希望的是:word自動儲存和使用者編輯之間是隔離開的,互不影響。

如果我們把一個程序中的多個任務通過線程的方式進行隔離,那麼按照前面提到的程序演進的理論來說,在單核CPU架構中可以通過CPU的時間片切換來實作不同任務的排程,以此做到充分利用CPU資源以達到最大的性能。其核心思想就是分時複用CPU

從性能上考慮,如果程序中存在大量的I/O處理,通過多線程能夠加快應用程式的執行速度。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

是以現在對線程總結如下:

  • 線程可以認為是輕量級的程序,是以線程的建立、銷毀要比程序更快
  • 線程是CPU的最小排程單元/最小執行單元(核心),同一程序下的所 有線程共享該程序的資源,在多核CPU架構中能夠實作真正的并行執行,在單核CPU架構中也能做到多個線程分時複用CPU

并行和并發:

并行:同時執行多個任務,在多核心CPU架構中才能真正實作并行,單核CPU架構中隻是宏觀是,實際上是串行的

并發:同時處理多個任務的能力,通常我們會通過TPS或者QPS來表

示某某系統支援的并發數是多少

Erlang之父Joe Armstrong通過一張圖型的方式來解釋并發和并行的 差別,圖檔如下

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

作業系統線程模型

在作業系統中,線程可以實作在使用者模式下,也可以實作在核心模式下,也可以兩者結合實作。

這裡首先要了解使用者模式/核心模式,給出一個圖簡單了解一下

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

1、線程實作在使用者空間

當線程在使用者空間下實作時,作業系統對線程的存在一無所知,作業系統隻能看到程序,而不能看到線程。所有的線程都是在使用者空間實作,線程的管理由應用程式完成,即使用者空間中的線程庫來完成。

優勢是:

  • 線程切換無須經過核心,
  • 允許程序按照應用的特定需要選擇排程算法,且線程庫的線程排程算法
  • 與作業系統的低級排程算法無關,
  • 能夠運作在任何作業系統上,核心無須做任何改變

弊端:

某個使用者級線程的阻塞可能導緻整個程序阻塞,無法享受到多線程的紅利。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

2、線程實作在作業系統核心中

在這種模式下,作業系統知道線程的存在,線程的管理工作都由核心來完成,由核心所提供的線程API來使用線程。

優勢:

在多處理器上,核心能夠同時排程同一程序中的多個線程并行執行 。若程序中的一個線程被阻塞,核心能夠排程同一程序的其他線程占有處理器運作,也可以運作其他程序中的線程 。

弊端:

在使用者态使用線程時,由于線程的排程和管理在核心實作 。線程需要使用者态→核心态→使用者态的模式切換,系統開銷較大

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

hotspot采用的就是作業系統核心線程。

3、混合式

作業系統既支援使用者級線程,又支援核心級線程

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

何為線程的上下文切換?(Context Switch)

CPU 在切換前把目前線程的狀态儲存下來,以便下次切換回這個線程時可以再次加載這個線程的狀态,然後加載下一線程的狀态并執行。線程的狀态儲存及再加載, 這段過程就叫做上下文切換。

嚴格來說,如果單純指上下文切換應包含多種情況:

1、線程内由于發生系統調用,CPU要儲存使用者态資料,切換到核心态執行,完事再切回來接着執行

2、程序切換

3、同一程序内的不同線程切換

4、不同程序的線程間切換

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

3、協程

被稱為綠色的線程,它屬于使用者管理而不是作業系統(OS)管理的,作業系統并不知道協程,它隻知道線程,協程是線上程之上的。

比如:作業系統線上程等待IO的時候,會阻塞目前線程,切換到其它線程,這樣在目前線程等待IO的過程中,其它線程可以繼續執行。當系統線程較少的時候沒有什麼問題,但是當線程數量非常多的時候,卻産生了問題。

  • 一是系統線程會占用非常多的記憶體空間,
  • 二是過多的線程切換會占用大量的系統時間,耗費很多的系統資源

協程剛好可以解決上述2個問題。協程運作線上程之上,當一個協程執行完成後,可以選擇主動讓出,讓另一個協程運作在目前線程之上。協程并沒有增加線程數量,隻是線上程的基礎之上通過分時複用的方式運作多個協程,而且協程的切換在使用者态完成,切換的代價比線程從使用者态到核心态的代價小很多。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

5、面試

1、什麼是程序?

2、什麼是線程?

3、什麼是協程?

4、什麼是應用程式?

5、單核CPU設定多線程是否有意義?工作線程數是不是設定的越大越好?工作線程數(線程池中的線程數)設定多少合适?

對于要執行的任務,可大緻分為兩類:CPU密集型和IO密集型

CPU密集型:更多的是在做一些邏輯運算,較少的時候在IO等待上,這種使用多線程本質上是提升多核 CPU 的使用率,是以對于一個 4 核的 CPU,每個核一個線程,理論上建立 4 個線程就可以了,再多建立線程也隻是增加線程切換的成本。是以,對于 CPU 密集型的計算場景,理論上“線程的數量 =CPU 核數”就是最合适的。不過在工程上,線程的數量一般會設定為“CPU 核數 +1”,這樣的話,當線程因為偶爾的記憶體頁失效或其他原因導緻阻塞時,這個額外的線程可以頂上,進而保證 CPU 的使用率。

IO密集型:較多的時間在IO等待上,CPU參與的較少,故在單核CPU上設定多線程對這類任務的執行是有好處的,可以提高CPU的使用率。對于工作線程數的選擇,也并非越大越好,因為線程數越多,線程的上下文切換帶來的資源消耗也是需要考慮的,至于設定多少合适,有如下幾種選擇壓測,結合機器的配置不斷壓測得出具體的資料,一般這個線程數跟CPU的核數有關系

理論公式

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

線程基礎知識

1、線程的建立

1、繼承Thread

public class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println(("MyThread "+Thread.currentThread().getName()+" is running!"));
    }

    public static void main(String[] args) {
        new MyThread().start();
    }
}
           

2、實作Runnable

public class MyRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println("MyTask "+Thread.currentThread().getName()+" is running");
    }

    public static void main(String[] args) {
        new Thread(new MyRunnable()).start();
    }
}
           

3、使用 lambda 表達式

new Thread(()->{
            System.out.println("lambda runnable "+Thread.currentThread().getName()+" is running");
        }).start();
           

4、使用線程池

public static void main(String[] args) throws Exception{
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        executorService.execute(()->{
            System.out.println("ExecutorService Thead "+Thread.currentThread().getName()+" is running");
        });

        Future<String >future =executorService.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("Callable task "+Thread.currentThread().getName()+" is running"); return "success";
            }
        });
        String asyncResult = future.get();
        System.out.println("asyncResult is " +asyncResult);
    }
           

5、不使用線程池,通過new Thread 并獲得異步線程的傳回值,可以使用

FutureTask

public static void main(String[] args) {
        FutureTask<String> futureTask = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("FutureTask Callable task "+Thread.currentThread().getName()+" is running");
                return "FutureTask success";
            }
        });
        Thread thread = new Thread(futureTask);
        thread.start();

        String futureTaskResult = futureTask.get();
        System.out.println("futureTaskResult is " +futureTaskResult);
    }
           

FutureTask既可以執行,又能将執行結果裝到自己裡面

public class FutureTask<V> implements RunnableFuture<V> 
{...} 
public interface RunnableFuture<V> extends Runnable, 
Future<V> {...} 
           

其實:向線程池通過submit送出Callable任務時,底層也是将其包裝成一個FutureTask

//
    java.util.concurrent.AbstractExecutorService#submit(java.u
                                                                til.concurrent.Callable<T>)
    public <T> Future<T> submit(Callable<T> task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task);
        execute(ftask);
        return ftask;
    }
    protected <T> RunnableFuture<T> newTaskFor(Callable<T>
                                                       callable) {
        return new FutureTask<T>(callable);
    }
           

本質都是 new Thread ,調用 start 方法!!!

2、線程的狀态

線程是作業系統裡的一個概念,雖然各種不同的開發語言如 Java、C# 等都對其進行了封裝,但是萬變不離作業系統。Java 語言裡的線程本質上就是作業系統的線程,它們是一一對應的。

雖然不同的開發語言對于作業系統線程進行了不同的封裝,但是對于線程的生命周期這部分,基本上是雷同的。是以,我們可以先來了解一下通用的線程生命周期模型

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

下面來看java中線程的生命周期,java中線程有6中狀态分别如下:

1、NEW:線程剛剛建立,還沒啟動

2、RUNNABLE:可運作狀态,由線程排程器排程執行,RUNNABLE又可分為兩個部分,READY和RUNNING

3、WAITING:等待被喚醒,處于這種狀态的線程不會被配置設定CPU執行時間,它們要等待被顯式地喚醒,否則會處于無限期等待的狀态。

4、TIMED WAITING:逾時被喚醒,處于這種狀态的線程不會被配置設定CPU執行時間,不過無須無限期等待被其他線程顯示地喚醒,在達到一定時間後它們會自動喚醒

5、BLOCKED:線程等待擷取一個鎖,來繼續執行下一步的操作,比較經典的就是synchronized 關鍵字, synchronized 是需要經過作業系統排程的,隻有這類情況線程狀态才是BLOCKED,其他擷取鎖的情況是WAITING。

6、TERMINATED:線程結束

這6種狀态之間的關系如圖所示:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計
public class C02_ThreadState {

    // NEW ---> RUNNABLE ----> TERMINATED
    public static void t1() throws Exception {
        Thread t1 = new Thread(()->{
            //運作階段
            System.out.println(Thread.currentThread().getState());
            ThreadUtil.sleepSeconds(3);
        });
        // 剛建立未啟動
        System.out.println(t1.getState());
        t1.start();
        // 等待t1的結束
        t1.join();
        System.out.println(t1.getState());
    }

    // WAITING    TIMED_WAITING
    public static void t2() {
        Thread t2 = new Thread(()->{
            LockSupport.park();
            ThreadUtil.sleepSeconds(5);
        });
        t2.start();
        ThreadUtil.sleepSeconds(1);
        System.out.println(t2.getState());

        LockSupport.unpark(t2);
        ThreadUtil.sleepSeconds(1);
        System.out.println(t2.getState());
    }

    // BLOCKED
    public static void t3() {
       Object o = new Object();
       new Thread(()->{
           synchronized (o) { //持有鎖 5s
               ThreadUtil.sleepSeconds(5);
           }
       }).start();

        Thread t3 = new Thread(() -> {
            synchronized (o) { //等待鎖,BLOCKED
                ThreadUtil.sleepSeconds(1);
            }
        });
        t3.start();

        ThreadUtil.sleepSeconds(1);
        System.out.println(t3.getState());
    }

    /**
     * Lock 和 synchronized 在等待鎖時的線程狀态
     *
     * synchronized: BLOCKED (其他情況是WAITING,synchronized是需要經過作業系統排程的,這種阻塞是重量級的,線程狀态是BLOCKED)
     * Lock: WAITING
     */
    public static void t4() {
        Lock lock = new ReentrantLock();
        new Thread(()->{
            try {
                lock.lock();
                ThreadUtil.sleepSeconds(5);
            }finally {
                lock.unlock();
            }
        }).start();

        Thread t4 = new Thread(() -> {
            try {
                lock.lock();
                ThreadUtil.sleepSeconds(1);
            }finally {
                lock.unlock();
            }
        });
        t4.start();
        ThreadUtil.sleepSeconds(1);
        System.out.println(t4.getState());
    }




    public static void main(String[] args) throws Exception {
        //t1();
        //t2();
        //t3();
        t4();
    }


}

           

3、線程中斷/打斷

1、線程中斷的核心API

// java.lang.Thread#interrupt 
public void interrupt() {...}
           

中斷線程,但是這裡僅僅隻是設定了線程的中斷标志位為true,中斷之後線程是結束、還是等待新的任務或是繼續運作至下一步,就取決于這個程式本身(開發者決定)。

// java.lang.Thread#isInterrupted 
public boolean isInterrupted() {...}
           

查詢線程是否被中斷過,即查詢中斷标志位。

interrupt() 結合 isInterrupted() 也是讓線程優雅結束的一種方案

// java.lang.Thread#interrupted 
public static boolean interrupted() {...}
           

靜态方法,查詢目前線程是否被中斷過,并重置中斷标志位。

2、interrupt 遇到 wait,join,sleep

如果線程被Object.wait, Thread.join和Thread.sleep三種方法之一阻塞,

此時調用該線程的interrupt()方法,那麼該線程将抛出一個InterruptedException中斷異常(該線程必須事先預備好處理此異常,由開發者處理),進而提早地終結被阻塞狀态。如果線程沒有被阻塞,這時調用interrupt()将不起作用,直到執行到wait(),sleep(),join()時,才馬上會抛出InterruptedException

這也是一種可以讓線程退出的解決方案。

另外注意:在觸發 InterruptedException 異常的同時,JVM 會同時把線程的中斷标志位清除

3、interrupt 遇到 synchronized/lock

interrupt 能否打斷鎖的競争過程呢?

如果某一線程正在使用synchronized競争鎖,這個過程是無法被打斷的。如果某一線程正在使用Lock(ReentrantLock)競争鎖,得分情況,如果是用 lock.lock() 擷取鎖,則競争過程中無法被打斷,如果是用lock.lockInterruptibly() 擷取鎖,則競争過程中是可以被打斷的。

4、線程退出

結束一個線程有多少種方法?

如果是正常退出,很簡單, run 方法執行結束,線程就退出結束了;但是如果現在正在執行過程中需要結束退出該如何來做?

1、stop

@Deprecated //已廢棄 
public final void stop() {...}
           

這是一種粗暴的線程終止行為,線上程終止之前沒有對其做任何的清除操作,是以具有固有的不安全性。(類似直接拔電源),比如:如果線程持有ReentrantLock 鎖,被 stop() 的線程并不會自動調用 ReentrantLock 的unlock() 去釋放鎖,那其他線程就再也沒機會獲得 ReentrantLock 鎖,這實在是太危險了。

2、suspend+resume

@Deprecated 
public final void suspend() {...} //暫停 
@Deprecated 
public final void resume() {...} //恢複 
           

不建議使用,原因是suspend有死鎖的風險,如果某一線程正持有一把鎖,suspend是不會釋放的,隻有等該線程再次恢複後才有可能釋放,這其中就有死鎖的風險。

3、volatile 共享變量(标志位)(相關變種寫法原理是一樣的)

// volatile 共享變量 
    private static volatile boolean stop = false;
    public static void e3() {
        Thread t3 = new Thread(()->{
            long i= 0;
            while (!stop) {
//do something 
// wait recv accept 
                i++;
            }
            System.out.println("variable i = "+i);
        });
        t3.start();
        ThreadUtil.sleepSeconds(2);//停2s (ThreadUtil是自己封 
        裝的工具類)
        stop = true;
    }

           

這種方式相對優雅,但不足之處在于兩點,一是很難做到精細控制,比如,變量 i 到增到100後退出,像這種依賴某些其他中間變量的狀态來退出的,都很難做到精細的控制;二是時間上也不可控,如果在循環過程中有一些阻塞方法一直未結束阻塞,即使共享變量 stop 變為true了,也無法及時退出。

4、interrupt() + isInterrupted() / Thread.interrupted() / InterruptedException

也是一種标志位的方式,隻不過是線程自己的标志位,跟上面一種是類似

的。

5、面試

1、建立線程是繼承Thread好還是實作Runnable好?

實作Runnable相對較好,實作Runnable還能繼承其他類,操作比較靈活,另外其實 Thread 類本身也是實作了 Runnable 接口,是以繼承 Thread 也是變相實作Runnable接口,還導緻無法繼承其他類,是以為什麼不選擇直接實作Runnable接口呢

public class Thread implements Runnable { 
....... 
} 
           

線程三大特性

從這節開始學習線程的三大特性:

  • 可見性:Visibility
  • 有序性:Ordering
  • 原子性:Atomicity

而這三個特性往往是并發程式設計bug的源頭,而并發程式設計的bug往往也都是疑難雜症,如果想要快速定位這些問題的根源,我們就得了解這些問題的本質,而這些又跟我們底層作業系統和硬體裝置有關系。

1、三大特性的根源

我們知道CPU,記憶體,IO裝置是一台計算機的核心組成部分,三者雖然都在不斷的疊代,不斷的變快,但在這個大家都在發展的曆史長河中一直都存在一個主要沖突:三者之間的速度存在着量級上的差異,我們都知道CPU遠快于記憶體,記憶體遠遠快于IO裝置。

為了合理利用 CPU ,平衡這三者的速度差異,計算機體系結構、作業系統、編譯程式都做出了貢獻,主要展現為:

  • CPU 添加高速緩存,來平衡與記憶體的速度差異;
  • 作業系統支援多程序、多線程,以分時複用 CPU,進而均衡 CPU 與I/O 裝置的速度差異;
  • 編譯程式優化指令執行次序,使得緩存能夠得到更加合理地利用。

而并發程式設計很多bug的根源也都在這裡。

1.1、CPU緩存導緻可見性問題

如下圖所示:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

對于共享變量 i ,首先要将其從記憶體中讀到CPU中,然後對其進行相關操作,如果線程A對其進行了修改操作,線程B能夠立馬看到線程A操作的結果,我們将其稱之為線程之間的可見性。

在單核CPU架構下, 所有的線程都是在一顆 CPU 上執行,因為所有線程都是操作同一個 CPU 的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的。

但是在多核CPU時代,每顆 CPU 都有自己的緩存,當多個線程在不同的CPU 上執行時,這些線程操作的是不同的 CPU 緩存,如下圖:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

線程A對CPU1緩存中的資料進行了修改,線程B不能立馬可見,因為線程B操作的是CPU2的緩存,這就帶來了多個線程操作共享變量時的資料不一緻問題,具體場景見如下代碼

private static boolean running =true;
    
    private static void t1() throws Exception{
        new Thread(()->{
            while (running){
                
            }
            System.out.println("thread exit");
        }).start();

        TimeUnit.SECONDS.sleep(5);
        running=false;
    }
           

1.2、線程切換導緻原子性問題

前面講程序和線程發展史的時候說過,早期計算機是單程序的,後來引入了多程序,這樣即便是在單核CPU上,從宏觀上我們依然可以并發執行多個程式,當然在微觀上是作業系統給每個程序配置設定一個時間片,多個程序分時複用CPU,好處就是不會因為某個程序等待IO而浪費CPU資源,當然帶來的問題是要進行CPU的排程,早期作業系統确實是以程序為機關來排程CPU的,不同程序間是不共享記憶體空間的,是以程序要做任務切換就要切換記憶體映射位址,如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

這種切換屬于一種重量級的切換,現代的作業系統都基于更輕量的線程來排程,而一個程序建立的所有線程,都是共享一個記憶體空間的,是以線程切換的成本相對就很低了,并且線程切換的時機大都是在時間片結束的時候。如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

這裡需要注意的是我們現在一般都使用的是進階程式設計語言,而進階程式設計語言中的一句代碼可能在底層對應着多條CPU的指令,拿java中如下代碼來說:

// 假設i的初始化值為0 
i+=1
           

在底層至少需要三條CPU指令:

1:把變量 i 的值從記憶體 load 到CPU寄存器

2:在CPU中執行+1的操作

3:将結果 store 到記憶體(當然也可能隻存到CPU緩存而沒重新整理到記憶體)

雖然作業系統能保證每條指令執行的時候是具備原子性的,但是作業系統進行線程切換,可以發生在任意一條CPU指令執行完成之後(注意是CPU指令級别)。那這對進階程式設計語言來說多線程并發時就會造成原子性問題,如下圖所示:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性。CPU 能保證的原子操作是 CPU 指令級别的,而不是進階語言的操作符,是以,很多時候我們需要在進階語言層面保證操作的原子性。具體場景見如下代碼

static class AtomicRunnable implements Runnable {
            int i = 0;
            @Override
            public void run() {
                i+=1;
                System.out.println("---"+i);
            }
        }
        public static void main(String[] args) {
            AtomicRunnable runnable = new AtomicRunnable();
            for (int i=0;i<100000;i++) {
                new Thread(runnable).start();
            }
        }
    }

           

1.3、性能優化導緻有序性問題

所謂有序性,很容易想到就是程式按照代碼的先後順序來執行。但是有時候為了提高性能,在不影響最終結果的前提下會優化代碼/指令的執行順序,這裡會有這兩種情況的出現:

編譯優化:

編譯器能夠自由的以優化的名義去改變指令順序,如下:

x=5; 
y=6; 
z=x+y;
           

優化後可能變為

y=6; 
x=5; 
z=x+y;
           

所謂順序,指的是你可以用順序的方式推演程式的執行,但是程式指令的執行不一定是完全順序的。編譯器保證結果一定 等于 順序方式推演的結果

處理器亂序執行:

為了使得處理器内部的運算單元盡量被充分利用,處理器可能會對輸入指令進行亂序執行(Out-Of-Order Execution)優化,也就是說處理器可能會次序颠倒的執行指令。資料可能在寄存器,處理器緩沖區和主記憶體中以不同的次序移動,而不是按照程式指定的順序,而這個是我們看不到也感覺不到的,并且出現了問題也很難重制。

亂序執行技術是處理器為提高運算速度而做出違背代碼原有順序的優化。

  • 單核環境下,處理器保證做出的優化不會導緻執行結果遠離預期目标,

    但在多核環境下卻并非如此。

    多核環境下, 如果存在一個核的計算任務依賴另一個核的計算任務的中間結果,而且對相關資料讀寫沒做任何防護措施,那麼其順序性并不能靠代碼的先後順序來保證。

現舉幾個例子驗證程式會出現編譯優化/亂序執行的現象:

private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws
            Exception {
        for (long i = 0; i < Long.MAX_VALUE; i++) {
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            CountDownLatch latch = new CountDownLatch(2);
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
                latch.countDown();
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
                latch.countDown();
            });
            t1.start();
            t2.start();
            latch.await();
            if (x == 0 && y == 0) {
                System.out.println("第" + i + "次執行結果是 x=0,y=0,執行結束");
                break;
            }
        }
    }
           

當然最終要能出現 x=0,y=0 的情況得看執行的情況,可能要等很長時間或者也可能不會出現、

第1962次執行結果是 x=0,y=0,執行結束
           

2、對象的建立有一個中間狀态

public class C01_NewObject {
    int m = 8;
    public static void main(String[] args) {
        C01_NewObject c = new C01_NewObject();
    }

}
           

對應的位元組碼如下

0 new #3 <com/learning/ts_03_ordering/C01_NewObject> 
3 dup 
4 invokespecial #4 
<com/learning/ts_03_ordering/C01_NewObject.<init> : ()V> 
7 astore_1 
8 return
           

我們能看到在代碼中一句簡單的 new 對象,其實對應着多條位元組碼。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

整個過程大緻可分為這麼幾步:

  • 配置設定一塊記憶體
  • 在記憶體空間上初始化對象
  • 将記憶體空間的位址指派給引用變量

要注意的是,在配置設定完記憶體還未初始化時,對象的執行個體變量是有一個初始預設值的,比如 int 就是0。初始化完成之後執行個體變量才會賦真正的值。

有了這些知識鋪墊之後,我們可以來看在java中一個經典的案例就是利用雙重鎖校驗建立單例對象,比如如下代碼

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


           

這段代碼看似完美,其實有着很大的問題,這個問題就出現在 new 關鍵字上。這個 new 編譯之後大緻對應以下幾個指令操作:

1:配置設定一塊記憶體M

2:在記憶體M上初始化Singleton對象

3:将M的位址指派給instance變量

但是實際經過指令優化之後可能變成這樣:

1:配置設定一塊記憶體M

2:将M的位址指派給instance變量

3:在記憶體M上初始化Singleton對象

優化後會導緻如下這個問題:線程A執行正在 new 建立對象,已經到第二個指令處了,此時線程B來到了第一個判斷所在的指令處,發現 instance 已經不為null,然後将其傳回,這也就導緻了線程B使用了一個未初始化完成的對象,如果在通路該對象的成員變量可能就會造成空指針異常,如下圖

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

補充知識點:線程切換是不會釋放鎖的。

2、JMM(Java Memory Model)

通過上一節我們可大概總結如下:導緻可見性是因為CPU緩存,導緻順序性是因為編譯優化,那也意味着解決可見性和順序性的辦法就是:禁用CPU緩存和編譯優化,但是這樣會導緻程式性能下降嚴重。為此我們不得不做出一個合理的取舍,相對合理的辦法就是:按需禁用CPU緩存和按需優化,也就是按照程式員的意願來做。

這裡就涉及到對于java程式員不得不知的JMM,Java 記憶體模型是個很複雜的規範,我們需要從多個次元來看待:

1、 記憶體模型 這個概念。我們可以了解為:在特定的操作協定下,對特定的記憶體或高速緩存進行讀寫通路的過程抽象。不同架構的實體計算機可以有不一樣的記憶體模型,JVM 也有自己的記憶體模型。

2、JVM 中試圖定義一種 Java 記憶體模型(Java Memory Model, JMM)來屏蔽各種硬體和作業系統的記憶體通路差異,以實作讓 Java 程式 在各種平台下都能達到一緻的記憶體通路效果

3、從開發者角度而言,Java記憶體模型描述了在多線程代碼中哪些行為是合法的,以及線程如何通過記憶體進行互動。它描述了“程式中的變量“ 和 ”從記憶體或者寄存器擷取或存儲它們的底層細節”之間的關系。

Java 記憶體模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來說,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及Happens-Before 規則

2.1、主記憶體與工作記憶體

JMM 的主要目标是 定義程式中各個變量的通路規則,即在虛拟機中将變量存儲到記憶體和從記憶體中取出變量這樣的底層細節。此處的變量(Variables)與Java 程式設計中所說的變量有所差別,它包括了執行個體字段、靜态字段和構成數值對象的元素,但不包括局部變量與方法參數,因為後者是線程私有的,不會被共享,自然就不會存在競争問題。

JMM 規定了所有的變量都存儲在主記憶體(Main Memory)中。

每條線程還有自己的工作記憶體(Working Memory),工作記憶體中保留了該線程使用到的變量的主記憶體的副本。工作記憶體是 JMM 的一個抽象概念,并不真實存在,它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬體和編譯器優化。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

線程對變量的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變量。不同的線程間也無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞均需要通過主記憶體來完成。

注意:為了獲得較好的執行效能,

1、JMM 并沒有限制執行引擎使用處理器的特定寄存器或緩存來和主存進行互動,

2、JMM 也沒有限制即時編譯器調整指令執行順序這類優化措施

2.2、JMM解決什麼問題?

1、工作記憶體資料一緻性:可見性問題

各個線程操作資料時會使用工作記憶體中的主記憶體中共享變量副本,當多個線程的運算任務都涉及同一個共享變量時,可能導緻各自的共享變量副本不一緻。如果真的發生這種情況,資料同步回主記憶體以誰的副本資料為準?

Java 記憶體模型主要通過一系列的資料同步協定、規則來保證資料的一緻性。

2、限制指令重排序優化:有序性問題

Java 中重排序通常是編譯器或運作時環境為了優化程式性能而采取的對指令進行重新排序執行的一種手段。重排序可分為兩類:編譯期重排序和運作期重排序(處理器亂序優化),分别對應編譯時和運作時環境。

同樣的,指令重排序不是随意重排序,它需要滿足以下幾個條件:

  • 在單線程環境下不能改變程式運作的結果。即時編譯器(和處理器)需 要保證程式能夠遵守 as-if-serial 屬性。通俗地說,就是在單線程情 況下,要給程式一個順序執行的假象。即使經過重排序後的執行結果要 與順序執行的結果保持一緻。
  • 存在資料依賴關系的不允許重排序。
  • 多線程環境下,如果線程處理邏輯之間存在依賴關系,有可能因為指令 重排序導緻運作結果與預期不同。

2.3、JMM記憶體互動

JMM 定義了 8 個操作來完成主記憶體和工作記憶體之間的互動操作。JVM 實作時必須保證下面介紹的每種操作都是 原子的(對于 double 和 long 型的變量來說,load、store、read、和 write 操作在某些平台上允許有例外 )。lock (鎖定) - 作用于主記憶體的變量,它把一個變量辨別為一條線程獨占的狀态。

  • unlock (解鎖) - 作用于主記憶體的變量,它把一個處于鎖定狀态的變量 釋放出來,釋放後的變量才可以被其他線程鎖定。
  • read (讀取) - 作用于主記憶體的變量,它把一個變量的值從主記憶體傳輸 到線程的工作記憶體中,以便随後的 load 動作使用。
  • load (載入) - 作用于工作記憶體的變量,它把 read 操作從主記憶體中得到 的變量值放入工作記憶體的變量副本中。
  • use (使用) - 作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳 遞給執行引擎,每當虛拟機遇到一個需要使用到變量的值得位元組碼指令時就會執行這個操作。
  • assign (指派) - 作用于工作記憶體的變量,它把一個從執行引擎接收到 的值賦給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作。
  • store (存儲) - 作用于工作記憶體的變量,它把工作記憶體中一個變量的值 傳送到主記憶體中,以便随後 write 操作使用。
  • write (寫入) - 作用于主記憶體的變量,它把 store 操作從工作記憶體中得 到的變量的值放入主記憶體的變量中。

如果要把一個變量從主記憶體中複制到工作記憶體,就需要按序執行 read 和 load 操作;如果把變量從工作記憶體中同步回主記憶體中,就需要按序執行store 和 write 操作。但 Java 記憶體模型隻要求上述操作必須按順序執行,而沒有保證必須是連續執行。

JMM 還規定了上述 8 種基本操作,需要滿足以下規則:

  1. read 和 load 必須成對出現;store 和 write 必須成對出現。即不允許一個變量從主記憶體讀取了但工作記憶體不接受,或從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  2. 不允許一個線程丢棄它的最近 assign 的操作,即變量在工作記憶體中改變了之後必須把變化同步到主記憶體中。
  3. 不允許一個線程無原因的(沒有發生過任何 assign 操作)把資料從工作記憶體同步回主記憶體中。
  4. 一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load 或 assign )的變量。換句話說,就是對一個變量實施 use 和 store 操作之前,必須先執行過了 load 或 assign 操作。
  5. 一個變量在同一個時刻隻允許一條線程對其進行 lock 操作,但 lock 操作可以被同一條線程重複執行多次,多次執行 lock 後,隻有執行相同次數的 unlock 操作,變量才會被解鎖。是以 lock 和 unlock 必須成對出現。
  6. 如果對一個變量執行 lock 操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前,需要重新執行 load 或 assign 操作初始化變量的值。
  7. 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作,也不允許去 unlock 一個被其他線程鎖定的變量。
  8. 對一個變量執行 unlock 操作之前,必須先把此變量同步到主記憶體中(執行 store 和 write 操作)

    注意:規則6,規則8需要大家留意一下!!!

整體如下圖所示:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

2.4、Happens-Before

Java 記憶體模型裡面,最晦澀的部分就是 Happens-Before 規則了,

Happens-Before 規則最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System 的論文中提出來的,在這篇論文中,HappensBefore 的語義是一種因果關系。

如何來了解Happens-Before呢?如果就字面意思的話網上很多文章都翻譯稱:先行發生,Happens-Before 并不是說前面一個操作發生在後續操作的前面,它真正要表達的是:前面一個操作的結果對後續操作是可見的。

打個比方:A Happens-Before B,可表明A操作的結果對B是可見的。

另外:Happens-Before有一個特性就是傳遞性:即 A Happens-Before B,B Happens-Before C,則 A Happens-Before C .

Happens-Before 限制了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化後一定遵守 Happens-Before 規則,具體的一些規則如下:

1、程式的順序性規則

這條規則是指在一個線程中,按照程式順序(可能是重排序後的順序),前面的操作 Happens-Before 于後續的任意操作,程式前面對某個變量的修改一定是對後續操作可見的

ClassReordering {
        int x = 0, y = 0;
public void writer() {
        x = 1;
        y = 2;
        }
public void reader() {
        int r1 = y;
        int r2 = x;
        }
        } 

           

2、volatile 變量規則

這條規則是指對一個 volatile 變量的寫操作, Happens-Before 于後續對這個 volatile 變量的讀操作。比如下方代碼:

class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    // 線程A 先 
    public void writer() {
        x = 42;
        v = true;
    }
    // 線程B 後 
    public void reader() {
        if (v == true) {
// 這裡x會是多少呢? 
        }
    } 
    }
           

注意:

1、我們聲明一個 volatile 變量 volatile int x = 0,它表達的是:告訴編譯器,對這個變量的讀寫,不能使用 CPU 緩存,必須從記憶體中讀取或者寫入

2、volatile 可以用來解決可見性問題

這裡有兩點:

線程B能看到線程A對變量v的寫結果

結合順序性規則和傳遞性特性可知線上程B中仍然能得到x的值為42

注意:第二點隻有從jdk1.5開始才能滿足,因為Java 記憶體模型在 1.5 版本對 volatile 語義進行了增強(禁止指令重排),1.5以前有可能x的值還為0。

3、管程中鎖的規則

對一個鎖的解鎖 Happens-Before 于後續對這個鎖的加鎖。

當然這裡需要先大緻了解一下什麼是管程:

管程(Monitors,也稱為螢幕),是一種通用的同步原語,能夠實作對共享資源的互斥通路,Java 中指的就是 synchronized,synchronized 是 Java裡對管程的實作。

管程中的鎖在 Java 裡是隐式實作的,例如下面的代碼,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實作的

int x = 10; 
public void syn() { 
synchronized (this) { //此處自動加鎖 
if (this.x < 12) { 
this.x = 12; 
} 
} //此處自動解鎖 
}

           

從這個規則我們可以得出,釋放鎖之後,同步代碼塊中的操作結果對後續加鎖時是可見的。同時結合前面講的JMM記憶體操作可知,unlock時會将變量從工作記憶體刷到主記憶體中,擷取鎖時會從主記憶體中去讀取變量值到工作記憶體中,也能證明鎖的解鎖 Happens-Before 于後續對這個鎖的加鎖。

4、線程啟動規則

它是指主線程 A 啟動子線程 B 後,子線程 B 能夠看到主線程在啟動子線程B 前的操作。

static int var = 66; 
// 主線程A 
public static void t1() { 
Thread B = new Thread(()->{ 
// 主線程調用B.start()之前 
// 所有對共享變量的修改,此處皆可見 
// 此例中,var==77 
}); 
// 此處對共享變量var修改 
var = 77; 
// 主線程啟動子線程 
B.start(); 
} 

           

5、線程join規則

它是指主線程 A 等待子線程 B 完成(主線程 A 通過調用子線程 B 的 join()方法實作),當子線程 B 完成後(主線程 A 中 join() 方法傳回),主線程能夠看到子線程的操作。當然所謂的“看到”,指的是對共享變量的操作結果可見。

static int var = 55;
//主線程A 
public static void t1() {
        Thread B = new Thread(()->{
// 此處對共享變量var修改 
        var = 66;
        });
// 主線程啟動子線程 
        B.start();
//主線程等待子線程B結束 
        B.join()
// 子線程所有對共享變量的修改 
// 在主線程調用B.join()之後皆可見 
// 此例中,var==66 
        } 

           

6、線程中斷規則

對線程 interrupt() 方法的調用 Happens-Before 被中斷線程的代碼檢測到中斷事件的發生,比如我們可以通過Thread.interrupted()/isInterrupted 方法檢測到是否有中斷發生。

7、對象終結規則

一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。

3、可見性解決方案

3.1、volatile

volatile 是 JVM 提供的 最輕量級的同步機制,中文意思是不穩定的,易變的,用 volatile 修飾變量是為了保證變量在多線程中的可見性,它表達的含義是:告訴編譯器,對這個變量的讀寫,不能使用 CPU 緩存,必須從記憶體中讀取或者寫入。

volatile 變量的兩個特性:

保證變量對所有線程的可見性:當一條線程修改了 volatile 變量的值,

新值對于其他線程來說是可以立即得知的。而普通變量不能做到這一點

線程寫 volatile 變量的過程:

  1. 改變線程工作記憶體中 volatile 變量副本的值
  2. 将改變後的副本的值立即從工作記憶體重新整理到主記憶體

線程讀 volatile 變量的過程:

  1. 從主記憶體中讀取 volatile 變量的最新值到線程的工作記憶體中
  2. 從工作記憶體中讀取 volatile 變量的副本
    并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計
private static volatile boolean running = true;
private static void t1() {
        new Thread(()->{
        while (running) {
//System.out.println("eat eat eat "); 
//ThreadUtil.sleepSeconds(1); 
        }
        System.out.println("thread exit");
        }).start();
        ThreadUtil.sleepSeconds(5);
        running = false;
        } 

           

注意:

1、volatile并不能保證并發操作的原子性,即不保證線程安全

// volatile 能保障可見性但是無法保障原子性,線程安全無法保障
    private static volatile int count = 0;
    public static void t2() throws InterruptedException
    {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
//啟動兩個線程 
        t1.start();
        t2.start();
//等待兩個線程執行結束
        t1.join();
        t2.join();
//輸出count的最終結果 
        System.out.println("count="+count);
    }

           

可能在某一時刻,t1和t2 從主記憶體中讀到了相同的count=100,然後經過工作記憶體操作之後均為101,t1和t2 将工作記憶體中的101刷到主記憶體,雖然重新整理了2次但是最終的結果還是101。

2、volatile修飾引用類型,它隻能保證引用本身的可見性,不能保證所

引用對象内部屬性的可見性

static class A {
        /*volatile*/ boolean stop = false;
        private void t3() {
            while (!stop) {
            }
            System.out.println("program stopped");
        }
    }
// volatile 修飾引用類型,隻能保證該引用是可見的,對于所 引用對下的屬性是不可見的
    private static volatile A a = new A();
    private static void t4() {
        new Thread(a::t3,"t1").start();
        ThreadUtil.sleepSeconds(5);
        a.stop = true;
    }

           

禁止進行指令重排序(相當于禁止編譯優化,解決有序性,後面再講)

3.2、synchronized

synchronized 加鎖的含義不僅僅局限于互斥行為,還包括記憶體可見性。執行解鎖操作時會将工作記憶體中的共享變量刷到主記憶體(相當于JMM中的unlock )。

執行加鎖操作時會清空工作記憶體中共享變量副本的值,需要使用時從主記憶體重新加載(相當于JMM中的 lock )。

但是要注意的是:使用鎖來保證可見性太笨重,因為 synchronized 是線程獨占的,其他線程會被阻塞,這裡面還存在一些線程排程開銷,因為它是靠作業系統核心互斥鎖實作的。而 volatile 是相對輕量級的,但是 synchronized除了保證可見性還能保證原子性,而 volatile 不能保證原子性。

代碼示範如下:

// synchronized 也能保證可見性 
private static boolean run = true; 
private static void t5() { 
new Thread(()->{ 
while (run) { 
synchronized (C00_KnowVisibility.class) { 
} 
}
System.out.println("thread exit"); 
}).start(); 
ThreadUtil.sleepSeconds(5); 
run = false; 
} 

           

注意:

如果實驗會發現, Thread.sleep 也會觸發可見性機制,代碼如下:

private static boolean run = true; 
private static void t5() { 
new Thread(()->{ 
while (run) { 
/*synchronized (C00_KnowVisibility.class) { 
}*/ 
ThreadUtil.sleepSeconds(1); 
}
System.out.println("thread exit"); 
}).start(); 
ThreadUtil.sleepSeconds(5); 
run = false; 
} 

           

線程sleep時是阻塞狀态,時間結束時變為可運作狀态,獲得cpu運作權限時從主記憶體中同步共享變量資料

3.3、final

final 修飾變量時,代表它是不可變的,既然是不可變的,也就不存在不同線程間工作記憶體資料不一緻的問題,而且編譯器想怎麼優化怎麼優化,

一個對象的final字段值是在它的構造方法裡面設定的(或者聲明時直接初始化)。假設對象被正确的構造了,一旦對象被構造,在構造方法裡面設定給final字段的的值在沒有同步的情況下對所有其他的線程都會可見。另外,引用這些final字段的對象或數組都将會看到final字段的最新值

public class C01_FinalField {
        final int x;
        int y;
        static C01_FinalField f;
        // 正确的構造函數 
        public C01_FinalField() {
            x = 3;
            y = 4;
        }
        static void writer() {
            f = new C01_FinalField();
        }
        static void reader() {
            if (f != null) {
                int i = f.x;
                int j = f.y;
                System.out.println("x="+i+",y="+j);
            }
        }
        public static void main(String[] args) {
            Thread t1 = new Thread(()->{
// t1 thread read 
                C01_FinalField.reader();
            });
            t1.start();
// main thread write 
            C01_FinalField.writer();
            ThreadUtil.sleepSeconds(5);
        }
    }

           

對一個對象來說,被正确的構造是什麼意思呢?簡單來說,它意味着這個正在構造的對象的引用在構造期間沒有被允許逸出。

上面的類展示了final字段應該如何使用。一個正在執行reader方法的線程

保證看到f.x的值為3,因為它是final字段。它不保證看到f.y的值為4,因為f.y不 是final字段。

但是:如果 C01_FinalField 的構造函數是如下這樣的

// 不正确的構造函數 
    public C01_FinalField() {
        x = 3;
        y = 4;
        C02_FinalThisEscape.obj = this; // this 逃逸/逸出 
    }

    public class C02_FinalThisEscape {
        public static C01_FinalField obj;
        public static void readX() {
            System.out.println("obj.x="+obj.x);
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
// t1 thread read 
            C02_FinalThisEscape.readX();
        });
        t1.start();
// main thread write 
        C01_FinalField.writer();
        ThreadUtil.sleepSeconds(5);
    }


           

那麼,從 C02_FinalThisEscape.obj 中讀取this的引用線程不會保證讀取到的x的值為3,因為在構造函數中可能會發生指令重排。

當然在 1.5 以後 Java 記憶體模型對 final 類型變量的重排進行了限制。現在隻要我們提供正确構造函數沒有“逸出”,就不會出問題了。

4、有序性解決方案

4.1、volatile

前面講到volatile的特性:一是能保證變量對所有線程的可見性,解決可見性問題,而是禁止進行指令重排序,解決的就是有序性問題。

具體一點解釋,禁止重排序的規則如下:

  • 寫 volatile 變量時,可以確定 volatile 寫之前的操作不會被編譯器 重排序到 volatile 寫之後。
  • 讀 volatile 變量時,可以確定 volatile 讀之後的操作不會被編譯器 重排序到 volatile 讀之前。

這樣來看,之前所講的雙重鎖校驗建立單例對象的代碼就可以用 volatile解決因為重排序導緻的有序性問題

public class C02_KnowOrdering {
        private C02_KnowOrdering(){}
        // 使用volatile禁止指令重排序 
        static volatile C02_KnowOrdering instance;
        public static C02_KnowOrdering getInstance() {
            if (instance == null) {
                synchronized (C02_KnowOrdering.class) { // 保證并發線程A/B的有序
                    if (instance == null) { // 保證并發線程A/B,B不會修改A建立的對象
                                instance = new C02_KnowOrdering();
                    }
                }
            }
            return instance;
        }
        public static void main(String[] args) {
            C02_KnowOrdering instance = getInstance();
        }
    }
           

另外:synchronized 雖不能禁止指令重排,但能保證有序性

這個有序性是相對語義來看的,線程與線程間,每一個 synchronized 塊可以看成是一個原子操作,塊與塊之間看起來是原子操作,塊與塊之間有序可見,當然了 synchronized 塊裡的非原子操作依舊可能發生指令重排。

4.2、記憶體屏障

我們說volatile能禁止指令重排序,它的底層到底如何實作的呢?這裡要從不同的層面來看這個問題

java源碼層面

使用的是語言層面的:volatile關鍵字

位元組碼層面

添加了通路标記(acces flag): [static volatile]

jvm層面

jvm拿到了帶有volatile标記的位元組碼,它的處理是采用記憶體屏障,保證屏障兩邊的指令不可以重排,保證有序。

下面我們來看JSR記憶體屏障的規範要求:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

另外在系統底層也有支援記憶體屏障的系統原語,比如:

  1. lfence,是一種Load Barrier 讀屏障。在讀指令前插入讀屏障,可以讓高速緩存中的資料失效,重新從主記憶體加載資料
  2. sfence, 是一種Store Barrier 寫屏障。在寫指令之後插入寫屏障,能讓寫入緩存的最新資料寫回到主記憶體
  3. mfence, 是一種全能型的屏障,具備ifence和sfence的能力

hotspot實作

hotspot虛拟機在記憶體屏障的實作上,略有不同,并沒有直接采取系統底層支援的記憶體屏障原語,

src\share\vm\interpreter\bytecodeInterpreter.cpp

//
// Now store the result on the stack 
//
TosState tos_type = cache->flag_state(); 
int field_offset = cache->f2_as_index(); 
if (cache->is_volatile()) { 
if (support_IRIW_for_not_multiple_copy_atomic_cpu) { 
OrderAccess::fence(); 
}
........
           

檢視 OrderAccess::fence()

src\os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp

inline void OrderAccess::fence() { 
if (os::is_MP()) { 
// always use locked addl since mfence is sometimes 
expensive 
#ifdef AMD64 
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", 
"memory"); 
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", 
"memory"); 
#endif 
} 
}

           

它是通過一個 lock 字首來完成的。

Lock不是一種記憶體屏障,但是它能完成類似記憶體屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以了解為CPU指令級的一種鎖。Lock字首實作了類似的能力.

  1. 它先對總線/緩存加鎖,然後執行後面的指令,最後釋放鎖後會把高速緩存中的髒資料全部重新整理回主記憶體。
  2. 在Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。Lock後的寫操作會讓其他CPU相關的cache line失效,進而從新從記憶體加載最新的資料。這個是通過緩存一緻性協定做的。

5、原子性解決方案

通過前面我們知道發生原子性的根源是CPU在執行完任意指令後都有可能發生線程切換。如果能夠禁用線程切換的話那這個問題也就迎刃而解了。作業系統做線程切換是依賴 CPU 中斷的,是以禁止 CPU 發生中斷就能夠禁止線程切換。

知識點:CPU中斷

讓CPU停下目前的工作任務,去處理其他事情,處理完後回來繼續執行剛才的任務,這一過程便是中斷。

可參考知乎文章:https://zhuanlan.zhihu.com/p/360548214

當然這種方案在單核CPU是可行的,但是在多核CPU中就不行了,為什麼?我們來分析一下

我們以在32位CPU上執行long 型變量的寫操作為例:long 型變量是 64位,在 32 位 CPU 上執行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32位,如下圖所示)

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

在單核 CPU 場景下,同一時刻隻有一個線程執行,禁止 CPU 中斷,意味着作業系統不會重新排程線程,也就是禁止了線程切換,獲得 CPU 使用權的線程就可以不間斷地執行,是以兩次寫操作一定是:要麼都被執行,要麼都沒有被執行,具有原子性。

但是在多核場景下,同一時刻,有可能有兩個線程同時在執行,一個線程執行在 CPU-1 上,一個線程執行在 CPU-2 上,此時禁止 CPU 中斷,隻能保證某個CPU 上的線程連續執行,并不能保證同一時刻隻有一個線程執行,如果這兩個線程同時寫 long 型變量高 32 位的話,那就有可能出現一些詭異 的Bug了。

也就是說真正保證并發原子性的是:同一時刻隻有一個線程執行,這個條件非常重要,我們稱之為互斥。如果我們能夠保證對共享變量的修改是互斥的,那麼,無論是單核 CPU 還是多核 CPU,就都能保證原子性了。

在并發程式設計領域,有兩大核心問題:一個是互斥,即同一時刻隻允許一個線程通路共享資源;另一個是同步,即線程之間如何通信、協作。加鎖是我們能想到的最直接也是最通用的互斥解決方案,加鎖的模型如下圖所示:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

這裡需要注意的地方:

1、就是鎖和要保護資源之間的對應關系,圖中虛線部分,很多時候就是忘記了這個關系進而導緻了很多了問題

2、鎖并不能并不能改變CPU時間片切換的特點,隻是當其他線程要通路這個資源時,發現鎖還未釋放,是以隻能等待。同時也說明線程切換是不會釋放鎖的。

5.1、synchronized

Java 語言提供的 synchronized 關鍵字,就是鎖的一種實作。

synchronized 關鍵字可以用來修飾方法,也可以用來修飾代碼塊,一般的用法

如下:

public class C01_KnowSynchronized {

    static int i = 0;

    // 1、修飾非靜态方法 鎖定的是目前類的Class對象
    public synchronized void foo() {
        i++;
    }

    // 2、修飾靜态方法   鎖定的是目前類的執行個體對象this、
    public static synchronized void bar() {
        i--;
    }


    Object obj = new Object();

    public void car() {
        //3、修飾代碼塊 鎖定的是指定的對象    檢視位元組碼可看出隐式的加鎖和解鎖
        synchronized (obj) {
            i+=2;
        }
    }


    public static void main(String[] args) {

    }
}

           

回顧前面講的互斥鎖模型,結合我們的代碼,有幾個要注意的問題:

1、加鎖和解鎖操作在哪裡展現的?synchronized 的加鎖和解鎖是隐式實作的,可以檢視位元組碼

2、synchronized 的鎖對象是什麼,也就是說鎖定的是哪個對象?

  • 如果修飾的是代碼塊,鎖對象是我們自己指定的,指定哪個對象就鎖定哪個對象。
  • 如果修飾的是非靜态方法,鎖定的是目前執行個體對象 this 。
  • 如果修飾的是靜态方法,鎖定的是目前類的 Class 對象。

5.2、解決 i+=1 問題

回到之前的一個問題,代碼如下:

public class C02_AddOneProblem {

    long i = 0L;

    /**
     * addOne() 的結果能對get()可見嗎?
     *
     * @return
     */
    public /*synchronized*/   long get() {
        return i;
    }


    /**
     *  我們知道:i+=1 并非原子操作,會有線程安全問題
     *  要想得以解決就可以加鎖
     *
     *  對于`addOne`方法,添加了`synchronized`修飾之後,無論是單核 CPU 還是多核 CPU,隻有一個線程能夠執行 addOne() 方法,
     *  是以一定能保證原子操作。
     *
     * addOne能保證可見性嗎?
     * 1、基于 `Happens-Before`規則中有一條管程中的鎖規則:對一個鎖的解鎖 Happens-Before 于後續對這個鎖的加鎖,
     *   再結合Happens-Before 的傳遞性原則,我們知道,`addOne`方法中+1操作的結果肯定會在釋放鎖之前刷到主記憶體,
     *  鎖釋放後下一個進入到`addOne`方法的線程擷取鎖時能夠擷取到上一個線程的操作結果。
     *  即前一個線程在臨界區修改的共享變量,對後續進入臨界區的線程是可見的。
     */
    public synchronized void addOne() {
        i+=1;
    }

}

           

對于 addOne 方法,添加了 synchronized 修飾之後,無論是單核 CPU 還是多核 CPU,隻有一個線程能夠執行 addOne() 方法,是以一定能保證原子操作。

至于可見性,前面提到的 Happens-Before 規則中有一條管程中的鎖規則:對一個鎖的解鎖 Happens-Before 于後續對這個鎖的加鎖,再結合HappensBefore 的傳遞性原則,我們知道, addOne 方法中+1操作的結果肯定會在釋放鎖之前刷到主記憶體,鎖釋放後下一個進入到 addOne 方法的線程擷取鎖時能夠擷取到上一個線程的操作結果。即前一個線程在臨界區修改的共享變量,對後續進入臨界區的線程是可見的。

我們也許一不小心就忽略了還有一個 get 方法,執行 addOne() 方法後,變量 i 的值對 get() 方法是可見的嗎?

這個可見性是沒法保證的。管程中鎖的規則,是隻保證後續對這個鎖的加鎖的可見性,而 get() 方法并沒有加鎖操作,是以可見性沒法保證。那如何解決呢?

  • 變量 i 設定成 volatile
  • 方法 get 也加鎖

均可!

5.3、鎖和資源的關系

前面提到,受保護資源和鎖之間的關聯關系非常重要,他們的關系是怎樣的呢?

一個合理的關系是:鎖和受保護資源之間的關聯關系是 1:N 的關系。如下圖

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

但有時候我們寫出的代碼往往破壞了這個關系,我們舉幾個例子:

1、多把鎖保護同一個資源的情況 :現實世界中可以,并發程式設計領域不行。

// 這樣的加鎖方式有什麼問題 ?  add線程和sub線程之間是不可見的
class LR{

    static long i = 0L;

    synchronized void subOne() {
        i-=1;
    }

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

代碼是用兩個鎖保護一個資源。這個受保護的資源就是靜态變量 i ,兩個鎖分别是 `this 和 LR.class 。我們可以用下面這幅圖來形象描述這個關系。

由于臨界區 subOne() 和 addOne() 是用兩個鎖保護的,是以這兩個臨界區沒有互斥關系,臨界區 addOne() 對 變量 i 的修改對臨界區 subOne() 也沒有可見性保證,這就導緻并發問題了。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

2、一把鎖如何保護多個資源

這裡就涉及到一個鎖粒度的問題。當我們要保護多個資源的時候,首先要區分這些資源之間是否有關聯關系。

  • 如果要保護多個沒有關聯關系的資源:

最好的做法是用不同的鎖對受保護資源進行精細化管理,能夠提升性能。這種鎖還有個名字,叫細粒度鎖。對應到程式設計領域也很簡單,比如:銀行業務中有針對賬戶餘額(餘額是一種資源)的取款操作,也有針對賬戶密碼(密碼也是一種資源)的更改操作,我們可以為賬戶餘額和賬戶密碼配置設定不同的鎖來解決并發問題

class Account {
    // 鎖:保護賬戶餘額
    private final Object balLock = new Object();
    // 賬戶餘額
    private  Integer balance;

    // 鎖:保護賬戶密碼
    private final Object pwLock = new Object();
    // 賬戶密碼
    private String password;

    public Account(){};

    public Account(Integer balance) {
        this.balance = balance;
    }


    // 取款
    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;
        }
    }

           

當然,我們也可以用一把互斥鎖來保護多個資源,例如我們可以用 this 這一把鎖來管理賬戶類裡所有的資源:賬戶餘額和使用者密碼,但是用一把鎖有個問題,就是性能太差,會導緻取款、檢視餘額、修改密碼、檢視密碼這四個操作都是串行的。而我們用兩把鎖,取款和修改密碼是可以并行的。

  • 如果要保護有關聯關系的多個資源:

    此時就稍微有點複雜了,還是拿轉賬業務來說:賬戶A給賬戶B轉100元,賬戶 A 減少 100 元,賬戶 B 增加 100 元,這兩個賬戶就是有關聯關系的;我們先代碼實作一個賬戶轉賬操作

class Account {
    private int balance;
    public Account(){};

    public Account(Integer balance) {
        this.balance = balance;
    }


 // 模拟轉賬操作
    void  transfer(Account target, int amt){
        if (this.balance > amt) {
            this.balance -= amt;//目前賬号扣錢
            target.balance += amt;//目标賬号加錢
        }
    }

           

我們應該如何保證這個轉賬操作沒有問題呢?興許你馬上有了答案,使用synchronized 關鍵字修飾一下 transfer 方法就好了呀,但事實真的是這樣嗎?

synchronized void  transfer1(Account target, int amt){
        if (this.balance > amt) {
            this.balance -= amt;//目前賬号扣錢
            target.balance += amt;//目标賬号加錢
        }
    }
           

在這段代碼中,臨界區内有兩個資源,分别是轉出賬戶的餘額this.balance 和轉入賬戶的餘額 target.balance ,并且用的是一把鎖this ,符合我們前面提到的,多個資源可以用一把鎖來保護,這看似正确,其實有問題,問題就出在 this 這把鎖上,this 這把鎖可以保護自己的餘額this.balance ,卻保護不了别人的餘額 target.balance ,就像你不能用自家的鎖來保護别人家的資産一樣,如下圖:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

至于中間是如何産生線程安全問題的,我們逐一來分析如下:

假設:有3個賬戶,A,B,C,餘額都是200;現在A給B轉100,B給C轉100;我們期望的結果是A有餘額100,B的餘額200,C的餘額300;

并發代碼如下:

// 測試 transfer1 transfer2
    private static void test1() throws Exception{
        long start = System.currentTimeMillis();
        for (long i=0;i<100000;i++) {

            Account a = new Account(200);
            Account b = new Account(200);
            Account c = new Account(200);

            Thread t1 = new Thread(()->{
                a.transfer1(b,100); // 進入transfer1的鎖對象是 a
                //a.transfer2(b,100);
            });
            Thread t2 = new Thread(()->{
                b.transfer1(c,100); // 進入transfer1的鎖對象是 b
                //b.transfer2(c,100);
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            if (b.balance !=200 ) {
                System.out.println("oh 出錯了!"+","+(System.currentTimeMillis() - start));
                break;
            }
        }
    }
           

中間過程分析如下:假設線程t1和線程t2在不同CPU上同時執行,我們期望在執行 transfer 方法時它們時互斥的,他們其實不是互斥的,因為線程t1拿到的鎖對象this是a,線程t2拿到的鎖對象this是b。根本就不是一把鎖,故兩個線程可同時執行臨界區代碼,那就會産生以下問題,如圖:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

那如何修正這一問題呢?

很簡單,隻要我們的鎖能覆寫所有受保護資源就可以了。在上面的例子中,this 是對象級别的鎖,是以 A 對象和 B 對象都有自己的鎖,我們隻需要讓多個線程共享同一把鎖即可,比如用 Account.class 作為共享的鎖。

Account.class 是所有 Account 對象共享的,而且這個對象是 Java 虛拟機在加載 Account 類的時候建立的,是以我們不用擔心它的唯一性

void  transfer2(Account target, int amt){
        synchronized (Account.class) {
            if (this.balance > amt) {
                this.balance -= amt;//目前賬号扣錢
                target.balance += amt;//目标賬号加錢
            }
        }
    }
           

思考:使用 Account.class 作為共享的鎖雖然可以解決前面所講的問題,但是這樣會導緻所有的轉賬操作就變成串行的了,多線程的優勢就不存在了,這個在實踐中是不可行的,那如何優化呢?

5.4、死鎖

在前面留下的思考中使用 Account.class 作為共享的鎖會導緻并發轉賬其實不存在,所有賬戶的轉賬操作都是串行的,這樣的話性能太差,那如何優化呢?

這又得回到鎖的粒度上來,用 Account.class 作為互斥鎖,鎖定的範圍太大,相當于鎖定了所有的賬戶,而每次轉賬我們隻需要鎖定轉出和轉入兩個賬戶就可以了,而鎖定兩個賬戶範圍就小多了,這樣的鎖,我們前面把它叫細粒度鎖。使用細粒度鎖可以提高并行度,是性能優化的一個重要手段。

現實世界中也是如此:假設你去食堂吃飯,食堂就兩個視窗,視窗A賣馄饨,視窗B賣餃子。現在有100人去食堂排隊買馄饨和餃子,你作為其中一員,你在買時可能會遇到以下三種情況

  • 視窗A和視窗B都沒人,那你直接買完這倆走人
  • 視窗A有人,視窗B沒人,或者視窗B有人,視窗A沒人,你的做法肯定 是先去沒人的視窗買,然後再等有人的視窗那人買完後你再去買。
  • 視窗A和視窗B都有人,排隊等待。

這個道理和我們這裡的轉賬操作道理是一樣的,在程式設計中如何實作呢?其實用兩把鎖就夠了,轉出賬戶一把,轉入專戶一把,比如如下代碼

// 降低鎖粒度 提升性能 但要預防死鎖
    void  transfer3(Account target, int amt){
        synchronized (this) { //鎖定轉出賬戶
            synchronized (target) { // 鎖定轉入賬戶
                if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
           

鎖模型如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

但是一旦鎖粒度變細,我們就一定要警覺起來, 因為使用細粒度鎖是有代價的,這個代價就是可能會導緻死鎖

死鎖的一個比較專業的定義是:一組互相競争資源的線程因互相等待,導

緻“永久”阻塞的現象。

我們來測試死鎖的發生:

private static void test2() throws Exception{
        long start = System.currentTimeMillis();
        final int max_count = 100000;
        Account a = new Account(20000),b=new Account(20000);
        CountDownLatch latch = new CountDownLatch(max_count);
        for (int i=0;i<max_count;i++) {
            if (i % 2==0) {
                Thread t = new Thread(() -> {
                    //a.transfer3(b,100);
                    //a.transfer4(b,100);
                    a.transfer5(b,100);
                    latch.countDown();
                });
                //t.setPriority((i % 10)+1);
                t.start();
            }
            if (i % 2 ==1) {
                Thread t = new Thread(() -> {
                    //b.transfer3(a,100);
                    //b.transfer4(a,100);
                    b.transfer5(a,100);
                    latch.countDown();
                });
                // t.setPriority((i % 10)+1);
                t.start();
            }

        }
        latch.await();
        System.out.println("耗時:"+(System.currentTimeMillis()-start)+"毫秒");

    }

           

那到底是如何發生的呢?

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

5.5、如何預防死鎖

這裡要說一下:如果程式真的發生了死鎖,一般沒有特别好的辦法,很多時候隻能重新開機應用,是以我們一般是探究如何發現死鎖以及在編寫程式的時候如何主動規避死鎖。

1、如何發現死鎖?

jps , jstack , jconsole , jvisualvm 等工具

2、如何規避死鎖?

規避死鎖就需要分析死鎖發生的條件,有個叫 Edward G.Coffman, Jr先生的人于1971年首次提出,隻有以下這四個條件都發生時才會出現死鎖,被成為:科夫曼條件

  • 互斥,共享資源 X 和 Y 隻能被一個線程占用;
  • 保持和等待,線程 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X,依舊保持着;
  • 不可被剝奪/搶占,其他線程不能強行搶占線程 T1 占有的資源;
  • 循環等待,線程 T1 等待線程 T2 占有的資源,線程 T2 等待線程 T1 占 有的資源,就是循環等待。

也就說如果我們在程式設計的時候隻要注意,如果能破壞其中的一個條件,程式就可以成功避免死鎖問題的發生。

首先,互斥這個我們無法破壞,因為我們使用鎖就是想利用它的互斥特性來保護我們的資源,除此之外其他三個是可以在程式中主動規避的,我們依次來看:

破壞保持和等待

就是說我們最好一次性申請所有的資源,不要存在申請一部分,等待另一部分的情況,拿A給B轉賬來說,我們需要申請A的賬戶和B的賬号,我們要做的是讓這兩個賬号資源同時被申請,這樣就不存在申請一部分,等待另一部分的情況了,對應到程式設計領域,我們需要建立一個新的角色來負責同時申請和釋放全部的資源,我們暫且把它叫做Allocator。

void  transfer4(Account target, int amt){
        Allocator allocator = Allocator.getInstance();
        //一次性申請要操作的兩個資源,如果申請不到相當于循環等待
        while (!allocator.apply(this,target)) {} ;

        try {
            synchronized (this) { //鎖定轉出賬戶
                synchronized (target) { // 鎖定轉入賬戶
                    if (this.balance > amt) {
                        this.balance -= amt;
                        target.balance += amt;
                    }
                }
            }
        } finally {
            //操作完成釋放對應的資源
            allocator.free(this,target);
        }
    }


// Allocator 應該為單例
class Allocator {
    private static volatile Allocator instance;
    private Allocator() {}

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

    private List<Object> als =  new ArrayList<>();

    // 一次性申請所有資源
    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 void free( Object from, Object to){
        als.remove(from);
        als.remove(to);
    }

           

其實我們可以了解為:變相的申請了一把鎖,這把鎖能保護轉入和轉出的賬戶,對其他賬戶無影響,這樣鎖的範圍既沒有那麼大,又能成功的保護臨界區的所有資源。

擴充:文中當我們在一次性申請要操作的兩個資源時,如果資源申請不到,我們采取的辦法是采用循環的方式來不斷申請,直到申請成功,

這裡可以如何優化呢?

破壞不可被剝奪/搶占

破壞不可搶占條件看上去很簡單,核心是要能夠主動釋放它占有的資源,這一點 synchronized 是做不到的。原因是 synchronized 申請資源的時候,如果申請不到,線程直接進入阻塞狀态了,而線程進入阻塞狀态,啥都幹不了,也釋放不了線程已經占有的資源(即阻塞時不會釋放已擷取的鎖)。

不過在 java.util.concurrent 這個包下面提供的 Lock 是可以輕松解決這個問題的。

破壞循環等待

破壞這個條件相對容易,需要對資源進行排序,然後按序申請資源,隻要不出現以下寫法即可

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

  /**
     * 比較簡單的能一眼看出來會有死鎖問題的發生 
     * 比如: 
     * 目前賬戶this==a,tgrget==b 
     * 在不同的線程中同時調用; 
     * a.transfer3(b,100) 
     * a.xxxOperation(b) 
     *
     * 就會發生死鎖問題 
     * @param target
     */
    void xxxOperation(Account target) {
        synchronized (target) {
            synchronized (this) {
// operation 
            }
        }
    }

           

擴充:優化破壞保持和等待中的循環等待

針對前面提出的如何優化下面的循環等待呢?

如果 apply() 操作耗時非常短,而且并發沖突量也不大時,這個方案還挺不錯的,因為這種場景下,循環上幾次或者幾十次就能一次性擷取轉出賬戶和轉入賬戶了。但是如果 apply() 操作耗時長,或者并發沖突量大的時候,循環等待這種方案就不适用了,因為在這種場景下,可能要循環上萬次才能擷取到鎖,太消耗 CPU 了。

那這種如何優化呢?

其實在這種場景下,最好的方案應該是:如果線程要求的條件不滿足,則線程阻塞自己,進入等待狀态;當線程要求的條件滿足後,通知等待的線程重新執行。其中,使用線程阻塞的方式就能避免循環等待消耗 CPU 的問題。那Java 語言是否支援這種等待 - 通知機制呢?

答案是:一定支援,下面我們就來看看 Java 語言是如何支援等待 - 通知機制的。

在 Java 語言裡,等待 - 通知機制可以有多種實作方式,比如 Java 語言内置的 synchronized 配合 wait()、notify()、notifyAll() 這三個方法就能輕松實作

知識點:在并發程式設計領域,有兩大核心問題:一個是互斥,即同一時刻隻允許一個線程通路共享資源;另一個是同步,即線程之間如何通信、協作,而wait()、notify()、notifyAll()就是線程同步的一種。

一個完整的等待 - 通知機制是這樣的:線程首先擷取互斥鎖,當線程要求的條件不滿足時,釋放互斥鎖,進入等待狀态;當要求的條件滿足時,通知等待的線程,重新擷取互斥鎖。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

在這個圖中,左邊有一個等待隊列,同一時刻,隻允許一個線程進入synchronized 保護的臨界區,當有一個線程進入臨界區後,其他線程就隻能進入圖中左邊的等待隊列裡等待。這個等待隊列和互斥鎖是一對一的關系,每個互斥鎖都有自己獨立的等待隊列。并發程式中,當一個線程進入臨界區後,由于某些條件不滿足,需要進入等待狀态,Java 對象的 wait() 方法就能夠滿足這種需求。如上圖所示,當調用 wait() 方法後,目前線程就會被阻塞,并且進入到右邊的等待隊列中,這個等待隊列也是互斥鎖的等待隊列。 線程在進入等待隊列的同時,會釋放持有的互斥鎖,線程釋放鎖後,其他線程就有機會獲得鎖,并進入臨界區了。

那線程要求的條件滿足時通過調用 Java 對象的 notify() 和 notifyAll() 方法通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經滿足過,為什麼說是曾經滿足過呢?因為 notify() 隻能保證在通知時間點,條件是滿足的。而被通知線程的執行時間點和通知的時間點基本上不會重合,是以當線程執行的時候,很可能條件已經不滿足了(保不齊有其他線程插隊)。除此之外,還有一個需要注意的點,被通知的線程要想重新執行,仍然需要擷取到互斥鎖(因為曾經擷取的鎖在調用 wait() 時已經釋放了)。對于前面的案例,優化如下

// 使用 等待---通知機制 優化; Allocator是單例的,this 作為互斥鎖

    synchronized void apply2(Object from, Object to){
        //經典寫法,這裡為什麼要用wile?用if行不行?
        while (als.contains(from) || als.contains(to)) {
            try {
                this.wait();
            } catch (InterruptedException e) {
            }
        }
        als.add(from);
        als.add(to);
    }
    synchronized void free2( Object from, Object to){
        als.remove(from);
        als.remove(to);
        this.notifyAll();
        //this.notify();//随機喚醒,先看優先級,然後随機
    }
}

           

知識點:在文中經典寫法那,不用while,隻用if行不行?

注意:

1、 wait()、notify()、notifyAll() 方法操作的等待隊列是互斥鎖的等待隊列,是以如果 synchronized 鎖定的是 this,那麼對應的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 鎖定的是 target,那麼對應的一定是 target.wait()、target.notify()、target.notifyAll() 。

2、而且 wait()、notify()、notifyAll() 這三個方法能夠被調用的前提是已經擷取了相應的互斥鎖,是以我們會發現 wait()、notify()、notifyAll() 都是在synchronized{}内部被調用的,可以說他們是一個組合;如果在synchronized{}外部調用,或者鎖定的 this,而用 target.wait() 調用的話,JVM會抛出一個運作時異常:java.lang.IllegalMonitorStateException

3、另外,盡量使用 notifyAll(),notify() 是會随機地通知等待隊列中的一個線程(線程優先級高的被喚醒的機率大,同優先級的随機),而 notifyAll() 會通知等待隊列中的所有線程。從感覺上來講,應該是 notify() 更好一些,因為即便通知所有線程,也隻有一個線程能夠進入臨界區。但那所謂的感覺往往都蘊藏着風險,實際上使用 notify() 也很有風險,它的風險在于可能導緻某些線程永遠不會被通知到,是以除非經過深思熟慮,否則盡量使用notifyAll()。

6、安全,活躍,性能問題

并發程式設計中我們需要注意的問題有很多,很慶幸前人已經幫我們總結過了,主要有三個方面,分别是:安全性問題、活躍性問題和性能問題。

安全性問題:

前面我們也說并發程式設計領域有兩大核心問題:互斥和同步,而這也主要是為了解決并發的安全性問題。我們經常說:這個方法不是線程安全的,這個類不是線程安全的,等等。其實線程安全的本質就是正确性, 我們希望程式按照我們期望的來執行,但是有時候又會出一些很詭異的bug,導緻程式沒有按照我們期望的執行,而這些詭異bug的源頭大都來自我們前面講的三大特性:有序性,可見性,原子性,那是不是所有的代碼都會出現這三種問題呢?

其實隻有一種情況需要:存在共享資料并且該資料會發生變化,通俗地講就是有多個線程會同時讀寫同一資料。那如果能夠做到不共享資料或者資料狀态不發生變化,不就能夠保證線程的安全性了嘛。

當多個線程同時通路同一資料,并且至少有一個線程會寫這個資料的時候,如果我們不采取防護措施,那麼就會導緻并發 Bug,對此還有一個專業的術語,叫做資料競争(Data Race),比如

public class Test { 
private long count = 0; 
void add10K() { 
int idx = 0; 
while(idx++ < 10000) { 
count += 1; 
} 
} 
}

           

那是不是在通路資料的地方,我們加個鎖保護一下就能解決所有的并發問

題了呢?

顯然沒有這麼簡單。例如,對于上面示例,我們稍作修改,增加兩個被synchronized 修飾的 get() 和 set() 方法, add10K() 方法裡面通過 get() 和set() 方法來通路 value 變量,修改後的代碼如下所示。對于修改後的代碼,所有通路共享變量 value 的地方,我們都增加了互斥鎖,此時是不存在資料競争的。但很顯然修改後的 add10K() 方法并不是線程安全的

public class Test { 
private long count = 0; 
synchronized long get(){ 
return count; 
}
synchronized void set(long v){ 
count = v; 
}
void add10K() { 
int idx = 0; 
while(idx++ < 10000) { 
set(get()+1) 
} 
} 
} 

           

假設 count=0,當兩個線程同時執行 get() 方法時,get() 方法會傳回相同的值 0,兩個線程執行 get()+1 操作,結果都是 1,之後兩個線程再将結果 1 寫入了記憶體。你本來期望的是 2,而結果卻是 1。這種問題,有個官方的稱呼,叫競态條件(Race Condition)。所謂競态條件,指的是程式的執行結果依賴線程執行的順序。

例如上面的例子,如果兩個線程完全同時執行,那麼結果是

1。如果兩個線程是前後執行,那麼結果就是

2。在并發環境裡,線程的執行順序是不确定的,如果程式存在競态條件問題,那就意味着程式執行的結果是不确定的,而執行結果不确定這可是個大 Bug。

是以你也可以按照下面這樣來了解競态條件。在并發場景中,程式的執行依賴于某個狀态變量,也就是類似于下面這樣:

if (狀态變量 滿足 執行條件) { 
執行操作 
} 
           

那面對資料競争和競态條件問題,又該如何保證線程的安全性呢?

其實這兩類問題,都可以用互斥這個技術方案,而實作互斥的方案有很多,CPU 提供了相關的互斥指令,作業系統、程式設計語言也會提供相關的 API。從邏輯上來看,我們可以統一歸為:鎖。

活躍性問題

所謂活躍性問題,指的是某個操作無法執行下去。我們常見的“死鎖”就是一種典型的活躍性問題,當然除了死鎖外,還有兩種情況,分别是“活鎖”和“饑 餓”。

什麼是活鎖呢?

但有時線程雖然沒有發生阻塞或死鎖,但仍然會存在執行不下去的情況,這就是所謂的“活鎖”。

可以類比現實世界裡的例子,路人甲從左手邊出門,路人乙從右手邊進門,兩人為了不相撞,互相謙讓,路人甲讓路走右手邊,路人乙也讓路走左手邊,結果是兩人又相撞了。這種情況,基本上謙讓幾次就解決了,因為人會交流啊。可是如果這種情況發生在程式設計世界了,就有可能會一直沒完沒了地“謙讓”下去,成為沒有發生阻塞但依然執行不下去的“活鎖”。

解決“活鎖”的方案很簡單,謙讓時,嘗試等待一個随機的時間就可以了。例如上面的那個例子,路人甲走左手邊發現前面有人,并不是立刻換到右手邊,而是等待一個随機的時間後,再換到右手邊;同樣,路人乙也不是立刻切換路線,也是等待一個随機的時間再切換。由于路人甲和路人乙等待的時間是随機的,是以同時相撞後再次相撞的機率就很低了。“等待一個随機時間”的方案雖然很簡單,卻非常有效,Raft 這樣知名的分布式一緻性算法中也用到了它。

什麼是饑餓呢?

所謂“饑餓”指的是線程因無法通路所需資源而無法執行下去的情況。“不患寡,而患不均”,如果線程優先級“不均”,在 CPU 繁忙的情況下,優先級低的線程得到執行的機會很小,就可能發生線程“饑餓”;持有鎖的線程,如果執行的時間過長,也可能導緻“饑餓”問題。

解決“饑餓”問題的方案很簡單,有三種方案:一是保證資源充足,二是公平地配置設定資源,三就是避免持有鎖的線程長時間執行。這三個方案中,方案一和方案三的适用場景比較有限,因為很多場景下,資源的稀缺性是沒辦法解決的,持有鎖的線程執行的時間也很難縮短。倒是方案二的适用場景相對來說更多一些。那如何公平地配置設定資源呢?在并發程式設計裡,主要是使用公平鎖。所謂公平鎖,是一種先來後到的方案,線程的等待是有順序的,排在等待隊列前面的線程會優先獲得資源。

性能問題

使用“鎖”要非常小心,但是如果小心過度,也可能出“性能問題”。“鎖”的過度使用可能導緻串行化的範圍過大,這樣就不能夠發揮多線程的優勢了,而我們之是以使用多線程搞并發程式,為的就是提升性能。

Java SDK 并發包裡之是以有那麼多東西,有很大一部分原因就是要提升在

某個特定領域的性能。

不過從方案層面,我們可以這樣來解決這個問題。

第一,既然使用鎖會帶來性能問題,那最好的方案自然就是使用無鎖的算法和資料結構了。在這方面有很多相關的技術,例如線程本地存儲 (ThreadLocal Storage, TLS)、寫入時複制 (Copy-on-write)、樂觀鎖等;Java 并發包裡面的原子類也是一種無鎖的資料結構;Disruptor 則是一個無鎖的記憶體隊列,性能都非常好……

第二,減少鎖持有的時間。互斥鎖本質上是将并行的程式串行化,是以要增加并行度,一定要減少持有鎖的時間。這個方案具體的實作技術也有很多,例如使用細粒度的鎖,一個典型的例子就是 Java 并發包裡的ConcurrentHashMap,它使用了所謂分段鎖的技術;還可以使用讀寫鎖,也就是讀是無鎖的,隻有寫的時候才會互斥。

性能方面的度量名額有很多,我覺得有三個名額非常重要,就是:吞吐量、延遲和并發量。

吞吐量:指的是機關時間内能處理的請求數量。吞吐量越高,說明性能越好。

延遲:指的是從送出請求到收到響應的時間。延遲越小,說明性能越好。

并發量:指的是能同時處理的請求數量,一般來說随着并發量的增加、延遲也會增加。是以延遲這個名額,一般都會是基于并發量來說的。例如并發量是 1000 的時候,延遲是 50 毫秒。

7、面試

1、在 32 位的機器上對 long 型變量進行加減操作是否存在并發隐患?

long類型64位,在32位的機器上,對long類型的資料操作通常需要多條指令組合出來,無法保證原子性,是以并發的時候會出問題

2、wait() 方法和 sleep() 方法都能讓目前線程挂起一段時間,那它們的差別是什麼?

  • wait與sleep不同點在于:
  • wait是Object中的方法,Sleep是Thread中的方法
  • wait會釋放所有鎖而sleep不會釋放鎖資源.
  • wait隻能在同步方法和同步塊中使用,而sleep任何地方都可以

兩者相同點:

都會讓出CPU執行時間,等待再次排程!

CAS

CAS(Compare-and-Swap/Exchange),即比較并替換,是一種實作并發常用到的技術。CAS的整體架構如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

我們通過一個例子來認識一下

/**
 * CAS
 * 自旋
 * 底層的:
 * lock cmpxchgl 指令
 */
public class C00_KnowCAS {

    public static void main(String[] args) {
        AtomicInteger count = new AtomicInteger();
        count.incrementAndGet();
    }
}
           

翻看 AtomicInteger#incrementAndGet() 的源碼、

public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
           

這裡涉及到一個重要的類 Unsafe ,我們檢視該類中的 getAndAddInt 方法

public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!compareAndSwapInt(o, offset, v, v + delta));
        return v;
    }
           

循環體内是做計算, while 中是進行 CAS 操作,這樣通過不斷循環并通過CAS修改值的方式我們叫做自旋。

檢視 compareAndSwapInt 方法

public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);

           

是一個native方法,是以我們需要找到更底層的源碼,故下載下傳 jvm–>jdk8u- ->hotspot 源碼,找到 unsafe 的底層實作 src\share\vm\prims\unsafe.cpp

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv 
*env, jobject unsafe, jobject obj, jlong offset, jint e, 
jint x)) 
UnsafeWrapper("Unsafe_CompareAndSwapInt"); 
oop p = JNIHandles::resolve(obj); 
jint* addr = (jint *) index_oop_from_field_offset_long(p, 
offset); 
return (jint)(Atomic::cmpxchg(x, addr, e)) == e; 
UNSAFE_END

           

這最終需要走到: Atomic::cmpxchg 函數中,而 Atomic::cmpxchg 在linux_x86和windows_x86的實作分别如下: src\os_cpu\linux_x86\vm\atomic_linux_x86.inline.hpp

inline jint Atomic::cmpxchg (jint 
exchange_value, volatile jint* dest, jint 
compare_value) { 
int mp = os::is_MP(); 
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)" 
: "=a" (exchange_value) 
: "r" (exchange_value), "a" 
(compare_value), "r" (dest), "r" (mp) 
: "cc", "memory"); 
return exchange_value; 
}
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

           

src\os_cpu\windows_x86\vm\atomic_windows_x86.inline.hpp

inline jint Atomic::cmpxchg (jint 
exchange_value, volatile jint* dest, jint 
compare_value) { 
// alternative for InterlockedCompareExchange 
int mp = os::is_MP(); 
__asm { 
mov edx, dest 
mov ecx, exchange_value 
mov eax, compare_value 
LOCK_IF_MP(mp) 
cmpxchg dword ptr [edx], ecx 
} 
} 

           

當然這裡重點關注linux即可,當然這其中有個比較關鍵的代碼os::is_MP() ,是用來判斷目前系統是否是多處理器,

實作的邏輯是這樣的,CAS在底層是有一條對應的彙編指令的,就是:cmpxchgl ,硬體直接支援,當然如果是多核處理器,在該指令前會加一個lock 指令,合起來就是CAS底層的實作原理,對應這樣一條指令

lock cmpxchgl 指令
           

為什麼要加一個lock指令,因為 cmpxchgl 本身是一個非原子操作,不能保證在CAS的過程中不會被别的CPU線程破壞,加上 lock 後,保證 lock 後的指令執行時是互斥的。

CAS雖然很高效的解決了原子操作問題,但是CAS仍然存在三大問題。

  1. 自旋(循環)時間長開銷很大,如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷,注意這裡的自旋是在使用者态/SDK 層面實作的。
  2. 隻能保證一個共享變量的原子操作,對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。
  3. ABA問題,在使用CAS前要考慮清楚“ABA”問題是否會影響程式并發的正确性,如果需要解決ABA問題,改用傳統的互斥同步可能會比CAS更高效。

Atomic:

前面講的解決原子性問題都是基于互斥鎖方案,但其實對于比較簡單的原子性問題,還有一種無鎖方案,JUC包将這種無鎖方案封裝提煉之後,實作了一系列的原子類: jre\lib\rt.jar!\java\util\concurrent\atomic

無鎖方案相對互斥鎖方案,最大的好處就是性能。互斥鎖方案為了保證互斥性,需要執行加鎖、解鎖操作,而加鎖、解鎖操作本身就消耗性能;同時拿不到鎖的線程還會進入阻塞狀态,進而觸發線程切換,線程切換對性能的消耗也很大。 相比之下,無鎖方案則完全沒有加鎖、解鎖的性能消耗,同時還能保證互斥性,既解決了問題,又沒有帶來新的問題,可謂絕佳方案。

并發包裡提供的原子類内容很豐富,我們可以将它們分為五個類别:

  • 原子化的基本資料類型:AtomicBoolean、AtomicInteger 和AtomicLong
  • 原子化的對象引用類型:AtomicReference、 AtomicStampedReference 和AtomicMarkableReference,需要注意 的是,對象引用的更新需要重點關注 ABA 問題,
  • AtomicStampedReference 和 AtomicMarkableReference 這兩個原 子類可以解決 ABA問題,解決的方案也很簡單就是咱們所說的版本号 機制(樂觀鎖),比如AtomicStampedReference 實作的 CAS 方法就增加了版本号參數,方法簽名如下:
public boolean compareAndSet(V expectedReference, 
V newReference, 
int expectedStamp, 
int newStamp) 
           

原子化數組: AtomicIntegerArray、AtomicLongArray 和AtomicReferenceArray,利用這些原子類,我們可以原子化地更新數組裡面的每一個元素。這些類提供的方法和原子化的基本資料類型的差別僅僅是:每個方法多了一個數組的索引參數了。

原子化對象屬性更新器: AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater,利用它們可以原子化地更新對象的屬性,這三個方法都是利用反射機制實作的,比如 AtomicIntegerFieldUpdater 建立更新器的方法簽名如下:

public static <U> AtomicIntegerFieldUpdater<U> 
newUpdater(Class<U> tclass, 
String fieldName) 
           

需要注意的是,對象屬性必須是 volatile 類型的,隻有這樣才能保證可見性。

原子化的累加器:DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder,這四個類僅僅用來執行累加操作,

相比原子化的基本資料類型,速度更快,但是不支援compareAndSet() 方法。如果你僅僅需要累加操作,使用原子化的累加器性能會更好

最後:

無鎖方案相對于互斥鎖方案,優點非常多,首先性能好,其次是基本不會出現死鎖問題(但可能出現饑餓和活鎖問題,因為自旋會反複重試),最後我們所有原子類的方法都是針對一個共享變量的,如果你需要解決多個變量的原子性問題,建議還是使用互斥鎖方案。原子類雖好,但使用要慎之又慎。

synchronized

0、Linux核心同步機制

POSIX threads(簡稱pthreads)是在多核平台上進行并行程式設計的一套常用的API。線程同步(Thread Synchronization)是并行程式設計中非常重要的通訊手段,其中最典型的應用就是用pthreads提供的鎖機制(lock)來對多個線程之間共 享的臨界區(Critical Section)進行保護。

pthreads提供的鎖機制如下:

1、Mutex(互斥量):pthread_mutex_t,通過對該結構的操作,來判斷資源是否可以通路,Mutex屬于sleep-waiting類型的鎖,例如在多核機器上有兩個線程A,B,如果此時鎖被A持有,那麼B就會被阻塞,在等待隊列中等待。

man -k mutex 
pthread_mutex_consistent (3p) - mark state protected by 
robust mutex as consistent 
pthread_mutex_destroy (3p) - destroy and initialize a 
mutex 
pthread_mutex_getprioceiling (3p) - get and set the 
priority ceiling of a mutex (REALTIME THREADS) 
pthread_mutex_init (3p) - destroy and initialize a mutex 
pthread_mutex_lock (3p) - lock and unlock a mutex 
pthread_mutex_setprioceiling (3p) - get and set the 
priority ceiling of a mutex (REALTIME THREADS) 
pthread_mutex_timedlock (3p) - lock a mutex (ADVANCED 
REALTIME) 
pthread_mutex_trylock (3p) - lock and unlock a mutex 
pthread_mutex_unlock (3p) - lock and unlock a mutex 
           

2、Spin lock(自旋鎖):pthread_spinlock_t,屬于busy-waiting類型的鎖,它不會引起調用者睡眠等待,如果擷取不到鎖則進入忙等待,它會不停的嘗試去擷取鎖,俗稱自旋,擷取鎖的性能相對較高,但是費CPU,是以自旋鎖不應該被長時間的持有。

man -k spin 
pthread_spin_destroy (3p) - destroy or initialize a spin 
lock object (ADVANCED REALTIME THREADS) 
pthread_spin_init (3p) - destroy or initialize a spin lock 
object (ADVANCED REALTIME THREADS) 
pthread_spin_lock (3p) - lock a spin lock object (ADVANCED 
REALTIME THREADS) 
pthread_spin_trylock (3p) - lock a spin lock object 
(ADVANCED REALTIME THREADS) 
pthread_spin_unlock (3p) - unlock a spin lock object 
(ADVANCED REALTIME THREADS) 
           

3、 Condition Variable(條件變量):pthread_cond_t,條件變量是利用線程間共享的全局變量,進行同步的一種機制

man -k cond
           

4、Read/Write Lock(讀寫鎖):pthread_rwlock_t,讀寫鎖是用來解決讀多寫少問題的,讀操作可以共享,寫操作是排他的。

man -k rwlock
           

另外核心還提供了信号量(semaphore)機制,也可用于互斥鎖的實作

5、semaphore:sem_t

man -k sem
           

1、synchronized多層面解讀

這一節我們先從不同的層面來認識synchronized關鍵字的底層實作。

1.1、源碼層面

源碼層面是最好了解的,代碼如下,這裡不再贅述

public static void synClass() { 
Object obj = new Object(); 
synchronized (obj) { 
} 
} 
           

1.2、位元組碼層面

下面我們來看一看上面的代碼生成的位元組碼

0 new #2 <java/lang/Object> 
3 dup 
4 invokespecial #1 <java/lang/Object.<init> : ()V> 
7 astore_1 
8 aload_1 
9 dup 
10 astore_2 
11 monitorenter 
12 aload_2 
13 monitorexit 
14 goto 22 (+8) 
17 astore_3 
18 aload_2 
19 monitorexit 
20 aload_3 
21 athrow 
22 return
           

其中跟synchronized關鍵字相關的就是這樣的位元組碼

monitorenter 
........ 
monitorexit 
           

monitorenter主要是擷取螢幕鎖,monitorexit主要是釋放螢幕鎖

1.3、jvm層面

如果一旦擷取了某個對象的鎖,我們來看一下,擷取到對象鎖前後該對象

有什麼變化

public static void synJvm() { 
Object obj = new Object(); 
System.out.println(ClassLayout.parseInstance(obj).toPrinta 
ble()); 
synchronized (obj) { 
System.out.println(ClassLayout.parseInstance(obj).toPrinta 
ble()); 
} 
} 
           

列印輸出的結果如下

java.lang.Object object internals: 
OFFSET SIZE TYPE DESCRIPTION VALUE 
0 4 (object header) 01 00 00 00 (00000001 
00000000 00000000 00000000) (1) 
4 4 (object header) 00 00 00 00 (00000000 
00000000 00000000 00000000) (0) 
8 4 (object header) e5 01 00 f8 (11100101 
00000001 00000000 11111000) (-134217243) 
12 4 (loss due to the next object alignment) 
Instance size: 16 bytes 
Space losses: 0 bytes internal + 4 bytes external = 4 
bytes total 
java.lang.Object object internals: 
OFFSET SIZE TYPE DESCRIPTION VALUE 
0 4 (object header) 58 f3 2f 92 (01011000 
11110011 00101111 10010010) (-1842351272) 
4 4 (object header) a3 00 00 00 (10100011 
00000000 00000000 00000000) (163) 
8 4 (object header) e5 01 00 f8 (11100101 
00000001 00000000 11111000) (-134217243) 
12 4 (loss due to the next object alignment) 
Instance size: 16 bytes 
Space losses: 0 bytes internal + 4 bytes external = 4 
bytes total 
           

在鎖的使用過程中伴随着一系列的鎖更新過程。

2、markword

通過列印一個Object對象加鎖前後記憶體布局的變化可知,對一個對象使用synchronized關鍵字加鎖,鎖資訊是存儲在對象頭markword中的。我們可以從JVM源碼中找到關于對象頭markword的說明

src\share\vm\oops\markOop.hpp

// 64 bits: 
// -------- 
// unused:25 hash:31 -->| unused:1 age:4 
biased_lock:1 lock:2 (normal object) 
// JavaThread*:54 epoch:2 unused:1 age:4 
biased_lock:1 lock:2 (biased object) 
// PromotedObject*:61 --------------------->| 
promo_bits:3 ----->| (CMS promoted object) 
// size:64 ---------------------------------------------- 
------->| (CMS free block) 
//
// unused:25 hash:31 -->| cms_free:1 age:4 
biased_lock:1 lock:2 (COOPs && normal object) 
// JavaThread*:54 epoch:2 cms_free:1 age:4 
biased_lock:1 lock:2 (COOPs && biased object) 
// narrowOop:32 unused:24 cms_free:1 unused:4 
promo_bits:3 ----->| (COOPs && CMS promoted object) 
// unused:21 size:35 -->| cms_free:1 unused:7 ----------- 
------->| (COOPs && CMS free block) 
           

markword圖示如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

我們發現,markword的後三位被設定成了跟鎖相關的标志位,其中有兩位是鎖标志位,1位是偏向鎖标志位。

3、鎖更新

前面我們看到了synchronized在位元組碼層面是對應 monitorenter 合 monitorexit ,而真正實作互斥的鎖其實依賴作業系統底層的 Mutex Lock 來實作,首先要明确一點,這個鎖是一個重量級的鎖,由作業系統直接管理,要想使用它,需要将目前線程挂起并從使用者态切換到核心态來執行,這種切換的代價是非常昂貴的。

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

确實jdk1.6之前每次擷取的都是重量級鎖,無疑在很多場景下性能不高,故jdk1.6對synchronized做了很大程度的優化,其目的就是為了減少這種重量級鎖的使用。

整體鎖更新的過程大緻可以分為兩條路徑,如下:

并發程式設計(線程發展史 基礎知識 三大特性 JMM 三大特性解決方案 CAS SYN Unsafe)并發程式設計

未加過鎖時的對象狀态:

// 未使用過鎖的狀态 hashcode
    public static void noSyn() {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        //如果調用了hashcode
        int hashCode = obj.hashCode();
        System.out.println(Integer.toBinaryString(hashCode));
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());

    }
           

markword後三位: 0 01

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

1000011010101010110100100111000
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 38 69 55 (00000001 00111000 01101001 01010101) (1432958977)
      4     4        (object header)                           43 00 00 00 (01000011 00000000 00000000 00000000) (67)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

           

synchronized預設輕量級鎖

// 偏向未啟動,synchronized預設 輕量級鎖
    public static void lightweightSyn() {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
           

markword後兩位:00

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           d0 e9 55 6d (11010000 11101001 01010101 01101101) (1834346960)
      4     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

           

輕量級鎖:線程在自己的線程棧生成 Lock Record ,使用CAS的方式将markword設定為指向自己線程 LOCK Record 的指針,設定成功者得鎖。

過度競争使用重量級鎖

如果鎖競争加劇(線程自旋超過10次,-XX:PreBlockSpin,自旋線程數超過CPU核數的一半)更新重量級鎖,向作業系統申請資源,線程挂起進入鎖的等待隊列,等待作業系統排程。

jdk1.6後加入了自适應自旋Adapative Self Spinning,jvm自己控制何時更新為重量級鎖。

使用偏向鎖

偏向鎖,偏向的是第一個來擷取鎖的線程。

所謂上偏向鎖,指的是擷取鎖的線程在markword中寫自己的線程ID的過程,偏向鎖更新為輕量級鎖時首先要撤銷偏向鎖,如何設定輕量級鎖。

偏向鎖預設是打開的,但是啟動有一個時延,預設4s,之是以要延遲,是因為JVM虛拟機自己有一些預設的啟動線程,裡面有好多sync代碼,這些代碼啟動時就肯定會有競争,如果直接使用偏向鎖,就會造成偏向鎖不斷的進行鎖撤銷和鎖更新的操作,效率較低。

public static void biasedSyn() {
        ThreadUtil.sleepSeconds(5);//也可添加啟動參數 -XX:BiasedLockingStartupDelay=0 可立即啟動,但不建議
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
           
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 88 00 5a (00000101 10001000 00000000 01011010) (1509984261)
      4     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      8     4        (object header)                           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

           

markword後三位:1 01

細心的你也許會發現,還未加鎖時,對象的鎖狀态位就已經是 101了,的确,偏向鎖一旦啟動後,這時候New出來的對象就是匿名偏向鎖對象 ,就是說他已經就是偏向鎖了,但是沒有線程ID,裡面空的。有線程來搶,将自己的ID貼出來,就是偏向鎖。

另外注意:如果已啟動偏向鎖,但是加鎖前調用了hashcode,則無法使用偏向鎖 原因是markword中存了hashcode後沒位置存偏向鎖線程id了,加鎖時直接就是輕量級鎖了。

另外:有鎖更新,是不是也有鎖降級呢?

https://www.zhihu.com/question/63859501

STW

4、鎖消除,鎖粗化

鎖消除(lock eliminate):虛拟機的運作時編譯器在運作時如果檢測到一些要求同步的代碼上不可能發生共享資料競争,則會去掉這些鎖

// 鎖消除 append方法本身是添加了synchronized的,但sb變量是線程私 有的不會發生競争 
public static void lockEliminate() { 
StringBuffer sb = new StringBuffer(); 
sb.append("hello").append("ts"); 
} 

           

鎖粗化(Lock coarsening):将臨近的代碼塊用同一個鎖合并起來。

// 鎖粗化 
public static String lockCoarsening() { 
int i=0; 
StringBuffer sb = new StringBuffer(); 
while (i<100) { 
sb.append(i); 
i++; 
}
return sb.toString(); 
} 

           

5、底層彙編

synchronized在底層對應着一條彙編指令

// 檢視 synchronized 對應的彙編指令 
// 1,下載下傳插件:hsdis-amd64.dll,放 
到%JAVA_HOME%\jre\bin\server下, 
// 2,運作需要添加參數: -XX:+UnlockDiagnosticVMOptions - 
XX:+PrintAssembly 
static volatile int count = 0; 
static void m1() { 
for (int i=0;i<10000;i++) { 
m2(); 
m3(i); 
} 
}
private synchronized static void m2() { 
}
private static void m3(int i) { 
count = i; 
} 

           

Unsafe

https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html