内存对齐
Java内存对齐(Memory Alignment)是一种通过填充(padding)字节来确保内存中的数据按特定边界对齐的技术,目的是提高程序的内存访问效率。内存对齐的核心思想是在访问内存时,CPU可以更高效地读取和写入数据,因为现代处理器通常一次会读取固定大小的数据块(如 4 字节或 8 字节)。如果数据是对齐的,处理器能够以更少的指令读取数据。
在Java中,内存对齐的概念主要与对象的内存布局有关。JVM(Java虚拟机)会在分配对象时考虑对齐,以提高内存访问效率,尤其是在涉及到多字节数据类型(如 long
和 double
)时。
Java内存对齐的几个关键点:
- 对象头(Object Header) :
- Java对象在内存中的起始部分是对象头,包含标记字段(Mark Word)和类型指针(class pointer),通常为 8 或 12 字节,具体取决于 JVM 和压缩指针(compressed oops)的使用情况。
- 字段对齐 :
- JVM 对对象的实例字段进行内存对齐。为了优化访问性能,JVM 会按照字段的数据类型将其对齐到特定边界:
byte
、boolean
类型对齐到 1 字节边界。short
、char
对齐到 2 字节边界。int
、float
对齐到 4 字节边界。long
、double
对齐到 8 字节边界。
- 如果字段的起始位置不是其对齐边界的整数倍,JVM 会在前面插入填充字节来对齐。
- JVM 对对象的实例字段进行内存对齐。为了优化访问性能,JVM 会按照字段的数据类型将其对齐到特定边界:
- 对象的整体对齐 :
- JVM 会按照8字节对齐Java对象的内存分配,也就是说,每个对象的大小都会是8字节的倍数。如果对象大小不是8字节的倍数,JVM 会在对象末尾添加填充字节以满足对齐要求。
- 性能影响 :
- 内存对齐的目的是减少CPU访问内存时的开销。当数据是对齐的,CPU可以在单次内存操作中读取或写入更多数据,减少不必要的指令数。
- 如果数据没有对齐,可能会导致跨内存边界的访问,增加CPU的内存读取指令次数,降低性能。
内存对齐的示例:
java
class Example {
byte a;
int b;
long c;
}
在这个类中,JVM可能会按照以下方式为对象布局内存:
byte a
使用 1 字节。int b
需要对齐到 4 字节边界。long c
需要对齐到 8 字节边界。
内存布局可能是这样的:
java
| byte a (1 byte) | padding (3 bytes) | int b (4 bytes) | long c (8 bytes) |
因此,总大小为 16 字节(8 的倍数)。
结论:
Java的内存对齐是一种JVM优化手段,用来确保对象和字段按特定的边界对齐,从而提高内存访问性能。尽管Java开发者通常无需直接处理内存对齐细节,但了解它有助于理解JVM的内存管理和程序性能优化。
Object Header
在JDK 21中,Object Header
的大小取决于是否启用了指针压缩(Compressed Oops),以及运行时平台(32位或64位)。以下是相关的详细说明:
1. Object Header 的组成
Object Header
通常包含两部分:
- Mark Word:用于存储对象的标志信息(如哈希码、锁信息、垃圾回收相关信息等)。
- Class Pointer:指向该对象的类元数据,用于识别对象的类型。
在64位 JVM 中,默认情况下启用了 指针压缩(Compressed Oops),以减少对象指针的大小,从而节省内存。
2. Object Header 大小
- 64位 JVM (指针未压缩时,通常用于堆内存超过32GB的情况):
Mark Word
:8字节Class Pointer
:8字节- 总计:16字节
- 64位 JVM (默认启用指针压缩):
Mark Word
:8字节Class Pointer
:由于启用了指针压缩,类指针的大小变为 4 字节。- 总计:12字节
- 32位 JVM :
Mark Word
:4字节Class Pointer
:4字节- 总计:8字节
3. 指针压缩后的情况
当启用了指针压缩(默认启用,除非堆内存超过 32GB 或手动禁用时),类指针大小会由 8 字节压缩为 4 字节。此时:
- 64位 JVM :
Mark Word
:8字节Class Pointer
:4字节(压缩后)- 总计:12字节
- 32位 JVM:不涉及指针压缩,依然是 8 字节的对象头。
4. 额外注意事项
除了 Mark Word
和 Class Pointer
,某些情况下,数组类型的对象还会有一个额外的 4 字节来存储数组的长度,因此数组对象的头部会稍大一些。
总结
- 64位 JVM 不启用指针压缩时 :Object Header 大小是 16字节。
- 64位 JVM 启用指针压缩时(默认) :Object Header 大小是 12字节。
- 32位 JVM :Object Header 大小是 8字节。
32GB的特例
原因
当堆内存小于32GB时,压缩指针的技术是非常有效的,因为大部分对象的引用地址在32位范围内就足够使用了,因此可以通过压缩指针来节省大量内存。但是,当堆内存大于32GB时,JVM默认禁用指针压缩,这是出于以下几个主要原因:
- 寻址范围限制: 指针压缩使用32位指针来表示对象引用,但它通过位移(一般是以8字节为单位)来表示实际地址。这样,32位指针的最大可寻址范围是 2^32 * 8 = 32GB。如果堆内存超过32GB,压缩指针无法继续使用32位引用表示整个堆内存,因为这已经超出了压缩指针的寻址范围。
- 性能开销: JVM在使用指针压缩时,需要在对象访问时进行压缩和解压缩操作,这会增加一定的CPU开销。对于大于32GB的堆,JVM选择禁用指针压缩来避免这种性能损耗,尽管这会增加指针的大小(从32位变成64位)。
- 设计折中: 32GB是一个合理的折中点。它在保证堆内存不超过32GB时,通过指针压缩优化内存利用率,而当堆超过32GB时,虽然禁用了压缩指针导致内存占用增加,但避免了复杂的压缩机制带来的性能开销。对于大内存的应用场景,性能往往比内存占用更重要,因此这个限制是合理的。
总结
JVM在默认情况下启用指针压缩,但如果堆内存超过32GB,就会禁用指针压缩。这个32GB的特例是因为:
- 32GB是压缩指针的最大寻址范围。
- 超过32GB的堆内存中,压缩指针带来的性能提升并不显著,而且需要解压指针反而可能增加开销。
如果用户手动配置,JVM也允许在超过32GB的情况下继续使用压缩指针,不过这通常不是默认行为。
对象图
对象图(Object Graph)指的是对象之间通过引用相互关联所形成的图结构。简单来说,它描述了对象之间的引用关系,包括对象嵌套、对象的引用层次等。
当我们谈论对象图时,特别是在计算内存占用时,指的是不仅需要考虑一个对象本身的大小,还需要考虑它所引用的其他对象(如果是引用类型)的大小。这就涉及到多层嵌套对象的情况。例如:
java
class Node {
String data;
Node next;
}
在这个例子中,Node
对象不仅包含基本类型(如data
,String
对象本身也是引用类型),它还包含一个 next
引用,它指向另一个 Node
对象。这种引用关系可以继续嵌套,形成一棵链式结构的对象图。
因此,对象图可以是简单的(只包含少量对象),也可以是非常复杂的(对象之间通过多个引用形成深层嵌套的关系)。
对象图的内存占用计算
当你要计算某个对象的内存占用时,如果该对象包含引用类型,尤其是包含嵌套的对象(即对象图),需要递归地计算整个对象图中所有对象的大小。这被称为深度计算(Deep Size Calculation)。例如:
java
class Person {
String name;
Address address;
}
class Address {
String city;
String street;
}
在这个例子中,Person
对象包含了一个 Address
对象,而 Address
对象又包含了两个 String
对象。因此,在计算 Person
对象的内存占用时,不能仅仅计算 Person
本身的字段,还要计算其引用的 Address
对象和 String
对象的大小。
像 JOL
或 Instrumentation
中的 GraphLayout.parseInstance()
可以帮助你计算整个对象图(包括所有嵌套对象)的内存占用,确保你获得的是对象和它所引用对象的总大小。
对象图示例
假设一个Person
对象引用了一个Address
,Address
又包含多个String
对象,那么对象图就会像这样:
plain
Person
├── name (String)
└── address (Address)
├── city (String)
└── street (String)
这个结构就是一个简单的对象图。如果你想计算 Person
对象的大小,必须计算所有相关对象的内存占用,也就是 Person
+ String name
+ Address
+ String city
+ String street
的总和。
深度和浅度内存大小
- 浅度大小(Shallow Size) :指仅计算对象本身的内存大小,而不包括它所引用的对象。例如,计算
Person
对象时,只计算它的两个字段name
和address
的引用大小,而不递归计算引用的对象本身的大小。 - 深度大小(Deep Size):指计算对象以及它所引用的所有对象的内存大小,包含了整个对象图的所有部分。
估算对象大小
在Java中,直接评估一个对象的内存占用大小并不是一个简单的任务,因为JVM会对对象内存进行优化,包括对象头、字段对齐、对象指针压缩等。因此,Java标准库中并没有直接提供测量对象内存占用大小的工具。
不过,可以使用一些外部工具和类库来帮助评估Java对象的内存大小。以下是常用的几种方式:
1. **java.lang.instrument.Instrumentation**
** 类**
这是Java提供的一个标准方式来获取对象的内存大小,但它不能直接使用,必须通过JVM代理(Agent)来获取 Instrumentation
实例。通过 Instrumentation
类的 getObjectSize(Object obj)
方法,可以准确获取对象的大小。通常这个方法会用来精确计算对象内存大小。
示例代码:
需要首先创建一个代理类(Agent
),并在启动时通过 -javaagent
参数加载该代理。
- 创建一个
**Agent**
类:
java
import java.lang.instrument.Instrumentation;
public class ObjectSizeAgent {
private static Instrumentation instrumentation;
public static void premain(String agentArgs, Instrumentation inst) {
instrumentation = inst;
}
public static long getObjectSize(Object obj) {
return instrumentation.getObjectSize(obj);
}
}
- 使用
**Agent**
获取对象大小:
java
public class ObjectSizeTest {
public static void main(String[] args) {
String str = "Hello World!";
System.out.println("Object size: " + ObjectSizeAgent.getObjectSize(str) + " bytes");
}
}
- 启动程序时 :需要添加
-javaagent
选项:
java
java -javaagent:path/to/your-agent.jar ObjectSizeTest
这种方式提供了比较精确的内存大小计算,但由于需要通过 -javaagent
来启动应用程序,使用场景相对有限。
2. 使用 **jol**
(Java Object Layout)工具
JOL
是一个Java对象布局工具,它可以帮助分析对象在JVM中的布局,并估算对象的内存占用情况。JOL
提供了一系列API来检测对象的内存大小。
示例代码:
xml
<!-- Maven dependency -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class ObjectSizeTest {
public static void main(String[] args) {
String str = "Hello World!";
// 获取对象的基本布局
System.out.println(ClassLayout.parseInstance(str).toPrintable());
// 获取对象的内存大小
System.out.println("Shallow size: " + GraphLayout.parseInstance(str).totalSize() + " bytes");
}
}
ClassLayout.parseInstance()
:打印对象在内存中的布局。GraphLayout.parseInstance()
:获取对象的深度内存使用情况,包含对象引用的大小。
JOL
的好处是使用简单,无需 JVM 代理,可以轻松集成到Java项目中,并且可以处理复杂对象图的内存计算。
3. 手动估算对象大小
虽然不推荐,但在某些情况下可以手动估算对象的内存占用,考虑以下几个因素:
- 对象头:在64位JVM中,通常是12字节(启用了指针压缩)或16字节(未启用指针压缩)。
- 字段大小 :基本类型字段占用固定的内存大小:
byte
、boolean
:1字节short
、char
:2字节int
、float
:4字节long
、double
:8字节
- 引用类型:4字节(启用了指针压缩)或8字节(未启用指针压缩)。
- 对象对齐:JVM会对对象按8字节或16字节进行内存对齐。
虽然可以通过这些规则进行粗略估算,但很难计算出复杂对象或引用对象的实际内存占用,特别是在涉及指针压缩、字段对齐以及对象图时。
4. **Runtime**
** 类的内存使用**
Runtime
类可以获取JVM的整体内存使用情况,但它不能精确测量单个对象的内存占用。可以通过比较操作前后内存来间接估算对象的大小:
java
public class MemoryUsageTest {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
// 清理垃圾回收器
runtime.gc();
// 获取初始内存使用
long beforeMemory = runtime.totalMemory() - runtime.freeMemory();
// 创建对象
String str = "Hello World!";
// 获取对象创建后的内存使用
long afterMemory = runtime.totalMemory() - runtime.freeMemory();
// 估算对象大小
long objectSize = afterMemory - beforeMemory;
System.out.println("Estimated object size: " + objectSize + " bytes");
}
}
这种方式只是粗略的估算,因为JVM中的垃圾回收和其他内存分配可能影响结果。
结论
- 最精确的方法 :使用
java.lang.instrument.Instrumentation
来获取对象大小,但需要通过-javaagent
代理来运行。 - 较为方便的方法 :使用
JOL
库来分析对象布局并计算对象大小。JOL
提供了更直观的API,尤其适合分析复杂对象。 - 手动估算 或 使用
**Runtime**
类:这两种方法只适合粗略估算,并不精确。
因此,实际开发中,使用 JOL
或 Instrumentation
是推荐的方法。
区分32 位与64 位地址
如何判断:
1. 32 位地址:
- 32 位地址 指的是对象的引用(指针)只使用 4 字节(32 位)。
- 在 16 进制表示 中,32 位地址通常长度为 8 个字符 (0x 后面是 8 个字符),即最多到
0xFFFFFFFF
。
在输出中,例如 0x00000000e0000000
,这是一个 8 个字符的地址,看起来像 32 位地址,或者启用了压缩指针(Compressed Oops)的情况。
2. 64 位地址:
- 64 位地址 指的是对象的引用使用 8 字节(64 位)。
- 在 16 进制表示 中,64 位地址的长度通常是 16 个字符 ,例如
0x00000000FFFFFFFFFFFFFFFF
。
如果 JVM 禁用了指针压缩,并且在使用 64 位地址,通常会看到 16 个字符长度的地址,而不是 8 个字符。
判断依据:
- 提供的地址是类似
0x00000000e0000000
这样的 8 字符地址,意味着可能启用了指针压缩。在启用指针压缩时,虽然 JVM 是 64 位的,但对象引用会被压缩成 32 位,因此地址长度仍然是 32 位(即 8 个字符的地址)。 - 如果地址是 16 个字符 (例如
0x00007FFFDAC00000
),则表示 JVM 在使用 64 位指针。
总结:
在案例中,地址像 0x00000000e0000000
和 0x00000000f0a00000
都是 32 位的地址(8 字符长度),所以很有可能 JVM 启用了指针压缩(Compressed Oops)。如果这些地址是 16 个字符长的,则可以确定 JVM 使用的是 64 位地址,并且指针压缩没有启用。
这就是如何通过地址长度来判断是否使用了压缩指针(32 位)还是完整的 64 位地址。
指针压缩
指针压缩的优势
指针压缩是一种优化技术,主要目的是在 64 位 JVM 中减少内存中对象引用(指针)的大小。通常,64 位 JVM 中的对象指针需要 8 个字节,但通过指针压缩,指针可以缩小为 4 个字节,从而:
- 节省内存:由于指针占用更少的空间,可以在堆内存中放入更多的对象,降低内存的总体占用,尤其在大量小对象存在时优势明显。
- 提高缓存命中率:由于对象占用的空间减少,更多的对象数据可以加载到 CPU 缓存中,从而提高性能。
32GB 堆内存场景下的指针压缩
JVM 在启用指针压缩时,默认可以支持高达 32GB 的堆内存。如果堆内存大于 32GB,JVM 会自动禁用指针压缩(UseCompressedOops = false
)。因此,当你的 JVM 堆大小正好为 32GB 时,指针压缩可能会自动启用。
指针压缩的潜在风险
虽然启用指针压缩可以带来内存和性能方面的好处,但在某些情况下,可能会存在一些潜在的风险或需要考虑的地方:
- 对象访问的额外开销: 指针压缩通过使用偏移量来减少指针大小,指针需要经过解压缩处理才能访问实际内存地址。这意味着在访问内存时,可能需要额外的指针解压缩操作。虽然这在大多数场景下对性能影响较小,但在某些高性能、低延迟应用中,可能会有轻微的性能损失。
- 超过 32GB 堆大小时自动禁用: 当 JVM 堆大小超过 32GB 时,指针压缩会自动禁用,JVM 会使用完整的 64 位指针。此时,内存占用会显著增加,因此如果你的堆内存配置接近 32GB 边界,建议仔细监控内存使用情况,避免突然禁用压缩指针后内存占用激增。
- 大对象分配效率下降: 如果你的应用程序需要频繁分配非常大的对象(如大型数组、缓存等),指针压缩在这类场景中可能不会带来明显的好处。虽然指针压缩减少了对象头的大小,但它不会对那些超大对象的内存占用有太大影响。
- 调试复杂度: 启用指针压缩时,内存地址和对象引用的表示形式与 64 位系统的标准内存布局不同。对于一些低层次的内存调试、垃圾收集分析工具或者一些依赖于具体内存布局的应用,启用指针压缩可能会导致分析过程稍微复杂。
- 垃圾回收延迟: 在一些情况下,启用指针压缩会稍微增加垃圾回收的复杂度,尤其是在堆接近 32GB 的临界值时。GC 需要额外处理压缩指针的解码和管理,但这通常不会成为主要瓶颈。
适用场景
指针压缩通常适用于以下场景:
- 应用程序拥有大量小型对象,内存消耗较大。
- 系统配置的堆大小接近 32GB 以下,这样可以充分利用指针压缩的内存节省优势。
- 目标是优化内存占用,并提升 CPU 缓存命中率。
关闭指针压缩
如果你在 32GB 内存的环境中遇到了任何指针压缩带来的问题,你可以通过以下方式手动禁用指针压缩:
bash
-XX:-UseCompressedOops
这将强制禁用指针压缩,让 JVM 使用完整的 64 位指针。
总结
在 32GB 堆内存环境中启用指针压缩(Compressed Oops)通常是有益的,可以节省内存和提升性能。但需要注意的是:
- 如果堆内存接近 32GB 边界,需要仔细监控内存和 GC 表现。
- 某些高性能场景下,可能会产生轻微的解压缩开销。
- 如果发现启用压缩后带来了性能问题,随时可以通过
-XX:-UseCompressedOops
禁用该功能。
总体而言,指针压缩对于大多数应用是有益的,风险较小。
大对象
大对象的内存分配和指针压缩的影响
指针压缩的主要优化对象是 对象头(Object Header) 和 对象引用(Object References) 。它能有效减少引用类型对象 中引用指针 的大小,但对于对象本身的内存分配大小(尤其是大对象),指针压缩的优化效果并不总是那么显著。
具体来说:
- 指针压缩的作用 :在 JVM 中,指针压缩减少了 64 位引用的大小,从 8 字节 (标准 64 位引用)压缩为 4 字节 。这对于引用类型数组(如
Object[]
或ArrayList
内部结构)可以减少每个对象引用的大小,节省内存空间。 - 大对象的分配 :对于那些占用大量内存的数据(如
int[]
、long[]
等原生类型的数组或超大块内存),指针压缩不会减少它们本身占用的内存,因为它主要影响的是对象引用 ,而不是实际对象数据的大小。原生类型的数组本身不会受指针压缩的影响。
风险点 3 的具体解释
指针压缩对于大对象可能不太"有利"的原因,主要包括以下几点:
- 仅对引用有效 :
- 指针压缩只会对引用产生影响,并不会减少数据本身(如原生类型数组的元素)的大小。
- 举个例子,假设你有一个
int[]
数组,其中包含 10 万个int
值(每个int
占 4 字节),数组本身的大小是 400,000 字节。即使启用了指针压缩,这个数组的内存占用也不会改变,因为指针压缩不会影响数组中原生类型的元素。 - 如果这个数组是
Object[]
,指针压缩能将每个对象引用从 8 字节压缩为 4 字节,但这仅仅影响的是数组中引用的大小,而不是对象数据。
- 大对象的内存分配方式 :
- 在 JVM 中,大对象(超过某一特定大小,通常是 32KB)会被直接分配在老年代,而不是年轻代。GC(垃圾回收)在处理老年代时通常会更复杂,因此对于大对象的回收和分配,指针压缩的好处可能不如对小对象明显。
- 内存对齐带来的浪费 :
- 指针压缩在内存对齐时依赖于 8 字节对齐,即便压缩后的指针占 4 字节,仍然需要 8 字节对齐。这意味着,尽管指针压缩减少了单个引用的大小,但在某些情况下,内存的对齐要求可能导致并没有节省预期的那么多空间。
- 对于超大对象,JVM 的内存管理机制会更倾向于优化大对象的分配,压缩的收益相对较小,甚至没有明显的效果。
- 访问性能影响 :
- 对于引用类型的大型对象,虽然指针压缩节省了内存,但 JVM 访问这些对象时需要对指针进行解压缩操作。这对 CPU 有一定的计算开销,可能会影响访问效率。尤其是在频繁访问大数组中引用类型的场景下,指针解压缩会增加一定的性能开销。
总结
指针压缩确实能帮助减少引用类型数组 的内存占用,但它的影响主要体现在引用指针的大小 上,而不是对象数据的大小。因此,对于原生类型的大数组 或大数据对象,指针压缩的优化作用有限。而在一些极端场景下,指针压缩可能会增加访问的开销(如解压缩),这是需要注意的。
因此,指针压缩的优势更多体现在小对象和引用类型上,对于大对象(特别是非引用类型的大对象),它的效果可能不会显著,甚至有时对性能有微小的负面影响。
ZGC与指针压缩
在采用 ZGC(Z Garbage Collector) 的情况下,实际上指针压缩(Compressed Oops)仍然是可以启用的 ,并且默认是启用的。ZGC 在设计时考虑到了指针压缩,并通过支持**"colored pointers"**(带颜色的指针)来实现高效的垃圾收集和内存管理。
ZGC 与指针压缩的兼容性
ZGC 是专为低延迟场景设计的垃圾收集器,能够在非常大的堆内存(甚至可达 TB 级)上实现非常短的暂停时间(通常低于 10ms)。ZGC 的核心特性之一是通过使用带颜色的指针来实现并发垃圾回收。
带颜色的指针(Colored Pointers)
ZGC 使用指针上的一些位来存储额外的元数据,这些位称为"颜色"。ZGC 通过将对象引用中的某些位用作标记位(颜色位),用于区分对象的不同状态(如是否已经移动、是否标记为活跃对象等)。通过这种方式,ZGC 可以在并发环境下标记和移动对象而无需暂停所有线程。
在启用指针压缩 时,ZGC 可以利用 64 位虚拟地址空间 的某些高位来存储这些颜色信息,同时仍然支持指针压缩。这使得在具有较大堆内存的系统中,ZGC 和指针压缩可以同时工作,以达到节省内存和提高性能的目的。
指针压缩在 ZGC 中的作用
- 减少引用指针大小: 启用指针压缩后,ZGC 能将对象引用从 64 位压缩为 32 位,从而减少引用类型对象的内存占用。这对那些有大量对象引用的程序非常有利,尤其是在大规模堆内存环境中。
- 优化内存使用: ZGC 通常用于大型内存应用场景(数百 GB 或 TB 级的堆内存),在这种情况下,指针压缩能够有效减少内存占用,提升内存效率,尤其在引用类型较多的场景下(例如,引用类型的数组、对象链等)。
- 性能优化: 指针压缩可以减少 CPU 缓存的压力,更多的数据能够放入 CPU 缓存中,这可能会提高访问引用对象的性能。
ZGC 指针压缩的限制
虽然 ZGC 支持指针压缩,但当堆内存超过一定大小(约 4TB )时,JVM 可能会自动禁用指针压缩,因为此时 32 位的压缩指针将无法寻址更大的内存空间。对于一般应用,尤其是在使用少于 32GB 堆内存的情况下,指针压缩通常是默认启用且有效的。
如何查看 ZGC 是否启用了指针压缩
你可以通过以下 JVM 参数来确认和控制指针压缩的启用情况:
- 查看是否启用了指针压缩: 运行以下命令查看 JVM 是否启用了指针压缩:
bash
java -XX:+UseCompressedOops -XX:+UseZGC -XX:+PrintFlagsFinal -version | grep UseCompressedOops
如果输出结果为 UseCompressedOops = true
,则表示指针压缩已经启用。
- 强制启用或禁用指针压缩:
- 启用指针压缩:
bash
-XX:+UseCompressedOops
- 禁用指针压缩:
bash
-XX:-UseCompressedOops
总结
在采用 ZGC 的情况下,指针压缩是默认启用的,ZGC 与指针压缩之间并不冲突。两者可以配合工作,在内存效率和垃圾回收性能之间找到很好的平衡。唯一需要注意的是,当堆内存极大(通常超过 4TB)时,JVM 会自动禁用指针压缩,以确保内存寻址空间足够大。
32GB与指针压缩的关系
之前提到的 32GB 和 4TB 可能引起了一些混淆。让我详细解释一下这两个数字的背景,以及它们与**指针压缩(Compressed Oops)**的关系。
1. 32GB 内存与指针压缩的关系
在 64 位 JVM 中,默认情况下,如果堆内存大小小于 32GB ,指针压缩(Compressed Oops) 会被启用 。这是因为在小于 32GB 的堆内存场景中,JVM 可以使用 32 位的压缩指针来寻址整个堆,且能够节省内存空间,提高效率。
- 小于 32GB:JVM 默认启用指针压缩。32 位的压缩指针可以有效覆盖 32GB 的地址空间。
- 等于或大于 32GB :在某些情况下,JVM 可能会禁用指针压缩,因为 32 位压缩指针无法高效地寻址超过 32GB 的内存地址。不过,启用或禁用指针压缩具体取决于 JVM 的配置以及 JDK 版本。
2. 4TB 与 ZGC 中指针压缩的关系
当我们谈论 ZGC(Z Garbage Collector )时,提到的 4TB 其实是指 ZGC 允许的最大堆内存限制(使用 指针压缩 的情况下)。ZGC 在设计时通过**colored pointers(带颜色的指针)**来优化内存管理,允许使用 32 位的压缩指针来寻址最大约 4TB 的堆内存。
- 小于 4TB(使用 ZGC 且启用指针压缩):指针压缩仍然有效,ZGC 通过带颜色的指针来管理内存。
- 等于或大于 4TB :如果堆内存超过 4TB,JVM 会自动禁用指针压缩,因为 32 位的压缩指针已经无法再寻址如此大的内存空间。
为什么这两者不同?
- 32GB 限制:这是在没有特殊垃圾收集器(如 ZGC)时,普通压缩指针的限制。32 位指针最多能寻址 32GB 的内存空间。
- 4TB 限制(针对 ZGC) :ZGC 引入了带颜色的指针,通过扩展 32 位指针的寻址能力,使其能够寻址到 4TB 的内存空间。因此,ZGC 在 32GB 到 4TB 的范围内仍可以启用指针压缩,但如果超过 4TB,就必须禁用压缩。
总结
- 32GB 限制:适用于普通的 JVM 设置,如果堆内存大于 32GB,指针压缩可能会被禁用。
- 4TB 限制:适用于 ZGC 等高级垃圾回收器,在使用带颜色的指针时,指针压缩可以启用,直至堆内存接近 4TB。如果超过 4TB,指针压缩将无法再使用。
因此,32GB 和 4TB 是指针压缩在不同情境下的两个阈值:普通垃圾收集器在 32GB 左右可能禁用指针压缩,而 ZGC 能将其扩展到 4TB。
为什么JVM能寻址32GB?
32 位操作系统的 4GB 内存限制和 JVM 中 32 位指针能寻址 32GB 堆内存空间,背后的原因主要涉及到虚拟内存寻址 和 JVM 内部实现的不同方式。
1. 32 位操作系统的 4GB 限制
在 32 位的操作系统中,地址总线只能使用 32 位来进行内存寻址。因为 32 位地址总线能够表示的地址范围是 2^32 = 4,294,967,296 ,也就是 4GB 的物理内存。因此,在 32 位的操作系统中,应用程序最多可以使用 4GB 的内存空间。
操作系统会将这 4GB 地址空间划分为 内核空间 和 用户空间 ,通常来说,内核空间 (例如,系统资源、硬件地址)占用 1GB,而剩下的 3GB 是应用程序的用户空间。
2. JVM 中 32 位指针如何寻址 32GB
JVM 的压缩指针(Compressed Oops)机制是针对 64 位 JVM 实现的一种内存优化技术。即便是在 64 位环境 下,它使用 32 位指针 来寻址内存地址,从而节省对象引用的内存占用。但 JVM 并不直接使用 32 位操作系统的物理地址空间概念,而是通过虚拟内存寻址 和对象偏移机制来实现更大的内存寻址能力。
如何实现 32 位指针寻址 32GB 堆内存?
通过压缩指针(Compressed Oops),JVM 利用了以下机制来实现对 32GB 堆内存的寻址:
- 基于偏移量的指针压缩 : JVM 使用 32 位压缩指针 来表示对象的地址,但这些地址并不是绝对的物理地址,而是相对堆内存起始位置的偏移量。也就是说,每个指针指向的地址是相对于堆内存起始位置的一个偏移值。
- 地址对齐 : JVM 会利用对象地址的对齐性质。通常,对象在内存中的地址是按照 8 字节 对齐的(也就是对象的地址必须是 8 的倍数)。因此,在压缩指针的实现中,JVM 并不需要使用完整的 64 位地址,而是可以将 32 位指针表示的偏移量乘以 8 ,从而扩展可寻址的内存范围:
- 32 位指针最多表示的偏移量为 2^32 = 4,294,967,296。
- 乘以 8 之后,实际可以表示的内存范围为 4GB * 8 = 32GB。
因此,压缩指针通过将指针乘以 8(即 地址对齐)的方式,能够将 32 位的地址空间扩展到 32GB。
3. 虚拟内存与物理内存
在现代操作系统中,**虚拟内存(Virtual Memory)**的概念使得每个进程都有一个独立的虚拟地址空间,通常远大于物理内存的大小。操作系统会将虚拟内存映射到实际的物理内存或磁盘上的页面,从而给程序提供一个大于物理内存的运行环境。
JVM 作为运行在操作系统上的进程,利用了虚拟内存技术。因此,即便物理内存有限,JVM 依然可以管理和使用较大的堆空间。这也是为什么在 64 位系统上,JVM 可以分配远超过 4GB 的内存(甚至是 32GB 或更多),并通过指针压缩进一步优化内存使用。
总结
- 32 位操作系统的 4GB 限制 :这是由于 32 位地址总线的物理内存限制,无法寻址超过 4GB 的物理内存。
- JVM 的指针压缩机制 :在 64 位 JVM 中,通过使用 32 位的压缩指针 (相对于堆起始位置的偏移量),并结合 8 字节的地址对齐 ,JVM 可以使用 32 位指针 来寻址 32GB 的堆内存空间。
- 虚拟内存的作用:现代操作系统通过虚拟内存技术,使 JVM 可以在 64 位操作系统中使用大于物理内存的地址空间。
因此,指针压缩的实现与操作系统的 32 位地址总线不同,主要依赖虚拟内存和 JVM 内部的偏移量与对齐策略,从而允许 32 位指针寻址到 32GB 的堆内存。
为什么ZGC可以寻址4TB?
压缩指针的工作方式(Compressed Oops)
在大多数情况下,压缩指针(Compressed Oops) 是指 JVM 将 64 位指针 压缩成 32 位 来减少对象引用所占的内存。这种压缩的原理是利用8 字节对齐,也就是说:
- JVM 假设对象的地址总是 8 字节对齐的,即所有对象的地址都能被 8 整除。
- 因此,实际存储的地址不是完整的 64 位地址,而是去掉低 3 位的 32 位偏移量(该位运算等同于除以8)。在需要解压缩时,只需将存储的压缩指针乘以 8 就能还原完整的内存地址。
通过这种方式,32 位的压缩指针可以寻址的最大堆空间就是:
- 2^32 个对象的地址,乘以 8 字节对齐 = 32GB。
因此,普通的 Compressed Oops 最多只能寻址到 32GB 的堆内存。
8字节对齐,等同于所有对象的地址后三位都是000
为什么ZGC可以支持4TB?
因为ZGC的染色指针寻址位有42位
为什么ZGC不支持压缩指针?
- 内存布局不变:染色指针通常是通过在指针的高位或低位添加标志位来实现的。这种方式会使得指针的位数变得不一致,压缩指针可能导致原有的标记信息丢失。
- 性能开销:压缩指针涉及到对内存中的对象进行重新排列,这可能引入额外的性能开销,尤其是在需要频繁访问这些对象时。染色指针的设计通常是为了在不增加额外开销的情况下进行垃圾回收。
- 指针的有效性 :压缩指针可能会导致指向已释放内存区域的指针变得无效,从而引发错误。染色指针的使用目的是确保在垃圾回收期间指针始终有效。
- 对象移动:在垃圾回收中,如果对象被压缩或移动(例如,进行内存整理),而相应的指针未更新,那么这些指针将指向旧的内存地址。这可能导致程序在访问这些指针时发生崩溃或读取无效数据。
- 并发访问:在多线程环境下,如果一个线程在进行压缩操作时,另一个线程正在访问对象,未更新的指针可能会导致数据竞争和不一致性,从而引发运行时错误。
- 引用更新:在某些垃圾回收策略中,可能会在不同阶段更新对象的引用。如果这些引用未在压缩后正确调整,可能会导致指针失效,从而无法找到原来的对象。
- 回收后的访问:在某些情况下,压缩可能会使对象被回收,而指向这些对象的指针仍然存在。如果后续代码尝试访问这些指针,可能会导致空指针异常或未定义行为。
- 这些场景表明,压缩指针需要非常小心的处理,以确保所有指针在内存重排后仍然有效和可靠,这在实现上增加了复杂性。
- 算法复杂性:压缩指针通常需要复杂的算法来确保所有指针在压缩后仍然指向有效对象,而染色指针的实现通常较为简单,压缩会增加实现的复杂性。
因此,在设计时,染色指针通常选择不压缩,以保证其性能和可靠性。
硬件限制
低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?
不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T