原文地址:JVM Anatomy Quark #24: Object Alignment
問題
- Java 對(duì)象有對(duì)齊的限制么?
- 我聽說 Java 對(duì)象是 8 字節(jié)對(duì)齊的,是這樣么?
- 我可以通過調(diào)整對(duì)齊來改善壓縮引用的性能么?
理論
許多硬件實(shí)現(xiàn)要求對(duì)數(shù)據(jù)的訪問是對(duì)齊的,這使得 N 字節(jié)寬度數(shù)據(jù)的訪問地址總是 N 的倍數(shù)。即使對(duì)數(shù)據(jù)的普通訪問沒用特殊要求,特殊操作(特別是原子操作)通常也有對(duì)齊約束。
舉例來說,x86 平臺(tái)通常接受未對(duì)齊的讀寫操作,跨越兩個(gè)緩存行的未對(duì)齊 CAS 操作也可以工作,但是對(duì)齊可以提高吞吐量。其它平臺(tái)可能會(huì)直接拒絕這樣的原子操作,產(chǎn)生一個(gè) SIGBUS 信號(hào)或者另外一種硬件異常。對(duì)于跨越多個(gè)緩存行的數(shù)據(jù),x86 平臺(tái)也不會(huì)保證訪問的原子性,這可能發(fā)生在未對(duì)齊的情況下。另外對(duì)于大部分類型和明確聲明 volatile 的訪問,Java 規(guī)范要求保證訪問的原子性。
所以對(duì)于 Java 對(duì)象中的 long 字段,這占用 8 字節(jié)內(nèi)存,基于性能因素,我們需要保證 8 字節(jié)對(duì)齊。如果該字段是 volatile 的,那么基于正確性的因素,我們也需要 8 字節(jié)對(duì)齊。在一個(gè)簡單的方法中[1],為了實(shí)現(xiàn)這一點(diǎn),需要做兩件事:對(duì)象內(nèi)部字段的偏移應(yīng)該 8 字節(jié)對(duì)齊,以及對(duì)象本身應(yīng)該 8 字節(jié)對(duì)齊。java.lang.Long 實(shí)例就是這樣的:[2]
$ java -jar jol-cli.jar internals java.lang.Long
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
java.lang.Long object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) ce 21 00 f8
12 4 (alignment/padding gap)
16 8 long Long.value 0
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
value 字段位于偏移 16 字節(jié)(這是 8 的倍數(shù))的位置,并且對(duì)象 8 字節(jié)對(duì)齊。
即使沒有字段需要被特殊對(duì)待,但是對(duì)象頭部仍然需要原子性訪問。從技術(shù)上講,大部分 Java 對(duì)象可以 4 字節(jié)對(duì)齊,而不是 8 字節(jié),然而實(shí)現(xiàn)這一目標(biāo)所需的運(yùn)行時(shí)工作非常巨大。
所以在 Hotspot 中最小的對(duì)象對(duì)齊是 8 字節(jié)。不過,對(duì)齊可以更大嗎?當(dāng)然可以,有一個(gè)對(duì)應(yīng)的 VM 參數(shù):-XX:ObjectAlignmentInBytes。這帶來兩個(gè)后果,一個(gè)負(fù)面的,一個(gè)正面的。
實(shí)例大小變大
當(dāng)然一旦對(duì)齊變大,就意味著每個(gè)對(duì)象平均的內(nèi)存空間浪費(fèi)將會(huì)增加。例如將對(duì)象對(duì)齊從 16 字節(jié)增加至 128 字節(jié):
$ java -XX:ObjectAlignmentInBytes=16 -jar jol-cli.jar internals java.lang.Long
java.lang.Long object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) c8 10 01 00
12 4 (alignment/padding gap)
16 8 long Long.value 0
24 8 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total
$ java -XX:ObjectAlignmentInBytes=128 -jar jol-cli.jar internals java.lang.Long
java.lang.Long object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) a8 24 01 00
12 4 (alignment/padding gap)
16 8 long Long.value 0
24 104 (loss due to the next object alignment)
Instance size: 128 bytes
Space losses: 4 bytes internal + 104 bytes external = 108 bytes total
見鬼,每個(gè)實(shí)例 128 字節(jié),而只有 8 字節(jié)有用數(shù)據(jù),這太浪費(fèi)了。為什么有人會(huì)這么做?
壓縮引用閾值被移動(dòng)
(一語雙關(guān))
還記得《壓縮引用》中的這張圖嗎?

我們可以通過引用移位對(duì)大于 4GB 的堆內(nèi)存啟用壓縮引用。移位的大小依賴引用中有多少低位比特是零。也就是,對(duì)象是如何對(duì)齊的!默認(rèn) 8 字節(jié)對(duì)齊,那么最低 3 位是零,所以移動(dòng) 3 位,那么就有 2(32+3)字節(jié) = 32 GB 壓縮引用空間。如果 16 字節(jié)對(duì)齊,那么就有 2(32+4)字節(jié) = 64 GB 壓縮引用堆空間!
實(shí)驗(yàn)
所以對(duì)象對(duì)齊增加了實(shí)例大小,這增加了堆內(nèi)存占用;但是可以在大堆內(nèi)存上啟用引用壓縮,這減少了堆內(nèi)存占用!依賴堆內(nèi)存的結(jié)構(gòu)。我們可以使用之前的測(cè)試用例,但是稍微自動(dòng)化一下。
這個(gè)測(cè)試用例嘗試識(shí)別容納給定數(shù)量對(duì)象的最小堆內(nèi)存:
import java.io.*;
import java.util.*;
public class CompressedOopsAllocate {
static final int MIN_HEAP = 0 * 1024;
static final int MAX_HEAP = 100 * 1024;
static final int HEAP_INCREMENT = 128;
static Object[] arr;
public static void main(String... args) throws Exception {
if (args.length >= 1) {
int size = Integer.parseInt(args[0]);
arr = new Object[size];
IntStream.range(0, size).parallel().forEach(x -> arr[x] = new byte[(x % 20) + 1]);
return;
}
String[] opts = new String[]{
"",
"-XX:-UseCompressedOops",
"-XX:ObjectAlignmentInBytes=16",
"-XX:ObjectAlignmentInBytes=32",
"-XX:ObjectAlignmentInBytes=64",
};
int[] lastPasses = new int[opts.length];
int[] passes = new int[opts.length];
Arrays.fill(lastPasses, MIN_HEAP);
for (int size = 0; size < 3000; size += 30) {
for (int o = 0; o < opts.length; o++) {
passes[o] = 0;
for (int heap = lastPasses[o]; heap < MAX_HEAP; heap += HEAP_INCREMENT) {
if (tryWith(size * 1000 * 1000, heap, opts[o])) {
passes[o] = heap;
lastPasses[o] = heap;
break;
}
}
}
System.out.println(size + ", " + Arrays.toString(passes).replaceAll("[\\[\\]]",""));
}
}
private static boolean tryWith(int size, int heap, String... opts) throws Exception {
List<String> command = new ArrayList<>();
command.add("java");
command.add("-XX:+UnlockExperimentalVMOptions");
command.add("-XX:+UseEpsilonGC");
command.add("-XX:+UseTransparentHugePages"); // faster this way
command.add("-XX:+AlwaysPreTouch"); // even faster this way
command.add("-Xmx" + heap + "m");
Arrays.stream(opts).filter(x -> !x.isEmpty()).forEach(command::add);
command.add(CompressedOopsAllocate.class.getName());
command.add(Integer.toString(size));
Process p = new ProcessBuilder().command(command).start();
return p.waitFor() == 0;
}
}
在可以分配 100+ GB 堆的大內(nèi)存機(jī)器上執(zhí)行這個(gè)測(cè)試用例,這將會(huì)得出預(yù)期的結(jié)果。讓我們從平均對(duì)象大小開始討論。注意這些是特定測(cè)試中的平均對(duì)象大小,也就是分配的一些小 byte[] 數(shù)組。結(jié)果如下:

意料之中,增大對(duì)齊增加了平均對(duì)象大?。?6 字節(jié)和 32 字節(jié)對(duì)齊“稍微”增加了對(duì)象大小,但是 64 字節(jié)大幅增加了平均對(duì)象大小。注意對(duì)象對(duì)齊基本上表示對(duì)象的最小大小,一旦最小值增加了,平均值也會(huì)增加。
正如我們?cè)?a target="_blank" rel="nofollow">《壓縮引用》中看到的,堆內(nèi)存達(dá)到 32 GB 壓縮引用將會(huì)失效。但是注意更大的對(duì)齊將會(huì)延遲這一限制,對(duì)齊越大,內(nèi)存空間越大。例如,16 字節(jié)對(duì)齊,將會(huì)在壓縮引用中移動(dòng) 4 位,也就是 64GB 空間。32 字節(jié)對(duì)齊移動(dòng)5 位,也就是 128GB 空間。[3]在這個(gè)測(cè)試中,某些情況下對(duì)齊變大導(dǎo)致的對(duì)象變大將會(huì)與壓縮引用減少的空間占用相抵消。當(dāng)然,當(dāng)壓縮引用最后被關(guān)閉時(shí),對(duì)齊的成本就表現(xiàn)出來了。
在“最小堆大小”圖中可以很清楚看到這種現(xiàn)象:

Here, we clearly see the 32 GB and 64 GB failure thresholds. Notice how 16-byte and 32-byte alignment took less heap in some configurations, piggybacking on more efficient reference encoding. That improvement is not universal: when 8-byte alignment is enough or when compressed references fail, higher alignments waste memory.
在這里我們可以清楚看到 32 GB 和 64 GB 這兩個(gè)閾值。注意 16 字節(jié)和 32 字節(jié)對(duì)齊在某些情況下如何占用更少堆內(nèi)存,也就是借助更有效的引用編碼。這個(gè)改善并不是普遍的:當(dāng) 8 字節(jié)對(duì)齊足夠或者壓縮引用失敗的情況下,更大的對(duì)齊將會(huì)浪費(fèi)內(nèi)存。
結(jié)論
對(duì)象對(duì)齊是一件很有趣的事情。雖然它會(huì)顯著增加對(duì)象大小,但是一旦啟用壓縮引用,這又會(huì)降低整體的內(nèi)存占用。有時(shí)候?yàn)榱藴p少內(nèi)存占用,增加一點(diǎn)兒對(duì)齊是有意義的 [原來如此!]。然而在許多情況下,這將會(huì)增加整體內(nèi)存占用。需要對(duì)給定應(yīng)用程序和給定數(shù)據(jù)集進(jìn)行仔細(xì)研究,以確定調(diào)整對(duì)齊是否有好處。利器當(dāng)慎用。
- 比較麻煩的方法是讓內(nèi)存管理器在不同的起始地址分配對(duì)象。這需要檢查分配路徑上的對(duì)象,從性能角度來看這很有趣。
- 這個(gè)以及之后的內(nèi)存表示都是用 JOL 生成的。
- 粗略計(jì)算:因?yàn)樵?Hotspot 中
-XX:ObjectAlignmentInBytes最大接受 256,這意味著最多移動(dòng) 8 位,也就是 1024 GB 內(nèi)存。