要點提煉|理解JVM之內存模型&線程

本篇將介紹虛擬機如何實現多線程、多線程之間由於共享和競爭數據而導致的一系列問題及解決方案。

  • 概述
  • Java內存模型
  • Java與線程

1.概述

a. 多任務處理 的必要性:

  • 充分利用計算機處理器的能力,避免處理器在磁盤I/O、網絡通信或數據庫訪問時總是處於等待其他資源的狀態。
  • 便於一個服務端同時對多個客戶端提供服務。通過指標 TPS (Transactions Per Second)可衡量一個服務性能的高低好壞,它表示每秒服務端平均能響應的請求總數,進而體現出程序的併發能力。

b.硬件的效率與一致性

為了更好的理解Java內存模型,先理解物理計算機中的併發問題,兩者有很高的可比性。

為了平衡計算機的存儲設備與處理器的運算速度之間幾個數量級的差距,引入一層 高速緩存(Cache)來作為內存與處理器之間的緩衝:

  • 將運算需要使用到的數據複製到緩存中,讓運算能快速進行;
  • 當運算結束後再從緩存同步回內存之中,而無須讓處理器等待緩慢的內存讀寫。

但是基於高速緩存的存儲交互在多處理器系統中會帶來 緩存一致性 (Cache Coherence)的問題。這是因為每個處理器都有自己的高速緩存,而它們又共享同一 主內存 (Main Memory),當多個處理器的運算任務都涉及同一塊主內存區域時,就可能導致各自的緩存數據不一致。解決辦法就是需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作。如下圖。

要點提煉|理解JVM之內存模型&線程

因此,這裡所說的 內存模型 可以理解為:在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。

2. Java內存模型 (Java Memory Model,JMM)

a. 目的 :屏蔽掉各種硬件和操作系統的內存訪問差異,實現Java程序在各種平臺下都能達到一致的內存訪問效果。

b. 方法 :通過定義程序中各個 變量訪問規則 ,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。

注意:這裡的變量與Java中說的變量不同,而指的是實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因為後者是線程私有的,不會被共享,自然就不會存在競爭問題。

c. 結構

:模型結構如圖,和上張圖進行類比。

要點提煉|理解JVM之內存模型&線程

  • 主內存 (Main Memory):所有變量的存儲位置。直接對應於物理硬件的內存。

注意:這裡的主內存、工作內存與 要點提煉| 理解JVM之內存管理 說的Java內存區域中的Java堆、棧、方法區等並不是同一個層次的內存劃分。

  • 工作內存 (Working Memory):每條線程還有自己的工作內存,用於保存被該線程使用到的變量的主內存副本拷貝。為了獲取更好的運行速度,虛擬機可能會讓工作內存優先存儲於寄存器和高速緩存中。

注意:

  • 線程對變量的所有操作都 必須 在工作內存中進行,而不能直接讀寫主內存中的變量。
  • 不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞
    必須 通過主內存來完成。
  • 交互協議 :用於規定一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節。共有8種操作:
  • ①用於主內存變量:
  • 鎖定 ( lock ):把變量標識為一條線程獨佔的狀態。
  • 解鎖 ( unlock ):把處於鎖定狀態的變量釋放出來。
  • 讀取 ( read ):把變量的值從主內存傳輸到線程的工作內存中,以便隨後的 load 動作使用。
  • 載入 ( load ):把 read 操作從主內存中得到的變量值放入工作內存的變量副本中。
  • ②用於工作內存變量:
  • 使用 ( use ):把工作內存中一個變量的值傳遞給執行引擎。
  • 賦值 ( assign ):把從執行引擎接收到的值賦給工作內存的變量。
  • 存儲 ( store ):把工作內存中變量的值傳送到主內存中,以便隨後的 write 操作使用。
  • 寫入 ( write ):把 store 操作從工作內存中得到的變量的值放入主內存的變量中。

結論:注意是順序非連續

  • 如果要把變量從主內存 複製 到工作內存,那就要 順序 地執行 read 和 load 。
  • 如果要把變量從工作內存 同步 回主內存,就要 順序 地執行 store 和 write 。

d.確保併發操作安全的 原則

①在Java內存模型中規定了執行上述8種基本操作時需要滿足如下規則:

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

可見這麼多規則非常繁瑣,實踐也麻煩,下面再介紹一個等效判斷原則--先行發生原則。

先行發生原則 :是Java內存模型中定義的兩項操作之間的偏序關係。下面例舉一些“天然的”先行發生關係,無須任何同步器協助就已經存在,可以在編碼中直接使用。

  • 程序次序規則 (Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。
  • 管程鎖定規則 (Monitor Lock Rule):一個unlock操作先行發生於後面對 同一個 鎖的lock操作。
  • volatile變量規則
    (Volatile Variable Rule):對一個volatile變量的 操作先行發生於後面對這個變量的 操作。
  • 線程啟動規則 (Thread Start Rule):Thread對象的 start() 先行發生於此線程的每一個動作。
  • 線程終止規則 (Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測.可通過 Thread.join() 結束、 Thread.isAlive() 的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則 (Thread Interruption Rule):對線程 interrupt() 的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。可通過 Thread.interrupted() 檢測到是否有中斷髮生。
  • 對象終結規則 (Finalizer Rule):一個對象的初始化完成先行發生於它的 finalize() 的開始。
  • 傳遞性 (Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A一定先行發生於操作C。

e.Java內存模型保證併發過程的原子性、可見性和有序性的措施:

  • 原子性 (Atomicity):一個操作要麼都執行要麼都不執行。
  • 可直接保證的原子性變量操作有: read 、 load 、 assign 、 use 、 store 和 write,因此可認為基本數據類型的訪問讀寫是具備原子性的。
  • 若需要保證更大範圍的原子性,可通過更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式地使用 lock 和 unlock 這兩個操作,反映到Java代碼中就是同步代碼塊 synchronized 關鍵字。
  • 可見性 (Visibility):當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。
  • 通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現。
  • 提供三個關鍵字保證可見性: volatile 能保證新值能 立即 同步到主內存,且每次使用前立即從主內存刷新; synchronized 對一個變量執行unlock操作之前可以先把此變量同步回主內存中;被 final 修飾的字段在構造器中一旦初始化完成且構造器沒有把 this 的引用傳遞出去,就可以在其他線程中就能看見final字段的值。
  • 有序性 (Ordering):程序代碼按照指令順序執行。
  • 如果在本線程內觀察,所有的操作都是有序的,指“線程內表現為串行的語義”;如果在一個線程中觀察另一個線程,所有的操作都是無序的,指“指令重排序”現象和“工作內存與主內存同步延遲”現象。
  • 提供兩個關鍵字保證有序性: volatile 本身就包含了禁止指令重排序的語義; synchronized 保證一個變量在同一個時刻只允許一條線程對其進行lock操作,使得持有同一個鎖的兩個同步塊只能串行地進入。

3.Java與線程

a.線程實現的三種方式

①使用 內核線程 (Kernel-Level Thread,KLT)

  • 定義 :直接由操作系統內核支持的線程。
  • 原理 :由內核來完成線程切換,內核通過操縱調度器(Scheduler)對線程進行調度,並負責將線程的任務映射到各個處理器上。每個內核線程可以視為內核的一個分身, 這樣操作系統就有能力同時處理多件事情。
  • 多線程內核 (Multi-Threads Kernel):支持多線程的內核
  • 輕量級進程 (Light Weight Process,LWP):內核線程的一種高級接口
  • 優點 :每個輕量級進程都由一個內核線程支持,因此每個都成為一個獨立的調度單元,即使有一個輕量級進程在系統調用中阻塞,也不會影響整個進程繼續工作。
  • 缺點 :由於基於內核線程實現,所以各種線程操作(創建、析構及同步)都需要進行系統調用,代價相對較高,需要在 用戶態 (User Mode)和 內核態 (Kernel Mode)中來回切換;另外,一個系統支持輕量級進程的數量是有限的。
  • 一對一線程模型 :輕量級進程與內核線程之間1:1的關係,如圖所示
要點提煉|理解JVM之內存模型&線程

②使用 用戶線程 (User Thread,UT)

  • 定義 :廣義上認為一個線程不是內核線程就是用戶線程;狹義上認為用戶線程指的是完全建立在用戶空間的線程庫上,而系統內核不能感知線程存在的實現。
  • 優點 :由於用戶線程的建立、同步、銷燬和調度完全在用戶態中完成,不需要內核的幫助,甚至可以不需要切換到內核態,所以操作非常快速且低消耗的,且可以支持規模更大的線程數量。
  • 缺點 :由於沒有系統內核的支援,所有的線程操作都需要用戶程序自己處理,線程的創建、切換和調度都是需要考慮的問題,實現較複雜。
  • 一對多的線程模型進程 :進程與用戶線程之間1:N的關係,如圖所示
要點提煉|理解JVM之內存模型&線程

③使用用戶線程加輕量級進程混合

  • 定義 :既存在用戶線程,也存在輕量級進程。
  • 優點 :用戶線程完全建立在用戶空間中,因此用戶線程的創建、切換、析構等操作依然廉價,並且可以支持大規模的用戶線程併發;操作系統提供支持的輕量級進程作為用戶線程和內核線程之間的橋樑,可以使用內核提供的線程調度功能及處理器映射,且用戶線程的系統調用要通過輕量級線程來完成,大大降低了整個進程被完全阻塞的風險。
  • 多對多的線程模型 :用戶線程與輕量級進程的數量比不定,即用戶線程與輕量級進程之間N:M的關係,如圖所示
要點提煉|理解JVM之內存模型&線程

那麼Java線程的實現是選擇哪一種呢?答案是 不確定 的。操作系統支持怎樣的線程模型,在很大程度上決定了Java虛擬機的線程是怎樣映射的。線程模型只對線程的併發規模和操作成本產生影響,而對Java程序的編碼和運行過程來說,這些差異都是透明的。

b.Java線程調度的兩種方式

線程調度:指系統為線程分配處理器使用權的過程。

協同式線程調度 (Cooperative Threads-Scheduling)

  • 線程本身 來控制線程的執行時間。線程把自己的工作執行完後,要主動通知系統切換到另外一個線程上。
  • 好處 :實現簡單;切換操作自己可知,不存在線程同步的問題。
  • 壞處 :線程執行時間不可控,假如一個線程編寫有問題一直不告知系統進行線程切換,那麼程序就會一直被阻塞。

搶佔式線程調度 (Preemptive Threads-Scheduling)

  • 系統 來分配每個線程的執行時間。
  • 好處 :線程執行時間是系統可控的,不存在一個線程導致整個進程阻塞的問題。
  • 可以通過設置線程 優先級 ,優先級越高的線程越容易被系統選擇執行。

但是線程優先級並不是太靠譜,一方面因為Java的線程是通過映射到系統的原生線程上來實現的,所以線程調度最終還是取決於操作系統,在一些平臺上不同的優先級實際會變得相同;另一方面優先級可能會被系統自行改變。

c.線程的五種狀態

在任意一個時間點,一個線程只能有且只有其中的一種狀態:

  • 新建 (New):線程創建後尚未啟動
  • 運行 (Runable):包括正在執行(Running)和等待著CPU為它分配執行時間(Ready)兩種
  • 無限期等待 (Waiting):該線程不會被分配CPU執行時間,要等待被其他線程顯式地喚醒。以下方法會讓線程陷入無限期等待狀態:
  • Object.wait()
  • Thread.join()
  • LockSupport.park()
  • 限期等待 (Timed Waiting):該線程不會被分配CPU執行時間,但在一定時間後會被系統自動喚醒。以下方法會讓線程進入限期等待狀態:
  • Thread.sleep()
  • Object.wai()
  • Thread.join()
  • LockSupport.parkNanos()
  • LockSupport.parkUntil()
  • 阻塞 (Blocked):線程被阻塞

注意區別:

  • 阻塞狀態:在等待獲取到一個 排他鎖 ,在另外一個線程放棄這個鎖的時候發生;
  • 等待狀態:在等待一段 時間 或者 喚醒動作 的發生,在程序等待進入同步區域的時候發生。
  • 結束 (Terminated):線程已經結束執行

下圖是線程狀態之間的轉換:

要點提煉|理解JVM之內存模型&線程


分享到:


相關文章: