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


 

什么是線程安全?

為什么有線程安全問題?

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

 

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

代碼:

public class ThreadTrain implements Runnable {

private int trainCount = 100;

 

@Override

public void run() {

while (trainCount > 0) {

try {

Thread.sleep(50);

} catch (Exception e) {

 

}

sale();

}

}

 

public void sale() {

if (trainCount > 0) {

System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");

trainCount--;

}

}

 

public static void main(String[] args) {

ThreadTrain threadTrain = new ThreadTrain();

Thread t1 = new Thread(threadTrain, "①號");

Thread t2 = new Thread(threadTrain, "②號");

t1.start();

t2.start();

}

 

}

 

 運行結果:

一號窗口和二號窗口同時出售火車第九九張,部分火車票會重復出售。

結論發現,多個線程共享同一個全局成員變量時,做寫的操作可能會發生數據沖突問題。

線程安全解決辦法:

:如何解決多線程之間線程安全問題

:使用多線程之間同步synchronized或使用鎖(lock)。

:為什么使用線程同步或使用鎖能解決線程安全問題呢?

:將可能會發生數據沖突問題(線程不安全問題),只能讓當前一個線程進行執行。代碼執行完成后釋放鎖,讓后才能讓其他線程進行執行。這樣的話就可以解決線程不安全問題。

:什么是多線程之間同步

:當多個線程共享同一個資源,不會受到其他線程的干擾。

 

內置的鎖

Java提供了一種內置的鎖機制來支持原子性

每一個Java對象都可以用作一個實現同步的鎖,稱為內置鎖,線程進入同步代碼塊之前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖。

內置鎖為互斥鎖,即線程A獲取到鎖后,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖。

內置鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:

1.修飾需要進行同步的方法(所有訪問狀態變量的方法都必須進行同步),此時充當鎖的對象為調用同步方法的對象

2.同步代碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,並且充當鎖的對象不一定是this,也可以是其它對象,所以使用起來更加靈活

同步代碼塊synchronized

就是將可能會發生線程安全問題的代碼,給包括起來。

synchronized(同一個數據){

 可能會發生線程沖突問題

}

就是同步代碼塊 

synchronized(對象)//這個對象可以為任意對象 

    需要被同步的代碼 

 

對象如同鎖,持有鎖的線程可以在同步中執行 

沒持有鎖的線程即使獲取CPU的執行權,也進不去 

同步的前提: 

1,必須要有兩個或者兩個以上的線程 

2,必須是多個線程使用同一個鎖(ThreadTrain threadTrain = new ThreadTrain();) 

必須保證同步中只能有一個線程在運行 

好處:解決了多線程的安全問題 

弊端:多個線程需要判斷鎖,較為消耗資源、搶鎖的資源。 

 代碼樣例:

public void sale() {

synchronized (this) {//this 是threadTrain (ThreadTrain threadTrain = new ThreadTrain();)

if (trainCount > 0) {

System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");

trainCount--;

}

}

}

同步方法

什么是同步方法?

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

代碼樣例

public synchronized void sale() {

if (trainCount > 0) {

System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");

trainCount--;

}

}

同學們思考問題?同步方法使用的是什么鎖

答:同步方法使用this鎖。

證明方式: 一個線程使用同步代碼塊(this明鎖),另一個線程使用同步函數。如果兩個線程搶票不能實現同步,那么會出現數據錯誤。

代碼:

class Thread009 implements Runnable {

private int trainCount = 100;

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() + "," + "出售第" + (100 - 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() + "," + "出售第" + (100 - trainCount + 1) + "");

trainCount--;

}

 

}

}

 

public class Test009 {

public static void main(String[] args) throws InterruptedException {

Thread009 threadTrain = new Thread009();

Thread t1 = new Thread(threadTrain, "窗口1");

Thread t2 = new Thread(threadTrain, "窗口2");

t1.start();

Thread.sleep(40);

threadTrain.flag = false;

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 修飾靜態方法使用鎖是當前類的字節碼文件

多線程死鎖

 什么是多線程死鎖?

   :同步中嵌套同步,導致鎖無法釋放

 代碼:

class Thread009 implements Runnable {

private int trainCount = 100;

private Object oj = new Object();

public boolean flag = true;

 

public void run() {

 

if (flag) {

while (trainCount > 0) {

synchronized (oj) {

try {

Thread.sleep(10);

} catch (Exception e) {

// TODO: handle exception

}

sale();

}

 

}

} else {

while (trainCount > 0) {

sale();

}

 

}

 

}

 

public synchronized void sale() {

synchronized (oj) {

try {

Thread.sleep(10);

} catch (Exception e) {

 

}

if (trainCount > 0) {

System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "");

trainCount--;

}

}

}

}

 

public class Test009 {

public static void main(String[] args) throws InterruptedException {

Thread009 threadTrain = new Thread009();

Thread t1 = new Thread(threadTrain, "窗口1");

Thread t2 = new Thread(threadTrain, "窗口2");

t1.start();

Thread.sleep(40);

threadTrain.flag = false;

t2.start();

 

}

}}

Threadlocal

什么是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 count;

};

 

};

 

public Integer getNum() {

int count = threadLocal.get() + 1;

threadLocal.set(count);

return count;

}

}

 

public class ThreadLocaDemo2 extends Thread {

private Res res;

 

public ThreadLocaDemo2(Res res) {

this.res = res;

}

 

@Override

public void run() {

for (int i = 0; i < 3; i++) {

System.out.println(Thread.currentThread().getName() + "---" + "i---" + i + "--num:" + res.getNum());

}

 

}

 

public static void main(String[] args) {

Res res = new Res();

ThreadLocaDemo2 threadLocaDemo1 = new ThreadLocaDemo2(res);

ThreadLocaDemo2 threadLocaDemo2 = new ThreadLocaDemo2(res);

ThreadLocaDemo2 threadLocaDemo3 = new ThreadLocaDemo2(res);

threadLocaDemo1.start();

threadLocaDemo2.start();

threadLocaDemo3.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定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(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

可見性也就是說一旦某個線程修改了該被volatile修飾的變量,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,可以立即獲取修改之后的值。

Java中為了加快程序的運行效率,對一些變量的操作通常是在該線程的寄存器或是CPU緩存上進行的,之后才會同步到主存中,而加了volatile修飾符的變量則是直接讀寫主存。

 

Volatile 保證了線程間共享變量的及時可見性,但不能保證原子性

class ThreadVolatileDemo 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 ThreadVolatile {

public static void main(String[] args) throws InterruptedException {

ThreadVolatileDemo threadVolatileDemo = new ThreadVolatileDemo();

threadVolatileDemo.start();

Thread.sleep(3000);

threadVolatileDemo.setRuning(false);

System.out.println("flag 已經設置成false");

Thread.sleep(1000);

System.out.println(threadVolatileDemo.flag);

 

}

}

運行結果:

 

已經將結果設置為fasle為什么?還一直在運行呢。

原因:線程之間是不可見的,讀取的是副本,沒有及時讀取到主內存結果。

解決辦法使用Volatile關鍵字將解決線程之間可見性, 強制線程每次讀取該值的時候都去“主內存”中取值

Volatile特性

  1.保證此變量對所有的線程的可見性,這里的“可見性”,如本文開頭所述,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存(詳見:Java內存模型)來完成。

 

  2.禁止指令重排序優化。有volatile修飾的變量,賦值后多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個內存屏障(指令重排序時不能把后面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,並不需要內存屏障;(什么是指令重排序:是指CPU采用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。

 

volatile 性能:

  volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。

VolatileSynchronized區別

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

這里的第3happens- before關系,是根據happens- before的傳遞性推導出來的。

這里A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序后的執行順序)。在第一章提到過,如果A happens- before BJMM並不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前。這里操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B后的執行結果,與操作A和操作Bhappens- 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

}

 

Public void reader() {

    if (flag) {                //3

        int i =  a * a;        //4

        ……

    }

}

}

flag變量是個標記,用來標識變量a是否已被寫入。這里假設有兩個線程ABA首先執行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中。

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

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM