Java併發編程問題出現的原因?

簡介

從事java web後端開發,尤其是toc平臺都必須要用到多線程併發,而能夠高效地、正確使用併發編程也是一件比較有挑戰的事情,也很能體現一個程序員的水平,同時去查找多線程併發問題,通常也是一件及其困難的事情,一些bug很詭異,通常並不能快速、重複的捕捉到,這就需要我們對併發的原理及本質有深入的瞭解,能夠追本溯源。這裏就介紹下併發出現的問題及原因。

併發背景

我們知道程序在運行,cpu需要從內存、磁盤讀取程序及數據,儘管多年來硬件設備不斷迭代更新,這三個模塊處理速度始終存在無法跨越的速度鴻溝,cpu使用效率會受到內存和磁盤的制約,因此爲了提高cpu的利用率,計算機系統結構、操作系統、編譯系統等組件都進行了相應的工作,具體表現如下:

  • cpu增加高速緩存,均衡與內存之間速度差異
  • 編譯程序對指令執行次序,使得緩存能夠得到更加合理地利用
  • 操作系統增加了進程、線程,對cpu時間進行分片,進而可以均衡cpu與I/o設備的速度差異。
    這些發揮cpu利用率的做法,併發程序問題的根源也是來自於此。
    接下來講下併發有序性、可見性、原子性問題

有序性問題

有序性指的是程序按照代碼的先後順序執行。編譯器爲了優化性能,有時候會改變程序中語句的先後順序,一眼看過去編譯器調整了語句的執行順序並不會影響程序的執行結果,如:
a=3;b=4,調整爲b=4;a=3。但是在併發多線程情況下這種編譯器改變程序的順序會帶來意想不到的結果。
java 領域一個經典的案例就是利用雙重檢查創建單例對象:

主要原因在new 操作上,正常 new 操作應該是:

  1. 分配一塊內存 M;
  2. 在內存 M 上初始化 Singleton 對象;
  3. 然後 M 的地址賦值給 instance 變量。
    但是實際上優化後的可能執行路徑是這樣的:
  4. 分配一塊內存 M;
  5. 將 M 的地址賦值給 instance 變量;
  6. 最後在內存 M 上初始化 Singleton 對象。
    這是後會帶來如下問題:
    我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上;如果此時線程 B 也執行 getInstance() 方 法,那麼線程 B 會發現instance != null,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空 指針異常。
    在這裏插入圖片描述

可見性問題

一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱爲可見性。在單核cpu情況下多個線程都是在同一個cpu緩存進行數據操作,所以一個線程對緩存的寫,對另外一個線程來 說一定是可見的。當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存,這個時候兩個線程可能在不同的核心上操作,同一個變量分別保存到不同的cpu緩存當中,線程 A 對變量 的操作對於線程 B 而言就不具備可見性了。

在這裏插入圖片描述

原子性問題

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱爲原子性。cpu能保證的原子操作是指令級別的,而不是高級語言中的一條語句,一條高級語言的語句會拆分成多條cpu指令執行,如count+1,實際上對應至少三條指令,如:
指令 1:首先,需要把變量 count 從內存加載到 CPU 的寄存器;
指令 2:之後,在寄存器中執行 +1 操作;
指令 3:最後,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。
操作系統做任務切換,可以發生在任何一條CPU 指令執行完,而不是整條java語句。如下圖如果兩個線程都同時去做count+=1操作如下情況可能會出現:
在這裏插入圖片描述
這種情況就導致併發bug問題。

總結

通過可見性性、原子性、有序性幾個問題闡述:提到緩存導致的可見性問題,線程切換帶來 的原子性問題,編譯優化帶來的有序性問題,其實這些緩存、線程、編譯優化的目的都是提高程序性能。但是在使用過程中稍不注意會 帶來另外一個問題,如果能夠深刻理解可見性、 原子性、有序性在併發場景下的原理,很多併發 Bug 都是可以理解、可以診斷的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章