使用 Synchronized 關鍵字

使用 Synchronized 關鍵字來解決併發問題是最簡單的一種方式,咱們只須要使用它修飾須要被併發處理的代碼塊、方法或字段屬性,虛擬機自動爲它加鎖和釋放鎖,並將不能得到鎖的線程阻塞在相應的阻塞隊列上。java

基本使用

咱們在上篇文章介紹線程的基本概念時,提到了多線程的好處,可以最大化 CPU 使用效率、更友好交互等等,可是也提出了它帶來的問題,好比競態條件、內存可見性問題。git

咱們引用上篇文章中的一個案例:github

image

一百個線程隨機地爲 count 加一,因爲自增操做非原子性,多線程之間不正常的訪問致使 count 最終的值不肯定,始終得不到預期的結果。緩存

使用 synchronized 即刻就能解決,看代碼:bash

image

代碼稍做修改,如今的程序不管你運行多少次,或者你增大併發量,最後 count 的值老是正確的 100 。微信

大概什麼意思呢?多線程

咱們的 JAVA 中,對於每一個對象都有一把『內置鎖』,而 synchronized 中的代碼在被線程執行以前,會去嘗試獲取一個對象的鎖,若是成功,就進入並順利執行代碼,不然將會被阻塞在該對象上。併發

除此以外,synchronized 除了能夠修飾代碼塊,還能夠直接修飾在方法上,例如:性能

public synchronized void addCount(){......}
複製代碼
public static synchronized void addCount(){......}
複製代碼

這是兩種不一樣的使用方式,前一種是使用 synchronized 修飾的實例方法,那麼 synchronized 使用的就是當前方法調用時所屬的那個實例的『內置鎖』。也就是說,addCount 方法調用前會去嘗試獲取調用實例對象的鎖。優化

然後一種 addCount 方法是一個靜態方法,因此 synchronized 使用的就是 addCount 所屬的類對象的鎖。

synchronized 的使用方式仍是很簡單的,何時加鎖,何時釋放鎖都不須要咱們操心,被 JVM 封裝好了,下面咱們就來簡單看看 JVM 是如何實現這種間接鎖機制的。

基本實現原理

咱們先看一段簡單的代碼:

public class TestAxiom {
    private int count;

    @Test
    public void test() throws InterruptedException {
        synchronized (this){
            count++;
        }
    }
}
複製代碼

這是一段很是簡單的代碼,使用 synchronized 修飾代碼塊,保護 count++ 操做。如今咱們反編譯一下:

image

能夠看到,在執行 count++ 指令以前,編譯器加了一條 monitorenter 指令,count++ 指令執行結束時又加了一條 monitorexit 指令。準確意義上來講,這就是兩條加鎖的釋放鎖的指令,具體細節咱們稍後再看。

除此以外,咱們的 synchronized 方法在反編譯後並無這兩條指令,可是編譯器卻在方法表的 flags 屬性中設置了一個標誌位 ACC_SYNCHRONIZED。

這樣,每一個線程在調用該方法以前都會檢查這個狀態位是否爲 1,若是狀態爲 1 說明這是一個同步方法,須要首先執行 monitorenter 指令去嘗試獲取當前實例對象的內置鎖,並在方法執行結束執行 monitorexit 指令去釋放鎖。

其實本質上是同樣的,只是 synchronized 方法是一種隱式的實現。下面咱們來看一看這個內置鎖的具體細節。

Java 中一個對象主要由如下三種類型數據組成:

  • 對象頭:也稱 Mark Word,主要存儲的對象的 hash 值以及相關鎖信息。
  • 實例數據:保存的當前對象的數據,包括父類屬性信息等。
  • 填充數據:這部分是應 JVM 要求,每一個對象的起始地址必須是 8 的倍數,因此若是當前對象不足 8 的倍數字節時用於字節填充。

咱們的『內置鎖』在對象頭裏面,而 Mark Word 的一個基本結構是這樣的:

image

先不去管什麼是,輕量鎖,重量鎖,偏向鎖,自旋鎖,這是虛擬機一種鎖優化機制,經過鎖膨脹來優化性能,這一點的細節咱們之後再介紹,你先把它們統一理解爲一把鎖。

其中,每把鎖會有一個標誌位用於區分鎖類型,和一個指向鎖記錄的指針,也就是說鎖指針會關聯另外一種結構,Monitor Record。

image

Owner 字段存儲的是擁有當前鎖的線程惟一標識號,當某個線程擁有了該鎖以後就會把本身的線程號寫入這個字段中。若是某個線程發現這裏的 Owner 字段不是 null 也不是本身的線程號,那麼它將會被阻塞在 Monitor 的阻塞隊列上直至某個線程走出同步代碼塊併發起喚醒操做。

總結一下,被 synchronized 修飾的代碼塊或者方法在編譯器會被額外插入兩條指令,monitorenter 會去檢查對象頭鎖信息,對應到一個 Monitor 結構,若是該結構的 Owner 字段已經被佔用了,那麼當前線程將會被阻塞在 Monitor 的一個阻塞隊列上,直到佔有鎖的線程釋放了鎖並喚起一波新的鎖競爭。

synchronized 的幾個特性

一、可重入性

一個對象每每有多個方法,這些方法有的是同步的,有的是非同步的,那麼若是一個線程已經得到了某個對象的鎖並進入了其某個同步方法,而這個同步方法中還須要調用同一實例的另外一個同步方法,是否須要從新競爭鎖?

這對於某些鎖來講,是須要從新競爭鎖的,可是咱們的 synchronized 是「可重入的」,也就是說,若是當前線程得到了某個對象的鎖,那麼該對象的全部方法都是能夠無需競爭鎖式調用的。

緣由也很簡單,monitorenter 指令找到 Monitor,查看了 Owner 字段的值等於當前線程的線程號,因而將 Nest 字段增長一,表示當前線程屢次持有該對象的鎖,每調用一次 monitorexit 都會減一 Nest 的值。

二、內存可見性

引用上篇文章的一個例子:

image

線程 ThreadTwo 不停的監聽 flag 的值,而咱們主線程對 flag 進行了修改,因爲內存可見性,ThreadTwo 看不見,因而程序一直死循環。

某種意義上,synchronized 是能夠解決這類內存可見性問題的,修改代碼以下:

image

主線程先得到 obj 的內置鎖,而後啓動 ThreadTwo 線程,該線程因爲獲取不到 obj 的鎖而被阻塞,也就是它知道已經有其餘線程在操做共享變量,因此等到本身得到鎖的時候必定要從內存從新讀一下共享變量。

而咱們的主線程會在釋放鎖的時候將私有工做內存中全部的全局變量的值刷新到內存空間,這樣其實就實現了多線程之間的內存可見性。

固然有一點你們要注意,synchronized 修飾的代碼塊會在釋放鎖的時候刷新本身更改過的全局變量,可是另外一個線程要想看見,必須也從內存中從新讀才行。而通常狀況下,不是你加了 synchronized 線程就會從內存中讀數據的,而只有它在競爭某把鎖失敗後,得知有其餘線程正在修改共享變量,這樣的前提下等到本身擁有鎖以後纔會從新去刷內存數據。

你也能夠試試,讓 ThreadTwo 線程不去競爭 obj 這把鎖,而隨便給它一個對象,結果依然會是死循環,flag 的值只會是 ThreadTwo 剛啓動時從內存讀入的初始數據的緩存版。

可是說實話,解決內存可見性而使用 synchronized 代價過高,須要加鎖和釋放鎖,甚至還須要阻塞和喚醒線程,咱們通常使用關鍵字 volatile 直接修飾在變量上就能夠了,這樣對於該變量的讀取和修改都是直接映射內存的,不通過線程本地私有工做內存的。

關於 synchronized 關鍵字咱們暫時先介紹到這,後續還會涉及到它的,咱們還要介紹近幾個 JDK 版本對於 synchronized 的優化細節,包括自旋鎖,偏向鎖,重量級鎖之間的鎖膨脹機制,也是這種優化使得如今的 synchronized 性能不輸於 Lock。


文章中的全部代碼、圖片、文件都雲存儲在個人 GitHub 上:

(github.com/SingleYam/o…)

歡迎關注微信公衆號:OneJavaCoder,全部文章都將同步在公衆號上。

image
相關文章
相關標籤/搜索