Jvm基础篇-02-自动内存管理

文章目录

1. 概述

  • 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。不过,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

2. 运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
在这里插入图片描述

2.1 程序计数器

程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器

在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

  • 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
  • 因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果正在执行的是本地(Native)方法,这个计数器值则应为空(undefined)。

此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域;

2.2 Java虚拟机栈

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

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息

每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

与对象内存分配关系最密切的区域是“堆”和“栈”两块。
其中,“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分

  • 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。

  • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
  • 请注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
  • 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
    • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
    • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务

  • 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
  • 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和Out OfMemoryError异常。

2.4 Java堆

对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。

  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

  • Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。

  • 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。

  • 不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存

  • 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.5 方法区

方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量即时编译器编译后的代码缓存等数据

  • 虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
  • 在JDK8以前,很多人把方法区称呼为“永久代”(PermanentGeneration)。而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
  • 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载
  • 以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

2.6 字符串常量池、class常量池、运行时常量池

2.6.1 class常量池

当java文件被编译成class文件之后,会在class文件中生成我们所说的class常量池,class文件中除了包含类的版本、字段、方法接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(文本字符串、被声明为final的常量、基本数据类型的值)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

jvm的方法区里存放着类的版本,字段,方法,接口和常量池。常量池里存储着字面量和符号引用。
在这里插入图片描述

2.6.2 运行时常量池

运行时常量池是方法区的一部分。
下面是《深入理解Java虚拟机》一段摘录:
在这里插入图片描述

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

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

2.6.3 字符串常量池

字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。string pool在每个HotSpot VM的实例只有一份,被所有的类共享。
在jdk1.8后,将String常量池放到了堆中

在这里插入图片描述

2.7 直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

  • 在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/0方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。

  • 这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

  • 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

3. HotSpot虚拟机对象探秘

介绍完Java虚拟机的运行时数据区域之后,我们大致明白了Java虚拟机内存模型的概况,接下来以最常用的虚拟机HotSpot和最常用的内存区域Java堆为例,深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

3.1 对象的创建

Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?

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

3.2 对象的内存分配

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来

3.2.1 对象的内存分配--指针碰撞

假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。

3.2.2 对象的内存分配--空闲列表

但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

3.2.3 指针碰撞与空闲列表区别

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

  • 因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;
  • 而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

3.3 并发之下内存分配如何处理

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种可选方案:

  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
  • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

3.4 对象的最开始初始化

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。

这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

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

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。

但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即CIass文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。

一般来说(由字节码流中new指令后面是否跟随invok-especial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

3.5 对象的内存布局

openJdk对象头地址
在HotSpot虚拟机里对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

3.5.1 对象头

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,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向);

  • 对象头的另外一部分是类型指针(klass pointer)
    即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
    • 并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

3.5.2 实例数据

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。

HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

3.5.3 对齐填充

对象的第三部分是,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

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

3.6 对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象

  • 由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息;
    其结构如图所示。
    在这里插入图片描述

  • 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示。
    在这里插入图片描述

这两种对象访问方式各有优势

  • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销(HotSpot所默认)

4. 常见OutOfMemoryError异常

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能;

4.1 Java堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

  • 限制Java堆的大小不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展
  • 通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。

举个案例如下所示:

import java.util.ArrayList;

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *
 * @author wql
 * @date 2023/5/6 21:33
 */
public class HeapOOM {
    static class OOMObject {}
    public static void main(String[] args) {
        ArrayList<OOMObject> objects = new ArrayList<>();
        while (true) {
            objects.add(new OOMObject());
        }

    }
}

结果如下:

D:\working\Jdk8\bin\java.exe -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:61238,suspend=y,server=n -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -javaagent:C:\Users\wangqinglong01\AppData\Local\JetBrains\IntelliJIdea2022.1\captureAgent\debugger-agent.jar -Dfile.encoding=UTF-8 -classpath "D:\working\Jdk8\jre\lib\charsets.jar;D:\working\Jdk8\jre\lib\deploy.jar;D:\working\Jdk8\jre\lib\ext\access-bridge-32.jar;D:\working\Jdk8\jre\lib\ext\cldrdata.jar;D:\working\Jdk8\jre\lib\ext\dnsns.jar;D:\working\Jdk8\jre\lib\ext\jaccess.jar;D:\working\Jdk8\jre\lib\ext\jfxrt.jar;D:\working\Jdk8\jre\lib\ext\localedata.jar;D:\working\Jdk8\jre\lib\ext\nashorn.jar;D:\working\Jdk8\jre\lib\ext\sunec.jar;D:\working\Jdk8\jre\lib\ext\sunjce_provider.jar;D:\working\Jdk8\jre\lib\ext\sunmscapi.jar;D:\working\Jdk8\jre\lib\ext\sunpkcs11.jar;D:\working\Jdk8\jre\lib\ext\zipfs.jar;D:\working\Jdk8\jre\lib\javaws.jar;D:\working\Jdk8\jre\lib\jce.jar;D:\working\Jdk8\jre\lib\jfr.jar;D:\working\Jdk8\jre\lib\jfxswt.jar;D:\working\Jdk8\jre\lib\jsse.jar;D:\working\Jdk8\jre\lib\management-agent.jar;D:\working\Jdk8\jre\lib\plugin.jar;D:\working\Jdk8\jre\lib\resources.jar;D:\working\Jdk8\jre\lib\rt.jar;D:\code\demo-2023\out\production\demo-2023;D:\working\idea2022\IntelliJ IDEA 2022.1.4\lib\idea_rt.jar" com.ooo.HeapOOM
Connected to the target VM, address: '127.0.0.1:61238', transport: 'socket'
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid13144.hprof ...
Heap dump file created [26687608 bytes in 0.096 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:267)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
	at java.util.ArrayList.add(ArrayList.java:464)
	at com.ooo.HeapOOM.main(HeapOOM.java:20)
  • Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。
  • 出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。
  • 要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。
    • 第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
    • 可以使用第三方工具打开的堆转储快照文件。如果是内存泄漏,可进一步通过工具查看泄漏对象到GCRoots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GCRoots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GCRoots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
    • 如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

以上是处理Java堆内存问题的简略思路;

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

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

关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

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

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小

譬如参数-Xss128k可以正常用于32位Windows系充下的JDK6,但是如果用于64位Windws系统下的JDK11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K,如果低于这个最小很制,HotSpot虚拟器启动时会给出如下提示:

The Java thread stack size sp ecified is too small.Specify at least 228k

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。

如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。

但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。

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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。

  • String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

案例1:运行时常量池导致的内存溢出(ps: 演示的时候请使用jdk6)


/**
 * VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M
 *
 * @author wql
 * @date 2023/5/6 21:33
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        HashSet<String> set = new HashSet<>();
        short i = 0;
        while (true){
            set.add(String.valueOf(i++).intern());
        }
    }
}

结果如下所示:

Exception in thread "main"java. Lang. OutOfMemoryError: 
Per mGen space at java. lang. String. inter n(Native Method)...
  • 从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryEror异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即JDK6的HotSpot虚拟机中的永久代)的一部分。

  • 而使用JDK7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK7中继续使用
    -XX:MaxPermSize参数或者在JDK8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK6中的溢出异常,循环将一直进行下去,永不停歇。

  • 出现这种变化,是因为自JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK7及以上版本,限制方法区的容量对该测试用例来说是毫无意义;

我们再来看看方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。

虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等);

在JDK8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。
在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了

  • -XX:MaxMetaspaceSize
    设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize
    指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio
    作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有一XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比;

4.4 本机直接内存溢出

  • 直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致;

    • 越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getunsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用unsafe的功能

    • 在JDK10时才将unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

5. 垃圾回收方法论

上面已经介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。

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

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

5.1 如何判断对象是否可回收?

简单的说就是内存中已经不再被使用到的空间就是垃圾.
要进行垃圾回收,如何判断一个对象是否可以被回收

5.1.1 引用计数法

Java中,引用和对象是关联的。如果要操作对象则必须用引用进行
因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,给对象中添加一个引用计数器,
每当一个地方引用它,计数器值加1
每当一个引用失效时,计数器值减1。
任何时刻计数器值为零的对象就是不可能再被使用的,那么这个对象就是可回收对象
那为什么主流的Java虚拟机里面都没用这种算法呢?其中最主要的原因是它很难解决对象之间相互循环引用的问题
在这里插入图片描述

5.1.2 可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

  • 这个算法的基本思路就是通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。
    在这里插入图片描述
    如图所示,对象object5、object6、object7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。
5.1.2.1 GC Roots的对象

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

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

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

譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GCRoots集合中去,才能保证可达性分析的正确性。

5.2 对象的几种引用(强软弱虚)

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在JDK1.2版之前,Java里面的引用是很传统的定义:

  • 如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象一一很多系统的缓存功能都符合这样的应用场景。

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

5.2.1 强引用

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

5.2.2 软引用

软引用是用来描述一些还有用,但非必须的对象。

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

5.2.3 弱引用

弱引用也是用来描述那些非必须对象

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

5.2.4 虚引用

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。

5.3 对象的逃脱死亡(finalize())

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

  • 如果对象在进行可达性分析后发现没有与GCRoots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
  • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
  • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束

  • 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,

  • 稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可

  • 譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的fimalize()方法不会被再次执行;

5.4 方法区回收

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型

回收废弃常量与回收Java堆中的对象非常类似。

  • 举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。

  • 常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景
    如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,

还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassunLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,

XX:+TraceClassunLoading参数需要FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

6. 分代收集理论

垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在这我们暂不过多讨论算法实现,只重点介绍分代收集理论和几种算法思想及其发展过程。

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

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;

如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域–因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分; 也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把java堆划分为新生代(Young Generation)老年代(Old Generation)两个区域。

顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

6.1 弱分代假说

(Weak Generational Hypothesis)
绝大多数对象都是朝生夕灭的。

6.2 强分代假说

(Strong Generatio-nal Hypothesis)

熬过越多次垃圾收集过程的对象就越难以消亡。

6.3 跨代引用假说

假如要现在进行一次只局限于新生代区域内的收集(MinorGC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GCRoots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。
遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。
跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用;

只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。

此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

6.3.1 记忆集和卡表

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

  • 事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(PartialGC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便更好地理解。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

  • 如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构;

  • 这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。
    而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。

  • 那设计者在实现记忆集的时候,便可以选择更为粗扩的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

    • 字长精度
      每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
    • 对象精度
      每个记录精确到一个对象,该对象里有字段含有跨代指针。
    • 卡精度
      每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
  • 其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。

  • 前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。

卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等

  • 关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。

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

CARD_TABLE [this address>>9]=0;
  • 字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。

  • 一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。
    那如果卡表标识内存区域的起始地址是0×0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000 ~ 0×01FF、0x0200 ~ 0×03FF、0X0400~0×05FF的卡页内存块,如图所示。
    在这里插入图片描述
    一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
    在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

6.3.2 写屏障

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

  • 卡表元素何时变脏的答案是很明确的–有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

  • 但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

    • 假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;
  • 但在编译执行的场景中呢?
    经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

  • 先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。

  • 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

  • 在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(post-Write Barrier)

    • HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障;

    • 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。

  • 伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题

  • 假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏;

在JDK7之后,HotSpot虚拟机增加了一个新的参数-XX:+useCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

6.3.3 并发的可达性分析

  • 曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。

  • 在根节点枚举这个步骤中,由于GCRoots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。

  • 可从GCRoots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

  • 要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

  • 想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

    • 为了能解释清楚这个问题,我们引入三色标记(Tricolor Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
    • 白色表示对象尚未被垃圾收集器访问过
      显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
    • 灰色表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
    • 黑色表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
      黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。

  • 但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面演示了这样的致命错误具体是如何产生的。
    在这里插入图片描述
    并发出现”对象消失“ 问题的示意图

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

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

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

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

  • CMS是基于增量更新来做并发标记的
  • G1、Shenandoah则是用原始快照来实现

6.4 相关名词解释

6.4.1 部分收集(Partial GC)

指目标不是完整收集整个Java堆的垃圾收集
其中又分为:

  • 新生代收集(Minor GC/Young GC)
    指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/0ldG C)
    指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。

6.4.2 混合收集(MixedGC)

指目标是收集整个新生代以及部分老年代的垃圾收集。
目前只有G1收集器会有这种行为。

6.4.3 整堆收集(FullGC)

收集整个Java堆和方法区的垃圾收集。

7. 垃圾回收算法

7.1 引用计数

在这里插入图片描述

7.2 标记清除-算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。
在这里插入图片描述

如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象

标记过程就是对象是否属于垃圾的判定过程;

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
它的主要缺点有两个:

  • 第一个是执行效率不稳定
    • 如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题
    • 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

7.3 标记复制-算法

标记-复制算法常被简称为复制算法。

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

  • 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

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

标记-复制算法的执行过程如图所示。
在这里插入图片描述
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代

  • HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活

因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)

  • 内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

7.4 标记整理-算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如图所示。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
在这里插入图片描述

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

7.5 回收算法小结

分代收集算法:

  • 次数上频繁收集Young区
  • 次数上较少收集Old区
  • 基本不动元空间

4大算法:

  1. 引用计数法
    定义:给每个对象分配一个计算器,当有引用指向这个对象时,计数器加1,当指向该对象的引用失效时,计数器减1。最后如果该对象的计算器为0时,java垃圾回收器会认为该对象是可回收的。
    优点: 实时性
    无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。应用无需挂起, 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember 错误。
    缺点: 每次对对象赋值时均要维护引用计数器,且计数器本身也一定的消耗
    较难处理循环引用

  2. 复制算法
    针对Young: 年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想是将内存分为两块,每次只用其中一块,当这块内存用完,就将还活着的对象复制到另外一块上面,复制算法不会产生内存碎片。
    原理: 从根集合(GC Root)开始,通过Tracing从From 中找到存活对象,拷贝到To中;从From,To交换身份,下次内存分配从To开始
    优点: 没碎片
    缺点: 耗空间

  3. 标记清除
    针对老年代 一般是标记清除或者是标记整理的混合,算法分成标记和清除两个阶段,先标记处要回收的对象,然后统一回收这些对象。
    缺点: 两次扫描,耗时严重 会产生内存碎片
    优点: 不需要额外的空间
    用通俗的话解释一下标记清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要会回收的对象标记一遍,完成标记清理工作接下来就让程序恢复运行

  4. 标记压缩
    在标记清除算法上面加了一层整理
    优点: 没碎片
    缺点:耗时长

7.5.1 为什么新生代都是复制算法

因为新生代对象生存时间比较短,80%都是要回收的对象,采用标记-清除算法则内存空间碎片化严重,采用复制算法可以灵活高效,且便与整理空间。

7.5.2 老年代都是标记整理算法

标记整理算法解决来标记-清除算法的内存碎片化的问题,又解决了复制算法的两个Survivor区的问题,因为老年代的空间比较大,不可能采用复制算法,特别占用内存空间,

7.5.3 GC用什么算法比较好

分代收集算法: 新生代使用复制算法,老年代使用标记整理算法

7.6 jdk8和11 默认垃圾回收器

JDK8 默认垃圾回收器是 ParallelGC 即 Parallel Scavenge + Parallel Old

C:\Users\wangqinglong01>java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=267373952 
-XX:MaxHeapSize=4277983232
-XX:+PrintCommandLineFlags 
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops 
-XX:-UseLargePagesIndividualAllocation 
-XX:+UseParallelGC
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

JDK11 默认垃圾回收器是 G1

C:\Users\wangqinglong01>java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=8 
-XX:GCDrainStackTargetSize=64 
-XX:InitialHeapSize=258496576 
-XX:MaxHeapSize=4135945216 
-XX:+PrintCommandLineFlags 
-XX:ReservedCodeCacheSize=251658240 
-XX:+SegmentedCodeCache 
-XX:+UseCompressedClassPointers 
-XX:+UseCompressedOops 
-XX:+UseG1GC 
-XX:-UseLargePagesIndividualAllocation
java version "11.0.16.1" 2022-08-18 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.16.1+1-LTS-1)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.16.1+1-LTS-1, mixed mode)

C:\Users\wangqinglong01>

8. HotSpot的算法细节实现 Safepoint

固定可作为GCRoots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,若要逐个检查以这里为起源的引用肯定得消耗不少时间。在HotSpot的解决方案里,是使用一组称为Oop-Map的数据结构存放着对象引用的。

一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

8.1 安全点(Safepoint)

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行中但没有分配处理器时间调用的线程)都跑到最近的安全点,然后停顿下来。
这里有两种方案可供选择:

8.1.1 抢先式中断(Preemptive Suspension)

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

8.1.2 主动式中断(Voluntary Suspension)

而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

8.2 安全区域(Safe Region)

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

9. 经典的垃圾回收器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。

JDK11正式发布之前,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。
各款经典收集器之间的关系如图所示。
HotSpot虚拟机的垃圾回收器
HotSpot虚拟机的垃圾回收器
图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,

图中收集器所处的区域,则表示它是属于新生代收集器或是老年代收集器。

9.1 Serial收集器

Serial收集器是最基础、历史最悠久的收集器
曾经(在JDK1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。

这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

下图示意了Serial/Serial Old收集器的运行过程。
在这里插入图片描述串行收集器是最古老,最稳定以及效率高的收集器,只使用一个线程去回收但其在进行垃圾收集过程中可能会产生较长的停顿(Stop-The-World”状态)。虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,有线程交互的开销可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器。
对应JVM参数是: -XX:+UseSerialGC
开启后会使用:Serial(Young区用)+Serial Old(Old区用)的收集器组合
表示: 新生代、老年代都会使用串行回收收集器,新生代使用复制算法,老年代使用标记-整理算法
-Xms10m -Xmx10m -XX:+PrintGCDetails-XX:+UseSerialGC

9.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

ParNew收集器的工作过程如下图示。
在这里插入图片描述
ParNew收集器其实就是Serial收集器新生代的并行多线程版本,最常见的应用场景是配合老年代的CMS GC工作,其余的行为和Serial收集器完全一样,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。它是很多java虚拟机运行在Serve模式下新生代的默认垃圾收集器。
常用对应JVM参数: -XX:+UseParNewGC 启用ParNew收集器,只影响新生代的收集,不影响老年代 开启上述参数后,会使用:ParNew(Young区用)+Serial Old的收集器组合,新生代使用复制算法,老年代采用标记-整理算法

9.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器
也是能够并行收集的多线程收集器……

  • Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,那它有什么特别之处呢?
    Parallel Scavenge收集器的特点是它的关注点与其他收集器不同
    • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间
    • Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)

所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
在这里插入图片描述

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

  • 停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
    而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaXGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

    • -XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数
      收集器将尽力保证内存回收花费的时间不超过用户设定值。
      不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
    • -XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数
      也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
      譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
  • 由于与吞吐量关系密切,ParallelScavenge收集器也经常被称作“吞吐量优先收集器”

  • 除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+useAdaptiveSizepolicy值得我们关注。

  • 这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GCErgonomics)。

  • 如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),
    然后使用-XX:MaxGCPauseMills参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性

9.4 Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

  • 这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

  • 如果在服务端模式下,它也可能有两种用途:

    • 一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用
    • 另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Serial Old收集器的工作过程如图所示。
在这里插入图片描述

9.5 Parallel Old 收集器

Parallel Old 是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

  • 这个收集器是直到JDK6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态原因是:

    • 如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(pS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。
    • 由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。
    • 同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。
    • 直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

ParallelOld收集器的工作过程如图所示
在这里插入图片描述

9.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器

  • 目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。

  • CMS收集器就非常符合这类应用的需求。

  • CMS里面三色标记其实就是用三种颜色区分不同的内存区域。

    • 白色标记:没有遍历到的区域,可以理解为没有标记
    • 灰色标记:自己标记完成,但引用区域没来得及标记
    • 黑色标记:自己已经标记,直接引用的对象区域已经标记
  • CMS在每次进行标记的时候。从根root广度优先遍历。

  • 第一次遍历根root最终被标记为黑色,且引用对象标记为灰色。
    再次遍历时黑色区域引用区域也已经标记,则不会再次扫描标记,灰色区域需要进行扫描并最终标记为黑色区域。白色区域<未标记>的认为是垃圾,进行清理。

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

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

  • 由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作
    所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

通过图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。
在这里插入图片描述

  • CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。
  • CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度;
    至少有以下三个明显的缺点:
    • CMS回收线程和用户线程并行会对程序造成影响;
    • CMS收集器无法处理“浮动垃圾”(Floating Garbage)
      • 由于,有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生
      • 在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
        • 在JDK5的默认设置下,CMS收集器当老年代使用了92%的空间后就会被激活,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。
    • CMS是一款基于“标记-清除”算法实现的收集器,就可能想到这意味着收集结束时会有大量空间碎片产生。
      • 空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次FullGC的情况。
      • 为了解决这个问题,CMS收集器提供了一个 -XX:+useCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长
      • 因此虚拟机设计者们还提供了另外一个参数-XX:CMSFulIGCsBefore-Compaction(此参数从JDK9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

9.7 Garbage First 收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1是一款主要面向服务端应用的垃圾收集器

  • 在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

  • 首先, 堆不再分成连续的年轻代和老年代空间。而是划分为多个(通常是2048个)可以存放对象的 小堆区(smaller heap regions)。每个小堆区都可能是 Eden区, Survivor区或者Old区. 在逻辑上, 所有的Eden区和Survivor区合起来就是年轻代, 所有的Old区拼在一起那就是老年代

G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking)
    仅仅只是标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking)
    从GCRoot开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking)
    对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation)
    负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
在这里插入图片描述

G1底层理解:

区域化内存划片Region,整体编为了一些列不连续的内存区域,避免了全内存区的GC操作。
核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置这些子区域的大小,在堆的使用上,G1并不要求对象的存储一定是物理上连续的只要逻辑上连续即可,每个分区也不会固定地为某个代服务,可以按需在年轻代和老年代之间切换。
启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
大小范围在1MB~32MB,最多能设置2048个区域,也即能够支持的最大内存为:32MB*2048=65536MB=64G内存
在这里插入图片描述

  • G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器

  • 这些Region的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。

  • 这些Region的一部分包含老年代,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中 G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 CMS内存碎片问题的存在了。

比起cms有两个优势:

  • G1不会产生内存碎片。

  • 是可以精确控制停顿。该收集器是把整个堆(新生代、老生代)划分成多个固定大小的区域,

  • 每次根据允许停顿的时间去收集垃圾最多的区域。

9.8 从G1角度对比Cms

在这里插入图片描述
G1,基于堆内存,划分为 2048 个 region, 每个 region具体根据配置的堆内存大小算一下即可;如上图描述所示;

9.8.1 从工作流程对比:

在这里插入图片描述

  • G1的初始标记(同CMS的 初始标记阶段):
    • STW,暂停所有的工作线程
    • 然后标记出GC Roots 能直接可达的对象
    • 一旦标记完成,就恢复工作线程继续执行
    • 这个阶段时间比较短
  • G1的并发标记(同CMS的 并发标记阶段)
    • 从上一个阶段标记出来的对象,开始遍历整个老年代,标记出所有的可达对象
    • 耗时会比较长
    • 但是不需要STW,用户线程和GC线程一起执行
    • 三色标记
  • G1的最终标记(同CMS的重新标记)
    • 上个阶段标记的对象,可能有误差,需要进行修正;
    • 需要STW, 但是时间也不是很长
    • 原始快照(解决并发扫描时的对象消失问题) (CMS采用的是增量更新解决)
  • G1的筛选回收(类似CMS的并发清除阶段)
    • 需要STW,来清除垃圾对象
    • 可以通过 -XX: MaxGCPauseMillis来指定GC的STW停顿的时间,所以可能不会回收所有的垃圾对象,默认200ms
    • 采用的复制算法,不会产生碎片(会把某个region里的垃圾对象复制到另外空闲的region区域,比如相邻的)

9.8.2 G1里面的GC概念

  • YoungGC: Eden区满,会触发G1的YoungGC, 对Eden区进行GC;
  • MixedGC: 老年代的占用率达到了-XX:initiatingHeapOccupancyPercent指定的百分比,回收所有的新生代以及部分老年代,以及大对象区
  • FullGC:在进行MixedGC过程中,采用的复制算法,如果复制过程中内存不够,则会触发FullGC,会STW,并采用单线程来进行标记-整理算法进行GC,相当于一次Serial GC

9.9 垃圾回收最后小结

在这里插入图片描述
====从新生代出发=
第一种: -XX:+UseSerialGC 可互相激活
新生代 :Serial + 老年代: Serial Old 都是串行
第二种: -XX:+UseParNewGC 可互相激活
新生代 ParNew + 老年代: Serial Old 新生代是并行,老年代是串行 要被废了
第三种: -XX:+UseParallelGC或-XX:+UseParallerOldGC可互相激活
新生代 Parallel (Scavenge) + 老年代: Parallel Old

====从老年代出发=
第一种: -XX:+UseParallerOldGC 可互相激活
新生代 Parallel (Scavenge) + 老年代: Parallel Old
第二种: -XX:+UseConcMarkSweepGC 并发标记清除 停顿比较低
新生代 ParNew + 老年代: ConcMarkSweepGC(CMS)+ Serial Old 组合
Serial Old将作为CMS出错的后备收集器

10. 内存分配与回收策略

10.1 对象优先在Eden分配

  • 大多数情况下,对象在新生代Eden区中分配。
  • 当Eden区没有足够空间进行分配时,虚拟机将发起一次新生代Minor GC
  • HotSpot虚拟机提供了-XX:+PrinteGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
  • 在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析;
    -XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1

10.2 大对象直接进入老年代

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

  • 大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。

  • 在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。

  • HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

注意:

  • -XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。

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

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

10.4 动态对象年龄判定

  • 为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代
  • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

10.5 空间分配担保

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

    • 如果这个条件成立,那这一次Minor GC可以确保是安全的。
    • 如果不成立,则虚拟机会先查看-XX:HandlepromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
      • 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
      • 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
      • 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次FullGC。
  • 解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在MinorGC后仍然存活的情况–最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代;

  • 这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alan0517

感谢您的鼓励与支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值