之前在學習 C++ 關鍵字 volatile 的時候,看過阿里數據庫大牛何登成關於 volatile 的文章《C/C++ volatile關鍵詞深度剖析》,看的雲裏霧裏。主要是當時沒理解什麼是可見性、原子性和有序性;沒有理解什麼是內存模型及一些規範。相信不少初學者和我同樣,在學習 Java 併發編程書籍的時候都直奔 volatile 、synchronized、wait/notify 的主題去了,其實這是不對的。好比在看《Java 高併發程序設計》這本書的時候請必定不要跳過第 1/2 章。最近看了極客時間上併發編程專欄的文章後,有種醍醐灌頂的感受,一下讓我想通了不少在學習 C++ 時不理解的知識點。加上整理,將本身理解的一些知識點作個串聯並記錄下來,爲併發編程作準備。文章可能有點長,但內容比較簡單,請耐心看完。java
在 CPU 性能低、內存小、硬盤貴的年代,別說多線程就是單線程能正常跑完都謝天謝地了,因此那時候大部分程序都是串行的。然而隨着硬件的發展,CPU 的性能愈來愈高、核數愈來愈多、內存愈來愈大、磁盤也愈來愈便宜。爲了在程序中充分利用計算機硬件,特別是那些寶貴的資源(如 CPU ),單線程/單進程已經不能知足要求了,便開始漸漸出現了併發程序。併發程序一方面能夠充分利用計算機資源,另外一方面能夠更快的響應用戶,一箭雙鵰,何樂而不爲。但新的世界大門打開,它有光,也必有黑暗。學習併發編程,咱們能夠在新的世界裏遨遊;理解併發編程,咱們能夠避開新世界裏的黑暗。mysql
在討論併發編程的時候咱們更多的是討論同一份代碼的程序併發,而不是不一樣代碼程序之間的併發。前者是程序員須要解決的問題,後者是操做系統須要解決的問題。併發編程經常使用的有幾種方式:多進程和多線程以及他們的組合。在用 C++ 編程的時候還會考慮到多進程,而在 Java 編程的時候徹底只考慮多線程這種方式了。這樣的選擇也是有道理的,畢竟操做系統對線程的切換比進程的切換消耗的資源少、速度快。進程與線程的區別每一個被面試過的程序員都應該倒背如流了,這裏不在贅述,本段要強調的是後續的討論都是基於單進程的多線程併發編程。程序員
IO 密集型和 CPU 密集型這兩個名詞相信你們都不陌生,前者是指程序執行過程當中 IO操做(磁盤讀寫、網絡讀寫)會多一些,好比 mysql 進行數據讀寫;後者是指執行過程當中 CPU 使用會多一些,好比 matlab 作矩陣乘法。能夠說,全部程序在執行過程當中不是在使用 CPU 就是在進行 IO 操做,固然還有休眠的時候。正是由於此,纔有了併發的可能性。想象一下,若是某臺機器上的全部程序都只使用 CPU 或者只進行 IO,如何併發?好比 while(true){} 這樣的程序!面試
咱們知道 CPU 是計算機的大腦,理論上在進行任何操做的時候都須要 CPU ,那爲啥 CPU 密集型和 IO 密集型能分開討論呢?這不得不提一下 DMA(Direct Memory Access)技術,它讓 IO 操做只在開始和結束的時候須要使用下 CPU ,其餘時間 CPU 能夠幹其餘事情。它讓 CPU 和 IO 並行工做成了可能。因此程序能進行併發的前提有以下兩點:sql
併發編程難主要有如下幾點緣由:數據庫
都說打蛇打七寸,學習併發編程也要把握住其關鍵技術。但學習併發編程關鍵點的前提是弄懂這些關鍵技術都是爲了解決什麼問題以及怎麼解決的,這樣才能學的更快、記得更牢。多線程併發編程關鍵技術點都是圍繞可見性、原子性和有序性創建的。掌握了可見性、原子性和有序性的定義和觸發問題的場景,在定位併發 BUG 時便有跡可循,在學習併發編程時也遊刃有餘。編程
可見性(Visibility):一個線程對共享變量的修改,另一個線程能馬上看到,咱們稱之爲可見性。對串行程序來講可見性問題是不存在,但併發程序就不必定了,其中一種可能以下:api
CPU 執行指令的速度是內存讀寫速度的數十倍甚至上百倍,若是 CPU 每執行一條讀寫指令都去內存中讀寫數據的話,那 CPU 的性能就被大大浪費了。學過操做系統的都知道,爲了解決這個問題硬件工程師在 CPU 和內存之間加入了高速緩存,CPU 執行讀寫指令時將數據讀入緩存,並將執行結果存入緩存,當運算結束後再在某個時候將結果同步到內存。這樣作好處多多,但會引入新的問題:緩存一致性。 緩存
原子性(Atomicity):一個或者多個操做在 CPU 執行的過程當中不被中斷的特性,咱們稱之爲原子性。原子這個詞程序員並不陌生,由於數據庫中也有原子的概念。從化學角度來看,原子是不可再被分割的基本微粒。回到計算機的世界,不少不了解底層程序員覺得高級語言的每條語句都是一個原子操做,其實否則,好比簡單的賦值操做在 C++ 中以上代碼至少須要三條 CPU 指令。網絡
若是是單線程串行即便一個語句分紅多條指令執行,也不會存在原子性問題。而在併發程序就不必定了。Java 的 long 類型是 8 字節的,在 32 系統上,該類型變量的讀寫就須要兩次操做內存(即兩條虛擬機指令),而 Java 虛擬機規範又容許兩次虛擬機指令操做是非原子的。這樣機會出現如下場景:
這樣 i 讀出來是一個拼接後錯誤值,出現原子性問題。具體見《深刻理解Java虛擬機:JVM高級特性與最佳實踐(第2版)》12.3.4-對於long和double型變量的特殊規則這一章節。
有序性(Ordering):即程序執行的順序性。咱們老是覺得代碼是從前日後依次執行的,在單線程狀況下確實是這樣。但在併發程序中可能就會出現亂序,從而致使有序性問題。一句話總結爲:若是在本線程內觀察,全部的操做都是有序的;若是在一個線程中觀察另外一個線程,全部的操做都是無序的。舉個栗子,以下代碼:
class OrderingExample {
int x = 0;
boolean flag = false;
public void writer() {
x = 42; //宇宙的終極答案
flag = true;
}
public void reader() {
if (flag == true) {
//x = ?
}
}
}
複製代碼
在單線程中 x 確定等於 42,而在併發程序中,x 不必定等於 42,也有多是 0。因爲編譯器優化、指令重排等可能致使以上代碼在被執行時是這樣:
class OrderingExample {
int x = 0;
boolean flag = false;
public void writer() {
flag = true;
x = 42; //宇宙的終極答案
}
public void reader() {
if (flag == true) {
x = ?
}
}
}
複製代碼
當線程 1 剛執行到 writer 中的 flag = true ,準備接着執行 x = 42 時,cpu 切換到線程 2 執行reader 中的 if(flag==true),並進入 x = ?代碼,此時 x = 0,出現有序性問題。
因爲可見性、原子性和有序性致使併發程序在讀寫內存中共享變量存在種種問題,那該如何解決呢?這就是內存模型須要作的事:內存模型定義了共享內存系統中多線程程序讀寫操做行爲的規範。經過這些規則來規範對內存的讀寫操做,從而保證指令執行的正確性。它與處理器有關、與緩存有關、與併發有關、與編譯器也有關。它解決了CPU多級緩存、處理器優化、指令重排等致使的內存訪問問題,保證了併發場景下的可見性、原子性和有序性。
內存模型只是一種模型也即一種規範,每種語言有着本身的具體的實現細節,Java 有 Java 的內存模型 JMM,C++ 有着 C++ 的內存模型(依賴操做系統的內存模型)。這裏須要注意的是內存模型與對象模型的區別,在學習 C++ 的時候有本比較出名的書《深刻理解 C++ 對象模型》它講的是 C++ 對象在內存中如何佈局的,跟 C++ 內存模型徹底是兩碼事,Java 中同理。同時還要區份內存模型與內存結構的區別,不少人容易混淆這兩個概念,不信百度下內存模型,不少講的都是堆棧之類的。而堆棧、靜態區對應的應該是內存結構。
原本不想加這段的,由於前傳只想寫一些與語言無關的知識點。但爲了提醒下 Java 程序員,加上了這段。咱們都知道 C++ 程序是不可移植的,主要由於它的編譯與操做系統息息相關。而 Java 不同,它能作到真正的 write once,run everywhere,主要是由於它有 Java 虛擬機(Java Vitual Machine,JVM)。JVM 在操做系統上作了一層抽象,屏蔽了操做系統層面的細節。對於 Java 程序來講,JVM 就是它的操做系統,因此操做系統中的不少概念都直接搬到了 JVM 中,好比進程/線程、IO 操做等,大多時候不少書籍都不對其進行區分,由於這些 api 大部分狀況下都是 JVM 調用 native 方法實現的。但有些概念卻有所不一樣,好比虛擬機指令、虛擬機程序計數器、主內存與工做內存、JMM 等,可能由於這些實現與操做系統不大同樣。這裏作個對照,在 Java 虛擬機中:虛擬機指令對應 CPU 指令;主內存對應物理內存;工做內存對應 CPU 緩存。
本篇文章講的併發編程內容與語言無關,更多的是學習併發編程的一些前置概念知識,是我我的的一些理解。但願對你有所幫助,錯誤的地方還請多多指教。記得關注公衆號哦,記錄着一個 C++ 程序員轉 Java 的學習之路。