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

寫在前面

最近,一直有小夥伴讓我整理下關於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版)》

Tags: