深入理解Java虚拟机

深入理解Java虚拟机

第一章

《第二部分 自动内存管理》

第二章 Java内存区域 与 内存溢出

2.2 运行时数据区

PC(程序)计数器

虚拟机栈

本地方法栈

方法区

对象访问

直接访问 和 句柄访问

image-20230624113830703

内存溢出异常

2.3 HotSpot 的对象

2.3.1 对象的创建

内存分配:

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

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

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

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

内存分配的同步问题:

两种可选方案:

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

2.3.2 对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)、
  • 实例数据(Instance Data)和
  • 对齐填充(Padding)。

对象头:对象头部分包括两类信息。

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

对象头的另外一部分是类型指针(Class),即对象指向它的类型元数据的指针,Java 虚拟机 通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数 据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身

实例数据:

第三部分是对齐填充:

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

2.3.3 对象的访问定位

  • 句柄访问:Java 堆中将可能会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自 具体的地址信息,其结构如图 2-2 所示。
  • 直接指针访问:Java 堆中对象的内存布局就必须考虑如何放置访问 类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的 话,就不需要多一次间接访问的开销,如图 2-3 所示

优劣:

  • 句柄来访问的最大好处就是 reference 中存储的 是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变 句柄中的实例数据指针,而 reference 本身不需要被修改。

  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开 销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行 成本,

image-20230829075900309

第三章 垃圾回收器和内存分配策略

3.2 对象存活判断

3.2.1 引用计数器

问题:计数时性能消耗

  • 必须要配合大量额外处理才能保证正确地工作譬如单纯的引用计数就很难解决对象之 间相互循环引用的问题

3.2.2 可达性分析 - (根搜索算法)

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

·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法 堆栈中使用到的参数、局部变量、临时变量等。

·在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。

·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

·在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。

·Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象 (比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

·所有被同步锁(synchronized 关键字)持有的对象。
·反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

3.2.4 再谈引用

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

这种定义并 没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或 者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。 譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空 间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都 符合这样的应用场景。

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为**强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)**4 种,这 4 种引用强度依次逐渐减弱

1、·强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类 似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象。

2、·软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在 系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果 这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。

3、·弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引 用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前 内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。

4、·虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是 否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对 象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时 收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

3.2.4 finalize()

至少要经历两次标记过程:

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

如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个 名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方 法开始运行,但并不承诺一定会等待它运行结束。

  • 第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它 就真的要被回收了。

3.2.5 回收方法区

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类 频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不 会对方法区造成过大的内存压力

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

1、回收常量:已经没有任何字符串对象引用常量池中的 “j ava〞常量,且虚拟机中也没有其他地方引1用这个字面量。

2、回收类型:判定一个类型是否属于 “不 再被使用的类〞的条件就比较苛刻了。需要同时满足下面三个条件:

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

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

+TraceClassUnLoading 查看类加载和卸载信息,其中-verbose:class 和-XX: +TraceClassLoading 可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参 数需要 FastDebug 版[1]的虚拟机支持。

3.3 垃圾收集算法

3.3.1 分代收集理论

遵循了“分代收集”(Generational Collection)[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实 际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就

越难以消亡。

标记清楚

复制

标记整理

3.4 HotSpot 的算法实现细节

3.4.1 根节点枚举

3.5 经典垃圾回收器

3.5.1 Serial

S e r i a l 收 集 器

3.5.2 Parallel

P a r N e w 收 集 器

Parallel Scavenge收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收
集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Par alle l Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput) 。

3.5.3 CMS

3.5.4 G1

目前在小内存应用上CMS的表现大 概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优分势的 java堆容量平衡点通常在6GB至8GB之

3.8 内存分配与回收策略

第四章 性能监控与故障处理(工具)

JDK命令行工具

jps:Java Process Status

jstat: Java statistic(统计数据) monitoring tool

jinfo:Configuration Info for Java

jmap:memory map for java

jhap:Java Heap Analysis Tool

jstack:Stack Trace for Java

JDK可视化工具

JConsole

VisualVM

第五章 性能调优案例

《第三部分 虚拟机子系统》

第六章 类文件结构-字节码文件

第七章 类加载机制

类加载时机

1、main方法所在类

2、子类加载时,父类自动加载

3、reflect反射

4、new、putstatic、getstatic、invokestatic 四条指令 (static被引用,类初始化new)

类加载过程-阶段

加载:主要完成三件事

  • 通过类的通过全限定名加载类 的二进制流
  • 将二进制流 所代表的 静态存储结构 转化为 方法区的运行时数据结构 (.class文件的信息)
  • 在Java堆中,生成一个java.lang.Class对象,作为方法区数据的访问入口

连接:

  • 验证:保证字节码文件不会损害系统安全

    • 文件格式验证
    • 元数据验证
    • 字节码验证
    • 符号引用验证
  • 准备:类变量(类型)的初始零值(正式为类中定义的变量 (即静态变量,被st at ic修饰的变量)分配 内存并设置类变量初始值的阶段)

  • 解析:将符号引用转化为 直接引用:针对类、接口、方法、字段(这阶段会引起 类的尚未加载的父类完成加载)

初始化: 方法,收集 静态语句块类变量赋值动作 合并而成

使用

卸载

类加载器

《第四部分 程序编译与代码优化》

第十章 前端编译与优化

10.2 Javac编译器

10.3 语法糖

第十一章 后端编译与优化

11.2 即时编译器(JIT-编译器)

11.2.1 解释器与编译器

image-20230828231422225

11.2.2 编译对象与触发条件

在本章概述中提到了在运行过程中会被即时编译器编译的目标是“热点代码”,这里 所指的热点代码主要有两类,包括:

·被多次调用的方法。

·被多次执行的循环体。

11.2.3 编译过程

客户单编译器:

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示 (High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表 示)。HIR 使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这 可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。在此之前编译 器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码 被构造成 HIR 之前完成。

在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前 会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到 更高效的代码表示形式。

最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器 代码。客户端编译器大致的执行过程如图 11-5 所示。

image-20230828231535822

服务端编译器:

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对 性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到 GNU C++编译器使用-O2 参数时的优化强度。它会执行大部分经典的优化动作,如:无用代 码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传 播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些 与 Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空 值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优 化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编 译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等,本章的下半部分将会挑选 上述的一部分优化手段进行分析讲解,在此就先不做展开。

11.3 提前编译器(AOT)

Ahead Of Time

11.4 编译器优化技术

11.4.1 概述

image-20230828231714701

image-20230828231739082

11.4.2 方法内联

11.4.3 逃逸分析

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后, 它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸; 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种 称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法 通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线 程),则可能为这个对象实例采取不同程度的优化,如:

1、·栈上分配(Stack Allocations):

在 Java 虚拟机中,Java 堆上分配创建对象的内存 空间几乎是 Java 程序员都知道的常识,Java 堆中的对象对于各个线程都是共享和可见 的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集 子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回 收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这 个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧 出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的 比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁 了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线 程逃逸。

2、·标量替换(Scalar Replacement):

若一个数据已经无法再分解成更小的数据来表示 了,Java 虚拟机中的原始数据类型(int、long 等数值类型及 reference 类型等)都不能再 进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分 解,那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这 个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这 个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创 建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成 员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存 储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度 的要求更高,它不允许对象逃逸出方法范围内。

3、·同步消除(Synchronization Elimination):

线程同步本身是一个相对耗时的过程, 如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量 的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉

11.4.4 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含 义是:如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量 的值都没有发生变化,那么 E 的这次出现就称为公共子表达式。对于这种表达式,没有 必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E。如果 这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公 共子表达式消除(Global Common Subexpression Elimination)。

11.4.5 数组边界检查消除

《第五部分 高效并发》

第十二章 Java的内存模型 与 线程

12.3.4

Java 语言定义了 6 种线程状态,在任意一个时间点中,一个线程只能有且只有其中 的一种状态,并且可以通过特定的方法在不同状态之间转换。这 6 种状态分别是:

1、新建(New):创建后尚未启动的线程处于这种状态。

2、运行(Runnable):包括操作系统线程状态中的 Running 和 Ready,也就是处于此

状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。

3、·无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要 等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:

■没有设置 Timeout 参数的 Object::wait()方法;

■没有设置 Timeout 参数的 Thread::join()方法;

■LockSupport::park()方法。

4、·限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间, 不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法 会让线程进入限期等待状态:

■Thread::sleep()方法;

■设置了 Timeout 参数的 Object::wait()方法;

■设置了 Timeout 参数的 Thread::join()方法;

■LockSupport::parkNanos()方法;

■LockSupport::parkUntil()方法。

5、阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在 等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时 候,线程将进入这种状态。

6、·结束(Terminated):已终止线程的线程状态,线程已经结束执行。

image-20230828224251429

第十三章 线程安全 与 锁优化

如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁 (Lightweight Locking)、偏向锁(Biased Locking)

13.3 锁优化

13.3.1 自旋锁 与 自适应自旋

13.2 锁消除

13.3 锁粗化

13.3.4 轻量级锁 (1 、2)

步骤:

1、在 代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚 拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储 锁对象目前的 Mark Word 的拷贝(官方为这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图 13-3 所示。

2、然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的 指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后两个比特)将转变为“00”,表示此对象处于轻量级 锁定状态。这时候线程堆栈与对象头的状态如图 13-4 所示。

image-20230828230635684

image-20230828230724696

失效:

如果出现两条以上的线程争用同一个锁的情况,那 轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻 塞状态。

13.3.5 偏向锁 (1)

步骤:

1、当锁对象第一次 被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为 “1”,表示进入偏向模式。

2、同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。

3、如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同 步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新 操作等)。

问题:发现一个问题:当对象进入偏向状态的时候,Mark Word 大部分的空间(23 个比特)都用于存储持有锁的线程 ID 了,这部分空间占用了原 有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?

结论:

1、当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;

2、而当一个对象当前正处于偏向锁状态,又收到 需要计算其一致性哈希码请求[1]时,它的偏向状态会被立即撤销,并且锁会膨胀为重量 级锁。


深入理解Java虚拟机
http://example.com/2023/06/24/书籍-笔记/深入理解Java虚拟机/
作者
where
发布于
2023年6月24日
许可协议