詳解 JVM 中的方法區
本文將深入JVM將從不同版本的jdk對方法區展開介紹,希望對你有所啟發。

一、元空間基本概念
1. 什么是JVM方法區
方法區主要是用于存儲類信息、靜態變量以及常量信息的。是各個線程共享的一個區域。我們都知道JVM中有個區域叫堆區,所以有時候人們也會稱方法區為Non-Heap(非堆)。
在JDK8之前方法區存放在一個叫永久代的空間里。 在JDK8之后由于HotSpot和JRockit的合并,所以方法區就被作為元數據區了。
2. 方法區和永久代是什么關系?
其實方法區并不是一個實際的區域,他不過是JVM虛擬機規范提出的一個概念而已。在HotSpot實現方法區的方式就在JVM內存中劃分一個區域作為永久代來存放這些數據。
在JDK8之前我們可以用下面的參數來調整永久代的大小:
-XX:PermSize=N //方法區 (永久代) 初始大小
-XX:MaxPermSize=N //方法區 (永久代) 最大大小,超過這個值將會拋出 OutOfMemoryError 異常:java.lang.OutOfMemoryError: PermGen3. 為什么JDK8之后要把永久代 (PermGen)換成元數據區(MetaSpace)
將數據放在永久代固然沒問題,但是隨著時間的推移,方法區使用的空間可能會逐漸變大,若我們分配大小不當很可能造成線上OOM問題,所以設計者們就在方法區移動到本地內存中,通過本地內存來存放數據。并且元數據區默認分配值為unlimited(我們也可以通過-XX:MetaspaceSize來動態調整),理論上是沒有明確大小,是可以動態分配空間的,這樣一來由于元數據區就不會受到JVM內存分配的約束了,所以理論上發生OOM的概率會小于永久代。
4. 深入理解Java虛擬機關于方法區的說法
筆者查閱權威 《深入理解Java虛擬機》中看到,《Java虛擬機規范》對于方法區的實現即元空間或者永久代垃圾回收行為沒有強制要求。 原因很簡單,方法區進行垃圾收集的回收的收益不是很大,它并不像堆內存的新生代那樣,在一次新生代的垃圾回收就能回收 70%-90%的內存空間。這也使得大部分人(包括筆者)認為方法區不涉及GC的,實際上對于 jdk8版本的Hotspot虛擬機而言,JVM中某一個類符合以下這3個條件時將會卸載類并回收這個類的元數據空間:
- 在堆中沒有任何基于當前類或者基于該類派生子類的實例。
- 該類的java.lang.Class對象沒有在任何地方被引用,以及無法通過反射等方式訪問該類的方法。
- 加載該類的類加載器被回收,這個條件除非是精心設計過的可替換類加載器的場景,否者很難實現。
需要注意的是,在判斷是否有實例還在使用當前類以及是否有類加載器引用這個類這兩個步驟的時候,為了能夠明確這兩點,可能需要掃描全部堆空間的,這也就意味著元空間的回收可能伴隨著FullGC。
二、實踐
1. jdk8之后靜態變量存儲在元空間?
靜態變量屬于類變量而非實例變量,這意味著它的生命周期跟隨類而非任何對象實例,對應我們給出下面這段代碼印證,試想我們基于Counter無參構造創建了個對象實例,他們的輸出的id上多少呢?
public class Counter {
//靜態變量count
private static int count = 0;
//實例變量id
private int id;
public int getId() {
return id;
}
public Counter() {
this.id = ++count;
}
}答案是毫無疑問的,對應的兩個對象實例對應的id上1和2,原因很簡單,靜態變量上所有相同類的實例對象所共享,其生命周期伴隨著類加載開始,隨著類卸載而結束:
{"id":1}
{"id":2}那么我們的問題也就來了,jdk8版本將類的元數據信息存到元空間上,同時我們也知道類的元信息記錄著類的字段、方法等描述信息以及靜態字段,那么對應靜態對象是存在堆上還是元空間上呢?
對此我們不妨做這樣一個實驗:
- 將堆內存設置為50M
- 創建一個20M的靜態byte數組
- 通過監控工具查看堆內存分布情況
對應給出如下代碼,這里筆者基于arthas的heapdump指令將其堆內存快找導出并用mat打開:
private static byte[] largeByteArray = new byte[20 * 1024 * 1024];
public static void main(String[] args) throws InterruptedException {
TimeUnit.DAYS.sleep(1);
}可以看到靜態數組byte對應的堆內存占用大約是20m左右,由此說明在jdk8版本對應靜態實例變量分配在堆空間:

結合權威資料的說法,在jdk8版本下,當一個類被加載時,會基于calss文件在元空間中生成該類的元信息,包括但不限于:
- 類的方法定義
- 常量池
- 訪問標志
- 靜態字段
本著java一切皆對象的原則,jvm會基于該元信息在堆內存中生成這個java.lang.class對象作為元信息的容器,包括方法名、字段名、訪問標志,而與之對應的靜態字段的引用也會存儲在這個類對象中,并指向堆中這個靜態字段對應的實例。當多個實例對象訪問靜態字段時,本質上都是基于這個class對象元數據信息從而獲取靜態實例的值:

2. 代理對象創建不當導致元空間OOM問題
可以看到最后一點比較苛刻,所以就導致如果我們使用Spring等框架通過增強技術生成大量的新類型載入元空間內存,導致元空間內存溢出 (Caused by: java.lang.OutOfMemoryError: Metaspace),就像下面這段代碼一樣,為了更快看到效果,我們手動設置一下元空間大小-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m:
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
//設置代理目標
enhancer.setSuperclass(EmptyObject.class);
enhancer.setUseCache(false);
//設置單一回調對象,在調用中攔截對目標方法的調用
enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(objects, args));
enhancer.create();
}
}我們通過jconsole定位查看當前進程的類加載信息:

可以看到大量EmptyObject的增強類被加載至元空間中:

鍵入命令jmap定位加載的類信息再次進行確認:
jmap -histo 4532可以看到生成了大量的net.sf.cglib.proxy相關的類:
num #instances #bytes class name
----------------------------------------------
1: 3824742 600680704 [C
2: 1932145 170028760 java.lang.reflect.Method
3: 3806008 91344192 java.lang.String
4: 1779516 37754664 [Ljava.lang.Class;
5: 26568 15064520 [I
6: 618402 14841648 net.sf.cglib.core.Signature
7: 79344 12595728 java.lang.Class
8: 154765 12381200 java.lang.reflect.Constructor
9: 308844 9883008 net.sf.cglib.proxy.MethodProxy
10: 308844 9883008 net.sf.cglib.proxy.MethodProxy$CreateInfo我們以MethodProxy進行定位可以看到這個類是在create方法創建的,這也就意味著上述代碼的最后一個create方法會創建大量的MethodProxy并存到元空間中導致元空間內存溢出:
public static MethodProxy create(Class c1, Class c2, String desc, String name1, String name2) {
MethodProxy proxy = new MethodProxy();
proxy.sig1 = new Signature(name1, desc);
proxy.sig2 = new Signature(name2, desc);
proxy.createInfo = new CreateInfo(c1, c2);
return proxy;
}所以盡管說jdk8將類信息存到原空間中,但我們日常進行開發也需要留意對于cglib等增強技術的使用是否得當,如果發現大量的增強類出現在元空間時,需要及時定位并解決。























