Java面试篇(JVM相关专题)

文章目录

  • [0. 前言](#0. 前言)
  • [1. 为什么要学 JVM](#1. 为什么要学 JVM)
  • [2. 什么是 JVM](#2. 什么是 JVM)
  • [3. JVM 的好处](#3. JVM 的好处)
    • [3.1 一次编写,到处运行](#3.1 一次编写,到处运行)
    • [3.2 自动内存管理(基于垃圾回收机制)](#3.2 自动内存管理(基于垃圾回收机制))
  • [4. 要学习哪些 JVM 的哪些内容](#4. 要学习哪些 JVM 的哪些内容)
  • [5. JVM 的组成](#5. JVM 的组成)
  • [6. 类加载器](#6. 类加载器)
    • [6.1 什么是类加载器,类加载器有哪些](#6.1 什么是类加载器,类加载器有哪些)
    • [6.2 什么是双亲委派模型](#6.2 什么是双亲委派模型)
    • [6.3 JVM 为什么要采用双亲委派机制](#6.3 JVM 为什么要采用双亲委派机制)
    • [6.4 类装载的执行过程](#6.4 类装载的执行过程)
      • [6.4.1 加载](#6.4.1 加载)
      • [6.4.2 验证](#6.4.2 验证)
      • [6.4.3 准备](#6.4.3 准备)
      • [6.4.4 解析](#6.4.4 解析)
      • [6.4.5 初始化](#6.4.5 初始化)
      • [6.4.6 使用](#6.4.6 使用)
      • [6.4.7 卸载](#6.4.7 卸载)
  • [7. 垃圾回收](#7. 垃圾回收)
    • [7.1 对象什么时候可以被垃圾回收器回收](#7.1 对象什么时候可以被垃圾回收器回收)
      • [7.1.1 引用计数法](#7.1.1 引用计数法)
      • [7.1.2 可达性分析算法](#7.1.2 可达性分析算法)
    • [7.2 垃圾回收算法有哪些](#7.2 垃圾回收算法有哪些)
      • [7.2.1 标记清除算法(用的比较少)](#7.2.1 标记清除算法(用的比较少))
      • [7.2.2 标记整理法](#7.2.2 标记整理法)
      • [7.2.3 复制法](#7.2.3 复制法)
    • [7.3 JVM 的分代回收](#7.3 JVM 的分代回收)
      • [7.3.1 分代收集算法-工作机制](#7.3.1 分代收集算法-工作机制)
      • [7.3.2 MinorGC、Mixed GC、FullGC 有什么区别](#7.3.2 MinorGC、Mixed GC、FullGC 有什么区别)
    • [7.4 JVM 有哪些垃圾回收器](#7.4 JVM 有哪些垃圾回收器)
      • [7.4.1 串行垃圾收集器](#7.4.1 串行垃圾收集器)
      • [7.4.2 并行垃圾收集器](#7.4.2 并行垃圾收集器)
      • [7.4.3 CMS(并发)垃圾回收器](#7.4.3 CMS(并发)垃圾回收器)
      • [7.4.4 G1 垃圾收集器](#7.4.4 G1 垃圾收集器)
    • [7.5 强引用、软引用、弱引用、虚引用的区别](#7.5 强引用、软引用、弱引用、虚引用的区别)
  • [8. JVM实践](#8. JVM实践)
    • [8.1 如何设置 JVM 的参数](#8.1 如何设置 JVM 的参数)
      • [8.1.1 war 包部署在 tomcat 中的设置](#8.1.1 war 包部署在 tomcat 中的设置)
      • [8.1.2 jar 包部署在启动参数设置](#8.1.2 jar 包部署在启动参数设置)
    • [8.2 JVM 有哪些参数可以调优](#8.2 JVM 有哪些参数可以调优)
      • [8.2.1 调整堆空间的大小](#8.2.1 调整堆空间的大小)
      • [8.2.2 虚拟机栈的设置](#8.2.2 虚拟机栈的设置)
      • [8.2.3 年轻代中 Eden 区和两个 Survivor 区的大小比例](#8.2.3 年轻代中 Eden 区和两个 Survivor 区的大小比例)
      • [8.2.4 年轻代晋升老年代的阈值](#8.2.4 年轻代晋升老年代的阈值)
      • [8.2.5 设置垃圾回收收集器](#8.2.5 设置垃圾回收收集器)
    • [8.3 JVM 调优的工具](#8.3 JVM 调优的工具)
      • [8.3.1 jps](#8.3.1 jps)
      • [8.3.2 jstack](#8.3.2 jstack)
      • [8.3.3 jmap](#8.3.3 jmap)
      • [8.3.4 jstat](#8.3.4 jstat)
      • [8.3.5 jconsole](#8.3.5 jconsole)
      • [8.3.6 VisualVM](#8.3.6 VisualVM)
      • [8.3.7 VisualVM 汉化](#8.3.7 VisualVM 汉化)
    • [8.4 内存泄漏的排查思路](#8.4 内存泄漏的排查思路)
      • [8.4.1 使用 jmap 命令获取运行中程序的 dump 文件](#8.4.1 使用 jmap 命令获取运行中程序的 dump 文件)
      • [8.4.2 使用 vm 参数获取 dump 文件](#8.4.2 使用 vm 参数获取 dump 文件)
    • [8.5 CPU 飚高的排查方案与解决思路](#8.5 CPU 飚高的排查方案与解决思路)
    • [8.6 解决 CPU 飚高问题的简单案例](#8.6 解决 CPU 飚高问题的简单案例)

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代码时都有自己独立的程序计数器

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

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

}

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

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

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

然后输入以下指令

java 复制代码
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


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

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

java 复制代码
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)的大小是没有上限的

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

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

第二步:添加虚拟机选项

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

java 复制代码
-XX:MaxMetaspaceSize=8m

第三步:测试

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

java 复制代码
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 的程序](#打印 Hello World 的程序) 为例来了解常量池,运行以下指令

java 复制代码
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 可能遇到的问题

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

java 复制代码
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 参数来解决该问题:

java 复制代码
--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED

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

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)

java 复制代码
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. 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行

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

java 复制代码
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 项目

shell 复制代码
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:阈值

java 复制代码
-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 进程信息


运行以下代码

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

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

shell 复制代码
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 空间的大小

剩余信息可自行研读


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

shell 复制代码
jmap -dump:format=b,file=F:\Blog\jvm\heap.hprof 22780

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

8.3.4 jstat

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


java 复制代码
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 文件,文件内容如下

bat 复制代码
visualvm.exe --jdkhome %JAVA_HOME%

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

8.4 内存泄漏的排查思路

重点关注OutOfMemoryError:java heap space

排查步骤:

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

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

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

shell 复制代码
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

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

shell 复制代码
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 文件中会自带一个打包插件,无需添加)

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 包

shell 复制代码
nohup java -jar jvm-1.0-SNAPSHOT.jar &

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

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

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

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

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

shell 复制代码
ps H -eo pid,tid,%cpu | grep 1741941

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

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

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

shell 复制代码
printf "%x\n" 1741965

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

shell 复制代码
jstack 1741941

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

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

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


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

shell 复制代码
sudo kill 1741941
相关推荐
测试19986 分钟前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
WaaTong20 分钟前
《重学Java设计模式》之 原型模式
java·设计模式·原型模式
m0_7430484421 分钟前
初识Java EE和Spring Boot
java·java-ee
AskHarries22 分钟前
Java字节码增强库ByteBuddy
java·后端
小灰灰__42 分钟前
IDEA加载通义灵码插件及使用指南
java·ide·intellij-idea
马剑威(威哥爱编程)1 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
夜雨翦春韭1 小时前
Java中的动态代理
java·开发语言·aop·动态代理
程序媛小果1 小时前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
追风林1 小时前
mac m1 docker本地部署canal 监听mysql的binglog日志
java·docker·mac
芒果披萨1 小时前
El表达式和JSTL
java·el