二、聊聊併發 — 深刻理解併發三問題

前言

上篇文章我們已經聊了線程安全,大概瞭解了對線程安全產生影響的重要因素是什麼,我們還聊到了多線程的消息傳遞方式和內存交互方式,正因為這種交互方式使得共享變量在多線程之前存在可見性問題,除此之外還有處理器為了指令優化導致的重排序以及原子操作問題,那這一篇我們就來細聊一下併發的這三個問題,讓大家對併發程序中的重排序、內存可見性以及原子性有一定的瞭解

指令重排序

重排序通常是編譯器或內存系統或者是處理器為了優化程序性能而採取的對指令進行重新排序執行的一種手段。按照程序運行在不同階段,大致可以將重排序分為三種:編譯器優化重排序指令級並行重排序內存系統重排序

  1. 編譯器優化重排序就是通過調整指令順序,在不改變程序語義的前提下,儘可能減少寄存器的讀取、存儲次數,充分複用寄存器的存儲值。
  1. 指令級並行重排序是現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
  1. 內存系統是不會進行重排序的,由於使用緩存和讀/寫緩衝區,這使得操作看上去可能是在亂序執行。

下面我們通過例子分析一下重排序對程序的影響。

<code>public class ConcurrentTest{
   private static int x, y;
   private static int a , b ;
   public static void main(String[] args) throws InterruptedException {
       int i = 0;
       for (; ; )   {
           i++;
           x = 0;
           y = 0;
           a = 0;
           b = 0;
           CountDownLatch latch = new CountDownLatch(1);
           Thread one = new Thread(() -> {
               try {
                   latch.await();
              } catch (InterruptedException e) {
                   e.printStackTrace();
              }
               a = 1;
               x = b;
          });
           Thread other = new Thread(() -> {
               try {
                   latch.await();
              } catch (InterruptedException e) {
                   e.printStackTrace();
              }
               b = 1;
               y = a;
          });
           one.start();
           other.start();
           latch.countDown();
           one.join();
           other.join();
           String result = "第" + i + "次 (" + x + "," + y + ")";
           if (x == 0 && y == 0) {
               System.err.println(result);
               break;
          } else {
               System.out.println(result);
          }
      }
  }  
}
運行結果:

...
...
第445次 (1,0)
第446次 (0,1)
第447次 (1,0)
第448次 (0,1)
第449次 (0,0)/<code>

上述的代碼我們模擬了兩個線程併發的場景,如果按照正常的代碼邏輯是不可能出現x==y==0,但在某一時刻出現了 x == y == 0的情況下,那我們就來分析一下。

二、聊聊併發 —  深刻理解併發三問題

在循環開始的時候我們就設置了x、y、a、b的值,讓它們一直為0,那我們就默認為他們的初始狀態為0,按照我們上面所說,如果兩個指令之間不存在數據依賴關係,編譯器、處理器可能對指令進行優化,調整他們的執行順序,假如這兩個線程的執行的指令都被優化,進行了上圖中的重排序,那麼某一時刻線程A看到 a 的值為 0,所以y = 0;同理,線程B也是同樣的道理,就有可能導致 y == x == 0。

我們前面也提到內存系統不會進行重排序,主要是因為線程A操作完變量以後,B線程不能及時看到A線程對變量修改後的結果,導致操作看上去是亂序的,那我們從內存可見性的角度再來分析一下。

二、聊聊併發 —  深刻理解併發三問題

如上圖所示,線程A、B操作共享變量,需要將共享變量拷貝一份副本到自己的工作內存中,操作結束以後,將修改後的值同步到主內存中,但這個過程中有很多沒辦法預料的結果發生(在不能控制線程的執行順序情況下),假如線程A先執行,已經修改了變量b的值,但還沒有同步到主內存中,此時線程B從主內存中讀取的變量b的值還是0,最後x==y==0。假如A、B線程同時執行,都從主內存讀取到變量的值,操作完成以後,將值同步到的主內存中,此時同步以後主內存中還是 x == y == 0。所以說,當沒有任何其他措施的情況下,多線程去操作共享變量時,線程的執行順序完全由處理器進行調度,產生的結果也是不可預知的。

內存可見性

在我看來內存可見性可以換一種說法,用數據一致性這個詞來代替可能會比較好理解一些。作為軟件開發人員,我們可能對數據一致性這個詞比較敏感一點,數據一致性問題也是我們經常會遇到的。例如Redis作為緩存和數據庫之間的數據一致性,計算機硬件架構中高速緩衝區域和系統主內存之間的數據一致性。感興趣的朋友可以去看看高速緩衝區是什麼 CPU高速緩衝區,其中提到了一個概念:緩存一致協議,也就是我們所說的數據一致性。想對緩存一致協議瞭解一下的同學可以看這裡:緩存一致協議。等到我們瞭解了Java內存模型,我們會感覺到其實緩存一致協議和Java內存模型有很多相像之處。

看完上述所說的,你心裡可能也許大概有一點明白了。Java作為高級語義,能夠跨平臺運行在不同的操作系統上,其實是通過虛擬機屏蔽了這些底層細節,定義了一套自己讀寫內存數據的規範,讓開發人員不再需要關心硬件或操作系統中的緩存、內存交互問題。但是也抽象了主內存和本地內存的概念:所有的共享變量存在於主內存中,每個線程有自己的本地內存,線程讀寫共享數據也是通過本地內存交換的,所以可見性問題依然是存在的。我們這裡說的本地內存並不是真的是一塊給每個線程分配的內存,而是 JMM 的一個抽象,是對於寄存器、一級緩存、二級緩存等的抽象。但是內存模型也給出瞭解決這些問問題的辦法,我們後面再詳細的聊一聊。

原子性

原子操作即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

在Java併發編程中,如果對一個共享變量的操作不是原子操作,那有可能得不到你想要的結果。當多個線程訪問同一個共享變量,且對共享變量的操作不是原子操作,那可能存在一個線程執行這個操作執行一半,另一個線程也執行這個非原子的操作,這樣就會導致兩個線程執行結果有誤。我們通過例子來看一下。

<code>public class ConcurrencyTest {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread, "A");
Thread thread1 = new Thread(myThread, "B");
Thread thread2 = new Thread(myThread, "C");
Thread thread3 = new Thread(myThread, "D");
Thread thread4 = new Thread(myThread, "E");
thread.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}

class MyThread implements Runnable {
private volatile int count = 0;
public void run() {
//synchronized (this) {
count++;
System.out.println("線程" + Thread.currentThread().getName() + "計算,count=" + count);
//}
}
}
運行結果:
線程A計算,count=2
線程E計算,count=5
線程D計算,count=4
線程C計算,count=3
線程B計算,count=2/<code>

對此我們就不在這裡對原子性展開探討了,我會在後面的文章中詳細的來說一下Java中的原子操作問題。

總結

這裡主要聊了一下併發編程中重排序、原子性、可見性對其帶來的影響,也讓我們對此併發編程線程安全問題有了進一步認識。那下一篇文章我會來詳細的聊一聊如何解決這些線程安全的問題,以及內存模型對此的解決方案。


分享到:


相關文章: