深入理解java虚拟机-1.自动内存管理

文章目录

  • 1、自动内存管理
    • 1.1 Java内存区域与内存溢出异常
      • 1.1.1 运行时数据区域
        • 程序计数器
          • 程序计数器为什么是私有的?
        • java虚拟机栈
        • 本地方法栈
          • 虚拟机栈和本地方法栈为什么是私有的?
        • Java堆
          • 创建的对象一定会放在堆中吗?
        • 方法区
          • 运行时常量池
          • 直接内存
      • 1.1.2 HotSpot虚拟机对象探秘
        • 对象的创建
        • 对象的内存布局
        • 对象的访问定位
      • 1.1.3 实战:OutOfMemoryError异常
        • Java堆溢出
        • 虚拟机栈和本地方法栈溢出
        • 方法区和运行时常量池溢出
        • 本机直接内存溢出
    • 1.2 垃圾收集器与内存分配策略
      • 概述
      • 对象已死?
        • 引用计数算法
        • 可达性分析算法
        • 再谈引用(JDK 1.2版之后的4种引用)
        • 生存还是死亡
        • 回收方法区
      • 垃圾收集算法
        • 分代收集理论
        • 标记-清除算法
        • 标记-复制算法
        • 标记-整理算法
      • HotSpot的算法细节实现
        • 根节点枚举
        • 记忆集与卡表
        • 写屏障
        • 并发的可达性分析
        • 经典垃圾收集器
        • Serial收集器
        • ParNew收集器
        • Parallel Scavenge收集器
        • Serial Old收集器
        • Parallel Old收集器
        • CMS收集器
        • Garbage First(简称G1)收集器
        • 低延迟垃圾收集器
      • 选择合适的垃圾收集器
        • 收集器的权衡
        • 虚拟机及垃圾收集器日志
        • 垃圾收集器参数总结
      • 实战:内存分配与回收策略
        • 对象优先在Eden分配
        • 大对象直接进入老年代
        • 长期存活的对象将进入老年代
        • 动态对象年龄判定
        • 空间分配担保
    • 1.3 虚拟机性能监控、故障处理工具
    • 1.4 调优案例分析与实战

1、自动内存管理

1.1 Java内存区域与内存溢出异常

1.1.1 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

JVM内存结构图如下:
深入理解java虚拟机-1.自动内存管理_第1张图片

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间。

程序计数器跟线程相关,一个线程对应一个程序计数器
存放的是对应线程运行到了哪一行指令。

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
    需要注意的是,如果执行的是本地 方法(native修饰的方法,一般是C语言的方法),那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是正在执行的虚拟机字节码指令的地址;。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。

首先一个线程对应一个java虚拟机栈
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

简单说:栈里面的每一个元素是栈帧,一个栈帧对应线程执行的一个方法。线程运行哪个方法,这个方法就要被封装成栈帧放入到java虚拟机栈里面

栈帧的介绍:(了解)

一个栈帧(Stack Frame),又分为四块:
1.局部变量表:方法的局部变量。基本类型的变量直接存储。对象型的变量,在这个表里面存储的是引用。
其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
2.操作数栈:cpu在运行指令时需要用到的基础数据,都存放在这块空间里面。
3.动态链接:方法里面接口类型的变量调用方法时,真正的指令地址。符号引用转换为直接引用。
4.方法出口:栈帧对应的方法运行完后要返回到哪。

本地方法栈

这个空间的结构根java虚拟机栈的结构一模一样,是用来存储本地方法信息。也就是native修饰的方法。(本地方法:简单地讲,一个Native Method就是一个java调用非java代码的接口)。

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,将虚拟机栈和本地方法栈合并了。

虚拟机栈和本地方法栈都会出现StackOverflowError和OutofMemoryError,程序计数器是java虚拟机中唯一一块不会产生error的内存区域,

StackOverflowErro:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
OutofMemoryError代表的是,当再申请新的内存时,虚拟机分配给线程的内存大小中无法再分配新的内存,就会出现此error。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

虚拟机栈和本地方法栈为什么是私有的?

为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的

Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。

Java堆属于共享内存区域,在虚拟机启动时创建。
几乎所有(有例外)的对象实例都在这里分配内存。 所有的对象在实例化后的整个运行周期内,都被存放在堆内存中

Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx堆的最大空间大小和-Xms堆的最小空间大小控制)。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

Java堆是垃圾收集器管理的主要区域主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代”来设计,需要新生代、老年代收集器搭配才能工作,所以Java堆中还可以细分为:新生代和老年代;
深入理解java虚拟机-1.自动内存管理_第2张图片
从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

创建的对象一定会放在堆中吗?

一般认为new出来的对象都是被分配在堆上,但是这个结论不是那么的绝对,通过对Java对象分配的过程分析,可以知道有两个地方会导致Java中new出来的对象并一定分别在所认为的堆上。这两个点分别是Java中的逃逸分析TLAB(Thread Local Allocation Buffer)即:本地线程分配缓冲区

方法区

属于共享内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
比如:运行时常量池(Runtime Constant Pool)是方法区的一部分。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。 这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载

运行时常量池

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法

详细信息可参见:浅谈常量池

直接内存

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

1.1.2 HotSpot虚拟机对象探秘

对象的创建

创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,
可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值

接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

HotSpot虚拟机对象的对象头部分包括两类信息
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0.
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

接下来实例数据部分对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对象的第三部分是对齐填充,这并不是必然存在的也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄直接指针两种。

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示。
深入理解java虚拟机-1.自动内存管理_第3张图片

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示。
深入理解java虚拟机-1.自动内存管理_第4张图片

这两种对象访问方式各有优势
使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用直接指针方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发).

1.1.3 实战:OutOfMemoryError异常

读者需意识到内存溢出异常与虚拟机本身的实现细节密切相关,并非全是Java语言中约定的公共行为。因此,不同发行商、不同版本的Java虚拟机,其需要的参数和程序运行的结果都很可能会有所差别。

Java堆溢出

随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

代码清单2-3 Java堆内存溢出异常测试

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * @author zzm
 */
public class HeapOOM {

    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
         while (true) {
            list.add(new OOMObject());
        }
    }
}

指定堆的大小
深入理解java虚拟机-1.自动内存管理_第5张图片

运行结果:
深入理解java虚拟机-1.自动内存管理_第6张图片
Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。
第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

为了验证这点,我们可以做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为是否能让HotSpot虚拟机产生OutOfMemoryError异常:

实验1:使用-Xss参数减少栈内存容量。
结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

实验2:定义了大量的本地变量,增大此方法帧中本地变量表的长度。

结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

代码清单2-4 虚拟机栈和本地方法栈测试(作为第1点测试程序)

/**
 * VM Args:-Xss128k
 * @author zzm
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
      JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

设置 -Xss128k
深入理解java虚拟机-1.自动内存管理_第7张图片

运行结果:
深入理解java虚拟机-1.自动内存管理_第8张图片
继续验证第二种情况,这次代码就显得有些“丑陋”了,为了多占局部变量表空间,笔者不得不定义一长串变量,具体如代码清单2-5所示。

代码清单2-5 虚拟机栈和本地方法栈测试(作为第2点测试程序)

/**
 * @author zzm
 */
public class JavaVMStackSOF2 {
    private static int stackLength = 0;

    public static void test() {
        long unused1, unused2, unused3, unused4, unused5,
             unused6, unused7, unused8, unused9, unused10,
             unused11, unused12, unused13, unused14, unused15,
             unused16, unused17, unused18, unused19, unused20,
             unused21, unused22, unused23, unused24, unused25,
             unused26, unused27, unused28, unused29, 
             unused30,
             unused31, unused32, unused33, unused34, unused35,
             unused36, unused37, unused38, unused39, unused40,
             unused41, unused42, unused43, unused44, unused45,
             unused46, unused47, unused48, unused49, unused50,
             unused51, unused52, unused53, unused54, unused55,
             unused56, unused57, unused58, unused59, unused60,
             unused61, unused62, unused63, unused64, unused65,
             unused66, unused67, unused68, unused69, unused70,
             unused71, unused72, unused73, unused74, unused75,
             unused76, unused77, unused78, unused79, unused80,
             unused81, unused82, unused83, unused84, unused85,
             unused86, unused87, unused88, unused89, unused90,
             unused91, unused92, unused93, unused94, unused95,
             unused96, unused97, unused98, unused99, unused100;

        stackLength ++;
         test();

        unused1 = unused2 = unused3 = unused4 = unused5 =
        unused6 = unused7 = unused8 = unused9 = unused10 =
        unused11 = unused12 = unused13 = unused14 = unused15 =
        unused16 = unused17 = unused18 = unused19 = unused20 =
        unused21 = unused22 = unused23 = unused24 = unused25 =
        unused26 = unused27 = unused28 = unused29 = unused30 =
        unused31 = unused32 = unused33 = unused34 = unused35 =
        unused36 = unused37 = unused38 = unused39 = unused40 =
        unused41 = unused42 = unused43 = unused44 = unused45 =
        unused46 = unused47 = unused48 = unused49 = unused50 =
        unused51 = unused52 = unused53 = unused54 = unused55 =
        unused56 = unused57 = unused58 = unused59 = unused60 =
        unused61 = unused62 = unused63 = unused64 = unused65 =
        unused66 = unused67 = unused68 = unused69 = unused70 =
        unused71 = unused72 = unused73 = unused74 = unused75 =
        unused76 = unused77 = unused78 = unused79 = unused80
        =
        unused81 = unused82 = unused83 = unused84 = unused85 =
        unused86 = unused87 = unused88 = unused89 = unused90 =
        unused91 = unused92 = unused93 = unused94 = unused95 =
        unused96 = unused97 = unused98 = unused99 = unused100 = 0;
    }

    public static void main(String[] args) {
        try {
            test();
        }catch (Error e){
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

直接运行
深入理解java虚拟机-1.自动内存管理_第9张图片

实验结果表明:
无论是由于栈帧太大还是虚拟机栈容量太小当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都StackOverflowError异常。
如果测试时不限于单线程,通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的.具体如代码清单
2-6所示。但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
原因其实不难理解,
操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽,代码清单2-6演示了这种情况。

代码清单2-6 创建线程导致内存溢出异常

/**
 * VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行)
 * @author zzm
 */
public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {
        }
    }
      public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

重点提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作,由于在Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上,无限制地创建线程会对操作系统带来很大压力,上述代码执行时有很高的风险,可能会由于创建线程数量过多而导致操作系统假死。

结果:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。也是由于这种问题较为隐蔽,从JDK 7起,以上提示信息中“unable to create native thread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resource limits reached”。

方法区和运行时常量池溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。
前面曾经提到HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量,具体实现如代码清单2-7所示,请读者测试时首先以JDK 6来运行代码

代码清单2-7 运行时常量池导致的内存溢出异常

/**
 * VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
 * VM Args:-Xms6m -Xmx6m
 * @author zzm
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        // 使用Set保持着常量池引用,避免Full GC回收常量池行为
        Set<String> set = new HashSet<String>();
        // 在short范围内足以让6MB的PermSize产生OOM了
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)

运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代)的一部分。

而使用JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使用-XX:
MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇[1]。出现这种变化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx参数限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:

JDK 8 版本 限制最大堆到6MB
深入理解java虚拟机-1.自动内存管理_第10张图片

-Xms6m -Xmx6m

深入理解java虚拟机-1.自动内存管理_第11张图片
我们再来看看方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单2-8里笔者借助了CGLib[3]直接操作字节码运行时生成了大量的动态类。

值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,类似这样的代码确实可能会出现在实际应用中:当前的很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存。另外,很多运行于Java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性,随着这类动态语言的流行,与代码清单2-9相似的溢出场景也越来越容易遇到。

代码清单2-9 借助CGLib使得方法区出现内存溢出异常

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 * @author zzm
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}

在JDK 7中的运行结果:

Caused by: java.lang.OutOfMemoryError: PermGen space
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
    ... 8 more

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。
在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。
这类场景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

在JDK 8以后永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。不过为了让使用者有预防实际应用里出现类似于代码清单2-9那样的破坏性的操作,HotSpot还是提供了一些参数作为元空间的防御措施,主要包括:

·-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。

·-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

·-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致,代码清单2-10越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

代码清单2-10 使用unsafe分配本机内存

/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * @author zzm
 */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError
    at sun.misc.Unsafe.allocateMemory(Native Method)
    at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

1.2 垃圾收集器与内存分配策略

概述

垃圾收集(Garbage Collection,下文简称GC)。
垃圾收集需要完成的三件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理

对象已死?

垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

引用计数算法

引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

局限性:譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

如图3-1所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。
深入理解java虚拟机-1.自动内存管理_第12张图片

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

再谈引用(JDK 1.2版之后的4种引用)

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

·强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

·软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

·弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

·虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

生存还是死亡

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

回收方法区

垃圾收集算法

重点介绍分代收集理论和几种算法思想及其发展过程。

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;

设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域[2]。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

统一定义:

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为

整堆收集Full GC):收集整个Java堆和方法区的垃圾收集。

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程.

主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如图3-2所示。
深入理解java虚拟机-1.自动内存管理_第13张图片

标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法:

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
这样实现简单,运行高效,不过其缺陷也显而易见,
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局[1]。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存.
深入理解java虚拟机-1.自动内存管理_第14张图片
还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

HotSpot的算法细节实现

常见的对象存活判定算法和垃圾收集算法,Java虚拟机实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

根节点枚举
记忆集与卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

代码清单3-5 以对象指针来实现记忆集的伪代码

Class RememberedSet {
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

·字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

·对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

·卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集[1],这也是目前最常用的一种记忆集实现形式,

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块.
深入理解java虚拟机-1.自动内存管理_第15张图片
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

写屏障

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

并发的可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。
在根节点枚举(见3.4.1节)这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

如果用户线程与收集器是并发工作呢?
收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。
一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好
另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

·赋值器插入了一条或多条从黑色对象到白色对象的新引用;

·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

经典垃圾收集器

各款经典收集器之间的关系如图3-6所示。

深入理解java虚拟机-1.自动内存管理_第16张图片
在JDK 7 Update 4之后(在这个版本中正式提供了商用的G1收集器

如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

Serial收集器

Serial [ˈsɪəriəl] 连续的;多次的

这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。
深入理解java虚拟机-1.自动内存管理_第17张图片
HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等,我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,但是仍然没有办法彻底消除(这里不去讨论RTSJ中的收集器),探索更优秀垃圾收集器的工作仍在继续。

迄今为止,它依然是HotSpot虚拟机运行在客户端模式下默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;
对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。ParNew收集器的工作过程如图3-8所示。
深入理解java虚拟机-1.自动内存管理_第18张图片
ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作
可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

在JDK 5发布时,HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作

G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK 9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。

Parallel Scavenge收集器

CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

深入理解java虚拟机-1.自动内存管理_第19张图片
-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)[1]。如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
深入理解java虚拟机-1.自动内存管理_第20张图片

Parallel Old收集器

arallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的.在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如图3-10所示。
深入理解java虚拟机-1.自动内存管理_第21张图片

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

1)初始标记(CMS initial mark)

2)并发标记(CMS concurrent mark)

3)重新标记(CMS remark)

4)并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见3.4.6节中关于增量更新的讲解),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过图3-11可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。
深入理解java虚拟机-1.自动内存管理_第22张图片
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿
但是它还远达不到完美的程度,至少有以下三个明显的缺点
1.首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
2.由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
3.CMS是一款基于“标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

Garbage First(简称G1)收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路基于Region的内存布局形式
发展历程:从JDK 6 Update 14开始就有Early Access版本的G1收集器供开发人员实验和试用,但由此开始G1收集器的“实验状态”(Experimental)持续了数年时间,直至JDK 7 Update 4,Oracle才认为它达到足够成熟的商用程度,移除了“Experimental”的标识;到了JDK 8 Update 40的时候,G1提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”(Fully-Featured Garbage Collector)。
设计目标:设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

实现思路:首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,如图3-12所示。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率
深入理解java虚拟机-1.自动内存管理_第23张图片
局限
G1收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:
1.·譬如,将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?解决的思路我们已经知道:使用记忆集避免全堆作为GC Roots扫描.但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作

·譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过(见3.4.6节):CMS收集器采用增量更新算法实现而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

·譬如,怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:

·初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

·并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

·最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

·筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望[4]。
深入理解java虚拟机-1.自动内存管理_第24张图片
毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

CMS,G1的优缺点比较
相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的[6]。

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间.

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角[1]”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

延迟成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图3-14所示。
深入理解java虚拟机-1.自动内存管理_第25张图片

图3-14中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。由图3-14可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿;CMS和G1分别使用增量更新和原始快照(见3.4.6节)技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。

选择合适的垃圾收集器

收集器的权衡

我们应该如何选择一款适合自己应用的收集器呢?
这个问题的答案主要受以下三个因素影响

  1. 应用程序的主要关注点是什么?
    如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;
    如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;
    而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

  2. 运行应用的基础设施如何?
    譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows等。

  3. 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?

举个例子:
假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点,那么:

·如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。

·如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。

·如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在Win-dows操作系统下,那ZGC就无缘了,试试Shenandoah吧。

·如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。

虚拟机及垃圾收集器日志

阅读分析虚拟机和垃圾收集器的日志是处理Java虚拟机内存问题必备的基础技能,垃圾收集器日志是一系列人为设定的规则,多少有点随开发者编码时的心情而定,没有任何的“业界标准”可言,换句话说,每个收集器的日志格式都可能不一样。除此以外还有一个麻烦,在JDK 9以前,HotSpot并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。直到JDK 9,这种混乱不堪的局面才终于消失,HotSpot所有功能的日志都收归到了“-Xlog”参数上,这个参数的能力也相应被极大拓展了:

-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

命令行中最关键的参数是选择器(Selector),它由标签(Tag)和日志级别(Level)共同组成。标签可理解为虚拟机中某个功能模块的名字,它告诉日志框架用户希望得到虚拟机哪些功能的日志输出。垃圾收集器的标签名称为“gc”,由此可见,垃圾收集器日志只是HotSpot众多功能日志的其中一项,
日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度,默认级别为Info,HotSpot的日志规则与Log4j、SLF4j这类Java日志框架大体上是一致的。另外,还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:

·time:当前日期和时间。

·uptime:虚拟机启动到现在经过的时间,以秒为单位。

·timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。

·uptimemillis:虚拟机启动到现在经过的毫秒数。

·timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。

·uptimenanos:虚拟机启动到现在经过的纳秒数。

·pid:进程ID。

·tid:线程ID。

·level:日志级别。

·tags:日志输出的标签集。

如果不指定,默认值是uptime、level、tags这三个,此时日志输出类似于以下形式:

[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s

下面笔者举几个例子,展示在JDK 9统一日志框架前、后是如何获得垃圾收集器过程的相关信息,以下均以JDK 9的G1收集器(JDK 9下默认收集器就是G1,所以命令行中没有指定收集器)为例。

1)查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc:

bash-3.2$ java -Xlog:gc GCTest
[0.222s][info][gc] Using G1
[2.825s][info][gc] GC(0) Pause Young (G1 Evacuation Pause) 26M->5M(256M) 355.623ms
[3.096s][info][gc] GC(1) Pause Young (G1 Evacuation Pause) 14M->7M(256M) 50.030ms
[3.385s][info][gc] GC(2) Pause Young (G1 Evacuation Pause) 17M->10M(256M) 40.576ms

2)查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc*,用通配符*将GC标签下所有细分过程都打印出来,如果把日志级别调整到Debug或者Trace(基于版面篇幅考虑,例子中并没有),还将获得更多细节信息:

bash-3.2$ java -Xlog:gc* GCTest
[0.233s][info][gc,heap] Heap region size: 1M
[0.383s][info][gc ] Using G1
[0.383s][info][gc,heap,coops] Heap address: 0xfffffffe50400000, size: 4064 MB, Compressed Oops mode: Non-zero based:
0xfffffffe50000000, Oop shift amount: 3
[3.064s][info][gc,start ] GC(0) Pause Young (G1 Evacuation Pause)
gc,task ] GC(0) Using 23 workers of 23 for evacuation
[3.420s][info][gc,phases ] GC(0) Pre Evacuate Collection Set: 0.2ms
[3.421s][info][gc,phases ] GC(0) Evacuate Collection Set: 348.0ms
gc,phases ] GC(0) Post Evacuate Collection Set: 6.2ms
[3.421s][info][gc,phases ] GC(0) Other: 2.8ms
gc,heap ] GC(0) Eden regions: 24->0(9)
[3.421s][info][gc,heap ] GC(0) Survivor regions: 0->3(3)
[3.421s][info][gc,heap ] GC(0) Old regions: 0->2
[3.421s][info][gc,heap ] GC(0) Humongous regions: 2->1
[3.421s][info][gc,metaspace ] GC(0) Metaspace: 4719K->4719K(1056768K)
[3.421s][info][gc ] GC(0) Pause Young (G1 Evacuation Pause) 26M->5M(256M) 357.743ms
[3.422s][info][gc,cpu ] GC(0) User=0.70s Sys=5.13s Real=0.36s
[3.648s][info][gc,start ] GC(1) Pause Young (G1 Evacuation Pause)
[3.648s][info][gc,task ] GC(1) Using 23 workers of 23 for evacuation
[3.699s][info][gc,phases ] GC(1) Pre Evacuate Collection Set: 0.3ms
gc,phases ] GC(1) Evacuate Collection Set: 45.6ms
gc,phases ] GC(1) Post Evacuate Collection Set: 3.4ms
gc,phases ] GC(1) Other: 1.7ms
gc,heap ] GC(1) Eden regions: 9->0(10)
[3.699s][info][gc,heap ] GC(1) Survivor regions: 3->2(2)
[3.699s][info][gc,heap ] GC(1) Old regions: 2->5
[3.700s][info][gc,heap ] GC(1) Humongous regions: 1->1
[3.700s][info][gc,metaspace ] GC(1) Metaspace: 4726K->4726K(1056768K)
[3.700s][info][gc ] GC(1) Pause Young (G1 Evacuation Pause) 14M->7M(256M) 51.872ms
[3.700s][info][gc,cpu ] GC(1) User=0.56s Sys=0.46s Real=0.05s

3)查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug:

bash-3.2$ java -Xlog:gc+heap=debug GCTest
[0.113s][info][gc,heap] Heap region size: 1M
[0.113s][debug][gc,heap] Minimum heap 8388608 Initial heap 268435456 Maximum heap 4261412864
[2.529s][debug][gc,heap] GC(0) Heap before GC invocations=0 (full 0):
[2.529s][debug][gc,heap] GC(0) garbage-first heap total 262144K, used 26624K [0xfffffffe50400000, 0xfffffffe50500800,
0xffffffff4e400000)
[2.529s][debug][gc,heap] GC(0) region size 1024K, 24 young (24576K), 0 survivors (0K)
[2.530s][debug][gc,heap] GC(0) Metaspace used 4719K, capacity 4844K, committed 5120K, reserved 1056768K
[2.530s][debug][gc,heap] GC(0) class space used 413K, capacity 464K, committed 512K, reserved 1048576K
[2.892s][info ][gc,heap] GC(0) Eden regions: 24->0(9)
[2.892s][info ][gc,heap] GC(0) Survivor regions: 0->3(3)
[2.892s][info ][gc,heap] GC(0) Old regions: 0->2
[2.892s][info ][gc,heap] GC(0) Humongous regions: 2->1
[2.893s][debug][gc,heap] GC(0) Heap after GC invocations=1 (full 0):
[2.893s][debug][gc,heap] GC(0) garbage-first heap total 262144K, used 5850K [0xfffffffe50400000, 0xfffffffe50500800, 0xffffffff4e400000)
[2.893s][debug][gc,heap] GC(0) region size 1024K, 3 young (3072K), 3 survivors (3072K)
[2.893s][debug][gc,heap] GC(0) Metaspace used 4719K, capacity 4844K, committed 5120K, reserved 1056768K
[2.893s][debug][gc,heap] GC(0) class space used 413K, capacity 464K, committed 512K, reserved 1048576K

4)查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX:+Print-GCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint:

bash-3.2$ java -Xlog:safepoint GCTest
[1.376s][info][safepoint] Application time: 0.3091519 seconds
[1.377s][info][safepoint] Total time for which application threads were stopped: 0.0004600 seconds, Stopping threads took:
0.0002648 seconds
[2.386s][info][safepoint] Application time: 1.0091637 seconds
[2.387s][info][safepoint] Total time for which application threads were stopped: 0.0005217 seconds, Stopping threads took:
0.0002297 seconds

5)查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在JDK 9之前使用-XX:+PrintAdaptive-SizePolicy,JDK 9之后使用-Xlog:gc+ergo*=trace:

bash-3.2$ java -Xlog:gc+ergo*=trace GCTest [0.122s][debug][gc,ergo,refine] Initial Refinement Zones: green: 23, yellow:
69, red: 115, min yellow size: 46
[0.142s][debug][gc,ergo,heap ] Expand the heap. requested expansion amount:268435456B expansion amount:268435456B
[2.475s][trace][gc,ergo,cset ] GC(0) Start choosing CSet. pending cards: 0 predicted base time: 10.00ms remaining time:
190.00ms target pause time: 200.00ms
[2.476s][trace][gc,ergo,cset ] GC(0) Add young regions to CSet. eden: 24 regions, survivors: 0 regions, predicted young
region time: 367.19ms, target pause time: 200.00ms
[2.476s][debug][gc,ergo,cset ] GC(0) Finish choosing CSet. old: 0 regions, predicted old region time: 0.00ms, time
remaining: 0.00
[2.826s][debug][gc,ergo ] GC(0) Running G1 Clear Card Table Task using 1 workers for 1 units of work for 24 regions.
[2.827s][debug][gc,ergo ] GC(0) Running G1 Free Collection Set using 1 workers for collection set length 24
[2.828s][trace][gc,ergo,refine] GC(0) Updating Refinement Zones: update_rs time: 0.004ms, update_rs buffers: 0, update_rs
goal time: 19.999ms

6)查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuring-Distribution,JDK 9之后使用-Xlog:gc+age=trace:

bash-3.2$ java -Xlog:gc+age=trace GCTest
[2.406s][debug][gc,age] GC(0) Desired survivor size 1572864 bytes, new threshold 15 (max threshold 15)
[2.745s][trace][gc,age] GC(0) Age table with threshold 15 (max threshold 15)
[2.745s][trace][gc,age] GC(0) - age 1: 3100640 bytes, 3100640 total
[4.700s][debug][gc,age] GC(5) Desired survivor size 2097152 bytes, new threshold 15 (max threshold 15)
[4.810s][trace][gc,age] GC(5) Age table with threshold 15 (max threshold 15)
[4.810s][trace][gc,age] GC(5) - age 1: 2658280 bytes, 2658280 total
[4.810s][trace][gc,age] GC(5) - age 2: 1527360 bytes, 4185640 total

表3-3给出了全部在JDK 9中被废弃的日志相关参数及它们在JDK 9后使用-Xlog的代替配置形式。

表3-3 JDK 9前后日志参数变化
深入理解java虚拟机-1.自动内存管理_第26张图片

垃圾收集器参数总结

深入理解java虚拟机-1.自动内存管理_第27张图片

实战:内存分配与回收策略

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。
对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。

对象优先在Eden分配

HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析,

大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销
HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作

长期存活的对象将进入老年代

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

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

空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。

1.3 虚拟机性能监控、故障处理工具

1.4 调优案例分析与实战

来源:深入理解java虚拟机 —— 周志明

你可能感兴趣的:(java,java)