Java 虚拟机 (JVM) 全面解析

目录

第一章JVM快速入门

1、什么是JVM

2、常见的JVM

3、结构图

4、执行引擎Execution Engine

5、本地方法接口Native Interface

6、本地方法栈Native Method Stack

7、PC寄存器(程序计数器)

第02章 类加载器ClassLoader

1、类加载器的作用

2、类加载的过程

3、类加载器分类

4、双亲委派模型

4.1、双亲委派模型

面试题:是否可以自定义一个java.lang.String类进行使用

4.2、双亲委派模型的源码分析

面试题:如何打破双亲委派模型

第03章 方法区Method Area

1、方法区存储什么

2、方法区演进细节

第04章 虚拟机栈stack

1、栈溢出

2、Stack 栈是什么?

3、栈运行原理

4、栈存储什么?

4.1、局部变量表(Local Variables)

4.2、操作数栈(Operand Stack)

4.3、动态链接(Dynamic Linking)

4.4、方法返回地址(Return Address)

4.5、一些附加信息

4.6、完整的内存结构图如下

5、设置栈的大小

第05章 堆heap

1、堆体系概述

1.1、堆、栈、方法区的关系

1.2、堆空间概述

1.3、分代空间

1.3.1、堆空间划分

1.3.2、JDK1.7及之前堆空间

1.3.3、JDK1.8及之后堆空间

2、分代空间工作流程

2.1、新生代

2.2、老年代

2.3、永久代/元空间

2.4、GC总结

3、JVM结构总结

3.1. 类加载器(Class Loaders)

3.2. 运行时数据区(Runtime Data Areas)

3.3. 执行引擎(Execution Engine)

3.4. 垃圾回收器(Garbage Collector)

3.5. 本地接口(Native Interface)

3.6. 内存管理

3.7. 工具与调试

4、堆参数

4.1、查看堆内存大小

4.2、设置堆内存大小

4.3、OOM演示

5、Java VisualVM的使用

第06章 垃圾回收GC

1、内存管理

1.1、C/C++的内存管理

1.2、Java的内存管理

2、方法区的垃圾回收

2.1、类的生命周期

1. 加载(Loading)

3. 初始化(Initialization)

4. 使用(Using)

5. 卸载(Unloading)

2.2、方法区回收

3、垃圾判定-对象已死?

3.1、引用计数法(Reference-Counting)

3.2、可达性分析算法

4、垃圾回收算法-清除已死对象

4.1、标记清除(Mark-Sweep)

4.2、复制算法(Copying)

4.3、标记压缩(Mark-Compact)

4.4、分代收集算法(Generational-Collection)

5、四种引用

5.1、强引用

5.2、软引用

5.3、弱引用

5.4、虚引用

6、垃圾收集器

6.1、JVM中的默认垃圾收集器

6.2、七款经典垃圾收集器

垃圾收集器与分代关系

垃圾回收器的组合

6.3、Serial/Serial Old收集器

6.3、ParNew 收集器

6.5、CMS收集器(老年代)

6.6、G1收集器(区域化分代式)


第一章JVM快速入门

1、什么是JVM

JVM:Java Virtual Machine,Java虚拟机。

JVM是JRE的一部分,安装了JRE就相当于安装了JVM,就可以运行Java程序了。

位置:JVM是运行在操作系统之上的,他与硬件没有直接的交互。

JVM的作用:加载并执行Java字节码(.class)文件。

Java程序特点:跨平台性。

2、常见的JVM

  • JCP组织(Java Community Process 开放的国际组织 ):Hotspot虚拟机(Open JDK版),sun2006年开源

  • Oracle:Hotspot虚拟机(Oracle JDK版),闭源,允许个人使用,商用收费

  • BEA:JRockit虚拟机

  • IBM:J9虚拟机

  • 阿里巴巴:Dragonwell JDK(龙井虚拟机),电商物流金融等领域,高性能要求。

可以通过如下cmd的命令查看当前所使用的jvm(需要安装jdk并配置好环境变量):

java -version

JVM规范和JVM

  • Java虚拟机规范(Java Virtual Machine Specification)是一份由Sun Microsystems(现在是Oracle Corporation)制定的文档,他定义了Java虚拟机的结构、指令集、类文件格式、类加载器、字节码执行引擎等方面的内容。Java虚拟机规范是Java平台的核心标准之一,它确保了Java程序在不同的硬件和操作系统上都能够正确地运行。
  • JVM是实现Java虚拟机规范的软件,它是Java程序运行的基础。Java虚拟机负责将Java程序转换成机器码执行,并提供了一系列的功能和特性,例如内存管理、线程管理、异常处理等。Java虚拟机可以在不同的硬件和操作系统上运行,从而实现Java程序的跨平台性。

3、结构图

JVM的作用:加载并执行Java字节码(.class)文件; 加载字节码文件、分配内存(运行时数据区)、运行程序。

JVM的特点:一次编译到处运行、自动内存管理、自动垃圾回收。

  • 类加载器子系统:将字节码文件(.class)加载到内存肿的方法区。
  • 运行时数据区:
    • 方法区:存储已被虚拟机加载的类的元数据信息(元空间)。也就是存储字节码信息。
    • 堆:存放对象实例,几乎所有的对象实例都在这里分配内存。
    • 虚拟机栈(java栈):虚拟机栈描述的是Java方法执行的内存模型。每个方法被执行的时候都会创建一个栈帧(Stack Frasme)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
      • 局部变量表(Local Variable Table):局部变量表就是存放方法中局部变量的地方,类似一个临时存储空间。每次调用方法时,会创建一个局部变量表,方法的参数和在方法内部定义的变量都会放在这个表里。等方法执行完,这个表也会被销毁。
      • 操作数栈(Operand Stack):是JVM中的一个临时数据区,用来执行计算。它就像是一个用来存放中间计算结果的栈,类似于你做数学运算时用手指临时记住的数字。
      • 动态链接(Dynamic Linking)是JVM中的一个机制,简单来说,它是在运行时将方法或变量的符号引用转换为实际引用的过程。JVM不会在编译时把所有引用都硬编码到程序中,而是等到运行时,才根据需要去寻找这些引用。
      • 方法出口是指在JVM中,当一个方法执行完毕时,程序控制流如何从该方法返回到调用它的地方。简单来说,方法出口就是方法结束并返回到调用者的地方
    • 本地方法接口:虚拟机使用到的native类型的方法,负责调用操作系统类库。(例如Thread类中有很多Native方法的调用)。
    • 执行引擎:包含解释器、即时编译器和垃圾收集器,负责加载到JVM中的字节码指令。

注意:

  • 多线程共享方法区和堆;

    • 多线程共享方法区是因为类的定义和方法等信息是不变且全局唯一的,没必要为每个线程单独存储。

    • 多线程共享堆是因为多个线程可能会共享和操作相同的对象,共享堆内存让它们能够访问相同的数据。

  • Java栈、本地方法栈、程序计数器是每个线程私有的。

    • Java栈私有是因为:每个线程都有自己独立的执行路径,方法调用链是线程特有的,不能被其他线程访问。如果多个线程共享同一个栈,彼此会干扰对方的调用过程,导致数据混乱。

    • 本地方法栈私有是因为:和Java栈类似,线程调用本地方法时,其执行过程也是独立的。如果多个线程共享同一个本地方法栈,调用信息会混乱,无法保证线程的独立性和安全性。

    • 程序计数器私有是因为:每个线程都有自己的执行顺序,程序计数器需要独立保存每个线程的当前执行状态。如果多个线程共享一个程序计数器,切换线程时会丢失执行位置,导致执行混乱。

4、执行引擎Execution Engine

Execution Engine执行引擎负责解释命令,提交操作系统执行。

JVM执行引擎通常由两个主要组成部分构成:解释器和即时编译器(Just-In-Time Compiler,JIT Compiler)。

  1. 解释器:当Java字节码被加载到内存中时,解释器逐条解析和执行字节码指令。解释器逐条执行字节码,将每条指令转换为对应平台上的本地机器指令。由于解释器逐条解析执行,因此执行速度相对较慢。但解释器具有优点,即可立即执行字节码,无需等待编译过程。

  2. 即时编译器(JIT Compiler):为了提高执行速度,JVM还使用即时编译器。即时编译器将字节码动态地编译为本地机器码,以便直接在底层硬件上执行。即时编译器根据运行时的性能数据和优化技术,对经常执行的热点代码进行优化,从而提高程序的性能。即时编译器可以将经过优化的代码缓存起来,以便下次再次执行时直接使用。

JVM执行引擎还包括其他一些重要的组件,如即时编译器后端、垃圾回收器、线程管理器等。这些组件共同协作,使得Java程序能够在不同的操作系统和硬件平台上运行,并且具备良好的性能。

5、本地方法接口Native Interface

本地接口的作用是融合不同的编程语言为 Java 所用。

在Java虚拟机中,本地方法接口(Native Method Interface,JNI)和本地库(Native Library)是用于与底层系统交互的机制。本地方法接口允许Java代码调用使用其他编程语言(如C、C++)编写的本地方法。这些本地方法在Java代码中通过接口定义,并且以native关键字声明。当Java代码调用本地方法时,JVM会将控制权转移到本地方法实现所在的本地库。

本地方法:被native所修饰的方法。例如Thread类中有一些标记为native的方法:

本地库是一个包含本地方法实现的动态链接库(DLL - windows函数库)或共享对象文件(SO - Linux函数库)。它是使用其他编程语言编写的,通常是为了与底层操作系统或硬件进行交互。本地库可以通过JNI加载到JVM中,并提供给Java代码调用。

6、本地方法栈Native Method Stack

本地方法栈(Native Method Stack):本地方法栈存储了从Java代码中调用本地方法时所需的信息。是线程私有的。

本地方法栈是JVM专门为调用非Java语言方法而设计的,它与操作系统和硬件交互,通过JNI为Java程序提供更强大的功能。每个线程都有自己的本地方法栈,保证本地方法的调用是独立且线程安全的。

7、PC寄存器(程序计数器)

PC寄存器(程序计数器,Program Counter Register)是JVM中每个线程私有的一块小内存区域,用于记录当前线程正在执行的字节码指令的地址或行号。它类似于一个指针,指向线程正在执行的字节码指令的下一条指令。

特点:线程私有空间 ,唯一一个不会出现内存溢出的内存空间。

作用:

  1. 跟踪执行位置:PC寄存器保存了线程正在执行的方法的当前指令地址。当线程切换或恢复时,它可以通过PC寄存器知道该从哪里继续执行。

  2. 支持线程切换:Java程序是多线程的,JVM通过时间片轮转方式在不同线程之间切换执行。每个线程都有自己的PC寄存器来记录它的执行位置,线程切换后可以从正确的地方继续执行。它在多线程环境下非常重要,能够确保线程切换时线程执行的准确性。

  3. 区分字节码和本地方法:当执行Java方法时,PC寄存器保存的是字节码指令地址;而如果当前线程正在执行本地方法(native method),PC寄存器的值为未定义(因为本地方法不由JVM直接管理)。

第02章 类加载器ClassLoader

1、类加载器的作用

类加载器(ClassLoader)是JVM的一个关键组件,用于动态加载、链接和初始化Java类。它的主要职责是在程序运行时将类的字节码加载到JVM中,以便JVM可以执行这些类。

类加载器的主要职责:

  1. 加载类:将类的字节码从文件系统、网络等来源读取到内存中,并创建相应的Class对象。
  2. 链接类:在类加载过程中,进行验证、准备和解析步骤。验证字节码的合法性,分配内存,初始化静态变量和常量。
  3. 初始化类:执行类的静态初始化代码(如静态块、静态变量赋值)。

2、类加载的过程

类加载过程:

  • 类加载过程:加载->链接->初始化

  • 链接过程又可分为三步:验证->准备->解析

  • 类的生命周期:加载->链接->初始化->使用->卸载

加载是类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流

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

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

public class ClassLoaderDemo1 {

    public static void main(String[] args) {

        // 创建一个 Car 类的实例对象
        Car car = new Car();

        // 获取 car 对象的类信息,返回的是 Car 类的 Class 对象
        Class<? extends Car> aClass = car.getClass();

        // 通过 Class 对象获取类加载器(ClassLoader)
        ClassLoader classLoader = aClass.getClassLoader();

        // 输出 Car 类的类加载器。对于应用程序类(如 Car 类),
        // 类加载器通常是 AppClassLoader(即用户定义类的加载器)
        System.out.println(classLoader); // 输出:sun.misc.Launcher$AppClassLoader@...

        // 创建一个整数数组
        int[] arr = {1, 2, 3};

        // 获取数组对象的 Class 对象,并尝试获取数组类的类加载器
        // 数组类(如 int[])的 Class 对象是由 JVM 自身创建的,不使用类加载器加载
        // 因此,数组类的类加载器为 null
        System.out.println(arr.getClass().getClassLoader()); // 输出:null
    }
}

从上面的介绍可以看出:

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。

  • 每个 Java 类都有一个引用指向加载它的 ClassLoader

  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

3、类加载器分类

类加载器分为四种,前三种为虚拟机自带的加载器。

  • 启动类加载器(BootstrapClassLoader):由C++实现。主要用来加载 JDK 内部的核心类库。

  • 扩展类加载器(ExtClassLoader/PlatformClassLoader):由Java实现,派生自ClassLoader类。主要用来加载 JDK 内部的扩展类库。

  • 应用程序类加载器(AppClassLoader):也叫系统类加载器。由Java实现,派生自ClassLoader类。面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

  • 自定义加载器 :程序员可以定制类的加载方式,以满足自己的特殊需求。派生自ClassLoader类。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

Java 9之前的ClassLoader

  • Bootstrap ClassLoader加载$JAVA_HOME中jre/lib/rt.jar,加载JDK中的核心类库

  • ExtClassLoader加载相对次要、但又通用的类,包括$JAVA_HOME中jre/lib/ext/*.jar

Java 9及之后的ClassLoader

  • Bootstrap ClassLoader,使用了模块化设计,加载lib/modules启动时的基础模块类,例如:java.base

  • ExtClassLoader更名为PlatformClassLoader,使用了模块化设计,加载lib/modules中平台相关模块,例如:java.scripting、java.compiler。

查看类加载器的层级关系:

public class ClassLoaderDemo2 {

    public static void main(String[] args) {

        // 创建 ClassLoaderDemo2 的实例对象
        ClassLoaderDemo2 demo2 = new ClassLoaderDemo2();
        
        // 获取 demo2 对象的类的类加载器,并打印它
        // demo2.getClass() 获取 ClassLoaderDemo2 类的 Class 对象
        // demo2.getClass().getClassLoader() 获取 ClassLoaderDemo2 类的类加载器
        // 这个类加载器是 AppClassLoader(应用类加载器)
        System.out.println(demo2.getClass().getClassLoader()); 
        // 获取系统类加载器,并打印它
        // ClassLoader.getSystemClassLoader() 返回当前线程的上下文类加载器(通常是 AppClassLoader)
        System.out.println(ClassLoader.getSystemClassLoader());
        
        // 获取 demo2 的类加载器的父类加载器,并打印它
        // demo2.getClass().getClassLoader().getParent() 获取 AppClassLoader 的父类加载器
        // 父类加载器是 PlatformClassLoader(平台类加载器),负责加载 Java 扩展库
        System.out.println(demo2.getClass().getClassLoader().getParent()); 
        
        // 获取 PlatformClassLoader 的父类加载器,并打印它
        // demo2.getClass().getClassLoader().getParent().getParent() 获取 PlatformClassLoader 的父类加载器
        // 父类加载器是 BootstrapClassLoader(启动类加载器),负责加载 JVM 核心类库
        // 启动类加载器由 JVM 内部实现,不是 Java 类的一部分,所以返回 null
        System.out.println(demo2.getClass().getClassLoader().getParent().getParent()); 
        
        // 创建一个新的 String 对象
        String s = new String();
        
        // 获取 String 对象的类加载器,并打印它
        // String 类是由 BootstrapClassLoader 加载的,所以 s.getClass().getClassLoader() 返回 null
        // 因为 String 是 Java 核心类库的一部分,由 JVM 启动类加载器加载
        System.out.println(s.getClass().getClassLoader()); 
    }
}

注意,这里的(getParent方法)父子关系并不是代码中的extends的关系,而是逻辑上的父子

        getParent这个方法用于获取当前 ClassLoader 的父类加载器(即加载当前 ClassLoader 的类加载器)。在 ClassLoader 体系中,每个 ClassLoader 可能有一个父加载器,这个父加载器负责加载其自身的类

4、双亲委派模型

4.1、双亲委派模型

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上

  • 1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器PlatformClassLoader去完成。

  • 2、当PlatformClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器BootStrapClassLoader去完成。

  • 3、如果BootStrapClassLoader加载失败,会用PlatformClassLoader来尝试加载。

  • 4、若PlatformClassLoader也加载失败,则会使用AppClassLoader来加载。

  • 5、如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

其实这就是所谓的双亲委派模型。简单来说:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上

目的:

一,性能,避免重复加载;

二,安全性,避免核心类被修改。

面试题:是否可以自定义一个java.lang.String类进行使用

案例演示(在JDK8下测试,JDK8以上会出现编译时异常):自定义java.lang.String类,发现无法加载自定义类,只能加载jdk中的String。

package java.lang;
public class String {
    static {
        System.out.println("自定义String");
    }
}

调用String类:

public class StringDemo {
    public static void main(String[] args) {
        String s = new String("就不告诉你");		// 无法调用自定义类。原因:双亲委派模型
        System.out.println(s);
    }
}

4.2、双亲委派模型的源码分析

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        //首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    //当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

总结双亲委派模型的执行流程

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。

  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。

  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。

面试题:如何打破双亲委派模型

注意⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的

ClassLoader 类有两个关键的方法:

  • protected Class<?> loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。

  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法。如果想打破双亲委派模型则需要重写 loadClass() 方法

第03章 方法区Method Area

1、方法区存储什么

方法区是被所有线程共享。《深入理解Java虚拟机》书中对方法区存储内容的经典描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等:

2、方法区演进细节

第04章 虚拟机栈stack

1、栈溢出

例如,如下代码:

public class StackRecurrenceDemo {

    public static void main(String[] args) {
        StackRecurrenceDemo.test();
    }

    public static void test(){
        test();
    }
}

通常在递归调用时出现:

2、Stack 栈是什么?

  • 栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,每个线程都有自己的栈,它的生命周期是跟随线程的生命周期,线程结束栈内存也就释放,是线程私有的

  • 线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)

3、栈运行原理

public class StackDemo {

    public static void main(String[] args) {
        // 1. main() 方法开始执行
        System.out.println("main()开始");
        
        // 2. 创建 StackDemo 对象
        StackDemo test = new StackDemo();
        
        // 3. 调用 method2() 方法,main() 栈帧保持在栈中
        test.method2();
        
        // 14. method2() 执行完毕,返回 main(),main() 开始执行最后一行
        System.out.println("main()结束");
        
        // 15. main() 方法执行完毕,栈帧弹出
    }

    public void method2(){
        // 4. method2() 方法开始执行,method2() 栈帧压入栈中
        System.out.println("method2()执行...");
        
        // 5. 调用 method3() 方法,method2() 栈帧保持在栈中
        this.method3();
        
        // 12. method3() 执行完毕,返回 method2(),method2() 继续执行
        System.out.println("method2()结束...");
        
        // 13. method2() 执行完毕,method2() 栈帧弹出
    }

    public void method3() {
        // 6. method3() 方法开始执行,method3() 栈帧压入栈中
        System.out.println("method3()执行...");
        
        // 7. 调用 method4() 方法,method3() 栈帧保持在栈中
        this.method4();
        
        // 10. method4() 执行完毕,返回 method3(),method3() 继续执行
        System.out.println("method3()结束...");
        
        // 11. method3() 执行完毕,method3() 栈帧弹出
    }

    public void method4() {
        // 8. method4() 方法开始执行,method4() 栈帧压入栈中
        System.out.println("method4()执行...");
        
        // 9. method4() 执行完毕,method4() 栈帧弹出
        System.out.println("method4()结束...");
    }
}
  • JVM对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”或者“后进先出”原则。

  • 一个线程中只能由一个正在执行的方法(当前方法),因此对应只会有一个活动的当前栈帧

当一个方法1(main方法)被调用时就产生了一个栈帧1 并被压入到栈中,栈帧1位于栈底位置

方法1又调用了方法2,于是产生栈帧2 也被压入栈,

方法2又调用了方法3,于是产生栈帧3 也被压入栈,

……

执行完毕后,先弹出栈帧4,再弹出栈帧3,再弹出栈帧2,再弹出栈帧1,线程结束,栈释放。

4、栈存储什么?

栈中的数据都是以栈帧(Stack Frame)的格式存在。栈帧是一个内存区块,是一个数据集,包含方法执行过程中的各种数据信息。

4.1、局部变量表(Local Variables)

也叫本地变量表。

作用:存储方法参数和方法体内的局部变量:8种基本类型变量、对象引用(reference)。

可以用如下方式查看字节码中一个方法内定义的的局部变量,当程序运行时,这些局部变量会被加载到局部变量表中。

public class LocalVariableTableDemo {

    public static void main(String[] args) {
        int i = 100;
        String s = "hello";
        char c = 'c';
        Date date = new Date();
    }
}

查看局部变量:

  • 可以使用javap命令:

类路径> javap -v 类名.class
  • 或者idea中的jclasslib插件:

4.2、操作数栈(Operand Stack)

作用:也是一个栈,在方法执行过程中根据字节码指令记录当前操作的数据,将它们入栈或出栈。用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间。

public class OperandStackDemo {

    public static void main(String[] args) {
        int i = 15;
        int j = 8;
        int k = i + j;
    }
}

0: bipush 15   		//一个8位带符号整数15压入栈,虚拟机将15当作byte类型处理
2: istore_1			//将int类型值15存入局部变量1
3: bipush 8    		//一个8位带符号整数8压入栈,虚拟机将8当作byte类型处理
5: istore_2			//将int类型值8存入局部变量2
6: iload_1			//从局部变量1中装载int类型值15入栈
7: iload_2			//从局部变量2中装载int类型值8入栈
8: iadd				//执行int类型的加法,将操作数栈中的数据出栈并相加,得到结果23,然后将23入栈
9: istore_3			//将操作数栈中int类型值23存入局部变量3
10: return			//返回

4.3、动态链接(Dynamic Linking)

作用:可以知道当前帧执行的是哪个方法。指向运行时常量池中方法的符号引用。程序真正执行时,类加载到内存中后,符号引用会换成直接引用

代码演示:

//编译以下代码,并查看字节码
public class DynamicLinkingDemo {

    public void methodA(){
        methodB(); //方法A引用方法B
    }

    public void methodB(){

    }
}

4.4、方法返回地址(Return Address)

作用:可以知道调用完当前方法后,上一层方法接着做什么,即“return”到什么位置去。存储当前方法调用完毕后下一条指令的地址。

4.5、一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。

4.6、完整的内存结构图如下

5、设置栈的大小

在StackRecurrenceDemo中添加count变量:

public class StackRecurrenceDemo {

    private static long count = 0;
    public static void main(String[] args) {
        StackRecurrenceDemo.test();
    }

    public static void test(){
        System.out.println(count++);
        test();
    }
}

在idea中设置:

在命令行中设置:

java -Xss1m YourClassName
完整的写法是:-XX:ThreadStackSize=1m  

单位可以使用m、k、默认字节
-Xss1m  
-Xss1024k
-Xss1048576

第05章 堆heap

1、堆体系概述

1.1、堆、栈、方法区的关系

HotSpot是使用指针的方式来访问对象:

  • 堆内存用于存放对象和数组

  • 堆中会存放指向对象类型数据的地址

  • 栈中会存放指向堆中的对象的地址

1.2、堆空间概述

  • 一个Java程序运行起来对应一个进程,一个进程对应一个JVM实例,一个JVM实例中有一个运行时数据区。

  • 在 Java 虚拟机(JVM)中,堆空间是管理 Java 对象的内存区域。堆空间的主要作用是动态分配和管理 Java 对象的内存,在JVM启动的时候被创建,并且一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。。

1.3、分代空间

1.3.1、堆空间划分

堆内存逻辑上分为三部分:

  • Young Generation Space 新生代/年轻代 Young/New

  • Tenured generation space 老年代/养老代 Old/Tenured

  • Permanent Space/Meta Space 永久代/元空间 Permanent/Meta

新生代又划分为:

  • 新生代又分为两部分: 伊甸园区(Eden space)和幸存者区(Survivor space) 。

  • 幸存者区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。From区和To区。

堆内存内部空间所占比例:

  • 新生代与老年代的默认比例: 1:2

  • 伊甸园区与幸存者区的默认比例是:8:1:1

1.3.2、JDK1.7及之前堆空间

1.3.3、JDK1.8及之后堆空间

注意:方法区(具体的实现是永久代和元空间)逻辑上是堆空间的一部分,但是 虚拟机的实现中将方法区和堆分开了,如下图:

2、分代空间工作流程

2.1、新生代

工作过程:

(1)新创建的对象先放在伊甸园区。

(2)当伊甸园的空间用完时,程序又需要创建新对象,此时,触发JVM的垃圾回收器对伊甸园区进行垃圾回收(Minor GC,也叫Young GC),将伊甸园区中不再被引用的对象销毁。

(3)然后将伊甸园区的剩余对象移动到空的幸存0区。

(4)此时,伊甸园区清空。

(5)被移到幸存者0区的对象上有一个年龄计数器,值是1。

(6)然后再次将新对象放入伊甸园区。

(7)如果伊甸园区的空间再次用完,则再次触发垃圾回收,对伊甸园区和s0区进行垃圾回收,销毁不再引用的对象。

(8)此时s1区为空,然后将伊甸园区和s0区的剩余对象移动到空的s1区。

(9)此时,伊甸园区和s0区清空。

(10)从伊甸园区被移到s1区的对象上有一个年龄计数器,值是1。从s0区被移到s1区的对象上的年龄计数器+1,值是2。

(11)然后再次将新对象放入伊甸园区。如果再次经历垃圾回收,那么伊甸园区和s1区的剩余对象移动到s0区。对象上的年龄计数器+1。

(12)当对象上的年龄计数器达到15时(-XX:MaxTenuringThreshold),则晋升到老年代。

总结:

  • 针对幸存者s0,s1,复制(复制算法)之后有交换,谁空谁是to

  • 垃圾回收时,伊甸园区和from区对象会被移动到to区

2.2、老年代

经历多次Minor GC/Young GC仍然存在的对象(默认是15次)会被移入老年代,老年代的对象比较稳定,不会频繁的GC。

若老年代也满了,那么这个时候将产生Major GC(同时触发Full GC),进行老年代的垃圾回收。

若老年代执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常OutOfMemoryError

2.3、永久代/元空间

永久代是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。

如果出现 java.lang.OutOfMemoryError:PermGen space/java.lang.OutOfMemoryError:Meta space,说明是Java虚拟机对永久代内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。

尽管方法区在逻辑上属于堆的一部分,对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

对于HotSpot虚拟机,很多开发者习惯将方法区称之为永久代 ,但严格说两者不同,或者说是使用永久代来实现方法区而已。

元空间内存溢出演示:

使用之前任意一个加载了jar包的web项目,配置适当的元空间内存的大小,然后启动,

例如:-XX:MaxMetaspaceSize=20m

2.4、GC总结

  • 频繁回收新生代

  • 很少回收老年代

  • 几乎不在永久代/元空间回收

部分收集:

  • 年轻代收集(Minor GC / Young GC):伊甸园区 + 幸存者区垃圾收集

  • 老年代收集(Major GC / Old GC):老年代垃圾收集

  • 混合收集(Mixed GC):收集新生代以及老年代。G1垃圾收集器有这种方式

整堆收集(Full GC):

  • 新生代、老年代和方法区的垃圾收集

年轻代GC触发机制(Minor GC ):

年轻代的Eden空间不足,触发Minor GC。

每次Minor GC在清理Eden的同时会清理Survivor From区。

Minor GC非常频繁,回收速度块。

老年代GC触发机制(Major GC 和 Full GC ):

老年代空间不足,触发Major GC。

Major GC比Minor GC速度慢的多。

如果Major GC后,内存还不足,就报OOM。

Full GC触发机制:

Full GC(Full Garbage Collection)是Java虚拟机对堆内存中的所有对象进行全面回收的过程。Full GC的执行时机取决于Java虚拟机的实现和具体的垃圾回收策略。

一般情况下,Full GC发生的情况包括:

  1. 当堆内存空间不足以分配新对象时,会触发一次Full GC:这种情况下,Java虚拟机会先执行一次新生代的垃圾回收(Minor GC),如果仍然无法满足内存需求,则会执行Full GC。

  2. 在某些垃圾回收器中,当老年代空间不足以容纳晋升到老年代的对象时,会执行Full GC。这通常发生在长时间运行的应用程序中,随着对象的逐渐增加,老年代空间可能会变得不足。

  3. 手动调用System.gc()方法或Runtime.getRuntime().gc()方法可以触发Full GC。但值得注意的是,这只是建议Java虚拟机进行垃圾回收的请求,并不能保证立即执行Full GC。

需要注意的是,Full GC是一项资源密集型的操作,会导致应用程序的停顿时间增加(Stop The World - STW),因为在Full GC期间,应用程序的线程会被挂起。因此,在设计和开发应用程序时,应尽量避免频繁触发Full GC,以减少对应用程序性能的影响。

3、JVM结构总结

JVM(Java Virtual Machine)结构主要包括以下几个关键部分:

3.1. 类加载器(Class Loaders)

  • 职责:加载、链接和初始化 Java 类。
  • 主要类型
    • Bootstrap ClassLoader:加载核心 Java 库(如 rt.jar)。
    • Platform ClassLoader:加载 JDK 和 JRE 的类。
    • Application ClassLoader:加载应用程序的类和 JAR 文件。

3.2. 运行时数据区(Runtime Data Areas)

  • 堆(Heap):存储所有 Java 对象和数组。分为新生代(Young Generation)和老年代(Old Generation)。
  • 栈(Stacks):每个线程有一个栈,用于存储方法调用的局部变量、操作数栈、帧数据等。
  • 程序计数器(Program Counter Register):跟踪当前线程执行的指令地址。
  • 本地方法栈(Native Method Stack):支持本地方法调用,通常用于与操作系统的交互。

3.3. 执行引擎(Execution Engine)

  • 解释器(Interpreter):逐条解释执行字节码。
  • 即时编译器(JIT Compiler):将热点字节码编译为本地机器码,提高性能。

3.4. 垃圾回收器(Garbage Collector)

  • 职责:自动管理堆内存,回收不再使用的对象。
  • 主要类型
    • Serial GC:单线程垃圾回收。
    • Parallel GC:多线程垃圾回收。
    • CMS GC:并发标记清除。
    • G1 GC:分区化垃圾回收,减少停顿时间。

3.5. 本地接口(Native Interface)

  • JNI(Java Native Interface):允许 Java 代码调用本地(C/C++)代码。

3.6. 内存管理

  • 元空间(Metaspace):存储类的元数据(在 JDK 8 及以后,永久代被元空间取代)。
  • Direct Memory:直接内存,绕过堆内存,供 I/O 操作使用。

3.7. 工具与调试

  • 监控工具jstatjmapjconsoleVisualVM 用于监控和分析 JVM 性能。

总结:JVM 通过类加载器加载类,使用运行时数据区管理内存和执行线程,利用执行引擎执行字节码,并通过垃圾回收器进行内存管理。其结构和机制支持高效、可靠的 Java 程序运行。

4、堆参数

4.1、查看堆内存大小

/**
 * 查看堆内存大小
 * -XX:+PrintGCDetails
 */
public class HeapSpaceInitialDemo {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024;

        //起始内存
        System.out.println("-Xms : " + initialMemory + "K," + initialMemory / 1024 + "M");
        //最大内存
        System.out.println("-Xmx : " + maxMemory + "K," + maxMemory / 1024 + "M");
    }
}
  • -Xms表示堆的起始内存,等价于-XX:InitialHeapSize,默认是物理电脑内存的1/64。

  • -Xmx表示堆的最大内存,等价于-XX:MaxHeapSize,默认是物理电脑内存的1/4。

  • (在JDK17下测试更接近这两个数字)

4.2、设置堆内存大小

  • -Xmn 表示新生代堆大小,等价于-XX:NewSize,默认新生代占堆的1/3空间,老年代占堆的2/3空间

使用下面的VM options参数启动HeapSpaceInitialDemo,

-Xms600m -Xmx600m -Xmn200m

通常会将-Xms和-Xmx配置相同的值,目的是为了在Java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能。

4.3、OOM演示

OOM异常:

JVM启动时,为堆分配起始内存,当堆中数据超过-Xmx所指定的最大内存时,将会抛出java.lang.OutOfMemoryError: Java heap space 异常,此时说明Java虚拟机堆内存不够。

原因有二:

(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

OOM演示:

/**
 * 演示如何在 JVM 中触发 `java.lang.OutOfMemoryError` 异常。
 * JVM 参数设置堆内存大小为 30MB (`-Xms30m -Xmx30m`)。
 */
public class OOMDemo {

    public static void main(String[] args) throws InterruptedException {
        // 创建一个 ArrayList,用于存储分配的字节数组。
        ArrayList<byte[]> list = new ArrayList<>();

        while (true) {
            // 打印最大堆内存大小
            System.out.print("最大堆大小:Xmx=");
            System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

            // 打印当前剩余的堆内存
            System.out.print("剩余堆大小:free mem=");
            System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

            // 打印当前总的堆内存
            System.out.print("当前堆大小:total mem=");
            System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");

            // 每次分配一个 1MB 的字节数组并添加到列表中
            list.add(new byte[1024 * 1024]);

            // 暂停 100 毫秒,以便可以观察内存的变化
            Thread.sleep(100);
        }
    }
}

5、Java VisualVM的使用

jdk工具jvisualvm的使用_jvisualvm使用教程-优快云博客

第06章 垃圾回收GC

1、内存管理

1.1、C/C++的内存管理

在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现

内存泄漏。我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收

内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出

#include "Test.h"
init main(){
    while(true){
        Test* test = new Test();
        delete test; //手动回收内存
    }
    return 0;
}

1.2、Java的内存管理

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

线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。因此线程不共享的程序计数器、虚拟机栈、本地方法栈中没有垃圾回收。

2、方法区的垃圾回收

2.1、类的生命周期

1. 加载(Loading)
  • 步骤:类加载器读取类的 .class 文件(或从 JAR 包中)并将其内容读入内存。
  • 操作:类加载器将 .class 文件的字节码转换为 Class 对象,并将其存储在方法区中。此阶段仅将类的信息加载到内存中,还没有对类的内容进行检查或初始化。

2. 连接(Linking)

连接阶段包括三个子步骤:验证、准备和解析:

验证(Verification)

  • 目的:确保类的字节码符合 Java 虚拟机规范,且不会破坏 JVM 的安全性。
  • 检查内容
    • 类文件的结构是否符合 JVM 规范。
    • 字节码中的指令是否合法,是否有类型安全问题。
    • 类之间的引用是否正确,确保不会造成类加载的非法操作。

准备(Preparation)

  • 目的:为类的静态变量分配内存并设置初始值。
  • 操作
    • 静态变量分配内存空间,并初始化为默认值(如 0null 等)。
    • 这个过程不涉及实际的初始化值(例如,静态变量的初始化值会在初始化阶段进行)。

解析(Resolution)

  • 目的:将常量池中的符号引用转换为直接引用。
  • 操作
    • 符号引用是类、方法、字段等的字符串描述,例如 java.lang.String
    • 解析将这些符号引用替换成实际的内存地址引用(即实际的类、方法、字段对象)。
3. 初始化(Initialization)
  • 步骤:初始化静态变量和执行静态代码块。
  • 操作
    • 执行类的静态初始化器(static 代码块)。
    • 为静态变量赋予显式初始值(如果有的话)。
    • 这是类生命周期中实际执行的阶段,它会真正给类中的静态字段赋予值。
4. 使用(Using)
  • 步骤:类被应用程序使用。
  • 操作
    • 类可以被实例化,方法可以被调用,字段可以被访问。
    • 这通常是应用程序的主要运行阶段,类的对象在这个阶段被创建和操作。
5. 卸载(Unloading)
  • 步骤:类的生命周期结束,类被卸载。
  • 操作
    • 垃圾回收:在 JVM 中,类卸载通常发生在类加载器被卸载或类不再被任何活跃的线程引用时。
    • 方法区中的类卸载
      • 类的实例没有被使用(即,没有引用了)。
      • 类加载器被卸载或者类加载器的 ClassLoader 实例被垃圾回收。
      • 被类引用的所有类(包括方法、字段等)都可以被垃圾回收。
    • 具体过程
      • 清除:移除类相关的所有信息,包括类的数据、方法和静态字段。
      • 回收:释放类占用的内存空间。通常,这涉及到从方法区中删除类的元数据。

2.2、方法区回收

方法区中能回收的内容主要就是不再使用的类。判定一个类可以被卸载。需要同时满足下面三个条件:

1、此类所有实例对象没有在任何地方被引用,在堆中不存在任何该类的实例对象以及子类对象。

Car car = new Car();
car = null;

2、该类对应的 java.lang.Class 对象没有在任何地方被引用。

Car car = new Car(); 
Class<? extends Car> aClass = car.getClass(); 
car = null;
aClass = null;

3、加载该类的类加载器没有在任何地方被引用。

Car car = new Car(); 
Class<? extends Car> aClass = car.getClass(); 
ClassLoader classLoader = aClass.getClassLoader();

car = null;
aClass = null;
classLoader = null;

总结:方法区的回收通常情况下很少发生,但是如果通过自定义类加载器加载特定的少数的类,那么可以在程序中释放自定义类加载器的引用,卸载当前类,当前对象置空,垃圾回收会对这部分内容进行回收。

3、垃圾判定-对象已死?

如何判断堆上的对象可以回收?

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

public class Demo {

    public static void main(String[] args) {
        // 1. 在 main 方法中创建 Demo 类的实例对象
        Demo demo = new Demo();
        
        // 2. 调用 Demo 类的实例方法 f1
        demo.f1();
        
        // 3. 将 demo 变量设置为 null,解除对 Demo 对象的引用
        demo = null;
        
        // 4. 由于没有其他对 Demo 对象的引用,Demo 对象会成为垃圾对象,等待垃圾回收
    }

    public void f1() {
        // 5. 在 f1 方法中创建 Date 类的实例对象
        Date d = new Date(); 
        
        // 6. Date 对象 d 被创建并存储在方法的栈帧中
        // 注意:此方法中的 Date 对象 d 是局部变量,方法 f1 执行完毕后,d 的引用会被销毁
        
        // 7. 当方法 f1 执行完毕,d 的引用将被移除,Date 对象 d 如果没有其他引用,也会成为垃圾对象,等待垃圾回收
    }
}

特殊情况:循环引用的对象也可以被回收

public class ReferenceCounting {
    public static void main(String[] args) {
        // 1. 创建 A 类的一个实例对象 a1
        A a1 = new A(); 
        
        // 2. 创建 B 类的一个实例对象 b1
        B b1 = new B(); 
        
        // 3. 将 b1 赋值给 a1 对象的 b 属性
        a1.b = b1; 
        
        // 4. 将 a1 赋值给 b1 对象的 a 属性
        b1.a = a1; 
        
        // 5. 将 a1 变量设置为 null,解除对 A 对象的引用
        a1 = null; 
        
        // 6. 将 b1 变量设置为 null,解除对 B 对象的引用
        b1 = null; 
        
        // 此时,虽然 a1 和 b1 的引用都被置为 null,但 a1 和 b1 对象之间的相互引用导致它们的引用计数都未达到 0
        // 导致这两个对象不会立即被垃圾回收器回收
    }
}

class A {
    // 7. 类 A 有一个 B 类型的成员变量 b
    B b;
}

class B {
    // 8. 类 B 有一个 A 类型的成员变量 a
    A a;
}

如何判断堆上的对象没有被引用?

常见的有两种判断方法:引用计数法可达性分析法

3.1、引用计数法(Reference-Counting)

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。

基本思路:

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;

任何时刻计数器为0的对象就是不可能再被使用的。

优点:

  • 简单,高效,现在的objective-c、python等用的就是这种算法。

缺点:

  • 引用和去引用伴随着加减算法,影响性能

  • 很难处理循环引用,相互引用的两个对象则无法释放。

因此目前主流的Java虚拟机都摒弃掉了这种算法

3.2、可达性分析算法

实现简单,执行高效,解决引用计数算法中循环引用的问题,是Java和C#选择的算法。

基本思路:

可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系

将一系列GC Root的集合作为起始点,按照从上至下的方式搜索所有能够被该合集引用到的对象(是否可达),并将其加入到该和集中,这个过程称之为标记(mark),被标记的对象是存活对象。 最终,未被探索到的对象便是死亡的,是可以回收的。

可以理解为没在关系网中的对象

在Java语言中,可以作为GC Root的对象包括下面几种:

  1. 静态变量(Static Variables):静态变量属于类,而不是实例对象。它们在整个程序执行期间都存在,并且被认为是 GC Root 对象。

  2. 活动线程(Active Threads):正在运行的线程也被视为 GC Root 对象。因为线程是程序执行的控制流,如果一个线程还在运行,那么它引用的对象也应该被保留。

  3. 栈帧(Stack Frames)中的局部变量和输入参数:栈帧中的局部变量和输入参数也是 GC Root 对象。它们在方法调用期间创建,并且随着方法的结束而销毁。

  4. JNI 引用(JNI References):通过 Java Native Interface (JNI) 在 Java 代码和本地代码之间传递的对象也被视为 GC Root 对象。这些对象的生命周期由本地代码管理。

4、垃圾回收算法-清除已死对象

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收。

在介绍JVM垃圾回收算法前,先介绍一个概念:Stop-the-World:

Stop-the-world意味着 JVM由于要执行GC而停止了应用程序的执行,并且这种情形会在任何一种GC算法中发生。

当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成

事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有高吞吐 、低停顿的特点。

吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即

吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。

比如:虚拟机总共运行了 100 分钟,其中GC花掉 1 分钟,那么吞吐量就是 99%

4.1、标记清除(Mark-Sweep)

标记-清除算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。正如名字一样,算法分为2个阶段

(1)标记:使用可达性分析算法,标记出可达对象。

(2)清除:对堆内存从头到尾进行线性便遍历,如果发现某个对象没有被标记为可达对象,则将其回收。

缺点:

  • 效率问题(两次遍历)

  • 空间问题(标记清除后会产生大量不连续的碎片。JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。)

4.2、复制算法(Copying)

核心思想:

(1)标记:使用可达性分析算法,标记出可达对象。

(2)复制:将所有存活对象从From空间复制到To空间中。复制过程中,可能需要更新对象内部的引用指向新的地址。

(3)交换空间:From空间和To空间的角色发生交换,即将To空间作为新的From空间,而原来的From空间成为To(垃圾)空间。

(4)清理阶段:垃圾回收器可以简单地将整个From空间视为垃圾。

优点:

  • 实现简单

  • 不产生内存碎片

缺点:

  • 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

年轻代中使用的是Minor GC,这种GC算法采用的就是复制算法:

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。一般情况下,新创建的对象都会被分配到Eden区。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法。

4.3、标记压缩(Mark-Compact)

也叫标记整理算法。

标记整理算法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存

(1)标记:使用可达性分析算法,标记出可达对象。

(2)整理:将存活对象紧凑地移动到堆内存的一端,以消除内存空间的碎片化问题。同时清理垃圾对象。

(3)更新:更新所有指向被移动对象的引用,确保引用仍然指向正确的地址。

优点:

标记整理算法不仅可以弥补标记清除算法中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

缺点:

如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。

难道就没有一种最优算法吗?

回答:无,没有最好的算法,只有最合适的算法。==>分代收集算法

4.4、分代收集算法(Generational-Collection)

内存效率:

复制算法 > 标记清除算法 > 标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

内存整齐度:

复制算法 > 标记整理算法 > 标记清除算法。

内存利用率:

标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存

为了尽量兼顾上面所提到的三个指标,标记整理算法相对来说更平滑一些,但效率上依然不尽如人意。

比复制算法多了一个标记的阶段,又比标记清除多了一个整理内存的过程

分代回收算法实际上是复制算法和标记整理法、标记清除的结合,并不是真正一个新的算法。

一般分为老年代(Old Generation)和年轻代(Young Generation)

老年代就是很少垃圾需要进行回收的,年轻代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。

年轻代(Young Gen)

年轻代特点是区域相对老年代较小,对像存活率低。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenure Gen)

老年代的特点是区域较大,对像存活率高。

这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现

5、四种引用

创建一个User类:

public class User {

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int id;
    public String name;

    @Override
    public String toString() {
        return "[id=" + id + ", name=" + name + "] ";
    }
}

5.1、强引用

不回收

只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

User user = new User(1, "zhangsan");

案例:

public class StrongReferenceTest {

    public static void main(String[] args) {
        //定义强引用
        User user = new User(1, "zhangsan");
        //定义强引用
        User user1 = user;

        //设置user为null,User对象不会被回收,因为依然被user1引用
        user = null;

        //强制垃圾回收
        System.gc();

         try {
             TimeUnit.SECONDS.sleep(1);
         } catch (InterruptedException e) {
             throw new RuntimeException(e);
         }
        System.out.println(user1);
    }
}

5.2、软引用

内存不足即回收

SoftReference 类实现软引用。在系统要发生内存溢出(OOM)之前,才会将这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。软引用可用来实现内存敏感的高速缓存

SoftReference<User> userSoftRef = new SoftReference<>(new User(1, "zhangsan"));

案例:内存空间充足时,不会回收软引用的可达对象。注意测试环境设置为 -Xms10m -Xmx10m

//-Xms10m -Xmx10m
public class SoftReferenceTest {

    public static void main(String[] args) {
        //创建对象,建立软引用
        SoftReference<User> userSoftRef = new SoftReference<>(new User(1, "zhangsan"));

        //上面的一行代码,等价于如下的三行代码
        //User u1 = new User(1,"zhangsan");
        //SoftReference<User> userSoftRef = new SoftReference<>(u1);
        //u1 = null;//如果之前定义了强引用,则需要取消强引用,否则后期userSoftRef无法回收

        //从软引用中获得强引用对象
        System.out.println(userSoftRef.get());

        //内存不足测试:让系统认为内存资源紧张
        //测试环境: -Xms10m -Xmx10m
        try {
            //默认新生代占堆的1/3空间,老年代占堆的2/3空间,因此7m的内容在哪个空间都放不下
            byte[] b = new byte[1024 * 1024 * 7]; //7M
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {

            System.out.println("finally");

            //再次从软引用中获取数据
            //在报OOM之前,垃圾回收器会回收软引用的可达对象。
            System.out.println(userSoftRef.get());
        }
    }
}

5.3、弱引用

发现即回收

WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集(GC)之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

WeakReference<User> userWeakRef = new WeakReference<>(new User(1, "zhangsan"));

案例:

public class WeakReferenceTest {

    public static void main(String[] args) {
        //构造了弱引用
        WeakReference<User> userWeakRef = new WeakReference<>(new User(1, "zhangsan"));
        //从弱引用中重新获取对象
        System.out.println(userWeakRef.get());

        System.gc();
        
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        
        // 不管当前内存空间足够与否,都会回收它的内存
        System.out.println("After GC:");
        //重新尝试从弱引用中获取对象
        System.out.println(userWeakRef.get());
    }
}

5.4、虚引用

也叫幽灵引用、幻影引用

对象回收跟踪

PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。它主要用于执行一些清理操作或监视对象的回收状态。

ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<User> obj = new PhantomReference(new User(1, "tom"), phantomQueue);

案例:

public class PhantomReferenceTest {

    public static void main(String[] args) {
        User obj = new User(1, "zhangsan");
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

        obj = null; // 解除强引用

        // 在这里,对象可能已经被垃圾回收了,但我们无法通过虚引用获取它

        // 判断虚引用是否被回收
        boolean isCollected = false;
        while (!isCollected) {
            System.gc(); // 建议垃圾回收器执行回收操作
            try {
                Thread.sleep(1000); // 等待1秒钟
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (phantomRef.isEnqueued()) { //判断虚引用是否已经被回收。
                isCollected = true;
            }
        }

        // 输出虚引用是否被回收
        System.out.println("虚引用是否被回收:" + isCollected);
    }
}

在上面的示例中,我们创建了一个PhantomReference对象phantomRef,它引用了一个User实例obj。当我们解除obj的强引用后,obj将成为垃圾回收的候选对象。然后,我们通过调用System.gc()方法建议垃圾回收器执行回收操作,并等待一段时间以确保垃圾回收完成。最后,我们使用isEnqueued()方法判断虚引用是否已经被回收。

需要注意的是,由于垃圾回收操作的不确定性,虚引用的回收并不是立即发生的,所以程序中需要等待一段时间才能得出结论。另外,虚引用的主要作用是允许程序员在对象被回收之前进行一些清理操作,而不是直接获取对象的引用。

6、垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

6.1、JVM中的默认垃圾收集器

查看JVM中默认的垃圾收集器

java -XX:+PrintCommandLineFlags -version

  • JDK7:Parallel Scavenge + Serial Old

  • JDK8 及 JDK 7U40之后的版本:Parallel Scavenge + Parallel Old

  • JDK9+:G1

6.2、七款经典垃圾收集器

垃圾收集器与分代关系

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、Parallel Old、CMS

整堆收集器:G1

垃圾回收器的组合

 新生代GC和老年代GC搭配工作

  • JDK9中Serial GC 不再能够和 CMS GC一起使用

  • JDK9中ParNew GC 也不再和Serial Old GC 一起使用

  • JDK14中Parallel Scavenge 和 Serial Old GC 一起使用被废弃

  • JDK10中CMS GC被废弃

  • Serial Old GC 是 CMS GC的替补 。

6.3、Serial/Serial Old收集器

串行收集器是最古老、最稳定的收集器,垃圾收集的过程中会Stop The World,只使用一个线程去回收。

工作过程:

新生代(Serial)使用复制算法、老年代(Serial Old)使用标记整理算法

参数控制:

-XX:+UseSerialGC 串行收集器(单线程收集器)

6.3、ParNew 收集器

Parallel收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例。

工作过程:

新生代使用复制算法、老年代使用标记整理算法。

参数控制:

-XX:+UseParallelGC 使用Parallel收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,主要用于在多线程或多核处理器环境中并行地进行垃圾回收,使用标记整理算法。这个收集器是在JDK 1.6中才开始提供

参数控制:

-XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

6.5、CMS收集器(老年代)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)

  • 并发标记(CMS concurrent mark)

  • 重新标记(CMS remark)

  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

  • 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

  • 并发标记阶段就是进行GC Roots Tracing的过程,从CG Roots直接能关联到的对象开始遍历整个树对象,这个过程耗时长,但不需要停顿用户线程

  • 而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

  • 并发清理:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,这个阶段也可以与用户线程并发。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

优点:

并发收集、低停顿 缺点:

产生大量空间碎片、并发阶段会降低吞吐量

参数控制:

-XX:+UseConcMarkSweepGC 使用CMS收集器 -XX:+UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长 -XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理 -XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

CMS是一种预处理垃圾回收器,它不能等到old内存用尽时回收,需要在内存用尽前,完成回收操作,否则会导致并发回收失败。

6.6、G1收集器(区域化分代式)

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  1. 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

  2. 分代收集:分代概念在G1中依然得以保留。虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。也就是说G1可以自己管理新生代和老年代了

  3. 空间整合:由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。

  4. 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒

上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。

每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

为了避免全堆扫描,G1使用了Remembered Set来管理相关的对象引用信息。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏了。

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

1、初始标记(Initial Making)

2、并发标记(Concurrent Marking)

3、最终标记(Final Marking)

4、筛选回收(Live Data Counting and Evacuation)

看上去跟CMS收集器的运作过程有几分相似,不过确实也这样。

  • 初始阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以用的Region中创建新对象,这个阶段需要停顿线程,但耗时很短

  • 并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这一阶段耗时较长但能与用户线程并发运行。

  • 而最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但可并行执行。

  • 最后筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这一过程同样是需要停顿线程的,但Sun公司透露这个阶段其实也可以做到并发,但考虑到停顿线程将大幅度提高收集效率,所以选择停顿。下图为G1收集器运行示意图:

整堆收集器: G1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

贰陆.256

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值