专业名字解析
- JCP:Java Community Process(Java社区)
- JSR:Java Specification Requests(Java规范提案)
- JEP:JDK Enhancement Proposals(Oracle Java版本管理)
- JMM:Java Memory Model(Java内存模型)
- OSR:On-Stack Replacement(栈上替换)
- TCK:Technology Compatibility Kit(技术兼容性测试工具)
- AOT:Ahead of Time Compilation(提前编译)
- ART:Android Runtime(安卓运行时)
- IR:Intermediate Representation(中间表示)
- JVMPI:Java Virtual Machine Profiler Interface(Java虚拟机信息监控接口)
- JVMDI:Java Virtual Machine Debug Interface(Java虚拟机调试接口)
- JVMTI:Java Virtual Machine Tool Interface(Java虚拟机工具接口)
- JVMCI:Java Virtual Machine Compiler Interface(Java编译器接口)
- TLAB:Thread Local Allocation Buffer(线程私有分配缓冲区)
Java技术优势
- 一次编写,到处运行(在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性)
- 提供了高效的自动内存管理和相对安全的内存访问机制
- 实现了热点代码检测和运行时编译优化能力
- 严格向前兼容,有完善的技术生态和丰富的API接口(并发、IO)
Java技术体系
Java语言、Java虚拟机、Java类库API、Class文件格式、Java三方框架
JDK(Java Development Kit)
JDK是支持Java程序开发的最小环境,包括:Java语言、Java虚拟机、Java类库
JRE(Java Runtime Environment)
JRE是支持Java程序运行的标准环境,包括:Java类库API中的Java SE API子集和Java虚拟机
Java发展史
-
1995年5月23日,Java 1.0发布,第一次提出“Write Once,Run Anywhere”
-
1996年1月23日,JDK 1.0发布,提供了一个纯解释执行的Java虚拟机实现(Sun Classic VM)
-
1997年2月19日,JDK 1.1发布,新增内部类和反射等语法
-
1998年12月4日,JDK 1.2发布,第一次内置了JIT(Just In Time)即时编译器
-
2000年5月8日,JDK 1.3发布,改进主要体现在Java类库上(如数学运算和新的Timer API)
-
2002年2月13日,JDK 1.4发布,新增如正则表达式、异常链、NIO、日志类、XML解析器等
-
2004年9月30日,JDK 5发布,此版本在语法易用性上做了非常大的改进,如:自动装箱、泛型、动态注解、枚举、可变长参数、遍历循环等语法特性。在虚拟机和API层面上,这个版本改进了Java内存模型、提供了java.util.concurrent并发包等
-
2006年12月11日,JDK 6发布,这个版本对Java虚拟机内部做了大量改进,包括锁与同步、垃圾收集、类加载等方面的实现都有相当多的改动
-
2006年,Sun陆续将SunJDK的各个部分在GPLv2协议下开放了源码,形成了Open-JDK项目,其中也包括HotSpot虚拟机
-
2009年4月20日,Oracle宣布收购Sun Microsystems,Java商标正式划归Oracle所有,Java语言本身并不属于哪个公司所有,它由JCP组织进行管理
-
2011年7月28日,JDK 7发布,提供新的G1收集器、加强对非Java语言的调用支持(JSR-292,这项特性在到JDK 11还有改动)、可并行的类加载架构等
-
2014年3月18日,JDK 8发布,新增对Lambda表达式的支持、彻底移除HotSpot的永久代
-
2017年9月21日,JDK 9发布,主要新增了Jigsaw模块化支持
-
2018年3月20日,JDK 10发布,这版本的主要研发目标是内部重构,诸如统一源仓库、统一垃圾收集器接口、统一即时编译器接口等
-
2018年3月27日,Android在Java侵权案中败诉(Google采用Java的语法和API类库,开发出来的程序却不能运行在其他Java虚拟机之上),和微软侵权案类似,J++借用语法和API,但程序不兼容标准Java虚拟机
-
2018年9月25日,JDK 11发布,新增ZGC垃圾收集器
-
2019年3月20日,JDK 12发布,加入了由RedHat领导开发的Shenandoah垃圾收集器,Oracle马上用实际行动抵制了这个新收集器,在OracleJDK 12里把Shenandoah的代码通过条件编译强行剔除掉,使其成为历史上唯一进入了OpenJDK发布清单,但在OracleJDK中无法使用的功能
Java虚拟机发展史
-
Classic VM:基于句柄的内存管理,JDK 1.0中所带的虚拟机,这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。直到JDK 1.4的时候,Classic VM才完全退出商用
-
HotSpot VM:基于准确式的内存管理,在JDK 1.2时,它与Classic VM并存,但默认是使用Classic VM,而在JDK 1.3时,HotSpot VM成为默认虚拟机
-
BEA JRockit / IBM J9 VM:J9最初是为嵌入式领域设计的,后来逐渐扩展为IBM所有平台共用的虚拟机,嵌入式、桌面、服务器端都用它,而HotSpot在嵌入式领域使用的是CDC/CLDC以及Java SE Embedded,这也从侧面体现了J9的模块化和通用性做得非常好
-
Apache Harmony:Apache软件基金会旗下以Apache License协议开源的实际兼容于JDK 5和 JDK 6的Java程序运行平台,它含有自己的虚拟机和Java类库API。但是它并没有通过TCK认证,所以我们不能用一句“Apache的JDK”或者“Apache的Java虚拟机”来直接介绍它
-
Dalvik:Dalvik并不是一个Java虚拟机,它没有遵循《Java虚拟机规范》,不能直接执行Java的Class文件,使用寄存器架构而不是Java虚拟机中常见的栈架构。但是它与Java却又有着千丝万缕的联系,它执行的DEX文件可以通过Class文件转化而来,使用Java语法编写应用程序,可以直接使用绝大部分的Java API。不过到了Android 4.4时代,支持AOT的ART虚拟机迅速崛起,在当时性能还不算特别强大的移动设备上,提前编译要比即时编译更容易获得高性能,所以在Android 5.0里ART就全面代替了Dalvik虚拟机
Java技术展望
无语言倾向:2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言。Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的IR,譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程序特化。从严格的角度来看,Graal VM才是真正意义上与物理计算机相对应的高级语言虚拟机,理由是它与物理硬件的指令集一样,做到了只与机器特性相关而不与某种高级语言特性相关
新一代即时编译器:HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2),通常它们会在分层编译机制下与解释器互相配合来共同构成HotSpot虚拟机的执行子系统。自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器,Graal编译器是以C2编译器替代者的身份登场的,Graal编译器本身就是由Java语言写成,在保持输出相近质量的编译代码的同时,开发效率和扩展性上都要显著优于C2编译器
向Native迈进:从大型单体应用架构向小型微服务应用架构发展的技术潮流下,单个微服务很可能就不再需要面对数十、数百GB乃至TB的内存,有了高可用的服务集群,也无须追求单个服务要7×24小时不间断地运行,它们随时可以中断和更新;但相应地,Java的启动时间相对较长,需要预热才能达到最高性能等特点就显得相悖于这样的应用场景。一直把软件服务作为重点领域的Java自然不可能对此视而不见,在最新的几个JDK版本的功能清单中,已经陆续推出了跨进程的、可以面向用户程序的类型信息共享(Application Class Data Sharing,AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度,原本CDS只支持Java标准库,在JDK 10时的AppCDS开始支持用户的程序代码)、无操作的垃圾收集器(只做内存分配而不做回收的收集器,对于运行完就退出的应用十分合适)等改善措施。而酝酿中的一个更彻底的解决方案,是逐步开始对AOT提供支持。但是提前编译的坏处也很明显,它破坏了Java“一次编写,到处运行”的承诺,必须为每个不同的硬件、操作系统去编译对应的发行包,也显著降低了Java链接过程的动态性,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定。直到Substrate VM出现,才算是满足了人们心中对Java提前编译的全部期待。Substrate VM是在 Graal VM 0.20版本里新出现的一个极小型的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理和JNI访问等组件,目标是代替HotSpot用来支持提前编译后的程序执行。它还包含了一个本地镜像的构造器(Native Image Generator),用于为用户程序建立基于Substrate VM的本地运行时镜像。这个构造器采用指针分析(Points-To Analysis)技术,从用户提供的程序入口出发,搜索所有可达的代码。在搜索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,Substrate VM就可以直接从目标程序开始运行,而无须重复进行Java虚拟机的初始化过程。Substrate VM补全了Graal VM“Run Programs Faster Anywhere”愿景蓝图里的最后一块拼图,让Graal VM支持其他语言时不会有重量级的运行负担。
灵活的胖子:早期(JDK 1.4及之前)的HotSpot虚拟机为了提供监控、调试等不会在《Java虚拟机规范》中约定的内部功能和数据,就曾开放过JVMPI与JVMDI供运维和性能监控、IDE等外部工具使用。到了JDK 5时期,又抽象出了层次更高的JVMTI来为所有Java虚拟机相关的工具提供本地编程接口集合,到JDK 6时JVMTI就完全整合代替了JVMPI和JVMDI的作用。在JDK 9时期,HotSpot虚拟机开放了JVMCI,使得在Java虚拟机外部增加、替换即时编译器成为可能,这个改进实现起来并不费劲,但比起之前JVMPI、JVMDI和JVMTI却是更深层次的开放,它为不侵入HotSpot代码而增加或修改HotSpot虚拟机的固有功能逻辑提供了可行性。Graal编译器就是通过这个接口植入到HotSpot之中。到了JDK 10,HotSpot又重构了Java虚拟机的垃圾收集器接口
运行时数据区域
方法区、堆、虚拟机栈、本地方法栈、程序计数器
- 程序计数器:在Java内存模型中,程序计数器用于标注栈顶方法的字节码指令行号
- 栈帧:局部变量表、操作数栈、动态连接(指向运行时常量池中该栈帧所属方法的引用)、方法出口
- 动态连接:指向运行时常量池中该方法的引用,通过动态连接和程序计数器可以决定唯一的字节码指令地址
- 返回地址:方法正常退出时,调用者的程序计数器的值可以作为返回地址。而方法异常退出时,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息
- Java堆:线程共享的内存区域,垃圾回收的主阵地,几乎所有的对象实例都在这里分配内存
- 方法区:线程共享的内存区域,用于存储已被虚拟机加载的类型信息、运行时常量池、静态变量、代码缓存等数据。在JDK 7中,已经把原本放在永久代的字符串常量池、静态变量信息移到了堆中存储,而到了JDK 8,完全废弃了永久代的概念,把永久代还剩余的类型信息、代码缓存等内容全部移到了用本地内存实现的元空间中,具体参见JEP 122: Remove the Permanent Generation
- 运行时常量池:运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
- 直接内存:在JDK 1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据
- ByteBuffer.allocate、ByteBuffer.allocateDirect
对象的创建过程
- 当虚拟机遇到new字节码指令时,首先检查字节码中符号引用对应的类是否执行过加载流程,如果没有则首先执行类的加载:加载、验证、准备、解析。对应new指令,还会继续执行初始化动作
- 类加载通过后,虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定。根据垃圾收集算法的不同,内存分配方式可以分为指针碰撞和空闲列表两种,另外,在分配内存时,虚拟机通过CAS+TLAB来解决内存地址冲突问题。内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行
- 接下来,Java虚拟机还要对对象头进行必要的设置,例如类型信息、哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等
- 执行init构造方法
- 构造函数初始化顺序:父类普通成员和非静态块、父类构造函数、子类普通成员和非静态块、子类构造函数
-
class Parent { static { Log.e("offer", "" + "Parent"); } public static int value = 123; } class Child extends Parent { static { Log.e("offer", "" + "Child"); } public static int c_value = 123; } public static void main() { Log.e("offer", "" + Child.c_value); //05-10 03:41:08.957 4250-4250/com.lede.train.commondemo E/offer: Parent //05-10 03:41:08.957 4250-4250/com.lede.train.commondemo E/offer: Child //05-10 03:41:08.957 4250-4250/com.lede.train.commondemo E/offer: 123 Log.e("offer", "" + Child.value); //05-10 03:41:08.957 4250-4250/com.lede.train.commondemo E/offer: Parent //05-10 03:41:08.957 4250-4250/com.lede.train.commondemo E/offer: 123 } class Parent{ public static int value = 1; static { value = 2; } } class Child extends Parent{ public static int B = value ; } public static void main() { Log.e("offer", "" + Child.B); //2 }
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(MarkWord(32位系统占用4字节,64位系统占用8字节)、类型指针(32位系统占用4字节,64位系统未开启指针压缩占用8字节,64位系统开启指针压缩占用4字节)、数组长度(占用4字节))、实例数据和对齐填充(HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全)
// https://www.cnblogs.com/rickiyang/p/14206724.html
public class ObjectSizeTest {
int i;
int j;
int k;
public static void main(String[] args) {
ObjectSizeTest objectSizeTest = new ObjectSizeTest();
System.out.println(ClassLayout.parseInstance(objectSizeTest).toPrintable());
synchronized (objectSizeTest) {
System.out.println(ClassLayout.parseInstance(objectSizeTest).toPrintable());
}
ObjectSizeTest[] objectSizeTests = new ObjectSizeTest[5];
System.out.println(ClassLayout.parseInstance(objectSizeTests).toPrintable());
}
}
// OFFSET SIZE TYPE DESCRIPTION VALUE
// 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
// 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
// 8 4 (object header) 40 00 06 00 (01000000 00000000 00000110 00000000) (393280)
// 12 4 int ObjectSizeTest.i 0
// 16 4 int ObjectSizeTest.j 0
// 20 4 int ObjectSizeTest.k 0
// Instance size: 24 bytes
// Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
//
// com.example.lib.ObjectSizeTest object internals:
// OFFSET SIZE TYPE DESCRIPTION VALUE
// 0 4 (object header) 05 98 00 dd (00000101 10011000 00000000 11011101) (-587163643)
// 4 4 (object header) bb 7f 00 00 (10111011 01111111 00000000 00000000) (32699)
// 8 4 (object header) 40 00 06 00 (01000000 00000000 00000110 00000000) (393280)
// 12 4 int ObjectSizeTest.i 0
// 16 4 int ObjectSizeTest.j 0
// 20 4 int ObjectSizeTest.k 0
// Instance size: 24 bytes
// Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
//
// [Lcom.example.lib.ObjectSizeTest; object internals:
// OFFSET SIZE TYPE DESCRIPTION VALUE
// 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
// 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
// 8 4 (object header) 78 5d 0d 00 (01111000 01011101 00001101 00000000) (875896)
// 12 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
// 16 20 com.example.lib.ObjectSizeTest ObjectSizeTest;.<elements> N/A
// 36 4 (loss due to the next object alignment)
// Instance size: 40 bytes
// Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
指针压缩:因为我们的对齐方式,低三位是没有用处的,那我们可以利用这三位来实现指针压缩,4G->32G
对象的访问定位
句柄访问、直接指针访问
哪些对象需要回收
引用计数法、可达性分析法(涉及到GC Roots、引用类型、二次筛选)
GC Roots(全局引用、执行上下文)
- 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中类静态属性引用的对象(JDK7之后存储在堆中)
- 在本地方法栈中JNI引用的对象
- 分代收集和局部回收临时加入的对象(Remember Set记忆集&卡表&写屏障)
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
引用类型
强引用、软引用、弱引用(WeakHashMap、ThreadLocal)、虚引用
ThreadLocalMap通过get方法来判断Entry中的Key是否被回收,而WeakHashMap通过ReferenceQueue的poll方法来判断Entry是否被回收
二次筛选(是否有必要执行Finalize方法)
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。相反,该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法
回收方法区(字符串常量池、类类型)
- 字符串常量可以被回收:JVM 字符串常量池中的垃圾回收
- [info][gc,stringtable] GC(24) Cleaned string and symbol table, strings: 207393 processed, 165162 removed, symbols: 16089 processed, 0 removed
- 字符串常量为什么能被回收(Young GC时,StringTable做为GC Root,被缓存对象继续存活,Full GC时,StringTable不作为GC Root,缓存对象可以被回收)jdk8中,字符串常量池位于堆内存哪个区域,有独立空间吗?
- 方法区的回收策略本来就和堆区的回收策略不同,StringTable相关逻辑和ldc指令完全由JVM内部处理
判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
根节点枚举
所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,号称几乎不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。在HotSpot虚拟机中,通过OopMap来快速定位根节点引用(核心思想是运行时记录,而非从头遍历)
并发的可达性分析
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用 >> 对应增量更新方案(CMS)
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用 >> 对应原始快照方案(G1、Shenandoah)
- 无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的
垃圾收集名词解析
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
- 新生代收集(Minor GC / Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC / Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
如何回收
垃圾收集算法
- 分代收集算法(大部分对象都是朝生夕灭的)
- 标记清除算法(内存碎片化问题)
- 标记整理算法(从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,但是从整个程序的吞吐量来看,移动对象会更划算。关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的。其中,CMS多数时间都采用标记清除算法,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记整理算法收集一次,以获得规整的内存空间)
- 复制算法(Eden、Survivor1、Survivor2、空间担保)
什么时候回收
安全点
只是在“特定的位置”记录了OopMap信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方
test指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了
安全区域
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行,否则它就必须一直等待,直到收到可以离开安全区域的信号为止
经典垃圾收集器
新生代收集器
Serial:Serial收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束
ParNew:ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致
Parallel Scavenge:Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性
老年代收集器
Serial Old:Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法,主要作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用
CMS:初始标记、并发标记、重新标记、并发清除。存在资源敏感、浮动垃圾、并发失败问题
Parallel Old:Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记整理算法实现
全堆收集器
G1:G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集
初始标记、并发标记、最终标记、筛选回收,其中只有并发标记阶段是并发的,筛选回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案
G1 VS CMS
相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记清除”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,更有利于程序长时间运行
就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间
就执行负载来说,它们都使用到写屏障,CMS用写后屏障来维护卡表以及增量更新信息,而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担
经典组合
Serial & Serial Old、ParNew & CMS(Serial Old兜底)、Parallel Scavenge & Parallel Old、G1
低延迟垃圾收集器
CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的
Shenandoah 和 ZGC ,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标
Shenandoah并发整理算法(转发指针 - Brooks Pointer)
在Shenandoah之前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但这种方案将导致用户态频繁切换到核心态,代价非常大
Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,从结构上来看,Brooks提出的转发指针与早期Java虚拟机使用过的句柄定位有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面
转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截
ZGC并发整理算法(染色指针 & 转发表)
追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对 象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关—某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够 影响它的存活判定结果
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样使用了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的解题思路。重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self- Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢, 因此ZGC对用户程序的运行时负载要比Shenandoah来的更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的
虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针等诸多约束,但它带来的收益也是非常可观的
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量
内存分配与回收策略
- 对象优先分配在Eden区
- 大对象直接进入老年代
- 长期存活的对象进入老年代
- 动态对象年龄判断
- 空间分配担保(在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会判断是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的,如果小于,或者虚拟机设置为不允许冒险,那这时就要改为进行一次Full GC)
Class文件结构
可以使用javap -verbose来查看类信息
magic、minor_version、major_version
constant_pool_count、constant_pool(常量池计数从1开始,常量池主要用于存储字面量和符号引用)
由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度,而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译
JDK 7时增加了三种:CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info。出于性能和易用性的考虑,在JDK 11中又增加了第四种常量CONSTANT_Dynamic_info
access_flags、this_class、super_class、interfaces_count、interfaces、fields_count、fields、methods_count、methods、attributes_count、attributes
如果将字段m的声明改为“final static int m=123;”,那就可能会存在一项名称为ConstantValue的属性,其值指向常量123,该字段将在准备阶段直接赋值为123。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的
与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器clinit方法和实例构造器init方法
Code属性
异常表实际上是Java代码的一部分,尽管字节码中有最初为处理异常而设计的跳转指令,但《Java 虚拟机规范》中明确要求Java语言的编译器应当选择使用异常表而不是通过跳转指令来实现Java异常及finally处理机制
Exceptions属性
Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常
ConstantValue属性
Java虚拟机规范中并没有强制要求字段必须设置ACC_FINAL标志,只要求有ConstantValue属性的字段必须设置ACC_STATIC标志而已,对final关键字的要求是Javac编译器自己加入的限制。而对ConstantValue的属性值只能限于基本类型和String,因为此属性值只是一个常量池的索引号,由于Class文件格式的常量类型中只有与基本属性和字符串相对应的字面量,所以就算ConstantValue属性想支持别的类型也无能为力
InnerClasses属性、Deprecated属性、Synthetic属性
StackMapTable属性:在虚拟机类加载的字节码验证阶段被新类型检查验证器(TypeChecker)使用
Signature属性
如果当前的Signature属性是类文件的属性,则这个结构表示类签名,如果当前的Signature属性是方法表的属性,则这个结构表示方法类型签名,如果当前Signature属性是字段表的属性,则这个结构表示字段类型签名
Java泛型擦除只是把字节码中的泛型信息擦出了,元数据中的泛型信息通过Signature来保存
BootstrapMethods属性
根据《Java虚拟机规范》的规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性,另外,即使CONSTANT_InvokeDynamic_info类型的常量在常量池中出现过多次,类文件的属性表中最多也只能有一个BootstrapMethods属性。BootstrapMethods属性和JSR-292中的InvokeDynamic指令和java.lang.Invoke包关系非常密切,要介绍这个属性的作用,必须先讲清楚 InovkeDynamic指令的运作原理
bootstrap_method的每个成员必须包含以下三项内容:
- bootstrap_method_ref的值必须是一个对常量池的有效索引。常量池在该索引处的值必须是一个CONSTANT_MethodHandle_info结构
- num_bootstrap_arguments项的值给出了bootstrap_arguments数组成员的数量
- bootstrap_arguments[]数组的每个成员必须是一个对常量池的有效索引。常量池在该索引出必须是下列结构之一:CONSTANT_String_info、CONSTANT_Class_info、 CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_MethodHandle_info或CONSTANT_MethodType_info
MethodParameters属性
LocalVariableTable属性是Code属性的子属性,没有方法体存在,自然就不会有局部变量表,但是对于其他情况,譬如抽象方法和接口方法,是理所当然地可以不存在方法体的,对于方法签名来说,还是没有找到一个统一完整的保留方法参数名称的地方。所以JDK 8中新增的这个属性,使得编译器可以 (编译时加上-parameters参数)将方法名称也写进Class文件中,而且MethodParameters是方法表的属性,与Code属性平级的,可以运行时通过反射API获取
RuntimeVisibleAnnotations注解
RuntimeVisibleAnnotations是一个变长属性,它记录了类、字段或方法的声明上记录运行时可见注解,当我们使用反射API来获取类、字段或方法上的注解时,返回值就是通过这个属性来取到的
字节码指令(方法调用)
- invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式
- invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
- invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
- invokestatic指令:用于调用类静态方法
- invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
invokevirtual vs invokeinterface:invokevirtual对应的方法在虚方法表中的位置是固定的,而invokeinterface不一定,所以两者的执行策略是不一样的
字节码指令(同步指令)
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持
虚拟机类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的
类的生命周期
加载、连接(验证、准备、解析)、初始化、使用、卸载
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”,而加载、验证、准备自然需要在此之前开始:
- 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时
- 使用java.lang.reflect包的方法对类型进行反射调用时
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK 7新加入的动态语言支持时,如果java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
Java语言中对数组的访问要比C/C++相对安全,很大程度上就是因为newarray指令生成的数组类包装了数组元素的访问,而 C/C++中则是直接翻译为对数组指针的移动
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类创建过程遵循以下规则:
- 如果数组的组件类型(指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上
- 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组标记为与引导类加载器关联
- 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到
验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
- 文件格式验证
- 元数据验证
- 字节码验证(StackMapTable)
- 符号引用验证:该校验行为将在“解析阶段”中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,如果无法通过符号引用验证,Java虚拟机将会抛出java.lang.IncompatibleClassChangeError的子类异常 ,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError
准备
准备阶段是正式为类变量(即静态变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了
通常情况下类变量被初始化为零值,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化位ConstantValue属性所指定的初始值
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容
- 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、new、putfield、putstatic等用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info八种常量类型
类或接口的解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
- 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败
- 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象
- 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限, 将抛出java.lang.IllegalAccessError异常
字段解析:要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
- 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
- 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
- 否则,查找失败,抛出java.lang.NoSuchFieldError异常
以上解析规则能够确保Java虚拟机获得字段唯一的解析结果,但在实际情况中,Javac编译器往往会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但Javac编译器就可能直接拒绝其编译为Class文件
方法解析(接口方法类似):方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:
- 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常
- 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
- 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
- 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError异常
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常
初始化
初始化阶段就是执行类构造器<clinit>方法的过程。<clinit>并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物
<clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
<clinit>方法与类的构造函数不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>方法执行前,父类的<clinit>方法已经执行完毕
<clinit>方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>方法。但接口与类不同的是,执行接口的<clinit>方法不需要先执行父接口的<clinit>方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>方法
class InnerInt {
public int i;
InnerInt() {
System.out.println("InnerInt init");
i = 2;
}
}
interface InterfaceCInit {
InnerInt innerInt = new InnerInt();
}
public class InterfaceCInitTest implements InterfaceCInit {
public static void main(String[] args) {
System.out.println("main");
// System.out.println(innerInt.i);
}
}
Java虚拟机必须保证一个类的<clinit>方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待
类加载器
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
双亲委派优点
- 避免重复加载 + 避免核心类篡改:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换
- 模块化开发+类型回收:在实际应用中,我们经常需要在一个程序中使用多个第三方库,这些库可能会存在同名类。如果使用了双亲委派机制,就可以保证不同的类加载器只会加载自己的类,从而避免了类名冲突的问题
双亲委派模型:Classloader在加载类时,首先查找自己的缓存,如果没命中,则优先请求父Classloader加载,如果父Classloader加载失败,则该Classloader再尝试加载
BootstrapClassloader、ExtensionClassloader、ApplicationClassLoader
loadClass:findLoadedClass、parent.loadClass、findClass
虚拟机字节码执行引擎
在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java虚拟机执行引擎的统一外观。在不同的虚拟机实现中,执行引擎在执行字节码时,通常会有解释执行和编译执行两种,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
栈帧
栈帧包括局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。 在编译Java程序源码的时候,局部变量表的大小和操作数栈的深度就已经被分析计算出来了,并且写入到方法表的Code属性之中
局部变量表:用于存放方法参数和方法内部定义的局部变量
- Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽
- 当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法,那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用
- 为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的
操作数栈:Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。操作数栈的最大深度在编译期被写到Code属性的max_stacks数据项中
动态连接:指向运行时常量池中该栈帧所属方法的引用,用于支持方法执行过程中定位字节码
方法返回地址:方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常表来确定的,栈帧中就一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。这里写的“可能”是由于这是基于概念模型的讨论,只有具体到某一款Java虚拟机实现,会执行哪些操作才能确定下来
方法调用
解析调用:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法,对应虚拟机指令为invokestatic和invokespecial
多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,那自然我们得出的结论就只会对方法有效,对字段是无效的,因为字段不使用这条指令
方法的接收者与方法的参数统称为方法的宗量
分派调用:静态多分派、动态单分派,对应虚拟机指令为invokevirtual、invokeinterface
虚方法表:避免动态类型判断,以空间换时间
动态类型语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,JDK 7以前的字节码指令集中,方法调用指令的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info),方法的符号引用在编译时产生,但动态类型语言只有在运行期才能确定方法的接收者。这样,在Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销
JDK 7时新加入的java.lang.invoke包是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)
举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:void sort(int list[], const int size, int (*compare)(int, int))
但在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sort()方法就是这样定义的:void sort(List list, Comparator c)
不过,在拥有方法句柄之后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具了
private static MethodHandle getPrintlnMH(Object receiver) {
// MethodType:代表“方法类型”,包含
// 了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找
// 符合给定的方法名称、方法类型,并且符合调用权限的方法句柄,因为这里调用的是一个虚方法,
// 按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,
// 这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情
return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
void sort(List list, MethodHandle compare)
仅站在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有众多相似之处。不过,它们也有以下这些区别:
- Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual|invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的
- Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级
invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现,另一个用字节码和Class中其他属性、常量来完成
如何直接访问祖父类?
void thinking(){
try {
MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup)
lookupImpl.get(null)).findSpecial(GrandFather.class,"thinking mh.invoke(this);
} catch (Throwable e) {
}
}
基于栈的字节码解释执行引擎
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现
栈结构VS寄存器结构
- 移植性好,不需要考虑硬件层面的寄存器架构
- 编译器实现简单,不需要考虑空间分配的问题,所需空间都在栈上操作
- 指令数量较多,涉及入栈出栈操作
- 无法直接在真机上运行,需要转译
前端编译及优化
Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高
- 前端编译器:Javac
- 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器
- 提前编译器:Jaotc
Javac编译过程
- 准备过程:初始化插入式注解处理器
- 解析过程:词法分析、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树
- 填充符号表:产生符号地址和符号信息
- 注解处理过程:处理插入式注解的过程中可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号
- 语义分析与字节码生成过程:语义分析、解语法糖(泛型、自动拆装箱)、字节码生成
后端编译与优化
解释器与两级及时编译器共存。解释器可以提高程序响应速度,可以作为激进优化的逃生门,及时编译器可以大幅提高程序运行速度
编译层次划分
- 第0层:纯解释执行,并且解释器不开启性能监控功能
- 第1层:使用C1编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能
- 第2层:使用C1编译器将字节码编译为本地代码来运行,开启方法及回边次数统计等有限的性能监控功能
- 第3层:使用C1编译器将字节码编译为本地代码来运行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息
- 第4层:使用C2编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些激进优化
编译对象与触发条件
热点代码主要有:被多次调用的方法、被多次执行的循环体
热点代码判定方式:基于采样的热点探测、基于计数器的热点探测(方法调用计数器、回边计数器)
编译器优化技术
- 最重要的优化技术之一:方法内联
- 最前沿的优化技术之一:逃逸分析
- 语言无关的经典优化技术之一:公共子表达式消除
- 语言相关的经典优化技术之一:数组边界检查消除
方法内联
Java语言中默认的实例方法是虚方法,因此大部分方法都不能内联。而C和C++语言默认的方法是非虚方法,如果需要用到多态,就用virtual关键字来修饰
为了解决虚方法的内联问题,Java虚拟机引入了类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,在此基础上实现了守护内联和内联缓存
逃逸分析
分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
如果能证明一个对象不会逃逸到方法或线程之外,我们可以进行栈上分配、标量替换、同步消除等优化动作。在这方面,C和C++语言里面原生就支持了栈上分配,不使用new操作符即可。因此在灵活运用栈内存方面,确实是Java的一个弱项
目前逃逸分析技术仍在发展之中,未完全成熟,主要原因是计算耗时过大
公共表达式消除
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共表达式
检查消除
数组越界会产生ArrayIndexOutOfBoundsException异常,空指针访问会产生NullPointException异常,除数为零会得到ArithmeticException异常。在C和C++中出现类似的问题,一个不小心就会出现Segment Fault信号
在检查消除阶段,虚拟机会注册一个Segment Fault信号的异常处理器,处理真正发生的异常,并去掉检查操作
Java内存模型
Java内存模型中定义了八种操作来完成主内存与工作内存之间的交互,Java虚拟机必须保证下面提及的每一种操作都是原子的、不可再分的:
- lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态
- unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中
- load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign:作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中
- write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述八种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施use、store操作之前,必须先执行assign和load操作
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)
这八种内存访问操作以及上述规则限定,再加上专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。但是除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们可以通过“先行发生原则”来确定一个操作在并发环境下是否安全
volatile变量特性
- 第一个语义是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的
- 第二个语义是禁止指令重排序优化,普通的变量仅会保证所有依赖赋值结果的地方都能获取到正确的结果,而不保证变量赋值操作的顺序与代码中的执行顺序一致
- lock addl $0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果
Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的"long和double的非原子性协定"
原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到java代码中就是同步块synchronized关键字,因此在synchronized块之间的操作也具备原子性
可见性:除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final
有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入
先行发生原则
- 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作
- 管程锁定规则:unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后
- 线程启动规则:Thread对象的start方法先行发生于此线程的每一个动作
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join方法是否结束、Thread::isAlive的返回值等手段检测线程是否已经终止
- 线程中断规则:对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted方法检测到是否有中断发生
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize方法的开始
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
线程状态
New、Runnable、Running、Waiting(无限期的join和wait)、Timed Waiting(sleep、限期的join和wait)、Blocked、Terminated
线程池状态
Running、Shutdown、Stop、Tidying、Terminated
Java语言中的线程安全
不可变、绝对线程安全(逻辑上需要保证线程安全)、相对线程安全、线程兼容、线程对立
线程安全的实现方式
互斥同步(Synchronized、java.util.concurrent包(LockSupport))、非阻塞同步(CAS)、无同步方案(可重入代码、线程本地存储)文章来源:https://www.toymoban.com/news/detail-475478.html
锁优化
锁消除、锁粗化、偏向锁、自旋锁&自适应自旋、轻量级锁文章来源地址https://www.toymoban.com/news/detail-475478.html
到了这里,关于深入理解Java虚拟机(读书笔记)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!