JVM-面试

什么是JVM?

  • JVM 即 Java 虚拟机,它是整个 java实现跨平台的最核心的部分,所有的 java 程序会首先被编译为.class 的类文件,这种类文件可以在虚拟机上执行,也就是说 class 并不直接与机器的操作系统相对应,而是经过虚拟机间接与操作系统交互,由虚拟机将程序解释给本地系统执行

JVM内存模型

内存结构

在这里插入图片描述
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为以下6个运行时数据区域

程序计数器 (私有)

  • 可以看作是当前线程执行的 字节码的行号指示器

  • 在JVM的内存模型中,字节码解释器 是通过改变 计数器的值 来选取下一条需要执行的字节码指令

  • 它是 程序控制流的指示器,分支、循环、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

    • 如果正在执行的是一个Java方法,程序计数器记录的是 正在执行的虚拟机字节码指令的地址

    • 如果正在执行的是Native方法,程序计数器值为 (Undefined)

为什么程序计数器设为私有?

  • JVM 的多线程是通过 线程轮流切换、分配处理器执行时间 的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核) 都只会执行一个线程的指令
  • 为了保证 线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器

虚拟机栈(私有)

  • 生命周期与线程相同,描述的是 Java方法执行的 线程内存模型
  • 每个方法在执行的时候,JVM都会同步创建一个 栈帧 用于存储 局部变量表、操作数栈、动态链接、方法出口 等信息
    • 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 虚拟机栈 中 入栈到出栈的过程

局部变量表

  • 存放了编译器可知的各种 JVM 基本数据类型对象引用( reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress类型(指向了一条字节码指令的地址)
  • 这些数据类型在局部变量表中的存储空间以 局部变量槽(Slot) 表示,其中 64位长度的 longdouble 类型的数据会占用 两个变量槽,其余的数据类型只占用一个
  • 局部变量表所需的内存空间在 编译期间 完成分配,当进入一个方式时,这个方法需要在 栈帧 中分配多少的局部变量空间是 完全确定的,在方法运行期间不会改变局部变量表的大小

这里说的 ‘大小’ 是指变量槽的数量,JVM 真正使用多大的内存空间(譬如按照1个变量槽占用 32个比特、64个比特,或者更多) 来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情

本地方法栈(私有)

  • 本地方法栈则为 JVM 使用到的 Native 方法服务

  • 堆是 JVM 所管理的内存中最大的一块,在 JVM 启动时创建,是被 所有线程共享的一块内存区域
  • 唯一目的就是 存放对象实例,几乎所有的 对象实例 以及 数组 都在这里 分配内存

堆可以被实现成固定大小的,也可以是可扩展的。目前主流的JVM都是按照可扩展来实现的 (参数 -Xmx 和 -Xms设定)

堆可以处于 物理上 不连续的内存空间中,但在 逻辑上 它应该被视为连续的。这点像用磁盘空间去存储文件一样,不要求每个文件都连续存放,但对于大对象(典型的如数组对象),多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间

方法区

  • 所有线程共享的内存区域,它用于存储已被 JVM **加载 **的 类型信息、常量、静态变量、即时编译器编译后的代码缓存 等数据

运行时常量池

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

运行时常量池和 Class文件常量池

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

哪些地方会OOM

虚拟机栈 和 本地方法栈

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

  • 在队中没有完成内存实例分配,并且堆也无法扩展时,抛出 OutOfMemoryError 异常

方法区

  • 当方法区无法满足新的内存分配需求时,抛出 OutOfMemoryError 异常

运行时常量池

  • 当常量池无法再申请到内存会抛出 OutOfMemoryError 异常

内存泄漏和内存溢出

内存泄漏

  • 程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出

内存溢出

  • 程序申请内存时,没有足够的内存供申请者使用

内存泄漏的分类

  • **常发性内存泄漏 : ** 发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏
  • **偶发性内存泄漏 : **发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要
  • **一次性内存泄漏 : **发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次
  • **隐式内存泄漏 : ** 程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏

JVM内存设置

设置JVM内存设置

  • -Xmx Java Heap最大值,默认值为物理内存的1/4,最佳设值应该视物理内存大小及计算机内其他内存开销而定
  • -Xms Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值
  • -Xmn Java Heap Young区大小,不熟悉最好保留默认值
  • -Xss 每个线程的Stack大小,不熟悉最好保留默认值

OOM问题原因、解决、定位

内存溢出原因

  • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据
  • 集合类中有对 对象的引用,使用完后未清空,使得 JVM不能回收
  • 代码中存在死循环或循环产生过多重复的对象实体
  • 使用的第三方软件中的BUG
  • 启动参数内存值设定的过小

内存溢出解决方案

  • 第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)
  • 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其 它异常或错误。
  • 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
    • 重点排查以下几点:
    • 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询
    • 检查代码中是否有死循环或递归调用
    • 检查是否有大循环重复产生新对象实体
    • 检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收
  • 第四步,使用内存查看工具动态查看内存使用情况(jvisualvm)

线上内存OOM问题是最难定位的问题,最常见的原因:

  • 本身资源不够
  • 申请的太多
  • 资源耗尽

Java服务OOM,最常见的原因为:

  • 有可能是内存分配确实过小,而正常业务需要使用更大的内存
  • 某一个对象被频繁申请,却没有释放,内存不断泄露,导致内存耗尽
  • 某一个资源被不断申请,系统资源耗尽,例如:不断创建线程,不断发起网络连接

1. 确认是不是内存本身就分配过小
jmap -heap pid
(可以查看新生代,老年代堆内存的分配大小以及使用情况,看是否本身分配过小)
在这里插入图片描述

2. 找到最耗内存的对象
jmap -histo:live pid | more
结果以表格的形式显示存活对象的信息,并按照所占内存大小排序
实例数 || 所占内存大小 || 类名

如果发现某类对象占用内存很大,很可能是类对象创建太多,且一直未释放

  • 申请完资源后,未调用close释放资源
  • 消费者消费速度慢,生产者不断往队列中投递任务,导致队列中任务累积过多

在这里插入图片描述

3. 确认释放是资源耗尽
pstree:查看进程创建的线程数
netstat:网络连接数

还有另一种方法,通过

ll /proc/pid/fd 查看占用句柄
ll /proc/pid/task 查看线程数
在这里插入图片描述

四种引用类型

在这里插入图片描述

  • 强引用 : 在Java中最常见的就是强引用。在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。有强引用的对象一定为可达性状态,所以不会被垃圾回收机制回收即强引用是造成Java内存泄漏的主要原因
  • 软引用 : 软引用通过SoftReference类实现。如果一个对象只有软引用,则在系统内存空间不足时该对象将被回收
  • 弱引用:弱引用通过WeakReference类实现,如果一个对象只有弱引用,则在垃圾回收过程中一定会被回收
  • 虚引用:虚引用通过PhantomReference类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态

类加载机制

类加载阶段

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-02K3blIW-1651902044334)(D:\Work-Space\TyporaImage\image-20210904153522319.png)]

  • 一个类型从被加载到JVM内存中开始到卸载出内存,它的生命周期将会经历 加载、验证、准备、解析、初始化、使用、卸载
  • JVM的类加载分为5个阶段:加载、验证、准备、解析、初始化
  • 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而 解析 不一定
    • 在某些情况可以在 初始化 之后再开始,为了支持 Java的动态绑定

加载

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

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

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

验证

  • 确保 Class文件的 字节流中包含的信息符合 JVM的全部约束要求,保证这些信息被当做代码运行后不会危害 JVM 自身的安全

准备

  • 正式为类中定义的 静态变量 分配内存 并设置 类变量初始值 的阶段
  • 这里的内存分配仅包括 类变量,而 包括 实例变量,实例变量将会在对象实例化时随着对象一起分配在

public static int value = 123

变量 value在 准备阶段过后的初始值为 0 而不是123,因为这个时候尚未开始执行任何 Java方法,而把 value 赋值为123的 putstatic 指令是程序被编译后,存放于类构造器()方法之中,所以把 value 赋值为 123 的动作要到 类的初始化阶段 才会被执行

public static final int value = 123

编译时Javac将会为value 生成 ConstantValue属性,在准备阶段JVM就会根据 ConstantValue的设置将 value 赋值为 123

解析

  • JVM将 常量池内的 符号引用 替换为 直接引用 的过程
  • 解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 这7类符号引用进行

初始化

  • 初始化阶段就是执行 类构造器()方法的过程
  • ()方法是由 编译器自动收集类中的 所有类变量的赋值动作静态语句块 中语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量。则定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问
  • ()方法 与 类的构造函数(()方法) 不同,它不需要显式的调用父类构造器,JVM 会保证子类的 ()方法执行前,父类的 ()方法已经自行完毕。即 JVM 第一个执行的 ()方法 肯定是 java.lang.Object
  • ()方法 对于类和接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以 不为 这个类生成 ()方法
  • 接口中不能使用 静态语句块,但有变量初始化的赋值操作,即接口与类一样都会生成 ()方法。但是执行 接口的()方法 不需要 先执行 父接口的 ()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化也一样不会执行接口的 ()方法

初始化

什么情况下需要开始类加载过程的第一个阶段 【加载】,并没有进行强制约束,可以交给 JVM 的具体实现来自由把握。

但是对于初始化阶段,则是严格规定了有且只有 六种 情况必须立即对类进行 【初始化】 (加载、验证、准备自然需要在此之前开始)

  • 遇到 new、getstatic、pubstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
    1. 使用 new 关键字实例化对象
    2. 读取或设置一个类型的静态字段 (被 final 修饰、已在编译器把结果放入常量池的静态字段除外)
    3. 调用一个类型的静态方法
  • 使用 java.lang.reflect 包的方法 对类型进行反射调用 的时候,如果类型没有进行初始化,则需要先触发其初始化
  • 初始化类时,如果其 父类还没有进行过初始化,则需要先触发其父类的初始化
  • JVM启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),JVM会先初始化这个主类
  • 当一个接口中定义了 JDK 8 新加入的默认方法(default),如果这个接口的实现类发生了初始化 即 该接口要在之前初始化
  • 使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果为 REF_getStatic、REF_putStatic、ERF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

认证阶段详细描述

文件格式验证

  • 验证 字节流是否符合 Class文件格式的规范,并且能被当前版本的JVM处理
    • 主、次版本号是否在当前 JVM 接收范围之内
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)

元数据验证

  • 对字节码描述的信息进行语义分析
    • 此类是否有父类 (除了 java.lang.Object之外,所有的类当应有 父类)
    • 这个类的父类是否继承了不被允许继承的类 (被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

字节码验证

  • 通过 数据流分析控制流分析,确定程序语义是 合法的、符合逻辑的
  • 元数据验证 后,这阶段要对类的方法体(Class文件中的Code属性) 进行校验分析,保证被校验类的方法在运行时不会做出危害JVM安全的行为
    • 保证任意时刻 操作数栈 的数据类型与 指令代码序列都能配合工作 (例如:在操作数栈中放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中)
    • 保证任何 跳转指令 都不会跳转到 方法体以外 的字节码指令上

符号引用验证

  • 发生在 JVM将 符号引用 转换为 直接引用 的时候,转化动作发生在 解析 阶段
  • 符号引用验证 即 该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源
  • 符号引用验证的主要目的 确保解析行为能正常执行,如果无法通过符号引用验证,JVM会抛出异常
    • 符号引用的类、字段、方法的可访问性(public、private、protected…) 是否可被当前类访问
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类

符号引用主要包括下面几类常量:

被模块导出或者开放的包

类和接口的全限定名

字段的名称和描述符

方法的名称和描述符

方法句柄和方法类型

动态调用点和动态常量

类加载器

JVM提供了3种类加载器,分别是启动类加载器、扩展类加载器和应用程序类加载器
在这里插入图片描述

  • 启动类加载器:负责加载Java_HOME/lib目录中的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库
  • 扩展类加载器:负责加载Java_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库
  • 应用程序类加载器:负责加载用户路径(classpath)上的类库
  • 也可以通过 继承java.lang.ClassLoader实现自定义加载器

双亲委派模型

  • 除了顶层的启动类加载器外,其余的类加载器都有自己的父类加载器 (组合关系来复用父加载器的代码)
  • 如果一个类加载器收到了类加载的请求,它不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,只有当父加载器反馈无法加载这个请求,子加载器才会尝试自己的完全加载

使用双亲委派模型组织类加载器之间的关系 好处:

  • 保证 Java 程序的稳定运作

  • 例如Object类,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载。因此 在程序的各种类加载器环境中都能保证是同一个类

手写String能被加载吗

  • 无论何种自定义类加载器,最终都会调用ClassLoader.defineClass
  • ClassLoader.defineClass中会检查类名,类名以java.开头的,不予加载

垃圾回收

如何识别垃圾

引用计数法

  • 对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收

产生的问题 : 循环引用

  • 实例化两个同一类型的对象,使它们互相指向对方
  • 将两个对象置为null,但是由于它们指互相指向了对方(引用计数都为1),所以无法回收

可达性算法

  • 以一系列叫做GC Root的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点,直到所有的结点都遍历完毕
  • 如果相关对象不在任意一个以GC Root为起点的引用链中,则这些对象会被判断为垃圾,会被GC回收
    在这里插入图片描述

a,b对象可回收,就一定会被回收吗?

  • 当对象可回收时,发生GC的时候会先判断对象是否执行了finalize方法,如果未执行,则会先执行finalize方法将当前对象与GC Roots关联
  • 执行finalize方法之后,GC会再次判断对象是否可达,如果不可达则会被回收。finalize方法只会被执行一次,但如果对象再次被GC,则会忽略finalize方法,对象会被回收

哪些对象可以作为GC Root

  • 虚拟机栈中栈帧的本地变量表中引用的对象

  • 本地方法栈中JNI(一般说的Native方法)引用的对象

  • 方法区中常量引用的对象

  • 方法区中类静态属性引用的对象

虚拟机栈中引用的对象
a是栈帧中的本地变量,当a=null时,由于此时a充当了GC Root的作用,a与原来指向的实例 new Test() 断开了连接,所以对象会被回收

public class Test {
    public static  void main(String[] args) {
	Test a = new Test();
	a = null;
    }
}

方法区中类静态属性引用的对象
当栈帧中的本地变量a=null时,由于a原来指向的对象与GC Root(变量a)断开了连接,所以a原来指向的对象回被回收,而由于给s赋值了变量的引用,s在此时是累静态属性引用,充当了GC Roots的作用,它指向的对象依然存活

public class Test {
    public static Test s;
    public static  void main(String[] args) {
	Test a = new Test();
	a.s = new Test();
	a = null;
    }
}

方法区中常量引用的对象
常量 s 指向的对象并不会因为 a 指向的对象被回收而回收

public class Test {
	public static final Test s = new Test();
        public static void main(String[] args) {
	    Test a = new Test();
	    a = null;
        }
}

本地方法栈中 JNI 引用的对象
本地方法: 一个 java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使 JAVA 可以实现和本地机器的紧密联系,调用系统级的各接口方法

当调用 Java 方法时,虚拟机会创建一个栈桢并压入 Java 栈,而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法
在这里插入图片描述

垃圾回收算法

标记清除算法

  • 标记清除算法其过程分为 标记清除 两个阶段。在标记阶段标记所有需要回收的对象,在清除阶段清除可回收的对象并释放其所占用的内存空间
  • 标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间
    • 因此如果内存中可被回收的小对象居多,则会引起内存碎片化的问题,引起大对象无法获得连续可用空间的问题

复制算法

  • 复制算法将内存划分为两块大小相等的内存区域,即区域1和区域2,新生成的对象都被存放在区域1中,在区域1内的对象存储满后会对区域1进行一次标记,并将标记后仍然存活的对象全部复制到区域2中,然后直接清理整个区域1的内存
  • 复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用造成大量的内存浪费
    • 如果在系统中有大量长时间存活的对象时,将这些对象将在区域1和区域2之间来回复制会影响系统的运行效率。即该算法只在对象为 “朝生夕死” 状态时运行效率较高

标记整理法

  • 标记整理算法结合了标记清除算法 和 复制算法的优点,其标记阶段和标记清除算法相同,在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存
  • 每次垃圾清除都要频繁地移动存活的对象,效率十分低下

分代垃圾算法

大部分的对象都很短命,都在短时间内都被回收了,所以分代收集算法根据对象存活周期的不同将堆分成新生代老生代(Java8以前还有个永久代),默认比例为 1 : 2

新生代又分为 Eden 区, from Survivor 区(简称S0),to Survivor 区(简称 S1),三者的比例为 8: 1 : 1,这样就可以根据新老生代的特点选择最合适的垃圾回收算法,把新生代发生的 GC 称为 Young GC(也叫 Minor GC),老年代发生的 GC 称为 Old GC(也称为 Full GC)

为什么堆要分新生代和老年代

  • 为了更有效的管理内存

  • 如果不分新老代,垃圾收集器每次都要把那些长期存在的对象生命周期很短的对象放在一起回收,一般长生命周期的对象可能跟应用生命周期一致,基本回收不掉的

    • 比如Spring 框架里面的Bean管理相关的对象(ApplicationContext),整个应用运行期间都存在,这种一般经过几次回收最后都放在老年代,但是如果不区分新老代,每次都一起回收,性能消耗很大
  • 区分新老代之后,老年代放长期存活的对象,新生代就放生命周期短的对象,老年代对象很稳定,新生代回收不影响老年代,回收效率能大大提高

为什么新生代还要分 Eden、From、To区域

  • 如果每次回收都在新生代整块内存上进行,标记需要清理的对象、清理这些被标记的对象、移动年龄晋升到老年代的对象到老年代,对象被回收掉后会产生很多内存碎片,如果要解决内存碎片则需要移动剩下的对象,导致整个回收流程效率很低
  • 如果没有Survivor区(s0,s1),新生代回收的过程中将存活的对象直接被送到老年代,这样的话老年代很快被填满则触发Full GC,Full GC频繁会影响程序的执行和响应速度
对象在新生代的分配与回收
  • 大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区, Eden 区将满时,触发 Young GC
  • 经过Young GC 后只有少部分对象会存活,它们会被移到 S0 区,同时对象年龄加**一( Minor GC 的次数),**最后把 Eden 区对象全部清理以释放出空间
  • 当触发下一次 Young GC 时,把 Eden 区和 S0 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间
  • 若再触发下一次 Young GC,则重复上一步,此时变成了 从 Eden、S1 区将存活对象复制到 S0 区,每次垃圾回收,S0, S1 角色互换。在 Eden 区的垃圾回收采用的是复制算法,因为在 Eden 区分配的对象大部分在 Young GC 后都消亡了,只剩下极少部分存活对象,S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销
对象晋升老年代
  • 当对象的年龄达到了设定的阈值,则会从S0(或S1)晋升到老年代

    • 在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代
  • 当某个大对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,如果把大对象分配在 Eden 区,Young GC 后再移动到 S0,S1 会有很大的开销

空间分配担保
  • 在发生 Young GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
  • 如果大于,那么Young GC 可以确保是安全的,如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败
  • 如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Young GC,否则可能进行一次 Full GC
Stop The World
  • 如果老年代满了,会触发 Full GC同时回收新生代和老年代(即对整个堆进行GC),它会导致STW,造成挺大的性能开销

  • STW在GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起
    在这里插入图片描述

  • 一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)

  • 把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。新生代如果只设置 Eden则每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC

Safe Point

由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC

Safe Point 主要指的是以下特定位置:

  • 循环的末尾
  • 方法返回前
  • 调用方法的 call 之后
  • 抛出异常的位置

新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法。在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销,所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收

垃圾收集器种类

Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器
在这里插入图片描述

  • 新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
  • 老年代工作的垃圾回收器:CMSSerial Old, Parallel Old
  • 同时在新老生代工作的垃圾回收器:G1
    图片中的垃圾收集器如果存在连线,则代表它们之间可以配合使用

新生代收集器

Serial收集器
  • Serial 收集器是工作在新生代的,单线程的垃圾收集器,它在进行垃圾收集时,其他用户线程会暂停,直到垃圾收集结束
  • Client 模式下,对于限定单个 CPU 的环境来说,Serial 单线程模式无需与其他线程交互,减少了开销,专心做 GC 能将其单线程的优势发挥到极致。在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆,STW 时间可以控制在一百多毫秒内,所以对于运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器
ParNew收集器

在这里插入图片描述

  • ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程,其他像收集算法,STW,对象分配规则,回收策略与 Serial 收集器完成一样,在底层上,这两种收集器也共用了相当多的代码
  • ParNew 主要工作在 Server 模式,服务端接收的请求多,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,许多运行在 Server 模式下的虚拟机的首选新生代收集器
  • 除了 Serial 收集器,只有它能与 CMS 收集器配合工作
Parallel Scavenge 收集器
  • Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器
  • CMS 等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),CMS 等垃圾收集器更适合用到与用户交互的程序,因为停顿时间越短,用户体验越好,而 Parallel Scavenge 收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务

老年代收集器

Serial Old收集器
  • Serial Old 是工作于老年代单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用
  • 如果在 Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用(后文讲述),它与 Serial 收集器配合使用示意图如下
    在这里插入图片描述
Parallel Old 收集器
  • Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理法,两者组合示意图如下,这两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标
    在这里插入图片描述
CMS收集器

CMS 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择
老年代主要用标记整理法,而 CMS 虽然工作于老年代,但采用的是标记清除法

  • 初始标记

  • 并发标记

  • 重新标记

  • 并发清除
    在这里插入图片描述

  • 初始标记重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短

  • 整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的

CMS收集器的三个缺点

  • CMS 收集器对 CPU 资源非常敏感。比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
  • CMS 无法处理浮动垃圾,可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生,由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了
  • CMS 采用的是标记清除法,会产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的
G1收集器

G1的几个特点

  • 像 CMS 收集器一样,能与应用程序线程并发执行
  • 整理空闲空间更快
  • 需要 GC 停顿时间更好预测
  • 不会像 CMS 那样牺牲大量的吞吐性能
  • 不需要更大的 Java Heap

与 CMS 相比,它在以下两个方面表现更出色

  • 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行
  • STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内

在这里插入图片描述
为什么G1能建立可预测的停顿模型?

  • G1对堆空间的分配与传统的垃圾收集器不一样, G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址
  • Region还多了一个H,表示这些Region存储的是巨大对象,即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动

G1这样分配的好处
传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小,这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

G1收集器的工作步骤

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

在这里插入图片描述
整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值