JVM
类加载过程详解
概述:在java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。
类加载机制
java文件中的代码在编译后,就会生成JVM能够识别的二进制字节流class文件,class文件中描述的各种信息,都需要加载到虚拟机中才能被运行和使用。
类加载机制,就是虚拟机把类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型的过程。类从加载到虚拟机内存开始,到卸载出内存结束,整个生命周期包括七个阶段,如下图:
加载阶段
所谓加载,简言之就是查找并加载类的二进制数据,生成Class的实例。
这阶段的虚拟机需要完成三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口。
类模型与Class实例的位置
类模型的位置:加载的类在JVM中创建相应的类的结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)
Class实例的位置:类加载器将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。
数组类的加载
因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。创建数组类(简下述称A)的过程:
- 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
- JVM使用指定的元素类型和数据维度来创建新的数组类。
如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定,否则数组类的可访问性将被默认定义为public。
验证阶段
此阶段是为了确保class文件的字节流包含的信息符合当前虚拟机的要求,不会危害虚拟机自身的安全。分为以下4个校验动作:
- 文件格式验证:验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理,通过该阶段后,字节流会进入内存的方法区中进行存储。
- 元数据验证:对字节码描述的信息进行语言分析,对类的元数据信息进行语义校验,确保其描述的信息符合java语言规范要求。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。这个阶段对类的方法进行校验分析,保证类的方法在运行时不会做出危害虚拟机安全的事件。
- 符号引用验证:对类自身以外的信息(常量池中各种符号引用)进行校验,确保解析动作能正常运行。(该动作发生在解析阶段中)
说明:其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。文件格式验证之外的验证操作将会在方法区中进行。
准备阶段
此阶段为类的静态变量分配内存,并将其初始化为默认值。java虚拟机为各类型的变量默认的初始值如表所示:
注意:
-
此阶段不包含基本数据类型的字段用static final修饰的情况,因为其会在编译时生成ConstantValue属性,在类加载的准备阶段会根据ConstantValue的值为该字段赋值,它没有默认值,必须显示地赋值,否则编译时会报错。可以理解为在编译期即把结果放入了常量池中。
//一般情况:static final修饰的基本数据类型、字符串类型字面量会在准备阶段赋值 private static final String str = "Hello World"; //特殊情况:static final修饰的引用类型不会在准备阶段赋值,而是在初始化阶段赋值 private static final String str = new String("Hello world");
-
此阶段进行内存分配的仅包括类变量,而不包括实例变量。类变量会分配在方法区中,而实例变量则随着对象一起分配到java堆中。
-
在这个阶段并不会向初始化阶段那样会有初始化或者代码被执行。
解析阶段
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。
对同一符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机可以对第一次解析的结果进行缓存,从而避免解析动作重复进行。invokedynamic对应的引用称为“动态调用限定符”,必须等到程序实际运行到这条指令的时候,解析动作才能进行。因此,当碰到由前面的invokedynamic指令触发过的解析的符号引用时,并不意味着这个解析结果对其他的invokedynamic指令也同样生效。
1.符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何字面量,只要使用时无歧义定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定已经加载到内存中。
2.直接引用:直接引用是可以直接定位到目标的指针、相对偏移量或是一个能间接定位目标的句柄。直接引用是与虚拟机的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化阶段
初始化阶段是执行初始化方法()方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的java程序代码。
static和final的搭配问题
- 对于基本类型的字段来说,如果使用static final修饰,则显示赋值在准备阶段中进行。
- 对于String来说,如果使用字面量的方式赋值且使用static final修饰的话,则显示赋值在准备阶段中进行。
- 在初始化阶段()中赋值的情况,排除上述的在准备环节赋值的情况之外的情况。
public static final int INT_CONSTANT = 10; //在链接阶段的准备环节赋值
public static final int NUM1 = new Random().newtInt(10); //在初始化阶段<clinit>()中赋值
public static int a =1; //在初始化阶段<clinit>()中赋值
public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100); //在初始化阶段<clinit>()中赋值
public final Integer INTEGER_CONSTANT2 = Integer.valueOf(100); //在初始化阶段<clinit>()中赋值
public static final String str1 = "Hello"; //在链接阶段的准备环节赋值
public static final String str2 = new String("Hello"); //在初始化阶段<clinit>()中赋值
public static String str3 = new String("Helloworld"); //在初始化阶段<clinit>()中赋值
()的线程安全性
虚拟机会保证一个类的clinit()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其它线程都需要阻塞等待,直到活动线程执行clinit()方法完毕。所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞是很难被发现的。
如果之前的线程成功的加载了类,则等待的线程就没有机会再执行clinit()方法了。那么,当其它线程需要这个类时,虚拟机会直接返回给它已经准备好的信息,因为同一个类加载器下,一个类只会被初始化一次。
类的主动使用:
对于初始化阶段,虚拟机严格规范了有且只有6种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
- 遇到new、getstatic、putstatic、invokestatic这4条指令时。对应的场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已经在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候。例如:Class.forName("com.jyu.java.Test")
- 当初始化一个类的时候,如果发现其父类还没进行初始化,则必须对父类进行初始化。(与接口的区别:接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候,才会进行初始化)
- 当虚拟机启动时,用户指定的要执行的主类(包含main的类)
- MethodHandle和VarHandle可以看做是轻量级的反射调用机制,当初次调用其实例时,就应该初始化其指向的方法所在的类。
- 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类的被动使用:
- 对于静态字段,只有直接定义这个字段的类才会被初始化,通过其子类来引用父类中定义的静态字段,只会触发其父类的初始化而不会触发子类的初始化。子类会被加载,并不会被初始化。
- 通过数组定义类引用,不会触发此类的初始化。
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
- 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
clinit()方法的特点:
-
clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不可以访问。
public class Test { static{ i=0; //给变量赋值可以正常编译通过 System.out.println(i); //编译器会提示非法向前引用 } static int i=1; }
-
clinit()方法与实例构造器方法不同,它不需要显示调用父类构造器,虚拟机会保证子类的clinit()方法执行之前,父类的clinit()方法已经执行完毕,所以父类中定义的静态语句块要优先与子类的变量赋值操作,虚拟机中第一个被执行的clinit()方法的类是java.lang.Object。
-
clinit()方法对于类或接口并不是必需的,如果一个类中没有静态语句块,也就没有对变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。
-
接口中不能使用静态语句块,仍然有变量初始化操作,因此仍然会生成clinit()方法,与类不同的是,执行接口中的clinit()方法不需要先执行父接口的clinit()方法。只有父接口中定义的变量被使用时,才需要初始化父接口,同时,接口实现类在初始化时也不会执行接口的clinit()方法。
类的卸载
卸载类需要满足3个要求:
- 该类所有的实例对象都已被GC,也就是说堆不存在该类的实例对象。
- 该类没有在其它任何地方被引用。
- 该类的类加载器的实例已被GC。
只有当代表Sample类的Class对象不在被引用,即不可触及时,Class对象就会结束声明周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
参考:
类加载器与双亲委派模型
类加载机制生命周期的第一阶段,即加载阶段需要由类加载器来完成,类加载器根据一个类的全限定名读取类的二进制字节流到JVM中,然后生成对应的java.lang.Class对象实例。
类加载的分类:
- 显示加载指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClassLoader().loadClass()加载对象。
- 隐式加载则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。
类加载器
JVM中内置了三个重要的ClassLoader,除了BootstrapClassLoader其它类加载器均由java实现且全部继承自java.lang.ClassLoader:
- 启动类加载器(BootstrapClassLoader):最顶层的加载类,负责加载在%JAVA_HOME%/lib目录下的jar包和被-Xbootclasspath参数所指定的路径中的类库。
- 扩展类加载器(ExtensionClassLoader):主要负责加载%JAVA_HOME%/lib/ext目录下的jar包和类,或被java.ext.dirs系统变量所指定的路径下的jar包。
- 应用程序类加载器(ApplicationClassLoader):负责加载用户类路径classPath所指定的类库,如果应用程序中没有用户自定义的类加载器,一般情况下这个就是程序中默认的类加载器。
- 自定义加载器(CustomClassLoader):由应用程序根据自身需要自定义。
任意一个类在JVM中的唯一性,是由加载它的类加载器和类的全限定名一起确定的。因此,比较两个类是否“相等”的前提是这两个类是由同一个类加载器加载的,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
命名空间:
- 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成。
- 在同一命名空间中,不会出现类的完整名称(包括类的包名)相同的两个类。
- 在不同的命名空间中,有可能会出现类的完整名称(包括类的包名)相同的两个类。
类加载机制的基本特征:
- 可见性:子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
- 单一性:由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。
双亲委派模型
类加载器之间的关系(源码分析)
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
Thread.currentThread().setContextClassLoader(this.loader);
双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父类加载器,如上图所示。(类加载器之间的父子关系不是以继承的关系实现的,而是使用组合关系来复用父加载器的代码)Launcher初始化了ExtClassLoader和AppClassLoader,并且将ExtClassLoader作为AppClassLoader的父类,而ExtClassLoader的父类为null,null并不代表ExtClassLoader没有父类加载器,而是BootstrapClassLoader。
双亲委派模型的工作原理
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层级的类加载器都是如此,因此所有请求最终都会被传到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。因此,加载过程可以看成自底向上检查类是否已经加载。
双亲委派模型的优点
- 使用双亲委派模型来组织类加载器之间的关系,java类随着它的类加载器一起就具备了一种带有优先级的层次关系。
- 避免类的重复加载,当父类加载器已经加载了该类时,子类加载器就没必要再加载一次。
- 解决各个类加载器的基础类的统一问题,越基础的类由越上层的加载器进行加载。避免java核心API中的类被随意替换,规避风险,防止核心API库被随意篡改。
例如类 java.lang.Object,它存在在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的 Bootstrap ClassLoader 进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,那系统中将会出现多个不同的 Object 类,程序将混乱。因此,如果开发者尝试编写一个与 rt.jar 类库中重名的 Java 类,可以正常编译,但是永远无法被加载运行。
ClassLoader源码解析
除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader。
public final ClassLoader getParent() {
if (parent == null)
return null;
return parent;
}
返回该类加载器的超类加载器
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,检查该类是否已经加载过了
Class<?> c = findLoadedClass(name);
// 如果没有加载过
if (c == null) {
if (parent != null) {
// 先委托给父加载器去加载,注意这是个递归调用
c = parent.loadClass(name, false);
} else {
// 如果父加载器为空,查找 Bootstrap 加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
// 如果父加载器没加载成功,调用自己的 findClass 去加载
if (c == null) {
c = findClass(name);
}
}
return c;
}
}
加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException异常。该方法中的逻辑就是双亲委派模型的实现。
protected Class<?> findClass(String name) throws ClassNotFoundException{
//1. 根据传入的类名 name,到在特定目录下去寻找类文件,把 .class 文件读入内存
...
//2. 调用 defineClass 将字节数组转成 Class 对象
return defineClass(buf, off, len);
}
findClass()是JDK1.2之后ClassLoader新添加的方法,在JDK1.2之后已不提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中,因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载。而同样如果想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass()中实现你自己的加载逻辑即可。
protected final Class<?> defineClass(byte[] b, int off, int len){}
此方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象,也可以通过其它方式实例化class对象,如通过网络接收一个类的字节码,然后转换为byte字节流创建对应的Class对象。
protected final void resolveClass(Class<?> c)
使用该方法可以使用类的加载器创建该类的Class对象的同时并对该类进行链接流程。
扩展:
如果在自定义的类加载器中重写loadClass(String)方法,抹去其中的双亲委派机制,那么是不是就能够加载核心类库了呢?
答案是不行。因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器或扩展类加载器,最终都必须调用defineclass(String,byte[],int,int,ProtectionDomain)方法,而该方***执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护。
扩展:Tomcat的类加载机制 详情请阅读此博客:Tomcat 的类加载机制_张维鹏的博客-CSDN博客
参考:
JVM主要组成部分与内存区域
JVM整体架构:
JVM主要组成部分
JVM包含两个子系统和两个组件,分别为:
- Classloader(类装载子系统):根据给定的全限定类名来装载class文件到运行时数据区的方法区中。
- Execution engine(执行引擎子系统):执行引擎也叫解释器,负责解释class的指令,然后提交给操作系统执行。
- Runtime data area(运行时数据区组件):即常说的JVM的内存
- Native Interface(本地接口组件):与native lib交互,它的作用是融合不同的编程语言为java所用,是其它编程语言的交互的接口。
首先通过编译器把java源代码转换成字节码,ClassLoader再把字节码加载到内存中,将其放在运行时数据区的方法区内,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解释器执行引擎,将字节码翻译成底层系统指令,再交由CPU去执行,而这个过程中需要调用其它语言的本地库接口(Native Interface)来实现整个程序的功能。
运行时数据区
1.JDK1.8之前的JVM内存区域如下图:
2.JDK1.8之后的JVM内存区域如下图:
程序计数器
当前线程执行的字节码的行号指示器,记录当前线程执行到程序的哪个位置,通过改变计数器的值,可以选取下一条需要执行的字节码指令。
PC寄存器为什么会被设定为线程私有的?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致进程中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。
注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈
描述java方法执行的内存模型,每个方法执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成,就对应着一个栈帧在虚拟机中入栈到出栈的过程。该区域线程私有,生命周期与线程的生命周期相同。
栈帧的内部结构:
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个执行对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其它于此对象相关的位置)。
关于Slot的理解:
- 局部变量表,最基本的存储单元是Slot(变量槽)
- 在局部变量表中,32位以内的类型只占用一个slot,64为的类型(long和double)占用两个slot。
- byte、short、char在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被赋值到局部变量表中的每一个slot上。
- 如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
操作数栈主要保存计算过程的中间结果同时作为计算过程中变量临时的存储空间。如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如:invokedynamic指令。
在java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用了另外的其它方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。(动态链接也叫指向运行时常量池的方法引用)
静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期将调用的方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
虚方法和非虚方法:
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其它方法称为虚方法。
普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- invokedynamic:动态解析出需要调用的方法,然后执行
虚方法表:如果在每次动态分配的过程中都需要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表。虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
方法返回地址存放该方法的pc寄存器的值。一个方法的结束有两种方式:1.正常执行完成 2.出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
java虚拟机栈会出现两种错误:
- StackOverFlowError:若java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前java虚拟机栈的最大深度的时候,就抛出StackOverFlowError错误。
- OutOfMemoryError:java虚拟机栈的内存大小可以动态扩展,如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
本地方法栈
其与虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。在HotSpot虚拟机中和java虚拟机栈合二为一。
堆
用于存储对象实例,是占用内存最大的区域,可划分为新生代和老年代,新生代又可细分为Eden区、From Survivor区、To Survivor区。
堆内存结构(JDK7及之前)
堆内存空间结构(JDK8及之后,元空间也被称为非堆,元空间使用的是直接内存)
堆空间大小的设置
java堆区用于存储java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项“-Xmx”和“-Xms”来进行设置。
- "-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
- "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
年轻代与老年代
年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集,这种垃圾收集被称为Minor GC。年轻代被分为三个部分——Eden区和两个Survivor区,默认比例是8:1:1
老年代主要存储被长时间使用的对象及大对象(避免在Eden区和两个Survivor区之间发生大量的内存拷贝),新生代与老年代在堆结构的占比默认为1:2。-XX:NewRation=2可设置占比。
对象分配过程
new的对象先放入Eden区,此区有大小限制。当Eden区的空间被填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(MinorGC),将Eden区中不再被其它对象所引用的对象进行销毁,再加载新的对象放到Eden区。接着将Edne区中的剩余存活对象移动到To Survivor区。如果再次触发垃圾回收,则存活对象会反复在两个Survivor区中存放,并且对象的年龄还会增加1(Eden区->Servivor区后对象的初始年龄为1),当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThread
年轻代GC触发机制:当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden区满,Survivor满不会引发GC。MinorGC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代GC(Major GC/Full GC)触发机制:出现了MajorGC,经常会伴随至少一次的MinorGC。MajorGC的速度一般会比MinorGC慢10倍以上,STW的时间更长,如果MajorGC后内存还不足,就报OOM。
TLAB
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此可以将这种内存分配方式称之为快速分配策略。
- -XX:UseTLAB设置是否开启TLAB空间。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,可以通过-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
小结:堆空间的参数设置
// 详细的参数内容会在JVM下篇:性能监控与调优篇中进行详细介绍,这里先熟悉下
-XX:+PrintFlagsInitial //查看所有的参数的默认初始值
-XX:+PrintFlagsFinal //查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms //初始堆空间内存(默认为物理内存的1/64)
-Xmx //最大堆空间内存(默认为物理内存的1/4)
-Xmn //设置新生代的大小。(初始值及最大值)
-XX:NewRatio //配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio //设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold //设置新生代垃圾的最大年龄
-XX:+PrintGCDetails //输出详细的GC处理日志
//打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc
-XX:HandlePromotionFalilure://是否设置空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
-
如果大于,则此次Minor GC是安全的
-
如果小于,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败。 -
- 如果
HandlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果
-
-
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
-
-
-
- 如果小于,则改为进行一次Full GC。
-
-
- 如果
HandlePromotionFailure=false
,则改为进行一次Full GC。
- 如果
JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行MinorGC,否则将进行FullGC。
逃逸分析概述
在java虚拟机中,对象是在java堆中分配内存的,但是有一种特殊情况,那就是经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无须在堆上分配内存,也无须进行垃圾回收了。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要是指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配。
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分可以不存储在内存,而是存储在CPU寄存器中。
方法区
用于存储类信息,包括运行时常量池、静态变量、常量、即时编译后的代码(即class文件)等数据。与java堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败会抛出OOM异常,该区域被所有线程共享。对这个块区域进行垃圾回收的主要目标是对常量池的回收和对类型的卸载,但是一般比较难实现。
方法区是一个JVM规范,永久代与元空间都是其一种实现方式。JDK8之前,Hotspot中方法区的实现是永久代(Perm),JDK8开始使用元空间(Metaspace),以前永久代的静态变量和字符串常量池移至堆内存,其它内容移至元空间,元空间直接在本地内存分配。
为什么要使用元空间取代永久代的实现?
-
永久代的方法区,和堆使用的物理内存是连续的。对于永久代,由于类及方法的信息等比较难确定其大小,所以指定永久代的大小比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出,并且每次FullGC之后永久代的大小都会改变,如果动态生成很多class的话,就很可能出现OOM,毕竟永久代的空间配置有限。
-
JDK8之后,方法区存在于元空间,物理内存不在与堆连续,而是直接存在于本地内存中,理论上其内存有多大,元空间就有多大。
设置方法区内存的大小
JDK7及之前:通过-XX:Permsize来设置永久代初始分配空间,默认值是20.75M。通过-XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M。当JVM加载的类信息容量超过了最大可分配空间,会报OutOfMemoryError:PermGen space异常。
JDK8之后:元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定。windows下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1//即没有限制
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace。
方法区的内部结构
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.object都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型的直接接口的一个有序列表
域信息(也称成员变量)
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括:域名称、域类型、域修饰符。
方法信息
JVM必须保存所有方法的以下信息:
- 方法的名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符
- 方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表:每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量
全局常量(static final)
补充:
- JDK1.7之前,运行时常量池包含的字符串常量池和静态变量存放在方法区,此时Hotspot虚拟机对方法区的实现为永久代。
- JDK1.7字符串常量池和静态变量被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆中,运行时常量池剩下的东西还在方法区,也就是Hotspot中的永久代。
- JDK1.8永久代被元空间取而代之,这时候字符串常量池还在堆中,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。
StringTable为什么要调整位置?
因为永久代的回收效率很低,在FullGC的时候才会触发,而FullGC是老年代空间不足、永久代空间不足时才会触发。这就导致StringTable回收效率不高。而开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
HotSport虚拟机对象探秘
对象的创建步骤
-
类加载检查:虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
-
分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需的内存大小在类加载完成后便可确定。分配方式有指针碰撞和空闲列表,具体选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由所采用的的垃圾收集器是否带有压缩整理功能决定。
-
指针碰撞:如果java是堆绝对规整的,所有用过的内存都放在一边,所有没用过的内存放在另一边,中间存放一个指针作为分界点指示器。只需要向着没用过的内存方向将该指针移动对象内存大小即可。
-
空闲列表:如果java堆不是规整的,虚拟机就必须维护一张列表,列表上记录了可用的内存块,在分配内存时,从列表上找到一个足够大的连续内存块分配给对象,并更新列表上的记录。
-
-
内存分配并发问题:在分配内存空间的过程中,需要考虑在并发情况下,线程是否安全的问题。因为创建对象在虚拟机中是非常频繁的行为,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。因此必须要保证线程安全,解决这个问题有两种方案:
- CAS以及失败重试:对分配内存空间的操作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。CAS操作需要输入两个数值,一个旧值(操作前期望的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,否则不进行交换。
- TLAB(Thread Local Allocation Buffer,本地线程分配缓存):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块私有内存,也就是本地线程分配缓冲。TLAB的目的是在为新对象分配内存空间时,让每个java应用线程能使用自己专属的的分配指针来分配空间,减少同步开销。
-
初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,保证了对象的实例字段在java代码中可以不赋初始值就可以直接使用。
-
设置对象的对象头:在Hotspot中,对象在堆内存布局分成三部分(对象头、实例数据、对齐填充)
-
对象头:包含两部分信息:
- 运行时数据:用于存储对象自身的运行时数据,如哈希码、GC代年龄、锁状态标志、线程持有的锁、偏向线程ID等。
- 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个java数组,那对象头中还必须有一块用于记录数组长度的数据。
-
实例数据:是对象真正存储的有效信息,是在程序代码中所定义的各种类型的字段内容,相同宽度的字段会被分配到一起。
-
对齐填充:并不是必然存在的,仅起着占位符的作用。
-
- 执行init方法进行初始化:初始化成员变量,执行实例代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。new指令之后会接着执行构造方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完整创建出来。
对象的访问方式
-
通过句柄访问对象:在java堆中画出一块内存专门作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。
-
通过直接指针访问对象
-
优劣对比
- 使用句柄,reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄的实例数据指针,而reference本身不需要修改。
- 直接指针,速度快,节省一次指定定位的开销。
执行引擎
执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。执行引擎借助PC寄存器来完成工作。
解释器:当java虚拟机器启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
JIT(Just In Time Compiler)编译器:虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
Hotspot虚拟机采用解释器与即时编译器并存的架构。在java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
扩展:既然Hotspot虚拟机已经内置了JIT编译器,为什么还需要再使用解释器来“拖累”程序的执行性能呢?
首先明确,当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。所以,对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当java虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
StringTable
String声明为final的,不可被继承。实现了Serializable和Comparable接口,表示支持序列化且可以比较大小。在jdk8及以前内部定义了final char[] value用于存储字符串数据,jdk9时改为byte[]。
- 当对字符串重新赋值、调用replace()方法修改指定字符串、对现有的字符串进行连接操作时,需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。字符串常量池是不会存储相同内容的字符串的。
String的intern()方法的使用
-
jdk1.6中,将这个字符串对象尝试放入字符串常量池中。
- 如果字符串常量池中存在,则不会放入。返回已有的字符串常量池中的对象的地址。
- 如果没有,会把此对象复制一份(相当于重新new了一个对象),放入字符串常量池,并返回字符串常量池中的对象地址(返回新对象)。
解释:1.6中相当于重新new了一个对象放在常量池中,s3还是原来的对象,而s4则是intern()出来的新对象,所以两者地址不一样。
-
jdk1.7起,将这个字符串对象尝试放入字符串常量池。
- 如果字符串常量池中存在,则不会放入。返回已有的字符串常量池中的对象的地址。
- 如果没有,则会把对象的引用地址复制一份,放入字符串常量池,并返回字符串常量池中的引用地址。
解释:1.8之后则把s3对象的引用地址放在常量池中,s4也是用字符串常量池的引用地址,所以s3和s4是相同的地址。
补充:String s3 = new String("1") + new String("1")等价于new String("11")。但是常量池中并不生成字符串“11”。如果拼接符号左右有至少一个是String类型的变量,就会创建一个StringBuilder对象,再使用对象的append()方法将左右两边的数据添加到该对象中,再使用toString()方法,toString()方***生成一个新的String对象,再将String对象的索引传给赋值符号右边的变量。此时不会在常量池中生成一个字符串字面量。
案例:
参考:
JVM垃圾回收详解
垃圾回收的过程
JVM内存区域的程序计数器,虚拟机栈、本地方法栈的生命周期是和线程同步的,随着线程的销毁而自动释放内存,所以只有堆和方法区需要GC,方法区主要是针对常量池的回收和对类型的卸载,堆区针对的是不再使用的对象进行回收内存空间。我们常说的GC一般指的是堆区的垃圾回收,堆内存空间可以进一步划分新生代和老年代,新生代会发生Minor GC,老年代会发生Full GC。
当Eden区没有足够的内存空间给对象分配内存时,虚拟机会发起一次Minor GC,在GC开始的时候,对象会存在Eden和From区,To区是空的。进行GC时,Eden区存活的对象会被复制到To区,From区存活的对象会根据年龄值决定去向,达到阈值(默认15)的对象会被移动到老年代中,没有达到阈值的对象会被复制到To区(但有可能存在没有达到阈值就从Survivor区直接移动到老年代的情况:在进行GC的时候会对Survivor中的对象进行判断,若Survivor空间中年龄相同的对象的总和大于等于Survivor空间一半的话,年龄大于或等于该年龄的对象就会被复制到老年代;再把Eden区的对象复制到To区的时候,To区可能已经满了,这个时候Eden区中的对象就会被直接复制到老年代中)。这时Edne区和From区已经被清空了。接下来From区和To区交换角色,以保证To区在GC开始时是空的。MinorGC会一直重复这样的过程,直到To区被填满,To区被填满之后,会将所有对象移动到老年代中。如果老年代内存空间不足,则会触发一次Full GC。
对象存活判断:在堆里存放着几乎所有的java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占有的内存空间,这个过程被称为垃圾标记阶段。
引用计数算法
对象创建时,给对象添加一个引用计数器,每当有一个地方引用到它时,计数器值加1;引用失效时,计数器值减1;当计数值为0时,这个对象就是不可能被引用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
可达性分析算法
以“GC Roots”对象为起点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连接时,则证明此对象是不可达的。
GC Roots对象包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈(native方法)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
所有被同步锁持有的对象
老年代中的对象(引用了年轻代的对象)
对象的finalization机制
java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。finalize()方法允许在子类被重写,用于在对象被回收时进行资源释放。
目前最普遍使用的判断对象是否存活的算法是可达性算法,对象在真正死亡前,需要经历两个阶段:
- 可达性分析后,没有与GC Roots相连接的引用链,会被第一次标记并筛选。如果对象没有覆盖finalize()方法或已经调用过finalize()方法,则不会调用finalize()方法。否则对象会被放在F-Queue队列中,等待线程执行finalize()方法。
- 若对象想要存活下来,finalize()方法是最后的机会,只需在finalize()方法中重新与引用链上的对象相关联,否则,GC对F-Queue队列进行第二次小规模标记后对象真正死亡。
垃圾收集算法
-
标记-清除算法:从引用根结点开始遍历,标记出所有被引用的对象。然后对堆内存进行线性遍历,统一回收掉所有没有被标记的对象。缺点:效率低,会产生大量不连续的内存碎片,需要维护一个空闲列表。这里的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的连续空间是否足够,如果够,就存放覆盖原有的地址中的内容。
-
复制算法:将可用内存划分成大小相等的两块,每次只使用其中的一块,当这块的内存用完时,就将还存活的对象复制到另一个内存中,然后再把原来的内存空间清理掉。缺点:内存缩小为原来的一半。
-
标记-压缩(整理)算法:首先标记出需要回收的对象,接着将所有存活的对象都向一端移动,然后清理掉端边界以外的内存。
-
分代收集算法:根据各个年龄代的特点选择合适的收集算法。在新生代中,每次垃圾收集都有大量的对象死去,因此采用复制算法。老年代中,因为对象的存活率高,没有额外的空间对它进行担保,因此使用“标记-清除”和“标记-整理”算法。
-
增量收集算法:每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。通过对线程间冲突的妥善处理,运行垃圾收集线程以分阶段方式完成标记、清理或复制工作。此种算法虽然能减少系统的停顿时间,但是因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
-
分区算法将整个堆空间划分成连续的不同小区间。
System.gc()的理解
在默认情况下,通过system.gc()或者Runtime.getRuntime().gc()的调用,会显示触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用(不能保证立即生效)。
内存溢出:没有空闲内存,并且垃圾收集器也无法提供更多内存
- java虚拟机的堆内存设置不够
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
内存泄漏:严格来说,只有对象不会再被程序用到了,但是GC又不能回收它们的情况,才叫内存泄漏。内存泄漏举例如下:
- 单例模式:单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
- 一些提供close的资源未关闭导致内存泄漏。数据库连接,网络连接和io连接必须手动close,否则是不能被回收的。
Stop-the-world,简称STW,指的是GC事件发生过程中,没有任何响应,这个停顿称为STW
垃圾回收的并发和并行
- 并行:指多条垃圾收集线程并行工作,多个CPU同时执行垃圾回收,此时用户线程仍处于等待状态。
- 串行:单线程执行,同一时间段只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
- 并发:指同一时间段内,用户线程与垃圾收集线程同时执行,但是程序触发GC时,会同时触发STW,所以还是会出现只有垃圾回收线程单独执行的情况。
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点。”
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任务位置开始GC都是安全的。
强引用:是指在程序代码之中普遍存在的引用赋值,即"Object obj = new Object()"这种引用关系。无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收被引用对象。
软引用:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会收掉被弱引用关联的对象。
虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的在于这个对象被收集器回收时能收到一个系统通知。
垃圾收集器
评估GC的性能指标:在最大吞吐量优先的情况下,降低停顿时间
-
Serial收集器:
Serial收集器是一个新生代收集器,使用复制算法。由于是单线程执行的,所以在进行垃圾收集时,必须暂停其它所有的用户线程(Stop the world),对于限定单个CPU的环境来说,由于没有线程切换的开销,可以获得最高的单线程收集效率。
-
ParNew收集器:
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为和Serial收集器完全一样。
在多核CPU上,回收效率会高于Serial收集器;反之在单核CPU,效率会不如Serial收集器。ParNew收集器默认开启和CPU数量相同的线程数,可以通过-XX:ParallelGCThreads参数来限制垃圾收集器的线程数;ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,主要原因是,除Serial收集器之外,目前只有ParNew它能与CMS收集器配合工作。
-
Parallel Scavenge收集器:
Parallel Scavenge收集器是新生代收集器,使用复制算法,并行多线程收集。Parallel Scavenge收集器的特点是它的关注点与其它收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。高吞吐量可以最高效率的利用CPU时间,尽快完成程序的运算任务,主要适用于在后台不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精准控制吞吐量: 1.-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。 2.-XX:GCTimeRation:直接设置吞吐量大小,是一个大于0小于100的整数,也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间。 3.支持自适应的GC调节策略。它还提供一个参数:-XX:+UseAdaptiveSizePolicy,打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、新生代晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到最大吞吐量。
-
Serial Old收集器:
Serial Old是Serial收集器的老年代版本,使用单线程执行和“标记-整理”算法。
主要用途:client模式下默认的老年代垃圾收集器。在server模式下主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备垃圾收集方案,在并发收集发生Concurrent Mode Failure的时候,临时启动Serial Old收集器重新进行老年代的垃圾收集。
-
Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,jdk1.6之后开始提供,使用多线程和“标记-整理算法”
在JDK1.6之前,新生代使用Parallel Scavenge收集器只能搭配老年代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old正是为了在老年代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和年老代Parallel Old收集器的搭配策略。
-
CMS收集器:
CMS(Concurrent Mark Sweep)收集器应用于老年代,采用多线程的“标记-清除”算法实现的,实现真正意义上的并发垃圾收集器,是一种以获取最短回收停顿时间为目标的收集器。整个收集过程大致分为4个步骤,如下所示:
- 初始标记:需要停顿所有用户线程,初始标记仅仅是标记出GC Roots能直接关联到的对象,速度很快。
- 并发标记:进行GC Roots根搜索算法阶段,从GC Roots的直接关联对象开始遍历整个对象图的过程。这个过程耗时较长但是不需要停顿用户线程,可以并发运行。
- 重新标记:为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。需要停顿所有用户线程,停顿时间会比初始标记稍长,但比并发标记阶段要短。
- 并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
整个过程耗时最长的并发标记和并发清除过程,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS的弊端:
- CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3)/4。
- CMS收集器无法处理浮动垃圾,可能出现"Concurrent Mode Failure",失败后会导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留到下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。
- 由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其它收集器那样等到老年代几乎完全被填满后再进行收集,需要预留一部分内存空间提供给并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足用户线程,就会出现"Concurrent Mode Failure"失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了
- CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。CMS收集器提供了一个XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。
G1垃圾收集器原理详解
G1垃圾收集器的内存模型:
G1收集器不采用传统的新生代和老年代物理隔离的布局方式,仅在逻辑上划分新生代和老年代,将整个堆内存划分为2048个大小相等的独立内存块Region,每个Region是逻辑连续的一段内存,具体大小根据堆的实际大小而定,整体被控制在1M-32M之间,且为2的N次幂,并使用不同的Region来表示新生代和老年代,G1不再要求相同类型的Region在物理内存上相邻,而是通过Region的动态分配方式实现逻辑上的连续。
G1收集器通过跟踪Region中的垃圾堆积情况,每次根据设置的垃圾回收时间,回收优先级最高的区域,避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。
分区Region:
G1垃圾收集器将堆内存划分为若干个Region,每个Region分区只能是一种角色,空白区域代表是未分配的内存空间,最后还有个特殊的区域H区,专门用于存放巨型对象,如果一个对象的大小超过Region容量的50%以上,G1就认为这是个巨型对象。在其它垃圾收集器中,这些巨型对象会被分配在老年代中,但如果它是一个短期存活的巨型对象,放入老年代就会对垃圾收集器造成负面影响,触发老年代频繁GC。为了解决这个问题,G1划分了一个H区专门存放巨型对象,如果一个H区装不下巨型对象,那么G1会寻找连续的H分区来存储,如果寻找不到连续的H区的话,就不得不启动Full GC了。
Remember Set:
在串行和并行收集器中,GC是通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,为每个分区各自分配了一个RSet(Remember Set),它内部类似于一个反向指针,记录了其它Region对当前Region的引用情况,这样就带来一个极大的好处:回收某个Region时,不需要全堆扫描,只需要扫描它的RSet就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是initial mark的根之一。
事实上,并非所有的引用都需要记录在RSet中,如果引用源是本分区的对象,那么就不需要记录在RSet中;同时G1每次GC时,所有的新生代都会被扫描,因此引用源是年轻代的对象,也不需要在RSet中记录;所以最终只需要记录老年代到新生代之间的引用即可。
Card Table
如果一个线程修改了Region内部的引用,就必须要去通知RSet,更改其中的记录。需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,因此G1回收器引入了Card Table解决这个问题。
一个Card Table将一个Region在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片Card,因此Card是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过卡片Card来查找,每次堆内存的回收,也都是对指定分区的卡片进行处理。每个Card都用一个Byte来记录是否修改过,Card Table就是这些Byte的集合,是一个字节数组,由Card的数组下标来标识每个分区的空间地址。默认情况下,每个Card都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为0,即标记为脏被引用,此外RSet也将这个数组下标记录下来。
一个Region可能有多个线程在并发修改,因此也可能会并发RSet。为避免冲突,G1垃圾回收器进一步把RSet划分成了多个HashTable,每个线程都在各自的HashTable里修改。最终,从逻辑上来说,RSet就是这些HashTable的集合。(HashTable的key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的index)
前面三个数据结构的关系如下:
图中RSet的虚线表明的是,RSet并不是一个和Card Table独立的、不同的数据结构,而是指RSet是一个概念模型。实际上,Card Table是RSet的一种实现方式。G1对内存的使用以分区为单位,而对对象的分配则以卡片为单位。
RSet的写屏障:
写屏障是指每次Reference引用类型在执行写操作时,都会产生Write Barrier写屏障暂时中断操作并额外执行一些动作。
G1收集器的写屏障是跟RSet相辅相成的,产生写屏障时都会检查要写入的引用指向的对象是否和该Reference类型数据在不同的Region,如果不同,才通过CardTable把相关引用信息记录到引用指向对象所在Region对应的RSet中,通过过滤就能使RSet大大减少。
- 写前栅栏:即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。但JVM并不会立即维护RSet,而是通过批量处理,在将来更新。
- 写后栅栏:当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理。
每次将一个老年代对象的引用修改为指向年轻代对象,都会被写屏障捕获并记录下来,因此在年轻代回收的时候,就可以避免扫描整个老年代来查找根。
**Collect Set:**是指在Evacuation阶段,由G1垃圾回收器选择的待回收的Region集合,在任意一次收集器中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。G1的软实时性就是通过CSet的选择来实现的,对应于算法的两种模式:
- fully-young generational mode:也称young GC,该模式下CSet将只包含young region,G1通过调整新生代的region的数量来匹配软实时的目标;
- partially-young mode:也称Mixed GC,该模式会选择所有的young region,并且选择一部分的old region,old region的选择将依据Marking cycle phase中存活对象的计数,筛选出回收收益最高的分区添加到CSet中(存活对象最少的Region进行回收)
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%) 设置数量上限。
G1 Young GC:
当Eden区已满,JVM分配对象到Eden区失败时,便会触发一次STW式的年轻代收集young GC,将Eden区的存活对象拷贝到to survivor区;from survivor区存活的对象则根据存活次数阈值分别晋升到PLAB、to survivor区和老年代中;如果survivor空间不够,Eden区的部分数据会直接晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行**。**
young GC的详细回收过程:
- 根扫描:根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
- 更新RSet:处理dirty card队列中的card,更新RSet,此阶段完成后,RSet可以准确的反映老年代对所在的region分区中对象的引用。
- 处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
- 拷贝对象:将Eden区存活的对象拷贝到to survivor;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到老年代空间。
- 处理引用:处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片的,所以复制过程可以达到内存整理的效果,减少碎片。
G1 Mixed GC:
年轻代不断进行垃圾回收活动后,为了避免老年代的空间被耗尽。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾回收Mixed GC,Mixed GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。Mixed GC步骤主要分为两步:
- 全局并发标记(global concurrent marking)
- 拷贝存活对象(evacuation)
全局并发标记
在进行混合收集前,会先执行global concurrent marking,在G1 GC中,它并不是一次GC过程的必须环节,主要是为Mixed GC提供标记服务的。其执行过程分为五个步骤:
-
初始标记:会标记出所有GC Roots节点以及直接可达的对象,这一阶段需要STW,但是耗时很短。
初始标记过程与young GC息息相关。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道。
-
根区域扫描:扫描初始标记的存活区中(即survivor区)可直达的老年代区域对象,并标记为根对象。该阶段与应用程序并发运行,并且只有完成该阶段后,才能开始下一次STW的young GC。
因为RSet是不记录从young region出发的引用,那么就可能出现一种情况,一个老年代的存活对象,只被年轻代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。
-
并发标记:从GC Roots对堆中的对象进行可达性分析,找出存活的对象,此过程可能被young GC中断,并发标记阶段产生的新的引用(或引用的更新)会被SATB的write barrier记录下来,同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。在此阶段中,如果发现区域中的所有对象都是垃圾,那这个区域会立即被回收。同时,并发标记过程中,会计算每个区域中对象的存活比例。
-
重新标记:此阶段就是为了修正在并发标记期间,因应用程序继续运作而导致标记产生表动的那一部分标记记录,就是去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象。
-
清除:该阶段主要是排序各个Region的回收价值和成本,并根据用户所期望的GC停顿时间来制定回收计划。(这个阶段并不会实际去做垃圾的回收,也不会执行存活对象的拷贝)
清理阶段执行的详细操作有以下几点:
- RSet梳理:启发式算法根据活跃度和RSet尺寸对分区定义不同等级,同时RSet梳理也有助于发现无用的引用。(不懂!)
- 整理堆分区:为混合收集识别回收收益高(基于释放空间和暂停目标)的老年代分区集合。
- 识别所有空闲分区:即发现无存活对象的分区,该分区可在清除阶段直接回收,无需等待下次收集周期。
拷贝存活对象(evacuation)
当G1发起全局并发标记之后,并不会马上开始混合收集,G1会先等待下一次年轻代收集,然后在该young gc收集阶段中,确定下次混合收集的CSet
全局标记完成后,G1就知道哪些old region的可回收垃圾最多了,只需要等待合适的时机就可以开始回收了,而混合回收除了回收young region,还会回收部分old region。根据停顿目标,G1可能没法一次回收掉所有的old region候选分区,只能选择优先级高的若干个region进行回收,所以G1可能会产生连续多次的混合收集与应用线程交替执行,而这些被选中的region就是CSet了,而单次的混合回收算法与上文的Young GC算法完全一样,只不过CSet中多了老年代的内存分段;而第二个步骤就是将这些region中存活的对象复制到空闲region中去,同时把已经被回收的region放到空闲region列表中。
G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集,并且确定是否结束混合收集周期。
- 并发标记结束以后,老年代中100%为垃圾的region就直接被回收了,仅部分为垃圾的region会被分成8次回收(可以通过-XX:G!MixedGCCountTarget设置,默认阈值为8),所以Mixed GC的回收集(CSet)包括八分之一的老年代内存分段、Eden区内存分段、Survivor区内存分段。
- 由于老年代的内存分段默认分为8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越先被回收。并且有一个阈值决定内存分段是否被回收-XX:G1MixedGCLiveThresholdPercent,默认为65%,即垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
- 混合回收并不一定要进行8次,有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存有10%的空间浪费,即如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收,因为GC会花费很多的时间,但是回收到的内存却很少。
G1垃圾回收流程小结:当应用程序开始运行时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当达到IHOP阈值时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高效益的老年代分区。但随后G1并不会马上开启一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集,在这次STW中,G1将开始整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集。
FullGC:
当G1无法在堆空间中申请到新的分区时,G1变会触发担保机制,执行一次STW式的、单线程的Full GC,Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。
G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
- 从老年代分区拷贝存活对象时,无法找到可用的空闲分区
- 分配巨型对象时在老年代无法找到足够的连续分区
参考: