【JVM系列】JVM堆内存分代模型与垃圾回收
欢迎关注,分享更多原创技术内容~
微信公众号:ByteRaccoon、知乎:一只大狸花啊、稀土掘金:浣熊say
微信公众号海量Java、数字孪生、工业互联网电子书免费送~
谈谈对象与堆的关系
为什么Java对象要分配在堆上?
我们都知道,对象的实际分配是在堆上进行的,即JVM会在堆上开辟一段内存空间以0和1的方式记录下这个对象中的所有成员变量、方法之类的信息。而JVM的执行引擎会操作虚拟机栈从里面的"局部变量表"和"操作数栈"中拿到数据和指令让CPU去执行相关指令。在这个过程中,存储在局部变量表中的对象,实际上是堆中对象的一个引用或者说指针,当需要使用对象中的某个方法或者属性的时候,需要去堆中访问对象的实际物理内存地址。
那么,为什么要将对象实际数据放在堆中,然后通过引用的方式再去获取实际的对象方法和数据呢?
其实这是设计到堆和虚拟机栈的空间大小的问题,Java线程运行的时候每个线程都会有一个独立的栈,而这些栈又会有多个栈帧进行压栈和出栈操作来进行方法的执行和跳转。一般Java栈的大小是有限的,默认大概1MB左右,而一个栈帧的大小更加有限,大概在几百字节~几kb左右。
那么为什么要将栈和栈帧的大小设置得这么小呢?其实主要有以下几个原因:
- 线程数量和内存消耗: 一个应用程序中可能有多个线程,每个线程都需要独立的虚拟机栈。如果每个线程的栈都设置得很大,将会占用大量内存。限制每个线程的栈大小可以在有限的物理内存环境中更好地管理资源。
- 栈帧的生命周期: 栈帧是方法调用的基本单位,它包含了局部变量表、操作数栈、动态链接、方法返回地址等信息。由于方法调用的生命周期通常很短,栈帧在方法执行完毕后就会被销毁。相对较小的栈帧可以更灵活地分配和回收内存。
- 递归调用的深度: 虚拟机栈的大小会限制递归调用的深度。较小的栈大小有助于防止无限递归导致栈溢出。递归调用深度的限制有助于防止程序因递归错误而无法正常执行。
- 平台兼容性: 不同的操作系统和硬件平台可能对栈大小有一些限制。较小的默认栈大小可以提高应用程序的可移植性和兼容性。
虚拟机栈的大小通常可以通过命令行参数进行调整,例如使用 -Xss256k
表示将栈大小设置为 256KB。因此,对于这么小的栈和栈帧空间,Java的对象一般是无法直接分配在栈上的,只能分配在堆上,然后通过引用的方式间接的去拿到对象的成员变量,方法等。
大多数对象的存活周期极短
在 Java 中,大多数对象的存活周期短暂,它们在分配后很快就会被垃圾回收器回收。这种情况通常出现在方法的局部变量或临时对象上,以下是一个简单的 Java 代码示例,演示了大多数对象存活周期短暂的情况:
java
public class ShortLivedObjectsExample {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
createShortLivedObject();
}
}
private static void createShortLivedObject() {
// 创建一个短暂存活的对象
String shortLivedObject = "Short lived object";
// 打印对象内容
System.out.println(shortLivedObject);
// 对象离开作用域后,将被垃圾回收
}
}
这个示例包含了一个 createShortLivedObject
方法,该方法在每次迭代中创建一个字符串对象,并打印其内容。由于字符串对象是不可变的,它们的值在创建后不会发生改变。在 createShortLivedObject
方法的末尾,对象离开了作用域,对应的就是createShortLivedObject的栈帧会被移出虚拟机站,因此它的引用将被丢弃。在这种情况下,由于对象的生命周期非常短暂,垃圾回收器可能很快就会回收这些对象。
少数对象的存活周期很长
在 Java 中,少数对象可能具有较长的生命周期,例如缓存对象、单例模式对象、或者在整个应用程序生命周期内都需要保持的全局对象。以下是一个简单的 Java 代码示例,演示了一个长期存活的对象:
java
public class LongLivedObjectExample {
private static LongLivedObject longLivedObject;
public static void main(String[] args) {
initializeLongLivedObject();
// 在应用程序的其他部分使用 longLivedObject
useLongLivedObject();
// 在应用程序结束时,longLivedObject 可能仍然存在
// 例如,在整个应用程序生命周期内需要保持的对象
}
private static void initializeLongLivedObject() {
// 创建一个长期存活的对象
longLivedObject = new LongLivedObject("Long lived object");
}
private static void useLongLivedObject() {
// 在应用程序的其他部分使用 longLivedObject
System.out.println("Using long lived object: " + longLivedObject.getValue());
}
}
class LongLivedObject {
private String value;
public LongLivedObject(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
在这个示例中,LongLivedObject
类代表一个长期存活的对象,它的实例在整个应用程序生命周期(main线程的执行周期)内都可能存在。在 LongLivedObjectExample
类中,通过 initializeLongLivedObject
方法创建了一个 LongLivedObject
实例,并将其赋值给 longLivedObject
静态变量。然后,通过 useLongLivedObject
方法在应用程序的其他部分使用了这个长期存活的对象。
这种情况下,longLivedObject
对象的生命周期跨足了 main
方法的执行范围,因此在应用程序结束时,这个对象可能仍然存在,这种长期存活的对象通常需要特别注意内存管理,以避免内存泄漏问题。
JVM堆内存分代模型------年轻代和老年代
由于Java中大多数的对象存活周期短,朝生夕死,那么每次对整个堆内进行全量的回收是一笔并不划算的买卖。因为,大量的对象需要频繁清理,GC的频率很高,而少部分的对象存活时间较长无需频繁清理。如果每次都进行全堆扫描的话,就会造成STW时间极长,用户体验极差。
因此,根据大量Java对象是朝生夕死的这个特点,HotSpot团队设计了分代的堆内存模型。简单来说,对于存活周期较短的对象我们放在"年轻代"中,年轻代的内存空间小,可以频繁的进行GC和回收。对于生命周期长的对象来说,我们就将其放在老年代中,老年代的GC频率较低。这样以来,就能够在一定程度上降低GC过程造成的STW问题,提升系统的效率。
总的来说,对于Serial系列、ParNew、PS系列、CMS和G1垃圾回收器来说, 都采用"分代回收"算法,分代回收的核心思想在于,"针对不同生命周期的对象,可以采用不同的回收策略来提升回收的效率"。Hotspot VM将内存划分为:新生代、老年代和永久代,新生代主要管理Java中那些生命周期很短的对象,老年代管理生命周期较长的对象。永久带管理的则比较特殊,与JDK版本有关,后面会专门介绍。
新生代(Young Generation)
由于大多数的Java对象的存活周期很短,因此,Java中大多数对象都是在新生代被创建的。这些对象的生存时间一般很短,对于新生代进行的垃圾回收操作被称为(Minor GC)。由于新生代每次垃圾回收所存活下来的对象一般很少,因此,采用的垃圾回收算法是"复制算法",只需要将少量存活下来的对象进行复制就可以了,比较节省性能。新生代内部分成了3个区域,Eden区,Survivor 0 和 Survivor 1区域:
- Eden区:一般对象都是在Eden区被创建的,由于Java对象朝生夕死的特点,Eden区的GC会很频繁,但相应的是空间较小,GC效率高。
- Survivor 0区:当Eden区满时,存在的对象会被复制到Survivor 0区,每经过一次MinorGC对象的年龄+1
- Survivor 1区:当Survivor 0 区满的时候,Survivor中存活且不满足晋升条件的对象会被移动到Survivor 1区中。该区域的对象也是每经历一次Minor GC年龄就会+1,当年龄达到阈值"MaxTenuringThreshold"(默认15)之后,该区域的对象会被放到老年代。
新生代的内存区域一般比较小,大概占整个堆空间的2/10左右,GC的频率也比较高,只要当Eden区分配对象失败的时候就会触发。因此,像Serial,ParNew等会造成STW的的垃圾回收算法,在年轻代所造成的停顿影响也不会特别大。
老年代(Old Generation)
老年代中的对象一般是那种存活周期较长的对象,这些对象的引用一般不容易失效,因此针对老年代的GC并不会那么频繁。在新生代中的对象经过MaxTenuringThreshold=15次GC之后,依旧存活的对象就会被移动到老年代,或者对于那些本身就比较大的新生对象,由于新生代无法提供如此大的内存空间也会直接被放到老年代。
同时,由于老年代的的存活对象可能比较多,为了防止内存区间的浪费,一般采用"标记-整理"算法,该算法能够很好的解决内存空间碎片问题,但同时GC的资源消耗较高。所以,老年代如果采用Serial Old这样的单线程的算法的话,可能会造成极为严重的停顿问题,影响Java程序的性能。为此,HotSpot团队推出了一系列降低停顿和提升吞吐量的垃圾回收器,如:CMS、PS Old和G1等。
老年代的垃圾回收一般是新生代对象晋升到老年代或者是较大的新对象在老年代分配失败就会触发老年代的垃圾回收 。对于Serial Old、PS Old等垃圾回收器来说是没有严格区分老年代的垃圾回收(Major GC)和整堆的垃圾回收(Full GC)的,即Major GC就是对应的一次Full GC。而CMS垃圾回收则不同,其针对老年代的垃圾回收就只回收老年代(Major GC),只有在发生"并发分配错误"的时候才会触发整堆的Full GC。
另外,G1和ZGC垃圾回收器的具体实现和传统的分代垃圾回收器有所不同,这个具体到后面相关小节再详细介绍。
永久代
永久代其实是Java 8之前的一个说法,对应的其实是"方法区"的一个实现方式,方法区也被称为"No-Heap"即非堆,所以其严格意义上并非是堆内存的部分。
但是在方法区的HotSpot实现中,JDK 8之前都采用了"永久代"来进行实现,"永久代"实际也是一块儿内存区域,主要用来存储类加载信息、常量池等、static变量和JIT实时编译代码等。一般存储在永久代的对象数据的生命周期都是比较长,的不容易被垃圾回收。虽然,严格意义上永久代并不是堆中的一部分,但是在Java 8之前,Full GC除了对年轻代和老年代的空间进行垃圾回收之外,也会对永久代进行垃圾回收。
在JDK 8之后,方法区的实现方法改成了Meta Space,和永久代不同的是Meta Space的内存空间是Native的内存空间,无需垃圾回收区进行管理,因此垃圾回收也不设计这部分。可以参考前面的文章了解更多"方法区"和"永久代"相关内容。
三种不同的GC
考虑到上面堆内存的分区不同和大多数对象朝生夕死的特点,针对"年轻代"、"老年代"和"永久代"的内存的回收被分为三种不同的GC,分别是:Minor GC、Major GC和Full GC,它们描述了垃圾回收的不同阶段和范围:
- Minor GC(新生代GC):
- Minor GC主要关注清理年轻代(Young Generation)的内存区域。
- 年轻代通常分为三个部分:Eden区和两个Survivor区(通常是S0和S1)。
- 在Minor GC过程中,首先会进行Eden区的垃圾回收,存活的对象将会被移动到其中一个Survivor区。之后,再清理Eden区和另一个Survivor区。这个过程会使得年轻代中的对象晋升到老年代(Old Generation)。
- 因为新生代的对象朝生夕死的特点,Minor GC通常发生频繁,但它的停顿时间相对较短。
- Major GC(老年代GC):
- Major GC主要关注清理老年代的内存区域。
- 触发Major GC的条件包括老年代空间不足,永久代(在Java 8及之前的版本)或Metaspace空间不足等。
- Major GC的执行可能伴随较长的停顿时间,因为它需要整理老年代的内存,移动对象以减少碎片化。
- Full GC(完全GC):
- Full GC是对整个堆内存(包括年轻代、老年代、永久代或Metaspace等)进行清理的一种垃圾回收操作,它是Major GC的一种特殊情况。
- 触发Full GC的条件可能包括老年代空间不足、永久代/Metaspace空间不足、显式调用
System.gc()
等。 - Full GC的执行会导致相对较长的停顿时间,因为它需要对整个堆内存进行回收。
Minor GC主要处理年轻代的垃圾回收,Major GC关注老年代的垃圾回收,而Full GC是对整个堆内存的完全清理。针对不同的GC方式,HotSpot团队设计了不同的垃圾收集器可以进行选择。如年轻代就有Serial、ParNew、和Parallel Scavenge等、老年代有Serial Old、Parallel Old和CMS等垃圾回收器。
而最新的垃圾回收器如G1和ZGC则没有特别严格的分代垃圾回收器的概念,其老年代和新生代都是采用的同一种垃圾回收器。像ZGC甚至没有将堆内存分为年轻代、老年代等概念。
JVM设置堆大小参数设置
Java堆的大小可以通过设置Java虚拟机的启动参数来进行调整,以下是这些参数的详细说明:
参数 | 描述 | 示例 |
---|---|---|
-Xms | 设置Java堆的初始大小。 | java -Xms256m -jar YourApplication.jar |
-Xmx | 设置Java堆的最大大小。 | java -Xmx512m -jar YourApplication.jar |
-XX:NewRatio | 设置新生代与老年代的比例。具体值表示新生代大小相对于老年代大小的倍数。 | java -XX:NewRatio=2 -Xms512m -Xmx1024m -jar YourApplication.jar |
-XX:SurvivorRatio | 设置Eden区与Survivor区的比例。默认值为8,表示Eden区的大小是一个Survivor区的八倍。 | java -XX:SurvivorRatio=4 -Xms512m -Xmx1024m -jar YourApplication.jar |
-XX:MaxPermSize (JDK 7及更早版本) | 设置永久代(PermGen)的最大大小,用于存储类元数据、字符串常量池等。 | java -XX:MaxPermSize=256m -Xms512m -Xmx1024m -jar YourApplication.jar |
-XX:MaxMetaspaceSize (JDK 8及以后版本) | 设置Metaspace的最大大小,用于存储类元数据。与永久代不同,Metaspace没有固定大小限制。 | java -XX:MaxMetaspaceSize=256m -Xms512m -Xmx1024m -jar YourApplication.jar |
-XX:+HeapDumpOnOutOfMemoryError | 让JVM在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用 | java -XX:+HeapDumpOnOutOfMemoryError -jar YourApplication.jar |
这些参数允许你根据应用程序的特定需求微调内存管理。调整这些设置需要谨慎进行,通常需要通过实验和监控来找到最佳配置,以确保应用程序在性能和稳定性方面都能够表现良好。
总结
本节主要介绍了为什么对象会被new在堆上和堆上对象的朝生夕死的特点,其次堆内存在Java中被分为"年轻代"、"老年代"和"永久代",针对不同堆内存区域垃圾回收被分为了Minor GC、Major GC和Full GC等。