併發程式設計之多執行緒執行緒安全

蔣老溼發表於2019-03-04
併發程式設計之多執行緒執行緒安全

什麼是執行緒安全?

為什麼有執行緒安全問題?

當多個執行緒同時共享,同一個全域性變數或靜態變數,做寫的操作時,可能會發生資料衝突問題,也就是執行緒安全問題。但是做讀操作是不會發生資料衝突問題。

案例: 需求現在有100張火車票,有兩個視窗同時搶火車票,請使用多執行緒模擬搶票效果。

public class ThreadTrain implements Runnable {
    private int trainCount = 10;

    @Override
    public void run() {
        while (trainCount > 0) {
            try {
                Thread.sleep(500);
                sale();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void sale() {
        if (trainCount > 0) {
            --trainCount;
            System.out.println(Thread.currentThread().getName() + ",出售第" + (10 - trainCount) + "張票");
        }
    }

    public static void main(String[] args) {
        ThreadTrain threadTrain = new ThreadTrain();
        Thread t1 = new Thread(threadTrain, "1臺");
        Thread t2 = new Thread(threadTrain, "2臺");
        t1.start();
        t2.start();
    }
}
複製程式碼

執行結果:

一號視窗和二號視窗同時出售火車第九九張,部分火車票會重複出售。
結論發現,多個執行緒共享同一個全域性成員變數時,做寫的操作可能會發生資料衝突問題。

執行緒安全解決辦法:

問: 如何解決多執行緒之間執行緒安全問題
答: 使用多執行緒之間同步synchronized或使用鎖(lock)。

問: 為什麼使用執行緒同步或使用鎖能解決執行緒安全問題呢?
答: 將可能會發生資料衝突問題(執行緒不安全問題),只能讓當前一個執行緒進行執行。程式碼執行完成後釋放鎖,讓後才能讓其他執行緒進 行執行。這樣的話就可以解決執行緒不安全問題。

問: 什麼是多執行緒之間同步
答: 當多個執行緒共享同一個資源,不會受到其他執行緒的干擾。

問: 什麼是多執行緒同步
答: 當多個執行緒共享同一個資源,不會受到其他執行緒的干擾。

內建的鎖

Java提供了一種內建的鎖機制來支援原子性,每一個Java物件都可以用作一個實現同步的鎖,稱為內建鎖,執行緒進入同步程式碼塊之前自動獲取到鎖,程式碼塊執行完成正常退出或程式碼塊中丟擲異常退出時會釋放掉鎖

內建鎖為互斥鎖,即執行緒A獲取到鎖後,執行緒B阻塞直到執行緒A釋放鎖,執行緒B才能獲取到同一個鎖
內建鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:

  1. 修飾需要進行同步的方法(所有訪問狀態變數的方法都必須進行同步),此時充當鎖的物件為呼叫同步方法的物件
  2. 同步程式碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,並且充當鎖的物件不一定是this,也可以是其它物件,所以使用起來更加靈活

同步程式碼塊synchronized

就是將可能會發生執行緒安全問題的程式碼,給包括起來。
synchronized(同一個資料){
 可能會發生執行緒衝突問題
}
就是同步程式碼塊 
synchronized(物件)//這個物件可以為任意物件 
{ 
    需要被同步的程式碼 
} 
複製程式碼

物件如同鎖,持有鎖的執行緒可以在同步中執行,沒持有鎖的執行緒即使獲取CPU的執行權,也進不去,同步的前提:

  1. 必須要有兩個或者兩個以上的執行緒
  2. 必須是多個執行緒使用同一個鎖

必須保證同步中只能有一個執行緒在執行
好處: 解決了多執行緒的安全問題
弊端: 多個執行緒需要判斷鎖,較為消耗資源、搶鎖的資源。
程式碼樣例:

private void sale() {
        synchronized (this) {
            if (trainCount > 0) {
                --trainCount;
                System.out.println(Thread.currentThread().getName() + ",出售第" + (10 - trainCount) + "張票");
            }
        }
    }
複製程式碼

同步方法

在方法上修飾synchronized 稱為同步方法

public synchronized void sale() {
	if (trainCount > 0) {
		System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
		trainCount--;
	}
}
複製程式碼

同步方法使用的是什麼鎖?

答:同步函式使用this鎖。
證明方式: 一個執行緒使用同步程式碼塊(this明鎖),另一個執行緒使用同步函式。如果兩個執行緒搶票不能實現同步,那麼會出現資料錯誤。
參考:方法鎖,物件鎖以及類鎖的用法與區別

package com.itmayiedu;

class Thread0009 implements Runnable {
    private int trainCount = 10;
    private Object oj = new Object();
    public boolean flag = true;

    public void run() {

        if (flag) {
            while (trainCount > 0) {
                synchronized (this) {
                    try {
                        Thread.sleep(10);
                    } catch (Exception e) {
                        // TODO: handle exception
                    }
                    if (trainCount > 0) {
                        System.out.println(Thread.currentThread().getName() + "," + "出售第" + (10 - trainCount + 1) + "票");
                        trainCount--;
                    }
                }

            }
        } else {
            while (trainCount > 0) {
                sale();
            }

        }

    }

    public synchronized void sale() {
        try {
            Thread.sleep(10);
        } catch (Exception e) {
            // TODO: handle exception
        }
        if (trainCount > 0) {
            System.out.println(Thread.currentThread().getName() + "," + "出售第" + (10 - trainCount + 1) + "票");
            trainCount--;
        }

    }
}

public class Test009 {
    public static void main(String[] args) throws InterruptedException {
        Thread0009 threadTrain1 = new Thread0009();
        Thread0009 threadTrain2 = new Thread0009();
        threadTrain2.flag = false;

        Thread t1 = new Thread(threadTrain1, "視窗1");
        Thread t2 = new Thread(threadTrain2, "視窗2");

        t1.start();
        Thread.sleep(40);
        t2.start();
    }
}
複製程式碼

靜態同步函式

方法上加上static關鍵字,使用synchronized 關鍵字修飾 或者使用類.class檔案。
靜態的同步函式使用的鎖是 該函式所屬位元組碼檔案物件
可以用 getClass方法獲取,也可以用當前 類名.class 表示。
程式碼樣例:

public static void sale() {
		synchronized (ThreadTrain3.class) {
			if (trainCount > 0) {
				System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
				trainCount--;
			}
		}
}
複製程式碼

總結:
synchronized 修飾方法使用鎖是當前this鎖。
synchronized 修飾靜態方法使用鎖是當前類的位元組碼檔案

多執行緒死鎖

同步中巢狀同步,導致鎖無法釋放

public class ThreadTrain3 implements Runnable {
	private static int trainCount = 100;

	@Override
	public void run() {
		while (trainCount > 0) {
			try {
				Thread.sleep(50);
			} catch (Exception e) {

			}
			sale();
		}
	}

	public static void sale() {
		synchronized (ThreadTrain3.class) {
			if (trainCount > 0) {
				System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
				trainCount--;
			}
		}

	}

	public static void main(String[] args) {
		ThreadTrain3 threadTrain = new ThreadTrain3();
		Thread t1 = new Thread(threadTrain, "①號");
		Thread t2 = new Thread(threadTrain, "②號");
		t1.start();
		t2.start();
	}

}
複製程式碼
併發程式設計之多執行緒執行緒安全

什麼是Threadlocal

ThreadLocal提高一個執行緒的區域性變數,訪問某個執行緒擁有自己區域性變數。
當使用ThreadLocal維護變數時,ThreadLocal為每個使用該變數的執行緒提供獨立的變數副本,所以每一個執行緒都可以獨立地改變自己的副本,而不會影響其它執行緒所對應的副本。
ThreadLocal的介面方法
ThreadLocal類介面很簡單,只有4個方法,我們先來了解一下:

  • void set(Object value)設定當前執行緒的執行緒區域性變數的值。
  • public Object get()該方法返回當前執行緒所對應的執行緒區域性變數。
  • public void remove()將當前執行緒區域性變數的值刪除,目的是為了減少記憶體的佔用,該方法是JDK 5.0新增的方法。需要指出的是,當執行緒結束後,對應該執行緒的區域性變數將自動被垃圾回收,所以顯式呼叫該方法清除執行緒的區域性變數並不是必須的操作,但它可以加快記憶體回收的速度。
  • protected Object initialValue()返回該執行緒區域性變數的初始值,該方法是一個protected的方法,顯然是為了讓子類覆蓋而設計的。這個方法是一個延遲呼叫方法,線上程第1次呼叫get()或set(Object)時才執行,並且僅執行1次。ThreadLocal中的預設實現直接返回一個null。

案例:建立三個執行緒,每個執行緒生成自己獨立序列號。

class Res {
	public static Integer count = 0;
	public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
		protected Integer initialValue() {
			return 0;
		};
	};

	public Integer getNum() {
		int count = threadLocal.get() + 1;
		threadLocal.set(count);
		return count;
	}
}

public class Test006 extends Thread {

	private Res res;

	public Test006(Res res) {
		this.res = res;
	}

	@Override
	public void run() {
		for (int i = 0; i < 3; i++) {
			System.out.println(Thread.currentThread().getName() + "," + res.getNum());
		}
	}

	public static void main(String[] args) {
		Res res = new Res();
		Test006 t1 = new Test006(res);
		Test006 t2 = new Test006(res);

		t1.start();
		t2.start();
	}

}
複製程式碼

ThreadLoca實現原理, ThreadLoca通過map集合,Map.put(“當前執行緒”,值);

多執行緒有三大特性

什麼是原子性

即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。這2個操作必須要具備原子性才能保證不出現一些意外的問題。
我們運算元據也是如此,比如i = i+1;其中就包括,讀取i的值,計算i,寫入i。這行程式碼在Java中是不具備原子性的,則多執行緒執行肯定會出問題,所以也需要我們使用同步和lock這些東西來確保這個特性了。
原子性其實就是保證資料一致、執行緒安全一部分,

什麼是可見性

當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。
若兩個執行緒在不同的cpu,那麼執行緒1改變了i的值還沒重新整理到主存,執行緒2又使用了i,那麼這個i值肯定還是之前的,執行緒1對變數的修改執行緒沒看到這就是可見性問題。

什麼是有序性

程式執行的順序按照程式碼的先後順序執行。
一般來說處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。如下:

int a = 10;    //語句1
int r = 2;    //語句2
a = a + 3;    //語句3
r = a*a;     //語句4
複製程式碼

則因為重排序,他還可能執行順序為 2-1-3-4,1-3-2-4
但絕不可能 2-1-4-3,因為這打破了依賴關係。
顯然重排序對單執行緒執行是不會有任何問題,而多執行緒就不一定了,所以我們在多執行緒程式設計時就得考慮這個問題了。

Java記憶體模型

共享記憶體模型指的就是Java記憶體模型(簡稱JMM),JMM決定一個執行緒對共享變數的寫入時,能對另一個執行緒可見。 從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(mainmemory)中,每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。 本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化。

併發程式設計之多執行緒執行緒安全

從上圖來看,執行緒A與執行緒B之間如要通訊的話,必須要經歷下面2個步驟:

  1. 首先,執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
  2. 然後,執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。

下面通過示意圖來說明這兩個步驟:

併發程式設計之多執行緒執行緒安全

如上圖所示,本地記憶體A和B有主記憶體中共享變數x的副本。假設初始時,這三個記憶體中的x值都為0。執行緒A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。隨後,執行緒B到主記憶體中去讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變為了1。

從整體來看,這兩個步驟實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為java程式設計師提供記憶體可見性保證。

總結: 什麼是Java記憶體模型:java記憶體模型簡稱jmm,定義了一個執行緒對另一個執行緒可見。共享變數存放在主記憶體中,每個執行緒都有自己的本地記憶體,當多個執行緒同時訪問一個資料的時候,可能本地記憶體沒有及時重新整理到主記憶體,所以就會發生執行緒安全問題。

Volatile

可見性也就是說一旦某個執行緒修改了該被volatile修飾的變數,它會保證修改的值會立即被更新到主存,當有其他執行緒需要讀取時,可以立即獲取修改之後的值。
在Java中為了加快程式的執行效率,對一些變數的操作通常是在該執行緒的暫存器或是CPU快取上進行的,之後才會同步到主存中,而加了volatile修飾符的變數則是直接讀寫主存。

Volatile 保證了執行緒間共享變數的及時可見性,但不能保證原子性

class ThreadDemo004 extends Thread {
    public boolean flag = true;

    @Override
    public void run() {
        System.out.println("執行緒開始...");
        while (flag) {

        }
        System.out.println("執行緒結束...");
    }

    public void setRuning(boolean flag) {
        this.flag = flag;
    }
}

public class Test0004 {
    public static void main(String[] args) throws InterruptedException {
        ThreadDemo004 threadDemo004 = new ThreadDemo004();
        threadDemo004.start();
        Thread.sleep(3000);
        threadDemo004.setRuning(false);
        System.out.println("flag已經改為false");
        Thread.sleep(1000);
        System.out.println("flag:" + threadDemo004.flag);
    }
}
複製程式碼

已經將結果設定為fasle為什麼?還一直在執行呢。
原因:執行緒之間是不可見的,讀取的是副本,沒有及時讀取到主記憶體結果。
解決辦法使用Volatile關鍵字將解決執行緒之間可見性, 強制執行緒每次讀取該值的時候都去“主記憶體”中取值

Volatile特性

  1. 保證此變數對所有的執行緒的可見性,這裡的“可見性”,如本文開頭所述,當一個執行緒修改了這個變數的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變數做不到這點,普通變數的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。
  2. 禁止指令重排序優化。有volatile修飾的變數,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),只有一個CPU訪問記憶體時,並不需要記憶體屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程式規定的順序分開傳送給各相應電路單元處理)。

volatile 效能:
  volatile 的讀效能消耗與普通變數幾乎相同,但是寫操作稍慢,因為它需要在原生程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

Volatile與Synchronized區別

  1. 從而我們可以看出volatile雖然具有可見性但是並不能保證原子性。
  2. 效能方面,synchronized關鍵字是防止多個執行緒同時執行一段程式碼,就會影響程式執行效率,而volatile關鍵字在某些情況下效能要優於synchronized。

但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。

重排序

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分下列三種型別:

名稱 程式碼示例 說明
寫後讀 a = 1;b = a; 寫一個變數之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變數之後,再寫這個變數。
讀後寫 a = b;b = 1; 讀一個變數之後,再寫這個變數。

上面三種情況,只要重排序兩個操作的執行順序,程式的執行結果將會被改變。
前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序。
注意,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮。

as-if-serial語義

s-if-serial語義的意思指:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作可能被編譯器和處理器重排序。為了具體說明,請看下面計算圓面積的程式碼示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C
複製程式碼

上面三個操作的資料依賴關係如下圖所示:

併發程式設計之多執行緒執行緒安全

如上圖所示,A和C之間存在資料依賴關係,同時B和C之間也存在資料依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果將會被改變)。但A和B之間沒有資料依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。下圖是該程式的兩種執行順序:

併發程式設計之多執行緒執行緒安全

as-if-serial語義把單執行緒程式保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單執行緒程式的程式設計師建立了一個幻覺:單執行緒程式是按程式的順序來執行的。as-if-serial語義使單執行緒程式設計師無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題。

程式順序規則

根據happens- before的程式順序規則,上面計算圓的面積的示例程式碼存在三個happens- before關係:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

這裡的第3個happens- before關係,是根據happens- before的傳遞性推匯出來的。
這裡A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序後的執行順序)。在第一章提到過,如果A happens- before B,JMM並不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裡操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B後的執行結果,與操作A和操作B按happens- before順序執行的結果一致。在這種情況下,JMM會認為這種重排序並不非法(not illegal),JMM允許這種重排序。
在計算機中,軟體技術和硬體技術有一個共同的目標:在不改變程式執行結果的前提下,儘可能的開發並行度。編譯器和處理器遵從這一目標,從happens- before的定義我們可以看出,JMM同樣遵從這一目標。

重排序對多執行緒的影響

/**
 * 重排序
 */
class ReorderExample {
	int a = 0;
	boolean flag = false;

	public void writer() {
		a = 1; // 1
		flag = true; // 2
		System.out.println("writer");
	}

	public void reader() {
		if (flag) { // 3
			int i = a * a; // 4
			System.out.println("i:" + i);
		}
		System.out.println("reader");
	}

	public static void main(String[] args) {
		ReorderExample reorderExample = new ReorderExample();
		Thread t1 = new Thread(new Runnable() {

			@Override
			public void run() {
				reorderExample.writer();
			}
		});
		Thread t2 = new Thread(new Runnable() {

			@Override
			public void run() {
				reorderExample.reader();
			}
		});
		t1.start();
		t2.start();

	}
}
複製程式碼

flag變數是個標記,用來標識變數a是否已被寫入。這裡假設有兩個執行緒A和B,A首先執行writer()方法,隨後B執行緒接著執行reader()方法。執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入?
答案是:不一定能看到。
由於操作1和操作2沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什麼效果?請看下面的程式執行時序圖:

併發程式設計之多執行緒執行緒安全

如上圖所示,操作1和操作2做了重排序。程式執行時,執行緒A首先寫標記變數flag,隨後執行緒B讀這個變數。由於條件判斷為真,執行緒B將讀取變數a。此時,變數a還根本沒有被執行緒A寫入,在這裡多執行緒程式的語義被重排序破壞了!

※注:本文統一用紅色的虛箭線表示錯誤的讀操作,用綠色的虛箭線表示正確的讀操作。

下面再讓我們看看,當操作3和操作4重排序時會產生什麼效果(藉助這個重排序,可以順便說明控制依賴性)。下面是操作3和操作4重排序後,程式的執行時序圖:

併發程式設計之多執行緒執行緒安全

在程式中,操作3和操作4存在控制依賴關係。當程式碼中存在控制依賴性時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取並計算a*a,然後把計算結果臨時儲存到一個名為重排序緩衝(reorder buffer ROB)的硬體快取中。當接下來操作3的條件判斷為真時,就把該計算結果寫入變數i中。

從圖中我們可以看出,猜測執行實質上對操作3和4做了重排序。重排序在這裡破壞了多執行緒程式的語義!

在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

本文程式碼地址

相關文章