三、Java併發編程:Java內存模型

一、Java內存模型的基礎

1. 併發編程模型的兩個關鍵問題

併發編程模型的兩個關鍵問題:線程之間如何通信和如何同步

  1. 線程之間如何通信?
  • 命令式編程

線程之間的通過消息傳遞來進行顯示通信

  • 共享內存併發模型

線程之間共享內存中的程序的公共狀態進行隱式通信

  1. 線程之間如何同步?

線程之間同步是指控制線程之間代碼執行的先後順序

  • 命令式編程

命令式編程線程之間的同步是隱式進行的,

  • 共享內存併發模型

程序員顯示指定代碼的互斥部分,線程間順序執行

2. Java內存模型的抽象結構

JVM運行時數據區劃分如下:

在這裏插入圖片描述

由圖可知,在程序運行時,只有方法區和堆可以由線程共享,只有共享的區域纔會出現內存可見性問題;Java線程之間的通信由Java內存模型(JMM)控制;從cpu的結構來分析,傳統的cpu讀寫數據需要和內存直接交互,但是cpu的讀寫速度遠遠高於內存的讀寫速度,所以新的cpu都會有一塊緩存,用於存放頻繁讀寫的數據;單線程時,cpu從內存中將數據copy到緩存中進行操作,操作完成後再將緩存中的數據刷新到內存中;但是多線程的情況下,如果兩個線程需要操作同一個變量,由於緩存的存在,就會出現內存可見性問題

在這裏插入圖片描述

3. 從源代碼到指令序列的重排序

爲了優化性能,在代碼執行時,編譯器處理器會對指令進行重排序操作;重排序分爲三種:

  1. 編譯器重排序

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序

  1. 指令級並行的重排序

現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序

  1. 內存重排序

4. 併發編程模型的分類

5. happens-before規則

happens-before是JMM最核心的規則,規則如下:

  1. 程序執行順序:單線程中,代碼按照順序依次執行
  2. 鎖定規則:對於同一個鎖來講,解鎖操作在上鎖操作之前發生
  3. volatile變量規則:對於volatile變量來講,寫操作在讀操作之前發生
  4. 傳遞規則:A操作在B操作之前發生,B操作在C操作之前發生,那麼A操作一定在C操作之前發生
  5. start()規則:線程的啓動操作在線程執行體中操作之前發生
  6. join()規則:A線程調用B線程的join()方法,B線程中的操作先於join()結束之前發生

二、重排序

爲了優化性能,在代碼執行時,編譯器處理器會對指令進行重排序操作;重排序分爲三種:

  1. 編譯器重排序

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序

  1. 指令級並行的重排序

現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序

  1. 內存重排序

多線程情況下,上述的重排序可能導致內存可見性問題

內存可見性問題

多個線程操作共享變量X,線程1對共享變量X進行了寫操作,但是由於指令重排序緩存中數據沒有及時刷新到主存中,線程2讀取到X的舊數據,造成的錯誤操作,這種問題稱爲內存可見性問題

1. 數據依賴性

在單個處理器的情況下,兩個操作訪問同一個變量,如果其中一個操作爲寫操作,兩個操作就存在數據依賴性;對於單處理器或者單線程情況下,重排序會遵守數據依賴性,不會改變兩個操作的順序;但是對於多線程,則不會遵循數據依賴性

舉個栗子:a=1,b=a;這個分兩個操作,操作1爲寫操作:a=1;操作2爲讀操作:b=a;共同變量爲a,這兩個操作就存在數據依賴性:操作2依賴於操作1;

2. as-if-serial語義

as-if-serial的意思是:在單處理器和單線程的情況下,不管怎麼重排序,程序的執行結果都不能被改變。編譯器,runtime 和處理器都必須遵守 as-if-serial 語義

3. 重排序對多線程的影響

在多線程的情況下,沒有數據依賴性,且遵守happens-before規則,對指令重排序,會造成內存可見性問題:線程1執行write()方法,線程2執行read()方法;正常情況下,程序執行完成後,a=1,flag=true
在這裏插入圖片描述

由於a和flag沒有依賴關係,可能會被重排序,當指令1和2重排時,就有可能造成內存可見性問題:在程序執行完成後,可能會出現:a=0,flag=false

在這裏插入圖片描述

而這種問題,在多線程編程中是有可能遇到的,也是需要去避免的

三、順序一致性

順序一致性具有兩個特點:

  1. 線程中的操作必須按照程序的順序來執行
  2. 線程中的每一個操作必須具有原子性,且操作結果對所有線程可見

1. 數據競爭

在一個線程中寫一個數據,另一個線程中讀同一個數據,且寫和讀沒有通過同步來排序,就會出現數據競爭的現象

2. 同步程序的順序一致性效果

順序一致性模型中,所有操作完全按程序的順序串行執行。而在JMM中,臨界區內的代碼可以重排序,但是最終不會改變程序的執行結果

臨界區

臨界區:代碼中可以訪問臨界資源的代碼片段稱爲臨界區(臨界資源指的是一次僅允許一個線程訪問的共享資源);Java中一般指synchronized修飾的區域或者lock加鎖的區域

3. 未同步程序的執行特性

對於未同步的程序,JMM提供最小安全性保證:線程讀取共享變量時,要麼是前某個線程寫入的值,要麼是0,null,false;JMM不保證未同步程序的執行結果與順序一致性模型的執行結果一致;順序一致性模型與JMM差異如下:

  1. 順序一致性模型保證程序中的代碼按照程序的順序執行;JMM中指令可能會重排序
  2. 順序一致性模型保證所有線程只能看到一致的操作執行順序,而JMM不保證所有線程能看到一致的操作執行順序
  3. 順序一致性模型保證對所有的變量的操作都具有原子性;32位JVM中,JMM不保證long型,double型變量的原子性操作

32位中,對64位的long型,double型數據的讀寫是拆分成兩個32位來操作的

四、volatile

volatile修飾變量,在多線程的情況下,操作volatile修飾的變量具有原子性(但不包含類似i++這種複合操作);

package com.lt.thread04;

import java.util.ArrayList;
import java.util.List;

/**
 * 驗證volatile不能滿足複雜操作的原子性:i++
 * @author lt
 * @date 2019年5月11日
 * @version v1.0
 */
public class Counter_2 {

	private volatile int m = 0;
	public void count(){
		m++;
	}
	public static void main(String[] args) throws Exception {
		Counter_2 c = new Counter_2();
		List<Thread> ts = new ArrayList<>();
		for(int i=0; i<1000; i++){
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					c.count();
				}
			}, "線程"+i);
			ts.add(t);
		}
		for(Thread t : ts){
			t.start();
		}
		//等待當前線程執行完畢
		for(Thread t : ts){
			t.join();
		}
		System.out.println(c.m);
	}
}

1. volatile的內存語義

  1. volatile寫的內存語義:當寫一個volatile變量時,JMM會將緩存中的數據刷新到主存中
  2. volatile讀的內存語義:當讀一個volatile變量時,JMM會將緩存中的數據置爲無效,然後去主存中讀取

2. volatile內存語義的實現

在這裏插入圖片描述

可以看到當第一個操作爲volatile讀時,不管第二個操作是什麼都不能進行重排序;當第二個操作爲volatile寫時,不論第一個操作是什麼都不能進行重排序;

volatile通過在指令序列中插入內存屏障的方法來實現內存語義,下面是基於保守策略的JMM內存屏障插入策略:

  1. 在每個volatile寫操作的前面插入一個StoreStore屏障
  2. 在每個volatile寫操作的後面插入一個StoreLoad屏障
  3. 在每個volatile讀操作的後面插入一個LoadLoad屏障
  4. 在每個volatile讀操作的後面插入一個LoadStore屏障
內存屏障

內存屏障,也稱內存柵欄,內存柵障,屏障指令等, 是一類同步屏障指令,是CPU或編譯器在對內存隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後纔可以開始執行此點之後的操作;硬件層的內存屏障分爲兩種:Load BarrierStore Barrier即讀屏障和寫屏障,兩種屏障兩兩組合形成四種內存屏障

  • LoadLoad屏障:對於這樣的語句Load1;LoadLoad;Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1;StoreStore;Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1;LoadStore;Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1;StoreLoad;Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能

五、鎖(synchronized,lock)的內存語義

1. 鎖(synchronized,lock)的內存語義

  • 獲取鎖:當獲取鎖時,線程會將緩存中的數據置爲無效,臨界區代碼回去主存中獲取數據(共享變量)
  • 釋放鎖:當釋放鎖時,線程會將緩存中的數據刷新到主存中去

2. 鎖(synchronized,lock)內存語義的實現

  1. 利用volatile的讀/寫的內存語義
  2. 利用CAS所附帶的volatile讀和volatile寫的內存語義

六、final的內存語義

1. final域的重排序規則

對於final域,編譯器和處理器要遵守兩個重排序規則:

  • 寫final域重排序規則

在構造函數內對一個final域的寫入,隨後把這個構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序;JMM禁止將final域的寫重排序到構造器外邊;編譯器會在final域寫之後構造器return之前,插入StoreStore屏障,以避免final域寫重排序到構造器外邊

  • 讀final域的重排序規則

初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序,編譯器會在讀final域操作之前插入LoadLoad屏障

2. final語義在處理器中的實現

final域的渡河寫是通過插入內存屏障來阻止重排序,但是在X86處理器中,寫-寫、讀-讀操作並不會插入內存屏障,所以在X86處理器中,final域的讀寫是有可能重排序的

七、happens-before

A happens-before B,即A在B之前發生,程序中用happens-before表示代碼執行的先後順序以及依賴關係;JMM允許在不改變程序執行結果的情況下進行重排序;as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變

1. happens-before規則

  1. 程序執行順序:單線程中,代碼按照順序依次執行
  2. 鎖定規則:對於同一個鎖來講,解鎖操作在上鎖操作之前發生
  3. volatile變量規則:對於volatile變量來講,寫操作在讀操作之前發生
  4. 傳遞規則:A操作在B操作之前發生,B操作在C操作之前發生,那麼A操作一定在C操作之前發生
  5. start()規則:線程的啓動操作在線程執行體中操作之前發生
  6. join()規則:A線程調用B線程的join()方法,B線程中的操作先於join()結束之前發生

八、多線程下花式創建單例

1. 單例模式(懶漢模式)

多線程情況下,爲了降低創建對象的開銷,採用延遲初始化進行單例創建,如下:

public class Singleton_1 {
	private static Singleton_1 instance = new Singleton_1();
    private Singleton_1 (){}
    public static Singleton_1 getInstance() {
      return instance;
    }
}

2. synchronized同步單例方法

但是,在多線程情況下,有可能會出現:多個線程在同時執行new Singleton(),這樣,就會創建多個實例;所以進一步改進:

public class Singleton_2 {
	private static Singleton_2 instance = new Singleton_2();
    private Singleton_2 (){}
    public synchronized static Singleton_2 getInstance() {
      return instance;
    }
}

3. synchronized同步單例代碼塊

這樣做可以保證創建出的實例只有一個,但是,多線程情況下,頻繁調用getInstance()會造成線程阻塞,降低效率,所以還需要改進:

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

這樣做似乎就很完美了,但是還是有缺陷:線程1在執行到instance = new Singleton_3();時,實際上需要三步來完成:

  1. 分配對象的內存空間
  2. 初始化對象
  3. instance指向剛分配的內存地址
    但是處理器可能會重排序:
  4. 分配對象的內存空間
  5. instance指向剛分配的內存地址
  6. 初始化對象
    這樣的排序是允許的!因爲在單線程中,2和3沒有happens-before關係,將2和3互換後,可以提高CPU性能,但是多線程情形下,這樣的互換很有可能導致空指針異常,看下面的時序圖:

在這裏插入圖片描述

線程1執行完①③操作,線程2來獲取實例,判斷instance不爲null(但實際上是null),線程2使用instance調用方法時,便會報空指針異常;所以,還得優化呀!看下面:

4. 基於volatile創建單例(推薦)

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

5. 基於CAS創建單例

看完上面的例子,只是使用volatile修飾instance,在多線程情形下便可以禁止①③操作的重排序;看到這兒,加個餐,不利用鎖去創建單例:

public class Singleton_5 {
	private static AtomicReference<Singleton_5> atomic = new AtomicReference<>();
    private Singleton_5 (){}
	public static Singleton_5 getInstance() {
		while(true){
			boolean flag = atomic.compareAndSet(null, new Singleton_5());
			if(flag) break;
		}
		return atomic.get();
    }
}

6. 基於類初始化創建單例

JVM在類初始化階段會獲取鎖,用於同步多個線程對同一個類的初始化操作;鑑於此,可以創建單例:

public class Singleton_6 {
	private static class Handler{
		private final static Singleton_6 INSTANCE = new Singleton_6();
	}
    private Singleton_6 (){}
	public static Singleton_6 getInstance() {
		return Singleton_6.Handler.INSTANCE;
    }
}

參考

【1】Java併發編程的藝術
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章