JVM 基础知识学习笔记
1. JVM 介绍
什么是 JVM ?
JVM 本质上是一个运行在计算机上的程序,它的职责是运行 Java 字节码文件。
JVM 的功能是什么 ?
- 解释和运行: 对字节码文件中的指令,实时的解释成机器码,让计算机执行。
- 内存管理: 自动为对象、方法等分配内存。自动的垃圾回收机制,回收不再使用的对象。
- 即时编译: 对热点代码进行优化,提升执行效率。
JVM 的即时编译是为了什么? 主要是为了支持跨平台特性。
由于 JVM 需要实时解释虚拟机指令,不做任何优化性能不如直接运行机器码的 C、C++ 等语言。
JVM 提供了即时编译(Just in Time 简称JIT)进行性能优化,最终能达到接近 C、C++ 语言的运行性能,甚至在特定场景下实现超越。
常见的 JVM 有哪些 ?
- HotSpot(Oracle JDK版)
- HotSpot(Open JDk版)
- GraalVM
- Dragonwell JDK(龙井)
- Eclipse OpenJ9(原 IBM J9)
2. 字节码文件详解
2.1 Java 虚拟机的组成
JVM 组成:
- 类加载器ClassLoader: 加载 class 字节码文件中的内容到内存中。
- 运行时数据区域(JVM管理的内存): 负责管理 JVM 使用到的内存,比如创建对象和销毁对象。
- 执行引擎(即时编译器、解释器、垃圾回收器等): 将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能。
- 本地接口: 调用本地已经编译的方法,比如虚拟机中提供的 c/c++ 的方法。
2.2 字节码文件的组成
推荐使用 jclasslib 工具查看字节码文件。
字节码文件的组成部分
- 基础信息: 魔数、字节码文件对应的 Java 版本号、访问标识(public final 等等)、父类和接口
- 常量池: 保存了字符串常量、类或接口名、字段名主要在字节码指令中使用
- 字段: 当前类或接口声明的字段信息。
- 方法: 当前类或接口声明的方法信息。字节码指令
- 属性: 类的属性,比如源码的文件名、内部类的列表等。
字节码常用工具:
- javap -v
- jclasslib
- 阿里Arthas
jar -xvf 命令解压 Jar 包
2.3 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。
类的生命周期-应用场景:
- 运行时常量池
- 多态的原理
- 类加载器的作用
- 类的加密和解密
2.3.1 生命周期概述
类的生命周期:
- 加载 Loading
- 连接 Linking (细分为验证、准备、解析)
- 初始化 initialization
- 使用 Using
- 卸载 Unloading
2.3.2 加载阶段
加载阶段的步骤
- 第一步是类加载器根据类的全限定名通过不同的渠道(渠道可以分为本地文件、动态代理生成、通过网络传输的类)以二进制流的方式获取字节码信息。
- 类加载器在加载完类之后,Java 虚拟机会将字节码中的信息保存到方法区(方法区是一个虚的,是一个概念)中。
- 生成一个InstanceKlass 对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
- 同时,Java 虚拟机还会在堆中生成一份与方法区中数据类似的 java.lang.Class 对象。作用是在 Java 代码中获取类的信息以及存储静态字段的数据(JDK8及之后)。
对于开发者来说,只需要访问堆中的 Class 对象而不需要访问方法区中所有信息。这样 Java 虚拟机就能很好地控制开发者访问数据的范围。
查看内存中的对象:
- hsdb: JDK自带工具,工具位于JDK安装目录lib文件夹中的 sa-jdi.jar 中。
- 启动命令:java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
jsp 命令 查看系统中所有 java 进程。
2.3.3 连接阶段
连接阶段:
- 验证: 验证内容是否满足《Java虚拟机规范》
- 准备: 给静态变量赋初值。
- 解析: 将常量池中的符号引用替换成指向内存的直接引用。
连接(Linking)阶段的第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。
主要包含如下4个部分,具体详见《Java虚拟机规范》:
- 文件格式验证,比如文件是否以 0xCAFEBABE 开头,主次版本号是否满足当前Java虚拟机版本要求。
- 元信息验证,例如类必须有父类(super 不能为空)
- 验证程序执行指令的语义,比如方法内的指令执行到一半强行跳转到其他方法中去。
- 符号引用验证,例如是否访问了其他类中 private 的方法等。
版本号检测:主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过。
连接(Linking)阶段的第二个环节准备,准备阶段为静态变量(static)分配内存并设置初始值。
准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值。
final 修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
连接(Linking)阶段的第三个环节解析,解析阶段主要是将常量池中的符号引用替换为直接引用。
符号引用就是在字节码文件中使用编号来访问常量池中的内容。
2.3.4 初始化阶段
初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
初始化阶段会执行字节码文件中 clinit 部分的字节码指令。
clinit 方法中执行的顺序与 Java 中编写的顺序是一致的。
以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是 final 修饰的并且右边是常量不会触发初始化。
- 调用 Class.forName(String className)。
- new 一个该类的对象时。
- 执行main方法的当前类。
添加 JVM 参数,-XX:+TraceClassLoading 参数可以打印出加载并初始化的类。
clinit 指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行的。
- 无静态代码块且无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。
- 静态变量的定义使用final关键字,这些变量会在准备阶段直接初始化。
直接访问父类的静态变量,不会触发子类的初始化。
子类的初始化 clinit 调用之前,会先调用父类的 clinit 初始化方法。
数组的创建不会导致数组中元素的类进行初始化。
final 修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。
2.4 类加载器
类加载器(ClassLoader) 是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。
类加载器只参与加载过程中的字节码获取并加载到内存这一部分。
2.4.1 类加载器的分类
类加载器分为两类,一类是 Java 代码中实现,一类是 Java 虚拟机底层源码实现的。
- 虚拟机底层实现:加载程序运行时的基础类。
- Java:继承自抽象类 ClassLoader,JDk中默认提供或自定义。
类加载器的设计 JDK8 和 8 之后的版本差别较大。
JDK8 及之前的版本中默认的类加载器有:
- 启动类加载器(Bootstrap)
- 扩展类加载器(Extension)
- 应用程序类加载器(Application)
通过 Arthas 的 classloader 来查看JDK中的类加载器。
2.4.1.1 虚拟机底层实现的类加载器
启动类加载器(Bootstrap ClassLoader) 是由 Hotspot 虚拟机提供的、使用 c++ 编写的类加载器。
默认加载Java安装目录 /jre/lib 下的类文件。
通过启动类加载器去加载用户Jar包:
- 放入 jre/lib 下进行扩展:不推荐。
- 使用参数进行扩展:推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展。
2.4.1.2 Java实现的类加载器
Java 中默认的类加载器:
- 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
- 它们的源码都位于 sun.misc.Launcher 中,是一个静态内部类。继承自 UrlClassLoader。具备通过目录或者指定 jar 包将字节码文件加载到内存中。
扩展类加载器 (Extension Class Loader) 是JDK中提供的、使用 Java 编写的类加载器。
默认加载 Java 安装目录 /jre/lib/ext 下的类文件。
通过扩展类加载器去加载用户 jar 包:
- 放入 /jre/lib/ext 下进行扩展:不推荐
- 使用参数进行扩展:推荐,使用 -Djava.ext.dirs=jar 包目录,进行扩展,这种方式会覆盖原始目录,可以用;(windows):(macos/linux)追加上原始目录。
Arthas 中类加载器相关功能:
- classloader -l: 查看有哪些类加载器。
- classloader -c hash:查看类加载器的加载路径。
- classloader -t: 查看类加载器的父子关系。
- sc -d className: 查看类的信息
2.4.2 双亲委派机制
由于 Java 虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题。
双亲委派机制的作用
- 保证类加载的安全性:通过双亲委派机制避免恶意代码替换 JDK 中的核心类库,比如 java.lang.String, 确保核心类库的完整性和安全性。
- 避免重复加载:双亲委派机制可以避免同一个类被多次加载。
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载。
双亲委派机制的问题:
- 如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?启动类加载器加载,根据双亲委派机制,它的优先级最高。
- 在自己的项目中去创建一个 java.lang.String 类,会被加载吗?不能,会返回启动类加载器加载在rt.jar包中的String类。
在 Java 中如何使用代码的方式去主动加载一个类呢?
- 使用 Class.forName 方法,使用当前类的类加载器去加载指定的类。
- 获取到类加载器,通过类加载器的 loadClass 方法指定某个类加载器加载。
每个 Java 实现的类加载器中保存了一个成员变量叫 “父”(Parent)类加载器,可以理解为它的上级,并不是继承关系。
应用程序类加载器的 parent 父类加载器是扩展类加载器,而扩展类加载器的 parent 是空,但是在代码逻辑上,扩展类加载器依然会把启动类加载器当成父类加载器处理。
启动类加载器使用 C++ 编写,没有父类加载器。
2.4.3 打破双亲委派机制
打破双亲委派机制的三种方式:
- 自定义类加载器: 自定义类加载器并重写 loadClass 方法,就可以将双亲委派机制的代码去除。Tomcat 通过这种方式实现应用之间类隔离。
- 线程上下文类加载器: 利用上下文类加载器,比如 JDBC 和 JNDI。
- Osgi框架的类加载器: 历史上 Osgi 框架实现了一套新的类加载机制,允许同级之间委托进行类的加载。
双亲委派机制的核心代码就位于 loadClass 方法中。
自定义类加载器父类怎么是 AppClassLoader 呢?
以JDK8为例,ClassLoader 类中提供了构造方法设置 parent 的内容。这个构造方法由另一个构造方法调用,其中父类加载器由 getSystemClassLoader 方法设置,该方法返回的是 AppClassLoader。
两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,在同一个 Java 虚拟机中,只有相同类加载器 + 相同的类限定名才会被认为是同一个类。
使用阿里 Arthas 不停机解决线上问题:
- 在出问题的服务器上部署一个 arthas, 并启动。
- jad --source-only 类全限定名 > 目录/文件名.java (jad 命令反编译,然后可以用其他编辑器,来修改源码)
- sc -d className: 查看类加载器的hashcode
- mc -c 类加载器的hashcode 目录/文件名.java -d 输出目录 (mc 命令用来编译修改过的代码)
- retransform class文件所在目录/xxx.class (用 retransform 命令加载新的字节码)
程序重启之后,字节码文件会恢复,除非将 Class 文件放入 jar 包中进行更新。
使用 retransform 不能添加方法或字段,也不能更新正在执行中的方法。
2.4.4 JDK9之后的类加载器
JDK8 之前的版本中,扩展类加载器和应用程序类加载器的源码位于 rt.jar 包中的 sun.misc.Launcher.java。
由于 JDK9 引入了 module 的概念,类加载器在设计上发生了很多变化。
- 启动类加载器使用 Java 编写,位于 jdk.internal.loader.ClassLoader 类中。Java 中的 BootClassLoader 继承自 BuiltinClassLoader 实现从模块中找到要加载的字节码资源文件。
- 扩展类加载器被替换为平台类加载器(Platform Class Loader), 平台类加载器遵循模块化方式加载字节码文件,所以继承关系从 UrlClassLoader 变成了 BuiltinClassLoader,BuiltinClassLoader 实现了从模块中加载字节码文件。
启动类加载器依然无法通过 Java 代码获取到,返回的仍然是 null,保持了统一。
平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊逻辑。
3. JVM 内存区域(运行时数据区)
Java 虚拟机在运行 Java 程序过程中管理的内存区域,称之为运行时数据区。
《Java虚拟机规范》中规定了每一部分的作用。
线程不共享:
- 程序计数器
- Java虚拟机栈
- 本地方法栈
线程共享:
- 方法区
- 堆
3.1 程序计数器
程序计数器(Program Counter Register) 也叫 PC 寄存器,每个线程会通过程序计数器记录当前要执行的字节码指令的地址。
在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。
程序计数器的作用:
- 程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
- 在多线程执行情况下,Java 虚拟机需要通过程序计数器记录 CPU 切换前解释执行到那一句指令并继续解释执行。
内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。
程序员无需对程序计数器做任何处理。
3.2 栈
Java 虚拟机栈(Java Virtual Machine Stack) 采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
Java 虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。
栈帧的组成:
- 局部变量表: 局部变量表的作用是在运行过程中存放所有的局部变量。
- 操作数栈: 是栈帧中虚拟机在执行指令的过程中用来存放临时数据的一块区域。
- 帧数据: 主要包含动态链接、方法出口、异常表的引用。
3.2.1 局部变量表
局部变量表的作用是在方法的执行过程中存放所有的局部变量。编译成字节码文件时就可以确定局部变量表的内容。
栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot),long 和 double 类型占用两个槽,其他类型占用一个槽。
实例方法中的序号为 0 的位置存放的是 this, 指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。
方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。
局部变量表保存的内容有: 实例方法的 this 对象,方法的参数,方法体中声明的局部变量。
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
3.2.2 操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。它是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。
3.2.3 帧数据
帧数据中的动态链接:当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。
动态链接就保存了编号到运行时常量池的内存地址的映射关系。
方法出口: 指的是方法在正确或异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
3.2.4 栈内存溢出
Java 虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。
Java 虚拟机栈内存溢出时会出现 StackOverflowError 的错误。
Java 虚拟机栈的默认大小:如果我们不指定栈的大小,JVM 将创建一个具有默认大小(1024kb)的栈。大小取决于操作系统和计算机的体系结构。
- Linux X86(64位): 1MB
- Linux ppc: 2MB
- BSD X86(64位): 1MB
- Solaris 64位: 1MB
- Windows: 基于操作系统的默认值。
Java 虚拟机栈-设置大小:
- 语法:-Xss栈大小
- 单位:字节(默认必须是1024的倍数)、k/K(KB)、m/M(MB)、g/G(GB)
Java 虚拟机栈-注意事项
- 与 -Xss 类似,也可以使用 -XX:ThreadStackSize 调整标志来配置堆栈大小。格式为 -XX:ThreadStackSize=1024
- Hotspot JVM 对栈大小的最大值和最小值有要求。
- 局部变量过多、操作数栈深度过大也会影响栈内存的大小。
一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为 -Xss256k 节省内存。
本地方法栈:
- Java 虚拟机栈存储了 Java 方法调用时的栈帧,而本地方法栈存储的是 native 本地方法的栈帧。
- 在 Hotspot 虚拟机中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
3.3 堆
一般 Java 程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。
栈上的局部变量表,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出 OutOfMemory 错误。
堆空间有三个需要关注的值:
- used: 当前已使用的堆内存。
- total: Java 虚拟机已分配的可用堆内存。
- max: Java 虚拟机可以分配的最大堆内存。
在 Arthas 中查看堆内存相关的功能:dashboard -i 刷新频率(毫秒)
随着堆中的对象增多,当 total 可以使用的内存即将不足时,Java 虚拟机会继续分配内存给堆。
如果堆内存不足,Java 虚拟机就会不断的分配内存,total 值会变大。total 最多只能与 max 相等。
如果不设置任何的虚拟机参数,max 默认是系统内存的 1/4 ,total 默认是系统内存的 1/64。在实际应用中一般都需要设置 total 和 max 的值。
堆-设置大小: 要修改堆的大小,可以使用虚拟机参数 -Xmx(max 最大值) 和 -Xms(初始的 total)
- 语法: -Xmx值 -Xms值
- 单位: 字节(默认必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
- 限制: Xmx 必须大于 2 MB, Xms 必须大于 1 MB
Java 服务端程序开发时,建议将 -Xmx 和 -Xms 设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向 Java 虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。
3.4 方法区
方法区(Method Area): 是存放基础信息的位置,线程共享,主要包含三部分内容
- 类的元信息: 保存了所有类的基本信息。
- 运行时常量池: 保存了字节码文件中的常量池内容。
- 字符串常量池: 保存了字符串常量。
方法区的作用:
- 方法区是用来存储每个类的基本信息(元信息),一般称之为 InstanceKlass 对象。在类的加载阶段完成。
- 方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
- 方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。字符串常量池存储在代码中定义的常量字符串内容。
方法区是《Java 虚拟机规范》 中设计的虚拟概念,每款 Java 虚拟机在实现上都各不相同。
Hotspot设计中,JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数控制。-XX:MaxPermSize=值
Hotspot设计中,JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。-XX:MaxMetaspaceSize=值
可以通过 ByteBuddy 框架,动态生成字节码文件,加载到内存中。
3.5 直接内存
直接内存 (Direct Memory) 并不在 《Java虚拟机规范》中存在,所以并不属于 Java 运行时的内存区域。
在 JDK1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
1.Java 堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
2.IO操作比如读文件,需要先把文件读入到直接内存(缓存区)再把数据复制到 Java 堆中。现在直接放入直接内存即可,同时 Java 堆上维护直接内存的引用,减少了数据复制的开销,写文件也是类似的思路。
要创建直接内存上的数据,可以使用 ByteBuffer
- 语法:ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
- 注意事项: arthas 的 memory 命令可以查看直接内存的大小,属性名为 direct。
如果需要手动调整直接内存的大小,可以使用 -XX:MaxDirectMemorySize=大小。单位:k/K、m/M、g/G
默认不设置该参数情况下,JVM 自动选择最大分配的大小。
4. JVM 垃圾回收
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
Java 中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection 简称GC)。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。
自动垃圾回收:自动根据对象是否使用由虚拟机来回收对象。
- 优点: 降低程序员实现难度、降低对象回收 bug 的可能性。
- 缺点: 程序员无法控制内存回收的及时性。
手动垃圾回收: 由程序员编程实现对象的删除。
- 优点: 回收即时性高,由程序员把控回收时机。
- 缺点: 编写不当容易出现悬空指针、重复释放、内存泄漏等问题。
4.1 方法区的回收
方法区中能回收的内容主要就是不再使用的类。
判定一个类可以被卸载。需要同时满足下面三个条件。
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用。
手动触发回收
- 语法: System.gc()
注意事项:调用 System.gc() 方法并不一定会立即回收垃圾,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收 Java 虚拟机会自行判断。
开发中此类场景一般很少出现,主要在 OSGI、JSP 的热部署等应用场景中。
每个 JSP 文件对应一个唯一的类加载器,当一个 JSP 文件修改了,就直接卸载这个 JSP 类加载器,重新创建类加载器,重新加载 JSP 文件。
4.2 堆回收
如何判断堆上的对象可以回收?只有无法通过引用获取到对象时,该对象才能被回收。
Java 中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
常见的判断堆上对象没有被引用的判断方法:
- 引用计数法
- 可达性分析法
Java 使用可达性分析法来判断对象是否可以被回收。
4.2.1 引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1。
引用计数法的优点是实现简单,C++ 中的智能指针就采用了引用计数法,但它也存在缺点,主要有两点:
- 每次引用和取消引用都需要维护计数器,对系统性能会有一定影响。
- 存在循环引用问题,循环引用就是A引用B,B同时引用A时会出现对象无法回收的问题。
如果想查看垃圾回收的信息,可以使用 -verbose:gc 参数。
4.2.2 可达性分析法
可达性分析将对象分为两类: 垃圾回收的根对象(GC Root)和普通对象,根对象与普通对象之间存在引用关系。
可达性分析算法指的是如果从某个到 GC Root 对象是可达的,对象就不可被回收。
哪些对象被称之为 GC Root 对象:
- 线程 Thread 对象。
- 系统类加载器加载的 java.lang.Class 对象,引用类中的静态变量。
- 监视器对象,用来保存同步锁 synchronized 关键字持有的对象。
- 本地方法调用时使用的全局对象。
4.2.3 五种对象引用
可达性算法中描述的对象引用,一般指的是强引用,即是 GC Root 对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。
其他引用方式:
- 软引用
- 弱引用
- 虚引用
- 终结器引用
4.2.3.1 软引用对象
软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
在 JDK1.2 版之后提供了 SoftReference 类来实现软引用,软引用常用于缓存中。
软引用中的对象如果在内存不足时回收,SoftReference 对象本身也需要被回收。
SoftReference 提供了一套队列机制:
- 软引用创建时,通过构造器传入引用队列。
- 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列。
- 通过代码遍历引用队列,将 SoftReference 的强引用删除。
4.2.3.2 弱引用对象
弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
在 JDK1.2 版之后提供了 WeakReference 类来实现弱引用,弱引用主要在 ThreadLocal 中使用。
弱引用对象本身也可以使用引用队列进行回收。
4.2.3.3 虚引用和终结器引用
这两种引用在常规开发中是不会使用的。
虚引用也叫幽灵引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java 中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
终结器引用指的是在对象需要被回收时,对象将会被放置在 Finalize 类中的引用队列中,并在稍后由一条由 FinalizerThread 线程从队列中获取对象,然后执行对象的 Finalize 方法,在这个过程中可以在 finalize 方法中再将自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收。
4.2.4 垃圾回收算法
垃圾回收算法的核心思想:
- 找到内存中存活的对象。
- 释放不再存活对象的内存,使得程序能再次利用这部分空间。
1960 年 John McCarthy 发布了第一个 GC 算法: 标记清除算法。
1963 年 Marvin L. Minsky 发布了复制算法。
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。
垃圾回收算法的分类:
- 标记清除算法: Mark Sweep GC
- 复制算法: Coping GC
- 标记-整理算法: Mark Compact GC
- 分代GC: Generational GC
垃圾回收算法的评价标准: Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为 Stop the World 简称 STW,如果 STW 时间过长则会影响用户使用。
判断 GC 算法是否优秀,可以从三个方面来考虑:
- 吞吐量: 指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 / (执行用户代码时间 + GC 时间),吞吐量数值越高,垃圾回收的效率就越高。
- 最大暂停时间: 指的是所有在垃圾回收过程中的 STW 时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短。
- 堆使用效率: 不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,最大暂停时间就越长,想要减少最大暂停时间,就会降低吞吐量。
不同的垃圾回收算法,适用于不同的场景。
4.2.4.1 标记清除算法
标记清除算法的核心思想分为两个阶段:
- 标记阶段: 将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
- 清除阶段: 从内存中删除没有被标记也就是非存活对象。
标记清除算法的优点: 实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
标记清除算法的缺点:
- 碎片化问题: 由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
- 分配速度慢: 由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
4.2.4.2 复制算法
复制算法的核心思想是:
- 准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用其中一块空间( From 空间)。
- 在垃圾回收 GC 阶段,将 FROM 中存活对象复制到 To 空间。
- 将两块空间的 From 和 To 名字互换。
复制算法的优点:
- 吞吐量高: 复制算法只需要遍历一次存活对象复制到 To 空间即可,比标记整理算法少了一次遍历的过程,因而性能较好,但是不如标记清除算法,因为标记清除算法不需要进行对象的移动。
- 不会发生碎片化: 复制算法在复制之后就会将对象按顺序放入 To 空间,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
复制算法的缺点:
- 内存使用效率低: 每次只能让一半的内存空间来为创建对象使用。
4.2.4.3 标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
标记整理算法的核心思想:
- 标记阶段: 将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
- 整理阶段: 将存活对象移动到堆的一端。清理掉存活对象的内存空间。
标记整理算法的优点:
- 内存使用效率高: 整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
- 不会发生碎片化: 在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。
标记整理算法的缺点:
- 整理阶段的效率不高: 整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过 Two-Finger、表格算法、ImmixGC 等高效的整理算法优化此阶段的性能。
4.2.4.4 分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代。
arthas 查看分代之后的内存情况:
- 在 JDK8 中,添加 -XX:+UseSerialGC 参数使用分代回收的垃圾回收器,运行程序,
- 在 Arthas 中使用 memory 命令查看内存,显示出三个内存区域的情况,eden_space、survivor_space、tenured_gen
分代垃圾回收算法:
- 分代回收时,创建出来的对象,首先会被放入 Eden 伊甸园区。
- 随着对象在 Eden 区越来越多,如果 Eden 区满,新创建的对象已经无法放入,就会触发年轻代的 GC,称为 Minor GC 或者 Young GC。
- Minor GC 会把需要 eden 中和 From 需要回收的对象回收,把没有回收的对象放入 To 区。
接下来,S0 会变成 To 区,S1 变成 From 区。当 eden 区满时再往里放入对象,依然会发生 Minor GC。
此时会回收 eden 区和 S1(from) 中的对象,并把 eden 和 from 区中剩余的对象放入 S0。
注意:每次 Minor GC 中都会为对象记录他的年龄,初始值为 0,每次 GC 完加 1 。
如果 Minor GC 后对象的年龄达到阀值 (最大 15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
当老年代中空间不足,无法放入新的对象时,先尝试 minor gc 如果还是不足,就会触发 Full GC,Full GC 会对整个堆进行垃圾回收。
如果 Full GC 依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出 Out of Memory 异常。
4.2.5 垃圾回收器
垃圾回收器是垃圾回收算法的具体实现。
由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。
具体的关系图如下。