【JVM】肝了一週,吐血整理出這份超硬核的JVM筆記(升級版)!!

冰河團隊發表於2020-11-22

寫在前面

最近,一直有小夥伴讓我整理下關於JVM的知識,經過十幾天的收集與整理,初版算是整理出來了。希望對大家有所幫助。

JDK 是什麼?

JDK 是用於支援 Java 程式開發的最小環境。

  1. Java 程式設計語言
  2. Java 虛擬機器
  3. Java API類庫

JRE 是什麼?

JRE 是支援 Java 程式執行的標準環境。

  1. Java SE API 子集
  2. Java 虛擬機器

Java歷史版本的特性?

Java Version SE 5.0

  • 引入泛型;
  • 增強迴圈,可以使用迭代方式;
  • 自動裝箱與自動拆箱;
  • 型別安全的列舉;
  • 可變引數;
  • 靜態引入;
  • 後設資料(註解);
  • 引入Instrumentation。

Java Version SE 6

  • 支援指令碼語言;
  • 引入JDBC 4.0 API;
  • 引入Java Compiler API;
  • 可插拔註解;
  • 增加對Native PKI(Public Key Infrastructure)、Java GSS(Generic Security Service)、Kerberos和LDAP(Lightweight Directory Access Protocol)的支援;
  • 繼承Web Services;
  • 做了很多優化。

Java Version SE 7

  • switch語句塊中允許以字串作為分支條件;
  • 在建立泛型物件時應用型別推斷;
  • 在一個語句塊中捕獲多種異常;
  • 支援動態語言;
  • 支援try-with-resources;
  • 引入Java NIO.2開發包;
  • 數值型別可以用2進位制字串表示,並且可以在字串表示中新增下劃線;
  • 鑽石型語法;
  • null值的自動處理。

Java 8

  • 函式式介面
  • Lambda表示式
  • Stream API
  • 介面的增強
  • 時間日期增強API
  • 重複註解與型別註解
  • 預設方法與靜態方法
  • Optional 容器類

執行時資料區域包括哪些?

  1. 程式計數器
  2. Java 虛擬機器棧
  3. 本地方法棧
  4. Java 堆
  5. 方法區
  6. 執行時常量池
  7. 直接記憶體

程式計數器(執行緒私有)

程式計數器(Program Counter Register)是一塊較小的記憶體空間,可以看作是當前執行緒所執行位元組碼的行號指示器。分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器完成。

由於 Java 虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式實現的。為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要一個獨立的程式計數器,各執行緒之間的計數器互不影響,獨立儲存。

  1. 如果執行緒正在執行的是一個 Java 方法,計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;
  2. 如果正在執行的是 Native 方法,這個計數器的值為空。

程式計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域。

Java 虛擬機器棧(執行緒私有)

Java 虛擬機器棧(Java Virtual Machine Stacks)是執行緒私有的,生命週期與執行緒相同。
虛擬機器棧描述的是 Java 方法執行的記憶體模型:每個方法被執行的時候都會建立一個棧幀(Stack Frame),儲存

  1. 區域性變數表
  2. 操作棧
  3. 動態連結
  4. 方法出口

每一個方法被呼叫到執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

這個區域有兩種異常情況:

  1. StackOverflowError:執行緒請求的棧深度大於虛擬機器所允許的深度
  2. OutOfMemoryError:虛擬機器棧擴充套件到無法申請足夠的記憶體時

本地方法棧(執行緒私有)

虛擬機器棧為虛擬機器執行 Java 方法(位元組碼)服務。

本地方法棧(Native Method Stacks)為虛擬機器使用到的 Native 方法服務。

Java 堆(執行緒共享)

Java 堆(Java Heap)是 Java 虛擬機器中記憶體最大的一塊。Java 堆在虛擬機器啟動時建立,被所有執行緒共享。

作用:存放物件例項。垃圾收集器主要管理的就是 Java 堆。Java 堆在物理上可以不連續,只要邏輯上連續即可。

方法區(執行緒共享)

方法區(Method Area)被所有執行緒共享,用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

和 Java 堆一樣,不需要連續的記憶體,可以選擇固定的大小,更可以選擇不實現垃圾收集。

執行時常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。儲存 Class 檔案中的符號引用、翻譯出來的直接引用。執行時常量池可以在執行期間將新的常量放入池中。

Java 中物件訪問是如何進行的?

Object obj =  new  Object();

對於上述最簡單的訪問,也會涉及到 Java 棧、Java 堆、方法區這三個最重要記憶體區域。

Object obj

如果出現在方法體中,則上述程式碼會反映到 Java 棧的本地變數表中,作為 reference 型別資料出現。

new  Object()

反映到 Java 堆中,形成一塊儲存了 Object 型別所有物件例項資料值的記憶體。Java堆中還包含物件型別資料的地址資訊,這些型別資料儲存在方法區中。

如何判斷物件是否“死去”?

  1. 引用計數法
  2. 根搜尋演算法

什麼是引用計數法?

給物件新增一個引用計數器,每當有一個地方引用它,計數器就+1,;當引用失效時,計數器就-1;任何時刻計數器都為0的物件就是不能再被使用的。

引用計數法的缺點?

很難解決物件之間的迴圈引用問題。

什麼是根搜尋演算法?

通過一系列的名為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到 GC Roots 沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可用的。

Java 的4種引用方式?

在 JDK 1.2 之後,Java 對引用的概念進行了擴充,將引用分為

  1. 強引用 Strong Reference
  2. 軟引用 Soft Reference
  3. 弱引用 Weak Reference
  4. 虛引用 Phantom Reference

強引用

Object obj =  new  Object();

程式碼中普遍存在的,像上述的引用。只要強引用還在,垃圾收集器永遠不會回收掉被引用的物件。

軟引用

用來描述一些還有用,但並非必須的物件。軟引用所關聯的物件,有在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍,並進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會丟擲記憶體異常。提供了 SoftReference 類實現軟引用。

弱引用

描述非必須的物件,強度比軟引用更弱一些,被弱引用關聯的物件,只能生存到下一次垃圾收集發生前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。提供了 WeakReference 類來實現弱引用。

虛引用

一個物件是否有虛引用,完全不會對其生存時間夠成影響,也無法通過虛引用來取得一個物件例項。為一個物件關聯虛引用的唯一目的,就是希望在這個物件被收集器回收時,收到一個系統通知。提供了 PhantomReference 類來實現虛引用。

有哪些垃圾收集演算法?

  1. 標記-清除演算法
  2. 複製演算法
  3. 標記-整理演算法
  4. 分代收集演算法

標記-清除演算法(Mark-Sweep)

什麼是標記-清除演算法?

分為標記和清除兩個階段。首先標記出所有需要回收的物件,在標記完成後統一回收被標記的物件。

有什麼缺點?

效率問題:標記和清除過程的效率都不高。

空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能導致,程式分配較大物件時無法找到足夠的連續記憶體,不得不提前出發另一次垃圾收集動作。

複製演算法(Copying)- 新生代

將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊的記憶體用完了,就將存活著的物件複製到另一塊上面,然後再把已經使用過的記憶體空間一次清理掉。

優點

複製演算法使得每次都是針對其中的一塊進行記憶體回收,記憶體分配時也不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。

缺點

將記憶體縮小為原來的一半。在物件存活率較高時,需要執行較多的複製操作,效率會變低。

應用

商業的虛擬機器都採用複製演算法來回收新生代。因為新生代中的物件容易死亡,所以並不需要按照1:1的比例劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間。每次使用 Eden 和其中的一塊 Survivor。

當回收時,將 Eden 和 Survivor 中還存活的物件一次性拷貝到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。Hotspot 虛擬機器預設 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80% + 10%),只有10%的記憶體是會被“浪費”的。

標記-整理演算法(Mark-Compact)-老年代

標記過程仍然與“標記-清除”演算法一樣,但不是直接對可回收物件進行清理,而是讓所有存活的物件向一端移動,然後直接清理掉邊界以外的記憶體。

分代收集演算法

根據物件的存活週期,將記憶體劃分為幾塊。一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點,採用最適當的收集演算法。

  • 新生代:每次垃圾收集時會有大批物件死去,只有少量存活,所以選擇複製演算法,只需要少量存活物件的複製成本就可以完成收集。
  • 老年代:物件存活率高、沒有額外空間對它進行分配擔保,必須使用“標記-清理”或“標記-整理”演算法進行回收。

Minor GC 和 Full GC有什麼區別?

Minor GC:新生代 GC,指發生在新生代的垃圾收集動作,因為 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般回收速度較快。
Full GC:老年代 GC,也叫 Major GC,速度一般比 Minor GC 慢 10 倍以上。

Java 記憶體

為什麼要將堆記憶體分割槽?

對於一個大型的系統,當建立的物件及方法變數比較多時,即堆記憶體中的物件比較多,如果逐一分析物件是否該回收,效率很低。分割槽是為了進行模組化管理,管理不同的物件及變數,以提高 JVM 的執行效率。

堆記憶體分為哪幾塊?

  1. Young Generation Space 新生區(也稱新生代)
  2. Tenure Generation Space養老區(也稱舊生代)
  3. Permanent Space 永久儲存區

分代收集演算法

記憶體分配有哪些原則?

  1. 物件優先分配在 Eden
  2. 大物件直接進入老年代
  3. 長期存活的物件將進入老年代
  4. 動態物件年齡判定
  5. 空間分配擔保

Young Generation Space (採用複製演算法)

主要用來儲存新建立的物件,記憶體較小,垃圾回收頻繁。這個區又分為三個區域:一個 Eden Space 和兩個 Survivor Space。

  • 當物件在堆建立時,將進入年輕代的Eden Space。
  • 垃圾回收器進行垃圾回收時,掃描Eden Space和A Suvivor Space,如果物件仍然存活,則複製到B Suvivor Space,如果B Suvivor Space已經滿,則複製 Old Gen
  • 掃描A Suvivor Space時,如果物件已經經過了幾次的掃描仍然存活,JVM認為其為一個Old物件,則將其移到Old Gen。
  • 掃描完畢後,JVM將Eden Space和A Suvivor Space清空,然後交換A和B的角色(即下次垃圾回收時會掃描Eden Space和B Suvivor Space。

Tenure Generation Space(採用標記-整理演算法)

主要用來儲存長時間被引用的物件。它裡面存放的是經過幾次在 Young Generation Space 進行掃描判斷過仍存活的物件,記憶體較大,垃圾回收頻率較小。

Permanent Space

儲存不變的類定義、位元組碼和常量等。

Class檔案

Java虛擬機器的平臺無關性

Class檔案的組成?

Class檔案是一組以8位位元組為基礎單位的二進位制流,各個資料專案間沒有任何分隔符。當遇到8位位元組以上空間的資料項時,則會按照高位在前的方式分隔成若干個8位位元組進行儲存。

魔數與Class檔案的版本

每個Class檔案的頭4個位元組稱為魔數(Magic Number),它的唯一作用是用於確定這個檔案是否為一個能被虛擬機器接受的Class檔案。OxCAFEBABE。

接下來是Class檔案的版本號:第5,6位元組是次版本號(Minor Version),第7,8位元組是主版本號(Major Version)。

使用JDK 1.7編譯輸出Class檔案,格式程式碼為:

前四個位元組為魔數,次版本號是0x0000,主版本號是0x0033,說明本檔案是可以被1.7及以上版本的虛擬機器執行的檔案。

  • 33:JDK1.7
  • 32:JDK1.6
  • 31:JDK1.5
  • 30:JDK1.4
  • 2F:JDK1.3

類載入器

類載入器的作用是什麼?

類載入器實現類的載入動作,同時用於確定一個類。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性。即使兩個類來源於同一個Class檔案,只要載入它們的類載入器不同,這兩個類就不相等。

類載入器有哪些?

  1. 啟動類載入器(Bootstrap ClassLoader):使用C++實現(僅限於HotSpot),是虛擬機器自身的一部分。負責將存放在\lib目錄中的類庫載入到虛擬機器中。其無法被Java程式直接引用。
  2. 擴充套件類載入器(Extention ClassLoader)由ExtClassLoader實現,負責載入\lib\ext目錄中的所有類庫,開發者可以直接使用。
  3. 應用程式類載入器(Application ClassLoader):由APPClassLoader實現。負責載入使用者類路徑(ClassPath)上所指定的類庫。

類載入機制

什麼是雙親委派模型?

雙親委派模型(Parents Delegation Model)要求除了頂層的啟動類載入器外,其餘載入器都應當有自己的父類載入器。類載入器之間的父子關係,通過組合關係複用。
工作過程:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器完成。每個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有到父載入器反饋自己無法完成這個載入請求(它的搜尋範圍沒有找到所需的類)時,子載入器才會嘗試自己去載入。

為什麼要使用雙親委派模型,組織類載入器之間的關係?

Java類隨著它的類載入器一起具備了一種帶優先順序的層次關係。比如java.lang.Object,它存放在rt.jar中,無論哪個類載入器要載入這個類,最終都是委派給啟動類載入器進行載入,因此Object類在程式的各個類載入器環境中,都是同一個類。

如果沒有使用雙親委派模型,讓各個類載入器自己去載入,那麼Java型別體系中最基礎的行為也得不到保障,應用程式會變得一片混亂。

什麼是類載入機制?

Class檔案描述的各種資訊,都需要載入到虛擬機器後才能執行。虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

虛擬機器和物理機的區別是什麼?

這兩種機器都有程式碼執行的能力,但是:

  • 物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面的。
  • 虛擬機器的執行引擎是自己實現的,因此可以自行制定指令集和執行引擎的結構體系,並且能夠執行那些不被硬體直接支援的指令集格式。

執行時棧幀結構

棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構, 儲存了方法的

  • 區域性變數表
  • 運算元棧
  • 動態連線
  • 方法返回地址

每一個方法從呼叫開始到執行完成的過程,就對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

Java 方法呼叫

什麼是方法呼叫?

方法呼叫唯一的任務是確定被呼叫方法的版本(呼叫哪個方法),暫時還不涉及方法內部的具體執行過程。

Java的方法呼叫,有什麼特殊之處?

Class檔案的編譯過程不包含傳統編譯的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址。這使得Java有強大的動態擴充套件能力,但使Java方法的呼叫過程變得相對複雜,需要在類載入期間甚至到執行時才能確定目標方法的直接引用。

Java虛擬機器呼叫位元組碼指令有哪些?

  • invokestatic:呼叫靜態方法
  • invokespecial:呼叫例項構造器方法、私有方法和父類方法
  • invokevirtual:呼叫所有的虛方法
  • invokeinterface:呼叫介面方法

虛擬機器是如何執行方法裡面的位元組碼指令的?

解釋執行(通過直譯器執行)
編譯執行(通過即時編譯器產生原生程式碼)

解釋執行

當主流的虛擬機器中都包含了即時編譯器後,Class檔案中的程式碼到底會被解釋執行還是編譯執行,只有虛擬機器自己才能準確判斷。

Javac編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一動作是在Java虛擬機器之外進行的,而直譯器在虛擬機器的內部,所以Java程式的編譯是半獨立的實現。

基於棧的指令集和基於暫存器的指令集

什麼是基於棧的指令集?

Java編譯器輸出的指令流,裡面的指令大部分都是零地址指令,它們依賴運算元棧進行工作。

計算“1+1=2”,基於棧的指令集是這樣的:

iconst_1
iconst_1
iadd
istore_0

兩條iconst_1指令連續地把兩個常量1壓入棧中,iadd指令把棧頂的兩個值出棧相加,把結果放回棧頂,最後istore_0把棧頂的值放到區域性變數表的第0個Slot中。

什麼是基於暫存器的指令集?

最典型的是x86的地址指令集,依賴暫存器工作。
計算“1+1=2”,基於暫存器的指令集是這樣的:

mov eax,  1
add eax,  1

mov指令把EAX暫存器的值設為1,然後add指令再把這個值加1,結果就儲存在EAX暫存器裡。

基於棧的指令集的優缺點?

優點:

  • 可移植性好:使用者程式不會直接用到這些暫存器,由虛擬機器自行決定把一些訪問最頻繁的資料(程式計數器、棧頂快取)放到暫存器以獲取更好的效能。
  • 程式碼相對緊湊:位元組碼中每個位元組就對應一條指令
  • 編譯器實現簡單:不需要考慮空間分配問題,所需空間都在棧上操作

缺點:

  • 執行速度稍慢
  • 完成相同功能所需的指令熟練多

頻繁的訪問棧,意味著頻繁的訪問記憶體,相對於處理器,記憶體才是執行速度的瓶頸。

Javac編譯過程分為哪些步驟?

  1. 解析與填充符號表
  2. 插入式註解處理器的註解處理
  3. 分析與位元組碼生成

什麼是即時編譯器?

Java程式最初是通過直譯器進行解釋執行的,當虛擬機器發現某個方法或程式碼塊的執行特別頻繁,就會把這些程式碼認定為“熱點程式碼”(Hot Spot Code)。

為了提高熱點程式碼的執行效率,在執行時,虛擬機器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器成為即時編譯器(Just In Time Compiler,JIT編譯器)。

直譯器和編譯器

許多主流的商用虛擬機器,都同時包含直譯器和編譯器。

  • 當程式需要快速啟動和執行時,直譯器首先發揮作用,省去編譯的時間,立即執行。
  • 當程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生程式碼,可以提高執行效率。

如果記憶體資源限制較大(部分嵌入式系統),可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率。同時編譯器的程式碼還能退回成直譯器的程式碼。

為什麼要採用分層編譯?

因為即時編譯器編譯原生程式碼需要佔用程式執行時間,要編譯出優化程度更高的程式碼,所花費的時間越長。

分層編譯器有哪些層次?

分層編譯根據編譯器編譯、優化的規模和耗時,劃分不同的編譯層次,包括:

  • 第0層:程式解釋執行,直譯器不開啟效能監控功能,可出發第1層編譯。
  • 第1層:也成為C1編譯,將位元組碼編譯為原生程式碼,進行簡單可靠的優化,如有必要加入效能監控的邏輯。
  • 第2層:也成為C2編譯,也是將位元組碼編譯為原生程式碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化。

用Client Compiler和Server Compiler將會同時工作。用Client Compiler獲取更高的編譯速度,用Server Compiler獲取更好的編譯質量。

編譯物件與觸發條件

熱點程式碼有哪些?

  • 被多次呼叫的方法
  • 被多次執行的迴圈體

如何判斷一段程式碼是不是熱點程式碼?

要知道一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這個行為稱為熱點探測。主要有兩種方法:

  • 基於取樣的熱點探測,虛擬機器週期性檢查各個執行緒的棧頂,如果發現某個方法經常出現在棧頂,那這個方法就是“熱點方法”。實現簡單高效,但是很難精確確認一個方法的熱度。
  • 基於計數器的熱點探測,虛擬機器會為每個方法建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值,就認為它是熱點方法。

HotSpot虛擬機器使用第二種,有兩個計數器:

  • 方法呼叫計數器
  • 回邊計數器(判斷迴圈程式碼)

方法呼叫計數器統計方法

統計的是一個相對的執行頻率,即一段時間內方法被呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會被減少一半,這個過程稱為方法呼叫計數器的熱度衰減,這個時間就被稱為半衰週期。

有哪些經典的優化技術(即時編譯器)?

  • 語言無關的經典優化技術之一:公共子表示式消除
  • 語言相關的經典優化技術之一:陣列範圍檢查消除
  • 最重要的優化技術之一:方法內聯
  • 最前沿的優化技術之一:逃逸分析

公共子表示式消除

普遍應用於各種編譯器的經典優化技術,它的含義是:

如果一個表示式E已經被計算過了,並且從先前的計算到現在E中所有變數的值都沒有發生變化,那麼E的這次出現就成了公共子表示式。沒有必要重新計算,直接用結果代替E就可以了。

陣列邊界檢查消除

因為Java會自動檢查陣列越界,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這無疑是一種效能負擔。

如果陣列訪問發生在迴圈之中,並且使用迴圈變數來進行陣列訪問,如果編譯器只要通過資料流分析就可以判定迴圈變數的取值範圍永遠在陣列區間內,那麼整個迴圈中就可以把陣列的上下界檢查消除掉,可以節省很多次的條件判斷操作。

方法內聯

內聯消除了方法呼叫的成本,還為其他優化手段建立良好的基礎。

編譯器在進行內聯時,如果是非虛方法,那麼直接內聯。如果遇到虛方法,則會查詢當前程式下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那麼也可以內聯,不過這種內聯屬於激進優化,需要預留一個逃生門(Guard條件不成立時的Slow Path),稱為守護內聯。

如果程式的後續執行過程中,虛擬機器一直沒有載入到會令這個方法的接受者的繼承關係發現變化的類,那麼內聯優化的程式碼可以一直使用。否則需要拋棄掉已經編譯的程式碼,退回到解釋狀態執行,或者重新進行編譯。

逃逸分析

逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法裡面被定義後,它可能被外部方法所引用,這種行為被稱為方法逃逸。被外部執行緒訪問到,被稱為執行緒逃逸。

如果物件不會逃逸到方法或執行緒外,可以做什麼優化?

  • 棧上分配:一般物件都是分配在Java堆中的,對於各個執行緒都是共享和可見的,只要持有這個物件的引用,就可以訪問堆中儲存的物件資料。但是垃圾回收和整理都會耗時,如果一個物件不會逃逸出方法,可以讓這個物件在棧上分配記憶體,物件所佔用的記憶體空間就可以隨著棧幀出棧而銷燬。如果能使用棧上分配,那大量的物件會隨著方法的結束而自動銷燬,垃圾回收的壓力會小很多。
  • 同步消除:執行緒同步本身就是很耗時的過程。如果逃逸分析能確定一個變數不會逃逸出執行緒,那這個變數的讀寫肯定就不會有競爭,同步措施就可以消除掉。
  • 標量替換:不建立這個物件,直接建立它的若干個被這個方法使用到的成員變數來替換。

Java與C/C++的編譯器對比

  1. 即時編譯器執行佔用的是使用者程式的執行時間,具有很大的時間壓力。
  2. Java語言雖然沒有virtual關鍵字,但是使用虛方法的頻率遠大於C++,所以即時編譯器進行優化時難度要遠遠大於C++的靜態優化編譯器。
  3. Java語言是可以動態擴充套件的語言,執行時載入新的類可能改變程式型別的繼承關係,使得全域性的優化難以進行,因為編譯器無法看見程式的全貌,編譯器不得不時刻注意並隨著型別的變化,而在執行時撤銷或重新進行一些優化。
  4. Java語言物件的記憶體分配是在堆上,只有方法的區域性變數才能在棧上分配。C++的物件有多種記憶體分配方式。

物理機如何處理併發問題?

運算任務,除了需要處理器計算之外,還需要與記憶體互動,如讀取運算資料、儲存運算結果等(不能僅靠暫存器來解決)。
計算機的儲存裝置和處理器的運算速度差了幾個數量級,所以不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache),作為記憶體與處理器之間的緩衝:將運算需要的資料複製到快取中,讓運算快速執行。當運算結束後再從快取同步回記憶體,這樣處理器就無需等待緩慢的記憶體讀寫了。
基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是引入了一個新的問題:快取一致性。在多處理器系統中,每個處理器都有自己的快取記憶體,它們又共享同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體時,可能導致各自的快取資料不一致。
為了解決一致性的問題,需要各個處理器訪問快取時遵循快取一致性協議。同時為了使得處理器充分被利用,處理器可能會對輸出程式碼進行亂序執行優化。Java虛擬機器的即時編譯器也有類似的指令重排序優化。

Java 記憶體模型

什麼是Java記憶體模型?

Java虛擬機器的規範,用來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各個平臺下都能達到一致的併發效果。

Java記憶體模型的目標?

定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出這樣的底層細節。此處的變數包括例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數和方法引數,因為這些是執行緒私有的,不會被共享,所以不存在競爭問題。

主記憶體與工作記憶體

所以的變數都儲存在主記憶體,每條執行緒還有自己的工作記憶體,儲存了被該執行緒使用到的變數的主記憶體副本拷貝。執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,不能直接讀寫主記憶體的變數。不同的執行緒之間也無法直接訪問對方工作記憶體的變數,執行緒間變數值的傳遞需要通過主記憶體。

記憶體間的互動操作

一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體,Java記憶體模型定義了8種操作:

原子性、可見性、有序性

  • 原子性:對基本資料型別的訪問和讀寫是具備原子性的。對於更大範圍的原子性保證,可以使用位元組碼指令monitorenter和monitorexit來隱式使用lock和unlock操作。這兩個位元組碼指令反映到Java程式碼中就是同步塊——synchronized關鍵字。因此synchronized塊之間的操作也具有原子性。
  • 可見性:當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取之前從主記憶體重新整理變數值來實現可見性的。volatile的特殊規則保證了新值能夠立即同步到主記憶體,每次使用前立即從主記憶體重新整理。synchronized和final也能實現可見性。final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把this的引用傳遞出去,那麼其他執行緒中就能看見final欄位的值。
  • 有序性:Java程式的有序性可以總結為一句話,如果在本執行緒內觀察,所有的操作都是有序的(執行緒內表現為序列的語義);如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的(指令重排序和工作記憶體與主記憶體同步延遲線性)。

volatile

什麼是volatile?

關鍵字volatile是Java虛擬機器提供的最輕量級的同步機制。當一個變數被定義成volatile之後,具備兩種特性:

  1. 保證此變數對所有執行緒的可見性。當一條執行緒修改了這個變數的值,新值對於其他執行緒是可以立即得知的。而普通變數做不到這一點。
  2. 禁止指令重排序優化。普通變數僅僅能保證在該方法執行過程中,得到正確結果,但是不保證程式程式碼的執行順序。

為什麼基於volatile變數的運算在併發下不一定是安全的?

volatile變數在各個執行緒的工作記憶體,不存在一致性問題(各個執行緒的工作記憶體中volatile變數,每次使用前都要重新整理到主記憶體)。但是Java裡面的運算並非原子操作,導致volatile變數的運算在併發下一樣是不安全的。

為什麼使用volatile?

在某些情況下,volatile同步機制的效能要優於鎖(synchronized關鍵字),但是由於虛擬機器對鎖實行的許多消除和優化,所以並不是很快。

volatile變數讀操作的效能消耗與普通變數幾乎沒有差別,但是寫操作則可能慢一些,因為它需要在原生程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

併發與執行緒

併發與執行緒的關係?

併發不一定要依賴多執行緒,PHP中有多程式併發。但是Java裡面的併發是多執行緒的。

什麼是執行緒?

執行緒是比程式更輕量級的排程執行單位。執行緒可以把一個程式的資源分配和執行排程分開,各個執行緒既可以共享程式資源(記憶體地址、檔案I/O),又可以獨立排程(執行緒是CPU排程的最基本單位)。

實現執行緒有哪些方式?

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

Java執行緒的實現

作業系統支援怎樣的執行緒模型,在很大程度上就決定了Java虛擬機器的執行緒是怎樣對映的。

Java執行緒排程

什麼是執行緒排程?

執行緒排程是系統為執行緒分配處理器使用權的過程。

執行緒排程有哪些方法?

  • 協同式執行緒排程:實現簡單,沒有執行緒同步的問題。但是執行緒執行時間不可控,容易系統崩潰。
  • 搶佔式執行緒排程:每個執行緒由系統來分配執行時間,不會有執行緒導致整個程式阻塞的問題。

雖然Java執行緒排程是系統自動完成的,但是我們可以建議系統給某些執行緒多分配點時間——設定執行緒優先順序。Java語言有10個級別的執行緒優先順序,優先順序越高的執行緒,越容易被系統選擇執行。

但是並不能完全依靠執行緒優先順序。因為Java的執行緒是被對映到系統的原生執行緒上,所以執行緒排程最終還是由作業系統說了算。如Windows中只有7種優先順序,所以Java不得不出現幾個優先順序相同的情況。同時優先順序可能會被系統自行改變。Windows系統中存在一個“優先順序推進器”,當系統發現一個執行緒執行特別勤奮,可能會越過執行緒優先順序為它分配執行時間。

執行緒安全的定義?

當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方法進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件就是執行緒安全的。

Java語言操作的共享資料,包括哪些?

  • 不可變
  • 絕對執行緒安全
  • 相對執行緒安全
  • 執行緒相容
  • 執行緒對立

不可變

在Java語言裡,不可變的物件一定是執行緒安全的,只要一個不可變的物件被正確構建出來,那其外部的可見狀態永遠也不會改變,永遠也不會在多個執行緒中處於不一致的狀態。

如何實現執行緒安全?

虛擬機器提供了同步和鎖機制。

  • 阻塞同步(互斥同步)
  • 非阻塞同步

阻塞同步(互斥同步)

互斥是實現同步的一種手段,臨界區、互斥量和訊號量都是主要的互斥實現方式。Java中最基本的同步手段就是synchronized關鍵字,其編譯後會在同步塊的前後分別形成monitorenter和monitorexit兩個位元組碼指令。這兩個位元組碼都需要一個Reference型別的引數指明要鎖定和解鎖的物件。如果Java程式中的synchronized明確指定了物件引數,那麼這個物件就是Reference;如果沒有明確指定,那就根據synchronized修飾的是例項方法還是類方法,去獲取對應的物件例項或Class物件作為鎖物件。
在執行monitorenter指令時,首先要嘗試獲取物件的鎖。

  • 如果這個物件沒有鎖定,或者當前執行緒已經擁有了這個物件的鎖,把鎖的計數器+1;當執行monitorexit指令時將鎖計數器-1。當計數器為0時,鎖就被釋放了。
  • 如果獲取物件失敗了,那當前執行緒就要阻塞等待,知道物件鎖被另外一個執行緒釋放為止。

除了synchronized之外,還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實現同步。ReentrantLock比synchronized增加了高階功能:等待可中斷、可實現公平鎖、鎖可以繫結多個條件。

等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。

公平鎖:多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。synchronized中的鎖是非公平的。

非阻塞同步

互斥同步最大的問題,就是進行執行緒阻塞和喚醒所帶來的效能問題,是一種悲觀的併發策略。總是認為只要不去做正確的同步措施(加鎖),那就肯定會出問題,無論共享資料是否真的會出現競爭,它都要進行加鎖、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要被喚醒等操作。

隨著硬體指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略。先進行操作,如果沒有其他執行緒徵用資料,那操作就成功了;如果共享資料有徵用,產生了衝突,那就再進行其他的補償措施。這種樂觀的併發策略的許多實現不需要執行緒掛起,所以被稱為非阻塞同步。

鎖優化是在JDK的那個版本?

JDK1.6的一個重要主題,就是高效併發。HotSpot虛擬機器開發團隊在這個版本上,實現了各種鎖優化:

  • 適應性自旋
  • 鎖消除
  • 鎖粗化
  • 輕量級鎖
  • 偏向鎖

為什麼要提出自旋鎖?

互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發性帶來很大壓力。同時很多應用共享資料的鎖定狀態,只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。先不掛起執行緒,等一會兒。

自旋鎖的原理?

如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,讓後面請求鎖的執行緒稍等一會,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋)。

自旋的缺點?

自旋等待本身雖然避免了執行緒切換的開銷,但它要佔用處理器時間。所以如果鎖被佔用的時間很短,自旋等待的效果就非常好;如果時間很長,那麼自旋的執行緒只會白白消耗處理器的資源。所以自旋等待的時間要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,那就應該使用傳統的方式掛起執行緒了。

什麼是自適應自旋?

自旋的時間不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

  • 如果一個鎖物件,自旋等待剛剛成功獲得鎖,並且持有鎖的執行緒正在執行,那麼虛擬機器認為這次自旋仍然可能成功,進而執行自旋等待更長的時間。
  • 如果對於某個鎖,自旋很少成功,那在以後要獲取這個鎖,可能省略掉自旋過程,以免浪費處理器資源。

有了自適應自旋,隨著程式執行和效能監控資訊的不斷完善,虛擬機器對程式鎖的狀況預測就會越來越準確,虛擬機器也會越來越聰明。

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但被檢測到不可能存在共享資料競爭的鎖進行消除。主要根據逃逸分析。

程式設計師怎麼會在明知道不存在資料競爭的情況下使用同步呢?很多不是程式設計師自己加入的。

鎖粗化

原則上,同步塊的作用範圍要儘量小。但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作在迴圈體內,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

鎖粗化就是增大鎖的作用域。

輕量級鎖

在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

偏向鎖

消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。即在無競爭的情況下,把整個同步都消除掉。這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要同步。

參考:《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第2版)》

相關文章