多線程不安全的緣由和基本的解決方案

內容大綱

  • 共享變量在內存中的可見性
  • 什麼是原子性
  • synchronized實現可見性和原子性的方式
  • volatile實現可見性的方式

Java內存模型(JMM)

Java內存模型(JMM)描述了Java程序中變量(線程公用變量)的訪問規則(能夠看作是一種規範),以及在JVM中將變量存儲到內存內存中讀取出變量這樣的底層細節java

  • 全部的變量都存儲在主內存中
  • 每一個線程都有本身獨立的工做內存,裏面保存該線程使用到的變量副本(主內存中該變量的一份拷貝)

而且規定:git

  • 線程對共享變量的全部操做都必須在本身的工做內存中進行,不能直接從主內存中讀寫
  • 不一樣線程之間沒法直接訪問其餘線程工做內存中的變量,線程間變量的傳遞主要經過主內存來完成

舉個例子(修改線程A中的變量):安全

  1. 把工做內存A中更新過的共享變量刷新到主內存中
  2. 將主內存中的最新共享變量的值更新到工做內存B中

若是知足上面兩點,也就是說線程A中更新的共享變量線程B中可以及時獲得更新,就稱爲變量是可見的,反正則是不可見。多線程

共享變量、可見性和原子性

  • 共享變量:若是一個變量在多個線程的工做內存中都存在副本,那麼這個變量就是這幾個線程的共享變量。
  • 可見性:一個線程對共享變量值的修改,可以及時地被其餘線程看到。
  • 原子性: 一段指令像原子同樣不可分割,在執行結束以前,其餘線程不可打斷。

指令重排序和as-if-serial

代碼書寫順序與代碼實際執行的順序不一樣,指令重排序是編譯器或處理器爲了提升程序性能而作出的優化。併發

主要有三種方式:ide

  1. 編譯器優化的重排序(編譯器優化,主要是單線程下,在保證執行結果正確的前提下,重排序代碼順序讓代碼更符合機器執行)
  2. 指令級並行重排序(處理器優化,主要爲多核計算機同時執行作了優化)
  3. 內存系統的重排序(處理器優化,主要是運行內存,如主內存、工做內存進行重排序)

舉個例子:性能

int a = 2;
int b = 3;
int c = a + b;

在實際運行中多是:優化

int b = 3;
int a = 2;
int c = a + b;

上述例子中演示了指令重排序,那麼可能會有人問,第三行代碼若是重排序到前兩行代碼以前,豈不是會報錯嗎?this

有一個as-if-serial,其內容以下:線程

不管如何重排序,程序執行的結果應該與代碼順序執行結果一致。(Java編譯器、運行時和處理器都要保證Java在單線程下遵循as-if-serial語義)

所以在單線程的狀況下你沒必要擔憂指令重排序帶來什麼不良後果。

可是在多線程交錯執行時,重排序就可能形成內存可見性問題,詳情請繼續閱讀下文。

多線程中的指令重排序

package cn.com.dotleo;

/**
 * Created by liufei on 2018/6/16.
 */
public class SynchronizedDemo {

    // 共享變量
    private boolean ready = false;
    private int result = 0;
    private int number = 1;

    // 寫操做
    public void write() {
        ready = true;  // 1.1
        number = 2;  // 1.2
    }

    // 讀操做
    public void read() {
        if (ready) {  // 2.1
            result = number * 3;  //2.2
        }
        System.out.println("result的值爲:" + result);
    }

    private class ReadWriteThread extends Thread {
        private boolean flag;
        public ReadWriteThread(boolean flag) {
            this.flag = flag;
        }

        // 根據傳入執行不一樣的讀寫操做
        @Override
        public void run() {
            if (flag) {
                write();
            } else {
                read();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo demo = new SynchronizedDemo();
        // 啓動寫線程
        demo.new ReadWriteThread(true).start();
        // 啓動讀線程
        demo.new ReadWriteThread(false).start();
    }

}

代碼參見SynchronizedDemo

對於這個程序,若是執行main方法將可能有一下幾種狀況:

  • 1.1 -> 2.1 -> 2.2 -> 1.2 result:3
  • 1.2 -> 2.1 -> 2.2 -> 1.1 result:0

其實,2.1和2.2也是能夠重排序的:

int temp = number * 3;
if (ready) {
    result = temp;
}

所以就有了:

  • 2.2 -> 2.1 ->1.2 -> 1.1
  • ...

致使共享變量線程不安全的緣由

到這裏,能夠總結一下爲何會出現共享變量線程不安全的主要緣由了:

  1. 線程的交叉執行(原子性)
  2. 重排序結合線程的交叉執行(可見性)
  3. 共享變量更新後的值沒有在工做內存中與主內存之間及時更新(可見性)

可見性的實現方式

從Java語言層面講,主要支持一下兩種方式:

  • Synchronized
  • Volatile

不包括JDK 1.5提供的Java併發包

Synchronized

JMM關於Synchronized的兩條規定:

  1. 線程解鎖前,必須把共享變量的最新值刷新到主內存中
  2. 線程加鎖時,將清空工做內存中共享變量的值,使得使用共享變量時須要從主內存中從新獲取最新的值。(加鎖和解鎖須要是同一把鎖)

咱們先來修改一下原來的代碼,保證其共享變量在多線程下的可見性。

// 寫操做
    public synchronized void write() {
        ready = true;  // 1.1
        number = 2;  // 1.2
    }

    // 讀操做
    public synchronized void read() {
        if (ready) {  // 2.1
            result = number * 3;  //2.2
        }
        System.out.println("result的值爲:" + result);
    }

代碼參見SafeSynchronizedDemo

爲何這個操做能保證其可見性呢?咱們經過分析致使共享變量線程不可見的3個緣由逐一分析:

  1. Synchronized關鍵詞加鎖後,保證了線程不會交叉執行
  2. 若是線程不交叉執行,那麼不管如何重排序,都至關於單線程中的重排序,遵循as-if-serial語義
  3. 上面提到的Synchronized的兩條規定能保證它及時獲取而且在操做結束後及時更新主內存中的共享變量的值。

volatile

關於volatile,它有如下特性:

  • 可以保證變量的可見性
  • 不能保證變量的原子性

它的這些特性是經過內存屏障和禁止指令重排序優化來實現的。

  • 對volatile變量執行寫操做時,會在寫操做後加入一條store屏障指令,讓主內存中的變量及時更新
  • 對volatile變量執行讀操做時,會在讀操做前加入一條load屏障指令,更新主內存中的變量

舉一個volatile的例子說明它不具有原子性。

package cn.com.dotleo;

/**
 * Created by liufei on 2018/6/16.
 */
public class VolatileDemo {

    private volatile int num = 0;

    public int getNum() {
        return this.num;
    }

    public void increase() {
        this.num++;
    }

    public static void main(String[] args) {
        final VolatileDemo volatileDemo = new VolatileDemo();
        for (int i = 0; i < 500; i++) {
            new Thread(new Runnable() {
                public void run() {
                    volatileDemo.increase();
                }
            }).start();
        }

        // 爲了讓全部線程執行完畢
        // 若是還有子線程執行
        // 主線程讓出cpu資源
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println("num" + volatileDemo.getNum());
    }
}

代碼參見VolatileDemo

該程序的運行結果不老是500。由於increase()方法中的num++並不是原子操做,它包括了:

  1. 從主內存中獲取num的值
  2. num的值 + 1
  3. 將num + 1的值賦值給num

試分析一種狀況:

  1. 線程A獲取到num的值 = 0,因爲它不是原子性,cpu資源被線程B搶走
  2. 線程B獲取num的值 = 0
  3. 線程B對num + 1 = 1
  4. 線程B把num值更新到主內存中後結束
  5. 線程A從新得到cpu資源, 對它內存中num的副本 + 1 = 1
  6. 線程A將num = 1更新到主內存中

至此,兩次循環卻少加了,致使並不是咱們想要的num最終 = 500

volatile適用場景

要在多線程中安全的使用volatile,必須同時知足:

  1. 對變量的寫入操做不依賴當前值
  2. 該變量沒有包含在具備其餘變量的不變式中
相關文章
相關標籤/搜索