05_堆

堆的核心概述

image-20200916075349717

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
    • 堆内存的大小是可以调节的
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应该在运行时分配在堆上。
    • 实际上,是"几乎"所有的对象实例都在这里分配内存
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
  • 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

内存细分

现代垃圾收集器大部分都基于分代收集理论设计:

堆和方法区是一组从功能上定义的内存区域名称;分代命名是从垃圾回收的角度进行内存区域划分和命名,方便垃圾收集器对不同分代区域进行特定的针对性内存分配及回收逻辑。

  • Java 7及之前堆内存逻辑上分为两部分:新生代、老年代

    • Young(New) Generation Space
      • Eden、Survivor(from、to)
    • Tenure(Old) generation space
  • 方法区被划为永久代(在Java虚拟机规范中没有定义方法区的实际位置,在7及之前它和堆共用一块内存,它所占用的内存空间称为非堆内存):Permanent Space

    image-20200916082710521

  • Java8及之后堆内存逻辑上分为三部分:新生代、老年代

    • Young(New) Generation Space
      • Eden、Survivor
    • Tenure(Old) generation space
  • 永久代移除,使用元空间替代(在8之后出现,不占用堆内存,使用直接内存/又叫堆外内存,很蹩脚的命名):Meta Spoce

    image-20200916082818919

设置堆内存大小与OOM

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项-Xmx-Xms来进行设置

  • -Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常

通常会将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分配计算堆区的大小,从而提高性能。

默认情况下:

  • 初始内存大小:物理电脑内存大小/64
  • 最大内存大小:物理电脑内存大小/4

查看堆内存情况:

  • jps查看Java进程,jstat -gc 进程id查看内存使用情况和GC次数等
  • -XX:+PrintGCDetails

OOM

image-20200916085807115

年轻代与老年代

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

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做form区、to区)

image-20200916090311825

相关参数设置

  • 配置新生代与老年代在堆结构的占比

    • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
    • 可以根据需要进行修改,一般开发中不会修改

    image-20200916090614423

  • 在Hotspot中,Eden空间和另外两个Survivor空间空间缺省所占的比例是8:1:1。可以通过以下参数进行设置:

    • 设置新生代中Eden区与Survivor区的比例:-XX:SurvivorRatio=8。默认是8(from和to平分Survivor区域)
  • -XX:-UseAdaptiveSizePolicy:关闭自适应的内存分配策略(否则新生代中分区可能和SurvivorRatio参数是不匹配的)

几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了(IBM公司的专门研究表明,新生代中的80%的对象都是朝生夕死的)。

  • 可以使用选项-Xmn设置新生代最大内存大小,这个参数一般使用默认值即可。

图解对象分配过程

对象分配过程:概述

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生碎片。

  1. new的对象先放在Eden区。此区有大小限制
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区和from区进行垃圾回收(Minor GC/YGC),将Eden区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到Eden区
  3. 然后将Eden中的剩余对象移动到幸存者to区域(默认是0区),然后切换from和to
  4. 如果再次触发垃圾回收,同样对Eden和from区域进行回收,此时from区域为0区,to区域切换为1区,本次垃圾回收的幸存者将会移动到幸存者0区的
  5. 就这样经历不断的垃圾回收,幸存者一直在新生代的from区和to区之间移动。如果经过-XX:MaxTenuringThreshold=<N>设置的次数之后(默认15),该对象还没有被回收,该对象将被移到tenure老年代。
  6. 另外如果MinorGC后新建对象还是过大或者GC清理对象过少,剩余对象过多,to区无法承载新建对象,则该对象直接进入老年区(如果老年区够大)。
  7. 在老年代,相当悠闲。当老年代内存不足时,再次触发GC:Major GC,进行老年代的内存清理。
  8. 若老年代执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常:java.lang.OutOfMemoryError: Java heap space

image-20200916093717986

总结

  • 针对幸存者s0和s1区,复制之后有交换,谁空谁是to
  • 关于垃圾回收:频繁在新生区收集,很少在老年区收集,几乎不在永久区/元空间收集

image-20200916094118286

常用调优工具

  • JDK命令行:jstat、jinfo、jmap、jps、jstack等
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder(在JMC里面)
  • GCViewer
  • GC Easy

Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收的都是指新生代。

针对HotSpot VM的实现,它的垃圾收集按照回收区域又分为两大种类:一种是部分收集(Partial GC),另一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有CMS收集器会有单独收集老年代的行为
      • 注意,很多时候Major GC和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收(根据使用的垃圾收集器区分)
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有G1收集器会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

最简单的分代式GC策略的触发条件

  • 年轻代GC(Minor GC)触发机制:

    • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代指的是Eden+from。
    • 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
    • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 老年代GC(Major GC/Full GC)触发机制:

    • 指发生在老年代的GC,对象从老年代消失时,我们说"Major GC"或者"Full GC"发生了。
    • 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
      • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,会触发Major GC
    • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
    • 如果Major GC后,内存还不足,就报OOM了。
  • Full GC触发机制:

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

    1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
    2. 老年代空间不足
    3. 方法区空间不足
    4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    5. 由Eden区、suvivor space0(From Sapce)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
  1. CMS 因为资源不足无法进行多线程回收发生 Concurrent mode failed

说明:Full GC是开发或调优中尽量要避免的。这样暂停时间会短一些。

堆空间分代思想

为什么需要把Java堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有Eden、两块大小相同的Survivor(又称为from/to、s0/s1)构成,to总为空。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。

image-20200916105218715

分代的最大作用就是优化GC性能,对对象进行提前打标,在垃圾收集的时候就可以针对不同标识进行相关收集动作,防止在收集阶段才进行全对象的打标,会导致GC时间过长。

image-20200916105519846

内存分配策略

如果对象在Eden出生并经过第一次MinorGC后依然存货,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1.对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项-XX:MaxTenuringThresold来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden

  • 大于-XX:PretenureSizeThreshold的大对象直接分配到老年代

    • 尽量避免程序中出现过多的大对象,尤其是小于该阈值的大对象,因为它会先放在新生代,新生代的空间较小,很容易引发GC
  • 长期存活的对象分配到老年代

    • 每在年轻代经过一次Minor GC,对象年龄+1,年龄超过-XX: MaxTenuringThreshold的对象为长期存活
  • 动态对象年龄判断

    • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
  • 空间分配担保:-XX:HandlePromotionFailure

    在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

    • 如果大于,则此次Minor GC是安全的。(所有新生代对象都不能被收集且年龄满足晋升到老年代的极端情况下)
    • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保小范围GC带来的风险(例如MinorGC之后可能老年代内存还是不足以存放新生代幸存下来且要晋升到老年代的对象,此时还是要进行FullGC。所以此时相较于一开始就进行FullGC多了一次MinorGC)。
      • 如果该值为true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
        • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的,因为参考的是一个平均值
        • 如果小于,则改为进行一次Full GC
      • 如果该值为false,则改为进行一次Full GC

    在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,虽然源码中还定义了该参数,但是代码中已经不会再使用它了。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将会进行Full GC。

为对象分配内存:TLAB

为什么有TLAB(Thread Local Allocation Buffer)?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

什么是TLAB?

  • TLAB全称ThreadLocalAllocBuffer,是线程的一块私有内存,如果设置了虚拟机参数 -XX:UseTLAB在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的。

    TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销,提升内存分配的吞吐量,因此可以将这种内存分配方式称之为快速分配策略。

image-20200916113105504

在TLAB中分配内存直接通过指针碰撞方式(移动指针)进行分配。

TLAB的再说明:

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
  • 在程序中,开发人员可以通过选项-XX:UseTLAB设置是否开启TLAB空间
  • 默认情况下,TLAB空间的内存非常小(所以线程创建的时候Eden中的TLAB空间如果用完了,就不会给这个线程初始化TLAB),仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性到Eden空间中再申请内存。

小结堆空间的参数设置

打印所有的参数默认初始值
-XX:+PrintFlagsInitial
打印所有的参数的实际值
-XX:+PrintFlagsFinal
# 在JVM运行期间查看设置参数步骤:
# 1. jps:查看当前运行中的进程id
# 2. jinfo -flag SurvivorRatio 进程id:查看具体JVM进程的指定参数值

初始化堆空间内存(默认为物理内存的1/64)
-Xms128m
最大堆空间内存(默认为物理内存的1/4)
-Xmx128m
设置新生代大小(初始值及最大值)
-Xmn128m
配置新生代与老年代在堆结构的占比
-XX:NewRatio=3
设置新生代中Eden和S0/S1空间的比例
-XX:SurvivorRatio=4
设置新生代垃圾的最大年龄
-XX:MaxTenuringThreshold=15
输出详细的GC处理日志
-XX:+PrintGCDetails
打印GC简要信息
-XX:+PrintGC、-verbose:gc
设置是否空间分配担保
-XX:HandlePromotionFailure

   转载规则


《05_堆》 阿钟 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录