JVM


JVM

基础篇

常用命令

  1. javap -v 字节码文件名称 命令:javap 是 JDK 自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。(末尾加上 > /xxx/xxx.txt 可以把反编译的信息输出到指定文件中)
  2. jar -xvf jar包名称:解压 jar 包。
  3. 使用 IDEA 自带的 jclasslib 插件时,如果发现代码更改后字节码文件没有变,需要重新构建项目
  4. 在 JDK 安装目录下 lib 文件夹中sa-jdi.jar 可以帮助我们查看 JVM 内存信息。高级版本的 JDK 中并没有这个 jar 包,在 bin 目录控制台下输入 jhsdb hsdb 打开。
  5. jps:该命令可以把所有运行中的 java 进程信息展示出来。

初识 JVM

JVM 全称是 Java Virtual Machine,本质是一个运行在计算机上的程序,它的职责是运行 Java 字节码文件

JVM 的三大核心功能:

  1. 解释和运行:对字节码文件中的指令,实时的解释成机器码(主要是为了支持跨平台特性),让计算机执行。
  2. 内存管理:自动为对象、方法等分配内存。自动的垃圾回收机制,回收不再使用的对象。
  3. 即时编译(JIT):对热点代码进行优化,提升运行效率。被翻译成机器码的热点文件会被存储到内存中。

常见的 JVM 有 HotSpot,GraalVM,OpenJ9 等,另外 DragonWell 龙井 JDK 也提供了一款功能增强版的 JVM。我们平时默认使用的虚拟机是 Oracle 官方的 HotSpot。

Java 虚拟机的组成

JVM 的组成:

  1. 类加载器 ClassLoader:作用是把从磁盘上读取的字节码文件加载到内存中。
  2. 运行时数据区域:负责管理 JVM 使用到的内存(方法区、堆区)。(例如上述的字节码文件通过类加载器加载后会存放在这个区域中)
  3. 执行引擎:包括即时编译器、解释器、垃圾回收器等。负责将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能。
  4. 本地接口:用来调用本地已经编译的方法,比如虚拟机中提供的 C/C++ 方法。(虚拟机是使用 C/C++ 编写的,所以一些底层的接口需要调用相关方法)

字节码文件的组成

可以使用 jclasslib 工具来查看字节码文件。使用 Arthas 进行线上监控诊断。

字节码文件主要有五部分:

  1. 基础信息:魔数、字节码文件对应的 Java 主副版本号、访问标识(public final 等等)、父类和接口。
  2. 常量池:保存了字符串常量、类或接口名、字段名。主要在字节码指令中使用。注意,这里的常量池指的是编译期常量池(和运行时常量池不一致),可以理解成信息的记录,记录了程序中所有符号和常量的映射。
  3. 字段:当前类或接口声明的字段信息。
  4. 方法:当前类或接口声明的方法信息,字节码指令。
  5. 属性:类的属性,比如源码的文件名内部类的列表等。

重要组成部分说明:

  1. 魔数:在字节码文件中,起始的几个字节固定为 ca fe ba be。而一个文件是无法通过文件扩展名来确定文件类型的,文件扩展名是可以随意修改的,不会影响文件内容。我们使用软件打开文件的时候,软件会使用文件的头几个字节(文件头)去校验文件类型,如果该软件不支持这种类型,就会报错。所以字节码文件开头的魔数,就是用来唯一标定字节码文件类型的。
  2. 主副版本号:指的是编译字节码文件的 JDK 版本号,主版本号用来标识大版本号,JDK 1.0-1.1 使用了
    45.0 - 45.3,JDK 1.2 是 46。之后每升级一个大版本就加 1;副版本号是当主版本号相同时作为区分不同
    版本的标识,一般只需要关心主版本号。版本号的作用主要是判断当前字节码的版本和运行时的 JDK 是否兼容。(目前,主版本号 - 44 就是对应的 JDK 版本号)
  3. 常量池:避免相同内容的重复定义,节省空间。(比如两个字符串变量定义了相同内容,这个时候就会让两个字符串指向同一块空间)字节码在设计时,会根据字段的属性信息先去找常量池中的 String_info,再根据索引去找常量池中的 infoinfo 字面量的存在是为了当变量名和字符串文本名一致的时候,可以复用,节省空间),此时才是真正存储字符串文本内容的地方。
  4. 方法:字节码中的方法区域是存放字节码指令的核心位置,字节码指令的内容存放在方法的 Code 属性中。其中,操作数栈是临时存放数据的地方,局部变量表是存放方法中的局部变量的位置。

类的生命周期

类的生命周期描述了一个类加载、使用、卸载的整个过程。类的生命周期主要有五个阶段,分别是:加载、连接(连接又包括验证、准备和解析)、初始化、使用、卸载。

  1. 加载:类加载器根据类的全限定名通过不同的渠道(磁盘读取,动态代理,网络传输)以二进制流的方式获取字节码信息。程序员可以使用 Java 代码拓展不同的渠道。在类加载器加载完类之后,Java 虚拟机会将字节码中的信息保存到方法区(方法区是虚拟概念,真正的实现有所不同)中,生成一个 InstanceKlass 对象,保存类的所有信息,里面还包含实现特定功能比如多态的信息。除此之外,Java 虚拟机还会在堆中生成一份与方法区中数据类似的 java.lang.Class 对象,作用是在使用反射时可以在 Java 代码中去获取类的信息以及存储静态字段的数据。InstanceKlassjava.lang.Class 可以相互关联,InstanceKlass 中信息更丰富,但是有些需要对开发者闭源,并且 InstanceKlass 可以被 C/C++ 操作,是给 JVM 使用的;而 java.lang.Class 是给 Java 开发者使用的。

  2. 连接:连接阶段第一个环节是验证,验证的主要目的是检测 Java 字节码文件是否遵守了 《Java 虚拟机规范》 中的约束。这个阶段一般不需要程序员参与。第二个环节是准备,准备阶段为静态变量(static)分配内存并设置初始值(没有 final 修饰的情况下,一般为 0,如果有 final 修饰,则直接赋现有值)。第三个环节是解析,解析阶段主要是将常量池中的符号引用替换为直接引用。

  3. 初始化:初始化阶段会执行静态代码块中的代码,并为静态变量赋值(这里赋的值就是现有值,而不是初始值,执行的是 clinit 的字节码指令)。以下几种方式会导致类的初始化:

    1. 访问一个类的静态变量或者静态方法,注意变量是 final 修饰的并且等号右边是常量不会触发初始化(这种在连接阶段就已经赋好值了)。
    2. 调用 Class.forName(String className) 时。
    3. new 一个该类的对象时。
    4. 执行 Main 方法的当前类。
    public class Main {
        // 输出结果: DACBCB
        public static void main(String[] args) {
            // 1.启动main方法,初始化Main类,先走Main的clinit,执行static代码块,打印D
    
            // 2.打印A
            System.out.println("A");
    
            // 3.创建Main对象,调用构造方法,先走构造代码块,再走无参构造,打印CB
            new Main();
            // 4.重复创建,再次打印CB
            new Main();
        }
    
        public Main() {
            System.out.println("B");
        }
    
        {
            System.out.println("C");
        }
    
        static {
            System.out.println("D");
        }
    }

    clinit 指令在特定情况下不会出现,比如:

    1. 无静态代码块且无静态变量赋值语句。
    2. 有静态变量的声明,但是没有赋值语句。
    3. 静态变量的定义使用 final 关键字,这类变量会在准备阶段直接进行初始化。
    4. 数组的创建不会导致数组中元素的类进行初始化。

    如果出现继承,则:

    1. 直接访问父类的静态变量,不会触发子类的初始化。
    2. 子类的初始化 clinit 调用之前,会先调用父类的 clinit 初始化方法。
    public class Main {
    
        public static void main(String[] args) {
            // 输出结果为1
            // 因为是直接使用B02的静态变量,这个时候是只走父类的初始化的
            // 如果在前面加上 new B02();   那么就会打印2
            System.out.println(B02.a);
        }
    
    }
    
    class A02 {
        static int a = 0;
        static {
            a = 1;
        }
    }
    
    class B02 extends A02 {
        static {
            a = 2;
        }
    }

类加载器

类加载器(ClassLoader)是 JVM 提供给应用程序去实现获取类和接口字节码数据的技术,只参与加载过程中的字节码获取并加载到内存这一部分。通过加载字节码数据放入内存转换成 byte[],接下来调用虚拟机底层方法将 byte[] 转换成方法区和堆中的数据。

类加载器的分类

类加载器分为两类,一类是 Java 代码中实现的,一类是 Java 虚拟机底层源码实现的:

  • 虚拟机底层实现:原代码位于 Java 虚拟机的源码中,实现语言与虚拟机底层语言一致,比如 Hotspot 使用 C++。作用是加载程序运行时的基础类,比如 java.lang.String,保证基础类被正确加载。
  • JDK 中默认提供或者自定义:JDK 中默认提供了多种不同渠道的类加载器,程序员也可以自己根据需求定制。所有 Java 中实现的类加载器都需要继承 ClassLoader 这个抽象类。

类加载器的设计 JDK 8 和 8 之后的版本差别较大,JDK 8 及之前的版本中默认的类加载器有如下几种:

  • 虚拟机底层实现:启动类加载器 Bootstrap ,用于加载 Java 中最核心的类。
  • Java:扩展类加载器 Extension,允许扩展 Java 中比较通用的类;应用程序类加载器 Application,加载应用使用的类。
启动类加载器

启动类加载器(Bootstrap ClassLoader)是由 Hotspot 虚拟机提供的、使用 C++ 编写的类加载器。由启动类加载器加载的基础类,无法通过 .class.getClassLoader() 来获取类加载器,因为这种方法获取的类加载器是 Java 的类加载器,而并非虚拟机底层的类加载器。

如果要对核心类进行扩展,可以通过启动类加载器去加载用户 jar 包。可用的方法是使用参数进行扩展,在 IDEA 的运行调试配置中添加 -Xbootclasspath/a:jar包目录/jar包名 来进行扩展。

扩展类加载器

扩展类加载器和应用程序类加载器都是 JDK 中提供的、使用 Java 编写的类加载器。它们的源码都位于 sun.misc.Launcher 中,是一个静态内部类。继承自 URLClassLoader。具备通过目录或者指定 jar 包将字节码文件加载到内存中。在 JDK 8 中主要用来加载 java 安装目录下 /jre/lib/ext 下的扩展类文件。

扩展类加载器主要加载扩展功能(有些功能我们平时并不常用)。如果我们需要编写一些通用但是不常用的 jar 包,可以通过扩展类加载器来进行加载。在 JDK 8 中可以使用 -Djava.ext.dirs="原始jar包目录;新增jar包目录" 进行扩展。

应用程序类加载器

应用程序类加载器主要用来加载 classpath 下的类文件,包括了我们自己编写的 java 文件以及第三方库中的类文件。

双亲委派机制

由于 Java 虚拟机中有多个类加载器,双亲委派机制的核心是解决一个类到底由谁加载的问题。双亲委派机制的作用是:

  1. 保证类加载的安全性:通过双亲委派机制避免恶意代码替换 JDK 中的核心类库,比如 java.lang.String,确保核心类库的完整性和安全性。
  2. 避免重复加载:双亲委派机制可以避免同一个类被多次加载。

双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载。(由顶向下:启动类加载器、扩展类加载器、应用程序类加载器)

每个类加载器都有一个父类加载器,在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,避免重复加载。否则会将加载请求委派给父类加载器。如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载。所以看上去是自顶向下尝试加载。

ClassLoader 中包含了 4 个核心方法:

// 类加载的入口,提供了双亲委派机制,内部会调用findClass
public Class<?> loadClass(String name);

// 由类加载器子类实现,获取二进制数据调用defineClass,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据
protected Class<?> findClass(String name);

// 做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len);

// 执行类声明周期中的连接阶段
protected final void resolveClass(Class<?> c);

打破双亲委派机制

打破双亲委派机制的方法有三种:

  1. 自定义类加载器:自定义类加载器并重写 loadClass 方法,就可以将双亲委派机制的代码去除。Tomcat 通过这种方式实现应用之间的类隔离。

    一个 Tomcat 程序中是可以运行多个 Web 应用的,如果这两个应用中出现了相同限定名的类,比如 Servlet 类,Tomcat 要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载 Web 应用 1 中的 MyServlet 之后,Web 应用 2 中相同限定名的 MyServlet 类就无法被加载了。所以,Tomcat 使用了自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对应的类。

  2. 线程上下文类加载器:利用上下文类加载器加载类,比如 JDBC 和 JNDI 等。

    JDBC 中使用了 DriverManager 来管理项目中引入的不同数据库的驱动,比如 mysql 驱动、oracle 驱动。DriverManager 类位于 rt.jar 包中,由启动类加载器来加载。而我们自己引入的数据库驱动是由应用程序类加载器来加载,这违反了双亲委派机制(按照双亲委派机制的正常逻辑,DriverManager 类无法直接访问由应用程序类加载器加载的 JDBC 驱动类,因为类加载器通常只会访问自己或祖先加载器加载的类。但在 JDBC 场景中,DriverManager 却可以访问这些由应用程序类加载器加载的驱动类)。

    JDBC 是如何实现这种 “违反” 双亲委派机制的操作呢?首先,我们先来解决一个问题,就是:DriverManager 是如何知道 jar 包中要加载的驱动在哪儿的?实际上,JDBC 采用了 JDK 内置的 SPI(Service Provider Interface) 机制:在 classpath 路径下的 META-INF/servies 文件夹中,固定暴露了驱动接口文件,接着,在 DriverManager 的代码中,使用 ServiceLoader 来加载驱动。

    观察加载过程我们发现,驱动文件确实是由应用程序类加载器来加载的,那么,SPI 中是又是如何获取到应用程序类加载器的呢?实际上,SPI 中使用了线程上下文中保存的类加载器进行类加载(Thread.currentThread().getContextClassLoader()),而被保存的这个类加载器一般是应用程序类加载器。

    不过,JDBC 只是在 DriverManager 加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依旧遵循双亲委派机制,所以,从这个角度上看,JDBC 的驱动加载并不会真正打破双亲委派机制

  3. Osgi 框架的类加载器:历史上 Osgi 框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载。Osgi 还是用类加载器实现了热部署(指在服务不停止的情况下,动态更新字节码文件到内存中)的功能。不过,现如今我们可以使用 Arthas 来帮助我们进行热部署,基本步骤如下:

    1. 在服务器上部署 arthas,并启动。
    2. jad --source-only 类全限定名 > 目录/文件名.java,利用 jad 命令反编译,然后可以用其他编译器,比如 vim 来修改源码。
    3. sc -d 类全限定名,利用 sc 命令查看类对应的类加载器 hashcode。
    4. mc -c 类加载器的hashcode 目录/文件名.java -d 输出目录,使用 mc 命令来编译修改过的代码。
    5. retransform class文件所在目录/xxx.class,用 retransform 命令加载新的字节码。
    6. 注意事项:程序重启之后,字节码文件会恢复(因为 retransform 是做内存上的更新),除非将 class 文件放入 jar 包中进行更新。并且,retransform 也不能添加方法或者字段,也不能更新正在执行中的方法。

JDK 9 之后的类加载器

JDK 8 及之前的版本中,扩展类加载器和应用程序类加载器的源码位于 rt.jar 包中的 sun.misc.Launcher.java

由于 JDK 9 引入了 module 的概念,类加载器在设计上发生了很多变化:

  1. 启动类加载器使用 Java 编写,位于 jdk.internal.loader.ClassLoader 类中。Java 中的 BootClassLoader 继承自 BuiltinClassLoader,实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过 java 代码获取到,返回的仍然是 null,保持了统一。
  2. 扩展类加载器被替换成了平台类加载器(Platform Class Loader),平台类加载器遵循模块化方式加载字节码文件,所以继承关系从 URLClassLoader 变成了 BuiltinClassLoader,实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊逻辑
  3. 应用程序类加载器的继承关系发生了改变,从 URLClassLoader 变成了 BuiltinClassLoader,其余没有特殊变化。

运行时数据区域

运行时数据区域包括两大区域,一种是线程不共享区,包括了程序计数器、Java 虚拟机栈、本地方法栈;另一种是线程共享区,包括了方法区、堆区。

程序计数器

程序计数器(Program Counter Register),也叫 PC 寄存器,是线程不共享的。每个线程会通过程序计数器记录当前要执行的字节码指令的地址。

程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。在多线程执行情况下,Java 虚拟机需要通过程序计数器记录 CPU 切换前解释执行到哪一句指令并继续解释执行。

Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。

Java 代码在遇到异常,报错的时候,会把异常时的栈帧信息打印出来。也就是说,发生异常时,控制台上打印出来的,是栈帧信息。

Java 虚拟机栈中,主要由三个部分组成:

  1. 局部变量表:其作用是在运行过程中存放所有的局部变量。编译成字节码文件时可以就确定局部变量表的内容。实际栈帧中的局部变量表是一个数组,数组中每一个位置称为一个槽(slot),longdouble 类型占用两个槽,其他类型占用一个槽。在字节码文件记录的局部变量表信息中,Nr. 表示局部变量的编号,起始PC长度 划定了局部变量生效的范围,序号 表示槽的起始编号。实例方法中,序号为 0 的地方存放的是 this,指的是当前调用方法的对象。为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
  2. 操作数栈:操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域。在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。
  3. 帧数据:主要包含动态链接、方法出口、异常表的引用。当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。而方法出口,就是记录的调用一个方法后,程序计数器应该返回的地址(与汇编语言中的返回地址压栈类似)。最后,异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

每一个栈帧都是有限的,如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出,出现 StackOverflowError 的错误。虚拟机设置中,可以使用 -Xss栈大小 来设置栈帧最大大小,不过必须是 1024 的倍数。Windows(64 位)下的 JDK 8 测试最小值为 180 K,最大值为 1024 M。一般来说,工作中即使使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为 -Xss256k 来节省内存。

本地方法栈

除了 Java 虚拟机栈,栈区还包括有本地方法栈。Java 虚拟机栈存储了 Java 方法调用时的栈帧,而本地方法栈存储的是 native 本地方法的栈帧。在 Hotspot 虚拟机中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

一般 Java 程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出 OutOfMemory 错误。

堆空间有三个需要关注的值:used、total、max。used 指的是当前已使用的堆内存,total 是 java 虚拟机已经分配的可用堆内存,max 是 java 虚拟机可以分配的最大堆内存。随着堆中的对象增多,当 total 可以使用的内存即将不足时,java 虚拟机会继续分配内存给堆。然而,并不是当 used = max = total 时,堆内存才溢出。堆内存溢出的判断条件比较复杂,在后续的垃圾回收章节会继续详细介绍。

如果不设置任何的虚拟机参数,max 默认是系统内存的四分之一,total 默认是系统内存的六十四分之一。在实际应用中一般都需要设置 total 和 max 的值。使用 -Xmx 修改 max 值(必须大于 2 M),-Xms 修改 total 值(必须大于 1 M)。在服务端程序开发时,建议将 max 值和 total 值设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向 JVM 再次申请,减少了申请空间时的额外开销。

需要注意的是,如果使用 Arthas 显示 heap 堆大小,会发现其和设置的值并不相同。这是因为 Arthas 使用的是 JMX 技术中的内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。

方法区

方法区是 《Java 虚拟机规范》 中设计的虚拟概念,每款虚拟机设计各不相同。Hotspot 设计如下:

  • JDK 7 及之前的版本将方法去存放在堆区域中的永久代空间中,堆的大小由虚拟机参数 -XX:MaxPermSize=值 控制。
  • JDK 8 及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不
    超过操作系统承受的上限,可以一直分配。可以使用 -XX:MaxMetaspaceSize=值 将元空间最大大小进行限制。

方法区是存放基础信息的位置,线程共享,主要包含三个内容:

  1. 类的元信息:在类的加载阶段,会在方法区生成 InstanceKlass 对象,存放了字节码文件的基本信息。
  2. 运行时常量池:字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
  3. 字符串常量池:字符串常量池存储在代码中定义的常量字符串内容。早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。JDK 7 之前,运行时常量池逻辑包含字符串常量池,hotspot 虚拟机对方法区的实现为永久代;JDK 7 时,字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在永久代;JDK 8 之后 hotspot 移除了永久代,用处于直接内存中的元空间取而代之,字符串常量池还在堆区中。可以使用 intern 方法主动把字符串放到字符串常量池中,JDK 6 版本的 intern 方法会把第一次遇到的字符串实例复制一份到字符串常量池中;而 JDK 7 及以后因为字符串常量池就在堆上,故是在堆上复制一个引用到字符串常量池中。并且,因为载入基础包等原因,JVM 在加载后,字符串常量池中会默认加载完毕 java 等字符串。

值得一提的是,在 JDK 6 以及之前的版本中,静态变量是存放在方法区中的,也就是永久代。JDK 7 及之后的版本中,静态变量是存放在堆中的 Class 对象中,脱离了永久代。

直接内存

直接内存(Direct Memory)并不在《Java 虚拟机规范》中存在,所以并不属于 Java 运行时的内存区域,而是由操作系统本地分配的内存区域。在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:

  1. Java 堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
  2. IO 操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到 Java 堆中。现在直接放入直接内存即可,同时 Java 堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。

要创建直接内存上的数据,可以使用 ByteBuffer,语法(Arthas 的 memory 命令可以直接查看直接内存的大小,属性名 direct):

ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);

如果要手动调整直接内存的大小,可以使用 -XX:MaxDirectMemorySize=大小,来为 JVM 的直接内存分配大小。

自动垃圾回收

在 C/C++ 这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏(内存泄漏的积累可能会导致内存溢出)。我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。

Java 中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection 简称 GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如 C#、Python、Go 都拥有自己的垃圾回收器。如果想要查看垃圾回收的信息,可以使用 verbose:gc 参数。

线程不共享的部分(程序计数器、Java 虚拟机栈、本地方法栈),都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。

方法区的回收

方法区中能回收的内容主要是不再使用的类。判定一个类是否可以被卸载(类的声明周期最后一个阶段),需要同时满足三个条件:

  1. 此类所有实例对象都已经被回收,在堆中已经不存在任何该类的实例对象以及子类对象。

    Class<?> clazz = loader.loadClass("cn.hnu.my.A");
    Object o = clazz.newInstance();
    o = null;
  2. 加载该类的类加载器已经被回收。

    URLClassLoader loader = new URLClassLoader(
        new URL[]{new URL("file:D:\\lib\\")}
    );
    loader = null;
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用。

    Class<?> clazz = loader.loadClass("cn.hnu.my.A");
    clazz = null;

如果需要手动触发垃圾回收,可以调用 System.gc() 方法。调用这个方法并不一定会立即触发垃圾回收,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收 Java 虚拟机会自行判断。

开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。每个 jsp 文件对应一个唯一的类加载器,当一个 jsp 文件修改了,就就直接卸载这个 jsp 类加载器。重新创建类加载器,重新加载 jsp 文件。

堆回收

Java 中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许回收。

那如何判断堆上的对象有没有被引用呢?有常见的两种判断方法:引用计数法和可达性分析法。

引用计数法

引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1。引用计数法的优点是实现简单,C++ 中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:

  1. 每次引用和取消引用都需要维护计数器,对系统性能造成一定影响。
  2. 存在循环引用问题,当 A 中有 B,B 中有 A 的情况下,就会出现 AB 都无法回收的问题。
可达性分析

Java 使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。

所有普通对象都会和根对象形成引用关系,如果某个对象是从 GC Root 沿着引用链可达的,那么对象就不可回收。那么,哪些对象被称之为 GC Root 对象呢?有以下几种:

  1. 线程 Thread 对象,引用线程栈帧中的方法参数、局部变量等。
  2. 系统类加载器加载的 java.lang.Class 对象,引用类中的静态变量。
  3. 监视器对象,用来保存同步锁 synchronized 关键字持有的对象。
  4. 本地方法调用时使用的全局对象。
五种对象引用

可达性算法中描述的对象引用,一般指的是强引用,即是 GC Root 对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java 中还设计了几种其他引用方式:

  • 软引用:相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用,软引用常用于缓存中。使用的时候,通过要让 GC Root 来关联 SoftReference

    // 将数据放入软引用中
    byte[] bytes = new byte[1024 * 1024 * 100];
    SoftReference<byte[]> softReference = new SoftReference<>(bytes);

    软引用中的对象如果在内存不足时回收,SoftReference 对象本身也需要被回收。如何知道哪些 SoftReference 对象需要回收呢?SoftReference 提供了一套队列机制:

    1. 软引用创建时,通过构造器传入引用队列。
    2. 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列。
    3. 通过代码遍历引用队列,将 SoftReference 的强引用删除。

    软引用也可以使用继承自 SoftReference 类的方式来实现,StudentRef 类就是一个软引用对象。通过构造器传入软引用包含的对象,以及引用队列。

  • 弱引用:弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用,弱引用主要在 ThreadLocal 中使用。弱引用对象本身也可以使用引用队列进行回收。

  • 虚引用:也叫幽灵引用 / 幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java 中使用 PhantomReference 实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。(常规开发中不会使用)

  • 终结器引用:结器引用指的是在对象需要被回收时,对象将会被放置在 Finalizer 类中的引用队列中,并在稍后由一条由 FinalizerThread 线程从队列中获取对象,然后执行对象的 finalize 方法。在这个过程中可以在 finalize 方法中再将自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收。(常规开发中不会使用)

垃圾回收算法

算法评判标准

Java 是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:

  1. 使用可达性分析法找到内存中存活的对象。
  2. 释放不再存获对象的内存,使得程序能够再次利用这部分空间。

1960 年 John McCarthy 发布了第一个 GC 算法:标记 - 清除算法。1963 年 Marvin L. Minsky 发布了复制算法。本质上,后续的所有垃圾回收算法,都是在上述两种算法的基础上优化而来。

Java 垃圾回收过程会通过单独的 GC 线程来完成,但是不管使用哪一种 GC 算法,都会有部分阶段需要停止所
有的用户线程。这个过程被称之为 Stop The World 简称 STW,如果 STW 时间过长则会影响用户的使用。

所以,判断 GC 算法是否优秀,可以从三个方面来考虑:

  1. 吞吐量:吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即 吞吐量 = 执行用户代码时间 / (执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
  2. 最大暂停时间:最大暂停时间指的是所有在垃圾回收过程中的 STW 时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短。
  3. 堆使用效率:不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。

上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。不同的垃圾回收算法,适用于不同的场景。

标记清除算法

标记清除算法的核心思想分为两个阶段:

  1. 标记阶段,将所有存活的对象进行标记。Java 使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象。
  2. 清除阶段,从内存中删除没有被标记的,也就是非存活的对象。

优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

缺点:

  1. 碎片化问题:由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
  2. 分配速度慢:由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才
    能获得合适的内存空间。

复制算法

复制算法的核心思想是:

  1. 准备两块空间 From 和 To,每次在对象分配阶段,只能使用其中一块空间(From 空间)。
  2. 在垃圾回收 GC 阶段,将 From 中存活对象复制到 To 空间。
  3. 将两块空间的名字互换(保证对象只存在于 From 空间)。

优点:

  1. 吞吐量高:复制算法只需要遍历一次存活对象复制到 To 空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法因为标记清除算法不需要进行对象的移动。
  2. 不会发生碎片化:复制算法在复制之后就会将对象按顺序放入 To 空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。

缺点:内存使用效率低,每次只能让一般的内存空间来为创建对象使用。

标记整理算法

标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。

核心思想分为两个阶段:

  1. 标记阶段,将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出
    所有存活对象。
  2. 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。

优点:

  1. 内存使用率高:整个堆内存都可以使用,不会像复制算法只能用半个堆内存。
  2. 不会发生碎片化:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。

缺点:整理阶段的效率不高,整理算法有很多种,比如 Lisp2 整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过 Two-Finger、表格算法、ImmixGC 等高效的整理算法优化此阶段的性能。

分代垃圾回收算法

现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收
算法(Generational GC)。

分代垃圾回收将整个内存区域划分为年轻代(Young)和老年代(Old):Yong 区存放存活时间比较短的对象,Old 区存放存活时间比较长的对象。

进一步,Yong 区又会划分成几个区域:

  • 伊甸园区(Eden):对象刚刚被创建时会存放在这里。
  • 幸存者区(Survivor 0 和 Survivor 1,一共两个区,也就是 From 和 To 区):用来实现复制算法。

在 JDK 8 中,添加 -XX:+UserSerialGC 参数使用分代回收的垃圾回收器,运行程序。在 Arthas 中使用 memory 命令查看内存,显示出上述几个区域的使用情况。

分代回收时,创建出来的对象,首先会被放入 Eden 区。随着对象在 Eden 区越来越多,如果 Eden 区满,新创建的对象已经无法放入,就会触发年轻代的 GC,称为 Minor GC 或者 Young GC。Minor GC 会把 Eden 区和 From 区需要回收的对象回收,把没有回收的对象放入 To 区。

接下来,From 区会变成 To 区,To 区会变成 From 区。当 Eden 区满时再往里放入对象,依然会发生 Minor GC。每次 Minor GC 都会为存活对象记录年龄,初始值为 0,每次完成 GC,就会加 1。

如果 Minor GC 后对象的年龄达到阈值(最大 15,默认值和垃圾回收器有关),对象就会被晋升到老年代。老年代一般不会触发 GC,因为这里的对象被认为是经常使用,无需回收 (比如 Spring 的大部分 Bean 对象)。

当老年代空间不足,无法放入新对象时,JVM 会先尝试 Minor GC。如果还是空间不够,再去触发 Full GC,对整个堆进行垃圾回收。

垃圾回收器

垃圾回收器是垃圾回收算法的具体实现。

由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用。具体关系图如下:

Serial 垃圾回收器

Serial 是一种单线程串行回收的年轻代的垃圾回收器。如果年轻代的 Eden 区满了,Serial 回收器会使用复制算法,暂停用户线程,对 Eden 区和 From 区进行回收。

  • 优点:单 CPU 处理器下吞吐量非常出色。

  • 缺点:多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待。

  • 使用场景:Java 编写的客户端程序或者硬件配置有限的场景。

SerialOld 垃圾回收器

SerialOld 垃圾回收器是 Serial 回收器的老年代版本,也是采用单线程进行回收。其针对老年代区使用的回收算法是标记 - 整理算法。使用 -XX:+UseSerialGC 可以让新生代、老年代都是用串行回收器。

  • 优点:单 CPU 处理器下吞吐量非常出色。

  • 缺点:多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待。

  • 使用场景:与 Serial 垃圾回收器搭配使用,或者在 CMS 特殊情况下使用。

ParNew 垃圾回收器

ParNew 本质上是对 Serial 在多 CPU 下的优化,使用多线程进行垃圾回收。其针对年轻代区使用复制算法进行回收。使用 -XX:+UseParNewGC 可以让新生代使用 ParNew 回收器,老年代使用串行回收器。

  • 优点:多 CPU 处理器下停顿时间较短。
  • 缺点:吞吐量和停顿时间不如 G1,所以在 JDK 9 之后不建议使用。
  • 使用场景:JDK 8 及之前的版本中,与 CMS 老年代回收器搭配使用。

CMS 垃圾回收器

CMS 垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少用户线程的等待时间。其针对老年代区执行标记清除算法。参数:-XX:+UseConcMarkSweepGC

  • 优点:系统由于垃圾回收出现的停顿时间较短,用户体验好。
  • 缺点:内存碎片问题,退化问题,浮动垃圾问题。
  • 使用场景:大型的互联网系统中用户请求数据量大、频率高的场景。比如订单接口、商品接口等。

CMS 执行步骤:

  1. 初始标记:用极短的时间标记处 GC Root 能直接关联到的对象。(单线程)
  2. 并发标记:标记所有的对象,用户线程不需要暂停。(多线程)
  3. 重新标记:由于并发标记阶段有些对象会发生变化,存在错标、漏标等情况,需要重新标记。(单线程)
  4. 并发清理:清理死亡对象,用户线程不需要暂停。(多线程)

并发的处理使得两个单线程操作时间会下降,总体的系统暂停时间也会有所降低。如果老年代内存不足无法分配对象,CMS 就会退化成 SerialOld 单线程回收老年代。

Parallel Scavenge 垃圾回收器

Parallel Scavenge 是 JDK 8 默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。针对年轻代使用复制算法。参数:-XX:+UseParallelGC

  • 优点:吞吐量高,而且手动可控。为了提高吞吐量,JVM 会动态调整堆的参数。
  • 缺点:不能保证单次的停顿时间。
  • 使用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出。

Oracle 官方建议在使用 Parallel Scavenge 和 Parallel Old 这个组合的时候,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整堆内存大小。

  • 最大暂停时间:-XX:MaxGCPauseMillis=n,设置每次垃圾回收时的最大停顿毫秒数。
  • 吞吐量:-XX:GCTimeRatio=n,设置吞吐量为 n(用户线程执行时间 = n / n + 1)。
  • 自动调整内存大小:-XX:+UseAdaptiveSizePolicy 设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小。

Parallel Old 垃圾回收器

Parallel Old 是为 Parallel Scavenge 设计的老年代版本,利用多线程并发回收。对老年代区域使用标记 - 整理算法。参数:-XX:+UseParallelOldGC

  • 优点:并发执行,在多核 CPU 下效率较高。
  • 缺点:暂停时间会比较长。
  • 使用场景:与 Parallel Scavenge 配套使用。

G1 垃圾回收器

JDK 9 之后默认的垃圾回收器就是 G1(Garbage First)垃圾回收器。

Parallel Scavenge 关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用的空间大小。

CMS 关注暂停时间,但是吞吐量会下降。

而 G1 的设计目标就是将上述两种垃圾回收器的优点融合:

  1. 支持巨大的堆空间回收,并有较高的吞吐量。
  2. 支持多 CPU 并行垃圾回收。
  3. 允许用户设置最大暂停时间。

G1 回收器对堆空间进行了重新划分:G1 的整个堆会被划分成多个大小相等的区域,称之为区 Region,区域不要求是连续的。分为 Eden、Survivor、Old 区。Region 的大小通过 堆空间大小 / 2048 计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m 指定(其中 32 m 指定 region 大小为 32 M),Region size 必须是 2 的指数幂,取值范围从 1 M 到 32 M。

G1 垃圾回收有两种方式:

  1. 年轻代回收(Young GC):回收 Eden 和 Survivor 中不用的对象,回导致 STW,G1 可以通过参数 -XX:MaxGCPauseMillis=n(默认 200)设置每次垃圾回收的最大暂停时间毫秒数,G1 垃圾回收器会尽可能地保证暂停时间。

    创建的对象会存放在 Eden 区。当 G1 判断年轻代区不足(max 默认 60%),无法分配对象时需要回收时会执行 Young GC:标记出 Eden 和 Survivor 区域中的存活对象,根据配置的最大暂停时间选择某些区域将存获对象复制到一个新的 Survivor 区中(年龄 + 1),清空这些区域。

    G1 在进行 Young GC 的过程中会去记录每次垃圾回收时每个 Eden 区和 Survivor 区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个 Region 区域了。
    比如 -XX:MaxGCPauseMillis=n(默认200),每个 Region 回收耗时 40 ms,那么这次回收最多只能回收 4 个 Region。

    后续 Young GC 时与之前相同,只不过 Survivor 区中存活对象会被搬运到另一个 Survivor 区。当某个存活对象的年龄达到阈值(默认 15),将被放入老年代。部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为 Humongous 区。

  2. 混合回收(Mixed GC):多次回收后,会出现很多 Old 老年代区,此时总堆占有率达到阈值时(-XX:initiatingHeapOccupancyPercent 默认为 45%)会触发混合回收 Mixed GC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法完成。

    混合回收分为:初始标记(标记 GC Root 引用的对象为存活)、并发标记(将第一步中标记的对象引用的对象标记为存活)、最终标记(标记一些引用改变漏标的对象,不管新创建、不再关联的对象)、并发清理(将存活对象复制到别的 Region,不会产生内存碎片)。

    G1 对老年代的清理会选择存货度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1(Garbage First)名字的由来。如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现 Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。


文章作者: 热心市民灰灰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 热心市民灰灰 !
  目录