JVM基础知识汇总篇

☆* o(≧▽≦)o *☆嗨~我是小奥
个人博客:小奥的博客
CSDN:个人CSDN
Github:传送门
面经分享(牛客主页):传送门
文章作者技术和水平有限,如果文中出现错误,希望大家多多指正!
如果觉得内容还不错,欢迎点赞收藏关注哟! ❤️

文章目录

  • 目录
  • 概述
  • 一、内存区域与垃圾回收
    • 1.1 运行时数据区
      • 1.1.1 程序计数器
      • 1.1.2 虚拟机栈
        • (1) 栈运行原理
        • (2) 栈帧内部结构
          • ① 局部变量表
          • ② 操作数栈
          • ③ 动态链接
          • ④ 方法的返回地址
          • ⑤ 一些附加信息
        • (3) 方法的调用 (动态链接拓展)
          • ① 静态链接
          • ② 动态链接
          • ③ 早期绑定
          • ④ 晚期绑定
          • ⑤ 虚方法和非虚方法
          • ⑥ 普通调用指令和动态调用指令
          • ⑦ invokednamic指令
          • ⑧ 方法重写的本质
          • ⑨ 虚方法表
      • 1.1.3 本地方法栈
      • 1.1.4 堆
        • (1) 堆概述
        • (2) 设置堆内存大小与OOM
        • (3) 年轻代与老年代
        • (4) 对象分配过程
        • (5) Minor GC、Major GC、Full GC
        • (6) 堆空间分代思想
        • (7) 内存分配策略
        • (8) 为对象分配内存:TLAB
          • ① 为什么有TLAB?
          • ② 什么是TLAB?
          • ③ TLAB再说明
        • (9) 堆空间的参数设置
        • (10) 堆是分配对象的唯一选择吗
          • ① 逃逸分析概述
          • ② 逃逸分析:代码优化
          • ③ 逃逸分析并不成熟
      • 1.1.5 方法区
        • (1) 栈、堆、方法区的关系
        • (2) 方法区的理解
        • (3) 设置方法区大小和OOM
          • ① 设置方法区内存大小
          • ② 如何解决OOM
        • (4) 方法区内部结构
          • ① 方法区存储什么
          • ② 类型信息
          • ③ 域(Field)信息
          • ④ 方法信息
          • ⑤ non-final变量
        • (5) 运行时常量池 VS 常量池
          • ① 为什么需要常量池?
          • ② 常量池中有什么?
        • (6) 运行时常量池
        • (7) 方法区使用举例
        • (8) 方法区的演进细节
          • ① 永久代为什么要被元空间替代
          • ② String Table为什么要调整位置
          • ③ 静态变量存放在哪里
        • (9) 方法区的垃圾收集
          • ① 方法区的常量池
    • 1.2 对象实例化及直接内存
      • 1.2.1 对象实例化
        • (1) 创建对象的方式
        • (2) 创建对象的过程
          • ① 判断对象对应的类是否加载、链接和初始化
          • ② 为对象分配内存
          • ③ 处理并发问题
          • ④ 初始化分配的内存
          • ⑤ 设置对象的对象头
          • ⑥ 执行init方法进行初始化
      • 1.2.2 对象的内存布局
        • (1) 对象头(Header)
        • (2) 实例数据(Instance Data)
        • (3) 对齐填充(Padding)
        • (4) 举例
      • 1.2.3 对象的访问定位
      • 1.2.4 直接内存(Direct Memory)
        • (1) 概述
        • (2) 非直接缓存区
        • (3) 直接缓存区
        • (4) 可能导致OOM
    • 1.3 执行引擎
      • 1.3.1 执行引擎概述
        • (1) 概述
        • (2) 工作流程
      • 1.3.2 Java代码编译和执行过程
        • (1) 解释器(Interpreter)和JIT编译器
        • (2) 为什么Java是半编译半解释型语言
      • 1.3.3 机器码、指令、汇编
        • (1) 机器码
        • (2) 指令
        • (3) 汇编语言
        • (4) 高级语言
        • (5) 字节码
      • 1.3.4 解释器
        • (1) 解释器的工作机制
        • (2) 解释器分类
        • (3) 现状
      • 1.3.5 JIT编译器
        • (1) Java代码的执行分类
        • (2) HotSpot执行方式
        • (3) 概念解释
        • (4) 热点代码及探测技术
          • ① HotSpot采用的热点探测
          • ② 方法调用计数器
          • ③ 热度衰减
          • ④ 回边计数器
        • (5) JIT分类
          • ① JIT模式
          • ② 分层编译
          • ③ C1和C2的优化策略
          • ④ AOT编译器
    • 1.4 StringTable
      • 1.4.1 String的基本特性
        • (1) String在JDK9中存储的变更
        • (2) String的基本特性
      • 1.4.2 String的内存分配
      • 1.4.3 String的基本操作
      • 1.4.4 字符串拼接操作
      • 1.4.5 intern()的使用
        • (1) intern的使用:JDK6 VS JDK7/8
        • (2) intern()效率测试
      • 1.4.6 StringTable垃圾回收
      • 1.4.7 G1中的String去重操作
    • 1.5 垃圾回收概述及算法
      • 1.5.1 垃圾回收概述
        • (1) 什么是垃圾
        • (2) 为什么需要GC
        • (3) Java垃圾回收机制
        • (4) GC需要关注的区域
      • 1.5.2 垃圾回收相关算法
        • (1) 引用计数算法
        • (2) 可达性分析算法
        • (3) 再谈引用
        • (4) 对象的finalization机制
        • (5) 标记-清除算法
        • (6) 标记-复制算法
        • (7) 标记-整理算法
        • (8) 分代收集算法
          • ① 年轻代(Young Gen)
          • ② 老年代(Tenured Gen)
        • (9) 增量收集算法
        • (10) 分区算法
    • 1.6 垃圾回收相关概念
      • 1.6.1 System.gc()的理解
      • 1.6.2 内存溢出与内存泄漏
        • (1) 内存溢出(OOM)
        • (2) 内存泄漏(Memory Leak)
      • 1.6.3 Stop The World
      • 1.6.4 垃圾回收的并行与并发
        • (1) 并发
        • (2) 并行
        • (3) 垃圾回收的并发与并行
      • 1.6.5 安全点与安全区域
        • (1) 安全点
          • ① 抢先式中断
          • ② 主动式中断
        • (2) 安全区域
    • 1.7 垃圾回收器
      • 1.7.1 GC分类与性能指标
        • (1) 垃圾回收器概述
        • (2) 垃圾收集器分类
          • ① 按线程数分
          • ② 按照工作模式分
          • ③ 按碎片处理方式分
          • ④ 按工作内存分
        • (3) 评估GC的性能指标
          • ① 吞吐量
          • ② 暂停时间
          • ③ 吞吐量 VS 暂停时间
      • 1.7.2 不同的垃圾回收器概述
        • (1) 垃圾回收器发展史
        • (2) 7种经典的垃圾收集器
        • (3) 收集器与垃圾分代之间的关系
        • (4) 垃圾收集器组合关系
        • (5) 查看默认垃圾收集器
      • 1.7.3 Serial收集器:串行回收
      • 1.7.4 ParNew收集器:并行回收
      • 1.7.5 Parallel Scavenge收集器:吞吐量优先
      • 1.7.6 Serial Old收集器
      • 1.7.7 Parallel Old收集器
      • 1.7.8 CMS收集器:低延迟
        • (1) CMS的优点
        • (2) CMS的缺点
        • (3) 设置的参数
        • (4) JDK后续版本中CMS的优化
      • 1.7.9 G1回收器:区域化分代式
        • (1) 前面已经有很多强大的GC,为什么还要发布G1?
        • (2) 为什么叫G1?
        • (3) G1收集器的优点
          • ① 并行与并发
          • ② 分代收集
          • ③ 空间整合
          • ④ 可预测的停顿时间模型
        • (4) G1收集器的缺点
        • (5) G1回收器的参数设置
        • (6) G1收集器常见操作步骤
        • (7) G1收集器的适用场景
        • (8) 分区Region:化整为零
  • 二、字节码与类加载
    • 2.3 类的加载过程
      • 2.3.1 概述
        • ① 类的初始化
        • ② 接口的初始化
      • 2.3.2 类加载的过程
        • (1) 加载(Loading)
          • ① 加载的理解
          • ② 加载阶段
          • ③ 二进制流获取的方式
          • ④ 类模型与Class实例的位置
          • ⑤ 数组类的加载
        • (2) 链接(Linking)
        • (3) 链接--验证(Verification)
          • ① 文件格式验证
          • ② 元数据验证
          • ③ 字节码验证
          • ④ 符号引用验证
        • (4) 链接--准备(Preparation)
        • (5) 链接--解析(Resolution)
          • ① 类或者接口的解析
          • ② 字段解析
          • ③ 方法解析
          • ④ 接口方法解析
        • (6) 初始化(Initialization)
          • ① static与final的搭配问题
          • ② 类的初始化情况:主动使用和被动使用
        • (7) 类的使用(Using)
        • (8) 类的卸载(Unloading)
          • ① 类、类的加载器、类的实例之间的引用关系
          • ② 类的生命周期
          • ③ 类的卸载
    • 2.4 再谈类加载器
      • 2.4.1 概述
        • (1) 类加载器概念
        • (2) 类加载器的必要性
        • (3) 命名空间
      • 2.4.2 类加载器分类
        • (1) 启动类加载器(Bootstrap)
        • (2) 扩展类加载器(Extension)
        • (3) 系统类加载器(Application)
        • (4) 用户自定义类加载器
      • 2.4.3 ClassLoader源码解析
        • (1) ClassLoader的主要方法
          • ① getParent()
          • ② loadClass()
          • ③ findClass()
          • ④ defineClass
      • 2.4.5 双亲委派模型
        • (1) 定义
        • (2) 优点与缺点
        • (3) 破坏双亲委派机制
          • ① 第一次破坏
          • ② 第二次破坏
          • ③ 第三次破坏
        • (4) 热替换的实现
      • 2.4.6 沙箱安全机制
        • (1) JDK1.0时期
        • (2) JDK1.1时期
        • (3) JDK1.2时期
        • (4) JDK1.6时期
      • 2.4.7 自定义类加载器
        • (1) 为什么要自定义类加载器
        • (2) 实现方式
      • 2.4.8 Java模块化系统
        • (1) 模块化系统概述
        • (2) 模块的兼容性
        • (3) 模块化系统下的类加载器

目录

概述

JDK、JRE和JVM的区别

(1)JDK是Java开发工具包,是功能齐全的SDK,它包含JRE,提供了编译、运行Java程序所需要的各种工具(编译工具javac、打包工具jar)和资源,是整个Java的核心。

(2)JRE是Java运行时环境,它是运行已经编译的Java程序所需要的所有内容的集合,包含JVM以及Java核心类库。

(3)JVM是Java虚拟机,是整个Java实现跨平台的核心部分,负责解释执行字节码文件,是可运行Java字节码文件的虚拟计算机。

JDK包括JRE,JRE包含JVM。

笔记

一、内存区域与垃圾回收

1.1 运行时数据区

JVM基础知识汇总篇_第1张图片

1.1.1 程序计数器

JVM基础知识汇总篇_第2张图片

程序计数器是一块内存比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

程序计数器是线程私有的,是为了使线程切换后能够恢复到正确的执行位置,每个线程都需要由一个独立的程序计数器。各个线程的程序计数器互不影响,独立存储。

程序计数器的作用是:

  • 字节码解释器通过改变程序计数器来依次读取字节码指令,实现代码的流程控制。
  • 程序计数器用于记录线程的执行位置,当切换线程的时候能够恢复到正确的执行位置。

注意:

如果线程正在执行的是一个Java方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,那么这个计数器的值则应为空(Undefind)。

② 程序计数器是唯一一个不会发生OOM和栈溢出的内存区域。

③ 它的生命周期随着线程的创建而创建,随着线程的结束而停止。

1.1.2 虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

注意:虚拟机栈也是线程私有的,生命周期与线程相同。

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

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

  • (静态)如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
  • (动态)如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法中请到足够的内存,或者在创建新的线程时没有足够的内存(内存不足)去创建对应的虚拟机栈那Java虚拟机将会抛出一个 OutOfMemoryError异常。
(1) 栈运行原理

JVM对于栈的操作只有两个:每个方法执行时的入栈、执行结束后的出栈。

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

执行引擎运行的所有字节码指令只对当前栈帧进行操作。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

JVM基础知识汇总篇_第3张图片

(2) 栈帧内部结构

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

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operate Stack)
  • 动态链接(Dynamic Linking)
  • 方法返回地址(Return Address)
  • 一些附加信息

每个线程都有自己的栈,并且每个栈中都有很多栈帧,栈帧的大小主要是由局部变量表操作数栈决定的。

① 局部变量表
  • 定义为一个数字数组,局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。
  • 局部变量表是建立在线程的栈上,是线程的私有数据,不存在数据安全问题。
  • 局部变量表所需容量的大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套的次数由栈的大小决定的。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表直接或者间接引用的对象都不会被回收。

注意:reference类型并不等同于对象本身,可能是指向对象地址的起始地址的引用指针,也可能是指向一个代表对象的聚标或者其他与此对象相关的位置。

slot

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

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

② 操作数栈

每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(LIFO)的操作数栈,也可以称为表达式栈

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

如果被调用的方法带有返回值的话,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

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

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

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

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

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

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

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

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

代码举例:

	public static void main(String[] args) {
        byte i = 15;
        int j = 8;
        int k = i + j;
    }

字节码指令信息:

 0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

栈顶缓存技术

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

③ 动态链接

每个栈帧内部包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了能够实现动态链接。

动态链接的作用就是为了将常量池中的符号引用转换为调用方法的直接引用。

**静态链接:**被调用的目标方法在编译期可知,且运行期保持不变。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

**动态链接:**被调用的目标方法在编译期无法被确定下来,只能够在程序运行期间将调用方法的符号引用转化为直接引用的过程,称之为动态链接。

④ 方法的返回地址

方法的返回地址用来存放调用该方法的PC寄存器的值。

一个方法的结束有两种形式:正常执行完成或者出现未处理的异常,非正常退出。

  • 方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址
  • 方法异常退出时,返回地址要通过异常表来确定,栈帧中一般不会保存者这部分信息

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

  • 执行引擎遇到任意一个方法的返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称为正常完成出口
  • 在方法执行过程中遇到异常(exception),并且这个异常没有在方法内处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口

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

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

另外,一个方法在正常调用完成之后,究竟要使用哪个返回指令,还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含:

  • ireturn:当返回值是boolean、byte、char、short、int类型时使用
  • lreturn:long类型使用
  • freturn:float类型使用
  • dreturn:double类型使用
  • areturn
  • return:声明为void的方法、实例初始化方法、类和接口的初始化方法
⑤ 一些附加信息

栈帧中还允许携带Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。

(3) 方法的调用 (动态链接拓展)

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

① 静态链接

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

② 动态链接

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

对应的方法的绑定机制为:早期绑定(Early Binding)晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

③ 早期绑定

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

④ 晚期绑定

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

⑤ 虚方法和非虚方法

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法

静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法

⑥ 普通调用指令和动态调用指令

普通调用指令:

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

动态调用指令:

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

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法

⑦ invokednamic指令

JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。

但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。

Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。

⑧ 方法重写的本质

Java语言中方法重写的本质:

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

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

虚方法表会在类加载的链接阶段被创建并初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕。

1.1.3 本地方法栈

一个Native Method是一个Java调用非Java代码的接囗。本地接口的作用是融合不同的编程语言为Java所用。

Java虚拟机栈用于执行Java方法(也就是字节码)服务,而本地方法栈用于**管理本地方法(被native修饰的方法)**的调用。

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

允许被实现成固定或者是可动态扩展的内存大小:

  • 如果线程请求分配的容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存区创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。

本地方法的调用具体是在本地方法栈中登记native方法,在执行引擎执行时加载本地方法库。

当某个线程调用一个本地方法时,它就进入了一个全新并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限:

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存

并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。

在HotSpot中,直接将本地方法栈和虚拟机栈合二为一。

1.1.4 堆

对于Java应用程序来说,Java堆是虚拟机所管理内存区域中最大的一块。

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在堆区分配内存。

Java堆是垃圾收集器管理的内存区域。

如果从内存分配的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但它在逻辑上应该被视为连续的。

(1) 堆概述

Java7以及之前堆内存逻辑分为三部分:新生代 + 老年代 + 永久代

  • Young Generation Space 新生区 Young/New 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure
  • Permanent Space 永久区 Perm

Java8以及之后堆内存逻辑分为三部分:新生代 + 老年代 + 元空间

  • Young Generation Space 新生区 Young/New 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure
  • Meta Space 元空间 Meta

堆空间的内部结构(JDK7):

JVM基础知识汇总篇_第4张图片

堆空间的内部结构(JDK8):

JVM基础知识汇总篇_第5张图片

(2) 设置堆内存大小与OOM

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了。

  • -Xms:表示堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx:表示堆区的最大内存,等价于-XX:MAXHeapSize

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

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

默认情况下

  • 初始内存大小:物理电脑内存大小 / 64

  • 最大内存大小:物理电脑内存大小 / 4

(3) 年轻代与老年代

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致

堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)。其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。

JVM基础知识汇总篇_第6张图片

下面的参数在开发中一般不会调整:

JVM基础知识汇总篇_第7张图片

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

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 可以修改为-XX:NewRation=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
(4) 对象分配过程

① new的对象先放在伊甸园区。此区大小有限制。

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

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

④ 如果再次触发垃圾回收,此时上次幸存下来的存放到幸存者0区的,如果没有被回收,就会方法幸存者1区。

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

⑥ 如果一个对象的幸存次数达到了15次,就会将该对象放到老年代。(可以设置 -XX:MaxTenuringThreshold=N)。

⑦ 当老年代内存不足时,再次触发Major GC,进行老年代的内存清理。

⑧ 当老年代执行了Major GC后,如果依然无法进行对象的存储,就会产生OOM异常。

JVM基础知识汇总篇_第8张图片

流程图如下:

JVM基础知识汇总篇_第9张图片

(5) Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对上面的三个内存区域进行垃圾回收,大部分回收的都是指新生代。

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:

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

新生代GC(Minor GC)触发机制

  • 当新生代空间不足时,就会触发Minor GC,这里的新生代空间不足是指Eden区满,Survivor区满不会引发GC。(每次Minor GC会清理年轻代的内存)
  • 因为Java对象大多数都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • Minor GChi引发 STW,暂停用户其他线程,等垃圾回收结束,才会恢复用户线程。

老年代GC(Major GC 、Full GC)触发机制

(1)Major GC触发时机

  • 出现了Major GC,经常会伴随至少一次的Minor GC。也就是说老年代空间不足时,会现场时触发Minor GC,如果之后空间还不足,则触发Major GC。
  • Major GC的速度一般比Minor GC慢10倍以上,STW的时间更长。
  • 如果Major GC后,内存还不足,则会产生OOM异常。

(2)Full GC触发时机

  • 调用System.gc()时,系统建议执行Full GC,但是不一定会执行。
  • 老年代空间不足时
  • 方法区空间不足时
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
(6) 堆空间分代思想

为什么要把Java堆分代?优化GC性能,如果没有分代,所有的对象都在一块,GC的时候就要对堆的所有区域进行扫描。由于很多对象都是朝生夕死的,分代后方便将这些对象进行回收。

JVM基础知识汇总篇_第10张图片

(7) 内存分配策略

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

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

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

  • 优先分配到eden区
  • 大对象直接分配到老年代(尽量避免程序中出现过多的大对象)
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:如果survivor区中相同年龄的所有对象的大小总和大于survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无序等待MaxTenuringThreshold中要求的年龄
  • 空间分配担保:-XX:HandlePromotionFailure
(8) 为对象分配内存:TLAB
① 为什么有TLAB?
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是不安全的。
  • 为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
② 什么是TLAB?
  • 从内存模型而不是垃圾收集角度,对Eden区继续进行划分,JVM为每一个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种分配方式称之为快速分配策略

JVM基础知识汇总篇_第11张图片

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

JVM基础知识汇总篇_第12张图片

(9) 堆空间的参数设置
// 详细的参数内容会在JVM下篇:性能监控与调优篇中进行详细介绍,这里先熟悉下
-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:HandlePromotionFalilure//是否设置空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的。
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。
    • 如果HadnlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。
      • 如果大于,则尝试进行一次Minor GC,但是这次Minor GC是有风险的。
      • 如果小于,则改为一次Full GC。
    • 如果HadnlePromotionFailure=false,则改为进行一次Full GC。

在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC。

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

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

① 逃逸分析概述

如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
// 没有发生逃逸的对象,则可以分配到栈上,
// 随着方法执行的结束,栈空间就会被移除
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb; // 发生了逃逸
}
// 如果想使上述代码不发生逃逸,则可以使用以下写法 
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

在JDK6之后,HotSpot默认开启了逃逸分析。

如果使用的是较早的版本,开发人员可以通过:

  • 选项:-XX:+DoEscapeAnalysis 显式开启逃逸分析
  • 通过选项 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果
② 逃逸分析:代码优化

通过逃逸分析,编译器可以对代码做出如下优化:

  • 栈上分配:将堆分配转化为栈上分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不发生逃逸,对象可能是栈上分配的候选,而不是堆上分配。
  • 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存中,而是存储在CPU寄存器中。

(1)栈上分配

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

常见的栈上分配场景:给成员变量赋值、方法返回值、实例引用传递。

(2)同步省略

线程同步的代价是相当高的,同步的后果是降低并发性和性能。在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

public void f() {
    Object hellis = new Object();
    synchronized(hellis) {
        System.out.println(hellis);
    }
}
// 如果hellis对象的生命周期只存在f方法中,并不会被其他线程访问到,那么在JIT编译阶段就会被优化掉
public void f() {
    Object hellis = new Object();
	System.out.println(hellis);
}

(3)标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

public static void main(String args[]) {
    alloc();
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
    private int x;
    private int y;
}
// 以上代码,经过变量替换后,就会变成如下代码
private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}

标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。

标量替换参数设置

参数-XX:EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配到栈上。

③ 逃逸分析并不成熟

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

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

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

1.1.5 方法区

(1) 栈、堆、方法区的关系

JVM基础知识汇总篇_第13张图片

(2) 方法区的理解

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  • 方法区和堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且实际的物理内存和堆区一样都可以是不连续的。
  • 方法区的大小跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace
(3) 设置方法区大小和OOM
① 设置方法区内存大小

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

JDK7之前:

  • 通过设置永久代初始分配空间,默认值是20.75M,-XX:Permsize
  • 通过来设定永久代最大可分配空间。32位及机器默认是64M,64位机器默认是82M,-XX:MaxPermsize
  • 当JVM加载的类信息容量超过了这个值,会抛出异常OutOfMemoryError:PermGen space

JDK8之后:

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize指定。
  • 默认值依赖于平台。windows下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1 //即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
② 如何解决OOM
  • 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GCRoots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  • 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
(4) 方法区内部结构

JVM基础知识汇总篇_第14张图片

① 方法区存储什么

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

JVM基础知识汇总篇_第15张图片

② 类型信息

对每个加载的类型(类Class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 类型的完整的有效名称(全名 = 包名.类名)
  • 类型的直接父类的完整有效名(对于interface或者是java.lang.Object,都没有父类)
  • 类型的修饰符(public、abstract、final的某个子集)
  • 类型直接接口的一个有序列表
③ 域(Field)信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:域名称域类型域修饰符(public、private、protected、static、final、volatile、transient的某个子集)。

④ 方法信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法返回类型(或void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
⑤ non-final变量
  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它。
  • 全局常量(static final),被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
(5) 运行时常量池 VS 常量池

方法区,内部包含了运行时常量池。

字节码文件,内部包含了常量池。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

① 为什么需要常量池?

一个Java源文件、接口,编译后产生一个字节码文件。而Java中的字节码文件需要数据支持,通常这种数据会很大以至于不能直接存放到字节码中,换另一种方式,可以存到常量池中,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

比如下面这段简单的代码:

public class SimpleClass {
	public void sayHello() {
		System.out.println("hello");
	}
}

虽然代码很简单,但是却引用了String、System、PrintStream以及Object结构,这就需要用到常量池了。

② 常量池中有什么?

常量池中存储的数据类型包括:

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

例如下面这段代码:

public class MethodAreaTest2 {
    public static void main(String args[]) {
        Object obj = new Object();
    }
}

将会被翻译成如下字节码:

0: new #2  // Class java/lang/Object  创建一个新的Object对象(#2 表示常量池中的Object类)
1: dup // 复制栈顶数值并将复制值压入栈顶
2: invokespecial // Method java/lang/Object ""() V
(6) 运行时常量池
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池。
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
(7) 方法区使用举例
public class MethodAreaDemo {
    public static void main(String args[]) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a+b);
    }
}
(8) 方法区的演进细节

首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

HotSpot中方法区的变化:

JDK版本 方法区
JDK1.6及之前 永久代(permanet),静态变量存储在永久代上
JDK1.7 永久代,字符串常量池,静态变量移除,保存在堆中
JDK1.8 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,
但字符串常量池、静态变量仍然在堆中
① 永久代为什么要被元空间替代

随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:

  • **为永久代设置空间大小是很难确定的。**在某些场景下,如果动态加载类过多,容易产生Perm区的oom。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
  • 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
  • 对永久代进行调优是很困难的。
② String Table为什么要调整位置

JDK7中将String Table放到了堆空间中,因为永久代的回收效率很低,在Full GC的时候才会触发,而Full GC是老年代空间不足、永久代不足时才会触发。

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

③ 静态变量存放在哪里
(9) 方法区的垃圾收集

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

① 方法区的常量池

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

字面量比较接近Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包含下面三类常量:

  • 类和接口的全限定类名
  • 字段的名称和描述符
  • 方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

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

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息。

1.2 对象实例化及直接内存

1.2.1 对象实例化

(1) 创建对象的方式
  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法
  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public
  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis
(2) 创建对象的过程
① 判断对象对应的类是否加载、链接和初始化

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。

如果没有,那必须先执行相应的类加载过程。在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的.class文件:

  • 如果没有找到文件,则抛出ClassNotFoundException异常
  • 如果找到文件,则进行类加载,并生成Class对象
② 为对象分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

如果内存规整:

所有被使用过的内存都被放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的举例,这种分配方式称为**“指针碰撞(Bump The Pointer)”**。

如果内存不规整:

已经被使用的内存相互交错在一起,那么就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大小的内存划分给对象实例,并更新列表上的记录,这种分配方式被称为**“空闲列表(Free List)”**。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定的。

因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除( Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

③ 处理并发问题

对象创建在虚拟机中是非常频繁的行为,即时仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。

保证线程安全有两种方式:

  • 对分配内存空间的动作进行同步处理 – 实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。

虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

④ 初始化分配的内存

内存分配完成之后,虚拟机必须将分配的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

⑤ 设置对象的对象头

虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。

这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否使用偏向锁等,对象头会有不同的设置。

⑥ 执行init方法进行初始化

从虚拟机视角来看,执行完上面的流程,一个新的对象已经产生了。但是从Java程序的视角来看,对象的创建才刚刚开始-- 构造函数,即Class文件中的()方法还没有执行,所有字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构建好。

初始化成员变量,执行实例化代码,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

1.2.2 对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)实例数据( Instance Data)对齐填充(Padding)

(1) 对象头(Header)

HotSpot虚拟机对象头部分包括两部分信息。

第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别占32个比特和64个比特,官方称为“Mark Word”。

例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,其他状态下(轻量级锁定、重量级锁定、GC标记、可偏向)对象的存储内存如下表所示:

JVM基础知识汇总篇_第16张图片

**对象头的另一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针确定该对象是哪个类的实例。**此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

(2) 实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认分配策略中可以看到:

  • 相同宽度的字段总是被分配到一起存放。
  • 父类中定义的变量会出现在子类之前。
  • 如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认为true),那子类中较窄的变量也允许插入父类变量的空隙之中,节省一点点空间。
(3) 对齐填充(Padding)

**对齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。**由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全

(4) 举例
public class Customer{
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }

    public Customer() {
        acct = new Account();
    }
}

public class CustomerTest{
    public static void main(string[] args){
        Customer cust=new Customer();
    }
}

JVM基础知识汇总篇_第17张图片

1.2.3 对象的访问定位

由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象的访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

(1)如果使用句柄访问的话,Java堆中可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据类型数据各自的地址信息。

JVM基础知识汇总篇_第18张图片

(2)如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

JVM基础知识汇总篇_第19张图片

这两种对象访问方式各有优势:

  • 使用句柄访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被改变。
  • 使用直接指针访问最大好处就是速度快,它节省了一次指针定位的时间开销。

1.2.4 直接内存(Direct Memory)

(1) 概述

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。直接内存是在Java堆外的、直接向系统申请的内存空间。通常,访问直接内存的速度会优于Java堆,即读写性能高。

在JDK 1.4中新加入了NIO (New Input/Output)类,引入了一种基于通道(Channel〉与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

(2) 非直接缓存区

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

JVM基础知识汇总篇_第20张图片

(3) 直接缓存区

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

JVM基础知识汇总篇_第21张图片

(4) 可能导致OOM
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory 
    at java.nio.Bits.reserveMemory(Bits.java:693)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
    at com.atguigu.java.BufferTest2.main(BufferTest2.java:20)

由于直接内存在堆外,所以直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。

直接内存大小可以通过MaxDirectMemorySize设置。如果不指定,默认与堆的最大值-Xmx参数值一致。

1.3 执行引擎

1.3.1 执行引擎概述

(1) 概述

执行引擎是Java虚拟机核心的组成部分之一。执行引擎属于JVM的下层,里面包括解释器、即时编译器、垃圾回收器

JVM基础知识汇总篇_第22张图片

“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

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

如果想要一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的机器指令。

(2) 工作流程

① 执行引擎在执行过程中需要执行什么样的字节码指令完全依赖于PC寄存器。

② 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。

③ 每当方法在执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆中的对象实例信息,以及通过对象头的元数据指针定位到目标对象的类型信息

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程

1.3.2 Java代码编译和执行过程

JVM基础知识汇总篇_第23张图片

大部分的程序代码转化为物理机目标代码或者虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

Java代码编译是由Java源码编译器(前端编译器)来完成,流程图如下所示:

JVM基础知识汇总篇_第24张图片

Java字节码的执行是由JVM执行引擎(后端编译器)来完成,流程图如下:

JVM基础知识汇总篇_第25张图片

(1) 解释器(Interpreter)和JIT编译器

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT编译器:虚拟机将源代码直接编译成本地机器平台相关的机器语言。

(2) 为什么Java是半编译半解释型语言

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

JVM基础知识汇总篇_第26张图片

1.3.3 机器码、指令、汇编

(1) 机器码

各种用二进制编码方式表示的指令,叫做机器指令码。机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。

(2) 指令

由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好。由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。

(3) 汇编语言

由于指令的可读性还是太差,于是人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用mark地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

(4) 高级语言

为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言。当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序编译程序

JVM基础知识汇总篇_第27张图片

(5) 字节码

字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码。**字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。**字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode

1.3.4 解释器

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

为什么Java源文件不直接翻译成机器码?因为直接翻译的代价是比较大的。

(1) 解释器的工作机制

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

(2) 解释器分类

在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成。

  • Interpreter模块:实现了解释器的核心功能。
  • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令。
(3) 现状

由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃

为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升

不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

1.3.5 JIT编译器

(1) Java代码的执行分类

第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行 。

第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用**即时编译技术(JIT,Just In Time)**将方法编译成机器码后再执行 。

(2) HotSpot执行方式

HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。

同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许, HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现**“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)**退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。

JVM基础知识汇总篇_第28张图片

(3) 概念解释

Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。

  • 前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
  • JIT编译器:HotSpot VM的C1、C2编译器。
  • AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
(4) 热点代码及探测技术

在运行过程中会被即时编译器编译的目标是**“热点代码”**,这里的热点代码主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

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

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于 是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一 部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执 行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为 编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了

要知道某段代码是不是热点代码,是不是触发即时编译,这个行为称为**“热点探测”(HotSpot Code Detection)**,热点探测并不一定需要知道方法具体被执行了多少次,目前主流的热点探测判断方式有两种。

(1)采样热点探测

基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方 法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

(2)计数器热点探测

基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为 每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

① HotSpot采用的热点探测

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

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

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

当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译器。

② 方法调用计数器

这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。

当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法的调用计数器与回边计数器之和是否超过方法调用计数器的阈值。一旦已超过阈值,将会向即时编译器提交一个该方法的代码编译请求。

如果没有做任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入 口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。

③ 热度衰减

在默认情况下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让 它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay)。而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便执行的,可以使用虚拟机参数-XX:UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的决定次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

④ 回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到的控制流向后跳转的指令就称为**“回边(Back Edge)”**,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。

关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。

  • 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX:OnStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。
  • 虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX: InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStack ReplacePercentage默认值为140,- XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈 值为10700。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有 的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回 边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

(5) JIT分类
① JIT模式

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,被称为**“客户端编译器”(Client Compiler)“服务端编译器”(Server Compiler)**。或者简称为C1编译器和C2编译器。第三个是在JDK10才出现的、长期目标是代替C2的Graal编译器。

在**分层编译(Tiered Compilation)**的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中 一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机 会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

  • -client:指定Java虚拟机运行在client模式下,并使用C1编译器;C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
  • -server:指定Java虚拟机运行在server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方 式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

$java -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)

$java -Xint -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)

$java -Xcomp -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花 费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。

② 分层编译

为了在程序启动响应速度与运行效率之间达到最佳平衡, HotSpot虚拟机在编译子系统中加入了分层编译的功能。在JDK 7的服务端模式虚拟机中作为默认 编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包 括:

  • 第0层,程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层,使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第2层,仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层,仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层,使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可调整分层的数量。各层次编译之间的交互、转换关系如下图所示:

JVM基础知识汇总篇_第29张图片

实施分层编译后,解释器客户端编译器服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

③ C1和C2的优化策略

在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联去虚拟化冗余消除

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
  • 去虚拟化:对唯一的实现类进行内联。
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉。

C2的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在C2上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值。
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆。
  • 同步消除:清除同步操作,通常指synchronized。
④ AOT编译器

jdk9引入了AOT编译器(静态提前编译器,Ahead of Time Compiler)。Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。

所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。

优点:Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少Java应用给人带来“第一次运行慢” 的不良体验。

缺点:

  • 破坏了 java “ 一次编译,到处运行”的理念,必须为每个不同的硬件,OS编译对应的发行包。
  • 降低了Java链接过程的动态性,加载的代码在编译器就必须全部已知。
  • 还需要继续优化中,最初只支持Linux X64 java base。

1.4 StringTable

1.4.1 String的基本特性

  • String:字符串,使用一对""引起来表示
  • String声明为final的,不可被继承
  • String实现了Serializable接口:表示字符串是支持序列化的。
  • String实现了Comparable接口:表示string可以比较大小
  • String在jdk8及以前内部定义了final char[] value用于存储字符串数据。JDK9时改为byte[]
(1) String在JDK9中存储的变更

根据官方文档翻译

动机:

目前String类的实现将字符存储在一个char数组中,每个字符使用两个字节(16位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含Latin-1字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。

说明:

我们建议将String类的内部表示方法从UTF-16字符数组改为字节数组加编码标志域。新的String类将根据字符串的内容,以ISO-8859-1/Latin-1(每个字符一个字节)或UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。

与字符串相关的类,如AbstractStringBuilder、StringBuilder和StringBuffer将被更新以使用相同的表示方法,HotSpot VM的内在字符串操作也是如此。

String改成了byte[]数组存储,并且加上了编码标记,节省了一些空间。

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;
}
(2) String的基本特性

String:代表不可变的字符序列。简称:不可变性。

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
  • 当调用string的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

1.4.2 String的内存分配

在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

JDK6及以前,字符串常量池存放在永久代。

JDK7时,将字符串常量池的位置调整到Java堆内。

  • 所有的字符串都保存在堆中,和其他普通对象一样,这样在调优的时候仅需要调整堆大小就可以了。
  • 字符串常量池的概念原本用的比较多,这个改动让我们重新在Java7中使用String.intern()方法。

JDK8及以后,字符串常量存放在堆空间。

为什么要调整StringTable

在JDK7中,内部字符串不再分配在Java堆的永久代中,而是分配在Java堆的主要成分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主Java堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用String.intern()方法的大型应用程序将看到更明显的差异。

1.4.3 String的基本操作

Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类型。

public staic void main(String[] args) {
	 System.out.print1n("1"); //2321
    System.out.println("2");
    System.out.println("3");
    System.out.println("4");
    System.out.println("5");
    System.out.println("6");
    System.out.println("7");
    System.out.println("8");
    System.out.println("9");
    System.out.println("10"); //2330
    System.out.println("1"); //2321
    System.out.println("2"); //2322
}

1.4.4 字符串拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化。
  • 常量池中不会存在相同内容的变量。
  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
  • 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

举例1

public class Main {
    public static void main(String[] args) {
    	// 都是常量,前端编译器会进行代码优化 
    	// 通过idea直接看对应反编译的class文件,会显示String s1 = "abc"
        String s1 = "a" + "b" + "c";
        String s2 = "abc";
        // true,s1和s2实际上指向字符串常量池中的同一个值
        System.out.println(s1 == s2);
    }
}
// 上述代码的class文件 
public class Main {
    public Main() {
    }

    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "abc";
        System.out.println(s1 == s2);
    }
}

举例2

public class Main {
    public static void main(String[] args) {
        String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";
        // 字符串拼接过程只有有一个是变量,那么就会存放在堆中,原理是StringBuilder
        String s5 = s1 + "hadoop";
        String s6 = "javaEE" + s2;
        String s7 = s1 + s2;

        System.out.println(s3 == s4); // true 编译期优化
        System.out.println(s3 == s5); // false s1是变量,不能编译期优化
        System.out.println(s3 == s6); // false s2是变量,不能编译期优化
        System.out.println(s3 == s7); // false s1、s2都是变量
        System.out.println(s5 == s6); // false s5、s6 不同的对象实例
        System.out.println(s5 == s7); // false s5、s7 不同的对象实例
        System.out.println(s6 == s7); // false s6、s7 不同的对象实例

        String s8 = s6.intern();
        System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"

    }
}

举例3

public class Main {
    public static void main(String[] args) {
        String s0 = "beijing";
        String s1 = "bei";
        String s2 = "jing";
        String s3 = s1 + s2;
        System.out.println(s0 == s3); // false s3指向对象实例,s0指向字符串常量池中的"beijing"
        String s7 = "shanxi";
        final String s4 = "shan";
        final String s5 = "xi";
        String s6 = s4 + s5;
        System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
    }
}
  • 不使用final修饰,即为变量,如s1和s2,会通过StringBuilder进行拼接。
  • 使用final修饰,即为常量,会在编译期进行代码优化,实际开发中,能使用final的,尽量使用。

举例4

public class Main {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4);
    }
}
 0 ldc #2 <a>
 2 astore_1
 3 ldc #3 <b>
 5 astore_2
 6 ldc #4 <ab>
 8 astore_3
 9 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
24 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
27 astore 4
29 getstatic #9 <java/lang/System.out : Ljava/io/PrintStream;>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println : (Z)V>
46 return

从上面字节码中的内容可以看出,s1 + s2 实际上是new了一个StringBuilder对象,并使用了append方法将s1和s2添加进来,最后调用了toString方法赋值给s4。

字符串拼接操作性能对比

public class Main {
    public static void main(String[] args) {
        int times = 50000;

        // String
        long start = System.currentTimeMillis();
        testString(times);
        long end = System.currentTimeMillis();
        System.out.println("String: " + (end-start) + "ms");

        // StringBuilder
        start = System.currentTimeMillis();
        testStringBuilder(times);
        end = System.currentTimeMillis();
        System.out.println("StringBuilder: " + (end-start) + "ms");

        // StringBuffer
        start = System.currentTimeMillis();
        testStringBuffer(times);
        end = System.currentTimeMillis();
        System.out.println("StringBuffer: " + (end-start) + "ms");
    }

    public static void testString(int times) {
        String str = "";
        for (int i = 0; i < times; i++) {
            str += "test";
        }
    }

    public static void testStringBuilder(int times) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < times; i++) {
            sb.append("test");
        }
    }

    public static void testStringBuffer(int times) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < times; i++) {
            sb.append("test");
        }
    }
}
String: 7066ms
StringBuilder: 2ms
StringBuffer: 2ms

本实验进行5万次循环,String拼接方式的时间是StringBuilder.append方式的约3500倍,StringBuilder和StringBuffer效率差不多,实验数据不一定准确,只是为了说明效率差距的问题。

在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用StringBuilder进行append操作。

除此之外,StringBuilder空参构造器的初始化大小为16。那么,如果提前知道需要拼接String的个数,就应该直接使用带参构造器指定capacity,以减少扩容的次数。

1.4.5 intern()的使用

当调用intern方法时,如果常量池里已经包含了一个与这个String对象相等的字符串,正如equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个String对象被添加到池中,并返回这个String对象的引用。

由此可见,对于任何两个字符串s和t,当且仅当s.equals(t)为真时,s.intern() == t.intern()为真。

intern是一个native方法,调用的是底层C的方法。

public native String intern();

如果不是使用双引号声明的String对象,可以使用String提供的intern方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true。

("a" + "b" + "c").intern() == "abc"

通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)

(1) intern的使用:JDK6 VS JDK7/8

举例1

public class Main {
    public static void main(String[] args) {
        String s = new String("1");
        s.intern();
        String s2 = "1";
        // JDK6 false JDK7/8 false
        System.out.println(s == s2);
    }
}

String s = new String("1")

创建了两个对象。堆空间中一个新的字符串对象"1"和s变量本身。

s.intern()

将字符串对象"1"添加到字符串常量池中。

String s2 = "1"

s指向的是堆空间中的对象地址,而s2指向的是堆空间中常量池中的"1"的地址。

举例2

public class Main {
    public static void main(String[] args) {
        String s3 = new String("1") + new String("1");
        s3.intern();
        String s4 = "11";
        // JDK6 false JDK7/8 true
        System.out.println(s3 == s4);
    }
}

String s3 = new String("1") + new String("1")

等价于new String("11"),但是字符串常量池中并不会生成字符串"11"。

s3.intern()

由于此时常量池中并无"11",所以s3把记录的对象地址存入常量池。

所以s3和s4是同一个地址。

举例3

String str = new String("ab"); //创建了几个对象?
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/String
         3: dup
         4: ldc           #3                  // String ab
         6: invokespecial #4                  // Method java/lang/String."":(Ljava/lang/String;)V
         9: astore_1
        10: return
      LineNumberTable:
        line 5: 0
        line 6: 10

根据反编译后的字节码分析:

  • 一个对象是:new关键字在堆空间中创建的。
  • 另一个对象是:字符串常量池中的对象"ab",注意,这个对象只能存在一个。

举例4

String str = new String("a") + new String("b"); // 创建了几个对象?
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=2, args_size=1
         0: new           #2                  // class java/lang/StringBuilder
         3: dup
         4: invokespecial #3                  // Method java/lang/StringBuilder."":()V
         7: new           #4                  // class java/lang/String
        10: dup
        11: ldc           #5                  // String a
        13: invokespecial #6                  // Method java/lang/String."":(Ljava/lang/String;)V
        16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: new           #4                  // class java/lang/String
        22: dup
        23: ldc           #8                  // String b
        25: invokespecial #6                  // Method java/lang/String."":(Ljava/lang/String;)V
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: astore_1
        35: return
      LineNumberTable:
        line 5: 0
        line 6: 35
  • 对象1:new StringBuilder()
  • 对象2:new String("a")
  • 对象3:字符串常量池中的"a"。
  • 对象4:new String("b")
  • 对象5:字符串常量池中的"b"。
  • 对象6:new String("ab"),也就是最后的StringBuilder.toString。
(2) intern()效率测试
public class Main {
    static final int MAX_COUNT = 1000 * 10000;
    static final String[] arr = new String[MAX_COUNT];

    public static void main(String[] args) {
        Integer [] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
        long start = System.currentTimeMillis();
        for (int i = 0; i < MAX_COUNT; i++) {
            //arr[i] = new String(String.valueOf(data[i%data.length]));
             arr[i] = new String(String.valueOf(data[i%data.length])).intern();
        }
        long end = System.currentTimeMillis();
        System.out.println("使用intern花费的时间为:" + (end - start));

        try {
            Thread.sleep(1000000);
        } catch (Exception e) {
            e.getStackTrace();
        }
    }
}
不使用intern花费的时间为:8998
使用intern花费的时间为:1021

结论:对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用intern()方法能够节省内存空间。

1.4.6 StringTable垃圾回收

public class Main {
    public static void main(String[] args) {
        // -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
        for (int i = 0; i < 100000; i++) {
            String.valueOf(i).intern();
        }
    }
}
[GC (Allocation Failure) [PSYoungGen: 4096K->512K(4608K)] 4096K->1057K(15872K), 0.0017201 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 4608K->496K(4608K)] 5153K->1161K(15872K), 0.0014041 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 4608K, used 738K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
  eden space 4096K, 5% used [0x00000000ffb00000,0x00000000ffb3c970,0x00000000fff00000)
  from space 512K, 96% used [0x00000000fff80000,0x00000000ffffc010,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 11264K, used 665K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000)
  object space 11264K, 5% used [0x00000000ff000000,0x00000000ff0a6708,0x00000000ffb00000)
 Metaspace       used 3361K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 366K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13658 =    327792 bytes, avg  24.000
Number of literals      :     13658 =    580696 bytes, avg  42.517
Total footprint         :           =   1068576 bytes
Average bucket size     :     0.683
Variance of bucket size :     0.686
Std. dev. of bucket size:     0.828
Maximum bucket size     :         6
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      3308 =     79392 bytes, avg  24.000
Number of literals      :      3308 =    244984 bytes, avg  74.058
Total footprint         :           =    804480 bytes
Average bucket size     :     0.055
Variance of bucket size :     0.054
Std. dev. of bucket size:     0.233
Maximum bucket size     :         3

1.4.7 G1中的String去重操作

目前,许多大规模的Java应用程序在内存上遇到了瓶颈。测量表明,在这些类型的应用程序中,大约25%的Java堆实时数据集被String对象所消耗。此外,这些 "String "对象中大约有一半是重复的,其中重复意味着 "string1.equals(string2) "是真的。在堆上有重复的String对象,从本质上讲,只是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动和持续的String’重复数据删除,以避免浪费内存,减少内存占用。

注意,这里说的重复,是指在堆中的数据,而不是常量池中的,因为常量池中的数据不会重复。

背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面string对象占了25%
  • 堆存活数据集合里面重复的string对象有13.5%
  • string对象的平均长度是45

实现:

  • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
  • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。
  • 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
  • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。

一些命令行选项:

# 开启String去重,默认是不开启的,需要手动开启。 
UseStringDeduplication(bool)  
# 打印详细的去重统计信息 
PrintStringDeduplicationStatistics(bool)  
# 达到这个年龄的String对象被认为是去重的候选对象
StringpeDuplicationAgeThreshold(uintx)

1.5 垃圾回收概述及算法

1.5.1 垃圾回收概述

(1) 什么是垃圾

垃圾收集,不是Java语言的伴生产物。

关于垃圾收集有三个经典问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。

(2) 为什么需要GC

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

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

(3) Java垃圾回收机制

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

对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。

此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。

当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节

(4) GC需要关注的区域

GC主要关注 方法区 和 堆区。

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

从次数上讲:

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

1.5.2 垃圾回收相关算法

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

判断对象存活一般有两种方式:引用计数算法可达性分析算法

(1) 引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

优点:① 原理简单,垃圾对象便于辨识 。② 判定效率高,回收没有延迟性。

缺点:① 需要占用一些额外内存空间存储计数器。 ② 每次赋值都要更新计数器,增加了时间开销。 ③ 引用计数器有一个最大的缺陷就是无法处理循环引用的情况。

(2) 可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java技术体系里面。固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”加入,共同构成完整的GC Roots集合。比如分代收集和局部回收(Partial GC)。

如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。

注意:如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是**导致GC进行时必须“stop The World”**的一个重要原因。

(3) 再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

JDK1.2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)软引用(Soft Reference)、**弱引用(Weak Reference)虚引用(Phantom Reference)**4种,这4种引用强度逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
(4) 对象的finalization机制

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。 这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对 象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

注意:

  • 对象可以在被GC的时候自我拯救。
  • 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次。
(5) 标记-清除算法

最基础的垃圾收集算法。分别“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后,同一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收未被标记的对象。标记的过程就是对象是否属于垃圾的判断过程

它的主要缺点有两个:

  • 第一个是执行效率不太稳定,如果Java堆中包含大量的对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率随着对象数量的增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

JVM基础知识汇总篇_第30张图片

(6) 标记-复制算法

标记-复制算法常被简称为复制算法。复制算法是为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。

算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

优点:实现简单、运行高效。不会出现空间碎片化问题。

缺点:需要两倍的内存空间。复制对象需要产生额外的开销。

JVM基础知识汇总篇_第31张图片

(7) 标记-整理算法

标记-整理算法主要是针对老年代对象的存亡特征而涉及的。

其中的标记过程仍与“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-清除算法和标记-整理算法的本质差异在于前者是一种非移动式回收算法,而后者是移动式的。

优点:消除了标记-清除算法中内存区域分散的问题、消除了标记-复制算法中内存减半的代价。

缺点:移动对象的同时,如果对象被其他对象引用,还需要调整引用地址。

(8) 分代收集算法

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

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

① 年轻代(Young Gen)

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

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

② 老年代(Tenured Gen)

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

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

  • 标记阶段的开销与存活对象数量成正比
  • 清除阶段的开销与管理区域的大小成正比
  • 整理阶段的开销与存活对象的数量成正比

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

(9) 增量收集算法

上述现有的算法,在垃圾回收过程中,应用程序将处于Stop the World的状态,在STW的状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序则会被挂起很久,将严重影响用户体验和系统稳定性。

为了解决这个问题,就产生了增量收集算法(Incremental Collecting)。

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。

增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

缺点:由于在垃圾回收的过程中,间断性地还执行了应用程序代码,所以减少了系统的停顿时间,但是,因为线程之间的切换和上下文的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量下降。

(10) 分区算法

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

JVM基础知识汇总篇_第32张图片

1.6 垃圾回收相关概念

1.6.1 System.gc()的理解

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

然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

System.gc();

// System.java 
public static void gc() {
    Runtime.getRuntime().gc();
}
// Runntime.java
public native void gc();

从源码可以看出,System.gc底层就是直接调用了Runtime.getRunTime().gc(),而底层则是调用了native方法的gc()

1.6.2 内存溢出与内存泄漏

(1) 内存溢出(OOM)

javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存

没有空闲内存,也就说明Java虚拟机的堆内存不够,原因如下:

(1)Java虚拟机的堆内存设置不够

比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms-Xmx来调整。

(2)代码中创建了大量对象,并且长时间不能被垃圾收集器回收(存在引用)。

对于老版本的Oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space"。
随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace"。直接内存不足,也会导致OOM。

在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。例如,在引用机制分析中,涉及到JVM会尝试回收软引用指向的对象。

当然,也不是在任何情况下垃圾收集器都会被触发。比如,去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError。

(2) 内存泄漏(Memory Leak)

严格来说,只有对象不会再被程序使用了,但是GC又不能回收它们的情况,叫做内存泄漏。

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。

举例:

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

1.6.3 Stop The World

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

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行。
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

STW事件和采用哪款GC无关,所有的GC都有这个事件。STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。开发中不要用System.gc() 会导致Stop-the-World的发生。

1.6.4 垃圾回收的并行与并发

(1) 并发

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

JVM基础知识汇总篇_第33张图片

(2) 并行

**当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。**其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。

JVM基础知识汇总篇_第34张图片

并发 VS 并行

  • 并发,指的是多件事情,在同一时间段内同时发生。
  • 并行,指的是多件事情,在同一时间点上同时发生。
  • 并发的多个任务之间是互相抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则,都是并发执行的。
(3) 垃圾回收的并发与并行

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态,如ParNew、Parallel Scavenge、Parallel Old。

串行(Serial):相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JVM垃圾收集器进行回收,回收完,再启动程序的线程。

**并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。**用户线程在继续运行,而垃圾收集器线程运行于另一个CPU上,如:CMS、G1。

1.6.5 安全点与安全区域

(1) 安全点

程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为**“安全点(Safepoint)”**。

安全点的选择很重要,既不能太少以至于让收集器等待时间过长,也不能太频繁以至于过分增大运行时的内存负荷。安全点的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而 长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

如何在垃圾收集时让所有线程都跑到最近的安全点,然后停顿呢?

这里有两种方案可供选择:抢先式中断 (Preemptive Suspension)主动式中断(Voluntary Suspension)

① 抢先式中断

抢占式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断地方法不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上

现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC事件。

② 主动式中断

**主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。**轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

(2) 安全区域

安全点区域保证了程序执行时,在不太长时间内就会遇到可进入垃圾收集过程的安全点。

但是,程序“不执行”的时候呢?所谓的程序不执行就是没有处理分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全点的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

对于这种情况,就必须引入安全区域(Safe Region)来解决。

**安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。**我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时 间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全 区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。

1.7 垃圾回收器

1.7.1 GC分类与性能指标

(1) 垃圾回收器概述

垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。从不同角度分析垃圾收集器,可以将GC分为不同的类型。

(2) 垃圾收集器分类
① 按线程数分

按线程数分,可以分为串行垃圾回收器并行垃圾回收器

串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。

  • 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
  • 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。

和串行回收相反,并行收集可以运行多个CPU同时执行垃圾回收,因此提高了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-World”机制。

② 按照工作模式分

按照工作模式分,可以分为并发式垃圾回收器独占式垃圾回收器

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
③ 按碎片处理方式分

按碎片处理方式分,可分为压缩式垃圾回收器非压缩式垃圾回收器

  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
  • 非压缩式的垃圾回收器不进行这步操作。
④ 按工作内存分

按工作内存分,又可分为年轻代垃圾回收器和老年代垃圾回收器。

(3) 评估GC的性能指标
  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。

吞吐量暂停时间内存占用 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。

① 吞吐量

**吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)。**比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

② 暂停时间

**“暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态。**例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。

③ 吞吐量 VS 暂停时间

1.7.2 不同的垃圾回收器概述

(1) 垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection,对应的产品我们称为Garbage Collector。

  • 1999年随JDK1.3.1一起来的是串行方式的serialGc,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本。
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布。
  • Parallel GC在JDK6之后成为HotSpot默认GC。
  • 2012年,在JDK1.7u4版本中,G1可用。
  • 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  • 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • 2018年9月,JDK11发布。引入Epsilon 垃圾回收器,又被称为 "No-Op(无操作)“ 回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)。
  • 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC(Experimental)。
  • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  • 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macos和Windows上的应用。
(2) 7种经典的垃圾收集器
  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1
(3) 收集器与垃圾分代之间的关系

JVM基础知识汇总篇_第35张图片

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

JVM基础知识汇总篇_第36张图片

  • 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serail Old、ParNew/CMS、Parallel Scavege/Serial Old、Parallel Scavenge/Parallel Old、G1。
  • 其中Serial Old作为CMS出现“Concurrent Mode Failure”失败的后备预案。
  • (红色虚线)由于维护和兼容性测试的成本,在JDK8时将Serial + CMS、ParNew + Serial Old这两个组合声明为废弃(JEP173),并在JDK9中完全取消了这些组合的支持(JEP214),即:移除。
  • (绿色虚线)JDK14中:弃用Parallel Scavenge 和 Serial Old组合。
  • (绿色框线)JDK14中:删除CMS垃圾收集器(JEP363)。
(5) 查看默认垃圾收集器
-XX:+PrintCommandLineFlags // 查看命令行相关参数(包含使用的垃圾收集器)
jinfo -flag 相关垃圾回收器参数 进程ID // 使用命令行指数

在VM环境上添加参数后运行结果如下:

-XX:InitialHeapSize=257798976 -XX:MaxHeapSize=4124783616 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC 

1.7.3 Serial收集器:串行回收

Serial是最基础、历史最悠久的收集器,在JDK 1.3.1之前是HotSpot虚拟机新生代收集器的唯一选择

目前仍然是HotSpot虚拟机运行在Client客户端模式下的默认新生代收集器

JVM基础知识汇总篇_第37张图片

这个收集器是一个单线程工作的收集器,更重要的是强调在它进行来及收集时,必须暂停其他所有工作线程,直到它收集结束。

优点:与其他收集器的单线程相比,简单而高效。

  • 对于内存资源受限的环境,它是收集器里额外内存消耗(Memory FootPrint) 最小的;
  • 对于单核处理器或者处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。

在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的 内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一 百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以Serail收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

1.7.4 ParNew收集器:并行回收

ParNew收集器实质上是Serail收集器的多线程并行版本。Par是Parallel的缩写,New表明只能处理新生代。

除了同时使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

控制参数比如:-XX:SurivivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等。

JVM基础知识汇总篇_第38张图片

ParNew是很多JVM运行在Server服务端模式下新生代的默认垃圾收集器。尤其是JDK7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但是很重要的原因是:除了Serail收集器外,目前只有它能与CMS收集器配合工作

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数较少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)

由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?

  • ParNew收集器运行在多CPU的环境下,由于可以充分利用CPU、多核心等物理硬件资源优势,可以更快速的完成垃圾收集,提高程序的吞吐量。
  • 在单个CPU的环境下,ParNew收集器不比Serial 收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。、

CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。

ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。

随着被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是有好多好处的,参数-XX:ParallelGCThreads可以限制线程数量,默认开启和CPU数据相同的线程数。

并行(Parallel)和并发(Concurrent)

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

**并发(Concurrent):**并发描述的是垃圾收集线程与用户线程之间的关系,说明同一时间垃圾收集线程与用户线程都在运行。由于用户线程并未冻结,所以程序仍然能响应服务请求,但由于垃圾收集线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定的影响。

1.7.5 Parallel Scavenge收集器:吞吐量优先

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并发收集的多线程收集器。

那么Parallel Scavenge的出现是否多此一举?

  • Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间, 而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器
  • 自适应调节策略也是Parallel Scavenge与ParNew的一个重要区别。

高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序

参数配置

  • -XX:MaxGCPauseMillis控制最大垃圾收集停顿时间。参数允许的值时一个大于0的毫秒数,收集器将尽力保证内存回收花费的 时间不超过用户设定值。
  • -XX:GCTimeRatio直接设置吞吐量大小。参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的 比率,相当于吞吐量的倒数。
  • -XX:+UseAdaptiveSizePolicy:开启自适应调节策略。在这种模式下,,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数 了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时 间或者最大的吞吐量。
  • -XX:+UseParallelGC:手动指定年轻代使用Parallel并行收集器执行回收内存。
  • -XX:+UseParallelOldGC:手动指定老年代使用并行回收收集器。

1.7.6 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Serial Old收集器的工作过程如图所示:

JVM基础知识汇总篇_第39张图片

1.7.7 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK6才开始提供的。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如图所示。

JVM基础知识汇总篇_第40张图片

1.7.8 CMS收集器:低延迟

CMS收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程同时工作。

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

CMS收集器是基于标记-清除算法实现的,它的运作过程要比前面几种收集器来说更加复杂,分为四个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长那个但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记的时间短;

最后是并发清除阶段清除删掉标记阶段判断的已经死亡的对象,由于不需要移动存活的对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段,垃圾收集线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

JVM基础知识汇总篇_第41张图片

(1) CMS的优点

并发收集、低停顿。

(2) CMS的缺点

CMS收集器对处理器资源非常敏感。

在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计 算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生

在CMS的并发标记和并发清理阶 段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分 垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

JDK5默认配置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果实际应用中老年代的增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancy Fraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。

到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满 足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不 得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。

所以参数-XX:CMSInitiatingOccu-pancy Fraction 的值设置的太高将会很容易导致大量的并发失败发生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

③ **CMS基于标记-清除算法实现,收集结束后会有大量的空间碎片产生。**空间 碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找 到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的,JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。

这样空间碎片解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量 由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

Mark Sweep会造成内存碎片,为什么不使用Mark Compact算法呢?

因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存环境就无法使用,要保证用户线程继续执行,前提是它运行的资源不受影响,所以Mark Sweep更适合“Stop The World”场景下使用。

拓展

另外,为了缓解CMS收集器对处理器资源敏感出现的问题,虚拟机提供了一种称为**“增量式并发收集器”(Incremental Concurrent Mark Sweep、i-CMS)的CMS收集器变种**,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样, 是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的 时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变 慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从 JDK 7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9发布后i-CMS模式被完全废弃。

(3) 设置的参数
  • -XX:+UseConcMarkSweepGC:手动指定使用CMS收集器执行内存回收任务。开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young区用)+ CMS(Old区用)+ Serial Old的组合。
  • -XX:CMSInitiatingOccupanyFraction:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
    • JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低FullGC的执行次数。
  • -XX:+UseCMSCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
  • -XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理。
  • -XX:ParallelcMSThreads :设置CMS的线程数量。
    • CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
(4) JDK后续版本中CMS的优化

JDK9新特性:CMS被标记为Deprecate了(JEP291)

  • 如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。

JDK14新特性:删除CMS垃圾回收器(JEP363)

  • 移除了CMS垃圾收集器,如果在JDK14中使用 -XX:+UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM。

1.7.9 G1回收器:区域化分代式

(1) 前面已经有很多强大的GC,为什么还要发布G1?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

(2) 为什么叫G1?

因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。

G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

由于这种方式的侧重点在于回收垃圾最大的区间(Region),所以我们给G1起一个名字:垃圾优先(Garbage First)。

Garbage First(简称G1)收集器是垃圾收集技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器。主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimenta1的标识,是JDK9以后的默认垃圾回收器,取代了CMS回收器以及Parallel+Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。

与此同时,CMS已经在JDK9中被标记为废弃(deprecated)。在JDK8中还不是默认的垃圾收集器,需要使用-XX:+UseG1GC来启用。

(3) G1收集器的优点

与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:

① 并行与并发
  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程会发生“Stop The World”。
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以与应用程序同时执行。因此,一般来说,不会在整个回收阶段发生阻塞应用程序的情况。
② 分代收集
  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量
  • 堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代。

JVM基础知识汇总篇_第42张图片

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

这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
(4) G1收集器的缺点

相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

(5) G1回收器的参数设置
  • -XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务。
  • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(人的平均反应速度)。
  • -XX:+ParallelGCThread 设置STW工作线程数的值。最多设置为8(上面说过Parallel回收器的线程计算公式,当CPU_Count > 8时,ParallelGCThreads 也会大于8)。
  • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。
(6) G1收集器常见操作步骤

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

  • 第一步:开启G1垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

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

(7) G1收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。

用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:

  • 超过50%的Java堆被活动数据占用
  • 对象分配频率或者年代提升频率变化很大
  • GC停顿时间过长(长于0.5-1秒)

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

(8) 分区Region:化整为零

二、字节码与类加载

2.3 类的加载过程

2.3.1 概述

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制

在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)

JVM基础知识汇总篇_第43张图片

① 类的初始化

《Java虚拟机规范》严格规定了有且只有六种情况必须立即对类进行“初始化”:

(1)遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
  • 调用一个类型的静态方法的时候。

(2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

(3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

(5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

(6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

② 接口的初始化

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程, 这点与类是一致的,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成()类构造器,用于初始化接口中所定义的 成员变量。

接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种: 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

2.3.2 类加载的过程

(1) 加载(Loading)
① 加载的理解

所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java的原型 – 类模板对象。

所谓类模板对象,其实就是Java类在]VM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样]VM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。

反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。

② 加载阶段

在加载阶段,Java虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
③ 二进制流获取的方式
  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  • 从网络中获取,这种场景最典型的应用就是Web Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“$Proxy”的代理类的二进制字节流。
  • 由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
  • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文 件来保障程序运行逻辑不被窥探。
④ 类模型与Class实例的位置

**类模型的位置:**加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDKl.8之前:永久代;J0Kl.8及之后:元空间)。

**Class实例的位置:**类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。

⑤ 数组类的加载

创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。

一个数组类C创建的过程遵循如下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标 识在加载该组件类型的类加载器的类名称空间上。
  • 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C 标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。
(2) 链接(Linking)

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段 尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部 分,这两个阶段的开始时间仍然保持着固定的先后顺序。

(3) 链接–验证(Verification)

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为 载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟 机保护自身的一项必要措施。

验证大致会完成下面四个阶段的校验动作:文件格式验证元数据验证字节码验证符号引用验证

① 文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

  • 是否以魔数0xCAFEBABE开头。
  • 主、次版本号是否在当前Java虚拟机接受范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。

这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

② 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方 法重载,例如方法参数都一致,但返回值类型却不同等)。

第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息

③ 字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要 对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害 虚拟机安全的行为,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作 栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全 的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个 数据类型,则是危险和不合法的。

如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法 体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再 严密的检查,也依然不能保证这一点

由于数据流分析和控制流分析的高度复杂性,在JDK 6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能 多的校验辅助措施挪到Javac编译器里进行。

具体做法是给方法体Code属性的属性表中新增加了一项名 为**“StackMapTable(栈映射帧)”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查**,从而节省了大量校验时间。

JDK 6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier选项来关闭掉这项优化,或者使用参数-XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了 JDK 7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于50(对应JDK 6)的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型 推导的校验方式。

④ 符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源

本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如: java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。

(4) 链接–准备(Preparation)

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

强调:

  • 首先是这时候进行内存分配的 仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  • 其次是这里所说的初始值“通常情况”下是数据类型的零值。

JVM基础知识汇总篇_第44张图片

另外,会存在一些特殊情况:如果类字段 的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值。例如:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置 将value赋值为123。

(5) 链接–解析(Resolution)

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

**符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。**符号引用与虚拟机实现的内存布局无关,引 用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

**直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。**直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚 拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

解析的触发时机

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warraycheckcastgetfieldgetstaticinstanceofinvokedynamicinvokeinterfaceinvoke-specialinvokestaticinvokevirtualldcldc_wldc2_wmultianewarraynewputfieldputstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

多次解析问题

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。

无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在 同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够 成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪 怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。

不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令 触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为 invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符 (Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指 令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶 段,还没有开始执行代码时就提前进行解析。

解析的对象

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行,分别对应于常量池的CONSTANT_Class_infoCON-STANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_infoCONSTANT_MethodHandle_infoCONSTANT_Dyna-mic_infoCONSTANT_InvokeDynamic_info 8种常量类型。

① 类或者接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接 引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

  • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个 类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例 如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
  • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类 似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所 假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元 素的数组对象。
  • 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了, 但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限, 将抛出java.lang.IllegalAccessError异常。

针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型也不再意味着程序任 何位置都有它的访问权限,我们还必须检查模块间的访问权限。

如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:

  • 被访问类C是public的,并且与访问类D处于同一个模块。
  • 被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的 模块进行访问。
  • 被访问类C不是public的,但是它与访问类D处于同一个包中。
② 字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index 项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。

如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:

  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。

③ 方法解析

方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方 法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:

  • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError 异常。
  • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标 相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError异常。
  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError

最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此 方法的访问权限,将抛出java.lang.IllegalAccessError异常。

④ 接口方法解析

接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

  • 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找。
  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在 访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK 9中增 加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可 能因访问权限控制而出现java.lang.IllegalAccessError异常。

(6) 初始化(Initialization)

类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。

初始化阶段就是执行类构造器()方法的过程。()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物。

()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。

()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的()方法。

**Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。**如果在一个类的()方法中有耗时很长的操作,那就 可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

① static与final的搭配问题

使用static + final修饰的字段的显式赋值的操作,是在哪个阶段进行的赋值?

  • 情况一:在链接阶段的准备环节赋值
  • 情况二:在初始化阶段()中赋值

说明:

  • 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法通常是在链接阶段的准备环节进行的)
  • 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行。
  • 在初始化阶段()中赋值的情况: 排除上述的在准备环节赋值的情况之外的情况。
② 类的初始化情况:主动使用和被动使用

Java程序对类的使用分为两种:主动使用被动使用

主动使用:

Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即:如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成。

  • **实例化:**当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化等。
  • **静态方法:**当调用类的静态方法时,即当使用了字节码invokestatic指令。
  • **静态字段:**当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。(对应访问变量、赋值变量操作)
  • **反射:**当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName(“com.atguigu.java.Test”)
  • **继承:**当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • **default方法:**如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。
  • **main方法:**当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • **MethodHandle:**当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。(涉及解析REF getStatic、REF_putStatic、REF invokeStatic方法句柄对应的类)

被动使用:

除了以上的情况属于主动使用,其他的情况都属于被动使用。被动使用不会引起类的初始化

  • **静态字段:**当通过子类引用父类的静态变量,不会导致子类初始化,只有真正声明这个字段的类才会被初始化。
  • **数组定义:**通过数组定义类引用,不会触发此类的初始化
  • **引用常量:**引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。
  • **loadClass方法:**调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
(7) 类的使用(Using)

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

(8) 类的卸载(Unloading)
① 类、类的加载器、类的实例之间的引用关系

在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法,就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系。

一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表对象所属类的Class对象的引用。此外,所有的java类都有一个静态属性class,它引用代表这个类的Class对象。

② 类的生命周期

当类被加载、链接和初始化后,它的生命周期就开始了。当代表类的Class对象不再被引用时,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的声明周期。

③ 类的卸载

(1)启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范)。

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

(3)被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。

综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。

2.4 再谈类加载器

2.4.1 概述

(1) 类加载器概念

类加载器(Class Loader):Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节 流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为类加载器。

**作用:ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。**然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定。

(2) 类加载器的必要性

一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。理由有以下几个方面:

  • 避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时,手足无措。只有了解类加载器的 加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题。
  • 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
  • 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。
(3) 命名空间

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定其在Java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。即比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

命名空间:

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
  • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类 。
  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类 。

2.4.2 类加载器分类

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

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:

JVM基础知识汇总篇_第45张图片

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加戟器。
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。
(1) 启动类加载器(Bootstrap)

启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在 \lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够 识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类 库加载到虚拟机的内存中。

启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

  • 这个类加载器使用c/c++语言实现的,嵌套在JVM内部。
  • 这个类加载器用来加载Java的核心库(JAVAHOME/jre/lib/rt.jarsun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
  • 不继承ClassLoader,没有父类加载器。
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类。
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
  • 使用-XX:+TraceClassLoading参数可以查看被启动类加载器加载的类。
(2) 扩展类加载器(Extension)

**扩展类加载器(Extension):**这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。

根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩 展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现 的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

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

应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。

它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有 自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  • 由Java语言编写,由 sun.misc.Launcher$AppClassLoader实现。
  • 继承于ClassLoader类。
  • 父类加载器为扩展类加载器。
  • 它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库。
  • 应用程序中的类加载器默认是系统类加载器。
  • 它是用户自定义类加载器的默认父加载器。
  • 通过ClassLoader的getSystemClassLoader()方法可以获取到该类加载器。
(4) 用户自定义类加载器
  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
  • 体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。
  • 通过类的加载器可以实现插件机制。
  • **同时,自定义类加载器能够实现隔离。**例如tomcat、Spring等中间件和组件框架都在内部实现了自定义加载器,并通过自定义加载器隔离不同的组件模块。
  • 自定义类加载器通常需要继承于ClassLoader。

2.4.3 ClassLoader源码解析

ClassLoader与现有类的关系:

JVM基础知识汇总篇_第46张图片

另外,还有两个ExtClassLoader和AppClassLoader两个加载器通过URLClassLoader实现。

除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。

(1) ClassLoader的主要方法
① getParent()

返回用于委托的父类加载器,如果为null,则表示是引导类加载器。

	@CallerSensitive
    public final ClassLoader getParent() {
        if (parent == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            // 检查对父类加载器的访问权限,如果调用者的类加载器与此类加载器相同,则执行权限检查
            checkClassLoaderPermission(parent, Reflection.getCallerClass());
        }
        return parent;
    }
② loadClass()

加载具有指定二进制名称的类,默认按以下顺序搜索类:

  • 调用findLoadClass() 来检查类是否已经被加载。
  • 调用父类加载器上的loadClass()方法,如果父类为空,则使用虚拟机内置的类加载器。
  • 调用findClass()方法来查找类。

如果使用上述步骤找到该类,并且resolve为true,则此方法将调用结果Class对象上的resolveClass()方法。

注意,该方法中的逻辑就是双亲委派模式的实现。

	public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

	protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查这个类是否已经被加载。
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 父类不为空,则委托给父类加载 
                        c = parent.loadClass(name, false);
                    } else {
                        // 父类为空,则调用BootstrapClassLoader加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果找不到类,抛出ClassNotFoundException
                }

                if (c == null) {
                   	// 如果当前加载器的所有父类加载器都失败了,则由当前加载器重写findClass自定义类加载器
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // 定义类加载器以及统计数据
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c); // 链接指定的类
            }
            return c;
        }
    }
③ findClass()

查找具有指定二进制名称的类,此方法应该被遵循加载类的委托模型的类加载器实现覆盖,并且在检查父类加载器中请求的类之后,将由loadClass方法调用,默认实现抛出ClassNotFoundException异常。

	protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法,JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。

  • 在JDK1.2之前,在自定义类加载时,总会去继承ClassLoader类并重写loadClass方法,从而实现自定义的类加载类。但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式。
  • 需要注意的是ClassLoader类中并没有实现findClass()方法的具体代码逻辑,取而代之的是抛出ClassNotFoundException异常,同时应该知道的是findClass方法通常是和defineClass方法一起使用的。

一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载的类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象。

④ defineClass

深入理解Java类加载器(ClassLoader) - 掘金 (juejin.cn)

将字节数组转换为类class的实例,带有可选的ProtectionDomain。如果域为null,则将按照defineClass(String, byte[], int, int)的文档中指定的方式将默认域分配给该类。在使用类之前,必须对其进行解析。

包中定义的第一个类确定该包中定义的所有后续类必须包含的确切证书集。类的证书集是从类的ProtectionDomain中的CodeSource中获得的。添加到该包中的任何类都必须包含相同的证书集,否则将抛出SecurityException。注意,如果name为空,则不执行此检查。应该始终传入正在定义的类的二进制名称以及字节数。这确保了正在定义的类确实是认为的类。

指定的名称不能以“java”开头,因为“java. net”中的所有类。包只能由引导类加载器定义。如果name不为空,它必须等于字节数组"b"指定的类的二进制名称,否则将抛出NoClassDefFoundError。

	protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError
    {
        return defineClass(name, b, off, len, null);
    }
    
    protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                         ProtectionDomain protectionDomain)
        throws ClassFormatError
    {
        protectionDomain = preDefineClass(name, protectionDomain);
        String source = defineClassSourceLocation(protectionDomain);
        Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
        postDefineClass(c, protectionDomain);
        return c;
    }

根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。

  • defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象,也可以通过其他方式实例化class对象,如通过网络接收一个类的字节码,然后转换为byte字节流创建对应的Class对象。
  • defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象。

2.4.5 双亲委派模型

(1) 定义

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

(2) 优点与缺点

优点:

  • 避免类的重复加载,确保一个类的全局唯一性。(Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层次关系可以避免类的重复加载)
  • 保护程序安全,防止核心API被随意篡改。

缺点:

检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类

通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

(3) 破坏双亲委派机制

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的 类加载器实现方式。

直到Java 模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

① 第一次破坏

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。上面我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面, 按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样 既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

② 第二次破坏

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类 加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在。

但是如果基础类型又要回调用户的代码,那该怎么办呢?

一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务, 它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型 了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程 序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。

启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方 法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行 为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性 原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、 JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供 者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了 java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加 载提供了一种相对合理的解决方案。

③ 第三次破坏

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态 性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。

IBM公司主导的JSR-291(即OSGiR4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(osGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bund1e连同类加载器一起换掉以实现代码的热替换。在oSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。

当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  • 将以java.*开头的类,委派给父类加载器加载。
  • 否则,将委派列表名单内的类,委派给父类加载器加载。
  • 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
  • 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器 加载。
  • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。

说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的。

(4) 热替换的实现

热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。

热替换的关键需求在于服务不能中断,修改必须立即表现在正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的。

但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在Java中实现这一功能的一个可行的方法就是灵活运用ClassLoader。

注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这2个类是完全不同的。

根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:

JVM基础知识汇总篇_第47张图片

2.4.6 沙箱安全机制

沙箱安全机制:

  • 保护Java程序的安全
  • 保护Java原生的JDK代码

**Java安全模型的核心就是Java沙箱(sandbox)。**简单来说,沙箱就是一个限制程序运行的环境。

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

所有的Java程序运行都可以指定沙箱,可以定制安全策略。

(1) JDK1.0时期

在Java中将执行程序分成本地代码远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱(Sandbox)机制。如下图所示JDK1.0安全模型。

JVM基础知识汇总篇_第48张图片

(2) JDK1.1时期

JDK1.0中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略。允许用户指定代码对本地资源的访问权限。

如下图为JDK1.1的安全模型:

JVM基础知识汇总篇_第49张图片

(3) JDK1.2时期

在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示JDK1.2安全模型:
JVM基础知识汇总篇_第50张图片

(4) JDK1.6时期

当前最新的安全机制实现,则引入了**域(Domain)**的概念。

虚拟机会把所有代码加载到不同的系统域和应用域。系统域部分专门负责与关键资源进行交互,而各个应用领域部分则通过域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护与(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就有了当前域的所有权限,如下图:

JVM基础知识汇总篇_第51张图片

2.4.7 自定义类加载器

(1) 为什么要自定义类加载器

**① 隔离加载类。**在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。

② 修改类加载的方式。 类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。

**③ 扩展加载源。**比如从数据库、网络、甚至是电视机机顶盒进行加载 。

**④ 防止源码泄漏。**Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。

(2) 实现方式

Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。

在自定义ClassLoader的子类的时候,常见的有两种做法:

  • 方式一:重写loadClass()方法
  • 方式二:重写findClass()方法

这两种方法本质上差不多,毕竟loadClass()也会调用findClass(),但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。

loadClass()方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。**因此我们最好在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。**同时也避免了自己重写loadClass()方法的过程中必须写双亲委派的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。

当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。

2.4.8 Java模块化系统

(1) 模块化系统概述

在JDK 9中引入的**Java模块化系统(Java Platform Module System,JPMS)**是对Java技术的一次重 要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。

JDK 9的模块不仅仅像之前的JAR包那样只是 简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可反射访问模块的列表。
  • 使用的服务列表。
  • 提供服务的实现列表。

可配置的封装隔离机制需要解决的问题:

(1)JDK9之前基于类路径(ClassPath)来查找依赖的可靠性问题。

如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接 时才会报出运行的异常。

而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。

(2)原来类路径上跨JAR文件的public类型的可访问性问题。

JDK 9中 的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性 控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类 加载过程中完成的。

(2) 模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与“类路 径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是**某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。**只要是放在类路径上的JAR文件,无论其中是否包 含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只 要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文 件,它也仍然会被当作一个模块来对待。

模块化系统将按照以下的规则来保证使用传统类路径依赖的Java程序可以不经修改地直接运行在Java9及以后的Java板块上。

  • **JAR文件在类路径的访问规则:**所有类路径下的JAR文件及其他资源文件,都被视为自动打包在 一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路 径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
  • **模块在模块路径的访问规则:**模块路径下的具名模块(Named Module)只能访问到它依赖定义 中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。
  • **JAR文件在模块路径的访问规则:**如果把一个传统的、不包含模块定义的JAR文件放置到模块路 径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将 默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自 己所有的包。
(3) 模块化系统下的类加载器

为了保证兼容性,JDK9并没有从根本上改动三层类加载器架构以及双亲委派机制。但是为了模块化系统的顺利施行,模块化下的类加载器仍然发生了一些应该被 注意到变动,主要包括以下几个方面。

扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。

既然整个JDK都基于模块化进行构建(原来的rt.jar和tools.jar被拆分 成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留 \lib\ext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没 有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。

类似地,在新版 的JDK中也取消了\jre目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假 设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个“JRE”:

jlink -p $JAVA_HOME/jmods --add-moudles java.base --output jre

平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接 依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。

现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

最后,JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。

JVM基础知识汇总篇_第52张图片

在Java模块化系统明确规定了三个类加载器负责各自加载的模块,即前面所说的归属关系,如下所示:

(1)启动类加载器负责加载的模块:

JVM基础知识汇总篇_第53张图片

(2)平台类加载器负责加载的模块:

JVM基础知识汇总篇_第54张图片

(3)应用程序类加载器负责加载的模块:

JVM基础知识汇总篇_第55张图片

public class Main {
    public static void main(String[] args) {
        System.out.println(Main.class.getClassLoader());  // 系统类加载器
        System.out.println(Main.class.getClassLoader().getParent()); // 扩展类加载器
        System.out.println(Main.class.getClassLoader().getParent().getParent()); // 引导类加载器或平台类加载器

        //获取系统类加载器
        System.out.println(ClassLoader.getSystemClassLoader());
        //获取平台类加载器
        System.out.println(ClassLoader.getPlatformClassLoader());
        //获取类的加载器的名称
        System.out.println(Main.class.getClassLoader().getName());
    }
}

你可能感兴趣的:(JVM,JVM)