堆核心概述

  1. 所处位置

    堆是线程共享的,JVM运行起来就是一个进程,这个进程对应到一个Runtime类的实例,一个JVM进程只有一个实例,那么堆也是仅有一个,进程中的线程共享堆

    堆 - 图1

  2. 概述

    • 一个JVM实例(对应到一个进程)只存在一个堆内存,进程内的多个线程共享堆内存
    • Java堆空间在JVM启动的时候就被创建出来,其大小也就确定了(堆大小可以在创建之前调节,也可以动态调节)

      HeapDemo1和HeapDemo2代码完全一样

      堆 - 图2

      区别在于HeapDemo1设置的堆空间大小是10m,HeapDemo2的堆空间大小为20m

      堆 - 图3 找到jdk中bin目录下的jvisualvm,双击打开

      堆 - 图4

      HeapDemo1进程

      堆 - 图5

      HeapDemo2进程

      堆 - 图6


  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB),TLAB同样存储的是对象的实例。细分的目的是为了更好地回收内存,或者更快地分配内存
  • 此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存

    数组和对象永远不可能存储于栈上,因为栈帧中只保存引用,这个引用指向堆中的对象和数组

    堆 - 图7

  • 堆是GC(Garbage Collected,垃圾回收)的重点区域,方法结束后,从方法栈中出栈(栈帧中存储的是实例的引用),堆中的数据并不会立即回收,而是在垃圾收集的时候才会被移除(频繁GC会影响用户线程的执行)

  • 堆内存细分

    现代垃圾回收器大部分都是基于分代收集理论设计,堆空间细分为

    堆 - 图8

    以上说的逻辑上包含永久代/元空间,实际上永久代/元空间并不在堆中,而是方法区的实现

    堆 - 图9 演示JDK7和JDK8堆的区别

    JDK8之前叫做永久代,JDK8改为了元空间

    堆 - 图10

    JDK8

    堆 - 图11

    JDK7

    堆 - 图12


设置堆大小和OOM

  1. 设置堆内存大小

    注意:这里设置堆内存的大小只包含新生代+老年代

    官方文档

    -Xms:(-X表示jvm运行参数,ms memory start)设置堆区的起始内存,等价于-XX:InitalHeapSize

    -Xmx:设置堆区的最大内存,等价于 -XX:MaxHeapSize 开发中我们一般设置这两个参数相等,为了避免频繁的GC


  1. 默认内存大小

    如果不手动设置堆空间内存大小,默认设置的大小为

    初始内存大小:物理电脑内存大小的1/64

    最大内存大小:物理内存大小的1/4

  2. 演示堆内存大小

    默认情况下

    堆 - 图13

    手动设置堆空间起始内存和堆空间最大内存

    堆 - 图14

    堆 - 图15

    ❓:为什么设置的是600M,到这里却变成了575M?

    这里涉及到后面的垃圾回收的知识,在这里先说明

    S0或者S1区里面始终有一个是空的,也就是存放对象的只是Eden区+S0或者S1中的一个

    堆 - 图16 查看内存使用情况:

    1. jps查看进程,然后jstat -gc 进程id
    2. 使用参数-XX:+PrintGCDetails
      堆 - 图17
      同时可以注意到下图中的PSYoungGen中的total 179200 = 153600 + 25600
      也是Eden区大小+from或者to区的大小
      堆 - 图18
      -XX:+PrintGCDetails这个收集器日志参数,-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况

  1. OOM异常

    因为堆空间存放的是对象和数组,所以我们模拟不断地向堆空间中添加数组

    设置起始内存和最大内存为600m

    堆 - 图19

    当新生代和老年代都满了的时候,报OOM异常

    堆 - 图20

    堆 - 图21

    同时还可以看到是什么原因导致的

    堆 - 图22

年轻代与老年代

  • 存储在JVM中的对象可以分为两类:
    1. 一类是生命周期较短的瞬时对象,这些对象的创建和消亡都十分迅速
    2. 一类是对象的生命周期比较长,在某些极端的情况下甚至和JVM的生命周期保持一致
  • Java堆空间进一步细分可以分为:年轻代(YoungGen)和老年代(OldGen)
  • 其中年轻代又可以分为Eden空间、Survivor0和Survivor1(有时也称为from、to区)

    堆 - 图23

  • 新生代和老年代空间大小设置

    堆 - 图24

    设置起始内存和最大内存都是600M的时候,可以看到默认情况下新生代:老年代是1:2

    堆 - 图25

  • 新生代中Eden、S0和S1比例设置

    堆 - 图26

    但是我们看到其实并不是默认的8:1:1,而是6:1:1

    堆 - 图27

    关闭自适应大小并设置自定义大小比例

    堆 - 图28

    手动设置成8:1:1

    堆 - 图29

    堆 - 图30

  • 新生代和老年代的转换

    堆 - 图31

图解对象分配过程

演示对象在不同区之间的流转

  1. 一般情况下新创建的对象先放到Eden区
  2. 当Eden区放满的时候

    当Eden区满的时候,触发YGC(Minor GC)

    在垃圾回收的时候,会有一个过程叫做STW(Stop the world),此时用户线程就停止了

    然后判断Eden区哪些是垃圾,哪些不是垃圾

    垃圾被回收,非垃圾放入S0或者S1区,并且为每一个对象分配一个年龄计数器,从Eden过来的对象的年龄计数器设置为1

    堆 - 图32

  3. Eden区再次被放满,再次触发YGC

    注意:此时会将非垃圾放到空的幸存者区

    S0和S1又被称为from和to区,由于from和to并不是指定S0或者S1,所以是可变的。

    每次执行完GC之后,S0或者S1里面,谁空谁就是to 当Eden区再次放满的时候,再次触发YGC进行垃圾回收,此时会将Eden区的非垃圾和S0的非垃圾(也会判断是否是垃圾)的年龄计数器+1,并放到S1区

    堆 - 图33


  1. Eden再次被放满并且对象的年龄计数器达到阈值

    堆 - 图34

    当Eden再次触发垃圾回收的时候,

    如果幸存者区的对象达到了年龄计数器的阈值(默认是15)并且不是垃圾

    那么此对象会晋升(Promotion)到老年代中,并且年龄计数器+1

    如果没有达到年龄计数器的阈值,那么幸存者区的非垃圾对象和从Eden区过来的对象会放到to区,然后年龄+1 年龄计数器可以设置

    堆 - 图35


  1. 总结
    • 针对幸存者S0和S1区:垃圾回收之后,谁空谁是to
    • 关于垃圾回收:频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集

疑问

  1. 如果幸存者区满了会有垃圾回收吗?
    • 幸存者区满的时候,不会主动触发垃圾回收。而是在YGC的时候,一起将幸存者区进行垃圾回收
    • 如果幸存者区(已经被动垃圾回收之后)满了或者Eden区过来的数据Survivor区放不下,会有特殊规则将对象直接晋升为老年代
  2. 创建对象一定在Eden区吗?
    • 不一定,甚至有可能一创建对象就到了老年代

对象分配的特殊情况

堆 - 图36

Eden区先判断能否放得下新创建的对象

如果放不下,触发YGC,此时分为两步

  1. 新创建的对象在YGC之后的Eden区是否放得下,放得下就进去到Eden区,放不下进入老年代的判断
  2. YGC需要将Eden中的非垃圾和from区的数据放入到已经被动YGC的S0或者S1区,如果S0或者S1放不下这些数据,那么直接晋升为老年代

Java VisualVm演示

堆 - 图37

Minor GC、Major GC、Full GC

  • 针对HotSpot虚拟机的实现,GC主要分为两大类:部分收集(Partial GC)和整堆收集(Full GC)
    • 部分收集:不是完整收集整个Java堆的垃圾回收,其中又分为:
      • 新生代收集(Minor GC/ Young GC):只是新生代(Eden,S0,S1)的垃圾回收
      • 老年代收集(Major GC/ Old GC):只是老年代的垃圾收集
        • 目前只有CMS GC会有单独收集老年代的行为
        • 很多时候Major GC会和Full GC混合使用,需要具体分辨是老年代回收还是整堆回收
        • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
          • 目前只有G1 GC会有这个行为
    • 整堆收集(Full GC):收集堆和方法区(JDK8之前叫做永久代,JDK8改为元空间)
  • 新生代GC(Minor GC)触发机制
    • 新生代空间不足的时候,就会触发Minor GC,这里的新生代指的是Eden区满,Survivor区满并不会触发
    • 一般Minor GC非常频繁,但是回收速度也比较快。所以即使会引发STW(Stop the world),但是影响也不大
  • 老年代GC(Major GC)触发机制
    • 发生在老年代的GC,当对象从老年代消失的时候,我们就说“Major GC”或者“Full GC”发生了
    • 出现了Major GC,往往会伴随至少一次的Minor GC
    • Major GC的速度一般比Minor GC慢10倍以上,STW时间更长
    • 如果Major GC后还是内存不足,报OOM
  • Full GC触发机制

    堆 - 图38

堆空间分代思想

  1. 为什么需要进行Java堆空间分代?不分代就不能正常工作了吗?
    • 经研究,不同对象的生命周期不同,大部分都是临时对象
      • 新生代:有Eden、S0、S1(或from/to)构成,to总为空
      • 老年代:大部分是新生代中经历多次GC仍然存活的对象
    • 不分代完全可以,分代只是为了提高GC的性能。如果不分代的话,所有的对象都在同一块内存中,每次GC都需要对整个区域进行扫描,效率太低。分代的话,因为大部分都是临时对象,所以大部分的垃圾回收只需要扫描分代的新生代就行。

内存分配策略

  • 又称为对象提升(Promotion)规则
  • 规则如下:
    1. 对象优先在Eden分配
      • 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
    2. 大对象直接进入老年代
      • 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,这种需要尽量避免
      • 避免的主要原因:在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销
    3. 长期存活的对象将进入老年代
      • 对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
    4. 动态对象年龄判断
      • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。这样避免了每次从from区复制到to区的内存消耗
    5. 空间分配担保

      堆 - 图39

      Minor GC 不安全是什么意思?

      新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间

      取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁

      在JDK 6 Update 24之后,这个测试结果就有了差异,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略(也就是默认为true)

为对象分配内存:TLAB

分配缓冲区(Thread Local Allocation Buffer,TLAB)

  1. 为什么要有TLAB
    • 堆空间是线程共享的,任何线程都能够访问到堆空间的共享数据
    • 由于对象实例的创建在JVM中十分频繁,因此在并发环境下从堆区中划分空间是线程不安全的。既然线程不安全,又需要保证安全就必须加锁。进而影响了分配速度
    • TLAB的出现就是为了解决分配内存空间需要同步处理的问题把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  2. 什么是TLAB?

    • 从内存模型的角度来看,对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域

      堆 - 图40

    • 目的是为了更好地回收内存,或者更快地分配内存

  3. TLAB再说明

    • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
    • 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
    • 默认情况下,TLAB空间的内存非常小,仅占用整个Eden空间的1%

      堆 - 图41

    • 一旦对象的TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而保证在Eden空间分配内存

  4. 对象分配过程

    堆 - 图42

  5. 堆空间一定都是共享的吗?

    • 不是。对每一个线程来说,堆空间中都有一个线程独有的空间TLAB

堆空间的参数设置

-XX∶+PrintFlagsInitial∶查看所有的参数的默认初始值

-XX∶+PrintFlagsFinal ∶查看所有的参数的最终值(可能会存在修改,不再是初始值)

-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∶是否设置空间分配担保(JDK7以后默认为true设置不生效)

堆是分配对象的唯一选择吗?

  • new的对象一定在堆上面吗?
    • 不是
  • 逃逸:分析对象动态作用域,
    • 当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸
    • 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸
    • 从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
  • 如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化
    • 栈上分配(Stack Allocations):如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多栈上分配可以支持方法逃逸,但不能支持线程逃逸-XX:+/-DoEscapeAnalysis开启或关闭逃逸分析

      演示创建的对象未发生逃逸时,堆中的数量以及耗时和GC情况

      堆 - 图43

      设置堆空间为1G,确保不会进行垃圾回收,关闭逃逸分析,查看创建的对象数量情况

      -Xms1G -Xmx1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

      堆 - 图44

      开启逃逸分析之后创建的对象数量

      堆 - 图45

      可见,开启逃逸分析之后,创建的堆上面的对象显著减少 开启和关闭逃逸分析之后,GC的情况

      -Xms256m -Xmx256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

      未开启开启逃逸分析 发生了GC

      堆 - 图46

      开启逃逸分析之后发现并未发生GC

      堆 - 图47


  • 标量替换(Scalar Replacement):
    若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。
    假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。-XX:+/-EliminateAllocations:开启/关闭标量替换,默认开启

    堆 - 图48

    参数设置-Xms100m -Xmx100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:-EliminateAllocations

    堆 - 图49

  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉

    • 逃逸分析说明:目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。