1. 概念

堆是JVM内存管理的核心区域,它是JVM管理的一块最大的区域,一个JVM实例只存在一个堆内存。堆会在JVM启动时被创建,此时堆大小也就被确定了。堆可以处于物理上不连续的内存空间中,但在逻辑上它应该是连续的。堆是线程共享的,这里还可以划分线程私有的缓冲区(Thread Local Alloction Buffer,TLAB),从而保证线程之间的并发性。

几乎所有的对象实例都分配在堆内存空间中。数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。方法结束后,堆中的对象不会被立即移除,而是在垃圾收集时才会被移除。因此,它也是垃圾回收的重点区域。

例如,当前程序如下所示,程序不断地往list中添加对象:

  1. public class HeapOOMDemo {
  2. static class OOMObject{}
  3. public static void main(String[] args) {
  4. ArrayList<OOMObject> list = new ArrayList<>();
  5. HeapOOMDemo demo = new HeapOOMDemo();
  6. try {
  7. while (true) {
  8. list.add(new OOMObject());
  9. Thread.sleep(10000);
  10. }
  11. } catch (Exception e){
  12. System.out.println(list.size());
  13. e.printStackTrace();
  14. }
  15. }
  16. }

通过JDK自带的jvisualVM工具,我们可以看到堆内存空间的变化情况。
jvisualVM例子.png

2. 堆空间划分

在具体了解堆空间的划分之前,我们首先回顾一下当创建对象或数组时,运行时数据区是怎样的。假设程序如下所示,当程序经过编译会得到相应的字节码文件。字节码文件经过类加载子系统的加载、链接和初始化等一系列的操作,将字节码文件中的内容装换为运行时方法区中可以使用的类型。其中在方法区中存放的是类的实现逻辑。当调用Student s = new Student()实例化对象时,对象的创建是在堆中,类变量s保存在栈中,它存放的实例化对象在堆中的地址。
类内存图.png

在上面的例子中,我们将堆空间简单的看作一块较大的空间。如果将其细分,堆空间可以分为:

  • Java7及之后将堆分为三部分:
    • 新生代(Young Generation Space):其中又分为Eden区和两个Survivor区
    • 老年代(Tenure Generation Space)
    • 永久代(Permanent Space)
  • Java8及之后将堆同样分为三部分:
    • 新生代
    • 老年代
    • 元空间(Meta Space)

新创建的对象会在新生代中分配内存,经过多次回收仍然存活的对象存放在老年代中,静态属性和类信息等存放在永久代中。新生代中的对象生命周期较短,只需要在新生代中频繁的进行GC;老年代(元空间)中对象生命周期长,内存回收的频率较低,不需要频繁的GC;永久代一般不进行GC。还可以根据不同区域的特点选择不同的GC算法,从而提高GC的效率。

2.1年轻代和老年代

存储在JVM 中的对象可以被划分为两类:

  • 生命周期较短的瞬时对象,它的创建和消亡都很快
  • 生命周期非常长,在某些极端情况下还能够和JVM的生命周期保持一致

Java堆区的细化分可以分为年轻代和老年代,其中年轻代包括Eden空间、Survivor0空间和Survivor1空间。
heap.png

默认情况下,新生代和老年代在堆结构中的占比为:-XX:NewRatio=2,表示新生代占1,老年代占2。

HotSpot中,Eden和两个Survivor空间默认所占比例为8:1:1,当然用户可以使用-XX:SurvivorRatio进行调整。Eden空间所占比例最大的原因是:几乎所有的Java对象都是在Eden区被new创建的,而且绝大多数的Java对象的销毁在新生代中进行。

新生代中80%的对象都是朝生夕死。

同样可以通过-Xmn参数设置新生代的大小,一般选择默认值即可。

2.2 分代思想

堆中进行分代并不是必须的,分代的唯一理由就是优化GC的性能。如果没有分代,那么所有的对象都在一块,但执行垃圾回收时,JVM为了识别哪些是垃圾,它就需要对所有的区域进行扫描。但很多的对象都是朝生夕死的,对所有的区域进行扫描的是不必要的。

如果分代的话,把新创建的对象放到某一个地方,当GC的时候会先把这块存放朝生夕死独享的区域进行回收,这样就会腾出很大的内存空间。

3. 对象分配过程

为新对象分配内存不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和垃圾回收算法密切相关,所以还需要考虑GC执行完是否会在内存空间中产生内存碎片。内存分配的过程为:

  • 首先考虑将new的新对象放在Eden区
  • 当Eden区满时,程序又需要创建对象,JVM将对Eden区进行垃圾回收,这里指的是Minor GC。Minor GC将Eden区中不再被其它对象所引用的对象进行销毁,在将新对象放入Eden区
  • 然后将Eden区中的剩余对象移动到S0区
  • 如果再次触发垃圾回收,此时上次幸存下来的放到S0区;如果没有回收,就会放到S1区
  • 如果再次经历垃圾回收,此时会重新放回S0区,接着再去S1区
  • 如果对象的交换次数超过了设定的阈值,则将其放到老年代中,阈值可使用-XX:MaxTenuringThreshold=<N>参数设置

那么如何理解上面所描述的分配过程呢,下面我们通过图解的方式看一下。堆这里表示为新生代和老年代。其中新生代又可以分为Eden区、S0区、S1区。JVM刚启动时,由于堆中没有对象存放,因此四个区域都为空。假设经过一段时间,当在Eden区中放入两个新对象后,Eden区已满,此时就会触发Minor GC,JVM将会执行垃圾收集。

对象分配1.png
Minor GC的过程为:将判定为垃圾的对象进行回收,同时将剩下的对象复制到S0区,更加准确的说应该是将剩下的对象复制到from区。因为对于S0和S1来说,进行复制之后,谁空谁就是to区。同时这里对象还保持一个年龄计数值,当前值为1,因为它只进行了一次复制。
对象分配2.png

如果某个时刻Eden区又满了,而且from区也满,就会再次触发Minor GC。将Eden区中的垃圾进行回收,将剩余的对象复制到空的to区,同时将from区中的对象也移动到to区。执行完GC后,S0就变成了to区,S1此时为from区。

对象分配3.png
又经过一段时间的运行,Eden区和from区都满了,而且from区中的部分对象的年龄计数值到达15。那么除了垃圾收集外,移动年龄计数值小于15的对象到to区外,这里直接将计数值为15的对象晋升到老年代。
对象分配4.png

下面通过流程图说明一下上图所描述的过程:
对象创建流程图.png

  • 当有新对象创建的请求时,首先查看Eden区中是否还有空间存放。如果有,则为其分配内存,正常的完成对象的创建
  • 否则,触发YGC。JVM继续查看Survivor空间中数是否有空间存放,如果有则放置到S0/S1空间中,即所说的from空间;否则将其放入老年代
  • 执行完YGC后,如果再有对象创建请求,仍然首先查看Eden空间的情况。如果满足要求,则为其正常分配内存;否则,如果此时Survivor区已满,则查看老年代是否有空间存放。
  • 如果有,则正常分配;如果没有,则触发FGC。对老年代执行完垃圾收集之后再查看是否有空间,有则分配,没有则抛出OOM异常

4. Minor GC、Major GC和Full GC

JVM在进行垃圾回收时并不会针对所有的内存区域一起回收,大部分时候会指向的都是新生代。针对于HotSpot的实现,它里面的GC按照回收区域又分为两种类型:

  • 部分收集(Patarial GC):不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC,Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC ,Old GC):只是老年代的垃圾收集,目前只有CMS GC会有单独进行老年代收集
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
  • 整堆收集(Full GC):收集整个堆和方法区的垃圾收集

4.1 新生代GC的触发机制

当新生代空间不足时,就会触发Minor GC,这里的新生代指的是Eden区满,Survivor区满不会触发GC。因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。而且Minor GC会引发STW,暂停其他的用户线程,等待垃圾回收结束后才恢复。

4.2 老年代GC的触发机制

当对象从老年代消失时,我们就说触发了在老年代的GC,即Major GC或时Full GC。出现了Major GC,经常会伴随至少一次的Minor GC,即当老年代空间不足时,首先会触发Minor GC;如果执行后空间仍不足,则会触发Major GC;如果Major GC执行后仍不足,则抛出OOM异常。但并非绝对,不同的垃圾收集器的策略不同。

Major GC的速度一般会比Minor GC慢的多,因此它的发生频率也就低得多。

4.3 Full GC的触发机制

触发Full GC执行的情况有如下的五种:

  • 调用System.gc(),系统建议执行Full GC,但不是必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、So区和S1区复制时,对象大小大于to区的可用内存,则把对象转存到老年代,且老年代的可用内存大小小于该对象大小

4.4 GC示例

程序如下所示:

  1. import java.util.ArrayList;
  2. import java.util.List;
  3. public class GCTest {
  4. public static void main(String[] args) {
  5. int i = 0;
  6. try {
  7. List<String> list = new ArrayList<>();
  8. String a = "forlogen.csdn.cn.com";
  9. while (true) {
  10. list.add(a);
  11. a = a + a;
  12. i++;
  13. }
  14. } catch (Throwable t) {
  15. t.printStackTrace();
  16. System.out.println(i);
  17. }
  18. }
  19. }

输出结果为:

  1. [GC (Allocation Failure) [PSYoungGen: 2018K->508K(2560K)] 2018K->924K(9728K), 0.0006958 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  2. [GC (Allocation Failure) [PSYoungGen: 2008K->480K(2560K)] 2424K->2096K(9728K), 0.0005525 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  3. [GC (Allocation Failure) [PSYoungGen: 2509K->380K(2560K)] 7965K->6476K(9728K), 0.0007708 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  4. [Full GC (Ergonomics) [PSYoungGen: 380K->0K(2560K)] [ParOldGen: 6096K->4454K(7168K)] 6476K->4454K(9728K), [Metaspace: 3137K->3137K(1056768K)], 0.0055681 secs] [Times: user=0.09 sys=0.00, real=0.01 secs]
  5. [GC (Allocation Failure) [PSYoungGen: 72K->160K(2560K)] 7087K->7174K(9728K), 0.0004976 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  6. [Full GC (Ergonomics) [PSYoungGen: 160K->0K(2560K)] [ParOldGen: 7014K->5715K(7168K)] 7174K->5715K(9728K), [Metaspace: 3176K->3176K(1056768K)], 0.0028468 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  7. [GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 5715K->5715K(9728K), 0.0004541 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  8. [Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 5715K->5694K(7168K)] 5715K->5694K(9728K), [Metaspace: 3176K->3176K(1056768K)], 0.0054247 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  9. 15
  10. java.lang.OutOfMemoryError: Java heap space
  11. at java.util.Arrays.copyOfRange(Arrays.java:3664)
  12. at java.lang.String.<init>(String.java:207)
  13. at java.lang.StringBuilder.toString(StringBuilder.java:407)
  14. at Heap.GCTest.main(GCTest.java:20)

从输出结果中可以看出,JVM首先执行的是Minor GC,当Minor GC执行后仍然不够空间存放时才执行Full GC。如果Full GC执行后仍然不够,那么就会抛OutOfMemoryError。

5. 内存分配策略

如果对象在Eden区中存放并经过第一次Minor GC后仍然存活,并且被Survivor区所容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1。对象在Survivor区中每经过一次Minor GC,年龄计数值就加1.当它的年龄计数值增加到一定程度(默认15)时,它就会被直接晋升到老年代。

晋升判断的阈值可以通过-XX:MaxTenuringThreshold参数设置

针对于不同年龄段的对下个分配的原则为:

  • 优先分配到Eden区
  • 大对象直接分配到老年代
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:如果Survivor区中相同年龄的所有对象的大小综合大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenturingThreshold中要求的数值

5.1 大对象的分配

下面通过代码理解下大对象直接分配到老年代指的是什么,假设此时要分配一个很大的数组,那么JVM就会直接将其分配到老年代,如下所示:

  1. public class YoungOldAreaTest {
  2. public static void main(String[] args) {
  3. byte[] buffer = new byte[1024 * 1024 * 20];//20m
  4. }
  5. }

设置JVM参数-Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails,控制台输出为:

  1. Heap
  2. PSYoungGen total 18432K, used 2622K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
  3. eden space 16384K, 16% used [0x00000000fec00000,0x00000000fee8fb00,0x00000000ffc00000)
  4. from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
  5. to space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
  6. ParOldGen total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
  7. object space 40960K, 50% used [0x00000000fc400000,0x00000000fd800010,0x00000000fec00000)
  8. Metaspace used 3227K, capacity 4496K, committed 4864K, reserved 1056768K
  9. class space used 350K, capacity 388K, committed 512K, reserved 1048576K

从结果中可以看出,老年代中的20480k大小的区域存放的就是buffer数组。

6. TLAB(Thread Local Allocation Buffer)

JVM中堆空间是线程共享的,任何线程都可以访问堆中的共享数据。由于对象的创建是很频繁的,因此在并发环境下从堆区中划分内存空间是线程不安全的。为了满足同步机制就需要加锁等同步操作,而这会影响分配的速度。

因此,从内存模型的角度对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,这些私有区域就构成了TLAB。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,这种分配方式也被称为快速分配策略

尽管TLAB在Eden区中只占很少一部分,通常为1%,但它是JVM内存分配的首先方式。一旦对象在TLAB空间中分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
TLAB对象分配.png

7. 堆常用的jvm参数

  • -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)。具体查看某个参数的指令:
  • jps:查看当前运行中的进程
  • jinfo -flag SurvivorRatio 进程id
  • -Xms:初始堆空间内存 (默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
  • 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
  • -XX:HandlePromotionFailure:是否设置空间分配担保

Java堆的大小在JVM启动时就被确定下来,在启动之前可以通过-Xmx-Xms来设置堆空间的大小:

  • -Xmx:用于堆区的起始内存,默认大小为物理电脑内存/64
  • -Xms:用于表示堆区的最大内存,默认大小为物理电脑内存/4

一旦堆区中所需使用的内存大小超过了-Xmx所设置的大小,将会抛出OutOfMemeoryError异常。

通常将上面的两个参数设置成相同的值,表示不容许堆区的自动扩容。这样做能够避免在垃圾回收时,JVM清理完堆区后重新分隔计算堆区大小的开销,从而提升性能。

例如,我们可以通过Runtime类的方法来查看JVM默认设置的堆内存大小,以及系统自身的总内存大小。

  1. public class HeapDemo {
  2. public static void main(String[] args) {
  3. //返回Java虚拟机中的堆内存总量
  4. long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
  5. //返回Java虚拟机试图使用的最大堆内存量
  6. long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
  7. System.out.println("-Xms : " + initialMemory + "M");
  8. System.out.println("-Xmx : " + maxMemory + "M");
  9. System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
  10. System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
  11. }
  12. }
  1. -Xms : 126M
  2. -Xmx : 2010M
  3. 系统内存大小为:7.875G
  4. 系统内存大小为:7.8515625G

或者使用jpsjstat -gc 进程id指令查看;另一种方式是设置JVM参数-XX:+PrintGCDetails来查看。

  1. [0.003s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
  2. [0.013s][info ][gc,heap] Heap region size: 1M
  3. [0.015s][info ][gc ] Using G1
  4. [0.015s][info ][gc,heap,coops] Heap address: 0x0000000082600000, size: 2010 MB, Compressed Oops mode: 32-bit
  5. [0.040s][info ][gc ] Periodic GC disabled
  6. [0.115s][info ][gc,heap,exit ] Heap
  7. [0.115s][info ][gc,heap,exit ] garbage-first heap total 129024K, used 2048K [0x0000000082600000, 0x0000000100000000)
  8. [0.115s][info ][gc,heap,exit ] region size 1024K, 3 young (3072K), 0 survivors (0K)
  9. [0.115s][info ][gc,heap,exit ] Metaspace used 621K, capacity 4531K, committed 4864K, reserved 1056768K
  10. [0.115s][info ][gc,heap,exit ] class space used 56K, capacity 402K, committed 512K, reserved 1048576K

8. 逃逸技术(Escape Analysis)

之前我们说堆是对象内存分配的唯一选择,但这种说法准确嘛。随着JIT编译器的发展和逃逸技术逐渐成熟,栈上分配标量替换等技术将会导致一些微妙的变化,所有的对象都分配在堆上也渐渐变得不那么绝对了。因为,如果经过逃逸技术后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行GC,这就是堆外存储技术

逃逸技术可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸技术,HosSpot能够分析出一个新对象的引用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象的动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,那么认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法中所引用,则认为发生逃逸

当对象没有发生逃逸时,就可以将该对象分配到栈上,随着方法执行的结束,栈空间就会被移除。

例如,程序如下所示:

  1. public class EscapeAnalysis {
  2. public EscapeAnalysis obj;
  3. // 方法返回EscapeAnalysis对象,发生逃逸
  4. public EscapeAnalysis getInstance(){
  5. return obj == null? new EscapeAnalysis() : obj;
  6. }
  7. // 为成员属性赋值,发生逃逸
  8. public void setObj(){
  9. this.obj = new EscapeAnalysis();
  10. }
  11. // 对象的作用域仅在当前方法中有效,没有发生逃逸
  12. public void useEscapeAnalysis(){
  13. EscapeAnalysis e = new EscapeAnalysis();
  14. }
  15. // 引用成员变量的值,发生逃逸
  16. public void useEscapeAnalysis1(){
  17. EscapeAnalysis e = getInstance();
  18. }
  19. }

只要new的对象的引用出了方法体,那么就判定发生了逃逸。

8.1 栈上分配

将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能时栈分配的候选,而不是堆分配。

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用的栈内执行。最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需进行GC了。

常见的栈上分配的场景有:

  • 给成员变量赋值
  • 方法返回值
  • 实例引用传递

8.2 同步省略

在动态编译同步块的时候,JIT编译器可以借助逃逸技术判断同步块所使用的锁独享是否只能被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就大大的提高了并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。

8.3 标量替换

标量指一个无法再被分解成更小的数据的数据,Java中的原始数据类型就是标量。相对的,那些还可以被分解的数据叫做聚合量,Java中的对象就是聚合量,因为它可以分解成其他的聚合量和标量。

在JIT阶段,如果经过逃逸分析发现一个对象不会被外界所访问的话,那么经过JIT优化,就会把这个对象拆解为若干个成员变量替代,这个过程就是标量替换。