目录
一、堆的概述
二、堆内存划分
三、设置堆内存大小与OOM
四、年轻代与老年代
五、图解对象分配过程
六、总结
一、堆的概述
堆是JVM运行时数据区最重要的一部分,是JVM管理的最大的一块内存空间,同时也是垃圾回收的重要区域。堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,一个进程包含多个线程,所以同一个进程中的多个线程是共享一个堆空间的。
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,并且堆内存的大小是可以动态调节的【通过-Xms和-Xmx参数进行调整】。
《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上(理论上是全部对象,但是JVM优化,可能存在栈上分配)。如下图:
二、堆内存划分
Java 7及之前堆内存逻辑上分为三部分:新生代 + 老年代 + 永久区
- Young Generation Space: 新生区 Young/New, 又被划分为Eden区和Survivor区;
- Tenure generation space :老年代 Old/Tenure;
- Permanent Space:永久区;
Java 8及之后堆内存逻辑上分为三部分:新生代 + 老年代 + 元空间
- Young Generation Space: 新生区 Young/New, 又被划分为Eden区和Survivor区;
- Tenure generation space :老年代 Old/Tenure;
- Meta Space : 元空间,使用本地内存;
堆空间内部结构,JDK1.8以后使用元空间替换永久代,如下图:
三、设置堆内存大小与OOM
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,我们可以通过JVM参数"-Xmx"和"-Xms"来进行调整堆空间大小。
- -Xms:设置堆区的初始内存,等价于-xx:InitialHeapSize;
- -Xmx:设置堆区的最大内存,等价于-XX:MaxHeapSize;
一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutofMemoryError内存溢出异常。
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分配计算堆区的大小,从而提高性能。
默认情况下,初始内存大小:物理电脑内存大小 / 64;最大内存大小:物理电脑内存大小 / 4;
public class HeapSpaceTest {
public static void main(String[] args) throws InterruptedException {
// 返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");
//模拟休眠,方便查看内存分配情况
Thread.sleep(200000);
}
}
运行结果如下:
-Xms:236M
-Xmx:3499M
然后我们设置一下堆空间大小,使用-Xms和-Xmx设置,设置初始堆和最大堆大小都是600m:
控制台输出:
-Xms:575M
-Xmx:575M
可以看到,我们指定的堆大小明明是600m,为什么输出来的只有575m?分析如下。
下面我们介绍一下如何查看堆内存的内存分配情况,有两种方式:
- 第一种方式:jps -> jstat -gc 进程id;
- jps:查看Java进程信息;
- jstat:查看JVM在GC时的统计信息;
下面是几个重要字段代表的意思:
- S0C:survivor幸存者0区的总大小;
- S1C:survivor幸存者1区的总大小;
- S0U:survivor幸存者0区的已使用大小;
- S1U:survivor幸存者1区的已使用大小;
- EC:Eden区域总大小;
- EU:Eden区域已使用大小;
- OC:老年代总大小;
- OU:老年代已使用大小;
那么堆空间 = 老年代大小 + 新生代大小 = OC + (EC + S0C + S1C) = 409600 + (153600 + 25600 + 25600) = 614400M,然后614400 / 1024 = 600M,刚好是我们设置的JVM参数中堆大小【600M】。
那为什么前面的程序运行结果输出只有575M呢,原因是新生代中的survivor幸存者0区、survivor幸存者1区永远都是其中一个为空,一个不为空【也叫from、to区域,to区域永远都是空的】。所以前面的程序就是:
堆空间 = OC + (EC + S0C【 或者S1C,只加一个】) = 409600 + (153600 + 25600) = 588800M,然后588800 / 1024 =575M,刚好跟运行结果对应上。
- 第二种方式:加上JVM启动参数打印程序GC日志 => -XX:+PrintGCDetails;
观察程序输出:
同样,小伙伴们可自行计算一下,跟前面我们JPS方式计算出来结果是一样的。下面看一个OutOfMemory内存溢出的例子:
public class OOMTest {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
while (true) {
list.add(999999999);
}
}
}
可以看到,我们通过死循环,不断地往集合中存入数据,然后设置启动参数-Xms10m -Xmx10m,然后运行程序,结果如下:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:267)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
at java.util.ArrayList.add(ArrayList.java:464)
at com.wsh.rocketmq.rocketmqdemo.OOMTest.main(OOMTest.java:10)
运行后,就出现OOM了,可见,当堆空间大小超过我们设置的-Xmx指定的最大堆空间大小【这里是10m】时,表示堆已经无法分配空间给对象了,于是就报OOM内存溢出了。
四、年轻代与老年代
存储在JVM中的Java对象可以被划分为两类:
- 生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速;
- 生命周期非常长的对象,在某些极端的情况下还能够与JVM的生命周期保持一致;
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
其中年轻代又分为Eden、S0、S1三块,Eden:From:to大小比例默认是8:1:1,新生代:老年代 比例为1 : 2。
Eden:8 / 10 From:1 / 10 To:1 / 10
Young:Old = 1 :2
我们可以通过-XX:NewRatio参数配置新生代与老年代在堆结构的占比,默认-XX:NewRatio=2,表示新生代占1,老年代占2,即新生代占整个堆的1/3。当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整老年代的大小,来进行调优【适当增大老年代空间大小】。
在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1,可以通过参数-XX:SurvivorRatio调整这个空间比例。比如-XX:SurvivorRatio=9。
几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行了。(有些大的对象在Eden区无法存储时候,将直接进入老年代)
五、图解对象分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new的对象先放Eden区,此区有大小限制;
- 当Eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(MinorGC),将Eden区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区;
- 然后将Eden区的剩余对象移动到幸存者0区(S0);
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区(S0)的,如果没有回收,就会放到幸存者1区(S1);
- 如果再次经历垃圾回收,此时会重新放回幸存者0区(S0),接着再去幸存者1区(S1);
- 啥时候能去老年代呢?默认是经历15次,可以通过参数调整对象分代年龄【-XX:MaxTenuringThreshold= N进行设置】;
- 当老年代内存不足时,再次触发Major GC,进行老年代的内存清理;
- 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常;
我们创建的对象,一般都是存放在Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为 YGC / Minor GC操作。
当我们进行一次垃圾收集后,红色的将会被回收,而绿色的还会被占用着,存放在S0(Survivor From)区。同时我们给每个对象设置了一个年龄计数器,一次回收后就是1。
同时Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把 Eden和Survivor From中的对象进行一次收集,把存活的对象放到 Survivor To区,同时让年龄 + 1。
我们继续不断的进行对象生成 和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion晋升的操作,也就是将年轻代中的对象晋升到老年代中。
特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。
如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代。
不过对象分配也不一定完全按照上述讲的那样去分配,其中可能存在一些对象直接进入老年代的现象。如下图是对象分配的特殊情况:
六、总结
- 堆空间是JVM运行时数据区最大的一块区域,也是GC垃圾回收的重要区域,可以通过-Xms和-Xmx设置堆空间大小;
- 堆分为新生代 + 老年代 + 元空间(1.8之后),其中新生代可分为Eden + From(s0) + To(s1) ;
- 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to;
- 在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作;
- 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不在永久代和元空间进行收集;
- 新生代采用复制算法的目的:是为了减少内存碎片;