JVM详解

文章目录

    • JVM整体结构
    • Java类加载机制
      • 加载
      • 验证
      • 准备
      • 解析
      • 初始化
      • 类加载器
        • 常见类加载器
        • 自定义类加载器
        • 双亲委派模型
      • 类卸载
    • Java内存区域划分
      • 程序计数器
      • 虚拟机栈
      • 本地方法栈
        • TLAB
      • 方法区
      • 运行时常量池
      • 字符串常量池
      • 直接内存
    • JVM垃圾回收机制
      • 垃圾判定算法
        • 引用计数算法
        • 可达性分析算法
      • STW
      • 对象引用
        • 强引用
        • 软引用
        • 弱引用
        • 虚引用
      • 对象真正死亡
      • 垃圾收集算法
        • 标记清除算法
        • 复制算法
        • 标记整理算法
        • 分代收集思想
      • 垃圾收集器
        • Serial GC
        • ParNew GC
        • Parallel Scavenge GC
        • Serial Old GC
        • Parallel Old GC
        • CMS
        • G1
        • ZGC

JVM整体结构

Java虚拟机有很多,HotSpot VM是目前市面上高性能虚拟机的代表作之一。HotSpot 的技术优势就在于热点代码探测技术(名字就从这来的)和准确式内存管理技术。

热点代码探测,指的是,通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译,解释器就可以不再逐行的将字节码翻译成机器码,而是将一整个方法的所有字节码翻译成机器码再执行。

JVM 大致可以划分为三个区域,分别是类加载子系统(Class Loader)、运行时数据区(Runtime Data Areas)和执行引擎(Excution Engine)。下图为 HotSport 虚拟机结构图

在这里插入图片描述

  • 类加载子系统:将class文件加载到内存中。具体分为三个步骤:装载,链接,初始化。
  • 运行时数据区:JVM 定义了 Java 程序运行期间需要使用到的内存区域,简单来说,这块内存区域存放了字节码信息以及程序执行过程的数据,垃圾收集器也会针对运行时数据区进行对象回收的工作。包括方法区、堆、Java栈(虚拟机栈)、本地方法栈、程序计数器。其中线程共享,堆和方法区;Java栈、本地方法栈和程序计数器为每个线程独有一份的。
  • 执行引擎:将字节码指令解释/编译为对应平台上的本地机器指令才可以,简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。包括:解释器、JIT及时编译器、GC垃圾回收器。

Java类加载机制

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、卸载、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接。这几个阶段一般是顺序发生的,但在动态绑定的情况下,解析阶段发生在初始化阶段之后。

在这里插入图片描述

在这里插入图片描述

类加载器只负责class文件的加载,至于它是否可以运行,则由执行引擎决定。被加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量。

加载

加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口。

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。简单来说,加载指的是把从各个来源的class字节码文件,通过类加载器装载入内存中。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。类加载器并不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。

类加载阶段的作用:

  • 通过类的全限定名获取该类的二进制字节流;
  • 将字节流所代表的存储结构转化为方法区的运行时的数据结构;
  • 在内存中生成一个该类的java.lang.Class对象作为方法区的这个类的各种数据访问入口;

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

验证

验证是连接阶段的第一步,这一阶段的目的是确保class文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

大致都会完成以下四个阶段的验证:

  • 文件格式验证;
  • 元数据验证,是否符合Java语言的规范;
  • 字节码验证,确保程序语义合法,符合逻辑;
  • 符号引用验证,确保下一步解析能正常执行;

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这个过程将在方法区中进行分配。举个例子:

public String var1 = "var1";
public static String var2 = "var2";
public static final String var3 = "var3";

变量var1不会被分配内存,但是var2会被分配。var2会被分配初始值为null而不是var2。这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

这时候进行内存分配的仅包括类变量(Class Variables ,即静态变量,被 static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在Java堆中。这里不包含final修饰的static,因为final在编译的时候就已经分配了,也就是说var3被分配的值为var3

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。对应常量池中的CONSTANT Class infoCONSTANT Fieldref infoCONSTANT Methodref info等。

  • 符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量: 类和接口的全限定名 字段的名称和描述符 方法的名称和描述符。符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。
  • 直接引用:直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。直接引用通过对符号引用进行解析,找到引用的实际内存地址。

在编译的时候每个Java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

初始化

初始化阶段是执行初始化方法<clinit>方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。对于<clinit>方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit>方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。

<clinit>方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有<clinit>方法。

在准备阶段,静态变量已经被赋过默认初始值,而在初始化阶段,静态变量将被赋值为代码期望赋的值。举个例子:

public static String var2 = "var2";

在准备阶段变量var2的值为null,在初始化阶段赋值为var2

何时初始化:

  • 创建类的实例,也就是new一个对象需要初始化
  • 读取或者设置静态字段的时候需要初始化(但被final修饰的字段,在编译时就被放入静态常量池的字段除外。)
  • 调用类的静态方法
  • 使用反射Class.forName("");对类反射调用的时候,该类需要初始化
  • 初始化一个类的时候,有父类,先初始化父类
    • 接口除外,父接口在调用的时候才会被初始化;
    • 子类引用父类的静态字段,只会引发父类的初始化;
  • 被标明为启动类的类(即包含main方法),需要初始化

类加载器

类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。
典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。字节码可以是 Java 源程序(.java文件)经过 javac 编译得来,也可以是通过工具动态生成或者通过网络下载得来。其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。

从虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),属于虚拟机自身的一部分。另外一种就是自定义类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。

常见类加载器

rt.jar:rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。
我们常用内置库 java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*

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

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码(.class文件)进行加密,加载时再利用自定义的类加载器对其解密。

自定义加载器使用场景:

  1. 隔离加载类
  2. 修改类加载的方式
  3. 扩展加载源
  4. 防止源码泄漏

若要实现自定义类加载器,只需要继承java.lang.ClassLoader类,按需重写相关方法即可。

  • 如果不想打破双亲委派模型,那么只需要重写findClass方法
  • 如果想打破双亲委派模型,那么就重写整个loadClass方法

在JDK1.2之前,类加载尚未引入双亲委派模式,因此实现自定义类加载器时常常重写loadClass方法,提供双亲委派逻辑,从JDK1.2之后,双亲委派模式已经被引入到类加载体系中,自定义类加载器时不需要在自己写双亲委派的逻辑,因此不鼓励重写loadClass方法,而推荐重写findClass方法。

在Java中,任意一个类都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,即比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提之下才有意义,否则,即使这两个类来源于同一个class类文件,只要加载它的类加载器不相同,那么这两个类必定不相等(这里的相等包括代表类的Class对象的equals方法、isAssignableFrom方法、isInstance方法和instanceof关键字的结果)。

双亲委派模型

在这里插入图片描述

ClassLoader类使用委托模型来搜索类和资源,每个 ClassLoader实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。这种层次关系称为类加载器的双亲委派模型。 我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在JDK1.2期间被引入并广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者们推荐给开发者的一种类的加载器实现方式。

双亲委派模型工作流程:

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

为什么使用双亲委派模型?

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。

双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoaderBootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

如何打破双亲委派模型?

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的findClass方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass 方法。

类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,调用父加载器loadClass方法来加载类。重写 loadClass方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。

类卸载

卸载类即该类的Class对象被 GC。卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象;
  2. 该类没有在其他任何地方被引用;
  3. 该类的类加载器的实例已被 GC;

所以,在 JVM 生命周期内,由 JVM 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。JDK 自带的 BootstrapClassLoaderExtClassLoaderAppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

Java内存区域划分

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。

在这里插入图片描述

Java内存主要就是对运行时数据区域进行划分:

  • 程序计数寄存器
  • 虚拟机栈
  • 本地方法栈
  • 方法区

在这里插入图片描述

其中:方法区、堆、直接内存(非运行时数据区的一部分)为线程共享。程序计数寄存器、虚拟机栈、本地方法栈 为线程私有。

JDK 1.8 和之前的版本略有不同,这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。
在这里插入图片描述
在这里插入图片描述

程序计数器

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

特点:

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

程序计数器中既不存在GC又不存在OOM,所以不存在垃圾回收问题。

PC寄存器主要的作用,是用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。由于Java的多线程是通过线程轮流切换完成的,一个线程没有执行完时就需要一个东西记录它执行到哪了,下次抢占到了CPU资源时再从这开始,这个东西就是程序计数器,正是因为这样,所以它也是“线程私有”的内存。

代码演示

public class MainTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;

        String str = "abc";
        System.out.println(str);
        System.out.println(k);
    }
}

通过javap -verbose MainTest.class命令反编译.class文件,得到如下

// ...
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: bipush        10
         2: istore_1
         3: bipush        20
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: ldc           #2                  // String abc
        12: astore        4
        14: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        17: aload         4
        19: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        22: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        25: iload_3
        26: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        29: return
// ...

通过PC寄存器,我们就可以知道当前程序执行到哪一步了。
在这里插入图片描述

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的,当然也需要和其他运行时数据区域比如程序计数器配合。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

在这里插入图片描述

  • 局部变量表:Local Variables,被称之为局部变量数组或本地变量表。定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
  • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
  • 动态链接:主要服务一个方法需要调用其他方法的场景。class文件的常量池里保存有大量的符号引用比如方法引用的符号引用。
    当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为动态连接。

栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 *Error 错误。

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

除了 *Error 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 *ErrorOutOfMemoryError两种错误。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存,因为还有一些对象是在栈上分配的。

在方法执行结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。也就是触发了GC的时候,才会进行回收,如果堆中对象马上被回收,那么用户线程就会收到影响,一个方法频繁的调用频繁的回收程序性能会收到影响。所以堆是GC执行垃圾回收的重点区域。

Java7及之前,堆内存划分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区,新生区被划分为又被划分为Eden区和Survivor区;
  • Tenure Generation Space 养老区;
  • Permanent Space 永久区(逻辑上属于方法区);

Java 8及之后,堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space新生区,新生区被划分为又被划分为Eden区和Survivor区;
  • Tenure Generation Space 养老区;
  • Meta Space 元空间;

堆空间内部结构,JDK1.8之后永久代替换成了元空间,元空间使用的是本地内存。
在这里插入图片描述

对象分配内存步骤:

  1. 新的对象先放伊甸园区,此区有大小限制,如果对象过大可能直接分配在老年代(元空间)。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行MinorGC,将伊甸园区中的不再被其他对象所引用的对象进行销毁,再将新的对象放到伊甸园区,然后将伊甸园中的剩余对象移动到幸存者0区。
  3. 如果再次触发MinorGC,会首先将没有被回收的对象放到幸存者1区,然后判断幸存者0区中的对象是否能被回收,如果没有回收,就会放到幸存者1区。
  4. 重复步骤3、4,默认情况下如果一个对象被扫描了15次(阈值),都不能被回收,则将该对象晋升到老年代。
  5. 当老年代内存不足时,触发Major GC,进行老年代的内存清理。
  6. 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM错误。

可以用 -XX:MaxTenuringThreshold=N 进行设置幸存者区到老年代的GC扫描次数,默认15次,不过,设置的值应该在 0-15,否则会爆出以下错误。

MaxTenuringThreshold of 20 is invalid; must be between 0 and 15

为什么年龄只能是 0-15?因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

如果幸存者区满了?如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代。需要特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。

TLAB

堆空间都是共享的么?不是,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占,以此来保证线程安全。

TLAB全称Thread Local Allocation Buffer译为,线程本地分配缓冲区。因为堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度,使用锁又会影响性能,TLAB应运而生。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

在这里插入图片描述

从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配。尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。可以通过选项-XX:UseTLAB设置是否开启TLAB空间,默认是开启的。一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集,所以把方法区看作是一块独立于Java堆的内存空间。

方法区和永久代以及元空间是什么关系呢?方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

运行时常量池

运行时常量池是每一个类或接口的常量池的运行时表示形式。每一个运行时常量池都分配在Java虚拟机的方法区之中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间,超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

简单说来就是JVM在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。根据JVM规范,JVM内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分,运行时常量池存放在JVM内存模型中的方法区中。在不同版本的JDK中,运行时常量池所处的位置也不一样。以HotSpot为例,JDK1.7之前方法区位于永久代,由于一些原因在JDK1.8时彻底祛除了永久代,用元空间代替。

运行时常量池内容包含了Class常量池中的常量和字符串常量池中的内容。它包含多种不同的常量,也包括编译期就已经明确的数值字面量、到运行期解析后才能够获得的方法或者字段引用。它扮演了类似传统语言中符号表的角色,不过它存储数据范围比通常意义上的符号表要更为广泛。

保存在JVM方法区中的叫运行时常量池,在 class/字节码 文件中的叫Class常量池(静态常量池)。与类文件中的Class常量池不同的是,运行时常量池是在类加载到内存后动态生成的,相对于Class常量池的另一重要特征是具备动态性。运行时常量池的动态性体现在它不仅仅是简单地静态存储常量值,而是支持了Java程序在运行时动态加载类、操作字符串、实现动态代理和利用反射等高级特性。例如,反射机制可以通过运行时常量池中的符号引用动态加载类、获取和调用方法等。

字符串常量池

在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的,会单独开辟一块内存,用于保存字符串常量,这个内存区域被叫做字符串常量池。字符串常量池保存着所有字符串字面量,这些字面量在编译时期就被确定。

字符串常量池不同版本JDK中位置是不一样的,Java6及以前,字符串常量池存放在永久代,Java7将字符串常量池的位置调整到Java堆内。那么字符串常量池为什么要调整位置?

是为了优化内存管理和避免特定情况下的内存溢出问题。在早期的Java版本(如JDK 6及之前),字符串常量池通常是存放在永久代,GC对永久代的回收效率很低,只有在Full GC的时候才会触发,这就导致字符串常量池回收效率不高。而我们开发过程中,使用频率比较高,会有大量的字符串被创建,如果无法及时回收,容易导致永久代内存不足。所以JDK7之后将字符串常量池放到堆里,能及时回收内存,避免出现OOM错误。

直接内存

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过JNI的方式在本地内存上分配的。直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError错误出现。

JDK1.4中新加入的 NIO,引入了一种基于通道与缓存区的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。

public class MainTest {
    private static final int BUFFER = 1024 * 1024 * 20;
    public static void main(String[] args) {
        ArrayList<ByteBuffer> list = new ArrayList<>();
        int count = 0;
        try {
            while(true){
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
                list.add(byteBuffer);
                count++;
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        } finally {
            System.out.println(count);
        }
    }
}

通常,访问直接内存的速度会优于Java堆,即读写性能高。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小。直接内存大小可以通过 MaxDirectMemorySize 设置,如果不指定,默认与堆的最大值-Xmx参数值一致。系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。所以直接内存也有一些缺点,分配回收成本较高和不受JVM内存回收管理。

JVM垃圾回收机制

垃圾回收(Garbage Collection),顾名思义就是释放垃圾占用的空间,防止内存爆掉。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出

垃圾判定算法

在运行程序中,当一个对象已经不再被任何存活的对象引用时,就可以就可以判定该对象已经死亡了,这个对象就是需要被回收的垃圾。一般用这么几种算法,来确定一个对象是否是垃圾:

  • 引用计数算法
  • 可达性分析算法
引用计数算法

引用计数算法是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。在堆中判定新生代中的幸存者区是否可以进老年代,会有一个年龄计数器,这里用的就是引用计数算法。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。当p的指针断开的时候,内部的引用形成一个循环,从而造成内存泄漏。

在这里插入图片描述

虽然引用计数算法存在循环引用的问题,但是很多语言的资源回收选择,例如:因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制;具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。

Python如何解决循环引用?

  • 手动解除:很好理解,就是在合适的时机,将引用计数器中的计数属性置为零,解除引用关系。
  • 使用弱引用weakrefweakref是Python提供的标准库,旨在解决循环引用。
可达性分析算法

可达性分析算法是以根对象集合GCRoots为起始点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

在这里插入图片描述

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是,该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。只要你无法与GCRoot建立直接或间接的连接,系统就会判定你为可回收对象。所谓根集合GCRoots就是一组必须活跃的引用,即有在栈中有指针指向堆中的地址,它们是程序运行时的起点,是一切引用链的源头。

在Java中,GCRoots包括以下几种:

  1. 虚拟机栈中引用的对象;例如:各个线程被调用的方法中使用到的参数、局部变量等;
  2. 本地方法栈内,本地方法引用对象方法区中类静态属性引用的对象;例如:Java类的引用类型静态变量;
  3. 方法区中常量引用的对象;例如:字符串常量池里的引用;
  4. 所有被同步锁synchronized持有的对象;
  5. Java虚拟机内部的引用;例如:一些常驻的异常对象、系统类加载器等;
  6. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;
  7. 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GCRoots集合,比如:分代收集和局部回收;

除了堆空间产生对象的一些结构外,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间的对象的引用,都可以作为GCRoots进行可达性分析。

在这里插入图片描述

如何判定是否为GCroot?由于Root采用栈方式存放变量和指针,所以如果一个指针,保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个GCroot。代码演示:

public class StackReference {
    public void greet() {
        Object localVar = new Object(); // 这里的 localVar 是一个局部变量,存在于虚拟机栈中
        System.out.println(localVar.toString());
    }

    public static void main(String[] args) {
        new StackReference().greet();
    }
}
  • greet方法中localVar是一个局部变量,存在于虚拟机栈中,可以被认为是GCRoots
  • greet方法执行期间,localVar引用的对象是活跃的,因为它是从GCRoots可达的。
  • greet方法执行完毕后,localVar的作用域结束,localVar引用的 Object 对象不再由任何GCRoots引用(假设没有其他引用指向这个对象),因此它将有资格作为垃圾被回收掉。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,如果这点不满足,分析结果的准确性就无法保证。简单来说就是执行这个算法的时候,要停止程序标记对象,不能一边改变对象的引用一边判定对象是不是垃圾。

这点也是导致GC进行时必须Stop The World的一个重要原因。即使是号称几乎不会发生停顿的CMS收集器,标记根节点时也是必须要停顿的。

STW

Stop-The-World直译为:停止一切,简称STW,指的是垃圾回收发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,所以叫Stop-The-World

在垃圾回收标记阶段,JVM使用可达性分析算法进行标记那些对象是垃圾,如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。所以在垃圾回收的时候要STW,分析工作必须在一个能确保一致性的快照中进行。

STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。

STW事件和采用哪款GC无关,因为所有的GC都有这个事件。任何垃圾回收器都不能完全避免Stop-The-World情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。因此,在选择和调优垃圾收集器时,需要考虑其停顿时间。Java 中的一些垃圾收集器,如 G1和 ZGC,都会尽可能地减少了STW的时间,通过并发的垃圾收集,提高应用的响应性能。

对象引用

可达性分析是基于引用链进行判断的,在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:

  • 强引用(StrongReference):最传统的“引用”的定义;无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。强引用为JVM内部实现,其他三类引用类型全部继承自Reference父类。

在这里插入图片描述

上述引用垃圾回收的前提条件是,对象都是可触及的(可达性分析结果为可达),如果对象不可触及就直接被垃圾回收器回收了。

强引用

在Java程序中,最常见的引用类型是强引用,普通系统99%以上都是强引用,也就是我们最常见的普通对象引用,也是默认的引用类型。当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。代码演示:

// 强引用测试
public class MainTest {
    public static void main(String[] args) {
        StringBuffer var0 = new StringBuffer("hello world");
        StringBuffer var1 = var0;

        var0 = null;
        System.gc();
        try {
            Thread.sleep(3000);
        } catch (Interr
上一篇:Flutter——最详细(Badge)使用教程


下一篇:阿里云物联网应用层开发:第二部分,云产品流转