[Java併發-1]入門:併發編程Bug的源頭

背景介紹

如何解決併發問題,首先要理解併發問題的實際源頭怎麼發生的。編程

現代計算機的不一樣硬件的運行速度是差別很大的,這個你們應該都是知道的。緩存

計算機數據傳輸運行速度上的快慢比較:
CPU > 緩存 > I/O

如何最大化的讓不一樣速度的硬件能夠更好的協調執行,須要作一些「撮合」的工做併發

  • CUP增長了高速緩存來均衡與緩存間的速度差別
  • 操做系統增長了 進程,線程,以分時複用CPU,進而均衡CPU與I/O的速度差別(當等待I/O的時候系統切換CPU給系統程序使用)
  • 現代編程語言的編譯器優化指令順序,使得緩存可以合理的利用

上面說來併發才生問題的背景,下面說下併發產生的具體緣由是什麼編程語言


併發產生的緣由

緩存致使的可見性問題

先看下單核CPU和緩存之間的關係:
圖片描述性能

單核狀況下,也是最簡單的狀況,線程A操做寫入變量A,這個變量A的值確定是被線程B所見的。由於2個線程是在一個CPU上操做,所用的也是同一個CPU緩存。優化

這裏咱們來定義spa

一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲 「可見性」

多核CPU時代下,咱們在來看下具體狀況:操作系統

圖片描述

很明顯,多核狀況下每一個CPU都有本身的高速緩存,因此變量A的在每一個CPU中多是不一樣步的,不一致的。
結果程A恰好操做來CPU1的緩存,而線程B也恰好只操做了CPU2的緩存。因此這狀況下,當線程A操做變量A的時候,變量並不對線程B可見。線程


咱們用一段經典的代碼說明下可見性的問題:code

private void add10K() {
        int idx = 0;
        while (idx++ < 100000) {
            count += 1;
        }
    }

    @Test
    public void demo() {

        // 建立兩個線程,執行 add() 操做
        Thread th1 = new Thread(() -> {
            add10K();
        });
        Thread th2 = new Thread(() -> {
            add10K();
        });
        // 啓動兩個線程
        th1.start();
        th2.start();
        // 等待兩個線程執行結束

        try {
            th1.join();
            th2.join();
        } catch (Exception exc) {

            exc.printStackTrace();
        }

        System.out.println(count);

    }

你們應該都知道,答案確定不是 200000
這就是可見性致使的問題,由於2個線程讀取變量count時,讀取的都是本身CPU下的高速緩存內的緩存值,+1時也是在本身的高速緩存中。


線程切換帶來的原子性問題

進程切換最先是爲了提升CPU的使用率而出現的。

好比,50毫米操做系統會從新選擇一個進程來執行(任務切換),50毫米成爲「時間片」

圖片描述


早期的操做系統是進程間的切換,進程間的內存空間是不共享的,切換須要切換內存映射地址,切換成本大。

而一個進程建立的全部線程,內存空間都是共享的。因此如今的操做系統都是基於更輕量的線程實現切換的,如今咱們提到的「任務切換」都是線程切換。

任務切換的時機大多數在「時間片」結束的時候。


如今咱們使用的基本都是高級語言,高級語言的一句對應多條CPU命令,好比 count +=1 至少對應3條CPU命令,指令:

1, 從內存加載到CPU的寄存器
2, 在寄存器執行 +1
3, 最後,講結果寫回內存(緩存機制致使可能寫入的是CPU緩存而不是內存)

操做系統作任務切換,會在 任意一條CPU指令執行完就行切換。因此會致使問題

非原子操做的執行路徑圖


如圖所示,線程A當執行完初始化count=0時候,恰好被線程切換給了線程B。線程B執行count+1=1並最後寫入值到緩存中,CPU切換回線程A後,繼續執行A線程的count+1=1並再次寫入緩存,最後緩存中的count仍是爲1.
一開始咱們任務count+1=1應該是一個不能再被拆開的原子操做。

咱們把一個或多個操做在CPU執行過程當中的不被中斷的特性稱爲 原子性。

CPU可以保證的原子性,是CPU指令級別的。因此高級語言須要語言層面 保證操做的原子性。


編譯優化帶來的有序性問題

有序性。顧名思義,有序性指的是程序按照代碼的前後順序執行。

編譯器爲了優化性能,有時候會改變程序中語句的前後順序,例如程序中:a=6;b=7;編譯器優化後可能變成b=7;a=6;,在這個例子中,編譯器調整了語句的順序,可是不影響程序的最終結果。不過有時候編譯器及解釋器的優化可能致使意想不到的 Bug。

Java中的經典案例,雙重檢查建立單例對象;

public class Singleton {

  static Singleton instance;

  static Singleton getInstance(){

    if (instance == null) {

      synchronized(Singleton.class) {

        if (instance == null)

          instance = new Singleton();

        }

    }
    return instance;
  }
}

看似完美的代碼,其實有問題。問題就在new上。

想象中 new操做步驟:
1,分配一塊內存 M
2,在內存M上 初始化對象
3,把內存M地址賦值給 變量

實際上就行編譯後的順序是:
1,分開一塊內存 M
2,把內存M地址賦值給 變量
3,在 內存M上 初始化對象

優化致使的問題:

雙重檢查建立單例異常的路徑

如圖所示,當線程A執行到第二步的時候,被線程切換了,這時候,instance未初始化實例的對象,而線程B這時候執行到instance == null ?的判斷中,發現instance已經有「值」了,致使了返回了一個空對象的異常。

總結

1,緩存引起的可見性問題
2,切換線程帶來的原子性問題
3,編譯帶來的有序性問題

深入理解這些來龍去脈,能夠診斷大部分併發的問題!

相關文章
相關標籤/搜索