寫在前面
最近,一直有小夥伴讓我整理下關于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版)》
寫在最後
最後,附上并發程式設計需要掌握的核心技能知識圖,祝大家在學習并發程式設計時,少走彎路。