JVM

754 阅读1小时+

1、JVM概述

1.1、什么是JVM

JVM 全称是 Java Virtual Machine,中文译名 Java虚拟机。

  • 虚拟机(Virtual Machine),虚拟计算机。他是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。

  • VMware属于系统虚拟机,完全对物理计算机的仿真,提供一个可运行完整操作系统的平台。程序虚拟机的典型代表是java虚拟机,专门为执行某个计算机程序而设计。

  • JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件。

  • Java技术的核心就是Java虚拟机,因为所有的java程序都要在java虚拟机内部运行。

  • JVM是运行在操作系统之上的,它与硬件没有直接的交互。

    img

1.2、JVM作用

Java虚拟机负责装载字节码到其内部,解释/编译为对应平台上的机器码指令执行,每一条java指令,java虚拟机中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪。

特点:

  1. 一次编译到处运行;
  2. 自动内存管理;
  3. 自动垃圾回收功能;

现在的JVM不仅可以执行java字节码文件,还可以执行其他语言编译后的字节码文件,是一个跨语言平台。如 Scala、Kotlin、Clojure 等,都可以在 JVM 上运行,这些语言在编译后会生成可以在 JVM 上执行的字节码。

1.3、JVM整体组成部分

  1. 类加载器(ClassLoader)

  2. 运行时数据区(Runtime Data Area)

  3. 执行引擎(Execution Engine)

  4. 本地库接口(Native Interface)

    img

    img

程序在执行之前先要把 java 代码转换成字节码(class 文件),jvm 首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中的运行时数据区(Runtime Data Area) ,而字节码文件是 jvm 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU 去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这 4 个主要组成部分的职责与功能。 而我们通常所说的 JVM 组成指的是 运行时数据区(Runtime Data Area) ,因为通常需要程序员调试分析的区域就是“运行时数据区”,或者更具体的来说就是“运行时数据区”里面的 Heap(堆)模块。

2、类加载器

2.1、类加载子系统

img

类加载子系统负责从文件系统或者网络中加载class文件。classLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎(Execution Engine)决定。

加载的类信息存放于一块称为方法区的内存空间。

img

class file 存在于硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载 JVM 当中来,根据这个模板实例化出 n 个实例。 class file 加载到 JVM 中,被称为 DNA 元数据模板。 此过程就要有一个运输工具(类加载器 Class Loader),扮演一个快递员的角色。

2.2、类加载过程

img

2.2.1、加载

  1. 通过类名(地址)获取此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转换为方法区(元空间)的运行时结构。
  3. 在内存中生成一个代表这个类的java.lang.class对象,作为这个类的各种数据的访问入口。

2.2.2、链接

  1. **验证:**检验被加载的类是否有正确的内部结构,并和其他类协调一致;

    验证文件格式是否一致: class 文件在文件开头有特定的文件标识(字节码文件都以 CA FE BA BE 标识开头);主,次版本号是否在当前 java 虚拟机接收范围内.

    **元数据验证:**对字节码描述的信息进行语义分析,以保证其描述的信息符合java 语言规范的要求,例如这个类是否有父类;是否继承浏览不允许被继承的类(final 修饰的类).....

  2. **准备:**准备阶段则负责为类的静态属性分配内存,并设置默认初始值;

    不包含用 final 修饰的 static 常量,在编译时进行初始化.

    例如: public static int value = 123;value 在准备阶段后的初始值是 0,而不是 123.

  3. **解析:**将类的二进制数据中的符号引用替换成直接引用(符号引用是 Class 文件的逻辑符号(变量名、方法名等),直接引用指向的方法区中某一个地址)

2.2.3、初始化

初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。初始化阶段就是执行底层类构造器方法()的过程。此方法不需要定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来的。

类什么时候初始化:

JVM规定:每个类或者接口被首次主动使用时才对其进行初始化。

  • 通过 new 关键字创建对象
  • 访问类的静态变量,包括读取和更新
  • 访问类的静态方法
  • 对某个类进行反射操作
  • 初始化子类会导致父类的的初始化
  • 执行该类的 main 函数

除了以上几种主动使用,以下情况被动使用,不会加载类:

  • 引用该类的静态常量,注意是常量,不会导致初始化,但是也有意外,这里的常量是指已经指定字面量的常量,对于那些需要一些计算才能得出结果的常量就会导致类加载,比如:

    public final static int NUMBER = 5 ; //不会导致类初始化,被动使用

    public final static int RANDOM = new Random().nextInt() ; //会导致类加载

  • 构造某个类的数组时不会导致该类的初始化,比如:

    Student[] students = new Student[10] ;

类的初始化顺序:

对static修饰的变量或语句块进行赋值: 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。 顺序是:父类 static –> 子类 static

public class ClassInit{
    static{
        num = 20;
    }
    static int num = 10;
    public static void main (String[] args) {
        //num从准备到初始化值变化过程 num=0 -> num=20 -> num=10
        System.out.println(num);//10
    }
}
 
 
public class ClassInit{
    static int num = 10;
    static{
        num = 20;
    }
    public static void main (String[] args) {
        //num从准备到初始化值变化过程 num=0 -> num=10 -> num=20
        System.out.println(num);//20
    }
}

2.3、类加载器的分类

站在JVM的角度看,类加载器可以分为两种:

  • 引导类加载器(启动类加载器 Bootstrap ClassLoader).
  • 其他所有类加载器,这些类加载器由 java 语言实现,独立存在于虚拟机外部,并 且全部继承自抽象类 java.lang.ClassLoader.

站在 java 开发人员的角度来看,类加载器就应当划分得更细致一些.自 JDK1.2 以来 java 一直保持者三层类加载器:

img

2.3.1、引导类加载器(启动类加载器 BootStrap ClassLoader)

  • 这个类加载器使用 C/C++语言实现,嵌套在 JVM 内部。它用来加载 java 核心类库。并不继承于 java.lang.ClassLoader, 没有父加载器。
  • 负责加载扩展类加载器和应用类加载器,并为他们指定父类加载器。
  • 出于安全考虑,引用类加载器只加载存放在<JAVA_HOME>\lib 目录,或者被-Xbootclasspath 参数锁指定的路径中存储放的类。

2.3.2、扩展类加载器(Extension ClassLoader)

  • Java 语言编写的,由 sun.misc.Launcher$ExtClassLoader 实现。
  • 派生于 ClassLoader 类。
  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 系统安装目录的jre/lib/ext 子目录(扩展目录)下加载类库。如果用户创建的 jar 放在此目录下,也会自动由扩展类加载器加载。

2.3.3、应用程序类加载器(系统类加载器 Application ClassLoader)

  • Java 语言编写的,由 sun.misc.Launcher$AppClassLoader 实现。

  • 派生于 ClassLoader 类。

  • 加载我们自己定义的类,用于加载用户类路径(classpath)上所有的类。

  • 该类加载器是程序中默认的类加载器。

  • ClassLoader类 ,它是一个抽象类, 其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)。

2.4、双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要该类时才会将它的 class 文件加载到内存中生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

img

工作原理:

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请 求委托给父类的加载器去执行。

  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。

  • 如果父类加载器可以完成类的加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制。

  • 如果均加载失败,就会抛出 ClassNotFoundException 异常。

代码示例:

package java.lang; //此包为自己创建的java.lang包

public class String {
    public static void main(String[] args) {
        System.out.println("hello");
    }
}

运行结果:

image-20250122135335490

双亲委派优点:

  1. 安全,可避免用户自己编写的类替换 Java 的核心类,如 java.lang.String.
  2. 避免类重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次

2.5、如何打破双亲委派机制

  • Java 虚拟机的类加载器本身可以满足加载的要求,但是也允许开发者自定义类加载器。
  • ClassLoader 类中涉及类加载的方法有两个,loadClass(String name)findClass(String name),这两个方法并没有被 final 修饰,也就表示其他子类可以重写。
    • 重写 loadClass 方法(是实现双亲委派逻辑的地方,修改他会破坏双亲委派机制, 不推荐)
    • 重写 findClass 方法 (推荐

我们可以通过自定义类加载重写方法打破双亲委派机制, 再例如 tomcat 等都有自己定义的类加载器.

3、JVM运行时数据区

3.1、运行时数据区组成

JVM 的运行时数据区,不同虚拟机实现可能略微有所不同,但都会遵从 Java 虚拟机规范,Java 8 虚拟机规范规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域:

  • 程序计数器(Program Counter Register): 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
  • **Java 虚拟机栈(Java Virtual Machine Stacks):**描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个线帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程。
  • 本地方法栈(Native Method Stack): 与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的。
  • Java 堆(Java Heap): 是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java 堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • **方法区(Methed Area):**用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。方法区是很重要的系统资源,是硬盘和 CPU 的中间桥梁,承载着操作系统和应用程序的实时运行。

JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异,以最为流行的 HotSpot 虚拟机为例:

img

Java 虚拟机定义了程序运行期间会使用到的运行数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁.另外一些则是与线程一 一对应的。 这些与线程对应的区域会随着线程开始和结束而创建销毁。

如图: 红色的为多个线程共享,灰色的为单个线程私有的,即 :

  • 线程间共享:堆、方法区
  • 线程私有:程序计数器、栈、本地方法栈

img

3.2、程序计数器(Program Counter Register)

JVM 中的程序计数寄存器(Program Counter Register)这里翻译为程序计数器更容易理解。

程序计数器用来存储下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

img

  • 它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。

  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程生命周期保持一致。

  • 程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址。

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 它是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

img

img

3.3、Java 虚拟机栈(Java Virtual Machine Stacks)

3.3.1、栈的基本概念

栈是运行时的单位,即栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次方法的调用。Java 虚拟机栈是线程私有的,主管 Java 程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

img

3.3.2、栈的特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
  • JVM 直接对 java 栈的操作只有两个:调用方法 入栈。执行结束后 出栈
  • 对于栈来说不存在垃圾回收问题。

img

栈中会出现异常,当线程请求的栈深度大于虚拟机所允许的深度时 , 会出现StackOverflowError。

3.3.3、栈的运行原理

  • JVM 直接对 java 栈的操作只有两个,就是对栈帧的入栈和出栈,遵循先进后出/后进先出的原则。

  • 在一条活动的线程中,一个时间点上,只会有一个活动栈。即只有当前在执行的方法的栈帧(栈顶)是有效地,这个栈帧被称为当前栈(Current Frame),与当前栈帧对应的方法称为当前方法(CurrentMethod),定义这个方法的类称为当前类(Current Class)。

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

  • 如果在该方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为新的当前栈帧。

    img

  • 不同线程中所包含的栈帧(方法)是不允许存在相互引用的,即不可能在一个栈中引用另一个线程的栈帧(方法)。

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

  • Java 方法有两种返回的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常。不管哪种方式,都会导致栈帧被弹出。

3.3.4、栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)

    局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。

  • 操作数栈(Operand Stack)(或表达式栈)

    栈最典型的一个应用就是用来对表达式求值。在一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

  • 动态链接(Dynamic Linking) (或指向运行时常量池的方法引用)

    因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

  • 方法返回地址(Retuen Address)(或方法正常退出或者异常退出的定义)

    当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

img

3.4、本地方法栈(Native Method Stack)

  • Java 虚拟机栈管理 java 方法的调用,而本地方法栈用于管理本地方法的调用。

  • 本地方法栈也是线程私有的。

  • 允许被实现成固定或者是可动态扩展的内存大小。内存溢出方面也是相同的。

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量抛出 StackOverflowError。本地方法是用 C 语言写的。

  • 它的具体做法是在 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。

3.5、Java 堆内存

3.5.1、Java堆内存概述

img

  • 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。

  • Java 堆区在 JVM 启动时的时候即被创建,其空间大小也就确定了,是 JVM 管理的最大一块内存空间。

  • 堆内存的大小是可以调节。

    • 例如: -Xms:10m(堆起始大小) -Xmx:30m(堆最大内存大小)
    • 一般情况可以将起始值和最大值设置为一致,这样会减少垃圾回收之后堆内存重新分配大小的次数,提高效率。
  • 《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但逻辑上它应该被视为连续的。

  • 所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区。

  • 《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例都应当在运行时分配在堆上。

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

3.5.2、堆内存区域划分

Java8 及之后堆内存分为 :

  • 新生区(新生代)+老年区(老年代)
  • 新生区分为 Eden(伊甸园)区和 Survivor(幸存者)区

img

3.5.3、为什么分区(代)?

将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及 GC 频率。

针对分类进行不同的垃圾回收算法,对算法扬长避短。

img

3.5.4、对象创建内存分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配,在哪分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new 的新对象先放到伊甸园区,此区大小有限制。

  2. 当伊甸园的空间填满时,程序又需要创建对象时,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被引用的对象进行销毁,再加载新的对象放到伊甸园区。

  3. 然后将伊甸园区中的剩余对象移动到幸存者 0 区。

  4. 如果再次出发垃圾回收,此时上次幸存下来存放到幸存者 0 区的对象,如果没有回收,就会被放到幸存者 1 区,并且它们的年龄会增加 1,每次会保证有一个幸存者区是空的。

  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。

  6. 什么时候去养老区呢?默认是 15 次,也可以设置参数,最大值为 15

    -XX:MaxTenuringThreshold=

    在对象头中,它是由 4 位数据来对 GC 年龄进行保存的,所以最大值为 1111,即为15。所以在对象的 GC 年龄达到 15 时,就会从新生代转到老年代。

  7. 在老年区,相对悠闲,当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理。

  8. 若养老区执行了 Major GC 之后发现依然无法进行对象保存,就会产生 OOM 异常.。Java.lang.OutOfMemoryError:Java heap space

    例如:

    public static void main(String[] args) {
        List<Integer> list = new ArrayList();
        while(true){
            list.add(new Random().nextInt());
        }
    }
    

    img

3.5.5、新生区与老年区配置比例

配置新生代与老年代在堆结构的占比(一般不会调)

  • 默认**-XX:NewRatio**=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3

  • 可以修改**-XX:NewRatio**=4,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5

  • 当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整老年代的大小,来进行调优。

    img

  • 在 HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8 : 1 :1,当然开发人员可以通过选项**-XX:SurvivorRatio**调整这个空间比例。比如-XX:SurvivorRatio=8,新生区的对象默认生命周期超过 15 ,就会去养老区养老。

3.5.6、 分代收集思想 Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都新生区和老年区一起回收的,大部分时候回收的都是指新生区。针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类型:

  • 部分收集:不是完整收集整个 java 堆的垃圾收集。其中又分为:
    • 新生区收集(Minor GC/Yong GC):只是新生区(Eden,S0,S1)的垃圾收集。
    • 老年区收集(Major GC / Old GC):只是老年区的垃圾收集。
  • 整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集。整堆收集出现的情况:
  • System.gc()
  • 老年区空间不足
  • 方法区空间不足
  • 开发期间尽量避免整堆收集

3.5.7、堆空间的参数设置

官网地址

参数解释
-XX:+PrintFlagsInitial查看所有参数的默认初始值
-XX:+PrintFlagsFinal查看所有参数的最终值(修改后的值)
-Xms初始堆空间内存(默认为物理内存的 1/64)
-Xmx最大堆空间内存(默认为物理内存的 1/4)
-Xmn设置新生代的大小(初始值及最大值)
-XX:NewRatio配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio设置新生代中 Eden 和 S0/S1 空间比例
-XX:MaxTenuringTreshold设置新生代垃圾的最大年龄
XX:+PrintGCDetails输出详细的 GC 处理日志

3.5.8、字符串常量池

字符串常量池为什么要调整位置?

  • JDK7 及以后的版本中将字符串常量池放到了堆空间中。因为方法区的回收效率很低,在 Full GC 的时候才会执行永久代的垃圾回收,而 Full GC 是老年代的空间不足、方法区不足时才会触发。
  • 这就导致字符串常量池回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
public static void main(String[] args) {
    String temp = "world";
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        String str = temp + temp;
        temp = str;
        str.intern();//将字符串存储到字符串常量池中
    }
}

3.6、方法区

3.6.1、 方法区的基本理解

方法区,是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据、static final 常量、static 变量、即时编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域 “运行时常量池”。

Java 虚拟机规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但对HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。所以,方法区看做是一块独立于 java 堆的内存空间。

img

  • 方法区在 JVM 启动时被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出的错误。
  • 关闭 JVM 就会释放这个区域的内存。

3.3.2、栈+堆+方法区的交互关系

img

3.6.3、方法区大小设置

Java 方法区的大小不必是固定的,JVM 可以根据应用的需要动态调整。

元数据区大小可以使用参数:

  • -XX:MetaspaceSize :该参数用于设置元空间的初始大小。默认值依赖于平台,windows 下,-XXMetaspaceSize 是 21MB。一旦触及就会触发 Full GC。因此为了减少 FullGC 那么这个-XX:MetaspaceSize 可以设置一个较高的值。
  • -XX:MaxMataspaceSize: 此参数用于设置元空间的最大大小。-XX:MaxMetaspaceSize 的值是-1,即没有限制。

3.6.4、方法区的内部结构

img

方法区它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存,运行常量池等。

运行常量池就是一张表,虚拟机指令根据这张表,找到要执行的类名、方法名、参数类型、字面量(常量)等信息,存放编译期间生成的各种字面量(常量)和符号引用。

通过反编译字节码文件查看:

反编译字节码文件,并输出值文本文件中,便于查看。参数 -p 确保能查看private 权限类型的字段或方法:

 javap -v -p Demo.class > test.txt

3.6.5、方法区的垃圾回收

  • 有些人认为方法区(如 Hotspot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。

  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。

方法区的垃圾收集主要回收两部分内容:运行时常量池中废弃的常量和不再使用的类型。

类卸载

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

4、本地方法接口

img

4.1、什么是本地方法

简单来讲, 一个 Native Method 就是一个 java 调用非 java 代码的接口 ,一个Native Method 是这样一个 java 方法:该方法的底层实现由非 Java 语言实现,比如 C。这个特征并非 java 特有,很多其他的编程语言都有这一机制在定义一个 native method 时,并不提供实现体(有些像定义一个 Java interface),因为其实现体是由非 java 语言在外面实现的。

4.2、为什么要使用 Native Method

Java 使用起来非常方便,然而有些层次的任务用 java 实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  • 与 java 环境外交互。

    有时 java 应用需要与 java 外面的环境交互,这是本地方法存在的主要原因。 你可以想想 java 需要与一些底层系统,如某些硬件交换信息时的情况。本地方法正式这样的一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解 java 应用之外的繁琐细节。

  • Sun 的解释器是用 C 实现的,这使得它能像一些普通的 C 一样与外部交互。

    jre大部分是用 java 实现的,它也通过一些本地方法与外界交互。例如:类 java.lang.Thread 的 setPriority()方法是用 Java 实现的,但是它实现调用的是该类里的本地方法 setPriority0()。

4.3、native关键字

  • native 关键字用于声明一个方法是由本地代码(非 Java 代码,通常是 C 或 C++ 代码)实现的。这意味着该方法的实现不在 Java 类中,而是在外部的本地库中。
  • 当 Java 程序调用 native 方法时,它实际上会调用一个由本地代码编写的函数。

示例:

public class NativeExample {
    // 声明一个 native 方法
    public native void nativeMethod();

    static {
        // 加载包含 native 方法实现的本地库
        System.loadLibrary("NativeLibrary");
    }

    public static void main(String[] args) {
        NativeExample example = new NativeExample();
        example.nativeMethod();
    }
}

在上述代码中:

  • public native void nativeMethod(); 声明了一个 native 方法,它没有方法体,因为其实现将由本地代码提供。
  • static { System.loadLibrary("NativeLibrary"); } 是一个静态代码块,用于加载一个名为 NativeLibrary 的本地库。这里的 NativeLibrary 是库的名称,通常在不同的操作系统上会有不同的文件扩展名(如 .dll 表示 Windows 上的动态链接库,.so 表示 Linux 上的共享库,.dylib 表示 macOS 上的动态库)

5、执行引擎

5.1、概述

  • 执行引擎是 Java 虚拟机核心的组成部分之一。

  • JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。

  • 那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

注意区分概念:

  1. 前端编译:从 Java 程序员-字节码文件的这个过程叫前端编译.
  2. 执行引擎这里有两种行为:一种是解释执行,一种是编译执行(这里的是后端编译)。

5.2、什么是解释器?什么是 JIT 编译器?

  • 解释器: 当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • JIT(Just In Time Compiler)编译器: 就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。

5.3、为什么 Java 是半编译半解释型语言?

起初将 Java 语言定位为“解释执行”还是比较准确的。再后来,Java 也发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

原因:

  • JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
  • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。
  • JIT 编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的 JIT 代码缓存中(执行效率更高了)。
  • 是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。
  • JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。
  • 一个被多次调用的方法,或者是一-个方法体内部循环次数较多的循环体都可以被称之为“热点代码”。

目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测。

JIT 编译器执行效率高为什么还需要解释器?

  1. 当程序启动后,解释器可以马上发挥作用,响应速度快,省去编译的时间,立即执行。
  2. 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高。就需要采用解释器与即时编译器并存的架构来换取一个平衡点。

6、GC垃圾回收

6.1、什么是垃圾

  • 垃圾是指在运行程序中没有任何引用指向的对象,这个对象就是需要被回收的垃圾。
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出

6.2、为什么需要 GC?

  • 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象。

6.3、Java 垃圾回收机制

6.3.1、自动内存管理

自动内存管理的优点:

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样 降低内存泄漏 内存溢出的风险
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

6.3.2、关于自动内存管理的担忧

  1. 对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
  2. 此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutofMemoryError 时,快速地根据错误异常日志定位问题和解决问题。
  3. 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节 。

6.3.3、应该关心哪些区域的回收?

img

垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中,Java 堆是垃圾收集器的工作重点。

从次数上讲:

  • 频繁收集 Young 区
  • 较少收集 Old 区
  • 基本不收集元空间(方法区)

6.4、垃圾标记阶段算法

6.4.1、标记阶段的目的

垃圾标记阶段:主要是为了判断对象是否是垃圾对象

  1. 在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是有用对象,哪些是垃圾对象。只有被标记为己经是垃圾对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。

  2. 那么在 JVM 中究竟是如何标记一个垃圾对象呢?简单来说,当一个对象已经不再被任何引用指向时,就可以宣判为垃圾对象。

  3. 判断对象是否为垃圾对象一般有两种方式:引用计数算法和可达性分析算法。

6.4.2、引用计数算法

  1. 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

  2. 对于一个对象 A,只要有任何一个引用指向了对象 A,则对象 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

    • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
    • 缺点:
      • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
      • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
      • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java 的垃圾回收器中没有使用这类算法。

6.4.3、可达性分析算法

可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集

  1. 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
  2. 相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

可达性分析实现思路:

所谓"GCRoots”根就是一组必须活跃的引用,其基本思路如下:

  1. 可达性分析算法是以根(GCRoots)为起始点,按照从上至下的方式搜索被根对象所连接的目标对象是否可达。
  2. 使用可达性分析算法后,内存中的存活对象都会被根直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  3. 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。

img

GC Roots 可以是哪些元素?

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

    比如:各个线程被调用的方法中使用到的参数、局部变量等。

  2. 方法区中类静态属性引用的对象

    比如:Java 类的引用类型静态变量

  3. 所有被同步锁 synchronized 持有的对象

  4. Java 虚拟机内部的引用。

    基本数据类型对应的Class对象,一些常驻的异常对象( 如 : NullPointerException、OutofMemoryError),系统类加载器。

6.4.4、对象的 finalization 机制

finalize() 方法机制:

  • 对象销毁前的回调方法:finalize();
  • Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
  • 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize()方法,一个对象的 finalize()方法只被调用一次。
  • finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

Object 类中 finalize() 源码:

protected void finalize() throws Throwable { }

永远不要主动调用某个对象的 finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize()时可能会导致对象复活。
  • finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize()方法将没有执行机会。
  • 一个糟糕的 finalize()会严重影响 GC 的性能。比如 finalize 是个死循环。

6.4.5、生存还是死亡?

由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它立即进行回收就是不合理的。

为此,定义虚拟机中的对象可能的三种状态 。如下:

  • **可触及的:**从根节点开始,可以到达这个对象。
  • **可复活的:**对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
  • **不可触及的:**对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。

以上 3 种状态中,是由于 finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

具体过程:

  • 判定一个对象 objA 是否可回收,至少要经历两次标记过程:
    1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
    2. 进行筛选,判断此对象是否有必要执行 finalize()方法:
  • 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
  • 如果对象 objA 重写了 finalize()方法,且还未执行过,那么 objA 会被插入到队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其finalize()方法执行。
  • finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对队列中的对象进行第二次标记。如果 objA 在 finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态。

6.5、垃圾回收阶段算法

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在 JVM 中比较常见的三种垃圾收集算法是:

  • 标记-复制算法(Copying)
  • 标记-清除算法(Mark-Sweep)
  • 标记-压缩算法(Mark-Compact)

6.5.1、标记-复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

img

复制算法的优缺点:

优点:

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。

复制算法的应用场景

  • 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,效率较高
  • 老年代大量的对象存活,那么复制的对象将会有很多,效率会很低
  • 在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

6.5.2、标记-清除算法

img

  • 回收时,对需要存活的对象进行标记;

  • 回收不需要存活的对象。

  • 当堆中的有效内存空间被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:从引用根节点开始标记所有被引用的对象,标记的过程其实就是遍历所有的GC Roots ,然后将所有GC Roots可达的对象,标记为存活的对象。

  • 清除: 遍历整个堆,把未标记的对象清除。

  • 用通俗的话解释一下标记/清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。

  • 缺点:这个算法需要暂停整个应用,会产生内存碎片。两次扫描,严重浪费时间。

6.5.3、标记-压缩算法

应用背景:

  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。 如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

  • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进

标记压缩算法执行过程:

  • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。

img

标记-压缩算法与标记-清除算法的比较:

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。

  • 二者的本质差异在于标记-清除算法是一种非移动式的回收算法(空闲列表记录位置),标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。

  • 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

标记-压缩算法的优缺点:

优点

  1. 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  2. 消除了复制算法当中,内存减半的高额代价。

缺点

  1. 从效率上来说,标记-压缩算法要低于复制算法。
  2. 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  3. 移动过程中,需要全程暂停用户应用程序。即:STW

6.5.4、垃圾回收算法小结

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。而为了尽量兼顾上面提到的三个指标,标记-压缩算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。

标记清除标记压缩复制
速率中等最慢最快
空间开销少(会堆积碎片)少(无堆积碎片)通常需要活动对象的两倍空间(无堆积碎片)
移动对象

6.5.5、分代收集

为什么要使用分代收集:

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集应运而生。

分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关:比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

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

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

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

  • Mark 阶段的开销与存活对象的数量成正比。
  • Sweep 阶段的开销与所管理区域的大小成正相关。
  • Compact 阶段的开销与存活对象的数据成正比。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

6.5、垃圾回收相关概念

6.5.1、System.gc()

  • 在默认情况下,通过 System.gc()者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
  • 然而 System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用(不能确保立即生效)。
  • JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,我们可以在运行之间调用 System.gc()。

6.5.2、内存溢出与内存泄漏

6.5.2.1、内存溢出
  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
  • 由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。
  • 大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。
  • Javadoc 中对 OutofMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
6.5.2.2、内存泄漏
  • 内存泄漏也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏。
  • 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 OOM,也可以叫做宽泛意义上的“内存泄漏”。
  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutofMemory 异常,导致程序崩溃
  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

常见例子:

  • **单例模式:**单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
  • **一些提供 close()的资源未关闭导致内存泄漏:**数据库连接 dataSourse.getConnection(),网络连接 socketio 连接必须手动 close,否则是不能被回收的。

6.5.3、Stop the World

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿,为什么需要停顿所有 Java 执行线程呢?

  1. 分析工作必须在一个能确保一致性的快照中进行

  2. 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上

  3. 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证,会出现漏标,错标问题

  4. 被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

  5. 越优秀,回收效率越来越高,尽可能地缩短了暂停时间

STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

6.6、垃圾回收器

6.6.1、概述

如果说垃圾收集算法是内存回收的方法论,那么收集器就是内存回收的实践者。

垃圾收集器没有在 java 虚拟机规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。

由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的垃圾回收器。从不同角度分析垃圾收集器,可以将GC分为不同的类型。

实际使用时,可以根据实际的使用场景选择不同的垃圾回收器,这也是 JVM 调优的重要部分。

6.6.2、HotSpot虚拟机的垃圾收集器

图中展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。

img

6.6.3、GC 性能指标

吞吐量:

  • 定义:指应用程序在运行过程中,实际用于执行用户代码的时间与总运行时间的比值。总运行时间包括执行用户代码时间和 GC 时间。
  • 公式:吞吐量 = (总运行时间 - GC 时间)/ 总运行时间。例如,程序运行总时长为 100 秒,其中 GC 花费了 10 秒,那么吞吐量就是(100 - 10)/100 = 90%。
  • 意义:较高的吞吐量意味着应用程序有更多的时间用于执行实际业务逻辑,能处理更多的任务,系统的整体性能和处理能力更强。

暂停时间:

  • 定义:也叫停顿时间,是指在垃圾回收过程中,应用程序需要暂停运行的时间。这是由于 GC 需要在一个相对稳定的内存状态下进行工作,可能会导致所有应用线程被暂停。
  • 分类
    • Minor GC 暂停时间:发生在新生代的垃圾回收暂停时间,一般较短。
    • Major GC/Full GC 暂停时间:发生在老年代或整个堆的垃圾回收暂停时间,通常比 Minor GC 暂停时间长。
  • 意义:暂停时间越短,对用户体验和系统的实时性影响就越小。对于一些对响应时间要求极高的系统,如金融交易系统、实时游戏等,低暂停时间尤为重要。

内存占用:

  • 定义:主要包括堆内存和非堆内存的使用情况。堆内存用于存储对象实例,非堆内存用于存储方法区、JVM 内部数据结构等。
  • 指标
    • 最大堆内存:JVM 能够使用的最大堆内存空间。
    • 当前堆内存使用量:应用程序当前实际使用的堆内存大小。
    • 内存峰值:应用程序在运行过程中曾经达到过的最大内存使用量。
  • 意义:合理的内存占用可以确保系统稳定运行,避免内存泄漏和内存溢出等问题。同时,较小的内存占用可以降低硬件成本,提高资源利用率。

垃圾回收频率:

  • 定义:指在单位时间内垃圾回收发生的次数。
  • 意义:垃圾回收频率过高,说明可能存在内存分配不合理、对象生命周期管理不当等问题,会导致过多的时间花费在垃圾回收上,影响吞吐量;频率过低,则可能导致内存占用持续上升,甚至出现内存溢出。

内存碎片:

  • 定义:在内存分配和回收过程中,由于对象的大小和生命周期不同,可能会导致内存空间出现不连续的空闲区域,这些小的空闲区域就是内存碎片。
  • 指标
    • 内部碎片:指分配给对象的内存空间大于对象实际所需的空间,多余的部分就是内部碎片。
    • 外部碎片:指内存中存在许多分散的、较小的空闲区域,无法满足较大对象的分配需求。
  • 意义:内存碎片过多会导致内存利用率降低,增加内存分配的时间开销,甚至可能导致提前触发垃圾回收或内存分配失败。

引用处理时间:

  • 定义:指垃圾回收器处理对象引用的时间,包括对强引用、软引用、弱引用和虚引用的处理。
  • 意义:引用处理时间过长可能意味着引用关系复杂或垃圾回收器在处理引用时存在性能瓶颈,会影响垃圾回收的整体效率。

回收效率:

  • 定义:指垃圾回收器在一次回收过程中,能够回收的垃圾对象数量与总垃圾对象数量的比值。
  • 意义:回收效率高说明垃圾回收器能够有效地识别和回收不再使用的对象,释放内存空间,有助于提高内存的利用率和系统的性能。

6.6.4、单线程垃圾回收器(Serial

采用单线程进行垃圾回收,在进行垃圾回收时,会暂停所有的用户线程,直到垃圾回收完成。它采用复制算法,将内存分为两块,每次只使用其中一块,当这块内存满了,就将存活的对象复制到另一块内存,然后清除原来的内存空间。

img

6.6.5、多线程垃圾回收器(Parallel

也被称为吞吐量优先的垃圾回收器,它采用多线程并行的方式进行垃圾回收,可以利用多个 CPU 核心来加快垃圾回收速度。同样采用复制算法,在新生代进行垃圾回收时,会有多个线程同时进行对象的复制等操作,但同样也是会暂停其他用户线程。

img

6.6.6、CMS 回收器

6.6.6.1、CMS 概述

**CMS(Concurrent Mark Sweep,并发标记清除)**收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

6.6.6.2、垃圾回收过程
  • **初始标记:**Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
  • **并发标记:**垃圾回收线程,与用户线程并发执行。此过程进行可达性分析,标记出所有废弃对象。
  • **重新标记:**Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。
  • **并发清除:**只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。 这个过程非常耗时。

并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

img

**CMS 的优点:**可以作到并发收集

CMS 的弊端:

  1. CMS 是基于标记-清除算法来实现的,会产生内存碎片。
  2. CMS 在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  3. CMS 收集器无法处理浮动垃圾(floating garbage)。
6.6.6.3、三色标记算法

为了提高 JVM 垃圾回收的性能,从 CMS 垃圾收集器开始,引入了并发标记的概念。引入并发标记的过程就会带来一个问题,在业务执行的过程中,会对现有的引用关系链出现改变。

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。

  • 白色:表示对象尚未被垃圾收集器访问过,在初始阶段,所有对象都被标记为白色。
  • 灰色:表示对象已经被垃圾收集器访问过,但它的所有引用还没有被完全处理完,即它的子节点可能还存在未被访问的对象。
  • 黑色:表示对象已经被垃圾收集器访问过,并且它的所有引用也都已经被处理完,即它的所有子节点都已经被访问过。

算法执行过程:

  1. 初始标记:从根节点(如全局变量、栈中的引用等)开始,将直接可达的对象标记为灰色,这个阶段会暂停整个应用程序,速度很快。
  2. 并发标记:与应用程序并发执行,从灰色对象开始,递归地访问其引用的对象,将未访问过的对象标记为灰色,然后将当前灰色对象标记为黑色。在这个过程中,可能会有新的对象被分配内存,这些新对象默认是白色。
  3. 最终标记:为了处理并发标记阶段可能出现的漏标问题,会再次暂停应用程序,进行短暂的重新标记,修正可能存在的标记错误。
  4. 清除阶段:将所有白色对象视为垃圾进行清除,回收它们占用的内存空间。同时,将黑色对象重新标记为白色,为下一次垃圾收集做准备。

可能出现的问题及解决:

  • 对象漏标问题:在并发标记过程中,如果一个黑色对象的引用指向了一个白色对象,同时这个白色对象又没有其他灰色或黑色对象引用它,那么就可能导致这个白色对象被漏标,被错误地认为是垃圾。
  • 解决方法
    • 增量更新:当黑色对象引用了白色对象时,就将这个引用记录下来,在最终标记阶段再对这些记录的引用进行重新标记,确保白色对象不会被漏标。
    • 原始快照:在标记开始时,对所有对象的引用关系进行一次快照,在并发标记过程中,如果发现有对象的引用关系发生了变化,且变化后的对象是白色的,就将其标记为灰色,保证其不会被漏标。

优点:

  • 效率较高:三色标记算法可以与应用程序并发执行,减少了垃圾收集过程中对应用程序的暂停时间,提高了系统的响应速度和吞吐量。
  • 准确性较好:通过严格的标记过程和解决漏标问题的机制,能够较为准确地识别出垃圾对象和可达对象,保证了内存回收的准确性。

缺点:

  • 实现复杂:需要处理并发标记过程中的各种问题,如对象漏标、读写屏障的维护等,实现起来相对复杂。
  • 内存开销较大:在标记过程中,需要为每个对象维护颜色标记信息,并且可能需要记录一些引用关系等数据,会增加一定的内存开销。

6.6.7、G1(Garbage First)回收器

6.6.7.1、概述

G1(Garbage - First)垃圾回收器是 Java 7 Update 4 及以上版本引入的一款面向服务器端应用的垃圾回收器,旨在满足大内存、多处理器环境下的高效垃圾回收需求,适用于需要低延迟和高吞吐量的应用。

6.6.7.2、关键区域
  • Region(区域):G1 打破了传统的分代内存布局,将整个堆划分为多个大小相等的独立区域(Region)。每个 Region 可以是 Eden 区、Survivor 区、老年代或者 Humongous 区。其中,Humongous 区专门用于存储大对象,如果对象大小超过单个 Region 大小的一半,就会被放入 Humongous 区。
  • Remembered Sets(记忆集):每个 Region 都有一个 Remembered Set,用于记录从其他 Region 指向本 Region 中对象的引用。这样在进行垃圾回收时,就不需要扫描整个堆来确定对象的引用关系,从而提高了回收效率。
  • Collection Sets(收集集):在一次垃圾回收过程中,G1 会根据垃圾回收的目标和各个 Region 的垃圾回收价值,动态地选择一些 Region 组成 Collection Set,对这些 Region 进行回收。
6.6.7.3、回收过程

G1 的回收过程主要分为以下几个阶段:

  1. 初始标记(Initial Mark):该阶段标记与根对象直接关联的对象,需要暂停用户线程(Stop - The - World,STW),但暂停时间很短。
  2. 并发标记(Concurrent Marking):G1 垃圾回收线程与用户线程并发执行,从根对象开始遍历整个堆,标记出所有存活的对象。这个阶段不会导致应用程序停顿。
  3. 最终标记(Final Marking):完成并发标记阶段后,仍可能存在一些未被标记的存活对象。此阶段会暂停用户线程,处理并发标记阶段遗留的少量标记任务,更新 Remembered Sets。
  4. 筛选回收(Live Data Counting and Evacuation):对各个 Region 的存活对象进行统计,根据用户期望的停顿时间,选择收益最大的 Region 组成 Collection Set 进行回收。此阶段需要暂停用户线程,使用复制算法将存活对象复制到其他空闲 Region,然后清理这些被回收的 Region。
6.6.7.4、优缺点

优点:

  • 可预测的停顿时间:G1 可以通过 -XX:MaxGCPauseMillis 参数指定期望的最大垃圾回收停顿时间,它会根据这个目标动态地调整回收策略和选择回收的 Region,避免了长时间的停顿。
  • 高效利用内存:G1 采用 Region 机制和复制算法,减少了内存碎片的产生,提高了内存利用率。
  • 整体吞吐量较高:在大内存环境下,G1 能够并行处理垃圾回收任务,充分利用多核处理器的优势,保证了较高的整体吞吐量。

缺点:

  • 内存占用:Remembered Sets 和其他元数据的维护需要额外的内存空间,这可能会增加内存的开销。
  • 算法复杂度高:由于 G1 采用了复杂的算法和数据结构来管理和回收内存,其实现复杂度较高,在某些情况下可能会导致性能波动。
6.6.7.5、使用场景
  • 大内存应用:对于堆内存较大(如超过 4GB)的应用程序,G1 能够更好地管理和回收内存,避免传统垃圾回收器在大内存场景下的性能问题。
  • 对延迟敏感的应用:适用于需要快速响应、低延迟的应用,如 Web 服务器、实时数据处理系统等,因为 G1 可以控制垃圾回收的停顿时间,减少对应用程序响应时间的影响。

可以通过以下 JVM 参数来启用 G1 回收器:

java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar YourApp.jar

上述命令中,-XX:+UseG1GC 用于启用 G1 垃圾回收器,-XX:MaxGCPauseMillis=200 表示期望的最大垃圾回收停顿时间为 200 毫秒。

6.6.8、查看 JVM 垃圾回收器、设置垃圾回收器

6.6.8.1、查看 JVM 垃圾回收器
java -XX:+PrintCommandLineFlags -version

此命令会输出 JVM 启动时使用的所有命令行标志,其中包含了当前所使用的垃圾回收器信息。

输出示例:

-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=13 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=526865216 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8429843456 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC -XX:-UseLargePagesIndividualAllocation
java version "17.0.9" 2023-10-17 LTS
Java(TM) SE Runtime Environment (build 17.0.9+11-LTS-201)
Java HotSpot(TM) 64-Bit Server VM (build 17.0.9+11-LTS-201, mixed mode, sharing)

从输出中的 -XX:+UseG1GC 能得知当前使用的是 G1 垃圾回收器。

6.6.8.2、设置垃圾回收器

在启动 Java 程序时,可通过 JVM 参数来设置垃圾回收器。以下是设置不同垃圾回收器的示例:

  1. 设置 Serial 垃圾回收器

    Serial 垃圾回收器采用单线程进行垃圾回收,适用于单 CPU 环境下的小型应用。

    java -XX:+UseSerialGC -jar YourApp.jar
    
  2. 设置 Parallel 垃圾回收器

    Parallel 垃圾回收器属于多线程垃圾回收器,着重于提高吞吐量,适合对吞吐量要求高的应用。

    java -XX:+UseParallelGC -jar YourApp.jar
    
  3. 设置 CMS 垃圾回收器

    CMS(Concurrent Mark Sweep)垃圾回收器以获取最短回收停顿时间为目标,适合对响应时间要求高的应用。

    java -XX:+UseConcMarkSweepGC -jar YourApp.jar
    
  4. 设置 G1 垃圾回收器

    G1(Garbage - First)垃圾回收器适用于大内存、多核处理器的环境,能较好地控制垃圾回收的停顿时间。

    java -XX:+UseG1GC -jar YourApp.jar
    
  5. 设置 ZGC 垃圾回收器

    ZGC(Z Garbage Collector)是一款低延迟的垃圾回收器,适用于对延迟要求极高的应用。ZGC 从 JDK 11 开始支持,要使用 ZGC,需确保 JDK 版本支持且设置如下参数:

    java -XX:+UseZGC -jar YourApp.jar
    

7、JVM可视化工具

7.1、为什么要用可视化工具

开发大型 Java 应用程序的过程中难免遇到内存泄露、性能瓶颈等问题,比如文件、网络、数据库的连接未释放,未优化的算法等。随着应用程序的持续运行,可能会造成整个系统运行效率下降,严重的则会造成系统崩溃。为了找出程序中隐藏的这些问题,在项目开发后期往往会使用性能分析工具来对应用程序的性能进行分析和优化。

7.2、JDK 自带的工具

7.2.1、 JConsole

jconsole 是一款 JDK 自带的可视化监控工具,可以用于查看应用程序的运行概况、内存、线程、类、VM 概括、MBean 等信息。它是一个基于 JMX(java management extensions)的 GUI 性能监测工具,从 JDK1.5 开始加入。

7.2.1.1、启动方式

在命令行输入jconsole即可启动。如果 Windows 用户,也可以在 jdk 的安装目录的 bin 目录下,找到jconsole.exe,双击启动。界面如下。

image-20250123140334738

可以直接选择本地 JVM,也可以通过 JMX 方式连接远程 JVM。

如果监控远程服务,比如 tomcat 服务,可以在启动脚本中添加如下代码,以便支持远程连接。

-Dcom.sun.management.jmxremote.port=6969  
-Dcom.sun.management.jmxremote.ssl=false  
-Dcom.sun.management.jmxremote.authenticate=false

点击连接之后,可能会弹出如下的界面,选择“不安全的连接”即可。

image-20250123140425404

进入之后,就可以看到 jconsole 概览图和主要的功能,例如概述、内存、线程、类、VM、MBean 等板块。概览界面如下。

image-20250123140517870

7.2.1.2、内存板块

内存板块主要展示了内存的使用情况,同时可以查看堆和非堆内存的变化值对比,也可以点击执行 GC 来触发 GC 的执行。界面如下:

image-20250123140638092

7.2.1.3、线程板块

线程板块主要展示线程数的活动数和峰值,点击左下方线程可以查看线程的详细信息,比如线程的状态是什么,堆栈内容等,同时也可以点击“检测死锁”来检查线程之间是否有死锁的情况。界面如下:

image-20250123140900880

7.2.1.4、类板块

类板块主要展示已加载的类数量。界面如下:

image-20250123140932748

7.2.1.5、VM 概要板块

VM 概要板块主要展示 JVM 所有信息总览,包括基本信息、线程相关、堆相关、操作系统、VM 参数等。界面如下:

image-20250123141011472

7.2.1.6、MBean 板块

MBean 板块主要展示被管理的 Bean 的属性,方法等。界面如下:

image-20250123141105833

7.2.1.7、优缺点
  • 优点:简单易用,适合初学者快速了解 JVM 的基本运行状态。
  • 缺点:功能相对有限,缺乏深入的分析和诊断功能。

7.2.2、visualVm

VisualVM 也是一款 JDK 自带的可视化监控工具,利用它不仅能够监控服务的 CPU、内存、线程、类等信息,还可以捕获有关 JVM 软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享,从 JDK1.6 开始加入。

与此同时,VisualVM 使用也很简单,几乎 0 配置,功能比较丰富,几乎囊括了 JDK 自带命令的所有功能,也是平时使用最多的调优工具之一。

VisualVM 在 JDK 6 Update 7 至 JDK 11 中作为 JDK 的一部分被包含在内。不过从 JDK 12 开始,但是它仍然是开源的,并且可以通过其他方式获取和安装。

7.2.2.1、启动方式

在命令行输入jvisualvm即可启动。如果 Windows 用户,也可以在 jdk 的安装目录的 bin 目录下,找到jvisualvm.exe,双击启动。界面如下:

image-20250123141514856

可以直接选择本地 JVM,也可以远程连接 JVM。这里选择本地 JVM 进程,双击某个进程即可打开。

进入之后,就可以看到 VisualVM 相关的展示界面,例如概述、监视、线程、抽样器等板块。概述界面如下:

image-20250123141549388

7.2.2.2、监视板块

监视板块主要展示 cpu、内存、线程、类的统计图表,也支持手动执行垃圾回收和生成堆 Dump 文件。界面如下:

image-20250123141747251

点击“堆 Dump”按钮之后,就会生成一份当前时刻的堆文件快照,在界面上可以查询当前文件的对象大小分析结果。界面如下:

image-20250123141858988

同时还支持与另一个堆文件进行比较,展示差异信息。界面如下:

image-20250123142046906

7.2.2.3、线程板块

线程板块主要展示线程的活动数。界面如下:

image-20250123142121195

点击“线程 Dump”按钮之后,就会生成一份当前时刻的线程快照,在界面上可以查询线程的状态。界面如下:

image-20250123142212308

7.2.2.4、抽样器板块

抽样器板块可以对 CPU、内存在一段时间内进行数据抽样,以供分析。界面如下:

image-20250123142252793

比如,点击“CPU”按钮之后,可以实时的查询线程和方法占用 CPU 时间长短排名,点击“停止”按钮即可结束抽样:

image-20250123142311124

同理,点击“内存”按钮之后,可以实时的查询堆中对象占用大小排名以及每个线程分配的内存大小排名:

image-20250123142404439

7.2.2.5、插件安装

VisualVM 还有一大亮点,就是它支持插件安装。每个插件的关注点都不同,有的是监控 GC,有的是监控内存,有的是监控线程等,可以在插件市场上寻找对应的工具进行安装,以便更好的排查服务性能问题。

如何安装插件呢?

在 VisualVM 的菜单栏,点击“工具” -》 “插件”,便会弹出如下界面:

image-20250123142854768

在“可用插件”栏中,选中需要安装的插件,点击“安装”,一路完成,最后重启 VisualVM,即可完成插件的安装。

如果获取可用插件异常,可以访问如下地址:visualvm.github.io/pluginscent… JDK 版本的链接。

image-20250123142958782

拷贝出对应的链接,然后在设置栏中填入对应的地址即可:

image-20250123143112280

通常,我们会将 Visual GC 插件安装到 VisualVM 中,利用它可以更加直观的观察到整个垃圾回收的过程。界面如下:

image-20250123143219603

7.2.2.6、优缺点
  • 优点:操作便捷,功能丰富,且无需额外安装,使用成本低。
  • 缺点:在监控大规模集群时,性能表现可能欠佳。

7.2.3、Java Mission Control (JMC)

JMC, 即Java任务控制(Java Mission Control)是从Java7(7u40)和 Java8 的商业版本包括一项新的监控和控制特性。

JMC 程序 (JDK_HOME\bin目录下) 会启动一个窗口程序,然后让我们选择对那进程进行监控,JMC打开性能日志后,

主要包括7部分性能报告,分别是一般信息、内存、代码、线程、I/O、系统、事件。其中,内存、代码、线程及I/O是系统分析的主要部分。

image-20250123144629258

7.2.4、bin目录下可执行命令作用

  1. java命令
    • 用于启动 Java 虚拟机(JVM)来运行 Java 程序,从指定类的main方法开始执行。
    • 可通过命令行参数指定运行时选项,如内存分配、类路径等。
  2. javac命令
    • Java 编译器,将 Java 源文件(.java)编译成字节码文件(.class)。
    • 支持指定目标 Java 版本、输出目录等编译选项。
  3. javadoc命令
    • 从 Java 源文件中的文档注释生成 API 文档,生成 HTML 格式方便查看代码功能和使用方法。
    • 可通过命令行参数指定源文件目录、包名等。
  4. jar命令
    • 用于创建、查看和提取 Java 归档(JAR)文件,方便发布和部署 Java 应用程序或库。
    • jar cvf myjar.jar *可将当前目录文件和目录打包成myjar.jarjar xf myjar.jar可解压。
  5. jdb命令
    • Java 调试器,可设置断点、查看变量值、单步执行代码来调试 Java 程序。
    • 可在命令行启动jdb并指定要调试的 Java 类进行调试操作。
  6. javap命令
    • Java 反汇编工具,用于分析 Java 类文件的字节码,显示类的结构和方法的字节码指令。
    • javap -c MyClass可反汇编MyClass.class文件。
  7. jconsole命令
    • Java 监视和管理控制台,提供图形化界面监视 Java 应用程序的性能和资源使用情况。
    • 可查看 JVM 内存、线程、类加载情况,还可进行垃圾回收等管理操作。
  8. jvisualvm命令
    • 功能强大的 Java 性能分析工具,集成多种监视和分析功能,可进行 CPU、内存、线程等深入分析。
    • 可连接本地或远程 Java 进程,直观展示性能数据以找出性能瓶颈和问题。
  9. jps命令
    • Java Virtual Machine Process Status Tool,查看当前所有 Java 进程,列出进程 ID(PID)和主类名称等信息。
    • 支持-l -m -v等参数显示更多信息。
  10. jstat命令
    • Java Virtual Machine Statistics Monitoring Tool,用于监视 JVM 的各种运行时统计信息,如类加载、内存、垃圾回收等情况。
    • 可以通过不同的参数组合来查看具体的统计数据,例如jstat -gc可以查看垃圾回收的相关统计信息。
  11. jinfo命令
    • 用于查看和修改正在运行的 Java 进程的配置信息,如 Java 系统属性、JVM 命令行参数等。
    • 可以帮助开发人员了解和调整运行时的环境配置,例如jinfo -sysprops <pid>可以查看指定进程的系统属性。
  12. jmap命令
    • 用于生成 Java 进程的内存转储快照(heap dump),也可以查看堆内存的使用情况、对象分布等信息。
    • 对于分析内存泄漏、内存占用过高的问题很有帮助,如jmap -dump:format=b,file=heapdump.hprof <pid>可以生成指定进程的内存转储文件。
  13. jhat命令
    • jmap配合使用,用于分析jmap生成的内存转储快照文件。它会启动一个 HTTP 服务器,通过浏览器可以查看内存对象的详细信息、对象之间的引用关系等。
    • 例如,在生成了内存转储文件heapdump.hprof后,可以使用jhat heapdump.hprof启动分析服务器。
  14. keytool命令
    • 用于管理密钥库和证书,例如创建密钥对、生成证书请求、导入和导出证书等。
    • 在 Java 的安全应用中,如 SSL/TLS 通信、数字签名等场景中经常用到,如keytool -genkeypair -alias mykey -keystore mykeystore.jks可以创建一个密钥对并存储到密钥库中。
  15. native2ascii命令
    • 用于将含有本地编码字符的文件转换为 ASCII 编码的文件,或者进行反向转换。
    • 在处理国际化资源文件等场景中会用到,例如将包含中文字符的文本文件转换为 ASCII 编码的文件,以便在 Java 程序中正确处理。
  16. rmid命令
    • 用于在 RMI(Remote Method Invocation)系统中激活系统守护进程,它可以注册和激活远程对象,使得远程对象能够被其他 Java 程序访问。
    • 在 Java 的分布式应用开发中,当使用 RMI 技术时,可能会用到rmid来管理远程对象的生命周期。
  17. tnameserv命令
    • 是 RMI 系统中的命名服务工具,用于在 RMI 应用中提供命名服务,允许客户端通过名称来查找和访问远程对象。
    • 它为 RMI 应用中的远程对象提供了一种简单的命名和查找机制,方便了分布式应用中对象的定位和调用。

7.3、第三方调优工具

除了 JDK 自带的 JVM 可视化分析工具之外,市场上还诞生了很多比较优秀的性能监控工具,比如下面这几款工具。

  • MAT:一款功能强大的 Java 堆内存分析器,可以用于查找内存泄漏以及查看内存消耗情况,用户可以利用 visualvm 或者是 jmap 命令生产堆文件,然后导入工具中进行分析
  • GCeasy:一款在线的 GC 日志分析器,使用起来非常方便,用户可以通过它的 web 网站导入 GC 日志,实时进行内存泄漏检测、GC 暂停原因分析、JVM 配置建议优化等功能
  • GCViewer:一款非常强大的 GC 日志可视化分析工具,功能强大而且完全免费
  • JProfiler:一款商用的性能分析利器
  • arthas:阿里开源的一款线上监控诊断工具,可以查看应用负载、内存、gc、线程等信息

在此,我们重点的介绍一下 Arthas 这款工具,因为它的功能比较全,并且文档详细。

7.3.1、Arthas

根据官方的介绍,Arthas(阿尔萨斯)是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。

Arthas 官方地址如下:arthas.aliyun.com/

7.3.1.1、启动方式

Arthas 的启动方式非常简单,它就是一个 jar 包,通过java -jar命令即可运行。

jar 包的下载地址如下:arthas.aliyun.com/arthas-boot…

输入如下命令,输入如下命令,即可启动 Arthas:

 java -jar arthas-boot.jar

启动成功后,可以看到如下界面:

image-20250123133711899

意思是发现有以下几个 Java 应用服务,请选择想要监控的进程,输入前面对应的编号,就可以开启进行监控模式了。比如我输入编号 4。

image-20250123133756127

进入以上这个界面,就表示应用监听成功了。

7.3.1.2、dashboard仪表盘

dashboard 仪表盘用于展示整体项目运行情况,可以在命令窗口输入如下命令来查看:

dashboard

回车之后,一共有三块信息,分别是线程信息、内存信息、运行时信息,界面如下:

image-20250123134235112

7.3.1.3、thread 命令

如果我们想要具体查看某一个线程的运行情况,可以使用 thread 命令。例如查询线程 Id 为100的运行状态信息,命令如下。

thread 100

image-20250123134726425

还可以通过如下命令,查询所有的线程信:

thread -all

image-20250123134812792

7.3.1.4、jad 命令

某些时候我们想要查询线上的源码是否正确发布,可以通过 jad 命令来反编译类,以此来查询源码信息,例如反编译Application类,命令如下:

jad com.sq.RuoYiApplication

image-20250123135036442

更多命令请查看官方文档:arthas.aliyun.com/doc/

8、JMM内存模型

8.1、什么是JMM

Java 内存模型(Java Memory Model,JMM)是 Java 虚拟机(JVM)中定义的一种抽象的内存架构,用于屏蔽不同硬件和操作系统的内存访问差异,确保 Java 程序在不同的平台上都能正确地访问和操作内存。以下是关于 JMM 内存模型的详细学习内容:

8.2、基本概念

8.2.1、主内存与工作内存

  • 主内存:是所有线程共享的内存区域,存储了共享变量等数据。所有的变量都存储在主内存中,包括实例变量、静态变量等,但不包括局部变量,因为局部变量是线程私有的,不存在内存可见性问题。
  • 工作内存:每个线程都有自己独立的工作内存,线程对变量的操作(读取、赋值等)都在工作内存中进行。线程不能直接读写主内存中的变量,而是先将主内存中的变量拷贝到自己的工作内存中,然后在工作内存中进行操作,操作完成后再将结果写回主内存。

img

8.2.2、JMM内存模型与JVM内存结构

这里所讲的主内存、工作内存与Java内存结构中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

8.2.3、内存可见性

指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在 JMM 中,如果没有正确的同步措施,一个线程对共享变量的修改可能不会及时被其他线程看到,从而导致数据不一致等问题。

8.2.4、原子性

指一个操作是不可分割的,要么全部执行成功,要么全部不执行。在 JMM 中,对于基本数据类型的变量的读取和赋值操作一般是原子的,但对于longdouble类型的变量,在某些平台上可能不是原子的。

8.2.5、有序性

指程序执行的顺序按照代码的先后顺序执行。但在实际执行中,为了提高性能,编译器和处理器可能会对指令进行重排序。JMM 通过一些规则和机制来保证在一定条件下程序的有序性。

8.2.6、重排序

  • 数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器不会对存在数据依赖关系的操作进行重排序。
  • 指令重排序:为了提高程序的执行效率,编译器和处理器会对指令进行重排序。在单线程环境下,重排序不会影响程序的最终执行结果,但在多线程环境下,可能会导致程序出现错误。
  • 内存屏障:JMM 提供了内存屏障指令来禁止特定类型的处理器重排序,以保证内存的可见性和有序性。例如,volatile关键字就是通过内存屏障来实现其语义的。

8.3、内存交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)。

  • **lock(锁定):**作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • **unlock(解锁):**作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • **read(读取):**作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • **load(载入):**作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • **use(使用):**作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • **assign(赋值):**作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • **store(存储):**作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • **write(写入):**作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

img

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。

8.4、线程安全与同步机制

  • synchronized关键字:可以修饰方法或代码块,用于保证在同一时刻只有一个线程能够访问被 synchronized修饰的代码。它通过 monitorenter 和 monitorexit 指令来实现,在进入同步块时获取锁,退出同步块时释放锁,从而保证了原子性、可见性和有序性。
  • volatile关键字:用于修饰变量,保证变量的内存可见性。当一个变量被volatile修饰后,任何线程对它的修改都会立即刷新到主内存中,其他线程读取该变量时也会从主内存中获取最新的值。但volatile不能保证原子性,适用于对变量的读操作远多于写操作的场景。
  • final关键字:被final修饰的变量在初始化后就不能再被修改,对于final域的写操作和读操作也有特殊的内存语义,有助于保证内存的可见性和有序性。