Java面试篇(JVM相关专题)

2 篇文章 0 订阅

文章目录

0. 前言

Java 虚拟机(也就是 JVM )如今已经是 Java 程序员必学的内容了,主要原因有两个:

  1. 面试:尤其是对于要参加校招的应届生来说,JVM 基本上是必问(特别是大厂面试),掌握越深越好,而社招会更偏重于考察 Java 虚拟机调优的经验
  2. 线上环境优化:随着线上环境用户量和访问量的激增,Java 虚拟机越来越容易出现与内存、执行性能等相关的问题,所以掌握 Java 虚拟机的故障解决以及调优技术是非常有必要的

与 JVM 相关的知识整体是比较难的,并且大多数都是理论知识,需要花费不少时间理解,必要的时候还需要大家专门去记忆一下

1. 为什么要学 JVM

主要有三个原因:

  1. 应对面试:如果说在面试的时候,你对与 JVM 相关的知识一点都不了解的话,那面试官对你的印象会大打折扣
  2. 中高程序员必备技能:如果你说我只是一个 CRUD 的程序员,那压根就不需要了解 JVM,因为它跟我们日常开发几乎没啥关系。但如果你是一个有追求的程序员,想在这个行业长期发展,也期望从一个小白晋升成一个大牛的话,掌握 JVM 相关的知识就至关重要了
  3. 深入理解 Java:一旦你掌握了 JVM ,就知道了 Java 的运行机制,对问题的排查能力会有大幅度的提升,像内存泄漏、CPU 飚高等问题都与 JVM 相关,如果你能够解决这些问题,那你就会不断地靠近大佬这个级别

在这里插入图片描述

2. 什么是 JVM

JVM:Java Virtual Machine,是一个能够执行 Java 字节码的虚拟机进程

Java 代码要想运行的话,必须要先编译成 class 文件(也就是字节码文件)

简而言之,任何 Java 代码的运行都离不开 JVM 的支持,它是确保 Java 程序能够在不同平台上运行的基础

3. JVM 的好处

JVM 主要有两个好处:

  1. 一次编写,到处运行
  2. 自动内存管理(基于垃圾回收机制)

3.1 一次编写,到处运行

JVM 是运行在操作系统中的,我们平时都说 Java 是一个跨平台语言,它是怎么跨平台的呢,就是因为JVM,因为 JVM 帮你屏蔽了操作系统的差异,不管是在 Windows 系 还是在 Linux 系统,真正运行代码的是我们的 JVM,不是操作系统,所以说才能做到一次编写,到处运行

在这里插入图片描述

3.2 自动内存管理(基于垃圾回收机制)

这个好处一般会跟 C/C++ 进行对比,因为 C/C++ 需要管理员自己去管理内存,如果程序员编码不当,很容易造成内存泄漏的问题,而 JVM 的垃圾回收机制大大减轻了程序员的负担,减少了程序员出错的几率

4. 要学习哪些 JVM 的哪些内容

在这里插入图片描述

5. JVM 的组成

在这里插入图片描述

5.1 程序计数器

在这里插入图片描述

程序计数器(Program Counter,PC):用于记录线程执行的字节码指令的地址,相当于记录了线程执行到了哪一行字节码。每个线程都有自己的程序计数器,这意味着每个线程在执行Java代码时都有自己独立的程序计数器

上面说的可能比较抽象,我们来看一个简单的例子

public class Application
    
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }

}

上面是一个打印 Hello World 的代码

运行程序后我们在 target 目录下找到 Application 类的字节码文件,然后在终端中打开

在这里插入图片描述

补充知识:javap -v xxx.class 打印堆栈大小,局部变量的数量和方法的参数

然后输入以下指令

javap -v Application.class

控制台会输出很多信息,我们主要看 main 方法,以下信息详细记录了 main 方法的执行过程

在这里插入图片描述

我们的源码中只有一行代码,但在 class 字节码中却拆成了多行执行,我们对每一行做一个简单的分析

第 1 行是 getstatic ,它的含义就是获取一个静态的变量,那哪一个是静态的变量呢?静态变量指的是 System 类里面的 out 属性,这个属性是静态的,而且这个属性的类型是 PrintStream,以下是 System 类的源码

在这里插入图片描述

第 2 行是 ldc(load constant),加载一个常量,这个常量是一个字符串(Hello World)

第 3 行是 invokevirtual,表示要调用一个方法,调用哪个方法呢?从输出信息中可以看到调用的是 PrintStream 类的 println 方法

第 4 行是 return,意思就是这个方法结束了


为了方便大家理解,找一个代码行数比较多的代码

在这里插入图片描述

现在有一个线程要执行当前代码,当线程执行到第 10 行的时候时间片被其它线程夺走了,也就是说这个线程目前没有 CPU 的执行权了

为了下一次获取到 CPU 的执行权的时候,该线程能够继续执行第 10 行代码,该线程会记录当前执行到了第 10 行代码,等到下一次线程获取到 CPU 的执行权的时候,直接从第 10 行代码开始运行就可以了

在这里插入图片描述

5.2 堆

在这里插入图片描述

堆:线程共享的区域,主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则会抛出 OutOfMemoryError 异常(内存溢出)


在这里插入图片描述

我们主要关注三个部分(年轻代、老年代、元空间):

  1. 年轻代被划分为三部分,Eden 区(Eden 区主要存放新创建的对象)和两个大小严格相同的 Survivor 区根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到老年代区间
  2. 老年代主要保存生命周期长的对象,一般是一些老的对象
  3. 元空间保存主要保存类信息、静态变量、常量、编译后的代码

补充:Java 1.7 和 Java 1.8 的堆的区别是什么

在这里插入图片描述

Java 1.7 的堆中有一个方法区(也叫永久代),而 Java 1.8 中没有,这是因为 Java 1.8 之后,将方法区(也叫永久代)代放到了本地内存的元空间中

为什么要放到本地内存呢,因为方法区(也叫永久代)主要存放的是一些类或常量,随着动态类加载越来越多,方法区(也叫永久代)部分的内存将变得不可控,如果该部分内存小了,很容易会出现内存溢出的现象,如果大了,又有点浪费内存

所以 Java 1.8 之后做了优化,将方法区(也叫永久代)放到了本地内存,就是为了能够节省堆的内存空间,从而避免内存溢出

在这里插入图片描述

5.3 什么是虚拟机栈

在这里插入图片描述

Java Virtual Machine Stacks:Java 虚拟机栈,每个线程运行时所需要的内存,称为虚拟机栈(具备先进后出的特点)

每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

每个线程的栈空间由-Xss参数指定,举个例子,在内存足够的情况下,如果每个线程需要 1 MB的栈,并且有 200 个线程,那么这 200 个线程总共会占用 200 MB的内存空间


常见问题一:垃圾回收是否涉及栈内存

答:垃圾回收不涉及栈内存,垃圾回收主要涉及堆内存,当栈帧从栈中弹出后,栈内存就会被释放

常见问题二:虚拟机栈内存分配越大越好吗

答:未必,默认的虚拟机栈内存通常为 1024 k(1M),虚拟机栈内存过大会可能导致线程数变少。例如,如果机器当前的可用内存为 512 M,目前能活动的线程数则为 512 个,如果把虚拟机栈内存改为 2048 K,那么能活动的线程数就会减半。一般栈内存不需要调整,使用默认值即可

常见问题三:方法内的局部变量是不是线程安全的

  • 如果方法内成局部变量没有脱离方法的作用范围,那这个局部变量是线程安全的
  • 如果是局部变量引用了对象,并且脱离方法的作用范围,需要考虑这个局部变量的线程安全问题

在这里插入图片描述

常见问题四:栈内存溢出的情况

  • (常见)栈帧过多导致栈内存溢出,典型问题:递归调用(一般是没有出口的递归调用)
  • (少见)栈帧过大导致栈内存溢出

在这里插入图片描述

5.4 方法区

在这里插入图片描述

5.4.1 方法区的概念

Method Area:方法区,各个线程共享的内存区域,主要存储类的信息、运行时常量池

方法区在虚拟机启动的时候创建,在虚拟机关闭时释放

如果方法区中的内存无法满足分配请求,则会抛出 OutOfMemoryError: Metaspace


本地内存指的是操作系统的内存

在这里插入图片描述

下面演示内存不够的情况,先看下面的类

在这里插入图片描述

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
 * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
 * -XX:MaxMetaspaceSize=8m
 */
public class MetaspaceDemo extends ClassLoader { // 可以用来加载类的二进制字节码
    
    public static void main(String[] args) {
        MetaspaceDemo metaspaceDemo = new MetaspaceDemo();
        for (int i = 0; i < 100000; i++) {
            // ClassWriter 作用是生成类的二进制字节码
            ClassWriter classWriter = new ClassWriter(0);
            // 版本号, public, 类名, 包名, 父类, 接口
            classWriter.visit(Opcodes.V17, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
            // 返回 byte[]
            byte[] code = classWriter.toByteArray();
            // 执行了类的加载
            metaspaceDemo.defineClass("Class" + i, code, 0, code.length); // Class 对象
        }
    }
    
}

运行以上代码后,发现控制台没有任何报错信息,因为元空间(MetaSpace)的大小是没有上限的

我们可以手动设置元空间的大小

第一步:编辑启动类的配置

在这里插入图片描述

第二步:添加虚拟机选项

在这里插入图片描述

在这里插入图片描述

在虚拟机选项中填入以下内容

-XX:MaxMetaspaceSize=8m

在这里插入图片描述

第三步:测试

运行代码后,在控制台就可以看到以下信息(java.lang.OutOfMemoryError: Metaspace)

在这里插入图片描述

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.base/java.lang.ClassLoader.defineClass1(Native Method)
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1012)
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:874)
	at cn.edu.scau.MetaspaceDemo.main(MetaspaceDemo.java:21)

5.4.2 常量池

常量池可以看作是一张表,虚拟机根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

我们还是以 打印 Hello World 的程序 为例来了解常量池,运行以下指令

javap -v Application.class

在输出的信息中找到与常量池相关的内容(关键字:Constant pool)

在这里插入图片描述

在控制台中找到与 main 方法有关的信息

在这里插入图片描述

我们来分析一下 #7 ,#7 指的就是常量表中的第 7 行,查看常量池的第 7 行,发现是一个字段引用(Print Stream),同时该字段引用又需要常量表的第 8 行和第 9 行的内容,常量表的第 9 行的类型类 Name And Type ,记录的是字段的名称和类型,同时常量表的第 9 行又需要常量表的第 11 行和第 12 行的内容

在这里插入图片描述

在这里插入图片描述

5.4.3 运行时常量池

常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(符号引用)变为真实的内存地址

在这里插入图片描述

5.4.4 可能遇到的问题

如果你在运行上述代码时遇到以下问题

在这里插入图片描述

Exception in thread "main" java.lang.IllegalAccessError: class cn.edu.scau.MetaspaceDemo (in unnamed module @0x4eec7777) cannot access class jdk.internal.org.objectweb.asm.ClassWriter (in module java.base) because module java.base does not export jdk.internal.org.objectweb.asm to unnamed module @0x4eec7777
	at cn.edu.scau.MetaspaceDemo.main(MetaspaceDemo.java:15)

是因为在 Java 9 及以后的版本中,引入模块系统(JEP 261: The Java Module System)时导致的

错误信息表明 jdk.internal.org.objectweb.asm.ClassWriter 类位于 java.base 模块中,且该模块未向包含 cn.edu.scau.MetaspaceDemo 类的无名模块导出 jdk.internal.org.objectweb.asm

在 Java 中使用自定义类加载器加载类时,如果类使用到了内部类或一些私有 API ,可能会出现此类 IllegalAccessError 错误,这通常是因为模块系统限制了对某些类或包的访问权限


可以通过在运行 Java 程序时添加以下 JVM 参数来解决该问题:

--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED

如果你使用的是 Java 命令行,可以这样运行你的程序:

java --add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED -jar your-program.jar

5.5 直接内存

在这里插入图片描述

直接内存:并不属于 JVM 中的内存结构,不由 JVM 进行管理,是虚拟机的系统内存(也就是操作系统的内存),常见于 NIO 操作时,用作数据缓冲区,直接内存的分配回收成本较高,但读写性能非常高


我们先来看一个文件复制的案例——将一个文件分别采用穿透 IO和 NIO 的方式复制到另一个文件夹(文件大小为 23.9 MB)

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.NIO.ByteBuffer;
import java.NIO.channels.FileChannel;


public class DirectMemoryDemo {

    private static final String FROM = "F:\\Blog\\jvm\\06-JVM组成-你听过直接内存吗.mp4";

    private static final String TO = "F:\\Blog\\jvm\\video\\06-JVM组成-你听过直接内存吗.mp4";

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        io();
        NIO();
    }

    private static void NIO() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1MB);
            while (true) {
                int len = from.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.err.println("NIO 用时:" + (end - start) / 1000_000.0 + "ms");
    }

    private static void io() {
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1MB];
            while (true) {
                int len = from.read(buf);
                if (len == -1) {
                    break;
                }
                to.write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("IO用时:" + (end - start) / 1000_000.0 + "ms");
    }

}

代码运行后,可以看到 NIO 的效率比 传统 IO高很多(文件越大越明显)

在这里插入图片描述

我们来分析一下为什么 NIO 的效率比 传统 IO 高


常规 IO 的数据拷贝流程

在这里插入图片描述

Java 本身并不具备与磁盘文件直接交互的能力,Java 要与磁盘进行交互的话,需要调用操作系统提供的函数(也就是本地方法),这个过程涉及到了 CPU 运行状态的转换

首先会从用户态切换到内核态,切换到内核态后,由 CPU 去读取磁盘中的文件,将文件内容放到系统缓冲区中(如果文件较大时,不会一次性读取到系统缓冲区中,而是分批次读取)

但 Java 代码是不能直接在系统缓冲区中对文件内容进行操作的,所以 Java 会在堆中分配一块内存,然后将系统缓冲区中的文件内容复制到刚分配的堆内存,这个时候 CPU 的运行状态转换为用户态,然后调用 Java 中的输入输出流进行操作

Java 对文件内容进行操作后,将文件内容放回系统缓冲区,让 CPU 将系统缓冲区中的文件内容保存到磁盘中

由于操作流程中有不必要的数据复制操作,常规 IO 的效率不是很高


NIO 的数据拷贝流程

在这里插入图片描述

直接内存相当于在操作系统中划分出一块缓冲区,这块缓冲区 Java 代码可以访问,操作系统也可以访问,是一块双方共享的内存区域

数据加入到直接内存之后,进行磁盘文件读写操作的时候,Java 操作代码将变得十分方便,比传统的 io 操作少了从缓冲区中复制文件内容的操作,速度自然也能够提升不少

6. 类加载器

6.1 什么是类加载器,类加载器有哪些

在这里插入图片描述

类加载器主要用于装载字节码文件( *.class 文件)

JVM 只会运行二进制文件,类加载器的作用就是将字节码文件加载到 JVM 中,从而让 Java 程序能够启动


类加载器有四种:

  1. 启动类加载器:主要加载 JAVA_HOME/jre/lib 目录下的库扩展
  2. 拓展类加载器:主要加载 JAVA HOME/jre/lib/ext 目录中的类
  3. 应用类加载器:主要加载 classPath 下的类
  4. 自定义类加载器(了解即可):继承自应用类加载器,实现自定义类加载规则

在这里插入图片描述

6.2 什么是双亲委派模型

在这里插入图片描述

双亲委派模型:加载某一个类时,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果上级加载器没有加载该类,那么子加载器才会尝试加载该类

6.3 JVM 为什么要采用双亲委派机制

  1. 双亲委派机制可以避免某一个类被重复加载,当上级加载器已经加载该类后,则无需子加载器重复加载,保证类的唯一性
  2. 为了安全,保证类库 API 不会被修改

假如我们自己定义了一个 String 类,包名也是 java.lang

在这里插入图片描述

在编译阶段就失败了,错误信息如下:

软件包 ‘lang’ 存在于另一个模块中: java.base

根据双亲委派机制的规则,java.lang.String 由启动类加载器时加载,因为在核心 jre 库中有其相同名字的类文件,所以在编译阶段就报错了

双亲委派机制在一定程度上可以防止恶意篡改核心 API 库(毕竟你写的 String 类大概率没有官方写的好用。。。)

6.4 类装载的执行过程

在这里插入图片描述

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了 7 个阶段:

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化
  6. 使用
  7. 卸载

在这里插入图片描述

其中验证、准备和解析这三个部分统称为连接(linking)

6.4.1 加载

加载的流程:

  1. 根据类的全名获取类的二进制数据流
  2. 解析类的二进制数据流到方法区(相当于将类的信息存入方法区)
  3. 创建 java.lang.Class 类实例,表示该类型,作为这个类的各种数据在方法区中的访问入口

上面可能说的很抽象,下面是一个例子

在这里插入图片描述

现在有一个 Person 类,Person 类被加载后,就会存储到运行时数据区的两块区域中,一块是方法区(也就是元空间),存储的是 Person 类的信息(比如 Person 类的构造函数、方法、字段等),主要存储的是类的结构;另一块区域是堆,在堆中会开辟一块空间存储类的 Class 对象

等到创建对象的时候,比如现在有两个对象,一个是张三,一个是李四,这两个对象都是基于堆中的 Person.class 创建的,每个对象的对象头都指向了堆中的 Person.class ,但是类中的具体数据(比如方法、构造函数、字段等)需要通过方法区中的 Person.class 才能获取

6.4.2 验证

验证类是否符合 JVM 规范,主要是做安全性检查

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

前面三项进行的都是格式检查,比如文件格式是否错误、语法是否错误、字节码是否合规等

符号引用验证怎么理解呢,具体来说,Class 文件会在常量池会通过字符串记录自己将要使用的其他类或者方法,并检查这些类和方法是否存在

6.4.3 准备

类变量:用 static 修饰的变量

该阶段主要是为类变量分配内存并为类变量设置初始值,分为三种情况:

  1. static 变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
  2. static 变量是被 final 关键字修饰的基本类型。字符串常量(String 类底层的数据结构是一个被 final关键字修饰的字符数组),值已确定,赋值在准备阶段完成
  3. static 变量是被 final 关键字修饰的引用类型,那么赋值也会在初始化阶段完成

在这里插入图片描述

6.4.4 解析

把类中的符号引用改为直接引用

比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法

在这里插入图片描述

在这里插入图片描述

6.4.5 初始化

对类的静态变量,静态代码块执行初始化操作,初始化的规则如下:

  1. 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类
  2. 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行

大家可以运行以下代码,观察控制台的输出结果来加深对初始化规则的理解

public class InitializeDemo {

    public static void main(String[] args) {
        // 1. 首次访问这个类的静态变量或静态方法时
        System.out.println(Animal.number);

        // 2. 子类初始化,如果父类还没初始化,会引发父类先初始化
        System.out.println(Cat.sex);

        // 3. 子类访问父类静态变量,只触发父类初始化
        System.out.println(Cat.number);

    }
}

class Animal {

    static int number = 55;

    static {
        System.out.println("Animal 静态代码块...");
    }

}

class Cat extends Animal {

    static boolean sex = false;

    static {
        System.out.println("Cat 静态代码块...1");
    }

    static {
        System.out.println("Cat 静态代码块...2");
    }

}

6.4.6 使用

JVM 开始从入口方法开始执行用户的程序代码:

  1. 调用静态类成员信息(例如静态字段、静态方法)
  2. 使用 new 关键字为其创建对象实例

6.4.7 卸载

当用户程序代码执行完毕后,JVM 就会开始销毁创建的 Class 对象

7. 垃圾回收

7.1 对象什么时候可以被垃圾回收器回收

在这里插入图片描述

如果某个对象没有任何的引用指向它,那么这个对象现在就是垃圾,如果对象被定位成垃圾,就可能会被垃圾回收器回收


有两种方式来确定某个对象是不是垃圾

  1. 引用计数法
  2. 可达性分析算法

7.1.1 引用计数法

一个对象被引用了一次,该对象就会递增一次引用次数,如果这个对象的引用次数为 0 ,代表这个对象可回收

在这里插入图片描述

在这里插入图片描述

引用计数法比较简单,但存在一定的问题

当对象之间出现了循环引用的情况,引用计数法就会失效,出现内存泄漏问题

在这里插入图片描述

在这里插入图片描述

7.1.2 可达性分析算法

现在的虚拟机都是通过可达性分析算法来确定哪些内容是垃圾

在这里插入图片描述

上图中,X、Y 这两个节点是可回收的


Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象

扫描堆中的对象,看是否能够沿着 GC Root 对象为起点的引用链找到该对象,找不到,表示则表示对象可以回收

那哪些对象可以作为 GC Root 呢,主要有以下四种

第一种:虚拟机栈(栈帧中的本地变量表)中引用的对象

在这里插入图片描述

第二种:方法区中类静态属性引用的对象

在这里插入图片描述

第三种:方法区中常量引用的对象

在这里插入图片描述

第四种:本地方法栈中 JNI(Java Native Interface)引用的对象(了解即可)

7.2 垃圾回收算法有哪些

在这里插入图片描述

垃圾回收算法主要有三个:

  1. 标记清除算法
  2. 复制算法
  3. 标记整理算法

7.2.1 标记清除算法(用的比较少)

标记清除算法将垃圾回收分为2个阶段,分别是标记和清除

  1. 根据可达性分析算法得出的垃圾进行标记
  2. 对这些标记为可回收的内容进行垃圾回收

在这里插入图片描述

从上图可以看出标记清楚算法的优点和缺点:

  • 优点:标记和清除速度较快
  • 缺点:碎片化较为严重,内存不连贯

7.2.2 标记整理法

在这里插入图片描述

标记整理法与标记清除法类似,但标记整理法解决了标记清除算法的碎片化的问题

因为标记整理法比标记清除法多了一步,移动存活对象在内存中的位置(将存活的对象都向内存的某一端移动),当然,这一步对效率有一定的影响

很多老年代的垃圾回收算法都是采用标记整理法

7.2.3 复制法

在这里插入图片描述

复制法的主要思路就是将存活的对象复制到另一块内存区域中,然后清空原来的内存区域,复制的过程中就解决了碎片的整理过程

一般年轻代的垃圾回收算法采用的就是复制法


复制法的优点和缺点:

  • 优点:在垃圾对象较多的情况下,效率较高,而且垃圾清理后没有内存碎片
  • 缺点:将内存空间一分为二,但 2 块内存空间在同一个时刻,只能使用一个,内存使用率较低

7.3 JVM 的分代回收

在这里插入图片描述

分代收集算法

在 Java 8 中,堆被分成两个区域:新生代和老年代(1 : 2)

而在新生代内部,又划分了三个区域:Eden区(伊旬园区)、S0区(from)、S1区(to)

Eden区 : S0区 : S1 区 = 8 : 1 : 1

在这里插入图片描述

7.3.1 分代收集算法-工作机制

  • 新创建的对象,都会先分配到 Eden 区
  • 当 Eden区 内存不足时,标记 Eden区 与 from区 的存活对象
  • 将存活对象采用复制算法复制到 to 中,复制完毕后, Eden 区和 from 区的内存都得到释放
  • 经过一段时间后 Eden 区的内存又不足,标记 Eden 区 和 to 区存活的对象,将存活的对象复制到 from 区
  • 当幸存区的对象熬过几次回收(最多15次)后,将晋升到老年代(如果幸存区内存不足或对象较大会导致提前晋升)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

from 区域和 to 区域的角色一直在不断地交换,可参考复制法的原理

7.3.2 MinorGC、Mixed GC、FullGC 有什么区别

名词解释

STW:Stop The World,暂停所有线程,等待垃圾回收完成

  • Minor GC【Young GC】:发生在新生代的垃圾回收,暂停时间短(STW)
  • Mixed GC:新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
  • FulI GC:新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免

7.4 JVM 有哪些垃圾回收器

在这里插入图片描述

在 JVM 中,实现了多种垃圾收集器,包括:

  1. 串行垃圾收集器
  2. 并行垃圾收集器
  3. CMS(并发)垃圾收集器
  4. G1垃圾收集器

7.4.1 串行垃圾收集器

Serial 和 Serial Old 串行垃圾收集器,是指使用单线程进行垃圾回收,适合堆内存较小的情况(个人电脑),在企业开发中很少用

  • Serial 作用于新生代,采用复制算法
  • Serial Old 作用于老年代,采用标记-整理算法

串行垃圾回收器在进行垃圾回收时,只有一个线程在工作,并且 Java 应用中除了垃圾回收线程以外的所有线程都要暂停(STW),等待垃圾回收的完成

在这里插入图片描述

7.4.2 并行垃圾收集器

Parallel New 和 Parallel Old 是一个并行垃圾回收器,JDK8 默认使用此垃圾回收器

  • Parallel New 作用于新生代,采用复制算法
  • Parallel Old 作用于老年代,采用标记-整理算法

在这里插入图片描述

并行垃圾收集器在进行垃圾回收时,多个线程在工作,并且 Java 应用中除了垃圾回收线程以外的所有线程都要暂停(STW),等待垃圾回收的完成

7.4.3 CMS(并发)垃圾回收器

CMS,全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好,其最大特点是在进行垃圾回收时,应用仍然能正常运行

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

7.4.4 G1 垃圾收集器

在这里插入图片描述

G1 垃圾收集器的内容较多,且较难理解,此处只记录了简略部分,具体可参考视频教程:G1 垃圾回收器


G1 垃圾收集器

  • 应用于新生代和老年代,在 JDK 9 之后默认使用 G1 垃圾回收器
  • 划分成多个区域,每个区域都可以充当 eden,survivor,old,humongous,其中 humongous 专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收、并发标记、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度)会触发 FuIl GC

在这里插入图片描述

7.5 强引用、软引用、弱引用、虚引用的区别

在这里插入图片描述

强引用、软引用、弱引用、虚引用的区别章节的内容较多,且较难理解,此处只记录了简略部分,具体可参考视频教程:强引用、软引用、弱引用、虚引用的区别


强引用:只有在所有 GC Roots 对象都没有通过【强引用】引用该对象的情况下,该对象才能被垃圾回收(也就是通过所有的 GC Root 对象都找不到该对象)

在这里插入图片描述


软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收

在这里插入图片描述


弱引用:仅有弱引用引用该对象,在垃圾回收时,无论内存是否充足,都会回收弱引用对象

在这里插入图片描述


虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关
方法释放直接内存

在这里插入图片描述

8. JVM实践

8.1 如何设置 JVM 的参数

在这里插入图片描述

主要有两种设置方法:

  1. war 包部署在 tomcat 中的设置
  2. jar 包部署在启动参数设置

8.1.1 war 包部署在 tomcat 中的设置

修改 TOMCAT HOME/bin/catalina.sh 文件(Linux 环境下是修改 catalina.sh 文件,Windows 环境下是修改 catalina.bat 文件

在这里插入图片描述

8.1.2 jar 包部署在启动参数设置

通常在 Linux 系统下直接加参数启动 SpringBoot 项目

nohup java -Xms512m -Xmx1024m -jar xxx.jar --spring.profiles.active=prod &

指令说明:

  1. nohup:这个命令的作用是让Java进程在后台运行,即使当前用户会话结束(例如,用户登出)也不会影响Java进程
  2. java:这是Java虚拟机的命令,用于启动Java应用程序
  3. -Xms512m:这个参数指定了JVM启动时的初始堆大小(Initial Heap Size),即JVM初始分配给堆的内存大小为512MB
  4. -Xmx1024m:这个参数指定了JVM最大堆大小(Maximum Heap Size),即JVM可以分配给堆的最大内存大小为1024MB
  5. -jar xxx.jar:这个参数指定了一个JAR文件,JVM将会从这个JAR文件中加载应用程序
  6. --spring.profiles.active=prod:这是一个Java应用程序的启动参数,它告诉应用程序使用名为prod的环境配置。在Spring框架中,这个参数通常用于指定不同的配置文件,如开发环境(dev)、测试环境(test)和生产环境(prod)
  7. &:这个符号用于将当前命令放入后台执行。当你在命令行中输入这个命令后,它会立即返回命令行提示符,表明Java应用程序已经开始在后台运行

8.2 JVM 有哪些参数可以调优

在这里插入图片描述

对于 JVM 调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型

官网:Java HotSpot VM

  • 设置堆空间大小
  • 虚拟机栈的设置
  • 年轻代中 Eden 区和两个 Survivor 区的大小比例
  • 年轻代晋升老年代阈值
  • 设置垃圾回收收集器

8.2.1 调整堆空间的大小

通常是设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把堆空间的最大大小、堆空间的初始大小设置为相同的值

在这里插入图片描述

不指定单位默认为字节;指定单位,按照指定的单位设置


堆空间设置多少合适?

  1. 堆空间的最大大小的默认值是物理内存的 1/4,初始大小是物理内存的 1/64
  2. 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生 STW,暂停用户线程
  3. 堆内存大肯定是好的,但也存在风险,假如发生了 Full GC,它会扫描整个堆空间,暂停用户线程的时间长
  4. 设置参考推荐:尽量大,但也要考察一下当前计算机其他程序的内存使用情况

8.2.2 虚拟机栈的设置

虚拟机栈的设置:每个线程默认会开启 1M 的内存,用于存放栈帧、调用参数、局部变量等,但一般 256K 就够用了

通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统

-Xss

8.2.3 年轻代中 Eden 区和两个 Survivor 区的大小比例

设置年轻代中 Eden 区和两个 Survivor 区的大小比例,该值如果不设置,则默认比例为 8:1:1

通过增大 Eden 区的大小来减少 GC 发生的次数,但有时我们发现,虽然次数减少了,但 Eden 区满的时候,由于占用的空间较大,导致释放缓慢,此时 STW 的时间较长,因此需要按照程序情况去调优

-XX:SurvivorRatio=8

-XX:SurvivorRatIO= n 可以理解成对于每一个 Survivor 区,Eden 区的大小是其 n 倍,所以 SurvivorRatio=3 的时候,实际上 Eden 区和两个 Survivor 区的大小比就是 3:1:1

8.2.4 年轻代晋升老年代的阈值

threshold:阈值

-XX:MaxTenuringThreshold=threshold
  • 默认为 15
  • 取值范围 0-15

8.2.5 设置垃圾回收收集器

可以通过设置并行垃圾回收收集器,通过增大吞吐量提高系统性能

在这里插入图片描述

JDK 8 默认使用的是并行垃圾收集器(Parallel)

8.3 JVM 调优的工具

在这里插入图片描述

命令工具:

  • jps(Java Process Status):Java Development Kit(JDK)的一部分,用于显示当前系统中运行的 Java 进程信息
  • jstack:查看 Java 进程内线程的堆栈信息
  • jmap:用于生成堆转内存快照、查看内存使用情况
  • jstat:JVM 统计监测工具

可视化工具:

  • jconsole:用于对 JVM 的内存,线程,类的监控
  • VisualVM:能够监控线程、内存情况

8.3.1 jps

jps 指令主要用于查看系统中正在运行的 Java 进程信息


运行以下代码

public class ToolDemo {

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {

            }
        }, "t1").start();

        new Thread(() -> {
            while (true) {

            }
        }, "t2").start();

        new Thread(() -> {
            while (true) {

            }
        }, "t3").start();
    }
    
}

然后在终端中输入 jps 指令,查看系统中正在运行的 Java 进程信息(前面的数字是 pid,也就是进程 id)

在这里插入图片描述

8.3.2 jstack

查看 Java 进程内线程的堆栈信息

jstack [option] <pid>

我们在终端中输入以下指令

jstack 22780

终端中输出的信息较多,我们查找与刚才运行的 t1、t2、t3 线程相关的信息

在这里插入图片描述

8.3.3 jmap

用于生成堆转内存快照、查看内存使用情况


jmap -heap pid

显示 Java 堆的信息


显示 Java 堆的信息,format=b 表示以 hprof 二进制格式转储 Java 堆的内存,file=<filename> 用于指定快照dump文件的文件名

jmap -dump:format=b,file=heap.hprof pid
  • dump 文件是一个进程或系统在某一给定的时间的快照,比如在进程崩溃时,甚至是任何时候,我们都可以通过工具将系统或某进程的内存备份出来供调试分析用
  • dump 文件中包含了程序运行的模块信息、线程信息、堆栈调用信息、异常信息等数据,方便系统技术人员进行错误排查

我们在终端中执行以下指令

jmap -heap 22780

如果你遇到了以下问题,是因为从 Java 11 开始,jmap 和其他 JDK 工具(如 jstack、jinfo 等)被迁移到了 jhsdb 工具集中。因此,在较新的 Java 版本中,你需要使用 jhsdb jmap 命令

Error: -heap option used
Cannot connect to core dump or remote debug server. Use jhsdb jmap instead
jhsdb jmap --heap --pid=22780

控制台中输出的信息较多,我们摘取某个片段来分析一下

Garbage-First (G1) GC with 10 thread(s) // 当前 JVM 使用的是 G1 垃圾回收器

Heap Configuration:
   MinHeapFreeRatIO        = 40 // 空闲堆空间的最小百分比
   MaxHeapFreeRatIO        = 70 // 空闲堆空间的最大百分比
   MaxHeapSize              = 4200595456 (4006.0MB) // 堆空间允许的最大值
   NewSize                  = 1363144 (1.2999954223632812MB) // 新生代堆空间的默认值
   MaxNewSize               = 2518679552 (2402.0MB) // 新生代堆空间允许的最大值
   OldSize                  = 5452592 (5.1999969482421875MB) // 老年代堆空间的默认值
   NewRatIO                = 2 // 新生代与老年代的堆空间比值,此处的2表示新生代:老年代=1:2
   SurvivorRatIO           = 8 // 两个 Survivor 区和 Eden 区的堆空间比值为8,表示S0:S1:Eden=1:1:8
   MetaspaceSize            = 22020096 (21.0MB) // 元空间的默认值
   CompressedClassSpaceSize = 1073741824 (1024.0MB) // 压缩类使用空间大小
   MaxMetaspaceSize         = 17592186044415 MB // 元空间允许的最大值
   G1HeapRegionSize         = 2097152 (2.0MB) // 在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小

剩余信息可自行研读

在这里插入图片描述


我们再次在终端输入以下指令

jmap -dump:format=b,file=F:\Blog\jvm\heap.hprof 22780

在这里插入图片描述

可以看到,dump 文件已经生成了,那这个文件要怎么打开呢,后面会说到(会有专门的可视化工具打开该文件)

8.3.4 jstat

JVM 统计监测工具,可以用来显示垃圾回收信息、类加载信息、新生代统计信息等


jstat -gcutil pid

总结垃圾回收统计


jstat -gc pid

垃圾回收统计


我们在终端中输入以下指令

jstat -gcutil 22780

可以看到以下信息

在这里插入图片描述


我们在终端中输入以下指令

jstat -gc 22780

可以看到以下信息

在这里插入图片描述

8.3.5 jconsole

一个基于 jmx 的 GUI 性能监控工具,用于监控 JVM 的内存、线程、类

打开方式:Java 安装目录 的bin 目录下,双击启动 jconsole.exe

找到对应的进程,选择后点击连接按钮,然后点击不安全的连接

在这里插入图片描述

连接后可以看到概览,内存的使用情况,还能检测死锁

在这里插入图片描述

在这里插入图片描述

8.3.6 VisualVM

能够监控线程,内存情况,查看方法的 CPU 时间和内存中的对象,已被 GC 的对象,反向查看分配的堆栈

打开方式:Java 安装目录 的 bin 目录下,双击 jvisualvm.exe 文件启动 VisualVM

在这里插入图片描述


注意:在高版本的 JDK 中(JDK >= 9) 已经移除了该文件,如果高版本的 JDK 需要使用该工具,需要额外下载,下载地址:VisualVM: Download,下载完成后双击 bin 目录下的 visualvm.exe 文件启动 VisualVM

在这里插入图片描述

在 VisualVM 中打开 dump 文件

在这里插入图片描述

在这里插入图片描述

8.3.7 VisualVM 汉化

VisualVM 的界面默认是英文的,而且没有更改语言的选项,但是已经有前辈帮我们做好了 VisualVM 的汉化工作

VisualVM 汉化版的下载地址:VisualVM-汉化版-v2.1.8-1

在这里插入图片描述

但是该版本有点问题,直接双击 visualvm.exe 文件会报以下错误

在这里插入图片描述

因为该版本不是官方发布的可能会有点小问题(具体问题可能是无法检测到 JAVA_HOME 环境变量)

我们可以在 visualvm.exe 文件所在的目录下新建visualvm.bat 文件,文件内容如下

visualvm.exe --jdkhome %JAVA_HOME%

双击visualvm.bat 文件就可以启动 VisualVM 了

8.4 内存泄漏的排查思路

在这里插入图片描述

重点关注OutOfMemoryError:java heap space

在这里插入图片描述

排查步骤:

  1. 获取堆内存快照 dump 文件
  2. 使用 VisualVM 可视化工具打开 dump 文件(具体可参考本文的 8.3.6 VisualVM
  3. 通过查看堆信息的情况,定位内存溢出问题可能是哪几行代码导致的
  4. 找到对应的源代码,阅读上下文的情况,修复内存泄露的问题

获取堆内存快照 dump 文件有两种方式

8.4.1 使用 jmap 命令获取运行中程序的 dump 文件

jmap -dump:format=b,file=heap.hprof pid

8.4.2 使用 vm 参数获取 dump 文件

有的情况是内存溢出之后程序则会直接中断,而 jmap 只能打印在运行中的程序,所以可以通过参数的方式生成 dump 文件

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=dump文件的保存路径

8.5 CPU 飚高的排查方案与解决思路

在这里插入图片描述

第一步:使用 top 命令查看 CPU 的使用情况

第二步:在 top 命令的输出信息中查看是哪一个进程 CPU占用率较高,并记录该进程的 pid

第三步:查看进程中的线程信息

ps H -eo pid,tid,%cpu | grep 某个进程的pid

指令解释:

  • ps Hpsprocess status 的缩写,用于显示当前运行的进程。H选项表示显示进程的线程信息
  • -eo pid,tid,%cpu:这是ps命令的格式化输出选项,-e表示选择所有进程,-o后面跟着的是要显示的字段,这里指定了进程 ID(pid)、线程 ID(tid)和 CPU 使用率(%cpu)

第四步:根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号(先将十进制的线程 id 转换为十六进制的线程 id,接着在控制台找到有问题的线程,可以使用 printf "%x\n" id 指令来将十进制的线程 id 转换为十六进制的线程 id)

jstack 某个进程的pid

8.6 解决 CPU 飚高问题的简单案例

下面给出一个解决 CPU 飚高问题的简单示例

打包前先在 pom.xml 文件中添加以下打包插件,同时指定 mainClass 属性(如果是 SpringBoot 项目,pom.xml 文件中会自带一个打包插件,无需添加)

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                        <mainClass>cn.edu.scau.ToolDemo</mainClass>
                    </manifest>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

我们将 Java 程序打包成 jar 包(可以在 target 目录下找到该 jar 包)

在这里插入图片描述

在这里插入图片描述

接着进入 Linux 服务器的 /tmp 目录

cd /tmp

将 jar 文件上传到 /tmp 目录下,运行以下命令启动 jar 包

nohup java -jar jvm-1.0-SNAPSHOT.jar &

运行 jar 包后可以看到 CPU 的使用率瞬间达到了 100%

在这里插入图片描述

我们使用 top 命令查看 CPU 的使用情况,按下 C 键,让进程按照 CPU 的使用率从高到低排列(再次按下 C 键进程将按照 CPU 的使用率从低到高排列

在这里插入图片描述

可以看到 CPU 占用率排在第一位的是我们刚启动的 Java 程序

我们记录下该进程的 PID,然后按下 CTRL + C 键退出监控页面

接着输入以下指令查看进程中的线程信息

ps H -eo pid,tid,%cpu | grep 1741941

可以看到有三个线程的 CPU 占用率非常高

在这里插入图片描述

我们以线程 id 为1741965 的线程为例,定位到问题代码的源码行号

先将十进制的线程 id 转换为十六进制

printf "%x\n" 1741965

在这里插入图片描述

然后输入以下指令查看查看 Java 进程内线程的堆栈信息(注意:jstack 后面紧跟的是 PID)

jstack 1741941

然后根据这个十六进制的线程 id 定位到问题代码的源码行号

在这里插入图片描述

查看 ToolDemo.java 类的源代码,发现第 8 行代码是一个死循环

在这里插入图片描述

至此就找到了 CPU 飚高的原因


最后,记得杀掉该 Java 进程,让 CPU 恢复正常运转

sudo kill 1741941
  • 31
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

聂 可 以

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

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

打赏作者

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

抵扣说明:

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

余额充值