我們首先來看一下在多線程下處理共享變量時Java的記憶體模型,如圖所示
Java記憶體模型規定,将所有的變量都存放在主存中,當線程使用變量的時候,會把主記憶體裡面的變量指派到自己的工作區間或者叫工作記憶體,線程讀寫變量時操作的是自己的工作記憶體中的變量,Java記憶體模型是一個抽象的概念,那麼在實際中線程的工作記憶體是什麼呢?圖中顯示的是一個雙核CPU系統架構,每一個核都有自己的控制器和運算器,其中控制器包含一組寄存器和操作控制器,運算器執行算術邏輯運算。每一個核都有自己的一級緩存。 當一個線程操作共享變量的時,它首先從主存複制共享變量到自己的工作記憶體(私有記憶體)中,然後對工作記憶體的變量進行處理,處理完之後将變量值更新到主存中。假如線程A和線程B同時處理一個共享變量,會出現什麼情況呢?我們使用上圖2-5所示的CPU架構,假設線程A和B使用不同的CPU執行,并且目前兩級cache都為空,那麼由于這個時候cache的存在,将會導緻記憶體不可見問題: 線程A首先擷取到共享變量X的值,由于兩級cache都沒有命中,是以加載主記憶體中X的值,假如為0。然後把X=0值緩存到兩級cache中,線程A修改X=1,然後将其寫入兩級cache中,并且重新整理到主存中。線程A操作完畢後,線程A所在的CPU的兩級cache和主存中的X都為1。 線程B擷取到X的值,首選一級緩存沒有命中,然後看二級緩存,二級緩存命中了,是以傳回了一個X=1;到這裡一切都是正常的,因為這時候主記憶體中X=1,然後線程B修改X=2,并将其放到線程B所在的一級cache和二級cache中,最後更新主存中X=2。 線程A再次要修改X的值,擷取時一級緩存中命中,并且X=1,到這裡問題就出現了,明明線程B已經把X修改為2了,為何線程A讀取X的值還是1呢?這就是共享變量的記憶體不可見問題。也就是線程B寫入的值對線程A不可見。那麼如何解決共享變量線程不可見的問題呢?這裡就需要使用java中的volatile關鍵字解決這個問題,下面會講到。![]()
10-Java中共享記憶體可見性以及synchronized和volatile關鍵字
synchronized關鍵字介紹
synchronized塊是Java提供的一種原子性内置鎖,Java中的每一個對象都可以看成一個同步鎖來使用。這些Java内置的使用者看不到的鎖被稱為内置鎖,也叫螢幕鎖。線程的執行代碼塊在進入synchronized代碼塊前會自動的擷取到内部鎖,這時候其他線程通路該同步代碼塊會被阻塞挂起。拿到内部鎖的線程會在正常退出同步代碼塊或者抛出異常後或者在同步代碼塊内調用了該内置鎖資源的wait系列方法時會釋放該内置鎖。内置鎖是排它鎖,也就是當一個線程擷取到這個鎖之後,其他線程必須等待該線程釋放鎖後才能獲得該鎖。
synchronized的記憶體語義
前面介紹了共享變量記憶體可見性問題主要是由于線程當中工作記憶體所導緻的。下面我們來講解synchronized的一個記憶體語義,這個記憶體語義就是解決共享變量記憶體可見性問題。進入synchronized塊的記憶體語義是把synchronized塊内使用到的變量從線程的工作記憶體中清除,這樣在synchronized塊内使用到該變量時候就不會從工作記憶體中取,而是直接從主存中取,退出synchronized塊的記憶體語義是把sunchronized塊對共享變量的修改重新整理到主存中。其實這也是加鎖和釋放鎖的概念。當擷取鎖後會清空本地記憶體中将會用到的共享變量,在使用這些共享記憶體會從主存中加載,在釋放鎖時會将本地記憶體中修改的共享變量重新整理到主存中。synchronized除了用來解決共享變量記憶體不可見問題,還可以用來實作原子性操作。另外注意的是,synchronized關鍵字會不會引起線程上下文切換并帶來線程排程開銷。
上面介紹的是使用鎖的方式可以解決共享變量記憶體不可見問題。但是使用鎖太笨重,是以它會帶來線程上下文切換問題。對于解決記憶體可見性問題,Java還提供了一種弱形式的同步,也就是使用volatile關鍵字,該關鍵字確定一個變量的更新對其他線程馬上可見。當一個變量被聲明為volatile時,線程在寫入變量的時候不會把值緩存到寄存器或者其他地方,而是會把值重新整理傳回到主存中。當其他線程讀取該共享變量的時候,會直接從主存中重新擷取到最新值。而并不是使用工作記憶體中的值。voltile記憶體語義和synchronized語義有相似之處,當線程寫入volatile變量值的時候就等于線程退出synchronized同步塊(把寫入工作記憶體中共享變量的值同步到主記憶體),讀取volatile變量值時就相當于進入進入同步代碼塊(先清空本地記憶體中共享變量值,再從主存中擷取到最新值)。
下面使用volatile關鍵字解決記憶體可見性問題的例子,如下代碼中的共享變量value就是不安全的,因為這裡沒有适當的同步措施。
首先來看使用synchronized關鍵字進行同步的方式
然後使用volatile進行同步
在這裡使用volatile和synchronized是等價的。都解決的共享記憶體變量value不可見問題。但是前者是獨占鎖,其他線程調用會被阻塞等待,同時還存線上程上下文切換個線程重制排程的開銷。這也是使用鎖方式不好的地方。後者使用的是非阻塞算法,不會造成線程上下文切換的開銷。
所謂原子操作,是指執行一系列操作要麼一次性全部執行完,要麼全部都不執行。如果不能保證操作室原子性操作,那麼就會出現線程安全問題,如下:
首先執行javac ThreadNotSafeCount.java指令 然後執行javap -c ThreadNotSafeCount.class指令
我們該如何保證多個操作的原子性呢?最簡單的辦法就是使用synchronized關鍵字進行同步,代碼如下
使用synchronized關鍵字的确可以實作線程安全性,即記憶體可見性和原子性,但是synchronized是獨占鎖,内有擷取到内部鎖的線程會被阻塞掉,但是getValue()隻是讀操作,多個線程同時調用這個方法并不會引發線程安全問題,但是加了synchronized關鍵字後,同一時間隻能有一個線程可以調用,這顯然是不合理的,沒有必要。也許會有這樣一個疑惑,可以不可把這個方法上的synchronized關鍵字去掉呢?答案是不能的,因為這裡是靠synchronized來實作共享記憶體可見性的,那麼有沒有什麼更好的辦法呢?,答案是有的,下面講到的在内部使用非阻塞CAS算法實作的原子性操作類AtomicLong就是一個不錯的選擇。