目录
diyClassLoader.propertiies配置文件
[Parallel Scavenge收集器](#Parallel Scavenge收集器)
[Serial Old收集器](#Serial Old收集器)
[Parallel Old收集器](#Parallel Old收集器)
从整体上认识JVM
JVM是什么
我会把JVM理解为一个运行Java程序的平台,操作系统并不能识别Java源文件生成的.class字节码文件,但可以通过JVM加载字节码后翻译成机器指令交给操作系统执行,为Java程序的执行提供了保障
JVM组成架构
从全局上来看,JVM由多个系统组成,主要有:类加载系统、执行引擎系统、运行时数据区、垃圾回收系统以及本地接口和本地方法库;在Java程序启动和执行过程中,这些系统相互配合组成了整个Java的执行平台JVM,下面先简单介绍一下这些系统
类加载系统
用于加载Java源文件编译后生成的字节码文件,将符合格式要求的字节码数据加载进内存;类加载系统中,较为核心的部分有:类加载器、类加载过程和双亲委派机制
执行引擎系统
负责将加载进内存的class字节码指令转换成机器指令交给硬件执行,字节码指令可以通过解释器和即时编译器两种方式转换为机器指令
运行时数据区
编写的所有代码都会被加载到这里之后再开始执行,运行时数据区主要分为:PC程序计数器、线程栈、本地方法栈、元数据空间和堆
垃圾回收系统
帮助Java程序管理内存,对于垃圾对象的清除、存活对象的管理以及内存碎片的回收都由垃圾回收系统负责,核心部分有:垃圾收集算法、垃圾收集器和GC调优
本地接口和本地方法库
本地接口是Java调用非Java代码的接口,一般指C语言;本地方法库是在内存中专门开辟了一块区域,用于处理标记为native的方法,在执行引擎执行到调用本地方法时,将C语言编写的本地方法库加载出来
剖析类加载系统
类加载的概念
当JVM需要用到某个类时,会加载它的字节码文件,之后为它创建对应的Class对象,这个过程被称为类加载
类加载过程
加载
通过完全限定名查找class字节码文件二进制数据并将其加载进内存的过程
- 通过完全限定名定位到字节码文件,并获取其二进制字节流数据
- 把字节流的静态存储结构转换为运行时数据结构
- 在堆中为其创建一个Class对象,作为程序访问数据的入口
连接
连接包含验证、准备和解析,其中解析可能发生在初始化之后
验证
确保被加载的字节码数据的正确性,检测字节流中的数据是否符合虚拟机的要求,确保不会危害虚拟机自身安全,主要有:文件格式验证、元数据验证、字节码验证和符号引用验证
准备
为类中声明的静态变量分配内存空间,并将其初始化为默认值(数据类型对应的默认值)
解析
把类中对常量池内的符号引用转换为直接引用的过程
初始化
对类中的静态变量赋予初始值,执行类中的静态代码块
类加载器
虚拟机提供了三种类加载器,用户也可以自定义类加载器
- BootstrapClassLoader:引导类加载器
- ExtClassLoader:扩展类加载器
- AppClassLoader:系统类加载器或应用程序类加载器
- 用户自定义类加载器
BootstrapClassLoader
使用C++实现的,是JVM自身的一部分,主要负责将 JAVA_HOME/lib 路径下的核心类库加载到内存中,BootstrapClassLoader只为JVM提供服务,我们不能直接使用它来加载自己定义的类
ExtClassLoader
是sun.misc.Launcher类中的一个内部类ExtClassLoader,主要负责加载 JAVA_HOME/lib/ext 路径下的类库
java
package sun.misc;
// ...
public class Launcher {
// ...
// 扩展类加载器
static class ExtClassLoader extends URLClassLoader {
// ...
}
// ...
}
AppClassLoader
是sun.misc.Launcher类中的一个内部类AppClassLoader,主要负责加载类路径(常说的classpath路径)下的类,一般情况下,程序默认的类加载器就是AppClassLoader
java
package sun.misc;
// ...
public class Launcher {
// ...
// 系统类加载器
static class AppClassLoader extends URLClassLoader {
// ...
}
// ...
}
自定义类加载器
Java程序运行时一般都是通过如上三种类加载器相互配合执行的,如果有特殊的加载需求也可以通过继承ClassLoader类来实现自定义类加载器
四种类加载器之间的关系
先说结论
JVM启动时初始化BootstrapClassLoader,BootstrapClassLoader加载ExtClassLoader,并将ExtClassLoader父加载器设置为BootstrapClassLoader;BootstrapClassLoader加载完ExtClassLoader后接着加载AppClassLoader,并将AppClassLoader的父加载器设置为ExtClassLoader;自定义类加载器由AppClassLoader加载,加载完成后,AppClassLoader会成为自定义类加载器的父加载器
源码分析
java
package sun.misc;
// ...
public class Launcher {
// ...
public Launcher() {
ExtClassLoader var1;
try {
// 先初始化并创建ExtClassLoader,将其父加载器设置为null,因为BootstrapClassLoader是C++编写的
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
// 再创建AppClassLoader并将ExtClassLoader传递给AppClassLoader作为它的父加载器
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
// 将AppClassLoader设置为线程上下文类加载器
Thread.currentThread().setContextClassLoader(this.loader);
// ...
}
// ...
}
测试验证
java
package classload;
/**
* CustomizeClassLoader继承了ClassLoader,属于自定义类加载器
*/
public class CustomizeClassLoader extends ClassLoader {
public static void main(String[] args) {
CustomizeClassLoader customizeClassLoader = new CustomizeClassLoader();
printDetail("自定义类加载器", customizeClassLoader);
ClassLoader appClassLoader = customizeClassLoader.getParent();
printDetail("自定义类加载器的父加载器", appClassLoader);
printDetail("程序默认类加载器", ClassLoader.getSystemClassLoader());
printDetail("线程上下文类加载器", Thread.currentThread().getContextClassLoader());
ClassLoader extClassLoader = appClassLoader.getParent();
printDetail("AppClassLoader的父加载器", extClassLoader);
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
printDetail("ExtClassLoader的父加载器", bootstrapClassLoader);
}
public static void printDetail(String log, Object classLoader) {
String result = String.format("%s:%s", log, classLoader);
System.out.println(result);
}
}
bash
自定义类加载器:classload.CustomizeClassLoader@17550481
自定义类加载器的父加载器:sun.misc.Launcher$AppClassLoader@3eb07fd3
程序默认类加载器:sun.misc.Launcher$AppClassLoader@3eb07fd3
线程上下文类加载器:sun.misc.Launcher$AppClassLoader@3eb07fd3
AppClassLoader的父加载器:sun.misc.Launcher$ExtClassLoader@edf4efb
ExtClassLoader的父加载器:null
双亲委派机制
核心思想
- 从下往上(AppClassLoader -> ExtClassLoader -> BootstrapClassLoader)检查类是否已经被加载
- 从上至下(BootstrapClassLoader -> ExtClassLoader -> AppClassLoader)尝试加载类
具体过程
- 当AppClassLoader加载一个类时,它不会直接尝试加载这个类,首先会在自己的命名空间查找是否加载过这个类,如果没有会先将这个类加载请求委派给它的父加载器ExtClassLoader
- 当ExtClassLoader加载一个类时,它不会直接尝试加载这个类,也会在自己的命名空间查找是否加载过这个类,没有的话也会先将这个类加载请求委派给它的父加载器BootstrapClassLoader
- 如果BootstrapClassLoader加载失败,代表这个需要加载的类不在BootstrapClassLoader的加载范围内,会重新将这个类加载请求交给ExtClassLoader
- 如果ExtClassLoader加载失败,代表这个需要加载的类不在ExtClassLoader的加载范围内,会重新将这个类加载请求交给AppClassLoader
- 如果AppClassLoader也加载失败,代表这个类根据全限定名无法查找到,抛出ClassNotFoundException异常
好处
- 避免一个类在不同层级的类加载器中重复加载,如果父加载器已经加载过的类,不需要子加载器再加载一次
- 保障Java核心类的安全性问题,有效防止Java核心类在运行时被篡改
源码分析
其实双亲委派机制的实现逻辑就体现在ClassLoader类中的loadClass方法,ExtClassLoader没有重写loadClass方法,而AppClassLoader虽然重写了loadClass方法,但是最终调用的还是父类的loadClass方法,所以无论是ExtClassLoader还是AppClassLoader都没有打破父类的loadClass中定义的双亲委派逻辑,BootstrapClassLoader、ExtClassLoader、AppClassLoader这些JVM自带的类加载器默认遵守双亲委派机制
java
package java.lang;
// ...
public abstract class ClassLoader {
// ...
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 用全限定名先尝试从自己的命名空间中查找Class对象
Class<?> c = findLoadedClass(name);
// 如果找到就不需要加载了,如果没找到就开始类加载
if (c == null) {
long t0 = System.nanoTime();
try {
// 先将类加载任务委托给自己的父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 如果父加载器为null,代表当前为ExtClassLoader
// 直接委托给BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 如果都没有找到,则执行自定义实现的findClass去查找并加载
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 是否需要在加载时进行解析
if (resolve) {
resolveClass(c);
}
// 返回加载后生成的Class对象
return c;
}
}
// ...
}
打破双亲委派机制
Java中提供了很多核心接口的定义,被称为SPI接口,为了方便加载第三方的实现类,SPI提供了一种动态服务发现机制(一种约定或者规范),只要第三方编写实现类时,在工程内新建META-INF/services/目录并在该目录下创建一个和服务接口全限定名同名的文件,那么在程序启动时,就会根据约定找到所有符合规范的实现类,然后交给线程上下文类加载器加载。
具体案例
DriverManager类
Java中的DriverManager核心类位于rt.jar包中,是Java用于管理不同数据库厂商实现的驱动,数据库厂商实现的驱动类需实现java.sql.Driver接口,这里先看看DriverManager部分源码
java
package java.sql;
// ...
public class DriverManager {
// ...
/**
* Load the initial JDBC drivers by checking the System property
* jdbc.properties and then use the {@code ServiceLoader} mechanism
*/
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
// 通过ServiceLoader加载Driver的实现类(Java的SPI机制)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ...
}
});
}
// ...
}
ServiceLoader的load方法
java
package java.util;
// ...
public final class ServiceLoader<S>
implements Iterable<S>
{
private static final String PREFIX = "META-INF/services/";
// ...
// 接上文中的例子,这里传入的参数为Driver的Class对象
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程上下文类加载器。默认为AppClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 通过AppClassLoader加载Driver接口的实现类
return ServiceLoader.load(service, cl);
}
// ...
}
MySQL实现的驱动类Driver
项目中引入了MySQL8.0


整个执行流程总结
DriverManager核心类位于rt.jar包,通过BootstrapClassLoader加载器加载,在DriverManager中需要加载Driver接口的实现类(如com.mysql.cj.jdbc.Driver),但它的实现类位于classpath类路径下,这里直接获取到线程上下文类加载器(AppClassLoader)进行加载类路径下的实现类,打破了双亲委派机制
自定义类加载器使用举例
这里自实现一个类加载器(继承ClassLoader),用于加载指定目录下的class字节码文件
DiyClassLoader自定义类加载器
java
package classloader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Paths;
/**
* 自定义类加载器
*/
public class DiyClassLoader extends ClassLoader {
// .class文件存放的根目录
private final String rootPath;
// 字节码文件后缀名
public static final String CLASS_SUFFIX = ".class";
// 指定根路径
public DiyClassLoader(String rootPath) {
this.rootPath = rootPath;
}
/**
* 重写ClassLoader的findClass方法
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 获取字节码文件的字节流数据
byte[] classData = getClassFileByteData(name);
if (classData.length == 0) {
throw new ClassNotFoundException("Failed to load class " + name);
}
// 这里调用defineClass方法,defineClass方法执行类的加载、验证、准备、解析等操作
// name已是全限定名
return defineClass(name, classData, 0, classData.length);
} catch (Exception e) {
throw new ClassNotFoundException("Failed to load class " + name, e);
}
}
/**
* @param fullClassName:类的完全限定名
* @return:class文件字节流数据
*/
private byte[] getClassFileByteData(String fullClassName) throws Exception {
// 转换为全路径
File targetFile = Paths.get(rootPath, (fullClassName.replace(".", "/") + CLASS_SUFFIX)).toFile();
// 返回字节流数据
try (FileInputStream fis = new FileInputStream(targetFile);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
}
}
}
定义一个工具类,方便使用自定义的类加载器
java
package classloader;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
/**
* 自定义类加载器工具类
*/
public class DiyClassLoaderUtils {
private DiyClassLoaderUtils() {
}
// 自定义的类加载器
public static final ClassLoader DIY_CLASS_LOADER;
// 指定存放由自定义类加载器加载的类的根路径
public static final String ROOT_PATH;
public static final String ROOT_PATH_KEY = "rootPath";
static {
// 加载配置文件中指定的根路径
try (InputStream stream = ClassLoader.getSystemResourceAsStream("diyClassLoader.propertiies")) {
Properties properties = new Properties();
properties.load(stream);
// 根路径赋值
ROOT_PATH = properties.getProperty(ROOT_PATH_KEY);
// 通过根路径来实例化自定义类加载器对象
DIY_CLASS_LOADER = new DiyClassLoader(ROOT_PATH);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* @param fullClassName:全限定名
* @return:该类的Class对象
*/
public static Class<?> getClassForDiyClassLoader(String fullClassName) throws ClassNotFoundException {
return DIY_CLASS_LOADER.loadClass(fullClassName);
}
}
diyClassLoader.propertiies配置文件
bash
rootPath=/Users/wangxinjie/IdeaProjects
将字节码文件放入指定目录
注:完整目录为配置的rootPath+包的路径
java
package classloader;
public class DiyClassLoaderTest {
public static void main(String[] args) {
printSuccess();
}
private static void printSuccess(){
System.out.println("diy classLoader test success...");
}
}

使用自定义的类加载器
测试类:
java
package classloader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class MainTest {
public static void main(String[] args) throws Exception {
// 自定义类加载器 -> Class对象
Class<?> cls = DiyClassLoaderUtils.getClassForDiyClassLoader("classloader.DiyClassLoaderTest");
System.out.println(cls.getClassLoader());
// 执行main方法
Constructor<?> constructor = cls.getConstructor();
Object instance = constructor.newInstance();
Method main = cls.getDeclaredMethod("main", String[].class);
main.invoke(instance, (Object) new String[]{});
}
}
运行结果:
bash
classloader.DiyClassLoader@b684286
diy classLoader test success...
详解执行引擎系统
概述
虚拟机的执行引擎负责解释编译执行自身定义的指令集代码,Java字节码指令无法直接被操作系统识别,但一个Java程序却能在操作系统上运行起来的根本原因是因为Java虚拟机的执行引擎系统
工作模式
主要工作是把字节码指令解释或者编译成对应平台上的本地机器指令,现代高性能Java虚拟机的执行引擎默认采用解释器+JIT即时编译器共存的模型工作,程序启动时,可以通过JVM参数指定工作模式
bash
# 完全采用解释器模式执行程序
-Xint
# 完全采用JIT即时编译器模式执行程序
-Xcomp
# 采用解释器+JIT即时编译器混合模式共同执行程序(默认工作模式)
-Xmixed
执行器
执行引擎系统中包含两种执行器:解释器和即时编译器
解释器
执行引擎获取到由前端编译器javac编译后的.class字节码文件后,在运行时是通过解释器转换为最终的机器码执行
JIT即时编译器
为了提高执行效率,Java虚拟机引入了JIT即时编译技术,即时编译器的作用是将经常执行的热点代码编译成本地的机器码,后续执行直接执行对应的机器码即可
热点探测技术
热点代码的判断基准是通过热点探测技术,当某个方法或代码块被调用执行的次数在一定的时间达到了规定的阈值,那么JIT即时编译器就会对该代码进行优化并将其直接编译成机器码,提升程序执行性能
计数器
热点探测技术是基于计算器来实现的,Java虚拟机(HotSpot)会为每个方法创建两种计数器:方法调用计数器和回边计数器
方法调用计数器
在Server模式下的虚拟机,它的默认阈值为10000次,当达到阈值后触发JIT即时编译,后台线程编译完成后会生成本地机器指令存放于CodeCache(热点代码缓冲区,Server模式下默认大小为2496KB,位于元空间)中
bash
# 设置CodeCache的最大大小
-XX:ReservedCodeCacheSize=128m
回边计数器
用于统计方法中循环体的执行次数,同样的当达到阈值触发JIT即时编译
bash
# 设置计数器的阈值
-XX:CompileThreshold=5000
小项目的性能优化
一般来讲,如果项目规模不大,且上线后较长一段时间不需要进行版本迭代,就可以尝试关闭热度衰减(关闭后,方法调用计数器的判断基准变为绝对调用次数),这样可以使Java程序在线上运行的时间越久,执行性能越好(程序中频繁调用的方法会被编译为本地机器码)
bash
# 关闭热度衰减
-XX:-UseCounterDecay
运行时内存区域
也叫运行时数据区,JVM运行Java程序,会把自身管理的内存分为若干个不同的数据区域,主要包含:程序计数器、线程栈(虚拟机栈)、本地方法栈、元空间和堆,从程序运行的角度来看,可以分为线程私有区域和线程共享区域
线程私有区域
Java程序运行时,创建每条线程JVM都会为它分配的区域,这些区域的生命周期同线程一样,随着线程的启动死亡而创建销毁,其他线程是不可见的,只有当前线程能访问
程序计数器
JVM为每个线程创建的较小区域,用于记录当前线程正在执行的字节码指令地址,当执行引擎执行完某个字节码指令后,程序计数器需要进行对应的更新,将指针改成下一条要执行的指令地址,执行引擎会根据程序计数器中记录的地址进行对应指令的执行,当执行到C编写的native方法时,计数器则为Undefined,保证线程发生CPU时间片切换后能恢复到正确的位置执行;该区域是JVM所有区域中唯一不会发生OOM(内存溢出)的区域
虚拟机栈
也叫线程栈,作为程序运行时执行的单位,负责程序运行时具体如何执行如何处理数据,每个线程创建时,都会为其创建一个虚拟机栈,默认大小为1M
bash
# 设置线程栈的大小
-Xss1m
栈帧
每个方法的调用到执行结束都对应着虚拟机栈中一个栈帧的入栈到出栈的过程,对于执行引擎而言,只会操作栈顶的栈帧,与它关联的方法叫做当前方法,栈帧包含局部变量表、操作数栈、动态链接和方法出口
存在两种异常
虚拟机栈区域中不存在垃圾回收,但存在OOM,在Java虚拟机规范中,对该区域规定了如下两种异常
- StackOverflowError:当前线程请求的栈深度大于虚拟机栈允许的深度时抛出的异常
- OutOfMemoryError:无法申请到足够的空间时抛出的异常
本地方法栈
用于执行C语言编写的native本地方法,本地方法会被编译为基于本机硬件和操作系统的程序,本地方法是运行在操作系统而非JVM中,使用的是操作系统的程序计数器而不是JVM的程序计数器
线程共享区域
在程序运行过程中,这些区域对于所有线程是可见的,主要包含:堆和元空间
堆
概述
JVM启动时创建出来,对于JVM来说,堆空间是唯一的,每个JVM只会存在一个堆空间,同时容量大小会在创建时就被确定,堆空间主要解决的是数据存储问题,程序执行过程中,大部分的实例对象和数组都会被放到堆中存储
bash
# 堆的起始内存大小
-Xms1g
# 堆的最大内存大小
-Xmx1g
内存划分
Java堆空间结构变化是比较频繁的,从根本上来讲,影响堆结构划分的原因是JVM运行时所使用的垃圾收集器,由垃圾收集器决定了堆空间的结构划分;在Java8以及之前的版本中,几乎所有的垃圾收集器都会把堆空间划分为至少新生代和年老代两个区域,而在Java9之后的垃圾收集器中,大多数垃圾收集器并不分代
- JDK7及之前:堆包含新生代、年老代、永久代
- JDK8:堆包含新生代和年老代,永久代改为元空间,位于堆外
- JDK9:堆从逻辑上保留了分代概念,但是物理内存并不分代
- JDK11:堆不存在分代的概念
JDK8堆空间的内存划分
内嵌默认的垃圾收集器为:Parallel Scavenge + Parallel Old,堆空间被划分为新生代和老年代,默认情况下新生代和老年代空间占比为1:2
- 新生代:一个Eden区和两个Survivor区(s0、s1区),默认比例8:1:1(实际情况并非如此,而是6:1:1,因为JVM存在自适应机制)
- 老年代:一个Old区
bash
# 设置新生代和老年代空间占比,新生代占用堆空间的 1/(1+N)
-XX:NewRatio=N
# 设置Survivor区和Eden区空间占比(Eden:S0:S1 = N : 1 : 1)
-XX:SurvivorRatio=N
JDK9堆空间的内存划分
默认垃圾收集器为G1,堆内存物理分区,堆中内存区域被划分为一个个的Region区,但是逻辑上还是分代的,源码中定义了Region的数量限制为2048个,实际可以超过2048个,但超过该数量后内存难以管理
- Eden Region
- Survivor Region
- Old Region
- Humongous Region:大对象直接放到该区域,对象大小超过一个Region大小的50%为大对象
bash
# 设置每个区的空间大小(建议使用默认值,不去设置)
-XX:G1HeapRegionSize=4m
# 设置新生代初始占比(默认占堆内存空间的5%,这里设置的就是5%)
-XX:G1NewSizePercent=5
# 设置新生代最多占用堆空间的60%(默认就是60%)
-XX:G1MaxNewSizePercent=60
元空间
主要用于存放运行时常量池和类的元数据信息,它的默认最大值受限于本地内存的总量,元空间在类加载时会动态扩容,建议为其设置一个上限容量,
bash
# 设置元空间初始容量
-XX:MetaspaceSize=256m
# 设置元空间最大容量
-XX:MaxMetaspaceSize=256m
内存溢出
OutOfMemoryError内存溢出错误,在JVM内存区域中,除了程序计数器之外,其他区域都存在内存溢出的风险
堆空间内存溢出
案例
用于测试堆内存溢出的Java程序
java
package oom;
import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
public static void main(String[] args) {
List<Object> container = new ArrayList<>();
while (true) {
container.add(new TestOOMObject());
}
}
private static class TestOOMObject {
private byte[] data = new byte[512];
}
}
设置JVM启动参数
bash
# 设置初始堆内存10M,最大堆内存10M
-Xms10m -Xmx10m
运行结果如下

线上堆空间OOM原因
- 内存中加载数据量过于庞大导致OOM,如一次性从数据库中查询很多条数据导致创建一个大型数据数组
- 系统流量超出原有的预估值,导致大量请求进入系统,创建大量对象,堆内存过小出现OOM
元空间内存溢出
元空间主要存储类元信息和运行时常量池,这里对于测试元空间内存溢出的思路是运行时产生大量类字节码,使得元空间内存被占满,导致OOM
案例
用于测试元空间内存溢出的Java程序
java
package oom;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
public class MetaSpaceOOM {
public static void main(String[] args) {
while (true) {
// 不断创建字节码信息
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MetaSpaceOOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor)
(o, method, objects, methodProxy)
-> methodProxy.invokeSuper(o, args));
enhancer.create();
}
}
public static class MetaSpaceOOMObject {
}
}
设置JVM参数
bash
# 设置元空间初始容量和最大容量都为10M
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
运行结果如下

元空间OOM原因
- 加载的类信息过多
- JIT即时编译器生成的热点代码过多
- 运行时常量池溢出
对象实例
对象的内存布局
Java对象在内存中一般由对象头、实例数据和对齐填充三部分组成
对象头
对象头的结构较复杂,包含MarkWord、类型指针(KlassWord)、数组长度
MarkWord
占用8个字节,存储着unused、HashCode、分代年龄、是否偏向锁和锁标记位
KlassWord
占用4个字节(开启指针压缩),类型指针指向类元数据,JVM通过这个指针确定该对象是哪个类的实例
bash
# 开启指针压缩(默认开启)
-XX:+UseCompressedOops
数组长度
占用4个字节,如果该对象是数组对象的话,对象头中会记录数组长度
实例数据
java
package oom;
public class ObjectInstance {
// 4个字节
private int a;
// 8个字节
private long b;
// 4个字节(开启指针压缩)
private Object obj;
}
对齐填充
在Java对象中可能存在也可能不存在,Java对象的总大小必须为8字节的整数倍,如果不足整数倍就会出现对齐填充,将对象大小补成8的整数倍
查看对象占用内存大小
添加Maven依赖
XML
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
Object对象
java
package oom;
import org.openjdk.jol.info.ClassLayout;
public class WatchObj {
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

数组对象
java
package oom;
import org.openjdk.jol.info.ClassLayout;
public class WatchObj {
public static void main(String[] args) {
// 4*9 + 8 + 4 + 4 = 52 + 4 = 56
int[] arrayObj = new int[9];
System.out.println(ClassLayout.parseInstance(arrayObj).toPrintable());
}
}

自定义对象
java
package oom;
import org.openjdk.jol.info.ClassLayout;
public class WatchObj {
public static void main(String[] args) {
// 8 + 4 + 4 + 8 + 4 + 4 + 4 + 4 = 40
CustomerObject customerObject = new CustomerObject();
System.out.println(ClassLayout.parseInstance(customerObject).toPrintable());
}
private static class CustomerObject {
private int a;
private long b;
private int[] c;
private long[] d = new long[7];
private Object e;
}
}

对象分配过程
Java中,创建对象的方式有很多种(如:new关键字、反射机制、反序列化、第三方库等),但无论通过何种方式创建对象,底层都将创建过程分为:类加载检测、内存分配、初始化内存、设置对象头、执行构造方法
类加载检测
通过指令的参数在常量池中定位到符号引用,检查该符号引用所代表的类是否经过加载解析初始化,若没有则执行类的加载过程(加载 -> 验证 -> 准备 -> 解析 -> 初始化)
内存分配
尝试栈上分配
JIT即时编译器的激进优化,建立在逃逸分析基础上,将对象分配在线程栈的局部变量表中,减少对象实例的产生,减少堆内存的使用和GC次数
尝试TLAB分配
Thread Local Allocation Buffer,Java虚拟机在Eden区为每个线程划分的一块私有区域叫TLAB区,默认占Eden区的1%大小;大部分对象是分配在堆中,堆是线程共享区域,会发生线程安全问题可能多个线程同时往一块内存上分配,这种竞争会带来性能开销;总结:若开启了TLAB分配,当一个线程尝试为一个对象分配内存时,会先尝试在TLAB区域进行分配
bash
# 开启TLAB分配(默认开启)
-XX:+UseTLAB
# 设置TLAB区和Eden区的占比(默认N=1,即TLAB区占用Eden区的1%)
-XX:TLABWasteTargetPercent=N
老年代分配
尝试TLAB分配失败后,会进行判定是否满足老年代分配标准,若满足则直接分配到老年代,老年代分配条件:大对象、长期存活对象、动态年龄判断符合条件的对象
bash
# 设置大对象的阈值(适用于ParNew垃圾收集器)
-XX:PretenureSizeThreshold=1m
新生代分配
若栈上分配、TLAB分配、老年代分配都未成功,此时就来到Eden区尝试新生代分配,在新生代分配中,会有两种分配方式
- 指针碰撞:Java中为新对象分配堆内存的一种方式,适用于不会产生内存碎片的垃圾收集器,如ParNew
- 空闲列表:同样是Java中为新对象分配堆内存的一种方式,适用于会产生内存碎片的垃圾收集器,如CMS
初始化内存
经过内存分配后,当前创建的Java对象会在内存中被分配到一块区域,接着初始化分配到的这块空间,JVM将分配到的内存空间(不包括对象头)都初始化为零值,可以保证对象的实例字段在Java代码中不赋初始值就能直接使用,程序可以访问到数据类型所对应的零值,避免空指针异常
设置对象头
JVM对对象进行必要的设置,将信息写入对象头
- MarkWord(标记字):存储对象自身的运行时数据,如HashCode、GC分代年龄、锁标志、锁信息等
- KlassWord(类型指针):指向方法区的类元数据,JVM通过该指针知道对象属于哪个类
- 数组长度:如果对象是数组的话,额外记录数组的长度
执行构造方法
JVM调用对象的构造方法(字节码中的<init>),这个过程会调用父类的构造方法,最终完成对象的初始化,变成一个真正意义上的可用对象
- 执行实例变量的显式赋值
- 执行实例代码块
- 执行构造器中的语句
阐述垃圾回收系统
堆和元空间这两块区域的内存分配和回收都是动态的,垃圾收集机制所关注的就是这两块区域
判定垃圾的算法
垃圾收集机制只会回收运行过程中产生的垃圾对象,垃圾指的是运行过程中已经没有任何指针指向的对象,这些对象就是垃圾收集机制回收的目标
引用计数法
对象自身携带一个引用计数器,用于记录自身的引用情况;这种算法存在一个严重的缺点,就是无法处理两个或多个对象间的循环引用问题,正是因为这个缺点,Java中并没有采用这种算法作为判定对象存活的算法,而是采用了可达性分析算法实现对象存活判定
可达性分析算法
该算法中有一个GCRoots的概念,GC时,会以GCRoots为根节点,从上至下进行搜索分析,搜索走过的路线叫引用链,当一个对象没有被任何一个引用链相连时,就是不可用对象,即垃圾
可作为GCRoots的对象
- 线程栈中引用的对象
- 元空间中静态属性引用的对象
- 元空间运行时常量池中常量引用的对象
- 本地方法栈中引用的对象
垃圾收集时需要STW的原因之一
使用可达性分析算法判断对象是否存活和需要回收哪些对象时,必须要在一个保证一致性的内存快照中进行,如果不满足这个条件的话,会导致结果不准确,这也是垃圾收集时为啥要STW的一个重要原因,即使是使用CMS等号称不会发生STW的并发垃圾收集器,枚举根节点也是必须STW的
三色标记算法
自CMS收集器之后,应用比较广泛的一种并发标记算法,可以让JVM发生GC时只进行短暂的STW即可实现存活对象标记的一种算法,CMS垃圾收集器以及不分代收集器之所以能做到低延迟的根本原因就是使用了三色标记算法
垃圾回收算法
具体的垃圾回收的工作要交给垃圾收集算法和垃圾收集器来完成;垃圾收集算法是在堆内存不足时触发,需要先停止应用程序(STW),将JVM中所有的用户线程暂停,最大程度保证结果的准确性
标记-清除算法
分为标记阶段和清除阶段,标记阶段根据可达性分析算法,通过GCRoots标记所有存活对象,未被标记的即为垃圾对象,然后在清除阶段,会清除所有未被标记的对象
- 标记阶段会STW,遍历堆中所有的GCRootss,耗时长,可能造成应用长时间无响应
- GC后产生内存碎片,清理出来的内存不连续,需要维护空闲列表带来额外的开销
- 分配数组对象或大对象时,需要连续的内存空间,内存碎片多的话可能会导致没有足够的连续空间存放
复制算法
将内存划分为两块,同一时刻只会使用其中一块内存空间,当发生GC时,将使用的那块区域中存活对象复制到未使用的那块区域,复制完成后,对当前使用的区域进行全面回收
- 牺牲一部分内存,只能使用一半的内存空间
- 存活对象的复制,对象的移动开销,适合于新生代的垃圾收集策略(老年代空间分配担保机制),存活率较低
- 不适用于老年代的垃圾收集,一方面是老年代中对象存活率较高,生命周期较长,另一方面是没有新的内存区域为老年代提供空间分配担保
标记-整理算法
分为两个阶段,标记阶段和整理阶段;标记阶段会基于GCRoots节点遍历内存中的对象,标记存活对象,整理阶段会将所有存活对象移动到内存一端,然后对存活对象边界之外的内存进行统一回收
- 整体收集效率不高,因为不仅仅要标记对象,还要移动存活对象
- 一般的,老年代垃圾收集会使用标记整理算法,老年代垃圾回收次数没有新生代那么频繁,同时标记整理算法适合存活率较高的场景
垃圾收集种类
新生代收集(YoungGC)
发生在新生代的垃圾收集,当Eden区满了触发,发生次数较频繁,导致的STW几乎可以忽略不计
老年代收集(OldGC)
老年代满了触发的垃圾收集,通常发生OldGC时也会伴随发生YoungGC一起发生,目前只有CMS垃圾收集器存在单独收集老年代的行为
混合收集(MixedGC)
收集范围包括新生代空间和部分老年代空间,目前只有G1垃圾收集器有此行为
全面收集(FullGC)
是所有垃圾收集中最耗时、停顿最久的垃圾收集,会对所有可能发生垃圾收集的区域进行全面回收,涵盖新生代、老年代和元空间,一般触发FullGC的原因有如下几种
- 老年代空间不足时触发FullGC(CMS垃圾收集器除外)
- 元空间内存不足时触发FullGC
- 新生代对象晋升到老年代,老年代空间无法承载晋升对象时触发FullGC
- 发生空间分配担保机制时,会先触发FullGC
垃圾收集器
常见名词
- 串行收集:暂停所有用户线程,单条垃圾收集线程执行垃圾收集
- 并行收集:暂停所有用户线程,多条垃圾收集线程执行垃圾收集(依赖于多核CPU)
- 独占执行:垃圾收集线程执行时,抢占所有资源,整个应用程序暂停
- 并发执行:用户线程和垃圾收集线程同时(交替)执行,不会停下其中一类线程
- 吞吐量:吞吐量 = (用户线程执行总时长) / (用户线程执行总时长 + 垃圾收集线程执行总时长)
- 停顿时间:垃圾收集线程执行时,所有用户线程的暂停时间(整个应用程序暂停的时间)
- 吞吐量优先:保证程序更高吞吐,允许垃圾收集时长时间应用程序暂停
- 响应时间优先:确保用户更好的体验感,可以用吞吐量换取更快的响应,垃圾收集时,用户线程暂停的时间越短越好
Serial收集器
作用于新生代的垃圾收集器,属于单线程的垃圾收集器,即串行垃圾收集器,在执行垃圾收集时,以单线程的方式工作
bash
# 启用Serial垃圾收集器(开启该参数后,老年代默认使用Serial Old收集器)
-XX:+UseSerialGC
- 收集动作:串行GC,单线程
- 采用的垃圾收集算法:复制算法
- STW:垃圾收集过程是在STW中进行

ParNew收集器
作用于新生代区域的垃圾收集器,可以看作是Serial收集器的多线程版本,多个线程执行垃圾收集缩短系统的停顿时间,Server模式的应用程序,老年代选择了CMS垃圾收集器,新生代可以选择ParNew收集器,ParNew收集器可以通过控制GC线程数量来缩短程序的停顿时间,更关心程序的响应时间
bash
# 启用ParNew收集器
-XX:+UseParNewGC
# 需要多核CPU的支持,默认会根据CPU的核数开启不同的GC线程数,以达到最优的垃圾收集效果
# 指定GC线程数
-XX:ParallelGCThreads=N
- 收集动作:并行GC,多线程
- 采用的垃圾收集算法:复制算法
- STW:垃圾收集过程发生在STW中,使用多个线程执行垃圾收集

Parallel Scavenge收集器
作用于新生代区域的多线程并行垃圾收集器,Parallel Scavenge收集器更关心程序的吞吐量,即一段时间内,用户代码执行时长和程序总执行时长的占比,目标是让程序达到可控的吞吐量,被称为吞吐量优先的垃圾收集器
bash
# 启用Parallel Scavenge收集器
-XX:+UseParallelGC
# 开启自适应调节策略(默认开启)
-XX:+UseAdaptiveSizePolicy
# 期望的最大GC停顿时间(这里设置的是50ms)
-XX:MaxGCPauseMillis=50
# 期望的吞吐量,设置GC时间占用总执行时间的占比(这里设置的是期望GC时间不超过2%)
-XX:GCTimeRatio=49
- 收集动作:并行GC,多线程
- 采用算法:复制算法
- STW:GC过程发生在STW中,采用多线程进行垃圾收集

Serial Old收集器
作用于老年代区域的垃圾收集器,和Serial收集器一样执行垃圾收集过程采用单个GC线程进行垃圾收集,属于Serial收集器的老年代版本,也作为CMS收集器的备用收集器
bash
# 开启Serial Old收集器(和Serial相同)
-XX:+UseSerialGC
- 收集动作:串行GC,单线程
- 采用算法:标记-整理算法
- STW:垃圾收集过程在STW中进行,采用单线程方式进行垃圾收集

Parallel Old收集器
收集老年代区域的垃圾,是Parallel Scavenge收集器的老年代版本,同样是采用多线程并行收集垃圾,目标也同样是吞吐量优先
bash
# 启用Parallel Old收集器
-XX:+UseParallelOldGC
- 收集动作:并行GC,多线程收集
- 采用算法:标记-整理算法
- STW:垃圾收集过程发生在STW,采用多线程进行垃圾收集

CMS收集器
ConcurrentMarkSweep,在该垃圾收集器中首次实现了并发收集的概念,做到垃圾收集线程和用户线程能够一起工作,也就是不停止用户线程,垃圾收集线程也能工作;CMS追求的目标是响应时间优先,属于多线程收集器
bash
# 启用CMS收集器
-XX:+UseConcMarkSweepGC
- 收集动作:并发GC,多线程并行执行
- 采用算法:标记-清除算法
- STW:GC过程会发生STW,但并非整个GC过程都在STW中进行,使用多线程执行垃圾收集

垃圾收集步骤
初始标记
仅仅标记和GCRoots直接关联的对象,在STW中进行,执行该过程速度快
并发标记
从根节点开始,对内存区域进行可达性分析找到所有存活对象,执行该过程不需要STW,GC线程和用户线程可以同时(交替)进行
重新标记
修正并发标记阶段中带来的GC标记变动,需要在STW中进行,停顿时间要比初始标记时间要长
并发清除
回收存活对象以外的垃圾对象,该过程不会STW,用户线程和GC线程并发执行
总结
比较重视用户体验的程序,可以选择用ParNew+CMS收集器组合,响应速度优先,关心用户体验;和用户交互比较少,更多的是计算类型的后台程序可以选择Parallel Scavenge + Parallel Old收集器组合,吞吐量优先