內存是程序員逃不開的話題,固然Java由於有GC使得咱們不用手動申請和釋放內存,可是瞭解Java內存分配是作內存優化的基礎,若是不瞭解Java內存分配的知識,可能會帶偏咱們內存優化的方向。因此這篇文章咱們以「一個對象佔多少內存」爲引子來談談Java內存分配。 文章基於JDK版本:1.8.0_191java
文章標題提出的問題是」一個對象到底佔多少內存「,看似很簡單,但想說清楚並不容易,但願本文的探討能讓你有收穫。ios
在開始以前我仍是決定先提一個曾經陰魂不散,困擾我好久的問題,瞭解這個問題的答案有助於咱們理解接下來的內容。程序員
想解答這個問題,須要從字節碼入手,還須要咱們瞭解一些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方法,我已經添加了註釋bash
// 加載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,因此指令是編譯期已經肯定了,虛擬機只須要根據指令執行就行,根本不關心它操做的這個地址是什麼類型的,因此也就不用額外的字段去存類型了,解答咱們前面提的問題!app
咱們開始步入正題,要說內存分配,首先就要了解咱們分配的對象,那Java中分配的對象有哪些類型呢?工具
在Java中數據類型分爲二大類。oop
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虛擬機規範定義了對象類型在內存中的存儲規範,因爲如今基本都是64位的虛擬機,因此後面的討論都是基於64位虛擬機。 首先記住公式,對象由 對象頭 + 實例數據 + padding填充字節組成,虛擬機規範要求對象所佔內存必須是8的倍數,padding就是幹這個的
而Java中對象頭由 Markword + 類指針kclass(該指針指向該類型在方法區的元類型) 組成。
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, 這種類型的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存儲的是該對象所屬的類在方法區的地址,因此是一個指針,默認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,那他們分別佔用多大的內存呢?
那該如何驗證咱們的結論呢?畢竟咱們仍是相信眼見爲實!很幸運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,只是編譯器層面的限制,在實際內存中不管是私有的,仍是公開的,都按規則存放在一塊兒,對虛擬機來講並無什麼分別!
咱們常規的認識是對象的分配是在堆上,棧上會有個引用指向該對象(即存儲它的地址),究竟是不是呢,咱們來作個試驗! 咱們在循環內建立一億個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虛擬機上面的分配狀況
咱們前面使用了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是不可見的類,並且它在初始化的時候有檢查當前類的加載器,若是不是系統加載器會報錯。可是好消息是,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的內存分配機制,才能正確的去作內存優化!!。