JVM(java虚拟机)学习笔记

JVM

概述

学习JVM前,首先介绍下虚拟机概念,虚拟机是一台虚拟的计算机,是一款软件,执行一系列的虚拟计算机指令。一般分为系统虚拟机和程序虚拟机。

  • 比如VMWare就属于系统虚拟机,是对物理计算机的仿真,提供一个可运行完整操作系统的软件平台。
  • 程序虚拟机典型代表就是java虚拟机,专门为执行单个计算机程序而设计,jvm中执行的指令称为java字节码指令。

无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。

JVM是一台执行java字节码的虚拟计算机,拥有独立的运行机制,其运行的java字节码也不一定由java语言编译而成。它是java技术的核心,所有的java程序都运行在java虚拟机内部。负责将字节码装载到内部,解释\编译为对应平台上的机器指令执行。具有一次编译,到处运行;自动内存管理;自动垃圾回收功能。

JVM启动是通过引导类加载器(bootstrap class loader)创建一个初始类来完成。

执行一个java程序的时候,真正执行的是一个叫做java虚拟机的进程。

jvm退出的情况:

程序正常执行结束

程序执行过程中遇到了异常或错误而异常终止

操作系统出现错误导致jvm进程终止

某线程调用Runtime类或System类的exit方法或Runtime类的halt方法,并且java安全管理器也允许这次的exit或halt操作。

类加载子系统

JVM整体架构图如下

类加载子系统示意图

image-20240106212452398

负责从文件系统或者网络中加载class文件,在文件开头有特定的文件标识。

ClassLoader只负责class文件的加载,由Execution Engine决定是否可以运行。

加载的类信息存放在方法区的内存空间,除了类的信息外,还存放运行时的常量池信息,可能包括字符串字面量和数字常量,这些是class文件中常量池部分的内存映射。

类加载过程

第一个环节:加载

通过一个类的全类名获取定义此类的二进制字节流。

将这个字节流代表的静态存储结构转化为方法区的运行时结构。

在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

第二个环节:链接

该环节分为三个小过程

验证

确保Class文件字节流中的信息符合jvm的要求,包括文件格式验证,元数据验证,字节码验证,符号引用验证。

准备

为类变量,即static变量分配内存设置变量的默认初始值,零值。

不包含被final修饰的,被final修饰的static变量在编译时候就分配了,准备阶段显式初始化。

实例变量不会被分配初始化。

解析

将常量池的符号引用转为直接引用,事实上解析操作往往伴随jvm在执行完初始化后再执行。符号引用是一组符号描述引用的目标,直接引用是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型等。

第三个环节:初始化

执行类构造器方法<clinit>()的过程,是javac编译器自动收集类中所有的类变量的赋值动作和静态代码块中的语句合并而来。其中指令按照语句在源文件的出现顺序执行。它不同于类的构造器,构造器是jvm视角下的<init>()。

若该类具有父类,会保证子类的<clinit>()执行前父类的该方法已经执行完毕。

jvm必须保证一个类的<clinit>()在多线程下被同步加锁。

类加载器分类

image-20240107131806437

引导类加载器,扩展类加载器,系统类/应用类加载器,用户自定义加载器。

引导类加载器

使用c/c++实现,嵌套于jvm内部,加载java的核心类库提供jvm自身需要的类。加载扩展类和应用类加载器,并指定为他们的父加载器。

扩展类加载器

由java编写,派生于ClassLoader类,父加载器为引导类加载器。从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK安装目录的jre/lib/ext子目录下加载类库,如果用户创建的JAR放在此目录,也会由扩展类加载器进行加载。

应用类加载器/系统类加载器

由java编写,派生于ClassLoader类,父加载器为扩展类加载器。加载环境变量classpath或系统属性java.class.path指定路径下的类库,一般来说java应用的类都由它加载,通过ClassLoader#getSystemClassLoader()可以获取到该类加载器。

用户自定义类加载器

可以自定义类加载器制定类的加载方式,使用场景:隔离加载类,修改类加载的方式,扩展加载源,防止源码泄露。

实现步骤:

继承ClassLoader类重写findclass方法,将类加载逻辑写入其中。如果没有复杂的需求可以直接继承URLClassLoader类,可以避免自己编写findClass方法及其获取字节码流的方式,让自定义类加载器编写更加简洁。

几种获取ClassLoader的途径:

获取当前类的ClassLoader:

getClassLoader()

获取当前线程上下文的ClassLoader

Thread.currentThread().getContextClassLoader()

获取系统的ClassLoader

ClassLoader.getSystemClassLoader()

获取调用者的ClassLoader

DriverManager.getCallerClassLoader()

双亲委派机制

jvm对class文件采用按需加载,加载某个类的时候采用双亲委派机制,把请求交给父类处理是一种任务委派模式。

image-20240107135908196

一个类加载器收到类加载请求,把请求委托交给父类加载器,如果父类加载器还存在父类加载器进一步向上委托最终达到引导类加载器。如果父类可以完成类加载就成功返回否则交给子加载器尝试加载。

优点:

  • 防止类的重复加载
  • 保护程序安全,防止核心API被随意篡改

其他

在JVM中两个Class对象是否为同一个类的两个必要条件:

类的完整类名必须一致,包括包名。

加载这个类的ClassLoader(指的是其实例对象)必须相同。

jvm必须知道一个类型是由启动类加载器还是由用户类加载器加载的,如果一个类是由用户类加载器加载,jvm将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。

类的主动使用和被动使用
  • 创建类的实例
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射
  • 初始化一个类的子类
  • jvm启动时被标明为启动类的类
  • jdk7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化、则初始化。

除了上面的情况,其他使用java类的方式都被看作是对类的被动使用都不会导致类的初始化。

运行时数据区

image-20240107152757986

jvm定义若干种程序运行期间使用的运行时数据区,其中一些随着jvm启动而创建,jvm退出而销毁,另外一些与线程一一对应。

上面灰色为单独线程私有,红色为多个线程共享,即:

  • 每个线程独立包括程序计数器PC,栈,本地方法栈

  • 线程间共享堆,堆外内存/方法区(永久代或元空间,JIT的代码cache),jdk1.8后也叫元空间,使用本地内存。

JVM中每个线程都与操作系统的本地线程直接映射,一个java线程准备好执行以后,此时一个操作系统的本地线程也同时创建,java线程终止后本地线程也会回收。

jvm中主要的后台系统线程

  • 虚拟机线程:该线程的操作是需要jvm到达安全点才会出现,必须在不同的线程中发生的原因是他们都需要jvm达到安全点,这样堆才不会变化,这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。

  • 周期任务线程:是时间周期事件的体现,用于周期性操作的调度执行。

  • GC线程:对jvm里不同种类的垃圾收集行为提供了支持。

  • 编译线程:在运行时会将字节码编译到本地代码。

  • 信号调度线程:接收信号发送给jvm在内部通过调用适当的方法进行处理。

程序计数器(PC Register)

是对物理pc寄存器的一种抽象模拟,用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令,是一块很小的内存空间,也是运行速度最快的存储区域,每个线程都有自己的程序计数器是线程私有的,生命周期与线程一致。存储当前线程正在执行的java方法的jvm指令地址;或者,如果执行的是native方法,则是未指定值(undefined),因为该方法是由c/c++编写的,同时程序计数器没有GC和OOM。

使用pc寄存器存储字节码指令地址有什么用?

因为cpu不停地切换各个线程,这时候切换回来以后,就得知道从哪开始继续执行。jvm的执行引擎需要通过改变pc寄存器的值来明确下一条应该执行哪条字节码指令。

pc寄存器为什么设定为线程私有?

多线程在特定时间段内比如一个cpu时间片内只会执行其中某一个线程的方法,就会不停地做任务切换必然导致中断和恢复,为了能够准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程都分配一个计数器,线程间独立计算不会出现相互干扰的情况。

虚拟机栈

由于跨平台的设计,java指令根据栈来设计的,优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。 栈是运行时的单位,堆是存储的单位,栈解决程序的运行问题,程序如何执行如何处理数据,堆解决的是数据存储的问题。

每个线程创建时都会创建一个虚拟机栈,内部保存一个个的栈帧,对应一次次的java方法调用,是线程私有的,生命周期和线程一致。保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。栈不存在GC,但是存在OOM问题。

栈中可能出现的异常

如果采用固定大小的java虚拟机栈,如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,会抛出StackOverflowError。

如果java虚拟机栈可以动态扩展,在尝试扩展的时候无法申请到足够的内存,或创建新的线程时没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError。可以使用参数-Xss来设置线程的最大栈空间,比如-Xss256k,-Xss1024MB。idea可以在运行设置处的VMoption设置该参数。

栈的存储单位(栈帧/Stack Frame)

栈中的数据都是以栈帧格式存在,线程中正在执行的每个方法对应一个栈帧,栈帧是一个内存区块,是一个数据集,维护着方法执行中的各种数据信息。执行引擎运行的所有字节码指令只针对当前栈帧进行操作,

不同线程所含的栈帧不允许存在相互引用,方法返回之际,当前栈会传回此方法执行结果给前一个栈帧,然后虚拟机会丢弃当前栈帧,使前一个栈帧成为当前栈帧。不管是函数正常返回还是抛出异常都会导致栈帧弹出

栈帧的内部结构

  • 布局变量表(LV,Local Variables)

  • 操作数栈\表达式栈(Operand Stack)

  • 动态链接(Dynamic Linking),指向运行时常量池的方法引用

  • 方法返回地址(Return Address),方法正常退出或者异常退出的定义

  • 一些附加信息

局部变量表

也称为局部变量数组或本地变量表,定义为一个数字数组,存储方法参数和定义在方法体内部的局部变量,包括基本数据类型、对象引用、returnAddress类型,局部变量是线程私有数据,不存在数据安全问题,局部变量表的容量在编译期确定,方法运行期间大小不会变。

局部变量表的存储单元是Slot(槽),32位以内的类型占用一个Slot,64位的占用两个Slot。一个实例方法被调用,方法参数和内部定义的局部变量按照顺序复制到局部变量表的每一个Slot上,如果访问表中一个64bit的局部变量时只需要使用前一个索引即可,如果当前帧是由构造方法或者实例方法创建的,该对象的引用this将会放在index0的slot,其余参数按照参数表顺序继续排列。

image-20240108223640556

slot是可以重复利用的,一个局部变量过了作用域,在之后声明的新的局部变量有可能复用过期的局部变量的槽位节省空间。

栈帧中与性能调优关系最为密切的部分就是局部变量表,其中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

操作数栈

栈帧还包含一个后进先出的操作数栈或者表达式栈,在方法执行过程中,根据字节码指令往栈中写入数据或提取数据,即入栈\出栈。操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间,每一个操作数栈都会拥有一个明确的栈深度用于存储数值,所需要的最大深度在编译期间就确定好了。同样的,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度,操作数栈以数组实现,只能通过入栈和出栈的操作完成一次数据访问,不能采用访问索引的方式来进行数据访问。如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。由于操作数是存储在内存中的,频繁的执行内存读写操作必然会影响执行速度,为了解决这个问题,HotSpot JVM设计者提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

动态链接\指向运行时常量池的方法引用

每个栈帧中包含一个指向运行时常量池中该栈帧所属方法的引用,为了支持当前方法的代码能够实现动态链接。所有的变量和方法引用都作为符号引用保存在class文件的常量池里,一个方法调用了另外的其他方法,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是将这些符号引用转换为调用方法的直接引用。常量池的作用就是为了提供一些符号和变量便于指令的识别。

方法的调用

JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接:

    一个字节码文件被装载在JVM中,被调用的方法在编译期可知,且运行期保持不变。这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接。

  • 动态链接:

    被调用方法在编译期无法被确定,只能在程序运行期间将调用方法的符号引用转换为直接引用,这种引用转换过程具备动态性,因此称为动态链接。

对应的方法绑定机制为:早期绑定和晚期绑定,绑定是一个字段,方法或者类在符号引用替换为直接引用的过程,仅发生一次。

  • 早期绑定:

    被调用方法在编译期可知,运行期保持不变,即可将这个方法与所属类型进行绑定,明确了被调用的方法,因此就可以使用静态链接的方法将符号引用转换为直接引用。

  • 晚期绑定:

    被调用方法在编译期无法被确定下来,只能在程序运行期间根据实际的类型绑定相关的方法。

java中任何一个普通的方法都具备虚函数的特征,相当于C++语言中的虚函数,java中不希望某个方法拥有虚函数的特征时,可以使用关键字final来标记这方法。

  • 非虚方法:

    方法在编译期确定了具体的调用版本,运行时不可变,这样的方法称为非虚方法。

    静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其他方法都称为虚方法。

jvm中提供以下几条方法调用指令:

  • 普通调用指令:

    • invokestatic 调用静态方法,解析阶段确定唯一方法版本

    • invokespecial 调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本

    • invokevirtual 调用所有虚方法

    • invokeinterface 调用接口方法

  • 动态调用指令:

    • invokedynamic 动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,不可人为干预,invokedynamic指令由用户确定方法版本,invokestatc指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。jdk7才增加了invokedynamic,这是为java实现动态类型语言支持而做的改进。但是jdk8出现Lamda表达式后,该指令才有直接生成方式。

java中方法重写的本质:

  • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C
  • 如果类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,通过则返回这个方法的直接引用,查找过程结束,不通过则返回java.lang.IllegalAccessError,说明程序试图访问或修改一个属性或调用一个方法,但没有权限访问。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

面向对象的编程中,频繁使用到动态分派,每次动态分派过程中都要重新在类的方法元数据中搜素合适的目标就可能影响执行效率。因此为了提高性能,JVM采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现,使用索引表来代替查找。每一个类都有一个虚方法表,表中存放着各个虚方法的实际入口。虚方法表在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完毕之后,JVM把该类的虚方法表也初始化完毕。

方法返回地址

存放调用该方法的PC寄存器的值,一个方法的结束,有两种方式,正常执行完毕和出现未处理的异常,非正常退出。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。通过异常退出的,返回地址是通过异常表来确定的,栈帧中一般不会保存这部分信息。执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层方法的调用者,简称正常完成出口;返回指令包含ireturn(返回值是boolean,byte,char,short,int)、lreturn、freturn、dreturn以及areturn(引用类型),另外还有一个return供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

一些附加信息

栈帧还允许携带jvm实现相关的一些附加信息,比如对程序调试提供支持的信息

本地方法栈

本地方法

一个Native Method就是一个java调用非java代码的接口,一个Native Method就是这样一个java方法。该方法的实现为非java语言,比如使用c或c++,定义一个Native Method时,并不提供实现体,其实现体是由非java语言在外部实现的。、

使用Native Method的缘由:

  • 有时java与java外面的环境交互,是本地方法存在的主要原因。比如和一些底层系统,操作系统或者某些硬件交换信息时的情况,本地方法正是这样的一种交流机制。
  • jvm始终不是一个完整的系统,经常依赖一些底层系统的支持,这些底层系统常常是强大的操作系统,通过使用本地方法得以用java实现jre与底层系统的交互,甚至jvm的一部分就是c语言编写的。以及要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

目前本地方法使用越来越少了,一般是与硬件有关的应用。

本地方法栈用于管理本地方法的调用,是线程私有的,允许被实现为固定大小或者可动态扩展的内存大小。Hot Spot JVM的本地方法是由c语言实现的,具体做法是Native Method Stack中登记Native方法,在Execution Engine执行时加载本地方法库。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限。本地方法可以通过本地方法接口访问虚拟机内部的运行时数据区,可以使用本地处理器中的寄存器,可以直接从本地内存的堆中分配任意数量的内存。在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

一个JVM实例只存在一个堆内存,堆也是java内存管理核心区域。它在JVM启动的时候被创建,空间大小也被确定了,是JVM管理的最大一块内存空间(当然后面元空间使用本地内存后也不一定,不用过于纠结),但是堆内存可以通过参数进行设置。java虚拟机规范指出堆可以处于物理不连续的内存空间中但是逻辑上应该被视为连续的。所有线程共享堆内存,但其中还可以划分有线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。“几乎”所有的对象实例和数组都应该运行时分配到堆上,数组和对象可能永远不会存储在栈上,因为栈帧中保存的引用指向对象或数组在堆中的位置。但是以上不是绝对的,从实际的使用角度看也有特殊情况,在后面我们会提到。方法结束后堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,堆也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

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

  • jdk7及以前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区

    • Young Generation Space 新生区 Young\New
      • 又被细划分为Eden区和Survivor区
  • Tenure Generation Space 养老区 Old\Tenure

  • Permanent Space 永久区 Perm

  • jdk8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间

    • Young Generation Space 新生区 Young\New
      • 又被细划分为Eden区和Survivor区
  • Tenure Generation Space 养老区 Old\Tenure

  • Meta Space 元空间 Meta

但是实际上不论是永久区还是元空间都没有被算入堆空间内存中,而是单独的一块内存空间。

堆空间大小的设置

可以通过“-Xms”和“Xmx”来设置堆内存。

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

当堆区内存大小超过最大内存后会抛出OOM异常,通常会将-Xms和-Xmx两个参数配置相同的值,目的是能够在java的垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。默认情况下初始内存大小为物理内存的 1 / 64 1/64 1/64,最大内存大小为物理内存的 1 / 4 1/4 1/4,其中内存大小的计算对于新生代中的s0和s1区只计算其中一个的内存。

年轻代与老年代

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

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

堆区进一步细分,还可以划分为年轻代和老年代,其中年轻代又可以划分为Eden区,Survivor0区和Survivor1区(有时也称为from区,to区,因为这两个是交替存放对象的,谁空谁是to区)

配置新生代和老年代在堆结构的占比:

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

并且Eden区和s0,s1区的比例为8:1:1,但是由于内存分配的自适应机制,实际运行中可能会发生变化,后续细节会在下面提出。

也可以通过选项-XX:SurvivorRatio来调整空间比例

几乎所有的java对象都是在Eden区被new出来的,绝大部分对象的销毁都在新生代进行了

对象内存分配过程

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

  • new的对象现放在Eden区,此区域有大小限制
  • 当Eden的空间填满后,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(YGC\Minor GC),将Eden区的不在被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区
  • 然后将Eden区的剩余对象移动到s0区
  • 如果再次触发GC,此时上次幸存下来放到s0区的,如果没有回收就会放到s1区
  • 如果再次经历垃圾回收,此时会重新放回s0区,接着再去s1区
  • 当次数到达阈值默认是15次后,将会被放到养老区,可以通过-XX:MaxTenuringThreshold=<N>进行设置
  • 当养老区内存不足,再次触发GC:Major GC,进行养老区的内存清理
  • 若养老区执行Major GC后发现仍然无法进行对象的保存,就会产生OOM异常

针对于s0和s1区的总结是复制之后有交换,谁空谁是to区,垃圾回收频繁的发生在新生区,很少在养老区,几乎不在永久区\元空间。

image-20240116220122185

常用的调优工具
  • JDK命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • java Flight Recorder
  • GCViewer
  • GC Easy
Minor GC、Major GC与Full GC

JVM进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。

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

  • 部分收集:不是完整收集整个java堆的垃圾收集。其中又分为:

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

  • 年轻代GC的触发机制:

    • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满了指的是Eden区满,Survivor区满不会引发GC,每次Minor GC会清理年轻代的内存。
    • 因为java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
    • Minor GC会引发STW,即stop the world,GC是一个单独的线程,当GC触发时,会使用户线程暂停,等GC结束后用户线程才恢复运行,这就是STW。

image-20240117134852305

  • 老年代GC(Major GC/Full GC)触发机制:

    • 发生在老年代的GC,对象从老年代消失时,可以说Major GC或Full GC发生了

    • 出现了Major GC,经常会伴随至少一次的Minor GC(也不是绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)

      • 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足则会触发Major GC
    • Major GC的速度一般会比Minor GC慢10倍以上,STW时间更长,如果Major GC后内存还是不足则会报OOM。

  • Full GC触发机制:

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

    • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
    • 老年代空间不足
    • 方法区空间不足
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    • 由Eden区,From区向To区复制时,对象大小大于To区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

​ Full GC是开发或者调优中尽量要避免的,避免过长的STW时间。

​ 参数设置里填入-XX:+PrintGCDetails会在控制台输出程序运行的GC日志便于进行分析

堆空间分代思想

为什么需要把java堆分代?不分代就不能正常工作了吗?

其实不分代完全可以,分代的唯一理由就是优化GC性能,如果没有分代,那所有的对象都在一块儿。GC的时候就要找到哪些对象没用,会对堆的所有区域进行扫描。而很多对象都是朝生夕灭的,如果分代的话,把新创建的对象放到一起,GC的时先把这块存储朝生夕灭对象的区域进行回收,这样就会腾出很大空间出来也减少了一定的GC时间。

内存分配策略(或对象提升(Promotion)规则)

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor区容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认15岁,其实每个JVM,每个GC都有所不同)时,就会晋升到老年代。对象晋升老年代的年龄阈值可以通过选项-XX:MaxTenuringThreshold来设置。

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

  • 优先分配到Eden

  • 大对象直接分配到老年代

    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代

  • 动态对象年龄判断

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

    这点会在后面详细指出

    • -XX:HandlePromotionFailure
对象分配过程:TLAB

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

  • 什么是TLAB?

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

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实将TLAB作为内存分配的首选,即JVM默认开启TLAB。我们也可以通过选项-XX:UseTLAB设置是否开启TLAB空间,默认情况下,TLAB空间内存非常小仅仅占整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占Eden区的百分比大小。一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性从而直接在Eden空间分配内存。

小结堆空间的参数设置

官网说明:

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

  • -XX:+PrintFlagsInitial;查看所有的参数默认初始值
  • -XX:+PrintFlagsFinal;查看所有的参数的最终值(可能会存在修改,不再是初始值)
    • 查看具体某个参数的指令:jps查看运行的进程,jinfo -flag 具体某个参数比如SurvivorRatio 进程id
  • -Xms;初始堆空间内存(默认为物理内存的1/64)
  • -Xmx;最大堆空间内存(默认为物理内存的1/4)
  • -Xmn;设置新生代的大小
  • -XX:NewRatio;配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio;设置新生代Eden和s0,s1空间的比例
  • -XX:MaxTenuringThreshold;设置新生代的最大年龄
  • -XX:PrintGCDetails;输出详细的GC处理日志
  • -XX:HandlePromotionFailure;设置是否空间分配担保

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

  • 如果大于则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的
      • 如果小于,则改为进行一次Full GC
    • 如果HandlePromotionFailure=false,则改为进行一次Full GC

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

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

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”。

jvm中,对象都是在java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就有可能被优化成栈上分配。就无需在堆上分配内存,无需进行GC,这也是最常见的堆外存储技术。

如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种有效减少java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸,例如作为调用参数传递到其他地方中。

没有发生逃逸的对象,则可以分配到栈上,随着方法的执行结束,栈空间就被移除。

public static StringBuffer creatStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

上述代码中sb就逃出方法,想要使它不逃出方法,可以这样写

public static String creatStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

在JDK 6U23版本之后,Hotspot中默认开启了逃逸分析,较早的版本中可以通过

  • 选项-XX:+DoEscapeAnalysis显式开启逃逸分析
  • 通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果

在开发中能使用局部变量的,就不要在方法外定义。

逃逸分析:代码优化

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

一、栈上分配。将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

二、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中,对于jvm来说就可以存储在栈中。

  • 栈上分配:

    • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收。局部变量对象也被回收,这样就无需进行GC。
  • 同步省略:

    线程同步的代价是非常高的,会降低并发性和性能。

    • 在动态编译同步块的时候,JIT编译器借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,从而大大提高并发性和性能。这个取消同步的过程就是同步省略也叫锁消除。
  • 分离对象或标量替换:

    标量(Scalar)是指一个无法再分解更小的数据的数据,比如java中的八种基本数据类型。相对的可以在分解的数据就叫做聚合量,java中的对象就是聚合量。在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其他包含的若干个成员变量替代,这就是标量替换。进行标量替换后可以大大减少堆内存的占用,同时也为栈上分配提供了很好的基础。

逃逸分析小结:

  • 关于逃逸分析很早就已经提出了,jdk1.6才有实现,目前这项技术不是很成熟,根本原因在于无法保证逃逸分析的性能消耗一定高于他的消耗。虽然经过逃逸分析可以做标量替换,栈上分配,同步省略。但是逃逸分析自身需要进行一系列复杂的分析,这其实也是一个相对耗时的过程。一个极端的例子就是进过逃逸分析后,发现没有一个对象是不逃逸的,那这个分析过程就浪费掉了。
  • 虽然这项技术并不成熟,但是它也是即时编译器优化技术中一个十分重要的手段。Hotspot jvm中并未进行栈上分配,所以可以明确的说对象实例都创建在堆上。

所以堆是分配对象存储的唯一选择吗?这是一个否定之否定的过程。

方法区

栈、堆、方法区的交互关系

image-20240118173341368

image-20240118173419137

方法区的理解

《java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择区进行垃圾收集或者进行空间压缩,但对于Hotspot JVM来说,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区看做是一块独立于java堆的内存空间。

方法区域堆一样,是各个线程共享的内存区域,在jvm启动时候被创建,它的实际物理内存空间和堆区一样都可以是不连续的,方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。方法区大小决定系统可以保存多少个类,如果定义了太多类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space,这是jdk7及其以前或者

java.lang.OutOfMemoryError:Metaspace,这是jdk8及其以后。比如加载了大量的第三方jar包,Tomcat部署工程过多,大量动态生成反射类等。

Hotspot中方法区的演进

在jdk7及以前,习惯上把方法区称为永久代。jdk8开始,用元空间取代了永久代。

image-20240118175150955

jdk8完全废弃了永久代的概念,改用和JRockit一样在本地内存中实现的元空间来代替,元空间本质和永久代类似,不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,使用本地内存,同时内部结构也调整了。

设置方法区大小与OOM

方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。

jdk7及以前:

  • 通过-XX:PermSize来设置永久代初始分配空间,默认是20.75m
  • -XX:MaxPermSize来设置永久代最大可分配空间,32位机器默认是64m,64位机器默认是82m
  • 当jvm加载的类信息容量超过这个值,会报异常OutOfMemoryError:PermGen space

jdk8及以后:

  • 元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,代替上述原有的两个参数。
  • 默认值依赖于平台,Windows下,-XX:MetaspaceSize是21m,-XX:MaxMetaspaceSize是-1,即没有限制,直到耗尽物理机内存
  • 和永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。对于初始的元空间大小,这就是初始的高水平线,一旦触及这个水位线,Full GC将会触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于GC释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值,如果释放的空间过多则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,Fulll GC会多次调用,为了避免频繁GC,建议将MaxMetaspaceSize设置为一个相对较高的值。

如何解决这些OOM:

  • 要解决OOM或者heap space的异常,一般手段是首先通过内存映像分析工具对dump出来的堆转储快照进行分析,确认内存中的对象是否是必要的,也就是分清楚到底是出现了内存泄漏还是内存溢出
  • 如果是内存泄漏,进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是 通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收他们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置
  • 如果不存在内存泄漏,就是说内存中的对象确实都需要存活,那就应当检查虚拟机堆参数,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
方法区的内部结构

image-20240118210128842

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

  • 类型信息

    对每个加载的类型(类class,接口interface,枚举enum,注解annotation),jvm必须在方法区中存储以下类型信息:

    • 这个类型的完整有效名称(全名 = 包名.类名)
    • 这个类型直接父类的完整有效名(对于interface或者Object类,都没有父类)
    • 这个类型的修饰符(public,abstract,final的某个子集)
    • 这个类型直接接口的一个有序列表
  • 域(Field)信息

    • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
    • 域的相关信息包括:域名称,域类型,域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
  • 方法信息

    • 方法名称
    • 方法的返回类型(或void)
    • 方法参数的数量和类型(按顺序)
    • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
    • 方法的字节码,操作数栈,局部变量表及大小(abstract和native方法除外)
    • 异常表(abstract和native方法除外)
      • 每个异常处理的开始和结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
  • non-final的类变量

    • 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
  • final的类变量

    • 被声明为final的类变量处理方法则不同,每个全局常量在编译的时候就会被分配了。
  • 常量池

    • 方法区,内部包含了运行时常量池,字节码文件内部包含了常量池,弄清楚方法区需要理解字节码文件,因为加载类的信息都在方法区,弄清楚方法区的运行时常量池需要理解字节码中的常量池

      image-20240118220723205

    • 一个有效的字节码文件除了包含类的版本信息,字段,方法以及接口等描述信息外,还包含一项信息那就是常量池表,包括各种字面量和对类型,域和方法的符号引用。

    • 为什么需要常量池呢?一个java源文件中的类,接口,编译后产生一个字节码文件,而字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里。换另一种方式,存在常量池中,这个字节码包含了指向常量池的引用,在动态链接的时候会用到运行时常量池。

    • 常量池中有什么?包括数量值,字符串值,类引用,字段引用,方法引用。

    • 常量池可以看做一张表,jvm指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等类型。

  • 运行时常量池

    • 运行时常量池是方法区的一部分,常量池表是字节码文件的一部分,用于存储编译期生成的各种字面量与符号引用,这部分内容在类加载后存放在方法区的运行时常量池中。
    • 在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池,池中的数据项就像数组一样,通过索引访问。
    • 运行时常量池中包含多种不同的常量,包括编译期就已经确定的数值字面量,也包括到运行期解析后才能获得的方法或字段引用。此时不再是常量池中的符号引用,这里转换为真实地址,相较于常量池的另一重要特点是具备动态性。
    • 运行时常量池类似传统编程语言中的符号表,但是它包含的数据比符号表要更加丰富一些。当创建类或接口的运行时常量池时,如果所需内存超过了方法区的最大值会抛出OOM异常。
方法区的演进细节

首先来说只有Hotspot才有永久代

image-20240118222119345

jdk6

为什么永久代被元空间替代?

  • 随着jdk8到来,Hotspot VM中没有永久代,其中的元数据信息被转移到了一个与堆不相连的本地内存区域,这个区域叫做元空间。第一为永久代设置空间大小很难确定,某些场景下动态加载类过多容易产生永久代的OOM。而元空间使用本地内存,默认情况下,元空间大小仅受本地内存限制。第二是对永久代进行调优是很困难的。

  • StringTable为什么要调整?

    jdk7中将StringTable放到了堆空间中,因为永久代的回收效率很低,在Full GC的时候才会触发。而Full GC是老年代的空间不足、永久代不足时才会触发,导致StrngTable回收效率不高。开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能即使回收内存。

方法区的垃圾收集

上面提到过,方法区的垃圾收集效果难以令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时候有确实是必要的。

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

    • 该类的所有实例都被回收,堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则通常是很难达成的
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • jvm被允许对满足上述三个条件的无用类进行回收,但是不是必然回收,关于是否要对类型进行回收,还提供了相关参数进行控制。在大量使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常需要jvm具备类型卸载的能力,保证方法区不会有过大的内存压力

对象的实例化内存布局与访问定位

对象的实例化

创建对象的步骤

  • 判断对象对应的类是否加载、链接、初始化

    • jvm遇到一条new指令,首先检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经加载、解析和初始化。(即判断类元信息是否存在)如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的class文件,如果没有找到文件,则抛出ClassNotFoundException,如果找到则进行类加载并生成相应的Class类对象
  • 为对象分配内存

    首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4字节大小。选择哪种分配方式由java堆是否规整决定,堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

    • 如果内存空间规整则采用指针碰撞
      • 所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离。如果垃圾回收器选择的是Serial、ParNew这种基于标记压缩算法的,jvm采用这种分配方式,一般使用带有compact(整理)过程的收集器,使用指针碰撞。
    • 如果内存空间不规整,则虚拟机需要维护一个列表,使用空间列表分配
      • 内存不是规整的,已使用的内存和未使用的内存相互交错,虚拟机要维护一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,称为空闲列表法。一般对应像CMS这种使用标记清除算法的垃圾收集器。
  • 处理并发安全问题

    • 采用CAS(Compare And Swap)失败重试、区域加锁保证更新的原子性;每个线程预先分配一块TLAB。
  • 初始化分配到的空间(即默认初始化)

    所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。

  • 设置对象头

    将对象所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中,这个过程的具体设置方式取决于JVM实现。

  • 执行init方法进行初始化

    在java程序视角来看,初始化才正式开始,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随由invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序编写的顺序执行初始化,这样一个真正可用的对象才完全创建出来。

即加载类元信息 → 为对象分配内存 → 处理并发问题 → 属性的默认初始化 → 设置对象头信息 → 属性的显式初始化、代码块中初始化、构造器中初始化

对象的内存布局

一个举例

对象访问定位

JVM如何通过栈帧中的对象引用访问到其内部的对象实例呢?

image-20240120142505921

句柄访问

效率相较于直接访问较低,需要额外开辟内存存放句柄,但是reference中存放稳定的句柄地址,对象被移动时只会改变句柄中实例数据指针即可,reference本身不需要改动。

直接访问(Hotspot采用)

直接内存(并不是运行时数据区的一部分)

直接内存是直接向系统申请的内存空间,来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存,通常访问直接内存的速度优于java堆,读写性能高。因此在读写频繁的场合可以考虑直接使用直接内存,java的NIO库允许java程序使用直接内存用于数据缓冲区。

image-20240120143947343

读写文件,需要与磁盘交换,要从用户态切换到内核态。在内核态是,需要内存如上图的操作,使用IO需要两份内存存储重复数据,效率低。·

image-20240120144115120

使用NIO时,如上图,操作系统划出的直接缓存区可以被java代码直接访问,只有一份,NIO适合对大文件的读写操作。

直接内存也可能OOM,由于直接内存在java堆外,它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存有限,java堆和直接内存的总和依然受限于操作系统给出的最大内存。并且直接内存的分配回收成本较高,不受JVM内存回收管理。它的大小可以通过MaxDirectMemorySize进行设置,如果不指定默认与堆的最大值-Xmx参数值一致。

简单来说java process memory = java heap + native memory

StringTable

String的基本特性

String声明为final的不可被继承,实现了Serializable和Comparable接口,支持序列化,可比较大小,jdk8及其以前在内部定义了final char[] value用于存储字符串数据,jdk9改为了byte[]加上编码标记用于节约空间。同时基于String的StringBuilder和StringBuffer和Hotspot VM内部的String结构也做了相应的改动。

String代表不可变的字符序列:

  • 当对字符串重新赋值需要重写指定的内存区域赋值,不能使用原有的value进行赋值
  • 当对现有的字符串进行拼接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
  • 当调用replace()方法修改指定字符或字符串也需要重新指定内存区域赋值,不能使用原有的value进行赋值

通过字面量的方式(区别于new)给一个字符串赋值,此时字符串值声明在字符串常量池中。

字符串常量池中是不会存储相同内容的字符串的

  • String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接影响就是调用String.intern()时性能大大下降
  • 使用-XX:StringTableSize可以设置其长度,jdk6及其之前是固定的1009大小,当常量池中字符串过多就会导致效率下降很快,StrngTableSize的设置没有要求。在jdk7中,长度默认值为60013,1009是可设置的最小值

String的内存分配

在java中有八种基本数据类型和一种比较特殊的类型String,这些类型为了使它们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就是类似一个java系统级别提供的缓存,八种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊,主要使用方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法,后面会详细提到
  • jdk6及其以前,字符串常量池存放在永久代,jdk7及其以后将字符串常量池的位置调整到java堆内。所有的字符串都保存在堆中和其他普通对象一样,在进行调优时仅需要调整堆大小。这个改动使得我们有足够的理由重新考虑在jdk中使用String.intern()
  • 为什么要调整?第一个是永久代默认空间比较小,而字符串使用频繁,容易导致OOM。第二个是永久代垃圾回收频率低,一般只有Full GC才会回收永久代。

java语言规范要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。

字符串的拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化

    String s1 = "a" + "b" + "c";//编译期优化,等同于"abc
    String s2 = "abc";//abc一定是放在字符串常量池中
    s1 == s2的结果是true
    
  • 常量池中不会存在相同内容的常量

  • 只要其中有一个是变量,结果就在堆中,变量拼接的原理是StringBuilder

  • 如果拼接的结果调用intern()方法,则主动将常量池中没有的字符串对象放入池中,并返回此对象地址

    String s1 = "aa";
    String s2 = "bb";
    String s3 = "aabb";
    String s4 = "aa" + "bb";
    String s5 = s1 + "bb";
    String s6 = "aa" + s2;
    String s7 = s1 + s2;
    String s8 = s6.intern();
    则s3 == s4,s3 != s5,s3 != s6,s3 != s7,s5 != s6,s5 != s7,s6 != s7,s3 == s8。
    

    对于s5来说,只要拼接时候有变量,执行细节如下:

    • 首先new StringBuilder(),jdk5之前使用StringBuffer
    • 然后调用其append()方法分别拼接aa和bb
    • 然后再调用toString()方法,约等于new String(“aabb”)返回一个String对象

​ 在进行大量的拼接操作时避免使用String拼接,可以用StringBuilder进行append()。实际开发中如果基本确定前后添加的字符串长度不高于某个值,可以使用指定大小的StringBuilder构造器可以进一步提高效率。

intern方法细节

如果不是字面量声明的String对象,可以使用String所提供的intern方法,会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。调用intern方法,其返回结果所指向的类实例必须和直接以常量形式出现的字符串实例完全相同,确保字符串在内存中只有一份拷贝节约内存,加快字符串操作的执行速度。

一个问题,如何保证变量s指向的是字符串常量池中的数据?

  • 直接以字面量形式定义
  • 调用intern()方法

new String(“ab”)会创建几个对象?

  • 在字符串常量池中没有ab的情况下,答案是两个
  • 一个是new关键字在堆空间创建的对象
  • 另一个是字符串常量池中的对象,可以通过字节码指令ldc证明

String str = new String(“a”) + new String(“b”);创建几个对象?

  • 一个是拼接需要的StringBuilder
  • 两个new对象
  • 常量池中的a和b两个对象
  • 最后StringBuilder调用toString()方法,new String(“ab”),但是toString的调用不会在字符串常量池中出现ab

再来一个问题

String s1 = new String("1") + new String("1");
s1.intern();
String s2 = "11";
System.out.print(s1 == s2);

上面代码在jdk6和jdk7/8的结果分别为false和true,由上面我们可知,第一行代码结束后字符串常量池中不会有11,jdk6中执行第二行代码后就是在常量池中创建了11自然两个对象地址不一样。但是在jdk7/8中,常量池放入堆中,考虑节省空间,此前已经有了一个对象内容是11,则此时常量池中并没有创建11而是创建一个指向堆空间中11的那个对象的地址。

总结intern()的使用:

  • jdk6中,将这个字符串对象尝试放入池,如果池里有,则返回池中该对象的地址,如果没有会把此对象复制一份放入池中并返回该地址
  • jdk7起,如果池中有,返回池中对应的地址,如果没有会把对象的引用地址复制一份放入池中,返回池中的引用地址。
  • 当程序中有存在重复字符串时,使用intern方法可以节省内存空间

StringTable的垃圾回收

-XX:+PrintStringTableStatistics可以打印GC信息

G1的String去重操作

测试表明,java堆中存活的数据集合差不多25%是String对象,更近一步这里面一半的String对象是重复的,指的是string1.equals(string2) == true。在G1中则会实现自动持续对这种重复的String对象去重,节约内存。

当垃圾收集器工作时,访问堆上存活的对象,都会检查是否是候选的要去重的String对象。如果是则把对象的一个引用插入到队列中等待后续处理。一个去重的线程在后台运行,处理这个队列,从队列删除这个元素然后尝试去重它引用的String对象。使用一个hashtable记录所有的被String对象使用的不重复的char数组,去重时,检查这个hashtable,看堆上是否已经存在一个一模一样的char数组。如果存在,String对象会被调整引用那个数组,释放对原来数组的引用,最终被垃圾收集器回收,如果查找失败,char数组会被插入到hashtable中,便于后面共用该数组。

UseStringDeduplication可以开启去重,默认不开启。

执行引擎

概述

执行引擎是jvm核心的组成部分之一,物理机的执行引擎是直接建立在处理器,缓存,指令集和操作系统层面上,而虚拟机的执行引擎是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行不被硬件直接支持的指令集格式。JVM主要任务是装载字节码到其内部,但字节码并不能直接运行在操作系统之上,因为字节码指并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM识别的字节码指令,符号表以及其他辅助信息。执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令,充当了将高级语言翻译为机器语言的译者。

执行引擎的工作过程

第12章_执行引擎的工作过程

执行引擎在执行过程中究竟执行什么样的字节码指令完全依赖于PC寄存器,每当执行完一项指令后,PC寄存器就会更新下一条需要被执行的指令地址。当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。jvm的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

java代码编译和执行的过程

第12章_程序执行过程

大部分程序代码转换为物理机的目标代码或虚拟机能执行的指令集之前都需要经过上图中的各个步骤。

对于java程序来说,通过javac生成字节码指令的过程就对应于上图中的橙色部分。绿色部分就是解释执行,而蓝色部分是编译运行。

image-20240120151728120

字节码由JVM执行引擎执行,流程如下

image-20240120151830418

解释器:当jvm启动时根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

java是半编译半解释型语言,因为jvm执行java代码时通常会将解释执行与编译执行二者结合起来进行。

第12章_理解执行引擎

解释器与编译器

  • 解释器

    jvm设计者初衷为了满足java程序实现跨平台特性,避免采用静态编译的方式直接生成本地机器指令,诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。所担任的角色就是一个运行时的翻译,将字节码中的内容"翻译"为对应平台的本地机器指令执行,在java历史中,有两套解释执行器,即古老的字节码解释器,和现在普遍使用的模板解释器。

    字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高解释器的性能。

    • 在Hotspot vm中,解释器主要由Interpreter模块和Code模块构成。
      • Interpreter模块:实现解释器的核心功能
      • Code模块:用于管理Hotspot vm运行时生成的本地机器指令。

现在JVM支持一种叫做即时编译的技术,目的是避免函数被解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

  • JIT编译器

    Hotspot vm采用解释器与即时编译器并存的架构,jvm运行时解释器和JIT编译器能够相互协作,取长补短,尽力选择最合适的方式权衡编译本地代码的时间和直接解释执行代码的时间。当程序启动后,解释器可以马上发挥作用,省去编译的时间立即执行。编译器想要发挥作用,把代码编译成本地代码需要一定的执行时间,但编译为本地代码后执行效率高。对于服务端应用来说,启动时间并非是关注重点,但对于那些看重启动时间的应用场景来说,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,jvm启动时解释器可以先发挥作用不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用把越来越多的代码编译成本地代码,获得更高的执行效率。同时解释执行在编译器进行激进优化不成立的时候,可以作为编译器的逃生门。

一个案例:

  • 机器在热机状态下可以承受负载要大于冷机状态,如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

    在生产环境的发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器至多占到整个集群的1/8。曾经有这样的故障案例,某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器勉强可以承载流量,但由于刚启动的JVM是解释执行,还没有进行热点代码统计和jit动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。 -阿里团队

对于java语言的编译期是一段不确定的操作过程,可能是指一个前端编译器或者说编译器的前端,将.java文件编译为.class文件的过程。也可能是指jvm的后端运行期编译期(JIT编译器)把字节码转变为机器码的过程,还可能是使用静态提前编译器(AOT编译器)直接将.java文件编译为本地机器代码的过程。

前端编译器:Sun的Javac,Eclipse JDT中的增量式编译器(ECJ)

JIT编译器:Hotspot vm的C1、C2编译器

AOT编译器:GNU Compiler for the Java(GCJ)、Excelsior JET

热点代码及探测方式:

  • 是否需要启动JIT将字节码直接编译为对应平台的本地机器指令,需要根据代码被调用执行的频率而定。需要被编译为本地代码的字节码,称为热点代码,JIT编译器在运行时针对频繁被调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令。一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码,因此都可以通过JIT编译为本地机器指令,由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR。一个方法要被调用多少次或者一个循环体执行多少次才可以达到热点标准?需要一个明确的阈值,目前Hotspot采用的热点探测方式是基于计数器的热点探测。为每一个方法建立2个不同类型的计数器,分别为方法调用计数器和回边计数器,前者用于统计方法的调用次数,后者用于统计循环体执行的循环次数。

    方法调用计数器默认阈值在Client模式下为1500次,Server模式下是10000次,超过阈值后触发JIT编译,可以通过-XX:CompileThreshold来设定。当一个方法被调用时,先检查该方法是否存在JIT编译过的版本,存在则优先使用编译后的本地代码执行。否则将此方法调用计数器加1,然后判断方法调用计数器和回边计数器值之和是否超过方法调用计数器的阈值,如果超过将会向JIT提交该方法的代码编译请求。

    image-20240121015657477

    第12章_方法调用计数器

    第12章_回边计数器

  • 热度衰减

    如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给JIT编译,那这个方法的调用计数器就会被减少一半,称为方法调用计数器的热度衰减,这段时间称为此方法统计的半衰周期。这个动作时GC时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外参数-XX:CounterHalfLifeTime可以设置半衰周期的时间,单位是秒。

  • 设置程序执行方式

    默认情况下Hotspot采用解释器与编译器并存的架构,可以通过命令显式为jvm指定运行时是完全采用解释器执行还是完全采用即时编译器执行。

    -Xint:完全采用解释器模式执行程序

    -Xcomp:完全采用即时编译器模式执行程序,如果即时编译出现问题,解释器会介入执行

    -Xmixed:采用解释器+即时编译器的混合模式执行程序

  • Hotspot vm中有两个JIT编译器,分别为Client Compiler和Server Compiler,简称为C1,C2编译器。可以通过如下命令显式指定:

    -client:jvm运行在Client模式下,使用C1编译器,对字节码进行简单和可靠的优化,耗时短。

    -server:jvm运行在Server模式下,使用C2编译器,进行耗时较长的优化以及激进优化,但优化的代码执行效率更高

    c1主要使用方法内联、去虚拟化、冗余消除的优化策略

    • 方法内联:将引用的函数代码编译到引用处,减少栈帧生成,参数传递和跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:运行期间把一些不会执行的代码折叠掉

    c2优化主要是在全局层面,逃逸分析是优化的基础。有标量替换,栈上分配,同步省略这几种优化,在前面详细讲过。

    分层编译策略:程序解释执行(不开启性能监控)可以触发C1编译,加上性能监控C2编译会根据性能监控信息进行激进优化。JDK7以后,如果显式执行-server,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同执行编译任务。

垃圾回收

什么是垃圾

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不及时对内存中的垃圾进行清理,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,甚至导致内存溢出。

为什么需要GC

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收。除了释放没用的对象,垃圾回收也可以清楚内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。随着业务的扩大,复杂,没有GC就不能保证应用程序的正常进行,而经常造成STW的GC跟不上实际需求,所以才会不断优化GC。

Java垃圾回收机制

java具有自动内存管理,无需开发人员手动参与内存的分配和回收,降低内存泄漏和溢出的风险,同时将我们从繁重的内存管理中释放出来专注业务开发。但是自动内存管理像一个黑匣子,过度依赖会弱化当程序中出现内存溢出时问题定位和解决的能力,当垃圾收集成为系统更高并发量的瓶颈就必须要对这些"自动化"的技术实施必要的监控和调节。

垃圾回收器可以对年轻代,老年代甚至是全堆和方法区的回收。其中,java堆是垃圾收集器的工作重点,从次数上讲,频繁收集年轻代,较少收集老年代,基本不动方法区。

垃圾回收相关算法

垃圾标记阶段的引用计数算法

堆中几乎存放所有的对象实例,GC进行垃圾回收之前,首先要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程称为垃圾标记阶段。判断一个对象存活一般有两种方式,引用计数算法和可达性分析算法。

引用计数算法比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。一个对象只要有任何一个引用了它,它的引用计数器就加1,引用失效就减1,当计数器值为0表示该对象不可能再被使用,可进行回收。

优点是实现简单,垃圾对象便于识别,判定效率高,回收没有延迟性。缺点是需要单独的字段存储计数器,增加了空间开销;每次赋值都需要更新计数器的值,伴随加减法操作,增加了时间开销;更严重的问题是无法处理循环引用的情况,这是致命缺陷,所以java的垃圾回收器没有使用这类算法。

image-20240129160638313

引用计数算法,是很多语言的资源回收选择,例如Python,更是同时支持引用计数和垃圾收集机制。java并没有采用引用计数,那么Python是怎么解决循环引用呢?一个是手动解除,即在合适的时机解除引用关系;二是使用弱引用weakref,是Python提供的标准库,用于解决循环引用。

垃圾标记阶段的可达性分析算法

可达性分析算法或根搜索算法,追踪性垃圾收集,对比于引用计数算法而言不仅同样具备实现简单和执行高效的特点,还可以有效地解决引用计数中循环引用的问题防止内存泄漏的发生。

基本思路:

  • 算法以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。所谓的GC Roots根集合就是一组必须活跃的引用。MAT和JProfiler都可以查看程序中的GC Roots
  • 使用该算法后,内存中存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称为引用链。
  • 如果目标对象没有任何引用链相连则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
  • 只有能够被根对象集合直接或间接连接的对象才是存活对象。

image-20240129161823498

在java中,GC Roots包括以下几类元素:

  • 虚拟机栈中引用的对象,比如各个线程被调用的方法中使用到的参数、局部变量等
  • 本地方法栈内JNI引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象比如字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象
  • jvm内部的引用,比如基本数据类型对应的class对象,一些常驻的异常对象,系统类加载器等
  • 反映jvm内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了上面固定的GC Roots集合以外,根据用户选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时加入,共同构成完整的GC Roots集合,比如分代收集和局部回收(Partial GC)

  • 如果只针对java堆中的某一块区域进行垃圾回收,必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候需要一并将关联的区域对象也加入GC Roots集合中去考虑才能保证可达性分析的准确性。
  • 由于Root采用栈方式存放变量和指针,所以如果一个指针保存了堆内存的对象但是自己又不存放在堆内存里面那它就是一个Root。

如果要使用可达性分析算法判断内存是否可以回收,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足那么分析结果的准确性就无法保证。这点也是导致GC进行时必须STW的一个重要原因,即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

对象的finalization机制

java提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,垃圾回收此对象之前总会先调用这个对象的finalize()方法。该方法允许在子类中重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

永远不要主动调用某个对象的finalize方法,应该交给垃圾回收机制调用,一是在finalize方法中,对象可能会复活。二是该方法执行时间没有保障,它完全由GC线程来决定,极端情况下不发生GC则该方法将没有执行机会。三是一个糟糕的finalize方法会严重影响GC的性能。

由于该方法的存在,虚拟机中的对象一般可能处于以下三种状态:

  • 可触及的:从根节点开始,可以达到该对象
  • 可复活的:对象的所有引用被释放,但是对象可能在finalize方法中复活
  • 不可触及的:对象的finalize方法被调用,并且没有复活,那么就会进入不可触及状态,对象不可能被复活,因为finalize方法只会调用一次

只有在对象不可触及时才会被回收。

判定一个对象是否可回收,至少要经历两次标记过程:

  • 如果对象到GC Roots没有引用链,则进行第一次标记
  • 进行筛选,判断此对象是否有必要执行finalize方法
    • 如果对象没有重写finalize方法,或者finalize方法已经被虚拟机调用过,则虚拟机视为没有必要执行,对象被判定为不可触及
    • 如果对象重写了finalize方法并且该方法没有执行过,那么对象会被插入到F-Queue队列中,由一个虚拟机自动创建的,低优先级Finalizer线程触发其finalize方法执行
    • finalize方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记,如果对象的finalize方法中与引用链上的任何一个对象建立了联系,那么第二次标记时,对象会被移除即将回收集合,之后对象会再次出现没有引用存在的情况,此后finalize方法不会被再次调用,对象直接变成不可触及状态,finalize方法只会调用一次
垃圾清除阶段的标记清除算法

标记-清除(Mark-Sweep)算法是一种非常基础和常见的垃圾收集算法,当堆中的有效内存被耗尽时,就会停止整个程序即STW,然后进行两项工作,第一是标记,第二是清除。

  • 标记:垃圾收集器从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的Header中记录为可达对象。
  • 清除:垃圾收集器对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

第14章_标记-清除算法

缺点:

  • 效率不算高
  • 进行GC时,会导致STW,用户体验较差
  • 清理出来的空闲内存不是连续的,会产生内存碎片需要维护一个空闲列表

注意:这里的清除不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放

垃圾清除阶段的复制算法

核心思想是将活着的内存空间划分为两块,每次只是用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收,新生代的s0和s1区就是这样的设计。

第14章_复制算法

优点:

  • 没有标记和清除过程,实现简单高效
  • 复制过去以后保证空间的连续性不会出现碎片问题

缺点:

  • 需要两倍的空间
  • 对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用还是时间开销也不少

特别的如果系统中的垃圾对象很多时,复制算法需要复制的存活对象数量并不会很多。所以对于新生代这种朝生夕死频繁的区域,一般采用该算法进行垃圾回收。

垃圾清除阶段的标记压缩(整理)算法

复制算法的高效性是建立在存活对象少,垃圾对象多的前提下,新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,复制成本很高,因此基于老年代的垃圾回收的特性需要使用其他的算法。

执行过程:

  • 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象
  • 第二阶段将所有的存活对象压缩到内存的一端按顺序排放,之后清理边界外所有的空间

第14章_标记-压缩算法

该算法最终效果等同于执行标记-清除算法后再进行一次内存碎片整理,因此也被称为标记-清除-压缩算法。本质在于标记-清除算法是一种非移动式的回收算法,标记-压缩算法是移动式的,是否移动回收后的存活对象是一项优缺点并存的风险决策。

显然,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,比维护一个空闲列表要少了一些开销。

优点:

  • 消除了标记-清除算法中,内存区域分散出现内存碎片的缺点
  • 消除了复制算法当中,内存减半的代价

缺点:

  • 从效率上看,要低于复制算法
  • 移动对象同时,如果对象被其他对象引用,还需要调整引用的地址
  • 移动过程中,全程都会STW
三种算法的对比
Mark-SweepMark-CompactCopying
速度中等最慢最快
空间开销少但会产生内存碎片少不产生内存碎片通常需要两倍空间但不产生内存碎片
移动开销
分代收集算法

前面的算法中,都具有自己的独特优势和特点,没有一种算法可以完全替代其他算法,因此分代收集算法应运而生。

该算法基于这样一个事实,不同的对象的生命周期是不一样的,因此不同生命周期的对象可以采用不同的收集方式以便提高回收效率。一般是把java堆分为新生代和老年代,根据各个年代的特点使用不同的回收算法提高垃圾回收的效率。

目前几乎所有的GC都是采用分代收集算法执行垃圾回收的,在Hotspot中,基于分代的概念,GC使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代相比于老年代区域较小,对象声明周期短,存活率低,回收频繁。这种情况适合复制算法进行回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收,而复制算法内存利用率不高的问题,也通过两个survivor的设计得到缓解。

老年代区域较大,对象生命周期长,存活率高回收频率不如年轻代。存在大量存活率高的对象一般是由标记-清除或者是标记-整理算法的混合实现。

  • Mark阶段的开销与存活对象数量成正比
  • Sweep阶段的开销与所管理区域的大小成正比
  • Compact阶段的开销与存活对象的数据成正比

以CMS回收器为例,基于Mark-Sweep实现的,对于对象的回收效率很高,对于碎片问题采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳,采用Serial Old执行Full GC达到对老年代的内存整理。

增量收集算法

为了解决GC时STW较长引起用户体验较差的问题,对实时垃圾收集算法的研究导致了增量收集算法的诞生。让垃圾收集线程和用户线程交替执行,每次只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复直到垃圾收集完成。基础仍是传统的标记-清除和复制算法,通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段方式完成标记、清理或复制工作。

缺点在于垃圾回收过程中,间断性执行了应用程序代码,能减少系统的停顿时间,但是线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量下降。

分区算法

为了更好控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标的停顿时间,每次合理地回收若干个小区间而不是整个堆空间,从而减少一次GC所产生的停顿。每个小区间独立使用,独立回收,好处在于可以控制一次回收多少个小区间。

垃圾回收相关概念

System.gc()的理解

默认情况下,System.gc()或者Runtime.getRuntime().gc()的调用会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。然而该方法附带一个免责声明,无法保证对垃圾收集器的调用,一般情况下不需要手动调用该方法,在一些特殊情况下,比如编写一个性能基准可以在运行之间调用该方法。另外可以使用System.runFinalization()来强制调用使用引用的对象的finalize方法。

内存溢出与内存泄漏
内存溢出

内存溢出相对于内存泄漏来说,虽然容易被理解,但是同样内存溢出也是引发程序崩溃原因之一。大多数情况下,GC会进行各种年龄阶段的垃圾回收,实在不行来一次独占式的Full GC操作,这时候会回收大量的内存以供应用程序使用。javadoc中对OOM的解释是没有空闲内存,并且垃圾收集器也无法提供更多内存。

jvm的堆内存不够,原因有二:

  • jvm的堆内存设置不够,比如要处理比较可观的数据量,但是没有显式指定堆大小或者值偏小,可以通过-Xms和-Xmx来调整。
  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的jdk,永久代大小有限,并且jvm对永久代垃圾回收非常不积极,所以当我们不断添加新类型的时候,永久代出现OOM非常多见。尤其是运行时存在大量动态类型生成的场合,类似intern字符串缓存占用太多空间,也会OOM,随着元空间的引入,方法区内存已经不再那么窘迫。

在抛出OOM之前,通常垃圾收集器会被触发,尽其所能去清理出空间,例如引用机制分析中涉及到jvm尝试去回收软引用指向的对象等。在java.nio.BIts.reserveMemory()方法中,能清楚的看到,System.gc()会被调用以清理空间。当然也不是在任何情况下垃圾收集器都会被触发的,比如去分配一个超大对象,类似一个超大数组超过堆的最大值,jvm可以判断出垃圾收集器并不能解决这个问题所以直接抛出OOM。

内存泄漏

也称为存储渗漏,严格来说只有对象不会再被程序用到了,但是GC又不能回收他们的情况称为内存泄漏。实际上很多时候不太好的实践或者疏忽导致对象的生命周期变得很长甚至导致OOM也可以叫做宽泛意义上的内存泄漏。

例如:

  • 单例模式

    单例的生命周期和应用程序是一样长,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  • 一些提供close的资源未关闭导致内存泄漏

    数据库连接,网络连接和io连接必须手动close否则是不能被回收的。

Stop The World

简称STW,是指GC事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉。被中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡顿一样。

STW和采用哪款GC无关,所有的GC都有该事件,就是G1也不能完全避免STW的发生,只能说垃圾回收器越来越优秀,回收效率越高尽可能缩短了暂停时间。STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的线程全部停掉。

垃圾回收器的并行与并发

并行:多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态,如ParNew、Parallel Scavenge、Parallel Old;

串行:相较于并行来说是单线程执行,如果内存不够则程序暂停,启动JVM垃圾回收器进行垃圾回收,回收完再启动程序的线程。

并发:指用户线程和垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户线程的运行,用户程序继续运行,而垃圾收集线程运行与另一个CPU上,如CMS,G1。

安全点和安全区域

程序执行时并非在所有地方都能够停顿下来开始GC,只有在特定的位置能停顿下来开始GC,这些位置被称为安全点。它的选择很重要,太少会导致GC等待时间太长,太频繁会导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据是否具有让程序长时间执行的特征为标准。比如选择一些执行时间较长的指令作为安全点,如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到了最近的安全点停顿下来呢?

  • 抢先式中断(目前没有虚拟机采用了)

    首先中断所有线程,如果还有线程不在安全点,就恢复线程让线程跑到安全点。

  • 主动式中断

    设置一中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真则将自己进行中断挂起。

该机制保证了程序执行时,在不太长的时间内就会遇到可能进入GC的安全点。但是程序不执行的时候呢?例如线程处于睡眠状态或者阻塞状态,这时候线程无法响应jvm的中断请求,走到安全点去中断挂起,jvm也不太可能等待线程被唤醒,对于这种情况就需要安全区域来解决。

完全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。实际执行时,当线程运行到安全区域的代码时,首先标识已经进入安全区域,如果这段时间发生GC,jvm会忽略标识为安全区域的进程。当线程即将离开安全区域时,会检查jvm是否已经完成GC,如果完成则继续进行否则必须等待直到收到可以安全离开的信号位置。

再谈引用

我们希望描述这样一类对象,当内存空间足够时,则能保留在内存中,如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。在jdk2之后,java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用,引用强度依次减弱,此外还有一个终结器引用。除了强引用外,剩下的引用都可以在java.lang.ref中的Reference子类找到。其中只有终结器引用是包内可见的,其余类型均为public可以在程序中直接使用。

  • 强引用:最传统的引用定义,是指在程序代码中普遍存在的引用赋值,无论任何情况下,只要强引用关系存在,垃圾回收器就永远不会回收掉被引用的对象。
  • 软引用:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收,如果这次回收后还没有足够的内存,才会抛出OOM
  • 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾收集器工作时无论内存空间是否足够都会回收掉弱引用关联的对象
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。
强引用

最常见的引用,也是默认的引用类型,强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象,也是造成内存泄漏的主要原因之一,强引用可以直接访问目标对象。

软引用

用于描述一些还有用但非必需的对象,只被软引用关联的对象在系统将要发生OOM之前,会把这些对象列入回收范围中进行第二次回收,如果这次回收还没有足够内存才会抛出OOM。

通常用来是实现内存敏感的缓存,如高速缓存就用到了软引用,如果还有空闲内存就暂时保留缓存,当内存不足时清理掉就保证了使用缓存的同时不会耗尽内存。垃圾回收器在某个时刻决定回收软可触及的对象时会清理软引用,并可选地把引用存放到一个引用队列。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;//销毁强引用,上面三行代码也可以用下面一行代码代替
SoftReference<Object> sf = new SoftReference<Object>(new Object());
弱引用

也是用于描述那些非必需的对象,被弱引用关联的对象只能生存到下一次GC为止。在GC时,只要发现弱引用,不管系统堆空间使用是否充足都会回收掉只被弱引用关联的对象。构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,可以跟踪对象的回收情况。软引用和弱引用都非常适合保存那些可有可无的缓存数据。当内存不足时,缓存数据被回收,不会导致OOM,而当内存充足时,这些缓存数据又可以存在相当长时间,起到加速系统的作用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;//销毁强引用,上面三行代码也可以用下面一行代码代替
WeakReference<Object> wf = new WeakReference<Object>(new Object());
虚引用

也称为幽灵引用或者幻影引用,虚引用的存在不会决定对象的声明周期,随时都可能被垃圾回收器回收。不能单独使用,也无法通过虚引用获取被引用的对象,但试图通过虚引用的get()方法获取对象时,总是null,为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如能在这个对象被垃圾回收器回收时收到一个系统通知。

虚引用必须要和引用队列一起使用,创建时必须要提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。由于虚引用可以跟踪对象的回收时间,因此可以将一些资源释放操作放置在虚引用中执行和记录。

Object obj = new Object();
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> pf = new PhantomReference<Object>(obj,phantomQueue);
obj = null;//销毁强引用
终结器引用

用于实现对象的finalize()方法,无需手动编码,其内部配合引用队列使用。GC时,终结器引用入队,由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。

垃圾回收器

GC分类和性能指标

从不同的角度分析垃圾回收器,可以将GC分为不同的类型。

  • 按垃圾回收的线程数分,可以分为串行垃圾回收器和并行垃圾回收器。

    image-20240203005924126

    串行回收指的是同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程暂停直到垃圾收集工作结束。在单CPU或者较小的应用内存等场合,串行回收器的性能表现可以超过并行和并发回收器,串行回收默认被应用在客户端的Client模式下的JVM中。

    和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收提升应用的吞吐量,不过仍然采用独占式也会由STW。

  • 按工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。

    并发式垃圾回收器与应用线程交替工作,以尽可能减少引用程序的停顿时间。独占式垃圾回收器一旦运行就停止应用程序的所有用户线程,直到垃圾回收过程完全结束。

    image-20240203010901777

  • 按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。前者在回收完后会对存活对象进行压缩整理,消除回收后的内存碎片,后者不会进行这步操作。

  • 按照工作的内存区间分,分为年轻代垃圾回收器和老年代垃圾回收器。

评估GC的性能指标:

  • 吞吐量:运行用户代码的时间占总运行时间的比例

    • 总运行时间 = 程序的运行时间 + 内存回收的时间
  • 垃圾收集开销:吞吐量的补数,垃圾收集时间与总运行时间的比例

  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间

  • 收集频率:相对于应用程序的执行,收集操作发生的频率

  • 内存占用:Java堆区所占的内存大小

  • 快速:一个对象从诞生到被回收所经历的时间

上面加粗的三者构成一个不可能三角,但是三者总体的表现会随着技术进步的越来越好,一款优秀的收集器最多同时满足其中两项。其中暂停时间的重要性愈发凸显,随着硬件发展内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大对延迟反而带来了负面效果。

吞吐量

高吞吐量的程序有更长的时间基准,快速相应通常是不必考虑的

第17章_吞吐量与暂停时间

高吞吐量会让应用程序的最终用户感觉只有应用程序在做生产性工作,直觉上吞吐量越高程序运行越快。

低延迟从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的,这取决于应用程序的类型,有时候甚至短暂的200ms暂停可能打断终端用户的体验,因此特别是对于一个交互式应用程序来说低延迟是非常重要的。

显然这两者是一个相互竞争的目标:

  • 如果选择吞吐量优先,必然降低内存收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
  • 相反,以低延迟优先,为了降低每次执行内存回收时的暂停时间,只能频繁地执行内存回收,又会引起年轻代内存的缩减和导致程序吞吐量的下降。

在设计或者使用GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一,或者尝试找到一个二者的折中,现在一个标准是在最大吞吐量优先的情况下,尽可能降低停顿时间。

不同垃圾回收器概述

垃圾收集器发展简史
  • 1999年jdk1.3.1发布串行的Serial GC,是第一款GC,ParNew垃圾收集器是它的多线程版本。
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC(CMS)随着jdk1.4.2发布
  • Parallel GC在jdk6之后成为Hotspot vm的默认GC
  • 2012年,jdk1.7u4版本中,G1可用
  • 2017年,jdk9中G1变成默认的垃圾收集器以代替CMS
  • 2018年3月,jdk10中G1的并行完整垃圾回收实现并行性来改善最坏情况下的延迟
  • 2018年9月,jdk11发布,引入Epsilon又称为No-Op无操作回收器,同时引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)
  • 2019年3月,jdk12发布,增强G1,自动返回未用堆内存给操作系统,同时引入Shenandoah GC,低停顿时间的GC(Experimental),由RedHat推出作用于Openjdk上。
  • 2019年9月,jdk13发布,增强ZGC,自动返回未用堆内存给操作系统
  • 2020年3月,jdk14发布,删除CMS GC,扩展ZGC在macOS和Windows上的应用

7款经典的垃圾回收器:

  • 串行回收器:Serial GC、Serial Old GC
  • 并行回收器:ParNew GC、Parallel Scavenge GC、Parallel Old GC
  • 并发回收器:CMS、G1

第17章_经典的7种GC

  • 新生代收集器:Serial GC、ParNew GC、Parallel Scavenge GC
  • 老年代收集器:Serial Old GC、Parallel Old GC、CMS
  • 整堆收集器:G1

收集器的组合关系(到jdk14):

第17章_垃圾收集器组合

将上面的所有虚线看作实线是jdk8以前的搭配

,其中CMS和Serial Old的搭配是CMS回收失败的后备方案,在后面讲解CMS时会详细说明。

红色虚线表明在jdk8中取消了该组合,被标为过时的,jdk9中彻底移除了这两条红色虚线的搭配。

绿色虚线是jdk14中,弃用了Parallel Scavenge GC和Serial Old GC组合,CMS GC的绿色边框是指jdk14中删除了该收集器。

jdk8中默认是Parallel Scavenge GC和Parallel Old GC。可以通过-XX:+PrintCommandLineFlags:查看命令行相关参数其中包含使用的垃圾收集器。或者使用jinfo -flag 相关垃圾收集器参数 进程ID也可以查看该收集器有没有使用。

没有万能的收集器,我们选择的只是对具体应用最合适的收集器。

Serial GC:串行回收

该收集器是最基本的垃圾收集器,采用复制算法,串行回收和STW机制的方法执行内存回收。除了年轻代以外,还提供了用于执行老年代垃圾收集的Serial Old收集器,采用标记-压缩算法,串行回收和STW机制。是运行在Client模式下的默认垃圾收集器,Serial Old在Server模式下主要有两个用途:与新生代的Paralle Scavenge配合使用,作为老年代CMS收集器的后备垃圾收集方案

image-20240203015640129

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

优势:简单高效,对于单CPU的场景,由于没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率,运行在Client模式下的虚拟机是个不错的选择。使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用该串行收集器。

ParNew GC:并行回收

ParNew收集器相当于Serial GC的多线程版本,只是处理新生代的,它除了采用并行回收的方式执行内存回收外,两款收集器几乎没有任何区别,也是采用复制算法、STW机制,是很多JVM在Server模式下新生代的默认垃圾收集器。

image-20240203020417732

对于新生代,回收次数频繁,使用并行方式高效,老年代回收次数少使用串行方式节省资源(CPU并行需要切换线程,串行省去切换线程的资源),目前除Serial外,只有ParNew GC能与CMS收集器配合工作。

可以通过-XX:+UseParNewGC指定使用该收集器,仅表示年轻代使用该收集器,不影响老年代所使用的收集器。-XX:ParallelGCThreads限制线程数量,默认开启和CPU数相同的线程数。

Parallel GC:吞吐量优先

Parallel Scavenge GC同样也是并行回收、复制算法、STW机制执行内存回收,和ParNew不同的是,该收集器目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器。同时自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

高吞吐量可以高效利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。也提供了Parallel Old收集器来代替老年代的Serial Old收集器,Parallel Old采用标记-压缩算法,并行回收和STW机制。

image-20240203021612567

在吞吐量优先的应用场景中,Parallel和Parallel Old的组合在Server模式下的内存回收性能很不错,jdk8中也是默认的垃圾收集器。

参数设置:

  • -XX:+UseParallelGC,手动指定年轻代使用Parallel收集器

  • -XX:+UseParallelOldGC,手动指定老年代使用Parallel Old收集器,这两个参数,默认开一个另一个也会被开启。

  • -XX:ParallelGCThreads,设置年轻代并行收集器的线程数,一般与CPU数相等,避免过多的线程数影响垃圾收集性能。默认情况下当CPU数量小于8个,它的值等于CPU数,大于8时ParallelGCThreads= 3 + 5 ∗ C P U c o u n t / 8 3+5*CPUcount/8 3+5CPUcount/8

  • -XX:MaxGCPauseMillis,设置垃圾收集器的最大停顿时间即STW时间,单位毫秒,该参数设置需要谨慎

    • 为了尽可能把停顿时间控制在MaxGCPauseMillis以内,收集器工作时会调整java堆大小或者其他一些参数
    • 对于用户来说STW越短体验越好,但是在服务器端更注重高并发整体的吞吐量,所有Parallel适合服务器端进行控制。
  • -XX:GCTimeRatio,垃圾收集时间占总时间的比例, 1 / ( N + 1 ) 1/(N+1) 1/(N+1),用于衡量吞吐量大小,取值范围 ( 0 , 100 ) (0,100) (0,100),默认值99也就是垃圾回收时间不超过 1 % 1\% 1%。与上一个参数有一定矛盾性,STW时间越长,该参数就容易超过设定的比例。

  • -XX:+UseAdaptiveSizePolicy,设置Parallel Scavenge收集器具有自适应调节策略,这种模式下年轻代的大小,Eden和Survivor的比例,晋升老年代的年龄等参数会被自动调整。来达到在堆大小、吞吐量、和STW之间平衡点。

CMS GC:低延迟

该收集器是Hotspot vm中第一款真正意义上的并发垃圾收集器,第一次实现让垃圾收集线程与用户线程同时工作。尽可能缩短垃圾收集时用户线程的停顿时间,适合与用户交互的程序。目前很大一部分java应用程序集中在互联网或者B/S系统的服务器上,这类应用油气重视服务的响应速度,希望系统的暂停时间最短,CMS就非常符合这个需求。CMS采用标记-清除算法,并发执行,但是也会STW。

但是无法与Parallel Scavenge配合工作,因为两者底层的框架不同不能兼容,所有新生代只能选择ParNew或者Serial中的一个,在G1出现之前,CMS使用还是非常广泛的,目前也仍然有很多系统使用CMS GC。

image-20240203161424093

  • 初始标记阶段:这个阶段中,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务是标记出GC Roots能直接关联的对象,一旦标记完成就会恢复之前暂停的所有应用线程,由于直接关联对象比较小所以这里的STW很短。
  • 并发标记阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记阶段:修正并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的STW时间通常比初始标记阶段稍长一些。
  • 并发清除阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间,由于不需要移动存活对象所以这个阶段也是可以和用户线程同时并发的。

尽管CMS采用非独占式的并发回收,但是在其初始化标记和重新标记这两个阶段仍然是STW的,但是最耗费时间的并发标记和并发清除阶段都不需要暂停用户线程,所以整体的回收时低停顿的。另外由于在垃圾收集阶段用户线程没有中断,所以CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率到达某一阈值时,便开始进行回收以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。哟啊是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机启动后备方案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS采用标记-清除算法,每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞技术,只能选择空闲列表执行内存分配。

那为什么不换成标记-压缩算法呢?因为当并发清除的时候,用压缩整理内存的话,原来的用户线程使用的内存不能正常使用。要保证用户线程继续执行,前提是它运行的资源不受影响,标记-压缩算法更适合STW场景下使用。

CMS的优点:

  • 并发收集
  • 低延迟

CMS的缺点:

  • 产生内存碎片,导致并发清除后,用户线程可用的空间不足,无法分配大对象的情况下,不得不提前触发Full GC。
  • CMS收集器对CPU资源非常敏感,在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS收集器无法处理浮动垃圾,可能出现Concurrent Mode Failure失败而导致另一次FUll GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程时同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的空间。

参数配置:

  • -XX:+UseConcMarkSweepGC,手动指定使用CMS收集器,开启后会自动将-XX:+UseParNewGC打开,即使用ParNew处理新生代和CMS处理老年代的组合。

  • -XX:CMSlnitiatingOccupanyFraction,设置堆内存使用率的阈值,一旦达到该阈值便开始进行回收。jdk5及以前默认值是68,jdk6及以后默认值是92,如果内存增长缓慢,则可以设置一个稍大的值。大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器,因此通过该选便可以有效降低Full GC的执行次数。

  • -XX:+UseCMSCompactAtFulllCollection,用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是STW更长了。

  • -XX:CMSFullGCsBeforeCompaction,设置在执行多少次Full GC后对内存空间进行压缩整理。

  • -XX:ParallelCMSThreads,设置CMS线程数量,默认启动线程数是 ( P a r a l l e l G C T h r e a d s + 3 ) / 4 (ParallelGCThreads + 3)/4 (ParallelGCThreads+3)/4

小结:

  • 想最小化使用内存和最小并行开销,可以选择Serial GC
  • 想最大化应用程序吞吐量,可以选择Parallel GC
  • 想最小化GC的停顿时间,可以选择CMS

G1 GC:区域分代化

G1是jdk7u4后引入的新的垃圾回收器,是当今收集器技术发展的最前沿的成果之一,于此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进行一步降低暂停时间同时兼顾良好的吞吐量。设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,担任全功能收集器的重任与期望。

G1是一个并行回收器,把堆内存分割为很多个不相关的区域(Region,物理上不连续),使用不同的Region来表示Eden、Survivor0区,Survivor1区,老年代等。有计划地避免在整个java堆中进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

G1是一款面向服务器端应用的垃圾收集器,主要针对配备多核CPU以及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。是jdk9以后的默认垃圾回收器,取代了CMS以及Parallel + Parallel Old组合。

优势
  • 并行与并发

    • 并行性:回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,此时用户线程STW
    • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
  • 分代收集

    • G1仍属于分代型垃圾收集器,区分年轻代和老年代,年轻代依然有Eden区和Survivor区。从堆的结构上看,不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
    • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
    • 和之前的各类回收器不同,它同时兼顾年轻代和老年代,对比其他回收器,或者工作在年轻代或者工作在老年代。
  • 空间整合

    • CMS:标记-清除算法、内存碎片、若干次GC后进行一次碎片整理
    • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的,region之间是复制算法,但整体上实际可看作是标记-压缩算法。两种算法都可以避免内存碎片,有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC,尤其是当java堆非常大的时候,G1的优势更加明显。
  • 可预测的停顿时间模型(软实时)

    这是G1相对于CMS的另一大优势,G1除了追求低停顿外还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所消耗的时间经验值),在后台维护一个优先列表,每次根据允许收集的时间,优先回收价值最大的region,保证G1收集器在有限的时间内可以尽可能高的收集效率。
    • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
缺点

在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS高,从经验上来说小内存应用上CMS表现大概率优于G1,而G1在大内存应用上则发挥其优势,平衡点在6-8GB之间。

参数设置
  • -XX:+UseG1GC,手动指定使用G1收集器
  • -XX:G1HeapRegionSize,设置每个Region的大小,值是2的幂,范围是1-32MB之间,根据最小的java堆大小划分出约2048个区域,默认是堆内存的1/2000
  • -XX:MaxGCPauseMillis,设置期望达到的最大GC停顿时间指标,会尽力实现但不保证达到,默认200ms
  • -XX:ParallelGCThreads,设置STW工作线程数的值,最多为8
  • -XX:ConcGCThreads,设置并发标记的线程数,将n设置为并行垃圾回收线程数的1/4左右
  • -XX:InitiatingHeapOccupancyPercent,设置触发并发GC周期的java堆占用率阈值,超过此值就触发GC,默认值45
G1的适用场景

面向服务端应用,针对具有大内存、多处理器的机器;最主要的应用是需要GC低延迟,并具有大堆的应用程序;

在下面的情况使用G1可能比CMS好:

  • 超过50%的java堆被活动数据占用
  • 对象分配频率或年代提升频率变化很大
  • GC停顿时间过长(长于0.5s至1s)

Hotspot的垃圾收集器里,除了G1,其他的收集器使用内置的jvm线程执行GC的多线程操作,而G1采用应用线程承担后台运行的GC工作,即当jvm的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区Region

使用G1时,它将整个java堆空间划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定。整体控制在1-32MB,且值为2的N次幂,所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的,都是一部分Region,且不需要连续的集合,通过Region动态分配方式实现逻辑上的连续。

image-20240203231833255

一个region有可能属于Eden、Survivor或者Old/Tenured内存区域,但是一个region只可能属于一个角色。同时还增加了一种新的内存区域,叫做Humongous内存区域,如上图的H块,主要用于存储大对象,如果超过1.5个region就放到H区。

对于堆中的大对象,默认直接会被分到老年代,但是如果是一个短期存在的大对象,就会对GC造成负面影响。为了解决这个问题,划分了H区,如果一个H区装不下大对象,那么会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC,G1的大多数行为都把H区作为老年代的一部分来看待。

G1回收器垃圾回收过程

G1 GC的垃圾回收主要包括如下三个环节:

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • 如果需要,单线程、独占式、高强度的Full GC还是继续存在的,它针对GC的评估失败提供了一种失败保护机制,即强力回收。

第17章_G1-GC垃圾回收过程

当年轻代的Eden区用尽时开始年轻代回收过程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收,然后从年轻代区间移动存活对象到Survivor区或者老年区,也有可能是两个区间都会涉及。

当堆内存使用达到一定值,默认45%,开始老年代并发标记过程。标记完成马上开始混合回收过程,对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了,同时这个老年代Region是和年轻代一起被回收的。

Remembered Set

简称Rset,为了解决一个对象被不同区域引用的问题。一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时是否需要扫描整个堆才能保证准确呢?

无论是G1还是其他分代收集器,jvm都是使用Rset来避免全局扫描,每个Region都有一个对应的Rset,每次Reference类型数据写操作时,都会产生一个写屏罩(Write Barrier)暂时中断操作。然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器是检查老年代对象是否引用了新生代对象),如果不同,就通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Rset中。当进行垃圾收集时,在GC根节点的枚举范围加入Rset就可以保证不进行全局扫描,也不会有遗漏。

image-20240203234007004

G1回收过程一:年轻代GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区,YGC时,首先G1停止应用程序的执行,G1创建回收集,指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

image-20240204003601959

然后开始如下回收过程:

  • 第一阶段,扫描根

    • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等,根引用连同RSet记录的外部引用作为扫描存活对象的入口
  • 第二阶段,更新RSet

    • 处理dirty card queue中的card(对于应用程序中的引用赋值语句Object a = b,jvm会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card,在年轻代回收的时候,G1会对dirty card queue中所有的card进行处理,来更新RSet保证实时准确的反映引用关系,不在引用赋值语句处更新RSet原因是性能需要,RSet处理需要线程同步,开销会很大,使用队列性能会好很多),更新RSet,此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用
  • 第三阶段,处理RSet

    • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
  • 第四阶段,复制对象

    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存分段中存活的对象如果年龄未达到阈值,年龄会加一,达到阈值会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间中。
  • 第五阶段,处理引用

    • 处理软,弱,虚,终结器,JNI Weak等引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
G1回收过程二:并发标记过程
  • 初始标记阶段:标记从根节点直接可达的对象,这个阶段是STW的,且会触发一次年轻代GC
  • 根区域扫描:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象,这一过程必须在YGC之前完成
  • 并发标记:在整个堆中进行并发标记(和应用程序并发执行),此过程可能会被YGC中断,在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)
  • 再次标记:由于应用程序持续进行,需要修正上一次的标记结果,是STW的,采用了比CMS更快的初始快照算法(SATB)。
  • 独占清理:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域,为下阶段做铺垫,是STW的,这个阶段不会实际上去做垃圾的收集。
  • 并发清理阶段:识别并清理完全空闲的区域
G1回收过程三:混合回收

当越来越多的对象晋升到老年代region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,Mixed GC,除了回收整个Young Region还会回收一部分的Old Region,从而可以对垃圾回收的耗时进行控制,注意此处的Mixed GC并不是Full GC。

  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算出来。默认情况下,这些老年代的内存分段会分为8次(可以通过-XX:G1MixedGCCountTarget设置)被回收
  • 混合回收的回收集包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。
  • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段,垃圾占内存分段比例越高的,越会被先回收,并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认65%。
  • 混合回收不一定要进行八次,有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。
G1回收可选过程四:Full GC

G1初衷就是避免Full GC的出现,但是如果上述方式不能正常工作,G1会停止应用程序的执行,使用单线程的内存回收算法进行垃圾回收,性能会非常差,STW时间长。一般原因有两个,一是晋升的时候没有足够的to区来存放晋升的对象;二是并发处理过程完成之前空间耗尽

G1回收器优化建议
  • 年轻代大小

    • 避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
    • 固定年轻代大小会覆盖暂停时间目标
  • 暂停时间目标不要太过严苛

    • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
    • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛,意味着承受更多的垃圾回收开销,会直接影响到吞吐量

7种经典垃圾回收器总结

image-20240204011218146

垃圾回收器的配置对于jvm优化是一个很重要的选择,选择合适的垃圾回收器可以让jvm的性能有一个很大的提升。怎么选择垃圾收集器呢?

  • 优先调整堆的大小让jvm自适应完成
  • 如果内存小于100m,使用串行收集器
  • 如果是单核、单机程序,并且没有停顿时间的要求使用串行收集器
  • 如果是多CPU,高吞吐量优先,允许停顿时间超过1s,选择并行或者jvm自己选择
  • 如果是多CPU,追求低停顿时间,需快速响应,使用并发收集器,官方推荐G1。

GC日志分析

  • -XX:+PrintGC 输出GC日志
  • -XX:+PrintGCDetails 输出详细的GC日志
  • -XX:+PrintGCTimeStamps 输出GC的时间戳,以基准时间的形式
  • -XX:+PrintGCDateStamps 输出GC的时间戳,以日期的形式
  • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
  • -Xloggc:…/logs/gc.log 输出日志文件到指定文件

GC和Full GC说明此次垃圾收集停顿的类型,使用Serial收集器在新生代的名字是Default New Generation,因此显示的是DefNew,使用ParNew收集器在新生代的名字是ParNew,使用Parallel Scavenge收集器在新生代名字是PSYoungGen,老年代收集和新生代一样,名字也是收集器决定的,使用G1收集器的话,会显示未garbage-first heap。Allocation Failure表明本次引起GC的原因是年轻代中没有足够的空间存储新的数据。

例如:

image-20240204012640889

image-20240204012711011

生成GC日志文件后,还能通过一些日志分析工具比如GCViewer,GCEasy(网页版就有),GCLogViewer等工具查看分析。

  • 26
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值