绿色健康小清新

耐得住寂寞,守得住繁华

JVM知识点总结

本篇文章为9篇并发文章的知识点简单总结:

👉 Github资源获取

JVM

Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。

JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。

Java虚拟机是基于栈的虚拟机,使用零地址指令。执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。


类加载子系统

类加载器子系统负责从文件系统或者网络中加载Class文件,至于它是否可以运行,则由Execution Engine决定。

Class文件通过 Javac 编译器将 .java 文件转为 JVM 可加载的 .class 字节码文件。编译过程分为: ① 词法解析,通过空格分割出单词、操作符、控制符等信息,形成 token 信息流,传递给语法解析器。② 语法解析,把 token 信息流按照 Java 语法规则组装成语法树。③ 语义分析,检查关键字使用是否合理、类型是否匹配、作用域是否正确等。④ 字节码生成,将前面各个步骤的信息转换为字节码。

类加载过程

加载阶段
  1. 通过一个类的全限定名获取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载class文件的方式:

  1. 从本地系统中直接加载
  2. 通过网络获取,典型场景:Web Applet
  3. 从zip压缩包中读取,成为日后jar、war格式的基础
  4. 运行时计算生成,使用最多的是:动态代理技术
  5. 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见
  6. 从加密文件中获取,典型的防Class文件被反编译的保护措施
链接阶段

验证:

  1. 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
  2. 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

准备:

  1. 为类变量(static变量)分配内存并且设置该类变量的默认初始值,即零值。类变量会分配在方法区中。
  2. 这里不包含用final修饰的static,因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化
  3. 注意:这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

解析:

  1. 将常量池内的符号引用转换为直接引用的过程
  2. 解析动作主要针对的是类或者接口、字段、类方法、方法类型、方法句柄和调用点限定符7类符号引用。
初始化阶段
  1. 初始化阶段就是执行类构造器方法<clinit>()的过程
  2. 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法
  3. <clinit>()方法中的指令按语句在源文件中出现的顺序执行。静态代码块中可以对之后定义的变量进行赋值,但不能访问。
  4. 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
  5. 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

类的初始化时机:

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(比如:Class.forName(“com.atguigu.Test”))
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类
  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)

ConstantValue

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,用final修饰不是在构造方法赋值(即直接赋值)的String类型或者基本类型成员变量,会带有ConstantValue属性。但只有被static关键字修饰的类变量才可以使用这项属性。

  1. 实例变量:在实例化时在构造器里赋值的。
  2. static修饰的类属性static+final修饰除了没有直接赋值的String类型或者基本数据类型,或者其他引用类型变量(即没有ConstantValue):在类加载的准备阶段赋初值,初始化阶段赋值。
  3. static+final修饰的直接赋值的String类型或者基本数据类型(也就是static+ConstantValue):JVM规范建议在初始化阶段赋值,但是HotSpot VM直接在准备阶段就赋值了(因此clinit方法中没有赋值代码)。因为final在编译的时候就会分配好了默认值,准备阶段会显式初始化

类加载器分类

启动类加载器
  1. 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  2. 并不继承自java.lang.ClassLoader,没有父加载器
  3. 加载扩展类和应用程序类加载器,并作为他们的父类加载器
  4. 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  5. 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器
  1. Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
  2. 派生于ClassLoader类,父类加载器为启动类加载器
  3. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载
系统类加载器
  1. Java语言编写,由sun.misc.LaunchersAppClassLoader实现
  2. 派生于ClassLoader类,父类加载器为扩展类加载器
  3. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  4. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  5. 通过classLoader.getSystemclassLoader()方法可以获取到该类加载器
用户自定义类加载器

在Java的日常应用程序开发中,类的加载几 乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。那为什么还需要自定义类加载器?

  1. 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)
  2. 修改类加载的方式
  3. 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
  4. 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)

实现方式:

  1. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在**findclass()**方法中
  2. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
ClassLoader

ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)


双亲委派机制

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,则抛出异常ClassNotFoundException,子加载器才会尝试自己去加载
  4. 如果子类加载器能加载,则加载此类,若无法加载,继续向下委派。

优势:

  1. 避免类的重复加载

  2. 保护程序安全,防止核心API被随意篡改

    • 自定义java.lang.String 没有被加载,启动类加载器会加载java的String
    • 自定义java.lang.ShkStart加载报错:阻止创建 java.lang开头的类

判断两个类是否相等

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  1. 类的全限定名相同
  2. 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

同一个class文件,被不同的classloader加载,这两个类对象也不相同。

只有同一个命名空间中的类才可以相互访问,并且一个类加载器的命名空间由该加载器以及所有父加载器所加载的类构成。

  • 子加载器所加载的类能访问父加载器所加载的类
  • 父加载器所加载的类不能访问子加载器所加载的类

如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中


运行时数据区

PC寄存器

  1. PC寄存器用来存储指向下一条指令的地址,并由执行引擎读取下一条指令,并执行该指令。如果是在执行native方法,PC寄存器的值为undefned
  2. 它是一块很小的内存空间,是运行速度最快的存储区域,是唯一没有内存溢出的区域。
  3. 每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
  4. 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  5. 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

虚拟机栈

  1. 每个线程在创建时都会创建一个虚拟机栈,是线程私有的。
  2. 栈的生命周期和线程一致,也就是线程结束了,该虚拟机栈也销毁了。
  3. 其内部保存一个个的栈帧(Stack Frame),每个栈帧对应着一次的Java方法调用,存储了局部变量表,操作数栈,动态链接,方法出口等信息,执行引擎运行的所有字节码指令只针对当前栈帧进行操作,当方法执行结束return或者抛出未捕获异常时就会弹出当前栈帧。
  4. 栈不需要GC,但是可能存在OOM和栈溢出。
  5. -Xss :设置栈的大小
栈帧
  1. 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
  2. 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
  3. 存储了局部变量表,操作数栈,动态链接(指向运行时常量池的方法引用),方法出口(方法正常退出或者异常退出的定义)和其他信息。
  4. 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public static void main(java.lang.String[]) throws java.io.FileNotFoundException;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
0: new #1 // class com/atguigu/java/chapter05/LocalVariablesTest
3: dup
4: invokespecial #2 // Method "<init>":()V
7: astore_1
8: bipush 10
10: istore_2
11: aload_1
12: invokevirtual #3 // Method test1:()V
15: ldc2_w #4 // long 100l
18: invokestatic #6 // Method java/lang/Thread.sleep:(J)V
21: goto 29
24: astore_3
25: aload_3
26: invokevirtual #8 // Method java/lang/InterruptedException.printStackTrace:()V
29: new #9 // class java/io/FileInputStream
32: dup
33: ldc #10 // String
35: invokespecial #11 // Method java/io/FileInputStream."<init>":(Ljava/lang/String;)V
38: pop
39: return
Exception table:
from to target type
15 21 24 Class java/lang/InterruptedException
LineNumberTable:
line 11: 0
line 12: 8
line 13: 11
line 16: 15
line 19: 21
line 17: 24
line 18: 25
line 20: 29
line 21: 39
LocalVariableTable:
Start Length Slot Name Signature
25 4 3 e Ljava/lang/InterruptedException;
0 40 0 args [Ljava/lang/String;
8 32 1 test Lcom/atguigu/java/chapter05/LocalVariablesTest;
11 29 2 num I
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 24
locals = [ class "[Ljava/lang/String;", class com/atguigu/java/chapter05/LocalVariablesTest, int ]
stack = [ class java/lang/InterruptedException ]
frame_type = 4 /* same */
Exceptions:
throws java.io.FileNotFoundException
  1. descriptor: ([Ljava/lang/String;)V:([Ljava/lang/String;)表示方法的参数,V表示返回值为void
  2. flags: ACC_PUBLIC, ACC_STATIC:方法的访问标志
  3. code属性:
    • stack=2, locals=4, args_size=1:操作数栈大小,局部变量表大小(不一定为变量个数),方法参数的个数(若为构造方法或实例方法,会隐含一个this变量,+1)
    • attribute_info :方法体内容。0,1,…29为字节码行号(指令地址),invokespecial #2 等为操作指令
    • Exception table:对应每个try-catch。当字节码在第from到to行之间出现了类型为type或其子类的异常,则转到target行继续运行
    • LineNumberTable:代码行号和字节码行号的对应关系
    • LocalVariableTable:局部变量表。start为当前变量在字节码中生命周期的起始位置,length为作用域长度,slot为这个变量在局部变量表中的槽位(槽位可复用),name就是变量名,Signature表示局部变量类型描述。若为构造或实例方法,会隐含一个this变量,slot为0
    • StackMapTable:包含多个栈映射帧,每个帧代表了一个字节码偏移量,用于表示执行到该字节码时局部变量表和操作数栈的验证类型。 在字节码验证阶段,新类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。
  4. Exceptions属性:对应每个throws
局部变量表
  1. 局部变量表也被称之为局部变量数组或本地变量表
  2. 每一个栈帧都有一个局部变量表,定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress返回值类型。
  3. 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的locals数据项中。在方法运行期间是不会改变局部变量表的大小的。
  4. 局部变量表中的变量只在当前方法调用中有效
    • 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
    • 当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  5. 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  6. 局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
  7. 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

slot:

  1. 局部变量表,最基本的存储单元是Slot(变量槽),局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
  2. 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型占用两个slot(1ong和double)。
    • byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true
    • long和double则占据两个slot
  3. 若为构造或实例方法,会隐含一个this变量,slot为0。this 不存在与 static 方法的局部变量表中,所以无法调用
  4. JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过load_index即可成功访问到局部变量表中指定的局部变量值。
  5. 当一个实例方法被调用的时候,参数和局部变量将会按照顺序通过进栈指令进栈,再通过store_index指令存储到局部变量表中的指定slot上。。(进栈指令:const,bipush,sipush,ldc)
  6. 栈帧中的局部变量表中的slot是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。因为局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

操作数栈
  1. 每一个栈帧都有一个操作数栈,用数组来实现, 主要用于计算过程中变量临时的存储空间,以及保存计算过程的中间结果

  2. 栈的大小在编译器确定下来,并保存在方法的Code属性的stack数据项中。

  3. 栈中的任何一个元素都是可以任意的Java数据类型

    • 32bit数据类型占用的栈容量为1
    • 64bit数据类型占用的栈容量为2
  4. 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

    eg:iadd指令用于整数加法,栈顶两个元素必须为int类型。

  5. 当int取值**-15**采用iconst指令,取值**-128127采用bipush指令,取值-3276832767**采用sipush指令,取值**-21474836482147483647**采用 ldc 指令。

  6. 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,再返回,被调用者会执行pop或者istore指令

  7. Java虚拟机的执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

  8. 栈顶缓存技术:将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

动态链接

动态链接的作用就是在每次运行时将符号引用转换为直接引用

  • 符号引用就是字符串,包含类的全限定名,方法名,方法参数,返回类型。
  • 直接引用就是偏移量/物理地址,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。
方法的调用

静态链接和动态链接:

  • 静态链接

    符号引用在类加载阶段时候就转化为了直接引用被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接

  • 动态链接

    符号引用在每次运行期都会转换为直接引用,被调用的方法在编译期无法被确定下来,这种情况下将调用方法的符号引用转换为直接引用的过程称之为动态链接。

早期绑定与晚期绑定:

早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广,绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程

  • 早期绑定

    符号引用在类加载阶段时候就转化为了直接引用被调用的方法,字段,类在编译期确定,且运行期保持不变时

  • 晚期绑定

    符号引用在每次运行期都会转换为直接引用,被调用的方法,字段,类在编译期无法被确定下来

虚方法和非虚方法:

  • 虚方法:除了静态方法、构造器方法、私有方法、final方法、父类方法(显示调用),其他都是虚方法。对应动态链接,符号引用在每次运行期都会转换为直接引用,被调用的方法在编译期无法被确定下来。

  • 非虚方法:**静态方法、构造器方法、私有方法、final方法、父类方法(显示调用)**都是非虚方法。对应静态链接,符号引用在类加载阶段时候就转化为了直接引用,在编译期确定,且运行期间保持不变。

调用方法的指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用<init>方法、私有及父类方法(显示调用),解析阶段确定唯一方法版本
  • invokevirtual:调用虚方法和部分final方法(eg:隐式调用父类的final方法,但不是虚方法)
  • invokeinterface:调用实现接口的方法
  • invokedynamic:用于支持动态类型语言,动态解析出需要调用的方法,然后执行。常用于使用lambda表达式创建匿名内部类。

分派:

静态链接与动态链接描述的是符号引用转化为直接引用的这个过程或者说这个动作;分派针对于多态,描述的是方法版本确定的过程分派和链接并不是一个层次的概念

  • 静态分派和动态分派

    • 静态分派:根据静态类型来决定方法执行版本的分派动作,在编译器确定重载就是静态分派
    • 动态分派:根据实际类型来决定方法的执行版本的分派动作,在运行期确定重写就是动态分派
  • 单分派和多分派:

    分派中根据“宗量”,又可以把分派分为单分派和多分派。

    方法的调用者方法的参数统称为宗量,根据宗量的多少可以将分派分为单分派和多分派。

    • 根据一个宗量对方法进行选择叫单分派
    • 根据多于一个宗量对方法进行选择就叫多分派。

    静态分派属于多分派。在重载中,影响方法调用的因素有两个:方法调用者和传入的参数。方法的调用者不同或者方法的传参不同都会调用到不同的方法。

    动态分派属于单分派。在重写中,调用方法,影响方法调用的因素只有一个:方法的接收者。

动态分派过程:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
  2. 如果在类型C中找到与符号引用相符合的方法,则进行访问权限校验。
    • 如果通过则返回这个方法的直接引用,查找过程结束
    • 如果不通过,则返回java.lang.IllegalAccessError 异常(指对于某个属性和方法,你没有访问权限)
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

虚方法表就是对动态分派过程的优化,使用索引表来代替查找。每个类中都有一个虚方法表,存放在方法区,表中存放着各个虚方法的实际入口。虚方法表会在类加载的链接阶段当类变量初始化后被创建并开始初始化

方法返回地址

只有正常退出才会有方法返回地址。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址

通过异常退出的,它会弹出当前方法对应的栈帧,然后在调用者的异常表中查找匹配的异常,若还没有则继续弹出栈帧


本地方法栈

本地方法栈与虚拟机栈作用相似。Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用


  1. 堆被所有线程共享,在虚拟机启动时创建。堆可以处于物理上不连续的内存空间,但对于数组这样的大对象,多数虚拟机出于简单高效的考虑会要求连续的内存空间。
  2. 堆用来存放对象实例,Java 里几乎所有对象都在堆分配内存。还有一些在栈上分配(方法逃逸-栈上分配,标量替换)
  3. 堆分为新生代和老年代,新生代又被分为eden区和survivor区,分代的唯一理由就是优化GC性能。可以通过**-XX:NewRatio修改新生代和老年代的比例,通过-XX:SurvivorRatio**修改eden区和survivor区的比例。
  4. 通过 -Xms-Xmx 设置堆的起始容量和最大容量
  5. 会发生OOM,通过内存映像分析工具对 Dump 出的堆转储快照分析,确认导致 OOM 的对象是否必要,分清是内存泄漏还是内存溢出。
    • 如果是内存泄漏,通过工具查看泄漏对象到 GC Roots 的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots 关联才导致无法回收,一般可以准确定位到产生内存泄漏代码的具体位置。
    • 如果不是内存泄漏,即内存中对象都必须存活,应当检查 JVM 堆参数与内存相比是否还有调整空间。再从代码检查是否存在某些对象生命周期过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

内存溢出和内存泄漏

内存溢出,指程序在申请内存时,没有足够的内存空间供其使用。

内存泄露 Memory Leak,对象不会再被程序用到了,但是GC又不能回收他们。eg:一些提供close()的资源未关闭导致内存泄漏

通过内存映像分析工具对 Dump 出的堆转储快照分析,确认导致 OOM 的对象是否必要,分清是内存泄漏还是内存溢出。

  • 如果是内存泄漏,通过工具查看泄漏对象到 GC Roots 的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots 关联才导致无法回收,一般可以准确定位到产生内存泄漏代码的具体位置。
  • 如果不是内存泄漏,即内存中对象都必须存活,应当检查 JVM 堆参数与内存相比是否还有调整空间。再从代码检查是否存在某些对象生命周期过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

对象分配
  1. 对象优先放在eden区,当创建对象时eden区不足以分配,JVM的垃圾回收器进行MinorGC:将eden区和from区中的不再被其他对象所引用的对象进行销毁。然后将剩余对象移动到to区,并且年龄计数器+1,如果此时有对象无法存放在to区,则通过分配担保机制提前转移到老年代。然后创建对对象,若还是无法创建,则是大对象,直接进入老年代。(注:from和to是轮流交换的)

  2. 我们继续不断的进行对象生成和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代

    可以设置新生区进入养老区的年龄限制,默认是15次。设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置

  3. 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代

  4. 当老年代内存不足时,触发Major GC。若依然无法进行对象的保存,就会产生OOM异常。

注意:TLAB和空间分配担保

将对象放到老年区又有四种情况:

  • 如果 Eden 执行了 YGC 还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接放到老年代
  • 如果 Eden 区满了,将对象往survivor区拷贝时,发现survivor区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年区
  • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代
  • 当survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中
GC分类
  1. Minor GC:当eden区满了,会触发MinorGC,会引发STW(暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行)

  2. Major GC:**对老年代进行垃圾回收,如果Major GC后,内存还不足,就报OOM。**一般认为Major GC等同于Full GC,因为目前只有CMS GC会单独对老年代进行垃圾回收。出现了MajorGc,经常会伴随至少一次的Minor GC。STW的时间更长。

  3. Full GC对整个堆和方法区进行垃圾回收

    触发情况:

    • 调用System.gc()时,系统建议执行FullGC,但是不必然执行
    • 老年代空间不足
    • 方法区空间不足
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    • 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
  4. Mixed GC:是G1 GC中独有的。它对整个新生代region根据global concurrent marking统计得出回收收益高的部分老年代region进行回收。

TLAB

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。TLAB的作用就是尽量避免从堆上直接分配内存从而避免频繁的锁争用。

如果设置了虚拟机参数-XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

如果当前线程的 TLAB 大小足够,那么从线程当前的 TLAB 中分配;如果不够,当前 TLAB 剩余空间小于最大浪费空间限制(这是一个动态的值),则在eden区中重新申请一个新的 TLAB 进行分配。否则,直接在 TLAB 外进行分配。而原先的TLAB会用dummy对象进行填充,GC 直接标记之后跳过这块内存,增加扫描效率。发生 GC 的时候,TLAB 被回收。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

TLAB的本质其实是三个指针管理的区域:start,top 和 end,其中 start 和 end 是占位用的,标识出这个 TLAB区域,top 是分配指针。

空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看**-XX:HandlePromotionFailure**设置值是否允担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则进行一次Full GC。
    • 如果HandlePromotionFailure=false,则进行一次Full GC。
方法逃逸

当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

使用逃逸分析,C2编译器可以对代码做如下优化:

  1. 栈上分配若对象不会逃逸,可以在线程的栈上进行分配对象,不需要GC。对象的生命周期和方法相同,随着栈帧的出栈而销毁。
  2. 分离对象(标量替换):若对象不会逃逸,JIT编译器把这个对象拆解成其中包含的若干个成员变量来代替。
  3. 同步省略(锁消除):如果同步块所使用的锁对象只能够被一个线程访问,JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

方法区

  1. 方法区被所有线程共享,在虚拟机启动时创建。方法区可以处于物理上不连续的内存空间

  2. 方法区的大小决定了系统可以保存多少个类。方法区存储类的元数据,元数据包括:

    • 运行时常量池:数字字面量,声明为final的常量,类和接口的全限定名, 字段的名称和描述符, 方法的名称和描述符

    • 类型信息:类,接口,枚举,注解等全类名,修饰符,直接父类的全类名,直接接口的有序列表

    • 字段信息:所有字段的字段名称,字段类型,字段修饰符

    • 方法信息:所有方法的方法名称,返回类型,修饰符,参数的数量和类型,字节码(bytecodes)、异常表、操作数栈、局部变量表及大小

    • 类变量虚方法表一个到class对象的引用一个到classLoader的引用

  3. 方法区在逻辑上是属于堆的一部分,在JDK 8以前,方法区称为永久代,JDK 6即以前静态变量存储在永久代上,JDK 7字符串常量池,静态变量移除,保存在堆中;JDK 8无永久代,称为元空间,方法区存储类的元数据,字符串常量池,静态变量存在堆中。

  4. -XX:MetaspaceSize:设置元空间大小,默认为21MB。

  5. 方法区的GC主要是废弃的常量和不再使用的类型。

    • 常量:只要常量池中的常量没有被任何地方引用,就可以被回收。
    • 类型:该类所有的实例都已经被回收,该类对应的java.lang.Class对象没有在任何地方被引用,加载该类的类加载器已经被回收

Q:为什么使用元空间,而不是永久代?

A:元空间直接使用本地内存,因此,空间大小受本地内存限制,而不是受堆限制,大小更容易确定。而且永久代进行调优很困难,方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低Full GC

Q:字符串常量池为什么要放在堆中?

A:放到堆里,能及时回收内存。若在方法区中,只有在Full GC才能被回收。

常量池

常量池,它包含了类中中引用的所有字符串常量、数字值、类名、接口名、方法名等。存储字面量符号引用

  • 字面量包括:数量值,字符串值
  • 符号引用:类引用,字段引用,方法引用
运行时常量池
  1. 运行时常量池就是常量池在程序运行时的称呼,包括编译期就已经明确的数值和final修饰的字面量,运行期解析后才能够获得的方法或者字段引用此时不再是常量池中的符号地址了,这里换为真实地址。
  2. 每个class一份,存放在方法区中(元空间中)。
  3. 相对于Class文件常量池的另一重要特征是:具备动态性。
字符串常量池
  1. 字符串常量池里存放的是字符串对象字符串对象引用
  2. 每个JVM中只有一份,存在于堆中
  3. String Pool是一个固定大小的Hashtable,而且不会存储相同内容的字符串对象

对象

对象的结构

Klass Word为指针,指向对应的类的元数据

对象创建的方式

  1. new:最常见的方式、单例类中调用getInstance的静态类方法,XXXFactory的静态方法
  2. Class的newInstance方法:反射的方式,在JDK9里面被标记为过时的方法,因为只能调用空参构造器,并且权限必须为 public
  3. **Constructor的newInstance(**Xxxx):反射的方式,可以调用空参的,或者带参的构造器
  4. 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口中的clone方法
  5. 使用序列化:从文件中,从网络中获取一个对象的二进制流,序列化一般用于Socket的网络传输
  6. 第三方库 Objenesis

对象创建的步骤

  1. 检查对象对应的类是否已经被加载

  2. 为对象分配内存

    首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。若内存规整,采用指针碰撞分配内存(标记压缩算法);若内存不规整,采用空闲列表分配内存(标记清除算法)。

  3. 处理并发问题。CAS操作或TLAB

  4. 初始化分配到的空间。给对象属性赋默认值

  5. 设置对象的对象头

  6. 执行init方法进行初始化。字节码中new指令之后会接着就是执行init方法。初始化顺序:①默认初始化 ②直接初始化/代码块初始化(并列关系,谁先谁后看代码编写的顺序)③构造器初始化

对象的访问定位

  1. 句柄访问:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低
  2. 直接指针:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

字符串常量池

String

  1. String是不可变的。当对字符串重新赋值,连接操作,replace()操作时,都会重新创建一个String对象

  2. String被声明为final的,不可被继承,String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小

  3. String在jdk8及以前内部定义了final char value[]用于存储字符串数据。JDK9时改为byte[]+编码标识符(拉丁字符只需要一个字节的存储空间)

  4. 字符串常量池里存放的是字符串对象字符串对象引用

  5. 每个JVM中只有一份,存在于堆中

  6. String Pool是一个固定大小的Hashtable,而且不会存储相同内容的字符串对象

String的内存分配

  1. 通过双引号会在字符串常量池中创建字符串对象
  2. 通过new,concat,replace等会在堆中创建字符串对象,并返回引用
  3. 通过intern()返回时,如果字符串常量池已创建出通过equals()比较相等的字符串对象,则直接返回;否则,则将堆中对此对象的引用添加到字符串常量池中,然后返回该引用

字符串拼接

  1. 常量(" "或final修饰的String对象)的拼接会在字符串常量池中创建对象(如果没有的话),原理是编译期优化。
  2. 拼接中只要其中有一个字符串是非常量对象,通过StringBuilder拼接并在堆中创建对象
  3. 如果拼接的结果调用intern()方法,根据该字符串是否在常量池中存在,分为:
    • 如果存在,则返回字符串在常量池中的地址
    • 如果不存在,则将堆中对此对象的引用添加到字符串常量池中,然后返回该引用。

intern()的使用

  1. intern是一个native方法,调用的是底层C的方法。

  2. intern就是确保从字符串常量池中返回对象引用,即当字符串内容相同时,获取的对象是同一个。

  3. 字符串常量池池最初是空的,由String类私有地维护。在调用intern方法时,如果字符串常量池已创建出通过equals()比较内容相等的对象,则直接返回;否则,则将堆中对此对象的引用添加到字符串常量池中,然后返回该引用。

  • new String(“ab”) 创建两个对象
  • new String(“a”) + new String(“b”) 创建五个对象

执行引擎

  1. 执行引擎(Execution Engine)的任务就是执行字节码指令,准确来说是将字节码指令解释/编译为对应平台上的本地机器指令才可以
  2. 虚拟机的执行引擎则是由软件自行实现的,能够执行那些不被硬件直接支持的指令集格式。

解释器和JIT编译器

  1. 解释器:当Java虚拟机启动时会根据预定义的规范对字节码指令采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  2. JIT(Just In Time Compiler)编译器:就是虚拟机将字节码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行

Java是半执行半解释型语言,因为JVM的执行引擎在执行字节码指令的时候,通常都会将解释执行与编译执行二者结合起来进行

解释器

一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 而模板解释器将每一条字节码指令和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

JIT编译器

在HotSpot VM中内嵌有两个JIT编译器,分别为Client CompilerServer Compiler,但大多数情况下我们简称为C1编译器C2编译器

C1编译器会对字节码进行简单的优化编译速度快,优化的代码执行效率低。优化策略:

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现方法进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2进行复杂的优化编译速度慢,优化的代码执行效率更高。优化策略基于方法逃逸分析:

  • 栈上分配若对象不会逃逸,可以在线程的栈上进行分配对象,不需要GC。对象的生命周期和方法相同,随着栈帧的出栈而销毁。
  • 分离对象(标量替换):若对象不会逃逸,JIT编译器把这个对象拆解成其中包含的若干个成员变量来代替。
  • 同步省略(锁消除):如果同步块所使用的锁对象只能够被一个线程访问,JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

解释器+JIT编译器

在Java虚拟器启动时,**解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,根据热点代码探测功能,把字节码指令编译成本地代码,获得更高的执行效率。**同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门

目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

  1. 方法调用计数器用于统计方法的调用次数
  2. 回边计数器则用于统计循环体执行的循环次数

方法调用计数器

方法调用计数器用于统计方法的调用次数,默认阀值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。这个阀值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。

当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本

  • 如果存在,则优先使用编译后的本地代码来执行
  • 如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。
    • 如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
    • 如果未超过阈值,则使用解释器对字节码文件解释执行

**热度衰减:**当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半。

回边计数器

回边计数器则用于统计循环体执行的循环次数,目的是为了触发OSR编译,即只编译该循环代码,然后将其替换,下次循环时就执行编译好的代码。


垃圾回收

标记阶段

目的:为了判断对象是否存活

判断对象存活一般有两种方式:引用计数算法可达性分析算法

  1. **引用技术法:**对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

    优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

    缺点:需要单独的字段存储计数器,这样的做法增加了存储空间的开销。每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销无法处理循环引用的情况,循环引用会造成内存泄漏

  2. 可达性分析算法:也叫根搜索算法。以根对象集合(GCRoots,记在OopMap中)为起始点,搜索连接的对象是否可达,若可达则进行标记,为可触及状态。如果目标对象没有任何引用链相连,则不标记,为可复活状态,在调用fanalize()方法后再判断是否回收。

GCRoots包含元素:

  1. 虚拟机栈中引用的对象

    eg:各个线程被调用的方法中使用到的参数、局部变量等。

  2. 本地方法栈内JNI(通常说的本地方法)引用的对象

  3. 方法区中静态属性引用的对象

    eg:Java类的引用类型静态变量

  4. 方法区中常量引用的对象

    eg:字符串常量池(StringTable)里的引用

  5. 所有被同步锁synchronized持有的对象

  6. Java虚拟机内部的引用

    基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),系统类加载器。

  7. 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

  8. 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合


finalization机制

对象后收前的回调函数:finalize()

  1. 如果对象objA到GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
    1. 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则objA被判定为不可触及的。
    2. 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。执行finalize方法完毕后,若objA对象与引用链上的任何一个对象建立了联系,则进行标记,为可触及的,否则,为不可触。(只能复活一次)

永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用

finalize()方法对应了一个finalize线程,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收

虚拟机中的对象一般有三种状态:

  1. 可触及的:从根节点开始,可以到达这个对象。
  2. 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  3. 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次

清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法是

  1. 标记-清除算法(Mark-Sweep)
  2. 复制算法(Copying)
  3. 标记-压缩算法(Mark-Compact)

标记-清除算法:

  1. 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。

    注意:标记的是被引用 的对象,也就是可达对象,并非标记的是即将被清除的垃圾对象

  2. 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲列表里

  3. 缺点:这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表;标记清除算法的效率不算高;需要STW

复制算法:

  1. 将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
  2. 新生代里面就用到了复制算法,Eden区和S0区存活对象整体复制到S1区
  3. 应用场景:复制的存活对象数量并不会太大。新生代相对老年代较小,对象生命周期短、存活率低,回收频繁,一次通常可以回收70% - 99% 的内存空间。
  4. 优点:效率高;消除了标记-清除算法中区域分散问题
  5. 缺点:需要两倍的内存空间;需要STW

标记-压缩算法:

  1. 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  2. 压缩:将所有的存活对象压缩到内存的一端,按顺序排放用一个指针来指向空闲内存起始地址。
  3. 优点:消除了标记-清除算法中区域分散问题;消除了复制算法内存减半的代价
  4. 缺点:效率比复制算法低;移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址;需要STW

此外还有分代回收算法,增量收集算法,分区算法。

分代回收:

根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

新生代区域相对老年代较小,对象生命周期短、存活率低,回收频繁,一次通常可以回收70% - 99% 的内存空间,所以可以使用复制算法。

老年代区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。因此存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

增量收集算法:

每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,让垃圾收集线程和应用程序线程交替执行。依次反复,直到垃圾收集完成。

增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

但线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法:

用在G1 GC中,将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。


OopMap和安全点&安全区域

gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过记录的引用地址信息,依次搜索可触及对象。

oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据安全点把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

安全点:程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint)”每个安全点都会记录一个OopMap信息。通过选择一些执行时间较长的指令作为Safe Point**,**如方法调用、循环跳转和异常跳转等。

安全区域:当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程。当线程即将离开Safe Region时,会检查JVM是否已经完成枚举GCRoots,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止。


跨代引用

一般的垃圾回收算法至少会划分出两个年代,年轻代和老年代。但是单纯的分代理论在垃圾回收的时候存在一个巨大的缺陷:为了找到年轻代中的存活对象,却不得不遍历整个老年代,反过来也是一样的。

理论上从GCRoots依次搜索可触及对象,就能从老年代对象找到新生代对象,但其实运用了剪枝的原理,如果遍历所有指向Old的root,就不再遍历了。

解决跨代引用使用记忆表卡表。卡表就是记忆集的一种具体实现。

卡表:每一个区域都有一个卡表,卡表中的每一位代表一个卡页,当对象进行引用的写操作时,产生一个写屏障暂停中断操作,若为跨带引用,则将该卡页对象的卡表位设为1。当Minor GC时,只需要扫描为1的卡页。

G1中的记忆集:G1垃圾回收器的记忆集的实现实际上是基于哈希表的,key代表的是其他region的起始地址,value是一集合,里面存放了对应区域的卡表的索引,因此G1的region能够通过记忆集知道,当前是哪个region有引用指向了它,并且能知道是哪块区域存在指针指向。


引用

强引用:就是直接赋值,只要对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。

软引用:软引用是用来描述一些还有用,但非必需的对象。内存不足就回收,即在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

弱引用:软引用是用来描述一些还有用,但非必需的对象。发现即回收。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

虚引用:它和没有引用几乎是一样的,随时都可能被垃圾回收器回收;虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况;get()方法取得对象时,返回null;

垃圾回收器

分类
  1. 按线程数分串行回收和并行回收:
    • 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,使用STW机制。
    • 并行收集指的是在同一时间段内允许有多个CPU用于执行垃圾回收操作,使用STW机制。
  2. 按工作模式分并发式垃圾回收器和独占式垃圾回收器:
    • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
    • 独占式垃圾回收器(Stop the World)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
  3. 按碎片处理方式分压缩式垃圾回收器和非压缩式垃圾回收器:
    • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。再分配对象空间使用指针碰撞
    • 非压缩式的垃圾回收器不进行这步操作,分配对象空间使用空闲列表

7款经典的垃圾回收器:

  1. 串行回收器:Serial、Serial old
  2. 并行回收器:ParNew、Parallel Scavenge、Parallel old (会有STW)
  3. 并发回收器:CMS、G1 (几乎没有STW)

性能指标
  1. 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
  2. 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  3. 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  4. 收集频率:相对于应用程序的执行,收集操作发生的频率。
  5. 内存占用:Java堆区所占的内存大小。
  6. 快速:一个对象从诞生到被回收所经历的时间。

主要是看吞吐量和暂停时间。现在的标准是在最大吞吐量优先的情况下,降低停顿时间


Serial回收器
  1. Serial收集器是最基本、历史最悠久的垃圾收集器了。serial与serial old组合使用

  2. Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。在单核CPU下效率更高。

  3. Serial收集器采用复制算法、串行回收和"Stop-the-World"机制的方式执行内存回收。

  4. 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。

  5. Serial Old是运行在Client模式下默认的老年代的垃圾回收器,Serial Old在Server模式下主要有两个用途:①与新生代的Parallel Scavenge配合使用②作为老年代CMS收集器的后备垃圾收集方案


ParNew回收器
  1. 与CMS垃圾回收器组合使用。
  2. ParNew收集器则是Serial收集器的多线程版本。采用并行回收,复制算法和stw机制。
  3. 在多核CPU下效率高。

Parallel回收器
  1. parallel scavenge回收器和parallel old组合使用。
  2. Parallel Scavenge收集器同样也采用了并行回收,复制算法和"Stop the World"机制。
  3. Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-the-World"机制
  4. 吞吐量优先,适合在后台运算而不需要太多交互的任务。
  5. 在Java8中,默认是此垃圾收集器。

CMS回收器
  1. 与ParNew回收器组合使用。
  2. 采用并发回收,是第一款并发垃圾回收器,采用标记-清除算法和STW机制。
  3. 低延迟优先,适合与用户交互的程序。因为最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的

工作原理:

  1. 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
  2. 并发标记(Concurrent-Mark)阶段使用三色标记算法从直接可达对象进行搜索并标记,,这个过程耗时较长但是不需要停顿用户线程**,可以与垃圾收集线程一起并发运行
  3. 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,**因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,并且也会导致“Stop-the-World”**的发生,但也远比并发标记 阶段的时间短。
  4. 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

注意:

  • 当堆内存使用率达到某一阈值时,便开始进行回收,要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,临时启用Serial old GC来重新进行Full GC。
  • CMS采用标记-清除算法,因为在并发清除的时候,要保证用户线程能继续执行,前提的它运行的资源不受影响,因此不能进行压缩。
  • 无法处理浮动垃圾,在并发标记阶段如果产生新的垃圾对象,CMS在之前已经进行了标记,最终会导致这些新产生的垃圾对象没有被及时回收

三色标记算法:

黑色:已经搜索完成
灰色:当前或准备搜索
白色:未被标记的对象(垃圾)

思想:首先会把GCRoots关联的对象标记为灰色,然后搜索可触及对象,变为灰色,原先对象搜索完了,就为黑色,同理再搜索灰色对象。

漏标问题:同时满足:①黑色对象指向了白色对象 ②.灰色对象取消对这个白色对象的引用

解决漏标(重新标记阶段):

  • CMS的解决:增量更新

    增量更新破坏的是第一个条件,我们在这个黑色对象增加了对白色对象的引用之后,将它的这个引用,记录下来,在最后标记的时候,再以这个黑色对象为根,对它的引用进行重新扫描.

    这样有一个缺点,就是会重新扫描这个黑色对象的所有引用,比较浪费时间

  • G1的解决:原始快照(Snapshot-At-The-Beginning(SATB))

    原始快照破坏的是第二个条件,我们在这个灰色对象取消对白色对象的引用之前,将这个引用记录下来,在最重新标记的时候,再以这个引用指向的白色对象为根,对它的引用进行扫描

    这样做的缺点就是,这个白色对象有可能并没有黑色对象去引用它,但是它还是被变灰了,就会导致它和它的引用,本来应该被垃圾回收掉,但是此次GC存活了下来,就是所谓的浮动垃圾.其实这样是比较可以忍受的,只是让它多存活了一次GC而已,浪费一点点空间,但是会比增量更新更省时间


G1回收器
  1. 它把堆内存分割为很多不相关的区域(Region)(物理上不连续的,所有的Region大小相同,且在JVM生命周期内不会被改变)。使用不同的Region来表示Eden、Survivor From 、Survivor To、Old、Humongous(主要用于存储大对象,如果超过0.5个Region,就放到H,果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储)。 Garbage First就是说每次根据允许的收集时间,优先回收价值最大的Region。
  2. 采用并行和并发混合回收,Region之间是复制算法,整体上是标记-压缩算法,采用STW机制。使用卡表和记忆集。
  3. 低延迟优先,适合与用户交互的程序G1比较适合大堆的机器,G1比CMS合适的情况:超过50%的Java堆被活动数据占用;对象分配频率或年代提升频率变化很大;GC停顿时间过长(长于0.5至1秒)

**可预测的停顿时间模型(软实时soft real-time):**能让使用者明确指定最大GC停顿时间指标,以及在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒

Region:

  1. 物理上不连续的,所有的Region大小相同,且在JVM生命周期内不会被改变
  2. 使用不同的Region来表示Eden、Survivor From 、Survivor To、Old、Humongous,Humongous主要用于存储大对象,如果超过0.5个Region,就放到H,果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储
  3. 每一个Region 包含了5个指针,分别是bottom、previous TAMS(Top at Mark Start)、next TAMS、top和end, 其中previous TAMS、next TAMS是前后两次发生并发标记时的位置. 在prevTAMS和nextTAMS以上的对象就是新分配的。

回收流程:

简单来说,就是Minor GC -> Concurrent Marking -> Mixed GC

  1. **当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。**在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
  2. 当堆内存使用达到一定值(默认45%,XX:InitiatingHeapOccupancyPercent)时,开始老年代并发标记过程。
  3. **标记完成马上开始混合回收过程。**对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

Minor GC:

  1. 第一阶段,扫描根

    根是指GC Roots,根引用连同RSet记录的外部引用作为扫描存活对象的入口。

  2. 第二阶段,更新RSet

  3. 第三阶段,处理RSet

    识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

  4. 第四阶段,复制对象。

    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象
    • 如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。
    • 如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用

    处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

并发标记过程:

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

  1. 初始标记阶段:**标记从根节点直接可达的对象。**这个阶段是STW的,并且会触发一次年轻代GC。正是由于该阶段时STW的,所以我们只扫描根节点可达的对象,以节省时间。

  2. 根区域扫描(Root Region Scanning):**标记从Survivor区直接可达的老年代区域对象。**这一过程必须在Young GC之前完成,因为Young GC会使用复制算法对Survivor区进行GC。

  3. 并发标记(Concurrent Marking):

    使用三色标记算法从直接可达对象进行搜索并标记,会计算每个区域的对象活性,若发现区域中的所有对象都是垃圾,那这个区域会被立即回收。

  4. 再次标记(Remark):修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。是STW的。G1中采用了比CMS更快的原始快照算法:Snapshot-At-The-Beginning(SATB)。(见三色标记算法处)

  5. 独占清理(cleanup,STW):计算各个区域的GC回收和存活对象比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集

  6. 并发清理阶段识别并清理完全为垃圾的区域。

Mixed GC:

标记完成马上开始混合回收过程。回收整个年轻代和部分老年代。

老年代分8次被回收,会优先回收垃圾多的region,垃圾占内存分段比例要超过65%,却越高的,越会被先回收,不一定进行8次,如果可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。算法和年轻代回收的算法完全一样。

Full GC:

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。


其它GC

Shenandoah GC

第一款不由Oracle公司团队领导开发的Hotspot垃圾收集器。Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。

ZGC

ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC的工作过程可以分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。

**ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。**所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

AliGC

AliGC是阿里巴巴JVM团队基于G1算法,面向大堆(LargeHeap)应用场景。


参数

  1. -Xss :设置栈的大小

  2. -Xms-Xmx 设置堆的起始容量和最大容量

  3. -XX:NewRatio=2:表示新生代占1,老年代占2,新生代占整个堆的1/3

    -XX:SurvivorRatio=8:表示Eden空间和另外两个survivor空间缺省所占的比例是8 : 1 : 1,

  4. -XX:MaxTenuringThreshold:新生代进入老年代的年龄阈值,默认为15

  5. -XX:UseTLAB:使用TLAB

  6. -XX:HandlePromotionFailure=true:是否设置空间分配担保

  7. -XX:MetaspaceSize:设置元空间大小,默认为21MB

-------------本文结束感谢您的阅读-------------
六经蕴籍胸中久,一剑十年磨在手

欢迎关注我的其它发布渠道