線程安全—可見性和有序性


 在並發編程中,需要處理的兩個關鍵問題:線程之間如何通信以及線程之間如何同步

通信是指線程之間以或者機制交換信息,java的並發采用的是共享內存模型,線程之間共享程序的公共狀態,通過讀寫內存中的公共狀態進行隱式通信。

同步是是指程序中用於控制不同線程間操作發生相對順序的機制。

 

最開始首先應該知道計算機中的緩存在其中起的作用

CPU Cache(高速緩存):由於計算機的存儲設備與處理器的處理設備有着幾個數量級的差距,所以現代計 算機都會加入一層讀寫速度與處理器處理速度接近相同的高級緩存來作為內存與處理器之間的緩沖,將運 算使用到的數據復制到緩存中,讓運算能夠快速的執行,當運算結束后,再從緩存同步到內存之中,這 樣,CPU就不需要等待緩慢的內存讀寫了。

主(內)存:一個計算機包含一個主存,所有的CPU都可以訪問主 存,主存比緩存容量大的多(CPU訪問緩存層的速度快於訪問主存的速度!但通常比訪問內存寄存器的速度還是要慢點)

運作原理:通常情況下,當一個CPU要讀取主存(RAM - Main Mernory)的時候,他會將主存中的數據讀 取到CPU緩存中,甚至將緩存內容讀到內部寄存器里面,然后再寄存器執行操作,當運行結束后,會 將寄存器中的值刷新回緩存中,並在某個時間點將值刷新回主存。

為什么需要CPU Cache?

 答:CPU 的頻率太快了,快到主存跟不上,這樣在處理器時鍾周期內,CPU常常需要等待主存,浪費資源。 所以cache 的出現,是為了緩解 CPU 和內存之間速度的不匹配問題 結構:cpu-> cache-> memory).

 

什么是java的內存模型?

共享變量:一個變量可以被多個線程使用,那么這個變量就是這幾個線程的共享變量。
Java Memory Model (JAVA 內存模型)描述線程之間如何通過內存(memory)來進行交互,描述了java程序中各種變量(線程共享變量)的訪問規則,以及在JVM中將變量存儲到內存和從內存中讀取出變量這樣的底層細節。 
具體說來, JVM中存在一個主存區(Main Memory或Java Heap Memory),對於所有線程進行共享,但線程不能直接操作主內存中的變量,每個線程都有自己獨立的工作內存(Working Memory),里面保存該線程使用到的變量的副本( 主內存中該變量的一份拷貝 )
規定:線程對共享變量的讀寫都必須在自己的工作內存中進行,而不能直接在主內存中讀寫。不同線程不能直接訪問其他線程的工作內存中的變量,線程間變量值的傳遞需要主內存作為橋梁。 
 
Heap(堆):Java里的堆是一個運行時的數據區,堆是由垃圾回收來負責的。實例域、靜態域、和數組元素都存儲在堆內存中。
Stack(棧):棧的優勢是存取速度比堆要快,僅次於計算機里的寄存器。局部變量、方法參數、對象的引用存儲在棧中。
 
 
java內存模型的抽象結構圖:
 
 

 每個線程之間共享變量都存放在主內存里面,每個線程都有一個私有的本地內存,本地內存是Java內存模型中抽象的概念,並不是真實存在的(他涵蓋了緩存寫緩沖區。寄存器,以及其他硬件的優化) 本地內存中存儲了以讀或者寫共享變量的拷貝的一個副本。

注意:由於工作內存(緩沖區)僅對自己的處理器可見,它會導致處理器質質性內存操作的順序可能會與內存實際的操作順序不一致,內存的操作順序被重排序了,這是與后面講的指令重排序不同的另一種重排序。

 
什么是內存的可見性?
可見性:一個線程對共享變量值得修改,能夠及時的被其他線程看到 
線程可見性原理: 
線程一對共享變量的改變想要被線程二看見,就必須執行下面兩個步驟:
①將工作內存1中的共享變量的改變更新到主內存中
②將主內存中最新的共享變量的變化更新到工作內存2中。
 
指令重排序:代碼書寫的順序與實際執行的順序不同,指令重排序是編譯器或處理器為了提高程序性能而做的優化。

1.編譯器優化的重排序(編譯器優化)

2.指令級並行重排序(處理器優化)

3.內存系統的重排序(處理器優化)

是不是所有的語句的執行順序都可以重排呢?

答案是否定的。為了講清楚這個問題,先講解另一個概念:數據依賴性

什么是數據依賴性?

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

名稱 代碼示例 說明
寫后讀 a = 1;b = a; 寫一個變量之后,再讀這個位置。
寫后寫 a = 1;a = 2; 寫一個變量之后,再寫這個變量。
讀后寫 a = b;b = 1; 讀一個變量之后,再寫這個變量。

上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。所以,編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。也就是說:在單線程環境下,指令執行的最終效果應當與其在順序執行下的效果一致,否則這種優化便會失去意義。這句話有個專業術語叫做as-if-serial semantics (as-if-serial語義)

int num1=1;//第一行
int num2=2;//第二行
int sum=num1+num;//第三行

  • 單線程:第一行和第二行可以重排序,但第三行不行
  • 重排序不會給單線程帶來內存可見性問題
  • 多線程中程序交錯執行時,重排序可能會照成內存可見性問題。

可見性分析:

導致共享變量在線程間不可見的原因:

  1. 線程的交叉執行
  2. 重排序結合線程交叉執行
  3. 共享變量更新后的值沒有在工作內存與主內存間及時更新

 

重排序對多線程的影響
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重排序時,可能會產生什么效果?

執行順序是:2 -> 3 -> 4 -> 1 (這是完全存在並且合理的一種順序,如果你不能理解,請先了解CPU是如何對多個線程進行時間分配的)
 

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

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

 
同步(synchronization)就是指一個線程訪問數據時,其它線程不得對同一個數據進行訪問,即同一時刻只能有一個線程訪問該數據,當這一線程訪問結束時其它線程才能對這它進行訪問。
package com.xidian.count;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

import com.xidian.annotations.ThreadSafe;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@ThreadSafe
public class CountExample3 {

    // 請求總數
    public static int clientTotal = 5000;

    // 同時並發執行的線程數
    public static int threadTotal = 200;

    public static int count = 0;

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal ; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (Exception e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}", count);
    }

    private synchronized static void add() {
        count++;
    }
}
View Code

volatile實現可見性

volatile變量每次被線程訪問時,都強迫從主內存中讀取該變量的值,而當變量發生變化的時候都會強迫線程將最新的值刷新到主內存中。
這樣不同的變量總能看到最新的值。
可以把volatile變量的單個讀寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步。

volatile關鍵字:

  • 能夠保證volatile變量的可見性
  • 只能保證單個volatile變量的原子性,對於volatile++這種復合操作不具有原子性
深入來說:通過加入內存屏障和禁止重排序優化來實現的。
  • 對volatile變量執行寫操作時,會在寫操作后加入一條store屏障指令

    • store指令會在寫操作后把最新的值強制刷新到主內存中。同時還會禁止cpu對代碼進行重排序優化。這樣就保證了值在主內存中是最新的。
  • 對volatile變量執行讀操作時,會在讀操作前加入一條load屏障指令

    • load指令會在讀操作前把內存緩存中的值清空后,再從主內存中讀取最新的值。

 

 

 

package com.xidian.count;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

import com.xidian.annotations.NotThreadSafe;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@NotThreadSafe
public class CountExample4 {

    // 請求總數
    public static int clientTotal = 5000;

    // 同時並發執行的線程數
    public static int threadTotal = 200;

    public static volatile int count = 0;

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal ; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (Exception e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}", count);
    }

    private static void add() {
        count++;
        // 1、count 從主存中取出count的值
        // 2、+1  在工作內存中執行+1操作
        // 3、count 將count的值寫回主存
        //及時將count用vilatile修飾,每次從主存中取到的都是最新的值,可是當多個線程同時取到最新的值,執行+1操作,當刷新到主存中的時候會覆蓋結果,從而丟失一些+1操作
    }
}
View Code
由程序運行結果可知,即使我們使用volatile修飾變量依然無法保證線程安全。
那是為什么呢?

volatile實現共享變量內存可見性有一個條件,就是對共享變量的操作必須具有原子性。比如 num = 10; 這個操作具有原子性,但是 num++ 或者num--由3步組成,並不具有原子性,所以是不行的。

假如num=5,此時有線程A從主內存中獲取num的值,並執行++,但在還未見修改寫入主內存中,又有線程B取得num的值,對其進行++操作,造成丟失修改,明明執行了2次++,num的值卻只增加了1.

volatile不具有原子性,它不適用於計數的場景,那么它適用於什么場景呢?
volatile使用條件:
  1. 對變量的寫入操作不依賴其當前值

    • 不滿足:number++、count=count*5
    • 滿足:boolean變量、記錄溫度變化的變量等
  2. 該變量沒有包含在具有其他變量的不變式中

    • 不滿足:不變式 low<up

綜上,volatile特別適合用來做線程標記量,如下圖

synchronized和volatile的比較;

  • synchronized鎖住的是變量和變量的操作,而volatile鎖住的只是變量,而且該變量的值不能依賴它本身的值,volatile算是一種輕量級的同步鎖
  • volatile不需要加鎖,比synchronized更加輕量級,不會阻塞線程。
  • 從內存可見性角度講,volatile讀相當於加鎖,volatilexie相當於解鎖。
  • synchronized既能保證可見性,又能保證原子性,而volatile只能保證可見性,無法保證原子性。
注:由於voaltile比synchronized更加輕量級,所以執行的效率肯定是比synchroized更高。在可以保證原子性操作時,可以盡量的選擇使用volatile。在其他不能保證其操作的原子性時,再去考慮使用synchronized。

有序性

Happens-before原則,先天有序性,即不需要任何額外的代碼控制即可保證有序性,java內存模型一個列出了八種Happens-before規則,如果兩個操作的次序不能從這八種規則中推倒出來,則不能保證有序性。

※程序次序規則:一個線程內,按照代碼執行,書寫在前面的操作先行發生於書寫在后面的操作。
※鎖定規則:一個unLock操作先行發生於后面對同一個鎖的lock操作
※volatile變量規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作
※傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
※線程啟動原則:Thread對象的start()方法先行發生於此線程的每一個動作
※線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
※線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()方法返回值手段檢測到線程已經終止執行
※對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始

第一條規則要注意理解,這里只是程序的運行結果看起來像是順序執行,雖然結果是一樣的,jvm會對沒有變量值依賴的操作進行重排序,這個規則只能保證單線程下執行的有序性,不能保證多線程下的有序性。

總結


免責聲明!

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



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