JVM

JVM

虚拟机与JVM

虚拟机

所谓虚拟机就是一台虚拟的计算机,它是一款软件,用来执行一系列虚拟计算机指令。大体上虚拟机可以分为系统虚拟机和程序虚拟机。

  • 大名鼎鼎的Visual Box,VMware 就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台;
  • 程序虚拟机的典型代表就是java虚拟机,它专门为执行某个单个的计算机程序而设计,在Java虚拟机中执行的指令我们称之为java字节码指令。

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

JVM

java虚拟机是一台执行java字节码的虚拟计算机,它拥有独立的运行机制,其运行的字节码文件也未必一定由java语言编写而成。
JVM平台的各种语言可以共享java虚拟机带来的跨平台性、优秀的垃圾回收器以及可靠的即使编译器。
java的核心技术就是java虚拟机,因为所有的java程序都运行在java虚拟机内部。
java虚拟机就是二进制字节码文件的运行环境,负责装载字节码到其内部,解释/编译为对应平台的机器执行指令。每一条java指令,java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里等。
java虚拟机的特点:

  • 一次编译、到处运行
  • 自动内存管理
  • 自动垃圾回收功能

JVM整体结构
字节码class文件通过类装载器子系统的加载会进入到运行时数据区中,其中,运行时数据区的方法区和堆是所有线程公用的资源,而java栈、本地方法栈和程序计数器是每个线程独有一份的,最后运行时方法区的Class实例会由执行引擎执行。

JVM的架构模型

java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。
具体来说,这两种架构的区别优缺点如下:

  • 基于栈式架构的特点:
    • 设计和实现更简单,适用于资源受限的系统;
    • 避开了寄存器的分配难题:使用零地址指令方式分配;
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器更容易实现;
    • 不需要硬件支持,可移植性更好,更好实现跨平台。
  • 基于寄存器架构的特点:
    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机;
    • 指令集架构则完全依赖于硬件,可移植性差;
    • 性能优秀和执行更高效;
    • 花费更少的指令去完成一项操作;
    • 在大部分情况下,基于寄存器架构的指令集往往都是以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。

由于跨平台性的设计,java的指令都是根据栈来进行设计的。不同平台的cpu架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现相同的功能需要更多的指令。

关于寄存器

百度词条解释:https://baike.baidu.com/item/寄存器/187682?fr=aladdin

JVM的生命周期

1、虚拟机的启动
java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建的一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
2、虚拟机的执行

  • 一个运行中的java虚拟机有着一个清晰的任务:执行java程序;
  • 程序开始的时候它才开始,程序结束他就停止;
  • 执行一个所谓的java程序的时候,真真正正在执行的是一个叫做java虚拟机的进程。

3、虚拟机的退出
有如下的几种情况:

  • 程序正常执行结束;
  • 程序在执行过程中遇到了异常或错误而异常终止;
  • 由于操作系统出现错误而导致java虚拟机进程停止;
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且java安全管理器也允许这次exit或halt操作;
  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API 来加载或者卸载java虚拟机时,java虚拟机的退出情况。

类加载子系统(class loader subsystem)

  • 类加载子系统负责从文件系统中或者网路中加载class文件,每一个class文件都必须按照虚拟机的规范添加特定的文件标识;
  • classLoader只负责class文件的加载,至于它是否可以运行,则有执行引擎(Execution Engine)决定;
  • 加载的类信息存放于一块叫做方法区的内存空间。除了类的信息之外,方法区中还会存放运行时常量池信息,可能还包括字符串常量和数字常量(这部分常量信息是class文件中常量池部分的内存映射)。

类加载子系统执行流程详解

1、加载(loading)

  • 首先类加载子系统会根据类的全限定类名获取定义此类的二进制字节流;
  • 然后将这个字节流所代表的静态存储结构转化为方法区的运行时的数据结构;
  • 最后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。

补充:加载.class文件的途径

  • 从本地系统磁盘中获取;
  • 通过网络获取,典型场景:Web Applet;
  • 通过.zip压缩包获取,成为日后jar、war格式打包的基础;
  • 程序运行时计算生成,例如动态代理技术;
  • 由其他的文件生成,典型场景:JSP;
  • 从专用的数据库中提取.class文件,比较少见;
  • 从加密文件中获取,典型的防Class文件被反编译的保护措施。

2、链接:链接部分主要分为三部分内容

  • 验证(Verify)
    • 验证的目的在于确保Class文件的字节流中包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机的安全;
    • 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
  • 准备(Prepare)
    • 准备阶段会为类变量分配内存并且设置该类变量的默认初始值,即零值,不同数据类型的类变量初始值不同;
    • 分配内存以及赋初值的操作不包含被final修饰的static类变量,因为final修饰的类变量是常量,在编译的时候就会分配内存以及赋予真正的值,即显式的初始化它,并且以后都不会进行修改;
    • 准备阶段不会为实例变量进行初始化,因为类变量是分配在方法区中,而实例变量是会随着对象一起分配到java堆之中。
  • 解析(Resolve)
    • 是将常量池中的符号引用转换为直接应用的过程;
    • 事实上,解析操作往往会伴随着JVM执行完初始化之后再执行;
    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。(可以理解为,我们最简单的java程序也是会引用很多的jdk类库资源的,在实际执行过程中远比我们编写的程序内容要复杂,而对于字节码文件来说,直接将这些引用都解析到文件中也是不现实的,所以会采用符号引用的方式去指向它,这个步骤是将符号引用翻译为能够直接获得到目标的直接引用。)
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等等。

3、初始化

  • 初始化阶段就是执行类构造器方法()的过程;
  • ()不需要定义,是javac编译器自动收集类的所有类变量的赋值动作(例如静态类变量)和静态代码块中的语句合并而来;
  • 构造器方法中的指令按照语句在源文件中出现的顺序执行;
  • ()不同于类的构造器,类的构造器在虚拟机的视角下是();
  • 若该类具有父类,jvm会保证子类的()执行前,父类的()方法已经执行完毕;
  • 虚拟机必须保证一个类的()方法在多线程下被同步加锁,即()方法有且仅会被某个线程执行一次,并且当某个线程在初始化某个类阻塞时,其他线程都会进入阻塞状态。

存疑:()方法只会对于显式赋值的静态类变量进行赋值动作,那么对于非静态的变量是什么时候进行赋值的?在链接的解析部分吗?
解答:针对类的实例成员变量,会在对象创建的时候在堆空间中分配实例变量空间,并进行默认赋值。

类加载器的分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义加载器(User-Defined ClassLoader)。从概念上来讲,自定义加载器指的是间接或直接继承了ClassLoader抽象类的加载器,并不是简单指由开发者自定义的类加载器。

双亲委派机制

JVM对class文件采用的是按需加载的方式,也就是说当需要使用某个类的时候才会将他的class文件加载到内存生成class对象。而且加载某个类的class文件时,JVM采用的是双亲委派机制,即将请求交给父类处理,它是一种任务委派模式。
双亲委派机制工作原理
1、如果一个类加载器收到了类加载请求,它并不会直接进行类的加载,而是把这个加载类的请求委托给自己的父类的加载器去执行;
2、如果父类加载器还存在其他的父类加载器,则进一步向上委托,依次递加,请求最终将到达顶层的启动类加载器;
3、如果父类加载器可以完成类的加载请求,就成功返回,倘若父类加载器无法完成此任务,子加载器才会自己尝试去加载,这就是双亲委派机制。
image.png
双亲委派机制的优势
1、避免类的重复加载,案例如下:
image.png
如上图所示,我们在工程目录src下新建了一个特殊的包名:java.lang。在jdk中java.lang是一个特殊的目录,其中包含了很多的java核心api,包括常用的字符串String类。
那么,当我自己新建一个java.lang目录并创建一个String类的时候,String类的加载会是一个什么情况呢?
事实上,按照双亲委派机制,当系统类加载器(Application Class Loader)接受到类加载请求的时候会持续向上委派请求,最终到达引导类加载器,也就是Bootstrap Class Loader,引导类加载器的职责主要是加载java的核心API,其中就包含String类。
所以,当接受到一个新的String类的加载请求的时候,如果没有加载过则会从jdk的核心包中加载,如果已经加载过则不进行重复加载,所以在此场景中,我们自己创建的String类其实是被忽略,不进行加载的。
由此原因, 当我们执行String类的main的时候,jvm会发现,java核心api包中的String类并没有main方法,由此报错。

2、保护程序安全,防止核心API被随意篡改,案例如下:
image.png
如上图所示,我们仍然是在自定义的java.lang包中随意创建了一个类,当启动加载该类的时候会出现异常:不允许在特殊的包java.lang中创建自定义类。
此方案保护了java核心api的安全,不允许随意的内容对其造成影响。

运行时数据区

image.png
JVM定义了若干种程序运行期间会用到的运行时数据区,其中有一些会随着虚拟器的启动而创建,随着虚拟机的退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区会随着线程的开始和结束进行创建和销毁。
与上方的运行时数据区概览图对应起来,其中深红色的区域为虚拟器启动时就会创建的数据区,所有线程共享,灰色区域则为每个线程都会有自己独立一份的数据区内容。即每个线程独立拥有程序计数器、本地方法栈、虚拟机栈,线程之间共享堆、堆外内存。

程序计数器(PC寄存器)

PC寄存器在运行时数据区中是占用内存非常少的一块区域,也是运行速度最快的存储区域。主要功能是存储指向下一条指令的地址,也就是下一个要执行的指令代码,由存储引擎来读取,然后决定执行什么指令,也是唯一一个在JVM规范中没有规定任何OutOtMemoryError情况的区域。
PC寄存器是每个线程都独立拥有的,其生命周期与线程的生命周期一致。

PC寄存器存在的意义?
由于CPU需要不断的切换工作线程,所以当切换到某个线程的时候就需要知道接下来从什么位置开始继续执行指令,PC寄存器就负责记录当前线程接下来要执行那一条指令内容。

虚拟机栈

每个线程创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用,虚拟机栈的生命周期和线程一致。它的主要职责是主管Java程序的运行,保存方法的局部变量、部分结果,并参与方法的调用和返回。
(虚拟机栈中保存的局部变量需要区分基本数据类型和引用数据类型的变量,虚拟机栈会直接保存基本数据类型的局部变量,但是只会保存引用类型变量的指针,指针指向引用类型变量在堆中的具体位置。)
栈是一种快速有效的分配存储方式,在运行时数据区中,虚拟机栈的访问速度仅次于程序计数器。JVM对于虚拟机栈的操作只有两个,分别是方法执行的入栈、压栈操作和方法执行完毕之后的出栈工作。对于栈来说,不存在垃圾回收的问题。

栈中可能出现的异常
JVM规范允许栈的大小是动态的或者固定不变的。
1、如果采用固定大小的虚拟机栈,那每一个线程的虚拟机栈容量可以在线程创建的时候指定。如果线程请求分配的栈容量超过JVM虚拟机栈所允许的最大容量,JVM会抛出一个StackOverflowError异常;
2、如果虚拟机栈的大小选择动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那JVM则会抛出一个OutofMenoryError异常。

指定虚拟机栈的大小:-Xss
通过-Xss配置可以指定虚拟机栈的大小,此配置是作用于JVM的,可以在启动jar的时候采用命令指定。

栈内部的存储结构

虚拟机栈是在每个线程中独立存在的,栈中的数据都是以栈帧的格式存在。在这个线程中正在执行的每个方法都各自对应一个栈帧,栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈帧的内部结构

image.png
如上图所示,虚拟机栈的每个栈帧都包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息这五个部分。

局部变量表

局部变量表也被称之为局部变量数组或本地变量表。
其结构定义为一个数字数组,主要用于存储方法参数和定义在方法内部的局部变量,这些数据类型包括各类基本数据类型、对象引用以及returnAddress(返回值)类型。由于局部变量是建立在线程的栈上,是线程的私有数据,因此不存在线程安全的问题,
局部变量表所需的容量大小是在编译期间确定下来的,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小的。

关于slot的理解
slot的意思是槽,槽是局部变量表的基本存储单元。
在局部变量表中32位的变量占用一个槽的长度,64位的变量例如long和double占用两个槽的长度。JVM会给局部变量表中的每一个槽分配索引,用于访问局部变量表中的每个值,如果访问的目标是一个占据两个槽位的64位变量,则只需要根据数据的起始槽的索引就可以访问到。
如果局部变量表所在的当前栈帧是非静态的,也就是通过构造方法创建的,通常在这种方法中都可以使用创建其的对象信息来进行一些操作,也就是this打点调用各种内容。针对这种栈帧,其内部的局部变量表会出现一个this的变量,也就表示该对象的引用,存放在index为0的槽位中。
slot是可以重复利用的,如果在一个栈帧中,某个局部变量在执行过程中结束了其作用域,那么在其作用域之后声明的变量就很有可能复用其槽位,从而达到节省资源的目的,如下图所示,变量c就复用了变量b的槽位:
image.png

操作数栈

操作数栈是一个用数组实现的栈结构,同时拥有栈和数组的个性特征,也称之为表达式栈。
在方法执行的过程中,操作数栈会根据字节码指令忘栈中提取或写入数据,执行复制、交换、求和等操作。操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈也是JVM执行引擎的一个工作区,当一个栈帧被创建的时候,如果没有进行任何的计算,则操作数栈就为空。每一个操作数栈都有一个明确的栈深度用于存储数值,其所需的最大深度在编译期间就存在,保存在Code属性中,为max_stack的值。
栈中元素可以是任意的java类型,32位的元素占据一个栈深度单位,64位的元素占据两个栈深度单位。
虽然操作数栈同时拥有栈和数组的个性特征,但是实际上对于元素的访问不是按照数组索引来获取的,而是按照栈的出栈、压栈的行为。

动态链接

在java源文件被编译成为字节码文件的时候,所有的变量和方法引用都会被编译为符号引用的形式保存在字节码文件的常量池中,而动态链接就是用来记录这些符号引用的,从而能够通过常量池来完成引用对象之间的调用。例如在方法A中调用了方法B,其实是方法A通过方法B的符号引用来找到常量池中的真正的方法B,在此过程中动态链接的作用就是将这些符号引用转换为调用方法的直接引用。

本地方法栈

虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈的特性与虚拟机栈大多相同,唯一不同的是其中管理的是涉及到本地方法的调用过程。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,此时它和虚拟机拥有同样的权限。
本地方法可以直接通过本地方法接口来访问虚拟机内部的运行时数据区,甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存。并不是所有的Java虚拟机都支持本地方法,在Hotspot虚拟机中,直接将本地方法栈和虚拟机栈合二为一。

堆空间

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。堆在JVM启动的时候就被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间,堆的大小也是可以设置调节的。
Java虚拟机规范规定堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程都共享Java堆,在这里还可以划分线程私有的缓冲区。
在虚拟机运行期间,所有的运行时实例和数组都应当分配在堆上。数组和对象实例可能永远都不会存储在栈中,栈帧中只需要保存引用,引用指向对象或者数组在堆中的位置。在方法结束之后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。堆是垃圾回收器执行垃圾回收的重要区域。

堆中的内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间的内存细分在不同的jdk版本中也会有不同:
1、java7之前的堆内存逻辑上分为三个部分:新生区+养老区+永久区;
2、java8之后的堆内存逻辑上分为三个部分:新生区+养老区+元空间

年轻代与老年代

image.png
堆区一般分为年轻代和老年代两个区域,其中年轻代更细分可以分为伊甸园区(Eden)、幸存者0区(Survivor0)、幸存者1区(Survivor1),幸存者0区和幸存者1区也被称之为from区和to区。
对象存储哪个区域是根据对象的生命周期来决定的,如果垃圾回收器在堆伊甸园区进行垃圾回收的时候发现部分对象仍未死亡,则其会被放置在幸存者0或者幸存者1区,如果幸存者0或者幸存者1区进行垃圾回收的时候发现部分对象仍然未死亡,则对象会被放置在老年代中。

年轻代和老年代的堆内存占比配置
可以通过虚拟机的配置参数来决定年轻代和老年代在堆中的内存分布配比,默认的大小为:
-XX:NewRatio=2
该配置为默认的配置,表示新生代在堆内存中1,老年代占2,新生代占整个堆空间的三分之一。
如果修改为如下:
-XX:NewRatio=4
则表示新生代在堆内存中占1,老年代占4,新生代占整个堆空间的五分之一。
关于年轻代的内存配置问题,还可以通过-Xmn参数来直接指定年轻代的最大内存数值,当同时使用了-Xmn和-XX:NewRatio进行配置的时候,-Xmn
的优先级更高。

在HotSpot虚拟机中,伊甸园区和另外两个幸存者区在年轻代中的内存占比为8:1:1,可以通过如下配置来设置这个空间占比比例:
-XX:SurvivorRatio=8

堆空间中的对象分配过程
1、new的对象最先出现在伊甸园区,伊甸园区的大小有限;
2、当伊甸园区的空间被对象填满的时候,程序又需要创建新的对象,则会触发垃圾回收器对伊甸园区进行垃圾回收(Minor GC),垃圾回收器会将伊甸园区中不再被其他对象所引用的对象进行销毁,再加载新的对象到伊甸园区中;
3、在此次垃圾回收结束之后,如果伊甸园区中存在未被回收的对象,则会给它赋予一个年龄计数,并将其转移到幸存者0区;
4、如果伊甸园区再次触发垃圾回收,此时如果上次幸存者0区中的对象仍然存活,则会将幸存者0区清空,将其所有内容转移到幸存者1区。所以由此可以说明幸存者区永远有一个是空的,此操作循环进行,对象移动的目标区被称之为to区,对象来源的幸存者区被称之为from区 ,from区和to区也是动态的;
5、如果生存在幸存者区中的对象的年龄计数达到了默认阈值15,则会将其转移到老年代,年龄计数会在每次GC后新增,可以通过参数-XX:MaxTenuringThreshold来查看和设置。
image.png
(上图除了正常的伊甸园区->幸存者区->老年代的情况之外,还描述了面对巨大对象的时候也存在从伊甸园区直接到达老年代的情况和巨大对象直接从幸存者区到达老年代而不用判断其年龄计数的情况。)

堆空间大小的配置

堆空间的大小在虚拟机启动的时候就已经确定,我们也可以通过配置虚拟机的启动参数来设置其堆空间的大小:
1、-Xms:用于表示堆区的起始内存;
2、-Xmx:用于表示堆区的最大内存。
(一般当堆中的内存使用大于-Xmx的值的时候,将会抛出OutOfMemoryError异常。)
通常情况下会将-Xms和-Xmx设置为相同的值,其目的是为了能够在垃圾回收器清理完堆区后不需要重新分隔计算堆区的大小,从而提升性能。
默认情况下:
1、-Xms=物理电脑内存大小/64;
2、-Xmx=物理电脑内存大小/4。

关于Minor GC、Major GC和Full GC

虚拟机在进行垃圾回收的时候,并非每次都针对新生代、老年代、方法区空间进行统一内存回收,大多数时候是针对新生代。
按照HotSpot的虚拟机实现,其按照GC回收区域将GC区分为两大类型:部分收集(Partial GC)和整堆收集(Full GC)。
1、部分收集指的是不是针对整个Java堆空间的内存收集,其中又分为:
a):新生代收集(Minor GC/Young GC):只是新生代的垃圾收集;
b):老年代收集(Major GC/ Old GC):只是老年代的垃圾收集;
(目前只有CMS GC会有单独收集老年代的行为。)
c):混合收集(Mixed GC):针对整个年轻代和部分老年代的垃圾收集;
(目前只有G1 GC会有混合收集的行为。)
2、整堆收集(Full GC):针对整个java堆和方法区的垃圾收集。

Minor GC的触发机制
当年轻代的内存空间不足的时候会触发Minor GC,这里的年轻代的内存空间不足指的是Eden,Survivor区的内存空间不足不会触发Minor GC,但是每次的Minor GC都会清理年轻代的空间,包括整理Survivor区。
Minor GC会引发STW现象,暂停其他的用户线程,直到垃圾回收工作完成,用户线程才恢复运行。
(STW:stop to world,指的是垃圾回收算法执行期间,将JVM内存冻结、应用程序停顿的一种现象。)

Major GC的触发机制
当老年代的空间不足的时候会触发Major GC,一般情况下,Major GC的发生会伴随至少一次的Minor GC,也就是说当老年代空间不足的时候,会先尝试进行Minor GC,如果Minor GC之后仍然空间不足,则尝试进行Major GC,如果Major GC之后仍然空间不足则会出现OOM异常。
Major GC同Minor GC相比效率慢十倍以上,这意味着更长时间的STW暂停。
(一般来讲,Major GC的发生会伴随至少一次的Minor GC,但非绝对的,在Parallel Scavenge收集器的收集策略中就存在直接进行Major GC的策略。 )

关于TLAB(Thread Local Application Buffer)

TLAB的含义为线程的私有缓存区域,因为堆区是所有线程共有访问的区域,所以如果面临多线程的情况就会出现线程安全问题。对此,JVM设计了TLAB的一块线程私有的区域来解决这个问题,它包含在Eden区中。
在分配内存的过程中,虽然并不一定所有的对象实例都能够在TLAB中完成内存分配,但是如果可以的话,虚拟机会将TLAB作为对象实例内存分配的首选,一旦对象在TLAB中分配内存失败,则虚拟机会尝试使用加锁机制来保障数据操作的原子性,并从Eden中分配空间。
可以使用-XX:UseTLAB来设置是否开启线程私有区域,默认情况是开启的,并且每一个线程私有区域仅占有Eden的1%,也可以通过选项-XX:TLABWasteTargetPercent来设置TLAB空间所占用Eden空间的百分比值。
(TLAB是堆内存为了保证线程安全所做的一个内存分配策略,与多线程安全的Volatile不同,Volatile是解决CPU从堆区中读取数据过程中的缓存问题,即CPU并不会每次都从堆空间中读取数据,中间也可能经过缓存空间,最终导致数据的不一致性。释疑资料:https://www.zhihu.com/question/494445759。)
image.png

关于堆空间相关的参数配置

  • -XX:+PrintFlagsInitial:查看所有参数的默认初始值;
  • -XX:+PrintFlagsFinal:查看所有参数的最终值(参数可能会被修改);
    • jinfo -flag SurvivorRatio(JVM参数名) 进程Pid:查看运行中的某个进程的某个指定的JVM当前值;
  • -Xms:初始堆空间内存(默认为物理内存的1/64);
  • -Xmx:最大堆空间内存(默认为物理内存的1/4);
  • -Xmn:设置年轻代的大小;
  • -XX:NewRatio:设置年轻代与老年代在堆空间中的占比;
  • -XX:SurvivorRatio:设置年轻代中Eden与S0/S1的空间占比;
  • -XX:MaxTenuringThreshold:设置年轻代中对象的最大年龄(达到阈值会转移到老年代);
  • -XX:PrintGcDetails:输出详细的GC日志;
    • -XX:+PrintGC:输出简易的GC日志;
    • -verbose:gc:输出简易的GC日志;
  • -XX:HandlePromotionFailure:是否设置空间分配担保。

关于空间分配担保参数的解释:
在发生Minor GC 之前,虚拟机会检查老年代的空间是否大于年轻代的所有对象的总空间,如果大于,那么此次的Minor GC是安全的。如果不大于,则需要查询空间分配担保是否开启,也就是-XX:HandlePromotionFailure是否为true。如果为true,则会判断老年代的剩余空间大小是否大于历次晋升到老年代的对象的平均内存大小,如果大于则尝试进行Minor GC,但是此次GC仍然是有风险的。如果小于,则直接进行Full GC,如果在上一次判断中发现没有开启空间分配担保,则也直接进行Full GC。

方法区

方法区和堆一样,是各个线程共享的内存区域,方法区在JVM启动的时候被创建,并且它同堆一样,允许其使用的物理内存空间是不连续的。方法区的大小是动态拓展的,也可以使用参数设定为具体的值。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,同样可能会造成方法区的内存溢出,出现OOM异常。
在Java7之前,方法区的落地实现为永久代,永久代属于堆空间的一部分,使用的是JVM的划分内存空间。而Java8之后,HotSpot虚拟机废除了永久代的概念,由原空间来表示方法区的实现,并且独立于堆内存之外,是直接使用本地物理内存来运行的区域,并且在组成结构上也不同。
在Java8之后,通过-XX:MetaspaceSize可以查看和设置元空间的初始内存大小,由于元空间是直接使用本地内存的,所以初始内存大小与本地机器的操作系统有关,例如在Windows平台下的初始大小为21M。元空间的最大内存大小可以通过-XX:MaxMetaspaceSize来查看,默认值是-1,即没有限制,直到耗尽本地内存为止,如果耗尽了本地内存空间仍然无法满足程序类的加载,则会出现OOM异常。
-XX:MetaspaceSize表示元空间的初始内存大小,也代表着一个初始的高水位线,当触发这个水位线,就会触发一次Full GC。Full GC会卸载掉无用的加载类,即这些类对应的类加载器不再存活,然后这个高水位线则会被重置。新的高水位线取决于上一次Full GC释放了多少的空间,如果上一次GC释放的空间不是非常多,也就意味着剩余类的大小仍然可能比较接近高水位线,则会适当提高MatespaceSize,如果上一次GC释放的空间比较多,则意味着剩余类的大小较低与高水位线,则可能会适当降低MatespaceSize。由此可以得知,如果MatesoaceSize的值设置的较低,则可能会触发多次的Full GC,为了避免GC频繁的情况,可以考虑通过配置将初始值设置得略高。
方法区主要存储已被虚拟机加载的类型信息(class、enum、interface)、常量、静态变量、即时编译器编译后的代码缓存等。
方法区中还有另外一个非常重要的区域就是运行时常量池。运行时常量池对应的是Java文件在编译成class文件之后产生的常量池表,常量池表表示了该Java文件中的所有常量和引用变量的符号引用,而在class文件进行类加载器加载之后,在虚拟机运行期间就会成为方法区中的运行时常量池,运行时常量池与字节码文件的常量池表最大的不同是,常量池表中的引用变量仅为可视的符号引用,表示该变量最终会引用的具体类型,而运行时常量池中的引用变量会变成真正的运行时引用地址。

为什么要将永久代替换为元空间?
永久代和元空间都被视为方法区的落地实现,在jdk8中开始彻底抛弃永久代的概念,主要的优点在于,永久代是基于虚拟机内存的,保存在堆空间中的区域,在频繁大量的类加载场景下,容易频繁的产生Full GC甚至产生OOM。而元空间是基于本地物理内存的,理论上会不停扩容直到物理内存不足为止,产生OOM的情况比永久代少。
同时,取消元空间也是Oracle将HotSpot虚拟机与JRocket虚拟机融合的一步,因为JRocket虚拟机无永久代区域。

为什么要将字符串常量池保存在堆空间中?
字符串常量池是保存字符串类型的常量的区域,在永久代被取消之后,字符串常量池并没有划分到元空间中,而是同静态常量一起被划分到堆空间。原因也是因为元空间和永久代的GC频率较低,只有老年代满或者永久代满的时候触发Full GC的情况下才会产生GC,然而字符串是程序中使用频率较高的变量,GC频率较低的话会导致字符串常量池中冗余过多的已结束生命周期的变量。放在堆空间中就是为了更高的GC频率,及时清理过期的字符串常量 。

内存中的对象

对象的实例化

创建对象的步骤
1、判断对象的类是否加载、链接、初始化;
虚拟机在接受到new对象的指令的时候,首先会根据指令的参数去元空间中寻找指令对应的符号引用是否存在,并确定符号引用对应的类是否被加载、链接、初始化(即类元信息是否存在)。如果类元信息不存在,则会参照双亲委派机制查找类的class文件进行加载,如果找不到对应的class文件则会抛出ClassNotFoundException异常,如果找到则进行类的加载并生成对应的实例。

2、为对象分配内存;
首先会计算对象占用内存大小,并在堆空间中给对象划分一块内存。对象的成员变量类型不同占用的内存空间也不同,基本数据类型按照基本数据类型本身需要的大小分配,例如int占据四个字节,double占据8个字节,而其他引用类型保存的是引用地址,占四个字节。
在分配内存的过程中,如果堆内存空间是规整的,那么虚拟机是采用的指针碰撞法来给对象分配内存。指针碰撞法即存在一个指针将已使用的内存空间和卫未使用的内存空间分隔开,新的对象分配了内存空间之后,指针会对应的移动一个新的对象内存大小的距离。一般情况下如果垃圾回收器是Serial、ParNew这种标记压缩算法,带有整理过程则会采用此种方式。
如果堆空间是不规整的,已使用的内存和未使用的内存相互交错,那么虚拟机会采用空闲列表法来为新对象分配内存。空闲列表法指的是虚拟机维护了一个列表,列表中记录了哪块内存空间是可用的,哪块是不可用的。给新对象分配空间的时候则从空闲列表中找到一个足够大的区域,然后更新列表。
选择何种分配方式由java堆空间是否规整决定,而java堆空间是否规整由采用何种的垃圾回收算法决定。

3、处理并发安全问题;
采用CAS机制,区域加锁来确保原子性,并采用TLAB给每个线程创建的对象赋予一个独立的缓存空间。
(存疑:TLAB和CAS加锁是并存的吗?对象创建的并发问题从何讨论?)

4、初始化分配到的空间;
指的是给对象成员变量赋予默认值的行为,确定对象成员变量在没赋值的情况下可以使用。

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

6、执行方法进行对象初始化。
方法是对象进行成员变量显示赋值的过程,在这个过程中会对直接显示赋值的成员变量进行赋值,覆盖其默认值,并且会执行代码块和构造方法。
image.png

对象的内存布局

在实例化好的对象内部,主要分为三个部分数据:对象头、实例数据、对齐填充。
对象头的内容大概分为两个部分:
1、运行时元数据:哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳;
2、类型指针:指向方法区中的类元数据,用来确定该的对象的类型。
(如果实例化对象是数组,还会记载数组的长度。)
实例数据则为对象真实的有效信息,包括从父类继承的所有的成员变量和类自己的成员变量信息。

执行引擎

虚拟机是一个相对于物理机的概念,这两种者都有代码执行的能力。其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统上的,而虚拟机的执行引擎则是由软件自由实现的,因此可以不受物理条件制约的制定指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装在字节码文件到内部,但字节码并不能直接运行在操作系统之上,字节码仅是能够被java虚拟机所识别的一个文件类型。所以在此JVM执行引擎的任务就是将字节码指令解释、编译为对应平台的本地机器指令。

关于解释器和即时编译器

解释器:当Java虚拟机启动的时候会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行。
JIT即时编译器:虚拟机通过即时编译器将源代码直接编译成和本地机器平台相关的机器语言。

为什么有执行效率更高的即时编译器之后HotSpot仍然需要解释器?
解释器和即时编译器相比的优势在于其可以在虚拟机启动的时候就立即助逐行翻译并执行字节码指令,即时编译器虽然在源代码编译完成之后执行速度非常快但是在字节码整体编译为机器语言这个过程中需要一定的时间,所以如果只采用即时编译器的话,会虽然执行效率很高,但是程序启动会因为整体编译转换会变得更慢。
所以当HotSpot虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成之后再启动,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器就逐渐发挥作用,根据热点探测功能,将有价值的字节码指令编译为机器指令,以换取更高的程序执行效率。

JIT编译器

JIT编译器就是即时编译器,由上面的内容可知HotSpot是兼备解释器和JIT编译器的,那么在程序运行时,如何进行是采用解释器还是JIT编译器这个选择呢?
是否采用JIT编译器来将代码整体编译为机器指令要根据代码被调用的频率决定,那些频繁被调用的代码被称之为热点代码,JIT编译器会针对热点代码进行深度优化,将其直接编译为本地机器指令,以提高程序的执行性能。
一般来讲,一个被多次调用的方法或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码。然而,一个方法究竟被调用多少次才会被判定为热点代码呢?这里主要采用热点探测功能,HotSpot虚拟机所采用的热点探测方式是基于计数器的热点探测。HotSpot会为每一个方法都建立两种类型的计数器,分别为方法调用计数器和回边计数器,其中方法调用计数器用于统计方法的调用次数,回边计数器则用于统计循环体执行的循环次数。

方法调用计数器
方法调用计数器用于统计方法被调用的次数,它的默认阈值在Client模式下是1000次,在Server模式下是10000次。超过这个阈值就会触发JIT编译,该阈值可以通过-XX:CompileThreshold来设定。
当一个方法被调用的时候会先查询是否有被JIT编译器编译过的版本,如果有则优先使用编译后的结果来执行,如果没有则将方法调用计数器+1,如果计数器数值达到阈值,则会向即时编译器提交一个该方法的编译请求 。

在HotSpot虚拟器中其实内置了两个JIT编译器,分别为Client Compiler和Server Compiler,简称为c1、c2编译器,可以通过指令来配置具体使用哪种编译器:
1、-client:指定虚拟机使用c1编译器,c1编译器会对字节码进行简单可靠的优化,耗时短,以达到更快的编译速度;
2、-server:指定虚拟机使用c2编译器,c2编译器的优化更激进,耗时更长,但是优化后的代码执行效率更高。

image.png

关于方法调用计数器的热度衰减
如果不做任何设置,方法调用计数器统计的并不是某个方法被调用的绝对次数,而是在一个相对执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法调用的次数仍不足以将其交给JIT编译器进行编译,这个方法的计数器的值就会减少一半,这个过程被称为方法调用计数器的衰减,而这段时间则被称之为此方法统计的半衰周期。
热度衰减的动作是虚拟机在进行垃圾收集的时候顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法调用计数器统计方法
调用的绝对次数。如此,只要程序运行的时间足够长,所有的方法都将被JIT编译器所编译。
另外,也可以使用-XX:CounterHalfLifeTime设置半衰周期的时间,单位是秒。

命令配置执行引擎的工作模式

HotSpot默认情况是解释器和即时编译器混合运行的,但是开发者也可以通过显示的设置来决定执行引擎采用何种工作模式:
1、-Xint:完全采用解释器执行程序;
2、-Xcomp:完全采用JIT编译器执行程序,如果即时编译出现问题,解释器会介入;
3、-Xmixed:采用解释器+即时编译器混合运行。
image.png

String

String是被final修饰的类,是不可被继承的,同时也是不可变的。
其不可变性可以理解为:当给一个字符串重新赋值时,需要重新指定内存赋值,不能使用原有的String中的value进行赋值;当对现有的字符串进行连接、replace()等操作的时候也需要重新指定内存赋值,不能使用原有的String中的value进行操作。
字符串常量池中是不会存储相同的字符串的。
String的String Pool是一个固定大小的HashTable,默认的大小长度为1009。如果String Pool中的String非常多,就会造成Hash冲突严重,从而导致链表非常长,链表长造成的直接影响就是当调用String.intern时性能会大幅下降。
在jdk1.6中,StringTable的长度是固定的1009,所以当String过多的时候性能下降严重。jdk7中StringTable的默认长度是60013,jdk8以后1009是可以设置的最小值,使用配置参数-XX:StringTableSize可以设置StringTable的长度。
字符串常量池在jdk6的版本是存放在永久代中,在jdk7以后存放在堆空间中,也是为了让字符串常量池拥有更大的内存空间和可以参与到GC中。

关于String.intern()
String.intern()会将目标字符串显式的写入到字符串常量池中,并返回字符串在常量池中的引用地址。如果目标字符串已经存在于常量池中,则直接返回引用地址。

关于字符串拼接
这里说的字符串拼接是String str = "a" + "b";这种形式,最终的结果会保存在字符串常量池中,并且采用了编译期拼接的方式,也就是说,字符串拼接其实在代码前端编译的时候就已经完成。所以,String str = "a" + "b";等同于String str = "ab";
如果字符串拼接的成员中包含变量,则底层是采用StringBuilder来实现的,实质上相当于在堆空间中新建了一个String对象,重新开辟了一个空间,此时其引用地址与字符串常量池中相同内容的字符串是不同的。

String str = "a";
String appendStr = str + "b";

System.out.println(appendStr == "ab");  //false
System.out.println(appendStr.intern() == "ab");  //true

// intern()会将字符串的内容写入到字符串常量池中,并返回地址,
// 如果该内容已经在常量池中存在,则直接返回地址,所以appendStr.intern() == "ab"的结果为true

如果字符串拼接中的成员变量都是被final修饰的,也就是说相当于常量,则其情况与字面量直接拼接的形式相同。

final String str = "a";
String appendStr = str + "b";

System.out.println(appendStr == "ab");  //true

面试题

new String("ab");实际上在内存中创建了几个对象?
答:两个,分别是new关键字在堆空间中创建的String的对象实例,另外一个指的是字符串"ab"在堆空间中的字符串常量池中的创建过程。
new String("a") + new String("b");实际上在内存中创建了几个对象?
答:六个,new String("a")和new String("b")和上一个问题的本质相同,都分别产生了一个String的实例对象和字符串在字符串常量池中的对象,总共四个。然后这两个对象之间又进行了字符串的拼接操作,当有变量参与的字符串拼接操作本质上是使用了StringBuilder的append()来实现的,并且在拼接完成之后会调用toString(),toString()内部又会重新创建一个字符串实例,所以加上StringBuilder和toString()中创建的实例对象总共为6个。(并且,StringBuilder的toString()创建了String实例之后是不会在字符串常量池中创建对象的,具体可以根据字节码指令来观察是否有ldc的操作。)
代码解析

    public static void main(String[] args) {
        String ab = new String("ab");
        ab.intern();
        String newAb = "ab";
        System.out.println(ab == newAb);  // false

        String reAb = new String("a") + new String("b");
        reAb.intern();
        String newReAb = "ab";
        System.out.println(reAb == newReAb);  // true
        
    }

解析上述案例中的不同情况产生的原因:
1、为什么StringBuilder.toString()不会在字符串常量池中创建对象,而new String("ab")会。
我们知道使用字面量的方式也就是String str = "ab";会在字符串常量池中创建对象,所以,new String("ab")也是同理,由于构造函数的入参中包含字面量,所以也会在字符串常量池中创建对象,但是StringBuilder.toString()则不会,其原因是因为它是通过char[]来创建的字符串,没有字面量,则不会在字符串常量池中创建对象。
所以,在第一个案例中,ab的引用是String对象在堆中的对象引用地址,newAb是字符串"ab"在字符串常量池中的引用地址,所以为false。
2、为什么第二个案例为true?
这里就需要明白intern()的第二个特性:如果发现目标字符串在堆中的某个字符串实例实体中存在,则不会多余的在字符串常量池中创建对象,而是直接保存堆中这个字符串实例的引用。
所以,在第二个案例中,reAb.intern()会发现在堆中有一个同样内容的字符串实例reAb,则会在字符串常量池中保存reAb的引用地址,然后newReAb期望创建一个"ab"的字面量,但是这个字符串已经在字符串常量池中存在,所以newReab其实获取到的是reAb的引用地址,所以为true。
此外,intern()的特性在不同的jdk版本中也存在不同。在jdk6的版本,由于字符串常量池存放在永久代中,所以如果intern()发现堆中存在与自己内容相同的对象实体,会将这个实体的内容赋值一份并写入在字符串常量池中,相当于冗余的操作。而在jdk7及以后的版本,字符串常量池保存在堆空间中,所以就只保持与自己相同内容的对象实例的引用,避免了内容冗余。

垃圾回收

概述

什么是垃圾呢?垃圾是指在运行程序中没有任何指针指向的对象
在堆空间中放着几乎所有的Java实例对象,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活的对象,哪些是已经死亡的对象。只有被标记为已死亡的对象才会被垃圾回收的时候释放其所占用的内存空间,因此这个阶段我们称之为垃圾标记阶段。对于JVM来说,当一个对象不被任何存活的对象继续引用时,就可以宣判为已经死亡。
在垃圾标记阶段,判断对象是否存活一般有两种方式:引用计数算法可达性分析算法

垃圾回收算法

引用计数算法

引用计数算法即对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器的值就加一;当引用失效则减一,只要对象的引用计数器的值为0,则表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识,判定效率高,回收没有延迟性。
缺点:
1、该方式需要单独的字段存储计数器的值,增加了存储空间的开销;
2、每次引用的变化都需要更新计数器,随着加减法的操作,代表着会增加时间开销;
3、无法处理循环引用的问题,该致命缺陷导致在Java垃圾回收器中没有使用该类算法。
关于循环引用问题:
image.png
如上图所示,在线程的对象引用中存在如上的循环引用情况。实例对象object是流程对象引用的开端,在使用引用计数器的情况下,实质上object已经指向了null,线程中的工作可能已经完成,但是其流程中的循环引用会导致部分对象的计数器永不为0,造成对象无法被回收的内存泄漏情况。

可达性分析算法

可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索并判断被根对象集合所连接的目标对象是否可达,根对象集合意思为一组必须活跃的引用。使用可达性分析之后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路被称之为引用链。如果目标对象没有任何引用链相连接,则是不可达的,意为该对象已经死亡,可以标记为垃圾对象。
在可达性算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象。
在JVM中,GC Roots包括如下几类元素:
1、虚拟机栈中引用的对象,比如各个线程被调用的方法中使用到的参数、局部变量等;
2、本地方法栈内引用到的对象;
3、方法区中类静态属性引用的对象,比如类的静态引用类型变量;
4、方法区中常量引用的对象,比如字符串常量池中的引用;
5、所有被同步锁synchronized持有的对象;
6、JVM的内部引用,例如基本数据类型对应的Class对象,一些常驻的异常对象(如NullPointerException、OutOfMemoryError),系统类加载器;
7、反映JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等。
(如果一个指针它指向了堆内存中的对象,但是自己又并不是存放在堆中,则可以认为是一个GC Root。)
在使用可达性分析算法进行垃圾标记的过程中,分析工作必须在一个能保证一致性的快照中进行,这点不满足的话就无法保证分析结果的准确性。这也是导致GC时会产生"Stop The World"的一个重要原因。

对象的finalization机制

java语言提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有一个引用指向一个对象,在回收该对象之前总会调用该对象的finalize()。finalize()允许在子类中被重写,用于对象在回收时进行资源释放。通常在这个方法中执行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的finalize(),应该交给垃圾回收器调用,理由如下:
1、在finalize()调用的时候可能会导致对象复活;
2、finalize()的执行时间是没有保障的,完全由GC行为决定,极端情况下如果不发生GC则有可能一直不会执行finalize();
3、一个糟糕的finalize()会影响GC的性能。
由于finalize()的存在,JVM中的对象存在三种不同的状态。如果某个对象是从所有根节点都无法访问到的,则说明该对象不再被使用,可以进行回收。但是事实上,这个对象也并不绝对被回收,
这个时候他们处于缓刑阶段。一个无法触及的对象可能在某种条件下复活自己,如果这样,那么对它的回收则是不合理的。
为此,虚拟机中对象的三种状态有如下定义:
1、可触及的:从根结点开始可以到达的对象;
2、可复活的:对象的所有引用都被释放,但是对象可能会在finalize()中被复活;
3、不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态,不可触及状态的对象不可能被复活,因为finalize()只会被调用一次,只有对象在不可触及的状态时才会被回收。
判断一个对象是否可回收,至少要经过两次标记过程:
1、如果对象到GC Roots没有引用链,则进行第一次标记;
2、判断该对象是否需要执行finalize():
a)、如果对象没有重写finalize(),或者finalize()已经被虚拟机调用过,则虚拟机视为没有必要执行,该对象会被判定为不可被触及的;
b)、如果对象重写了finalize()且还未被执行过,那么对象会被插入到F-Queue队列中,由一个虚拟机自动创建的、优先级低的Finalizer线程触发其finalize();
c)、finalize()是对象逃离死亡的最后机会,稍后GC会对F-Queue中的对象进行二次标记,如果对象在finalize()中与引用链上的任何一个对象建立了联系,那么在第二次标记时,该对象会被移出即将回收的集合。之后,对象如果再次出现没有引用存在的情况,finalize()不会被第二次调用,对象会直接变为不可触及状态,也就是说finalize()只会被调用一次。

finalization机制实例

package com.xsh.jvm;

/**
 * 模拟对象在finalization过程中的复活
 */
public class ReviveObjTest {

    /**
     * GC Root(类的静态引用类型变量)
     */
    public static ReviveObjTest obj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize()被执行");
        // 使即将被回收的obj对象与引用链中的对象产生关系
        obj = this;
    }

    public static void main(String[] args) throws Exception {
        obj = new ReviveObjTest();
        obj = null;
        System.out.println("第一次垃圾回收");
        System.gc();

        // 线程休眠是因为执行finalize()的线程优先级低,避免对象未复活程序就执行完毕了
        Thread.sleep(2000);
        if (obj == null) {
            System.out.println("obj以死亡");
        } else {
            System.out.println("obj还活着");
        }
        
        // 第二次垃圾回收obj已死亡,因为finalize()只会被执行一次
        obj = null;
        System.out.println("第二次垃圾回收");
        System.gc();
        Thread.sleep(2000);
        if (obj == null) {
            System.out.println("obj以死亡");
        } else {
            System.out.println("obj还活着");
        }
    }
}

当在标记阶段通过标记算法确认了哪些对象是需要回收的垃圾对象之后,GC接下来的任务就是进行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的内存空间为新对象分配内存。
目前在JVM中比较常用的三种垃圾回收算法是:标记清除算法、复制算法、标记压缩算法。

标记清除算法(Mark-Sweep)

标记清除算法的执行过程:
当堆中的有效内存空间耗尽的时候,就会停止整个程序,即Stop the world,然后进行垃圾对象的标记和清理工作。第一项工作为标记,第二项则是清除。
标记工作是从GC ROOT根结点开始遍历,标记所有可达的被引用的对象,一般是在对象的头信息中记录标记信息。清除工作则是在标记工作之后,对堆内存中的所有对象进行线性的循环遍历,如果发现某些对象如果没有被标记,那么则说明其从根节点不可达,为垃圾对象,进而对这些不可达的对象进行回收。

标记回收算法的缺陷在于其回收效率并不算高,并且回收的内存空间是不规整的,会产生内存碎片。面对不规整的堆内存空间的时候,给新的对象分配内存就无法使用指针碰撞的方式,而是维护一个空闲列表,碎片化的内存空间在面对大对象的内存空间分配的时候也会面临瓶颈。(新对象内存空间分配方式参照第四节笔记内容。)
注意,在此对于垃圾对象内存的清除并不是说直接清除掉内存空间,而是将垃圾对象的内存空间记录在空闲内存地址列表中,如果下次有新的对象需要分配内存空间的时候,就直接覆盖垃圾对象所在的内存区域。

复制算法(Copying)

复制算法的核心思想是将内存空间分为两块,每次只采用其中一块,在垃圾回收的时候将存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。(堆空间的幸存者0和幸存者1区就采用了这种回收算法。)
复制算法相对于标记清除算法没有标记和清除的过程,实现简单,性能高校。并且将存活对象复制到空闲空间之后可以保证剩余内存空间是规整的,不会出现碎片问题。该算法的缺点也是显而易见的,其需要两倍的内存空间才能完成工作。而且如果内存中垃圾对象非常少的情况,就会导致复制存活对象到空闲空间这个操作需要复制的对象非常多,其性能上的问题也会随之产生,不仅是复制对象这个工作,同时对象内存空间变化需要对这些对象在其他位置的引用也跟着重置,会产生更多的性能开销。也是因为这个原因,所以HotSpot虚拟机会在新生代的幸存者0和幸存者1区采用复制算法,因为新生代的对象大多都是朝生夕死的。

标记压缩算法(Mark-Compact)

复制算法的高效性是建立在存活对象少、死亡对象多的场景下,所以更加适合年轻代而不是老年代。针对老年代的空间回收,标记清除算法固然可行,但是由于其会产生碎片内存空间的问题,在其基础之上进行改进才产生了标记压缩算法。
标记压缩算法执行的第一阶段同标记清除算法相同,也是从根节点开始遍历并标记所有被引用的对象。第二阶段则是将所有的存活对象压缩到内存的一端,按顺序排放。之后清理边界之外的内存空间,这样就形成了一个针对内存碎片化问题的解决,实现了碎片空间的整理。
标记压缩算法相对于标记清除算法和复制算法而言,优化了会碎片内存空间的问题,并且不需要像复制算法一样需要二倍内存空间。但是其性能相比复制算法和标记清除较差,会产生更长时间的Stop the world。

增量收集算法

增量收集算法的产生主要是针对上述的算法在进行垃圾回收器的执行过程中会将程序处于Stop the world的状态, 增量收集算法会让垃圾回收线程和应用程序线程交替执行。每次垃圾回收线程只收集一小片区域的内存空间,接着切换到应用线程,依次反复直到垃圾回收完成,这样的操作相当于减少了一次性的程序暂停时间。
总的来说,增量收集算法的基础仍然是传统的标记清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾回收线程以分阶段的方式完成标记清理或复制工作。
增量收集算法通过切换线程的方式减少了每一次的系统停顿时间,但是由于线程切换和上下文转换的消耗,会导致垃圾回收的总体成本的上升,造成系统吞吐量的下降。

分区算法

一般来说,在相同条件下,堆空间越大一次GC所需要的时间就越长,有关GC产生的停顿也就越长。为了更好的控制GC产生的停顿时间,将一个大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小空间,而不是整个堆空间,从而减少一次GC所产生的停顿。

对象的引用

在jdk1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这四种引用的强度依次减弱。

1、强引用(StrongReference)
最传统的引用的定义,是指在程序代码中普遍存在的引用赋值,即类似"Object obj = new Object()"这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾回收器就永远不会回收掉被引用的对象。
2、软引用(SoftReference)
在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收之后还没有足够的内存,才会抛出内存溢出异常。
软引用通常实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足的时候清理掉,这样就可以保证了使用缓存的同时不会耗尽内存。与弱引用相比,java虚拟机会尽量让软引用存活时间长一些,迫不得已才清除。
3、弱引用(WeakReference)
被弱引用关联的对象只能生存到下一次垃圾回收之前,当垃圾回收器工作的时候,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
4、虚引用(PhantomReference)
一个对象是否有虚引用的存在,完全不会对其生存之间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

垃圾回收器

垃圾回收器按照执行线程数的区分上可以分为串行垃圾回收器并行垃圾回收器,串行垃圾回收器和并行垃圾回收器的主要区别就是在进行垃圾回收的过程中的线程数不同,串行垃圾回收器同一时间只有一个线程在执行GC,并行垃圾回收器则代表在进行垃圾回收的时候由多个线程并行完成任务。在单核CPU环境下,串行垃圾回收器的效率比并行垃圾回收器效率高,在多核CPU环境下,并行垃圾回收器效率比串行垃圾回收器效率高。
无论是串行还是并行垃圾回收器,都无法避免Stop the world。
按照工作模式来区分分为并发式垃圾回收器独占式垃圾回收器,并发式垃圾回收器指的是执行垃圾回收任务的时候是垃圾回收线程和用户线程交替进行,独占式垃圾回收器则指的是在进行垃圾回收工作的时候一旦任务启动,就一定会在清理完所有垃圾之后才让用户线程恢复执行。
按照内存碎片处理方式来区分,则分为压缩式垃圾回收器非压缩式垃圾回收器。
按照工作的内存空间分布来区分,则分为年轻代垃圾回收器老年代垃圾回收器

7款经典的垃圾回收器
1、串行回收器:Serial、Serial Old;
2、并行回收器:ParNew、Parallel Scavenge、Parallel Old;
3、并发回收器:CMS、G1。
7款经典的垃圾回收器与堆空间的关系
1、新生代收集器:Serial、ParNew、Parallel Scavenge;
2、老年代收集器:Serial Old、Parallel Old、CMS;
3、整堆收集器:G1。

Serial

Serial收集器是历史最悠久的垃圾回收器,是JDK1.3之前回收新生代的唯一选择。同时,Serial收集器因为是串行的垃圾回收器,也是HotSpot虚拟机在Client模式下的默认新生代垃圾回收器。
Serial采用复制算法回收新生代的垃圾,与其对应的还有一个Serial Old收集器用于回收老年代,采用的是标记压缩算法。同时,Serial Old垃圾回收器在Server模式的HotSpot中还主要充当另外两个角色,一是与另外一个回收新生代的垃圾回收器Parallel Scavenge搭档进行堆空间的垃圾回收,二是作为老年代垃圾回收器CMS的后备垃圾回收方案。
Serial的优势是简单而高效,对于限定单个CPU环境的情况下,Serial收集器由于没有线程交互的开销,仅仅专心进行垃圾收集就可以获得最高效的工作效率。所以,Serial在HotSpot虚拟机中主要用于Client类型的虚拟机中。在HotSpot虚拟机中可以中参数-XX:+UseSerialGC指定年轻代和老年代都使用Serial收集器,即年轻代和老年代分别使用Serial+Serial Old的收集器组合。

ParNew

如果说Serial是年轻代的单线程垃圾回收器,那么ParNew就是年轻代的多线程垃圾回收器。ParNew除了完成垃圾回收工作的时候采用的是多线程处理,其他的处理方式几乎与Serial没有区别,ParNew是HotSpot在Server模式下年轻代的默认垃圾回收器。
在程序中可以使用-XX:+UserParNewGC手动指定程序使用ParNew垃圾回收器,它仅表示指定年轻代的垃圾回收器,对老年代没有影响。并且由于ParNew是多线程的垃圾回收器,所以还可以通过-XX:ParallelGCThreads限制线程数量,默认情况下是开启和CPU核心数相同的线程数量。

Parallel Scavenge

HotSpot的年轻代中除了ParNew是基于并行回收的垃圾回收器之外,还有一个Parallel Scavenge垃圾回收器Parallel Scavenge与ParNew的不同在于其目标是达到一个可控的吞吐量,是一个以吞吐量优先的垃圾回收器。
(吞吐量和暂停时间是判定垃圾回收器特性的两个标准,吞吐量则意味着尽可能的少进行GC,保证单位时间内用户线程的吞吐量,但同时也意味着一次GC的时间会偏长。暂停时间则意味着GC的频率相对较高,但是每次GC任务的时间较短。)
Parallel垃圾回收器是针对年轻代的并行垃圾回收器,同时也有一个Parallel Old是针对老年代的垃圾回收器,也是基于标记压缩算法的并行垃圾回收器。
Parallel+Parallel Old是java8的默认垃圾回收器。

相关参数配置
1、-XX:+UseParallelGC:手动指定年轻代使用Parallel收集器;
2、-XX:+UseParallelOldGC:手动指定老年代使用Parallel Old收集器;
(以上两个命令是互相激活的,指定其中之一,另外一个会自动搭配使用)
3、-XX:ParallelGCThread:设置年轻代并行收集器的线程数,在默认情况下,当CPU核心数小于8个,则默认线程数与CPU核心数相同;如果CPU核心数大于8,则ParallelGCThread的值为3+((5*CPU_COUNT)/8);
4、-XX:MaxGCPauseMillis:设置垃圾回收器的最大停顿时间;
5、-XX:GCTimeRatio:设置垃圾回收器执行时间占总时间的比例,用于衡量吞吐量的大小,取值范围是0-100,默认为99,即垃圾回收时间不超过程序运行时间的百分之一;
6、-XX:+UseAdaptiveSizePolicy:设置是否开启自适应调节策略,在这种策略下,垃圾回收器会追求堆空间大小、垃圾回收频率和单次回收时间之间的平衡,进而自动调整年轻代中伊甸园区和幸存者区的大小,还有对象晋升到老年代的年龄计数的值。

Concurrent Mark Sweep(CMS)

在jdk1.5时期,HotSpot推出了一款强交互的垃圾回收器:CMS,这款垃圾回收器是HotSpot虚拟机中第一款真正意义上的并发垃圾回收器,第一次实现了用户线程和垃圾回收线程同时工作。CMS垃圾回收器的关注点是尽可能的缩短垃圾收集的时候用户线程的停顿时间,提供良好的响应速度,采用的是标记清除算法。
CMS作为老年代的垃圾回收器无法与Patallel Scavenge进行搭配使用,只能跟Serial和ParNew搭配使用。

**CMS的工作原理
**image.png
CMS的垃圾回收过程比较复杂,一共分为初始标记、并发标记、重新标记、和并发清理四个主要阶段。
1、初始标记阶段:在这个阶段中,程序中的所有工作线程都会出现STW,这个阶段的任务仅仅是标记出GC ROOTS能直接关联到的对象,一旦标记完成之后就会恢复之前被暂停的所有应用线程,由于关联的对象数量相对较小,所以这个阶段的速度较快;
2、并发标记阶段:是从GC ROOTS的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是可以与用户线程同时运行,不需要暂停用户线程;
3、重新标记阶段:由于在并发标记阶段中,程序的用户线程和垃圾回收线程会同时或者交叉运行,因此为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分的对象的标记记录,这个阶段的停顿时间会比初始标记的时间长,但是也远比并发标记阶段短;
4、并发清除阶段:此阶段清除掉标记阶段的已经死亡的对象,释放内存空间。由于采用的是标记清除算法,不会整理内存空间移动存活对象,所以这个阶段也可以是垃圾回收线程与用户线程同时进行的。

由于CMS在垃圾清理阶段用户线程没有中断,所以在回收过程中,还应当保证用户线程有足够的内存空间可用。因此,CMS收集器不能像其他收集器一样等到老年代几乎完全被填满的时候才运行,而是当堆内存使用率达到某个阈值时,便开始进行回收,以确保应用程序在CMS工作过程中仍然有足够的空间支持应用程序运行。如果运行期间预留的内存空间不足以保证用户线程的运行,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案,临时启用Serial Old收集器来进行垃圾收集,此时就会导致较长的STW时间。
并且,CMS采用的是标记清除算法,所以有可能会产生不规整的内存空间,此时给新对象分配内存空间的时候就无法采用指针碰撞的方式,而是采用空闲列表的方式。

CMS的优点:并发收集延迟低(STW时间短);
CMS的缺点:
1、因为采用标记清除算法,会产生内存空间碎片,内存碎片可能会导致无法正常给大对象分配内存空间而触发Full GC;
2、CMS收集器相比于其他的垃圾回收器会占用更多的CPU资源,虽然它在垃圾收集的时候不会导致用户线程停顿,但是会因为占用了一部分线程而增加CPU的压力;
3、CMS收集器无法处理浮动垃圾,并且可能会出现"Concurrent Mode Failure"导致另一次Full GC的产生。浮动垃圾指的是当垃圾回收线程与用户线程并发运行的时候,此时的用户线程也可能产生一定的垃圾对象,此时CMS是无法对这一部分的垃圾对象进行标记清除的,只能等到下一次GC来处理浮动垃圾的内容。
(浮动垃圾产生的阶段是在并发标记而不是并发清理,重新标记阶段只是会对并发标记阶段的已标记的对象进行校验,而并发标记阶段会存在与之并发运行的用户线程,如果用户线程产生了垃圾并且没有在并发标记阶段被标记,那么重新标记也不会被标记,则成为浮动垃圾。)

相关参数配置
1、-XX:+UseConcMarkSweepGC:指定程序使用CMS垃圾回收器进行垃圾回收,设置该参数之后程序的垃圾回收器组合为ParNew+CMS+Serial Old组合;
2、-XX:CMSInitiatingOccupanyFraction:设置老年代内存使用率的阈值,一旦达到该阈值,CMS就进行垃圾回收;
在jdk1.5版本之前,该阈值的默认值为68,即当老年代的空间使用率达到68%的时候就开始进行垃圾回收,jdk1.6之后默认值为92。
如果内存增长缓慢,则可以设置一个较大的值,大的阈值可以有效降低CMS的执行频率;反之,如果内存增长迅速,则应该降低这个阈值,以避免因为垃圾回收线程与用户线程并发执行导致内存不够出发Serial Old收集器,避免老年代串行回收垃圾。
3、-XX:+UseCMSCompactAtFullCollection:用于指定执行完Full GC后对内存空间进行碎片整理,优化碎片化内存空间的情况;
4、-XX:CMSFullGCsBeforeCompaction:指定在执行多少次Full GC后对内存空间进行碎片整理;
5、-XX:ParallelCMSThreads:设置CMS的线程数量,CMS默认启动的线程数=(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数配置,当CPU资源比较紧张的时候,受到CMS收集器线程的影响,会导致程序在垃圾回收阶段体验较为糟糕。

Garbage First(G1)

G1是面向服务端的垃圾回收器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特性。是java9之后的默认垃圾回收器,取代了上一代并发垃圾回收器CMS。
G1是不区分年轻代、老年代的垃圾回收器,它把堆内存分割为很多不相关联的区域(Region),不相关联指的是物理上不连续的。使用不同的Region来表示伊甸园区、幸存者区、老年代等。
G1 GC是有计划的避免在整个堆中进行全区域的垃圾收集,G1跟踪每个Region里面的垃圾堆积价值大小(回收所获得的空间大小以及回收所需要的时间),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

G1的特点(优点)
1、并行与并发:G1支持在回收期间多个GC线程同时工作的,以此充分发挥多核CPU的特点,并且也拥有与应用程序交替执行的能力,部分工作可以和用户线程同时执行。
2、分代收集:从Region分代上来看,G1依然属于分代垃圾回收器,它会在逻辑上将Region区分为伊甸园区、幸存者区、老年代。但是它与其他垃圾回收器不同的是,其他的垃圾回收器一般只负责回收某一个分代的内容,比如Serial只回收年轻代,Serial Old只回收老年代。G1可以同时兼顾年轻代和老年代,而不是只能回收其中之一。
3、空间整合:与CMS在若干次GC之后才会进行一次内存整合不同,G1的内存回收是基于Region的,Region之间是复制算法,但整体上可以看作是标记压缩算法。两种算法都可以避免内存碎片,这种特性有利于程序长时间运行,尤其是当Java堆非常大的时候,G1的优势更加明显;
4、 可预测的时间停顿模型(soft real time):这时G1相对于CMS的另一大优势,G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间单位内,消耗在垃圾收集上的时间不得超过N毫秒。
由于分区的原因,G1可以选择部分区域进行回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。G1跟踪各个Region里面堆积的垃圾价值大小(回收所获得的空间大小以及回收所需要的时间),在后台维护一个优先列表,每次根据允许收集的时间,优先回收价值最大的Region,保证了G1收集器在有效的时间内获得尽可能高的回收效率。

G1的不足(缺点)
于CMS相比,G1还无法做到全方面的碾压。比如在用户程序运行的过程中,G1无论是进行垃圾回收占用的内存空间,还是程序执行时对CPU产生的额外负载都比CMS要高。一般来说,在小内存应用上CMS的表现要较好于G1,而G1在大内存应用上则发挥其优势。

相关参数配置
1、-XX:+UseG1GC:指定程序使用G1垃圾回收器进行垃圾回收;
2、-XX:G1HeapRegionSize:设置每个Region的大小,值是2的幂,范围是1MB-32MB之间,目标是根据最小的Java堆大小划分出越2048个区域,默认是堆内存的1/2000;
3、-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标(JVM会尽力达到,不能保证绝对),默认值是200ms;
4、-XX:ParallelGCThread:设置STW时工作线程的数量,最大为8;
5、-XX:ConcGCThreads:设置并发标记的工作线程数,通常为ParallelGCThread的四分之一;
6、-XX:InitiatingHeapOccupancyPercent:设置触发并发GC的堆占用阈值,超过此值则触发GC,默认值为45。

Region
G1垃圾回收器会将堆内存分割为2048个小Region,每一个Region的大小都是2的幂,并且当JVM启动之后不允许修改。Region是在逻辑上区分为伊甸园区、幸存者区和老年代,相同代的Region并不一定是连续的,如下图所示:
image.png
一个Region有可能是伊甸园区、幸存者区、老年代中的任意角色,但是一个Region只能属于一个角色,不存在两个角色混合,但是在一次GC中被清除干净的Region后续也可能被分配为全新的角色身份。并且G1的分区还提供了一个Humongous的区域,用来存放大对象,如果对象的所需内存大于1.5个Region的大小,则存放在H区。
对于堆中的大对象,一般情况下会直接被分配到老年代中,但是如果大对象是一个短期存在的,就会对垃圾收集器产生负面影响。所以,G1才独立划分H区用于存放大对象,如果一个H区的大小也无法完全放下一个大对象,G1会寻找连续的H区进行存放,如果无法寻找到合适的连续的H区,则会触发Full GC。

G1垃圾回收过程
G1垃圾回收过程主要包含三个环节:年轻代GC(young GC)、老年代并发标记(Conurrent Marking)、混合回收(Mixed GC)。
(如果需要,单线程、独占式、高强度的Full GC仍然是存在的,它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
当年轻代的Eden区用尽时开始年轻代回收过程,G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代收集期间,G1 GC会暂停所有的应用程序线程,启动多线程进行年轻代回收。然后从Eden区移动存活对象到Survivor区间或者老年代区间,也有可能是两个区间都会涉及。
当堆内存使用达到一定值(默认为45%)时,开始老年代并发标记过程。
标记完成开始混合回收过程,对于一个混合回收期,G1 GC从老年代区间中移动存活对象到空闲区间,此时命中的空闲区间也被视为老年代的一部分。与其他垃圾回收器不同,G1 GC不需要对整个老年代进行回收工作,一次只需要扫面/回收一部分老年代的区间即可,并且这个回收工作是老年代和年轻代同时混合进行的。
在并发标记阶段,如果发现某个区中的对象全部都为垃圾对象,则会在并发标记阶段直接被清除。而老年代中存在的某些区域中部分对象为垃圾的情况,默认情况下会对这些区域分8次进行回收,也就是上面提到的混合回收期不会对整个老年代进行回收工作,对这些区域分几次回收由参数-XX:G1MixedGCCountTarget决定。混合回收阶段的回收目标包括八分之一的老年代内存分段以及年轻代,混合回收的算法和年轻代相同采用复制算法。针对老年代的内存分段,G1会优先回收垃圾更多的分段,垃圾占比越高的分段越先被回收。并且有一个参数来控制判断垃圾占比的阈值:-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是区域的垃圾占比超过百分之六十五才会被选择回收,原因是如果一个区段内存活的对象比例较高,则会导致复制算法需要复制更多比例的对象,效率较低。
G1设计的初衷就是要避免Full GC的出现,但是如果上述的回收过程不能正常工作,G1会暂停程序进入STW状态,使用单线程的内存回收算法进行垃圾回收。导致G1垃圾回收过程失败的原因主要有两种可能:1、垃圾回收的时候没有足够的内存空间来存放晋升的对象;2、并发处理过程结束之前内存空间耗尽。

总结

image.png

GC日志分析

内存分配与垃圾回收的参数列表:
1、-XX:+PrintGC:输出GC日志,类似-verbose:gc;
2、-XX:+PrintGCDetails:输出GC的详细日志;
3、-XX:+PrintGCTimeStamps:输出GC的时间戳(以基准时间的形式);
4、-XX:+PrintGCDateStamps:输出GC的时间戳(以日期的形式);
5、-XX:+PrintHeapAtGC:在进行GC的前后打印出堆的信息;
6、-Xloggc:./logs/gc.log:日志文件的输出路径。

youngGC日志解析:
image.png
Full GC日志解析:
image.png

关于GCLog和GCEasy
使用参数-XX:+PrintGCDetails可以将程序的垃圾回收过程输出到控制台,同样的还有-Xloggc:./logs/gc.log可以将gc日志输出到指定的目录。一般情况下采用的肯定也是目录文件的形式,并且还可以通过可视化工具GCEasy来对垃圾回收文件进行可视化分析。
以下为实验代码和代码对应的配置以及GCEasy的使用:
1、JVM配置:

-Xms60m -Xmx60m -XX:+PrintGCDetails -Xloggc:./logs/gc.log

2、程序源码:

   public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        for (int i = 0; i < 500; i++) {
            // 100kb
            byte[] arr = new byte[1024 * 100];
            list.add(arr);

            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

3、使用GCEasy分析gc.log文件
GCEasy官网地址:https://gceasy.io/diamondgc-report.jsp?oTxnId_value=d0342bf3-03b8-4366-83a5-7788aa466865
直接上传gc.log文件即可。

posted @ 2022-12-01 15:13  原野上找一面墙  阅读(207)  评论(0)    收藏  举报