JVM知识学习及查漏补缺

内存与垃圾回收

JVM与Java体系结构

Java及JVM简介

JVM:跨语言的平台

每个语言都需要转换成字节码文件,最后转换的字节码文件都能通过Java虚拟机进行运行和处理

  • 随着Java7的正式发布,Java虚拟机的设计者们通过JSR-292规范基本实现在Java虚拟机平台上运行非Java语言编写的程序

  • Java虚拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果满足并包含Java虚拟机的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。

字节码

  • 我们平时说的java字节码,指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码

  • 不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行。

  • Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式—Class文件格式所关联,Class文件中包含了Java虚拟机指令集(或者称为字节码、Bytecodes)和符号表,还有一些其他辅助信息。

OpenJDK与OracleJDK

OpenJDK是开源的,OracleJDK是商业付费的

在JDK11之前,OracleJDK中还会存在一些OpenJDK中没有的、闭源的功能。但在JDK11中,我们可以认为OpenJDK和OracleJDK代码实质上已经完全一致的程度。

虚拟机与Java虚拟机
  • 虚拟机
    • 虚拟的计算机,它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机
    • VMWare、VirtualBox等属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台
    • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令
  • Java虚拟机
    • Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成
    • JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器
    • Java技术的核心就是Java虚拟机,因为所有的Java程序都运行在Java虚拟机内部
    • 作用
      • Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里
    • 特点
      • 一次编译,到处运行
      • 自动内存管理
      • 自动垃圾回收功能
JVM的整体结构

  • HotSpot VM是目前市面上高性能虚拟机的代表作之一
  • 它采用解释器与即时编译器并存的架构
  • 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步
Java代码执行流程

JVM的架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

两者区别:

  • 基于栈的指令集架构特点
    • 设计和实现更简单,适用于资源受限的系统
    • 避开了寄存器的分配难题:使用零地址指令方式分配
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
    • 不需要硬件支持,可移植性更好,更好实现跨平台
  • 基于寄存器的指令集架构
    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机
    • 指令集架构则完全依赖硬件,可移植性差
    • 性能优秀和执行更高效
    • 花费更少的指令去完成一项操作
    • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
JVM的生命周期

虚拟机的启动

Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

虚拟机的执行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
  • 程序开始执行时他才运行,程序结束时他就停止
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程

虚拟机的退出

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统用现错误而导致Java虚拟机进程终止
  • 某线程调用Runtime类或system类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作
  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况

类加载子系统

概述

类加载子系统即Class Loader SubSystem

类加载器与类的加载过程

类加载子系统作用:

  • 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识(CAFEBABE)
  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

加载

  • 通过一个类的全限定名获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
  • 加载class文件的方式
    • 从本地系统中直接加载
    • 通过网络获取,典型场景:Web Applet
    • 从zip压缩包中读取,成为日后jar、war格式的基础
    • 运行时计算生成,使用最多的是:动态代理技术
    • 由其他文件生成,典型场景:JSP应用
    • 从专有数据库中提取.class文件,比较少见
    • 从加密文件中获取,典型的防Class文件被反编译的保护措施

链接

  • 验证
    • 目的在子确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证
  • 准备
    • 为类变量分配内存并且设置该类变量的默认初始值,即零值(数据类型的默认值,0、false、null)
    • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
    • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
  • 解析
    • 将常量池内的符号引用转换为直接引用的过程
    • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info等

初始化

  • 初始化阶段就是执行<clinit>()的过程
  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
  • 构造器方法中指令按语句在源文件中出现的顺序执行
  • <clinit>()不同于类的构造器(构造器是虚拟机视角下的<init>())
  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
类加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

这里的四者之间的关系是包含关系,不是子父类的继承关系

虚拟机自带的加载器

  • 引导类加载器(启动类加载器,Bootstrap ClassLoader)

    • 由c/c++实现,嵌套在JVM内部

    • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

      1
      Arrays.stream(Launcher.getBootstrapClassPath().getURLs()).forEach(System.out::println);

    • 并不继承自ava.lang.ClassLoader,没有父加载器

    • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器

    • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

  • 扩展类加载器(Extension ClassLoader)

    • 由sun.misc.Launcher.ExtClassLoader实现

    • 派生于ClassLoader类

    • 父类加载器为引导类加载器

    • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/1ib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载

      1
      2
      String extDirs = System.getProperty("java.ext.dirs");
      Arrays.stream(extDirs.split(";")).forEach(System.out::println);

  • 系统类加载器(应用程序类加载器,System ClassLoader)

    • 由sun.misc.Launcher.AppClassLoader实现
    • 派生于ClassLoader类
    • 父类加载器为扩展类加载器
    • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
    • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
    • 通过ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器
  • 用户自定义类加载器

    • 需要自己实现自定义类加载器的场景

      • 隔离加载类
      • 修改类加载的方式
      • 扩展加载源
      • 防止源码泄漏
    • 实现步骤

      • 继承抽象类ava.lang.ClassLoader类,实现自己的类加载器
      • 是建议把自定义的类加载逻辑写在findClass()方法中
      • 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁
ClassLoader的使用说明

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

方法名称 描述
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回结果为java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回结果为java.lang.Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例
defineClass(String name, byte[] b, int offset, int len) 把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例
resolve(Class<?> c) 连接指定的一个Java类

sun.misc.Launcher 它是一个java虚拟机的入口应用

获取ClassLoader的途径

  • 获取当前ClassLoader

    1
    clazz.getClassLoader();
  • 获取当前线程上下文的ClassLoader

    1
    Thread.currentThread().getContextClassLoader();
  • 获取系统的ClassLoader

    1
    ClassLoader.getSystemClassLoader();
  • 获取调用者的ClassLoader

    1
    DriverManager.getCallerClassLoader();
双亲委派机制

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

工作原理

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

沙箱安全机制

1
2
3
4
5
6
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("this is custom String");
}
}

自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的string类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制

其他

如何判断两个class对象是否相同

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

对类加载器的引用

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的

类的主动使用和被动使用

主动使用,分为七种情况:

  • 创建类的实例
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如Class.forName)
  • 初始化一个类的子类
  • Java虚拟机启动时被标明为启动类的类
  • JDK 7 开始提供的动态语言支持: java.lang.invoke.MethodHandle实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用都不会导致类的初始化

运行时数据区概述及现场

概述

运行时数据区是在类加载完成后的阶段

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

线程私有:程序计数器、本地方法栈、虚拟机栈

线程共享:堆、堆外内存(元数据区、永久代、代码缓存)

每个JVM只有一个Runtime实例(单例模式),这个就是运行是环境

线程
  • 线程是一个程序里的运行单元
  • JVM允许一个应用有多个线程并行的执行
  • 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
  • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建
  • Java线程执行终止后,本地线程也会回收
  • 操作系统负责所有线程的安排调度到任何一个可用的CPU上
  • 一旦本地线程初始化成功,它就会调用Java线程中的run()方法
JVM系统线程

主要的后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持
  • 编译线程:这种线程在运行时会将字节码编译成到本地代码
  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理

程序计数器

JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

作用

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

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

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

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法

程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)

分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

它是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域

常见问题

使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

PC寄存器为什么被设定为私有的?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

虚拟机栈

概述

JVM为了跨平台是基于栈的指令集架构

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用,是线程私有的

内存中的栈与堆

栈是运行时的单位,而堆是存储的单位

栈管运行,堆管存储

生命周期

生命周期和线程一致

作用

主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回

特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • JVM直接对Java栈的操作只有两个
    • 每个方法执行,伴随着入栈
    • 执行结束后出栈
  • 对于栈来说不存在垃圾回收问题(栈存在溢出的情况)
  • 栈可能出现的异常
    • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常
    • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常

设置栈内存大小

可以使用参数 -Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度

栈的存储单位

栈中存储什么

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在

在这个线程上正在执行的每个方法都各自对应一个栈帧

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

栈运行原理

JVM直接对Java栈的操作只有两个,就是对栈帧的入栈出栈

在一个线程中,一个时间点上只有一个活动的栈帧,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)

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

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

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

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

栈帧的内部结构

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)
  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用
  • 方法返回地址(Return Address):方法正常退出或者异常退出的定义
  • 一些附加信息
局部变量表

局部变量表也被称之为局部变量数组或本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

变量槽

  • 局部变量表,最基本的存储单元是Slot(变量槽)
  • 参数值的存放总是在局部变量数组的下标0开始,到数组长度-1的索引结束
  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
  • 在局部变量表里,32位以内的类型只占用一个slot(包括引用类型、returnAddress类型),64位的类型(long和double)占用两个slot
  • byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可(比如:访问long或doub1e类型变量)
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列

变量槽的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的

1
2
3
4
5
6
7
8
9
public void m6() {
int a = 1;
{
int b = 0;
b = a + 1;
}
// 变量b过了作用域,变量c复用它的槽位
int c = a + 1;
}

类变量与局部变量的对比

参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配

类变量在类加载链接的准备阶段设置默认值,在初始化阶段显式赋值(代码中定义的初始值)

实例变量随着对象的创建,会在堆空间中分配实例变量空间,并设置默认值

局部变量不存在系统初始化过程,只要定义了局部变量,就要在定义时显式赋值,否则,编译不通过

补充说明

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表

在方法执行时,虚拟机使用局部变量表完成方法的传递

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

操作数栈

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈

某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈

代码示例

1
2
3
4
5
6
public int add() {
int a = 1;
int b = 2;
int k = a + b;
return k;
}

字节码指令信息

1
2
3
4
5
6
7
8
9
10
0 iconst_1 // 1入栈
1 istore_1 // 1出栈,把1赋值给局部变量表下标1的变量
2 iconst_2 // 2入栈
3 istore_2 // 2出栈,把2赋值给局部变量表下标2的变量
4 iload_1 // 1入栈,从局部变量表下标1的变量读取
5 iload_2 // 2入栈,从局部变量表下标2的变量读取
6 iadd // 12出栈,3入栈
7 istore_3 // 3出栈,把3赋值给局部变量表下标3的变量
8 iload_3 // 3入栈,从局部变量表下标3的变量读取
9 ireturn // 3出栈,返回

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的(长度确定,内容为空)

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值

栈中的任何一个元素都是可以任意的Java数据类型

  • 32bit的类型占用一个栈单位深度
  • 64bit的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证

Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度

为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

动态链接

动态链接、方法返回地址、附加信息也会被统称为帧数据区

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接,如invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

运行时常量池的作用:为了提供一些符号和常量,便于指令的识别

方法的调用:解析与分派

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

静态链接

当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接

动态链接

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接

静态链接和动态链接不是名词,而是动词,这是理解的关键

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)

绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用

晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定

虚方法和非虚方法

  • 非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的
    • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
  • 虚方法:在编译期无法确定具体的调用版本

普通调用指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法

invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法

动态调用指令:

  • invokedynamic:动态解析出需要调用的方法,然后执行

动态类型语言和静态类型语言

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。

静态类型语言是判断变量自身的类型信息动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征

1
String s = "abc";	// Java是静态类型语言
1
var s = 'abc'; // JavaScript是动态类型语言

Java7中增加了invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进,但Java7中没有提供生成这个指令的方法,需要借助ASM这种底层字节码工具来生成

直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式

方法重写的本质

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
  • 若在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError 异常
  • 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

虚方法表

  • 每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就会影响到执行效率

  • 为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现,使用索引表来代替查找

方法返回地址

存放调用该方法的pc计数器的值(告诉执行引擎下一个要执行的指令的位置)

一个方法的结束,有两种方式:

  • 正常执行完成
  • 出现未处理的异常,非正常退出

在方法退出后都返回到该方法被调用的位置

方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址

通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 执行引擎遇到方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口

    • ireturn:返回值是byte、short、char、boolean、int类型时使用
    • lreturn:返回值是long类型时使用
    • freturn:返回值是float类型时使用
    • dreturn:返回值是double类型时使用
    • areturn
    • return:声明为void的方法,实例初始化方法<init>,类和接口的初始化方法<clinit>
  • 在方法执行过程中遇到异常,简称异常完成出口

    • 在本方法的异常表中没有搜索到匹配的异常处理器

      示例代码

      行号表

      异常表

      以上异常表说明,若15行到18行代码之间出现异常,则跳到16行开始执行

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

一些附加信息

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

例如:对程序调试提供支持的信息

相关问题
  • 栈溢出的情况(StackOverflowError)

    • 死递归
  • 调整栈的大小不能保证不出现溢出(通过-Xss设置栈的大小)

  • 垃圾回收不会涉及到虚拟机栈

  • 方法中定义的局部变量是否线程安全

    • 如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的

      1
      2
      3
      4
      5
      public void m1() {
      StringBuilder sb = new StringBuilder();
      sb.append("s");
      sb.append("b");
      }
    • 反之则是线程不安全的

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public void m2(StringBuilder sb) {
      sb.append("s");
      sb.append("b");
      }
      public StringBuilder m3() {
      StringBuilder sb = new StringBuilder();
      sb.append("s");
      sb.append("b");
      return sb;
      }
运行时数据区 是否存在Error 是否存在GC
程序计数器
虚拟机栈 是(SOE)
本地方法栈
方法区 是(OOM)

本地方法接口和本地方法栈

本地方法

一个本地方法(Native Method)是一个Java调用非Java代码的接囗

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序

Java中使用native关键字修饰的方法调用的是本地方法

1
public native void methodNative1(int x);

native关键字可以与其它Java关键字连用,但是abstract除外

为什么使用本地方法
  • Java应用需要与Java外面的环境交互,而本地方法为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节,即可实现与Java外部环境的交互
  • 是操作系统交互,通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的
  • Sun’s Java,Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互
本地方法栈
  • Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
  • 本地方法栈,也是线程私有的
  • 允许被实现成固定或者是可动态扩展的内存大小
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常
  • 本地方法是使用C语言实现的
  • 在本地方法栈中记录本地方法,在执行引擎执行时加载本地方法库
  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等
  • 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一

概述
  • 堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含多个线程,他们是共享同一堆空间的

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

    • 堆内存的大小是可以调节的
  • 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)

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

  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置

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

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

内存细分

  • JDK7及之前堆内存逻辑上分为三部分:新生代、老年代、永久代

    • 新生代:Young/New(Young Generation Space),又可以分为Eden区和Survivor区
    • 老年代:Old/Tenure(Tenure Generation Space)
    • 永久代:Perm(Permanent Space)
  • JDK8及之后堆内存逻辑上分为三部分:新生代、老年代、元空间

    • 新生代

    • 老年代

    • 元空间:Meta(Meta Space)

设置堆内存大小与OOM
  • 堆的大小在JVM启动时就已经设定好了,可以通过下面的参数设置

    • -Xms:用于表示堆区的起始内存,等价于-XX:InitialHeapSize

    • -Xmx:用于表示堆区的最大内存,等价于-XX:MaxHeapSize

    • 堆内存大小查看方式

      • jsp获取进程号,jstat -gc 进程号

      • -XX:+PrintGCDetails

      • 从上面的的情况可以发现新生代的显示大小=eden区大小+1个S0/S1区(from/to)大小

  • 一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常

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

  • 默认情况下,初始内存大小:物理电脑内存大小 / 64

  • 默认情况下,最大内存大小:物理电脑内存大小 / 4

新生代与老年代
  • 存储在JVM中的Java对象可以被划分为两类

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
  • Java堆区进一步细分的话,可以划分为新生代(YoungGen)和老年代(oldGen)

  • 新生代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)

  • 配置新生代与老年代在堆结构的占比

    • 默认**-XX:NewRatio=2**,表示新生代占1,老年代占2,新生代占整个堆的1/3
    • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
  • 在HotSpot中,Eden空间和另外两个survivor空间默认新情况下所占的比例是8:1:1

    • 使用-XX:SurvivorRatio=8设置比例,表示eden占8,s0和s1分别占1

  • 几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行了

  • 可以使用选项"-Xmn"设置新生代最大内存大小

    • 这个参数一般使用默认值就可以了
    • 会使通过-XX:NewRatio设置的比例失效
对象分配过程
  1. new的对象先放eden区,此区有大小限制
  2. 当eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对eden区进行垃圾回收(MinorGC),将eden区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到eden区
  3. 然后将eden区中的剩余对象移动到survivor0区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到survivor0区的,如果没有回收,就会放到survivor1区
  5. 如果再次经历垃圾回收,此时会重新放回survivor0区,接着再去survivor1区
  6. 当经历的垃圾回收次数(分代年龄)满足一定次数后进入老年区,默认是15
    • 通过-Xx:MaxTenuringThreshold=15参数进行设置
  7. 当老年区内存不足时,再次触发Major GC,进行老年区的内存清理
  8. 若老年区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常

总结

  • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
  • 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集

Minor GC、Major GC、Full GC

GC按照回收区域可以分为两种

  • 部分收集(Partial GC)
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集
      • 目前,只有CMSGC会有单独收集老年代的行为
      • 很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

GC策略触发条件

  • 新生代GC(Minor GC)触发机制
    • 当新生代空间不足时,就会触发Minor GC,这里的空间不足指的是eden区满,survivor区满不会引发GC
    • 因为Java对象大多都具备朝生夕灭的特性.,所以Minor GC非常频繁,一般回收速度也比较快
    • Minor GC会引发STW(stop-the-world),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 老年代GC(Major GC / Full GC)触发机制
    • 对象从老年代消失时,即发生了 “Major GC” 或 “Full GC”
    • 出现了Major Gc,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
      • 也就是在老年代空间不足时,会先尝试触发Minor Gc。如果之后空间还不足,则触发Major GC
    • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
    • 如果Major GC后,内存还不足,就报OOM了
  • Full GC触发机制,触发Full GC执行的情况有如下五种:
    • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
    • 老年代空间不足
    • 方法区空间不足
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    • 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
    • Full GC 是开发或调优中尽量要避免的
堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了吗?

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来

内存分配策略

内存分配策略即对象提升(Promotion)规则

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1。对象在survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被提升到老年代

对象提升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代(尽量避免程序中出现过多的大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:如果survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
  • 空间分配担保: -XX:HandlePromotionFailure
为对象分配内存:TLAB

Thread Local Allocation Buffer,存放在堆中,是线程私有的

为什么有TLAB?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

什么是TLAB?

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计

其他说明

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  • 在程序中,开发人员可以通过选项-XX:UseTLAB设置是否开启TLAB空间,默认开启
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项 -XX:TLABWasteTargetPercent 设置TLAB空间所占用Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
堆空间的设置参数

更多参数可查看官网文档

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
    • 打印gc简要信息:-XX:+PrintGC、-verbose:gc
  • -XX:HandlePromotionFailure:是否设置空间分配担保
    • 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
    • 如果大于,则此次Minor GC是安全的
    • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败
    • 若允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的
      • 如果小于,则改为进行一次Full GC
    • 若不允许,则改为进行一次Full Gc
    • JDK7及之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC
其他问题

堆是分配对象的唯一选择吗

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配.。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术

逃逸分析概述

  • 将堆上的对象分配到栈,需要使用逃逸分析手段
  • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上
  • 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中
  • JDK7及以上,HotSpot中默认就已经开启了逃逸分析
    • 早期版本-XX:+DoEscapeAnalysis开启逃逸分析

逃逸分析:代码优化

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
    • 常见的逃逸情况:成员变量赋值、方法返回值、实例引用传递
  • 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步
    • JIT编译器进行锁消除
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中
    • **标量(scalar)**是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量
    • 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
    • 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量来代替,这个过程就是标量替换
    • 标量替换可以大大减少堆内存的占用
    • 使用-XX:+EliminateAllocations参数开启标量替换,默认开启

逃逸分析并不成熟

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段

Oracle Hotspot JVM中并未实现栈上分配那些不会逃逸的对象,所以可以明确所有的对象实例都是创建在堆上

方法区

概述

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

方法区的理解
  • 方法区看作是一块独立于Java堆的内存空间
  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace
    • 加载大量的第三方的jar包
    • Tomcat部署的工程过多(30~50个)
    • 大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存

HotSpot中方法区的演进

  • 在jdk7及以前,习惯上把方法区,称为永久代
  • jdk8开始,使用元空间取代了永久代
  • 本质上,方法区和永久代并不等价
    • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM(超过-XX:MaxPermsize上限)
  • 到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  • 永久代、元空间二者并不只是名字变了,内部结构也调整了
  • 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
设置方法区大小与OOM
  • 方法区的大小不必是固定的,JVM可以根据应用的需要动态调整
  • jdk7及以前
    • 通过-XX:Permsize来设置永久代初始分配空间,默认值是20.75M
    • 通过-XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
  • JDK8以后
    • 元数据区大小可以使用参数-XX:MetaspaceSize-XX:MaxMetaspaceSize指定
    • 默认值依赖于平台
      • windows下:-XX:MetaspaceSize=21M,-XX:MaxMetaspaceSize=-1(-1为无限制)
    • 与永久代不同,若不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存
    • -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
    • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值

如何解决这些OOM

  • 一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置
  • 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗
方法区的内部结构
  • 方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等
  • 类型信息,对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
    • 类型的完整有效名称(全名=包名.类名)
    • 类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
    • 类型的修饰符(public,abstract,final的某个子集)
    • 类型直接接口的一个有序列表
  • 域(Field)信息
    • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
    • 域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
  • 方法(Method)信息,JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
    • 方法名称
    • 方法的返回类型(或void)
    • 方法参数的数量和类型(按顺序)
    • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
    • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
    • 异常表(abstract和native方法除外)
      • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
  • non-final的类变量
    • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
    • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
  • 全局常量(static final)
    • 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了

运行时常量池与常量池

  • 方法区,内部包含了运行时常量池
  • 字节码文件,内部包含了常量池
  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区
  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用

为什么需要常量池?

Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池

常量池中有什么?

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分
  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常
方法区的演进细节

Hotspot中方法区的变化:

版本 说明
JDK1.6及之前 有永久代(permanet),静态变量存储在永久代上
JDK1.7 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在
JDK1.8 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在中。

为什么永久代要被元空间替代?

  • 为永久代设置空间大小是很难确定的,在某些场景下,如果动态加载类过多,容易产生Perm区的oom
  • 对永久代进行调优是很困难的

StringTable为什么要调整位置?

jdk7中将StringTable放到了堆空间中,因为永久代的回收效率很低,在full gc的时候才会触发,而full gc是老年代的空间不足、永久代不足时才会触发,这就导致StringTable回收效率不高。

而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

静态变量存放在那里?

静态变量的引用存放在方法区,对象数据存放在堆中

实例变量的引用存放在堆中,对象数据存放在堆中

局部变量的引用存放虚拟机栈-栈帧的局部变量表中,对象数据存放在堆中

方法区的垃圾回收

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏

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

  • 方法区内常量池之中主要存放的两大类常量:字面量和符号引用

    • 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等
    • 而符号引用则属于编译原理方面的概念,包括下面三类常量:
      • 类和接口的全限定名
      • 字段的名称和描述符
      • 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

  • 回收废弃常量与回收Java堆中的对象非常类似

  • 判定一个类型是否属于“不再被使用的类”需要同时满足下面的条件:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • 满足以上三个条件仅仅是被允许,不是必然被回收
    • 关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

总结

对象实例化及直接内存

对象的实例化

对象的内存布局

对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的?

通过栈上reference访问

对象访问方式主要有两种

  • 句柄访问

    • 优点:reference中存储句柄地址,对象被移动(GC时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改

  • 直接指针:Hotspot采用此方式

    • 直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

直接内存
  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域

  • 直接内存是在Java堆外的、直接向系统申请的内存区间

  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆,即读写性能高

    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
  • 也可能导致OutOfMemoryError异常(OutOfMemoryError: Direct buffer memory)

  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存

  • 缺点

    • 分配回收成本较高
    • 不受JVM内存回收管理
  • 直接内存大小可以通过MaxDirectMemorySize设置

  • 如果不指定,默认与堆的最大值-Xmx参数值一致

非直接缓存区

使用IO读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低

直接缓存区

使用NIO时,操作系统划出的直接缓存区可以被Java代码直接访问,只有一份。NIO适合对大文件的读写操作

执行引擎

概述
  • 执行引擎(Execution Engine)属于JVM的下层,里面包括解释器、即时编译器、垃圾回收器
  • 执行引擎是Java虚拟机核心的组成部分之一
  • “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
  • JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息
  • 执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令,简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者

执行引擎的工作流程

  • 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
  • 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
  • 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
  • 从外观上来看,所有的Java虚拟机的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程
Java代码编译和执行过程

Java代码编译是由Java源码编译器(前端编译器)来完成

Java字节码的执行是由JVM执行引擎(后端编译器)来完成

解释器与JIT编译器

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

Java是半编译半解释型语言

  • JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的
  • 后来Java也发展出可以直接生成本地代码的编译器
  • 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行
机器码、指令、汇编语言
  • 机器码
    • 各种用二进制编码方式表示的指令,叫做机器指令码
    • 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错
    • 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快
    • 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同
  • 指令
    • 由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令
    • 指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好
    • 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同
  • 指令集
    • 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集, 如常见的
      • x86指令集,对应的是x86架构的平台
      • ARM指令集,对应的是ARM架构的平台
  • 汇编语言
    • 由于指令的可读性还是太差,于是人们又发明了汇编语言
    • 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令
    • 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行
  • 高级语言
    • 高级语言比机器语言、汇编语言更接近人的语言
    • 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码,完成这个过程的程序就叫做解释程序或编译程序
    • 高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码

字节码

  • 字节码是一种中间状态的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
  • 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关
  • 字节码的实现方式是通过编译器和虚拟机器,编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令
解释器
  • 为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法
  • 工作机制
    • 将字节码文件中的内容“翻译”为对应平台的本地机器指令执行
    • 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作
  • 分类
    • 字节码解释器:在执行时通过纯软件代码模拟字节码的执行,效率非常低下
    • 模板解释器:将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能
      • 在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成
        • Interpreter模块:实现了解释器的核心功能
        • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
JIT编译器

HotSpot JVM执行方式

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率

概念解释

  • 前端编译器:将Java文件编译为字节码文件
  • JIT编译器:将字节码文件转为机器码
  • AOT编译器:直接把Java文件编译为本地机器码
    • 优点:Java虚拟机加载已经预编译的二进制库,可以直接执行,不需要JIT编译器预热
    • 缺点:
      • 不跨平台
      • 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知
      • 支持的平台少

热点代码及探测技术

  • 需要被编译为本地代码的字节码被称为热点代码,,JIT编译器在运行时会针对那些频繁被调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能

  • 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR(On Stack Replacement)编译

  • 热点代码的判断标准要依靠热点探测功能

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

  • 采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)

    • 方法调用计数器用于统计方法的调用次数

      • 默认阀值在Client模式下是1500次,在Server模式下是10000次,超过这个阈值,就会触发JIT编译

      • 通过-XX:CompileThreshold参数设定阈值

      • 当一个方法被调用时先检查是否被JIT编译过,若存在则优先使用编译后的本地代码执行,若不存在则计数器加1,并判断计数器值是否超过阈值,若超过阈值则向JIT编译器提交一个该方法的代码编译请求

    • 回边计数器则用于统计循环体执行的循环次数

  • 热点衰减

    • 若没有任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数,当超过一定时间限度,若方法的调用次数没有超过阈值,该方法的计数器值会减少一半,此过程就是热点衰减,此时间段就为半衰周期
    • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,使用-XX:-UseCounterDecay参数来关闭热度衰减
    • 使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒

HotSpotVM 可以设置程序执行方法

  • 默认情况下HotSpot VM是采用解释器与即时编译器并存的架构
  • 使用-Xint参数完全采用解释器模式执行程序
  • 使用-Xcomp参数完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
  • 使用-Xmixed参数采用解释器+即时编译器的混合模式共同执行程序

HotSpotVM中 JIT 分类

  • JIT的编译器分为了两种,分别是C1和C2,在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,一般简称为C1编译器 和 C2编译器
  • -client:指定Java虚拟机运行在Client模式下,并使用C1编译器;C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度
  • -server:指定Java虚拟机运行在server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高
  • 分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化,Java7及之后若显式使用-server参数则默认开启分层编译策略
  • C1编译器优化策略
    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:在运行期间把一些不会执行的代码折叠掉
  • C2编译器优化策略(基于逃逸分析)
    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
    • 同步消除:清除同步操作,通常指synchronized

StringTable

概述
  • 字符串常量池不会存储相同内容的字符串
  • 字符串常量池是一个固定大小的hashtable,若放进池中的字符串很多,就会造成hash冲突,从而导致链表变很长,进而造成调用String.intern时性能大幅下降
  • 使用-XX:StringTablesize参数设置长度
    • jdk6默认长度为1009,参数设置的值没有要求
    • jdk7默认长度为60013,参数设置的值没有要求
    • jdk8默认长度为60013,参数设置的值最小为1009
内存分配
  • 字符串常量池的使用方式有两种
    • 直接使用双引号声明出来的String对象会直接存储在常量池中
    • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法
  • Java6及以前,字符串常量池存放在永久代
  • Java7及之后,将字符串常量池的位置调整到Java堆内
字符串拼接操作
  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 只要其中有一个是变量,结果就在堆中,变量拼接的原理是StringBuilder(StringBuilder的toString不会在常量池中保存对应的字符串)
  • 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
  • 不使用final修饰,即为变量,会通过new StringBuilder进行拼接
  • 使用final修饰,即为常量,会在编译器进行代码优化,在实际开发中,能够使用final的,尽量使用
  • StringBuilder空参构造器的初始化大小为16,若知道拼接次数,就应该直接使用带参构造器指定capacity,以减少扩容的次数
intern()的使用
  • 若字符串在字符串常量池中不存在,则将字符串放到字符串常量池中,并返回字符串常量池中的地址,若存在则直接返回字符串常量池中的地址

  • intern()保证变量指向字符串常量池中的数据

  • JDK6与JDK7/8的不同

    1
    2
    3
    4
    5
    6
    7
    String s3 = new String("1") + new String("1"); // 不会在字符串常量池中保存"11"
    // jdk7中会把s3引用地址复制到池中
    s3.intern();
    String s4 = "11"; // 池中存在"11",且与指向s3指向一致
    System.out.println(s3==s4);
    //jdk6 false
    //jdk7/8 true
    • 若字符串常量池中已存在,则不会放入,返回字符串常量池中的地址
    • 若字符串常量池中不存在
      • jdk6中会把字符串复制一份,放到字符串常量池中,并返回字符串常量池中的地址
      • jdk7及之后会把字符串对象的引用地址复制一份,放到字符串常量池中,并返回这个引用地址
  • 对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间

StringTable的垃圾回收
1
2
3
4
5
6
7
8
9
10
public class StringGCTest {
/**
* -Xms15m -Xmx15m -XX:+PrintGCDetails
*/
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}

StringTable是会发生垃圾回收的

垃圾回收概述

什么是垃圾
  • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出
为什么需要回收
  • 如果不进行垃圾回收,内存迟早都会被消耗完
  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
  • 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行
早期垃圾回收
  • 在早期的C/C++时代,垃圾回收基本上是手工进行的,开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放
    • 这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担
    • 若由于编码问题忘记回收,那么就会产生内存泄露问题
Java垃圾回收机制
  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
  • GC主要关注区域为堆和方法区,其中堆是GC工作的重点区域
    • 频繁收集Young区
    • 较少收集Old区
    • 基本不收集Perm区(元空间)

垃圾回收相关算法

  • 标记阶段需要区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
  • 当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡
  • 判断对象存活一般有两种方式:引用计数算法可达性分析算法
标记阶段:引用计数算法
  • 对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1,只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收
  • 优点:
    • 实现简单,垃圾对象便于辨识
    • 判定效率高,回收没有延迟性
  • 缺点:
    • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况,这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法
标记阶段:可达性分析算法
  • 可达性分析算法又称为根搜索算法或追踪性垃圾收集
  • 相比于引用计数算法有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
  • 所谓"GC Roots”根集合就是一组必须活跃的引用
  • 算法基本思路
    • 以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
    • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
    • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象
    • 只有能够被根对象集合直接或者间接连接的对象才是存活对象
  • GC Roots包括以下几类元素
    • 虚拟机栈中引用的对象,如各个线程被调用的方法中使用到的参数、局部变量
    • 本地方法栈内JNI(通常说的本地方法)引用的对象
    • 方法区中类静态属性引用的对象,如Java类的引用类型静态变量
    • 方法区中常量引用的对象,如字符串常量池里的引用
    • 所有被同步锁synchronized持有的对象
    • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器
    • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
    • 除了以上这些固定的以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)
      • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性
    • 判断的小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
  • 可达性分析工作必须在一个能保障一致性的快照中进行
    • 所以GC进行时必须STW(stop-the-world)
对象的finalization机制
  • Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
  • 对象被回收之前会调用对象的finalize()方法
  • 通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等
  • 永远不要主动去调用某个对象的finalize()方法
    • 在finalize()时可能会导致对象复活
    • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会
    • 一个糟糕的finalize()会严重影响GC的性能
  • 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
    • 可触及的:从根节点开始,可以到达这个对象
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活
    • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
  • 对象回收判定过程
    • 若对象到GC Roots没有引用链,进行第一次标记
    • 判断对象是否有必要执行finalize()方法
      • 若对象没有重写finalize()方法,或finalize()方法已经被虚拟机调用过,则对象被判定为不可触及的
      • 若对象重写了finalize()方法,且还未执行过,那么对象将被放入F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行
      • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记,如果对象在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,对象会被移出“即将回收”集合,若之后对象再次出现没有引用存在的情况,则它的finalize()方法不会再调用,直接判定为不可触及的,即一个对象的finalize方法只会被调用一次
GC Roots查看

使用MemoryAnalyzer(MAT)软件查看

  • 先获取dump文件

    • 使用命令jmap获取进程的dump文件
    • 使用jvisualvm获取
    • 使用-XX:+HeapDumpOnOutOfMemoryError参数在发生OOM时生成dump文件到程序目录下
  • 使用MAT打开dump文件,通过下图所示方式查看GC Roots

使用jprofiler进行GC Roots溯源

清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存

目前在JVM中比较常见的三种垃圾收集算法是标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)

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

  • 执行过程

    • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象

    • 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收

      • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址

  • 缺点

    • 标记清除算法的效率不算高
    • 在进行GC的时候,需要停止整个应用程序,用户体验较差
    • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表
清除阶段:复制算法(Copying)
  • 核心思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收

  • 优点

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

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

    • 在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代
    • 也就是适合于垃圾对象占比较大的场景(减小复制的开销)
清除阶段:标记-压缩算法(Mark-Compact)
  • 也称为标记-整理算法

  • 执行过程

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

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

  • 优点

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

    • 从效率上来说,标记-整理算法要低于复制算法
    • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
    • 移动过程中,需要全程暂停用户应用程序(STW)
三种算法对比
算法名称 速度 空间开销 移动对象
标记-清除算法 中等 少(会堆积内存碎片)
复制算法 最快 需要存活对象的2倍大小(不堆积内存碎片)
标记-压缩算法 少(不堆积内存碎片)

三种算法各有优缺点,所以没有最优的算法,只有最合适的算法

分代收集算法
  • 不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率
  • 新生代特点:空间相对老年代较小,对象生命周期短、存活率低,回收频繁
    • 这种情况使用复制算法进行垃圾回收,速度是最快的
  • 老年代特点:空间较大,对象生命周期场、存活率高,回收没有新生代频繁
    • 一般是由标记-清除算法或标记-压缩算法混合实现
    • 标记阶段的开销与存活对象的数量成正比
    • 清除阶段的开销与所管理空间大小成正比
    • 压缩阶段的开销与存活对象的数量成正比
  • 以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理
增量收集算法、分区算法
  • 增量收集算法
    • 为了解决STW问题诞生了增量收集(Incremental Collecting)算法
    • 基本思想
      • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成
      • 增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
    • 缺点:因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降
  • 分区算法
    • 也是为了解决STW问题
    • 一般来说,在相同条件下,堆空间越大,一次Gc时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿
    • 分区算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间
    • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间

垃圾回收相关概念

System.gc()的理解
  • 底层是Runtime.getRuntime().gc()

  • 在默认情况下,通过system.gc()的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存

  • 无法保证对垃圾收集器的调用(不能确保立即生效)

内存溢出与内存泄露
  • 内存溢出(OOM)
    • javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存
    • 没有空闲内存的情况说明Java虚拟机的堆内存不够,原因如下
      • Java虚拟机的堆内存设置不够
      • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
    • 在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间
    • 也不是在任何情况下垃圾收集器都会被触发,如分配一个超大对象,超过堆的最大值,JVM判断GC并不能解决这个问题,所以直接抛出OOM
  • 内存泄露(Memory Leak)
    • 只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏
    • 实际开发中由于一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”
    • 内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃
    • 常见的内存泄露案例
      • 单例模式:单例的生命周期和应用程序一样长,当单例只持有对外部对象的引用时,这个外部对象不会被回收,可能会导致内存泄露
      • 一些closeable的资源未及时关闭,如数据库连接,网络连接和io操作
stop-the-world
  • 指的是GC事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应

  • 所有的GC都有这个事件

  • STW是JVM在后台自动发起和自动完成的

  • 开发中不要用System.gc()会导致Stop-the-World的发生

垃圾回收的并行与并发
  • 并发(Concurrent):一个处理器上“同时”处理多个任务,这个同时并不是真正意义上的同时,只是CPU执行速度很快,感觉上像是同时进行
  • 并行(Parallel):多个处理器执行多个任务,任务之间不会互相抢占资源,同时进行
    • 决定并行的因素不是CPU的数量,而是CPU的核心数
  • 并行垃圾回收(Parallel)
    • 多条垃圾回收线程并行工作,此时用户线程仍处于等待状态
    • ParNew、Parallel Scavenge、Parallel Old
  • 串行垃圾回收(Serial)
    • 单线程执行
    • 若内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收,回收完,再启动程序的线程
  • 并发垃圾回收(Concurrent)
    • 用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行,而垃圾收集程序线程运行于另一个CPU上
    • CMS、G1
安全点与安全区域

安全点

  • 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点(Safepoint)
  • 安全点若太少可能导致GC等待的时间太长,若太多可能会导致运行时的性能问题
  • 一般选择一些执行时间较长的指令作为安全点,如方法调用、循环跳转和异常跳转
  • 检查所有线程都跑道安全点的方法
    • 抢先式中断(目前没有虚拟机采用):首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点
    • 主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起

安全区域

  • 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点,但若线程处于Sleep或Blocked状态时,无法响应JVM的中断请求,去安全点进行中断挂起,JVM不能等待线程被唤醒,此时就需要安全区域(Safe Region)来解决

  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的

  • 可以把安全区域看做是扩展了安全点

  • 执行过程

    • 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Relgion,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
    • 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止
强引用
  • 最传统引用的定义,在程序代码之中普遍存在的引用赋值,如“Object obj = new Object()”这种引用关系
  • 在任何情况下,只要强引用关系还存在,就不会被垃圾回收
  • 强引用可以直接访问目标对象
  • 强引用是造成Java内存泄漏的主要原因之一
软引用
  • 在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常
  • 软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存
弱引用
  • 只被弱引用关联的对象只能生存到下一次垃圾收集发生为止
  • 由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象,在这种情况下,弱引用对象可以存在较长的时间
  • 软引用、弱引用都非常适合来保存那些可有可无的缓存数据
  • 对于弱引用对象,GC总是进行回收
虚引用
  • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例
  • 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
终结期引用
  • 它用于实现对象的finalize() 方法,也可以称为终结器引用
  • 无需手动编码,其内部配合引用队列使用
  • 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时才回收被引用的对象

垃圾回收器

GC分类与性能指标
  • 垃圾回收器分类

    • 线程数分,可以分为串行垃圾回收器和并行垃圾回收器
    • 工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
      • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
      • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束
    • 碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器
      • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片
      • 非压缩式的垃圾回收器不进行这步操作
    • 工作的内存区间分,又可分为新生代垃圾回收器和老年代垃圾回收器
  • 评估GC的性能指标

    • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)

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

      • 吞吐量优先,意味着在单位时间内,STW的时间最短

    • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例

    • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间

      • 暂停时间优先,意味着尽可能让单次STW的时间最短
    • 收集频率:相对于应用程序的执行,收集操作发生的频率

    • 内存占用:Java堆区所占的内存大小

    • 快速:一个对象从诞生到被回收所经历的时间

不同的垃圾回收器概述
  • 7种经典的垃圾收集器

    • 串行回收器:Serial、Serial Old
    • 并行回收器:ParNew、Parallel Scavenge、Parallel old
    • 并发回收器:CMS(Concurrent Mark Sweep)、G1
  • 7种经典的垃圾收集器与垃圾分代之间的关系

    • 新生代收集器:Serial、ParNew、Parallel Scavenge
    • 老年代收集器:Serial Old、Parallel Old、CMS
    • 整堆收集器:G1
  • 垃圾收集器的组合关系

    • 两个收集器间有连线,表明它们可以搭配使用
    • 其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案
    • 红色虚线表示由于维护和兼容性测试的成本,在JDK8时将组合声明为废弃,并在JDK9中移除
    • 绿色虚线表示在JDK14移除
    • 蓝色虚框表示在JDK14移除
  • JDK8默认使用ParallelGC(Parallel Scavenge)和ParallelOldGC

  • JDK9及以上默认使用G1GC

Serial回收器:串行回收
  • Serial采用复制算法、串行回收和STW机制的方式执行内存回收
  • Serial用于新生代,Serial Old用于老年代
  • Serial Old采用标记-压缩算法、串行回收和STW机制执行内存回收
    • 在clinet模式下是默认的老年代垃圾收集器
    • 在server模式下有两个用途:
      • 与新生代的Parallel Scavenge组合使用
      • 作为老年代CMS收集器的后备方案
  • 是单线程收集器,它在进行垃圾回收时会暂停其他所有的工作线程,直到它收集结束
  • 优势
    • 简单而高效(与其他收集器的单线程相比),没有切换线程交互的开销
    • 在内存不大的场景下,可以在较短时间内完成垃圾回收,只要频繁发生,使用串行收集器是可以接受的
    • 使用-XX:+UseSerialGC参数可以指定新生代和老年代都使用串行收集器
ParNew回收器:并行回收
  • ParNew除了采用并行回收的方式执行内存回收外,其他与Serial几乎没有区别,即也是使用复制算法、STW机制
  • 在多核环境下,ParNew可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量
  • 在单核环境下,ParNew不比Serial更高效
  • 可以与Serial Old和CMS组合使用
  • 使用-XX:+UseParNewGC参数指定使用ParNew作为新生代的垃圾收集器
  • 使用-XX:ParallelGCThreads参数限制线程数量,默认开启和CPU数相同的线程数
Parallel回收器:吞吐量优先
  • Parallel Scavenge与ParNew一样采用复制算法、并行回收和STW机制

  • 与ParNew不同的是

    • Parallel Scavenge的目标是达到一个可控制的吞吐量,它被视为一个吞吐量优先的垃圾收集器
    • 自适应调节策略与ParNew不同
  • 高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务

  • 还提供了Parallel Old来代替老年代的Serial Old

  • Parallel Old采用标记-压缩算法、并行回收和STW机制

  • 参数配置

    • -XX:+UseParallelGC手动指定新生代使用Parallel并行收集器执行内存回收任务(jdk8默认开启)

    • -XX:+UseParallelOldGC手动指定老年代都是使用并行回收收集器

    • 上面两个,默认开启一个,另一个也会被开启(互相激活)

    • -XX:ParallelGCThreads设置新生代并行收集器的线程数,一般与CPU数相等,避免过多时影响垃圾收集性能

    • -XX:MaxGCPauseMillis设置垃圾收集器最大停顿时间(即STw的时间),单位是毫秒

      • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数
      • 该参数使用需谨慎
    • -XX:GCTimeRatio设置垃圾收集时间占总时间的比例(=1/(N+1)),用于衡量吞吐量的大小

      • 取值范围(0, 100)。默认值99,也就是垃圾回收时间不超过1%
      • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性,暂停时间越长,Radio参数就容易超过设定的比例
    • -XX:+UseAdaptivesizePolicy设置Parallel Scavenge收集器具有自适应调节策略(默认开启)

      • 在这种模式下,新生代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点
      • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作
CMS回收器:低延迟
  • CMS(Concurrent-Mark-Sweep)收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作

  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间

  • CMS的垃圾收集算法采用标记-清除算法和STW机制

  • 与新生代的Serial和ParNew组合使用

  • 工作原理

    • 整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段
    • 初始标记阶段:所有线程都由于STW机制出现短暂的暂停,此阶段仅仅是标记出GC Roots能直接关联到的对象,由于直接关联对象比较小,所以这个阶段暂停时间非常短
    • 并发标记阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    • 重新标记阶段:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段暂停时间比初始标记阶段稍长,但比并发标记阶段时间短
    • 并发清除阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿

  • 由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用

    • 不能像其他收集器一样能老年代满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收
    • 若在CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,此时虚拟机启动后备预案,临时启用Serial Old来重新进行老年代的垃圾收集
  • 由于使用标记-清除算法,垃圾回收后会产生内存碎片,为新对象分配空间时只能选择空闲列表来执行内存分配

    • 由于执行并发清理阶段时,用户线程没有暂停,无法整理内存中的碎片,即无法使用标记-压缩算法
  • 优点:

    • 并发收集
    • 低延迟
  • 缺点:

    • 会产生内存碎片,在无法分配大对象时不得不提前触发Full GC
    • 对CPU资源非常敏感,由于占用了一部分线程而导致应用程序变慢,总吞吐量会降低
    • 无法处理浮动垃圾,重新标记阶段修正的是标记产生变动的标记记录,没有对并发标记阶段中新产生的垃圾进行标记,这些垃圾就是浮动垃圾,只能留到下次GC处理
  • 参数设置

    • -XX:+UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收
      • 启该参数后会自动将-XX:+UseParNewGC打开
    • -XX:CMSInitiatingOccupanyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收
      • JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
      • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能
      • 反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full Gc的执行次数
    • -XX:+UseCMSCompactAtFullCollection用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生
      • 开启此参数会造成停顿时间变得更长
    • -XX:CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理
    • -XX:ParallelCMSThreads设置CMS的线程数量
      • CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是新生代并行收集器的线程数,当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕
小结
  • 若想要最小化地使用内存和并行开销,选择Serial
  • 若想要最大化应用程序的吞吐量,选择Parallel
  • 若想要最小化GC的中断或停顿时间,选择CMS
G1回收器:区域化分代式

概述

  • G1(Garbage First)是在Java7之后引入的,适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量
  • 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望
  • G1是一个并行回收器,将堆内存分割为很多不相关的区域(Region)(物理上不连续的)
    • 使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等
  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)
  • 是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征
  • 是jdk9及之后的默认垃圾回收器
  • 在jdk8中需要使用-XX:+UseG1GC来启用

特点

  • 并行与并发

    • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,此时用户线程STW
    • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
  • 分代收集

    • 从分代上看,G1依然属于分代型垃圾回收器,它会区分新生代和老年代,新生代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、新生代或者老年代都是连续的,也不再坚持固定大小和固定数量
    • 堆空间分为若干个区域(Region),这些区域中包含了逻辑上的新生代和老年代
    • 和之前的各类回收器不同,它同时兼顾新生代和老年代。对比其他回收器,或者工作在新生代,或者工作在老年代
  • 空间整合

    • G1将内存划分为一个个的region,内存的回收是以region作为基本单位的,Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
  • 可预测的停顿时间模型(软实时 soft real-time)

    • G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率
    • 相比于CMS,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多

缺点

  • 在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高
  • 在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势,平衡点在6-8GB之间

参数设置

  • -XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务

  • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000

  • -XX:MaxGCPauseMillis设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是200ms(人的平均反应速度)

  • -XX:+ParallelGCThread 设置STW时GC工作线程数的值

  • -XX:ConcGCThreads设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右

  • -XX:InitiatingHeapOccupancyPercent设置触发并发GC周期的Java堆占用率阈值,超过此值,就触发GC,默认值是45

G1回收器调优

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优

  1. 开启G1垃圾收集器
  2. 设置堆的最大内存
  3. 设置最大的停顿时间

G1中提供了三种垃圾回收模式:Young GC、Mixed GC和Full GC,在不同的条件下被触发

适用场景

  • 面向服务端应用,针对具有大内存、多处理器的机器
  • 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案
    • 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒
  • 用来替换掉JDK1.5中的CMS收集器,在下面的情况时,使用G1可能比CMS好
    • 超过50%的Java堆被活动数据占用
    • 对象分配频率或年代提升频率变化很大
    • GC停顿时间过长(长于0.5至1秒)
  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程

分区Region:化整为零

  • 所有的Region大小相同,且在JVM生命周期内不会被改变
  • 一个region有可能属于Eden,Survivor或者Old/Tenured内存区域,但是一个region只可能属于一个角色
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,主要用于存储大对象,如果超过1.5个region,就放到H
  • 设置H区的原因:对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待
  • 每个Region内都是通过指针碰撞来分配空间

垃圾回收过程

GC的垃圾回收过程主要包括如下三个环节:

  • 新生代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • 单线程、独占式、高强度的Full GC兜底

详细过程

  • 当新生代的Eden区用尽时开始新生代回收过程;G1的新生代收集阶段是一个并行的独占式收集器。在新生代回收期,G1暂停所有应用程序线程,启动多线程执行新生代回收。然后从新生代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及
  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程
  • 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和新生代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和新生代一起被回收的

垃圾回收过程:Remembered Set(RSet)

存在问题:

  • 一个对象被不同区域引用的问题
  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
  • 在其他的分代收集器,也存在这样的问题(而G1更突出)回收新生代也不得不同时扫描老年代?
  • 这样的话会降低MinorGC的效率

解决方法:

  • 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
  • 每个Region都有一个对应的Remembered Set
  • 每次引用类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时中断操作
  • 然后检查将要写入的引用指向的对象是否和该引用类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)
  • 若不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
  • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏

垃圾回收过程:新生代GC

  • 当Eden空间耗尽时,G1会启动一次新生代垃圾回收过程
  • 新生代垃圾回收只会回收Eden区和Survivor区
  • 首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,新生代回收过程的回收集包含新生代Eden区和Survivor区所有的内存分段
  • 详细回收过程
    1. 扫描根:扫描GC Roots
    2. 更新RSet:处理dirty card queue中的card,更新RSet,更新之后,RSet可以准确的反映老年代对所在的内存分段中对象的引用
    3. 处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
    4. 复制对象:遍历对象树,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间
    5. 处理引用:处理软、弱、虚、终极引用

垃圾回收过程:并发标记过程

  1. 初始标记:标记从根节点直接可达的对象,这个阶段是STW的,并且会触发一次新生代GC
  2. 根区域扫描:扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象,这一过程必须在YoungGC之前完成
  3. 并发标记:在整个堆中进行并发标记,此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)
  4. 再次标记:修正上一次的标记结果,是STW的,G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)
  5. 独占清理:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域,是STW的,这个阶段并不会实际上去做垃圾的收集,给下一个阶段做铺垫
  6. 并发清理:识别并清理完全空闲的区域

垃圾回收过程:混合回收

  • 此过程回收部分老年代,而不是全部老年代
  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收
  • 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段,回收算法与新生代的回收算法一样
  • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收
  • 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收

垃圾回收过程:兜底Full GC

  • 导致G1 Full GC的原因
    • 回收时没有足够的内存空间来存放晋升的对象
    • 并发处理过程完成前空间耗尽

G1回收器优化建议

  • 年轻代大小
    • 避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
    • 固定年轻代的大小会覆盖暂停时间目标
  • 暂停时间目标不要太过严苛
    • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
    • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量
垃圾回收器总结

GC日志分析
  • 通过阅读Gc日志,我们可以了解Java虚拟机内存分配与回收策略

  • 内存分配与垃圾回收的参数列表

    • -XX:+PrintGC 输出GC日志。类似:-verbose:gc

      1
      2
      3
      4
      [GC (Allocation Failure)  16334K->14650K(59392K), 0.0027508 secs]
      [GC (Allocation Failure) 30934K->30905K(59392K), 0.0030729 secs]
      [Full GC (Ergonomics) 30905K->30652K(59392K), 0.0062041 secs]
      [Full GC (Ergonomics) 46981K->46848K(59392K), 0.0062492 secs]
      • GC、Full GC:GC的类型,前者只在新生代上进行,后者在新生代、老年代、永久代/元空间上进行
      • Allocation Failure:GC发生的原因,分配失败
      • 16334K->14650K:堆在GC前后的占用大小
      • 59392K:现在堆的大小
      • 0.0027508 secs:GC持续时间
    • -XX:+PrintGCDetails 输出GC的详细日志

      • PSYoungGen:使用了Parallel Scavenge并行垃圾收集器的新生代GC前后占用大小的变化
      • ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后占用大小的变化
      • Metaspace: 元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
      • Times:
        • user:指的是垃圾收集器花费的所有CPU时间
        • sys:花费在等待系统调用或系统事件的时间
        • real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间
    • -XX:+PrintGCTimeStamps输出GC的时间戳(以基准时间的形式)

      1
      2
      3
      4
      0.101: [GC (Allocation Failure)...
      0.104: [GC (Allocation Failure)...
      0.107: [Full GC (Ergonomics)...
      0.114: [Full GC (Ergonomics)...
    • -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)

      1
      2
      3
      4
      2022-10-23T12:17:35.854+0800: [GC (Allocation Failure)...
      2022-10-23T12:17:35.857+0800: [GC (Allocation Failure)...
      2022-10-23T12:17:35.860+0800: [Full GC (Ergonomics)...
      2022-10-23T12:17:35.867+0800: [Full GC (Ergonomics)...
    • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

    • -Xloggc:/opt/logs/gc.log 日志文件的输出路径

    • 补充说明

      • “[GC”、“[Full GC"说明了这次垃圾收集的停顿类型,如果有"Full"则说明GC发生了"Stop The World”

      • 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是"[DefNew"

      • 使用ParNew收集器在新生代的名字会变成"[ParNew",意思是"Parallel New Generation"

      • 使用Parallel scavenge收集器在新生代的名字是"[PSYoungGen"

      • 老年代的收集和新生代道理一样,名字也是收集器决定的

      • 使用G1收集器的话,会显示为"garbage-first heap"

      • Allocation Failure 表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了

      • [PSYoungGen: 16334K->2040K(18432K)] 16334K->14654K(59392K) :[新生代GC前占用大小->GC后占用大小(总大小)] 堆空间GC前占用大小->GC后占用大小(总大小)

      • user代表用户态回收耗时,sys内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间

  • 新生代GC日志

  • Full GC日志

  • 日志分析工具

    • gcviewer
    • gceasy
垃圾回收器的新发展
  • 目前的默认选项G1 GC在不断的进行改进
  • JDK11出现了Epsilon垃圾回收器,"No-Op(无操作)"回收器
  • JDK11出现ZGC(可伸缩的低延迟垃圾回收器,处于实验性阶段)
    • 在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟
    • 基于Region内存布局,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发标记-压缩算法的,以低延迟为首要目标
    • 工作过程可以分为4个阶段:并发标记,并发预备重分配,并发重分配,并发重映射
    • 除了初始标记的是STW的,其余都是并发执行,所以停顿时间非常短
  • JDK12出现Shenandoash GC,低停顿时间的GC(实验性),由oracle公司团队领导开发
  • 阿里巴巴JVM团队基于G1算法,面向大堆(LargeHeap)应用场景开发了AliGC

字节码与类的加载

官方文档

字节码文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

  • u1、u2、u4、u8表示无符号的1字节、2字节、4字节、8字节
  • 所有的表都以"_info"结尾,由于表没有固定长度,所以通常会在其前面加上个数说明
魔数
  • 每个Class文件开头的4个字节的无符号整数称为魔数
  • 魔数是Class文件的标识符
  • 魔数值固定为0xCAFEBABE
  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动
字节码文件版本号
  • 魔数后面4个字节为文件的版本号,前2个字节为副版本号(minor_version),后两个字节为主版本号(major_version)

  • 编译器版本与文件版本对应关系

  • 高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件

常量池
  • 常量池是Class文件中内容最为丰富的区域之一

  • 常量池对于Class文件中的字段和方法解析也有着至关重要的作用

  • 常量池计数器的计数从1开始,而不是从0开始

    • 这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示
  • 常量池表项中,用于存放编译时期生成的各种字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放

    • 它包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量

    • 字面量包括:文本字符串、声明为final的常量值

    • 符号引用包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符

      • 全限定名:如"com/zhuweitung/jvm/Sample;“,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个”;"表示全限定名结束

      • 名称:指没有类型和参数修饰的方法或者字段名称

      • 描述符:描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值

    • 常量池中的每一项都具备相同的特征,第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)

      • 15、16、18的常量项类型是用来支持动态语言调用的(jdk1.7时才加入的)
      • 在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用
      • 当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中
访问标识
  • 在常量池后,紧跟着访问标记

  • 用于识别一些类或者接口层次的访问信息

  • 每个类都会有ACC_SUPER标志

    • ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的
  • ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的

类索引、父类索引、接口索引集合
  • 在访问标记后,会指定该类的类别、父类类别以及实现的接口

  • 格式如下

    长度 含义
    u2 this_class
    u2 super_class
    u2 interfaces_count
    u2 interfaces[interfaces_count]
  • 这些索引确定了类的继承关系

    • 类索引用于确定这个类的全限定名
    • 父类索引用于确定这个类的父类的全限定名
    • 接口索引集合就用来描述这个类实现了哪些接口
字段表集合
  • 官方文档

  • 用于描述接口或类中声明的变量

  • 包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量

  • 字段表集合中不会列出从父类或者实现的接口中继承而来的字段

  • 字段表数据项

  • 字段表访问标识

  • 若一个字段还拥有一些属性,用于存储更多的额外信息,则将属性具体内容存放在属性表集合中

方法表集合
  • 官方文档

  • methods:指向常量池索引集合,它完整描述了每个方法的签名

  • 在字节码文件中,每一个method_info项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等

  • 方法表集合只当前类或接口中声明的方法,不包括从父类或父接口继承的方法

  • 方法表集合有可能会出现由编译器自动添加的方法,如初始化方法<clinit>和实例初始化方法<init>

  • 方法表数据项

  • 方法表访问标识

属性表集合
  • 官方文档

  • 方法表集合之后的属性表集合,指的是class文件所携带的辅助信息,比如该class文件的源文件的名称

  • 这类信息通常被用于Java虚拟机的验证和运行,以及Java程序的调试

  • 字段表、方法表都可以有自己的属性表

  • 属性表数据项通用格式,不同属性类型info内容结构会不一样

  • 属性类型

  • 常见属性结构

    • ConstantValue:表示一个常量字段的值

      1
      2
      3
      4
      5
      ConstantValue_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 constantvalue_index; // 字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。(例如,值是1ong型的,在常量池中便是CONSTANT_Long)
      }
    • Code

      • Code属性就是存放方法体里面的代码
      • 接口或者抽象方法,没有具体的方法体,因此也就不会有Code属性
      • 官方文档

    • LineNumberTable

      • 位于Code结构的属性表
      • 用来描述Java源码行号与字节码行号之间的对应关系
      • 官方文档
      1
      2
      3
      4
      5
      6
      7
      8
      9
      LineNumberTable_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 line_number_table_length;
      {
      u2 start_pc;
      u2 line_number;
      } line_number_table[line_number_table_length];
      }
    • LocalVariableTable

      • 局部变量表
      • 位于Code结构的属性表
      • 用于确定方法在执行过程中局部变量的信息
      • 官方文档
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      LocalVariableTable_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 local_variable_table_length;
      {
      u2 start_pc;
      u2 length;
      u2 name_index; // 局部变量名索引
      u2 descriptor_index; // 局部变量类型描述符索引
      u2 index; // 槽位,可复用
      } local_variable_table[local_variable_table_length];
      }
    • SourceFile

      1
      2
      3
      4
      5
      SourceFile_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 sourcefile_index; // 源码文件索引
      }

字节码指令集

概述
  • 官方文档

  • 字节码指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的0到多个代表此操作所需参数(操作数)构成

  • 大多数的指令都不包含操作数,只有一个操作码

  • 由于一个指令只有一个字节长度(0~2^8-1),所以指令集的操作码总数不超过256条

  • 对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务

    • i代表对int类型的数据操作
    • l代表long
    • f代表float
    • d代表double
加载与存储指令
  • 加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递
  • 局部变量入栈指令:将一个局部变量加载到操作数栈
    • xload、xload_n
    • x为i、l、f、d、a
    • n为0到3,即指令中没有包含操作数,但是操作码中已经隐含了操作数
  • 常量入栈指令
    • 将一个常量加载到操作数栈
    • bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_0、lconst_0、fconst_0、dconst_0
    • const系列
      • 把简单数值类型入栈
      • m1表示-1
      • iconst范围为-1~5
      • lconst范围为0~1
      • fconst范围为0~2
      • dconst范围为0~1
    • push系列
      • bipush将单字节常量入栈
      • sipush将双字节常量入栈
    • ldc系列命令将数值常量或字符串常量从常量池入栈
      • ldc 1字节操作数,操作数为指向常量池中的int、float、String的索引
      • ldcw 2字节操作数
      • ldc2_w 2字节操作数,索引指向的数据类型是long或者double
  • 出栈装入局部变量表指令
    • 将一个数值从操作数栈存储到局部变量表
    • xstore、xstore_n、xastore
  • 出栈装入局部变量表指令
    • xstore_n,n为0~3
    • astore_n,表示将引用类型或者returnAddress类型值存入局部变量
    • xastore,表示将int、long、float、double、引用类型等存入数组
算数指令
  • 算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈

  • 当一个操作产生溢出时,将会使用Infinity值表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示

  • 可以分为两种:对整型数据进行运算的指令与对浮点类型数据进行运算的指令

  • fcmpg与fcmpl的区别在于对Nan值的处理

    • 若操作数1=操作数2,将0入栈
    • 若操作数1>操作数2,将1入栈
    • 若操作数1<操作数2,将-1入栈
    • 若遇到Nan值,fcmpg会将1入栈,fcmpl会将-1入栈
类型转换指令
  • 类型转换指令可以将两种不同的数值类型进行相互转换

  • 一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题

  • 宽化类型转换即类型提升,窄化类型转换(强制类型转换)则相反

  • 从byte、char和 short类型到int类型的宽化类型转换实际上是不存在的

  • 若浮点值为Nan,转换为int或long类型的话就是0

对象的创建于访问指令
  • 可分为创建指令、字段访问指令、数组操作指令、类型检查指令

  • 创建指令

    • 创建类实例与数组使用不同的字节码指令

      创建指令 含义
      new 创建类实例
      newarray 创建基本类型数组
      anewarray 创建引用类型数组
      multilanewarray 创建多维数组
  • 字段访问指令

    字段访问指令 含义
    getstatic、putstatic 访问类字段(static字段,或者称为类变量)的指令
    getfield、 putfield 访问类实例字段(非static字段,或者称为实例变量)的指令
  • 数组操作指令

    • 取数组长度的指令:arraylength,该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
    • 指令xaload表示将数组的元素压栈,要求操作数栈顶元素为数组索引,栈顶顺位第2个元素为数组引用
    • xastore要求操作数栈顶要有3个元素:值、索引、数组引用
  • 类型检查指令

    类型检查指令 含义
    instanceof 判断给定对象是否是某一个类的实例
    checkcast 检查类型强制转换是否可以进行
    • 指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈
    • 指令checkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常
方法调用与返回指令
  • 方法调用指令:invokevirtual、invokeinterface、invokespecial、invokestatic、invokedynamic

    方法调用指令 含义
    invokevirtual 调用对象的实例方法
    invokeinterface 调用接口方法
    invokespecial 调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法
    invokestatic 调用命名类中的类方法(static方法)
    invokedynamic 调用动态绑定的方法
    • invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态,这也是Java语言中最常见的方法分派方式
    • invokeinterface指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用
    • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发
    • invokestatic指令用于调用命名类中的类方法(static方法),这是静态绑定的
    • invokedynamic指令用于调用动态绑定的方法,这个是JDK1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法
    • invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的,而前面4条调用指令的分派逻辑都固化在java虚拟机内部
  • 方法返回指令是根据返回值的类型区分

    方法返回指令 void int long float double reference
    xreturn return ireturn lreturn freutrn dreturn areturn
    • 通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃
    • 如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区
    • 最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者
操作数栈管理指令
  • 可以用于直接操作操作数栈
  • 将一个或两个元素从栈顶弹出,并且直接废弃:pop,pop2
    • pop:将栈顶的1个Slot数值出栈。例如1个short类型数值
    • pop2:将栈顶的2个slot数值出栈。例如1个double类型数值,或者2个int类型数值
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup,dup2,dup_x1,dup2_x1,dup_x2,dup2_×2
    • 不带_x的指令是复制栈顶数据并压入栈顶
    • dup后面的数字表示复制的操作数槽位
    • 对于带x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置,即dup_x1表示在栈顶2个slot下面插入,dup2_×2表示栈顶4个slot下面插入
  • 将栈最顶端的两个Slot数值位置交换:swap。Java虚拟机没有提供交换两个64位数据类型(long、double)数值的指令
  • 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等
控制转移指令
  • 比较指令

    • dcmpg、dcmpl、fcmpg、fcmpl、lcmp
    • 数值类型的数据才能比较大小,boolean、引用数据类型不能比较大小
  • 条件跳转指令

    • 条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转
    • 条件跳转指令有:ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnonnull,接收两个字节的操作数,用于计算跳转的位置
    • ifeq,iflt,ifle,ifne,ifgt,ifge与0进行比较
    • 过程:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置
  • 比较条件跳转指令

    • 较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一
    • if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
      • 其中指令助记符加上“if_”后,以字符“i”开头的指令针对int型整数操作(也包括short和byte类型),以字符“a”开头的指令表示对象引用的比较
    • 接收两个字节的操作数作为参数,用于计算跳转的位置
    • 同时在执行指令时,栈顶需要准备两个元素进行比较。指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句
  • 多条件分支跳转指令

    • 多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitch和lookupswitch

      指令名称 描述
      tableswitch 用于switch条件跳转,case值连续
      lookupswitch 用于switch条件跳转,case值不连续
    • tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高

    • lookupswitch内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低

  • 无条件跳转指令

    • 主要的无条件跳转指令为goto,接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处

    • 如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_w,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围

    • 指令jsr、jsr_w、ret虽然也是无条件跳转的,但主要用于try-finally语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令

      指令名称 描述
      goto 无条件跳转
      goto_w 无条件跳转(宽索引)
      jsr 跳转至指定16位offset位置,并将jsr下一条指令地址压入栈顶
      jsr_w 跳转至指定32位offer位置,并将jsr_w下一条指令地址压入栈顶
      ret 返回至由指定的局部变量所给出的指令位置(一般与jsr、jsr_w联合使用)
异常处理指令
  • athrow指令:显式抛出异常的操作(throw语句)都是由athrow指令来实现
    • 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上
  • 处理异常:在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的
  • 异常表
    • 如果一个方法定义了一个try-catch 或者try-finally的异常处理,就会创建一个异常表,它包含了每个异常处理或者finally块的信息。异常表保存了每个异常处理信息
    • 当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程
    • 不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标
同步控制指令
  • 同步方法

    • 是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法
    • 当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置
    • 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁
    • 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁
    • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放
    • 对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未直接出现在字节码中
  • 同步代码块

    • 当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块
    • 当线程退出同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态
    • 指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的
    • 编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束
    • 为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令

类的加载过程详解

加载阶段
  • 加载的目的就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象

  • 可以分为三步

    • 通过类的全名,获取类的二进制数据流,获取方式如下

      • 虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
      • 读入jar、zip等归档数据包,提取类文件
      • 事先存放在数据库中的类的二进制数据
      • 使用类似于HTTP之类的协议通过网络进行加载
      • 在运行时生成一段class的二进制信息等
      • 在获取到类的二进制信息后,Java虚拟机就会处理这些数据,并最终转为一个java.lang.Class的实例
      • 如果输入数据不是ClassFile的结构,则会抛出ClassFormatError
    • 解析类的二进制数据流为方法区内的数据结构(Java类模型)

      • 加载的类在JVM中创建相应的类结构,类结构会存储在方法区
    • 创建java.lang.Class类的实例,表示该类型,作为方法区这个类的各种数据的访问入口

      • 在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象

  • 数组类的加载

    • 创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建
    • 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型,然后JVM使用指定的元素类型和数组维度来创建新的数组类
链接阶段
  • 验证:目的是保证加载的字节码是合法、合理并符合规范的

    • 格式检查
      • 魔数检查
      • 版本检查
      • 长度检查
    • 语义检查
      • 是否继承final
      • 是否有父类
      • 抽象方法是否有实现
    • 字节码验证
      • 跳转指令是否指向正确位置
      • 操作数类型是否合理
    • 符号引用验证
      • 符合引用的直接引用是否存在
  • 准备:为类的静态变量分配内存,并将其初始化为默认值

    类型 默认初始值
    byte (byte)0
    short (short)0
    int 0
    long 0L
    float 0.0f
    double 0.0
    char \u0000
    boolean false
    reference null
    • final修饰的静态变量在编译阶段就已经赋默认值了,准备阶段进行显示赋值(字面量定义时)
  • 解析:将类、接口、字段和方法的符号引用转为直接引用

初始化阶段
  • 类的初始化阶段是类装载的最后一个阶段,到了此阶段,才真正开始执行类中定义的Java程序代码

  • 类的初始化阶段的主要工作是执行类的初始化方法<clinit>

    • 此方法由Java编译器生成,无法在Java程序中直接调用
    • 它由类静态成员的赋值语句以及静态代码块合并而成
  • 类变量赋值各种情况示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static final int INT_CONSTANT = 10; // 在链接阶段的准备环节赋值
    public static final int NUM1 = new Random().nextInt(10); // 在初始化阶段clinit>()中赋值
    public static int a = 1; // 在初始化阶段<clinit>()中赋值

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100); // 在初始化阶段<clinit>()中赋值
    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(100); // 在初始化阶段<clinit>()中概值

    public static final String s0 = "helloworld0"; // 在链接阶段的准备环节赋值
    public static final String s1 = new String("helloworld1"); // 在初始化阶段<clinit>()中赋值
    public static String s2 = "hellowrold2"; // 在初始化阶段<clinit>()中赋值
    • 没有final修饰或涉及到方法或构造器调用的基本数据类型或String类型的显式赋值在初始化阶段进行
  • <clinit>的线程安全性

    • 虚拟机会在内部确保其多线程环境中的安全性
    • 虚拟机会保证一个类的<clinit>在多线程环境中被正确地加锁、同步
    • 函数<clinit>带锁线程安全的
  • 类的主动使用与被动使用

    • 主动使用会调用<clinit>,被动使用不会调用<clinit>
    • 主动使用的情况
      • 实例化:new、反射、clone、反序列化
      • 调用类静态方法:即invokestatic指令
      • 访问类静态变量:在初始化阶段会进行显式赋值的各种情况
      • 反射
      • 继承
        • Java虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但此规则并不适用于接口
          • 在初始化一个类时,并不会初始化它所实现的接口
          • 在初始化一个接口时,并不会初始化它的父接口
          • 只有当程序首次适用特定接口的静态字段时或接口包含default方法,才会导致该接口的初始化
      • default方法:一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
      • main方法
      • MethodHandle
  • 除了主动使用的情况,其他情况均属于被动使用

    • 被动使用不会引起类的初始化(加载但不初始化)

    • 并不是在代码中出现的类,就一定会被加载或者初始化,如果不符合主动使用的条件,类就不会初始化

    • 常见的被动使用情况

      • 通过子类引用父类的静态变量,不会导致子类的初始化

      • 数组定义

        1
        2
        Obj[] arr = new Obj[10];
        System.out.println(arr.getClass());
      • 引用常量:就是访问不在初始化阶段显式赋值的类变量

      • loadClass方法:调用ClassLoader类的loadClass()方法加载一个类

使用阶段
  • 任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤
  • 开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用new关键字为其创建对象实例
卸载阶段
  • 类、类的加载器、类的实例之间的引用关系

    • 在类加载器的内部实现中,用一个Java集合来存放所加载类的引用,而一个Class对象总是会引用它的类加载器(通过getClassLoader()方法获取),即某个类的Class实例与其类的加载器之间为双向关联关系

    • 一个类的实例总是引用代表这个类的Class对象,通过getClass()方法返回代表对象所属类的Class对象的引用

  • 类的生命周期

    • 当一个类被加载、链接、初始化后,它的生命周期开始了
    • 当类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Class对象在方法区的数据也会被卸载,结束类的生命周期
  • 启动类加载器加载的类型在整个运行期间是不可能被卸载的

  • 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小

  • 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到

类的加载器

概述
  • 类加载器是JVM执行类加载机制的前提
  • 类的加载分类
    • 显式加载:在代码中通过调用ClassLoader加载class对象,如Class.forName、this.getClass().getClassLoader().loadClass()
    • 隐式加载:虚拟机自动加载
  • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性
  • 命名空间
    • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
    • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
    • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
  • 类加载机制的基本特征
    • 双亲委派模型
    • 可见性:子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,反之不行
    • 单一性:由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载
类的加载器分类
  • 引导类加载器:Bootstrap ClassLoader

    • 使用C/C++语言实现的,嵌套在JVM内部
    • 用来加载Java的核心库(JAVAHOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)
    • 没有父加载器
    • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
    • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 自定义类加载器:所有派生于抽象类ClassLoader的类加载器

    • 扩展类加载器:Extension ClassLoader
      • Java语言编写,由sun.misc.Launcher.ExtClassLoader实现
      • 继承于ClassLoader类
      • 父类加载器为启动类加载器
      • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库
    • 系统类加载器:System ClassLoader
      • java语言编写,由sun.misc.Launcher.AppClassLoader实现
      • 继承于ClassLoader类
      • 父类加载器为扩展类加载器
      • 它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库
      • 应用程序中的类加载器默认是系统类加载器
      • 它是用户自定义类加载器的默认父加载器
    • 用户自定义类加载器:User-Defined ClassLoader
      • 通过类加载器可以实现非常绝妙的插件机制
      • 自定义加载器能够实现应用隔离
  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器

  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class ClassLoader {
    ClassLoader parent;
    public ClassLoader(ClassLoader parent) {
    this.parent = parent;
    }
    }
    class ParentClassLoader extends ClassLoader {
    public ParentClassLoader(ClassLoader parent) {
    super(parent);
    }
    }
    class ChildClassLoader extends ClassLoader {
    public ChildClassLoader(ClassLoader parent) {
    super(parent);
    }
    }
ClassLoader源码解析
  • 继承体系

  • 主要方法

    • getParent():返回该类加载器的超类加载器
    • loadClass(String name):加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回 ClassNotFoundException异常。该方法中的逻辑就是双亲委派模式的实现
    • findClass(String name):查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法,JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用
      • 一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象
    • defineClass(String name, byte[] b,int off,int len):根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的
      • defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象
  • SecureClassLoader与URLClassLoader

    • SecureClassLoader扩展了ClassLoader,新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法
    • URLClassLoader这个实现类为findClass()、findResource()等方法提供了具体的实现,并新增了URLClassPath类协助取得Class字节码流等功能
      • 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类
  • ExtClassLoader与AppClassLoader

    • 两个类都继承自URLClassLoader,是sun.misc.Launcher的静态内部类
  • Class.forName()与ClassLoader.loadClass()

    • Class.forName()是一个静态方法,根据传入的类的全限定名返回一个Class对象,该方法在将Class文件加载到内存的同时,会执行类的初始化
    • ClassLoader.loadClass()是一个实例方法,该方法将Class文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化
双亲委派模型
  • 定义与本质

    • 定义:如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载
    • 本质:规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载
  • 优势

    • 避免类的重复加载,确保一个类的全局唯一性
    • 保护程序安全,防止核心API被随意篡改
  • 用户自定义类加载器可以重写loadclass方法来抹去双亲委派机制,但依旧不能加载核心类库。因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用 java.lang.ClassLoader.defineclass(String,byte[],int,int,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护

  • 弊端

    • 检查类是否加载的委托过程是单向的,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类
  • 由于Java虚拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已

  • 热替换

    • 热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为
    • 热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中
沙箱安全机制
  • 沙箱安全机制的作用
    • 保证程序安全
    • 保护Java原生的JDK代码
  • Java安全模型的核心就是Java沙箱
  • 沙箱机制就是将Java代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问
自定义类的加载器
  • 为什么要自定义类加载器
    • 隔离加载类
    • 修改类加载的方式
    • 扩展加载源:比如从数据库、网络、甚至是电视机机顶盒进行加载
    • 防止源码泄漏
  • 实现方式
    • 继承ClassLoader类
    • 方式一:重写loadClass()方法
      • loadclass()这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题
      • 一般避免重写此方法
    • 方式二:重写findclass()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyClassLoader extends ClassLoader {
private String classpath;
public MyClassLoader(String classpath) {
this.classpath = classpath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String classfile = classpath + File.separator + name + ".class";
byte[] bytes = new byte[0];
Class<?> result = null;
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(classfile))) {
bytes = IOUtils.readAllBytes(bis);
result = defineClass(null, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
}
if (result == null) {
throw new ClassNotFoundException(name);
}
return result;
}
}

性能监控与调优

概述

调优概述
  • 监控的依据
    • 运行日志
    • 异常堆栈
    • GC日志
    • 线程快照
    • 堆转储快照
  • 调优的大方向
    • 合理地编写代码
    • 充分并合理的使用硬件资源
    • 合理地进行JVM调优
性能优化步骤
  • 性能监控:发现问题
    • GC频繁
    • cpu load过高
    • OOM
    • 内存泄露
    • 死锁
    • 程序响应时间较长
  • 性能分析:排查问题
    • 打印GC日志,通过GCviewer或者 http://gceasy.io 来分析异常信息
    • 灵活运用命令行工具、jstack、jmap、jinfo等
    • dump出堆文件,使用内存分析工具分析文件
    • 使用阿里Arthas、jconsole、JVisualVM来实时查看JVM状态
    • jstack查看堆栈信息
  • 性能调优:解决问题
    • 适当增加内存,根据业务背景选择垃圾回收器
    • 优化代码,控制内存使用
    • 增加机器,分散节点压力
    • 合理设置线程池线程数量
    • 使用中间件提高程序效率,比如缓存、消息队列等
性能指标
操作 响应时间
打开一个站点 几秒
数据库查询一条记录(有索引) 十几毫秒
机械磁盘一次寻址定位 4毫秒
从机械磁盘顺序读取1M数据 2毫秒
从SSD磁盘顺序读取1M数据 0.3毫秒
从远程分布式换成Redis 读取一个数据 0.5毫秒
从内存读取 1M数据 十几微妙
Java程序本地方法调用 几微妙
网络传输2Kb数据 1 微妙
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
  • 吞吐量
    • 对单位时间内完成的工作量(请求)的量度
    • 在GC中:运行用户代码的事件占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)
    • 吞吐量为1-1/(1+n),其中-XX::GCTimeRatio=n
  • 并发数:同一时刻,对服务器有实际交互的请求数
  • 内存占用:Java堆区所占的内存大小

监控及诊断工具之命令行

jps:查看正在运行的Java进程
1
2
3
jps -help
usage: jps [-help]
jps [-q] [-mlvV] [<hostid>]
  • 参数
    • -q:仅仅显示LVMID(local virtual machine id),即本地虚拟机唯一id。不显示主类的名称等
    • -l:输出应用程序主类的全类名 或 如果进程执行的是jar包,则输出jar完整路径
    • -m:输出虚拟机进程启动时传递给主类main()的参数
    • -v:列出虚拟机进程启动时的JVM参数。比如:-Xms20m -Xmx50m是启动程序指定的jvm参数
    • hostid:RMI注册表中注册的主机名。如果想要远程监控主机上的 java 程序,需要安装 jstatd
  • 如果某 Java 进程关闭了默认开启的UsePerfData参数(即使用参数-XX:-UsePerfData),那么jps命令(以及下面介绍的jstat)将无法探知该Java 进程
jstat:查看JVM统计信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jstat -help
Usage: jstat -help|-options
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

jstat -options
-class
-compiler
-gc
-gccapacity
-gccause
-gcmetacapacity
-gcnew
-gcnewcapacity
-gcold
-gcoldcapacity
-gcutil
-printcompilation
  • jstat(JVM Statistics Monitoring Tool):用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。常用于检测垃圾回收问题以及内存泄漏问题

  • 参数

    • vmid:进程号

    • -option

      • 类装载相关选项

        • -class:显示ClassLoader的相关信息:类的装载、总空间、卸载数量、类装载所消耗的时间等

      • 垃圾回收相关选项

        • -gc:显示与GC相关的堆信息。包括Eden区、两个Survivor区、老年代、永久代等的容量、已用空间、GC时间合计等信息

          表头 含义(字节)
          EC Eden区的大小
          EU Eden区已使用的大小
          S0C 幸存者0区的大小
          S1C 幸存者1区的大小
          S0U 幸存者0区已使用的大小
          S1U 幸存者1区已使用的大小
          MC 元空间的大小
          MU 元空间已使用的大小
          OC 老年代的大小
          OU 老年代已使用的大小
          CCSC 压缩类空间的大小
          CCSU 压缩类空间已使用的大小
          YGC 从应用程序启动到采样时young gc的次数
          YGCT 从应用程序启动到采样时young gc消耗时间(秒)
          FGC 从应用程序启动到采样时full gc的次数
          FGCT 从应用程序启动到采样时的full gc的消耗时间(秒)
          GCT 从应用程序启动到采样时gc的总时间
        • -gccapacity:显示内容与-gc基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间

        • -gcutil:显示内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比

        • -gccause:与-gcutil功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因

        • -gcnew:显示新生代GC状况

        • -gcnewcapacity:显示内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间

        • -geold:显示老年代GC状况

        • -gcoldcapacity:显示内容与-gcold基本相同,输出主要关注使用到的最大、最小空间

        • -gcpermcapacity:显示永久代使用到的最大、最小空间

      • JIT相关选项

        • -compiler:显示JIT编译器编译过的方法、耗时等信息

        • -printcompilation:输出已经被JIT编译的方法

    • interval:用于指定输出统计数据的周期,单位为毫秒。即:查询间隔

    • count:用于指定查询的总次数

    • -t:可以在输出信息前加上一个Timestamp列,显示程序的运行时间。单位:秒

    • -h:可以在周期性数据输出时,输出多少行数据后输出一个表头信息

  • 使用jstat判断是否内存泄露一般步骤

    1. 在长时间运行的 Java 程序中,我们可以运行jstat命令连续获取多行性能数据,并取这几行数据中 OU 列(即已占用的老年代内存)的最小值
    2. 然后,我们每隔一段较长的时间重复一次上述操作,来获得多组 OU 最小值。如果这些值呈上涨趋势,则说明该 Java 程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏
jinfo:实时查看和修改JVM配置参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jinfo -help
Usage:
jinfo [option] <pid>
(to connect to running process)
jinfo [option] <executable <core>
(to connect to a core file)
jinfo [option] [server_id@]<remote server IP or hostname>
(to connect to remote debug server)
where <option> is one of:
-flag <name> to print the value of the named VM flag
-flag [+|-]<name> to enable or disable the named VM flag
-flag <name>=<value> to set the named VM flag to the given value
-flags to print VM flags
-sysprops to print Java system properties
<no option> to print both of the above
  • 参数

    • option

      • -flag name:输出对应名称的参数

      • -flag [±]name:开启或者关闭对应名称的参数 只有被标记为manageable的参数才可以被动态修改

      • -flag name=value:设定对应名称的参数

      • -flags:输出全部的参数

      • -sysprops:输出系统属性

      • no option:输出全部的参数和系统属性

    • pid:进程号

  • 拓展

    • java -XX:+PrintFlagsInitial 查看所有JVM参数启动的初始值
    • java -XX:+PrintFlagsFinal 查看所有JVM参数的最终值
    • java -XX:+PrintCommandLineFlags 查看哪些已经被用户或者JVM设置过的详细的XX参数的名称和值
jmap:导出内存映像文件&内存使用情况
1
2
3
4
5
6
7
8
jmap -h
Usage:
jmap [option] <pid>
(to connect to running process)
jmap [option] <executable <core>
(to connect to a core file)
jmap [option] [server_id@]<remote server IP or hostname>
(to connect to remote debug server)
  • jmap(JVM Memory Map):作用一方面是获取dump文件(堆转储快照文件,二进制文件),它还可以获取目标Java进程的内存相关信息,包括Java堆各区域的使用情况、堆中对象的统计信息、类加载信息等

  • 参数

    • option

      • -dump:生成dump文件(Java堆转储快照),-dump:live只保存堆中的存活对象

        • 手动导出
          • jmap -dump:format=b,file=filename.hprof pid
          • jmap -dump:live,format=b,file=filename.hprof pid
        • 自动导出:程序出现OOM时自动导出
          • +XX:+HeapDumpOnOutOfMemory
          • +XX:HeapDumpPath=filename.hprof
        • 由jmap导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差
          • 假如某些对象的生命周期在两个安全点之间,那么:live选项将无法探知到这些对象
          • 如果某个线程长时间无法跑到安全点,jmap将一直等下去
      • -heap:输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等

      • -histo:输出堆空间中对象的统计信息,包括类、实例数量和合计容量,-histo:live只统计堆中的存活对象

      • -J flag:传递参数给jmap启动的jvm

      • -finalizerinfo:显示在F-Queue中等待Finalizer线程执行finalize方法的对象,仅linux/solaris平台有效

      • -permstat:以ClassLoader为统计口径输出永久代的内存状态信息,仅linux/solaris平台有效

      • -F:当虚拟机进程对-dump选项没有任何响应时,强制执行生成dump文件,仅linux/solaris平台有效

    • pid:进程号

jhat:JDK自带堆分析工具
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
jhat -h
Usage: jhat [-stack <bool>] [-refs <bool>] [-port <port>] [-baseline <file>] [-debug <int>] [-version] [-h|-help] <file>

-J<flag> Pass <flag> directly to the runtime system. For
example, -J-mx512m to use a maximum heap size of 512MB
-stack false: Turn off tracking object allocation call stack.
-refs false: Turn off tracking of references to objects
-port <port>: Set the port for the HTTP server. Defaults to 7000
-exclude <file>: Specify a file that lists data members that should
be excluded from the reachableFrom query.
-baseline <file>: Specify a baseline object dump. Objects in
both heap dumps with the same ID and same class will
be marked as not being "new".
-debug <int>: Set debug level.
0: No debug output
1: Debug hprof file parsing
2: Debug hprof file parsing, no server
-version Report version number
-h|-help Print this help and exit
<file> The file to read
  • jhat(JVM Heap Analysis Tool):Sun JDK提供的jhat命令与jmap命令搭配使用,用于分析jmap生成的heap dump文件(堆转储快照)。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)
  • 参数
    • option
      • -stack false|true:关闭:打开对象分配调用栈跟踪
      • -refs false|true:关闭:打开对象引用跟踪
      • -port port-number:设置jhat HTTP Server的端口号,默认7000
      • -exclude exclude-file:执行对象查询时需要排除的数据成员
      • -baseline exclude-file:指定一个基准堆转储
      • -debug int:设置debug级别
      • -version:启动后显示版本信息就退出
      • -J flag:传入启动参数,比如-J-Xmx512m
    • file:jmap导出的dump文件
jstack:打印JVM中线程快照
1
2
3
4
5
6
7
8
9
10
jstack -h
Usage:
jstack [-l] <pid>
(to connect to running process)
jstack -F [-m] [-l] <pid>
(to connect to a hung process)
jstack [-m] [-l] <executable> <core>
(to connect to a core file)
jstack [-m] [-l] [server_id@]<remote server IP or hostname>
(to connect to a remote debug server)
  • jstack(JVM Stack Trace):用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆栈的集合
  • 生成线程快照(thread dump)的作用:可用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用jstack显示各个线程调用的堆栈情况
  • 需要留意以下内容
    • 死锁,Deadlock(重点关注)
    • 等待资源,Waiting on condition(重点关注)
    • 等待获取监视器,Waiting on monitor entry(重点关注)
    • 阻塞,Blocked(重点关注)
    • 执行中,Runnable
    • 暂停,Suspended
    • 对象等待中,Object.wait() 或 TIMED_WAITING
    • 停止,Parked
  • 参数
    • option
      • -F:当正常输出的请求不被响应时,强制输出线程堆栈
      • -l:除堆栈外,显示关于锁的附加信息
      • -m:如果调用本地方法的话,可以显示C/C++的堆栈
    • pid:进程号
jcmd:多功能命令行
  • 在JDK 1.7以后,新增了一个命令行工具jcmd。它是一个多功能的工具,可以用来实现前面除了jstat之外所有命令的功能。比如:用它来导出堆、内存使用、查看Java进程、导出线程信息、执行GC、JVM运行时间等

  • jcmd拥有jmap的大部分功能,并且在Oracle的官方网站上也推荐使用jcmd命令代jmap命令

  • jcmd -l:列出所有的JVM进程

  • jcmd pid help:针对指定的进程,列出支持的所有具体命令

  • jcmd pid 具体命令:显示指定进程的指令命令的数据

    • Thread.print 可以替换 jstack指令
    • GC.class_histogram 可以替换 jmap中的-histo操作
    • GC.heap_dump 可以替换 jmap中的-dump操作
    • GC.run 可以查看GC的执行情况
    • VM.uptime 可以查看程序的总执行时间,可以替换jstat指令中的-t操作
    • VM.system_properties 可以替换 jinfo -sysprops 进程id
    • VM.flags 可以获取JVM的配置参数信息
jstatd:远程主机信息收集
  • 之前的指令只涉及到监控本机的Java应用程序,而在这些工具中,一些监控工具也支持对远程计算机的监控(如jps、jstat)。为了启用远程监控,则需要配合使用jstatd 工具
  • jstatd是一个RMI服务端程序,它的作用相当于代理服务器,建立本地计算机与远程监控工具的通信。jstatd服务器将本机的Java应用程序信息传递到远程计算机

监控及诊断工具之GUI工具

概述
  • JDK自带的工具
    • jconsole:JDK自带的可视化监控工具。查看Java应用程序的运行概况、监控堆信息、永久区(或元空间)使用情况、类加载情况等
    • jvisualvm:jvisualvm是一个工具,它提供了一个可视界面,用于查看Java虚拟机上运行的基于Java技术的应用程序的详细信息
    • jmc Mission Control,内置Java Flight Recorder。能够以极低的性能开销收集Java虚拟机的性能数据
  • 第三方工具
    • MAT:MAT(Memory Analyzer Tool)是基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
    • JProfiler:商业软件,需要付费,功能强大
    • Arthas:alibaba开源的Java诊断工具
JConsole
  • 用于对JVM中内存、线程和类等的监控,是一个基于JMX(java management extensions)的GUI性能监控工具
JVisualVm
  • 主要功能
    • 生成/读取堆内存/线程快照
    • 查看JVM参数和系统属性
    • 查看运行中的虚拟机进程
    • 程序资源的实时监控
    • JMX代理连接、远程环境监控、CPU分析和内存分析
  • 支持安装插件进行功能扩展,插件中心
Eclipse MAT
  • MAT(Memory Analyzer Tool)工具是一款功能强大的Java堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况

  • MAT可以分析heap dump文件,可以直观地看到当前的内存信息,这些内存信息包含:

    • 所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
    • 所有的类信息,包括classloader、类名称、父类、静态变量等
    • GCRoot到所有的这些对象的引用路径
    • 线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
  • histogram

    • 与jmap -histo效果类似
    • 展示各个类的实例数目以及这些实例的shallow heap(浅堆)总和
    • 还能计算retained heap(深堆),并支持基于实例数据或retained heap的排序方式
  • thread overview

    • 查看系统中Java线程
    • 查看局部变量信息
  • 浅堆(shallow heap)

    • 是指一个对象所消耗的内存
    • 对象头(对象标记和类型指针)+基础数据类型(如int4字节、long8字节等)+引用类型(4字节)+对齐填充
    • 引用类型都是4字节,是指向地址的长度,与地址所对应数据长度无关
    • 对齐填充后最终的大小为8字节的倍数
  • 保留集(retained set)

    • 对象A的保留集指当对象A被垃圾回收后,可以被释放的所有的对象集合(包括对象A本身),即对象A的保留集可以被认为是只能通过对象A被直接或间接访问到的所有对象的集合
  • 深堆(retained heap)

    • 指对象的保留集中所有的对象的浅堆大小之和
Arthas
  • 上述工具都必须在服务端项目进程中配置相关的监控参数,然后工具通过远程连接到项目进程,获取相关的数据。这样就会带来一些不便,比如线上环境的网络是隔离的,本地的监控工具根本连不上线上环境

  • Arthas不需要远程连接,也不需要配置监控参数,同时也提供了丰富的性能监控数据

  • 遇到以下问题可以帮你解决:

    • 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
    • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
    • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
    • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
    • 是否有一个全局视角来查看系统的运行状况?
    • 有什么办法可以监控到JVM的实时运行状态?
    • 怎么快速定位应用的热点,生成火焰图?
  • 基础指令

  • JVM相关指令

  • class/classloader相关指令

  • monitor/watch/trace相关指令

  • 其他指令

JVM运行时参数

标准参数选项
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
java -help
用法: java [-options] class [args...]
(执行类)
或 java [-options] -jar jarfile [args...]
(执行 jar 文件)
其中选项包括:
-d32 使用 32 位数据模型 (如果可用)
-d64 使用 64 位数据模型 (如果可用)
-server 选择 "server" VM
默认 VM 是 server.

-cp <目录和 zip/jar 文件的类搜索路径>
-classpath <目录和 zip/jar 文件的类搜索路径>
用 ; 分隔的目录, JAR 档案
和 ZIP 档案列表, 用于搜索类文件。
-D<名称>=<值>
设置系统属性
-verbose:[class|gc|jni]
启用详细输出
-version 输出产品版本并退出
-version:<值>
警告: 此功能已过时, 将在
未来发行版中删除。
需要指定的版本才能运行
-showversion 输出产品版本并继续
-jre-restrict-search | -no-jre-restrict-search
警告: 此功能已过时, 将在
未来发行版中删除。
在版本搜索中包括/排除用户专用 JRE
-? -help 输出此帮助消息
-X 输出非标准选项的帮助
-ea[:<packagename>...|:<classname>]
-enableassertions[:<packagename>...|:<classname>]
按指定的粒度启用断言
-da[:<packagename>...|:<classname>]
-disableassertions[:<packagename>...|:<classname>]
禁用具有指定粒度的断言
-esa | -enablesystemassertions
启用系统断言
-dsa | -disablesystemassertions
禁用系统断言
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument
-splash:<imagepath>
使用指定的图像显示启动屏幕
-X参数选项
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java -X
-Xmixed 混合模式执行 (默认)
-Xint 仅解释模式执行
-Xbootclasspath:<用 ; 分隔的目录和 zip/jar 文件>
设置搜索路径以引导类和资源
-Xbootclasspath/a:<用 ; 分隔的目录和 zip/jar 文件>
附加在引导类路径末尾
-Xbootclasspath/p:<用 ; 分隔的目录和 zip/jar 文件>
置于引导类路径之前
-Xdiag 显示附加诊断消息
-Xnoclassgc 禁用类垃圾收集
-Xincgc 启用增量垃圾收集
-Xloggc:<file> 将 GC 状态记录在文件中 (带时间戳)
-Xbatch 禁用后台编译
-Xms<size> 设置初始 Java 堆大小
-Xmx<size> 设置最大 Java 堆大小
-Xss<size> 设置 Java 线程堆栈大小
-Xprof 输出 cpu 配置文件数据
-Xfuture 启用最严格的检查, 预期将来的默认值
-Xrs 减少 Java/VM 对操作系统信号的使用 (请参阅文档)
-Xcheck:jni 对 JNI 函数执行其他检查
-Xshare:off 不尝试使用共享类数据
-Xshare:auto 在可能的情况下使用共享类数据 (默认)
-Xshare:on 要求使用共享类数据, 否则将失败。
-XshowSettings 显示所有设置并继续
-XshowSettings:all
显示所有设置并继续
-XshowSettings:vm 显示所有与 vm 相关的设置并继续
-XshowSettings:properties
显示所有属性设置并继续
-XshowSettings:locale
显示所有与区域设置相关的设置并继续

-X 选项是非标准选项, 如有更改, 恕不另行通知。
-XX参数选项
  • Boolean类型格式

    • -XX:+<option> 启用option属性
    • -XX:-<option> 禁用option属性
  • 非Boolean类型格式

    • -XX:<option>=<number> 设置option数值,可以带单位如k/K/m/M/g/G

      -XX:<option>=<string> 设置option字符值

添加JVM参数选项
  • IDEA等开发工具在VM Options中配置即可

  • Tomcat在catalina.sh/catalina.bat文件中添加

    1
    2
    3
    4
    # linux下catalina.sh添加
    JAVA_OPTS="-Xms512M -Xmx1024M"
    # windows下catalina.bat添加
    set "JAVA_OPTS=-Xms512M -Xmx1024M"
  • 程序运行中

    1
    2
    3
    4
    # 设置Boolean类型参数
    jinfo -flag [+|-]<name> <pid>
    # 设置非Boolean类型参数
    jinfo -flag <name>=<value> <pid>
常用的JVM参数选项
  • 打印相关
    • -XX:+PrintCommandLineFlags 程序运行时JVM默认设置或用户手动设置的XX选项
    • -XX:+PrintFlagsInitial 打印所有XX选项的默认值
    • -XX:+PrintFlagsFinal 打印所有XX选项的实际值
    • -XX:+PrintVMOptions 打印JVM的参数
  • 堆、栈、方法区等内存大小设置
      • -Xss128k 即 -XX:ThreadStackSize=128k 设置线程栈的大小为128K
      • -Xms2048m 即 -XX:InitialHeapSize=2048m 设置JVM初始堆内存为2048M
      • -Xmx2048m 即 -XX:MaxHeapSize=2048m 设置JVM最大堆内存为2048M
      • -Xmn2g 即 -XX:NewSize=2g -XX:MaxNewSize=2g 设置年轻代大小为2G
      • -XX:SurvivorRatio=8 设置Eden区与Survivor区的比值,默认为8
      • -XX:NewRatio=2 设置老年代与年轻代的比例,默认为2
      • -XX:+UseAdaptiveSizePolicy 设置大小比例自适应,默认开启
      • -XX:PretenureSizeThreadshold=1024 设置让大于此阈值的对象直接分配在老年代,只对Serial、ParNew收集器有效
      • -XX:MaxTenuringThreshold=15 设置新生代晋升老年代的年龄限制,默认为15
      • -XX:TargetSurvivorRatio 设置MinorGC结束后Survivor区占用空间的期望比例
    • 方法区
      • -XX:MetaspaceSize / -XX:PermSize=256m 设置元空间/永久代初始值为256M
      • -XX:MaxMetaspaceSize / -XX:MaxPermSize=256m 设置元空间/永久代最大值为256M
      • -XX:+UseCompressedOops 使用压缩对象
      • -XX:+UseCompressedClassPointers 使用压缩类指针
      • -XX:CompressedClassSpaceSize 设置Klass Metaspace的大小,默认1G
    • 直接内存
      • -XX:MaxDirectMemorySize 指定DirectMemory容量,默认等于Java堆最大值
  • OutOfMemory相关的选项
    • -XX:+HeapDumpOnOutMemoryError 内存出现OOM时生成Heap转储文件,与下面指令互斥
    • -XX:+HeapDumpBeforeFullGC 出现FullGC时生成Heap转储文件,与上面指令互斥
    • -XX:HeapDumpPath=<path> 指定heap转储文件的存储路径,默认当前目录
    • -XX:OnOutOfMemoryError=<path> 指定可行性程序或脚本的路径,当发生OOM时执行脚本
  • 垃圾收集器相关选项
    • Serial回收器
      • -XX:+UseSerialGC 年轻代使用Serial GC, 老年代使用Serial Old GC
    • ParNew回收器
      • -XX:+UseParNewGC 年轻代使用ParNew GC
      • -XX:ParallelGCThreads 设置年轻代并行收集器的线程数,一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能
    • Parallel回收器
      • -XX:+UseParallelGC 年轻代使用 Parallel Scavenge GC,互相激活
      • -XX:+UseParallelOldGC 老年代使用 Parallel Old GC,互相激活
      • -XX:ParallelGCThreads
      • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STW的时间),单位是毫秒。为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。对于用户来讲,停顿时间越短体验越好;但是服务器端注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。该参数使用需谨慎
      • -XX:GCTimeRatio 垃圾收集时间占总时间的比例(1 / (N+1)),用于衡量吞吐量的大小,取值范围(0,100),默认值99,也就是垃圾回收时间不超过1%,与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例
      • -XX:+UseAdaptiveSizePolicy 设置Parallel Scavenge收集器具有自适应调节策略。在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作
    • CMS回收器
      • -XX:+UseConcMarkSweepGC 年轻代使用CMS GC。 开启该参数后会自动将-XX:+UseParNewGC打开。即:ParNew(Young区)+ CMS(Old区)+ Serial Old的组合
      • -XX:CMSInitiatingOccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。JDK5及以前版本的默认值为68,DK6及以上版本默认值为92%。如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Fu1l GC的执行次数。
      • -XX:+UseCMSInitiatingOccupancyOnly 是否动态可调,使CMS一直按CMSInitiatingOccupancyFraction设定的值启动
      • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完Full GC后对内存空间进行压缩整理。以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
      • -XX:CMSFullGCsBeforeCompaction 设置在执行多少次Full GC后对内存空间进行压缩整理。
      • -XX:ParallelCMSThreads 设置CMS的线程数量。CMS 默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads 是年轻代并行收集器的线程数。当CPU 资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
      • -XX:ConcGCThreads 设置并发垃圾收集的线程数,默认该值是基于ParallelGCThreads计算出来的
      • -XX:+CMSScavengeBeforeRemark 强制hotspot在cms remark阶段之前做一次minor gc,用于提高remark阶段的速度
      • -XX:+CMSClassUnloadingEnable 如果有的话,启用回收Perm 区(JDK8之前)
      • -XX:+CMSParallelInitialEnabled 用于开启CMS initial-mark阶段采用多线程的方式进行标记。用于提高标记速度,在Java8开始已经默认开启
      • -XX:+CMSParallelRemarkEnabled 用户开启CMS remark阶段采用多线程的方式进行重新标记,默认开启
      • -XX:+ExplicitGCInvokesConcurrent
      • -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 这两个参数用户指定hotspot虚拟在执行System.gc()时使用CMS周期
      • -XX:+CMSPrecleaningEnabled 指定CMS是否需要进行Pre cleaning阶段
    • G1回收器
      • -XX:+UseG1GC 手动指定使用G1收集器执行内存回收任务。
      • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
      • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
      • -XX:ParallelGCThread 设置STW时GC线程数的值。最多设置为8
      • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
      • -XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。
      • -XX:G1NewSizePercent 新生代占用整个堆内存的最小百分比(默认5%)
      • -XX:G1MaxNewSizePercent 新生代占用整个堆内存的最大百分比(默认60%)
      • -XX:G1ReservePercent=10 保留内存区域,防止 to space(Survivor中的to区)溢出
  • GC日志相关选项
    • -XX:+PrintGC 即 -verbose:gc 打印简要日志信息
    • -XX:+PrintGCDetails 打印详细日志信息
    • -XX:+PrintGCTimeStamps 打印程序启动到GC发生的时间,搭配-XX:+PrintGCDetails使用
    • -XX:+PrintGCDateStamps 打印GC发生时的时间戳,搭配-XX:+PrintGCDetails使用
    • -XX:+PrintHeapAtGC 打印GC前后的堆信息,如下图
    • -Xloggc:<file> 输出GC导指定路径下的文件中
    • -XX:+TraceClassLoading 监控类的加载
    • -XX:+PrintGCApplicationStoppedTime 打印GC时线程的停顿时间
    • -XX:+PrintGCApplicationConcurrentTime 打印垃圾收集之前应用未中断的执行时间
    • -XX:+PrintReferenceGC 打印回收了多少种不同引用类型的引用
    • -XX:+PrintTenuringDistribution 打印JVM在每次MinorGC后当前使用的Survivor中对象的年龄分布
    • -XX:+UseGCLogFileRotation 启用GC日志文件的自动转储
    • -XX:NumberOfGCLogFiles=1 设置GC日志文件的循环数目
    • -XX:GCLogFileSize=1M 设置GC日志文件的大小
  • 其他参数
    • -XX:+DisableExplicitGC 禁用hotspot执行System.gc(),默认禁用
    • -XX:ReservedCodeCacheSize=<n>[g|m|k]、-XX:InitialCodeCacheSize=<n>[g|m|k] 指定代码缓存的大小
    • -XX:+UseCodeCacheFlushing 放弃一些被编译的代码,避免代码缓存被占满时JVM切换到interpreted-only的情况
    • -XX:+DoEscapeAnalysis 开启逃逸分析
    • -XX:+UseBiasedLocking 开启偏向锁
    • -XX:+UseLargePages 开启使用大页面
    • -XX:+PrintTLAB 打印TLAB的使用情况
    • -XX:TLABSize 设置TLAB大小

分析GC日志

GC日志结构剖析
  • 透过日志看垃圾收集器
    • Serial收集器:新生代显示 “[DefNew”,即 Default New Generation
    • ParNew收集器:新生代显示 “[ParNew”,即 Parallel New Generation
    • Parallel Scavenge收集器:新生代显示"[PSYoungGen",JDK1.7使用的即PSYoungGen
    • Parallel Old收集器:老年代显示"[ParOldGen"
    • G1收集器:显示”garbage-first heap“
  • 透过日志看GC原因
    • Allocation Failure:表明本次引起GC的原因是因为新生代中没有足够的区域存放需要分配的数据
    • Metadata GCThreshold:Metaspace区不够用了
    • Ergonomics:JVM自适应调整导致的GC
    • System:调用了System.gc()方法
  • 透过日志看GC前后情况
    • 中括号内:GC回收前年轻代堆大小,回收后大小(年轻代堆总大小)
    • 括号外:GC回收前年轻代和老年代大小,回收后大小(年轻代和老年代总大小)
  • 透过日志看GC时间:C日志中有三个时间:user,sys和real
    • user:进程执行用户态代码(核心之外)所使用的时间。这是执行此进程所使用的实际CPU 时间,其他进程和此进程阻塞的时间并不包括在内。在垃圾收集的情况下,表示GC线程执行所使用的 CPU 总时间
    • sys:进程在内核态消耗的 CPU 时间,即在内核执行系统调用或等待系统事件所使用的CPU 时间
    • real:程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。对于并行gc,这个数字应该接近(用户时间+系统时间)除以垃圾收集器使用的线程数
    • 由于多核的原因,一般的GC事件中,real time是小于sys time+user time的,因为一般是多个线程并发的去做GC,所以real time是要小于sys+user time的。如果real>sys+user的话,则你的应用可能存在下列问题:IO负载非常重或CPU不够用
GC日志分析工具
  • GCEasy:GCEasy是一款在线的GC日志分析器,可以通过GC日志分析进行内存泄露检测、GC暂停原因分析、JVM配置建议优化等功能,大多数功能是免费的
  • GCViewer:一款离线的GC日志分析器

常见内存泄露情况

  • 静态集合类

    • 静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收

      1
      2
      3
      4
      5
      6
      7
      public class MemoryLeak {
      static List list = new ArrayList();
      public void oomTests() {
      Object obj=new Object(); // 局部变量
      list.add(obj);
      }
      }
  • 单例模式

    • 单例模式,和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和 JVM 的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏
  • 内部类持有外部类

    • 内部类持有外部类,如果一个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏
  • 各种连接,如数据库连接、网络连接和IO连接等

  • 变量不合理的作用域

    • 一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生

      1
      2
      3
      4
      5
      6
      7
      8
      public class UsingRandom {
      private String msg;
      public void receiveMsg(){
      readFromNet(); // 从网络中接受数据保存到msg中
      saveDB(); // 把msg保存到数据库中
      // 上面执行完成后msg依旧被引用,无法回收,可以手动置为null或者将变量声明在方法内部以避免此情况
      }
      }
  • 改变哈希值

    • 当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了
    • 否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏
  • 缓存泄露

    • 如代码中加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据
    • 对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值
  • 监听器和其他回调

    • 如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚
    • 需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为WeakHashMap中的键

JVM知识学习及查漏补缺
https://blog.kedr.cc/posts/2285784166/
作者
zhuweitung
发布于
2022年10月11日
许可协议