【Java並發】線程安全和內存模型


一、概述

1.1 什么是線程安全?

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

1.2 案例

  • 需求現在有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();
    }
    
}

運行結果

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

1.3 線程安全解決辦法:

  • 使用多線程之間同步synchronized使用鎖(lock)。將可能會發生數據沖突問題(線程不安全問題),只能讓當前一個線程進行執行。代碼執行完成后釋放鎖,讓后才能讓其他線程進行執行。這樣的話就可以解決線程不安全問題。當多個線程共享同一個資源,不會受到其他線程的干擾。

二、synchronized

2.1 概述

  • Java提供了一種內置的鎖機制來支持原子性
  • 每一個Java對象都可以用作一個實現同步的鎖,稱為內置鎖,線程進入同步代碼塊之前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖
  • 內置鎖為互斥鎖,即線程A獲取到鎖后,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖
  • 內置鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:
    • 1.修飾需要進行同步的方法(所有訪問狀態變量的方法都必須進行同步),此時充當鎖的對象為調用同步方法的對象
    • 2.同步代碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,並且充當鎖的對象不一定是this,也可以是其它對象,所以使用起來更加靈活

2.2 同步代碼塊

  • 就是將可能會發生線程安全問題的代碼,給包括起來。格式如下:
synchronized(對象) { //這個對象可以為任意對象
    需要被同步的代碼 
} 
  • 對象如同鎖,持有鎖的線程可以在同步中執行,沒持有鎖的線程即使獲取CPU的執行權,也進不去
  • 同步的前提:
  • 1,必須要有兩個或者兩個以上的線程
  • 2,必須是多個線程使用同一個鎖
  • 必須保證同步中只能有一個線程在運行
  • 好處:解決了多線程的安全問題
  • 弊端:多個線程需要判斷鎖,較為消耗資源

代碼樣例

  • 將上面的例子中的sale()方法加鎖
public void sale() {
    synchronized (this) {
        if (trainCount > 0) {
            System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
            trainCount--;
        }
    }
}

2.3 同步方法

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

代碼樣例

public synchronized void sale() {
    if (trainCount > 0) {
        System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
        trainCount--;
    }
}
  • 同步函數使用this鎖。注意這里是非靜態的普通方法
  • 證明方式: 一個線程使用同步代碼塊(this明鎖),另一個線程使用同步函數。如果兩個線程搶票不能實現同步,那么會出現數據錯誤。

代碼如下

/**
 * 
 * 測試 同步方法(非靜態) 的鎖是 this 對象
 * @author hao
 *
 */
public class Test_SyncFun {
    public static void main(String[] args) throws InterruptedException {
        MyThread threadTrain = new MyThread();
        Thread t1 = new Thread(threadTrain, "窗口1");
        Thread t2 = new Thread(threadTrain, "窗口2");
        t1.start();
        Thread.sleep(40);
        threadTrain.flag = false;
        t2.start();

    }
}

class MyThread 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--;
        }

    }
}

2.4 靜態同步函數

  • synchronized 關鍵字修飾 靜態方法
  • 靜態的同步函數使用的鎖是 該函數所屬字節碼文件對象
  • 可以用 getClass方法獲取,也可以用當前 類名.class 表示。

代碼樣例


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

//上面的就等同於如下代碼塊,鎖對象為當前類的字節碼文件對象

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

2.5 總結

  • synchronized 修飾普通方法使用鎖是當前this鎖。
  • synchronized 修飾靜態方法使用鎖是當前類的字節碼文件

三、多線程死鎖

3.1 什么是死鎖

  • 同步中嵌套同步,導致鎖無法釋放
  • 多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程序不可能正常終止。
  • 線程 A 持有資源 2,線程 B 持有資源 1,他們同時都想申請對方的資源,所以這兩個線程就會互相等待而進入死鎖狀態。

示例


/**
 * 死鎖
 *
 */
public class DeadLockDemo {
    private static Object resource1 = new Object();//資源 1
    private static Object resource2 = new Object();//資源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "線程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "線程 2").start();
    }
}
  • 線程 A 通過 synchronized (resource1) 獲得 resource1 的監視器鎖,然后通過Thread.sleep(1000);讓線程 A 休眠 1s 為的是讓線程 B 得到執行然后獲取到 resource2 的監視器鎖。線程 A 和線程 B 休眠結束了都開始企圖請求獲取對方的資源,然后這兩個線程就會陷入互相等待的狀態,這也就產生了死鎖。上面的例子符合產生死鎖的四個必要條件。

  • 操作系統中產生死鎖必須具備以下四個條件:

    • 互斥條件:該資源任意一個時刻只由一個線程占用。
    • 請求與保持條件:一個進程因請求資源而阻塞時,對已獲得的資源保持不放。
    • 不剝奪條件:線程已獲得的資源在末使用完之前不能被其他線程強行剝奪,只有自己使用完畢后才釋放資源。
    • 循環等待條件:若干進程之間形成一種頭尾相接的循環等待資源關系。

3.2 如何避免

  • 我們只要破壞產生死鎖的四個條件中的其中一個就可以了。
  • 破壞互斥條件
    • 這個條件我們沒有辦法破壞,因為我們用鎖本來就是想讓他們互斥的(臨界資源需要互斥訪問)。
  • 破壞請求與保持條件
    • 一次性申請所有的資源。
  • 破壞不剝奪條件
    • 占用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它占有的資源。
  • 破壞循環等待條件

    • 靠按序申請資源來預防。按某一順序申請資源,釋放資源則反序釋放。破壞循環等待條件。
  • 我們對線程 2 的代碼修改成下面這樣就不會產生死鎖了。

new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "線程 2").start();

分析

  • 線程 1 首先獲得到 resource1 的監視器鎖,這時候線程 2 就獲取不到了。然后線程 1 再去獲取 resource2 的監視器鎖,可以獲取到。然后線程 1 釋放了對 resource1、resource2 的監視器鎖的占用,線程 2 獲取到就可以執行了。這樣就破壞了破壞循環等待條件,因此避免了死鎖。

四、Threadlocal

4.1 什么是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。

4.2 案例

  • 創建三個線程,每個線程生成自己獨立序列號。
package com.hao.threadlocal;

public class ThreadLocaDemo extends Thread {
    private Res res;
    public ThreadLocaDemo(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();
        ThreadLocaDemo threadLocaDemo1 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo2 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo3 = new ThreadLocaDemo(res);
        threadLocaDemo1.start();
        threadLocaDemo2.start();
        threadLocaDemo3.start();
    }

}

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;
    }
}

運行結果 可以看出每個線程會自己生成num,互不干擾

Thread-1---i---0--num:1
Thread-2---i---0--num:1
Thread-0---i---0--num:1
Thread-2---i---1--num:2
Thread-1---i---1--num:2
Thread-2---i---2--num:3
Thread-1---i---2--num:3
Thread-0---i---1--num:2
Thread-0---i---2--num:3

4.3 ThreadLoca實現原理

  • 最終的變量是放在了當前線程的 ThreadLocalMap 中,並不是存在 ThreadLocal 上,ThreadLocal 可以理解為只是ThreadLocalMap的封裝,傳遞了變量值。 ThrealLocal 類中可以通過Thread.currentThread()獲取到當前線程對象后,直接通過getMap(Thread t)可以訪問到該線程的ThreadLocalMap對象。

  • 每個Thread中都具備一個ThreadLocalMap,而ThreadLocalMap可以存儲以ThreadLocal為key的鍵值對。ThreadLocalMap的 key 就是 ThreadLocal對象,value 就是 ThreadLocal 對象調用set方法設置的值。ThreadLocal 是 map結構是為了讓每個線程可以關聯多個 ThreadLocal變量。這也就解釋了 ThreadLocal 聲明的變量為什么在每一個線程都有自己的專屬本地變量。

4.4 內存泄漏問題

  • ThreadLocalMap 中使用的 key 為 ThreadLocal 的弱引用,而 value 是強引用。所以,如果 ThreadLocal 沒有被外部強引用的情況下,在垃圾回收的時候會 key 會被清理掉,而 value 不會被清理掉。這樣一來,ThreadLocalMap 中就會出現key為null的Entry。假如我們不做任何措施的話,value 永遠無法被GC 回收,這個時候就可能會產生內存泄露。ThreadLocalMap實現中已經考慮了這種情況,在調用 set()、get()、remove() 方法的時候,會清理掉 key 為 null 的記錄。使用完 ThreadLocal方法后 最好手動調用remove()方法
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

關於弱引用

  • 如果一個對象只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命周期。在垃圾回收器線程掃描它 所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程, 因此不一定會很快發現那些只具有弱引用的對象。

  • 弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。

五、Java內存模型

5.1 主內存和工作內存

  • 處理器上的寄存器的讀寫的速度比內存快幾個數量級,為了解決這種速度矛盾,在它們之間加入了高速緩存。
  • 加入高速緩存帶來了一個新的問題:緩存一致性。如果多個緩存共享同一塊主內存區域,那么多個緩存的數據可能會不一致,需要一些協議來解決這個問題。
  • 共享內存模型指的就是Java內存模型(簡稱JMM),JMM決定一個線程對共享變量的寫入時,能對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。

  • 從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟
  • 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  • 然后,線程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,定義了一個線程對另一個線程可見。共享變量存放在主內存中,每個線程都有自己的本地內存,當多個線程同時訪問一個數據的時候,可能本地內存沒有及時刷新到主內存,所以就會發生線程安全問題。

5.2 內存間交互操作

  • Java 內存模型定義了 8 個操作來完成主內存和工作內存的交互操作。

  • read (讀取) :作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
  • load (載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放人工作內存的變量副本中。
  • use (使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
  • assign (賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store (存儲) :作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨后的write操作使用。
  • write (寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放人主內存的變量中。
  • lock (鎖定):作用於主內存的變量,它把一個變量標識為-條線程獨占的狀態。
  • unlock (解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。

5.3 多線程有三大特性

1. 原子性

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

2. 什么是可見性

  • 當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
  • 若兩個線程在不同的cpu,那么線程1改變了i的值還沒刷新到主存,線程2又使用了i,那么這個i值肯定還是之前的,線程1對變量的修改線程沒看到這就是可見性問題。

3. 什么是有序性

  • 程序執行的順序按照代碼的先后順序執行。
  • 一般來說處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。如下:
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,因為這打破了依賴關系,語句4不能再其他語句之前。
  • 顯然重排序對單線程運行是不會有任何問題,而多線程就不一定了,所以我們在多線程編程時就得考慮這個問題了。

5.4 Volatile

1. 什么是Volatile

  • 可見性也就是說一旦某個線程修改了該被volatile修飾的變量,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,可以立即獲取修改之后的值。
  • 在Java中為了加快程序的運行效率,對一些變量的操作通常是在該線程的寄存器或是CPU緩存上進行的,之后才會同步到主存中,而加了volatile修飾符的變量則是直接讀寫主存。
  • volatile 保證了線程間共享變量的及時可見性,但不能保證原子性

2. 代碼

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關鍵字將解決線程之間可見性, 強制線程每次讀取該值的時候都去“主內存”中取值

3. volatile特性

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

4. volatile 性能:

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

5. volatile與synchronized區別

  • (1)我們可以看出volatile雖然具有可見性但是並不能保證原子性。
  • (2)性能方面,synchronized關鍵字是防止多個線程同時執行一段代碼,就會影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized。但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。

5.5 重排序

1. 數據依賴性

  • 如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分下列三種類型:
  • 名稱   代碼示例      說明
  • 寫后讀  a = 1;b = a;   寫一個變量之后,再讀這個位置。
  • 寫后寫  a = 1;a = 2;   寫一個變量之后,再寫這個變量。
  • 讀后寫  a = b;b = 1;   讀一個變量之后,再寫這個變量。

  • 上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。

  • 前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。

  • 注意,這里所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。

2. as-if-serial語義

  • as-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語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

3. 程序順序規則

  • 根據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同樣遵從這一目標。

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

  • 現在讓我們來看看,重排序是否會改變多線程程序的執行結果。請看下面的示例代碼:
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是否已被寫入。這里假設有兩個線程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的處理器可以提前讀取並計算aa,然后把計算結果臨時保存到一個名為重排序緩沖(reorder buffer ROB)*的硬件緩存中。當接下來操作3的條件判斷為真時,就把該計算結果寫入變量i中。
  • 從圖中我們可以看出,猜測執行實質上對操作3和4做了重排序。重排序在這里破壞了多線程程序的語義!
  • 在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);
  • 但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。


免責聲明!

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



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