JVM虚拟机
2024-07-14 18:06:13 0 举报
AI智能生成
本篇专注于《JVM虚拟机》一书的阅读摘要,不仅涵盖了核心知识点,还融入了诸多概念的简易解读。分别介绍了JVM的发展历史、运行时数据区、类文件结构、类加载机制、字节码执行引擎、性能优化、垃圾收集、虚拟机安全、语言和编译、工具与库、Java新特性以及未来发展方向等主题。通过阅读本书,读者可以学习到如何更好地使用Java语言进行程序开发,理解JVM的工作原理,并对Java虚拟机有更深入的了解。【如侵权请联系删除】
作者其他创作
大纲/内容
国内Java虚拟机资料贫乏的原因是:在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性。
Java程序运行的虚拟机
Java类库
[用户_97008097]
Java编程语言
Java的技术体系主要支撑
前言
1,摆脱了硬件平台的束缚,实现了“一次编写,到处运行”的理想
2,提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题
3,它实现了热点代码检测和运行时编译及优化
4,有一套完整的应用程序接口,以及第三方开源类库
1.1 java的优势
Java程序设计语言
各种硬件平台上的Java虚拟机实现
Class文件格式
Java类库API
来自商业机构和开源社区的第三方Java类库
1.2 Java技术体系
1996年1月23日,JDK1.0发布
1.3 Java发展史
1.4.1 虚拟机始祖:Sun Classic/Exact VM
优势:1,基于准确式内存管理2,热点探测技术
1.4.2 武林盟主:HotSpot VM。是Sun/OracleJDK和OpenJDK中默认的Java虚拟机,也是目前使用范围最广的Java虚拟机。
1.4.3 小家碧玉:Mobile/Embedded VM。面相移动和嵌入式市场。
1.4.4 天下第二:BEA JRockit/IBM J9 VM
1.4.5 软硬合璧:BEA Liquid VM/Azul VMBEA Liquid 直接取代操作系统,控制硬件Azul VM 直接管理巨量硬件资源
1.4 Java虚拟机家族
Graal VM
HotSpot 的两个即时编译器:1,编译耗时短但输出代码优化程序低的客户端编辑器(C1)2,编译耗时长但输出代码优化程序高的客户端编辑器(C1)
JDK10起,加入了Graal 编译器
1.5.2 新一代即时编译器
1.5 展望Java技术的未来
OpenJDK 是Sun公司在2006年末把Java开源而形成的项目
1.6 实战:自己编译JDK
第1章 介绍了Java体系的过去、现在的情况以及未来的发展趋势,并在实践中介绍了如何自己编译一个OpenJDK12。
第一部分 走进Java
2.1 概述。对于C、C++的开发人员,它们必须负责每个对象的从开始到终结。而对于Java的开发人员,后面的工作被JVM去做了。
在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码命令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不响应,独立存储,我们称这类内存区域为“线程私有”的内存。
如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。问题:如果这个时候CPU时间片刚好用完?Undefined是一个特殊值,而不是普通意义上理解的空、未定义。
此内存区域是唯一一个没有规定任何OOM情况的区域。
2.2.1 程序计数器。是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,可可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。局部变量槽(Slot)
对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度(默认是256kb),将抛出StackOverflowError异常。如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
2.2.2 Java虚拟机栈。线程私有。生命周期与线程相同。
2.2.3 本地方法栈。线程私有。是为虚拟机使用到的本地(Native)方法服务。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分配抛出StackOverflowError和OutOfMemoryError异常。
此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。《Java虚拟机规范》中对Java堆的描述:所有的对象实例以及数组都应当在堆上分配。为什么说几乎,在逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙变化。
Java堆是垃圾收集器管理的内存区域。
Java堆中经常出现的“新生代”“老年代”等等这些区域划分仅仅是一部分垃圾收集器的共同特性或者说是设计风格而已,而非某个Java虚拟机具体实现的固有内存布局。
将Java堆细分的目的只是为了更好的回收内存,或者更快地分配内存。
根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
当前主流的Java虚拟机都是按照可扩展来实现的(通过-Xmx和Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OOM。
2.2.4 Java堆。线程共享。在虚拟机启动时创建。
在JDK8之前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”, 或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
但对于BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题。有容量限制,尤其是当加载大量类或使用动态代理等技术时
事情出现转机是Oracle收购BEA获得了JRockit的所有权后。准备将JRockit中的优秀功能移植到HotSpot虚拟机上,但两者对方法区的实现存在巨大差异。
考虑到HotSpot未来的发展,在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存来实现方法区的计划。到了JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出。到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间来代替,把JDK7中永久代还剩余的内存(主要是类型信息)全部移到元空间了。
《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样”永久“存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM
问题:方法区,永久代,元空间的概念混淆?1,永久代和元空间是方法区的实现方式2,元空间与永久代的主要区别在于:元空间不再位于JVM的堆内,而是使用本地内存(Native Memory)。这意味着元空间的大小不再受JVM堆大小的限制,可以动态扩展(受限于系统内存),从而减少了内存溢出的问题。垃圾收集器会回收元空间吗?元空间的管理及其内存的回收不直接由操作系统负责,而是由JVM和类加载器机制共同管理
2.2.5 方法区。线程共享。别名:非堆。用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译后的代码缓存等数据。
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。关于符号引用和直接引用运行时常量池就类似于一种关系映射表。运行时常量池相对于Class文件常量池的另外一个特征就是具备动态性,并非预置入Class文件中常亮池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中。
受到方法区内存的限制,当常量池无法再申请到内存时会抛出OOM。
2.2.6 运行时常量池。是方法区的一部分。
JDK1.4中新加入了NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByBuffer对象作为这块内存的引用进行操作。这样显著提高性能,因为避免了Java堆和Native堆中来回复制数据。
但是,直接内存会受到本机总内存大小以及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,通常会忽略直接内存,使得可能出现OOM。
虽然独立于堆外,但是也受限于操作系统分配到JVM进程的内存。
2.2.7 直接内存。并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但这部分也被频繁的使用,也可能导致OOM。
2.2 运行时数据区域。Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
1,当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。
3,内存分配完成后,虚拟机必须将分配的内存空间(但不包含对象头)都初始化为零值,如果使用了 TLAB,这一项工作可以提前至TLAB分配时顺便进行。这些操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,是程序能访问到这些字段的数据类型所对应的零值。
4,接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁,对象头会有不同的设置方式。
5,此时,从虚拟机的视角看,一个新的对象已经产生了。但是从Java程序的视角看,对象创建才刚开始----构造函数。Java编译器在遇到new关键字的地方同时生成两条字节码指令:--new 指令--init ()方法
2.3.1 对象的创建。在语言层面,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程?
第一部分是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。(32bit和64bit)Mark Word它是独立于对象自身定义数据的额外存储成本,考虑到存储效率,Mark Word是一个动态定义的数据结构。这个动态的意思是根据对象的状态复用这部分的存储空间。
第二部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。注:并部署所有的虚拟机实现都必须在对象数据上保留类型指针。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
对象头
对齐填充。无特殊含义。仅仅是占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8的整数倍。为什么?如果不是8的倍数,那么数据对齐又是一种效率消耗。
2.3.2 对象的内存布局。在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充
句柄访问:Java堆中将可能会划分出一块内存来作为句柄池(映射),reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。好处:reference中存储的是稳定句柄地址,在对象被移动(如垃圾回收)时只会改变句柄中的梳理数据指针,而reference本身不需要被修改。
直接指针访问:reference中存储的直接就是对象地址,不需要多一次间接访问的开销。好处:速度更快,节省了一次指针定位的时间开销。主流的HotSpot使用的就是直接指针访问。
2.3.3 对象的访问定位。创建对象自然是为了后续使用该对象,我们的Java程序会通过reference数据来操作堆上的具体对象。由于reference类型在 《Java虚拟机规范》里面只规定了它是一个执行对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中的对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
2.3 HotSpot虚拟机对象探秘
2.4.1 Java堆溢出。只要不断地创建对象,并且保证GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象。VM参数:1,-Xms20M 堆空间最小20MB2,-Xmx20M 堆空间最大20MB3,-XX:+HeapDumpOnOutOfMemoryError 发生OOM生成dump文件异常信息:java.lang.OutOfMemoryError:Java heap space分析工具:Eclipse Memory Analyzer
1,如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverFlowError异常。验证思路:VM参数:-Xss128K递归调用某个方法32位Windows下的JDK6:-Xss128K 就可正常使用64位Windows下的JDK11:-Xss不得低于180KLinux:-Xss不低于228K
2,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
其他知识:操作系统分配给每个进程的内存是有限制的,譬如32Windows的单个进程内存限制为2GB。如果使用HotSpot虚拟机的默认参数,栈深度在大多数情况下到达1000~2000是完全没有问题。如果是建立多线程导致的内存溢出,在不能减少线程数量或者更换64位操作系统的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。错误信息:unable to create native thread
2.4.2 虚拟机栈和本地方法栈溢出。由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展。所以除非在创建线程申请内存时就因无法获得足够内存而出现OOM,否则线程在运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError。
JDK6的VM参数:-XX:PermSize=6M -XX:MaxPermSize=6M循环调用string对象的intern方法。异常信息:java.lang.OutOfMemoryError: PermGen space
而自JDK7,原本存放在永久代的字符串常量池被移动到Java堆中JDK8,只用使用了元空间(本地内存)
在JDK6、7,如果有很多的代理类生成,就可以模拟出方法区溢出。但是在JDK8以后,元空间登场就比较难容易模拟。
JDK8中HotSpot提供的作为元空间防御措施:1,-XX:MaxMetaspaceSize 设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。2,-XX:MetaspaceSIze 指定元空间的初始空间大小,单位字节,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX: MaxMetaspaceSize的情况下,适当提高该值。3,-XX:MinMetaspaceFreeRatio 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比。类似的患有-XX:Max_MetaspaceFreeRatio用于控制最大的元空间剩余容量的百分比。
2.4.3 方法区和运行时常量池溢出。运行时常量池是方法区的一部分。
2.4.4 本地直接内存溢出。直接内存(Direct Memory)的容量大小可以通过-XX: MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值一致。JDK10 将Unsafe::allocateMemory 开发给外部使用,用于申请堆外内存。由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内容方面的原因了。
2.4 实战:OutOfMemoryError异常
第2章 Java内存区域与内存溢出异常介绍了虚拟机中内存是如何划分的,那部分区域、什么样的代码和操作可能导致内存溢出异常,并讲解了各个区域出现内存溢出异常的常见原因。
程序计算器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭。这3个区域的内存大体在编译期即可确定,因此这几个区域的内存分配和回收都具备确定性,就不需要多考虑如何回收的问题。当方法结束或者线程结束时,内存自然就已经回收了。
Java堆和方法区这两个区域有着显著的不确定性:一个接口的多个实现类需要内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,才能知道程序究竟会创建那些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器关注的正是这部分内存。
3.1 概述。1960年,Lisp语言已经开始使用内存动态分配和垃圾收集技术。垃圾收集需要完成的三件事情:1,那些内存需要回收?2,什么时候回收?3,如何回收?
大多数面试者是这样描述的:在对象中添加一个引用计数器,没当有一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;当计数器为0的对象就是不可能再被使用的。客观的说,引用计数算法虽然占用了一些额外的内存空间,但是它的原理简单,判定效率高。例如微软COM(Component Object Model)技术、Python语言以及在游戏脚本领域的Squirrel。
但是,主流的Java虚拟机都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法需要考虑很多例外情况,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
3.2.1 引用计数算法
当前主流的商用程序语言(Java、C#)的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots之间没有任何的引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
3.2.2 可达性分析算法
在JDK1.2版本之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象---很多系统的缓存功能都符合这样的应用场景。
在JDK1.2版本之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种。1,强引用是最传统的“引用”的定义,是指在程序代码中普遍存在的引用赋值,即类似“Object ojb = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。2,软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。案例:假设有一个图片缓存系统,可以使用软引用来保存最近访问但不是必须保留的图片数据。SoftReference<String> softText = new SoftReference<>(new String(\"Soft Hello\"));3,弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一代垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2 版之后提供了WeakReference类来实现弱引用。案例:设计一个不希望影响垃圾回收机制、可被自动清理的缓存时。4,虚引用也称为“幽灵引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。案例:应用场景通常涉及资源清理和管理,特别是在需要精确控制资源释放时机,而又不想直接影响对象生命周期的情况下。虚引用主要用于跟踪对象的回收,当对象被垃圾回收器标记为即将回收时,虚引用会被加入到与之关联的引用队列中,从而允许程序在对象被回收后采取进一步的操作,如关闭文件、释放外部资源等。
3.2.3 再谈引用
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”,这个时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有执行finalize()方法。假如对象没有重写finalize()方法【Object类的finalize()方法什么都不做】,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是:如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将可能导致F-Queue队列中的其他对象永久处于等待,甚至导致内存回收子系统崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器键归队F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己------只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK=null; public void isAlive(){ System.out.println(\"是的,我还活着\"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println(\"finalize方法执行\"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable{ SAVE_HOOK = new FinalizeEscapeGC(); SAVE_HOOK = null; System.gc(); Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println(\"第一段我已经被回收了\"); } System.out.println(\"======\"); SAVE_HOOK = null; System.gc(); Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println(\"第二段我已经被回收了\"); } }}/** * 打印结果: * finalize方法执行 * 是的,我还活着 * ====== * 第二段我已经被回收了 */代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的finalize()方法都只会被系统自动调用异常,如果对象面临下一次回收,它的finalize()方法不会再次执行,因此第二段代码的自救行动失败了。这种拯救对象的方法如今已被官方明确声明为不推荐使用的语法。建议使用try-finally或其他方式
3.2.4 生存还是死亡?
方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中尤其是新生代中,对常规应用进行一次垃圾收集通常可以回收70%到99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。判定一个类型是否属于“不再被使用的类”需要同时满足三个条件:1,该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。2,加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常很难达成。3,该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.2.5 回收方法区有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
3.2 对象已死?怎么确定那些对象需要回收
分代假说理论建立在两个分代假说之上:1,弱分代假说:对大多数对象都是朝生夕灭的。2,强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域----因而才有了“Minor GC”“Major GC”\"Full GC“这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法-------因而发展除了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。基于分代收集理论,设计者一般至少会把Java堆划分为新生代和老年代。
名词定义:1,部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:--新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。--老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。【目前只有CMS收集器会有单独收集老年代的行为】--混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。【目前只有G1收集器会有这种行为】2,整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
3.3.1 分代收集理论。
算法分为“标记”和“清除”两个阶段:首先标记出所需要的回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对对象是否属于垃圾的判定过程。之所以说他基础:后续的收集算法大都以标记-清除算法为基础。对其缺点进行改进而得到的。
标记-清除算法的主要缺点:1,执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。2,内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致当以后在程序运行过程中需要分配大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
3.3.2 标记-清除算法。最早出现也是最基础的垃圾收集算法。
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,就提出了标记-复制算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
缺陷:代价是将可用内存缩小为原来的一半,空间浪费未免太多了。
1989年,Andrew Apple提出了新的策略,具体做法是:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Servivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。理论上有10%的新生代是被“浪费的”。但是,没人能百分百保证每次回收都只有不多于10%的对象存活,因此Apple式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一个Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多是老年代)进行分配担保。
分配担保:如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。想到的问题:直接进入老年代,那么对象的分代年龄怎么算?分配担保是一种特殊情况,它不依赖于对象年龄的常规累加。
3.3.3 标记-复制算法。简称为复制算法。
标记-复制算法在对象存活率高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
是否移动回收后的存活对象是一项优缺点并存的风险决策:1,如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户用户线程才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,Stop The World 。2,如果不考虑移动和整理存活对象的话,碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户线程最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用线程的吞吐量。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的。而关注延迟的CMS收集器则是基于标记-清除算法的。
3.3.4 标记-整理算法。出现原因是标记-复制算法的缺陷。
还有一种“和稀泥”解决方案可以不在内存分配和访问上增加太大的额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,知道内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规则的内存空间。CMS收集器面临空间碎片过多时采用的就是这种处理办法。
3.3 垃圾收集算法。从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”【直接垃圾收集】和“追踪式垃圾收集”【间接垃圾收集】。本节介绍的所有算法均属于追踪式垃圾收集的范畴。
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。
迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,因此就会受到STW的困扰。
为了减少STW的时间,HotSpot的解决方案是:使用一组称为OopMap的数据结构来达到这个目的。在类加载和即时编译阶段,OopMap记录了程序中一些特定点的引用地址,当开始垃圾回收时,直接去用OopMap的地址匹配就行,而不是从头开始一行一行代码的重新遍历。
3.4.1 根节点枚举
虽然在OopMap的协助下,HotSpot可以快速准确完成GC Roots枚举,但是也引来了新的问题:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
OopMap中的特定点就被称为安全点。安全点的设定决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
安全点位置的选取:基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列的复用,所以只有具备这些功能的指令才会产生安全点。
抢先式中断。不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让他一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
主动式中断。当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标准,一旦发现中断标志位为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要再Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。轮询操作在代码中频繁出现,这要求必须高效。HotSpot使用内存保护陷阱的方式。
对于安全点,另一个需要考虑的是:如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。两种方案:
3.4.2 安全点
新的问题:程序“不执行”的时候,比如线程线程处于sleep或者blocked状态,这个时候线程无法响应虚拟机的中断请求。所以引入了安全区域。
安全区域:指能够确保某一段代码片段中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾回收都是安全的。也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明的安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
问题:JVM怎么确定进入了安全区域?当线程即将进入一段不会引用对象的代码区域时(例如,执行一个长时间的计算任务前,或者线程即将进入Slepp、Blocked状态),它会首先向JVM发出请求,声明自己即将进入安全区域。
3.4.3 安全区域。
在前面讲解分代收集理论的时候,提高了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象结构。
一些可供选择的记录精度:1,字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。2,对象精度:每个记录精确到一个对象,该对象里有字段含有跨代执行。3,卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代执行。--卡精度所指的是用一种称为“卡表”的方式去实现记忆集,这也是目前最常见的一种font color=\"#e74f4c\
3.4.4 记忆卡与卡表。
卡表元素何时变脏?有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
如何变脏?即如何在对象赋值的那一刻去更新维护卡表?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间。但在编译执行的场景中,经过即时编译后的代码已经是纯粹的机器指令流,这就必须找到一个在机器码层面的手段,把维护卡表的动作放在每个赋值操作之中。
在HotSpot虚拟机里是通过写屏障 技术维护卡表状态。写屏障可以看作是在虚拟机层面面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环绕通知,共程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范围内。在切面中对引用字段赋值操作之后,进行卡表更新操作。
卡表在高并发场景下还面临着“伪共享”问题。伪共享:现代中央处理器的缓存系统重是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低。JDK7之后,VM的新参数-XX:+UseCondCardMark,用来决定是否开启卡表更新判断。卡表更新判断:不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
3.4.5 写屏障。使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护,例如它们何时变脏、谁来把它们变脏等。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
借用三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记:1,白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。2,黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。3,灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过。
增量更新破坏的是第一个条件。当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一遍。
原始快照要破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
问题:如果STW,这样标记没有任何问题,但是如果用户线程和收集器同时工作?就面临着标记颜色的变化(即修改对象图的结构)。Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”问题,即原本应该是黑色的对象被误标为白色:1,赋值器插入了一条或多条从黑色对象到白色对象的新引用;2,赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。所以要解决并发扫描对象消失的问题,只需破坏这两个条件的任意一个即可。解决方案:增量更新和原始快照。
无论是对引用关系记录的插入或删除,虚拟机的记录操作都是通过写屏障实现的。
3.4.6 并发的可能性分析。前置知识:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长。
3.4 HotSpot的算法细节实现。Java虚拟机实现上面描述的算法时,需要对执行效率有一个考量标准。
3.5.2 ParNew收集器。ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的控制参数都与Serial收集器完全一致。除了Serial收集器外,目前只有它能与CMS收集器配合使用。JDK9之后,由于G1(面向全堆)的出现ParNew合并入CMS,成为它专门处理新生代的组成部分。并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态。并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程和用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
也是能够并行收集的多线程收集器,与Parallel相似。
Parallel Scavenge收集器的关注点是达到一个可控制的吞吐量。吞吐量 = 运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)
-XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间收集器将尽力保证内存回收话费的时间不超过用户设定值。不要异想天开任务停顿时间少垃圾收集速度就更快。停顿时间是以牺牲吞吐量和新生代空间位代价换取的。
-XX:GCTimeRatio 直接设置吞吐量大小(0-100)垃圾收集时间占总时间的比率。
-XX:+UseAdaptiveSizePolicy 自适应调节策略的开关。当开关打开,就不需要指定新生代的大小、Eden与Servivor的比例、晋升老年代对象大小等细节参数,虚拟机会根据监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量。
提供了两个参数用于精确控制吞吐量
3.5.3 Parallel Scavenge收集器。新生代收集器,基于标记-复制算法。拥有自适应调节策略。
在服务端模式下,它也可能有两种用途:1,在JDK5以及之前的版本中与Parallel Scavenge收集器配合使用。2,作为CMS收集器发生失败时的后备预案,在并发收集发生ConcurrentMode Failure时使用。
3.5.4 Serial Old收集器。是Serial收集器的老年代版本,单线程收集,使用标记-整理算法。
3.5.5 Paralle Old收集器。是Paralle Scavenge收集器的老年代版本,支持多线程,基于标记-整理算法。Parallel Scavenge 只能与Searial Old 配合使用,无法与CMS配合使用。Parallel Scavenge 与 Parallel Old搭配,吞吐量优先才算是真正的实现了。
1,初始标记 CMS initial mark需要STW。
2,并发标记 CMS concurrent mark
3,重新标记 CMS mark需要STW。
4,并发清除 CMS concurrent sweep
步骤
缺点:1,对处理器资源非常敏感。2,无法处理浮动垃圾,有可能出现 Con-current Mode Failure失败而导致另一次完全STW的 Full GC的产生(虚拟机的方案是使用Serial old作为后备)浮动垃圾:在并发标记和并发清理过程中产生的新的垃圾对象。3,基于标记-清除就意味着收集结束时产生大量空间碎片。当出现较大对象时,因空间不足,提前触发了Full GC。-XX:+UseCMSCompactAtFullCollection开关。用于CMS不得不进行进行Full GC时开启内存碎片的合并整理过程。(JDK9已废弃)-XX:+CMSFullGCsBefore-Compaction,要求CMS在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前先进行碎片整理。(JDK9已废弃)
面向堆内存任何部分来组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是那块内存中存放的垃圾数量最多,回收效益最大,这就是G1收集器的Mixed GC模式。
还有一类特殊的Humongous区域,专门用来存储大对象(大小超过一个Region容量一半的对象即可判定为大对象)。-XX:G1HeapRegionSize 设置每个Region的大小,取值范围是1MB~32MB。而对于超过整个Region容量的超级大对象,将会被放在N个连续的Humongous Region中。
待解决的问题:1,Region里面存在的跨Region引用对象如何解决?使用记忆集避免全堆作为GC Roots扫描。G1的记忆集是哈希表,key是别的Region的起始地址,value是一个集合(存储的元素是卡表的索引号)2,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?G1是通过原始快照算法来实现的。(而CMS是采用的增量更新)与CMS中Concurrent Mode Failure类似,如果内存回收速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间的STW。3,怎样建立起可靠的停顿预测模型?-XX:MaxGCPauseMillis 这个参数指定的停顿时间只意味着垃圾收集发生之前的期望值G1是怎么做才能满足用户的期望?G1会记录每个Region回收耗时、每个Region记忆集里的脏卡数量等可测量的成本,通过得到的统计信息再去尽量满足用户的期望。
初始标记。仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记。从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时较长,但可与用户线程并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的并发时有引用变动的对象。
最终标记。对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
筛选回收。负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成。
G1收集的步骤
G1 开始,垃圾收集器的设计方向转到了应用的内存分配速率,而不追求一次把整个Java堆全部清理干净。
G1之所以没有完全取代CMS,是因为G1的每个Region都有卡表,这也是维护成本,还有使用原始快照的方式又增加了额外的负担。
3.5.7 Garbage First 收集器。主要面向服务端应用。2004年提出,2012年JDK7才出来。
3.5 经典垃圾收集器。收集算法是内存回收的方法论,垃圾收集器就是内存回收的实践者。
衡量垃圾收集器的三项最重要的指标是:内存占用、吞吐量和延迟。
实现的目标是把垃圾收集的停顿时间控制在10毫秒以内。
更像是G1 的继承者。它与G1的三个不同之处:1,支持并发的整理算法(G1的回收阶段可以多线程并发,但却不能与用户线程并发)2,默认不使用分代收集3,摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系。
初始标记。与G1一样。
并发标记。与G1一样。
最终标记。与G1一样。
并发清理。这个阶段用于清理那些整个区内连一个存活对象都没有找到的Region。
并发回收。与之前HotSpot中其他收集器的核心差异。Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事如果将用户线程冻结起来再做那非常easy,但是如果两者必须同时并发进行的话就很复杂。这个时候,用户线程仍然可能不停对被移动对象进行读写访问,移动对象是一次性的,但移动之后整个内存新对象与旧对象的地址转换就很难一瞬间完成。Shenandoah是通过读屏障和“Brooks Pointers”的转发指针来解决。
初始引用更新。这个阶段并不更新地址,只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动而已。STW。
并发引用更新。就是新旧对象引用的变更。
最终引用更新。解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。
并发清理。
工作过程
Brooks Pointer。转发指针。实现对象移动与用户线程并发的解决方案。Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。相当于副本。为了确保多线程下的安全性,引入了读屏障。
3.6.1 Shenandoah收集器。只有OpenJDK才会包含,Oracle JDK则不存在。使用转发指针和读屏障来实现并发整理。
与Shenandoah的目标高度相似。
主要特征:基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理算法,以低延迟为首要目标。
核心技术:染色指针。直接将少量额外的信息存储在指针上的技术。
3.6.2 ZGC收集器。全称是Z Garbage Collector。JDK11的商用版本。
3.6 低延迟垃圾收集器。
3.7.1 Epsilon收集器。不进行垃圾收集。目标是为了支持短时间、小规模的服务。对于那些只需运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出的应用Epsilon无疑是恰当的选择。
1,应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是尽快算出结果,那吞吐量就是最主要的关注点。如果是SLA引用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点。如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则不可忽视。2,运行应用的基础设施如何?譬如硬件规格;处理器数量;分配内存大小;操作系统。3,使用JDK的发性商是什么?版本好是多少?
3.7.2 收集器的权衡。
JDK9之后,HotSpot所有功能的日志都收归到-Xlog 参数上了。-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
1,查看GC基本信息,JDK9之前使用-XX:+PrintGCJDK9之后使用-Xlog:gc
2,查看GC详细信息。JDK9之前使用-XX:+PrintGCDetatilsJDK9之后使用-Xlog:gc*
3,查看GC前后的堆、方法区可用容量变化。JDK9之前使用-XX:+PrintHeapAtGCJDK9之后使用-Xlog:gc+heap=debug
4,查看GC过程中用户线程并发时间以及停顿的时间
5,查看收集器Ergonomics机制自动调节的相关信息。
6,查看熬过收集后剩余对象的年龄分布信息。
其他参数对比可以自行了解。
3.7.3 虚拟机及垃圾收集器日志
3.7.4 垃圾收集器参数总结。
3.7 选择合适的垃圾收集器。一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与监控子系统协作等之职责。
Java体系的的自动内存管理,最根本的目标是自动化地解决两个问题:1,自动给对象分配内存2,自动回收分配给对象的内存
本节内容基于HotSpot,垃圾收集器使用默认的Serial 和Serial old组合。
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
3.8.1 对象优先在Eden分配
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串,或者元素数量庞大的数组。
-XX:PretenureSizeTHreshold 用于指定大于该值的对象直接在老年代分配。这样做的目的是避免在Eden区以及两个Survivor区之间来回复制,产生大量的内存复制操作。
3.8.2 大对象直接进入老年代
对象通常在Eden区诞生,每次Minor GC,对象Age加1。
-XX:MaxTenuringThreshold 来指定进入老年代的年龄(默认15)
3.8.3 长期存活的对象将进入老年代。
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX: MaxTenuringTHredhold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。
3.8.4 动态对象年龄判定。
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么这一次Minor GC可以确保是安全的。
如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置是否允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这有风险。如果小于,或者-XX:HandlePromotionFailure参数设置不允许冒险,那这时就要改为进行一次Full GC。
3.8.5 空间分配担保。
3.8 实战:内存分配和回收策略
第3章 介绍了垃圾收集的算法和HotSpot虚拟机中提供的几款垃圾收集器的特点及运作原理。通过代码实例验证了Java虚拟机中自动内存分配及回收的主要规则。
4.1 概述。给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括但不限于异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。
JDK的bin目录中下的一些基础工具
商业授权工具:主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder),JMC这个原本来自于JRockit的运维监控套件从JDK7 Update40开始就被集成到Oracle JDK中,JDK11之前都无法独立下载,但是在商业环境中使用它则是要付费的。
正式支持工具:这一类工具属于被长期支持的工具,不同平台、不同版本的JDK之间,这类工具可能略有差异,但是不会出现其中一个工具突然消失的情况。
实验性工具:这一类工具在它们的使用说明书中被声明为“没有技术支持,并且是实验性质的”产品,日后可能会转正,也可能在某个JDK版本中无声无息地消失。但事实上它们通常都非常稳定而且功能强大,也能在处理应用程序性能问题、定位故障时发挥很大的作用。
根据软件可用性和授权的不同,分为三类
bin目录的命令看着很小,其实它们底层的逻辑都在JDK的类库里面。
Linux的JDK,这些工具不少是由shell脚本直接写成,可以用文本编辑器打开并编辑修改它们。
如果工作中需要监控运行与JDK5的虚拟机之上的程序,在程序启动时需要添加参数“-Dcom.sun.management.jmxremote”开启JMX管理功能。如果运行在JDK6或以上版本的虚拟机上,JMX是默认开启的。
功能点:1,列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main函数所在的类)名称以及这些进程的本地虚拟机唯一ID(Local Virtual MachineIdentifier)。
命令格式:jps [ options ] [ hostid ]
-q 只输出LVMID,省略主类的名称
-m 输出虚拟机进程启动时传递给主类main函数的参数
-l 输出主类的全名,如果进程执行的是JAR包,则输出JAR路径
-v 输出虚拟机进程启动时的JVM参数
jps工具的主要选项
4.2.1 jps:虚拟机进程状况工具。类似于unix的ps命令
它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。
jstat命令格式:jstat [ option vmid [interval[s|ms] [count]] ]
对于命令格式中的VMID与LVMID特别说明:如果是本地虚拟机进程,VMID与LVMID是一致的。如果是远程虚拟机进程,那VMID的格式应当是:[protocol:][//]lvmid[@hostname[:port]/servername]
参数interval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次。
选项option代表用户希望查询的虚拟机信息,主要分为三类:类加载、垃圾收集、运行期编译状态。
4.2.2 jstat:虚拟机统计信息监视工具。
作用是实时查看和调整虚拟机各项参数。
jps -v 只能查看到虚拟机启动时显式指定的参数列表
jinfo -flag则可以知道未被显式指定的参数的系统默认值。
JDK6之后,jinfo在win和linux平台都有提供,并且假如了在运行期间修改部分参数值的能力:可以使用-flag[+|-]name或者-flag name=value在运行期修改一部分运行期可写的虚拟机参数值
在jdk6中,jinfo 对于windows平台只提供了最基本的-flag选项
jinfo命令格式:jinfo [ option ] pid
4.2.3 jinfo: Java配置信息工具。
用于生成堆转储快照(一般称为heapdump或dump文件)。
如果不使用jmap命令,要想获取Java堆转储快照的暴力手段是:-XX: +HeapDumpOnOutOfMemoryError参数。也可以通过-XX: +HeapDumpOnCtrlBreak参数让虚拟机生成堆转储快照文件,又或者在Linux系统下通过kill -3命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。
jmap 还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率,当前用的是那种收集器。
jmap命令格式:jmap [ option ] vmid
4.2.4 jmap:Java内存映射工具
jhat命令与jmap搭配使用,来分析jmap生成的堆转储快照。jmap内置了微型HTTP/Web,就是可以在浏览器上查看。
jhat难用的两点原因:1,一般不会在部署应用程序的服务器上直接分析堆转储快照,即使可以这样做,也会尽量将dump文件复制到其他机器上进行分析,因为分析工具耗时而且耗费硬件资源。2,分析功能相对简陋。其他专业分析工具:VisualVM、Eclipse Memory Analyzer、IBM HeapAnalyzer
4.2.5 jhat:虚拟机堆转储快照分析工具。
用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)
线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等等。
jstack命令格式:jstack [ option ] vmid
-F 当正常输出的请求不被响应时,强制输出线程堆栈
-l 除堆栈外,显示关于锁的附加信息
-m 如果调用到本地方法的话,可以显示C/C++的堆栈
主要选项
JDK5起,java.lang.Thread类新增了一个getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。
4.2.6 jstack:Java堆栈跟踪工具。
4.2 基础故障处理工具
服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JNI代码)实现的API集合。服务性代理是以HotSpot内部的数据结构为参照物进行设计,把这些C++的数据抽象出Java模型对象,相当于HotSpot的C++代码的一个镜像。通过服务性代理的API,可以在一个独立的Java虚拟机的进程内存中dump出来的转储快照里还原出它的运行状态细节。核心概念:映射。
/** * staticObj、instanceObj、localObj存放在哪里? */public class JHSDB_TestCase { static class Test { static ObjectHolder staticObj = new ObjectHolder(); ObjectHolder instanceObj = new ObjectHolder(); void foo() { ObjectHolder localObj = new ObjectHolder(); System.out.println(\"done\"); // 这里设一个断点 } } private static class ObjectHolder {} public static void main(String[] args) { Test test = new JHSDB_TestCase.Test(); test.foo(); }}
4.3.1 JHSB:基于服务性代理的调试工具JDK才引入
基于JMX(Java Manage-ment Extensions)的可视化监视管理工具。主要功能通过JMX的MBean对系统进行信息收集和参数调整。
1,启动JConsole jconsole.exe命令
2,内存监控
3,线程监控阻塞死锁
4.3.2 JConsole:Java监视与管理控制台
全称是All-in-One Java Troubleshooting Tool 。
主要功能有:1,显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)2,监视应用程序的处理器、垃圾收集、堆、方法区以及线程的信息(jstat、jstack)。3,dump以及分析堆转储快照(jmpa、jhat)。4,方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。5,离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者进行Bug反馈。6,其他插件带来的无限可能性。
BTrace动态日志跟踪。VisualVM插件,它本身也是一个可运行的独立程序。BTrace的作用是不中断目标程序运行的前提下,通过HotSpot虚拟机的Instrument功能动态加入原本并不存在的调试代码。
阿里巴巴开源的诊断工具Arthas也是通过Instrument实现了与BTrace类似的功能。
4.3.3 VisualVM:多合-故障处理工具。基于Net-Beans平台开发的产物
飞行记录报告包含以下几类信息:1,一般信息:关于虚拟机、操作系统和记录的一般信息2,内存:关于内存管理和垃圾收集的信息。3,代码:关于方法、异常错误、编辑和类加载的信息。4,线程:关于应用程序中线程和锁的信息。5,I/O:关于文件和套接字输入、输出的信息。6,系统:关于正在运行Java虚拟机的系统、进程和环境变量的信息。7,事件:关于记录中的事件类型的信息,可以根据线程或堆栈跟踪,按照日志或图形的格式查看。
4.3.4 Java Mission Control:可持续在线的监控工具。非Net-Beans平台,而是由IBM捐赠的Eclipse RCP作为基础框架。
4.3 可视化故障处理工具
4.4 HotSpot虚拟机插件及工具。
第4章 介绍了随JDK发布的基础命令工具与可视化的故障处理工具的使用方法。
主要问题:过大的堆内存进行回收时带来的长时间的停顿。
考虑的点:1,应用的部署方式(单体,集群)2,垃圾收集器的选择3,控制Full GC的频率
控制Full GC频率的关键是老年代的相对稳定,这主要取决于应用中绝大多少对象能否符合“朝生夕死”的原则,即大多数对象的生存时间不应当太长,尤其是不能成批量的、长生存时间的大对象产生,这样才能保证老年代空间的稳定。
单个Java虚拟机实例来管理大内存,还需要考虑以下几个问题:1,回收大块内存而导致的长时间停顿,自从G1收集器的出现,增量你回收得到比较好的应用,这个问题有所缓解,但要到ZGC和Shenandoah收集器成熟之后才得到相对彻底的解决2,大内存必须有64位Java虚拟机支持,但由于压缩指针、处理器缓存容量等,64位虚拟机的性能测试结果普遍略低于相同版本的32位虚拟机。3,必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生Dump(要产生十几GB乃至更大),太大了4,相同的程序在64位虚拟机中消耗的内存一般比32位虚拟机要打,这是由于指针膨胀,以及数据类型对齐补白等因素导致,可以开启(默认开启)压缩指针功能来缓解。
当采用集群部署的方式需要面对的问题:1,节点竞争全局的资源,在本案例中最典型的就是磁盘IO。2,很难高效利用某些资源池,譬如连接池3,大量使用本地缓存,因为每个逻辑节点上都有一份缓存,这个时候可以考虑集中缓存。
最终的解决方案:1,建立5个逻辑集群2,每个进程按2GB内存计算(其中堆固定为1.5G)3,使用类似nginx来做负载均衡。
5.2.1 大内存硬件上的程序部署策略。
5.2.2 集群间同步导致的内存溢出。
案例中正好有大量的NIO操作需要用到直接内存。
虚拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就主动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,“顺便”帮它清理一下内存的废弃对象。否则就不得不一直等待抛出OOM时,先捕获到异常,在Catch块里通过System.gc命令触发垃圾收集。
如果打开了-XX: +DisableExplicitGC 开关,禁止人工收集,就只能等着OOM了。
在处理小内存或32位的应用问题时,除了Java堆和方法区之外,我们注意到下面这些区域也会占用较多内存:1,直接内存:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OOM:Direct buffer memory2,线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError3,Socker缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37kb和25kb内存,连接多的话这块内存占用也比较可观。如果无法分配,可能会抛出IOException:Toomany open files。4,JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中,而是占用Java虚拟机的本地方法栈和本地内存的。5,虚拟机和垃圾收集器。
5.2.3 堆外内存导致的溢出错误
场景:每个用户请求的处理都需要执行一个外部Shell脚本来获得一些系统信息。执行这个Shell脚本是通过Java的Runntime.getRunntime().exec()方法来调用的。Java虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗必然会很大,而且不仅仅是处理器消耗,内存负担也很重。
解决方案是使用Java的api调用。
5.2.4 外部命令导致系统缓慢。
场景:OA系统与服务系统异步同步消息socket太多导致服务系统崩溃。
解决:使用MQ
5.2.5 服务器虚拟机进程崩溃。
如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑直接将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX: MaxTenuringThreshold=0或者-XX: +Always-Tenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再去清理它们。
5.2.6 不恰当数据结构导致内存占用过大。
场景:GUI程序的心跳检测发生误报。
日志显示偶尔存在一次将近1min的停顿。还观察到这个GUI程序内存变化的一个特点,当它最小化的时候,资源管理器中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时他的工作内存被自动交换到磁盘的页面文件职中了,这样发生垃圾收集时就有可能导致因为恢复页面文件的操作导致不正常的垃圾收集停顿。
在Java的GUI程序中要避免这种现象,可以加入参数-Dsun.awt.keepWorkingSetOnMinimize=true来解决。这个参数在许多AWT程序上都有应用,例如JDK自带的VisualVM,启动配置文件中就有这个参数。
5.2.7 由Windows虚拟机内存导致的长时间停顿。
5.2.8 由安全点导致长时间停顿。
5.2 案例分析。本节讨论的基础是如何在不改变已有软硬件版本和规格的前提下,调整部署和配置策略去解决或者缓解问题。
5.3 实战:Eclipse运行速度调优。
第5章 分享了几个比较有代表性的实际案例,还准备了一个所有开发人员都能“亲身实践”的练习,希望读者能通过实践类获得故障处理和调优的经验。
第二部分 自动内存管理
6.1 概述。计算机只认识0和1,高级程序语言怎么转换成0和1构成的二进制格式的。
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式---字节码(Byte Code)是构成平台无关性的基石。
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件包含了Java虚拟机指令集、符号表以及若干其他辅助信息。
对于不同的语言使用Java虚拟机,都需要配备不同的编译器。例如JRuby程序--> jrubyc编译器 ->字节码 -> Java虚拟机。
6.2 无关性的基石。
Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没。
Class文件既有文件存储的,也有动态生成的。
无符号数。属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表。是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯以“_info”结尾。表用于描述有层次关系的复合数据结构的数据。整个Class文件本质上也可以视为一张表。
Class文件结构的两种数据类型
可自行查看Class文件格式对照表。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号。第7和第8个字节是主版本号。
高版本的JDK能向下兼容以前的版本的Class文件,但是不能运行以后的版本的Class文件。
6.3.1 魔数与Class文件的版本。
紧接着主、次版本号是常量池的入口,常量池可以理解为Class文件里的资源仓库。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。备注:容量计数值是从1开始。设计者将第0项空出来的目的:如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”,可以把索引值设置为0。
字面量。含义接近于Java语言的常量概念,如文本字符串,被声明final的常量值等。
被模块导出或者开放的包
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
方法句柄和方法类型
动态调用点和动态常量
符号引用。而符号引用则属于编译原理方面的概念。
常量池中主要存放两大类常量
在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期间转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
基于长度的限定,Java中的方法、字段名最大长度是65535。64kb。
JDK提供的专门分析Class 文件字节码的工具:javap
6.3.2 常量池
在常量池结束之后,紧结着的2个字节代表访问标志,这个标志用于识别一些类或接口层次的访问信息,包括:1,这个Class是类还是接口2,是否定义为public类型3,是否定义为abstract类型4,如果是类的话,是否被声明为final
6.3.3 访问标志。
类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件由这三项数据确定该类型的继承关系。
类索引用于确定这个类的全限定名。父类索引用于确定这个类的父类的全限定名。接口索引集合就用来描述这个类实现了那些接口。对于接口索引集合,入口的第一项u2类型的数据为接口计数器,表示索引表的容量。
6.3.4 类索引、父类索引与接口索引集合。
用于描述接口或者类中声明的变量。Java语言中的字段包括类变量级以及实例级变量,但不包括在方法内部声明的局部变量。
6.3.5 字段表集合。
方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性表集合几项。
6.3.6 方法表集合。
属性表不再要求各个属性表具有严格顺序。
6.3.7 属性表集合。
6.3 Class类文件的结构。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(操作数)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
执行模型:do { 自动计算PC寄存器的值加1; 根据PC寄存器指示的位置,从字节码流中取出操作码; if (字节码存在操作数) 从字节码流中取出操作数; 执行操作码所定义的操作;} while (字节码流长度 > 0)
6.4.1 字节码与数据类型。
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
6.4.2 加载和存储指令。
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈。
1,加法指令后缀:add2,减法指令后缀:sub3,乘法指令后缀:mul4,除法指令后缀:div5,求余指令后缀:rem6,取反指令后缀:neg7,位移指令后缀:shl8,按位或指令后缀:or9,按位异或指令后缀:xor10,局部变量自增指令:iinc11,比较指令指令后缀:cmpg
6.4.3 运算指令。
可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
1,int类型到long、float或者double类型2,long类型到float、double类型3,float类型到double类型
6.4.4 类型转换指令。
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。
1,创建类实例的指令:new2,创建数组的指令:newarray、anewarray、multianewarray3,访问类字段(static字段、或者称为类变量)和实例字段(非statis字段、或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic4,把一个数组元素加载到操作数栈的指令:baload、...5,将一个操作数栈的值存储到数组元素中的指令:bastore.....6,取数组的长度:arraylength7,检查类实例类型的指令:instanceof,checkcast
6.4.5 对象创建与访问指令。
1,将操作数栈的栈顶一个或两个元素出栈:pop、pop22,复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2...3,将栈最顶端的两个数值互换:swap
6.4.6 操作数栈管理指令。
可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条执行继续执行程序。从概念模型理解:可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。
1,条件分支:ifeq、iflt ......2,复合条件分钟:tableswitch、lookupswitch3,无条件的分支:goto.......
6.4.7 控制转移指令。
方法调用(分派、执行过程)
1,invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分配(虚方法分配)。2,invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。3,invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有化方法和父类方法。4,invokestatic指令:用于调用类静态方法5,invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。
6.4.8 方法调用和返回指令。
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常情况时自动抛出。
6.4.9 异常处理指令。
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,一般称为锁)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。
同步一段指令集序列通常是由Java语言中的sysnchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronzied关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持。
6.4.10 同步指令。
6.4 字节码指令简介。
《Java虚拟机规范》描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统和具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看做程序在各种Java平台之间互相安全地交互的手段。
虚拟机实现的方式主要有两种:1,将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集。2,将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译代码生成技术)
6.5 公有设计,私有实现。
6.6 Class文件结构的发展
第6章 类文件结构讲解了Class文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法,以实战的方式演示了Class的数据是如何存储和访问的。
7.1 概述。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备、解析三个部分统称为连接。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)
《Java虚拟机规范》严格规定了有且只有6种情况必须立即对类进行“初始化”:1,遇到new,getstatic,putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。涉及场景:-使用new关键字实例化对象的时候。-读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。-调用一个类型的静态方法的时候。2,使用java.lang.reflect包的方法对类型进行反射调用的时候。3,当初始化类的时候。要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类全部都已经初始化过了,只有在真正使用到父接口的时候才会初始化。4,当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始化这个主类。5,当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发初始化。6,当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。这6种场景属于主动引用。
被动引用。
7.2 类加载的时机。
在加载阶段,Java虚拟机需要完成以下三件事情:1,通过一个类的全限定类名来获取定义此类的二进制字节流。2,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。3,在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
没有指明要从哪里获取二进制字节流,如何获取,这就提供了多样性:1,从ZIP压缩包中读取,比如日后的JAR、EAR、WAR2,从网络中获取,典型应用是Web Applet3,运行时计算,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()为特定接口生成形式为“$Proxy”代理类的二进制字节流。4,由其他文件生成,典型场景是JSP5,从数据库中读取6,从加密文件中获取 等等。
非数组类型的加载阶段是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成。重新一个类加载器的findClass()或loadClass()方法
数组类型。它本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态够造出来的。但数组类与类加载器仍然密切相关,因为数组类的元素类型最终还是要靠类加载器来完成加载。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分。
懒加载模式
7.3.1 加载。
验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。
四个阶段的校验动作:1,文件格式校验2,元数据验证-这个类是否有父类-这个类的父类是否继承了不允许被继承的类(被final修饰的类)-如果这个类是抽象类,是否实现了其父类或接口之中要求实现的所有方法-类中的字段、方法是否与父类产生矛盾3,字节码验证这个阶段主要的目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行数据分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。4,符号引用验证这个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段发生。
-Xverify: none参数可以用来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
7.3.2 验证。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。容易混淆的概念:1,准备阶段进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。2,这里所说的初始值“通常情况”下是数据类型的零值。3,如果类字段属性存在常量属性,在准备阶段就会直接赋值。
7.3.3 准备。
将常量池内的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用:是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
1,类或接口的解析。3个步骤:-不是数组类型,虚拟机直接传递全限定名-是数组类型,并且数组的元素为对象,那就会按照上一步去做-访问权限校验
2,字段解析。-首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。-然后进行搜索-成功返回引用后,权限验证
3,方法解析。与字段解析步骤一致。
4,接口方法解析。与方法解析步骤一致。
7.3.4 解析。
在初始化阶段时,变量会根据程序员通过程序编码指定的主观计划去初始化类变量和其他资源。官方表达:初始化阶段就是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编辑器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
<clinit>()方法与类的构造器函数(实例构造函数<init>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译期可以不为这个类生成<clinit>()方法。
7.3.5 初始化。
7.3 类加载的过程。
Java虚拟机设计团队有意将类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码就是类加载器。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。两个类是否“相等”,包括代表类的Class对象的equals()方法、isAssignableForm()方法、isInstance()方法返回的结果。其实是不是同一个类加载器。实验:当同一个Class文件被两个不同的类加载器去加载,在Java虚拟机中仍然是两个互相独立的类。
7.4.1 类与类加载器。
一,启动类加载器(BootStrap ClassLoader)负责加载java_home\\lib目录下,或者被-Xbootclasspath参数指定的路径
二,扩展类加载器(Extension Class Loader )负责加载java_home\\lib\\ext目录,或者被java.ext.dirs系统变量指定的路径
三,应用程序类加载器(Application Class Loader).负责加载用于类路径上的所有类库
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
7.4.2 双亲委派模型。
双亲委派模型并不是具有强制约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。
第一次被破坏:JDK1.2为了兼容之前的逻辑
第三次被破坏:是由于用户对程序动态性的追求。例如代码热替换,模块热部署。可以去了解OSGi的模块化热部署。
7.4.3 破坏双亲委派模型。
7.4 类加载器。
模块化的关键目标是可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整。
可配置的封装隔离机制首先要解决JDK9之前基于类路径来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。
由类路径 对应到 模块路径
JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包。
模块在模块路径的访问规则:模块路径下的具名模块只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。
JAR文件在模块路径的访问规则:如果一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。
Java模块化系统目前没有版本号的概念,只能人工去选择模块。
7.5.1 模块兼容性。
模块下的类加载器仍然发生了一些变动:1,扩展类加载器 被平台类加载器取代。2,平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,现在启动类加载器、平台类加载器、应用程序类加载器全部继承于jdk.internal.loader.BuiltinClassLoader.
JDK9中虽仍然维持着三层类加载器和双亲委派的架构。但是当平台及应用程序类加载器收到类加载请求,在委派给父加载器之前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
7.5.2 模块化下的类加载器。
7.5 Java模块化系统。JDK9引入。
第7章 虚拟机类加载机制介绍了类加载过程的“加载” “验证” “准备” “解析”和 “初始化” 五个阶段中虚拟机,分别进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
8.1 概述。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行和编译执行两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的及时编译器一起工作。从外部看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。
每一个栈帧都包括了局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息。
一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为“当前方法”。
用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference(引用地址)或returnAddress类型的数据。它运行变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化。
对于long和double这两种64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。
由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有别static修饰),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
局部变量不像前面介绍的类变量那样存在“准备阶段”。类的字段变量有两次赋初始值的过程,一个在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。
8.2.1 局部变量表。
是一个后入先出栈。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译期必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。举例:一个long和一个float不会出现在iadd指令上
另外再概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多数虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分的重叠。不仅节约空间,还直接共享一部分数据。
8.2.2 操作数栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转换为直接引用,这种转换被称为静态解析。另外一部分将在每一次运行期间都被转化为直接引用,这部分就称为动态连接。
8.3.3 动态连接。
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是有返回值以及返回值的类型将根据遇到何种方法返回指令来决定(当一个方法完成执行并返回一个值时,这个返回值会被直接压入到调用者方法的操作数栈中)。【正常调用退出】
第二种是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。【异常调用完成】。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值。
无论采用那种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:1,恢复上层方法的局部变量表和操作数栈2,把返回值(如果有的话)压入调用者栈帧的操作数栈3,调整PC计数器的值以指向方法调用指令后面的一条指令。
8.2.4 方法返回地址。
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。
8.2.5 附加信息。
8.2 运行时栈帧结构。
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用那一个方法),暂时还未涉及方法内部的具体运行过程。
Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符合引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。
所用方法调用的目标方法在Class文件里面都是一个常量池中符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。
编译期可知,运行期不可变。符合这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部可不访问,这两种方法各自的特点决定了他们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机支持以下5条方法调用字节码指令:1,invokestatic。属于调用静态方法2,invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。3,invokevirtual。用于调用所有的虚方法。4,invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。5,invokedynamic。先在运行期动态解析出调用点限定符所引用的方法,然后再执行该方法。
在类加载的时候可以把符号引用解析为该方法的直接引用的方法有:静态方法,私有方法,实例构造器,父类方法,final方法
8.3.1 解析。
java语言的“重载”和“重写”在Java虚拟机中是如何实现的?这里的关注点事虚拟机如何确定正确的目标方法。
静态类型:父类实际类型:子类Human man = new Man();Human woman = new Woman();
静态类型和实际类型在程序中都可能发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的。而实际类型变化的结果在运行期间才可确定。
由于静态类型在编译期可知,所以在编译阶段,Javac编译期就根据参数的静态类型决定了会使用那个重载版本。
所有依赖静态类型来决定方法执行版本的分配动作,都称为静态分配。
需要注意的Java编译器虽然能确定出方法的重载版本,但是很多情况情况下这个重载版本不是唯一的,往往只能确定一个相对更合适的版本。原因是字面量天生的模糊性。涉及到类型的自动转换。
一,静态分配。重载。重载是基于方法签名的不同(参数类型、数量或顺序)在编译时做出的决策
二,动态分配。重写。是基于对象的实际类型在运行时做出的决策
8.3.2 分配。
8.3 方法调用。
动态类型语言的关键特征是它的类型检查的主体过程是在运行期间而不是编译期进行的。例如:ErLang、JS、Lua、Python
动态类型与静态类型语言各有好坏。
8.4.1 动态类型语言
在Java虚拟机上实现的动态类型语言使用的一种曲线救国方式(如编译时留个占位符)来实现,这样一来又增加了复杂度,带来了额外的性能和内存开销。
invokedynamic指令以及java.lang.invoke包的技术支持。
8.4.2 Java与动态类型。
这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为【方法句柄】(Method Hendle)。
仅站在Java语言的角度看,MethodHandle在使用和效果上与Reflection有众多相似之处。它们的区别如下:1,它俩本质上都是在模拟方法调用,但Reflection在模拟Java代码层次的方法调用,而MethodHandle是模拟字节码层次的调用。2,Reflection是重量级,而MethodHandle是轻量级。3,由于MethodHandle是对字节码的方法指令调用的模拟,理论上虚拟机在这方面做的各种优化,在MethodHandle同样支持。而Reflection则很难直接实施这些优化。
8.4.3 java.lang.invoke包
invokedynamic指令,首次引入于JDK7,但在JDK8中得到应用,尤其是Lambda表达式和方法引用等功能。1,动态绑定于链接:传统Java字节码中的方法调用指令(如invokevirtual,invokestatic等)在编译时就确定了调用的目标方法。而invokedynamic指令则将方法调用的解析推迟到了运行时,允许在程序执行过程中动态地选择和绑定方法实现。这意味着调用点可以在不修改原有字节码的情况下,指向不同的方法实现,为动态类型语言提供了灵活的调用机制。2,BootStrap Methods(引导方法):当JVM遇到invokedynamic指令时,它会调用预先指定的引导方法。这个引导方法负责初始化一个方法句柄,该句柄指向实际要执行的目标方法。引导方法可以是静态的,也可以执行复杂的逻辑来决定最终调用那个方法,比如根据运行时环境动态地选择示实现。3,动态类型语言支持:对于像Groovy,JPython这样的动态类型语言,invokedynamic
8.4.4 invokedynamic指令。
invokedynamic指令与此前4条传统的invoke*指令的最大区别是它的分配逻辑不是有虚拟机决定的,而是由程序员决定的。
怎么实现调用祖父类的方法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() { // 请读者在这里填入适当的代码(不能修改其他地方的代码) // 实现调用祖父类的thinking()方法,打印\"i am grandfather\" }}
import static java.lang.invoke.MethodHandles.lookup;import java.lang.invoke.MethodHandle;import java.lang.invoke.MethodType;class Test {class GrandFather { void thinking() { System.out.println(\"i am grandfather\"); }}class Father extends GrandFather { void thinking() { System.out.println(\"i am father\
void thinking() { try { MethodType mt = MethodType.methodType(void.class); Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField(\"IMPL_LOOKUP\
8.4.5 实战:掌握方法分派规则。
8.4 动态类型语言支持。
实际的虚拟机实现,譬如HotSpot的模板解释器工作的时候,并不是按照下文中的动作一板一眼地进行机械式计算,而是动态产生每条字节码对应的汇编代码来运行,这与概念模型中执行过程的差异很大,但是结果却能保证是一致的。
无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后获得执行能力。
8.5.1 解释执行。
Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,他们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,通俗的说就是我们主流PC机中物理硬件直接支持的指令集架构,这些指令集依赖寄存器进行工作。
基于栈的指令集示例:1+1iconst_1iconst_1iaddistore_0两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不太参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈中。
基于栈的指令集主要优点是可移植性,不用像寄存器一样收到硬件的约束。栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢。出栈近栈的操作意味着频繁的内存访问。
8.5.2 基于栈的指令集与基于寄存器的指令集。
8.5.3 基于栈的解释器执行过程。
8.5 基于栈的字节码解释执行引擎。
第8章 虚拟机字节码执行引擎 分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。
一个功能健全的Web服务器,要解决如下问题:1,部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。2,部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。3,服务器需要尽可能地保证自身安全不受部署的Web应用程序影响。4,支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。(热替换)
Tomcat的目录结构:1,/common目录下。类库可被Tomcat和所有的Web应用程序共同使用。2,/server目录下。类库可被Tomcat使用,对所有的Web应用程序都不可见。3,/shared目录。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。4,/WebApp/WEB-INF目录。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。
Tomcat自定义的类加载器:1,Catalina类加载器2,Shared类加载器3,WebApp类加载器4,Jsp类加载器
9.2.1 Tomcat:正统的类加载器架构
全称:Open Service Gateway Initiative。是OSGi联盟制定的一个基于Java语言的动态模块化规范。
OSGi中的每个模块(称为Bundle)与普通的Java类库区别并不很大,两者一般都是以JAR格式进行封装,并且内部存储都是Java的Package和Class.但是一个Bundle可以声明它所依赖的Package(通过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块变为平级模块之间的依赖,而且类库的可见性能得到了精确的控制。
OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。
9.2.2 OSGi:灵活的类加载器架构。
查看在运行时产生的代理类中写了写什么,可以在main()方法中加入:System.getProperties().put(\"sun.misc.ProxyGenerator.saveGeneratedFiles\
9.2.3 字节码生成技术与动态代理的实现。
把高版本JDK中编写的代码放到低版本JDK环境中去部署应用,这种名为“Java逆向移植”的工具(Java Backproting Tools)应用而生,Retrotranslator和Retrolambda是这类工具中的杰出代表。
原理:Backporting工具在旧版本JDK中模拟新版本的JDK功能。
9.2.4 Backport工具:Java的时光机器。
9.2 案例分析关于类加载器和字节码的案例。
需求场景:程序维护过程中,排查问题时,想查看内存中的一些参数值,却苦于没有办法吧这些值输出到界面或日志中。【在服务端执行临时代码】
1,不依赖某个JDK版本才加入的特性
2,不侵入原有程序
3,临时代码应直接支持Java语言
4,临时代码应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里说的不需要并不是不可以。
5,临时代码的执行结果能返回客户端。
9.3.1 目标:在服务端执行临时代码
1,如何编译提交到服务器的Java代码?
2,如何执行编译之后的Java代码
3,如何收集Java代码的执行结果
程序实现需要解决的问题
9.3.2 思路。
9.3.4 实现。
9.3.5 验证。
9.3 实战:自动动手实现远程执行功能。
第9章 类加载及执行子系统的案例与实战 通过几个类加载及执行子系统的案例,介绍了使用类加载器和处理字节码的一些值得欣赏和借鉴的思路,并通过一个实战练习加深读者对前面理论知识的理解。
第三部分 虚拟机执行子系统
10.1 概述。在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述因为它可能是指一个前端编译器(叫“编译期前端”更准确一点)把*.java文件转变成*.class文件的过程。例如:JDK中的Javac也可能是指Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;例如:HotSpot虚拟机的C1、C2编译器还可能是指静态的提前编译器(常称AOT编译器)直接把程序编译成与目标机器指令集相关的二进制代码的过程。例如:JDK中的Jaotc
1,准备过程:初始化插入式注解处理器。
2,解析与填充符号表过程,包括:-词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象方法树。-填充符号表。产生符号地址和符号信息。
3,插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,后续讲解。
4,分析与字节码生成过程:-标注检查。对语法的静态信息进行检查。-数据流及控制流分析。对程序动态运行过程进行检查。-解语法糖。将简化代码编写的语法糖还原为原有的形式。-字节码生成。将前面各个步骤生成的信息转化成字节码。
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程:
10.2.1 Javac的源码与调试
词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如int a = b + 2这句代码中就包含了6个标记,分别是int、a、=、b、+、2
语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(AST)是一种用来描述程序代码语法结构的树形表示形式,它的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释。
1,词法、语法分析。
符号表是由一组符号地址和符号信息构成的数据结构,可以理解为哈希表结构。
2,填充符号表。
10.2.2 解析与填充符号表。
插入式注解处理器可以看做是一组编译器的插件,当这个插件工作时,允许读取、修改、添加抽象语法树中的任意元素。
如果这些插件在处理注解期间对语法树进行过修改,编译期将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器没有再对语法树进行修改为止。
Lombok就是插入式注解处理器的典型代表。
10.2.3 注解处理器。
经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源代码程序的语义是符合逻辑的。
语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质检查,譬如进行类型检查、检查流控制、数据流检查等等。
1,标注检查。要检查的内容包括诸如变量使用前是否已被声明、变量和赋值之间的数据类型是否能够匹配等等。
2,数据及控制流分析。对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上是一致的,但校验范围不同。
3,解语法糖。Java中常见的泛型、变长参数、自动装箱拆箱等,JVM运行时并不直接支持这些语法,他们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
4,字节码生成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法书、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。例如前面提到的<init>()方法和<clinit>()方法。
10.2.4 语义分析与字节码生成。
10.2 Javac编译器。由java语言实现。
C#里面的泛型无论在程序源码里面、编译后的中间语言表示(这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List<int>与List<String>就是两个不同的类型,它们由系统在运行期生成,有着独立的虚方法表和类型数据。
Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java来说,ArrayList<Integer>与ArrayList<String>其实是同一个类型。
Java中不支持的泛型用法:1,无法对泛型进行实例判断2,无法使用泛型创建对象3,无法使用泛型创建数组
1,Java与C#的泛型。
2,泛型的历史背景。
原始类型的泛型不支持(目前Java不支持)ArrayList<int> ilist = new ArrayList<int>();ArrayList<long> llist = new ArrayList<long>();ArrayList list;list = ilist;list = llist;
当泛型遇见重载1:public class GenericTypes { 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)\"); }} // 这段代码无法编译通过当泛型遇见重载2:public class GenericTypes { 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; } public static void main(String[] args) { method(new ArrayList<String>()); method(new ArrayList<Integer>()); }} // 可以正确执行
3,类型擦除。
10.3.1 泛型。泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛华的数据类型编写相同的算法,这极大地增强了编程语言的类型系统以及抽象能力。
10.3.2 自动装箱、拆箱与遍历循环。建议实际编码中尽量避免使用自动装箱和拆箱。
Java语言条件编译的实现,也是Java语言的一颗语法糖,根恶布尔常量值的真假,编译期将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。
由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。
10.3.3 条件编译。
10.3 Java语法糖的味道。
程序写的好不好的辅助校验工具:CheckStyle、FindBug、Klocwork等。
命名规范:1,类(或接口):符合驼峰式命名,首字母大写。2,方法:符合驼峰式命名,首字母小写。3,字段:类或实例变量。符合驼峰式命名法,首字母小写。常量。要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
10.4.1 实战目标。编写一款拥有自己编码风格的校验工具(NameCheckProcessor)。
实现注解处理器的代码需要继承抽象类javax.annotation.processing.AbstractProcessor。这个抽象类中只有一个子类必须实现的抽象方法:process()。它是Javac编译器在执行注解处理器代码时要调用的过程。
第一个参数:annotations获取到此注解处理器所要处理的注解集合。第二个参数:roundEnv 获取访问到当前这个轮次(Round)中的抽象语法树节点,每个语法树节点在这里都表示一个Element。实例变量:processingEnv,它是AbstractProcessor中的一个protected变量,在注解处理器初始化 的时候(init()方法执行的时候)创建,继承了AbstractProcessor的注解处理器代码可以直接访问它。它代表了注解处理器框架同的一个上下文环境,要创建新的代码、向编译期输出信息、获取其他工具类等都需要用到这个实例变量。
需要配合使用的注解:@SupportedAnnotationTypes@SupportedSourceVersion
10.4.2 代码实现。
我们可以通过Javac命令的-processor 参数来执行编译时需要附带的注解处理器。如果有多个注解处理器的话,用逗号隔开。
10.4.3 运行与测试。
10.4 实战:插入式注解处理器。
第10章 前端编译与优化 分析了Java语言中泛型、主动装箱拆箱、条件编译等多种语法糖的前因后果,并实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。
11.1 概述。如果我们把字节码看做是程序语言的一种中间表示形式的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器 进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译期被称为即时编译器。
1,为何HotSpot虚拟机要使用解释器与即时编译期并存的架构?2,为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?3,程序何时使用解释器执行?何时使用编译器执行?4,哪些程序代码会被编译为本地代码?如何编译本地代码?5,如何从外部观察到即时编译器的编译过程和编译结果?
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。当程序启动后,随着时间的推移,编译期逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提示效率。
HotSpot内置了两个(或三个)即时编译器,其中有两个存在已久,客户端编译器(C1)和服务端编译器(C2)。第三个是JDK10出现的,计划代替C2的Crall编译器。
通过-client和-server参数强制指定虚拟机运行在客户端模式还是服务端模式。
由于即时编译器占用程序运行时间,分层编译的概念就出现了:1,第0层。程序纯解释执行,并且解释器不开启性能监控功能。2,第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。3,第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。4,第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集分支跳转、虚方法调用版本等全部的统计信息。5,第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
11.2.1 解释器与编译器。
热点代码主要有两类:1,被多次调用的方法。2,被多次执行的循环体。对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”,目前主流的热点探测判定方式有两种:1,基于采样的热点探测。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现栈顶,那这个方法就是“热点方法”。好处是实现简单高效,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。J9使用。2,基于计数器的热点探测。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法就是执行次数,如果执行次数超过一定的阈值就认定为是“热点方法”。好处是精确严谨,缺点是是实现复杂,需要为每个方法建立并维护计数器。HotSpot使用。HotSpot为每个方法准备了两类计数器:方法调用计数器、回边计数器。回边的意思是指在循环边界往回跳转。
11.2.2 编译对象与触发条件。
在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译期还未完成编译之前,都仍然按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。用户可通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。
客户端编译器:第一阶段:一个平台独立的前端就爱那个字节码构造成一种高级中间代码表示。第二阶段:一个平台相关的后端从高级中间代码中产生低级中间代码表示。第三阶段:在平台相关的后端使用线性扫描算法优化,然后产生机器代码。
服务端编译器:无用的代码消除循环展开循环表达式外提消除公共子表达式常量传播基本块重排序范围检查消除空值检查消除
11.2.3 编译过程。
需要FastDebug或SlowDebug优化级别的HotSpot虚拟机支持。如果是自己编译的jdk,将参数--with-debug-level设置为fastDebug或slowDebug
-XX:+PrintCompilation要求虚拟机在即时编译时将编译成本地代码的方法名称打印出来。
平台适配器:hsdis-sparc。用于反汇编。然后放置在JAVA_HOME/lib/amd64/server目录下
11.2.4 实战:查看及分析即时编译结果
11.2 即时编译期。
AOT的优势:1,启动速度更快:因为程序已经被编译成机器码,所以应用程序启动时无需等待编译过程,这使得启动时间大大减少。2,更好的性能:理论上,AOT编译可以对代码进行更深入的优化,因为它在编译时拥有更多时间和资源来分析和优化代码,从而可能产生更高效的机器码。3,离线运行:编译后的程序不需要编译器或解释器即可运行,适合在没有这些工具的环境中部署,如嵌入式系统或移动设备。4,安全性增强:源代码或中间代码不再随应用程序分发,这可以增加逆向工程的难度,提高应用的安全性。劣势:1,编译时间较长:AOT编译整个应用程序可能需要较长时间,尤其是在大型项目中,这会影响开发周期和迭代速度。2,体积增大:编译后的机器码通常比源代码或中间代码占用更多空间,可能导致最终的应用程序体积增大。3,平台依赖:AOT编译通常需要针对特定的目标平台进行,这意味着如果要支持多个平台,就需要为每个平台单独编译,增加了维护成本。4,动态特性受限:一些语言的动态特性(如反思、动态类型加载等)在AOT环境下难以实现或完全不可用,可能需要额外的工作来绕过这些限制,或者在设计时就必须规避这些特性。
11.3.1 提前编译的优劣得失
JDK9引入了用于支持对Class文件和模块进行提前编译的工具Jaotc,以减少程序的启动时间和到达全速性能的预热时间。
静态链接库示例:libHelloWorld.so
java -XX:AOTLibrary=./libHelloWorld.so HelloWorldHello World!
11.3.2 实战:Jaotc的提前编译。
11.3 提前编译器。
即时编译期对这些代码优化变换是建立在代码的中间表示或者是在机器码之上的,而不是直接在Java源码上去做的。
11.4.1 优化技术概览。
内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。
概念理解:把目标方法的代码原封不动地【复制】到发起调用的方法之中,避免发生真实的方法调用而已。
11.4.2 方法内联。
基本原理:分析对象动态作用域,当一个对象在方法里面被定义,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种成为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸。从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说就是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化:
1,栈上分配:在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
2,标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
3,同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
-XX:+DoEscapeAnalysis,手动开启逃逸分析-XX:+EliminateAllocations ,手动开启标量替换-XX:EliminateLocks,手动开启同步消除-XX:+PrintEliminateAllocations 查看标量的替换情况
11.4.3 逃逸分析。
如果一个表达式E之前已经被计算过了,并且从先前的计算机到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。
11.4.4 公共子表达式消除。
无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次不漏地进行则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无须判断了。更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
11.4.5 数组边界检查消除。
11.4 编译期优化技术。
HotSpot即时编译期以及提前编译器共同的最新成果。
11.5 实战:深入理解Graal编译器。
第11章 后端编译与优化 讲解了虚拟机的热点探测方法、HotSpot的即时编译器、编译触发条件,以及如何从虚拟机外部观察和分析即时编译的数据和结果,还选择了几种常见的编译期优化技术进行讲解。
第四部分 程序编译与代码优化
12.1 概述。
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
缓存一致性问题。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统。
12.2 硬件的效率与一致性。
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
JMM内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
12.3.1 主内存与工作内存。
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了一下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外。)
1,lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。2,unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。3,read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。4,load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。5,use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。6,assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。7,store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。8,write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,|如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。备注:只要求这两个操作必须按顺序执行,但不要求连续执行。
JMM还规定了在执行上述8种基本操作时必须满足如下规则:1,不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。2,不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。3,不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。4,一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。5,一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。6,如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。7,如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。8,对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
12.3.2 内存间交互操作。
volatile是Java虚拟机提供的最轻量级的同步机制。
1,保证此变量对所有线程的可见性。单个volatile并不能保证线程安全性,即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。参数-XX:+PrintAssembly参数可以更严谨的进行反汇编。所有仍然要通过加锁来保证原子性:1,运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。2,变量不需要与其他的状态变量共同参与不变约束。
2,禁止指令重排序优化。普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
当一个变量被定义成volatile之后,它将具备两项特性:
12.3.3 对于volatile型变量的特殊规则。
JMM要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种情况非常罕见。
JDK9,HotSpot增加了实验性的参数-XX:+AlwaysAtomicAccesses 来约束虚拟机对所有数据类型进行原子性的访问。
针对double类型,现代中央处理器中一般都包含专门用于处理浮点数的浮点运算器,用来专门处理单、双精度的浮点数据。
在实际的开发中,除非该数据有明确可知的线程竞争,否则无需将long和double变量专门声明为volatile。
12.3.4 针对long和double型变量的特殊规则。
1,原子性。由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。如果应用场景需要一个更大范围的原子性保证,JMM还提供了lock和unlock操作来满足这种需求。尽快虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次字节码指令monitorenter和monitorexit来隐式地使用这两个操作。synchronized
2,可见性。可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得。而final的可见性是指:被final修饰的字段在构造器中一旦初始化完,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。
3,有序性。如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
12.3.5 原子性、可见性与有序性。
这个原则是判断数据是否存在竞争,线程是否安全的非常有用的手段。
先行发生事Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在:1,程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。备注:这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。2,管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。3,volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指时间上的先后。4,线程启动规则:Thread对象的start()方法先行发生于此线程的每个动作。5,线程终止规则:线程中的所有操作都先行发生于对线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。6,线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。7,对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。8,传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
12.3.6 先行发生原则。
12.3 Java内存模型。JMM模型来屏蔽各种硬件和操作系统的内存访问差异。
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本单位。
实现线程主要有三种方式:1,内核线程实现使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程的缺陷:1,用户态与内核态来回切换的资源消耗2,要消耗一定的内核资源
2,用户线程实现。使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种。狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。
3,混合实现。N:M在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。既又。
以HotSpot为例(一般),它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎样映射的。
4,Java线程的实现。
12.4.1 线程的实现。
协同式线程调度。线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。缺陷:执行时间不可控制,可能导致长时间的阻塞。
抢占式线程调度。那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有什么办法的。优势:线程执行时间可控。Java使用的就是抢占式线程调度。
线程调度时指系统为线程分配处理器使用权的过程,调度方式主要有两种。
Java语言一共设置了10个级别的线程优先级。优先级越高的线程越容易被系统选择执行。这只是一种优先级,并不稳定,因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的。
12.4.2 Java线程调度。
Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中一个状态,并且可以通过特定的方法在不同状态之间转换。
1,新建(New):创建后尚未启动的线程处于这种状态。
2,运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
3,无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:-没有设置Timeout参数的Object::wait()方法-没有设置Timeout参数的Thread::join()方法-LockSupport::park()方法
4,限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:-Thread::sleep()方法-设置了Timeout参数的Object::wait()方法-设置了Timeout参数的Thread::join()方法-LockSupport::parkNanos()方法-LockSupport::parkUntil()方法
5,阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
6,结束(Terminated):已终止线程的线程状态,线程已经结束执行。
12.4.3 状态切换。
12.4 Java与线程。
12.5.1 内核线程的局限。1:1的内核线程模型时如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统商的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。当请求达到数百万级到达线程池时,系统即时能够处理得过来,但其中的切换损耗也是相当可观的。
内核线程的调度成本主要来自于用户态与核心态之间的状态切换,而这两种状态切换的开销主要来自于响应中断、保护和恢复执行现场的成本。
“程序的上下文”:以程序员的角度来看,是方法调用过程中的各种局部的变量与资源。以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。
有栈协程:有栈协程:await、async、yield
12.5.2 协程的复苏。
案例:2018的Jetty版本。Loom项目
在新并发模型下,一段使用纤程并发的代码会被分为两部分——执行过程(Continuation)和调度器(Scheduler)。1,执行过程主要用于维护执行现场,保护、恢复上下文状态。2,调度器则负责编排所有要执行的代码的顺序。将调度程序与执行过程分离的好处是,用户可以选择自行控制其中的一个或者多个,而且Java中现有的调度器也可以被直接重用。[用户_97008097]
12.5.3 Java的解决方案。
12.5 Java与协程。
第12章 Java内存模型与线程 讲解了虚拟机Java内存模型的结构及操作,以及原子性、可见性和有序性在Java内存模型中的体现;介绍了先行发生原则的规则及使用,以及线程在Java语言之中是如何实现的;还提前介绍了目前仍然在实验室状态的Java协程的相关内容。
13.1 概述。面向过程思想:数据和过程分离,站在计算机的角度去抽象和解决问题。面向对象思想:数据和行为看做对象的一部分,是站在现实世界的角度去抽象和解决问题。
定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么就称为这个对象是线程安全的。
共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何保护操作来保证多线程环境下的正确调用。
1,不可变。在Java语言里面(特指JDK5之后,Java内存模型被修正之后),不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。只要一个不可变的对象被正确构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。
2,绝对线程安全。要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
3,相对线程安全。相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
4,线程兼容。线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全的使用。
5,线程对立。线程对立时指不管调用端是否采取了同步操作,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。
13.2.1 Java语言中的线程安全。
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
使用synchronized的注意点:-被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。-被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
ReentrantLock与synchronized增加了一些高级功能:1,等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。2,公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。3,锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。
为什么推荐使用synchronized而非ReentrantLock:1,synchronized是在Java语法层面的同步,足够清晰,也足够简单。2,Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话则可以由Java虚拟机来确保即使出现异常,锁也能被自动释放。
1,互斥同步。阻塞同步。悲观。
需要硬件支持,硬件保证某些从语义上看起来需要多次操作行为可以通过一条处理器指令就能完成,这类指令常用的有:1,测试并设置(Test-and-Set)2,获取并增加(Fetch-and-Increment)3,交换(Swap)4,比较并交换(Compare-and-Swap,下文称CAS)5,加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)
JDK5之后,Java类库中才开始使用CAS操作。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译处理的结果就是一个平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为无条件的内联进去。
JDK9之前想要使用CAS操作,就要采用参数手段突破Unsafed 访问限制,要么就通过Java类库API来间接使用它。JDK9之后,Java类库在VarHandle类里开放了面向用户程序使用的CAS操作。
2,非阻塞同步。乐观并发。
同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的
可重入代码:这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。特征:不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等
线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。示例:TheadLocal。
3,无同步方案。
13.2.2 线程安全的实现方法。
13.2 线程安全。
场景:共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
目前的绝大多数个人电脑和服务器都是多核,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
-XX: +UseSpinning自旋锁开关。JDK6默认开启。
自旋等待不能代替阻塞。
自旋等待的时间必须有一定的限度,如果自旋锁超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是10次,参数-XX: PreBlockSpin
JDK6引入了自适应自旋。意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态类决定。
13.3.1 自旋锁与自适应自旋。
是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
锁消除,意思是我即使在代码中加锁了,但是即时编译器发现无须加锁,也会进行优化,进行锁消除
13.3.2 锁消除。
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
理解:扩大临界区。
13.3.3 锁粗化。
它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
基于对象头中的Mark Word。
由于对象头信息是对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存存储尽量多的信息。
轻量级锁的工作过程:1,在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。2,虚拟机将使用CAS操作尝试把对象的Mark Workd更新为执行Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。如果这个这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先或检查对象的Mark Word是否指向了当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程进制用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。3,解锁过程同样是通过CAS操作来进行的。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争关系的”这一经验法则。
13.3.4 轻量级锁。
JDK6引入,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
“偏”就是偏心的偏、偏袒的偏。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
参数-XX: +UseBiased Locking。偏向锁开关。JDK6默认开启。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向锁设置为“1”,表示进行偏向模式。同时会用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对Mark Word的更新操作时。)一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。
问题:当对象进入偏向状态的时候,Mark Word大部分空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。
如果在程序中大多数的锁都总是被多个不同的线程访问,那偏向模式是多余的。参数-XX:-UseBiaseLocking 可以用来禁止偏向锁优化反而可以提升性能。
13.3.5 偏向锁。
13.3 锁优化。
第13章 线程安全与锁优化介绍了线程安全所涉及的概念和分类、同步实现的方式以及虚拟机的底层运作原理,并且介绍了虚拟机实现高效并发所做的一系列锁优化措施。
第五部分 高效并发
深入理解JVM虚拟机
![JVM虚拟机](https://www.processon.com/chart_image/template/thumb/6693933d95015f55cdc89858.png?tid=66820abeae4058056f1b33d7)
收藏
![Java并发编程实战](https://www.processon.com/chart_image/template/thumb/6693903b339a5b613a74123c.png?tid=65dd9f63235aaa17952ff0df)
收藏
0 条评论
回复 删除
下一页