一文徹底喫透 JVM 系列
大家好,我是冰河~~
最近,一直有小夥伴讓我整理下關於 JVM 的知識,經過十幾天的收集與整理,初版算是整理出來了。希望對大家有所幫助。
JDK 是什麼?
JDK 是用於支持 Java 程序開發的最小環境。
-
Java 程序設計語言
-
Java 虛擬機
-
Java API 類庫
JRE 是什麼?
JRE 是支持 Java 程序運行的標準環境。
-
Java SE API 子集
-
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 容器類
運行時數據區域包括哪些?
-
程序計數器
-
Java 虛擬機棧
-
本地方法棧
-
Java 堆
-
方法區
-
運行時常量池
-
直接內存
程序計數器(線程私有)
程序計數器(Program Counter Register)是一塊較小的內存空間,可以看作是當前線程所執行字節碼的行號指示器。分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器完成。
由於 Java 虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式實現的。爲了線程切換後能恢復到正確的執行位置,每條線程都需要一個獨立的程序計數器,各線程之間的計數器互不影響,獨立存儲。
-
如果線程正在執行的是一個 Java 方法,計數器記錄的是正在執行的虛擬機字節碼指令的地址;
-
如果正在執行的是 Native 方法,這個計數器的值爲空。
程序計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域。
Java 虛擬機棧(線程私有)
Java 虛擬機棧(Java Virtual Machine Stacks)是線程私有的,生命週期與線程相同。 虛擬機棧描述的是 Java 方法執行的內存模型:每個方法被執行的時候都會創建一個棧幀(Stack Frame),存儲
-
局部變量表
-
操作棧
-
動態鏈接
-
方法出口
每一個方法被調用到執行完成的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。
這個區域有兩種異常情況:
-
StackOverflowError:線程請求的棧深度大於虛擬機所允許的深度
-
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,;當引用失效時,計數器就 - 1;任何時刻計數器都爲 0 的對象就是不能再被使用的。
引用計數法的缺點?
很難解決對象之間的循環引用問題。
什麼是根搜索算法?
通過一系列的名爲 “GC Roots” 的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到 GC Roots 沒有任何引用鏈相連(用圖論的話來說就是從 GC Roots 到這個對象不可達)時,則證明此對象是不可用的。
Java 的 4 種引用方式?
在 JDK 1.2 之後,Java 對引用的概念進行了擴充,將引用分爲
-
強引用 Strong Reference
-
軟引用 Soft Reference
-
弱引用 Weak Reference
-
虛引用 Phantom Reference
強引用
Object obj = new Object();
代碼中普遍存在的,像上述的引用。只要強引用還在,垃圾收集器永遠不會回收掉被引用的對象。
軟引用
用來描述一些還有用,但並非必須的對象。軟引用所關聯的對象,有在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍,並進行第二次回收。如果這次回收還是沒有足夠的內存,纔會拋出內存異常。提供了 SoftReference 類實現軟引用。
弱引用
描述非必須的對象,強度比軟引用更弱一些,被弱引用關聯的對象,只能生存到下一次垃圾收集發生前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。提供了 WeakReference 類來實現弱引用。
虛引用
一個對象是否有虛引用,完全不會對其生存時間夠成影響,也無法通過虛引用來取得一個對象實例。爲一個對象關聯虛引用的唯一目的,就是希望在這個對象被收集器回收時,收到一個系統通知。提供了 PhantomReference 類來實現虛引用。
有哪些垃圾收集算法?
-
標記 - 清除算法
-
複製算法
-
標記 - 整理算法
-
分代收集算法
標記 - 清除算法(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 的執行效率。
堆內存分爲哪幾塊?
-
Young Generation Space 新生區(也稱新生代)
-
Tenure Generation Space 養老區(也稱舊生代)
-
Permanent Space 永久存儲區
分代收集算法
內存分配有哪些原則?
-
對象優先分配在 Eden
-
大對象直接進入老年代
-
長期存活的對象將進入老年代
-
動態對象年齡判定
-
空間分配擔保
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 文件,只要加載它們的類加載器不同,這兩個類就不相等。
類加載器有哪些?
-
啓動類加載器(Bootstrap ClassLoader):使用 C++ 實現(僅限於 HotSpot),是虛擬機自身的一部分。負責將存放在 \ lib 目錄中的類庫加載到虛擬機中。其無法被 Java 程序直接引用。
-
擴展類加載器(Extention ClassLoader)由 ExtClassLoader 實現,負責加載 \ lib\ext 目錄中的所有類庫,開發者可以直接使用。
-
應用程序類加載器(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 編譯過程分爲哪些步驟?
-
解析與填充符號表
-
插入式註解處理器的註解處理
-
分析與字節碼生成
什麼是即時編譯器?
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++ 的編譯器對比
-
即時編譯器運行佔用的是用戶程序的運行時間,具有很大的時間壓力。
-
Java 語言雖然沒有 virtual 關鍵字,但是使用虛方法的頻率遠大於 C++,所以即時編譯器進行優化時難度要遠遠大於 C++ 的靜態優化編譯器。
-
Java 語言是可以動態擴展的語言,運行時加載新的類可能改變程序類型的繼承關係,使得全局的優化難以進行,因爲編譯器無法看見程序的全貌,編譯器不得不時刻注意並隨着類型的變化,而在運行時撤銷或重新進行一些優化。
-
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 之後,具備兩種特性:
-
保證此變量對所有線程的可見性。當一條線程修改了這個變量的值,新值對於其他線程是可以立即得知的。而普通變量做不到這一點。
-
禁止指令重排序優化。普通變量僅僅能保證在該方法執行過程中,得到正確結果,但是不保證程序代碼的執行順序。
爲什麼基於 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 版)》
寫在最後
❝
如果覺得文章對你有點幫助,請微信搜索並關注「 「冰河技術」 」微信公衆號,跟冰河學習高併發編程技術。
❞
最後,附上併發編程需要掌握的核心技能知識圖,祝大家在學習併發編程時,少走彎路。
後記:
記住:你比別人強的地方,不是你做過多少年的 CRUD 工作,而是你比別人掌握了更多深入的技能。不要總停留在 CRUD 的表面工作,理解並掌握底層原理並熟悉源碼實現,並形成自己的抽象思維能力,做到靈活運用,纔是你突破瓶頸,脫穎而出的重要方向!
你在刷抖音,玩遊戲的時候,別人都在這裏學習,成長,提升,人與人最大的差距其實就是思維。你可能不信,優秀的人,總是在一起。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/GAjZbMs9TQjM6zb91zfDrA