阅读 183

JVM精通面试系列 | 掘金技术征文

01 Java代码是怎么运行的?

Java 和 C++ 在运行方式上的区别

  1. C++ 代码无需额外的运行时编译。代码直接编译成 CPU 所能理解的 代码格式,也就是机器码。
  2. Java 要在虚拟机中运行呢

JRE 和 JDK的区别

JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。我们 Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开 发、诊断工具。

为什么 Java 要在虚拟机里运行

Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行 这种复杂的程序并不现实。所以呢,在运行 Java 程序之前,我们需要对其进行一番转换。

java字节码转换具体是怎么操作的呢?

主流思路是这样子的,设计一个面向 Java 语言特性的虚 拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。

创造java虚拟机带来了哪些好处

  1. 各个现有平台(如 Windows_x64、 Linux_aarch64)上都有JVM实现。这么做的意义在于,一旦一个程序被转换成 Java 字节码, 那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运 行”。
  2. 虚拟机的另外一个好处是它带来了一个托管环境,其中最广为人知的当属自动内存管理与垃圾回收,除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于 书写这些无关业务逻辑的代码。

Java 虚拟机具体是怎么运行 Java 字节码的

从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机 中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法 区内的代码。

Java 虚拟机堆和栈的作用

Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。

运行过程中,栈是如何工作的

  1. 在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java 虚拟机不要求栈帧在内存空间里连续分布。
  2. 当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

简单介绍下解释执行和即时编译

在 HotSpot 里面,编译过程有两种形式

  • 第一种是解释执行,即逐条将字节码翻译成机器 码并执行
  • 第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所 有字节码编译成机器码后再执行。

前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。

HotSpot 默认采用混合 模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的 热点代码,以方法为单位进行即时编译。

Java 虚拟机的运行效率究竟是怎么样的

HotSpot 采用了多种技术来提升启动性能以及峰值性能

  • 即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算 资源。
  • 对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方 式运行
  • 对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想 的运行速度。
  • 理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编 译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

目前java一共有几种编译器

三种,C1、C2 和 Graal。Graal 是 Java 10 正式引入的实验性即时编译器,

为什么要引入引入多个即时编译器

是为了在编译时间和生成代码的执行效率之间进行取舍。

介绍一下三种编译器的功能

  • C1 又叫 做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单, 因此编译时间较短。
  • C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对 复杂,因此编译时间较长,但同时生成代码的执行效率较高。
  • Graal 编译结果的性能略优于 OpenJDK 中的 C2;对 Scala 程序而言,它的 性能优势可达到 10%(企业版甚至可以达到 20%!)。这背后离不开 Graal 所采用的激进优化 方式。

介绍下java 编译方式与编译器的关系

从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方 法中的热点会进一步被 C2 编译。

为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。

在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在 下次调用该方法时启用,以替换原本的解释执行。

Java 虚拟机 运行时内存区域的五个部分

分别为方法区、堆、PC 寄存器、Java 方法栈 和本地方法栈。Java 程序编译而成的 class 文件,需要先加载至方法区中,方能在 Java 虚拟机 中运行。

为了提高效率,HotSpot 虚拟机采用了什么策略

为了提高运行效率,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。

扩展阅读

使用asmtools.jar修改.class文件

AsmTools用户指南

02 Java的基本类型

Java 语言规范以及 Java 虚拟机规范是怎么定义 boolean 类型的

  • 在 Java 语言规范中,boolean 类型的值只有两种可能,它们分别用符号“true”和“false”来 表示。显然,这两个符号是不能被虚拟机直接使用的。
  • 在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。这个编码规则约束了 Java 字节码的具体实现。

在虚拟机中把boolea类型的值修改为3,会运行出错吗?

对于 Java 虚拟机来说,它看到的 boolean 类型,早已被映射为整数类型。因此,将原本声明 为 boolean 类型的局部变量,赋值为除了 0、1 之外的整数值,在 Java 虚拟机看来是“合 法”的。

Java 的基本类型都有默认值,它们在内存中的值是什么?

尽管他们的默认值看起来不一样, 但在内存中都是 0。

在基本类型中,哪些是无符号类型

boolean 和 char 是唯二的无符号类型。在不考虑违反规范的情况下, boolean 类型的取值范围是 0 或者 1。char 类型的取值范围则是 [0, 65535]。

声明为 byte、char 以及 short 的局部变量,是否也能够存储超出它们取值范围的数值呢?

答案是可以的。而且,这些超出取值范围的数值同样会带来一些麻烦。比如说,声明为 char 类 型的局部变量实际上有可能为负数。当然,在正常使用 Java 编译器的情况下,生成的字节码会 遵守 Java 虚拟机规范对编译器的约束,因此你无须过分担心局部变量会超出它们的取值范围。

Java中,正无穷和负无穷确切的值是多少

在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。

什么是NaN

整数 0x7F800000 等同于正无穷,那么 0x7F800001 这个数字对应的浮点数是 NaN,[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 对应的都是 NaN, 0x7FC00000。这个 数值,我们称之为标准的NaN,而其他的我们称之为不标准的 NaN。

解释栈帧有哪两个主要的组成部分

  • 局部变量区(除了普遍意义下的局部变量之外,它还包含实例方法的“this 指针”以及方法所接收 的参数。)
  • 字节码的操作数栈。

boolean、byte、char、short在栈上占用多少空间

boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和 引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。这种情况仅存在于局部变量

boolean、byte、char、short在堆上占用多少空间

对于byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。

jvm 把一个基本类型存储为另一个基本类型是做了什么操作

,当我们将一个 int 类型的值,存储到这些类型的字段或数组时,相当于做了一次隐式的掩 码操作。

对于 boolean、char 这两个无符号类型来说,加载伴随着零扩展。举个例子,char 的大小为两个字节。在加载时 char 的值会被复制到 int 类型的低二字节,而高二字节则会用 0 来填充。

对于 byte、short 这两个类型来说,加载伴随着符号扩展。举个例子,short 的大小为两个字 节。在加载时 short 的值同样会被复制到 int 类型的低二字节。如果该 short 值为非负数,即最 高位为 0,那么该 int 类型的值的高二字节会用 0 来填充,否则用 1 来填充。

介绍下虚拟机是如何加载基本类型做运算的

Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是 说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值 当成 int 类型来运算。

boolean 类型在 Java 虚拟机中被映射成了什么值

“true”被映射为 1,而“false”被映射为 0。

03 | Java虚拟机是如何加载Java类的?

Java 语言的类型可以分为哪两大类

基本类型(primitive types)和引用类型

什么是引用类型

:类、接口、数组类和泛型参数。由于泛型参数 会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种

Java 虚拟机中的类加载有几个步骤

从 class 文件到内存中的类,按先后顺 序需要经过加载、链接以及初始化三大步骤。

什么是加载

加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。

介绍一下类加载器

  • 启动类加载器(boot class loader)。启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。
  • 除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

什么是双亲委派模型

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请 求的类的情况下,该类加载器才会尝试去加载。

java一共有几种类加载器

  • 启动类加载器
  • 应用类加载器
  • 扩展类加载器

java的类加载器都做什么用

  • 启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录 下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。
  • 扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
  • 应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

什么场景需要自定义类加载器

举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解 密。

java9 之后类加载器做了哪些改变

Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

类加载器的命名空间有什么作用

在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

什么是链接

链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。

链接有几个阶段, 每个阶段有何作用

它可分为验证、准备以 及解析三个阶段。

  • 验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。
  • 准备阶段的目的,则是为被加载类的静态字段分配内存。
  • 解析阶段的目的,将符号引用解析成为实际引用。如果符号引用指向一个未被加载的 类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以 及初始化。)

什么是符号引用

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

java虚拟机规范对符号引用的说明

它仅规定了:如果某些字节码使用了符号 引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

什么是初始化

初始化便是为标记为常量值的字段赋值,以及执行方法的过程。Java 虚 拟机会通过加锁来确保类的方法仅被执行一次。只有当初始化完成之后,类才正式成为可执行的状态。

类的初始化何时会被触发?

JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

类的初始化是线程安全的吗?

类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个Singleton 实例。

jvm调试打印类加载的先后顺序

通过 JVM 参数 -verbose:class 来打印类加载的先后顺序。

04 | JVM是如何执行方法调用的?(上)

Java 虚拟机是怎么识别目标方法的

Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

类中出现多个名字相同,并且参数类型和个数相同的方法能否编译通过

无法通过编译。在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们 的参数类型必须不同。这些方法之间的关系,我们称之为重载。

如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?

如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。

Java 虚拟机是怎么识别方法的。

Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。

什么是方法描述符

方法描述符,它是由方法的参数类型以及返回类型所构成。在同 一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证 阶段报错。

字节码中方法描述符都包含哪些部分

方法名字、参数类型、返回类型 三个部分

Java 虚拟机是如何判定方法为重写

方法重写的判定基于方法描述符。也就是说,如果子类定义了与父类中 非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。

关于重写的区分在虚拟机的什么阶段完成

Java 虚拟机会在类的验证阶段发现并报错。

关于重载的区分在虚拟机的什么阶段完成

于对重载方法的区分在编译阶段已经完成,由于 Java 编译器已经区 分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。 因为,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compiletime polymorphism);而重写则被称为动态绑定(dynamic binding)。

什么是静态绑定与动态绑定

Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态 绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。

Java 字节码中与调用相关的指令共有哪五种

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。

详细介绍一下符号引用

在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。

非接口符号引用, Java 虚拟机是如何将其变为实际引用的

假定该符号引用所指向的类为 C,

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  3. 如果没有找到,在 C 的直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

接口符号引用, Java 虚拟机是如何将其变为实际引用的

假定该符号引用所指向的接口为 I

  1. 在 I 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 Object 类中的公有实例方法中搜索
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3的要求一致。

实际引用在静态绑定和动态绑定中指代什么

对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

重载以及重写的概念

重载指的是方法名相同而参数类型不相同的方法之 间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。

Java语言与java虚拟机识别方法的方式有什么不同

Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。

05 | JVM是如何执行方法调用的?(下)

如何识别代码中使用了虚方法调用

Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而 接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方 法调用。

JVM通过什么判断是决定静态绑定还是动态绑定

虚方法调用包括 invokevirtual 指令和 invokeinterface 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。

哪些指令和方法会采用静态绑定

静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。

动态绑定是采用什么实现的

Java 虚拟机采取了一种用空间换取时间的策略,通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。在解析虚方法调用时,Java 虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。

什么是方法表

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

动态绑定的过程是怎样的

对于动态绑定的方法调用而言,实际引用则是方法表的 索引值(实际上并不仅是索引值)。在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

即时编译的两种优化手段

内联缓存(inlining cache)和方法内联(method inlining)。

什么是内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

介绍下单态内联缓存和多态内联缓存

  • 单态内联 缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比 较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
  • 多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

java虚拟机使用哪种内联缓存

为了节省内存空间,Java 虚拟机只采用单态内联缓存。

什么情况下java 虚拟机会采用超多态内联缓存

当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。

06 | JVM是如何处理异常的?

异常的基本概念

所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接 子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无 法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并 且处理的异常。而 Exception 又分为 RuntimeException 和其他类型。RuntimeException 和 Error 属于非检查异常。其他的 Exception 皆属于检查异常,在触发时需要显式捕获,或者在方法头用 throws 关键字声明。

什么是非检查异常和检查异常

RuntimeException 和 Error 属于 Java 里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。

Java 虚拟机是如何捕获异常的?

在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。

其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。

image

Java 虚拟机捕获异常的执行过程

当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。

如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。

finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。

image

catch 代码块捕获异常,并且触发了另一个异常,则finally 捕获异常是哪个?

是catch触发的另一个异常。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。

什么是 Supressed 异常

Supressed 异常是java 7之后引入的,新特性允许开发人员将一个异常附于另一 个异常之上。因此,抛出的异常可以附带多个异常的信息。然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁 琐。

如何更好的使用 Supressed 异常新特性

Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Supressed 异常。当然,该语法糖的主要目的并不是使用 Supressed 异常,而是精简资源打开 关闭的用法。Java7的try-with-resources

07 | JVM是如何实现反射的?

什么是反射

反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序 的动态行为。比如

  • 通过 Class 对象访问该类中的所有方法,可以通过 Method.setAccessible(位于 java.lang.reflect 包,该方法继承自 AccessibleObject)绕过 Java 语言的访问权限,在私有方法所在类之外的地方调用该方法。
  • Spring 框架的依赖反转(IoC),便是依赖于反射机制。

方法的反射调用, Method.invoke是怎么实现的

Method.invoke 的源代码,它实际上委派给 MethodAccessor 来 处理。MethodAccessor 是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反 射调用,另一个则使用了委派模式。可以简称“本地实现”和“委派实现”

每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。

简述一下反射调用的执行过程

反射调用先是调用了 Method.invoke,然后进入委派实现 (DelegatingMethodAccessorImpl),再然后进入本地实现 (NativeMethodAccessorImpl),最后到达目标方法。

为什么反射调用还要采取委派实现作为中间层?直接交给本地实现不可以么?

Java 的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使 用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现 中切换。

动态实现和本地实现相比,性能上有什么差别

动态实现和本地实现相比,其运行效率要快上 20 倍 [2] 。这是因为动态实现无需经过 Java 到C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍 [3]。

虚拟机什么时候会从本地实现切换为动态实现

考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold=来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。

如果想一直使用反射的动态实现怎么办

反射调用的 Inflation 机制是可以通过参数-Dsun.reflect.noInflation=true来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。

方法的反射调用采用什么实现方式

在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过 15次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。

反射调用的性能开销来源于哪

方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。

说说你常用的反射API

参考==>java 反射API

说说反射性能优化的几种方式

  1. 既然每个反射调用对应的参数个数是固定的,那么我们可以选择在循环外新建一个 Object 数组,设置好参数,并直接交给 反射调用。但如果性能小号消耗不减反升则需要配合下面第2点一起使用。

  2. 可以关闭反射调用的 Inflation 机制,从而取消委派实现,并且直接使用动态实 现。此外,每次反射调用都会检查目标方法的权限,而这个检查同样可以在 Java 代码里关闭, 在关闭了这两项机制之后,

  3. 提高 Java 虚拟机关于每个调用能够记录的类型数目(对应虚拟机参数 -XX:TypeProfileWidth,默认值为 2,这里设置为 3)。

08 | JVM是怎么实现invokedynamic的?(上)

方法调用会被编译成哪四种字节码指令

在 Java 中,方法调用会被编译为 invokestatic,invokespecial,invokevirtual 以及 invokeinterface 四种指令。这些指令与包 含目标方法类名、方法名以及方法描述符的符号引用捆绑。

什么是invokedynamic

在方法的四个调用指令中,Java 虚拟机明确要求方法调用需要提供目标方法的类名。在 这种体系下,有两个解决方案。一是调用其中一种类型的方法; 另一种是通过反射机制,来查找并且调用各个类型中的赛跑方法。

比起直接调用,这两种方法都相当复杂,执行效率也可想而知。为了解决这个问题,Java 7 引入了一条新的指令 invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许 应用程序将调用点链接至任意符合条件的方法上。

什么是方法句柄

方法句柄是一个强类型的,能够被直接执行的引用。该引用可以指向常规的静态方法或者实 例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的 虚构方法,语义上等价于目标字段的 getter 或者 setter 方法。

它仅关心所指向方法的参数类型以及返回类 型,而不关心方法所在的类以及方法名。

需要注意的是,它并不会直接指向目标字段所在类中的 getter/setter,毕竟你无法保证已 有的 getter/setter 方法就是在访问目标字段。

方法句柄是如何创建的

方法句柄的创建是通过 MethodHandles.Lookup 类来完成的。

方法句柄的类型(MethodType)由哪部分组成

方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来 确认方法句柄是否适配的唯一关键。

方法句柄查找方法时,是如何区分具体的调用类型

对于用 invokestatic 调用 的静态方法,我们需要使用 Lookup.findStatic 方法;对于用 invokevirutal 调用的实例方法, 以及用 invokeinterface 调用的接口方法,我们需要使用 findVirtual 方法;对于用 invokespecial 调用的实例方法,我们则需要使用 findSpecial 方法。

方法句柄的权限问题

  • 权限检查是在句柄的创建阶段完成的。
  • 在实际调用过程中,Java 虚拟机并不会检查方法句柄的权限。
  • 方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于 Lookup 对象 的创建位置。
  • 方法句柄没有运行时权限检查,因此,应用程序需要负责方法句柄的管理。
  • 发布了某 些指向私有方法的方法句柄,那么这些私有方法便被暴露出去了。

方法句柄的调用是哪两种

  • 严格匹配参数类型的调用方式 invokeExact
  • 自动适配参数类型的调用方式 invoke

@PolymorphicSignature注解有什么用

@PolymorphicSignature是方法句柄 API 的一个特殊的注解类 。在碰到被它注解的方法调用时,Java 编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明 的描述符。

invoke是如何实现自动适配参数类型的

invoke 会调用 MethodHandle.asType 方法,生成一个适配器方法句 柄,对传入的参数进行适配,再调用原方法句柄。调用原方法句柄的返回值同样也会先进行适 配,然后再返回给调用者。

方法句柄是如何实现对参数增删改的操作

  • 改操作就是刚刚介绍的 MethodHandle.asType 方法。
  • 删操作指的是将传入的部分参数就地抛 弃,再调用另一个方法句柄。它对应的 API 是 MethodHandles.dropArguments 方法。
  • 增操作会往传入的参数中插入额外的参数,再调用另一个方法句柄,它对应的 API 是 MethodHandle.bindTo 方法。Java 8 中捕获类型的 Lambda 表达式便是用这种操作来 实现的

什么是方法的柯里化

举个例子,有一个指向 f(x, y) 的方法句柄,我们可以 通过将 x 绑定为 4,生成另一个方法句柄 g(y) = f(4, y)。在执行过程中,每当调用 g(y) 的方法 句柄,它会在参数列表最前面插入一个 4,再调用指向 f(x, y) 的方法句柄。

invokeExact 或者 invoke 的调用具体会进入哪个方法

invokeExact 的目标方法就是方法句柄指向的方法。 java 虚拟机会对 invokeExact 调用做特殊处理,调用至一个共享的、与方法句柄类型 相关的特殊适配器中。这个适配器是一个 LambdaForm,我们可以通过添加虚拟机参数将之导 出成 class 文件(-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)。

Java 虚拟机隐藏了的栈信息要如何显示

启用-XX:+ShowHiddenFrames

关于方法句柄的优化

方法句柄的执行次数超过一个阈值时会进行优化(对应参数 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,默认值为 127)。

关于方法句柄持有的适配器优化

方法句柄一开始持有的适配器是共享的。 当它被多次调用之后,Invokers.checkCustomized 方法会为该方法句柄生成一个特有的适配 器。这个特有的适配器会将方法句柄作为常量,直接获取其 MemberName类型的字段,并继 续后面的 linkToStatic 调用。

方法句柄的调用和反射调用的共同点

都是间接调用,同样会面临无法内联的问题。

09 | JVM是怎么实现invokedynamic的?(下)

介绍下invokedynamic 指令

invokedynamic 是 Java 7 引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将 调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链 接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。

在第一次执行 invokedynamic 指令时,Java 虚拟机会调用该指令所对应的启动方法 (BootStrap Method),来生成前面提到的调用点,并且将之绑定至该 invokedynamic 指令 中。在之后的运行过程中,Java 虚拟机则会直接调用绑定的调用点所链接的方法句柄。

Lambda 表达式是如何借助 invokedynamic 来实现的

Java 编译器利用 invokedynamic 指令来生成实现了函数式接口的适配器。这里的 函数式接口指的是仅包括一个非 default 接口方法的接口,一般通过 @FunctionalInterface 注 解。不过就算是没有使用该注解,Java 编译器也会将符合条件的接口辨认为函数式接口。

在编译过程中,Java 编译器会对 Lambda 表达式进行解语法糖(desugar),生成一个方法来保存 Lambda 表达式的内容。该方法的参数列表不仅包含原本 Lambda 表达式的参数,还包含它所捕获的变量。(注:方法引用,如 Horse::race,则不会生成生成额外的方法。)

invokedynamic 指令是如何保证 Lambda 表达式的线程安全

无法共享同一个适配器类的实例。在每次执行 invokedynamic 指令时,所调用的方法句柄都需要新建一个适配器类实例。

如何查看一个类具体的适配器类

可以通过虚拟机参数 -Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH 导出这些 具体的适配器类。

Lambda 以及方法句柄的性能分析

测量结果显示,它与直接调用的性能并无太大的区别。也就是说,即时编译器能够将转换 Lambda 表达式所使用的 invokedynamic,以及对 IntConsumer.accept 方法的调用统统内联 进来,最终优化为空操作。

为什么Lambda与直接调用的性能并无太大的区别

Lambda 表达式所使用的 invokedynamic 将绑定一个 ConstantCallSite, 其链接的目标方法无法改变。因此,即时编译器会将该目标方法直接内联进来。对于这类没有捕 获变量的 Lambda 表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。

如何关闭即时编译器的逃逸分析

通过虚拟机参数 -XX:-DoEscapeAnalysis 来关闭逃逸分析。

invokedynamic指令什么时候会触发逃逸分析优化

它需要同时满足两件事:invokedynamic 指令所执行的方法句柄能够内联,和接下来的对 accept 方法的调用也能 内联。

只有这样,逃逸分析才能判定该适配器实例不逃逸。否则,我们会在运行过程中不停地生成适配器类实例。所以,我们应当尽量使用非捕获的 Lambda 表达式。

捕获型和未捕获型的 Lambda 表达式之间的区别

对于没有捕获其他变量的 Lambda 表达式,该 invokedynamic 指令始终返回同一个适配器类的实例。对于捕获了其他变量的 Lambda 表达式,每次执行 invokedynamic 指令将新建一个适配器类实例。

不管是捕获型的还是未捕获型的 Lambda 表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型 Lambda 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作

10 | Java对象的内存布局

java新建对象的几种方式

  • new 语句:被编译为 new 指令,以及对构造器的调用。每个类的构造器皆会直接或者 间接调用父类的构造器,并且在同一个实例中初始化相应的字段。
  • Object.clone 方法:直接复制已有的数据,来初始化新建对象的实例字段。
  • 反序列化:直接复制已有的数据,来初始化新建对象的实例字段。
  • Unsafe.allocateInstance 方法:没有初始化实例字段,而 new 语句和反射机制,则是通过调用构造器来初始化实例字段。

Java 对象的对象头(object header)由哪两部分组成

  • 标记字段:用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信 息以及锁信息
  • 类型指针:指向该对象的类。

分析并计算对象头的内存占用

在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位(bit),而类型指针又占了 64 位(bit)。也就是说, 每一个 Java 对象在内存中的额外开销就是 16 个字节(16byte=128bit)。

什么是压缩指针,有什么用

为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32位的。

这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字 节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引 用类型数组。

压缩指针是什么原理

有个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)。

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。

在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB的地址空间(超过 32GB 则会关闭压缩指针)。

内存对齐的作用范围

内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。

为什么字段之间也要内存对齐

字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

什么是字段重排列

字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为1),但都会遵循如下两个规则。

  • 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字 段地址与对象的起始地址差值。
  • 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。

虚共享是怎么回事?如何解决

假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。 然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。

Java 8 还引入了一个新的注释@Contended,用来解决对象字段之间的虚共享,这个注释也会影响到字段的排列。

如何查看跟踪对象字段的内存布局情况

使用 JOL 工具并添加虚拟机选项 -XX:-RestrictContended。如果在 Java 9 以上版本试验的话,在使用 javac 编译时 需要添加 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME

11 | 垃圾回收(上)

如何辨别一个对象是存是亡?

  • 引用计数法(reference counting):它的做法是为每个对象 添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。
  • 可达性分析算法:这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合 引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探 索到的对象便是死亡的,是可以回收的。

引用计数法的实现原理与可能发生的问题

具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是 说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。

除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那 便是无法处理循环引用对象。

举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露

什么是 GC Roots 呢

GC Roots包括(但不限于)如下几种:

  1. Java 方法栈桢中的局部变量;
  2. 已加载类的静态变量;
  3. JNI handles;
  4. 已启动且未停止的 Java 线程。

为什么主流垃圾回收期都采用可达性分析算法

可达性分析可以解决引用计数法所不能解决的循环引用问题。

举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。

可达性分析算法其实也会有其他问题,你知道吗,举例说一个?

在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将 引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃 圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。

可达性分析既然有误报或者漏报问题,那怎么解决呢

通过Java 虚拟机中的 Stop-the-world 机制来实现的。当 Java 虚拟机 收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状 态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性 分析。

字节码转换成机器码是不收jvm控制的,那它如何实现安全点检测

在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有 安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环 回边(back-edge)处插入安全点检测。

不同的即时编译器插入安全点检测的位置也可能不同

什么场景会用到安全点这一机制

  • 垃圾回收
  • Java 虚拟机其他一些对堆栈内容的一致性有要求的操作

垃圾回收的三种方式

  • 会造成内存碎片的清除
  • 性能开销较大的压缩
  • 堆使用效率较低的复制。

介绍下清除(sweep)

即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。

另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointerbumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

介绍下压缩(compact)

即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

介绍下复制(copy)

即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。

12 | 垃圾回收(下)

Java 虚拟机的堆划分为几个部分

Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及 两个大小相同的 Survivor 区。

image

Java 虚拟机的堆划分的策略是什么

默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。

new 对象时,新生代Eden 区都做了什么

当调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆 空间是线程共享的,因此这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。

接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。

如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

如果Eden 区用完怎么办

有一项技术被称之为 TLAB(Thread Local Allcoation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。

这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。

当 Eden 区的空间耗尽了怎么办?

Java 虚拟机便会触发一次 Minor GC,来收集新生 代的垃圾。存活下来的对象,则会被送到 Survivor 区。

新生代中的两个Survivor 区分别是如何工作的

我们分别用 from 和 to 来指代。其中 to 指向的Survivior 区是空的。

当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor区还是空的。

新手代中的对象什么时候会保存到老年代

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

Minor GC为什么采用标记-复制算法

Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理 想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记- 复制算法的效果极好。 Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。

Minor GC会有什么问题吗?

有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的 对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。这样一来,岂不是又做了一次全堆扫描呢?

Minor GC的问题是怎么解决掉的

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个 个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表 对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

卡表是如何解决Minor GC的问题

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

卡表是如何保证每个可能有指向新生代对象引用的卡都被标记为脏卡

Java 虚拟机需 要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。

写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一 律当成可能指向新生代对象的引用。Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。

Minor GC 的吞吐率计算公式

Minor GC 的吞吐率 = ( 应用运行时间/(应用运行时间 + 垃圾回收时间) )

卡表会有虚共享问题吗?如何解决

在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,造成虚共享,间接影响程序性能。

HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。

简述下Minor GC 对新生代进行垃圾回收的相关内容

因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。

针对新生代的垃圾回收器有几个,简单介绍下

针对新生代的垃圾回收器共有三个:

  • Serial:采用标记 - 复制算法,是一个单线程的
  • Parallel Scavenge:采用标记 - 复制算法,可以看成 Serial 的多线程版本,但更加注重吞吐率;不能与 CMS 一起使用
  • Parallel New:采用标记 - 复制算法,可以看成 Serial 的多线程版本

针对老生代的垃圾回收器有几个,简单介绍下

针对老年代的垃圾回收器有三个:

  • Serial Old :标记 - 压缩算法 ,单线程的
  • Parallel Old:标记 - 压缩算法,前者的多线程版本
  • CMS:标记 - 清除算法,并且是并发的。

介绍下G1垃圾收集器

G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。

G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。

关注下面的标签,发现更多相似文章
评论