@ -23,6 +23,7 @@ JVM 相关口水话
11. JVM 是如何实现反射的?
11. JVM 是如何实现反射的?
12. JVM 是如何实现泛型的?
12. JVM 是如何实现泛型的?
13. JVM 是如何实现异常的?
13. JVM 是如何实现异常的?
14. JVM 是如何实现注解的?
#### 内存区域
#### 内存区域
@ -94,7 +95,7 @@ Java 中的引用可以分为四类,强引用、软引用、弱引用和虚引
##### G1 及 ZGC
##### G1 及 ZGC
Garbage First(G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。它和 CMS 同样是一款主要面向服务端应用的垃圾收集器,不过在 JDK9 之后,CMS 就被标记为废弃了。在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),在要么就是整个 Java 堆(Full GC)。而 G1 是基于 Region 堆内存布局,虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或者老年代。收集器根据 Region 的不同角色采用不同的策略去处理。G1 会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些 Region 区,也就是垃圾最大的 Region 区,这就是 Garbage First 名字的由来。
Garbage First(G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。它和 CMS 同样是一款主要面向服务端应用的垃圾收集器,不过在 JDK9 之后,CMS 就被标记为废弃了,G1 作为默认的垃圾收集器,在 JDK 14 已经正式移除 CMS 了 。在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),在要么就是整个 Java 堆(Full GC)。而 G1 是基于 Region 堆内存布局,虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或者老年代。收集器根据 Region 的不同角色采用不同的策略去处理。G1 会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些 Region 区,也就是垃圾最大的 Region 区,这就是 Garbage First 名字的由来。
G1 收集器的运作过程大致可划分为以下四个步骤:
G1 收集器的运作过程大致可划分为以下四个步骤:
@ -116,7 +117,7 @@ G1 收集器的运作过程大致可划分为以下四个步骤:
G1 的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,但是 G1 也存在一些问题,比如停顿时间过长,通常 G1 的停顿时间在几十到几百毫秒之间,虽然这个数字其实已经非常小了,但是在用户体验有较高要求的情况下还是不能满足实际需求,而且 G1 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于 100GB 的系统上,会因内存过大而导致停顿时间增长。
G1 的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,但是 G1 也存在一些问题,比如停顿时间过长,通常 G1 的停顿时间在几十到几百毫秒之间,虽然这个数字其实已经非常小了,但是在用户体验有较高要求的情况下还是不能满足实际需求,而且 G1 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于 100GB 的系统上,会因内存过大而导致停顿时间增长。
ZGC 作为新一代的垃圾回收器,在设计之初就定义了三大目标:支持 TB 级内存,停顿时间控制在 10ms 之内,对程序吞吐量影响小于 15%。
ZGC 在 JDK11 被引入, 作为新一代的垃圾回收器,在设计之初就定义了三大目标:支持 TB 级内存,停顿时间控制在 10ms 之内,对程序吞吐量影响小于 15%。
#### 类加载机制
#### 类加载机制
@ -166,10 +167,54 @@ HotSpot 内置了多个 JIT 即时编译器,C1 和 C2,之所以引入多个
再说 Dalvik 和 ART。
再说 Dalvik 和 ART。
在官方文档上,已经没有 Dalvik 相关的信息了,Android 5 后,ART 全面取代了 Dalvik。Dalvik 使用 JIT 而 ART 使用 AOT。AOT 和 JIT 的不同之处在于,JIT 是在运行时进行编译,是动态编译,并且每次运行程序的时候都需要对 odex 重新进行编译;而 AOT 是静态编译,应用在安装的时候会启动 dex2oat 过程把 dex 预编译成 oat 文件,每次运行程序的时候不用重新编译。另外,相比于 Dalvik,ART 对 GC 过程也进行了改进,只有一次 GC 暂停,而 Dalvik 需要两次,而且在 GC 保持暂停状态期间并行处理。AOT 解决了应用启动和运行速度问题的同时也带来了另外两个问题,一个是应用安装和系统升级之后的应用安装时间比较长,二是优化后的文件会占用额外的存储空间。在 Android 7 之后,JIT 回归,形成了 AOT/JIT 混合编译模式,这种混合编译模式的特点是:应用在安装的时候 dex 不会被编译,应用在运行时 dex 文件先通过解释器执行,热点代码会被识别并被 JIT 编译后存储在 Code cache 中生成 profile 文件,再手机进入 IDLE(空闲)或者 Charging(充电)状态的时候,系统会扫描 App 目录下的 profile 文件并执行 AOT 过程进行编译。这样一说,其实是和 HotSpot 有点内味。
HotSpot 是基于栈结构的,而 Dalvik 是基于寄存器结构。 在官方文档上,已经没有 Dalvik 相关的信息了,Android 5 后,ART 全面取代了 Dalvik。Dalvik 使用 JIT 而 ART 使用 AOT。AOT 和 JIT 的不同之处在于,JIT 是在运行时进行编译,是动态编译,并且每次运行程序的时候都需要对 odex 重新进行编译;而 AOT 是静态编译,应用在安装的时候会启动 dex2oat 过程把 dex 预编译成 oat 文件,每次运行程序的时候不用重新编译。另外,相比于 Dalvik,ART 对 GC 过程也进行了改进,只有一次 GC 暂停,而 Dalvik 需要两次,而且在 GC 保持暂停状态期间并行处理。AOT 解决了应用启动和运行速度问题的同时也带来了另外两个问题,一个是应用安装和系统升级之后的应用安装时间比较长,二是优化后的文件会占用额外的存储空间。在 Android 7 之后,JIT 回归,形成了 AOT/JIT 混合编译模式,这种混合编译模式的特点是:应用在安装的时候 dex 不会被编译,应用在运行时 dex 文件先通过解释器执行,热点代码会被识别并被 JIT 编译后存储在 Code cache 中生成 profile 文件,再手机进入 IDLE(空闲)或者 Charging(充电)状态的时候,系统会扫描 App 目录下的 profile 文件并执行 AOT 过程进行编译。这样一说,其实是和 HotSpot 有点内味。
#### JVM 是如何执行方法调用的?
#### JVM 是如何执行方法调用的?
其实呢就是了解 Java 编译器和 JVM 是如何区分方法的。方法重载在编译阶段就能确定下来,而方法重写则需要运行时才能确定。
其实呢就是了解 Java 编译器和 JVM 是如何区分方法的。方法重载在编译阶段就能确定下来,而方法重写则需要运行时才能确定。
Java 编译器会根据所传入的参数的声明类型来选取重载方法,而 JVM 识别方法
Java 编译器会根据所传入的参数的声明类型来选取重载方法,而 JVM 识别方法依赖于方法描述符,它是由方法的参数类型以及返回类型所构成。JVM 内置了五个与方法调用相关的指令,分别是 invokestatic 调用静态方法、invokespecial 调用私有实例方法、invokevirtual 调用非私有实例方法、invokeinterface 调用接口方法以及 invokedynamic 调用动态方法。对于 invokestatic 以及 invokespecial 而言,JVM 能够直接识别具体的目标方法,而对于 invokevirtual 和 invokeinterface 而言,在绝大多数情况下,JVM 需要在执行过程中,根据调用者的动态类型来确定具体的目标方法。唯一的例外在于,如果虚拟机能够确定目标方法有且只有一个,比如方法被 final 修饰,那么它就可以不通过动态类型,直接确定目标方法。
上面所说的 invokespecial、invokeinterface 也被称为虚方法调用或者说动态绑定,相比于直接能定位方法的静态绑定而言,虚方法调用更加耗时。JVM 采用了一种空间换时间的策略来实现动态绑定。它为每个类生成一张方法表,用于快速定位目标方法,这个发生在类加载的准备阶段。方法表本质上是一个数组,它有两个特性,首先是子类方法表中包含父类方法表中所有的方法,其次是子类方法在方法表中的索引,与它所重写的父类方法的索引值相同。我们知道,方法调用指令中的符号引用会在执行之前解析为实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的方法,对于动态绑定而言,实际引用则是方法表的索引值。
JVM 也提供了内联缓存来加快动态绑定,它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。
#### JVM 是如何实现反射的?
反射呢是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序的动态行为。表现为两点,一是对于任意一个类,都能知道这个类的所有属性和方法,二是对于任意一个对象,都能调用它的任意属性和方法。
反射的使用还是比较简单的,涉及的 API 分为三类,Class、Member(Filed、Method、Constructor)、Array and Enumerated。我当时是直接扒 Oracle 官方文档看的,讲的很详细。
我对反射的好奇是来源于,经常会听说反射影响性能,那么性能开销在哪以及如何优化?
在此之前,我先讲讲 JVM 是如何实现反射的。
我们可以直接 new Exception 来查看方法调用的栈轨迹,在调用 Method.invoke() 时,是去调用 DelegatingMethodAccessorImpl 的 invoke,它的实际调用的是 NativeMethodAccessorImpl 的 invoke 方法。前者称为委派实现,后者称为本地实现。既然委派实现的具体实现是一个本地实现,那么为啥还需要委派实现这个中间层呢?其实,Java 反射调用机制还设立了另一种动态生成字节码的实现,成为动态实现,直接使用 invoke 指令来调用目标方法。之所以采用委派实现,是在本地实现和动态实现直接做切换。依据注释信息,动态实现比本地实现相比,其运行效率要快上 20 倍。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生产字节码比较耗时,仅调用一次的话,反而是本地实现要快上三四倍。考虑到很多反射调用仅会执行一次,JVM 设置了阈值 15,在 15 之下使用本地实现,高于 15 时便开始动态生成字节码采用动态实现。这也被称为 Inflation 机制。
在反手说一下反射的性能开销在哪呢?平时我们会调用 Class.forName、Class.getMethod、以及 Method.invoke 这三个操作。其中,Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法,如果匹配到,它还将遍历父类的公有方法,可想而知,这两个操作都非常耗时。下面就是 Method.invoke 调用本身的开销了,首先是 invoke 方法的参数是一个可变长参数,也就是构建一个 Object 数组存参数,这也同时带来了基本数据类型的装箱操作,在 invoke 内部会进行运行时权限检查,这也是一个损耗点。普通方法调用可能有一系列优化手段,比如方法内联、逃逸分析,而这又是反射调用所不能做的,性能差距再一次被放大。
优化反射调用,可以尽量避免反射调用虚方法、关闭运行时权限检查、可能需要增大基本数据类型对应的包装类缓存、如果调用次数可知可以关闭 Inflation 机制,以及增加内联缓存记录的类型数目。
#### JVM 是如何实现泛型的?
Java 中的泛型不过是一个语法糖,在编译时还会将实际类型给擦除掉,不过会新增一个 checkcast 指令来做编译时检查,不过类型不匹配就抛出 ClassCastException。
不过呢,字节码中仍然存在泛型参数的信息,如方法声明里的 T foo(T),以及方法签名 Signature 中的 "(TT;)TT",这些信息可以通过反射 Api getGenericXxx 拿到。
#### JVM 是如何实现异常的?
在 Java 中,所有的异常都是 Throwable 类或其子类,它有两大子类 Error 和 Exception。 当程序触发 Error 时,它的执行状态已经无法恢复,需要终止线程或者终止虚拟机,常见的比如内存溢出、对栈溢出等;Exception 又分为两类,一类是受检异常,比如 IOException,一类是运行时异常 RuntimeException,比如空指针、数组越界等。
接下来我会从三个方面阐述这个问题。
首先是,异常实例的构造十分昂贵。这是由于在构造异常实例时,JVM 需要生成该异常的栈轨迹,该操逐一访问当前线程的 Java 栈桢,并且记录下各种调试信息,包括栈桢所指向方法的名字、方法所在的类名以及方法在原代码中的位置等信息。
其次是,JVM 捕获异常需要异常表。每个方法都有一个异常表,异常表中的每一个条目都代表一个异常处理器,并且由 from、to、target 指针及其异常类型所构成。form-to 其实就是 try 块,而 target 就是 catch 的起始位置。当程序触发异常时,JVM 会坚持触发异常的字节码的索引值落到哪个异常表的 from-to 范围内,然后再判断异常类型是否匹配,匹配就开始执行 target 处字节码处理该异常。
最后是 finally代码块的编译。我们知道 finally 代码块一定会运行的(除非虚拟机退出了)。那么它是如何实现的呢?其实是一个比较笨的办法,当前 JVM 的做法是,复制 finally 代码块的内容,分别放在所有可能的执行路径的出口中。
#### JVM 是如何实现注解的?
其实也没啥银弹,主要就是要知道注解信息是存放在哪的?在 Java 字节码中呢是通过 RuntimeInvisibleAnnotations 结构来存储的,它是一个 Annotations 数组,毕竟类、方法、属性是可以加多个注解的嘛。在数组中的每一个元素又是一个 ElementValuePair 数组,这个里面存储的就是注解的参数信息。
运行时注解可以通过反射去拿这些信息,编译时注解可通过 APT 去拿,基本上就没啥东西了。