1. 程式人生 > >Java虛擬機器—記憶體模型與執行緒

Java虛擬機器—記憶體模型與執行緒

Java虛擬機器—記憶體模型與執行緒

Lyon

Lyon

Keep balance,Be a better man!

​關注他

3 人讚了該文章

前言:

本文主要介紹Java的記憶體模型和Java執行緒。

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在JVM中將變數儲存到記憶體以及從記憶體中取出的底層細節。主要涉及JVM中執行緒、主記憶體、工作記憶體的關係及之間的互動。

而Java執行緒主要介紹Java中執行緒的底層實現、執行緒排程和切換等過程。

主要內容包括:

1.Java記憶體模型

  • 主記憶體和工作記憶體
  • 記憶體間的互動
  • volatile型變數的規則
  • long和double變數的特殊規則
  • 原子性、可見性和有序性

2.Java執行緒

  • 執行緒的實現
  • Java執行緒排程
  • Java執行緒狀態轉換

0.概述

多工處理在現代計算機作業系統中幾乎已經是一項必備的功能了。在許多情況下讓計算機同時去做幾件事,不僅因為計算機的運算能力強大,還有一個重要的原因——計算機CPU的運算速度和它的儲存以及通訊子系統間的速度差距過大。大量的時間花費在了磁碟IO、網路通訊和資料庫訪問上。

由於計算機的儲存裝置與處理器的運算速度間有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的的快取記憶體(Cache)來作為記憶體和處理器之間的緩衝

:將運算需要使用的資料複製到緩衝中,讓運算能快速進行,在運算結束後再從快取同步回記憶體之中,這樣CPU就無須等待記憶體緩慢的讀寫速度了。

基於告訴快取,處理器和記憶體間速度相差過高的矛盾被解決了,但是帶來了新的問題:

快取一致性(Cache Coherence)。在多處理器系統中,每個CPU核心都有自己的快取記憶體,而他們又共享主記憶體,所以,當多個處理器之間的運算任務都設計到同一塊主記憶體區域時,將可能導致各自的快取資料不一致,如果出現這種情況,同步回主記憶體的資料以誰的快取資料為準呢?

為了解決快取一致性的問題,各個處理器訪問快取時都需要遵循一些協議,在讀寫時都需要根據協議進行操作,這類協議有MSI、MESI、MOSI、Synapse、Firefly和Dragon Protocol等

除了增加快取記憶體以外,為了使處理器內部的運算單元能儘量被利用,處理器可能會對輸入程式碼進行亂序執行優化(Out-Of-Order Excution),處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的(但是不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致),這就稱為處理器的亂序執行優化。與之類似,Java虛擬機器的即時編譯器JIT中也有類似的指令重排序(Instructijon Reorder)優化。


1.Java記憶體模型

由於計算機上的記憶體模型涉及到物理的主記憶體、快取記憶體和暫存器等。這些不同的計算機不同的操場系統可能會存在差異,Java虛擬機器規範中試圖定義一種Java記憶體模型,來遮蔽掉各種硬體和作業系統的記憶體訪問差異,讓Java程式在各個平臺下都能達到一致的訪問效果,Java的記憶體模型在JDK1.5時已經逐漸成熟和完善起來了。

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在JVM中將變數儲存到記憶體以及從記憶體中取出的底層細節。

1.為了獲得較高的執行效能,Java記憶體模型並沒有限制JVM執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制JIT即時編譯器進行程式碼執行順序調整這類的優化措施。
2.Java記憶體模型中的變數和Java語言中的變數有所區別包括例項欄位、靜態欄位和構成陣列物件的元素,而不包括執行緒私有的區域性變數和方法引數。

1.1主記憶體和工作記憶體

JVM中執行緒、主記憶體、工作記憶體關係如下:

Java記憶體模型規定了所有變數都儲存在主記憶體內,此處主記憶體隸屬於Java虛擬機器記憶體的一部分,而虛擬機器記憶體是作業系統分配的。每條Java執行緒還有自己的工作記憶體(類比上面的快取記憶體),工作記憶體中儲存了被該執行緒使用到的變數的主記憶體的副本,執行緒對變數的所有操作都在工作記憶體中進行,Java執行緒之間的變數值傳遞都通過主記憶體來完成。

虛擬機器的記憶體主要是PC的實體記憶體,但是為了優化程式執行效率虛擬機器也可能會讓工作記憶體優先儲存於暫存器和快取記憶體中。

1.2記憶體間的互動

關於主記憶體和工作記憶體間的互動協議,即一個變數如何從工作記憶體拷貝到工作記憶體、又是如何從工作記憶體同步回主記憶體之類的實現細節,Java記憶體模型中定義了8種操作,這8種操作實現時必須保證每一種操作都是原子的、不可再分的。(long、double的特殊性除外),這8種操作如下,其中前4條是作用於主記憶體,後4條作用於工作記憶體:

  • lock 鎖定,將一個變數標識為執行緒獨佔狀態
  • unlock 解鎖,將鎖定狀態的變數解除鎖定,釋放後的變數才可以被其他變數鎖定
  • read 讀取,將變數從主記憶體傳輸到執行緒的工作記憶體中,待之後的load載入
  • write 寫入,把store操作從工作記憶體中得到的變數值寫入主記憶體的變數中
  • load 載入,將read後從主記憶體得到的變數值載入到工作記憶體的變數副本中
  • use 使用,把工作記憶體中的一個變數值傳遞給位元組碼執行引擎,等待位元組碼指令使用
  • assign 賦值,把一個從執行引擎接收到的值賦值給工作記憶體的變數
  • store 儲存,把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write使用

1.3volatile型變數的規則

volatile關鍵字的含義

這個關鍵字定義的變數具有以下兩種特性:

  • 保證此變數對所有變數的可見性
  • 禁止指令重排序優化

1.保證此變數對所有執行緒的可見性

這裡的「可見性」是指,一旦這個變數的值被修改,則其修改後的值對於其他執行緒來說是立刻感知到的,不會存在快取和延遲的問題。volatile修飾後的變數會使得當前CPU的Cache立即寫入記憶體,且會使其他的CPU(執行緒)上同樣的變數Cache立即無效化。於是所有執行緒上的變數都從當前主記憶體修正過後的變數值來讀取,保證了此變數的修改對所有執行緒的可見性。

但是volatile只能保證可見性,並不能保證原子性,即很多場景下,我們仍然需要通過加鎖(sychronized或java.util.concurent包下的原子類)來保證原子性。

2.禁止指令重排序優化

volatile修飾後的變數,賦值後多執行了一個lock addl指令的操作,作用相當於一個記憶體屏障,使得指令重排序時不能把後面的指令重排序到記憶體屏障之前,從而阻止了指令重排序。

1.4long和double變數的特殊規則

對於64位的資料型別long和double,虛擬機器允許其讀寫操作劃分為兩次32位的操作,即JVM實現可以選擇不保證64位資料型別的load、store、write和read這4個操作的原子性,這就是所謂的long和double的非原子協定。

但是JVM實現中幾乎都用了手段來保證long和double型別讀寫操作的原子性,所以幾乎是沒有任何影響的。

1.5原子性、可見性和有序性

原子性(Atomicity):

由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store和write,我們大致可認為基本資料型別的讀寫訪問是原子性的。如果應用場景需要更大範圍地保證整體操作的“原子性”,記憶體模型上還可以通過lock和unlock操作來滿足需求,儘管JVM沒有將lock和unlock操作開放給使用者,但是提供了更高層次的位元組碼指令—monitorenter和monitorexit指令來隱式地使用這兩個操作,這兩個位元組碼反應到Java關鍵字就是:sychronized。所以在sychronized修飾過後的方法、操作等也具備原子性。

可見性(Visibility)

除了上面說的volatile關鍵字,Java還有兩個關鍵字來實現可見性:sychronized和final

有序性(Odering)

如果是在單個執行緒的角度來看,那麼所有的操作都是有序的,但是在多執行緒的角度,由於指令重排序和工作記憶體與主記憶體間同步延遲現象的發生,經常會導致「無序」的情況。

Java提供volatile和sychronized兩個關鍵字來保證執行緒之間操作的有序性。其中volatile是天然自帶立即同步工作記憶體和主記憶體和禁止了指令重排序來實現有序性的;sychronized是通過“一個時刻只允許一條執行緒對其進行lock操作”這條規則來實現的。


2.Java與執行緒

執行緒是比程序更輕量級的排程單位,執行緒的引入可以把一個程序的資源分配和執行排程分開,各個執行緒即可共享程序資源如記憶體地址、檔案IO等,又可以獨立排程。執行緒是CPU排程的最小單位。那麼Java中執行緒是如何實現,執行緒間的排程又是怎樣完成的呢?我們先介紹下作業系統中執行緒的實現,再來討論。

2.1執行緒的實現

主流的作業系統都提供了執行緒的實現,在Java中無論通過繼承Thread類還是實現Runnable介面,一個類要想建立執行緒,最終都會通過呼叫Thread類的start方法實現。而Thread類的大部分關鍵方法都是Native方法,即不是通過Java而是呼叫本地方法(通過C/C++)實現的。其實現過程依賴於Native方法和作業系統之間的互動,不同作業系統有著不一樣的實現。

廣義上執行緒(而不是Java執行緒)的實現主要有三種方式:

  • 使用核心執行緒實現
  • 使用使用者執行緒實現
  • 使用使用者執行緒+輕量級程序混合實現

1.使用核心執行緒實現

核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel)支援的執行緒,核心通過排程器(Schedule)對執行緒進行排程並負責將執行緒的任務對映到各個處理器上,每個執行緒視為核心的一個分身,這樣作業系統就有能力同時處理多個事情。

程式一般不會直接去使用核心執行緒,而是使用核心執行緒的另一種高階介面——輕量級程序(Light Weight Process ,LWP)輕量級程序就是我們通常意義上將的「執行緒」。每個輕量級程序都由一個核心執行緒直接支援,這種1:1的關係稱為一對一的執行緒模型:

優勢:由於核心執行緒的支援,使得每個輕量級程序都成為一個獨立的排程單元,即使有一個輕量級程序在系統呼叫中阻塞,也不會影響整個程序的繼續工作。

劣勢:由於基於核心執行緒實現,所以各種執行緒操作如建立、析構、同步都需要進行系統呼叫,而系統呼叫涉及到在使用者態(User Mode)和核心態(Kernel)中來回切換,主要的系統呼叫代價較高,且消耗一定的核心資源,因此一個作業系統能支援的輕量級程序數是有限的。

2.使用使用者執行緒實現

從廣義上來講,一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒(User Thread,UT),因此輕量級程序也屬於使用者執行緒。

從狹義上來講,使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知到使用者執行緒的存在。使用者執行緒的建立、同步、銷燬和呼叫完全在使用者態即可完成,無需核心的幫助。這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型:

優勢:不需要系統核心的支援,少了核心態使用者態之間相互切換的開銷,可以支援規模更大的執行緒數量。大部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。

劣勢:所有執行緒建立、排程和切換都需要使用者程式自己處理,而且由於作業系統只把CPU資源分配到程序,那諸如“阻塞如何處理”、“多處理器系統中如何將執行緒對映到其他處理器上”等這類問題解決起來將會比較麻煩,因而使用使用者執行緒實現的程式一般都比較複雜。

Java、Ruby等語言都曾經使用過使用者執行緒,最終由放棄了使用它。

3.使用使用者執行緒+輕量級程序混合實現

在這混合模式下,即存在使用者執行緒也存在輕量級程序,使用者執行緒還是完全建立在使用者空間中。在這種混合模式下,使用者執行緒和輕量級程序的數量比是不確定的即N:M的關係,這種就是多對多的執行緒模型。

優點:這種模式下使用者執行緒的建立、切換、析構依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統提供支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心提供的執行緒排程機制以及處理器對映,並且輕量級程序的實現大大降低整個系統程序被完全阻塞的風險。

Java執行緒的實現

Java執行緒在JDK1.2以前是基於“綠色執行緒”的方式2——使用者執行緒實現的。在JDK1.2中,執行緒模型替換為了基於作業系統的原生執行緒模型來實現,而不同作業系統具體執行緒模型實現也不同,所以Java執行緒的實現是分作業系統的。

對於常用的Sun JDK來說,Windows版本和Linux版本的作業系統提供的執行緒模型是一對一的模型,即一條使用者執行緒對應一條輕量級程序,所以Java執行緒也是使用方式1——核心執行緒來實現的。(一條Java執行緒對映到一條輕量級程序中)

而在Solaris平臺中(一種UNIX系統的衍生版),由於作業系統的特性可以同時支援一對一以及多對多(方式1和方式3),因此Solaris版的JDK可以用虛擬機器引數來指定Java使用的具體執行緒模型。

2.2Java執行緒排程

執行緒排程是指系統為執行緒分配CPU使用權的過程,主要排程方式有兩種:

  • 協同式執行緒排程(Cooperative Threads-Scheduling)
  • 搶佔式執行緒排程(Preemptive Threads-Scheduling)

使用協同式執行緒排程的多執行緒系統,執行緒執行的時間由執行緒本身來控制,執行緒把自己的工作執行完之後,要主動通知系統切換到另外一個執行緒上。使用協同式執行緒排程的最大好處是實現簡單,由於執行緒要把自己的事情做完後才會通知系統進行執行緒切換,所以沒有執行緒同步的問題,但是壞處也很明顯,如果一個執行緒出了問題,則程式就會一直阻塞。

使用搶佔式執行緒排程的多執行緒系統,每個執行緒執行的時間以及是否切換都由系統決定。在這種情況下,執行緒的執行時間不可控,所以不會有「一個執行緒導致整個程序阻塞」的問題出現。

Java使用的就是搶佔式執行緒排程。在Java中,Thread.yield()可以讓出CPU執行時間,但是對於獲取,執行緒本身是沒有辦法的。對於獲取CPU執行時間,執行緒唯一可以使用的手段是設定執行緒優先順序,Java設定了10個級別的程式優先順序,當兩個執行緒同時處於Ready狀態時,優先順序越高的執行緒越容易被系統選擇執行。

Java中的執行緒優先順序是通過對映到作業系統的原生執行緒上實現的,所以執行緒的排程最終取決於作業系統,作業系統中執行緒的優先順序有時並不能和Java中的一一對應,所以Java優先順序並不是特別靠譜。

2.3Java執行緒狀態轉換

Java語言中有6種執行緒狀態,在任意時刻裡,一個執行緒有且只有其中的一種狀態。

  • New新建
  • Runable可執行
  • Waiting等待
  • Timed Waiting限時等待
  • Blocked阻塞
  • Terminated結束

New:

建立後尚未啟動的執行緒處於此狀態。

Runable:

Java執行緒中的可執行狀態,對應作業系統中執行緒狀態中的Ready和Running。表示執行緒有可能正在執行,還有可能處於就緒狀態,等待CPU為其分配時間片來執行。

Waiting:

處於此狀態是,執行緒不會被CPU分配執行時間,且需要等待被其他執行緒顯示地喚醒,以下方法會讓執行緒處於無限等待狀態:

Thread.join()方法,未設定Timeout引數
Object.wait()方法,未設定Timeout引數
LockSupport.park()方法

Timed Waiting:

處於這種狀態的執行緒同樣不會被分配CPU執行時間,不過在等待一定時間後會被系統自動喚醒。以下方法會讓執行緒處於限時等待狀態:

Thread.sleep()方法
Thread.join()方法,設定了Timeout引數
Object.wait()方法,設定了Timeout引數
LockSupport.parkNanos()方法
LockSupport.parkUntil()方法

Blocked:

表示執行緒被阻塞,阻塞和等待狀態下都不會獲得CPU時間片。

Terminated:

結束,已終止的執行緒狀態,此狀態下,執行緒已終止執行。

 

 

編輯於 2018-10-02