第十二章——Java內存模型與線程

這一章將瞭解虛擬機Java內存模型的結構及操作;原子性、可見性、有序性在Java內存模型中的體現;介紹了先行發生原則的規則及使用,瞭解線程在Java中是如何實現的

硬件的效率與一致性

  硬件的多線程問題是什麼呢?現在的CPU速度很快,但是內存的讀寫速度很慢,所以CPU和內存之間加了一些高速讀寫的內存。每個CPU都有自己的高速內存,如果多個CPU同時處理同一塊主內存區域,就有可能各個CPU放在各自的高速內存中的數據不一樣,這個時候主內存應該聽誰的呢?於是規定了一些協議,說明了這種時候怎麼辦。

Java內存模型

  和硬件上多個CPU的情況類似,Java虛擬機也有主工作內存和屬於各個線程的工作內存(這個內存劃分和剛開始那章講的內存劃分不是一個層面的)。如果工作內存要用主內存中的變量,那麼它就要拷貝一份到自己的內存。就算是volatile變量也一樣。而且不同的工作內存之間不能直接訪問,必須通過主內存。

1. 內存間交互操作

  • lock(鎖定):作用於主內存變量,把一個變量標識爲一條線程獨佔的狀態。
  • unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來。
  • read(讀取):作用於主內存變量,把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作調用。
  • load(載入):作用於工作內存變量,把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存變量,把工作內存中的一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存變量,把一個從執行引擎接收到的值賦值給工作內存變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存變量,把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。
  • write(寫入):作用於主內存變量,把store操作從工作內存中得到的變量值放入主內存變量中。

  Java內存模型還規定了在執行上述八種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現。
  • 不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
  • 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,也就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
  • 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
  • 如果一個變量事先沒有被lock操作鎖定,則不允許對他執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
  • 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)。

2. 對於volatile型變量的特殊規則

  • 可見性,volatile變量在某個線程修改之後,其它的線程很快就會知道這件事,而普通的變量是做不到這一點的。雖然很快,但是也需要一定的時間(需要store、write、read、load等一系列操作才行,所以用它的時候要注意,並不是一般意義上的一個鎖,強調對其它線程的可見性),這是java虛擬機最輕量級的同步機制。
  • 禁止指令重排序優化,程序執行的時候是有指令重排這個機制的,爲了提高效率嘛。指令重排只能保證同一個線程裏面,你用到這個變量的時候這個變量的結果是正確的。但是不同的線程中使用同一個變量的時候,發生指令重排就不好了。賦值語句提前被執行的話,會導致使用它的線程產生錯誤結果。使用volatile可以禁止指令重排(在JDK1.5才完全做到真正的禁止)。
      上述的兩個volatile變量的特點,是由如下的特殊規則決定的(T表示一個線程,V和W分別表示兩個volatile變量,這個規則書上有個比較精確的描述,我就大概謝謝他是啥意思吧):
  1. 線程每次使用volatile變量之前(use前一條必須是load,load後一條必須是use),必須從主內存刷新最新的值。
  2. 線程每次修改完volatile之後(store前一條必須是assign,assign後一條必須是store),必須立刻同步到主內存。
  3. 保證代碼執行順序與程序順序相同。(T1:A->F->P,T2:B->G->Q,這些字母表示線程T1和T2分別對V和W的一系列操作,如果A在B前面,那麼P就在Q前面,保證這個順序。PS:A是use或assign,F是load或store,P是read和write)。

3. 對於long和double型變量的特殊規則

  Java內存模型要求八個基本操作都要有原子性,但是對於64位數據類型的read、load、store、write四個操作可以不保證原子性。規範允許虛擬機這麼做,但是又強烈建議把64位數據類型的讀寫操作作爲原子操作,所以寫代碼時不需要將用到的long和double專門聲明爲volatile。

4. 原子性、可見性與有序性

  Java內存模型是圍繞着在併發過程中如何處理原子性、可見性和有序性這三個特徵來建立的。

  • 原子性:由Java內存模型直接保證的原子性變量操作包括read、load、assign、use、store和write這六個,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的(long和double的非原子性協定例外,但是幾乎不會發生)。如果需要更大範圍的原子性,Java內存模型提供了lock和unlock操作來滿足。當然不能直接調用lock和unlock操作,但是虛擬機提供了更高層次的字節碼指令monitorenter和monitorexit來隱式的使用這兩個操作,反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
  • 可見性:是指變量被修改之後其他的線程可以看到,volatile變量是通過特殊的規則(改完必須同步,要讀取必須從主內存讀取)保證了可見性;synchronized是通過unlock之前必須將變量同步到主內存這條規則保證了可見性;final關鍵字則是因爲常量不變,自然可見了。
  • 有序性:只有一個線程,那自然是有序的(就算有指令重排也是有序的);但是因爲“指令重排”和“工作內存與主內存同步延遲”的原因,導致其它線程觀察它時,它是無序的。

  以上是併發的三種重要特性,可以注意到,這個synchronized哪裏都有,很萬能🤨,但是萬能有萬能的問題。

5. 先行發生原則

  Java語言的“先行發生”原則是Java內存模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。

  Java內存模型下有一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協作就已經存在。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們進行隨意的重排序。

  • 程序次序規則:在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。
  • 管程鎖定規則:一個unlock操作先行發生於後面(時間上的後面)對同一個鎖的lock操作。
  • volatile變量規則:對一個volatile變量的寫操作先行發生於後面(時間上的後面)對這個變量的讀操作。
  • 線程啓動規則:Thread對象的start方法先行發生於此線程的每一個動作。
  • 線程終止規則:線程中的所有操作都先行發生於對此線程的終止檢測。
  • 線程中斷規則:對線程interrupt方法的調用先行發生於被中斷線程的代碼檢測到中斷時間的發生。
  • 對象終結原則:一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

  “先行發生原則”和時間上的先後順序並沒有太大的關係,先調用的方法不一定先執行,衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則爲準。

Java與線程

1. 線程的實現

  不同的操作系統,對線程的實現是不一樣的,主要有三種方式:使用內核線程實現、使用用戶線程實現、使用用戶線程加輕量級進程混合實現。

  • 使用內核線程實現
      直接由操作系統內核支持的線程,由內核來完成線程切換,內核通過操縱調度器對線程進行調度,並負責將線程的任務映射到各個處理器上。

  程序一般不會直接去使用內核線程,而是使用內核線程的一種高級接口——輕量級進程(也就是通常意義的線程),每個輕量級進程都由一個內核線程支持,是1:1的關係。

  這種線程基於內核線程實現,各種操作需要進行系統調用,要在用戶態和內核態來回切換,代價較大,每個線程都要有一個內核線程支持,需要消耗一定的內核資源,因此一個系統支持輕量級進程的數量是有限的。

  • 使用用戶線程實現
      廣義上一個線程只要不是內核線程,就可以認爲是用戶線程,所以輕量級進程也可以算是用戶線程。狹義上講,用戶線程是完全建立在用戶空間的線程庫上,系統內核不能感知到線程存在。用戶線程的建立、同步、銷燬和調度完全在用戶態中完成,不需要內核的幫助,也就不需要切換到內核態,所以非常快,是一種1:N關係的實現。

  這種實現優勢是不需要系統內核,劣勢也是沒有系統內核支持,自己實現比較複雜,“阻塞如何處理”、“多處理器系統如何將線程映射到其他處理器上”這類問題解決起來異常困難,現在很少見了。

  • 混合實現
      既存在用戶線程,也存在輕量級進程,用戶線程還是完全建立在用戶空間中,所以線程切換快,沒有限制,而且還能使用內核對多線程的支持。是一種M:N的關係,許多Unix系列的操作系統,如Solaris、HP-UX等都提供了這種實現。
  • Java線程實現
      Java在JDK1.2之前是基於名爲“綠色線程”的用戶線程模型實現的,在JDK1.2中,替換成基於操作系統的原生線程模型來實現。

  對Sun JDK來說,Windows和Linux都是一對一的線程模型,一條Java線程就映射到一條輕量級進程中。而Solaris平臺中可以同時支持一對一和多對多的線程模型,可以通過虛擬機參數設置。

2. Java線程調度

  線程調度是指系統爲線程分配處理器使用權的過程,分爲協同式線程調度和搶佔式線程調度。

  • 協同式線程調度,線程的執行時間有線程本身來控制,線程把自己的工作執行完了要主動通知系統切換到另外一個線程上去。實現簡單,沒有同步問題,但是線程執行時間不可控,可能會有線程長期佔用CPU不讓出。
  • 搶佔式線程調度,每個線程將由系統來分配執行時間(Java中Thread.yield()可以讓出執行時間)。

3. 狀態轉換

  Java語言定義了5種進程狀態:

  1. 新建:創建後尚未啓動。
  2. 運行:Runable包含了操作系統線程狀態中的Running和Ready。
  3. 無期限等待:需要等待其他線程顯式的喚醒,否則不會被分配CPU時間。沒有Timeout參數的Object.wait方法和Thread.join方法,LockSupport.park()方法。
  4. 期限等待:一定時間後會自動喚醒的狀態,也不會被分配CPU時間。Thread.sleep方法,設置了Timeout參數的Object.wait方法和Thread.join方法,LockSupport.parkNanos方法,LockSupport.parkUntil方法。
  5. 阻塞:進程被阻塞了,“阻塞狀態”和“等待狀態”的區別是:“阻塞狀態”在等待着獲取到一個排他鎖,這個事件將在另外一個線程放棄這個鎖的時候發生;而“等待狀態”則在等待一段時間或者等待喚醒動作發生。在線程等待進入同步區域的時候會發生阻塞。
  6. 結束:已終止線程的狀態。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章