高效併發下的快取記憶體和指令重排
阿新 • • 發佈:2020-12-10
## 1. 前言
關於計算機系統處理器資源的高效使用,計算機系統設計就引入**快取記憶體**以解決CPU 運算速度與主記憶體儲存速度之間的速度不匹配問題;引入**指令重排**來提升 CPU 內部運算單元的執行利用效率。
> 提升計算機處理器的運算能力,最簡單、最有效的手段是讓計算機支援多工處理,可以充分利用處理器的運算能力。當然計算機作業系統的運算能力不單單取決於處理器,還需考慮系統中並行化與序列化的比重,磁碟I/O讀寫速度,網路通訊,資料庫互動等。
## 2. 快取記憶體
### 2.1 快取記憶體與快取一致性
![在這裡插入圖片描述](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ad94c7d7c80f46e28dc75566655e2ae5~tplv-k3u1fbpfcp-zoom-1.image)
2.1.1 快取記憶體
計算機處理器運算速度遠遠超出計算機儲存裝置的讀寫速度。一定程度上儲存裝置的讀寫速度限制了計算機系統的運算能力,引入快取記憶體作為處理器和儲存裝置之間的一層緩衝。快取記憶體的儲存速度接近處理器的運算速度,處理器無需等待主記憶體緩慢的讀寫操作,使得處理器高效的工作。
2.1.2 快取一致性
- 快取一致性問題
引入快取記憶體很好的處理了主記憶體讀寫速度與處理器運算速度相差幾個數量級的問題。
但多處理器計算機系統下,存在某個時刻下,主記憶體中某個資料在不同處理器快取記憶體中的資料不一致的情況。
- 處理方案
(1)處理器都是通過匯流排來和主儲存器(主記憶體)進行互動的,所以可以通過給匯流排加鎖,解決快取一致性問題;
(2)可以通過引入快取一致性協議,來處理快取一致性問題。
> 匯流排,匯流排英文標識為 Bus,公共汽車,匯流排是連線多個裝置或者接入點的資料傳輸通路,處理器所有傳出的資料都要通過匯流排互動主儲存器。
>
> 快取一致性協議,要求處理器要遵循這些協議,這些協議規定了讀寫操作的規範來保證快取一致性。
>
> Inter 處理器一般採用的是 MESI 協議。MESI(Modified Exclusive Shared Or Invalid)(也稱為伊利諾斯協議,是因為該協議由伊利諾斯州立大學提出)是一種廣泛使用的支援寫回策略的快取一致性協議,該協議被應用在Intel奔騰系列的CPU中。
### 2.2 工作記憶體與主記憶體
![在這裡插入圖片描述](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7cc987805d6441d9897ae44f7da60901~tplv-k3u1fbpfcp-zoom-1.image)
理解了快取記憶體,工作記憶體相似的,快取記憶體是從處理器角度出發,工作記憶體是從執行緒角度出發。
所有的變數儲存在主記憶體中,每條執行緒有自己的工作記憶體。此處主記憶體僅是虛擬機器記憶體的一部分,與 Java 記憶體模型(程式計數器、Java 堆、非堆、虛擬機器棧、本地方法棧) 沒有關聯。
- 工作記憶體中儲存了當前執行緒使用到變數的主記憶體拷貝,
- 執行緒對變數所有的操作都在工作記憶體中進行。
- 不同執行緒之間無法直接訪問對方工作記憶體的變數
- 執行緒間變數值的傳遞均需通過主記憶體來完成,工作記憶體互動主記憶體。
### 2.3 執行緒間工作記憶體互動主記憶體
每個執行緒都對應自己的工作記憶體,修改共享變數的值後,從當前工作記憶體儲存並寫入到主記憶體。同樣的,共享變數被其他執行緒修改後的新值,當前執行緒需要從主記憶體讀取並載入到當前工作記憶體,才能進行使用。
Java 記憶體模型定義了以下八種原子操作來作用於執行緒工作記憶體與主記憶體的互動。
|操作|名稱|作用記憶體|操作說明|
|-|-|-|:-|
|lock|鎖定|主記憶體|標識某個變數為執行緒獨佔|
|unlock|解鎖|主記憶體|釋放某個被執行緒獨佔的變數|
|read|讀取|主記憶體|變數的值從主記憶體傳輸到工作記憶體|
|load|載入|工作記憶體|把讀取到的值放入工作記憶體的變數副本|
|use|使用|工作記憶體|把變數值傳給執行引擎|
|assign|賦值|工作記憶體|把執行引擎接收到的值賦給變數|||||
|store|儲存|工作記憶體|把工作記憶體變數的值傳輸到主記憶體|
|write|寫入|主記憶體|變數值放入到主記憶體的變數中|
## 3. 原子性、可見性、有序性
3.1.1 性質
- 原子性
眾所周知,原子操作是不可再拆分的操作,即原子性操作是併發安全的;
> 原子操作包含 read、load、assign、use、store、write。
>
> lock 和 unlock 操作支援我們對一個更大範圍操作提供原子性保證。直觀來說,synchronized 關鍵字,被該關鍵字修飾的程式碼塊具有原子性,使用該關鍵字能保證程式碼塊的執行緒安全。
>
> synchronized 反映到位元組碼指令,包含 monitorenter 和 monitorexit 指令,這兩個指令隱式呼叫了 lock、unlock 操作。
- 有序性
在某個執行緒中所有的操作都是有序的。Java 程式中在另一個執行緒觀察當前執行緒的操作,都是無序的。
> volatile 和 synchronized 都可保證執行緒之間操作的有序性。
>
> volatile 具備禁止指令重排序的能力。
>
> synchronized 具備 lock、unlock 能力,支援一個變數在同一時刻只許一個執行緒對其進行 lock 鎖定操作。
- 可見性
可見性表現在多執行緒之間,一個執行緒修改了某個共享變數(執行緒間共享變數)的值,其他執行緒可以立即得到這個修改,即新值對其他執行緒是實時可見的。
> volatile 關鍵字修飾的共享變數,執行緒寫入新值,執行緒間是可見的。
>
> volatile 變數與普通變數的區別,在於 volatile 變數的新值會立即 store 儲存到主記憶體中,在使用 volatile 變數時會先從主記憶體 read 讀取新值 load 載入到當前工作記憶體。而普通變數使用時不會立即從主記憶體重新整理,當前工作記憶體若存在,則直接使用工作記憶體中變數的值。
3.1.2 可見性演示例項
關於可見性,郭嬸(郭霖)舉了一個栗子,有助理解,這邊就直接拿來了。
```java
/**
* @className: VisibilityDemo
* @description: 可見性演示例項
**/
public class VisibilityDemo {
private static volatile boolean flag = true;
public static void main(String... args) {
Thread thread1 = new Thread(() -> {
while (true) {
if (flag) {
flag = false;
System.out.println("Thread1 set flag to false");
}
}
}, "Thread-01");
Thread thread2 = new Thread(() -> {
while (true) {
if (!flag) {
flag = true;
System.out.println("Thread2 set flag to true");
}
}
}, "Thread-02");
// 分別啟動兩個執行緒
thread1.start();
thread2.start();
}
}
```
- 當共享變數 flag 為普通變數 `private static boolean flag` 時,程式中兩執行緒會交替列印資訊到控制檯,一段時間後,兩執行緒內部分支條件不再滿足,將不再列印資訊到控制檯;
```java
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
```
- 當共享變數由 volatile 修飾時 `private static volatile boolean flag`,程式中兩執行緒會持續交替列印資訊到控制檯;
```java
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
...
```
3.1.3 可見性演示例項問題分析
由於執行緒工作記憶體與主記憶體存在快取延時問題
一個普通的執行緒共享變數`private static boolean flag`,在上例中存在,隨著程式的執行,在某個時刻執行緒 Thread-01 的 flag 為 false,執行緒 Thread-02 的 flag 為 true,此時兩者都不會進入分支結構體,不再執行賦值操作,不再重新整理工作記憶體資料到主記憶體。兩個執行緒都會停止輸出資訊到控制檯。
宣告為 volatile 變數`private static volatile boolean flag`,會保證共享變數每次賦值都會即時儲存到主記憶體,每次使用共享變數時,會從主記憶體讀取並載入到當前執行緒工作記憶體再使用。使用關鍵字後的程式,兩執行緒會持續交替輸出資訊到控制檯。
## 4. 指令重排
### 4.1 就你TMD叫指令重排啊
在當前執行緒觀察 Java 程式,所有操作是有序的,但在其他執行緒觀察當前執行緒的操作是無序的。即執行緒內表現為序列的語義,多執行緒間存在工作記憶體與主記憶體同步延時及指令重排序現象。
### 4.2 指令重排的執行緒安全問題
- 多執行緒下指令重排的執行緒安全問題
我們知道處理器在指令集層面,會做一定的指令排序優化,來提升處理器運算速度。在單執行緒中可以保證對應高階語言的程式執行結果是正確的,即單執行緒下保證程式執行的有序性(及程式正確性);多執行緒情況下,在某個執行緒中觀察其他執行緒的操作是無序的(存線上程共享記憶體時,則無法保證程式正確性),這就是多執行緒下指令重排的執行緒安全問題。
4.2.1 指令重排演示例項
```java
import lombok.SneakyThrows;
/**
* @description: 指令重排:執行緒內表現為序列語義
* @author: niaonao
**/
public class OrderRearrangeDemo {
static boolean initFlag;
public static void main(String... args) {
Runnable customRunnable = new CustomRunnable();
new Thread(customRunnable, "Thread-01").start();
new Thread(customRunnable, "Thread-02").start();
}
static class CustomRunnable implements Runnable {
// @SneakyThrows 是 lombok 包下的註解
// 繼承了 Throwable 用於捕獲異常
@SneakyThrows
@Override
public void run() {
initFlag = false;
Integer number = null;
number = 1;
initFlag = true;
// 等待初始化完成
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
}
}
}
```
上面這個例子,在實際併發場景中很少出現執行緒安全問題,但存在指令重排引起執行緒安全問題的風險。
- 一般情況下執行結果為
```java
name: Thread-01, number: 1
name: Thread-02, number: 1
Process finished with exit code 0
```
- 指令重排存在的風險結果可能為
```java
name: Thread-01, number: 1
name: Thread-02, number: null
Process finished with exit code 0
```
4.2.2 指令重排演示例項問題分析
執行緒內保證程式的有序性,多執行緒下處理器指令重排優化存在的情況如下(這裡從高階語言來快速理解,其實指令我也做不到啊),下面並沒有列出所有情況。
```java
// 情況-01
initFlag = false;
Integer number = null;
number = 1;
initFlag = true;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-02
initFlag = false;
Integer number = null;
initFlag = true;
number = 1;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-03
initFlag = false;
initFlag = true;
Integer number = null;
number = 1;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-04
Integer number = null;
initFlag = false;
number = 1;
initFlag = true;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-05
Integer number = null;
initFlag = false;
initFlag = true;
number = 1;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-06
Integer number = null;
number = 1;
initFlag = false;
initFlag = true;
while (!initFlag) {
}
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-07
Integer number = null;
initFlag = false;
initFlag = true;
while (!initFlag) {
}
number = 1;
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
// 情況-07
Integer number = null;
initFlag = false;
initFlag = true;
while (!initFlag) {
}
number = 1;
System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
```
執行緒共享變數 initFlag 線上程 Thread-01 中已經執行 `initFlag = true` 操作後,線上程 Thread-02 中讀取到 initFlag 為 true,就會跳出 while 迴圈,此時由於指令重排,number 可能還沒有賦值為 1,程式列印到控制檯的資訊會是`name: Thread-02, number: null`。
### 4.3 禁止指令重排序
指令重排有執行緒安全風險,怎麼避免呢?
欸,問得好niaonao同學,請坐。Java 提供 volatile 關鍵字具備兩個特性,一是可見性,一是禁止指令重排。如4.2.1 指令重排演示例項,就用 volatile 修飾共享變數 `static boolean initFlag` 即可。
可見性就不再贅述了。關於禁止指令重排的原理是通過 volatile 修飾的共享變數,會新增一個記憶體屏障,處理器在做重排序優化時,無法將記憶體屏障後面的指令放在記憶體屏障前面。
>Powered By