併發系列之「Java中的synchronized關鍵字」

HoryChang發表於2020-10-13

本文為《Java併發程式設計之美》學習筆記

Java中共享變數的記憶體可見性問題

在講synchronized之前先來講一下Java中共享變數的記憶體可見性問題。

先來看看在多執行緒下處理共享變數時Java的記憶體模型:
併發系列之「Java中的synchronized關鍵字」

Java記憶體模型規定,將所有的變數都存放在主記憶體中,當執行緒使用變數時,會把主記憶體裡面的變數複製到自己的工作空間或者叫作工作記憶體,執行緒讀寫變數時操作的是自己工作記憶體中的變數。Java記憶體模型是一個抽象的概念,那麼在實際實現中執行緒的工作記憶體是什麼呢?

實際上的記憶體模型:
併發系列之「Java中的synchronized關鍵字」

圖中所示是一個雙核CPU系統架構,每個核有自己的控制器(Controller)運算器(ALU),其中控制器包含一組暫存器操作控制器,運算器執行算術邏輯運算。每個核都有自己的一級快取,在有些架構裡面還有一個所有CPU都共享的二級快取。那麼Java記憶體模型裡面的工作記憶體,就對應這裡的L1或者L2快取或者CPU的暫存器。

當一個執行緒操作共享變數時,它首先從主記憶體複製共享變數到自己的工作記憶體,然後對工作記憶體裡的變數進行處理,處理完後將變數值更新到主記憶體。那麼假如執行緒A和執行緒B同時處理一個共享變數,會出現什麼情況?我們使用上面實際的CPU架構圖,假設執行緒A和執行緒B使用不同CPU執行,並且當前兩級Cache都為空,那麼這時候由於Cache的存在,將會導致記憶體不可見問題,具體看下面的分析。

  • 執行緒A首先獲取共享變數X的值,由於兩級Cache都沒有命中,所以載入主記憶體中X的值,假如為0。然後把X=0的值快取到兩級快取,執行緒A修改X的值為1,然後將其寫入兩級Cache,並且重新整理到主記憶體。執行緒A操作完畢後,執行緒A所在的CPU的兩級Cache內和主記憶體裡面的X的值都是1。
  • 執行緒B獲取X的值,首先一級快取沒有命中,然後看二級快取,二級快取命中了,所以返回X= 1;到這裡一切都是正常的,因為這時候主記憶體中也是X=1。然後執行緒B修改X的值為2,並將其存放到執行緒2所在的一級Cache和共享二級Cache中,最後更新主記憶體中X的值為2;到這裡一切都是好的。
  • 執行緒A這次又需要修改X的值,獲取時一級快取命中,並且X=1,到這裡問題就出現了,明明執行緒B已經把X的值修改為了2,為何執行緒A獲取的還是1呢?這就是共享變數的記憶體不可見問題,也就是執行緒B寫入的值對執行緒A不可見。

那麼如何解決共享變數記憶體不可見問題?使用Java中的synchronized關鍵字就可以解決這個問題。

synchronized關鍵字簡介

synchronized塊是Java提供的一種原子性內建鎖,Java中的每個物件都可以把它當作一個同步鎖來使用,這些Java內建的使用者看不到的鎖被稱為內部鎖,也叫作監視器鎖

執行緒的執行程式碼在進入synchronized程式碼塊前會自動獲取內部鎖,這時候其他執行緒訪問該同步程式碼塊時會被阻塞掛起。

拿到內部鎖的執行緒出現以下幾種情況之一會釋放該內建鎖:

  • 正常退出同步程式碼塊;
  • 丟擲異常後;
  • 在同步塊內呼叫了該內建鎖資源的wait系列方法

內建鎖是排它鎖,也就是當一個執行緒獲取這個鎖後,其他執行緒必須等待該執行緒釋放鎖後才能獲取該鎖。

此外,由於Java中的執行緒是與作業系統的原生執行緒一一對應的,所以當阻塞一個執行緒時,需要從使用者態切換到核心態執行阻塞操作,這是很耗時的操作,而synchronized的使用就會導致上下文切換。

synchronized的記憶體語義

前面介紹了共享變數記憶體可見性問題主要是由於執行緒的工作記憶體導致的,下面我們來講解synchronized的一個記憶體語義,這個記憶體語義就可以解決共享變數記憶體可見性問題。

進入synchronized塊的記憶體語義是把在synchronized塊內使用到的變數從執行緒的工作記憶體中清除,這樣在synchronized塊內使用到該變數時就不會從執行緒的工作記憶體中獲取,而是直接從主記憶體中獲取。退出synchronized塊的記憶體語義是把在synchronized塊內對共享變數的修改重新整理到主記憶體。

其實這也是加鎖釋放鎖的語義,當獲取鎖後會清空鎖塊內本地記憶體中將會被用到的共享變數,在使用這些共享變數時從主記憶體進行載入,在釋放鎖時將本地記憶體中修改的共享變數重新整理到主記憶體。除可以解決共享變數記憶體可見性問題外,synchronized經常被用來實現原子性操作。

另外請注意,synchronized關鍵字會引起執行緒上下文切換並帶來執行緒排程開銷。

相關文章