文章目录

  • 堆-运行时数据区最重要的内容
  • 堆的核心概述
  • 内存细分:
  • JDK7之前:新生区(代)+养老区(代)+永久区(代)
  • JDK8及之后:新生代+老年代+元空间
  • 设置堆内存大小与OOM
  • 年轻代与老年代
  • 相关参数:
  • 对象分配过程
  • 示意图:
  • 流程图:
  • 总结:
  • Minor GC、Major GC 、Full GC
  • 部分收集:
  • 整堆收集:
  • minor GC 触发机制:
  • MajorGC (老年代GC)触发机制
  • FullGC触发机制
  • 堆空间分代思想
  • 内存分配策略
  • 为对象分配内存:TLAB
  • 什么是TLAB?
  • 为什么要有TLAB?
  • 其它说明:
  • 堆空间的参数设置(调优)
  • 堆是分配对象的唯一选择吗?
  • 逃逸分析概述
  • 代码优化
  • 逃逸分析的补充:
  • 附1:AdaptiveSizePolicy

堆-运行时数据区最重要的内容

堆的核心概述

java虚拟机栈如何动态扩展 java虚拟机堆_老年代

  • 一个JVM 实例只存在一个堆内存,堆也是Java内存管理的核心区域
  • Java堆区在JVM启动的时候就被创建,空间大小也是固定的,是JVM管理的最大的一块内存空间

注:堆内存大小可以调节, -Xms 堆的初始大小 -Xmx 堆的最大大小

  • Java虚拟机规范规定,堆可以物理上不连续,但是逻辑上应该被视为连续的
  • 所有的线程共享Java堆,在这里可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
  • Java虚拟机规范中对堆的描述:所有的实例对象以及数组都应当在运行时分配在堆上(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
  • "几乎"所有的对象都在这里分配内存

逃逸分析可能会进行栈上分配
但Hotspot虚拟机并未实现栈上分配

  • 数组和对象可能永远都不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
  • 堆,是GC(Garbage Collection,垃圾回收器)执行垃圾回收的重点区域

内存细分:

JDK7之前:新生区(代)+养老区(代)+永久区(代)
  • Young Generation Space: 新生代/新生区,又被划分为Eden区和Survivor区
  • Tenure generation space :老年代/养老区,Old/Tenure
  • Permanent Space: 永久区
JDK8及之后:新生代+老年代+元空间
  • Young Generation Space :新生代/新生区
  • 又被划分为 Eden区和Survivor区
  • Tenure generation Space : 老年代/养老区
  • Meta Space :元空间

新生代 === 新生区 ===年轻代

老年代 === 养老区 == 老年区

永久代 === 永久区
以上同一行概念是等价的

java虚拟机栈如何动态扩展 java虚拟机堆_老年代_02

设置堆内存大小与OOM

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启用时就确定了
  • -Xms 用来设置堆空间(新生代+老年代)的初始内存大小
  • -X 是JVM的运行参数
  • ms的含义是memory start
  • -Xmx 用来设置堆空间(新生代+老年代)的最大内存大小
  • windows操作系统下虚拟机的默认大小:
  • 初始内存大小:电脑物理内存/64
  • 最大内存大小:物理电脑内存/4
  • 开发/生产环境中建议把二者值设置成相同值,以避免频繁扩容和释放导致PC有额外压力
  • 查看设置的参数:
  • jps 或者 控制台输入:jstat -gc 进程id
  • 运行时配置参数 -XX:+PrintGCDetails

注意,查看参数时,由于surviver0和surviver1区同时只能生效一个,所以会导致程序计算-Xms大小和实际不一致

OOM :OutOfMemoryError:Java heap space,当堆空间超出内存空间大小时会报该错误。

年轻代与老年代

  • 存储在JVM的Java对象可以被分为两类:
  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另一类是对象的生命周期很长,某些极端情况下还能与JVM生命周期保持一致
  • Java堆区进一步区分,可以划分为年轻代和老年代;年轻代又可以区分为Eden空间、Survivor0、Survivor1空间(有时也叫from区、to区)

java虚拟机栈如何动态扩展 java虚拟机堆_老年代_03

相关参数:

  • -XX:NewRatio,设置新生代与老年代比例,默认值为2,此参数一般不用修改,如果确定很多对象生命周期很长,就可以手动把老年代调大一些。
  • -XX:NewRatio=2表示新生代占1,老年代占2,新生代占整个堆的1/3
  • -XX:NewRatio=4表示新生代占1,老年代占4,新生代占整个堆的1/5
  • -XX:SurvivorRatio,设置Eden空间与单个Survivor空间比例,Hotspot中,eden:Survivor0:Survivor1是8:1:1;
  • -XX:SurvivorRatio=8 默认值是8*

注意:虽然官方文档描述默认值为8,但实际情况可能略有区别,与自适应机制有关,也可能与其它有关

自适应机制:默认情况下有一个自适应机制,可能会导致看到的比例与设计比例不一致,可以通过-XX:-UseAdaptiveSizePolicy取消自适应,详细讲解请看文章末尾的附录1

  • -Xmn:设置新生代最大内存大小

补充说明:几乎所有的Java对象都是在Eden区被new出来的;绝大部分的Java对象的销毁都在新生代进行了;

对象分配过程

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

  1. new 的对象先放Eden区,此区有大小限制
  2. 当Eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor/Young GC,年轻代的垃圾回收),将Eden区的不再被其他对象引用的对象进行销毁,然后加载新的对象放在Eden区,然后将Eden中的剩余对象移到Survivor0区
  3. 如果再次触发垃圾回收,Survivor0区的对象中没有被回收的,就会放在Survivor1区
  4. 如果再次经历回收,Survivor1区的幸存对象会重新进到Survivor0区。默认循环15次之后,会进入到老年代
  • -XX:MaxTenuringThreshold=<N>:用来设置循环次数

注意:Survivor区满的时候不会触发YGC,但是Eden区满的时候回一起回收Survivor区。

示意图:

下图红色部分代表即将要进行回收的垃圾,绿色代表需要继续留存的。

java虚拟机栈如何动态扩展 java虚拟机堆_老年代_04

流程图:

java虚拟机栈如何动态扩展 java虚拟机堆_java虚拟机栈如何动态扩展_05

若放到survivor放不下,则一部分即使没有达到阈值也会直接放到老年代中

总结:

  • S0(Survivor0)、S1区(Survivor1)复制之后有交换,谁空谁是to区
  • GC频繁在新生区收集,很少在老年代收集,几乎不在元空间收集

Minor GC、Major GC 、Full GC

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

部分收集:
  • 年轻代GC:minor GC;老年代GC:Major GC;混合收集(Mixed GC)
  • 目前只有CMS GC 会单独收集老年代
  • 很多时候MajorGC和FullGC会混淆使用,需要注意分辨是老年代回收还是整堆回收
  • GC会导致STW(Stop The World),所以尽量减少GC,MajorGC和FullGC是主要目标
  • 混合收集收集新生代以及部分老年代的GC
  • 目前只有G1 GC会有这种行为
整堆收集:

Full GC:收集整个java堆和方法区的垃圾

minor GC 触发机制:
  • 年轻代的eden空间不足时,就会触发minor GC,Survivor不会触发,但是会一起回收
  • Java对象大多周期较短,所以MinorGC非常频繁,一般回收速度也比较快
  • minor GC 会触发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
MajorGC (老年代GC)触发机制
  • 发生在老年代的GC,对象从老年代消失时,该GC发生了
  • 出现了Major GC,经常会伴随 至少一次的MinorGC(并非绝对,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
  • 老年代空间不足时,会尝试先触发Minor GC,如果之后空间依然不足,触发MajorGC
  • MajorGC速度会比minorGC慢10倍以上,STW时间更长
  • MajorGC后空间依然不足,则报OOM
FullGC触发机制

触发FullGC有以下五种:

  • 调用System.gc()时,系统建议执行Full GC,但不是必然执行
  • 老年代空间不足
  • 方法区/元空间 空间不足
  • 通过MinorGC后进入老年代的平均大小大于老年代可用内存
  • 由Eden区、Survivor 0区(from区)向Survivor1区(to区)复制时,对象大小大于to 区可用内存,且老年代可用内存大小小于该对象大小(因为会存到老年代中)
  • Full GC是开发或调优尽量避免的

堆空间分代思想

为什么要把Java堆分代?

不同对象的生命周期不同,70-99%都是临时对象

  • 分代的理由就是优化GC的性能
  • 如果没有分代,则所有对象都在一起,每次GC都要扫描所有的区域
  • 如果分代,只需要高频扫描新生代即可

内存分配策略

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

可以通过-XX:MaxTenuringThreshold来设置晋升老年代的阈值

  • 优先分配到eden
  • 大对象直接分配到老年代-内存空间连续的对象
  • 尽量避免程序中出现过多的大对象
  • 长字符串、数组等都属于大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
  • 如果Survivor去中相同的年龄所有对象大小的总和大于Survivor空间的一般,年龄大于或等于该年龄的对象可以直接进入老年代,无需达到规定的阈值
  • 空间分配担保
  • -XX:HandlePromotionFailure

为对象分配内存:TLAB

什么是TLAB?

TLAB :Thread Local Allocation Buffer

  • 从内存模型而不是垃圾回收的角度,对Eden区域继续进行划分,为每个线程分配了一个私有的缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此可以将这种分配方式称之为 快速分配策略
  • OpenJDK衍生出来的JVM都提供了TLAB的设计

为什么要有TLAB?

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

其它说明:

  • 尽管不是所有的对象实例都能在TLAB中成功分配内存,但JVM确实将TLAB作为内存分配的首选
  • 在程序中,可以通过-XX:UseTLAB设置是否开启TLAB空间,通过-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过 使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

堆空间的参数设置(调优)

  • -XX:+PrintFlagsInitial:查看所有参数的默认初始值
  • -XX:+PrintFlagsFinal:查看所有参数的最终值
  • 具体查看某个参数的指令:jps:查看当前运行中的进程;jinfo -flag SurvivorRatio 进程id
  • -Xms:初始堆空间内存
  • -Xmx:最大堆空间内存
  • Xmn:设置新生代大小
  • -XX:NewRatio:设置新生代与老年代在堆结构的比例
  • -XX:SurvivorRatio:设置新生代中Enden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGcDetails:输出详细的GC处理日志
  • 打印GC简要信息:-XX:+PrintGC -verbose:gc
  • -XX:HandlePromotionFailure:是否设置空间分配担保
  • 在发生MinorGC之前,虚拟机会 检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
  • 如果大于,则此次MinorGC是安全的
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败
  • 如果是true,则会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
  • 如果大于,尝试进行一次MinorGC,但这次MinorGC依然是有风险的
  • 如果小于,则改为进行一次Full GC。
  • 如果是False,则改为进行一次FullGC

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

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

在《深入理解java虚拟机》种关于Java堆内存有这样一段描述: 随着JIT编译期的发展与 逃逸分析技术逐渐成熟, 栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了

JVM中,对象是在Java堆中分配的,这是一个常识。但是:


如果经过逃逸分析(Escape Analysis)后发现,一个对象没有逃逸出方法的话,n就可能被优化成栈上分配


这样就无需在堆上分配内存,也无需进行GC

此外,基于OpenJDK深度定制的TaoBaoJVM,其创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移到heap外,且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC回收效率的目的

逃逸分析概述

  • 将堆对象分配到栈上,需要使用逃逸分析手段
  • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
  • 通过逃逸分析,Java Hotspot编译器能分析出一个新对象引用的使用范围,从而决定是否将这个对象分配到堆上。
  • 逃逸分析的基本行为就是分析对象动态作用域
  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,对象被外部方法被引用,则认为发生逃逸。如:作为参数传递到其它地方中
  • 没有发生逃逸的对象,可以分配到栈上。随着方法执行的结束,栈空间就被移除。

JDK6U23之后,HotSpot默认开启逃逸分析,更早的版本可以通过-XX:+DoEscapeAnalysis开启逃逸分析,通过-XX:+PringEscapeAnalysis查看逃逸分析的筛选结果

  • 开发中能使用局部变量就不要使用全局变量

代码优化

使用逃逸分析,编译器可以对代码做出如下优化:

  1. 栈上分配:将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  2. 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 同步的性能代价很高,同步的后果是降低并发性和性能
  • 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断块所使用的锁对象是否只能被一个线程访问而没有被发布到其它线程。如果没有,JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样能大大提高并发和性能。这个取消同步的过程就是叫做同步省略,也叫锁消除
  1. 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
  • JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替代,这个过程就是标量替换

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

逃逸分析的补充:

  • 逃逸分析本身也并不是非常成熟的,其根本原因就是 无法保证逃逸分析的性能消耗一定高于被分析后减少的消耗。虽然经过逃逸分析可以做标量替换、栈上分配和锁消除。但逃逸分析本身也需要进行一系列复杂分析,也是一个相对耗时的过程
  • 逃逸分析虽然不成熟,但是它是 即时编译器优化技术中一个十分重要的手段
  • Hotspot中并未实现栈上分配,其优化主要是通过标量替换,因此其所有的对象实例都创建在堆上
  • JDK7以后发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并没有被转移到元数据区,而是直接在堆上分配。所以也符合:对象实例都分配在堆上

附1:AdaptiveSizePolicy

JDK1.8的默认垃圾回收器是UseParallelGC,默认启动了AdaptiveSizePolicy。这个参数会让垃圾回收器根据每次垃圾回收的GC时间和吞吐量来动态调整eden区和survivor区的比例。

AdaptiveSizePolicy有三个目的:

  • Pause goal : 应用达到预期的GC暂停时间。
  • Throughput goal : 应用达到预期的吞吐量,即应用正常运行时间/(正常运行时间+GC耗时)
  • Minimum footprint :近可能小的内存占用量

AdaptiveSizePolicy为了达到三个预期目标,涉及以下操作:

  • 如果GC停顿时间超过了预期值,会减小内存大小。理论上,减小内存,可以减少垃圾标记等操作的耗时,以此达到预期停顿时间。
  • 如果应用吞吐量小于预期,会增加内存大小。理论上,增大内存,可以降低GC的频率,以此达到预期吞吐量。
  • 如果应用达到了前两个目标,则尝试减小内存,以减小内存消耗

AdaptiveSizePolicy看上去很智能,但有时它会引发 GC 问题。

上文中提到,eden区和两个survivor区的比例有时不是8:1:1,其原因就是AdaptiveSizePolicy为了达到期望的目标而进行了调整。

Survior 区变小,老年代占比变高的原因分析如下:

  • 在默认 SurvivorRatio = 8 的情况下,没有达到吞吐量的期望,AdaptiveSizePolicy加大了 Eden 区的大小。Survivor0Survivor1区被压缩。
  • 当 YGC 发生时候,由于Survivor1区太小,存活的对象直接进入到老年代。老年代占用量逐渐变大。