Oracle Labs新公开一项黑科技: Graal VM. 口号: Run Programs Faster Anywhere, 是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为任何语言的运行平台使用.
Graal VM的基本工作原理: 将这些语言的源代码或源代码编译后的中间格式通过解释器转换为能被Graal VM接受的中间表示.譬如设计一个解释器专门对LLVM(Low Level Virtual Machine 底层虚拟机)输出的字节码进行转换来支持C和C++语言,这个过程称为程序特化.
从更严格的角度来看, Graal VM才算真正意义上与物理计算机相对应的高级语言虚拟机,因为它与物理硬件的指令集一样,做到了只与机器特性相关而不与某种高级语言特性相关.
HotSpot虚拟机中含有两个即时编译器, 分别是编译耗时短但输出代码优化程度较低的客户端编译器(C1)和编译耗时长但输出代码优化质量更高的服务端编译器(C2).
JDK10起, HotSpot加入了一个全新的Graal编译器,Graal编译器是以用来替代C2编译器.Graal编译器保持输出相近代码质量的同时,开发效率和扩展性都要显著优于C2编译器, C2编译器中优秀的代码优化技术可轻易移植到Graal编译器上,但Graal编译器中行之有效的优化在C2编译器里实现起来异常困难.Graal能够做比C2更加复杂的优化分析,如部分逃逸分析,也拥有比C2更容易使用激进预测性优化的策略,支持自定义的预测性假设等.
在微服务架构的视角下,应用拆分后,单个微服务很可能就不再需要面对大内存,有了高可用的服务机器,无须追求单个服务实时不间断的运行,它们随时可以中断和更新,但相应的Java的启动时间相对较长,需要预热才能达到最高性能等特点就显得相悖于这样的应用场景.因此逐步开始对提前编译提供支持.
提前编译能带来的最大好处是Java虚拟机加载这些已经预编译成二进制库之后就能够直接调用,无须再等待即时编译器在运行时将其编译成二进制机器码,可以减少即时编译带来的预热时间.
Substrate VM目标是代替HotSpot用来支持提前编译后的程序执行,包含了一个本地镜像的构造器,用于为用户程序建立基于Substrate VM的本地运行时镜像.这个构造器采用指针分析的技术,从用户提供的程序入口触发,搜索所有可达的代码,搜索的同时,还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照中,这样Substrate VM可直接从目标程序开始运行,无须重复进行Java虚拟机的初始化过程,但相应地,原理上决定了Substrate VM必须要求目标程序是封闭的,不能动态加载其他编译期不可知的代码和类库.
Substrate VM能显著降低内存占用以及启动时间.
HotSpot的定位是面向各种不同应用场景的全功能Java虚拟机,模块化方面是HotSpot的弱项.
HotSpot虚拟机现在能够在编译时指定一系列特性开关,让编译输出的HotSpot虚拟机可以裁剪成不同的功能,能够实现功能特性的组合拆分,反映到源代码则是条件编译和接口实现的分离.
HotSpot在JDK5时期,抽象出了Java虚拟机工具接口来为所有Java虚拟机相关工具提供本地编程接口集合; JDK9时期,开放了Java语言级别的编译器接口,使得在Java虚拟机外部增加,替换即时编译器成为可能; 到JDK10,HotSpot又重构了Java虚拟机的垃圾收集器接口,统一了其内部各款垃圾收集器的公共行为,如果未来这个接口完全开放,甚至有可能会出现其他独立于HotSpot的垃圾收集器实现.
一门语言的功能,语法是影响语言生产力和效率的重要因素,可有可无的语法糖也是直接影响语言使用者的幸福感程度的关键指标.
JDK10到13的新语法改进:
功能改进计划项目:
本次示例使用的Linux版本是Ubuntu18.04.1 LTS版本
Bootstrap JDK版本为 OpenJDK 11
编译的JDK版本为 JDK 12
要求至少需要10GB的磁盘空间和4GB的内存
建议在码云上下载OpenJDK的源码: https://gitee.com/mirrors/openjdk
点击标签: https://gitee.com/mirrors/openjdk/tags
下载OpenJDK 11版本代码的压缩包, 下载完成后上传到自己的虚拟机上并解压.
# 在路径/etc/apt下的sources.list文件
sudo cp sources.list sources.list.backup
# 修改为阿里云源
vim sources.list
# 修改为
deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
# 更新apt-get
sudo apt-get update
sudo apt-get upgrade
# 安装C++编译环境,注意必须在切换为阿里云源后进行安装
sudo apt-get install build-essential
# 安装必须的编译环境工具
sudo apt-get install libfreetype6-dev
sudo apt-get install libcups2-dev
sudo apt-get install libx11-dev libxext-dev libxrender-dev libxrandr-dev libxtst-dev libxt-dev
sudo apt-get install libasound2-dev
sudo apt-get install libffi-dev
sudo apt-get install autoconf
sudo apt-get install libfontconfig1-dev
sudo apt-get install libelf-dev
# 源码压缩包解压后中的doc目录下的building.html中有上面命令的详细说明
sudo apt-get install openjdk-11-jdk
# 删除安装的jdk
sudo apt-get remove openjdk-11-jdk
# 安装完成后,使用下面命令检查
java -version
# 出现以下信息则表明安装成功
openjdk version "11.0.17" 2022-10-18
OpenJDK Runtime Environment (build 11.0.17+8-post-Ubuntu-1ubuntu218.04)
OpenJDK 64-Bit Server VM (build 11.0.17+8-post-Ubuntu-1ubuntu218.04, mixed mode, sharing)
# 系统安装到的路径是
/usr/lib/jvm/java-11-openjdk-amd64
以上环境准备好后, 开始进行操作
# 进入解压后的源码目录中
bash(或sh) configure
# 出现下面信息,则是配置成功
Build performance summary:
* Cores to use: 8
* Memory limit: 7933 MB
# 进行编译,使用all则全部编译,使用images则只编译product,在building.html中有说明
make all(或images)
# 如果make不成功,则使用下面命令进行清理
make clean dist-clean
# 编译完成后,在下面目录可看到编译后的源码
源码目录/build/linux-x86_64-server-release/jdk
# 进入bin目录可看到java命令,测试
./java -version
# 出现下面信息则编译完成
openjdk version "12-internal" 2019-03-19
OpenJDK Runtime Environment (build 12-internal+0-adhoc.lzlg.openjdk-jdk-1232)
OpenJDK 64-Bit Server VM (build 12-internal+0-adhoc.lzlg.openjdk-jdk-1232, mixed mode)
Java虚拟机在执行Java程序的过程中把它所管理的内存划分为以下几个区域:
作用:当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程恢复等及基础功能都需要依赖计数器来完成。
由于java中的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,则每个线程都必须包含独立的计数器。因此计数器是个线程私有的内存区域。
如果线程正在执行的是一个Java方法, 这个计数器记录的是正在执行的虚拟机字节码指令的地址. 如果正在执行的是本地Native方法,这个计数器的值为空(Undefined)。
线程私有的,生命周期与线程相同。
每个Java方法执行是会同时创建一个栈帧(用于存储局部变量表,操作栈,动态链接,方法出口等信息),每一个方法被调用到执行完成,对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表:存放编译期可知的基本数据类型,对象引用(reference类型, 可能是引用, 也可能是代表对象的句柄)和returnAddress类型(指向字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示, 其中64位长度的long和double类型占用两个变量槽. 局部变量表所需的内存空间在编译期间完成分配,在方法运行期间大小(变量槽数量)不变。
异常:
1.StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度时
2.OutOfMemoryError:如果虚拟机栈容量可以动态扩展(HotSpot虚拟机的栈容量是不可以动态扩展的, 但以前的Classic虚拟机可以; HotSpot虚拟机会因为申请栈空间失败出现OOM异常),当栈扩展时无法申请到足够内存时
为虚拟机使用到的Native方法服务,同虚拟机栈,也会有StackOverflowError和OutOfMemoryError异常。
HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一.
所有线程共享的内存区域,虚拟机启动时创建,用于存放对象实例。
Java堆是垃圾收集器管理的主要区域,因此又称GC堆(Garbage Collected Heap)。
从内存回收角度:由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以Java堆会分为新生代,老年代; 或者细分为:Eden空间,From Survivor空间,To Survivor空间等。这些分代区域划分仅仅是一部分垃圾收集的共同特性, 而非某个Java虚拟机具体实现的固有内存布局, 更不是Java虚拟机规范里对堆的进一步细致划分. 现在(2023)有的新垃圾收集器不采用分代设计, 故分代提法是基于分代收集的垃圾收集器.
从内存分配角度:线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB)。
Java堆可以处于物理上的不连续的内存空间上,只要逻辑上是连续的即可。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛出OutOfMemoryError异常。
通过参数-Xmx(最大堆内存)和-Xms(最小堆内存)来调节堆大小。
各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。别名非堆(Non-Heap).
方法区是Java虚拟机规范中的概念, Java8以前HotSpot使用永久代实现方法区(把收集器的分代设计扩展至方法区), 到了Java8完全放弃永久代的概念,改用在本地内存中实现的元空间(Meta-space)来代替.
垃圾收集行为在该区域比较少出现,内存回收目标主要是针对常量池的回收和对类型的卸载。
同样,当前方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池是方法区的一部分,Class文件中的常量池中的内容是用于存放编译期生成的各种字面量和符号引用,在类加载后存放到运行时常量池中, 还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于Class文件常量池具备动态性,运行期间也可将新的常量放入池中(String的intern()方法)。
受到方法区内存限制, 当常量池无法在申请到内存时, 会抛出utOfMemoryError异常。
JDK 1.4 加入 NIO 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据.
从网上摘录的一段解释:
NIO的Buffer提供了一个可以不经过JVM内存直接访问系统物理内存的类——DirectByteBuffer。 DirectByteBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同,普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制;而DirectByteBuffer直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制。
直接内存的读写操作比普通Buffer快,但它的创建、销毁比普通Buffer慢(猜测原因是DirectByteBuffer需向OS申请内存涉及到用户态内核态切换,而后者则直接从堆内存划内存即可)。
因此直接内存使用于需要大内存空间且频繁访问的场合,不适用于频繁申请释放内存的场合。
直接内存的分配不会受到Java堆大小的限制,但会受到本机总内存大小以及处理器寻址空间的限制,如果根据实际内存设置-Xmx等参数信息,而忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常.
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载, 解析和初始化过.如果没有,则必须先执行相应的类加载过程.
类加载检查通过后,开始为新生对象分配内存,对象所需的内存的大小在类加载完成后可完全确定.为对象分配内存等同于把一块确定大小的内存块从Java堆中划分出来.
如果Java堆中内存是绝对规整的(使用过的内存和空闲内存有明显的分界),那内存的分配仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞(Bump The Pointer).当使用的是带压缩整理过程的收集器(Serial, ParNew等)时,系统采用指针碰撞的分配算法.简单高效.
如果Java堆中内存是不规整的(使用过的内存和空闲内存相互交错),此时无法进行指针碰撞,虚拟机维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找出一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表(Free List).当使用的是基于清除(Sweep)算法的收集器(CMS)时,理论上只能采用空闲列表.
如果在并发情况下,使用指针碰撞修改指针位置会造成混乱.两个解决方案: 一是对分配内存空间的动作进行同步处理,采用CAS配上失败重试的方式保证更新操作的原子性; 二是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存称为本地线程分配缓冲(TLAB),先在TLAB中分配,只有TLAB用完了,分配新的缓存区时才需要同步.可通过-XX:+/-UseTLAB
参数来使用/禁用TLAB.
内存分配完成之后, 虚拟机必须将分配到的内存空间都初始化为零值, 如果使用了TLAB,则此工作可以提前至TLAB分配时顺便进行.
然后,Java虚拟机堆对象进行必要的设置,将对象的类实例,元数据信息,哈希码,GC分代年龄等信息存放在对象的对象头(Object Header)中,根据虚拟机当前运行状态的不同,对象头会有不同的设置方式.
上面工作完成后,从虚拟机视角来看,一个新的对象已经产生了,但从Java程序的视角看,对象创建还需执行构造函数,即Class文件中的<init>
方法,new指令指挥会接着执行<init>()
方法,按照程序员的意愿对对象进行初始化,这样真正可用的对象才算完全被构造出来.
在HotSpot虚拟机里,对象在堆内存中的存储布局有: 对象头, 实例数据和对齐填充三部分.
对象头: 包括两类信息: 一类是存储对象自身运行时数据(哈希码, GC分代年龄, 锁状态标志, 线程持有的锁,偏向线程ID, 偏向时间戳),在32位和64位虚拟机中分配为32bit和64bit,称为Mark Word.
32位虚拟机中对象头Mark Word内容:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码(25bit),分代年龄(4bit),还有1bit固定为0 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,无记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,分代年龄 | 01 | 可偏向 |
另一类是类型指针,即对象指向它的类型元数据的指针,但并不是所有的虚拟机实现都必须在对象数据上保留类型指针.如果对象是一个Java数值,对象头中还必须有一块记录数值长度的数据,因为要确定Java对象的大小.
实例数据部分: 在程序代码里所定义的各种类型字段内容,存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响,HotSpot虚拟机默认的分配顺序是: longs/doubkes, ints, shorts, chars, bytes/booleans, oops(Oridinary Object Pointers), 相同宽度的字段总是被分配到一起存放,满足这个前提条件下,父类定义的变量会出现在子类之前.
对齐填充: 不是必然存在的,仅仅起着占位符的作用,HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,就是任何对象的大小都必须是8字节的整数倍.
Java程序会通过栈上的reference数据来操作堆上的具体对象,虚拟机规范只规定是一个指向对象的引用,对象的访问方式由具体的虚拟机实现.
主流的reference(引用)类型访问对象的方式:
1.使用句柄:Java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
优势:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身无需改变。
2.使用直接指针:Java堆中就必须考虑如何防止访问类型数据的相关信息,reference中直接存放对象的地址。
优势:速度更快,节省了一次指针定位的时间开销, 因对象访问的操作很频繁。
HotSpot虚拟机主要使用第二种方式进行对象访问.
import java.util.ArrayList;
import java.util.List;
/
* 虚拟机参数:
* -Xms20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError
* -XX:+HeapDumpOnOutOfMemoryError开启在OOM时Dump出当前的内存堆转储快照
*
* @author lzlg
* 2023/3/9 17:09
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
/
* 虚拟机参数:
* -Xss128k
* -Xss设置栈内存的大小
*
* @author lzlg
* 2023/3/9 17:13
*/
public class JavaVMStackOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackOF oom = new JavaVMStackOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}
上述是栈内存容量设置造成的OOM,此时栈深度根据设置的栈内存大小而相应的调整.
如果方法中的定义了大量的本地变量,也会造成栈深度相应的缩小.
还有一种创建线程数量太多造成的OOM, 32位系统会提示:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
String的intern()是一个本地方法,作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用; 否则, 会将此String对象包含的字符串添加到常量池中,并返回此String对象的引用.
import java.util.HashSet;
import java.util.Set;
/
* 需在JDK6环境下运行
* 虚拟机参数:
* -XX:PermSize=6m -XX:MaxPermSize=6m
* 设置永久代大小
*
* @author lzlg
* 2023/3/9 17:42
*/
public class RuntimeStringPoolOOM {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
上面代码在JDK6环境中运行会出现: Java.lang.OutOfMemoryError: PermGen space
说明运行时常量池的确属于方法区(永久代)的一部分.
但是使用JDK7或更高版本的JDK则不会得到相同的结果,因为自从JDK7起,原本存放在永久代的字符串常量池被移动至Java堆中,所以限制方法区不会造成OOM, 限制堆大小可出现:Java.lang.OutOfMemoryError: Java heap space
.
/
* 运行时常量池
*
* @author lzlg
* 2023/2/2 16:44
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// true
// 如果是JDK6及之前,则为false
// 因为intern()方法会把首次遇到的字符串实例复制到永久代的常量池中
// 并返回永久代常量池中的字符串引用
// StringBuilder创建的字符串实例对象在Java堆上,所以不是同一个引用
String s1 = new StringBuilder("计算机").append("技术").toString();
// 如果是JDK7及之后,则为true
// 因为字符串常量池在Java堆中,只需要在常量池中记录首次出现的实例的引用即可
// intern()返回的引用和由StringBuilder创建的字符串实例是同一个
System.out.println(s1.intern() == s1);
// false
// 因为java字符串在执行StringBuilder.toString()时就已经出现过了
// 在加载sun.misc.Version这个类的时候进入常量池的
// 字符串常量池中已经有java字符串实例的引用
// 和通过StringBuilder创建的java字符串(在java堆中)中引用不是同一个
// 不符合intern()方法的首次遇到原则
String s2 = new StringBuilder("ja").append("va").toString();
System.out.println(s2.intern() == s2);
}
}
下面是动态创建了太多类,造成方法区的内存溢出:
/
* Java方法区内存溢出
* Java8之后,方法区由元空间实现,不再有老年代
* 而方法区的字符串常量池则在java堆中
*
* 初始元空间大小 最大元空间大小
* -XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m
*
* @author lzlg
* 2023/2/2 17:11
*/
public class JavaMethodAreaOOM {
static class OOMObject {
}
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}
}
import java.nio.ByteBuffer;
/
* 直接内存溢出
* -XX:MaxDirectMemorySize=5m
*
* @author lzlg
* 2023/2/2 17:37
*/
public class DirectMemoryOOM {
public static void main(String[] args) {
// 使用ByteBuffer直接申请内存10MB
ByteBuffer.allocateDirect(10 * 1024 * 1024);
}
}
-Xms 堆的最小值 -Xmx 堆的最大值 (这两个参数相同时可避免堆自动扩展)
-XX:+HeapDumpOnOutOfMemoryError 可让虚拟机在出现内存溢出异常时Dump出当前内存堆转储快照
-Xoss 设置本地方法栈大小(存在但无效)-Xss 栈容量
-XX:PermSize 方法区容量 -XX:MaxPermSize 最大方法区容量
-XX:MaxDirectMemorySize 最大直接内存容量(如果不指定,默认和-Xmx的值一样)
-XX:SurvivorRatio 新生代Eden区与一个Survivor区的空间比例
Java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈随线程而生,随线程而灭,每一个栈帧中分配多少内存基本上在类结构确定下来时就已知的,因此这3个区域的的内存分配和回收具备确定性,不需要过多考虑如何回收的问题.
而Java堆和方法区这两个区域有着显著的不确定性,垃圾收集器关注的正是这部分内存如何管理.
概述:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减一,任何时刻计数器为0的对象就是不可能再被使用的。
优点:实现简单,判定效率高。
缺点:很难解决对象之间的相互循环引用的问题。
概述:通过一系列名为 GC Roots 的对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可能再被使用的。
在Java中固定可作为GC Roots的对象有以下几种:
如果只是Java堆中某一块区域发起垃圾收集时, 该区域的对象有可能被其他区域(关联区域)的对象所引用,这时需要将关联区域的对象也一并加入GC Roots集合中,才能保证可达性分析的正确性.
jdk1.2 之前引用的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存,某个对象的引用。
jdk1.2 之后:Java对引用的概念进行扩充,将引用分为(强度依次减弱):
强引用(Strong Reference)
类似 Object obj = new Object() 这类的引用
只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
软引用(Soft Reference)
描述一些还有用,但并非必需的对象。
只被软引用关联的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。
提供 SoftReference 类来实现软引用
弱引用(Weak Reference)
描述非必须对象,强度比软引用更弱
被弱引用关联的对象只能生存到下一次垃圾收集发生为止;当收集器开始工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象.
提供 WeakReference 类来实现弱引用
虚引用(Phantom Reference)
也称为幽灵引用或幻影引用,是最弱的一种引用关系。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
提供 PhantomReference 类来实现虚引用
要宣告在可达性分析算法中判定为不可达的对象死亡,最多会经历两次标记过程:
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。若对象没有覆盖(Override)finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况视为没有必要执行。
如果这个对象被判定有必要执行 finalize() 方法,那么该对象会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条虚拟机自动建立的,低调度优先级的 Finalizer 线程去执行它们的finalize()方法. 这里的执行是指虚拟机会触发这个方法开始运行,但不承诺会等待它运行结束.原因:防止执行缓慢或死循环导致的内存回收系统崩溃。finalize() 方法是对象逃脱死亡的最后一次机会。具体可看JDK源码中的Finalizer类.
稍后GC会对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出即将回收的集合;如果对象这时候还没逃脱,那基本上真的要被回收了。
注意:任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize()方法不会被再次执行。
不鼓励使用 finalize() 方法做关闭外部资源的请理性工作。推荐使用try-finally或其他方式.
Java虚拟机规范中不要求虚拟机在方法区中实现垃圾收集, 在方法区进行垃圾收集的回收性价比 较Java堆低.
主要回收两部分内容:废弃的常量和不再使用的类型。
废弃的常量
假如一个字符串 “abc” 曾经进入常量池中,但当前系统没有任何一个 String 对象的值是“abc”,也就是说已经没有任何字符串对象引用常量池中"abc"常量,且虚拟机中也没有其他地方引用这个字面量,如果这时发生内存回收,而且垃圾收集器判断确有必要的话,这个 “abc” 常量就会被系统清理出常量池。常量池中的其他类(接口),方法,字段的符号引用也与此类似。
不再使用的类型
需要同时满足3个条件才能算是不再使用的类型:
1.该类所有的实例都已经被回收,Java堆中不存在该类及其任何派生子类的实例.
2.加载该类的 ClassLoader 已经被回收
3.该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足以上3个条件的无用类进行回收(仅仅是被允许)。
是否对类进行回收,HotSpot虚拟机提供 -Xnoclassgc 参数进行控制,还可使用 -verbose:class 及 -XX:+TraceClassLoading(可在 Product 版的虚拟机中使用),-XX:+TraceClassUnLoading(需要FastDebug版的虚拟机支持)查看类加载和卸载信息.
在大量使用反射, 动态代理, CGLib等字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力.
从如何判定对象消亡的角度,垃圾收集算法可分为引用计数式垃圾收集(Reference Counting GC)和追踪式垃圾收集(Tracing GC)两大类,主流的Java虚拟机都是使用追踪式垃圾收集.下面的算法都是追踪式垃圾收集的范畴.
分代收集理论建立在两个分代假说上:
1)弱分代假说: 绝大多数对象都是朝生夕灭的.
2)强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡.
分代假说共同奠定了垃圾收集器的一致的设计原则: 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过垃圾收集过程的次数)分配到不同的区域之中存储.
具体的商用Java虚拟机设计至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域.
假如现在进行一次只局限于新生代区域的收集(Minor GC), 但新生代的对象完全有可能被老年代所引用的,为了找出新生代区域的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性(反过来也是一样,即老年代收集时),这样会为内存回收带来很大的性能负担,为解决该问题,需要第三条经验法则:
3)跨代引用假说: 跨代引用相对于同代引用来说占极少数.
根据上面两条假说(1和2)的隐含推论(3假说): 存在相互引用关系的两个对象,应该倾向于同时生存和消亡.
依据3假说,不应为了少量的跨代引用区扫描整个老年代,也不必浪费空间记录跨代引用,只需在新生代上建立一个全局的数据结构(记忆集 Remembered Set),这个结构把老年代划分成若个小块,标识出老年代的哪一块内存会存在跨代引用,当发生Minor GC时,只有包含了跨代引用的小块老年代内存里的对象才会被加入到GC Roots进行扫描,这样避免了扫描整个老年代.
回收类型的划分:
1)部分收集(Partial GC): 指目标不是完整收集整个Java堆的垃圾收集, 又分为: ①新生代收集(Minor GC/Young GC): 指目标只是新生代的垃圾收集; ②老年代收集(Major GC/Old GC): 指目标只是老年代的垃圾收集(目前只有CMS收集器会有单独收集老年代的行为); ③混合收集(Mixed GC): 指目标是收集整个新生代以及部分老年代的垃圾收集(目前只有G1收集器会有这种行为)
2)整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集.
首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。也可反过来,标记存活的对象,统一回收未被标记的对象.
缺点:
1.执行效率不稳定:大量对象必须进行大量标记和清除,导致标记和清除的两个过程执行效率都随着对象数量增长而降低.
2.内存空间的碎片化问题:标记清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象是存活的,这种算法会产生大量的内存间复制的开销,但对于多数对象可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收.
优点:实现简单,不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,运行高效。
缺点:空间浪费太多, 将可使用的内存缩小为了原来的一半。
该算法用于回收新生代。由于新生代的对象98%都是朝生夕死,无需按照1:1的比例划分内存空间。
Appel式回收: 将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中的一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活着的对象一次性复制到另外一块 Survivor 空间上,然后清理掉 Eden 和已用过的 Survivor 的空间。
HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。因没有办法保证每次回收都只有不多于10%的对象存活, 因此有逃生门安全设计: 当 Survivor 空间(只有10%的空间)不足以容纳一次Minor GC之后存活的对象时,需要依赖其他内存区域(通常是老年代)进行分配担保(Handle Promotion)。
该算法用于老年代, 标记所有需要回收的对象,然后让所有存活的对象都向内存空间一端移动,然后直接清理掉端边界以外的内存。
标记-清除算法和标记-整理算法的本质差异是是否移动回收后的存活对象:
1)如果移动存活对象,老年代每次回收都有大量对象存活区域,移动存活对象并更新所有引用会是一种极为负重的操作,而且必须全程暂停用户应用程序才能进行(Stop The World).
2)如果完全不考虑移动和整理存活对象的话, 弥散于堆中的存活对象导致的空间碎片化问题只能依赖更为复杂的内存分配器和内存访问器来解决,内存访问是用户程序最频繁的操作,势必会直接影响应用程序的吞吐量.
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但从整个程序的吞吐量来看,移动对象会更划算.关注吞吐量的Parallel Old收集器是基于标记-整理算法的, 而关注延迟的CMS收集器则是基于标记-清除算法的.
还有种做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间(为CMS收集器所采用).
所有收集器在根节点枚举这一步都是必须暂停用户线程的,现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举还是必须在一个能保障一致性的快照中才得以进行,不会出现分析过程中,根节点集合的对象引用关系还在不断变化.
目前主流Java虚拟机使用的都是准确式垃圾收集(虚拟机知道内存中某个位置的数据具体是什么类型),用户线程停顿下来后,不需要一个不漏地检查所有执行上下文和全局的引用位置,虚拟机有办法直接得到哪些地方存放着对象引用的.在HotSpot的解决方案中,是使用一组称为OopMap的数据结构.
一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来, 在即时编译过程中, 也会在特定的位置记录下栈里和寄存器里哪些位置是引用.这样收集器在扫描时就可直接得知这些信息,无需真正一个不漏地从方法区等GC Roots开始查找.
Hotspot只在特定的位置使用OopMap结构记录引用关系信息,这些位置称为安全点(Safepoint).安全点决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来,而是强制要求必须执行到达安全点后才能够暂停.
安全点位置的选取基本上是以是否具有让程序长时间执行的特征为标准进行选定的,长时间执行的最明显特征是指令序列的复用,例如方法调用,循环跳转,异常跳转等都属于指令序列复用.
让线程在垃圾收集发生时跑到最近的安全点的方案:
对于用户线程没有分配到处理器时间,线程无法响应虚拟机的中断请求,不能在走到安全的地方去中断挂起自己,此时引入安全区域(Safe Region)来解决.
安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,在这个区域中任意地方开始垃圾收集都是安全的,可把安全区域看作被扩展拉伸了的安全点.
当用户线程执行到安全区域里的代码时,首先会标识自己已经进入了安全区域,那样当这段时间内虚拟机发起垃圾收集时就不必管这些声明自己在安全区域的线程.当线程要立刻安全区域时,要检查虚拟机是否已经完成了根节点枚举(或其他需要用户线程暂停的阶段),如果完成了,那线程就当作没事发生过继续执行,否则就必须一直等待,直到收到可以离开安全区域的信号为止.
分代收集时为解决跨代引用问题建立了记忆集的数据结构.记忆集是一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构.
最简单是记录全部跨代引用对象的实现方案,但空间占用和维护成本高昂,而收集器只需通过记忆集判断某一块非收集区域是否存在有指向收集区域的指针即可,不需要记录所有细节,可以使用更为粗犷的记录粒度:
1)字长精度: 每个记录精确到一个机器字长, 该字包含跨代指针; 2)对象精度: 每个记录精确到一个对象, 该对象里有字段含有跨代指针; 3)卡精度: 每个记录精确到一块内存区域, 该区域内里有对象含有跨代指针.
第三种卡精度所指的是用一种卡表(Card Table)的方式实现记忆集,是目前最常用的一种记忆集实现方式.
卡表的最简单形式是一个字节数组,字节数组的每一个元素对应着其标识的内存区域中一块特定大小的内存块,这个内存块称作卡页(Card Page),卡页的大小都是以2的N次幂的字节数. 一个卡页的内存中有一个或更多对象的字段存在跨代指针,就将对于卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0.
卡表变脏时间点原则上应该发生在引用类型字段赋值的那一刻,如果是解释执行,虚拟机负责每条字节指令的执行,有充分的介入空间,但在即时编译后的代码已经是纯粹的机器指令流了,此时通过写屏障(Write Barrier)技术维护卡表状态.
写屏障可以看作在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的操作(比如更新卡表的状态).应用写屏障后,每次只要对引用进行更新就会产生额外的开销,但此开销比Minor GC扫描整个老年代的代价相比低得多.
卡表还会在高并发场景下面临伪共享的问题.伪共享是因为现代中央处理器的缓存系统是以缓存行(Cache Line)为单位存储的,当多线程修改相互独立的变量时,如果变量恰好共享同一个缓存行,就会彼此影响而导致性能降低.解决伪共享问题的一种简单解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当卡表元素未被标记过时才将其标记为变脏.可通过-XX:+UseCondCardMark开启, 开启后会增加一次额外判断的开销, 但能避免伪共享问题, 两者各有性能损耗.
包含标记阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段随着堆变大而等比例增加停顿时间,会影响几乎所有的垃圾收集器.
三色标记作为工具解释为什么必须在一个能保障一致性的快照上才能进行对象图的遍历:
白色: 表示对象尚未被垃圾收集器访问过,分析开始阶段,所有的对象都是白色的,在分析结束阶段,仍然是白色的对象代表不可达.
黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过,黑色对象代表是安全存活的.
灰色: 表示对象已经被垃圾收集器访问过,但这个对象尚至少存在一个引用还没有被扫描过.
可达性分析的扫描过程可看作对象图尚一股以灰色为波峰的波纹从黑向白推进的过程.如果用户线程此时是冻结的,收集器线程在工作,则标记阶段不会有问题.对象图扫描完成后, 白色对象就是要回收的对象.
如果用户线程与收集器是并发工作,收集器在对象图上标记颜色,同时用户线程在修改引用关系(即修改对象图的结构),可能会出现两种后果: 一种是把原本消亡的对象错误标记为存活,这种情况可以容忍,不过是产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好;另一种是把原本存活对象错误标记为已消亡,后果非常致命,程序肯定会发生错误.
当且仅当两个条件同时满足时,会产生对象消失的问题,即原本应该是黑色的对象被误标记为白色:
1)赋值器插入了一条或多条从黑色对象到白色对象的新引用.
2)赋值器删除了全部从灰色对象到该白色对象的直接或间接引用.
解决并发扫描的对象消失问题,只需破坏两个条件的任意一个即可,两种解决方案: 增量更新(Increment Update)和原始快照(Snapshot At The Beginning, SATB).
增量更新破坏的是第一个条件: 当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重写扫描一次.
原始快照破坏的是第二个条件: 当灰色对象要删除指向白色对象的引用关系是,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次.
CMS垃圾收集器是基于增量更新来作并发标记的,而G1, Shenandoah则用原始快照来实现.
收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。
收集器的并发和并行:
并行(Parallel)描述的是多条垃圾收集线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态。
并发(Concurrent)描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程未被冻结,所以程序仍然能响应服务请求,由于垃圾收集器占用了一部分资源,此时应用程序的处理的吞吐量将受到一定影响.
新生代收集器;基于标记-复制算法, 单线程(非只使用一条收集线程)收集器, 进行垃圾收集时,必须暂停其他所有的工作线程(“Stop The World”),直到它收集结束。
虚拟机运行在Client模式下的默认新生代收集器,简单而高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的,对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率.
Serial 收集器的多线程版本, 基于标记-复制算法, 是运行在Server模式下的虚拟机的首选(JDK7之前)新生代收集器。和Serial 收集器一样,能与 CMS(Concurrent Mark Sweep)收集器配合工作。
JDK5中使用CMS来收集老年代时, ParNew收集器是激活CMS(使用-XX:+UseConcMarkSweepGC)后的默认新生代收集器, 也可使用-XX:+/-UsePerNewGC选项(自JDK9之后取消此选项)来强制指定或者禁用它.
JDK9之后ParNew加CMS收集器的组合不再是官方推荐的服务端模式下的收集器解决方案了,官方希望被G1取代.ParNew收集器在单核心处理器环境中绝对不会有比Serial收集器更好的效果,由于存在线程交互的开销.默认开启的收集线程数与处理器核心数量相同,用-XX:ParallelGCThreads参数来限制垃圾收集的线程数.
新生代收集器,基于标记-复制算法实现的收集器,并行的多线程收集器。
关注点:达到一个可控制的吞吐量(CPU 用于运行用户代码的时间和CPU总消耗的时间的比值,即 吞吐量=运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间))。
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户的体验。高吞吐量可以最高效率利用CPU资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
两个参数控制吞吐量:-XX:MaxGCPauseMillis 最大垃圾收集停顿时间;-XX:GCTimeRatio 设置吞吐量的大小
-XX:MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值.垃圾收集停顿时间是以牺牲吞吐量和新生代空间为代价获取的, 设置参数过小,造成垃圾收集发生更频繁,停顿时间下降了,但吞吐量也降下来了.
-XX:GCTimeRatio 参数的值应设置为一个正整数,表示用户期望虚拟机消耗在GC上时间不超过程序运行时间的1/(1+N),默认值为99.
GC 自适应的调节策略:-XX:+UseAdaptiveSizePolicy 参数打开,无需手工指定新生代的大小(-Xmn),Eden 与 Survivor 区的比例(-XX:SurvivorRatio),晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。只需要把基本的内存数据设置好,然后使用-XX:MaxGCPauseMillis参数或-XX:GCTimeRatio参数给虚拟机设立一个优化目标,具体的细节参数调节工作则由虚拟机完成.
是Serial 收集器的老年代版本,单线程收集器,基于标记-整理算法,主要在Client模式下的虚拟机使用。
在Server模式下,在JDK5以及之前版本中与 Parallel Scavenge 收集器搭配使用。
作为CMS收集器的后备方案,在并发收集发生 Concurrent Mode Failure的时候使用。
是 Parallel Scavenge 收集器的老年代版本,多线程收集器,基于标记-整理算法
注重吞吐量及CPU资源较为稀缺的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
以获取最短回收停顿时间为目标的收集器,适合互联网站和 B/S系统的服务端上。CMS收集器符合这类应用对服务响应速度,希望系统停顿时间尽可能短的需求.
使用标记-清除算法
运作过程:
初始标记(CMS initial mark)
仅仅标记一下 GC Roots能直接关联到的对象,速度很快.
并发标记(CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对象图的过程,过程耗时较长但不需要停顿用户线程.
重新标记(CMS remark)
修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。停顿时间通常比初始标记稍长一些,但远比并发标记阶段的时间短。
并发清除(CMS concurrent sweep)
清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,这个阶段也可以与用户线程同时并发的.
初始标记和重新标记仍然需要Stop The World. 耗时最长的是并发标记和并发清除过程。
三个显著的缺点:
CMS 收集器对CPU资源非常敏感。占用一部分线程(或CPU的计算能力)导致应用程序变慢, 降低吞吐量。默认启动的回收线程数是(处理器核心数量 + 3) / 4
, 当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大.
CMS 收集器无法处理浮动垃圾(Floating Garage),可能出现 Concurrent Mode Failure 而导致另一次完全Stop The World的 Full GC 的产生。
浮动垃圾:由于CMS并发标记和并发清理阶段用户线程还在运行,会有新的垃圾对象产生,这部分垃圾出现在标记过程结束以后,CMS收集器无法在当次收集中处理掉它们, 只好留待下一次垃圾收集的时再清理掉, 这一部分垃圾就称为浮动垃圾。
因在垃圾收集阶段用户线程还需要持续运行, CMS 需要预留足够内存空间提供并发收集时的程序使用,JDK5默认情况下,CMS 收集器在老年代使用了 68%的空间后就会被激活(使用参数-XX:CMSInitiatingOccupancyFraction提高触发百分比,降低内存回收频率获取更好的性能;参数不能设置太高,否则容易产生大量 Concurrent Mode Failure, 性能反而降低)。
要是 CMS 运行期间预留的内存无法满足程序需求,就会出现一次并发失败(Concurrent Mode Failure),这时虚拟机会不得不启用后备预案, 冻结用户线程的执行, 临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间更长。
由于使用的是标记-清除算法,收集结束会产生大量的空间碎片,如果无法找到足够大的连续空间来分配当前的对象,会不得不提前触发一次 Full GC。
对应解决方案:
-XX:+UseCMSCompactAtFullCollection(此参数从JDK9开始废弃): 用于CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程, 由于内存整理必须移动存活对象,过程是无法并发的,虽然空间碎片问题解决了,但停顿时间变长了。
-XX:CMSFullGCsBeforeCompaction(此参数从JDK9开始废弃): 要求CMS收集器在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,即每次进入Full GC都进行碎片整理).
G1收集器开创了收集器面向局部收集的设计思路和基于Region的内容布局形式,是一款主要面向服务端应用的垃圾收集器,在JDK9之后,称为服务端模式下的默认垃圾收集器.
停顿时间模型的收集器: 能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒的目标.G1使用基于Region的堆内存布局实现此目标.
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间或者老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论新老对象都能获取很好的收集效果.
Region中有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象.每个Region的大小可通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂.超过Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1大多数行为都把Humongous Region作为老年代的一部分看待.
G1收集器能建立可预测的停顿时间模型,是因为它将Region作为单纯回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全区域的垃圾收集.具体的处理思路是让G1收集器跟踪各个Region里面的垃圾堆积的价值(回收所获得的空间大小以及回收所需时间的经验值)大小,然后维护一个优先级列表,每次根据用户设定允许的收集停顿时间(参数-XX:MaxGCPauseMillis指定,默认200毫秒),优先处理回收价值收益最大的那些Region.
G1收集器关键细节问题和解决:
将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
G1的记忆集的存储结构本质是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的原始是卡表的索引号,这种双向的卡表结构比原来的卡表实现起来更复杂,同时Region数量比传统收集器的分代数量明显要多得多,因此G1收集器有着更高的内存占用负担.大约相当于Java堆容量的10%到20%的额外内存.
并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
G1收集器是通过原始快照(SATB)算法来实现, G1为每个Region设计了两个名为TAMS的指针,把Region的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时,新分配的对象地址都必须要在这两个指针位置上.G1收集器默认在这个地址以上的对象是被隐式标记过的,默认是存活的,不纳入回收范围.也会因为内存回收的速度赶不上内存分配的速度,导致Full GC产生长时间的Stop The World.
怎样建立起可靠的停顿预测模型?
G1收集器的停顿预测模式是以衰减均值(Decaying Average)为理论基础来实现的.在垃圾收集过程中,G1收集器会记录每个Region的统计信息,衰减均值比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,而衰减平均值更准确的代表最近的平均状态.Region的统计状态越新越能决定其回收的价值.通过这些信息预测限制开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益.
G1收集器的运作过程:
设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡.如果把期望停顿时间调的比较低,由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小一部分,收集器的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积,应用运行时间一长,最终占满堆引发Full GC反而降低性能,通常把期望停顿时间设置为一两百毫秒或者两三百毫秒比较合理.
从G1开始,最先进的垃圾收集器的设计导向变为追求能够应付应用的内存分配速率,而不追求一次把整个Java堆全部清理干净,只要收集器的速度能跟上对象分配的速度,一切就能运作得很完美.
和CMS收集器的比较:
目前在小内存应用上CMS的表现大概率仍然要优于G1, 而在大内存应用上G1则大多能发挥其优势,这个优劣势的java堆容量的平衡点通常在6GB和8GB之间.以上仅是经验之谈,不同应用需要实际测试才能得到合适的结论.
衡量垃圾收集器的三项最重要指标: 内存占用(Footprint), 吞吐量(Throughput), 延迟(Latency).
Shenandoah收集器也是使用基于Region的堆内存布局,同样用着存放大对象的Humongous Region,默认的回收策略也是优先处理回收价值最大的Region,和G1相比的三个明显不同之处:
Shenandoah收集器的工作过程:
Brooks Pointer转发指针来实现对象移动于用户线程并发的一种解决方案, 在原有对象布局结构的最前面统一添加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己.此间接对象访问技术会造成每次对象访问会带来一次额外的转向开销,尽管已经被优化到只有一行汇编指令的程度.
当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上.
但是转发指针会出现多线程竞争问题,如果用户线程更新旧对象的某个字段发生在复制旧对象之前(还未更新转发指针的引用),则用户线程对对象的变更只发生在旧对象中,所以必须针对转发指针的访问操作采取同步措施,实际上Shenandoah收集器是通过CAS操作来保证并发时对象的访问正确性的.
转发指针必须注意的是执行频率的问题,因为面向对象的编程语言对对象的访问操作十分频繁,Shenandoah通过读屏障实现, 而读屏障数量比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何重量级操作.Shenandoah是第一款使用读屏障的收集器, 开发者计划在JDk13中将Shenandoah的内存屏障模型改进为基于引用访问屏障的实现,即只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等非其他引用字段的读写.Shenandoah收集器在实际应用中的表现,停顿时间确实有质的飞跃,但并未实现最大停顿时间在十毫米以内的目标,吞吐量明显下降,总运行时间最长.
ZGC收集器是一款基于Region内存布局的,暂时不设分代的,使用了读屏障,染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的垃圾收集器.
内存布局: ZGC的Region具有动态性-动态的创建和消耗,动态的区域容量大小.分为小型Region(容量固定为2MB,存放小于256KB的小对象), 中型Region(容量固定为32MB,存放大于等于256KB但小于4MB的对象),大型Region(容量不固定可动态变化,但必须为2MB的整数倍,用于放置4MB的大对象,每个大型Region只会存放一个大对象,大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂.)
染色指针(Colored Pointer): ZGC直接把标记信息记在引用对象的指针上,这样不需要直接访问对象.ZGC染色指针使用了Linux下64位指针除高18位不能来寻址,剩余的46位指针中的高4位存储四个标志信息,通过这些标志位,虚拟机可直接从指针中看到其引用对象的三色标记状态,是否进入了重分配集,是否只能通过finalize()方法才能访问到等信息,但也导致ZGC能够管理的内存不可以超过4TB(2的42次幂).详细原理说明请参考原书.
染色指针的优势:
ZGC通过虚拟内存映射技术解决了操作系统对染色指针寻址的支持, Linux/X86-64平台上的ZGC使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量更大.把染色指针中的标志位看作是地址的分段符,只要将这些不同的地址段都映射到同一个物理内存空间,这样使用染色指针就可以正常寻址了.
ZGC收集器的运作过程:
ZGC收集器虽然做到了几乎整个收集过程都全程可并发,短暂停顿时间只与GC Roots相关而与堆内存大小无关,同样实现了任何堆上停顿都小于十毫秒的目标.但ZGC由于没有分代,所以如果并发收集周期很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占空间,堆中剩余可腾挪的空间就越来越小,只能尽可能增加堆容量大小.所以还需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对此区域进行更频繁,更快的收集.
ZGC支持NUMA-Aware的内存分配.NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构.因每个处理器核心都有属于自己的内存管理器所管理的内存,如果要跨核访问被其他核心管理的内存,必须通过Inner-Connect通道来完成,但这要比访问处理器的本地内存慢得多,在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问.
Epsilon是一款以不能够进行垃圾收集的垃圾收集器,即自动内存管理子系统.Epsilon收集器是最小化功能的,进行堆的管理和对象分配功能,不进行垃圾收集的自动内存管理子系统.如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,可选择运行负载极小,没有任何回收行为的Epsilon收集器.
选择适合应用的收集器的三个因素:
实战中需根据系统实际情况去测试才是选择收集器的最终依据.
JDK9之前, HotSpot没有提供统一的日志处理框架,JDK9之后,日志功能都收归到了-Xlog参数上:
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
命令行最关键的参数是选择器(selector), 选择器由标签(tag)和日志级别(level)共同组成.
常用日志参数命令:
-XX:+PrintGC
, JDK9之后: -Xlog:gc
-XX:+PrintGCDetails
, JDK9之后: -Xlog:gc*
-XX:+PrintHeapAtGC
, JDK9之后: -Xlog:gc+heap=debug
-XX:+PrintGCApplicationConcurrentTime
以及-XX:+PrintGCApplicationStoppedTime
, JDK9之后: -Xlog:safepoint
-XX:+PrintAdaptiveSizePolicy
, JDK9之后: -Xlog:gc+ergo*=trace
-XX:+PrintTenuringDistribution
, JDK9之后: -Xlog:gc+age=trace
参数 | 说明 |
---|---|
UseSerialGC | 虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old收集器组合进行内存回收. |
UseParNewGC | 打开此开关后,使用ParNew+Serial Old收集器组合进行内存回收,JDK9后不再支持 |
UseConcMarkSweepGC | 打开此开关后,使用ParNew+CMS+Serial Old收集器组合进行内存回收. |
UseParallelGC | JDK9之前虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel收集器组合进行内存回收. |
UseParallelOldGC | 打开此开关后,使用Parallel Scavenge+Parallel Old收集器组合进行内存回收. |
SurvivorRatio | 新生代中Eden区域与Survivor区域的容量比值,默认为8,即Eden : Survivor From : Survivor To = 8 : 1 : 1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象直接在老年代分配 |
MaxTenuringThreshold | 晋升到老年代的对象年龄,最大值15,当超过这个参数值就进入老年代 |
UseAdaptiveSizePolicy | 动态跳转Java堆中各个区域大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的Eden和Survivor区的所有对象都存活的极端情况 |
ParallelGCThreads | 并行GC时进行内存回收的线程数量 |
GCTimeRatio | GC时间占总时间的比例,默认99,即允许1%的GC时间,仅在使用Parallel Scavenge收集器时生效 |
MaxGCPauseMillis | 设置GC的最大停顿时间,仅在使用Parallel Scavenge收集器时生效 |
CMSInitiatingOccupancyFraction | 设置CMS收集器在老年代空间被使用多少后触发垃圾收集,默认68%,仅在使用CMS收集器时生效 |
UseCMSCompactAtFullCollection | 设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用CMS收集器时生效,从JDK9开始废弃 |
CMSFullGCsBeforeCompaction | 设置CMS收集器进行若干次垃圾收集后再启动一次内存碎片整理,,仅在使用CMS收集器时生效,从JDK9开始废弃 |
UseG1GC | 使用G1收集器,是JDK9后Server模式的默认值 |
G1HeapRegionSize | 设置Region大小,并非最终值 |
MaxGCPauseMillis | 设置G1收集过程目标时间,默认值是200ms,不是硬性条件 |
G1NewSizePercent | 新生代最小值,默认值5% |
G1MaxNewSizePercent | 新生代最大值,默认值60% |
ConcGCThreads | 并发标记,并发整理的执行线程数,对不同的收集器,根据其能够并发的阶段,有不同的含义 |
InitiatingHeapOccupancyPercent | 设置触发标记周期的Java堆占用率阈值,默认值45% |
UseShenandoahGC | 使用Shenandoah收集器,只能在Open JDK12或支持Shenandoah的发行版本使用,目前仍要配合-XX:+UnlockExperimentalVMOptions使用 |
ShenandoahGCHeuristics | Shenandoah何时启动一次GC过程,可选值有:adaptive,static,compact,passive,aggressive |
UseZGC | 使用ZGC收集器,目前仍要配合-XX:+UnlockExperimentalVMOptions使用 |
UseNUMA | 启用NUMA内存分配支持,目前只有Parallel和ZGC支持,以后G1可能也会支持 |
注意: 以HotSpot虚拟机,以客户端模式运行,使用Serial加Serial Old客户端默认收集器下的内存分配和回收策略.
建议使用单独的java文件进行编译,这样才能测出效果,如果使用maven则会因为类加载等因素干扰.
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC.
private static final int _1MB = 1024 * 1024;
/
* VM参数: -XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void testAllocation() {
byte[] bytes1, bytes2, bytes3, bytes4;
bytes1 = new byte[2 * _1MB];
bytes2 = new byte[2 * _1MB];
bytes3 = new byte[2 * _1MB];
bytes4 = new byte[4 * _1MB];
}
[GC (Allocation Failure) [DefNew: 7292K->626K(9216K), 0.0033838 secs] 7292K->6770K(19456K), 0.0036492 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4804K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
from space 1024K, 61% used [0x00000000ff500000, 0x00000000ff59c850, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
Metaspace used 2540K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 278K, capacity 386K, committed 512K, reserved 1048576K
执行testAllocation()方法时,执行bytes4 = new byte[4 * _1MB];
会发生一次Minor GC,因为新生代的总空间为10MB(Eden区8192K, Survivor From区和To 区都是1024K),以及没有空间给bytes4分配(4MB),因为bytes1到3已经占用了6MB.此时已存在的bytes1到3对象无法放入Survivor区,因此只能通过分配担保机制提前进入老年代.
上面的GC打印信息,清楚看到此次Minot GC将新生代中的对象转移到了老年代,而最终新生代里还有bytes4对象.
private static final int _1MB = 1024 * 1024;
/
* VM参数: -XX:+UseSerialGC -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPertenureSizeThreshold() {
byte[] bytes;
bytes = new byte[4 * _1MB];
}
Heap
def new generation total 9216K, used 1312K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 16% used [0x00000000fec00000, 0x00000000fed482d8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)
Metaspace used 2540K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 278K, capacity 386K, committed 512K, reserved 1048576K
这里通过-XX:PretenureSizeThreshold=3145728
设置进入老年代的对象大小为3MB,所以bytes对象(4MB)直接进入老年代.
-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,Parallel Scavenge不支持此参数.
private static final int _1MB = 1024 * 1024;
/
* VM参数: -XX:+UseSerialGC -verbose:gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
*/
public static void testTenuringThreshold() {
byte[] bytes1, bytes2, bytes3;
bytes1 = new byte[_1MB / 4];
bytes2 = new byte[8 * _1MB];
bytes3 = new byte[8 * _1MB];
bytes3 = null;
bytes3 = new byte[8 * _1MB];
}
当设置-XX:MaxTenuringThreshold=1
对象年龄为1时:
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 1 (max 1)
- age 1: 903280 bytes, 903280 total
: 9759K->882K(18432K), 0.0047618 secs] 9759K->9074K(38912K), 0.0049266 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 1 (max 1)
: 9074K->0K(18432K), 0.0009101 secs] 17266K->9073K(38912K), 0.0010251 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 18432K, used 8356K [0x00000000fd800000, 0x00000000fec00000, 0x00000000fec00000)
eden space 16384K, 51% used [0x00000000fd800000, 0x00000000fe0290e0, 0x00000000fe800000)
from space 2048K, 0% used [0x00000000fe800000, 0x00000000fe800000, 0x00000000fea00000)
to space 2048K, 0% used [0x00000000fea00000, 0x00000000fea00000, 0x00000000fec00000)
tenured generation total 20480K, used 9073K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
the space 20480K, 44% used [0x00000000fec00000, 0x00000000ff4dc570, 0x00000000ff4dc600, 0x0000000100000000)
Metaspace used 2540K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 278K, capacity 386K, committed 512K, reserved 1048576K
发生了两次回收,第一次GC时bytes1对象进入了Survivor区(882K), 第二次GC时可以看到新生代的882K(bytes1对象)转移到了老年代(老年代中17266K = 8192K * 2 + 882K
, 可以看出确实转移到老年代) ,新生代被清理干净.
当设置-XX:MaxTenuringThreshold=15
对象年龄为15时:
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 15 (max 15)
- age 1: 903280 bytes, 903280 total
: 9759K->882K(18432K), 0.0037128 secs] 9759K->9074K(38912K), 0.0039601 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 15 (max 15)
- age 2: 902496 bytes, 902496 total
: 9074K->881K(18432K), 0.0009312 secs] 17266K->9073K(38912K), 0.0010727 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 18432K, used 9237K [0x00000000fd800000, 0x00000000fec00000, 0x00000000fec00000)
eden space 16384K, 51% used [0x00000000fd800000, 0x00000000fe0290e0, 0x00000000fe800000)
from space 2048K, 43% used [0x00000000fe800000, 0x00000000fe8dc560, 0x00000000fea00000)
to space 2048K, 0% used [0x00000000fea00000, 0x00000000fea00000, 0x00000000fec00000)
tenured generation total 20480K, used 8192K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
the space 20480K, 40% used [0x00000000fec00000, 0x00000000ff400010, 0x00000000ff400200, 0x0000000100000000)
Metaspace used 2540K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 278K, capacity 386K, committed 512K, reserved 1048576K
可以看到第二次GC时候bytes1对象的881k内存还在新生代存活,因为还没到指定的对象年龄15.
为了更好地适应不同程序的内存状况, HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代, 如果在Survivor空间中低于或等于某年龄的所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到-XX:MaxTenuringThreshold中要求的年龄。
private static final int _1MB = 1024 * 1024;
/
* VM参数: -XX:+UseSerialGC -verbose:gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
*/
public static void testTenuringThreshold() {
byte[] bytes1, bytes2, bytes3, bytes4;
bytes1 = new byte[_1MB / 2];
bytes2 = new byte[_1MB / 2];
bytes3 = new byte[8 * _1MB];
bytes4 = new byte[8 * _1MB];
bytes4 = null;
bytes4 = new byte[8 * _1MB];
}
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 1 (max 15)
- age 1: 1689728 bytes, 1689728 total
: 10527K->1650K(18432K), 0.0048067 secs] 10527K->9842K(38912K), 0.0050570 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [DefNew
Desired survivor size 1048576 bytes, new threshold 15 (max 15)
: 9842K->0K(18432K), 0.0011942 secs] 18034K->9841K(38912K), 0.0013271 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 18432K, used 8356K [0x00000000fd800000, 0x00000000fec00000, 0x00000000fec00000)
eden space 16384K, 51% used [0x00000000fd800000, 0x00000000fe0290e0, 0x00000000fe800000)
from space 2048K, 0% used [0x00000000fe800000, 0x00000000fe800000, 0x00000000fea00000)
to space 2048K, 0% used [0x00000000fea00000, 0x00000000fea00000, 0x00000000fec00000)
tenured generation total 20480K, used 9841K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
the space 20480K, 48% used [0x00000000fec00000, 0x00000000ff59c580, 0x00000000ff59c600, 0x0000000100000000)
Metaspace used 2540K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 278K, capacity 386K, committed 512K, reserved 1048576K
可用看到bytes1和bytes2对象都直接进入了老年代(18034K = 8192K * 2 + 1650K),这两个对象是同年龄的,且加起来已经达到了1024KB(Survivor空间是2048K)满足低于或等于某年龄的对象达到Survivor空间一半的规则.
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那这一次Minor GC可以确保是安全的. 如果不成立, 则虚拟机会查看-XX:HandlePromotionFailure
参数的设置值是否允许担保失败:如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的,如果小于或者-XX:HandlePromotionFailure
设置为不允许冒险,那就要改为进行一次 Full GC。
风险原因: 大量对象在Minor GC仍然存活(最极端情况所有新生代对象都存活),需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代.前提是老年代本身还有容纳这些对象的剩余空间,但有多少对象在这次回收活下来是在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间比较,决定是否进行Full GC.
但取平均值来比较是一种赌概率的解决方法,如果存活后的对象突增,远高于历史平均值的化,依然会担保失败,那就只能重新发起一次Full GC, 这样停顿时间更长了.
JDK6不再有-XX:HandlePromotionFailure参数,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC, 否则将进行Full GC.
JVM Process Status Tool,列出正在运行的虚拟机进程,并显示虚拟机执行主类(main() 函数所在的类)的名称,以及这些进程的本地虚拟机的唯一ID(LVMID,Local Virtual Machine Identifier)。
对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID,Process Identifier)是一致的。
jps [options] [hostid]
options选项 | 作用 |
---|---|
-q | 只输出LVMID,省略主类的名称 |
-m | 输出虚拟机进程启动时传递给主类main()函数的参数 |
-l | 输出主类的全名,如果进程执行的是Jar包,输出Jar路径 |
-v | 输出虚拟机进程启动时的JVM参数 |
JVM Statistics Monitoring Tool 用于监视虚拟机各种运行状态信息的命令行工具. 可显示本地或远程虚拟机进程中的类加载,内存,垃圾收集,即时编译等运行数据。
jstat [option vmid [interval [s|ms] [count]]]
interval:查询间隔,count:查询次数
省略这两个参数说明只查询一次
远程虚拟机进程的VMID格式:
[protocol:][//]lvmid[@hostname[:port]/servername]
例子:
jstat -gc 2764 250 20
每250毫秒查询一次进程id为2764的垃圾收集情况,一共查询20次
options选项 | 作用 |
---|---|
-class | 监视类装载,卸载数量,总空间以及类装载所耗费的时间 |
-gc | 监视Java堆状况,包括Eden,Survivor区容量,已用空间,GC时间合计等信息 |
-gccapacity | 监视内容与-gc基本相同,主要关注Java堆各个区域使用到的最大和最小空间 |
-gcutil | 监视内容与-gc基本相同,主要关注已使用空间占总空间的百分比 |
-gccause | 与-gcutil功能一样,但会额外输出导致上一次GC产生的原因 |
-gcnew | 监视新生代GC的状况 |
-gcnewcapacity | 监视内容和-gcnew基本相同,输出主要关注使用到的最大和最小空间 |
-gcold | 监视老年代GC的状况 |
-gcoldcapacity | 监视内容和-gcold基本相同,输出主要关注使用到的最大和最小空间 |
-gcpermcapacity | 输出永久代使用到的最大和最小空间 |
-compiler | 输出JIT编译器编译过的方法,耗时等信息 |
-printcompilation | 输出已经被JIT编译的方法 |
执行样例说明:
jstat -gcutil 2764
S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577
E --> Eden 使用了 6.20%的空间;S0-S1 --> Survivor 空间
O --> 老年代,P --> 永久代
YGC --> Young GC(Minor GC)发生次数 16 次,YGCT --> YGC总耗时 0.105秒
FGC --> Full GC 发生次数 3 次,FGCT --> FGC 总耗时0.577秒
Configuration Info for Java, 实时地查看和调整虚拟机的各项参数
jps -v 可查看虚拟机启动时显式指定的参数,jinfo -flag 可查看未被显式指定的参数的系统默认值。
JDK 6 以上,可使用 java -XX:+PrintFlagsFinal 查看参数默认值.
jinfo可使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来.
JDK 1.6 之前只提供查询参数的功能,之后加入了运行期修改参数的能力。可使用 -flag [+|-]name 或 -flag name=value 修改一部分运行期可写的虚拟机参数值。
jinfo [option] pid
Memory Map for Java, 用于生成堆转储快照(heapdump或dump文件)
其他获取dump文件的方式:
jmap不仅用于获取dump文件,还可查询finalize执行队列,Java堆和方法区的详细信息(空间使用率,当前用的是哪种收集器等)。
jmap [option] vmid
options选项 | 作用 |
---|---|
-dump | 生成Java堆转储快照,格式为:-dump:[live,]format=b,file=filename,live参数说明是否只dump出存活的对象。 |
-finalizeinfo | 显示在F-Queue中等待Finalizer线程执行finalize方法的对象,只在Linux / Solaris平台下有效 |
-heap | 显示Java堆详细信息(使用哪种回收器,参数配置,分代状况等),只在Linux / Solaris平台下有效 |
-histo | 显示堆中对象统计信息,包括类,实例数量和合计容量 |
-permstat | 以ClassLoader为统计口径显示永久代内存状态,只在Linux / Solaris平台下有效 |
-F | 当虚拟机进程对 -dump 选项没有响应时,可使用该选项强制生成dump快照,只在Linux / Solaris平台下有效 |
JVM heap Analysis Tool, 和jmap搭配使用,分析jmap生成的堆转储快照
jhat dump文件路径
一般不推荐使用, 分析功能相对简陋.
Stack Trace for Java, 用于生成虚拟机当前时刻的线程快照(threaddump或javacore文件)
线程快照:当前虚拟机内每一条线程正在执行的方法堆栈的集合;生成线程快照的主要目的是定位线程出现长时间停顿的原因(线程间死锁,死循环,请求外部资源长时间等待等)。
jstack [option] vmid
options选项 | 作用 |
---|---|
-F | 当正常输出的请求不被响应时,强制输出线程堆栈 |
-l | 除堆栈外,显示关于锁的附加信息 |
-m | 如果调用本地方法的话,可以显示C/C++的堆栈 |
JDK5起, java.lang.Thread新增的getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象, 可完成jstack的大部分功能.
JHSDB是基于服务性代理(Serviceability Agent)实现的进程外调试工具.
服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的, 主要基于Java语言(含少量JNI代码)实现的API集合.服务性代理以HotSpot内部的数据结果为参照物进行设计, 把这些C++的数据抽象出Java模型对象, 相当于HotSpot的C++代码的一个镜像.
通过服务性代理的API可在一个独立的Java虚拟机进程里分析其他HotSpot虚拟机的内部数据, 或者从HotSpot虚拟机进程内存中dump出来的转储快照里还原出它的运行状态细节.
使用jps -l
命令查看进程ID, 然后使用 jhsdb hsdb --pid 进程ID
命令进入图形化界面.
JConsole(Java Monitoring and Management Console)是基于JMX(Java Management Extensions)的可视化监视, 管理工具. 它的主要功能是通过JMX的MBean(Management Bean)对系统进行信息收集和参数动态调整.
通过JDK/bin目录下的jconsole.exe启动JConsole, 可监控内存, 线程, 堆栈使用情况等.
VisualVM(All-in-One Java Troubleshooting Tool)是功能最强大的运行监视和故障处理程序之一, 还提供性能分析(Profiling)等其他方面的能力. VisualVM通用性强, 对应用程序实际性能的影响较小, 可直接在生产环境中使用.
VisualVM有通过插件扩展功能的能力, 其中的BTrace动态日志跟踪, 能够实时调试现有程序, 能够实现动态修改程序行为, 因为它是基于Java虚拟机的Instrument开发的.
Instrument是Java虚拟机工具接口的重要组件, 提供了一套代理机制, 使得第三方工具程序可以以代理的方式访问和修改Java虚拟机内部的数据. 阿里巴巴开源的诊断工具Arthas也是如此.
Arthas地址: https://arthas.aliyun.com/doc/
JFR(Java Flight Recorder)飞行记录仪, 用于持续收集数据, 是一套内建在HotSpot虚拟机里面的监控和基于事件的信息搜索框架, 可持续在线, 在生产环境中对吞吐量的影响一般不会高于1%, JFR的监控过程的开始, 停止都是完全可动态的, 即不需要重启应用. JFR的监控对应用是完全透明的, 即不需要对应用程序的源码做任何修改, 或者基于特定的代理来运行.
JMC(Java Mission Control), 用于监控Java虚拟机, 与虚拟机之间通过JMX协议进行通信, JMC一方面作为JMX控制台, 显示来自虚拟机MBean提供的数据, 另一方面作为JFR的分析工具, 展示来自JFR的数据.
JFR的基本工作逻辑是开启一系列事件的录制动作, 当某个事件发生时, 这个事件的所有上下文数据将会以循环日志的形式被保存至内存或者指定的某个文件当中, 循环日志相当于数据流被保留在一个环形缓存中, 所以只有最近发生的事件的数据才是可用的. JMC从虚拟机内存或者文件中读取并展示这些事件数据, 并通过这些数据进行性能分析. JFR提供的数据质量很高, 有三四层楼那么高.
Ideal Graph Visualizer: 用于可视化展示C2即时编译器是如何将字节码转化为理想图, 然后转化为机器码的.
Client Compiler Visualizer: 用于查看C1即时编译器生成高级中间表示(HIR), 转化成低级中间表示(LIR)和做物理寄存器分配的过程.
MakeDeps: 帮助处理HotSpot编译依赖的工具.
Project Creator: 帮忙生成Visual Studio的.project文件的工具.
LogCompilation: 将-XX:+LogCompilation输出的日志整理成更容易阅读的格式的工具.
HSIDS: 即时编译器的反汇编插件, 配合-XX:+PrintAssembly参数使用, 通过JITWatch可视化分析.
各种不同平台的Java虚拟机和所有平台都统一使用的程序存储格式--字节码(Byte Code)是构成平台无关性的基石,也是实现语言无关性的基石。
Class文件是一组以字节为基础单位的二进制流,需要占用单个字节以上空间的数据时,会按照高位在前的方式分割成若干个字节进行存储。
int i=10; 那么i在内存中的布局如何哪? 假设内存是从低--->高增长的 在低位优先的硬件里面,内存布局如下: 00001010 00000000 00000000 00000000 而在高位优先的内存中: 00000000 00000000 00000000 00001010 这就是我们经常听说的高位优先,低位优先。
根据Java虚拟机规范,Class文件格式结构只有两种数据类型:无符号数和表。
无符号数:属于基本的数据类型,以u1,u2,u4,u8分别代表1,2,4,8个字节的无符号数。无符号数可用来描述数字,索引引用,数量值或者按照UTF-8编码构成字符串值。
表:由多个无符号数或其他表作为数据项构成的复合数据类型,所有的表习惯性地以 _info 结尾。用于描述有层次关系的复合结构的数据。整个Class文件本质上可视作一张表。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count-1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的集合。
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数值为:0xCAFEBABE。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java版本号从45开始,jdk1.1之后的每个jdk大版本发布主版本号向上加1,高版本的jdk能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件.
紧接着主次版本号之后的是常量池入口。常量池是Class文件结构中与其他项目关联最多的数据,也是占用Class文件空间最大的数据项目之一,是Class文件中第一个出现的表类型数据项目。
常量池中常量的数量是不固定的,在常量池的入口需放置一项u2类型的数据,代表常量池容量计数值(容量计数从1开始;例子:0x0016 --> 22 表示有21个常量)。
第0项常量空出来:为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量池项目的意思。
常量池中主要存放两大类常量:字面量(Literal)和符合引用(Symbolic References)。
字面量:接近于Java语言层面的常量概念,比如文本字符串,被声明为final的常量值等。
符号引用(编译原理方面的概念)包括以下几类常量:
1.被模块导出或者开放的包名(Package)
2.类和接口的全限定名(Fully Qualified Name)
3.字段的名称和描述符(Descriptor)
4.方法的名称和描述符
5.方法句柄和方法类型(Method Handle, Method Type, Invoke Dynamic)
6.动态调用点和动态常量(Dynamically-Computed Call Site, Dynamically-Computed Constant)
Java代码在进行Javac编译的时候, 并不像C和C++那样有连接步骤, 而是在虚拟机加载Class文件的时候进行动态连接.
常量池中的每一项常量都是一个表,最初有11种结构各不相同的表结构数据, 后来为更好支持动态语言调用, 额外增加了4种动态语言相关的常量, 为了支持Java模块化系统(Jigsaw), 又加入了CONSTANT_Module_info和CONSTANT_Package_info两个常量。这17种表的共同特点是表开始的第一位是一个u1类型的标志位(tag,取值见下表),代表当前这个常量属于哪种常量类型。
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 表示方法类型 |
CONSTANT_Dynamic_info | 17 | 表示一个动态计算常量 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
CONSTANT_Module_info | 19 | 表示一个模块 |
CONSTANT_Package_info | 20 | 表示一个模块中开放或者导出的包 |
由于Class文件中方法,字段等都需要引用CONSTANT_Utf8_info型的常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度就是方法和字段名的最大长度(u2类型的最大值65535),所以Java程序中超过64KB英文字符的变量或方法名,将会无法编译。
分析Class文件字节码工具:javap
例子:javap -verbose TestClass
常量池结束之后,紧接着的2个字节代表访问标志(access_flags),用于识别一些类或接口层次的访问信息,包括:这个Class是类还是接口,是否定义为public类型,是否定义为abstract类型,如果是类的话是否被声明为final等等。
虽然一共有16个标记位可以使用,当前只定义了9个,没有使用到的标记位要求一律为0。
TestClass 这个类 有 public 关键字修饰,但无 final 和 abstract 标记
因此它的access_flags的值为:0x0001(public类型的值:ACC_PUBLIC) | 0x0020(是否允许使用invokespecial字节码指令,jdk1.2之后编译出来的类这个标记为真:ACC_SUPER) = 0x0021
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引的集合(interfaces)是一组u2类型的数据的集合。Class文件中由这三项数据来确定这个类的继承关系。排在访问标志之后。
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
如何索引:它们的索引值指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。
接口索引集合入口的第一项是u2类型的接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口, 则该计数器值为0, 后面接口的索引表不再占用任何字节.
用于描述接口或类中声明的变量,包括类级变量或实例变量,但不包括方法内部声明的变量。
字段表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index(字段的简单名称) | 1 |
u2 | descriptor_index(描述符) | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
access_flags同类的access_flags,表示字段的访问修饰信息。
TestClass为例,解释下面三种特殊字符串的概念:
简单名称:类:TestClass;(去除路径部分的名称)字段:m;方法:function;
描述符:用来描述字段的数据类型,方法的参数列表和放回值
B--byte,C--char,D-double,F-float,I--int,J--long,S--short
Z--boolean,V--void,L--对象类型比如Ljava/lang/Object;
数组类型:每一维度使用前置的 [ 字符来描述;
二维字符串数组:java.lang.String[][] --> [[Ljava/lang/String;
一维整型数组:int[] --> [I
方法的描述符:按照先参数列表,后返回值的顺序,参数列表放在()之内
void inc() --> ()V
java.lang.String.toString() --> ()Ljava/lang/String;
int indexOf(char[] c,int a, int b, char[] z, int x, int y) --> ([CII[CII)I
全限定名:org/fenixsoft/clazz/TestClass;(包名全路径 . 变 /)
字段表集合中不会列出从超类或父接口中继承过来的字段, 但有可能出现原本Java代码之中不存在的字段, 譬如在内部类中为了保持对外部类的访问性, 编译器就会自动添加指向外部类实例的字段.
在Java语言中字段是无法重载的, 两个字段的数据类型, 修饰符不管是否相同, 都必须使用不一样的名称, 但对于Class文件来讲, 只要两个字段的描述符(包含了数据类型和修饰符)不是完全相同, 那字段重名就是合法的.
和字段表结构类似,用于对方法的描述。字段的访问标志和方法的访问标志不一样.
方法中的Java代码经过Javac编译成字节码指令之后, 存放在方法属性表集合中一个名为 “Code” 属性里面。
父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。有可能出现由编译器自动添加的方法,最典型的是类构造器 <cinit>()
和实例构造器 <init>()
方法。
Java语言中, 重载(Overload)一个方法, 除了要与原方法具有相同的简单名称之外, 还要求必须拥有一个与原方法不同的特征签名. 特征签名是指一个方法中各个参数在常量池中字段符号引用的集合, 因返回值不会包含在特征签名之中, 所以Java语言里面是无法仅仅靠返回值的不同来对一个已有方法进行重载的. 但在Class文件格式之中, 特征签名的范围更大一些, 只要描述符不是完全一致的两个方法就可以共存, 即如果两个方法有相同的名称和特征签名, 但返回值不同, 也是可用合法共存于同一个Class文件中的.
在Class文件,字段表和方法表中都可携带自己的属性表集合,用于描述某些场景专有的信息。
虚拟机规范中预定义的属性:已增加到29项
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java代码编译成的字节码指令 |
ConstantValue | 字段表 | final关键字定义的常量值 |
Deprecated | 类,方法表,字段表 | 被声明为deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | Java源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code属性 | 方法的局部变量描述 |
SourceFile | 类文件 | 源文件名称 |
Synthetic | 类,方法表,字段表 | 标识:方法或字段为编译器自动生成的 |
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示。而属性值的结构则是完全自定义的,只需通过一个u4的长度属性说明属性值所占用的位数长度即可。
Code 属性
存储字节码指令。接口或抽象类中的抽象方法不存在Code属性。
Code属性表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
说明:
attribute_name_index指向CONSTANT_Utf8_info型常量的索引,常量值固定为 "Code"
attribute_length属性值的长度, 固定为整个属性表长度减去6个字节
max_stack代表了操作数栈(Operand Stack)深度的最大值
max_locals代表了局部变量表所需的存储空间。在这里,max_locals单位是Slot(Slot是虚拟机为局部变量分配内存所使用的最小单位),长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位数据类型需要2个Slot来存放。
方法参数,显示异常处理器的参数(catch 块中的Exception类型的 e),方法体中定义的局部变量都需要使用局部变量表来存放。Java虚拟机将局部变量表中的变量槽进行重用, 当代码执行超出一个局部变量的作用域时, 这个局部变量所占的变量槽可被其他局部变量所使用, Javac编译器会根据变量的作用域来分类Slot并分配给各个变量使用,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。
code_length和code用来存储Java源程序编译后生成的字节码指令。code_length:字节码长度,code用于存储字节码指令的一系列字节流。每个字节码指令是u1类型的单字节。
code_length虽然是个u4类型的长度值(最大2^32 - 1),但虚拟机规范中限制了一个方法不允许超过65535条字节码指令。
虚拟机如何使用Code属性:虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的字节,并根据字节码指令表翻译出所对应的字节码指令。(具体见)
// 原始Java代码
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}
// 使用Javap命令计算的字节码指令
javap -verbose TestClass
{
public org.fenixsoft.clazz.TestClass();
Code:
Stack=1, Locals=1, Args_size=1
0: aload_0
1: invokespecial #10;
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lorg/fenixsoft/clazz/TestClass;
public int inc();
Code:
Stack=2, Locals=1, Args_size=1
0: aload_0
1: getfield #18;
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lorg/fenixsoft/clazz/TestClass;
}
实例构造器<init>()
和inc()
这两个方法里加入了this参数,所以参数列表(Args_size),方法体(Locals)内有一个变量this。
this参数说明:在任何实例方法中,都可以通过this关键字访问到此方法所属的对象。它的实现是通过Javac编译器在编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数即可。此处理只对实例方法有效.
字节码指令之后是这个方法的显式异常处理表集合(非必须存在)。
异常属性表的结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | start_pc | 1 |
u2 | end_pc | 1 |
u2 | handler_pc | 1 |
u2 | catch_type | 1 |
字段含义解释:如果字节码从第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则跳转到第handler_pc行继续处理。当catch_type的值为0时,代表任何的异常情况都需要转向到handler_pc处进行处理。
字节码层面的异常处理:
// Java 源码
public int inc() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
// 代码返回值说明:
// 1.如果没有出现异常,返回值为1
// 2.如果出现了Exception异常,返回值为2
// 3.如果出现了Exception以外的异常,则方法非正常退出,没有返回值
编译器为这段java代码生成了三条异常表记录,对应三条可能出现的代码执行路径:
Exceptions属性
不同于Code属性中的异常表,作用是列举出方法中可能抛出的受检查异常(Checked Exceptions),方法描述时throws关键字后面列举的异常。
LineNumberTable属性
用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。并不是运行时必须的属性,可在javac中使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成该属性,在抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候无法按照源码来设置断点。
LocalVariableTable及LocalVariableTypeTable属性
用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。不是运行时必须的属性,可在javac中使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,当其他人引用这个方法时,所有的参数名称都将丢失,给编写代码带来较大的不便,且在调试期间调试器无法根据参数名称从运行上下文中获得参数值。
在JDK 5引入泛型后,LocalVariableTable属性增加了 LocalVariableTypeTable。该属性和LocalVariableTable非常类似,仅把记录字段描述符的descriptor_index替换成了字段的特征签名(Signature)。这是因为描述符中泛型的参数化类型被擦除掉了,描述符不能准确地描述泛型类型。
SourceFile及SourceDebugExtension属性
用于记录生成这个Class文件地源码文件名称。属性可选,可使用javac地-g:none或-g:source选项来关闭或要求生成这项信息。如果不生成这项属性, 当抛出异常时, 堆栈中将不会显示出错代码所属的文件名.
JDK 5时为方便在编译器和动态生成的Class中加入供程序员使用的自定义内容, 新增SourceDebugExtension属性, 用于存储额外的代码调试信息.
ConstantValue属性
用于通知虚拟机自动为静态变量赋值,只有被static关键字修饰地变量(类变量)才能使用该属性。
虚拟机对变量的赋值:如果是实例变量在实例构造器<init>()
方法中进行。对于类变量:赋值在类构造器<cinit>()
方法中进行,或者使用ConstantValue属性来赋值。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(常量),且这个变量的数据类型是基本数据类型或java.lang.String的话,就生成ConstantValue属性来进行初始化,其他情况选择在<cinit>()
方法中进行初始化。
虚拟机规范中只强制要求有ConstantValue属性的字段必须设置ACC_STATIC标记,对final关键字的要求是Javac编译器自己加入的限制。ConstantValue的属性值只限于基本类型和String。
InnerClasses属性
用于记录内部类与宿主类之间的关联。
Deprecated 和 Synthetic 属性
都属于标记类型的布尔属性,只存在有和没有的区别,没有属性值的概念。
Deprecated 属性用于表示某个类,字段或方法,已经被程序作者定为不再推荐使用,可通过@deprecated注解设置。
Synthetic 属性代表此字段或方法并不是由java源码直接产生的,而是由编译器自行添加的。JDK 5之后,标识一个类, 字段或方法是编译器自动产生的, 也可以设置访问标记中的ACC_SYNTHETIC标记位。实现了越权访问(越过private修饰器)或其他语言限制的功能, 如枚举类中自动生成的枚举元素数组和嵌套类的桥接方法.
所有由不属于用户代码生成的类,方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标记位中的一项, 唯一的例外是实例构造器<init>()
方法和类构造器<cinit>()
方法.
StackMapTable属性
在JDK 6增加到Class文件规范中, 此属性会在虚拟机类加载的字节码验证阶段被新类型检查颜值区(Type Checker)使用, 目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器.
StackMapTable属性包含0至多个栈映射帧(Stack Map Frame), 每个栈映射帧都显示或隐式地代表了一个字节码偏移量, 用于标识执行到该字节码时局部变量表和操作数栈的验证类型. 类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束.
Java SE 7之后的Java虚拟机规范中, 明确规定对于版本号大于或等于50.0的Class文件, 如果方法的Code属性中没有附带StackMapTable属性, 则意味着带有一个隐式的StackMap属性, 此属性等同于帧数量为0的StackMapTable属性. 一个方法的Code属性最多只能有一个StackMapTable属性, 否则抛出ClassFormatError.
Signature属性
JDK 5增加到Class文件规范中, 此属性会记录泛型签名信息, 因Java语言的泛型采用的是擦除法实现的伪泛型, 泛型信息在编译之后被擦除, 可在运行期做反射时获得泛型信息.
BootstrapMethods属性
JDK 7时增加到Class文件规范中, 用于保存invokedynamic指令引用的引导方法限定符.
根据Java虚拟机规范, 如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量, 那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性, 另外, 即时CONSTANT_InvokeDynamic_Info类型的常量在常量池中出现多次, 类文件的属性表中最多只能有一个BootstrapMethods属性.
MethodParameters属性
JDK 8时加入到Class文件规范中, 是方法表的属性, 作用是记录方法的各个形参名称和信息. 使得不存在方法体的抽象方法和接口方法, 统一放置在此属性中. 编译时加上-parameters参数可使编译器将方法名称写进Class文件中.
在IDE中使用包里的方法获得方法调用的智能提示, 利于JAR包的传播.
模块化相关属性
JDK 9支持Java的模块化功能, 因为模块描述文件(module-info.java)最终是要编译成一个独立的Class文件来存储的, 所以Class文件格式也扩展了Module, ModulePackages, ModuleMainClass三个属性用于支持模块化.
Module属性表示该模块的名称, 版本, 标志信息, 存储了这个模块的requires, exports, opens, uses和provides定义的全部内容.
ModulePackages属性用于描述该模块中所有的包, 不论是不是被export或者open的.
ModuleMainClass属性用于确定该模块的主类.
运行时注解相关属性
JDK 5提供了注解支持, 为存储源码中注解信息, Class文件增加了RuntimeVisibleAnnotations, RuntimeInvisibleAnnotations, RuntimeVisibleParameterAnnotations, RuntimeInvisibleParameterAnnotations四个属性. JDK 8时, 进一步加强注解使用范围, 新增类型注解, Class文件增加了RuntimeVisibleTypeAnnotations和RuntimeInvisibleTypeAnnotations两个属性.
RuntimeVisibleAnnotations属性记录了类, 字段, 方法声明上运行时可见注解. 可供反射API获取.
Java虚拟机的指令是由一个字节长度的, 代表着某种特定操作含义的数字(称为操作码, Opcode)以及跟随其后的0至多个代表此操作所需要的参数(称为操作数, Operand)构成. Java虚拟机采用面向操作数栈的架构, 所以大多数指令不包含操作数, 只有一个操作码, 指令参数都存放在操作数栈中.
Java虚拟机操作码的长度为一个字节, 因此指令集的操作码总数不能够超过256条, 又由于Class文件格式放弃了编译后代码的操作数长度对齐, 因此虚拟机在处理超过一个字节的数据时, 不得不在运行时从字节中重建出具体数据的结构. 如将16位长度的无符号整数使用两个无符号字节存储起来, 那它们的值应为(byte1 <<< 8) | byte2
.
这样做的缺点会导致解释执行字节码时将损失一些性能, 优点是可省略掉大量的填充和符号间隔. 为了尽可能获得短小精干的编译代码, 用一个字节来代表操作码.
Java虚拟机的指令集中, 大多数指令都包含其操作所对应的数据类型信息, 使用操作码助记符表明专门位哪种数据类型服务: i代表对int类型的操作, l代表long, s代表short, b代表byte, c代表char, f代表float, d代表double, a代表reference. 也有没有明确指明操作类型的指令, 如arraylength指令, goto指令.
Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持, 有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型. 编译器会在编译期或运行期将byte, short类型的数据带符号扩展为相应的int类型数据, 将boolean和char类型数据零位扩展为相应的int类型数据, 与之类似, 处理boolean, byte, short, char类型的数组时, 也会转换为使用对应的int类型的字节码指令来处理.
将一个局部变量加载到操作栈: iload, iload_<n>
其他类型和int一致.
将一个数值从操作数栈存储到局部变量表: istore, istore_<n>
将一个常量加载到操作数栈: bipush, sipush, ldc, ldc_w, ldc2_w, aconst_null, iconst_<i>
扩充局部变量表的访问索引的指令: wide
大体上运算指令可分为: 对整型数据进行运算的指令, 对浮点型数据进行运算的指令. 不存在byte, short, char和boolean类型的算术指令, 应使用操作int类型的指令代替.
加法指令: iadd; 减法指令: isub, 乘法指令: imul; 除法指令: idiv; 求余指令: irem; 取反指令: ineg;
位移指令: ishl, ishr, iushr; 按位或指令: ior; 按位与指令: iand; 按位异或指令: ixor;
局部变量自增指令: iinc; 比较指令: dcmpg, dcmpl, lcmp
整数运算可能会导致溢出, 但虚拟机规范中并没有明确定义过整型数据溢出具体会得到什么结构, 仅规定在整型除法指令以及求余指令中当除数为零时会导致虚拟机抛出ArithmeticException异常, 其余任何整型数运算场景都不应该抛出运行时异常.
虚拟机规范要求虚拟机处理浮点数时, 必须完全支持IEEE 754中定义的非正规浮点数和逐级下溢的运算规则. 浮点数运算时, 所有的运算结果必须舍入到适当的精度, 非精确的结果必须舍入为可被表示的最接近的精确值, 如果有两种克表示的形式与该值一样接近, 那将优先选择最低有效位为0的, 称为最接近数舍入模式. 把浮点数转换为整数时, 使用向零舍入模式, 这种模式舍入结果会导致数字被截断, 所有小数部分的有效字节都会被丢弃掉, 选择一个最接近, 但是不大于原值的数字来作为最精确的舍入结果.
Java虚拟机处理浮点数运算时, 不会抛出任何运行时异常, 当一个操作产生溢出时, 会使用有符号的无穷大来表示, 若某个结果没有明确的数据定义的话, 将会使用NaN值来表示, 所有使用NaN值作为操作数的算术操作, 结果都是NaN.
对long类型数值比较时, Java虚拟机采用带符号的比较方式, 浮点数比较时, 采用IEEE 754规定所定义的无信号比较方式.
Java虚拟机直接支持数值类型的宽化类型转换, 如int类型转换到long, float, double类型; long转换到float, double类型; float类型转换到double类型.
处理窄化类型转换, 必须显式地使用转换指令来完成, 将int或long类型转换为整数类型T的时候, 转换过程仅仅是简单丢弃除最低位N字节以外的内容, N是类型T的数据类型长度.
将一个浮点值窄化位整型类型T(限于int和long类型)时, 必须遵循: 1)如果浮点值是NaN, 那结果就是0; 2)如果浮点值不是无穷大的话, 浮点值使用向零舍入模式, 获得整数值v, 如果v在目标类型T的范围之内, 那结果就是v, 否则根据v的符号, 转换为T所能表示的最大或最小正数.
从double类型窄化为float类型, 通过最接近舍入模式舍入得到一个可以使用float类型表示的数字, 如果转换结果的绝对值太小, 无法使用float表示的话, 将返回float类型的正负零; 如果转换结果的绝对值太大, 无法使用float表示的话, 将返回float类型的正负无穷大. 对于double类型的NaN值将按规定转换为float类型的NaN值.
Java虚拟机规范明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常.
创建类实例的指令: new
创建数组的指令: newarray, anewarray, multianewarray
访问类字段和实例字段的指令: getfield, putfield, getstatic, putstatic
把一个数组元素加载到操作数栈的指令: Taload (T表示类型i, l, a, b, s, l, a, f, d)
将一个操作数栈的值存储到数组元素的指令: Tastore (T表示类型i, l, a, b, s, l, a, f, d)
取数组长度的指令: arraylength
检查类实例类型的指令: instanceof, checkcast
将操作数栈的栈顶的一个或两个元素出栈: pop, pop2
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶: dup, dup2, dup_x1, dup_x2, dup2_x1, dup2_x2
将栈最顶端的两个数值互换: swap
条件分支: ifeq, iflt, ifle, ifne, ifnull, ifnonnull, if_icmpeq
复合条件分支: tableswitch, lookupswitch
无条件分支: goto, goto_w, jsr, jsr_w, ret
对于boolean, byte, char, short类型的条件分支比较操作, 都使用int类型的比较指令完成. 对于long, float, double类型的条件分支比较操作, 则会先执行相应类型的比较运算指令, 运算指令会返回一个整型值到操作数栈中, 随后在执行int类型的条件分支比较操作.
invokevirtual: 用于调用对象的实例方法, 根据对象的实际类型进行分派.
invokeinterface: 用于调用接口方法, 运行时搜索一个实现此接口方法的对象, 找出适合的方法进行调用.
invokespecial: 用于调用一些需要特殊处理的实例方法, 包括实例初始化方法, 私有方法, 父类方法.
invokestatic: 用于调用类静态方法.
invokedynamic: 用于在运行时动态解析出调用点限定符所引用的方法. 该指令的分派逻辑是由用户所设定的引导方法决定的.
方法返回指令: ireturn(byte, boolean, short,char, int使用), lreturn, freturn, dreturn, areturn. 还有一条return指令供声明为void的方法, 实例初始化方法, 类和接口的类初始化方法使用.
Java程序中显式抛出异常的操作由athrow指令来实现.
Java虚拟机中处理异常不是由字节码指令实现的, 而是采用异常表来完成.
方法级的同步是隐式的, 无需通过字节码指令来控制, 通过方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法.
同步一段指令集序列由synchronized语句块来表示, Java虚拟机的monitorenter和monitorexit两条指令来支持synchronized关键字的语义. 正确实现synchronized关键字需要Javac编译器和Java虚拟机共同协作支持.
编译器必须确保无论方法通过何种方式完成, 方法中调用过的每条monitorenter指令, 必须有其对应的monitorexit指令, 无论这个方法是正常结束还是异常结束.
虚拟机的类加载机制: 虚拟机把描述类的数据从Class文件(一串二进制的字节流)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
在Java语言里,类型的加载, 连接和初始化过程都是在程序运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能为Java应用程序提供极高的扩展性和灵活性。Java中天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
一个类型从被加载到虚拟机的内存中开始,到卸载出内存为止,整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中验证、准备和解析三个部分统称为连接(Linking)。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始(注意是开始,而不是进行或完成,因为这些阶段通常都是互相交叉地混合式进行的,会在一个阶段的执行过程中调用, 激活另外一个阶段),而解析阶段则不一定,解析阶段在某些情况下可以在初始化阶段之后再开始,为了支持Java语言的运行时绑定特性(动态绑定/晚期绑定)。
对于加载阶段,虚拟机规范中没有进行强制约束,交给虚拟机的具体实现来自由把握。
对于初始化阶段,虚拟机规范严格规定了有且只有六种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):
遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化。
生成这4条字节码指令的典型Java代码场景:使用new关键字实例化对象时;读取或设置一个类型的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候;调用一个类型的静态方法时;
使用java.lang.reflect包的方法对类进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
当初始化类时,发现其父类还没有进行初始化,则需要先触发父类的初始化。
当虚拟机启动时,用户需要指定一个执行的主类(包含main()方法的类),虚拟机会先初始化这个主类。
当使用JDK 7新加入的动态语言支持时, 如果一个java.lang.invoke.MethodHandle实例最后解析的结果为REF_getStatic, REF_putStatic, REF_invokeStatic, REF_newInvokeSpecial四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化, 则需要先触发其初始化.
当一个接口定义了JDK 8新加入的默认方法(default方法)时, 如果有这个接口的实现类发生了初始化, 那该接口要在其之前被初始化.
除了上述6种场景, 所有引用类型的方式都不会触发初始化, 称为被动引用.
/
* 被动引用演示一:
* 通过子类引用父类的静态字段, 不会导致子类初始化
*
* @author lzlg
* 2023/6/21 10:11
*/
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
// 输出结果: SuperClass init!
// 123
System.out.println(SubClass.value);
}
}
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否触发子类的加载和验证,虚拟机规范中并未明确规定,这点取决与虚拟机的具体实现。对于HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作会导致子类加载的。
/
* 被动引用演示二:
* 通过数组定义来引用类, 不会触发此类的初始化
*
* @author lzlg
* 2023/6/21 10:14
*/
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
上述代码, 运行后没有输出"SuperClass Init!"信息, 说明没有触发SuperClass的初始化阶段, 但是这个代码里面触发了另一个名为"[SuperClass"的类的初始化, 对于用户代码, 这并不是一个合法的类型名称, 它是一个由虚拟机自动生成的, 直接继承于Object的子类, 创建动作由字节码指令anewarray触发.
/
* 被动引用演示三:
* 常量在编译阶段会存入调用类的常量池种, 本质上没有直接引用到定义常量的类, 因此不会触发定义常量的类的初始化
*
* @author lzlg
* 2023/6/21 10:14
*/
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
public class NoInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
上诉代码中,没有输出"ConstClass init!"。这是因为虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但是在编译阶段通过常量传播优化, 已经将此常量的值 “hello world” 存储到了 NoInitialization 类的常量池中,以后对常量ConstClass.HELLOWORLD 的引用实际都被转化为 NoInitialization 类对自身常量池的引用了, 实际上NoInitialization 的Class文件之中并没有ConstClass类的符号引用入口.
加载(Loading)是类加载(Class Loading)过程的一个阶段,在此阶段中,虚拟机需完成三件事:
加载阶段既可以使用Java虚拟机里内置的启动类加载器来完成,也可由用户自定义的类加载器去完成,开放人员可通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()和loadClass()方法)。
对于数组类, 本身不通过类加载器创建, 是由Java虚拟机直接在内存种动态构造出来的, 一个数组类的创建过程:
加载阶段完成后,虚拟机外部的二进制字节流按照虚拟机所需的格式存储在方法区中,方法区的数据存储格式由虚拟机实现自行定义(虚拟机规范未规定此区域的具体数据结构)。然后在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,这两个阶段的开始时间保持固定的先后顺序。
验证是连接阶段的第一步,目的为了确保Class文件的字节流中包含的信息符合Java虚拟机规范的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证到输入的字节流不符合Class文件的存储格式,抛出java.lang.VerifyError异常或其子类异常。
根据Java虚拟机规范, 从整体上看, 验证阶段大致会完成下面四个阶段的检验动作:
文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确的解析并存储于方法区内,格式上符合描述一个Java类型信息的要求。
验证点有: 是否以魔数0xCAFEBABE开头,主次版本号是否在当前虚拟机处理范围之内。。。等等。
下面的三个阶段全部是基于方法区的存储结构进行的, 不会再直接读取, 操作字节流了.
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。主要目的是对类的元数据信息进行语义校验, 保证不存在与Java语言规范定义相悖的元数据信息.
验证点有: 这个类是否有父类; 这个类的父类是否继承了不允许继承的父类; 等等
字节码验证
主要目的是通过数据流和控制流分析,确定程序语义是合法的, 符合逻辑的. 在第二阶段元数据验证后,字节码验证阶段将对类的方法体(Class文件种的Code属性)进行校验分析。保证被校验类的方法体在运行时不会做出危害虚拟机安全的行为。
验证点有: 保证任何跳转指令都不会跳转到方法体以外的字节码指令上; 保证方法体的类型转换是有效的, 尤其是把父类对象赋值给子类数据类型时; 等等
JDK 6之后的Javac编译器和Java虚拟机进行了一项联合优化, 具体做法是给方法体Code属性的属性表中新增加一项StackMapTable的新属性, 这项属性描述了方法体所有的基本块(Basic Block, 指俺早控制流拆分的代码块)开始时本地变量表和操作栈应有的状态. 在字节码验证期间, Java虚拟机不需要根据程序推导这些状态的合法性, 只需检查StackMapTable属性中的记录是否合法就即刻, 这样就将字节码验证的类型推导转变为类型检查, 节省了大量的校验时间.
JDK 6的HotSpot虚拟机提供-XX:-UseSplitVerifier选项关闭这项优化, JDK 7之后, 对于主版本号大于50(JDK 6)的Class文件, 使用类型检查完成数据流分析校验是唯一的选择, 不允许退回到原来的类型推导的校验方式.
符号引用验证
此验证行为发生在虚拟机将符号引用转化为直接引用的时候(解析阶段进行),符号引用验证可看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验。目的确保解析动作能够正常执行,如果无法通过符号引用校验,将会抛出java.lang.IncompatibleClassChangeError异常的子类(java.lang.NoSuchFieldError等)。
验证点有: 符号引用中通过字符串描述的全限定名是否能找到对应的类; 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段; 等等
验证阶段虽重要,却不是必要的阶段,只要通过验证, 其后对程序运行期没有任何影响了. 如果所运行的代码被反复使用和验证过,可考虑使用-Xverfify:none参数来关闭大部分的类验证措施,缩短虚拟机类加载的时间。
准备阶段是正式为类变量(static变量)分配内存并设置类变量初始值的阶段,都在方法区(仅是逻辑概念)中进行分配。JDK 7之前, HotSpot使用永久代实现方法区, JDK 7之后类变量则会随着Class对象一起存放在Java堆中.
这时候进行内存分配的仅包括类变量(被static修饰的变量),这里说的初始值是数据类型的零值。
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | '\u0000' |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference(引用类型) | null |
public static int value = 123;
// 准备阶段过后,value的值变为0。把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器<cinit>()方法之中,在初始化阶段才会执行把value赋值为123。
//特殊情况:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段会被初始化为ConstantValue指定的值,如下情况
public static final int value = 123;
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定是已经加载到虚拟机内存当中的内容。
直接引用:是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关(同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同),如果有了直接引用,那引用的目标必定已在虚拟机的内存中存在。
虚拟机规范未规定解析阶段发生的具体时间,只要求在执行了anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic, invokeinterface,invokespecial,invokestatic,invokevirtual,ldc, ldc_w, ldc2_w, multianewarray,new,putfield,putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将被使用前才去解析它。Java虚拟机会在解析阶段对方法或字段的可访问性进行检查.
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外, 虚拟机实现可能会对第一次解析的结果进行缓存(在运行时直接引用常量池中的记录,并把常量标识为已解析状态),避免解析动作重复进行。无论是否真正执行了多次解析动作, Java虚拟机都需要保证的是同一个实体中, 如果一个符合引用之前已经被成功解析过, 那么后续的引用解析请求就应当一直能够成功, 同样地, 如果第一次解析失败了, 其他指令对这个符号引用的解析请求也应该收到相同的异常, 哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中.
对于invoke dynamic指令, 上述规则不成立, 每个解析结果不尽相同. 因invokedynamic指令的目的本来就是用于动态语言支持(动态的含义是指必须等到程序实际运行到这条指令时, 解析动作才能进行).
解析动作主要针对类或接口,字段,类方法,接口方法, 方法类型, 方法句柄和调用点限定符7类符号引用:
类或接口的解析
假设当前代码所处的类为D,要把D中的一个符号引用N解析为一个类或接口C的直接引用,需3个步骤:
JDK 9引入模块化后, 还必须检查模块间的访问权限. 如果D拥有C的访问权限, 则下面3条规则至少一条成立:
字段解析
先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果上诉解析过程中出现异常,都会导致字段符号引用解析的失败。如果上诉解析成功,这个字段所属的类或接口用C表示,会按照下面的步骤对C进行后续字段的搜索:
查找过程中成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,则抛出java.lang.IllegalAccessError异常。
Javac编译器会采取比上述规范更加严格的一些约束, 譬如一个同名字段同时出现在某个类的接口和父类当中, 或者同时在自己或父类的多个接口中出现, Javac编译器可能直接拒绝编译其为Class文件.
方法解析
方法解析和字段解析一样,同样先解析出方法表的class_index项中索引的方法所属类或接口的符号引用。如果解析成功,将会按照以下步骤对C类进行后续方法的搜索:
查找过程中成功返回了引用,将会对这个方法进行权限验证,如果发现不具备对字段的访问权限,则抛出java.lang.IllegalAccessError异常。
接口方法解析
接口方法需要先解析出接口方法表中的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,将会按照以下步骤对C接口进行接口方法的搜索:
JDK 9之前由于接口的方法默认都是public的,所以不存在访问权限的问题。JDK 9中增加了接口的静态私有方法, 也有了模块化的访问约束, 接口方法的访问也可能因为访问权限控制出现java.lang.IllegalAccessError异常.
类初始化是类加载过程的最后一步,Java虚拟机真正开始执行类中编写的Java程序代码。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源,初始化阶段是执行类构造器<cinit>()
方法的过程。
<cinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量。定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。<cinit>()
方法不需要显式地调用父类构造器,虚拟机会保证在子类的<cinit>()
方法执行之前,父类的<cinit>()
方法已经执行完毕。虚拟机中第一个执行<cinit>()
方法的是java.lang.Object。<cinit>()
方法先执行,所以父类中定义的静态语句块要优先于子类的变量赋值操作。<cinit>()
方法对于类或接口不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器可以不为这个类生成<cinit>()
方法。<cinit>()
方法。但接口和类不同的是:执行接口的<cinit>()
方法不需要先执行父接口的<cinit>()
方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。接口的实现类在初始化时一样不会执行接口的<cinit>()
方法。<cinit>()
方法在多线程环境下被正确的加载和同步,只会有一个线程执行这个类的<cinit>()
方法,其他线程都需要阻塞等待, 直到活动线程执行完毕<cinit>()
方法.实现通过一个类的全限定名来获取描述此类的二进制字节流,让应用程序自己决定如何去获取所需要的类的动作的代码称为类加载器(Class Loader)。
对于任意一个类,都必须由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。比较两个类是否相等,这两个类必须由同一个类加载器加载,否则即使这两个类来自同一个Class文件,被同一个Java虚拟机加载, 只要加载它们的类加载器不同,那这个两个类必定不相等(类的Class对象的equals()方法,isAssignableFrom()方法,isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况)。
package com.lzlg.test;
import java.io.IOException;
import java.io.InputStream;
/
* 类加载器与instanceof关键字演示
*
* @author lzlg
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
} else {
byte[] bytes = new byte[is.available()];
is.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
}
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("com.lzlg.test.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.lzlg.test.ClassLoaderTest);
}
}
运行结果:
class com.lzlg.test.ClassLoaderTest
false
从Java虚拟机角度,存在两种不同的类加载器:一种时启动类加载器(Bootstrap ClassLoader,C++语言实现,虚拟机自身一部分);另一种是所有其他的类加载器,由Java语言实现,独立于虚拟机外部,并全部都继承抽象类java.lang.ClassLoader。
从Java开放人员角度,JDK 8及之前, 都会使用到以下三种系统提供的类加载器:
我们的应用程序都是由这三种类加载器互相配合进行加载的,如果有必要可加入自定义的类加载器。
上图所展示的类加载器之间的层次关系,就称为类加载器的双亲委派模型(Parents Delegation Model)。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而都是使用组合(Composition)关系来复用父加载器的代码。非强制性的约束模型。
双亲委派模型的工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
好处:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如:类java.lang.Object类,无论哪一个类加载器要加载这个类,最终都是委派到启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类,保证了Java类型体系中最基础的行为,保证了Java程序的稳定运作。
// 双亲委派模型的代码实现
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 检查请求的类是否已经被加载过
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 此时父类加载器无法完成加载请求
// 调用自身的类加载器加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
双亲委派模型是非强制性的约束模型。直到Java模块化出现为止, 出现过3次较大规模的破坏情况:
双亲委派模型发生之前(jdk1.2发表之前)。为兼容已经存在的用户自定义类加载器代码,无法以技术手段避免loadClass()被子类覆盖的可能性, 只能在jdk1.2之后java.lang.ClassLoader添加了新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写findClass()方法,而不是loadClass()方法. 按照loadClass()方法的逻辑, 如果父类加载失败,则会调用自己的findClass()方法(见上面代码)来完成加载,这样既不影响用户按照自己的意愿去加载类, 也可保证新写出来的类加载器符合双亲委派规则。
模型缺陷导致。基础类回调用户代码(如JNDI服务),启动类加载器不可能认识用户提供的代码。引入线程上下文加载器(Thread Context ClassLoader),这个类加载器可通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器, JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码, 这是一种父类加载器去请求子类加载器完成类加载的行为, 违背了双亲委派模型的一般性原则. JDK 6时, JDK的java.util.ServiceLoader类, 以META-INF/services中的配置信息, 辅以责任链模式, 才算是给SPI的加载提供了一种相对合理的解决方案.
由于用户对程序动态性的追求导致的(代码热替换,模块热部署)。
OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现, 每一个程序模块(称为Bundle)都有一个自己的类加载器, 当需要更换一个Bundle时, 就把Bundle连同类加载器一起换掉以实现代码的热替换.
为了能够实现模块化的可配置的封装隔离机制, JDK 9引入了Java模块化系统. 除代码外, Java模块定义包含:
可配置的封装隔离机制解决了JDK 9之前基于类路径来查找依赖的可靠性问题. 此前, 如果类路径中缺失了运行时依赖的类型, 只能等程序运行到发生该类型的加载, 链接时才会报出运行时异常. 而JDK 9之后, 如果启用了模块化进行封装, 模块就可以声明对其他模块的显式依赖, 这样Java虚拟机能够在启动时验证应用程序开放阶段设定好的依赖关系在运行期是否完毕, 从而避免了很大一部分由于类型依赖而引发的运行时异常.
可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型可访问性问题. JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们, 模块提供了更精细的可访问性控制, 必须明确声明其中哪一些public的类型可以被其他哪一些模块访问, 这种访问控制主要是在类加载过程中完成的.
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制, JDK 9提出了与类路径相对应的模块路径(ModulePath)的概念. 放在类路径的JAR文件, 无论是否包含模块化信息, 都会被当作传统的JAR包来对待; 相应的, 只要放在模块路径的JAR文件, 即使没有使用JMOD后缀, 甚至不包含module-info.class文件, 仍然会被当作一个模块来对待. 有以下规则:
JDK 9的Java模块化系统目前不支持在模块定义中加入版本号来管理和约束依赖, 本身也不支持多版本号的概念和版本选择功能.
为了模块化系统的顺利施行, 模块化下的类加载器发生了以下变动:
扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader) 取代. 因模块化天然满足可扩展的需要, 无需再保留<JAVA_HOME>\lib\ext目录, 新版的JDK也取消了<JAVA_HOME>\jre目录, 因为随时可以组合构建出程序运行所需的JRE来, 如只使用java.base模块中的类型:
jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre
平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader, 现全部继承于jdk.internal.loader.BuiltinClassLoader, 在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑, 以及模块中资源可访问性的处理.
JDK 9虽然仍然维持着三层类加载器和双亲委派的架构, 但类加载的委派关系发生了变动. 当平台及应用程序类加载器收到类加载请求, 在委派给父加载器前, 要先判断该类是否能够归属到某一个系统模块中, 如果可以找到这样的归属关系, 就要优先委派给负责那个模块的类加载器完成加载.
执行引擎是Java虚拟机核心的组成部分之一,是由软件自行实现的,可以不受物理条件制约地定制指令集和执行引擎的结构体系,且能够执行不被硬件直接支持的指令集格式。在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,成为各种虚拟机执行引擎的统一外观(Facade)。
在不同的虚拟机实现中,执行引擎在执行字节码的时候通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的即时编译器执行引擎。从外观上看起来,所有的Java虚拟机执行引擎输入, 输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析的等效过程,输出的是执行结果。
栈帧(Stack Frame):用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中虚拟机栈的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始到执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
在编译Java程序源码的时候,栈帧中需要的分配内存已确定(仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式),且数据信息写入到方法表的Code属性中。
以Java程序的角度来看, 同一时刻, 同一条线程里面, 在调用堆栈的所有方法都处于执行状态. 对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的, 只有位于栈顶的栈帧才是生效的,称为当前栈帧,与这个栈帧关联的方法称为当前方法。执行引擎所运行的字节码指令都只针对当前栈帧进行操作.
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中没有明确指明一个Slot应占有的内存空间大小,允许Slot的长度随着处理器、操作系统或虚拟机的不同而变化。
Java虚拟机中的reference数据类型表示对一个对象实例的引用, 一般来说, 虚拟机实现至少通过引用做到两件事情: 一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引; 二是根据引用直接或间接地查找到对象所属数据类型在方法区中存储的类型信息.
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始到局部变量表最大的Slot数量。如果访问的是32位的数据类型的变量,索引n代表使用第n个slot;如果访问的是64位数据类型的变量,则说明会同时使用第n和n+1两个slot。对于两个相邻的共同存放一个64位数据的两个slot, 虚拟机不允许采用任何方式单独访问其中的某一个, 如果遇到进行这种操作的字节码序列, 虚拟机就应该在类加载的校验阶段中抛出异常.
当方法被调用时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程(实参到形参的传递)。如果执行的是实例方法,第0位索引的Slot默认是用于传递方法所属对象实例的引用(使用关键字this访问),其余参数按照参数表顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为尽可能节省栈帧用的内存空间, 局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以给其他变量来重用,这样设计除了节省栈空间外,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为。
// 代码1
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
// 代码2
public static void main(String[] args) {
{ // 改变 placeholder 的作用域
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
// 运行代码1,2;没有回收内存
// 代码3
public static void main(String[] args) {
{ // 改变 placeholder 的作用域
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
// 运行代码3;内存回收
placeholder能否被回收的根本原因是:局部变量表中Slot是否还存有关于placeholder数组对象的引用。代码2中,代码虽然已离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。代码int a = 0;
(或手动设置为null值)把placeholder变量对应的局部变量槽的清空了,因此placeholder在代码3的情况下被垃圾回收。
以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,不要过多依赖赋null值操作。另外,赋null值的操作在经过即时编译器优化之后会被消除掉。
局部变量和类变量不同,没有赋初始值不能使用。
操作数栈常被称为操作栈,是一个后入先出(Last In First Out)栈。操作数栈的最大深度在编译时被写入到Code属性max_stacks数据项中,操作数栈的每一个元素都可以是任意的Java数据类型. 32位数据类型所占的栈容量为1,64位为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候, 操作数栈的深度都不会超过在max_stacks数据项中设定的最大值.
在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容, 也就是出栈和入栈操作. 譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的, 又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递.
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码时,编译器要严格保证这一点,在类校验阶段的数据流分析还要再次验证。(例如:iadd指令,必须两个整数相加,最接近栈顶的两个元素的数据类型必须是int型。)
在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的,但大多数虚拟机的实现会令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的局部变量表重叠在一起,这样做不仅节约了一些空间, 更重要的是在进行方法调用时就可直接共用一部分数据,无须进行额外的参数复制传递。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class文件的常量池的大量符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
当一个方法被执行后,有两种方式退出。一种是执行引擎遇到任意一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方式称为正常调用完成。
另一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理,无论是Java虚拟机内部产生的异常, 还是代码中使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出方式称为异常调用完成。一个方法使用异常完成出口的方式退出,不会给它的上层调用者提供任何返回值的。
无论何种退出方式,方法退出之后,都必须返回到最初方法被调用的位置, 程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用于帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址, 栈帧中很可能会保存这个计数器值; 而方法异常退出时,返回地址要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用阶段的唯一任务是确定被调用方法的版本,暂不涉及方法的具体运行过程。一切方法调用在Class文件中存储的都是符号引用,而不是方法在实际运行时内存布局的入口地址(直接引用),因此某些调用需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。
在类加载的解析阶段,会将一部分的符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的。调用目标在程序代码写好、编译器进行编译时就已经确定下来,这类方法的调用称为解析(Resolution)。解析调用是个静态过程。
Java语言中符合“编译器可知,运行期不可变”的方法主要有静态方法和私有方法两大类。前者与类型直接相关,后者在外部不可访问, 都适合在类加载阶段进行解析。Java虚拟机支持以下5条方法调用字节码指令:
<init>()
方法,私有方法和父类中的方法。只要能被invokestatic,invokespecial指令调用的方法,都可在解析阶段确定唯一的调用版本,Java语言符合这个条件的方法有静态方法, 私有方法, 实例构造器, 父类方法, 和被final修饰的方法(尽管它使用invokevirtual指令调用). 这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用,这些方法统称为非虚方法。与之相反,其他方法称为虚方法(final方法除外)。
Java中被final修饰的方法也是非虚方法,由于无法被覆盖,没有其他版本,无须对方法接收者进行多态选择。
解析调用一定是个静态的过程, 在编译期间就完全确定, 在类加载的解析阶段就会被涉及的符号引用全部转变为明确的直接引用, 不必延迟到运行期再去完成.
分派(Dispatch)调用可能是静态的也可能是动态的.
静态分派
// 方法静态分派演示
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Women extends Human {
}
public void sayHello(Human guy) {
System.out.println("Hello, guy!");
}
public void sayHello(Man guy) {
System.out.println("Hello, gentleman!");
}
public void sayHello(Women guy) {
System.out.println("Hello, lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(women);
}
}
// 运行结果:
// Hello, guy!
// Hello, guy!
Human man = new Man();
上面代码中的Human称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的Man称为变量的实际类型(Actual Type)。静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译期是可知的。而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候不知道一个对象的实际类型是什么。
虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为依据的,由于静态类型在编译期可知, 因此在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载,静态分派发生在编译阶段, 因此确定静态分派地动作实际上不是由虚拟机来执行的。编译器虽然能确定方法的重载版本,但很多情况下,这个重载版本不是唯一的,往往只能确定一个更加合适的版本。
public class Overload {
public static void sayHello(Object arg) {
System.out.println("Hello, Object!");
}
public static void sayHello(int arg) {
System.out.println("Hello, int!");
}
public static void sayHello(long arg) {
System.out.println("Hello, long!");
}
public static void sayHello(Character arg) {
System.out.println("Hello, Character!");
}
public static void sayHello(Object arg) {
System.out.println("Hello, Object!");
}
public static void sayHello(char arg) {
System.out.println("Hello, char!");
}
public static void sayHello(char... arg) {
System.out.println("Hello, char...!");
}
public static void sayHello(Serializable arg) {
System.out.println("Hello, Serializable!");
}
public static void main(String[] args) {
sayHello('a');
}
}
上面代码运行结果:Hello, char!
注释掉sayHello(char arg)方法运行结果:Hello, int!(发生自动类型转换,char --> int)
继续注释掉sayHello(int arg)方法运行结果:Hello, long! (char --> int --> long)
自动类型转换能发生多次,按照 char --> int --> long --> float --> double 的顺序进行匹配。
继续注释掉sayHello(long arg)方法运行结果:Hello, Character! (自动装箱)
继续注释掉sayHello(Character arg)方法运行结果:Hello, Serializable!(自动装箱后,发现找不到装箱类的方法,但是找到了装箱类实现的接口类型Serializable,所以紧接着又发生一次自动转型。)
char可以转型成int,但是Character是绝对不会转型为Integer的,只能安全地转型为它实现的接口或父类。Character还实现了Comparable<Character>
接口,如果同时出现两个参数分别为Serializable和Comparable<Character>
的重载方法,那么它们此时的优先级是一样的,编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。
继续注释掉sayHello(Serializable arg)方法运行结果:Hello, Object! (char自动装箱转型为父类,如果有多个父类,将在继承关系中从下往上开始搜索,越接近上层的优先级越低,即使方法调用传入的参数值为null,这个规则仍然适用。)
继续注释掉sayHello(Object arg)方法运行结果:Hello, char...! (可见变长参数的重载优先级是最低的,这时字符’a'被当作一个char[]数组元素。注意:有一些在单个参数中能成立的自动转型,在变长参数中是不成立的, 如char转型为int.)
解析和分派是在不同层次上去筛选和确定目标方法的过程。静态方法在编译期确定, 在类加载期就进行解析,而静态方法显然也是可以有重载版本的,选择重载版本的过程是通过静态分派完成的。
动态分派
动态分派和体现多态性的重写(Override)密切关联。
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Women extends Human {
@Override
protected void sayHello() {
System.out.println("women say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
man.sayHello();
women.sayHello();
man = new Women();
man.sayHello();
}
}
// 运行结果
// man say hello
// women say hello
// women say hello
经过javap命令输出上面代码的字节码, 可知两个方法的调用指令和参数都完全一样, 但这两句指令的最终执行的目标方法并不相同, 原因是:invokevirtual指令的确定方法调用版本和实现多态查找。
invokevirtual指令的运行时解析过程大致步骤:
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了, 还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
在Java里面, 字段永远不参与多态, 哪个类的方法访问某个名字的字段时, 该名字指的就是这个类能看到的字段. 当子类声明了与父类同名的字段时, 虽然在子类的内存中两个字段都会存在, 但子类的字段会屏蔽父类的同名字段:
/
* 字段不参与多态
*
* @author lzlg
* 2023/7/1 17:35
*/
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, I have $" + money + ".");
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, I have $" + money + ".");
}
}
public static void main(String[] args) {
Father guy = new Son();
System.out.println("This guy has $" + guy.money + ".");
}
}
// 输出结果
// I am Son, I have $0.
// I am Son, I have $4.
// This guy has $2.
输出结果原因解释:
单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,根据分派基于多少种宗量,将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个的宗量对目标方法进行选择。
public class Dispatch {
static class QQ {
}
static class _360 {
}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("father choose 360");
}
}
public static class Son extends Father {
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
@Override
public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
// 运行结果
// father choose 360
// son choose qq
静态分派的过程:选择目标方法的依据有两点,一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择的结果产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
动态分派的过程:执行son.hardChoice(new QQ());
代码对应的invokevirtual指令时,由于编译期已经决定目标方法的签名是hardChoice(QQ),这时候参数的静态类型,实际类型都不会对方法的选择构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
Java语言在JDK 9引入的jdk.dynalink模块, 使用jdk.dynalink可以实现在表达式种使用动态类型, Javac编译期会将这些动态类型的操作翻译为invoke dynamic指令的调用点.
虚拟机动态分派的实现
动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法, 因此Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索元数据类型. 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table简称vtable, 与此对应的在invokeinterface执行时也会用到接口方法表-Interface Method Table, itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类虚方法表中的地址将会被替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号。这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
除了使用虚方法表之外, 为进一步提供性能, 还会使用类型继承关系分析, 守护内联, 内联缓存等多种非稳定的激进优化来争取更大的性能空间.
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的, 如JavaScript, Groovy, Lua等; 相对地, 在编译期就进行类型检查过程的语言是静态类型语言, 如C++和Java.
运行时异常是指只要代码不执行到这一行就不会出现问题, 与之相对的概念是连接时异常, 即使导致连接时异常的代码在一条根本无法被执行到的路径分支上, 类加载时也照样会抛出异常. 但在C语言中, 会在编译期直接报错.
代码: obj.println("Hello, world");
上述代码在Java语言中, 并且变量obj的静态类型为java.io.PrintStream, 那变量obj的实际类型就必须是PrintStream的子类才是合法的; 而在JavaScript语言中, 无论obj具体是何种类型, 无论继承关系如何, 只要这种类型的方法定义中确实包含println(String)方法, 能找到相同签名的方法, 调用就能成功.
产生上述差别的根本原因是: Java语言在编译期间就已经将println(String)方法完整的符号引用生成出来, 并作为方法调用指令的参数存储到Class文件中; 这个符号引用包含该方法定义在哪个类型中, 方法的名字已经参数顺序, 参数类型和方法返回值信息, 通过这个符号引用, Java虚拟机可翻译出该方法的直接引用.
而JavaScript等动态类型语言与Java有一个核心差异是变量obj本身没有类型, 变量obj的值才具有类型, 所以动态类型语言的编译器在编译时最多只能确定方法名称, 参数, 返回值信息, 而不会去确定方法所在的具体类型(方法接收者不固定). 变量无类型而变量值才有类型, 这个特点也是动态类型语言的一个核心特征.
动态类型和静态类型语言的优缺点:
JDK 7以前的字节码指令集中, 4条方法调用指令的第一个参数都是被调用方法的符号引用, 而方法的符号引用在编译时产生, 而动态类型语言只有在运行期才能确定方法的接收者, 这样在Java虚拟机上实现动态类型语言, 不得不使用占位符等其他方式来实现, 但这样势必会让动态类型语言的实现复杂度增加, 也会带来额外的性能和内存开销, 也无法使用方法内联等优化措施. 因此动态类型方法调用的底层问题应当在Java虚拟机层次上去解决, 因此有了invokedynamic指令和java.lang.invoke包.
这个包的主要目的是提供一种新的动态确定目标方法的机制, 称为方法句柄(Method Handle).
以前Java语言没有办法单独把一个函数作为参数进行传递, 在拥有方法句柄之后, Java语言可实现.
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
/
* Method Handle基础用法
*
* @author lzlg
* 2023/7/2 11:42
*/
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
// 无论obj是哪个实现类, 都能正确调用到println方法
getPrintlnMH(obj).invokeExact("hello, world.");
}
// getPrintlnMH实际上是模拟了invoke-virtual指令的执行过程, 只不过它的分派逻辑并非固化在Class文件的字节码上, 而是通过一个由用户设计的Java方法来实现.
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {
// MethodType表示方法类型, 第一个参数是方法的返回值, 随后的参数是方法的参数
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()用于在指定类中查找符合给定的方法名称, 方法类型, 且符合调用权限的方法句柄
return MethodHandles.lookup()
// 调用invoke-virtual指令的方法println
.findVirtual(receiver.getClass(), "println", mt)
// 绑定执行方法的对象实例(this)
.bindTo(receiver);
}
}
MethodHandle与Reflection(反射调用)的区别:
某种意义上说invokedynamic指令与MethodHandle机制的作用是一样的, 都是把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中, 让用户有更高的自由度. MethodHandle机制用上层代码和API实现, 而invokedynamic指令用字节码和Class中其他属性, 常量来完成.
每一处含有invokedynamic指令的位置都被称作动态调用点, 这条指令的第一个参数是CONSTANT_InvokeDynamic_info常量, 从此常量中可得到: 引导方法(Bootstrap Method, 存放在新增的BootStrapMethods属性中), 方法类型和名称. 引导方法是有固定的参数, 并且返回值规定是java.lang.invoke.CallSite对象, 这个对象代表了真正要执行的目标方法调用. 根据CONSTANT_InvokeDynamic_info常量提供的信息, 虚拟机可找到并执行引导方法, 从而获得一个CallSite对象, 最终调用到要执行的目标方法上.
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
/
* 使用MethodHandle解决子类访问祖父类的问题
*
* @author lzlg
* 2023/7/2 12:15
*/
public class UseMethodHandleTest {
class GrandFather {
void thinking() {
System.out.println("I am grandfather.");
}
}
class Father extends GrandFather {
void thinking() {
System.out.println("I am father.");
}
}
class Son extends Father {
void thinking() {
// JDK 7 Update 9之前会输出I am grandfather.
// JDK 7 Update 10修正之后会输出I am father.
// 原因是必须保证findSpecial查找方法版本时受到的访问约束应与invokespecial指令一样
// 两者必须保持精确对等, 因此只能访问到其直接父类中的方法版本.
try {
MethodType mt = MethodType.methodType(void.class);
MethodHandle mh = MethodHandles.lookup()
// 注意第四个参数是getClass()
.findSpecial(GrandFather.class, "thinking", mt, getClass());
mh.invoke(this);
} catch (Throwable e) {
throw new RuntimeException(e);
}
// 可使用下面方式完成对GrandFather的方法调用
try {
MethodType mt = MethodType.methodType(void.class);
// 反射调用使IMPL_LOOKUP可访问
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null))
// 注意第四个参数是GrandFather.class
.findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}
public static void main(String[] args) {
(new UseMethodHandleTest().new Son()).thinking();
}
}
Java虚拟机的执行引擎在执行Java代码的时候都有解释执行和编译执行两种选择。
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需经过:
这些步骤。其中3分支是传统编译原理中程序代码到目标代码的生成过程,而2分支是解释执行的过程。
对于一门具体语言的实现来说,词法, 语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。
Java语言中,Javac编译器完成了程序代码经过词法分析, 语法分析到抽象语法树, 再遍历语法树生成线性的字节码指令流的过程,这部分动作在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流里面的指令大部分都是零地址指令,依赖操作数栈进行工作。
与之相对的另一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二进制指令集。
基于栈的指令集架构 | 基于寄存器的指令集架构 | |
---|---|---|
不同 | iconst_1 iconst_1 iadd istore_0 | mov eax, 1 add eax, 1 |
优点 | 可移植性 不受硬件的约束 虚拟机实现更加简单 代码相对更紧凑 编译器实现更加简单 | 指令数量少 内存访问较不频繁 执行速度较快 |
缺点 | 执行速度较慢 指令数量多 内存访问较频繁 | 移植性差 受硬件的约束 |
// 源代码
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
// 对应的字节码
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
上诉执行过程仅仅是一种概念模型,虚拟机最终会对执行过程进行一些优化提高性能。实际情况会和上面描述的概念模型有非常大的差距,差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化。
功能健全的web服务器,都需要解决几个问题:
Tomcat服务器规划用户的类库结构和类加载器: 在Tomcat目录结构中,有三组目录(/common/*
, /server/*
, /shared/*
, 可能只有/lib/*
目录存在)可以存放Java类库,Web应用程序自身的目录/WEB-INF/*
。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现。
CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用;
而CatalinaClassLoader和SharedClassLoader自己能加载的类与对方相互隔离;
WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那个Class文件, 当服务器检测到JSP文件被修改时, 会替换掉目前的JasperLoader实例. 并通过再建立一个新的JSP类加载来实现JSP文件的HotSwap功能.
WebApp类加载器和Jsp类加载器通常会存在多个实例,一个web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
OSGi(Open Service Gateway Initiative):一个基于Java语言的动态模块化规范。OSGi的每个模块(Bundle)与普通的Java类库区别不大,两者一般都以Jar格式进行封装,并且内部存储的都是Java的Package和Class。每一个Bundle可以声明它所依赖的Java Package(通过Import-Package描述),也可以声明它允许导出发布的Java Package(通过Export-Package描述)。在OSGi里,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖(至少外观上如此),而且类库的可见性能得到非常精确的控制。一个模块里只有被Export过的Package才可被外界访问,其他的Package和Class将会被隐藏起来。基于OSGi的程序很可能实现模块级的热拔插功能。
灵活的类加载器架构:OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。某个Bundle声明了一个它所依赖的Package,如果有其他的Bundle声明发布了这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。一个Bundle类加载器为其他的Bundle提供服务时,会根据Export-Package列表严格控制访问范围。
在OSGi中, 类加载时可能进行的查找规则:
在OSGi里,加载器之间的关系不再是双亲委派模型的树型结构,而是已经进一步发展成一种更为复杂的, 运行时才能确定的网状结构,提供更优秀的灵活的同时,也可能会产生许多新的隐患(如高并发环境下死锁问题, 因进行类加载时通过synchronized关键字进行了同步, 该问题在JDK 7时得到解决, 通过在ClassLoader中增加了registerAsParallelCapable方法对可并行的类加载进行注册声明, 把锁的级别从ClassLoader对象本身, 降低为要加载的类名这个级别)。
Bean是面向接口编程的,那么Spring内部都是通过动态代理的方式对Bean进行增强的。动态代理中所谓的动态,是针对使用Java代码实际编写了代理类的静态代理而言的,它的优势不在于省去了编写代理类的那一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景中。
动态代理原理演示:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/
* 动态代理
*
* @author lzlg
* 2023/7/4 12:20
*/
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello, world.");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),
originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
// 保存生成的代理类的字节码class文件
System.getProperties().setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
产生的代理类字节码反编译后:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello {
private static Method m1;
private static Method m3;
private static Method m2;
private static Method m0;
public $Proxy0(InvocationHandler var1) throws {
super(var1);
}
public final boolean equals(Object var1) throws {
try {
return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}
public final void sayHello() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
public final String toString() throws {
try {
return (String)super.h.invoke(this, m2, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
public final int hashCode() throws {
try {
return (Integer)super.h.invoke(this, m0, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m3 = Class.forName("com.lzlg.study.jvm.test.DynamicProxyTest$IHello").getMethod("sayHello");
m2 = Class.forName("java.lang.Object").getMethod("toString");
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}
代理类为传入接口中的每一个方法, 包括从java.lang.Object继承来的equals(), hashCode(), toString()方法都生成了对应的实现, 并且同一调用了InvocationHandler对象的invoke()方法来实现这些方法的内容, 各个方法的区别不过是传入的参数和Method对象有所不同而已.
编译器分类:
前端编译器: 把java文件转变成class文件, 如JDK的javac;
Java虚拟机的即时编译器: 常称JIT编译器, 运行期把字节码转变成机器码的过程, Just In Time Compiler, 如HotSpot的C1, C2编译器, Graal编译器;
静态提前编译器: 常称AOT编译器, 直接把程序编译成与目标机器指令集相关的二进制代码的过程, Ahead Of Time Compiler, 如JDK的Jaotc;
Java中即时编译器在运行期的优化过程, 支撑了程序执行效率的不断提升; 而前端编译器在编译期的优化过程, 则是支撑着程序员的编码效率和语言使用者的幸福感的提高.
虚拟机规范严格定义了Class文件的格式,但对如何把Java源码编译为Class文件却描述得相当宽松, 给了Java前端编译器较大的灵活性, 导致Class文件编译过程在某种程度上是与具体的JDK或编译器实现相关的。
从Javac的代码总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程:
上述3个处理过程里, 执行插入式注解时, 可能会产生新的符号, 如果有新的符号产生, 就必须回到之前的解析, 填充符号表的过程中重新处理这些新符号, 三者之间的关系与交互顺序如图:
解析步骤包括词法分析和语法分析两个过程。
词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译时的最小元素,关键字、变量名 、字面量和运算符都可以称为标记。
语法分析是根据标记序列来构造抽象语法树的过程,抽象语法树(AST,Abstract Syntax Tree)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct)。
填充符号表:符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,可把它想象成哈希表中K-V值对的存储形式。符号表中所登记的信息在编译的不同阶段都要被用到. 符号表,在语义分析阶段,用于语义检查(检查一个名字的使用和原先的声明是否一致)和产生中间代码;在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。
JDK 5之后,Java提供对注解的支持,和普通的Java代码一样,在运行期间发挥作用。在JDK 6中提案设计了一组插入式注解处理器的标准API, 可以提前至编译期对代码的特定注解进行处理, 从而影响到前端编译器的工作过程.
插入式注解处理器可以看作是一组编译器的插件,可读取,修改,添加抽象语法树中的任意元素,如果这些插件在处理注解期间对语法树进行过修改,那编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有再对语法树进行修改为止, 每一次循环过程称为一个轮次(Round).
语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的检查,如类型检查, 数据及控制流检查.
标注检查:检查的内容包括诸如变量使用前是否已被声明,变量与赋值之间的数据类型是否能够匹配等等。在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化。
int a = 1 + 2;
代码经过常量折叠后,会被折叠为字面量 3.
数据及控制流分析:对程序上下文逻辑更进一步的验证,可检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值,是否所有的受检查异常都被正确处理了等问题。
编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本一致,但校验范围有所区别,有一些校验项只有在编译期或运行期才能进行。将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。
解语法糖:语法糖能够减少代码量, 增加程序的可读性, 从而减少程序代码出错的机会, 方便程序员使用该语言.
Java中最常用的语法糖:泛型,变长参数,自动装箱拆箱等,Java虚拟机运行时并不直接支持这些语法,在编译阶段被还原回原始的基础语法结构, 这个过程称为解语法糖.
字节码生成:此阶段不仅仅把前面各个步骤所生成的信息(语法树,符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
实例构造器<init>()
方法和类构造器<clinit>()
方法在这个阶段被添加到语法树之中,
这里的实例构造器并不等同于默认构造函数-如果用户代码中没有提供任何构造函数, 那编译器将会添加一个没有参数的, 可访问性与当前类型一致的默认构造函数,而这个工作在填充符号表阶段已经完成
这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块,
对实例构造器而言是 {} 块,对类构造器而言是 static {} 块
变量初始化,调用父类的实例构造器
仅仅是实例构造器,
<clinit>()
方法中无须调用父类的<clinit>()
方法, Java虚拟机会自动保证父类构造器的正确执行, 但<clinit>()
方法中经常会生成调用java.lang.Object的<init>()
方法的代码
等操作收敛到<init>()
和<clinit>()
方法中,并且保证无论源码中出现的顺序如何, 都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。
除生成构造器外, 还有把字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于等于JDK 5)的append()操作。
泛型
泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中, 分别构成泛型类, 泛型接口和泛型方法.
Java选择的泛型实现方式叫作类型擦除式泛型(Type Erasure Generics), 而C#选择的泛型实现方式是具现化式泛型(Reified Generics).
C#里面泛型无论在程序源码里,编译后的中间语言表示里,抑或是运行期的CLR里都是切实存在的,List<int>
与List<String>
就是两种不同的类型, 它们由系统在运行期生成,有自己独立的虚方法表和类型数据.
Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被被替换为原来的裸类型(Raw Type),并且在相应的地方插入了强制转型代码,对于运行期的Java语言来说, List<int>
与List<String>
其实是同一个类型.
Java的类型擦除式泛型在使用效果上还是运行效率上, 几乎全面落后C#的具现化式泛型, 而唯一优势是实现这种泛型的影响范围上: 只需要在Javac编译器上做出改进即可, 不需要改动字节码, 不需要改动Java虚拟机, 也保证了以前没有使用泛型的库可直接运行在Java 5.0之上.
public static void method(List<String> list) {
System.out.println("Invoke method List<String> list");
}
public static void method(List<Integer> list) {
System.out.println("Invoke method List<Integer> list");
}
// 以上这两个方法是不能共存的,因为类型擦除
public static String method(List<String> list) {
System.out.println("Invoke method List<String> list");
return "";
}
public static int method(List<Integer> list) {
System.out.println("Invoke method List<Integer> list");
return 1;
}
// 以上这两个方法是却是能共存的,
// Class文件方法表的数据结构中,方法重载要求方法具备不同的特征签名,而返回值不包含在方法的特征签名中,所以返回值不参与重载选择
// 但在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存,也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那么它们也是可以合法共存于一个Class文件中的。
由于Java泛型的引入,Java虚拟机规范做出了相应的修改,引入了诸如Signature, LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题。Signature是其中最重要的一项属性,它的作用是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型不是原生类型,而是包括了参数化类型的信息。
所谓的泛型擦除,仅仅对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这是我们能通过反射手段取得参数化类型的根本依据。
值类型可与引用类型一样, 具有构造函数, 方法或是属性字段, 而它与引用类型的区别在于它在赋值的时候通常是整体复制, 而不是像引用类型那样传递引用的; 值类型的实例很容易实现分配在方法的调用栈上, 这意味着值类型会随着当前方法的退出而自动释放, 不会给垃圾收集子系统带来任何压力.
自动装箱,拆箱与遍历循环
自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,而遍历循环则是把代码还原成了迭代器的实现(这是为何遍历循环需要被遍历的类实现Iterable接口的原因),变长参数在调用的时候变成一个数组类型的参数。
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true
System.out.println(e == f); // false
System.out.println(c == (a + b)); // true
System.out.println(c.equals(a + b)); // true
System.out.println(g == (a + b)); // true
System.out.println(g.equals(a + b)); // false
}
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
// Integer中把~128到127的数字缓存了
System.out.println("c == d : " + (c == d));
System.out.println("e == f : " + (e == f));
// 这里a + b自动拆箱
System.out.println("c == (a + b) : " + (c == (a + b)));
// Integer中把~128到127的数字缓存了
System.out.println("c.equals(a + b) : " + (c.equals(a + b)));
// 这里a + b自动拆箱并升级为long类型
System.out.println("g == (a + b) : " + (g == (a + b)));
// g是Long类型,而a + b自动拆箱后再装箱是Integer类型
System.out.println("g.equals(a + b) : " + (g.equals(a + b)));
}
条件编译
Java语言进行条件编译,方法就是使用条件为常量的if语句,只能使用条件为常量的if语句才有效果。
根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,在编译器解除语法糖的阶段中完成,只能写在方法体内部, 只能实现语句基本块级别的条件编译,没有办法实现根据条件调整整个Java类的结构。
public static void main(String[] args) {
if (true) {
System.out.println("Block 1");
} else {
System.out.println("Block 2");
}
}
// 被条件编译成:
public static void main(String[] args) {
System.out.println("Block 1");
}
// 使用while不能条件编译,会被拒绝编译
public static void main(String[] args) {
// 编译器提示 “Unreachable code”
while (false) {
System.out.println("");
}
}
使用注解处理器API编写命名规范检查工具:
需要继承抽象类:javax.annotation.processing.AbstractProcessor,覆盖abstract方法:process()。
方法的第一个参数:annotations:可获取到此注解处理器所需要处理的注解集合
第二个参数:roundEnv:访问到当前这个 轮次(Round) 中的抽象语法树节点,每个语法树节点在这里表示为Element,javax.lang.model包中定义了18类Element。
常用的实例变量:processingEnv,是AbstractProcessor中的一个protected变量,在注解处理器初始化的时候创建-init()方法执行的时候, 代表注解处理器框架提供的一个上下文环境,要创建新的代码,向编译器输出信息,获取其他工具类等都需要用到。
两个可以配合使用的Annotations:@SupportedAnnotationTypes(这个注解处理器对哪些注解感兴趣) 和 @SupportedSourceVersions(可以处理哪些版本的Java代码)。
每个注解处理器在运行的时候都是单例的,如果不需要改变生成语法树的内容,process()方法可返回false,通知编译期这个Round中的代码未发生变化,无须构造新的JavaCompiler实例。
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;
/
* 名称检查处理器
* 1.检查类名称,方法名称,字段名称是否符合驼峰命名法
* 2.检查常量是否字母全部大写,且用下划线隔开
*
* @author lzlg
* 2023/2/20 11:42
*/
// 支持的所有的注解
@SupportedAnnotationTypes("*")
// 支持的版本
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class NameCheckProcessor extends AbstractProcessor {
private NameChecker nameChecker;
/
* 初始化名称检测器组件
*/
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.nameChecker = new NameChecker(processingEnv);
}
/
* 对输入的语法树的各个节点进行名称检查
*/
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
for (Element element : roundEnv.getRootElements()) {
this.nameChecker.checkName(element);
}
}
return false;
}
}
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
/
* 名称检查器
*
* @author lzlg
* 2023/2/20 11:43
*/
public class NameChecker {
private NameCheckScanner nameCheckScanner;
public NameChecker(ProcessingEnvironment processingEnv) {
this.nameCheckScanner = new NameCheckScanner(processingEnv.getMessager());
}
public void checkName(Element element) {
this.nameCheckScanner.scan(element);
}
}
import javax.annotation.processing.Messager;
import javax.lang.model.element.*;
import javax.lang.model.util.ElementScanner8;
import javax.tools.Diagnostic;
import java.util.Optional;
/
* 名称检查浏览器
*
* @author lzlg
* 2023/2/20 11:45
*/
public class NameCheckScanner extends ElementScanner8<Void, Void> {
private final Messager messager;
public NameCheckScanner(Messager messager) {
super();
this.messager = messager;
}
/
* 检查Java类名
*/
@Override
public Void visitType(TypeElement e, Void unused) {
this.scan(e.getTypeParameters(), unused);
// 检查是否符合首字母大写驼峰命名法
Optional<String> optional = NameCheckUtils.checkCamelCase(e, true);
// 打印optional返回的信息
optional.ifPresent(message -> this.messager.printMessage(Diagnostic.Kind.WARNING, message, e));
super.visitType(e, unused);
return null;
}
/
* 检查方法名
*/
@Override
public Void visitExecutable(ExecutableElement e, Void unused) {
if (e.getKind() == ElementKind.METHOD) {
Name name = e.getSimpleName();
if (name.contentEquals(e.getEnclosingElement().getSimpleName())) {
this.messager.printMessage(Diagnostic.Kind.WARNING,
"一个普通方法" + name + "不应当与类名重复,避免与构造函数产生混肴.", e);
}
// 检查是否符合驼峰命名法,首字母小写
Optional<String> optional = NameCheckUtils.checkCamelCase(e, false);
// 打印optional返回的信息
optional.ifPresent(message -> this.messager.printMessage(Diagnostic.Kind.WARNING, message, e));
}
super.visitExecutable(e, unused);
return null;
}
/
* 检查变量名
*/
@Override
public Void visitVariable(VariableElement e, Void unused) {
// 如果是枚举常量
if (e.getKind() == ElementKind.ENUM_CONSTANT
// 常量值不为空
|| e.getConstantValue() != null
// 或者是常量
|| NameCheckUtils.isConstant(e)) {
// 检查常量是否符合命名规范
Optional<String> optional = NameCheckUtils.checkAllCaps(e);
// 打印optional返回的信息
optional.ifPresent(message -> this.messager.printMessage(Diagnostic.Kind.WARNING, message, e));
} else {
// 检查普通字段是否符合命名规范
Optional<String> optional = NameCheckUtils.checkCamelCase(e, false);
// 打印optional返回的信息
optional.ifPresent(message -> this.messager.printMessage(Diagnostic.Kind.WARNING, message, e));
}
super.visitVariable(e, unused);
return null;
}
}
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.VariableElement;
import java.util.EnumSet;
import java.util.Optional;
/
* @author lzlg
* 2023/2/20 11:53
*/
public class NameCheckUtils {
private NameCheckUtils() {
}
/
* 检查一个变量是否是常量
*/
public static boolean isConstant(VariableElement e) {
// 在接口中的变量是常量
if (e.getEnclosingElement().getKind() == ElementKind.INTERFACE) {
return true;
}
// 普通字段必须是public static final修饰的
return e.getKind() == ElementKind.FIELD &&
e.getModifiers().containsAll(EnumSet.of(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL));
}
/
* 检查是否符合驼峰命名法
*
* @param initialCaps 首字母是否大写
*/
public static Optional<String> checkCamelCase(Element e, boolean initialCaps) {
// 获取名称
String name = e.getSimpleName().toString();
// 单词前一个字符是否大写
boolean previousUpper = false;
// 标志是否校验成功
boolean conventional = true;
// 首字母
int firstCodePoint = name.codePointAt(0);
// 判断首字母是否大写
if (Character.isUpperCase(firstCodePoint)) {
previousUpper = true;
// 如果要求首字母小写,则违反命名规则,直接返回
if (!initialCaps) {
return Optional.of("名称" + name + "应当以小写字母开头.");
}
// 判断首字母是否小写
} else if (Character.isLowerCase(firstCodePoint)) {
// 如果要求首字母大写,则违反命名规则,直接返回
if (initialCaps) {
return Optional.of("名称" + name + "应当以大写字母开头.");
}
} else {
conventional = false;
}
// 如果首字母检查通过,开始检查其余字母
if (conventional) {
int cp = firstCodePoint;
// 遍历名称
for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
cp = name.codePointAt(i);
// 如果字母是大写
if (Character.isUpperCase(cp)) {
// 如果前面已有大写字母,则不符合命名规则
if (previousUpper) {
conventional = false;
break;
}
// 否则标记前面有大写字母
previousUpper = true;
} else {
// 否则前面没有大写字母
previousUpper = false;
}
}
}
// 判断是否符合驼峰命名法
if (!conventional) {
return Optional.of("名称" + name + "应当符合驼峰命名法.");
}
return Optional.empty();
}
/
* 检查是否全部大写,且有下划线
*/
public static Optional<String> checkAllCaps(Element e) {
// 获取名称
String name = e.getSimpleName().toString();
// 标志是否校验成功
boolean conventional = true;
// 首字母
int firstCodePoint = name.codePointAt(0);
// 判断首字母是否大写
if (!Character.isUpperCase(firstCodePoint)) {
conventional = false;
} else {
// 标志前面已经有下划线
boolean previousUnderscore = false;
int cp = firstCodePoint;
// 遍历name字符串取出每个字符
for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
cp = name.codePointAt(i);
// 如果是下划线,如果前面有下划线,则不符合命名规则,直接break跳出循环
if (cp == (int) '_') {
if (previousUnderscore) {
conventional = false;
break;
}
// 如果有下划线,则标记前面有下划线
previousUnderscore = true;
} else {
// 如果不是下划线,则标记前面没有下划线
previousUnderscore = false;
// 检查字符是否大写且不是数字
if (!Character.isUpperCase(cp) && !Character.isDigit(cp)) {
conventional = false;
break;
}
}
}
}
if (!conventional) {
return Optional.of("常量" + name + "应当全部以大写字母或下划线命名,并以字母开头.");
}
return Optional.empty();
}
public static void main(String[] args) {
String name = "hello,world";
System.out.println(name.codePointAt(2));
System.out.println(Character.charCount(name.codePointAt(2)));
}
}
/
* 测试名称检查器的代码
* javac -processor NameCheckProcessor BADLY_NAMED_CODE.java
*
* @author lzlg
* 2023/2/20 14:58
*/
public class BADLY_NAMED_CODE {
enum colors {
red, blue, green;
}
static final int _FORTY_TWO = 42;
public static int NOT_A_CONSTANT = _FORTY_TWO;
protected void BADLY_NAMED_CODE() {
return;
}
protected void NOTcamelCASEmethodNAME() {
return;
}
}
提前编译器和即时编译器
即时编译器:当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为热点代码(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器称为即时编译器(Just In Time Compiler,JIT编译器)。
主流的商用虚拟机,同时包含解释器与编译器.
解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。当程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗, 获得更高的执行效率。
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个逃生门(如果情况允许, HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当逃生门的角色),让编译器根据概率选择一些不能保证所有情况都正确, 但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立(如加载了新类后类型继承结构出现变化,出现罕见陷阱时)可以通过逆优化退回到解释状态继续执行,因此在整个虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。
HotSpot虚拟机内置了两个(或三个)即时编译器,称为客户端编译器-Client Compiler(C1)和服务端编译器-Server Compiler(C2), 第三个是JDK 10才出现得的, 长期目标是代替C2的Graal编译器. 在分层编译的工作模式出现以前, HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式, 用户可用-client
或-server
参数去强制指定虚拟机运行模式.
解释器与编译器搭配使用的方式在虚拟机中被称为混合模式(Mixed Mode), 用户可以使用参数-Xint
强制虚拟机运行于解释模式(Interpreted Mode), 这时编译器完全不介入工作, 全部代码都使用解释方式执行; 也可使用参数-Xcomp
强制虚拟机运行于编译模式(Complied Mode), 这时优先采用编译方式执行程序, 但解释器仍然要在编译无法进行的情况下介入执行过程.
由于即时编译器编译本地代码需要占用程序运行时间, 通常要编译出优化越高的代码, 所花费的时间便会越长; 而且想要编译出优化程度更高的代码, 解释器可能还要替编译器收集性能监控信息, 这对解释执行阶段的速度也有所影响. 为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译根据编译器编译,优化的规模与耗时,划分出不同的编译层次,其中包括:
以上层次并不是固定不变的, 根据不同的运行参数和版本, 虚拟机可以调整分层的数量.
实施分层编译后,解释器, 客户端编译器和服务端编译器会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务, 而在服务端编译器采用高复杂度的优化算法时, 客户端编译器可先采用简单优化来为它争取更多的编译时间.
运行过程中会被即时编译器编译的热点代码有两类,即:被多次调用的方法,被多次执行的循环体。
第一种情况, 由于是依靠方法调用触发的编译,编译器会以整个方法作为编译对象;而后一种情况,尽管编译动作是由循环体所触发的,但编译器依然必须以整个方法作为编译对象, 只是执行入口会稍有不同, 编译时会传入执行入口点字节码循环(BCI, Byte Code Index)。这种编译方式因为编译发生在方法执行的过程中,因此被形象地称为栈上替换(On Stack Replacement,OSR)。
要知道一段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测(Hot Spot Detection)。主流的热点探测判定方式有两种:
HotSpot虚拟机使用的是第二种,为每个方法准备了两个计数器:方法调用计数器(Innovation Counter)和回边计数器(Back Edge Counter)。
在默认设置下,方法调用计数器统计的是一段时间之内方法被调用的次数;当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器的热度衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作在虚拟机垃圾收集时顺便进行的。可使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减, 让方法计数器统计方法调用的绝对次数, 这样只要系统运行时间足够长, 程序中绝大部分都会被编译成本地代码. 可使用-XX:CounterHalfLifeTime参数设置半衰周期的时间, 单位是秒.
回边计数器,用于统计一个方法中循环体代码执行的次数(在字节码中遇到控制流向后跳转的指令被称为回边-Back Edge)。
设置参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,计算公式为:
1、虚拟机运行在Client模式下,方法调用计数器阈值(CompileThreshold,默认1500次)乘以OSR比率(OnStackReplacePercentage)除以100,其中OnStackReplacePercentage的默认值为933。阈值为13995.
2、虚拟机运行在Server模式下,方法调用计数器阈值(CompileThreshold,默认10000次)乘以 [ OSR比率(OnStackReplacePercentage)减去解释器监控比率(InterpreterProfilePercentage)] 除以100,其中OnStackReplacePercentage的默认值为140,InterpreterProfilePercentage默认值为33。阈值为10700.
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此回边计数器统计的是该方法循环执行的绝对次数。当回边计数器溢出时,会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
在默认条件下,无论是方法调用产生的标准编译请求, 还是栈上替换编译请求, 虚拟机在编译器还未完成编译之前,都仍然按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行(可通过参数-XX:-BackgroundCompilation来禁止). 如果禁止后台编译,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。
客户端编译器:相对简单快速的三段式编译器,主要关注局部性的优化,放弃许多耗时较长的全局优化手段。
服务端编译器:专门面向服务端的典型应用并为服务端的性能配置针对性调整过的编译器,是个能容忍很高优化复杂度的高级编译器,会执行大部分经典的优化动作。无用代码消除(Dead Code Elimination),循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)等优化措施。服务端编译器采用的寄存器分配器是一个全局图着色分配器, 可以充分利用某些处理器架构上的大寄存器集合.
可使用参数-XX:+PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来。
使用参数-XX:+PrintInlining要求虚拟机输出方法内联信息。
进一步跟踪本地代码生成的具体过程,使用参数-XX:+PrintCFGToFile(用于客户端编译器)或-XX:+PrintIdealGraphFile(用于服务端编译器)令虚拟机将编译过程中各个阶段的数据输出到文件中。
提前编译的研究分支:
做与传统C, C++编译器类似的, 在程序运行之前把程序代码编译成机器码的静态翻译工作.
这是传统的提前编译应用形式, 它在Java中存在的价值是直指即时编译器的最大弱点: 即时编译要占用程序运行时间和运算资源, 即时编译消耗的时间都是原本可用于程序运行的时间, 消耗的运算资源都是原本可用于程序运行的资源. 如果是在程序运行之前进行静态编译, 则编译过程中某些耗时的优化措施可以放心大胆地进行了, 以获得更好的运行时性能. 副作用是: 安装或部署稍微大一点的应用都是按分钟来计时的.
把原本即时编译器在运行时要做的编译工作提前做好并保存下来, 下次运行到这些代码时直接把它加载进来使用.
本质是给即时编译器做缓存加速, 去改善Java程序的启动时间, 以及需要一段时间预热后才能到达最高性能的问题, 这种提前编译被称为动态提前编译(Dynamic AOT).
Jaotc提前编译器是一个基于Graal编译器实现的, 目的是让用户可以针对目标机器, 为应用程序进行提前编译的工具. HotSpot运行时可直接加载这些编译结果, 加快程序启动速度, 减少程序达到全速运行状态所需的时间.
但这种提前编译方式不仅要和目标机器相关, 甚至还必须与HotSpot虚拟机的运行时参数绑定, 还会破坏平台中立性, 字节膨胀等缺点.
即时编译器相对于提前编译器的天然优势:
JDK 9之后才能运行, 使用打印出Hello,World的简单程序演示过程:
# 编译程序为字节码
javac HelloWorld.java
java HelloWorld
# 输出HelloWorld的Class文件编译好的静态链接库
jaotc --output libHelloWorld.so HelloWorld.class
# 使用Linux的ldd命令来确认是否是一个静态链接库
ldd libHelloWorld.so
# 使用nm命令来确认其中是否包含了HelloWorld的构造函数和main()方法的入口信息:
nm libHelloWorld.so
# 使用静态链接库来输出HelloWorld
java -XX:AOTLibrary=./libHelloWorld.so HelloWorld
# 带java虚拟机启动参数的,使用-J参数传递与目标虚拟机相关的运行时参数
jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g --output libjava.base-coop.so --module java.base
使用Java语言的语法来表示优化技术所发挥的作用:
// 优化前的原始代码
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
// 首先进行方法内联 Method Inlining
// 方法内联主要目的:一是除去方法调用成本,二是为其他优化建立良好的基础
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
// 下一步进行冗余访问消除(Redundant Loads Elimination)
// 假设中间代码不会改变b.value的值,这样可以把b.value替换为 z=y
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
// 第三步进行复写传播(Copy Propagation)
// 因为z与变量y完全相等,可使用y代替z
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
// 第三步进行无用代码消除(Dead Code Elimination)
// 代码中 y = y是没有意义的
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
消除方法调用的成本之外,更重要的意义是为其他优化手段建立良好的基础。方法内联的优化行为可理解为: 把目标方法的代码原封不动地复制到发起调用的方法之中, 避免发生真实的方法调用.
大多数的Java方法无法内联,因为Java语言默认的实例方法是虚方法. 对于一个虚方法, 编译器静态地去做内联的时候很难确定应该使用哪个方法版本。
为了解决虚方法的内联问题,引入了一种名为:类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类, 子类是否是否覆盖了父类的某个虚方法等信息。
编译器进行内联时,如果是非虚方法,直接进行内联。如果遇到虚方法,会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择.
如果查询结果只有一个版本,就可以假设"应用程序的全貌就是现在运行的这个样子"来进行内联, 这种内联被称为守护内联, 不过这种内联属于激进预测性优化,必须预留逃生门。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码可一直使用下去,但是如果加载了导致继承关系发生变化的新类,则必须抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
如果向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力, 使用内联缓存方式来缩减方法调用的开销.
内联缓存是一个建立在目标方法正常入口之前的缓存, 它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存. 通过该缓存来调用比用不内联的非虚方法调用仅多了一次类型判断的开销. 如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时会退化成超多态内联缓存,其开销相当于真正查找虚方法表进行方法分派。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用。如果作为调用参数传递到其他方法,这种行为称为方法逃逸。如果被外部线程访问到(如赋值给可在其他线程中访问的实例变量),这种行为称为线程逃逸。
如果证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程), 则可能为这个对象实例采取不同程度的优化:
直到JDK 7式, 逃逸分析优化才称为服务端编译器默认开启的选项, 可使用参数-XX:+DoEscapeAnalysis开启逃逸分析, 开启后可通过参数-XX:+EliminateAllocations来开启标量替换, 使用参数-XX:+EliminateLocks开启同步消除, 使用参数-XX:+PrintEliminateAllocations查看标量得替换情况.
含义是:如果一个表达式E已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。如果这种优化仅限于程序的基本块内,称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就被称为全局公共子表达式消除。
int d = (c * b) * 12 + a + (a + b * c);
// c * b 与 b * c是一样的表达式,简化为
int d = E * 12 + a + (a + E);
// 根据具体的上下文,进行代数化简,变为
int d = E * 13 + a * 2;
数组边界检查不是必须在运行期间一次不漏地进行,例如:数组下标是个常量,只要在编译期根据数据流分析确定数组长度的值,并判断下标没有越界,执行的时候就无须判断。数组访问发生在循环之中,并使用循环变量来进行数组的访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,数组长度)之内,那在循环中可把整个数组的上下界检查消除掉。
为了消除Java语言的安全检查判断的隐式开销, 使用隐式异常处理, 这样对字段空指针检查(算术运算除数为0)不会有任何额外的判空的开销; 而代价是当字段真的为空时, 必须跳转到异常处理器中恢复中断并抛出空指针异常, 速度远比一次判空检查要慢得多, 当字段极少为空时, 隐式一次优化是值得的.
Java虚拟机编译器接口(JVMCI)主要提供如下三种功能:
综合利用上述三项功能, 可以把一个在HotSpot虚拟机外部的, 用Java语言实现的即时编译器集成到HotSpot中, 响应HotSpot发出的最顶层的编译请求, 并将编译后的二进制代码部署到HotSpot的代码缓存中.
单独使用第三项功能, 又可以绕开HotSpot的即时编译系统, 让该编译器直接为应用的类库编译出二进制机器码, 将该编译器当作一个提前编译器去使用.
详细的Graal编译器源码分析过程可查看原书.
每秒事务处理数(Transactions Per Second,TPS):一秒内服务端平均能响应的请求总数。
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲, 将运算需要使用的数据复制到缓存中, 让运算能快速进行, 当运算结束后再从缓存同步回内存之中.
基于高速缓存的存储交互很好解决了处理器与内存速度之间的矛盾, 但引入了缓存一致性的问题,在多路处理器系统中,每个处理器都有自己的高速缓存,同时共享同一主内存,这种系统称为共享内存多核系统. 为解决缓存一致性的问题,需要各个处理器在访问缓存时遵循一些协议, 在读写时需要根据协议来进行操作。
为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致.
注意Java内存模型和Java虚拟机的内存规范完全不一样, 不能混肴.
Java内存模型的主要目的是定义程序中各种变量的访问规则,即在虚拟机中将变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量不包括局部变量与方法参数, 因为是线程私有的, 不会存在竞争问题.
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
操作 | 作用域 | 描述 |
---|---|---|
lock(锁定) | 主内存的变量 | 把一个变量标识为一条线程独占的状态 |
unlock(解锁) | 主内存的变量 | 把处于锁定状态的变量释放出来,这样才可被其他线程锁定 |
read(读取) | 主内存的变量 | 把一个变量的值从主内存传输到线程的工作内存中,以便随后的load |
load(载入) | 工作内存的变量 | 把read操作从主内存中得到的变量值放入工作内存的变量副本中 |
use(使用) | 工作内存的变量 | 把工作内存中的变量值传递给执行引擎 |
assign(赋值) | 工作内存的变量 | 把从执行引擎接收到的值赋值给工作内存的变量 |
store(存储) | 工作内存的变量 | 把工作内存中的变量值传送到主内存中,以便随后的write操作 |
write(写入) | 主内存的变量 | 把store操作从工作内存中得到的变量的值放入主内存的变量中 |
执行上述8种基本操作的规则:
当一个变量被定义成volatile之后,将具备两种特性:第一是保证此变量对所有线程的可见性,可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的,而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
volatile变量在各个线程的工作内存中不存在一致性问题,但Java里面的运算操作符并非原子操作,导致volatile变量的运算在并发下一样是不安全的。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁来保证原子性:
volatile boolean shutdown;
public void shutdown() {
shutdown = true;
}
public void doWork() {
while (!shutdown) {
// do stuff
}
}
使用volatile变量的第二个语义是禁止指令重排序优化, 普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的顺序一致, 因为在同一线程的方法执行过程中无法感知到这一点, 这是Java内存模型中描述的线程内表现为串行的语义.
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后,将initialized置为true来通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, confitOptions);
initialized = true;
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经被配置信息初始化完成
while (!initialized) {
sleep();
}
doSomethingWithConfig();
如果定义initialized的变量没有使用volatile修饰,可能会由于指令重排序的优化,导致位于线程A中的最后一句代码 initialized = true;
被提前执行,这样线程B中使用配置信息的代码就可能会出现错误。
对于有volatile修饰的变量, 赋值后多执行了一次将本处理器的缓存写入内存的操作, 这个操作的作用相当于一个内存屏障(Memory Barrier, 指重排序时不能把后面的指令重排序到内存屏障之前的位置). 只有一个处理器访问内存时, 不需要内存屏障, 但如果有两个或更多处理器访问同一块内存, 且其中一个在观测另一个, 就需要内存屏障保证一致性.
volatile变量读操作的性能消耗与普通变量几乎没有什么差别, 但写操作则可能会慢一些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行, 大多数场景下, volatile的总开销要比锁低.
原子性(Atomicity):直接保证的原子性变量操作包括read,load,assign,use,store,write;更大范围的原子性保证的字节码指令:monitorenter和monitorexit(Java代码中对应synchronized关键字, 虚拟机底层对应lock和unlock操作)。
可见性(Visibility):指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。除了volatile关键字,synchronized和final也能实现可见性。同步块的可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store, write操作)的规则确保的; 而final的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。
有序性(Ordering):如果在本线程内观察,所有的操作都是有序的(线程内表现为串行的语义),如果在一个线程中观察另一个线程,所有的操作都是无序的(指令重排序现象和工作内存与主内存同步延迟现象)。volatile和synchronized关键字来保证线程之间操作的有序性,volatile关键字本身包含禁止指令重排序的语义,而synchronized则是由一个变量在同一时刻只允许一条线程对其进行lock操作的规则保证的。
先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,操作A的产生的影响(包括修改内存中共享变量的值,发送了消息,调用了方法等)内被操作B观察到。
Java内存模型天然的先行发生关系,无需任何同步器协助就已经存在,可在编码中直接使用:
如何判断操作是否线程安全? 可依次根据上述的原则进行分析, 如果没有一个满足, 则操作不是线程安全的.
一个操作时间上的先发生不代表这个操作会是先行发生, 同样如果一个操作先行发生, 不能推导出这个操作必定是时间上的先发生.
线程是比进程更轻量级的调度执行单位。线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。
实现线程的主要有三种方式:使用内核线程实现,使用用户线程实现,使用用户线程加轻量级进程混合实现。
使用内核线程实现,内核线程(Kernel Thread,KLT)是直接由操作系统内核支持的线程。这种线程由内核来完成线程切换, 内核通过操纵调度器对线程进行调度, 并负责将线程的任务映射到各个处理器上.
程序一般不会直接使用内核线程, 而是使用内核线程的一种高级接口:轻量级进程(Light Weight Process,LWP),每个轻量级进程都由一个内核线程支持,只有先支持内核线程,才能有轻量级进程,轻量级进程与内核进程之间1:1的关系。
优点:由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。
局限性:由于基于系统内核线程实现, 因此需要系统调用. 而系统调用的代价相对较高,需要在用户态和内核态来回切换;每个轻量级进程进行都需要有一个内核线程的支持, 因此要消耗一定的内核资源,一个系统支持轻量级进程的数量是有限的。
使用用户线程实现,广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程。狭义上的用户线程是指完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在以及如何实现的。用户线程的建立,同步,销毁和调度完全在用户态中完成,不需要内核的帮助。进程与用户线程1:N的关系。
优点:用户线程无需切换到内核态,操作可以非常快速且低消耗的,能够支持更大的线程数量。
劣势:由于没有系统内核的支持, 所有的线程操作都由用户程序自己处理,诸如阻塞处理,线程映射到其他处理器上等这类问题解决起来将会异常困难,使用用户线程实现的程序通常都比较复杂。
混合实现,将内核线程与用户线程一起使用,用户线程还是完全建立在用户空间中,并且可支持大规模的用户线程并发。轻量级进程则作为用户线程与内核线程之间的桥梁,可使用内核提供的线程调度功能及处理器映射,且用户线程的系统调用是通过轻量级进程来完成,大大降低整个进程被完全阻塞的风险。用户线程和轻量级进程是M:N的关系。
Java线程的实现,主流的商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现的.
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有:协同式和抢占式。
协同式:线程的执行时间由线程本身来控制,线程把自己的工作执行完后,要主动通知系统切换到另一个线程上去。好处:实现简单,一般无线程同步的问题。坏处:线程执行时间不可控,甚至如果一个线程的代码编写有问题, 一直不告知系统进行线程切换, 那么程序就会一直阻塞在那里。
抢占式:每个线程由系统来分配执行时间,线程的执行时间是系统可控的,不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。
Java线程优先级不是一项稳定的调节调度的手段,原因是Java的线程是被映射到系统的原生线程上来实现的,线程调度由操作系统控制。
Java语言定义了6种线程状态,在任意一个时间点,一个线程有且只有一种状态:
B/S系统服务细分的架构要求每一个服务都必须在极短的时间内完成计算, 也要求每一个服务提供者都要能同时处理数量更庞大的请求, 而Java目前的并发编程机制与之矛盾. 1:1内核线程模型有切换, 调度成本高昂, 系统能容纳的线程数量有限的缺陷, 在每个请求本身的执行时间变得很短, 数量变得很多的情况下, 用户线程切换的开销甚至可能会接近用于计算本身的开销, 这会造成严重的浪费.
内核线程的调度成本主要来自于用户态与核心态之间的状态转化, 而这两种状态转换的开销主要来自响应中断, 保护和恢复执行现场的成本.
协程的主要优势是轻量, 同时可并存的协程数量可达十万计.
协程的局限: 需要在应用层面实现的内容特别多, 大部分语言和框架会将协程会设计为协同式调度.
OpenJDK的Loom项目: 重新提供对用户线程的支持(纤程-Fiber), 会有两个并发编程模型在Java虚拟机中并存, 可在程序中同时使用.
在新并发模型下, 一段使用纤程并发的代码会被分为两部分: 执行过程和调度器. 执行过程主要用于维护执行线程, 保护, 恢复上下文状态; 而调度器则负责编排所有要执行的代码的顺序. 将调度程序与执行过程分离的好处是: 用户可以选择自行控制其中的一个或多个, 而且Java中现有的调度器也可被直接重用.
首先要保证并发的正确性,然后在此基础上来实现高效。
线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。定义要求线程安全的代码必须具备一个特征:代码本身封装了所有必要的正确性保障手段,令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。
将Java语言中各种操作共享的数据分为以下五类:不可变,绝对线程安全,相对线程安全,线程兼容,线程对立。
不可变:不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何的线程安全保障措施。
如果共享数据是一个基本数据类型,只要在定义时使用final关键字修饰就可保证是不可变的。
如果共享数据是一个对象,那需要对象自行保证其行为不会对其状态产生任何影响才行。保证对象行为不影响自己状态的途径有多种,最简单的是把对象中带有状态的变量都声明为final。
绝对线程安全:绝对的线程安全能够满足线程安全的定义,但一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出非常高昂的,不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。如java.util.Vector类
相对线程安全:是通常意义上所讲的线程安全,在Java语言中,大部分声称线程安全的类都属于这种类型。需要保证对这个对象单次的操作是线程安全的,在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,可能需要在调用端使用额外的同步手段来保证调用的正确性。
线程兼容:是指对象本身并不是线程安全的,但可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用。Java API中大部分的类都是线程兼容的。
线程对立:是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。Java语言中线程对立的代码是很少出现的,而且通常是有害的,应当尽量避免。
互斥同步:是最常见的一种并发正确性保障手段,同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用。互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。因此,互斥是因,同步是果,互斥是方法,同步是目的。
Java里,最基本的互斥同步手段是synchronized关键字。synchronized关键字经过Javac编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指明了对象参数,那就是这个对象的引用作为reference;如果没有明确指定,那就根据synchronized修饰的方法类型是实例方法还是类方法,去取对应对象实例或类型对应的Class对象来作为锁对象。
synchronized同步块对同一条线程来说是可重入的,同一线程反复进入同步块也不会出现自己把自己锁死的问题;同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。意味着无法强制已获取锁的线程释放锁, 也无法强制正在等待锁的线程中断等待或超时退出.
synchronized是Java语言中一个重量级的操作,因为Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。
除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock), 基于Lock接口, 用户能够以非块结果来实现互斥同步, 摆脱语言特性的束缚, 改为在类库层面实现同步。在基本用法上,ReentrantLock和synchronized类似,都一样是可重入的,只是代码写法上有区别, 增加了以下高级功能:
ReentrantLock在功能上是synchronized的超集, 在性能上至少不会弱于synchronized, 但推荐优先使用synchronized. 因synchronized更简单, 更熟悉, 且是虚拟机来确保异常时释放锁, 所以从长远来看Java虚拟机更容易针对synchronized来进行优化。
非阻塞同步:互斥同步最主要的问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步,属于一种悲观的并发策略。随着硬件指令集的发展,有了另外一个选择:基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享数据的确有争用,产生了冲突,那再进行其他的补偿措施, 最常用的补偿措施是不断地重试, 直到出现没有竞争的共享数据为止. 这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步。
乐观并发策略需要操作和冲突检测这两个步骤具备原子性,只能靠硬件完成。
硬件保证从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
1到3条是已经存在于大多数指令集之中的处理器指令,后两条是现代处理器新增的。
CAS指令需要有三个操作数,分别是内存位置(变量的内存地址,V),旧的预期值(A)和准备设置的新值(B)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器才会用新值B更新V的值,否则它不执行更新,但不管是否更新了V的值,都会返回V的旧值。
JDK 5之后,Java程序中才可使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。JDK 9之前只有Java类库可以使用CAS, JDK 9之后Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作.
// AtomicInteger的原子操作方法incrementAndGet()
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) {
return current;
}
}
}
CAS操作的ABA问题:CAS从语义上来说并不是真正完美的,存在一个逻辑漏洞,如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍为A值,如果这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。(可通过控制变量值的版本来保证CAS的正确性)
无同步方案:要保证线程安全,不一定要进行同步,同步与线程安全两者没有必然的联系,同步只是保障共享数据争用时的正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
可重入代码:也叫纯代码,可在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。也不会对结果有所影响.
可重入性是更为基础的特性,可以保证代码线程安全, 所有的可重入代码都是线程安全的,但并非所有的线程安全的代码都是可重入的。
可通过简单的原则来判断:如果一个方法的返回结果是可预测的,只要输入了相同的数据,就能返回相同的结果,那它满足可重入性的要求,当然也是线程安全的。
线程本地存储:如果一段代码中所需要的数据都必须与其他代码共享,那就看这些共享数据的代码是否能保证在同一个线程中执行,如果能保证,就可把共享数据的可见范围限制在同一个线程之内, 这样无须同步也能保证线程之间不出现数据争用的问题.
可通过java.lang.ThreadLocal实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象(存储以ThreadLocal.threadLocalHashCode为key,以本地线程变量为值的K-V值对),ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可在线程K-V值对中找回对应的本地线程变量。
锁优化技术是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序地执行效率。
在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程稍等一会儿,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋等待本身虽然避免了线程切换的开销,但是要占用处理器时间的,如果锁被占用的时间很短,自旋等待的效果会非常好,反之如果锁被占用的时间长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,反而带来性能的浪费,因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数默认值是10次。可使用参数-XX:PreBlockSpin来更改.
JDK 6引入自适应的自旋锁:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那虚拟机会认为这次自旋很有可能再次成功,进而允许自旋等待持续相对更长的时间。反之,如果对于某个锁,自旋很少成功获得过,那以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
锁消除是指虚拟机即时编译器在运行时, 检测到某段需要同步的代码根本不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略。锁消除的主要判定依据来源于逃逸分析的数据支持。
原则上,在编写代码的时候,推荐将同步块的作用范围限制的尽量小,只在共享数据的实际作用域中才进行同步,这样为了使得需要同步的操作数量尽可能变小,即使存在锁竞争,等待锁的线程也能尽快拿到锁。
大多数情况下,上面的原则是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级相对于使用操作系统互斥量来实现的传统锁而言的,不能用来代替重量级锁,设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
HotSpot虚拟机的对象头分为两部分信息:第一部分用于存储对象自身的运行时数据(Mark Word)是实现轻量级锁和偏向锁的关键,另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有额外的部分存储数组长度。
Mark Word会根据对象的状态复用自己的存储空间(根据状态改变对象头中的存储内容):
状态 | 锁标记位 | 存储的内容 |
---|---|---|
未锁定 | 01 | 对象哈希码(25bit),分代年龄(4bit), 偏向模式(1bit)为0 |
轻量级锁定 | 00 | 指向调用栈中锁记录的指针 |
重量级锁定(膨胀) | 10 | 指向重量级锁的指针 |
GC标记 | 11 | 空,不需要记录信息 |
可偏向 | 01 | 偏向线程id(25bit), Epoch(2bit), 分代年龄(4bit), 偏向模式(1bit)为1 |
轻量级锁的工作过程:
轻量级锁能提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁通过使用CAS操作避免了使用互斥量的开销; 如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作的开销,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
JDK 6引入的一项锁优化措施,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。轻量级锁在无竞争的情况下使用CAS操作去消除同步使用的互斥量,偏向锁是在无竞争情况下把整个同步都消除掉,无CAS操作。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取的时候,虚拟机会把对象头的锁标记位设置为01(可偏向状态),把偏向模式设置为1, 并使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
一旦出现另外一个线程去尝试获取这个锁时,偏向模式马上宣告结束,根据锁对象目前是否处于被锁定的状态决定撤销偏向(偏向模式设置为0), 撤销后锁标志位恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作如轻量级锁那样执行。
当一个对象已经计算过一致性哈希码后, 它就再也无法进入偏向锁状态; 而当一个对象当前正处于偏向锁状态, 又收到需要计算其一致性哈希码请求时, 它的偏向状态会被立即撤销, 并且锁膨胀为重量级锁. 重量级锁的实现中, 对象头指向了重量级锁的为止, 代表重量级锁的ObjectMonitor类里有字段可以将记录非加锁状态下的Mark Word, 其中存储着原来的哈希码.
偏向锁可提高带有同步但无竞争的程序性能。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式是多余的。可通过参数-XX:-UseBiasedLocking来禁止偏向锁优化.