大家都知道,線程會存在安全性問題,那接下來我們從原理層面去了解線程爲
什麼會存在安全性問題,並且我們應該怎麼去解決這類的問題。
其實線程安全問題可以總結爲: 可見性、原子性、有序性這幾個問題,我們搞
懂了這幾個問題並且知道怎麼解決,那麼多線程安全性問題也就不是問題了
CPU 高速緩存
線程是 CPU 調度的最小單元,線程涉及的目的最終仍然是更充分的利用計算機
處理的效能,但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,處
理器還需要與內存交互,比如讀取運算數據、存儲運算結果,這個 I/O 操作是
很難消除的。而由於計算機的存儲設備與處理器的運算速度差距非常大,所以
現代計算機系統都會增加一層讀寫速度儘可能接近處理器運算速度的高速緩存
來作爲內存和處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運
算能快速進行,當運算結束後再從緩存同步到內存之中。
高速緩存從下到上越接近 CPU 速度越快,同時容量也越小。現在大部分的處理
器都有二級或者三級緩存,從下到上依次爲 L3 cache, L2 cache, L1 cache. 緩
存又可以分爲指令緩存和數據緩存:
指令緩存用來緩存程序的代碼,
數據緩存用來緩存程序的數據
L1 Cache:一級緩存,本地 core 的緩存,分成 32K 的數據緩存 L1d 和 32k 指
令緩存 L1i,訪問 L1 需要 3cycles,耗時大約 1ns;
L2 Cache:二級緩存,本地 core 的緩存,被設計爲 L1 緩存與共享的 L3 緩存
之間的緩衝,大小爲 256K,訪問 L2 需要 12cycles,耗時大約 3ns;
L3 Cache:三級緩存,在同插槽的所有 core 共享 L3 緩存,分爲多個 2M 的
段,訪問 L3 需要 38cycles,耗時大約 12ns;
緩存一致性問題
CPU-0 讀取主存的數據,緩存到 CPU-0 的高速緩存中,CPU-1 也做了同樣的事情,而 CPU-1 把 count 的值修改成了 2,並且同步到 CPU-1 的高速緩存,但是這個修改以後的值並沒有寫入到主存中,CPU-0 訪問該字節,由於緩存沒有更新,所以仍然是之前的值,就會導致數據不一致的問題
引發這個問題的原因是因爲多核心 CPU 情況下存在指令並行執行,而各個
CPU 核心之間的數據不共享從而導致緩存一致性問題,爲了解決這個問題,
CPU 生產廠商提供了相應的解決方案
總線鎖
當一個 CPU 對其緩存中的數據進行操作的時候,往總線中發送一個 Lock 信
號。其他處理器的請求將會被阻塞,那麼該處理器可以獨佔共享內存。總線鎖
相當於把 CPU 和內存之間的通信鎖住了,所以這種方式會導致 CPU 的性能下
降,所以 P6 系列以後的處理器,出現了另外一種方式,就是緩存鎖
緩存鎖
如果緩存在處理器緩存行中的內存區域在 LOCK 操作期間被鎖定,當它執行鎖
操作回寫內存時,處理不在總線上聲明 LOCK 信號,而是修改內部的緩存地
址,然後通過緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻
止同時修改被兩個以上處理器緩存的內存區域的數據,當其他處理器回寫已經
被鎖定的緩存行的數據時會導致該緩存行無效。
所以如果聲明瞭 CPU 的鎖機制,會生成一個 LOCK 指令,會產生兩個作用
Lock 前綴指令會引起引起處理器緩存回寫到內存,在 P6 以後的處理器中,LOCK 信號一般不鎖總線,而是鎖緩存
一個處理器的緩存回寫到內存會導致其他處理器的緩存無效
緩存一致性協議
處理器上有一套完整的協議,來保證 Cache 的一致性,比較經典的應該就是
MESI 協議(梅西協議)了,它的方法是在 CPU 緩存中保存一個標記位,這個標記爲有四種狀態:
M(Modified) 修改緩存,當前 CPU 緩存已經被修改,表示已經和內存中的數據不一致了
I(Invalid) 失效緩存,說明 CPU 的緩存已經不能使用了
E(Exclusive) 獨佔緩存,當前 cpu 的緩存和內存中數據保持一直,而且其他處理器沒有緩存該數據
S(Shared) 共享緩存,數據和內存中數據一致,並且該數據存在多個 cpu緩存中
每個 Core 的 Cache 控制器不僅知道自己的讀寫操作,也監聽其它 Cache 的讀
寫操作,嗅探(snooping)"協議
CPU 的讀取會遵循幾個原則:
如果緩存的狀態是 I(失效緩存),那麼就從內存中讀取,否則直接從緩存讀取
如果緩存處於 M 或者 E 的 CPU 嗅探到其他 CPU 有讀的操作,就把自己的緩存寫入到內存,並把自己的狀態設置爲 S
只有緩存狀態是 M 或 E 的時候,CPU 纔可以修改緩存中的數據,修改後,緩存狀態變爲 MC
CPU 的優化執行
除了增加高速緩存以爲,爲了更充分利用處理器內內部的運算單元,處理器可
能會對輸入的代碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果
充足,保證該結果與順序執行的結果一直,但並不保證程序中各個語句計算的
先後順序與輸入代碼中的順序一致,這個是處理器的優化執行;還有一個就是
編程語言的編譯器也會有類似的優化,比如做指令重排來提升性能。
併發編程的問題
前面說的和硬件有關的概念你可能聽得有點蒙,還不知道他到底和軟件有啥關
系,其實原子性、可見性、有序性問題,是我們抽象出來的概念,他們的核心
本質就是剛剛提到的緩存一致性問題、處理器優化問題導致的指令重排序問
題。
比如緩存一致性就導致可見性問題、處理器的亂序執行會導致原子性問題、指
令重排會導致有序性問題。爲了解決這些問題,所以在 JVM 中引入了 JMM 的
概念
內存模型
內存模型定義了共享內存系統中多線程程序讀寫操作行爲的規範,來屏蔽各種
硬件和操作系統的內存訪問差異,來實現 Java 程序在各個平臺下都能達到一致
的內存訪問效果。Java 內存模型的主要目標是定義程序中各個變量的訪問規
則,也就是在虛擬機中將變量存儲到內存以及從內存中取出變量(這裏的變
量,指的是共享變量,也就是實例對象、靜態字段、數組對象等存儲在堆內存
中的變量。而對於局部變量這類的,屬於線程私有,不會被共享)這類的底層
細節。通過這些規則來規範對內存的讀寫操作,從而保證指令執行的正確性。
它與處理器有關、與緩存有關、與併發有關、與編譯器也有關。他解決了 CPU
多級緩存、處理器優化、指令重排等導致的內存訪問問題,保證了併發場景下
的可見性、原子性和有序性,。內存模型解決併發問題主要採用兩種方式:限
制處理器優化和使用內存屏障。
Java 內存模型定義了線程和內存的交互方式,在 JMM (Java Memory Model)抽象模型中,分爲主內存、工作內存。主內存是所有線程共享的,工作內存是每個線程獨有的。線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,不能直接讀寫主內存中的變量。並且不同的線程之間無法訪問對方工作內存中的變量,線程間的變量值的傳遞都需要通過主內存來完成,他們三者的交互關係如下:
所以,總的來說,JMM 是一種規範,目的是解決由於多線程通過共享內存進行
通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會
對代碼亂序執行等帶來的問題。目的是保證併發編程場景中的原子性、可見性
和有序性。
由於邊寫是在電腦上,所以可能會導致手機端看起來排版不太美觀,後期我會把這一塊優化一下,儘量做到兼容
參考:Java併發編程的藝術