內存是程序員逃不開的話題,當然Java因爲有GC使得我們不用手動申請和釋放內存,但是瞭解Java內存分配是做內存優化的基礎,如果不瞭解Java內存分配的知識,可能會帶偏我們內存優化的方向。所以這篇文章我們以“一個對象佔多少內存”爲引子來談談Java內存分配。
文章基於JDK版本:1.8.0_191
文章標題提出的問題是”一個對象到底佔多少內存“,看似很簡單,但想說清楚並不容易,希望本文的探討能讓你有收穫。
在開始之前我還是決定先提一個曾經陰魂不散,困擾我很久的問題,瞭解這個問題的答案有助於我們理解接下來的內容。
Java虛擬機如何在運行時知道每一塊內存存儲數據的類型的?
- 我們知道Java中int佔4個字節,short佔2個字節,引用類型在64位機器上佔4個字節(不開啓指針壓縮是8個字節,指針壓縮是默認開啓的),那JVM如何在運行時知道某一塊內存存的值的類型是int還是short或者其他基礎類型,亦或者是引用的地址?比如以int爲例,4個字節只夠存儲int數據本身,並沒有多餘的空間存儲數據的類型!
想解答這個問題,需要從字節碼入手,還需要我們瞭解一些Java虛擬機規範的知識,
來看一個簡單的例子
public class Apple extends Fruit{
private int color;
private String name;
private Apple brother;
private long create_time;
public void test() {
int color = this.color;
String name = this.name;
Apple brother = this.brother;
long create_time = this.create_time;
}
}
很簡單的一個Apple類,繼承於Fruit,有一個test方法,將類成員變量賦值給方法本地變量,還是老套路,javac,javap 查看字節碼
javac Fruit.java Apple.java
javap -verbose Apple.class
// 輸出Apple字節碼
public class com.company.alloc.Apple extends com.company.alloc.Fruit
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#25 // com/company/alloc/Fruit."<init>":()V
#2 = Fieldref #8.#26 // com/company/alloc/Apple.color:I
#3 = Fieldref #8.#27 // com/company/alloc/Apple.name:Ljava/lang/String;
#4 = Fieldref #8.#28 // com/company/alloc/Apple.brother:Lcom/company/alloc/Apple;
#5 = Fieldref #8.#29 // com/company/alloc/Apple.create_time:J
// 省略......
{
// 省略......
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=6, args_size=1
0: aload_0
1: getfield #2 // Field color:I
4: iconst_1
5: iadd
6: istore_1
7: aload_0
8: getfield #3 // Field name:Ljava/lang/String;
11: astore_2
12: aload_0
13: getfield #4 // Field brother:Lcom/company/alloc/Apple;
16: astore_3
17: aload_0
18: getfield #5 // Field create_time:J
21: ldc2_w #6 // long 3l
24: lsub
25: lstore 4
27: return
// 省略......
}
我們重點看Apple類的test方法,我已經添加了註釋
// 加載Apple對象本身到棧
0: aload_0
// 獲取字段,#2 對應常量池中的序列,
// #2 = Fieldref #8.#26 // com/company/alloc/Apple.color:I
// 存儲的類型是int類型
1: getfield #2 // Field color:I
// 加載1這個常量進棧
4: iconst_1
// 執行加法
5: iadd
// 將棧頂的值存到本地變量表1的位置
6: istore_1
// 加載Apple對象本身到棧
7: aload_0
// 獲取字段,#3 對應常量池中的序列,
8: getfield #3 // Field name:Ljava/lang/String;
// 將棧頂的值存到本地變量表2的位置
11: astore_2
// .......
可以看到對於對象的成員變量,會存在一個常量池,保存該對象所屬類的所有字段的索引表,根據這個常量池可以查詢到變量的類型,而字節碼指令對於操作各種類型都有專門的指令,比如存儲int是istore,存儲對象是astore,存儲long是lstore,所以指令是編譯期已經確定了,虛擬機只需要根據指令執行就行,根本不關心它操作的這個地址是什麼類型的,所以也就不用額外的字段去存類型了,解答我們前面提的問題!
我們開始步入正題,要說內存分配,首先就要了解我們分配的對象,那Java中分配的對象有哪些類型呢?
Java數據類型有哪些
在Java中數據類型分爲二大類。
- 基礎數據類型(primitive type)
- 引用類型 (reference type)
基礎數據類型
Java中基礎數據類型有8種,分別是byte(1), short(2), int(4), long(8), float(4), double(8), char(2), boolean(1), 括號裏面是它們佔用的字節數,所以對於基礎數據類型,它們所佔用的內存是很確定的,也就沒什麼好說的, 簡單的記憶一下每種類型存儲所需的字節數即可。
Java中基礎數據類型是在棧上分配還是在堆上分配?
我們繼續深究一下,基本數據類佔用內存大小是固定的,那具體是在哪分配的呢,是在堆還是棧還是方法區?大家不妨想想看!
要解答這個問題,首先要看這個數據類型在哪裏定義的,有以下三種情況。
- 如果在方法體內定義的,這時候就是在棧上分配的
- 如果是類的成員變量,這時候就是在堆上分配的
- 如果是類的靜態成員變量,在方法區上分配的
引用類型
引用類型跟基礎數據類型不一樣,除了對象本身之外,還存在一個指向它的引用(指針),指針佔用的內存在64位虛擬機上8個字節,如果開啓指針壓縮是4個字節,默認是開啓了的。
爲了方便說明,還是以代碼爲例
class Kata {
// str1和它指向的對象 都在堆上
String str1 = new String();
// str2和它指向的對象都在方法區上
static String str2 = new String();
public void methodTest() {
// str3 在棧上,它指向的對象在堆上(也有可能在棧上,後面會說明)
String str3 = new String();
}
}
}
Java對象到底佔多大內存?
指針的長度是固定的,不去說它了,重點看它所指向的對象在內存中佔多少內存。
Java對象有三大類
- 類對象
- 數組對象
- 接口對象
Java虛擬機規範定義了對象類型在內存中的存儲規範,由於現在基本都是64位的虛擬機,所以後面的討論都是基於64位虛擬機。
首先記住公式,對象由 對象頭 + 實例數據 + padding填充字節組成,虛擬機規範要求對象所佔內存必須是8的倍數,padding就是幹這個的
對象頭
而Java中對象頭由 Markword + 類指針kclass(該指針指向該類型在方法區的元類型) 組成。
Markword
Hotspot虛擬機文檔 “oops/oop.hp”有對Markword字段的定義
64 bits:
--------
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)
這裏簡單解釋下這幾種object
- normal object,初始new出來的對象都是這種狀態
- biased object,當某個對象被作爲同步鎖對象時,會有一個偏向鎖,其實就是存儲了持有該同步鎖的線程id,關於偏向鎖的知識這裏就不再贅述了,大家可以自行查閱相關資料。
- CMS promoted object 和 CMS free block 我也不清楚到底是啥,但是看名字似乎跟CMS 垃圾回收器有關,這裏我們也可以暫時忽略它們
我們主要關注normal object,
這種類型的Object的 Markword 一共是8個字節(64位),其中25位暫時沒有使用,31位存儲對象的hash值(注意這裏存儲的hash值對根據對象地址算出來的hash值,不是重寫hashcode方法裏面的返回值),中間有1位沒有使用,還有4位存儲對象的age(分代回收中對象的年齡,超過15晉升入老年代),最後三位表示偏向鎖標識和鎖標識,主要就是用來區分對象的鎖狀態(未鎖定,偏向鎖,輕量級鎖,重量級鎖)
// 無其他線程競爭的情況下,由normal object變爲biased object
synchronized(object)
biased object的對象頭Markword前54位來存儲持有該鎖的線程id,這樣就沒有空間存儲hashcode了,所以
對於沒有重寫hashcode的對象,如果hashcode被計算過並存儲在對象頭中,則該對象作爲同步鎖時,不會進入偏向鎖狀態,因爲已經沒地方存偏向thread id了,所以我們在選擇同步鎖對象時,最好重寫該對象的hashcode方法,使偏向鎖能夠生效。
kclass
kclass存儲的是該對象所屬的類在方法區的地址,所以是一個指針,默認Jvm對指針進行了壓縮,用4個字節存儲,如果不壓縮就是8個字節。
關於Compressed Oops的知識,大家可以自行查閱相關資料來加深理解。
Java虛擬機規範要求對象所佔空間的大小必須是8字節的倍數,之所以有這個規定是爲了提高分配內存的效率,我們通過實例來做說明
class Fruit extends Object {
private int size;
}
Object object = new Object();
Fruit fruit = new Fruit();
有一個Fruit類繼承了Object類,我們分別新建一個object和fruit,那他們分別佔用多大的內存呢?
- 先來看object對象,通過上面的知識,它的Markword是8個字節,kclass是4個字節, 加起來是12個字節,加上4個字節的對齊填充,所以它佔用的空間是16個字節。
- 再來看fruit對象,同樣的,它的Markword是8個字節,kclass是4個字節,但是它還有個size成員變量,int類型佔4個字節,加起來剛好是16個字節,所以不需要對齊填充。
那該如何驗證我們的結論呢?畢竟我們還是相信眼見爲實!很幸運Jdk提供了一個工具jol-core可以讓我們來分析對象頭佔用內存信息。
jol的使用也很簡單
// 打印對象頭信息代碼
System.out.println(ClassLayout.parseClass(Object.class).toPrintable());
System.out.println(ClassLayout.parseClass(Fruit.class).toPrintable());
// 輸出結果
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.aliosuwang.jol.Fruit object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Fruit.size N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
可以看到輸出結果都是16 bytes,跟我們前面的分析結果一致。
除了類類型和接口類型的對象,Java中還有數組類型的對象,數組類型的對象除了上面表述的字段外,還有4個字節存儲數組的長度(所以數組的最大長度是Integer.MAX)。所以一個數組對象佔用的內存是 8 + 4 + 4 = 16個字節,當然這裏不包括數組內成員的內存。
我們也運行驗證一下。
String[] strArray = new String[0];
System.out.println(ClassLayout.parseClass(strArray.getClass()).toPrintable());
// 輸出結果
[Ljava.lang.String; object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 16 (object header) N/A
16 0 java.lang.String String;.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
輸出結果object header的長度也是16,跟我們分析的一致。到這裏對象頭部分的內存分配我們就瞭解的差不多了,接下來看對象的實例數據部分。
對象的實例數據(成員變量)的分配規則
爲了方便說明,我們新建一個Apple類繼承上面的Fruit類
public class Apple extends Fruit{
private int size;
private String name;
private Apple brother;
private long create_time;
}
// 打印Apple的對象分佈信息
System.out.println(ClassLayout.parseClass(Apple.class).toPrintable());
// 輸出結果
com.aliosuwang.jol.Apple object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Fruit.size N/A
16 8 long Apple.create_time N/A
24 4 int Apple.size N/A
28 4 java.lang.String Apple.name N/A
32 4 com.company.alloc.Apple Apple.brother N/A
36 4 (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可以看到Apple的對象頭12個字節,然後分別是從Fruit類繼承來的size屬性(雖然Fruit的size是private的,還是會被繼承,與Apple自身的size共存),還有自己定義的4個屬性,基礎數據類型直接分配,對象類型都是存的指針佔4個字節(默認都是開啓了指針壓縮),最終是40個字節,所以我們new一個Apple對象,直接就會佔用堆棧中40個字節的內存,清楚對象的內存分配,讓我們在寫代碼時心中有數,應當時刻有內存優化的意識!
這裏又引出了一個小知識點,上面其實已經標註出來了。
父類的私有成員變量是否會被子類繼承?
答案當然是肯定的,我們上面分析的Apple類,父類Fruit有一個private類型的size成員變量,Apple自身也有一個size成員變量,它們能夠共存。注意劃重點了,類的成員變量的私有訪問控制符private,只是編譯器層面的限制,在實際內存中不論是私有的,還是公開的,都按規則存放在一起,對虛擬機來說並沒有什麼分別!
方法內部new的對象是在堆上還是棧上?
我們常規的認識是對象的分配是在堆上,棧上會有個引用指向該對象(即存儲它的地址),到底是不是呢,我們來做個試驗!
我們在循環內創建一億個Apple對象,並記錄循環的執行時間,前面已經算過1個Apple對象佔用40個字節,總共需要4GB的空間。
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
newApple();
}
System.out.println("take time:" + (System.currentTimeMillis() - startTime) + "ms");
}
public static void newApple() {
new Apple();
}
我們給JVM添加上-XX:+PrintGC運行配置,讓編譯器執行過程中輸出GC的log日誌
// 運行結果,沒有輸出任何gc的日誌
take time:6ms
1億個對象,6ms就分配完成,而且沒有任何GC,顯然如果對象在堆上分配的話是不可能的,其實上面的實例代碼,Apple對象全部都是在棧上分配的,這裏要提出一個概念指針逃逸,newApple方法中新建的對象Apple並沒有在外部被使用,所以它被優化爲在棧上分配,我們知道方法執行完成後該棧幀就會被清空,所以也就不會有GC。
我們可以設置虛擬機的運行參數來測試一下。
// 虛擬機關閉指針逃逸分析
-XX:-DoEscapeAnalysis
// 虛擬機關閉標量替換
-XX:-EliminateAllocations
在VM options裏面添加上面二個參數,再運行一次
[GC (Allocation Failure) 236984K->440K(459776K), 0.0003751 secs]
[GC (Allocation Failure) 284600K->440K(516608K), 0.0004272 secs]
[GC (Allocation Failure) 341432K->440K(585216K), 0.0004835 secs]
[GC (Allocation Failure) 410040K->440K(667136K), 0.0004655 secs]
[GC (Allocation Failure) 491960K->440K(645632K), 0.0003837 secs]
[GC (Allocation Failure) 470456K->440K(625152K), 0.0003598 secs]
take time:5347ms
可以看到有很多GC的日誌,而且運行的時間也比之前長了很多,因爲這時候Apple對象的分配在堆上,而堆是所有線程共享的,所以分配的時候肯定有同步機制,而且觸發了大量的gc,所以效率低很多。
總結一下: 虛擬機指針逃逸分析是默認開啓的,對象不會逃逸的時候優先在棧上分配,否則在堆上分配。
到這裏,關於“一個對象佔多少內存?”這個問題,已經能回答的相當全面了。但是畢竟我們分析的只是Hotspot虛擬機,我們不妨延伸一下,看在Android ART虛擬機上面的分配情況
獲取Android ART虛擬機上面的對象頭大小
我們前面使用了jol工具來輸出對象頭的信息,但是這個jol工具只能用在hotspot虛擬機上,那我們如何在Android上面獲取對象頭大小呢?
方法的靈感來源
辦法肯定是有的,我這裏介紹的辦法,靈感的主角就是AtomicInteger,我是受到它的啓發,這個類我們知道是線程安全的int的包裝類。它的實現原理是利用了Unsafe包提供的CAS能力,不妨看下它的源碼實現
private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
private static final long VALUE;
static {
try {
VALUE = U.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
private volatile int value;
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
我們知道普通int對象的++操作不是原子性的,AtomicInteger提供了getAndIncrement()它卻能保證原子性,這一部分知識不是我們這篇要講的知識點,就不去說它們了。
getAndIncrement()方法內部調用了Unsafe對象的getAndAddInt()方法,第二個參數是VALUE,這個VALUE大有玄機,它表示成員變量在對象內存中的便宜地址,根據前面的知識,普通對象的結構 就是 對象頭+實例數據+對齊字節,那如果我們能獲取到第一個實例數據的偏移地址,其實就是獲得了對象頭的字節大小。
如何拿到並使用Unsafe
因爲Unsafe是不可見的類,而且它在初始化的時候有檢查當前類的加載器,如果不是系統加載器會報錯。但是好消息是,AtomicInteger中定義了一個Unsafe對象,而且是靜態的,我們可以直接通過反射來得到。
public static Object getUnsafeObject() {
Class clazz = AtomicInteger.class;
try {
Field uFiled = clazz.getDeclaredField("U");
uFiled.setAccessible(true);
return uFiled.get(null);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
拿到了Unsafe,我們就可以通過調用它的objectFieldOffset靜態方法來獲取成員變量的內存偏移地址。
public static long getVariableOffset(Object target, String variableName) {
Object unsafeObject = getUnsafeObject();
if (unsafeObject != null) {
try {
Method method = unsafeObject.getClass().getDeclaredMethod("objectFieldOffset", Field.class);
method.setAccessible(true);
Field targetFiled = target.getClass().getDeclaredField(variableName);
return (long) method.invoke(unsafeObject, targetFiled);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
return -1;
}
public static void printObjectOffsets(Object target) {
Class targetClass = target.getClass();
Field[] fields = targetClass.getDeclaredFields();
for (Field field : fields) {
String name = field.getName();
Log.d("offset", name + " offset: " + getVariableOffset(target, name));
}
}
我們來使用上面的工具測試打印之前的Fruit和Apple,
Log.d("offset", "------start print fruit offset!------");
Utils.printObjectOffsets(new Fruit());
Log.d("offset", "------start print apple offset!------");
Utils.printObjectOffsets(new Apple());
// 輸出結果 (Android 8.0模擬器)
offset: ------start print fruit offset!------
offset: size offset: 8
offset: ------start print apple offset!------
offset: brother offset: 12
offset: create_time offset: 24
offset: id offset: 20
offset: name offset: 16
通過輸出結果,看出在 Android8.0 ART 虛擬機上,對象頭的大小是8個字節,這跟hotspot虛擬機不同(hotspot是12個字節默認開啓指針壓縮),根據輸出的結果目前只發現這一點差別,各種數據類型佔用的字節數都是一樣的,比如int佔4個字節,指針4個字節,long8個字節等,都一樣。
總結
全文我們總結了以下幾個知識點
- Java虛擬機通過字節碼指令來操作內存,所以可以說它並不關心數據類型,它只是按指令行事,不同類型的數據有不同的字節碼指令。
- Java中基本數據類型和引用類型的內存分配知識,重點分析了引用類型的對象頭,並介紹了JOL工具的使用
- 延伸到Android平臺,介紹了一種獲取Android中對象的對象頭信息的方法,並對比了ART和Hotspot虛擬機對象頭長度的差別。
瞭解這些並不是爲了裝逼炫技,說實話,寫代碼做工程的沒什麼好裝的,用的都是別人的輪子,我只會感謝我知道這些還不算太晚,所以我把它們寫出來分享給大家。