聊一聊JVM
JVM
什麼是JVM?
JVM是java虛擬機的縮寫,本質上是一個程式,能識別.class位元組碼文件(.java文件編譯後產生的二進位程式碼),並且能夠解析它的指令,最終調用作業系統上的函數,完成我們想要的操作。
關於java語言的跨平台性(一次編譯,多次運行),就是應為JVM,可以把它想像出一個抽象層,運行在作業系統之上的,與硬體沒有直接的交互,只要這個抽象層JVM正確執行了.class文件,就能運行在各種作業系統之上了。
介紹幾個術語:
- JDK:java開發工具包,JDK=JRE+javac/java/jar等指令工具
- JRE:java運行環境,JRE=JVM+java基本類庫
JVM體系結構
java虛擬機主要分為五大模組:
- 類載入器
- 運行時數據區
- 執行引擎
- 本地方法介面
- 垃圾收集模組
方法區是一種特殊的堆,棧裡面不回有垃圾,用完就彈出了,否則阻塞了main方法。垃圾幾乎都在堆里,所以JVM性能調優%99都針對與堆。
目前最常用的JVM是Sun公司的HotSpot,此外還有BEA公司的JRockit和IBM公司的J9 VM。
類載入器
作用:載入.class位元組碼文件。
new一個對象的過程
//運行時,JVM將Test的資訊放入方法區
public class Test{
public static void main(String[] args){
Student s1 = new Student("Tom");//引用放在棧里,具體的實例放在堆里
Student s2 = new Student("Jerry");
Student s3 = new Student("Victor");
//三個hashCode是不同的,因為是三個不同的對象,對象是具體的
System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
System.out.println(s3.hashCode());
//class1,class2,class3為同一個對象,因為這是類模版,模版是抽象的
Class<? extends Stedent> class1 = s1.getClass();
Class<? extends Stedent> class2 = s2.getClass();
Class<? extends Stedent> class3 = s3.getClass();
System.out.println(class1.hashCode());
System.out.println(class2.hashCode());
System.out.println(class3.hashCode());
}
}
- 首先Class Loader讀取位元組碼文件,載入初始化生成Student模版類。
- 通過Student模版類new出三個對象。
類載入器的類別
public class Test{
public static void main(String[] args){
Student s = new Student("Tom");
Class<? extends Student> c = s.getClass();
ClassLoader classLoader = c.getClassLoader();
System.out.println(classLoader);//APPClassLoader
System.out.println(classLoader.getParent());//PlatformClassLoader
System.out.println(classzLader.getParent().getParent());//null,獲取不到(C++寫的)
}
}
根據返回結果,級別從高到低有三種載入器:
- 啟動類(根)載入器:BootStrapClassLoader。
- c++編寫的,載入java核心庫,構造拓展類載入器和應用程式載入器
- 根載入器載入拓展類載入器,並且將拓展類載入器的父載入器設置為根載入器
- 然後在載入應用程式載入器,應將應用程式的載入器的父載入器設置為拓展類載入器
- 由於根載入器涉及到虛擬機本地實現的細節,我們無法直接獲取到啟動類載入器的引用,這就是上面第三個結果為null的原因
- 載入文件存在於/jdk/jdk1.8/jre/lib/rt.jar
- 拓展類載入器:PlatformClassLoader
- java編寫,載入擴展庫,開發者可以直接使用標準擴展類載入器
- java9之前稱為ExtClassLoader
- 載入文件存在於…/lib/ext
- 應用程式載入器:AppClassLoader
- Java編寫,載入程式所在的目錄,是java默認的類載入器
- 用戶自定義載入器:CustomeClassLoader
- java編寫,用戶自定義的類載入器,可載入指定路徑的class文件
實際上,這些載入器的區別就是載入不同範圍或不同路徑的.class文件。
雙親委派機制
雙親委派機制是類載入器收到類載入的請求,會將這個請求向上委託給父類載入器去完成,一直向上委託,直到根載入器BootStrapClassLoader。根載入器檢查是否能夠載入當前類,能載入就結束,使用當前類載入器,否則就拋出異常,通知子載入器進行載入。
舉個例子,我們重寫java.lang包下的String類:
package java.lang;
public class String{
public String toString(){
return "xing";
}
public static void main(String[] args){
new String().toString;
}
}
//Error:(1,1) java:程式包已存在於另一個模組中:java:base
我們會發現報錯,這就是雙親委派機制起的作用,當類載入器委託到根載入器的時候,String類已經被根載入器載入過一遍了,所以不會再載入,從一定程度上防止了危險程式碼的植入。
作用總結:
- 防止重複載入同一個.class,通過不斷委託父載入器直到根載入器,如果父載入器載入過了,就不用再載入一遍,保證數據安全。
- 保證系統核心.class不被篡改。通過委託方式,不會去篡改核心.class,即使篡改也不會去載入,即使載入也不會是同一個.class對象了。不同的載入器載入同一個.class也不是同一個class對象,這樣保證了class執行安全。
沙箱安全機制
什麼是沙箱
java安全模型的核心就是java沙箱(sandbox)。
沙箱是一個限制程式運行的環境。沙箱機制就是將java程式碼限定在虛擬機特定的運行範圍中,並且嚴格限制程式碼對本地系統資源的訪問,通過這樣的措施來保證對程式碼的有效隔離,防止對本地系統的破壞。
沙箱主要限制系統資源訪問,包括CPU、記憶體、文件系統、網路。不同級別的沙箱對這些資源訪問的限制也不一樣。
所有的java程式運行都可以指定沙箱,可以訂製安全策略。
java中安全模型的演進
在java中將執行程式分為本地程式碼和遠程程式碼兩種:本地程式碼可信任,可以訪問一起本地資源。遠程程式碼不可信任,在早期的java實現中,安全依賴於沙箱機制。
如此嚴格的安全機制也給程式的功能擴展帶來障礙,比如當用戶希望遠程程式碼訪問本地系統文件的時候,就無法實現。因此在後續的java1.1中,針對安全機製做了改進,增加了安全策略,允許用戶指定程式碼對本地資源的訪問許可權。
在java1.2版本中,再次改進了安全機制,增加了程式碼簽名。不論本地程式碼或者遠程程式碼,都會按照用戶的安全策略設定,由類載入器載入到虛擬機中許可權不同的運行空間,來實現差異化的程式碼執行許可權控制。
當前最新的安全機制實現,則引入了域(Domain)的概念。虛擬機會把所有的程式碼載入到不同的系統域和應用域,系統域部分專門負責與關鍵資源進行交互,應用域部分則通過系統域的部分代理來對各種需要的資源進行訪問。虛擬機中不同的受保護域對應不一樣的許可權,存在於不同域中的類文件就具有了當前域的全部許可權。
組成沙箱的基本組件
-
位元組碼校驗器(bytecode verifier)
確保java類文件遵循java語言規範。這樣可以幫助java程式實現記憶體保護。但並不是所有的類文件都會經過位元組碼校驗,比如核心類。
-
類裝載器(class loader)
類裝載器在3個方面對java沙箱起作用
- 防止惡意程式碼去干涉善意的程式碼
- 守護了被信任的類庫邊界
- 將程式碼歸入保護域,確定了程式碼可以進行哪些操作。
虛擬機為不同的類載入器載入的類提供不同的命名空間,命名空間由一系列唯一的名稱組成,每一個被裝載的類將有一個名字,這個命名空間是由java虛擬機為每一個類裝載器維護的,他們互相之間甚至不可見。
-
存取控制器(access controller):存取控制器可以控制核心API對作業系統的存取許可權,而這個控制的策略設定可以由用戶指定。
-
安全管理器(security manager):是核心API和作業系統之間的主要介面。實現許可權控制,比如存取控制器優先順序高。
-
安全軟體包(security package):java.security下的類和擴展包下的類,允許用戶為自己的應用增加新的安全特性,包括安全提供者、消息摘要、數字簽名、加密、鑒別。
Native本地方法介面
JNI:java native interface
本地介面的作用是融合不同的程式語言為java所用,它的初衷是融合C/C++程式。
凡是帶native關鍵字的,就說明java的作用範圍達不到了,會去調用底層c語言庫,進入本地方法棧,調用本地方法介面JNI,拓展java的使用,融合不同的語言為java所用。
java誕生的時候C/C++橫行,為了立足,必須要能夠調用C/C++程式,於是在記憶體區域中專門開闢了一塊標記區域:Native Method Stack,登記Native方法,最終在執行引擎上執行的時候通過JNI載入本地方法庫中的方法。目前該方法的使用越來越少了,除非是與硬體有關的應用,比如通過java程式驅動印表機或者java系統管理生產設備,在企業級應用中已經比較少見。因為現在的異構領域間通訊很發達,比如可以用Socket通訊,也可以使用Web Service等。
運行時數據區
PC暫存器(Program Counter Register)
每個執行緒都有一個程式計數器,是執行緒私有的,就是一個指針,指向方法區中的方法位元組碼(用來存儲指向像一條指令的地址,也即將要執行的指令程式碼),在執行引擎讀取下一條指令,是一個非常小的記憶體空間,幾乎可以忽略不計。
方法區(Method Area)
方法區與java堆一樣,是各個執行緒共享的記憶體區域,用於存儲已被虛擬機載入的類資訊、常量、靜態變數、即時編譯器遍以後的程式碼等數據。雖然java虛擬機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名Non-Heap,因此實際上應該和堆區分開。
方法區中有啥?
- 靜態變數(static)
- 常量(final)
- 類資訊(構造方法,介面定義)
- 運行時的常量池
創建對象記憶體分析
public class Person{
int age;
String name = "xing";
public Person(int age, String name){
this.age = age;
this.name = name;
}
public static void main(String[] agrs){
Person s1 = new Person(18,"Tom");
}
}
/*
創建一個對象時,方法區中會生成對應類的抽象模版;還有對應的常量池、靜態變數、類資訊、常量。
我們通過類模版去new對象的時候,堆中存放實例對象,棧中存放對象的引用,每個對象對應一個地址指向堆中相同地址的實例對象。
*/
棧
主管程式的運行,生命周期和執行緒同步。執行緒結束,棧記憶體就釋放了,不存在垃圾回收。棧中存放8大基本類型,對象引用,實例的方法。
棧運行的原理
棧表示java方法執行的記憶體模型,每調用一個方法就會為每個方法生成一個棧幀(Stack Frame),每個方法被調用的完成的過程,都對應一個棧幀從虛擬機棧上入棧和出棧的過程。程式正在執行的方法一定在棧的頂部。
堆棧溢出(StackOverflowError)
public class Test{
public static void main(String[] args){
new Test().a();
}
public void a(){
b();
}
public void b(){
a();
}
}
//最開始,main()方法壓入棧中,然後執行a(),a()押入棧中,在調用b(),b()押入棧棧中,以此往複,最終導致棧溢出
堆
一個JVM只有一個堆記憶體(棧是執行緒級的),堆記憶體的大小是可以調節的,堆中存放實例化的對象。
堆記憶體詳解
-
年輕代
對象的誕生、成長甚至死亡的區
- Eden Space(伊甸園區):所有對象都是在此new出來的
- Survivor Space(倖存區)
- 倖存0區(From Space),動態的From和To會互相交換
- 倖存1區(To Space)
Eden區佔大容量,Survivor兩個區佔小容量,默認比例是8:1:1。
-
老年代
-
Perm元空間
存儲的是java運行時的一些環境或類資訊,這個區域不存在垃圾回收。關閉虛擬機就會釋放這個區域的記憶體,這個區域常駐記憶體,用來存放JDK自身攜帶的Class對象、Interface元數據。jdk1.8之前被稱為永久代。
注意:元空間在邏輯上存在,在物理上不存在。新生代+老年代的記憶體空間=JVM分配的總記憶體。
什麼是OOM
記憶體溢出,產生原因:
- 分配的太少
- 用的太多
- 用完沒釋放
GC垃圾回收
主要在年輕代和老年代。
首先對象出生在伊甸園區,假設伊甸園區只能存在一定數量的對象,則每當存滿時就會出發一次輕GC(Minor GC)。輕GC清理後,有的對象可能還存在引用,就活下來了,活下來的對象就進入倖存區;有的對象沒用了,就被GC清理掉了;每次輕GC都會使得伊甸園區為空。
如果倖存區和伊甸園區都滿了,則會進入老年代,如果老年代滿了,就會出發一次重GC(FullGC),年輕代+老年代的對象都會清理一次,活下來的對象都進入老年代。
如果新生代和老年代都滿了,則OOM。
- Minor GC:伊甸園區滿時觸發,從年輕代回收記憶體
- Full GC:老年代滿時觸發,清理整個堆空間
- Major GC:清理老年代
什麼情況下永久區會崩?一個啟動類載入了大量的第三方jar包,Tomcat部署了過多應用,或者大量動態生成的反射類,這些東西不斷的被載入,知道記憶體滿,就會出現OOM。
堆記憶體調優
查看並設置JVM堆記憶體
public class Test{
public static void main(String[] args){
//返回jvm試圖使用的最大記憶體
long max = Runtime.getRuntime().maxMemory();
//返回jvm的初始化記憶體
long total = Runtime.getRuntime().totalMemory();
//默認情況下:分配的總記憶體為電腦記憶體的1/4,初始化記憶體為電腦記憶體的1/64
System.out.println("max=" + max / (double) 1024 / 1024 / 1024 + "G");
System.out.println("total=" + total / (double) 1024 / 1024 / 1024 + "G");
}
}
我們可以手動調整堆記憶體的大小,在VM options 中可以指定jvm試圖使用的最大記憶體和jvm初始化記憶體的大小。
-Xms1024m -Xmx1024m -Xlog:gc*
- -Xms用來設置jvm試圖使用的最大記憶體
- -Xmx用來設置jvm初始化記憶體
- -Xlog:gc*用來列印GC垃圾回收資訊
怎麼排除OOM錯誤?
-
嘗試擴大堆記憶體看結果
-
利用記憶體快照工具JProfiler
作用:分析Dump記憶體文件,快速定位記憶體泄漏;獲得堆中的文件;獲得大的對象
Dump文件是進程的記憶體鏡像,可以把程式的執行狀態通過調試器保存到dump文件中
import java.util.ArrayList; public class Test{ byte[] array = new byte[1024*1024];//1M public static void main(String[] args){ ArrayList<Test> list = new ArrayList<>(); int count = 0; try{ while(true){ list.add(new Test()); count++; } }catch(Exception e){ System.out.println("count="+count); e.printStackTrace(); } } }
運行程式,報錯OOM。
接下來設置一下堆記憶體並附加生成dump文件的指令
-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError表示當JVM發生OOM時,自動生成DUMP文件。再次點擊運行,下載了對應的Dump文件。
分析步驟:
- 右鍵該類,點擊Show in Explorer
- 一直點擊上級目錄,直到找到.hprof文件
每次打開dump文件查看完後,建議刪除,打開文件後生成了很多內容,占記憶體。
GC垃圾回收
之前已經堆GC垃圾回收流程進行了大概的講解:JVM在進行GC時,大部分回收都是在年輕代。
GC演算法
-
引用計數法(很少使用)
- 每個對象在創建的時候,就給這個對象綁定一個計數器。
- 每當有一個引用指向該對象時,計數器加一;每當有一個指向它的引用被刪除時,計數器減一;
- 這樣,當沒有引用指向該對象時,該對象死亡,計數器為0,這時就應該對這個對象進行垃圾回收操作。
-
複製演算法
複製演算法主要發生在年輕代(倖存0區和倖存1區)
- 當Eden區滿的時候,會觸發
輕GC
,每觸發一次,活的對象就被轉移到倖存區,死的對象就被GC清理掉,所以每次觸發輕GC時,Eden區就會清空 - 對象被轉移到了倖存區,倖存區又分為
From Space
和To Space
,這兩塊區域是動態交換的,誰是空的誰就是To Space,然後From Space
就會把全部對象轉移到To Space
去; - 那如果兩塊區域都不為空呢?這就用到了
複製演算法
,其中一個區域會將存活的對象轉移到另一個區域去,然後將自己區域的記憶體空間清空,這樣該區域為空,又成為了To Space
- 所以每次觸發
輕GC
後,Eden區清空,同時To區也清空了,所有的對象都在From區
好處:沒有記憶體碎片
壞處:浪費記憶體空間(浪費倖存區一半的空間);對象存活率較高的場景下,需要複製的東西太多,效率會下降。
最佳使用環境:對象存活率較低的時候,也就是年輕代。
- 當Eden區滿的時候,會觸發
-
標記-清除演算法
為每個對象存儲一個標記位,記錄對象的生存狀態。
- 標記階段:這個階段內,為每個對象更新標記位,檢查對象是否死亡。
- 清除階段:該階段對死亡的對象進行清除,執行GC操作。
缺點:兩次掃描嚴重浪費時間;會產生記憶體碎片
優點:不需要額外的空間
-
標記-整理演算法
這個是標記-清除演算法的一個改進版,又叫做標記-清除-壓縮演算法。不同的是在第二個階段,該演算法並沒有直接對死亡的對象進行清理,而是將所有存貨的對象整理一下,放到另一處空間,然後把剩下的所有對象全部清除。可以進一步優化,在記憶體碎片不太多的情況下,就繼續標記清除,到達一定量的時候再壓縮。
有沒有最優的演算法?
沒有最優演算法,只有最合適的。
GC也稱為分代收集演算法,對於年輕代,對象存活率低用複製演算法;對於老年代,區域大,對象存活率高,用標記清除+標記壓縮混合實現。