JVM

jvm

JVM 全称是 Java Virtual Machine,中文译名 Java虚拟机。JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件。

image-20241014105033008

分为三个步骤:

1、编写Java源代码文件。

2、使用Java编译器(javac命令)将源代码编译成Java字节码文件。

3、使用Java虚拟机加载并运行Java字节码文件,此时会启动一个新的进程。

JVM的功能

JVM实例的创建:每次运行Java程序时,都会启动一个新的JVM实例(在大多数情况下)。这个实例负责执行当前Java程序的字节码。JVM的启动和初始化是自动完成的,无需程序员手动干预。

资源分配:JVM实例在启动时,会向操作系统请求必要的资源(如内存空间),并在其内部进行资源分配和管理。

环境初始化:JVM实例会初始化其内部环境,包括设置堆大小、栈大小、方法区大小等,以及加载必要的系统类和库。

Java虚拟机规范

JVM(Java Virtual Machine,Java虚拟机)规范是Oracle公司(及其前身Sun Microsystems)制定的一系列规范,用于指导JVM的设计和实现。这些规范定义了JVM如何加载、执行Java字节码,管理内存,处理垃圾回收,以及与其他语言和环境交互的方式。

Oracle HotSpot JVM‌:由Oracle公司开发,是目前最常用的JVM之一,也是Java官方推荐的JVM之一。

OpenJDK JVM‌:由Oracle公司主导的开源JVM项目,是Java官方的参考实现之一。

IBM J9 JVM‌:由IBM公司开发的JVM,具有高性能和低内存占用等优点。

Azul Zing JVM‌:由Azul Systems公司开发的JVM,专注于高性能、低延迟和可预测性的优化。

JRockit JVM‌:由BEA Systems公司开发的JVM,具有高性能和低内存占用等优点,目前已被Oracle公司收购。

Excelsior JET JVM‌:由Excelsior公司开发的JVM,可以将Java程序编译成本地机器代码,提高程序的执行效率。

字节码文件的组成

  • 基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
  • 常量池:保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
  • 字段: 当前类或接口声明的字段信息
  • 方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
  • 属性: 类的属性,比如源码的文件名、内部类的列表等

Java虚拟机的组成

  • 类加载器(Class Loader):负责加载类文件到JVM中。
  • 运行时数据区(Runtime Data Areas)
    • 方法区(Method Area):它存储了已被虚拟机加载的类信息、方法信息、字段信息、常量(final修饰)、静态变量、即时编译器编译后的代码缓存等。
    • 堆(Heap):Java堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,几乎所有的对象实例都在这里分配内存。
    • 栈(Stack):每个线程都有自己的栈,用于存储局部变量和部分计算过程的结果。
    • 程序计数器(Program Counter Register):是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
    • 本地方法栈(Native Method Stacks):与虚拟机栈所发挥的作用非常相似,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
  • 执行引擎(Execution Engine):负责执行字节码,或者将字节码转换为机器码来执行。
  • 本地接口(Native Interface):允许Java代码调用本地代码(通常是C或C++编写的库)。

image-20241014145633526

JVM的优势

  • 平台无关性(跨平台):由于Java程序运行在JVM上,而JVM可以在任何操作系统上运行,因此Java程序具有跨平台的特性。
  • 安全性:JVM提供了安全机制来防止恶意代码的攻击。
  • 自动内存管理:JVM负责自动管理内存的分配和回收,减轻了程序员的负担。
  • 多线程支持:JVM内置了对多线程的支持,简化了多线程编程的复杂性。

一、类加载器子系统

类加载器的作用

负责加载class文件到JVM,然后创建一个与之对应的Class对象(该对象中包含该类的信息,方法信息,字段字段等),存储在方法区。

image-20240701195251613

解释说明:

1、通过类加载器将User.class字节码文件加载到 JVM 中,然后创建一个与之对应的Class对象。

2、User字节码文件一旦加入到 JVM 以后,那么此时就可以使用其创建对应的实例对象。

3、可以通过调用实例对象的getClass方法获取字节码文件对象。

4、可以调用字节码文件对象的getClassLoader方法获取加载该类所对应的类加载器。

类的生命周期

  • 加载(Loading):JVM负责加载Java类文件(.class)到其内部的内存中,就是方法区。
  • 链接(Linking)
    • 验证(Verification):检查加载的类文件是否符合Java语言规范及JVM规范。
    • 准备(Preparation):为类变量分配内存并设置默认的初始值(注意,这里的初始值不是代码中设定的值,而是如0、null等默认值)。
    • 解析(Resolution):将类的符号引用替换为直接引用(例如,将类名、方法名等符号替换为它们在内存中的地址)。
  • 初始化(Initialization):根据程序员在Java代码中编写的初始化代码(如静态代码块、静态变量初始化等)来初始化类的变量。
  • 执行(Execution):JVM中的Java解释器(JIT编译器)负责将字节码转换成平台相关的机器码并执行。

image-20241014125625905

 public class MyClass {
    static int staticVariable = 10;
    static {
        System.out.println("静态代码块执行");
    }
 
    public static void main(String[] args) {
        System.out.println("静态变量的值: " + staticVariable);
    }
}

当JVM启动并执行这个main方法时,MyClass类将经历以下阶段:

  1. 加载:JVM寻找并加载MyClass的字节码文件。
  2. 链接:
    • 验证:检查MyClass是否符合JVM的要求。
    • 准备:为staticVariable分配内存,并设置默认值0。
    • 解析:如果有依赖其他类或方法等,则解析这些类或方法的符号引用。
  3. 初始化:为staticVariable赋值10,然后执行静态代码块。

总结

类加载:将某个class文件以二进制流的方式加载到jvm内存中,并且创建与之对应的Class对象存储到jvm的方法区中

将来:实例化一个对象(new User()) 就需要使用方法区中的Class进行实例对象的创建,实例对象的创建通常就是在jvm中的堆中

1. 加载阶段

主要将某个clss类安全的加载到jvm中,这个阶段需要使用特点的类加载器

2. 连接阶段

  • 验证,验证内容是否满足《Java虚拟机规范》。
  • 准备,给静态变量赋初值。
  • 解析,将常量池中的符号引用替换成指向内存的直接引用。

验证

验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:

1、文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。

img

2、元信息验证,例如类必须有父类(super不能为空)。

3、验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。

4、符号引用验证,例如是否访问了其他类中private的方法等。

准备

准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。

数据类型 初始值
int 0
long 0L
short 0
char ‘\u0000’
byte 0
boolean false
double 0.0
引用数据类型 null

注意:

public class Student{

public static int value = 1;

}

在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。

public class Student{

public static final int value = 1;

}

final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。

解析

解析阶段主要是将常量池中的符号引用替换为直接引用

  • 符号引用就是在字节码文件中使用编号来访问常量池中的内容
  • 每个Class对象的地址就是直接引用

3. 初始化阶段

初始化阶段:主要可以为static类型的属性赋予实际的初始值,并且也会执行static静态代码块。

public class Demo1 {
    public static int value = 1;
    static {
        value = 2;
    }
}

以下几种方式会导致类的初始化:

1.访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。

2.调用Class.forName(String className)。

3.new一个该类的对象时。

4.执行Main方法的当前类。

4. 使用

使用,将来利用该Class创建实例对象。

5. 卸载

卸载,当某个Clss不在被需要时,可以被垃圾回收器进行垃圾回收,从而释放方法区内存空间。

类加载器的分类

1、启动类加载器(Bootstrap ClassLoader):它是虚拟机的内置类加载器。负责加载Java核心类库,如rt.jar中的类。启动类加载器是由C++实现的,不是一个Java类,打印该加载器时为null。

2、扩展/平台类加载器(Extension/PlatformClassLoader):扩展类加载器负责加载Java的扩展类库,位于JRE的lib/ext目录下的jar包。

3、应用程序/系统类加载器(Application/System ClassLoader):也称为系统类加载器,它负责加载应用程序的类,即开发者自己编写的类。

4、自定义类加载器。

类加载器之间是存在逻辑上的继承关系,但是不存在物理上的继承

image-20241014162750865

public class StudentDemo01 {

    public static void main(String[] args) {

        // 获取加载Student类所对应的类加载器
        ClassLoader classLoader = Student.class.getClassLoader();
        System.out.println(classLoader);

        // 获取classLoader类加载器所对应的父类加载器
        ClassLoader loaderParent = classLoader.getParent();
        System.out.println(loaderParent);

        // 获取loaderParent类加载器所对应的父类加载器
        ClassLoader parentParent = loaderParent.getParent();
        System.out.println(parentParent);       // 引导类加载器,是通过null进行表示
    }

}

类加载的机制(双亲委派)

JVM对class文件采用的是按需加载的方式,当需要使用该类时,JVM才会将它的class文件加载到内存中产生class对象。

在加载类的时候,是采用的双亲委派机制

  • 如果一个类加载器接收到了类加载的请求,它自己不会先去加载,会把这个请求委托给父类加载器去执行。
  • 如果父类还存在父类加载器,则继续向上委托,一直委托到启动类加载器:Bootstrap ClassLoader
  • 如果父类加载器可以完成加载任务,就返回成功结果,如果父类加载失败,就由子类自己去尝试加载,如果子类加载失败就会抛出ClassNotFoundException异常,这就是双亲委派模式
  • 补充:每个类加载器在自己的范围内去加载某个类之前,先判断该类是否已经被加载。

image-20241014181253386

双亲委派机制的好处:

避免重复加载,确保类的唯一性:当一个类需要被加载时,首先会委派给父类加载器进行加载。如果父类加载器能够找到并加载该类,就不会再由子类加载器重复加载,避免了重复加载同一个类的问题。

提高安全性:双亲委派机制可以防止恶意代码通过自定义类加载器来替换核心类库中的类。因为在加载核心类库时,会优先委派给启动类加载器进行加载,而启动类加载器是由JVM提供的,具有较高的安全性。

指定加载类的类加载器

方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。

方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载。

 //获取main方法所在的类加载器,应用程序加载器
ClassLoader classLoader = String.class.getClassLoader();
System.out.println("classLoader = " + classLoader);

//使用应用程序加载器加载指定com.chs.A
Class<?> aClass = classLoader.loadClass("com.chs.a");
System.out.println("aClass = " + aClass);

打破双亲委派机制

打破双亲委派机制历史上有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:

  • 自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离。
  • 线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
  • Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。

二、运行时数据区

运行时数据区:是Java虚拟机用于存储和管理程序运行时数据的区域。

运行时数据区又可以为划分为如下几个部分:

image-20230615105135381

那些区域是线程共享的,那些是线程私有的?

  • 堆、方法区是各个线程共享的区域。一旦是多线程共享区域,意味着这部分可能会出现多线程并发安全问题。
  • 栈、本地方法栈、程序计数器各个线程私有区域。不会出现多线程并发安全问题。

程序计数器

作用:是一块较小的内存空间,可以理解为是当前线程所执行程序的字节码文件的行号指示器,存储的是当前线程所执行的行号

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

JVM栈

‌‌JVM栈的主要作用是管理‌Java程序运行时的方法调用参数传递。‌

JVM栈是Java虚拟机为每个线程分配的一块内存区域【线程私有】,用于存储方法的局部变量、‌操作数栈、‌动态链接、‌返回地址等信息

每个方法在执行时都会创建一个栈帧,并将其压入当前线程对应的JVM栈中。当方法执行完毕后,该栈帧会被弹出并销毁,JVM栈也相应地回收内存空间。‌

public class MethodDemo {   
    public static void main(String[] args) {        
         study();    
     }

    public static void study(){
        eat();

        sleep();
    }   
    
    public static void eat(){       
         System.out.println("吃饭");   
    }    
    
    public static void sleep(){        
        System.out.println("睡觉");    
        }
  }

main方法执行时,会创建main方法的栈帧=》接下来执行study方法,会创建study方法的栈帧=》进入eat方法,创建eat方法的栈帧=》eat方法执行完之后,会弹出它的栈帧=》然后调用sleep方法,创建sleep方法栈帧=》最后study方法结束之后弹出栈帧,main方法结束之后弹出main的栈帧。

JVM栈帧的具体功能和结构:

  • ‌局部变量表‌:局部变量表的作用是在运行过程中存放所有的局部变量,包括基本类型、对象引用等。每个线程的局部变量表是独立的,因此局部变量是线程安全的。‌
  • ‌操作数栈‌:操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
  • ‌帧数据,帧数据主要包含动态链接、方法出口、异常表的引用

1. 局部变量表

局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。

2. 操作数栈

操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。

3. 帧数据

帧数据主要包含动态链接、方法出口、异常表的引用。

动态链接

当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。、

方法出口

方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。

异常表

异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

StackOverflowError

JVM栈的大小是固定的【通常为1MB】,可以通过命令行参数【-Xss】进行调整。

如果JVM栈的深度超过了预设的阈值,或者当前线程所需要的栈空间已经超过了剩余的可用空间,那么JVM就会抛出StackOverflowError异常,从而保护了整个程序的安全性。

每个线程都有自己独立的JVM栈,用于支持线程的并发执行。栈太小或者方法调用过深,都将抛出StackOverflowError异常。

image-20241014190835295

如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。

image-20241014190936862

要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。

  • 语法:-Xss栈大小
  • 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB):

操作步骤如下,不同IDEA版本的设置方式会略有不同:

1、点击修改配置Modify options

2、点击Add VM options

3、添加参数

image-20241021093736341

一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存。

本地方法栈

Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。

本地方法:被native所修饰的方法

native方法的实现,并不是java实现的,而是通过c 然后调用操作系统底层的api。

JVM堆

Java虚拟机堆是Java内存区域中一块用来存放对象实例的区域,新创建的对象,数组等都使用堆内存。

栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

image-20241014192019729

在栈上通过s1s2两个局部变量保存堆上两个对象的地址,从而实现了引用关系的建立。

堆内存的溢出

当堆空间不足以为一个新对象开辟空间时,此时会开始gc垃圾回收去释放一些空间,如果还不足,就只能抛出OutOfMemory错误(堆内存溢出)

堆空间组成部分

image-20230615141641279

说明:

1、默认Java虚拟机堆内存的初始大小为物理内存的1/64,最大是物理内存的1/4。

2、新生代占整个堆内存的1/3、老年代占整个堆内存的2/3。 1比2

3、新生代又可以细分为:伊甸区(Eden)、幸存区(from/s0、to/s1),它们之间的比例默认情况下是8:1:1。

4、线程共享区域,因此需要考虑线程安全问题。

5、会产生OOM内存溢出问题。

创建新对象,在堆内存中的分配过程:

1、创建的大部分对象,内存的分配都是从堆中新生代的eden区开始
2、当eden区不足以分配一个新对象时,此时垃圾回收器开始工作,对eden区和s0(from)区进行垃圾回收
3、然后将eden区和s0区存活的对象复制到s1(to)区。此时再将eden+s0清空。
4、将s0和s1的角色互换。
5、然后将新对象在eden区进行分配。
6、后续如果在eden区又不足以分配新对像,此时继续将eden和s0进行垃圾回收,之后依然是将eden+s0存活的对象赋值到s1,然后s0和s1的角色互换
7、当youg区的一个对象经历15次垃圾回收依然没有将他回收掉,此时就要将这个对象移动到old区(意味着old
区通常存储一些生命周期较长的对像)。youg区通常存储一些生命周期较短的对象。
8、大对象会直接进入到old区,而不需要先在eden区进行分配。

注意:s0和s1这两块,在同一时刻,只有一块正在被使用,另一块一定是空闲的(空间利用率只有50%)。

年龄最多到15的对象会被移动到年老代中,没有达到阈值的对象会被复到“To”区域。

对象在Survivor区(S)中每熬过一次Minor GC,年龄就会增加1岁,年龄最多到15的对象会被移动到年老代中。

可以通过-XX:MaxTenuringThreshold来设置年龄阈值。

堆内存大小设定

-XX:NewRatio=2

-XX:SurvivorRatio=8

-Xms512m

-Xmx1024m

-Xmn256m

-XX:NewRatio参数:该参数用于设置新生代和老年代的初始比例。例如,-XX:NewRatio=2表示新生代占堆内存的1/3,老年代占堆内存的2/3。
-XX:SurvivorRatio参数:该参数用于设置Eden区和Survivor区的初始比例。例如,-XX:SurvivorRatio=8表示Eden区占新生代的8/10,每个Survivor区占新生代的1/10。
设置堆的初始大小。例如,-Xms512m表示将堆的初始大小设置为512MB。
设置堆的最大大小。例如,-Xmx1024m表示将堆的最大大小设置为1GB。
设置新生代的大小。例如,-Xmn256m表示将新生代的大小设置为256MB。
通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

建议:

Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。

堆内存分代意义

Java的堆内存分代是指将不同生命周期的对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为“代”。

这样做有助于提升垃圾回收的效率,因为这样的话就可以为不同的"代"设置不同的回收策略。

一般来说,Java中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。如果把这两部分对象放到一起分析和回收,效率太低。通过将不同时期的对象存储在不同的内存中,使用不同的垃圾回收器,提高回收效率和性能。

JVM中的各种GC:

Minor GC(Young Generation Garbage Collection)是指对年轻代(Young Generation)进行的垃圾回收操作。

Major GC专注于回收老年代中(Tenured Generation)的垃圾对象。

Full GC(Full Garbage Collection),它是指对整个堆内存进行回收,包括新生代和老年代。

方法区

方法区是被所有线程共享,它存储了已被虚拟机加载的类信息、方法信息、字段信息、常量(final修饰)、静态变量、即时编译器编译后的代码缓存等。

image-20240702212653950

方法区演进

‌方法区‌是‌JVM规范中定义的一个内存区域。

在HotSpot虚拟机中,JDK 7之前的方法区由永久代实现,永久代位于JVM的堆内存中;

JDK 8及以后,永久代被移除,元空间被引入作为替代。元空间使用本地内存,不再受JVM内存大小的限制,减少了内存溢出的风险。

永久代满了会抛出OutOfMemoryError: PermGen space;

而元空间满了会抛出OutOfMemoryError: Metaspace。

image-20241014194245986参数控制:

1、-XX:MetaspaceSize 设置元空间的初始大小

2、-XX:MaxMetaspaceSize 设置元空间的最大大小

变化的原因

1、元空间使用的是直接内存(jvm之外的本地内存),受本机可用内存的限制,降低内存溢出的概率。

2、可以加载更多的类。

3、提高内存的回收效率。

元空间的引入减少了内存溢出的风险,因为它可以动态地扩展和收缩。

永久代的回收效率较低,而元空间的回收效率较高,因为它使用本地内存而不是JVM内存。‌

三、执行引擎

执行引擎负责执行Java程序的字节码指令,执行引擎的结构如下所示:

image-20230615151955474

java字节码指令的执行方式

  1. 解释模式
  2. 编译模式
  3. 混合模式--hotspot虚拟机采用的是混合模式。

解释器(Interpreter)

作用:逐行读取并执行字节码。

工作原理:

  • 解释器读取程序计数器(PC)指定的字节码指令。
  • 将字节码指令翻译成相应的机器码指令,并立即执行。
  • 执行完一条指令后,更新程序计数器以指向下一条字节码指令。

解释器的优点是启动速度快,缺点是执行效率较低,因为每次都需要逐行翻译字节码。

即时编译器(JIT Compiler)

作用:将字节码编译成高效的本地机器码,提高执行效率。

工作原理:

  • 当某些方法或代码段被多次执行时,JIT编译器将这些热点代码(HotSpot Code)编译成本地机器码。
  • 编译后的本地代码被缓存起来,以便后续直接执行,无需再次解释。
  • JIT编译器还会进行各种优化,例如方法内联(Inlining)、循环展开(Loop Unrolling)等,以进一步提高执行性能。
将被调用的方法的代码直接嵌入到调用者的方法中,减少方法调用的开销。
循环展开是一种优化,它将循环体复制多次以减少循环开销。

垃圾回收器(Garbage Collector)

作用:管理内存,自动回收不再使用的对象,防止内存泄漏(OOM)。

工作原理:

  • 在程序运行期间,垃圾回收器不断地监视对象的生命周期。
  • 当检测到某些对象不再被引用时,回收这些对象所占用的内存。
  • 垃圾回收策略和算法有多种,如标记-清除(Mark-Sweep)、复制算法(Copying)、标记-整理(Mark-Compact)等。

垃圾对象判定

​ 要进行垃圾回收,那么首先需要找出垃圾,如果判断一个对象是否为垃圾呢?

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

引用计数法

堆中每个对象实例都有一个引用计数。当一个对象被创建时,为该对象实例分配给一个变量,该变量计数设置为1。

每个对象都有一个引用计数器,当该对象增加了一个引用,此时计数器+1,减少了一个引用,计数器-1,当一个对象的引用计数器=0时,表示他是垃圾对象,随时可以被回收。

  • 优点:实现简单。
  • 缺点:无法解决 循环引用 问题。

循环引用:A引用B;B引用A,但是其他对象都没有再用的这两个A,B对象,此时A,B这两个对象就是垃圾对象,但是它们的引用计数器都是1,所以不能被清理。这也称为内存泄漏(A和B一直占用着内存空间,但是它实际属于垃圾对象)

可达性分析算法(根搜索法)

首先需要筛选到一些根节点对象,根节点对象的特点就是不需要被清理的对象(有用的对像),从根节点对象开
始,依次做链路追踪。

image-20241014201427329

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

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

2、本地方法栈中引用的对象

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

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

5、线程Thread对象

6、系统类加载器加载的java.lang.Class对象

垃圾回收算法

1、找到内存中存活的对象

2、释放不再存活对象的内存,使得程序能再次利用这部分空间

1 、标记清除

1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。

2.清除阶段,从内存中删除没有被标记也就是非存活对象。

image-20241014202240327

对象D被清理掉了

优点:速度比较快。

缺点:会产生内存碎片,使得连续空间少。

image-20241014202353317

2、标记整理

1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。

2.整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。

image-20241014202958366

优点:无内存碎片。

缺点:效率较低。

3、复制

1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。

对象A首先分配在From空间

image-20241014203149544

2.在垃圾回收GC阶段,将From中存活对象复制到To空间

在垃圾回收阶段,如果对象A存活,就将其复制到To空间。然后将From空间直接清空。

image-20241014203206501

3.将两块空间的From和To名字互换

接下来将两块空间的名称互换,下次依然在From空间上创建对象。

image-20241014203219365

优点:无内存碎片

缺点:空间利用率只有50%;如果对象的存活率较高,复制算法的效率就比较低。

4、分代

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

分代垃圾回收将整个内存区域划分为年轻代和老年代:

image-20241014203637429

新生代对象的存活的时间都比较短,因此使用的是【复制算法】;

而老年代对象存活的时间比较长那么采用的就是【标记清除】或者【标记整理】。

选择的虚拟机参数如下

参数名 参数含义 示例
-Xms 设置堆的最小和初始大小,必须是1024倍数且大于1MB 比如初始大小6MB的写法: -Xms6291456 -Xms6144k -Xms6m
-Xmx 设置最大堆的大小,必须是1024倍数且大于2MB 比如最大堆80 MB的写法: -Xmx83886080 -Xmx81920k -Xmx80m
-Xmn 新生代的大小 新生代256 MB的写法: -Xmn256m -Xmn262144k -Xmn268435456
-XX:SurvivorRatio 伊甸园区和幸存区的比例,默认为8 新生代1g内存,伊甸园区800MB,S0和S1各100MB 比例调整为4的写法:-XX:SurvivorRatio=4
-XX:+PrintGCDetailsverbose:gc 打印GC日志

垃圾收集器

为什么分代GC算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:

  • 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
  • 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
  • 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。

分代GC算法将堆分成年轻代和老年代主要原因有:

1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。

2、新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。

3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。

常见的垃圾收集器汇总

image-20241014204327641

上面的 serial , parnew , Paraller Scavenge 是新生代的垃圾回收器;

下面的 CMS , Serial Old ,Paralle Old是老年代的垃圾收集器 ;

G1垃圾收集器可以作用于新生代和老年代; 连线表示垃圾收集器可以搭配使用。

1、Serial/Serial Old

Serial是一个单线程的垃圾收集器。

"Stop The World(STW)",它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。在用户不可见的情况下把用户正常工作的线程全部停掉。

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

参数控制: -XX:+UseSerialGC 年轻代和老年代都用串行收集器

image-20241014204757892

只有一个gc线程负责垃圾回收,并且会stop the world(STW,也就是一旦gc开始工作,则会将用户的工作线程暂停下来)

优点:由于会SW,垃圾清理的效果比较理想

缺点:会让用户的工作线程暂停,增勖加了客户端的等待时间。由于单线程,清理的效率较低。

使用场景:多用于桌面应用(内存占用较小的应用),Client端的垃圾回收器。

2、ParNew

ParNew 是 Serial 的 **多线程 **版本,除了使用多线程进行垃圾收集之外,其余行为与Serial收集器完全一样。

参数控制:

  • -XX:+UseParNewGC , 年轻代使用ParNew,老年代使用 Serial Old

  • -XX:ParallelGCThreads={value} ,控制gc线程数量

image-20241014205054944

优点:由于会SW,垃圾清理的效果比较理想

缺点:会让用户的工作线程暂停,增勖加了客户端的等待时间。

3、Parallel/Scavenge

Parallel 收集器 类似 ParNew 收集器,多线程 并行 收集。

更关注吞吐量(吞吐量优先),是JDK8默认的垃圾收集器。

新生代 复制算法、老年代标记整理算法(Parallel Old是Parallel的老年代版本)。

吞吐量

http并发请求的角度,吞吐量表示处理请求的能力。
在垃圾收集器的角度下,吞吐量表示用户工作线程的执行时间的比例。吞吐量越高,用户的工作线程占据的cpu时间越多。

cpu的总运行时间=工作线程占据的时间+gc线程占据的时间

吞吐量=工作线程占据的时间/cpu的总运行时间

吞吐量  =  运行用户代码时间  /(运行用户代码时间 + 垃圾收集时间 = cpu总消耗时间)

例如:JVM虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99% 。

image-20241014205940215

优点

吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数

缺点

不能保证单次的停顿时间

应用场景:高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

参数控制

-XX:+UseParallelGC 新生代启用Parallel GC

-XX:+UseParallelOldGC 新生代和老年代都启用Parallel GC

-XX:ParallelGCThreads=<N> 设置用于垃圾收集的线程数

-XX:GCTimeRatio=<N> 设置垃圾收集时间占程序运行时间的比例,默认为99,即1%的时间用于GC。

4、CMS收集器

CMS (Concurrent Mark Sweep 并发-标记-清除),老年代的收集器,基于“标记-清除”算法实现。

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器(响应时间优先)。降低STW的时间

CMS收集器主要用于要求低延迟(即:提高响应速度)的互联网项目。

更适合在交互性较强的场景下。

CMS垃圾收集的过程:

(1)初始标记--对根节点对象以及他的直接引用的对象进行标记。这个阶段会有STW,但是时间不会很长。

(2)并发标记--此时gc标记线程就会沿着上一步的标记对像,继续做可达性分析。于此同时,用户的工作线程也在执行。不会有STW出现。

(3)重新标记--因为上一步”"并发标记”的过程中,用户的工作线程也在执行(对象之间的引用关系会发生变更),所以可能会导致出现一些错误的标记,标记出的非垃圾对象,此时可能变成了垃圾对象:或者垃圾对象变成非垃圾对象。重新标记就是为了纠正’并发标记‘过程中出现
的一些错误标记。这个阶段会有STW,但是时间很短,发生错误的标记的对象不会很多。

(4)并发清除--之前三个阶段标记出的垃圾对象开始被回收,此时用户的工作线程也在继续执行,没有STW。

image-20241014211424223

参数控制:

-XX:+UseConcMarkSweepGC 老年代开启CMS

-XX:MaxGCPauseMillis=200 设置GC暂停等待时间,单位为毫秒

-XX:UseCMSCompactAtFullCollection 可以让 JVM 在执行标记清除完成后再做整理,避免内存碎片

-XX:ConcGCThreads 并发的 GC 线程数

5、G1收集器

JDK9及以后的版本中,G1是默认的垃圾收集器。

G1收集器既可以应用新生代也可以应用老生代

之前的垃圾收集器的共性问题:在垃圾收集过程中,一定会发生STW,垃圾收集器的发展就是为了能够尽量缩短STW的时间。

G1抛弃了将新生代和老年代作为整块内存空间的方式,但依然保留了新生代老年代的概念,只是老年代新生代的内存空间不再是物理连续的了,它们都是Region的集合。

每个Region的类型并不是固定的,=》可能之前是年轻代,经过了垃圾回收之后就变成了老年代。

G1采用 “局部收集” 设计思路 , 以Region为基本单位的内存布局,将java堆划分成一些大小相等的Region(建议不超过2048个)。

每个Region大小 = 堆总空间 / region个数。

可以通过参数-XX:G1HeapRegionSize来指定Region的大小。

关于region的类型:E、S、O、H

E类型的region组成了堆中的新生代的eden区。

S类型的region表示新生代的s区(s0,s1)

O类型的region组成了堆中的old区。

H类型的region.用于单独存储大对象(大对像直接进入到老年代,其实就是进入到H区),H区就是由多个连续的region组成。

image-20241014211931671

参数控制

1、-XX:+UseG1GC:表示使用G1收集器

2、-XX:G1HeapRegionSize:指定每一个Region的大小。

3、-XX:MaxGCPauseMillis:设置期望的最大GC停顿时间指标

4、-XX:ParallelGCThreads:设置并行垃圾回收的线程数

G1垃圾回收过程

image-20230615161238695

G1的垃圾收集的过程,分为4个阶段

(1)初始标记--对根节点对象以及他的直接引用的对象进行标记。这个阶段会有STW,但是时间不会很长。

(2)并发标记--此时gc标记线程就会沿着上一步的标记对像,继续做可达性分析。于此同时,用户的工作线程也在执行。不会有STW出现。

(3)最终标记--因为上一步”"并发标记”的过程中,用户的工作线程也在执行(对象之间的引用关系会发生变更),所以可能会导致出现一些错误的标记,标记出的非垃圾对象,此时可能变成了垃圾对象:或者垃圾对象变成非垃圾对象。重新标记就是为了纠正’并发标记‘过程中出现
的一些错误标记。这个阶段会有STW,但是时间很短,发生错误的标记的对象不会很多。

(4)筛选回收--并发筛选清除。
		将各个region的回收价值和回收成本进行排序。
		在期望的停顿时间内,选择一部分region进行垃圾回收; 
				region-1,可回收0.5m,预期100ms   垃圾回收耗时最小的
				region-2,可回收0.6m,预期130ms
				region-2,可回收0.7m,预期150ms
		垃圾回收的过程采用的是“复制”算法。
		region-1需要被垃圾回收,首先将存活对象复制到另一个region中,然后region-1整体被释放。
		优点:避免内存碎片。
		缺点:如果存活的对象过多,复制的过程较慢。

三色标记算法

三色标记法是基于可达性分析算法的一种实现方式。

垃圾收集器在标记的过程,有两种标记方式:串行标记(例如:serial,parallel)、并发标记(例如:cms、G1)。

1、串行标记,会暂停所有用户线程,全面进行标记;

2、并发标记,不会暂停用户工作线程。实现这种并发标记的算法就是 ===》三色标记法

三种颜色

三色标记算法使用的是三种颜色来区分对象的:

1、白色:本对象还没有被标记线程访问过

2、灰色:本对象已经被访问过,但是本对象引用的其他对象还没有被全部访问

3、黑色:本对象已经被访问过,并且本对象引用的其他对象也都被访问过了

image-20241014214105919

三色标记算法流程

(1)期初,所有对像全部是白色
(2)所有的根节点对像直接变成黑色
(3)将根节点对象的所有直接引用对象由白色标记成灰色,并灰色对象依次进入到一个队列中
(4)依次从队列中取出每个灰色对象,将当前灰色对象的所有直接引用对象由白色变成灰色,并把刚成为灰色的对象依放入队列中,最后将当前灰色对象变成黑色。
(5)反复上边的过程…

详细图解

1、起初所有对象都是白色

image-20240215133901503

2、三色标记初始阶段,所有GC Roots的直接引用(A、B、E)变成灰色,然后将灰色节点放入到一个队列中,此时GC Roots变成黑色

image-20240215133959578

3、然后从灰色队列中取出队头灰色对象,例如A,将他的直接引用C、D变成灰色,放入队列,A因为已扫描完它的所有直接引用对象,所以A变成黑色

image-20240215134113730

4、继续取出灰色对象,例如B对象,将它的直接引用F标记为灰色,放入队列,B对象此时标记为黑色

image-20240215134210327

5、继续从队列中取出灰色对象E,因为E没有直接引用其他对象,将E直接标记为黑色

image-20240215134242731

6、重复上述步骤,取出C 、D 、F 对象,他们都没有直接引用其他对象,直接变为黑色即可。

image-20240215134320959

7、最后,G对象是白色,说明G对象是一个垃圾对象,可以被清理掉。

三色标记算法弊端

因为并发标记的过程中,用户线程也在运行,那么对象引用关系很可能发生变化,进而就会产生常见的两个问题:

1、浮动垃圾:标记为不是垃圾的对象,变成了垃圾。

回到如下的状态,此时E已经被标记为黑色,表示不是垃圾,不会被清除。

image-20241014220943385

因为并发标记时,同一时刻某个用户线程将GC Root2和E对象之间的关系断开了(objRoot2.e = null;)

image-20241014221007579

很显然,E对象变为了垃圾对象,但是由于之前被标记为黑色,就不会被当作垃圾回收,这种问题称之为浮动垃圾。

2、漏标/错杀,标记为垃圾对象,变成了非垃圾。

image-20241014221618386

上述弊端的解决方案

  • 对于第一个问题,即使不去处理也无所谓,大不了等下一次GC的时候再清理。

  • 第二个问题就比较严重,会发生空指针异常(F被错杀),出现第二个问题必须满足两个条件:

    1、并发标记过程中黑色对象(A) 新增引用 到 白色对象(F)

    2、灰色对象(B) 断开了(减少引用) 同一个白色对象(F)引用

image-20241014221719677

  • 两种解决方案:

(1)增量更新(Incremental Update)==》CMS采用

​ 是站在A对象的角度(新增引用的对象),在赋值操作之前,加个写屏障,用来记录新增的引用(A.f = F)。在 重新标记 阶段,将A变成灰色入队,重新扫描一次,以保证不会漏标。

(2)原始快照(SATB, Snapshot At The Beginning)===》G1采用

​ 是站在B对象的角度(减少引用的对象),在将B.f = F 改成B.f = null 之前,写屏障记录下F,这个F称之为 原始快照。在 最终标记 阶段,直接将F设为黑色。可以保证F不被回收,但是可能成为浮动垃圾。

四种引用类型

强引用

Java中默认声明的就是强引用,比如:

Object obj = new Object();    //只要obj还指向Object对象,Object对象就不会被回收
obj = null;                   //手动置null

只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,直接抛出OutOfMemoryError,不会去回收。

如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null,这样一来,JVM就可以适时的回收对象了!

示例:

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class StrongReferenceDemo01 {

    private static List<Object> list = new ArrayList<Object>() ;
    public static void main(String[] args) {

        // 创建对象
        for(int x = 0 ;  x < 10 ; x++) {
            byte[] buff = new byte[1024 * 1024 * 1];
            list.add(buff);
        }
    }
}

软引用

内存够,软引用对象不会被回收;

内存不够,软引用对象会被回收。

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class SoftReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {
        // 创建数组对象
        for(int x = 0 ; x < 10 ; x++) {
            SoftReference<byte[]> softReference = new SoftReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(softReference) ;
        }
        System.gc();  // 主动通知垃圾回收器进行垃圾回收
        for(int i=0; i < list.size(); i++){
            Object obj = ((SoftReference) list.get(i)).get();
            System.out.println(obj);
        }
    }
}

我们发现无论循环创建多少个软引用对象,打印结果总是有一些为null,这里就说明了在内存不足的情况下,软引用将会被自动回收。

弱引用

无论内存是否足够,只要JVM开始GC,弱引用关联的对象都会被回收。

/**
 * JVM参数:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class WeakReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {

        // 创建数组对象
        for(int x = 0 ; x < 10 ; x++) {
            WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(weakReference) ;
        }

        System.gc();  // 主动通知垃圾回收器进行垃圾回收

        for(int i=0; i < list.size(); i++){
            Object obj = ((WeakReference) list.get(i)).get();
            System.out.println(obj);
        }
        
    }
}

打印全是null。

虚引用

如果一个对象仅持有虚引用,它就和没有任何引用一样,它随时可能会被回收。

在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

特点:

1、每次垃圾回收时都会被回收,主要用于监测对象是否已经从内存中删除。

2、虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。

3、程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

示例代码:

public class PhantomReferenceDemo {

    public static void main(String[] args) throws InterruptedException {

        // 创建一个引用队列
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<Object>();
        
        // 创建一个虚引用,指向一个Object对象
        PhantomReference<Object> phantomReference=new PhantomReference<Object>(new Object(),referenceQueue);
        
        // 主动通知垃圾回收器进行垃圾回收
        System.gc();
        
        // 从引用队列中获取元素, 该方法是阻塞方法
        System.out.println(referenceQueue.remove()); 

    }
}
posted @ 2024-11-05 21:09  CH_song  阅读(45)  评论(0)    收藏  举报