併發編程之多線程線程安全

著做權歸做者全部,任何形式的轉載都請聯繫做者得到受權並註明出處。

什麼是線程安全?

爲何有線程安全問題?

當多個線程同時共享,同一個全局變量或靜態變量,作寫的操做時,可能會發生數據衝突問題,也就是線程安全問題。可是作讀操做是不會發生數據衝突問題。java

案例: 需求如今有100張火車票,有兩個窗口同時搶火車票,請使用多線程模擬搶票效果。git

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();
    }
}
複製代碼

運行結果:程序員

一號窗口和二號窗口同時出售火車第九九張,部分火車票會重複出售。 結論發現,多個線程共享同一個全局成員變量時,作寫的操做可能會發生數據衝突問題。github

線程安全解決辦法:

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

問: 爲何使用線程同步或使用鎖能解決線程安全問題呢?
答: 將可能會發生數據衝突問題(線程不安全問題),只能讓當前一個線程進行執行。代碼執行完成後釋放鎖,讓後才能讓其餘線程進 行執行。這樣的話就能夠解決線程不安全問題。緩存

問: 什麼是多線程之間同步
答: 當多個線程共享同一個資源,不會受到其餘線程的干擾。安全

問: 什麼是多線程同步
答: 當多個線程共享同一個資源,不會受到其餘線程的干擾。bash

內置的鎖

Java提供了一種內置的鎖機制來支持原子性,每個Java對象均可以用做一個實現同步的鎖,稱爲內置鎖,線程進入同步代碼塊以前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖多線程

內置鎖爲互斥鎖,即線程A獲取到鎖後,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖 內置鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:app

  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語義容許對存在控制依賴的操做作重排序的緣由);但在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。

本文代碼地址

相關文章
相關標籤/搜索