JVM基础概念
JVM是一个运行在计算机上的程序,负责编译java字节码文件,支持跨平台特性。
java语言为了通过实时解释实现多平台支持,性能相对于C等语言较低,而JVM提供了JIT即时编译进行性能优化。
JVM与JIT
- JVM负责解释和执行Java字节码,JIT则负责将字节码转换为本地机器代码。
- 当Java程序被执行时,JVM会先解释执行字节码,JIT根据程序的运行情况动态地将频繁执行的代码片段进行编译优化,生成高效的本地机器代码。
JVM功能
- 1.解释执行字节码指令;
- 2.管理内存中对象的分配,完成自动的垃圾回收;
- 3.优化热点代码提升执行效率。
JVM组成
JVM组成分为类加载子系统、运行时数据区、执行引擎、本地接口这四部分。
JVM版本
字节码文件
基础信息
常量池
用于存储在编译时确定的常量,包括字符串、数字、类和接口的全限定名以及字段和方法的描述符等
常量池会根据数据分配空间,数据重复定义只会分配一个空间。
CONSTANT_Class:表示一个类或接口的全限定名。
CONSTANT_Fieldref:表示一个字段的名称和描述符,包括所属类或接口的全限定名。
CONSTANT_Methodref:表示一个方法的名称和描述符,包括所属类或接口的全限定名。
CONSTANT_String:表示一个字符串常量的值。
CONSTANT_Integer:表示一个整型常量的值。
CONSTANT_Float:表示一个浮点型常量的值。
CONSTANT_Long:表示一个长整型常量的值。
CONSTANT_Double:表示一个双精度浮点型常量的值。
字段
字段是类或接口中的数据成员,用于存储对象的状态信息。字段可以是静态的或非静态的,并且可以是基本类型或引用类型。在 Java 字节码中,字段被表示为 CONSTANT_Fieldref 常量项。
public class MyClass {
private static int staticField;
private int instanceField;
}
在上面的示例中,MyClass类有一个私有的静态字段staticField和一个私有的实例字段instanceField。
方法
属性
类生命周期
类的生命周期分为:加载-链接-初始化-使用-卸载。五个阶段
而链接又细分为:验证-准备-解析。
我们对于类的生命周期,主要关注加载-验证-准备-解析-初始化。
加载
加载阶段:就是找到需要加载的类并把类的信息加载到jvm的方法区中,然后在堆区中实例化一个java.lang.Class对象,作为方法区中这个类的信息的入口。开发者最终可以通过在堆中访问类的相关信息。
第一步,类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。
类的加载方式比较灵活,我们最常用的加载方式有两种,一种是根据类的全路径名找到相应的class文件,然后从class文件中读取文件内容;另一种是从jar文件中读取。另外,还有下面几种方式也比较常用:
- 从网络中获取:Applet。
- 根据一定的规则实时生成,比如动态代理模式,就是根据相应的类自动生成它的代理类。
- 从非class文件中获取,其实这与直接从class文件中获取的方式本质上是一样的,这些非class文件在jvm中运行之前会被转换为可被jvm所识别的字节码文件。
第二步,Java虚拟机会将字节码中的信息保存到方法区中。
生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
第三步,在堆中生成一份与方法区中数据类似的java.lang.Class对象。
在Java代码中去获取类的信息以及存储静态字段的数据。
链接—验证
这个阶段绝大多数不需要程序员参与。
当一个类被加载之后,必须要验证一下这个类是否合法,比如这个类是不是符合字节码的格式、变量与方法是不是有重复、数据类型是不是有效、继承与实现是否合乎标准等等。总之,这个阶段的目的就是保证加载的类是能够被jvm所运行。
- 1.文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
- 2.元信息验证,例如类必须有父类(super不能为空)。
- 3.验证程序执行指令的语义,比如方法内的指令执行到一半强行跳转到其他方法中去。
- 4.符号引用验证,例如是否访问了其他类中private的方法等。
链接—准备
准备阶段的工作是为类的静态变量分配内存并设为jvm默认的初值(被final修饰的基本类型直接赋值),对于非静态的变量,则不会为它们分配内存。
链接—解析
这一阶段的任务就是把常量池中的符号引用转换为直接引用。
- 符号引用就是在字节码文件中使用编号来访问常量池中的内容。
- 直接引用不使用编号,而是使用内存中地址进行访问具体的数据。
初始化
如果一个类被直接引用,就会触发类的初始化。
直接引用:
- 通过new关键字实例化对象。
- 读取或设置类的静态变量、调用类的静态方法(注意变量是final修饰的并且等号右边是常量不会触发初始化,初始化子类的时候,会先触发父类的初始化)。
- 调用main方法
- 通过反射方式执行以上三种行为(如Class.forName(String className))。
类初始化时会有如下的情况发生:
- 初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
- 初始化阶段会执行字节码文件中clinit部分的字节码指令,也就是静态代码块中的指令。
clinit指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行的。
1.无静态代码块且无静态变量赋值语句。
2.有静态变量的声明,但是没有赋值语句。
3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
4.直接访问父类的静态变量,不会触发子类的初始化。
类加载器
类加载器可具体分为两个大类
JAVA默认:启动类、拓展类、系统类加载器。
用户自定义:自定义类加载器。
加载器的分类
扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
启动类加载器——Bootstrap
用于加载JAVA核心类,是由Hotspot虚拟机提供的、使用C++编写的类加载器。默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
通过启动类加载器去加载用户jar包可以使用:-Xbootclasspath/a:jar包目录/jar包名 进行扩展。
拓展类加载器——Extension
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。 扩展类加载器的父加载器是Bootstrap启动类加载器 (注:不是继承关系)
通过扩展类加载器去加载用户jar包可以使用:-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录。
系统类加载器—Application
系统类加载器负责加载 classpath环境变量所指定的类库,是用户自定义类的默认类加载器。系统类加载器的父加载器ExtClassLoader扩展类加载器(注: 不是继承关系)。
双亲委派机制
逻辑概念
在加载时,每个自底向上检查是否加载,自顶向下尝试进行加载。
每个Java实现的类加载器中保存了一个成员变量叫“父”(Parent)类加载器,应用程序类加载器的parent父类加载器是扩展类加载器,而扩展类加载器的parent是空。启动类加载器由C++编写没有parent。
类加载的过程。
- 1.每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回。
- 2.否则会将加载请求委派给parent加载器。如果类加载的parent为null,则会提交给启动类加载器处理。
- 3.所有父加载器都无法加载则自己尝试加载,检查加载的类是否在自己的路径下,不在自己路径下则向“下级”返回加载失败。
所以具体的加载关系可以如下:
例如:
双亲委派机制的作用
1.保证类加载的安全性:通过双亲委派机制,让顶层的类加载器去加载核心类,避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
2.避免重复加载:双亲委派机制可以避免同一个类被多次加载,上层的类加载器如果加载过类,就会直接返回该类,避免重复加载。
打破双亲委派机制
为什么要打破?
比如:一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。
打破双亲委派机制的核心就是:跳过双亲委派机制,直接加载,而不用向上委托
自定义类加载器
查看JAVA两个类加载器父类ClassLoader的实现代码,可以发现findclass是实现双亲委派机制的重要代码。
如下是loadclass的源码。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 检查类是否已经被加载了
Class<?> c = findLoadedClass(name);
if (c == null) { //没有
long t0 = System.nanoTime();
try {
//双亲委派机制加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
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);
}
return c;
}
}
打破双亲委派机制的核心就是在自定义类加载器中重新实现findClass方法来寻找父类的操作,之后就可以直接调用findClass方法,跳过双亲委派机制,直接加载,而不用向上委托,还有就是继承Classloader类来设置parent。
package com;
import java.io.*;
/**
* @Date: 2022/5/2 10:09
* @author: ZHX
* @Description: 自定义类加载器
*/
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
//parent: 指定父加载器, AppClassLoader/ExtClassLoader/Bootstrap
public MyClassLoader(ClassLoader parent, String classPath) {
super(parent);
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
//要求返回的是你要加载的字节码文件的Class对象.
//这里都是我们说了算的。
//步骤:
//1. 从本地或网络某处读一个输入流到内存中 .
//2. 将流内容字节数组 封装成Class对象 (直接调ClassLoader的defineClass方法,JVM会帮我们按照.class文件格式创建好的。)
//1.
//处理得到完整路径
String path = this.classPath + name.replace(".", File.separator) + ".class";
//2.读取到内存
try (FileInputStream fis = new FileInputStream(path); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len = 0;
while ((len = fis.read(buffer)) != -1) {
//用ByteArrayOutputStream暂存一下。
baos.write(buffer, 0, len);
}
byte[] allByte = baos.toByteArray();
//将字节数组生成Class对象
return super.defineClass(name, allByte, 0, allByte.length);
} catch (IOException e) {
throw new ClassNotFoundException(name + "加载失败");
}
}
//测试下
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
//使用自己的类加载器,加载D:\\ + com.ali.Hello
MyClassLoader myClassLoader = new MyClassLoader("d:\\"); //
//加载 全限定名类
Class<?> clazz = myClassLoader.loadClass("com.ali.Hello");
clazz.newInstance();
System.out.println(clazz.getClassLoader()); //out: 使用的类加载器 MyClassLoader@481248
}
}
线程上下文中的类加载器
JDBC为例。JDBC采用DriverManager来管理不同数据库的驱动。DriverManager在jar包中由启动类加载器加载,而他所要加载的驱动类又是通过SPI机制调用线程中存有的系统类加载器加载的。
- 启动类加载器加载DriverManager。
- 在初始化DriverManager时,通过SPI机制加载jar包中的myql驱动。
- SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
这样由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。
OSGI模块(了解)
OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。
热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。
运行时数据区
运行时数据区主要分为:程序计数器,线程栈(JAVA虚拟机栈,本地方法栈),方法区,对象堆,直接内存。
其中线程共享的是方法区和堆。
程序计数器
是一块内存区域,每个线程会用自己的程序计数器记录即将执行的字节码指令的地址。(注意是地址)
程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
一个字节码文件的执行顺序如下:
- 虚拟机将字节码文件中的指令读到内存后,会将每条指令的编号(偏移量)转换为内存地址。
程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令。(图中程序计数器应该是地址,为了便于观察用编号替代)
上下文切换时,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。
java虚拟机栈
Java虚拟机栈采用栈来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈帧来保存。
每个栈帧包括:局部变量表,操作数栈,帧数据。这些在编译(java变class)时就可以确定大小。
局部变量表
是用于保存局部变量的一块内存。局部变量表保存的内容有:实例方法的this对象(第一个存放),方法的参数,方法体中声明的局部变量。
- 栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。
- 为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
操作数栈
是用于存放指令执行过程中临时数据的一块内存。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
动态链接
用于保存编号和内存地址的映射关系。
如下是字节码引用其他类的属性或者方法,需要将编号转为内存地址,这是就用动态链接中保存的映射关系进行转换。
方法出口
方法出口是一个地址。方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器指向上一个栈帧中的下一条指令的地址。
如下
add 方法执行完毕后 , 还要继续向下执行 , 将 add 方法之后执行的代码行号保存到 " 栈帧 " 中的方法出口中 ;这里 add 方法的 " 方法出口 " 是第 13 行代码 ;
异常表
存放了代码中异常处理信息。包括异常的范围和异常后转跳的位置。
本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
堆
一般Java程序中,堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。而栈空间内可以存放堆内对象的引用。
结构如下所示
当我们静态创建Student s1的时候,就可以实现静态变量存放引用,通过静态变量实现对象在线程之间的共享。
used total max
堆空间有三个关键的值used total max,我们通常只需要关注这几个值,used就是已使用,total时已分配的可用空间,max时最大的可用空间。分配内存时随着used不断增大,JVM会不断的增加total值。
通常total值默认为系统内存1/64,max为系统内存的1/4。
如图,蓝色为已使用,绿色为已分配给堆但未使用的,红色为未分配给堆的。
我们通常要手动设置total(至少1mb)和max(至少2mb),分别使用-Xms和-Xmx进行设置。而在设置时,建议将二者设置相同值。
方法区
方法区包含三个基本信息:类元信息、运行时常量池、字符串常量池。
方法区在不同JVM上的实现不同。hotspot在JDK8前放在堆的永久代空间,大小由虚拟机参数-XX:MaxPermSize=值来控制。JDK8后放在元空间,使用-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。
元空间:操作系统维护的直接内存,默认可一直分配。
类元信息
每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。
运行时常量池
- 运行时常量池中存放的是字节码中的常量池内容。
- 字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。
- 当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
字符串常量池
字符串常量池存储在代码中定义的字符串常量。
字符串常量池原本属于运行时常量池的一部分,他们存储位置时一致的,后续才将二者进行拆分。
JDK6
JDK7
JDK8
直接内存
直接内存主要为了解决两个问题
- Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
- 使用ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);创建直接内存上的数据。
- 使用-XX:MaxDirectMemorySize=size修改直接内存的大小设置。
垃圾回收
一个对象如果不再使用,则需要进行垃圾回收,也就是释放该对象,在C语言中我们手都的new和delete就是一种手动的垃圾回收。
如果不再使用的对象不被回收则称为内存泄漏,内存泄漏累计则会造成内存溢出。
JAVA中为了简化该操作引入了GC机制自动垃圾回收,主要负责对堆的内存进行回收。对于线程不共享的部分比如栈等,对象会随着线程销毁而被回收,方法的栈帧则会在方法执行后自动弹出释放。
自动回收的主要优势:
优:降低代码实现难度,降低bug出现概率。
缺:回收的及时性较低
手动回收:
优:及时性高,可控性高。
缺:可能出现指针悬空,重复释放等问题。
方法区回收
方法区主要时存储了类元信息,我们释放时也主要是释放不再使用的类的instanceKclass。
一个类的卸载必须同时满足:
- 1.此类所有实例对象都被回收,并且在堆中不存在任何此类的子类对象。
- 2.此类的加载器被回收
- 3.对应的class对象没有在别处被引用。
1.
2.
3.
我们也可以通过手动调用System.gc()来提醒垃圾回收器进行方法区回收。
堆回收
堆对象的回收必须要满足该对象没有在任何地方被引用。
如图,如果想回收A的实例对象,必须要将该实例对象的直接引用和B实例对象内对A实例对象的引用都消除。
为了得知是否引用已经都被清除,有两种方法:引用计数法和可达性分析算法。
引用计数法
每个对象都有一个引用计数器,被引用时+1,取消引用时-1。
但是循环引用出现时,就会出现对象无法回收的问题。
可达性分析算法
通过一系列被称为根对象的起点作为起始节点集,从这些节点开始,通过引用关系向下搜寻,搜寻走过的路径称为引用链,如果某个对象到起始节点集没有任何引用链相连,就说明该对象不可达,即可以被回收。
当JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW,然后枚举根节点,即找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。
什么是根对象
是JVM确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 )。
哪些对象可以成为根对象
- 1、方法区静态(static)属性引用的对象:全局对象的一种,只要Class对象不被回收,静态成员就不能被回收。
- 2、方法区常量(final)池引用的对象:全局对象,常量本身初始化后不会再改变,因此作为GC Roots也是合理的。
- 3、方法栈中栈帧引用的对象:执行上下文中的对象,线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量和参数对象就是根对象会存放到栈帧的本地变量表中。
- 4、JNI本地方法栈中引用的对象:native方法(C、C++)方法栈中的变量引用。
- 5、被同步锁持有的对象:被synchronized锁住的对象也是绝对不能回收的。
五种引用方式
强引用
平时使用的大多数对象引用,都是强引用,也是在可达性分析中提到的引用。把对象设置为null就是取消了引用。
Object obj =new Object();
String str ="hello world!";
软引用
软引用和强引用的区别就是,当堆空间不足时,软引用的对象会被回收。
通过SoftReference关键字来软引用对象,它的一个实例保存对一个java对象的软引用 ,并利用其中提供的get()方法 去获取对象的地址;如果返回NULL,说明此对象被回收。
软引用的获取过程如下:
- 1.将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
- 2.内存不足时,虚拟机尝试进行垃圾回收。
- 3.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
- 4.如果依然内存不足,抛出OutOfMemory异常。
软引用的释放过程如下:
- 1、软引用对象创建时,通过构造器传入引用队列
- 2、在软引用中包含的对象因空间不足或其他原因被回收后,该软引用对象会被放入引用队列
- 3、通过代码遍历引用队列,将SoftReference的强引用删除
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
for(int i=0; i<5; i++){
SoftReference<byte[]> ref = new SoftReference<>(new byte[4*1024*1024]);
list.add(ref); //将软引用添加到集合中
}
System.out.println("循环结束:" + list.size());
for(SoftReference<byte[]> ref : list){
System.out.println(ref.get()); //依次遍历出每个软引用对象的地址
//通过get获取它的地址
}
}
弱引用
弱引用在垃圾回收时,不管是否空间不足,都会被回收。主要在ThreadLocal中使用。
虚引用
虚引用唯一的用途是当对象被垃圾回收器回收时,操作系统可以接收到对应的通知。
Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
终结器引用
终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。
垃圾回收算法
垃圾回收算法要做的就是找出要回收的对象,然后释放。
评价一个垃圾回收算法的优劣有:最大暂停时间(STW时间)越小越好,吞吐量越高越好,堆使用效率越高越好。
这三者通常不可兼得,STW降低吞吐量降低,堆使用率提升吞吐量增加但STW增加(如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收)。
标记—清除算法
分为两个阶段:通过GC root遍历并且标记所有对象;删除未被标记的对象。
优:实现简单,堆使用率高。
缺:会产生大量的内部碎片,需要维护一个空闲链表,可能会在分配内存时需要较长的时间。
复制算法
- 1.将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
- 2.GC阶段开始,将GC Root搬运到To空间。
- 3.将GC Root关联的对象,搬运到To空间。
- 4.清理From空间,并把名称互换。
优:吞吐量高,相较于标记清除少了二阶段的遍历过程,不会发生碎片化。
缺:堆使用率低,只能用一半的空间分配给对象使用。
标记—整理算法
用于应对标记清理的碎片化问题。我们会在标记完成后清理前,先把所有的未标记对象移动到堆的一端,然后集中清理。
优:堆使用率高,不会有碎片化。
劣:整理阶段会增加STW时间,吞吐率低。
GC分代算法
分代垃圾回收将整个内存区域划分为年轻代和老年代。一般大小是1:8;
年轻带分为一个eden区,和两个幸存区(from,to)
分代的原因
1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
2、新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
算法执行如下:
-
1.新创建出来的对象,首先会被放入Eden区。
-
2.当Eden区域满时,触发Minor GC,Minor GC会从根对象开始,通过可达性分析标记所有存活的对象。将存活的对象复制到S0(from)区域中的一个空闲区域,并清空Eden区域。
-
3.接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。第二次和之后的minor gc会回收eden区和from中的对象,并把eden和from区中剩余的对象放入S0。
-
4.如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
-
5.当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会同时对年轻代和老年代进行垃圾回收。
-
6.如果Full GC无法回收掉老年代的对象,对象继续放入老年代就会抛出Out Of Memory异常。
垃圾回收器
垃圾回收器是垃圾回收算法的具体实现。
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。
年轻代——serial垃圾回收器
Serial是一种单线程的回收年轻代垃圾的算法,在复制算法场景下使用。
优:单核CPU下吞吐量出色。
缺:多核CPU下性能较低,堆偏大会长时间阻塞其他线程。
适用场景:硬件配置有限的场景。
老年代——SerialOld垃圾回收器
Serial的老年版。应用于标记整理算法。
优:单核CPU下吞吐量出色。
缺:多核CPU下性能较低,堆偏大会长时间阻塞其他线程。
使用场景:与Serial搭配使用,或者在CMS特殊情况下使用。
参数:-XX:+UseSerialGC
年轻代-ParNew垃圾回收器
Serial在多核CPU下的优化,使用多线程进行垃圾回收。
应用于复制算法
优:多CPU处理器下停顿时间较短
缺:吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用。
使用场景:JDK8和之前的场景与CMS搭配使用。
参数:-XX:+UseParNewGC
老年代- CMS(Concurrent Mark Sweep)垃圾回收器
致力于降低STW时间,允许用户线程和垃圾回收线程在某些步骤并行。
应用于标记清除算法
优:系统由于垃圾回收出现的停顿时间较短,吞吐量高。
适用:大型的互联网系统中用户请求数据量大、频率高的场景。
参数:-XX:+UseConcMarkSweepGC
CMS执行步骤
- 1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。(STW)
- 2.并发标记, 标记所有的对象,用户线程不需要暂停。
- 3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。(STW)
- 4.并发清理,清理死亡的对象,用户线程不需要暂停。
缺点
1、CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理。,也可以和Serialold搭配使用。
2.、无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。
3、如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
CMS的线程挣强问题
在CMS中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置,默认值为0,由系统计算得出。
计算公式为(-XX:ParallelGCThreads定义的线程数 + 3) / 4,ParallelGCThreads是STW停顿之后的并行线程数
ParallelGCThreads是由处理器核数决定的:
1、当cpu核数小于8时,ParallelGCThreads = CPU核数
2、否则 ParallelGCThreads = 8 + (CPU核数 – 8 )*5/8
年轻代-Parallel Scavenge垃圾回收器
致力于提高吞吐量,会根据最大暂停时间和吞吐量自动调整内存大小。允许手动设置最大暂停时间和吞吐量
应用于复制算法。
优:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数。
缺:不能保证单次的停顿时间。
适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。
参数:-XX:+UseParallelGC
自动调整内存大小:-XX:+UseAdaptiveSizePolicy
设置最大暂停时间:-XX:MaxGCPauseMillis=n
设置吞吐量:-XX:GCTimeRatio=n(用户线程执行时间 = n/n + 1)
老年代-Parallel Old垃圾回收器
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集。
应用于标记整理算法
优:并发收集,在多核CPU下效率较高
缺:暂停时间会比较长
适用场景:与Parallel Scavenge配套使用
参数:-XX:+UseParallelOldGC
G1垃圾回收器
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。并且这时的G1已经非常优秀,建议使用。
- Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。
- CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合:
1.支持巨大的堆空间回收,并有较高的吞吐量。
2.支持多线程并行垃圾回收。
3.允许用户设置最大暂停时间。
4.不会产生内存碎片。
参数1: -XX:+UseG1GC 打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值,最大暂停的时间。
G1内存结构
G1垃圾回收器会将整个堆分为若干区域称为region,每个区域的大小默认为堆/2048,也可通过-XX:G1HeapRegionSize=32m设置,但是指定的大小必须为2的整数幂。区域不要求连续,分为eden、survivor(from to)、Old。
G1垃圾回收器 – 年轻代回收
回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数-XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
G1在进行minor GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
-
1.新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行minor gc
-
2.标记处eden和suvivor中存活对象
-
3.根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空之前选择某些区域。
-
4、后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
-
5、当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
-
6、部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。
G1垃圾回收器 – 混合回收
mixed gc会回收所有年轻代和部分老年代对象、大对象。
7、多次minor GC之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeapOccupancyPercent 默认45%)会触发MixedGC。G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高。
最后清理阶段使用复制算法,不会产生内存碎片。
G1垃圾回收器 – FULL GC
如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。