深入理解Java併發機制之volatile和synchronized

本文轉載自個人掘金博客:https://juejin.im/user/5dcbe4ccf265da30454fa660

本文爲《Java併發編程的藝術》一書第二,三章的讀書筆記。這內容之前看過幾遍,不過容易忘,索性記下來吧,忘了就在看看,放在網上也方便- -。

前言

Java代碼在編譯後會變成Java字節碼,字節碼被類加載器加載到JVM裏,JVM執行字節碼,最終需要轉化爲彙編指令在CPU上執行,Java中所使用的併發機制依賴於JVM的實現和CPU的指令

Java內存模型(JMM)

線程之間的通信機制

線程之間的通信機制有兩種:

  1. 共享內存:在共享內存的併發模型裏,線程之間共享程序的公共狀態,通過寫-讀內存中的公共狀態 進行隱式通信。
  2. 在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須通過發送消息來顯式進行通信。

Java內存模型

Java的併發採用的是共享內存模型,Java線程之間的通信總是隱式進行。JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量(實例域,靜態域和數組元素)存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。JMM的抽象如下圖所示:

 

image.png

 

 

從上圖來看,線程A與線程B之間通信,必須經歷下面2個步驟:

  1. 線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  2. 線程B到主內存中去讀取線程A之前已更新過的共享變量。

從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來爲Java程序員提供內存可見性保證。

指令重排序

在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。

  1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序

happens-before規則

JMM通過happens-before的概念來闡述操作之間的內存可見性,它實際上可以理解是JMM禁止某種類型的處理器重排序所建立的規則

  1. 程序順序規則: 一個線程中的每個操作,happens-before於該線程中的任意後續操作。在JMM裏一個線程其實只要執行結果一樣,是允許重排序的,這邊的happens-before強調的重點也是單線程執行結果的正確性,但是無法保證多線程也是如此。
  2. 監視器鎖規則:對一個線程的解鎖,happens-before於隨後對這個線程的加鎖。
  3. volatile變量規則: 對一個volatile域的寫,happens-before於後續對這個volatile域的讀。
  4. 傳遞性:如果A happens-before B ,且 B happens-before C, 那麼 A happens-before C。
  5. start()規則: 如果線程A執行操作ThreadB_start()(啓動線程B) , 那麼A線程的ThreadB_start() happens-before 於B中的任意操作。
  6. join()原則: 如果A執行ThreadB.join()並且成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。
  7. interrupt()原則: 對線程interrupt()方法的調用先行發生於被中斷線程代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否有中斷髮生。
  8. finalize()原則:一個對象的初始化完成先行發生於它的finalize()方法的開始。

volatile

volatile是輕量級的synchronize,它在多處理器開發中保證了共享變量的“可見性”。 可見性:當一個線程修改一個共享變量時,另外一個線程能立即讀到這個修改的值。

volatile的定義:Java編程語言允許線程訪問共享變量。如果一個字段被申明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。

volatile的實現原理:有volatile修飾的共享變量的代碼生成彙編代碼時會多出一個Lock前綴的指令。

Lock前綴指令實際相當於一個內存屏障,它提供了以下功能:

  1. 緩存失效
  • 使得當前CPU的cache數據寫回到系統內存

  • 寫回到內存的操作,使得其他CPU的cache數據無效

  1. 禁止重排序
    • 重排序時不能把後面的指令重排序到內存屏障之前的位置

volatile變量具有下列特性:

  1. 可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。volatile變量的讀-寫可以實現線程之間的通信。

  2. 原子性。對任意單個volatile變量的讀/寫具有原子性,但類似於volatile變量++的這種符合操作不具有原子性。

volatile變量++操作不具備原子性,假設線程A,讀取了inc的值爲10,這時候線程B也讀取了inc 10並進行自增且寫入了主存。但是,由於A線程已經讀取過了,這時候inc值還是10,自增之後還是11,寫回主存。這種情況下,雖然兩個線程執行了兩次increase(),結果卻只加了一次。

sychronized

sychronized是Java實現同步的基礎,正確的使用可以保證線程安全,解決多線程中的併發同步問題。Java的所有對象都有一個互斥鎖,這個鎖由JVM自動獲取和釋放。

應用場景

sychronized的應用場景大致有兩種:

對象鎖

對象鎖的作用:同步實例方法的調用。以下兩種使用方式都是對象鎖:

  1. 修飾實例方法的代碼塊,鎖的是synchronized括號裏配置的對象。
        public void run() {
            synchronized (this) {
                ...
            }
        }
  1. 修飾實例方法,鎖的是當前實例對象。
        private synchronized void runInfo() {
            ...
        }
  1. 修飾靜態變量,鎖的申明靜態變量類的對象實例
        private static Object object = new Object();
        @Override
        public void run() {
            synchronized (object) {
                ...
            }
        }

類鎖

類鎖的作用:同步靜態方法的調用或者是同步類鎖修飾方法的調用(在類生命週期的“加載”階段,會生成加載類的一個java.lang.class對象實例,作爲方法區在這個類的各種數據訪問的入口)。以下兩種使用方式都是類鎖:

  1. 修飾Class對象,鎖的是Class對象
        public void run() {
            synchronized (Timer.class) {
				...
            }
        }
  1. 修飾靜態方法,鎖的是所在類的Class對象
        private static synchronized void runInfo() {
            ...
        }

Demo程序

4個Demo共用一個Main函數

    public static void main(String[] args) {
        Timer timer = new Timer();
        Thread thread1 = new Thread(new Timer(), "Thead-1");
        Thread thread2 = new Thread(new Timer(), "Thead-2");
        Thread thread3 = new Thread(timer, "Thead-3");
        Thread thread4 = new Thread(timer, "Thead-4");

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }

對象鎖1:修飾實例方法的代碼塊

    static class Timer implements Runnable {
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (this) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. Thread-1,2,3 爲三個獨立的對象實例,所以起止時間一樣。Thread3,4公用一個對象實例。對象鎖保證所修飾的代碼塊,在同一個時間只能有一個持該對象鎖的線程進行訪問同步代碼塊。
  2. 同時位於同步代碼塊外的區域各個線程可以併發執行到。
threadInfo: OuterThead-4,Start Time:20:04:29
threadInfo: OuterThead-2,Start Time:20:04:29
threadInfo: OuterThead-3,Start Time:20:04:29
threadInfo: OuterThead-1,Start Time:20:04:29
threadInfo: InnerThead-4,Start Time:20:04:29
threadInfo: InnerThead-2,Start Time:20:04:29
threadInfo: InnerThead-1,Start Time:20:04:29
threadInfo:Thead-4,End Time:20:04:34
threadInfo:Thead-2,End Time:20:04:34
threadInfo:Thead-1,End Time:20:04:34
threadInfo: InnerThead-3,Start Time:20:04:34
threadInfo:Thead-3,End Time:20:04:39

對象鎖2:修飾實例方法

    static class Timer implements Runnable {
        @Override
        public synchronized void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        }
    }
  1. 同上的對象鎖,Thread-1,2,3的三個對象實例獨立,所以3個線程併發同時執行,所以起止時間一樣。Thread3,4公用一個對象實例,同步的方法由Thread3,4順序執行。
  2. 同步範圍是整個方法。
threadInfo: OuterThead-1,Start Time:20:12:00
threadInfo: OuterThead-3,Start Time:20:12:00
threadInfo: OuterThead-2,Start Time:20:12:00
threadInfo: InnerThead-1,Start Time:20:12:00
threadInfo: InnerThead-3,Start Time:20:12:00
threadInfo: InnerThead-2,Start Time:20:12:00
threadInfo:Thead-1,End Time:20:12:05
threadInfo:Thead-3,End Time:20:12:05
threadInfo:Thead-2,End Time:20:12:05
threadInfo: OuterThead-4,Start Time:20:12:05
threadInfo: InnerThead-4,Start Time:20:12:05
threadInfo:Thead-4,End Time:20:12:10

對象鎖3:修飾靜態變量

    static class Timer implements Runnable {
        private static Object object = new Object();
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (object) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. 這裏的對象鎖有些特殊,由於靜態變量的實例對象只有一份,所以4個線程在遇到同步塊時需要排隊執行同步代碼塊,誰持有鎖就執行,執行完成後釋放鎖,由下一個線程獲取鎖執行。
  2. 同時位於同步代碼塊外的區域各個線程可以併發執行到。
threadInfo: OuterThead-4,Start Time:15:36:45
threadInfo: OuterThead-3,Start Time:15:36:45
threadInfo: OuterThead-2,Start Time:15:36:45
threadInfo: OuterThead-1,Start Time:15:36:45
threadInfo: InnerThead-4,Start Time:15:36:45
threadInfo:Thead-4,End Time:15:36:50
threadInfo: InnerThead-1,Start Time:15:36:50
threadInfo:Thead-1,End Time:15:36:55
threadInfo: InnerThead-2,Start Time:15:36:55
threadInfo:Thead-2,End Time:15:37:00
threadInfo: InnerThead-3,Start Time:15:37:00
threadInfo:Thead-3,End Time:15:37:05

類鎖1:修飾Class對象

    static class Timer implements Runnable {
        @Override
        public void run() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            synchronized (Timer.class) {
                System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }
        }
    }
  1. 類鎖保證所修飾的代碼塊,在同一個時間只能有一個持該類鎖的線程進行訪問同步代碼塊。
  2. 同時位於同步代碼塊外的區域各個線程可以併發執行到。
  3. 鎖的是類的Class對象,java.lang.class。
threadInfo: OuterThead-2,Start Time:20:17:46
threadInfo: OuterThead-1,Start Time:20:17:46
threadInfo: OuterThead-3,Start Time:20:17:46
threadInfo: OuterThead-4,Start Time:20:17:46
threadInfo: InnerThead-2,Start Time:20:17:46
threadInfo:Thead-2,End Time:20:17:51
threadInfo: InnerThead-4,Start Time:20:17:51
threadInfo:Thead-4,End Time:20:17:56
threadInfo: InnerThead-3,Start Time:20:17:56
threadInfo:Thead-3,End Time:20:18:01
threadInfo: InnerThead-1,Start Time:20:18:01
threadInfo:Thead-1,End Time:20:18:06

類鎖2:修飾靜態方法

    static class Timer implements Runnable {
        @Override
        public void run() {
            runInfo();
        }

        private static synchronized void runInfo() {
            String threadInfo = Thread.currentThread().getName();
            System.out.println("threadInfo: Outer" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            System.out.println("threadInfo: Inner" + threadInfo + ",Start Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("threadInfo:" + threadInfo + ",End Time:" + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        }
    }
  1. 類鎖保證所修飾的代碼塊,在同一個時間只能有一個持該類鎖的線程進行訪問同步代碼塊。
  2. 修飾靜態方法的類鎖同步的範圍是整個靜態方法。
  3. 鎖的是類的Class對象,java.lang.class。
threadInfo: OuterThead-1,Start Time:20:27:15
threadInfo: InnerThead-1,Start Time:20:27:15
threadInfo:Thead-1,End Time:20:27:20
threadInfo: OuterThead-3,Start Time:20:27:20
threadInfo: InnerThead-3,Start Time:20:27:20
threadInfo:Thead-3,End Time:20:27:25
threadInfo: OuterThead-4,Start Time:20:27:25
threadInfo: InnerThead-4,Start Time:20:27:25
threadInfo:Thead-4,End Time:20:27:30
threadInfo: OuterThead-2,Start Time:20:27:30
threadInfo: InnerThead-2,Start Time:20:27:30
threadInfo:Thead-2,End Time:20:27:35

synchronized的實現原理

JVM基於進入和退出monitor對象來實現方法同步和代碼塊同步。

代碼塊同步

對於代碼塊同步:

  1. JVM會在編譯後將monitorenter指令插入到同步代碼塊的開始位置,將monitorexit指令插入到方法結束處和異常處,JVM保證每個monitorenter必須有對應的monitorexit與之匹配。
  2. 任何對象都有一個monitor對象與之關聯(對象頭的Mark Word中會存指向互斥量monitor的指針),當一個monitor對象被持有後,它將處於鎖定狀態。
  3. 線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖,過程如下:
  • 如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者。
  • 如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1(可重入)。
  • 如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權。

方法同步

對於方法同步:

  1. JVM會在編譯後在同步方法的常量池中放入ACC_SYNCHRONIZED標誌。
  2. 當線程訪問方法時,會先檢查是否有ACC_SYNCHRONIZED,如果有設置,將會嘗試獲取對象所對應的monitor的所有權。獲取之後再進行方法的執行。
  3. 如果在方法執行過程中,發生了異常,並且方法內部並沒有處理該異常,那麼在異常被拋到方法外面之前監視器鎖會被自動釋放。

其實,不管是對象鎖還是類鎖,“鎖記錄”都是存儲在Java對象中,只不過類鎖的鎖記錄是存儲在java.lang.class的對象實例中,對象鎖的鎖記錄是存儲在其他類的對象實例中。這就引出了我們下面的內容。

Java對象的內存佈局

在HotSpot虛擬機中,對象在內存中存儲的佈局可以分爲3塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

 

image.png

 

 

對象頭

以JDK8 64位的HotSpot虛擬機而言,對象頭包括三部分信息:

  1. Mark Word: 存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等 。大小爲8個字節。

  2. Class Word: 類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例 。JDK 8默認開啓指針壓縮的情況下,大小爲4個字節。

  3. Array Length:只有對象是Java數組實例的時候,對象頭才包含這個部分。存儲數組的長度。大小爲4個字節。

實例數據

實例數據部分是對象真正存儲的有效信息,也是在程序代碼中所定義的各種類型的字段內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。

對齊填充

對齊填充可能存在,它僅僅起着佔位符的作用。HotSpot VM 要求對象的大小必須是8字節的整數倍。

從Java對象的內存佈局我們可以發現,鎖的相關信息就存儲在Java對象頭的Mark中。

synchronized鎖的優化

在JDK 1.6之前,synchronized鎖都是重量級鎖,通過互斥量monitor來實現。從JDK 1.6開始,HotSpot虛擬機團隊引入了適應性自旋、輕量級鎖和偏向鎖等鎖優化內容。這些鎖優化是虛擬機根據競爭情況自行決定的,都封裝到synchronized的實現中了。

線程的阻塞和喚醒需要CPU從用戶態轉爲核心態 ,而很多時候通過synchronized加鎖進行同步控制的時候,鎖狀態只會持續很短一段時間,爲了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。所以引入自旋鎖。

內核態:CPU可以訪問內存所有數據, 包括外圍設備, 例如硬盤, 網卡. CPU也可以將自己從一個程序切換到另一個程序。

用戶態:只能受限的訪問內存, 且不允許訪問外圍設備. 佔用CPU的能力被剝奪, CPU資源可以被其他程序獲取。

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。

偏向鎖

偏向鎖的加鎖,當線程進入和退出同步塊時,需要經歷幾個測試步驟:

  1. 當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID
  2. 該線程以後在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖
  3. 如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

偏向鎖的撤銷,當其他線程嘗試競爭偏向鎖時,就會釋放鎖,鎖的撤銷,需要等待全局安全點,分爲以下幾個步驟:

  1. 暫停擁有偏向鎖的線程,檢查線程是否存活

  2. 處於非活動狀態,則設置爲無鎖狀態

  3. 存活,則重新偏向於其他線程或者恢復到無鎖狀態或者標記對象不適合作爲偏向鎖(升級爲輕量級鎖)

  4. 喚醒線程

 

 

 

輕量級鎖

輕量級鎖的加鎖

  1. 線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中(Displaced Mark Word)。
  2. 然後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。
  3. 如果成功,當前線程獲得鎖;如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖

輕量級鎖解鎖

  1. 輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象。

  2. 如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

 

 

 

參考與感謝

  1. https://www.cnblogs.com/uoar/p/11673131.html
  2. https://juejin.im/post/5b6b982fe51d45195a7187cf
  3. https://juejin.im/post/5d5374076fb9a06ac76da894
  4. https://blog.csdn.net/carson_ho/article/details/82992269
  5. https://segmentfault.com/a/1190000004574249
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章