歡迎關注本人公衆號
概述
最近在看hbase源碼,裏面有對象佔用內存大小的計算。正好筆記記錄一下。
一般來說,int佔4個字節,long佔8個字節,等等。但是對象在堆中的存儲不止其包含的字段所佔用的空間,還包括對象頭,對齊填充等信息。接下來就結合hbase源碼分析一下對象在堆中的存儲情況。
原生類型(primitive type)的內存佔用
類型 | 佔用空間 |
---|---|
boolean | 在數組中佔1個字節,單獨使用時佔4個字節 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
boolean佔用內存空間說明
《Java虛擬機規範》一書中的描述:“雖然定義了boolean這種數據類型,但是隻對它提供了非常有限的支持。在Java虛擬機中沒有任何供boolean值專用的字節碼指令,Java語言表達式所操作的boolean值,在編譯之後都使用Java虛擬機中的int數據類型來代替,而boolean數組將會被編碼成Java虛擬機的byte數組,每個元素boolean元素佔8位
”。這樣我們可以得出boolean類型佔了單獨使用是4個字節,在數組中又是1個字節。
那虛擬機爲什麼要用int來代替boolean呢?爲什麼不用byte或short,這樣不是更節省內存空間嗎。大多數人都會很自然的這樣去想,我同樣也有這個疑問,經過查閱資料發現,使用int的原因是,對於當下32位的處理器(CPU)來說,一次處理數據是32位(這裏不是指的是32/64位系統,而是指CPU硬件層面),具有高效存取的特點。
另外也解決了僞共享問題,boolean類型的數據不會因爲佔用空間小而與其他數據類型的數據一起使用,注意這一點是我個人的一個猜測。歡迎來辯。
對象分佈基本概念
如圖,java對象在內存中佔用的空間分爲3類,
- 對象頭(Header);
- 實例數據(Instance Data);
- 對齊填充(Padding)。
而我們常說的基礎數據類型大小主要是指第二類實例數據。
對象頭
上圖中可以看到,對象頭分爲三個部分:
- Mark Word
- 指向類的指針
- 數組長度(只有數組對象纔有)
Mark Word
Mark Word記錄了對象和鎖有關的信息,當這個對象被synchronized關鍵字當成同步鎖時,圍繞這個鎖的一系列操作都和Mark Word有關。
Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit。
Mark Word在不同的鎖狀態下存儲的內容不同,在32位JVM中是這麼存的:
其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。
JDK1.6以後的版本在處理同步鎖時存在鎖升級的概念,JVM對於同步鎖的處理是從偏向鎖開始的,隨着競爭越來越激烈,處理方式從偏向鎖升級到輕量級鎖,最終升級到重量級鎖。
JVM一般是這樣使用鎖和Mark Word的:
1,當沒有被當成鎖時,這就是一個普通的對象,Mark Word記錄對象的HashCode,鎖標誌位是01,是否偏向鎖那一位是0。
2,當對象被當做同步鎖並有一個線程A搶到了鎖時,鎖標誌位還是01,但是否偏向鎖那一位改成1,前23bit記錄搶到鎖的線程id,表示進入偏向鎖狀態。
3,當線程A再次試圖來獲得鎖時,JVM發現同步鎖對象的標誌位是01,是否偏向鎖是1,也就是偏向狀態,Mark Word中記錄的線程id就是線程A自己的id,表示線程A已經獲得了這個偏向鎖,可以執行同步鎖的代碼。
4,當線程B試圖獲得這個鎖時,JVM發現同步鎖處於偏向狀態,但是Mark Word中的線程id記錄的不是B,那麼線程B會先用CAS操作試圖獲得鎖,這裏的獲得鎖操作是有可能成功的,因爲線程A一般不會自動釋放偏向鎖。如果搶鎖成功,就把Mark Word裏的線程id改爲線程B的id,代表線程B獲得了這個偏向鎖,可以執行同步鎖代碼。如果搶鎖失敗,則繼續執行步驟5。
5,偏向鎖狀態搶鎖失敗,代表當前鎖有一定的競爭,偏向鎖將升級爲輕量級鎖。JVM會在當前線程的線程棧中開闢一塊單獨的空間,裏面保存指向對象鎖Mark Word的指針,同時在對象鎖Mark Word中保存指向這片空間的指針。上述兩個保存操作都是CAS操作,如果保存成功,代表線程搶到了同步鎖,就把Mark Word中的鎖標誌位改成00,可以執行同步鎖代碼。如果保存失敗,表示搶鎖失敗,競爭太激烈,繼續執行步驟6。
6,輕量級鎖搶鎖失敗,JVM會使用自旋鎖,自旋鎖不是一個鎖狀態,只是代表不斷的重試,嘗試搶鎖。從JDK1.7開始,自旋鎖默認啓用,自旋次數由JVM決定。如果搶鎖成功則執行同步鎖代碼,如果失敗則繼續執行步驟7。
7,自旋鎖重試之後如果搶鎖依然失敗,同步鎖會升級至重量級鎖,鎖標誌位改爲10。在這個狀態下,未搶到鎖的線程都會被阻塞。
指向類的指針
該指針在32位JVM中的長度是32bit,在64位JVM中長度是64bit。
Java對象的類數據保存在方法區(java8以後在元數據區)。
數組長度
只有數組對象保存了這部分數據。
該數據在32位和64位JVM中長度都是32bit。
實例數據
我們在java類中定義的屬性。
對齊填充
HotSpot的對齊方式爲8字節對齊。
8字節填充的含義也就是不足8字節(或8字節的倍數)則將其填充至8字節(或8字節的倍數,這裏指的是大於當前值的最小一個8的倍數)。如:
原始長度 | 8字節對齊後長度 |
---|---|
6 | 8 |
7 | 8 |
8 | 8 |
9 | 16 |
10 | 16 |
我們來看一下hbase的計算對齊填充後長度的算法:
/**
* Aligns a number to 8.
* @param num number to align to 8
* @return smallest number >= input that is a multiple of 8
*/
public long align(long num) {
//The 7 comes from that the alignSize is 8 which is the number of bytes
//stored and sent together
return ((num + 7) >> 3) << 3;//將數字加7後抹0(後3位0)
}
這裏採用的是二進制位運算算法,位運算在計算機內是性能最高的算法,所以在閱讀開源軟件時,會大量看到二進制位運算。
8字節填充的長度計算其實很簡單,將數值先轉爲2進制,然後將倒數第四位置爲1,如果本來是1則不變。然後將後三位置爲0即可。
以26爲例,26的二進制表述爲11010
,那麼如何才能將倒數第四位置爲1呢?上面算法中加上7,即可以完成進位操作,7是比8小的最大的一個數,二進制是111
。然後進位操作以後,如何清除後面的三位置爲0呢?簡單,右移3位在左移3位即可完成。這也就是上述算法的實現原理。
指針壓縮
不壓縮時指針佔8字節。在64位開啓指針壓縮的情況下 -XX:+UseCompressedOops,存放Class指針的空間大小是4字節。
對象頭佔用空間總結
- 在32位系統下,存放Class指針的空間大小是4字節,MarkWord是4字節,對象頭爲8字節。
- 在64位系統下,存放Class指針的空間大小是8字節,MarkWord是8字節,對象頭爲16字節。
- 在64位開啓指針壓縮的情況下 -XX:+UseCompressedOops,存放Class指針的空間大小是4字節,MarkWord是8字節,對象頭爲12字節。
- 如果對象是數組,那麼額外增加4個字節。
hbase源碼分析–對象佔用空間計算
這裏先簡單介紹一下下面示例的背景。
以hbase 讀取wal日誌文件輸出爲例,裏面計算了對象佔用堆內存大小情況(裏面的total_size_sum和edit heap size就是指對象佔用空間的就算結果):
{
"sequence": 15,
"region": "c06475acdfec83fd5a6bf6d06c796bd1",
"actions": [
{
"qualifier": "HBASE::FLUSH",
"vlen": 103,
"row": "\\x00",
"family": "METAFAMILY",
"value": "\\x08\\x00\\x12\\x02t2\\x1A c06475acdfec83fd5a6bf6d06c796bd1 \\x0E*\\x06\\x0A\\x010\\x12\\x01023t2,,1574775527767.c06475acdfec83fd5a6bf6d06c796bd1.",
"timestamp": 1574840582064,
"total_size_sum": 200
}
],
"table": {
"name": "dDI=",
"nameAsString": "t2",
"namespace": "ZGVmYXVsdA==",
"namespaceAsString": "default",
"qualifier": "dDI=",
"qualifierAsString": "t2",
"systemTable": false,
"nameWithNamespaceInclAsString": "default:t2"
}
}
edit heap size: 240
position: 283
給出對象的java代碼:
public class KeyValue implements ExtendedCell, Cloneable {
protected byte [] bytes = null; // an immutable byte array that contains the KV
protected int offset = 0; // offset into bytes buffer KV starts at
protected int length = 0; // length of the KV starting from offset.
private long seqId = 0;
//省略其他代碼
}
背景:byte數組內存儲的實際字節大小爲146。
那麼根據上面第一節的描述,KeyValue對象佔用,情況爲:
對象頭:8(Mark Word)+4(指向類的指針,啓用了壓縮,所以指針佔4字節,下同)=12
byte[] 數組對象頭:8(Mark Word)+4(指向類的指針) + 4(數組長度,佔4字節)=16
keyvalue大小 = 12(KeyValue對象頭) + 4(bytes屬性對byte[]數組引用) + 4(int類型 offset) + 4(int類型 length ) + 8(long類型 seqId )=32
byte數組字節實際長度:146+16=162. byte數組是一個獨立的對象,所以需要對齊填充。162需要對齊填充,填充結果爲 168
total_size_sum =
keyvalue大小 + bytes數組大小 = 32 + 168(byte[] 數組對象實際佔用存儲空間) = 200
edit heap size = 240 是統計的WALEdit ,源碼在下面。cells 是一個ArrayList,所以在計算的時候,除了上面的200字節,還要加上ArrayList佔用的空間。計算方式跟上面一樣。
public class WALEdit implements HeapSize {
private ArrayList<Cell> cells = null; // KeyValue是Cell的實現類
}
由於不同的JVM實現,或者不同的機器,不同的啓動參數,都可能造成實際佔用空間不同,所以需要再實際運行過程中統計真實的佔用情況,而不是簡單的使用上面的公式計算。
在hbase中有專門統計對象大小的工具類ClassSize
:
REFERENCE = memoryLayout.oopSize();
OBJECT = memoryLayout.headerSize();
ARRAY = memoryLayout.arrayHeaderSize();
ARRAYLIST = align(OBJECT + REFERENCE + (2 * Bytes.SIZEOF_INT)) + align(ARRAY);
//headerSize源碼:
private static class UnsafeLayout extends MemoryLayout {
@SuppressWarnings("unused")
private static final class HeaderSize {
private byte a;
}
public UnsafeLayout() {
}
@Override
int headerSize() {
try {//這裏使用Unsafe本地方法,來獲取對象中第一個元素的偏移量。
return (int) UnsafeAccess.theUnsafe.objectFieldOffset(
HeaderSize.class.getDeclaredField("a"));
} catch (NoSuchFieldException | SecurityException e) {
LOG.error(e.toString(), e);
}
return super.headerSize();
}
//省略其他代碼
}
ObjectFieldOffSet
JAVA中對象的字段的定位可能通過staticFieldOffset方法實現,該方法返回給定field的內存地址偏移量,這個值對於給定的filed是唯一的且是固定不變的。
第一個屬性之前的空間也就是對象頭佔用的空間。以此來判斷對象的大小情況更加準確一些。
其他的也是採用類似運行時動態獲取佔用空間的方式,具體可以閱讀hbase源碼查看。
end.