jvm-class文件加载过程

Java虚拟机可以把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程被称为虚拟机的类加载机制(Class Loading)。

类加载的时机

Class文件只有在必须要被使用的时候才会被加载,java虚拟机规定,一个类或者接口在被初次使用前,必须执行初始化(这时就要加载Class文件了)。这里的”使用“指的是主动使用,当出现以下几种情况时算作主动使用:

  1. 当创建一个类的实例时,比如使用new创建对象、反射、克隆、反序列化。
  2. 当调用类的静态方法时。
  3. 当使用类或者接口的静态字段时(final常量除外)。
  4. 当初始化子类时,要求先初始化父类。
  5. 放使用 java.lang.reflect 包中的方法反射类的方法时。
  6. 作为启动虚拟机,含有main()方法的那个类。

除了以上了情况属于主动使用,其他情况均属于被动使用。被动使用不会引起类的初始化。

类加载的过程

类加载(Class Load)过程可以分为加载、连接、初始化三个过程,其中连接又可以细分为验证、准备、解析三个过程。如下图所示:

加载

加载(Loading)阶段是整个”类加载“(Class Loading)过程中的第一个阶段,这两个概念得先区分下。java虚拟机在加载阶段需要完成以下三件事情:

  1. 通过一个类的全限定名来过去此类的二进制字节流。
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。(class文件中定义的的静态变量会存放在方法区,运行时常量池也是方法区的一部分)。
  3. 当类型数据妥善安置在方法区后,(虚拟机)会在堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

虚拟机要读取的是类的二进制字节流,这个字节流不一定非得在class文件中,虚拟机可以从多种方式获得,这个地方被java开发者玩出了许多花样,但这里不做介绍。再有就是类的二进制文件加载需要用到类加载器和双亲委派模型。

连接

连接阶段可细分为验证准备解析三个部分。

验证

验证是连接阶段的第一步,验证的主要目的是确保Class文件的字节流包含的信息符合《java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。验证阶段可以分为四个部分:文件格式验证元数据验证字节码验证符号引用验证

文件格式验证 目的是验证字节流是否符合Class文件规范,并且能被当前的虚拟机所处理。比如:是否以指定魔数开头,主、次版本号是否在当前java虚拟机可接受的范围内,常量池中的常量类型是否有不被支持的常量类型,指向常量池的索引的值是否有指向不存在的常量,等等。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后(字节流格式上符合一个java类型信息的要求),这段字节流才被允许进入java虚拟机内存的方法区进行存储,所以后面三个验证全都是基于方法去扥存储结构上进行的,不会再直接读取,操作字节流了。

元数据验证 是对字节码描述的信息进行语义分析(主要侧重于数据类型),以保证其描述的信息符合《Java语言规范》的要求。比如:一些被定义为final的方法或者类被重写或者继承了。但凡语义上不符合规范的,虚拟机不会通过。

字节码验证 (这块的验证更侧重于逻辑)是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在对”元数据验证“中的数据类型校验完毕后,这一步骤就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机安全的行为。比如:函数调用是否传递了正确类型的参数,变量赋值是不是给了正确的数据类型等等。Code属性中有个StackMapTable属性就是在这个阶段用于检测在特定的字节码处,局部变量表和操作数栈是否有着正确的数据类型。

符号引用验证主要目的是确保后面的解析行为能正常执行,class文件在常量池会通过字符串记录自己要使用的其他类或者方法,”符号引用验证“就是检查这些类或者方法是否确实存在,并且当前类有权访问这些类和方法。

准备

当一个类通过验证时,虚拟机就会进入准备阶段,在这个阶段,虚拟机会为这个类的类变量(被static关键字修饰的成员变量是类变量,没有被static修饰的成员变量是实例变量)分配存储空间,并设置初始值。在JDK8以后,类变量则会随着Class对象一起存放在堆中,”类变量在方法区”这时候就完全是一种对逻辑概念的表述了。还有就是这里所说的初值”通常情况”下是数据类型对应的零值,下表中列出了java所有基本数据类型的零值。

数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char ‘\u0000’ reference null
byte (byte)0    

“通常情况”下初值是零值,但特殊情况下不是零值。被final关键字修饰的类变量如果给定初值,那在准备阶段该类变量的初值是给定初值而不是默认的类型零值。

以如下方式声明value变量:

public static final int value = 123;

则在准备阶段,value的值会被初始化成123。

以如下方式声明value变量:

public static int value = 123

则在准备阶段,value的值会被初始化成0。

以如下方式声明value变量:

public int value = 123

则在准备阶段,虚拟机不会给value分配内存空间,因为value是对象变量而非类变量。

解析

解析阶段的工作是将类、接口、字段、方法的符号引用转化为直接引用,也就是得到类、字段方法在内存中的指针或者偏移量。可以这么说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段,但只存在符号引用,不确定系统中一定存在该对象。

初始化

这是类加载的最后一个阶段,初始话阶段的就是执行类构造器 <clinit>() 方法的过程。<clinit>() 方法并不是开发者在java代码中直接编写的方法,它是javac编译器自动生成的。类构造器的作用就是给类的静态变量赋值和执行静态代码块:static{}

如果一个类中定义了如下代码:

public class SimpleStatic{
    public static final int value = 123;
    public static int id = 1;
    public static int number;
    
    static{
        number = 4;
    }
}

则在SimpleStatic的class文件加载过程中,在连接的准备阶段中,虚拟机给类变量(静态变量)value分配内存空间并赋初值123,虚拟机给类变量id和number分配内存空间并初始化为类型零值。在初始化阶段中,虚拟机给id赋值为1,执行static代码块后给number赋值为4。

<clinit> 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法,我们平时也把实力构造方法后称为类构造器,但在此处要做区分)不同。它不需要显式地调用父类构造器,java虚拟机会保证在子类的<clinit>方法执行前,父类的<clinit>方法已经执行完毕,因此,在java虚拟机中第一个被执行的<clinit>方法的类型肯定是 java.lang.Object

父类的<clinit>方法先与子类执行。也就意味着父类中的静态代码块先于子类的静态代码块执行。我们从new一个子类对象开始看起,方法的执行顺序应该是:父类的静态代码块 > 父类的实例构造函数 > 子类的静态代码块 > 子类的实例构造函数

父类的<clinit>方法不一定是必须的,如果一个类没有静态变量也没有静态代码块时,javac编译器就不会为这个类生成<clinit>方法。

总结

  1. 一个类只有后它被需要被主动使用的时候,虚拟机才会去加载它的二进制字节流(字节流一般在class文件中)。
  2. 类加载过程主要分为加载、验证、准备、解析、初始化这五个过程。
  3. 加载作用是定位类的二进制字节流在哪里,并且将它读进方法区,最后在堆中生成一个 java.lang.Class 对象。
  4. 验证可以细分为四个部分:文件格式验证、元数据验证、字节码验证、符号引用验证。
  5. 准备的作用是给类中的静态变量分配内存空间,并赋初值,如果静态变量被final修饰且指定了初值,则该静态变量的初值就是指定的初值,为被final修饰的静态变量赋它的类型初值。
  6. 解析的作用是将class文件中的类、字段、方法的符号引用转化成直接引用。
  7. 初始化过程就是执行类构造器<clinit>方法。该方法由java编译器自动生成,目的是给类的静态变量赋初值并且执行类中的静态代码块。
  8. 当我们在java代码中new一个对象时(第一次使用该对象),从代码执行角度看只是执行了一个实例构造函数。从虚拟机角度看,是执行了一整套的类加载过程,直到初始化阶段完毕后,最后才执行new关键字后面跟着的实例构造方法。

jvm-class文件

开发者编写的java代码是先编译成了字节码存放在 .class 文件中(开发者编写的影响程序执行每一个单词、字母、数字都会编译成对应的字节码,在class文件中恰当的地方放着),之后java虚拟机加载class文件并执行其中的字节码,从而将程序运行起来。这篇文章主要介绍class文件的结构以及各部分的作用。

class文件整体结构和格式

class文件中的基本结构如下图所示:

在java虚拟机规范中,Class文件使用一种类似于C语言结构体的方式进行描述,并且使用统一的无符号整形数作为基本数据类型,由u1、u2、u4、u8分别表示1个字节、2个字节、4个字节、8个字节整数(1字节=8位)。class文格式:

代码示例

文章中使用如下代码介绍class文件中的各个部分:

public class SimpleUser {

  public static final int TYPE = 1;

  private int id;
  private String name;

  public int getId() {
    return id;
  }

  public void setId(int id) throws IllegalStateException {
    try {
      this.id = id;
    } catch (IllegalStateException e) {
      System.out.println(e.toString());
    }
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
}

执行命令,将java文件编译class文件

javac SimpleUser.java

再执行命令,将二进制格式的class文件以十六进制打开。

hexdump SimpleUser.class

编译成对应的class文件的十六进制合适如下所示,java文件编异常class文件后,里面的内容就长这个样子,除了开头几个字节一眼能看明白是什么意思,其他的都得推算。

使用javap反编译工具,可以直观的查看字节码中所描述的信息。

执行命令 javap -verbose SimpleUser 得到的结果在这里:simple-user-file

class文件中各个部分的作用

魔数

魔数(Magic Number)作为是class文件的标志,用来告诉java虚拟机,这是个class文件。魔数是一个4字节无符号整数,并且该值固定:0xCAFEBABE。如果一个class文件不以0xCAFEBABE,虚拟机在执行文件校验的时候会抛异常。在class文件中的位置:行:0000000,列:0~3。

版本号

版本号表示当前class文件是由哪个版本的编译器产生的,小版本号在前,大版本号在后,每个版本号占2个字节。(行:0000000,列:4~7)数字:00 00 00 34表示当前编译器的版本是1.8。高版本的虚拟机可以执行低版本编译器生成的class文件,但是低版本虚拟机无法执行高版本编译器生成的class文件。

常量池

一个很重要、又有点复杂的地方。 从 simple-user-fileConstant pool 部分可以看出,实例代码中有50个常量。这些常量主要分为两大类:字面量符号引用

字面量 就相当于java概念中的常量,大概有两种:

  1. 文本字符串,比如:String s = ”abc“,”abc“会被放到常量池中。
  2. 被声明为final的常量的值,比如:final int TYPE = 1 ,”1“会被放到常量池中。

有些博客中写道:八种基本类型的值,比如:int length = 6,这块的6也会被放到常量池中,我测试时在常量池中没找到6(用的是jdk1.8)。

符号引用 是以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时可以无歧义的定义到目标即可。 可以理解为:按”一定规则“对java文件中的类、字段、方法、接口等信息进行了另一种”描述“,目的是为了虚拟机在加载时能找到被该符号引用所描述的”对象“在哪。因为在java程序运行时虚拟机需要精确地知道某个类在内存中的地址是多少,但是在java编译成class文件时根本不能确定它的内需地址,所以此时用符号引用来描述这个类,只要虚拟机在使用这个类时能找到它就可以了。符号引用主要包含以下几类:

  • 文件中包的名字;
  • 类和接口的全限定名称;
  • 成员变量(字段)的名称和描述符;
  • 成员方法的名称和描述符;
  • 方法句柄和方法类型;
  • 动态调用点和动态常量;

等等,这些”常量“的名称和描述符都是按一定的规范写死在常量池中的,程序运行时不可能发生变化。当虚拟机在做类加载时,将会从常量池中获得对应的符号引用,再在类创建时或者运行时解析、翻译到具体的内存地址中(将符号引用变成直接引用)。(简单理解就是,开发者写的类、字段、方法都存放在了这个地方,虚拟机运行时自己会来找并且解析的)

描述符 的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。

直到目前,总共有17中不同的类型的常量,每种常量都是一个独立的数据结构,(在常量池中的其他部分会存出现指向这些数据节结构的索引)。常量池类型如下图所示:

网上找的17种常量类型总表,从这里可以看出,常量池中保存了,常量的长度、名称、字面量值、符号引用的索引(类中字段如果引用类型,则用索引指向该符号引用真正所在的地方)等信息。

访问标志

常量池之后紧跟着就是访问标志了,占1个字节。这个标志用来表示Class是接口还是类,以及它的修饰符是什么,是否被声明为final,等等。

当前类、父类和接口

访问标记结束后,会指定该类的类别、父类类别以及实现的接口。格式如下:

u2	this_class;
u2	super_class;
u2  interfaces_count;
u2  interfaces[interfaces_count];

this_class 和 super_class都是2字节无符号整数,他们指向常量池中的CONSTANT_Class,以表示当前的类和父类 ,因为一个类可以实现多个接口,因此需要以数组的形式保存多个接口的索引。

类的字段

在类描述后,会有类的字段信息。因为一个有多个字段,所以需要先指明字段的个数。

u2 fileds_count;
field_info	fileds[fields_count]

fileds_countb是2字节无符号整数,表示字段的数量。field_info表示字段的具体信息:

field_info {
    u2	access_flags;
    u2	name_index;
    u2	descriptor_index;
    u2	attributes_count;
    attributes_info	attributes[attributes_count]
}
  • access_flags: 访问标记,该字段是public还是private,或者…….。
  • name_index: 字段的名字,不是直接存储名字,而是这块存放了一个指向常量池中的CONSTANT_Utf8结构的索引。

  • descriptor_index:表示字段的类型,这块也是个索引,指向了常量池中的一个CONSTANT_Utf8结构数据。

类的字段部分所包含的固定数据项到descriptor_index为止就全部结束了。后面紧跟的attributes_count、attributes_info[attributes_count]是属性表集合,用来存储一些额外的信息,比如,某个int类型的属性有初始值,那么这个属性集合就会被用到了。

类的方法

class文件对于类的方法的描述和字段的描述用了几乎完全一致的方式。类的方法信息由两部分构成:

u2 method_count;
method_info	methods[method_count]

method_info表示字段的具体信息:

method_info {
    u2	access_flags;
    u2	name_index;
    u2	descriptor_index;
    u2	attributes_count;
    attributes_info	attributes[attributes_count]
}

方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性表集合。方法的定义可以通过前面这四个信息来表达清楚,那方法里面的代码去哪里了呢?方法里的java代码,经过javac编译器译成字节码指令后,存放在方法属性表集合中一个名为”Code“的属性里面。

类的属性

属性表(attribute_info)会在多个场合出现,class文件、方法表、字段表都可以携带自己的属性表集合,以描述某些场景专有的信息。为了能正确解析Class文件,期初只定义了9项属性,知道Java SE 12版本中,预定以属性已经增加到了29项。这块只介绍属性表集合中Code属性以及跟它相关的属性,下表中只是罗列了29项属性中其中4项:

属性名称 使用位置 含义
Code 方法表 java代码编译成的字节码指令
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK 6中新增的属性,供新的类型检查验证器检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配

上表的第二列”使用位置“意思是:class文件由大概11个基本结构组成,这个属性会在哪个基本结构中出现。上表的意思是:表中第一行显示Code属性会在方法表中出现,其余三个属性将会在Code属性中出现。

code属性

这块的内容参考simple-user-file中的结果来看,会更直观些。

在类的方法那部分提到过,java程序方法体里面的代码经过javac编译器处理后,最终会变成字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,比如接口或者抽象类的方法中就不存在Code属性,如果方法表中有Code属性存在,那么它的结构将如下表所示:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_table_length
u2 attributes_count 1
attribute_info attributes attributes_count
  • attribute_name_index 是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为”Code“,它代表了该属性的属性名称。

  • attribute_length表示属性的长度。

  • max_stack代表了操作数栈深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个最大深度。虚拟机在运行时需要根据这个值来分配栈帧中操作数栈深度。

  • max_locals代表了局部变量表所需的存储空间。max_locals的单位是变量槽(Slot),变量槽是局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不大于32位的数据类型,每个局部变量占用一个槽,double和long这种64位长度的数据类型则需要两个槽来存放。

    java虚拟机会将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量表的作用域时,这个局部变量表占用的变量槽可以被其他的局部变量表使用,javac编译器会根据变量做用户来分配变量槽给各个变量使用。

  • code_length 代表字节码长度。

  • code 用来存储java源程序编译后生成的字节码指令。

  • exception_table_length和exception_table表示显式异常表,异常表对于Code来说并不是必须存在的(有些方法没有显式抛异常)。异常处理表告诉一个方法该如何处理字节码中可能抛出的异常。

到现在为止,Code属性的主体部分就介绍完了,但是Code属性中还包含更多的信息,这些信息都以属性的形式内嵌在Code属性中,除了类、方法、字段可以内嵌属性外,属性本身也可以内嵌属性。Code属性的中的attribute_info属性就里就包含着 LocalVariableTableLineNumberTableStackMapTable

  • LineNumberTable 用来记录字节码偏移量和行号的对应关系,在软件调试时,该属性有至关重要的作用,若没有它,调试器无法定位到对应的源码。
  • LocalVariableTable 这是局部变量表,它记录了一个方法中所有的局部变量。(java程序运行时,这货在栈帧中躺着)。
  • StackMapTable 在Class文件做类型校验时会用到该文件。

总结

  • 梳理这块的只是感觉好枯燥,不过后面的类加载机制肯定会用到这些的,梳理下还是很有必要的。

  • 常量池中存放了类、字段、方法的名字,如果字段是个字面量,那么它的值也被存放在这里了。

  • 常量池里存放了方法、类的符号引用,如果字段是引用类型的,那么它的符号引用也被存放在这里了。

  • 字段的描述性信息(修饰符、字段名索引、描述符、额外属性等)都存放在字段表集合中。

  • 方法的描述性信息都存放在方法表集合中。

  • 方法体中的java代码被编译成字节码后,存放在Code属性中,而Code属性是方法表中的attribute_info的一部分。(说白了,方法体编译成的字节码也存放在方法表中)。

jvm-垃圾收集器

之前文章介绍过垃圾回收机制,这篇文章垃圾回收的实际执行者–垃圾回收器。按照是否可并行,垃圾回收器可分为串行垃圾回收器和并行垃圾回收器;按照作用区域,可分为新生代垃圾回收器和老年代垃圾回收器。下图是按照作用区域将几款经典的收集器进行划分。

Serial

Serial是新生代串行回收器,采用标记-复制算法,它也是最早的垃圾收集器,目前会用在客户端模式下。串行收集器表明该收集器在执行时只会使用一条线程去晚上垃圾收集工作,而且在进行垃圾收集时,必须暂停用户其他所有的线程,直到它收集结束。“Stop The World”是为了配合可达性分析,但这项工作是在用户不可知的情况下,由虚拟机自发启动和完成的,对于实时性要求比较高的场景,STW带给用户很糟糕的体验。后续的每个新设计出来的虚拟机都在把降低STW的时间作为目标之一

ParNew

ParNet是新生代的并行收集器,更确切的说她只是Serial收集器的多线程版本,除了多收集垃圾以外,采用算法等其他功能都与Serial是一样的。所以ParNew在执行垃圾收集也需要暂停用户线程,但因为是多线程收集,暂停的时间减少了(在多核cpu环境下),在单核CPU中,ParNew的收集效率就没有Serial强了,因为每个时刻CPU都只能执行一个线程,ParNew线程之间的切换会产生时间开销。

Parallel Scavenge

Parallel Scavenge 是新生代多线程收集器,采用标记-复制算法。该收集器的目标是达到一个可控的吞吐量(ThroughPut),吞吐量就是:处理器用于处理用户代码的时间与处理器总消耗时间(用户代码花费时间与垃圾收集花费时间的和)的比值,很明显,比值越大,垃圾收集占用的时间就越小,程序的响应速度就越好。为了达到这个目的,该收集器提供了两个参数用于精确控制吞吐量:

最大垃圾收集停顿时间(-XX:MaxGCPauseMillis) : Parallel Scavenge会调整java堆的大小或者其他参数的大小,尽可能把垃圾回收时间控制在最大垃圾收集停顿时间之内。但是最大收集停顿时间如果设置的太小,则虚拟机会频繁地执行垃圾回收,从而增大了总的垃圾收集时间,降低了吞吐量。

垃圾收集时间占比(-XX:GCTimeRatio):这个参数直接设置的垃圾回收不得超过多长时间。

Parallel Scavenge还支持自适应的垃圾收集策略,使用 -XX:UseAdaptiveSizePolicy可以打开自适应策略。在该模式下,新生代的大小,eden和Survivor区的比例,晋升老年代的对象年龄等参水会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点,在手工调优比较难的场景下,可以使用中模式让虚拟机自己完成调优工作。

Serial Old

Serial Old是Serial收集器的老年代版本,采用标记-整理算法,同样也是个串行收集器。

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,采用标记-整理算法。

CMS

CMS(Concurrent Mark Sweep)收集器采用标记-清除算法的老年代并发垃圾收集器,它的目标是要获取最短回收停顿时间。目前大多数java应用集中在基于浏览器的B/S系统的服务端上,这类系统都对api的响应速度有很高的要求,CMS收集器很符合这类应用的需求。CMS收集器在JDK5中发布。

垃圾收集过程

CMS收集器的执行过程分为以下几步:

  1. 初始标记(CMS initial mark):仅仅标记下GC Roots能直接关联到的对象,速度很快。这个阶段需要STW配合。
  2. 并发标记(CMS concurrent mark):这个过程就是荣GC Roots直接关联的对象开始遍历整个图对象的过程,这个过程很长但是不需要暂停用户线程。( 其实就是标记各个引用链上都有哪些对象)。
  3. 重新标记(CMS remark):这个过程是为了修正在并发标记阶段,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录(之前标记是活的对象,可能在并发标记阶段已经死了,要修改这些对象的标记记录)。这个过程也需要STW配合,暂停时间也要比初始标记阶段的暂停时间长,但远远小于并发阶段花费的时间。
  4. 并发清除(CMS concurrent sweep)清除到标记阶段已经判定死亡的对象。这个过程可以与用户应用程序并发执行。

因为在耗时最长的并发标记和并发清除阶段中,垃圾收集器线程和用户线程可以并发执行,所以总体上说,CMS收集器的内存回收过程是和用户线程一起并发执行的。

优缺点

CMS的优点是并发收集低停顿。但也有三个很明显的缺点:

  1. CMS对处理器资源很敏感。对处理器资源敏感是并发程序的通病。多线程垃圾回收会占用一部分用户的线程,这样会导致用户程序运行变慢。处理器越少、核数越少,多线程回收对用户程序的影响越大。
  2. CMS无法处理“浮动垃圾(Floating Garbage)”,因为在并发清除阶段,用户程序仍在运行,这可能会产生新的垃圾,但是这部分垃圾是在标记过程结束后产生的,CMS无法收集他们,只能等待下一次CMS垃圾收集时在清理掉。
  3. CMS是基于标记-清除算法的,前面提到过,这种算法收集完对象后内存中会产生大量的空间碎片。这种情况不利于被大对象分配空间。若真遇到大对象在老年代请求内存空间,会触发一次Full GC。(Full GC:收集整个java堆和方法区的垃圾)。

G1

G1(Garbage Frist)是一款主要面向服务端应用的垃圾收集器。它开创了面向局部的收集思路和面向region的内存布局模式。

设计思路

之前提到过:继Serial收集器以后,设计收集器都把降低“Stop World Time”作为目标之一,最终目的是提高收集器的效率。G1的设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾手机上的时间大概率不超过N毫秒这样的目标。

实现这个目标,G1做了两件事情:

  1. 基于Region的堆内存布局模式

    G1之前的回收器基于分代理论的设计,将内存分成很大的固定大小的两个区域:新生代和老年代,新创建的对象分配在新生代,达到晋升年龄或者很大的对象直接晋级到老年代。

    G1也遵循分代收集理论的设计,也有新生代和老年代的概念。不同的是,G1将堆内存划分为多个大小相等的独立区域(Region),每个Region根据需要可以扮演Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1的大多数行为都把Humongous Region作为老年代的一部分看待。基于Region的堆内存布局模示意图如下图所示(E:Eden空间、S:Survivor空间、H:Humongous区域)G1将Region作为单次回收的最小单元,也就是说每次收集到了内存空间都是Region大小的整数倍。这样G1就能建立可预测的停顿时间模型。

    image-20200516110349592

    2004年Sun实验室发表第一篇关于G1的论文,提出了”化整为零“的思路,直到2012年4月JDK 7 update 4发布,用了将近10年时间才捣腾出能够商用的G1收集器。(细节实现还是很难的)

  2. 面向局部的收集思路

    G1之前出现的垃圾收集器要么是新生代收集器(Minor GC)、要么是老年代收集器(Major GC)、再要么是整堆收集器(Full GC),都是对自己的目标区域做全收集。

    G1收集以Region为最小单位,每次收集都是收集了一个或者多个Region区域,简而言之,G1收集的区域是由多个Regions(可以连续,也可以不连续)组成的回收集,从而避免了全堆收集。而且G1执行垃圾回收,衡量的标准不再是它属于那个分代,而是哪快内存中存放的垃圾数量最多,回收的收益最大,这就是G1收集器的混合收集模式(Mixed GC)。更具体的处理思路是:让G1收集器去跟踪Region里面垃圾堆积的”价值“大小,价值即回收所得的空间大小和回收所需时间的经验值,然后在后台维护一个优先级列表,每次都优先处理那些回收价值收益最大的Region,这也是Garbage Frist名字的由来。

这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1在有限的时间内回去尽可能高的收集效率。

垃圾收集过程

G1将堆划分成一个个的区域,每次回收的时候,只收集其中几个区域,以此来控制垃圾回收时产生的一次停顿的时间。

  • 初始标记:标记从根节点直接可达的对对象(先执行)。之后会执行一次新生代GC,新生代GC结束后,Eden空间被清空,Survivor空间也会被收集一部分数据,存活对象被移入另一个Survivor区域。(该阶段会暂停用户线程,但耗时很短)
  • 并发标记:从GC root开始对堆对象进行可达性分析,递归扫描整个对里的对象图,找出要收集的对象,这个阶段耗时很长。(该阶段与用户线程并发执行)
  • 重新标记:因为并发标记时,用户线程依旧在运行,因此,标记结果可能需要修正,这个阶段是对并发标记的结果做补充,耗时很短。(该阶段会暂停用户线程)。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,更具用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那部分Region中存活的对象复制到空的region中,再清理掉旧Regin的全部空间。(该阶段会暂停用户线程)

从这几个阶段可以看出,G1收集器除了并发标记外,其余阶段都是要暂停用户线程的。所以,G1并非纯粹的追求低延时,官方给他设定的目标是在延时可控的情况下获得尽可能高的吞吐量。

期望停顿时间

可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望时间,可使得G1在不用应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。期望停顿时间设置过大,用户程序执行会变得缓慢,因为G1毕竟是要冻结用户线程的。期望停顿时间设置过小,会导致每次只回收堆内存很小的一部分,收集器的收集速度跟不上分配器的分配速度,结果需要更频繁的执行垃圾回收操作,甚至会因为堆被占满引发Full GC而降低性能。所以通常将期望停顿回收之间设置为100-300毫秒之间。

从G1开始,垃圾回收器的设计思路变为追求能够应付内存分配速率,而不是追求一次性把整个java堆全部清理干净。这样收集器的速度只要能跟得上分配器的分配速度,那就能运作的很完美。这种新的收集器设计思路从工程实现上看是G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1与CMS相比较

G1与CMS相比有点有很多,最大停顿时间、Region内存分布、按受益价值动态确定回收集,这些创新先都是CMS没有的。除此之外,但从回收算法上来看:CMS采用”标记-清除“算法,G1从整体看是基于”标记-整理“算法实现的收集器,从局部看(两个Region之间)是基于”标记-复制“算法实现的是极其,不管是采用哪种,都表示G1执行之后不会产生碎片空间。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易无法找到连续的内存空间而以前触发下一次收集。

G1相比于CMS的缺点:从内存角度看,每个Region都要维护一份卡表,而且Region的数量比CMS收集器的分代数量(两个)明显要多得多,因此G1收集器要比CMS需要更大的额外内存。根据经验,G1至少要耗费大约相当于Java对容量10%~20%的额外内存来维持收集器工作。

目前在小内存应用上CMS的表现大概率仍然要优于G1,而在大内存上G1则大多能发挥其优势,这个优势的Java堆容量平衡点通常在6GB~8GB之间。随着HotSpot的开发者对G1的不断优化,也会让对比结果继续想G1倾斜。

总结

  1. 垃圾收集器基于分代理论而设计,都有自己针对的目标区域。Serial、Parallel Scavenge、ParNew是新生代垃圾收集器,并且都采用”标记-复制“算法。Serial Old、Parallel Old、CMS是老年代收集器,其中Serial Old、Parallel Old采用”标记-整理“算法,CMS采用”标记-清除算法“。G1是将Java堆划分成Region,并以Region为最小收集单位执行垃圾回收的收集器,成局部看G1采用”标记-复制“算法,从全局看G1采用”标记-整理“算法。
  2. 收集器执行收集的过程分为:初始标记、并发标记、重新标记、并发清除(垃圾回收)这四个阶段。G1是并发标记阶段可以与用户线程并行、其他阶段都要暂停(SWT)用户线程。其他收集器都是初始标记和冲洗标记两个阶段暂停用户线程,并发标记和并发清除都可以与用户线程并行。
  3. 设计多线程垃圾收集器的目的是提高垃圾收集的效率(降低SWT的耗费时间间),但多线程垃圾回收器对硬件有要求(起码得是多核CPU)。
  4. CMS垃圾收集器的优点:并发收集、低停顿;缺点:对处理器资源有要求、无法收集”浮动垃圾“、垃圾收集后会产生碎片空间。
  5. G1垃圾收集器优点:并发收集、低停顿(最大停顿时间由用户设定,推荐100~300毫秒)、垃圾收集后不会产生碎片空间、可处理”浮动垃圾“(因为并发标记后会有从新标记修正结果,在标记清楚阶段是冻结用户线程的);缺点:G1收集器运行时需要额外的内存配合收集器运行(大小是java存的10%~20%)。
  6. G1是基于新思路设计出来的收集器,旨在追求在有限的时间内获取尽可能高的收集效率,因此设计出了 基于Region的堆内存分配模式面向局部的垃圾收集策略。自G1开始,最先进的垃圾收集器的设计思路都开始变为追求:收集器的收集速度可以应付应用的内存分配速度,而不是追求一次性将java堆全部清理干净。所以说G1是收集器技术发展的一个里程碑。

jvm-垃圾收集机制

当分配到内存中的对象再也不会被访问的时候,垃圾收集器(Garbage Collection,GC)就会自动回收这些对象占用的内存空间,把腾出来的空间留给新的对象。栈中变量的存储空间随着栈帧的入栈和出栈自动分配和回收,所以垃圾收集器主要作用的区域有两个:堆和方法区。

方法区垃圾回收

类的常量池类型信息都存储在方法区里,这两部分内容也是垃圾回收的重点目标。

判断常量是否可回收

假如常量池中有个字面量 “NAME” ,但是当前系统中没有任何字符串的值是“NAME”,而且它也没有在别的地方被访问到。这个时候如果垃圾收集器执行垃圾回收,这个常量将会被虚拟机“清除”出去,将它占用的存储空间释放出来。

判断类型信息是否可回收

一个类型如果同时满足下列三个条件,那么它就允许被回收。

  1. 该类以及他的所有子类的所有实例都已经被回收了。
  2. 加载该类的类加载器已经被回收了。
  3. 该类对应的 java.lang.Class 没有在任何地方被引用,(这点是防止通过反射的方式访问该类)。

满足上三个条件表示类型“可被回收”,回收是否执行还需要配置外部参数。而且方法区的垃圾回收成果往往很低,所以该区不是垃圾回收的重点区域。

堆垃圾回收

如何判断对象可回收

堆中存放着几乎所有的对象,这些对象的引用放在栈中,而且会出现多个引用指向同一个对象的。如果一个对象没有引用指向它,则这个对象可回收。基于此,有两种判断对象是否可回收的算法。

引用计数算法

它的原理很简单:在对象中添加个引用计数器,每当有个地方引用它时,计数器就加一,当引用失效时,计数器就减一,当对象的引用计数器的值为零时,表示这个对象不可能再被使用了。这种算法优点是简单,缺点是无法解决两个对象循环引用的问题,也因为此,主流的java虚拟机都没有使用这种算法来管理内存

可达性分析算法

基本思想:通过一系列被称为“GC Root ”的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程走过的路径称为“引用链”,如果一个对象到“GC Root”之间没有任何引用链相连,则这个对象不可被访问。

固定可作为GC Root的对象包括以下几种:

  • 栈中(其实是栈栈帧中)的引用的对象。
  • 本都方法栈中引用的对象。
  • 方法区中类静态成员变量引用的对象。
  • 方法区域中常量引用的对象。
  • 所有被同步锁(synchronized 关键字)持有的对象。

判断对象是否可回收都和引用有关,HotSpot虚拟机采用直接引用法(引用中存储的值是对象的起始地址)。在JDK1.2后,java对引用的概念进行了扩充,将引用分为强引用软引用弱引用虚引用,四种引用强度一次减弱。强引用指向的对象在任何时候都不会被收集,即使内存溢出也不会被收集。其他三个引用指向的对象在一定程度下都是可以被回收的。

分代收集理论

可达性分析判断完对象是否可回收后,接下来就应该确定对象应该怎么回收。HotSpot中的垃圾收集算法属于追踪式垃圾收集的范畴。目前大多数垃圾收集器都遵循“分代收集”的理论,该理论建立在两个分代假说之上:

  1. 弱分代假说 :绝大多数对象都是朝生夕灭的。
  2. 强分代假说: 熬过越多次垃圾收集过程的对象就越难消亡。

这两个假说奠定了垃圾收集器的设计原则:将内存对象划分出不同的区域,然后将对象依据其年龄(分代年龄信息放在对象的头中)分配到堆的不同区域进行存储,针对不用的区域使用不同的垃圾收集算法。

一般会把java堆分成 新生代(Young Generation)老年代(Old Generation) 两个区域,新生代中每次垃圾回收时都会有大批的对象死去,而每次回收后存活的对象会逐步的晋升到老年代中存放(很显然,刚new出来的对象肯定是分配到新生代中的)。垃圾回收器根据区域中对象的消亡情况安排相应的收集算法,从而就有了:标记-清除、标记-复制、标记整理算法。如下图所示:

标记-清除算法(Mark-Sweep)

该算法分为”标记“和”清除“两个阶段:首先通过根节点标记所有从根节点开始可达的对象,因此,未被标记的对象就是不会再被访问的对象,然后清除所有未被标记的对象。该算法是最基础的垃圾收集算法,后续的垃圾收集算法都是在其基础上改进的。

算法缺点: 垃圾收集后会产生大量不连续的空间碎片,如果后面要给大对象分配内存,可能还要再执行一次垃圾收集操作。如果当前区域有大量的待收集对象,而且个头还不小,该算法的执行效率会大大降低(所以该算法不适合用在新生代区域中)。

标记-复制算法(Mark-copying)

该算法主要解决的是清楚大量可回收对象是效率低的问题。它将内存按照容量划分成大小相等的两块,分别叫做A1,A2,每次只使用其中一块(A1),当执行垃圾回收时,将内存A1中存活的对象复制到内存A2中去,再把内存A1中的所有对象全部清除。

优点:如果当前区域的可回收对象很多时,那需要复制的对象就很少了,可以大大提高垃圾收集效率(所以适合应用在新生代)。当向A2复制对象时,因为A2不存在空间碎片的问题,所以直接移动堆指针,按照顺序分配存储空间即可。

缺点:可用的内存空间减少一半,还是很浪费的。

主流的java虚拟机都优先采用这种收集算法回收新生代。并且根据新生代区域中的对象“朝生夕灭”的特点,IBM公司提出了“Appel式回收”,它将新生代划分成更细的区域 :把新生代划分为一块较大的Eden区域和两块较小的Survivor区域,这两块Survivor区域大小一样。每次分配内存只使用Eden和其中一个Survivor区域,发生垃圾收集时,将Eden和Survivor区域中存活的对象复制到另一个Survivor中,之后将Eden和已用过的那块Survivor区域中的对象直接清空。HotSpot虚拟机默认的Eden和Survivor区域的大小比例是8:1,这样仅仅只浪费了10%的内存空间(Appel式回收解决了标记-复制算法内存利用率不高的问题)。加入另一块Survivor上没有足够的空间存放上一次新生代收集存活下来的对象,那这些对象直接通过分配担保机制直接进入老年代。

标记-整理算法(Mark-Compact)

该算法是针对老年代对象消亡特征提出的。该算法依旧是先标记,不过后续操作不是直接收集可回收对象,而是将存活多想都向内存的另一端移动,然后直接清除掉边界以外的内存。老年代中有大量的存活对象,移动这些对象是一种极为负重的操作,而且还需要停暂停用户的应用程序才能进行,这种“暂停”被虚拟机的最初设计者形象的描述为“Stop The world”。“暂停”的目的是终止所有应用线程的执行,只有这样系统中才不会有新的垃圾产生,同时停顿保证了系统在某个瞬间的一致性,也有利于垃圾收集器更好地标记垃圾对象。

总结

垃圾回收在方法区和对两个区域发生,目的是腾出内存空间放置内存溢出,方法区的垃圾回收效果不明显,所以垃圾回收主战场是堆。

根据可达性分析算法,如果堆中的对象到GC Root之间没有任何引用链相连,那么这个对象是可被回收的。

根据分代收集理论将堆内存分为新生代和老年代两个区域,新创建的对象都分配在新生代中,多次回收操作后仍能存活的对象将晋升到老年代。

对于堆中不同的区域,采用不同的垃圾回收算法,新生代使用“标记-复制”算法,老年代使用“标记-整理”和“标记-清除”算法。

jvm-对象的创建、布局、访问

之前介绍过java虚拟机的运行时数据区域,这篇文章简单介绍java对象在运行时数据区域的是如何创建、布局和访问的(HotSpot虚拟机)。

创建

java程序执行 new Object() 时就是要创建对象了。java虚拟机收字节码的new指令时,首先会去常量池中检查是否能找到Object的符号引用(Object对象的以符号形式的书面描述,存放在.class文件中),并检查这个符号引用所代表的的累是否已被加载、解析、初始化过(初始化后,表明这个类已经创建完成了)。如果没有,那必须先执行Object的加载过程。

当类加载检查通过后,就需要为Object分配内存空间。这里有两种内存空间分配方式:指针碰撞和空闲列表,如果内存空间是规整的,使用指针碰撞,分配内存就是将指针向未被奉陪的内存区域方向移动一定距离;如果内存是“坑坑洼洼”的,使用空闲类表,在类表中找出一块满足需求的内存空间分配给Object,并且更新列表。对象的内存空间一般会分配在堆区域,这块区域是线程共享的,当不同线程中频繁创建对象时,会出现并发现象:对象A的内存分配了,但是指针还没来得及移动位置或者列表还没有及时更新时,又要给B分配内存空间,很可能会造成因为给B分配内存空间而把A给“抹掉”了。本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)可以解决并发分配问题,就是将堆按照线程分配成不同的空间,每个线程都有一个属于自己的小空间(这个空间被称为本地线程分配缓冲),哪个线程要创建对象就在它对应的小空间中分配内存。

内存分配完成后,java虚拟机必须将分配的内存空间初始化成零值(对象的字段类型所对应的零值),这个操作保证了java实例的字段即使没有赋初始值也能使用,这个初始化跟java object默认的无参构造函数初始化指的是同一个东西吗?后期查证。如果java程序的代码是 new Object(parameters) 那还需要执行这个指定的构造方法,按照开发者的意向对成员变量进行初始化,这样目标对象就完整的被创建出来了。

总结起来就三步:

  1. 检查.class文件中的符号应用并且加载并检查。
  2. 给Object分配内存空间,并且初始化成“零”值。
  3. 调用开发者指定的 object 构造函数初始化成员变量。

布局

对象在堆内存中的存储布局可以划分为三个部分:对象头,实例数据,对齐填充。

对象头(Header)部分包括两类分信息:一类是用于存储对象自身信息的,比如:哈希码,GC分带年龄等,另一类是类型指针,即该对象指向它的类型元数据(在方法区里存放着)的指针,java虚拟机通过这个指针来确定该对象是哪个类的实例。

实例数据(Instance Data):这块内容就是开发者在类中定义的各种类型的成员变量(也叫字段),无论是从父类继承的还是自己定义的,都在这块分布着。实例数据的引用实际存放在栈中。

对齐填充(Padding):由于HotSpot虚拟机要求任何对象的大小必须是8字节的整数倍,所以实例数据部分的成员变量如果没有对齐,就要通过填充来对齐。这部分开发者不用管。

访问

对象的引用在java栈中,对象实体在堆中,对象对应的类元数据信息在方法区。java程序会通过栈上的引用来访问对象。目前主流使用方式有使用句柄直接指针两种,Hotspot使用直接指针的方式(栈中的引用中直接存储对象的地址),好处就是访问速度快,省了一次指针定位的时间。