三、堆(Heap)
1、什么是堆
- 在Java虚拟机(JVM)中,堆(Heap)是用于动态分配内存的区域。在Java程序运行时,所有对象和数组都是在堆中分配内存的。堆是Java内存模型的重要组成部分,允许程序在运行时动态地分配和释放内存。
- 一个
JVM实例通常只有一个堆区域
,整个应用程序中的所有线程共享这个堆
。这个堆是由JVM在启动时根据配置参数(如-Xms
和-Xmx
)来初始化和管理的。 - 堆的大小JVM启动时就确定,并且创建了。
2、堆的分代策略
JVM将堆内存分为三代:年轻代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation,Java 8之后称为元空间(Metaspace))。
(1)年轻代(Young Generation)
年轻代主要存放新创建的对象,大部分对象在这里很快变得不可达。年轻代又细分为三个区域:
- Eden区: 大部分新对象在这里分配。
- 两个Survivor区(S0和S1): 在Eden区进行垃圾回收时,存活的对象会被移到Survivor区。这两个Survivor区会轮流使用,一个用作复制的目标,另一个空闲。
(2)老年代(Old Generation)
生命周期较长的对象会从年轻代晋升到老年代。老年代存放的对象相对稳定,垃圾回收频率较低,但回收时通常会进行全堆扫描,代价较高。
(3)永久代(Permanent Generation)/元空间(Metaspace)
永久代用于存储类的元数据(如类信息、方法信息等)。在Java 8及之后,永久代被元空间取代,元空间在本地内存中分配,而不再是堆的一部分。
Java虚拟机(JVM)中的分代垃圾回收策略是为了提高内存管理的效率和性能。通过将堆内存划分为不同的区域,并根据对象的生命周期对其进行管理,分代策略优化了垃圾回收的频率和速度。
3、堆的内存分配策略
在Java堆内存管理中,有两种主要的内存分配策略:指针碰撞(Bump-the-pointer)和空闲列表(Free List)。这两种策略用于不同的垃圾回收器和内存布局方式。下面将详细解释这两种策略,以及它们的优缺点和相关配置。
(1)指针碰撞(Bump-the-pointer)
指针碰撞是一种非常高效的内存分配方式,适用于内存连续分配的情形。堆内存被划分为两部分:已使用的部分和未使用的部分。JVM维护一个指针,该指针指向当前未使用部分的开始位置。当需要分配内存时,只需将指针向前移动所需的大小,分配过程非常简单高效。
工作原理
- 初始化: 在堆的开始位置设置一个指针,指向可用内存的起始位置。
- 分配内存: 当需要分配对象时,将指针向前移动对象大小的距离,并返回原指针位置作为分配地址。
- 回收内存: 垃圾回收时,通过压缩和整理,使存活对象连续存放在堆的一端,然后重置指针到存活对象的末尾。
优点
- 分配速度快: 内存分配只需移动指针,时间复杂度为O(1)。
- 低碎片: 通过压缩整理,可以减少内存碎片。
缺点
- 适用范围有限: 适用于对象存活时间较短、需要频繁分配和回收的情况(如年轻代)。
- 整理开销: 垃圾回收时需要对存活对象进行整理,开销较大。
相关配置参数
指针碰撞通常用于垃圾回收器G1和Parallel Scavenge的年轻代。具体配置参数取决于使用的垃圾回收器。例如:
-XX:+UseG1GC
:启用G1垃圾回收器。-XX:+UseParallelGC
:启用Parallel垃圾回收器。
(2)空闲列表(Free List)
空闲列表是一种较为灵活的内存分配方式,适用于内存块大小不固定的情形。JVM维护一个已回收内存块的列表,每次分配内存时,从空闲列表中找到适当大小的内存块进行分配。
工作原理
- 初始化: 创建一个空闲列表,记录所有可用的内存块。
- 分配内存: 当需要分配对象时,从空闲列表中找到适当大小的内存块,并将其从列表中移除。
- 回收内存: 垃圾回收时,将回收的内存块加入空闲列表。
优点
- 灵活性高: 可以处理不同大小的内存分配请求,适用于对象大小和生命周期不确定的情况(如老年代)。
- 无须整理: 不需要像指针碰撞那样频繁整理内存。
缺点
- 分配速度慢: 分配内存时需要遍历空闲列表找到适当的内存块,时间复杂度为O(n)。
- 内存碎片: 回收和分配过程中可能产生内存碎片,降低内存利用率。
相关配置参数
空闲列表通常用于垃圾回收器CMS(Concurrent Mark-Sweep)和老年代。具体配置参数包括:
-XX:+UseConcMarkSweepGC
:启用CMS垃圾回收器。-XX:+UseParallelOldGC
:启用Parallel Old垃圾回收器(老年代使用空闲列表)。
总结对比
特性 | 指针碰撞(Bump-the-pointer) | 空闲列表(Free List) |
---|---|---|
分配速度 | 快(O(1)) | 慢(O(n)) |
内存碎片 | 少,通过整理减少 | 多,需管理和合并碎片 |
适用场景 | 年轻代,短生命周期对象 | 老年代,大小不定的对象 |
整理开销 | 高,需要压缩和整理 | 无需整理,但需管理空闲块 |
相关垃圾回收器 | G1, Parallel Scavenge | CMS, Parallel Old |
4、对象在各个代之间的转移过程
一个对象从Eden区创建开始,到老年代,最后涉及元空间的过程如下:
-
对象在Eden区创建:
- 当使用
new
关键字或其他方式创建对象时,首先在Eden区分配内存。 - 大对象会直接进入老年代(如超过了新生代大小的对象)
- 当使用
-
Minor GC(小型垃圾回收):
- 当Eden区满时,会触发Minor GC。
- 在Minor GC期间,Eden区的存活对象会被复制到一个空闲的Survivor区(S0或S1)。
- Eden区的内存会被清空。
-
Survivor区的对象晋升:
- 存活下来的对象继续留在Survivor区,如果在多次Minor GC后仍存活(达到一定的年龄阈值,一般为15),对象会从Survivor区晋升到老年代。
- 大对象会直接进入老年代(如超过了新生代大小的对象)
-
老年代的对象:
- 在老年代的对象生命周期较长,通常只有在Major GC(也称为Full GC)时才会被回收。
- 当老年代的内存使用达到一定阈值时,会触发Major GC,清理老年代中的不可达对象。
-
永久代/元空间:
- 类的元数据、方法元数据等会存放在永久代(Java 7及以前)或元空间(Java 8及以后)。
- 元空间在本地内存中分配,不属于堆的一部分。
- 类加载器加载类时,类的元数据会存放到元空间中,这部分数据在类卸载时会被回收。
示例:对象从Eden到老年代的转移过程
假设我们创建一个新的对象:
public class Test {
public static void main(String[] args) {
Object obj = new Object(); // 对象在Eden区分配
}
}
-
创建对象:
new Object()
会在Eden区分配内存,创建obj
对象。
-
Eden区满时触发Minor GC:
- 如果Eden区满了,会触发Minor GC。
- 存活的
obj
对象会被复制到一个Survivor区(假设是S0)。
-
对象在Survivor区之间复制:
- 如果
obj
在下一次Minor GC时仍然存活,会从S0复制到S1。 - 每次Minor GC,存活对象在S0和S1之间复制。
- 如果
-
对象晋升到老年代:
- 当
obj
对象达到晋升年龄(如15次Minor GC),它会被移动到老年代。 - 大对象会直接进入老年代(如超过了新生代大小的对象)
- 当
-
Major GC回收老年代对象:
- 当老年代内存不足时,会触发Major GC,清理不可达的老年代对象。
总结
通过分代策略,JVM能够高效地管理内存,减少垃圾回收的开销。年轻代频繁进行Minor GC,快速回收短生命周期对象,而老年代的Major GC则更少进行,但处理存活时间长的对象。元空间管理类元数据,独立于堆内存。通过这些机制,JVM能够在性能和内存管理之间取得平衡。
5、Minor GC、Major GC、Full GC
在Java虚拟机(JVM)中,垃圾回收(Garbage Collection, GC)是管理内存的关键机制。垃圾回收器通过自动回收不再使用的对象来释放内存,避免内存泄漏和内存溢出。JVM中的垃圾回收可以分为三种主要类型:Minor GC、Major GC 和 Full GC。
(1)Minor GC
作用
Minor GC专门用于清理年轻代(Young Generation)的垃圾对象。年轻代中的对象生命周期通常较短,频繁创建和销毁,因此Minor GC发生频率较高。
触发条件
当Eden区满时,JVM会触发Minor GC。这种情况通常发生在新对象被频繁创建的情况下。
过程
- 复制存活对象: 在Minor GC期间,Eden区中的存活对象会被复制到一个空闲的Survivor区(S0或S1,这俩会交替空)。
- 清空Eden区: Eden区的所有内存会被清空,所有不可达的对象都会被回收。
- Survivor区轮换: 存活的对象在两个Survivor区之间轮换,最后达到一定年龄的对象会被晋升到老年代。
(2) Major GC
作用
Major GC,也称为Old GC,主要用于清理老年代(Old Generation)的垃圾对象。老年代存放生命周期较长的对象,Major GC的发生频率较低,但回收过程较慢。所需时间一般为Minor GC的十倍以上
触发条件
当老年代的内存使用达到一定的阈值时,JVM会触发Major GC。这通常发生在老年代中的对象越来越多,导致内存不足的情况下。
过程
- 标记存活对象: Major GC会首先标记所有存活的对象。
- 清理垃圾对象: 清理不可达的对象,释放老年代中的内存。
- 整理内存: 一些垃圾收集器(如CMS)可能会对内存进行压缩和整理,以减少内存碎片。
(3)Full GC
作用
Full GC是一次全面的垃圾回收操作,包括清理年轻代、老年代和永久代/元空间中的所有垃圾对象。Full GC的开销最大,因为它需要暂停所有应用线程(Stop-the-World,STW)进行全堆扫描和回收。
触发条件
Full GC可以由多种情况触发,包括:
- System.gc() 调用: 显式调用System.gc()会建议JVM执行Full GC。
- 老年代或永久代/元空间内存不足: 当老年代或元空间的内存不足时,可能会触发Full GC。
- JVM自适应调整: 某些情况下,JVM的自适应调整策略可能会触发Full GC。
过程
- 标记所有存活对象: Full GC会标记整个堆中的所有存活对象,包括年轻代、老年代和永久代/元空间。
- 清理垃圾对象: 回收所有不可达的对象,释放内存。
- 整理内存: 对内存进行压缩和整理,减少内存碎片。
垃圾回收器的种类
JVM提供了多种垃圾回收器,每种回收器在处理Minor GC、Major GC和Full GC时有不同的策略。常见的垃圾回收器包括:
- Serial GC: 单线程垃圾回收器,适用于单处理器机器。
- Parallel GC: 多线程垃圾回收器,适用于多处理器机器。
- CMS(Concurrent Mark-Sweep) GC: 低暂停时间的垃圾回收器,适用于需要响应时间的应用。
- G1(Garbage-First) GC: 适用于大堆内存和低暂停时间要求的应用,结合了并行和并发回收技术。
总结
- Minor GC: 清理年轻代的垃圾对象,触发频率高,回收速度快。
- Major GC: 清理老年代的垃圾对象,触发频率低,回收速度慢。
- Full GC: 全堆垃圾回收,包括年轻代、老年代和永久代/元空间,触发代价最高,通常是最后的手段。
6、TLAB-本地线程分配缓冲(Thread Local Allocation Buffer)
为什么
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
- 因为堆是线程共享的,那就会出现正在给A分配内存,还没有完成,B又来使用原先的内存状态分配内存的情况。
是什么
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
再说明
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
- 在程序中,开发人员可以通过选项
“-XX:UseTLAB”
设置是否开启TLAB空间。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABwasteTargetPercent”
设置TLAB空间所占用Eden空间的百分比大小。 - 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
7、对象在堆里的创建过程
(1)前提条件:类加载
类加载
- 当JVM遇到一个类的首次使用(通常是通过
new
关键字),会触发类加载过程。 - 类加载分为加载(Loading)、连接(Linking)和初始化(Initialization)三个阶段。
- 加载阶段:JVM通过类加载器(ClassLoader)读取类的字节码。
- 连接阶段:包括验证(Verification)、准备(Preparation)和解析(Resolution)。
- 验证:确保字节码符合JVM规范。
- 准备:为类的静态变量分配内存并设置初始值。
- 解析:将符号引用转换为直接引用。
- 初始化阶段:执行类的静态初始化块和静态变量的赋值。
对象初始化是对象在堆内存中创建的关键步骤,包括内存清零、对象头填充、字段初始化和构造方法调用等过程。对齐填充则是为了优化内存访问效率,对对象的大小进行调整。下面详细描述对象初始化的过程和对齐填充。
(2)初始化
-
内存分配
- 在JVM的堆内存中为对象分配一个连续的内存块,这个内存块的大小由对象的类定义,包括对象头和实例数据。
- 内存分配可能采用指针碰撞或空闲列表的方式,具体取决于垃圾收集器的实现。
-
内存清零
- 分配的内存块通常会被清零(非必须但常见),以确保对象的默认值是零值。
- 清零的目的是防止使用未初始化的内存,减少潜在的错误。
-
对象头填充
- 将对象头的内容填充到内存块的前几字节,具体包括:
- Mark Word:初始化对象的哈希码(通常为空),GC状态和锁状态等。
- 类型指针(Klass Pointer):指向对象所属的类元数据,JVM通过这个指针知道对象的类信息。
- 数组长度(如果是数组对象):存储数组的长度。
- 将对象头的内容填充到内存块的前几字节,具体包括:
-
字段初始化
- JVM将对象的字段按照类定义进行初始化,基本类型字段初始化为默认值(例如int为0,float为0.0,boolean为false等),引用类型字段初始化为null。
- 如果类有显示的字段初始值(例如
private int age = 25;
),JVM会在这一步将这些字段初始化为指定的值。
-
调用构造方法
- JVM调用类的构造方法(
<init>
),执行用户定义的初始化逻辑。 - 构造方法可能调用父类的构造方法(通过
super
),确保整个类继承链上的初始化顺序正确。 - 构造方法中可以进一步修改字段的初始值,设置对象的初始状态。
- JVM调用类的构造方法(
(3)对齐填充(Padding)
对齐填充的目的是优化内存访问效率,确保对象在内存中的对齐符合硬件要求。对齐填充通常发生在以下几个方面:
-
对象大小对齐
- JVM要求对象的大小是特定字节数的倍数(通常是8字节)。如果对象的实际大小不是8字节的倍数,JVM会在对象末尾添加填充字节,以满足对齐要求。
- 例如,一个对象实际大小是14字节,那么JVM会添加2字节的填充,使对象大小达到16字节。
-
字段对齐
- JVM可能会调整对象内部字段的排列顺序,以确保每个字段都对齐到其自然边界(例如,4字节的int字段对齐到4字节边界)。
- 字段对齐可以提高内存访问速度,因为硬件在读取或写入未对齐的数据时可能需要额外的操作。
(4)示例分析
假设有一个简单的Java类:
public class Person {
private int age;
private boolean isEmployed;
}
当创建一个Person
对象时:
Person person = new Person();
内存布局分析
-
内存分配
- JVM为
Person
对象分配一个内存块,包括对象头和实例数据。
- JVM为
-
内存清零
- 内存块被清零,确保初始值为0。
-
对象头填充
- 对象头(12字节,假设为32位系统):包含Mark Word(8字节)和类型指针(4字节)。
-
字段初始化
age
(4字节,初始值为0)isEmployed
(1字节,初始值为false)
-
对齐填充
- 为了对齐对象大小到8字节,可能会在
isEmployed
后面添加3字节的填充,使对象大小达到16字节。
- 为了对齐对象大小到8字节,可能会在
对象的内存布局示例(32位系统)
| 对象头 (Mark Word, 8字节) |
| 对象头 (类型指针, 4字节) |
| int age (4字节) |
| boolean isEmployed (1字节)|
| 填充 (3字节) |
在64位系统中,对象头可能是16字节,因此需要调整相应的对齐填充:
| 对象头 (Mark Word, 8字节) |
| 对象头 (类型指针, 8字节) |
| int age (4字节) |
| boolean isEmployed (1字节) |
| 填充 (3字节) |