深入理解Java虚拟机

第一章 走进Java

概述

Java 结构严谨、面向对象、摆脱硬件平台束缚,一次编译到处运行相对安全的内存管理与访问机制(避免了绝大部分内存泄漏和指针越界问题)、实现了热点代码检测和运行时编译及优化

Java技术体系

1、Java程序设计语言

2、各种硬件平台上的Java虚拟机实现

3、Class文件格式

4、Java类库API

5、来自商业机构和开源社区的第三方Java类库

根据Java各个组成部分的功能划分

JDk:Java程序设计语言、Java虚拟机、Java类库 JDK是支持Java程序开发的最小环境

JRE:Java类库中API中的Java SE API子集和Java虚拟机 JRE是支持Java程序运行的标准环境

根据技术所服务的领域划分

Java Card:支持Java小程序(Applets)运行在小内存设备(如智能卡)上的平台。

Java ME(Micro Edition):支持Java程序运行在移动终端(手机、PDA)上的平台。

Java SE(Standard Edition):支持面向桌面级应用(如Windos下的应用程序)的Java平台。

Java EE(Enterprise Edition):支持使用多层架构的企业应用(如ERP、MIS、CEM应用)的Java平台。

第二章 Java内存区域与内存溢出异常

概述

C、C++:即拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到中介的维护责任。

Java:不需要为每一个new对象写delete/free代码,不容易出现内训泄漏和内存溢出的问题。

运行时数据区域

img

程序计数器(线程私有)

程序计数器是一个较小的内存空间,当前线程所执行的字节码的行号指示器。****程序控制流的指示器****

当字节码解释其工作的时候就是通过改变程序计数器的值来选去下一条需要执行的命令。

分支、循环、跳转、异常处理、线程恢复等基本功能依赖它完成。

当处理多线程时,每个线程都需要有一个独立的程序计数器,它们互不影响、独立存储(线程私有)

如果线程执行Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址

如果线程执行Native方法,程序计数器值为空

程序计数器是唯一一个没有规定任何OutOfMemoryError(内存溢出)情况的区域

Java虚拟机栈(线程私有)

生命周期与线程相同。

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(帧:方法运行期的一个重要基本数据结构)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

每一个方法被调用到执行完毕的过程就对应着一个栈帧在虚拟机栈入栈和出栈的过程。

本地方法栈(线程私有)

与虚拟机栈的作用类似,虚拟机栈为Java方法服务,本地栈为虚拟机使用的本地方法服务。

Java堆(线程共享)

虚拟机所管的最大的一块。Java堆是被所有线程共享的一块内存区域。

在虚拟机启动的时候就被创建。用来存放对象实例

《Java虚拟机规范》中对Java堆的描述:“所有的对象实例以及数组都应当在堆上分配”。

Java堆是垃圾收集器管理的内存区域,因此有些资料中称此为“GC堆”。

从内存分配的角度看,所有线程共享的Java堆中可以划分为多个线程私有的分配缓冲区(TLAB)以提升对象分配的效率。

Java堆可以处于物理上不连续的内存空间中,但是在逻辑上他应该被视为连续的。

Java堆既可以被实现为固定大小的,也可以是可扩展的。

方法区(线程共享)

方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法区和Java堆一样不需要连续的内存空间、可以选择固定大小或可扩展,甚至不需要实现垃圾收集。

方法区中的内存回收目标主要是针对常量池的回首和对类型的卸载(回收效果不好、但是有时候必要)

运行时常量池

它是方法区的一部分,Class文件中有一项信息叫常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容会在类加载后放到方法区的运行时常量池中。

除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的特点:

1、Java虚拟机对运行时常量池没有做过任何细节要求,对Class文件常量池格式有严格规定。

2、运行时常量池具备动态性,运行期间可以将新的常量放入池中。(String类中的intern()方法)

直接内存

直接内存并不是虚拟机运行时数据区的一部分,在JDK1.4中新加入了NIO(New Input/Outout)类避免了在Java堆和Native堆中来回复制数据,在一些场景中显著提升性能。

HotSpot虚拟机对象探秘

对象的创建(普通对象,不包括数组和Class对象)

Java虚拟机遇到一条字节码new指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有先执行相应的类加载过程。接下来虚拟机为新生对象分配内存(把一块确定大小的内存块从Java堆中划分出来),在类加载完成后对象所需内存就会被计算出来,如果Java堆中的内存绝对规整,则分配方式为“指针碰撞”。否则虚拟机必须维护一个表,记录哪些内存块是可用的这种分配方式叫“空闲列表”。Java堆是否规整由垃圾收集器是否带有空间压缩整理能力决定。

创建对象是非常频繁的行为,在并发的情况下仅修改一个指针所指向的位置不是线程安全的

解决方法:

1、对分配的内存空间的动作进行同步处理

2、把分配内存的动作按照线程划分在不同的空间之中进行(每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),那个线程要分配内存就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定)。

内存分配完成之后,虚拟机将分配到的内存空间(除对象头之外)都初始化为零,如果使用了TLAB本操作可在TLAB分配前顺便进行。这一步操作目的:保证了对象的实例字段在Java代码中可以不赋初始值直接使用,使程序能访问到这些字短的数据类型所对应的零值。

Java虚拟机对对象进行必要的设置,把信息存储到对象的对象头之中。根据虚拟机当前运行的状态的不同,对象头会有不同的设置方式。

上述步骤完成后在虚拟机视角对象的创建已经完成。从Java程序视角,对象的创建才开始。

构造函数,即Class文件中的<init>()方法没有执行,那么所有的字段都为默认的零值,对象所需要的其他资源和状态信息也没有按照预定的意图构造好。</init>

对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三部分:对象头、实例数据、对齐填充

对象头部分包括两类信息:

1、MarkWord:用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程所持有的锁)。

2、类型指针:对象指向它的类型元数据的指针。Java虚拟机通过它来确定该对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,即我们在代码程序中所定义的各种类型的字段内容。

对齐填充起着占位符的作用,非必要存在。

对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。

reference类型是一个指向对象的引用,访问堆中对象的具***置的主流方式有两种:

1、使用句柄访问,Java堆中将可能会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。

好处:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

2、使用直接指针访问,Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话就不需要多一次间接访问的开销。

好处:速度更快,节省了一次指针定位的开销。

一般都使用直接指针访问

实战:OutOfMemoryError异常

除程序计数器之外,其他几个区域都有可能出现内存溢出异常(OOM)

Java堆OOM异常处理

首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否必要,也就是要分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与那些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确的定位到这些对象创建的位置,进而找到产生泄漏的代码的具***置。

如果不是内存泄漏(内存中的对象是必须存活的),应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看有没有向上调整的空间。再从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

1、如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

2、如果虚拟机栈允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

由于HotSpot虚拟机选择了不支持栈的动态扩展(可选),所以有两种情况,并给出了验证方法:

1)创建线程申请内存时就因无法获得足够内存而出现StackOverflowError异常

使用-Xss参数减少栈内存容量

2)线程运行时是不会因为扩展而导致内存溢出,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常

定义大量的本地变量,增大此方法帧中本地变量表的长度

这两种方法表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。可是如果在允许可动态扩展栈容量的虚拟机上就会变成OutOfMemoryError异常。

如果测试过程中不限于单线程,而是不断建立线程的方式在HotSpot上也会出现内存溢出异常,此时与栈容量空间是否足够不存在直接关系。主要取决于操作系统本身的内存使用状态。

方法区和运行时常量池溢出

在JDK 8以后,永久代被元空间替代。在默认的设置下,正常的动态创建新类型的测试用例很难破事虚拟机产生方法区的溢出异常。

本机直接内存溢出

直接内存的容量大小可以通过- XX:MaxDirectMemorySize参数来指定,如果不指定的话就默认与Java堆最大值(-Xmx)一致。

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果内存溢出之后的Dump文件很小,而程序中由直接或间接的使用了DirectMemory(典型的间接使用就是NIO),那就可以重点检查一下直接内存方面的原因。

第三章 垃圾收集器与内存分配策略

概述

垃圾收集(Garbage Collection 简称GC)

了解GC的目的:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们需要对这些“自动化”的技术实施必要的监控和调节。

程序计数器、虚拟机栈、本地方法栈 三个区域随线程而生,随线程而灭。不需要过多的考虑如何回收的问题,当方法结束或者线程结束的时候,内存也就自然回收了。

*Java堆、方法区* 有很显著的不确定性。这部分的内存分配和回收是动态的。Java堆接口的多个实现类需要的内存可能不一样;方法区的方法所执行的不同条件分支所需要的内存也可能不一样。

Minor GC:新生代GC,指发生在新生代的垃圾收集动作,所有的Minor GC都会触发全世界的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。

Major GC/Full GC:老年代GC,指发生在老年代的GC。

新生代:新创建的对象都是用新生代分配内存,Eden空间不足时,触发Minor GC,这时会把存活的对象转移进Survivor区。

老年代:老年代用于存放经过多次Minor GC之后依然存活的对象。

对象已死?

垃圾收集器在对堆进行回收之前,首先需要确定这些对象之中哪些还“存活”,哪些已经“死去”(不可能再被任何途径使用的对象)。

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加一;当引用失效时,计数器的值减一;任何时刻计数器为零的对象就是不可能再被使用的。

Java虚拟机没有使用该方法;

优点:原理简单、判定效率很高

缺点:有很多的例外情况需要考虑,必须配合大量额外处理才能保证正确地工作,比如单纯的引用计数器就很难解决对象之间互相循环引用的问题。

可达性分析算法

基本思路:通过一系列称为“GC Roots“的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为”引用链“(Reference Chain)“,如果某个对象到GC Roots间,诶有任何引用链相连,或者用凸轮的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被引用的。

Java虚拟机使用此方法。

在Java技术体系中,可作为GC Roots的对象包括:

1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法对战中使用的参数、局部变量临时变量。

2、在方法区中类静态属性引用的对象,比如java类的引用类型静态变量。

3、在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用。

4、在本地方法栈中JNI(通常所说的Native方法)引用的对象。

5、Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(如NullPointException、OutOfMemoryError)等,还有系统类加载器。

6、所有被同步锁(synchronized关键字)持有的对象。

7、反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

再谈引用

判定对象是否存活都和“引用”离不开关系。

在JDK1.2版本后,Java对引用的概念进行扩充,将引用分为,强引用、软引用、弱引用、虚引用,强度依次减弱

1、强引用:程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。

无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

2、软引用:用来描述一些还有用,但非必须对象。

在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

3、弱引用:用来描述非必须对象。

被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收店只被弱引用相关联的对象。

4、虚引用(幽灵引用/幻影引用):一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。

设置虚引用的目的:在这个对象被回收时收到一个系统通知。

生存还是死亡?

真正判断一个对象死亡需要两步:

1、可达性分析算法分析后发现没有和GC Roots相连接的引用链。

2、对象是否有必要执行fanalize()方法。

假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为*“没有必要执行”*

如果对象被判定确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法的开始运行,但是并不承诺一定会等待它运行结束。目的:如果某个对象的finalize()方法执行缓慢,或者产生了死循环,将可能导致F-Queue其他对象永久地处于等待状态,可能会导致整个内存回收子系统的崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会。

任何一个对象的finalize()方法都只会被系统自动调用一次

回收方法区

方法区垃圾收集的性价比很低:在Java堆中,尤其是新生代中,对常规应用可回首70%-99%的内存空间。方法区的判定条件十分苛刻。

主要回收两部分内容:废弃的常量和不再使用的类型。

判定一个类型是否属于“不再被使用的类”的条件:

1、该类所有实例都被回收,Java堆中不存在该类及其任何派生子类的实例。

2、加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则很难实现。

3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

从如何判定对象消亡的角度看,垃圾收集算法可划分为*“引用计数式垃圾收集”和“追踪式垃圾收集”*两大类,这两类也被称为“直接垃圾收集”和“间接垃圾收集”。下文介绍追踪式垃圾收集(主流)。标记算法配合书中的图看。

分代收集理论

分代收集名为理论,实则是一套符合大多数程序运行实际情况的经验法则。它建立在弱分代假说、强分代假说上。

根据这两个假说,形成了大多数的垃圾收集器的一致的设计原则:将Java堆划分为不同的区域,然后回收对象根据其年龄分配到不同区域。

这样做的好处:如果一个区域大多数对象都是“朝生夕灭”,那么把它们放在一起,每次回收只关注如何保留少量存活,而不是标记那些大量要被回收的对象,就能以较低的代价回收到大量的内存空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆中划分好不同的区域才能够针对不同的区域安排与里面的存储对象存亡特征相匹配的垃圾收集算法。

把分代收集理论放到现在上用的Java虚拟机上,设计者至少会把Java堆分成新生代和老生代两个区域。

3、跨代引用假说:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

标记-清除算法(最早出现、最基础)

首先标记出所有需要回收的对象,完成标记后,统一回收掉所有被标记的对象。也可以反过来标记存活的对象。标记方法在上节划线部分。

缺点:

1、*执行效率太不稳定*,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象的数量增长而降低;

2、*内存空间的碎片化问题*,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

为了解决标记-清除算法面对大量可回收对象时效率低的问题。

现在商用的Java虚拟机大多都优先采用了这种收集算法去回收新生代。

Appel式回收:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor空间。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间中,然后清理掉Eden和已经用过的那块Survivor空间。

任何人都没办法保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象,就需要依赖其他区域(实际上大多就是老年代)进行分配担保。

缺点:在对象存活率较高时就要进行较多的复制操作,效率就会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

标记-整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

“标记-整理”算法与“标记-清除”算法的本质差异在于后者是一种非移动式的回收算法,而前者是移动的。

是否移动回收后的对象是一项优缺点并存的风险策略:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作。而且这种对象移动操作必须全程暂停用户应用程序才能进行

如果完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必*会影响应用程序的吞吐量*

HotSpot的算法实现细节

根结点枚举

迄今为止,所有的收集器在根结点枚举这一步骤时都是必须暂停用户线程的,必须在一个能够保障一致性的快照中进行。

由于目前主流的Java虚拟机使用的都是准确式垃圾收集,所以当用户停顿下来时,虚拟机不用一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象引用。在HotSpot的解决方案里采用OopMap的数据结构来达到这个目的

安全点

在OopMap的协助下能很快的完成根结点枚举,但是可能导致引用关系发生变化,或者说导致OopMap内容变化的指令非常多,如果每一条指令都声称对应的OopMap,那将会需要大量的额外存储空间,空间成本高昂。

前面已经提到,只是在“特定的位置”记录这些信息,这些位置被称为安全点。

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制必须到达安全点后才能够暂停。

安全点的位置选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度过长这样的原因长时间执行。

“长时间执行”最明显的特征就是指令序列的复用。

对于安全点还有一点需要考虑:如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来。有两种方案:

1、抢先式中断:不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。(几乎不用)

2、主动式中断:不直接对线程操作,仅仅简单的设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的方法,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

优点:安全点机制保证了程序执行时,在不太长时间内就会遇到可进入垃圾收集过程的安全点。

缺点:当没有分配处理器时间时,程序就不执行了,典型的场景就是用户线程处于Sleep或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能够走到安全的地方挂起自己。

安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始收集垃圾都是安全的。可理解为拉伸了的安全点。

当用户线程执行到了安全区域里面的代码时,首先会标识自己已经进入安全区域,当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程。

当线程要离开安全区域时,他要检查虚拟机是否已经完成了根结点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了那线程就当做什么事都没发生,继续执行。否则一直等待到收到可以离开安全区域的信号。

记忆集和卡表

为了解决对象跨代引用带来的问题,垃圾收集器在新生代中建立了名为记忆集(Memory Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上,所有涉及部分区域收集行为的垃圾收集器都会面临这样的问题。

记忆集是一种用于记录从非收集区指向收集区的指针集合的抽象数据结构。

在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以,不需要了解跨代指针的全部细节。设计者可选择使用记录粒度来节省记忆集的存储和维护成本,下面列举一些可供选择的记录精度:

1、字长精度:精确到机器字长。

2、对象精度:精确到一个对象,该对象里有字段含有跨代指针。

3、卡精度:精确到一块内存区域,该区域内有对象含有跨代指针。(用一种称为“卡表”的方式实现记忆集,目前最常用的一种记忆集实现形式,卡表是记忆集的一种具体实现形式)。

写屏障

解决卡表元素如何维护问题,例如它们何时变脏,谁把它们变脏。

卡表元素何时变脏:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就会变脏,变脏的时间点原则上应该发生在引用类型字段赋值那一刻。

如何在对象赋值那一刻去维护卡表:当解释字节码时,虚拟机负责每条字节码指令的执行,有充分的介入空间;在编译执行的场景中通过“写屏障”。

写屏障可以看成在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范围内(写前屏障/写后屏障)。

并发的可达性分析

看书

经典垃圾收集器

HotSpot垃圾收集器

连线表示可以搭配使用,上下代表新生代或者是老生代收集器。

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器。它是一种单线程垃圾收集器,这就意味着在其进行垃圾收集的时候需要暂停其他的线程,也就是之前提到的”Stop the world“。虽然这个过程是在用户不可见的情况下把用户正常的线程全部停掉,听起来有点狠,这点是很难让人接受的。Serial、Serial Old收集器的工作示意图如下:

Serial收集器

优点:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得较高的收集效率。到目前为止,Serial收集器依然是Client模式下的默认的新生代垃圾收集器。

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,ParNew收集器的工作示意图如下:

ParNew收集器

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。除去性能因素,很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

但是,在单CPU环境中,ParNew收集器绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。然而,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。

Parallel Scavenge收集器

Parallel Scavenge收集器是新生代垃圾收集器,使用复制算法,也是并行的多线程收集器。与ParNew收集器相比,很多相似之处,但是Parallel Scavenge收集器更关注可控制的吞吐量。吞吐量越大,垃圾收集的时间越短,则用户代码则可以充分利用CPU资源,尽快完成程序的运算任务。

直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。

除此之外,Parallel Scavenge收集器还可以设置参数-XX:+UseAdaptiveSizePocily来动态调整停顿时间或者最大的吞吐量,这种方式称为GC自适应调节策略,这点是ParNew收集器所没有的。

Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。其运行过程与Serial收集器一样。

Serial Old收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法进行垃圾回收。其通常与Parallel Scavenge收集器配合使用,“吞吐量优先”收集器是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。

CMS收集器

CMS收集器(Concurrent Mark Sweep)的目标就是获取最短回收停顿时间。在注重服务器的响应速度,希望停顿时间最短,则CMS收集器是比较好的选择。

整个执行过程分为以下4个步骤:

1、初始标记 2、并发标记 3、重新标记 4、并发清除

初始标记和重新标记这两个步骤仍然需要暂停Java执行线程,初始标记只是标记GC Roots能够关联到的对象,并发标记就是执行GC Roots Tracing的过程,而重新标记就是为了修正并发标记期间因用户程序执行而导致标记发生变动使得标记错误的记录。其执行过程如下:

CMS收集器

由上图可知,整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,因此,总体上CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS的优点很明显:并发收集、低停顿。由于进行垃圾收集的时间主要耗在并发标记与并发清除这两个过程,虽然初始标记和重新标记仍然需要暂停用户线程,但是从总体上看,这部分占用的时间相比其他两个步骤很小,所以可以认为是低停顿的。

缺点

1、对CPU资源太敏感,这点可以这么理解,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢

2、CMS收集器无法处理浮动垃圾。所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”

3、由于CMS收集器是基于“标记-清除”算法的,前面说过这个算***导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC(这个后面还会提到)这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。

G1收集器

与前几个收集器相比,G1收集器有以下特点:

1、并行与并发

2、分代收集(仍然保留了分代的概念)

3、空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)

4、可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)

此外,G1收集器将Java堆划分为多个大小相等的Region(独立区域),新生代与老年代都是一部分Region的集合,G1的收集范围则是这一个个Region(化整为零)。

G1的工作过程如下:

1、初始标记(Initial Marking)

2、并发标记(Concurrent Marking)

3、最终标记(Final Marking)

4、筛选回收(Live Data Counting and Evacuation)

初始标记阶段仅仅只是标记一下GC Roots能够直接关联的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段的用户程序并发运行的时候,能在正确可用的Region中创建对象,这个阶段需要暂停线程。并发标记阶段从GC Roots进行可达性分析,找出存活的对象,这个阶段食欲用户线程并发执行的。最终标记阶段则是修正在并发标记阶段因为用户程序的并发执行而导致标记产生变动的那一部分记录,这部分记录被保存在Remembered Set Logs中,最终标记阶段再把Logs中的记录合并到Remembered Set中,这个阶段是并行执行的,仍然需要暂停用户线程。最后在筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。整个执行过程如下:

G1收集器

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标:内存占用、吞吐量、延迟。一款优秀的收集器最多可以同时达成其中的两项。

延迟最被重视。

Shenandoah收集器

Shenandoah收集器是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“商业收费版”功能更多。

Shenandoah收集器最初是由RedHat公司独立发展的,后来贡献给了OpenJDk。目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,相比CMS和G1,Shenandoah收集器不仅要进行并发的垃圾标记,还要并发的进行对象清理后的整理动作。

Shenandoah相比于正朔血统的ZGC更像是G1的下一代继承者,二者有相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致。

Shenandoah相比于G1的改进,在管理堆内存方面,它与G1至少有三个明显的不同之处:

1)支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。

2)Shenandoah(目前)是默认不使用分代收集的。

3)Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“链接矩阵”的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题发生概率。

Shenandoah收集器的工作过程:初始标记、并发标记、最终标记、并发清理、并发回收、初始引用更新、并发引用更新、最终引用更新、并发清理。

Shenandoah收集器的并发回收的核心是,转发指针。
转发指针的核心内容就是,在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。
在这里插入图片描述
转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码仍然可用,都会被自动转发到新对象上继续工作。

Brooks Pointers Brooks Pointers 转发指针在设计上决定了它是必然会出现多线程竞争问题的。Shenandoah收集器是通过比较交换(Compare And Swap,CAS)操作来保证并发时堆中的访问正确性的。

1、Shenandoah收集器保证了收集垃圾的低延迟。
2、但是使用了过多的写屏障,所以导致Shenandoah收集器的弱项很明显,当数据量大的时候会产生高运行负担而使得吞吐量下降。

ZGC收集器

ZGC内存布局:ZGC的Region具有动态性——动态创建和销毁以及动态的区域容量大小。

ZGC并发整理算法通过染色指针技术实现。

ZGC是充分利用多线程和大内存(ZGC的分页会根据cpu核优先分靠近的内存),适合大堆和服务器多核的配置。因为大部分stop the world都是遍历gc root对象,所以暂停时间不长。对比G1的话,感觉是G1虽然是按照吞吐量来选择最优的Region进行回收,但是其拷贝移动的过程还是要stop the world(因为G1只有写屏障没有读屏障),而这点ZGC就有优势,其通过读屏障、remark标记和重定向表来并发拷贝非GC Root对象。

选择合适的垃圾收集器

Epsilon收集器

近年来大型系统从传统单体向微服务、无服务化方向发展的趋势越发明显,传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化的特点,对短时间、小规模的服务形式有诸多不适。如果应用只要运行数分钟或者数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon最合适。

收集器的权衡

三个因素:

1、应用程序的关注点(吞吐量、延迟、内存占用)

2、运行应用的基础设施(硬件规格、处理器数量、操作系统)

3、JDK发行商、版本

实战:内存分配与回收策略

对象优先在Eden分配

大对象直接进入老年代

长期存活的对象将进入老年代

动态对象年龄判断

如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于的对象就可以直接进入老年代,无须等到-XX:MAXTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果成立,则这一次Minor GC安全,如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试一次Minor GC(有风险),如果小雨,或者-XX:HandlePromotionFailure设置不允许冒险则进行一次Full GC。

第四章 虚拟机性能监控、故障处理工具

基础故障处理工具

JDK中的bin目录下根据软件可用性和授权可分为三类:

1、商业授权工具:主要是JMC(Java Mission Control)及它要使用到的JFR(Java Flight Recorder)。

2、正式支持工具:这一类工具属于被常识支持的工具,不同平台、不同版本的JDK之间,这类工具可能会略有差异,但不会出现突然消失的情况。

3、实验性工具:“没有技术支持,并且是实验性质的”产品,日后可能会转正,也可能在以后的JDK版本消失。事实上它们通常都稳定且功能强大,也能在处理应用程序性能问题、定位故障时发挥很大作用。

JDK开发团队使用Java语言来实现这些故障处理工具的目的

当应用程序部署到生产环境后,无论是人工物理接触到服务器还是远程Telnet到服务器上都可能会受到限制。借助这些工具类库里面的接口和实现代码,开发者可以选择直接在应用程序中提供功能强大的*监控分析功能*。

jps:虚拟机进程状况工具

可以列出正在运行的虚拟机进程,并执行虚拟机执行主类(Main Class)名称以及这些进程的本地虚拟机唯一ID(LVMID)。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖jps命令现实主类的功能才能区分了。

jstat:虚拟机统计信息监视工具

jstat是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,在没有GUI图形界面、只提供了纯文本控制台环境的服务器上、它将是运行期定位虚拟机性能问题的常用工具。

jinfo:Java配置信息工具

jinfo的作用是实时查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看迅即启动时显式指定的参数列表,但是如果想要知道未被显式指定的参数的系统默认值,就只能用jinfo的-flag选项进行查询了。

jmap:Java内存映像工具

jmap命令用于生成堆转储快照(一般称为heapdump或者dump文件)。如果不使用jmap命令,想要获取哈吧堆转储快照也有其他暴力手段。

jmap命令还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前使用的是哪种收集器等。

jhat:虚拟机堆转储快照分析工具

jhat与jmap搭配使用,jhat内置一个HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。

很少用jhat的原因:

1、一般不会在部署引用程序的服务器上直接分析堆转储快照,尽量将堆转储快照文件复制到其他机器上进行分析,就没有必要再受命令行工具的限制了。

2、jhat的分析功能较为简陋。

jstack:Java堆栈跟踪工具

jstack命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的是定位线程出现长时间停顿的原因,

线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待什么资源。

可视化故障处理工具

用户可以使用这些工具更加便捷的进行进程故障诊断和调试工作。

JHSDB:基于服务性代理的调试工具

JHSDB是一款基于服务性代理实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言实现的API集合。服务性代理以HotSpot内部的数据结构为参照物进行设计,把这些C++的数据抽象出Java模型对象,相当于HotSpot的C++代码的一个镜像

通过服务性代理的API,可以在一个独立的Java虚拟机的进程里分析其他的HotSpot虚拟机的内部数据,或者从HotSpot虚拟机进内存中dump出来的转储快照里还原出它的运行状态细节。

JConsole:Java监视与管理控制台

JConsole是一款基于JMX的可视化监视、管理工具。它的主要功能是通过JMX的MBean对系统进行信息收集和参数动态调整。

VisualVM:多合-故障处理工具

VisualVM优点:不需要被监视的程序基于特殊的Agent去运行,因此它的通用性很强,对应用程序实际性能的影响也小,是的他可以直接应用在生产环境中。

Java Mission Control:可持续在线的监控工具

HotSpot虚拟机插件及工具

1、Ideal Graph Visualizer:用于可视化展示C2即时编译器是如何将字节码转化为理想图,然后转化为机器码的。

2、Client Compiler Visualizer[1]:用于查看C1即时编译器生成高级中间表示(HIR),转换成低级中间表示(LIR)和做物理寄存器分配的过程。

3、MakeDeps:帮助处理HotSpot的编译依赖的工具。

4、Project Creator:帮忙生成Visual Studio的.project文件的工具。

5、LogCompilation:将-XX:+LogCompilation输出的日志整理成更容易阅读的格式的工具。

6、HSDIS:即时编译器的反汇编插件。

第五章 调优案例分析与实战

案例分析

大内存硬件上的程序部署策略

一个每天 PV 为 15 万左右的在线文档类型的网站更换了硬件系统,参数为 4 个 CPU、16 GB内存,操作系统为 centos 5.4,Resin做为 web 服务器。选用 64 位 的jdk 1.5,通过 -Xmx 和 -Xms 参数将 Java堆固定到 12GB。使用一段时间后效果不理想,网站经常出现长时间失去响应的情况。

监控服务器运行状况后发现网站失去响应是因为垃圾收集停顿导致的,虚拟机运行在 server 模式,默认使用的吞吐量优先收集器,回收 12GB 的堆内存,一次 Full GC 的停顿时间高达 14 秒。并且由于程序设计问题,访问文档时要把文档从磁盘提取到内存,导致内存出现由文档序列化产生的大对象,这些大对象直接进入老年代,没有在 Minor GC 中清理掉。这情况下即使有 12GB 的堆,内存也很快就被耗尽,由此出现每个十几分钟出现十几秒的停顿。

程序部署上的主要问题,是由于过大的堆内存进行回收时带来的长时间停顿。硬件升级前使用 32 位系统 1.5GB 的堆,用户只是感觉使用网站比较缓慢,还不会发现明显的停顿。

在高性能上部署程序,主要有两种方式:

  • 通过 64 位 JDK 来使用大内存。
  • 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。

最后的部署方案调整为建立 5 个32 位 JDK的逻辑集群,每个进程按 2GB 内存计算(其中固定堆为 1.5GB),占用 10GB 内存。另外建立一个 Apache 服务器作为前端负载均衡代理访问门户。同时考虑到用户对响应速度要求较高,并且文档服务器的主要压力集中在磁盘和内存访问,CPU 资源敏感度较低,因此改为 CMS 收集器。

集群间同步导致的内存溢出

一个基于 B/S 的 MIS 系统,硬件为两台 2 个CPU,8 GB内存的 HP 小型机器,服务器使用 weblogic,每台机器启动三个 weblogic 实例,构成 6 节点的亲和式集群。

亲和式集群:由于http请求是无状态的,那么对于会话级别的事务,如何保持用户的状态?在单个服务器中,提供了session-sessionID的机制来保存用户的状态那么现在有多台服务器,如何记录用户的状态?有两个大方向:session粘性、共享session。其中 session 粘性这种方式也成为亲和式集群,给session创造粘性,意思是让用户每次都访问的同一个应用服务器这样就要在前端服务器apache中记录下,用户首次访问的是哪个tomcat,将用户后面发送的请求都发送到这个tomcat上去这样带来的后果是,各个服务器负载不均衡,因为只在用户首次访问的时候,采用了负载均衡分发,但是这个影响也不会那么明显。参考资料:web服务器集群(亲和式集群,集群中保持用户状态)

服务器启动一段时间后,不定期的出现内存溢出问题。在服务启动时增加 -XX:+HeapDumpOnOutOfMemoryError 参数运行一段时间,分析出很多大量的 NAKACK 对象。这是因为程序为共享数据引入了 JBossCache 包,其中有个过滤器filter,每当请求来时,都会同步操作时间到各个节点,导致集群各个节点的网络交互频繁。当网络不稳定时,重发数据在内存会出现大量的堆积,很快就导致内存溢出了。

堆外内存导致的溢出错误

当出现内存溢出时,试着调大堆内存但是却不管用,同时开启了 -XX:+HeapDumpOnOutOfMemoryError 参数,也没有堆转储文件生成。而且使用 jstat 监控程序发现 GC 不频繁,Eden、survivor 区、老年代以及永久代显示压力不大,那么就需要查看异常信息中内存溢出的信息,发现是 DirectByteBuffer.java 方法抛出的异常信息。说明这是堆外内存不足导致的溢出问题。

外部命令导致系统缓慢

一个应用系统在做压力测试时,通过操作系统的 mpstat 工具发现 CPU 使用率很高,并且系统占用绝大多数的 CPU 资源的程序并不是应用系统本身。这不是正常现象。

通过 Solaris 系统的 dtrace 脚本发现当前情况下花费 cpu 资源最多的系统是 fork 系统调用。这个 fork 系统调用是 Linux 用来产生新进程的,在 Java 虚拟机中,用户编写的Java 代码最多只有线程概念,不会出现进程。

经过排查发现,这个系统中每个用户的处理都会调用外部的 shell 脚本来获取系统信息。执行这个脚本是通过 Java 的 Runtime.getRuntime().exec() 方法来调用的。这种方法在 Java 虚拟机中是非常消耗资源的操作,即使外部命令可以很快的执行完毕,但是频繁调用创建进程的开销也很厉害。Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机一样环境变量的进程,然后用这个新的进程去执行外部命令,最后在退出这个进程。频繁的执行这个操作,不仅仅 cpu 还有内存的压力很大。

改进:去掉调用这个 shell 的方法,改用 Java 的 api 方式获取这些信息。

服务器 JVM 进程崩溃

一个系统运行一段时间后,发现在运行期间频繁的出现集群节点的虚拟机进程自动关闭的现象,留下一个 hs_err_pid###.log 文件,进程就消失了。从系统日志中发现,每个节点的虚拟机在崩溃前不久都会出现下的异常信息:java.net.SocketException: Connection reset 。

这是一个远程断开连接的异常,发现这是最近最近集成了 OA 系统。当工作流的待办事项发生变化时,要通过 web 服务通知 OA 系统。由于该系统使用的人数多,待办事项也变化快,为了不被 OA 系统系统拖累,采用了异步的方式调用 Web 服务。但是由于两边的服务处理速度不对等,时间越长就积累了越多的 web 调用,导致等待的线程和 socket 连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。

解决方法:将异步调用改为生产者/消费者的消息队列实现。

不恰当的数据结构导致内存占用过大

一个使用 ParNew + CMS 的收集器,使用 -Xms4g -Xmx8g 虚拟机参数的 Java 系统,有一个业务,每 10 分钟加载一次 80MB 的数据文件到内存中进行数据分析,这些数据在内存中形成超过 100 万个 HashMap<Long,Long> Entry,在这段时间里面 Minor GC 就会造成超过 500ms 的停顿时间,这个时间是接受不了的。

观察这个案例,发现平时的 minor gc 时间很短,原因是新生代的对象绝大多数是可清除的,在 minor gc 后 Eden 和 survivor 区基本都是出于空闲状态。而在分析数据阶段,800MB 的 Eden 很快被填满从而引发 GC,但 minor gc 之后,新生代中绝大部分对象存活下来了。我们知道 ParNew 收集器采用复制算法,这个算法的高效是建立在大多数对象都是“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到 survivor 区并维持这些对象正确的引用就成了一个沉重的负担,因此导致 GC 时间明显变长。

  • 治标方案:考虑去掉 survivor 空间,加入以下参数 -XX:SurvivorRatio=65535、-XX:MaxTenuringThreshold=0 或者 -XX:+AlwaysTenure。让新生代对象在第一次 minor gc 后进入老年代,等待 major gc 时在清理掉它们。
  • 治本方法:这里产生问题的本质原因是因为用 HashMap<Long,Long> 结构来存储文件数据效率太低。

分析下空间效率,在 HashMap<Long,Long> 结构中,只有 key 和 value 所存放的两个长整型数据是有效的,共 16B(2 * 8B)。这两个长整型数据包装成 java.lang.Long 对象之后,就分别具有了 8B 的 MarkWord 和 8B 的 Klass 指针,再加 8B 的存储数据的 long 值。在这两个 Long 的对象组成 Map.Entry 之后,又多了 16B 的对象头,然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段,为了对齐,还必须添加 4B 的空白填充,最后还有 HashMap 这个对 Entry 的 8B 的引用,这样增加两个长整型数字,实际消耗的内存为 (Long(24B * 2)+ Entry(32B)+ HashMap Ref(8B))= 88B,空间效率为 16B/88B = 18%,太低了

由 Windows 虚拟内存导致的长时间停顿

一个 GUI 桌面程序,每 15 秒会发送一次心跳检测,如果对方 30 秒以内都没有信号返回,那么就认为和对方程序的连接已经断开。

上线后程序偶然出现心跳检测误报的情况,查询日志发现误报的原因是程序偶尔会出现间隔一分钟左右的时间完全没有日志输出,处于停顿状态。

通过加入参数 -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -Xloggc:gclog.log 后,从 GC 日志中确认了停顿确实是由于 GC 导致。从 GC 日志中找到长时间停顿的具体信息(添加 -XX:+PrintReferenceGC 参数),发现真正执行 GC 的时间不是很长,但是从准备 GC,到真正开始 GC 之间所消耗的时间占据了绝大部分时间。

除了 GC 日志之外,还观察到这个 GUI 程序内存变化的一个特点,当它最小化时,资源管理器中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑是程序最小化的时候它的工作内存被自动交换到磁盘的页面文件中了,这样发生 GC 时就有可能因为恢复页面文件的操作而导致不正常的 GC 停顿。

从 MSDN 上查证后确认了这种猜想。因此在 Java 的 GUI 程序中要避免这种现象,可以加入参数 -Dsun.awt.keepWorkingSetOnMinimize=true 来解决

由安全点导致长时间停顿

有一个较大的承担公共计算任务的历险HBase集群,使用G1收集器。每天有大量的MapReduce或Spark离线分析任务对其进行访问,同时有很多其他在线集群Replication过来的数据写入,因为集群读写压力较大,而离线分析任务对延迟又不会特别敏感,所以将- XX:MaxGCPauseMills参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以上,而且实际垃圾收集器进行回收的动作就占其中的几百毫秒。

解决方法:

1、找出特别慢的线程,添加-XX:+SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的时间超过2000毫秒就认定为超时。

2、安全点以“是否具有让程序长时间执行的特征”进行设置,HotSpot虚拟机为了避免安全点过多带来过重的负担,认为循环次数小的话执行时间也不会长,所以使用int类型或者范围更小的数据类型作为所引致的循环默认不会放置安全点。所以把循环***的数据类型从int改为long即可。

实战:Eclipse运行速度调优

调优前的程序运行状态

运行平台 32 位 Windows7 系统,虚拟机为 hotspot VM 1.5 b64,硬件为 ThinkPad X201,i5处理器,4GB 内存,优化前 eclipse 设置的最大堆内存 512MB,开启了 JMX 管理。

image

  • 整个启动过程耗时 15 秒
  • 最后一次启动的数据中,垃圾收集总耗时 4.1 秒,其中:
    • Full GC 被触发 19 次,共耗时 3.166 秒
    • Minor GC 被触发 378 次,共耗时 0.983 秒
  • 加载类 9115 个,耗时 4.114 秒
  • JIT 编译时间为 1.999 秒
  • 虚拟机 512MB 的堆内存被分配为 40MB 的新生代(31.5 的 Eden 区和两个 4MB 的 survivor 空间)以及 472 MB 的老年代

主要问题在于非用户程序时间(编译时间、类加载时间、垃圾收集时间)非常高,有恨大的优化空间。

升级 JDK 1.6 的性能变化问题

升级了 jdk 1.6 后发现 eclipse 使用几分钟后出现了内存溢出异常,通过 visual VM 中的内存曲线发现永久代监控图中最大内存已经满了,无法再继续扩容。最大容量为 67MB,我们通过指定 -XX:MaxPermSize 参数明确指定永久代的最大容量。

编译时间和类加载时间优化

我们通过 jps 查找 Java 线程,再通过 jstat -class ${Java线程id} 来统计出类加载的信息,包括时间等。

发现两个 jdk 在字节码验证部分耗时差距严重,使用参数 -Xverify:none 禁止字节码校验过程。

JIT 编译时间是指虚拟机的 JIT编译器(just in time compiler)编译热点代码(Hotspot)的耗时。参数 -Xint 禁止编译器运作,强制虚拟机对字节码采用纯解释执行的方式执行。我们一般不设置这个参数。

调整内存设置控制垃圾收集频率

eclipse 启动时间内发生了 19 次 Full GC 和 378 次的 minor gc 导致 4 秒的停顿。

新生代频繁发生 minor gc 是因为虚拟机分配给新生代的空间不足,Eden 区和 survivor 区还不到 35MB,因此需要参数 -Xmn 调整新生代的大小。老年代的 full gc 不多,但是从gc 日志上发现每次 gc 完毕后总的空间扩容了,从 150KB 扩容到了 400MB,所以我们可以免去老年代扩容占用的时间。

由此得出结论,我们可以把 -Xms 和 -XX:PermSize 参数设置成和 -Xmx 和 -XX:MaxPermSize 参数值一样。

我们再加入参数 -XX:+DisableExplicitGC 屏蔽掉 System.gc() 。

选择收集器降低延迟

考虑到 eclipse 是交互性很强的程序,需要要使用响应速度快的垃圾收集器,这里我们使用老年的 CMS 收集器。设置参数 -XX:+UseConcMarkSweepGC 收集器。

第六章 类文件结构

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

无关性基石

各种不同平台的Java虚拟机,以及所有平台都同意支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石,同时,语言无关性越来越被开发者所重视。

实现语言无关性的基础仍然是虚拟机和字节码的存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息

作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们的交付媒介。

img

Java语言中各种语法、关键字、常量变量和运算符号的语义最终会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比Java语言本身更加强大才行,因此,有一些Java语言本身无法有效支持的语言特性并不代表在字节码中也无法有效表达出来,这位其他语言实现一些有别于Java语言特性提供了发挥空间。

Class类文件结构

Class文件是一组以字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用单个字节以上空间的数据项时,则会按照高位在前的方式分割成若干字节进行存储。

Class文件格式采用一种伪结构来存储数据,该结构只有两种数据类型:“无符号数”、“表”。

无符号数:基本数据类型,以u1、u2、u4、u8来表示1个字节、2个字节、4个字节、8个字节的无符号数,可用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值。

表:由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可是作为一张表。

魔数与Class文件的版本

每个Class文件的头4个字节被称为魔数,唯一作用:确定这个文件是否为一个能被虚拟机接受的Class文件。

使用魔数而不是拓展名来进行识别主要是基于安全考虑,拓展名可以随意改动。Class文件的魔数值为0xCAFEBABE。

紧接着魔数的四个字节是Class文件的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号。Java的版本号是从45开始的。

常量池

常量池可以比喻为Class文件中的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

由于常量池中的常量的数量是不固定的,所以在常量池入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java语言习惯不用,这个容量计数从1开始而不是从0开始。这样做的目的在于:如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。

常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等,而符号引用组属于编译原理方面的概念,主要包括被模块导出或开放的包(Package)、类和接口的权限定名(Fully Quualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符、方法句柄和方法类型、动态调用点和动态常量。

访问标志

在常量池结束后,紧接着的2个字符代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否被定义为public类型、是否定义为abstract类型;如果是类的话,是否被声明成final;等等。

类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

由于Java不允许多重继承,所以父类索引就一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object之外,所有Java类的父类索引都不为0。

接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

字表段集合

字表段(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”包括类级变量以及实力级变量,但不包括在方法内部声明的局部变量。

字段的名字、数据类型、修饰符等都是无法固定的,只能引用常量池中的常量来描述。下面是字段表的最种格式:

img

其中的access_flags与类中的access_flagsfei类似,是表示数据类型的修饰符,如public、static、volatile等。后面的name_index和descriptor_index都是对常量池的引用,分别代表字段的简单名称及字段和方法的描述符。这里简单解释下“简单名称”、“描述符”和“全限定名”这三种特殊字符串的概念。

前面有所提及,全限定名即指一个事物的完整的名称,如在org.lxh.test包下的TestClass类的全限定名为:org/lxh/test/TestClass,即把包名中的“.”改为“/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“,”来表示全限定名结束。简单名称则是指没有类型或参数修饰的方法或字段名称,如果一个类中有这样一个方法boolean get(int name)和一个变量private final static int m,则他们的简单名称则分别为get()和m。

而描述符的作用则是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序等)和返回值的。根据描述符规则,详细的描述符标示字的含义如下表所示:

img

对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个整数数组“int [][]”将为记录为“[[I”,而一个String类型的数组“String[]”将被记录为“[Ljava/lang/String”

用方法描述符描述方法时,按照先参数后返回值的顺序描述,参数要按照严格的顺序放在一组小括号内,如方法 int getIndex(String name,char[] tgc,int start,int end,char target)的描述符为“(Ljava/lang/String[CIIC)I”。

字段表包含的固定数据项目到descriptor_index为止就结束了,但是在它之后还紧跟着一个属性表集合用于存储一些额外的信息。比如,如果在类中有如下字段的声明:staticfinalint m = 2;那就可能会存在一项名为ConstantValue的属性,它指向常量2。关于attribute_info的详细内容,在后面关于属性表的项目中会有详细介绍。

最后需要注意一点:字段表集合中不会列出从父类或接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段。比如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

方法表集合

方法表(method_info)的结构与属性表的结构相同,不过多赘述。方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里,关于属性表的项目,同样会在后面详细介绍。

与字段表集合相对应,如果父类方法在子类中没有被覆写,方法表集合中就不会出现来自父类的方法信息。但同样,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”方法。</init></clinit>

在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。

属性表集合

属性表(attribute_info)在前面已经出现过多系,在Class文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但Java虚拟机运行时会忽略掉它不认识的属性。Java虚拟机规范中预定义了9项虚拟机应当能识别的属性(JDK1.5后又增加了一些新的特性,因此不止下面9项,但下面9项是最基本也是必要,出现频率最高的),如下表所示:

img

对于每个属性,它的名称都需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,每个属性值的结构是完全可以自定义的,只需说明属性值所占用的位数长度即可。一个符合规则的属性表至少应具有“attribute_name_info”、“attribute_length”和至少一项信息属性。

1)Code属性

前面已经说过,Java程序方法体中的代码讲过Javac编译后,生成的字节码指令便会存储在Code属性中,但并非所有的方法表都必须存在这个属性,比如接口或抽象类中的方法就不存在Code属性。如果方法表有Code属性存在,那么它的结构将如下表所示:

img

attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code”,它代表了该属性的名称。attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共是6个字节,所以属性值的长度固定为整个属性表的长度减去6个字节。

max_stack代表了操作数栈深度的最大值,max_locals代表了局部变量表所需的存储空间,它的单位是Slot,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用。

code_length和code用来存储Java源程序编译后生成的字节码指令。code用于存储字节码指令的一系列字节流,它是u1类型的单字节,因此取值范围为0x00到0xFF,那么一共可以表达256条指令,目前,Java虚拟机规范已经定义了其中200条编码值对应的指令含义。code_length虽然是一个u4类型的长度值,理论上可以达到2^32-1,但是虚拟机规范中限制了一个方法不允许超过65535条字节码指令,如果超过了这个限制,Javac编译器将会拒绝编译。

字节码指令之后是这个方法的显式异常处理表集合(exception_table),它对于Code属性来说并不是必须存在的。它的格式如下表所示:

img

它包含四个字段,这些字段的含义为:如果字节码从第start_pc行到第end_pc行之间(不含end_pc行)出现了类型为catch_type或其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理,当catch_pc的值为0时,代表人和的异常情况都要转到handler_pc处进行处理。异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常即finally处理机制,也因此,finally中的内容会在try或catch中的return语句之前执行,并且在try或catch跳转到finally之前,会将其内部需要返回的变量的值复制一份副本到最后一个本地表量表的Slot中,也因此便有了\http://blog.csdn.net/ns_code/article/details/17485221**这篇文章中出现的情况。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码和元数据两部分,那么在整个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

2)Exception属性

这里的Exception属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在throws关键字后面列举的异常。它的结构很简单,只有attribute_name_index、attribute_length、number_of_exceptions、exception_index_table四项,从字面上便很容易理解,这里不再详述。

3)LineNumberTable属性

它用于描述Java源码行号与字节码行号之间的对应关系。

4)LocalVariableTable属性

它用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的对应关系。

5)SourceFile属性

它用于记录生成这个Class文件的源码文件名称。

6)ConstantValue属性

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。在Java中,对非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;而对于类变量(static变量),则有两种方式可以选择:在类构造其中赋值,或使用ConstantValue属性赋值。</init>

目前Sun Javac编译器的选择是:如果同时使用final和static修饰一个变量(即全局常量),并且这个变量的数据类型是基本类型或String的话,就生成ConstantValue属性来进行初始化(编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值),如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在<clinit>方法中进行初始化。</clinit>

虽然有final关键字才更符合”ConstantValue“的含义,但在虚拟机规范中并没有强制要求字段必须用final修饰,只要求了字段必须用static修饰,对final关键字的要求是Javac编译器自己加入的限制。因此,在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性。而且ConstantValue的属性值只限于基本类型和String,很明显这是因为它从常量池中也只能够引用到基本类型和String类型的字面量。

下面简要说明下final、static、\static** final修饰的字段赋值的区别:**

  • static修饰的字段在类加载过程中的准备阶段被初始化为0或null等默认值,而后在初始化阶段(触发类构造器<clinit>)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。</clinit>

  • final修饰的字段在运行时被初始化(可以直接赋值,也可以在实例构造器中赋值),一旦赋值便不可更改;

  • static final修饰的字段在Javac时生成ConstantValue属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则Javac时会报错。可以理解为在编译期即把结果放入了常量池中。

    7)InnerClasses属性

    该属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那么编译器将会为它及它所包含的内部类生成InnerClasses属性。

    8)Deprecated属性和Synthetic属性

    该属性用于表示某个类、字段和方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@Deprecated注释进行设置。

    9)Synthetic属性

    该属性代表此字段或方法并不是Java源代码直接生成的,而是由编译器自行添加的,如this字段和实例构造器、类构造器等。

10)StackMapTable属性

一个相当复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

11)Signature属性

一个可选的定长属性,可以出现在类、字段表和方法表结构的属性表中。任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量或参数化类型,则Signature属性会为它记录泛型签名信息。

12)BootstrapMethods属性

用于保存invokedynamic指令引用的引导方法限定符。

13)MethodParameters属性

记录方法的各个形参名称和信息。

14)模块化相关属性

Module、ModulePackages、ModuleMainClass三个属性用于支持Java模块化相关功能

15)运行时注解相关属性

RuntimeVisibleAnnotations、RuntimeInvisibleAnnotations、RuntimeVisibleParameterAnnotations、RuntimeInvisibleParameterAnnotations、RuntimeVisibleTypeAnnotations、RuntimeInvisibleTypeAnnotations。

六个属性比较雷同。

字节码指令简介

Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于 Java 虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。

字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了 Java 虚拟机操作码的长度为一个字节(即 0 ~ 255),这意味着指令集的操作码总数不可能超过 256 条;又由于 Class 文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据的结构,如果要将一个 16 位长度的无符号整数使用两个无符号字节存储起来(将它们命名为 byte1 和 byte2),那它们的值应该是这样的:

(byte1 << 8) | byte2

这种操作在某种程度上会导致解释执行字节码时损失一些性能。但这样做的优势也非常明显,放弃了操作数长度对齐( 注:字节码指令流基本上都是单字节对齐的,只有 “tableswitch” 和 “lookupswitch” 两条指令例外,由于它们的操作数比较特殊,是以 4 字节为界划分开的,所以这两条指令也需要预留出相应的空位进行填充来实现对齐),就意味着可以省略很多填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由 Java 语言设计之初面向网络、智能家电的技术背景所决定的,并一直沿用至今。

如果不考虑异常处理的话,那么 Java 虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效地工作:

  1. do {
         自动计算 PC 寄存器的值加 1;
         根据 PC 寄存器的指示位置,从字节码流中取出操作码;
         if ( 字节码存在操作数 ) 从字节码流中取出操作数;
         执行操作码所定义的操作;
       } while ( 字节码流长度 > 0 );

字节码与数据类型

在 Java 虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。

对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i 代表对 int 类型的数据操作,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。也有一些指令的助记符中没有明确地指明操作类型的字母,如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令 goto 则是与数据类型无关的。

由于 Java 虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力:如果每一种与数据类型相关的指令都支持 Java 虚拟机所有运行时数据类型的话,那指令的数量恐怕就会超出一个字节所能表示的数量范围了。因此,Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会故意被设计成非完全独立的(Java 虚拟机规范中把这种特性称为 “Not Orthogonal”,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。

表列举了 Java 虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换 opcode 列的指令模板中的 T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持这种数据类型执行这项操作。例如,load 指令又操作 int 类型的 iload,但是没有操作 byte 类型的同类指令。

注意,从表可以看出,大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译器或运行期将byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型(Computational Type)。

img

加载和存储指令

加载和存储指令用于*将数据在栈帧中的局部变量表和操作数栈之间来回传输*,这类指令包括如下内容。

  • 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。</n></n></n></n></n>
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。</n></n></n></n></n>
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_<l>、fconst_<f>、dconst_<d>。</d></f></l>
  • 扩充局部变量的访问索引的指令:wide。

存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。

上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如 iload_<n>),这些指令助记符实际上是代表了一组指令(例如 iload_<n>,它代表了 iload_0、iload_1、iload_2 和 iload_3 这几条指令)。这几组指令都是某个带有一个操作数的通用指令(例如 iload)的特殊形式,对于这若干组特殊指令来说,它们省略掉了显示的操作数,不需要进行取操作数的动作,实际上操作数就隐含在指令中。除了这点之外,它们的语义与原生的通用指令完全一致(例如 iload_0 的语义与操作数为 0 时的 iload 指令语义完全一致)。这种指令表示方法在这里以及《Java 虚拟机规范》中都是通用的。</n></n>

运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令,无论是哪种算术指令,都使用 Java 虚拟机的数据类型,由于没有直接支持 byte、short、char 和 boolean 类型的算术指令,对于这类数据的运算,应使用操作 int 类型的指令代替。整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现,所有的算术指令如下。

  • 加法指令:iadd、ladd、fadd、dadd。
  • 减法指令:isub、lsub、fsub、dsub。
  • 乘法指令:imul、lmul、fmul、dmul。
  • 除法指令:idiv、ldiv、fdiv、ddiv。
  • 求余指令:irem、lrem、frem、drem。
  • 取反指令:ineg、lneg、fneg、dneg。
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
  • 按位或指令:ior、lor。
  • 按位与指令:iand、land。
  • 按位异或指令:ixor、lxor。
  • 局部变量自增指令:iinc。
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

Java 虚拟机的指令集直接支持了在 《Java 语言规范》中描述的各种对整数及浮点数操作的语义。数据运算可能会导致溢出。例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实 Java 虚拟机规范没有明确定义过整型数据溢出的具体运算结果,仅规定了在处理整型数据时,只有除法指令(idiv 和 ldiv)以及求余指令(irem 和 lrem)中当出现除数为零时会导致虚拟机抛出 ArithmeticException 异常,其余任何整型数运算场景都不应该抛出运行时异常。

Java 虚拟机规范要求虚拟机实现在处理浮点数时,必须严格遵循 IEEE 754 规范中所规定的行为和限制。也就是说,Java 虚拟机必须完全支持 IEEE 754 中定义的非正规浮点数值(Denormalized Floating-Point Numbers)和逐级下溢(Gradual Underflow)的运算规则。这些特征将会使某些数值算法处理起来变得相对容易一些。

Java 虚拟机要求在进行浮点数运算时,所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的。这种舍入模式也是 IEEE 754 规范中的默认舍入模式,称为向最接近数舍入模式。

在把浮点数转换为整数时,Java 虚拟机使用 IEEE 754 标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近但是不大于原值的数字来作为最精确的舍入结果。

另外,Java 虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是 Java 语言中的异常,请勿与 IEEE 754 规范中的浮点异常互相混淆,IEEE 754 的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用 NaN 值来表示。所有使用 NaN 值作为操作数的算术操作,结果都会返回 NaN。

在对 long 类型数值进行比较时,虚拟机采用带符号的比较方式,而对浮点数值进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),虚拟机会采用 IEEE 754 规范所定义的无信号比较(Nonsignaling Comparisons)方式。

类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显示类型转换操作,或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。

Java 虚拟机直接支持(即转换时无需显示的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversions,即小范围类型向大范围类型的安全转换):

  • int 类型到 long、float 或者 double 类型。
  • long 类型到 float、double 类型。
  • float 类型到 double 类型。

相对的,处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。

在将 int 或 long 类型窄化转换为整数类型 T 的时候,转换过程仅仅是简单地丢弃除最低位 N 个字节意外的内容,N 是类型 T 的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低 N 个字节的首位了。

在将一个浮点值窄化转换为整数类型 T(T 限于 int 或 long 类型之一)的时候,将遵循以下转换规则:

  • 如果浮点值是 NaN,那转换结果就是 int 或 long 类型的 0。
  • 如果浮点值不是无穷大的话,浮点值使用 IEEE 754 的向零舍入模式取整,获得整数值 v,如果 v 在目标类型 T(int 或 long)的表示范围之内,那转换结果就是 v。
  • 否则,将根据 v 的符号,转换为 T 所能表示的最大或者最小整数。

从 double 类型到 float 类型的窄化转换过程与 IEEE 754 中定义的一致,通过 IEEE 754 向最接近数舍入模式舍入得到一个可以使用 float 类型表示的数字。如果转换结果的绝对值太小而无法使用 float 来表示的话,将返回 float 类型的正负零。如果转换结果的绝对值太大而无法使用 float 来表示的话,将返回 float 类型的正负无穷大,对于 double 类型的 NaN 值将按规定转换为 float 类型的 NaN 值。

尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是 Java 虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常

对象创建与访问指令

虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(数组和普通类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。

  • 创建类实例的指令:new。
  • 创建数组的指令:newarray、anewarray、multianewarray。
  • 访问类字段(static 字段,或者成为类变量)和实例字段(非 static 字段,或者成为实例变量)的指令:getfield、putfield、getstatic、putstatic。
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
  • 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
  • 取数组长度的指令:arraylength。
  • 检查类实例类型的指令:instanceof、checkcast。

操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
  • 复制栈顶一个或两个数值并将复制值或双份的复制重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
  • 将栈最顶端的两个数值互换:swap。

控制转移指令

控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

在 Java 虚拟机中有专门的指令集用来处理 int 和 reference 类型的条件分支比较操作,为了可以无须明显标识一个实体值是否 null,也有专门的指令用来检测 null 值。

与前面算术运算是的规则一致,对于 boolean 类型、byte 类型、char 类型和 short 类型的条件分支比较操作,都是使用 int 类型的比较指令来完成,而对于 long 类型、float 类型和 double 类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp),运算指令会返回一个整形值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为 int 类型的比较操作,int 类型比较是否方便完善就显得尤为重要,所以 Java 虚拟机提供的 int 类型的条件分支指令是最为丰富和强大的。

方法调用和返回指令

方法调用(分派、执行过程),先列举以下 5 条用于方法调用的指令。

  • invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
  • invokeinterface 指令用于调用接口方法,它会在运行时搜索一下实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic 指令用于调用类方法(static 方法)。
  • invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条return 指令共声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。

异常处理指令

在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现,除了用 throw 语句显式抛出异常情况之外,Java 虚拟机规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。例如,在前面介绍的整数运算中,当除数为零时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常。

而在 Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令来实现,现在已经不用了),而是采用异常表来完成的。

同步指令

Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 方法标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。

同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,正确实现 synchronized 关键字需要 javac 编译器与 Java 虚拟机两者共同协作支持。

img

编译后,这段代码生成的字节码序列如下:

img

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须执行其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。

从代码清单的字节码序列中可以看到,为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

公有设计,私有实现

Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。

只要优化以后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整保持,那实现者就可以选择以任何方式去实现这些语义,虚拟机在后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述一致即可。

虚拟机的实现方式主要有两种:

1、将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机指令集。

2、将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)。

第七章 虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。

与那些在编译时需要进行连接的语言不同,在Java语言中,类型的加载、连接和初始化的过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍外增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性。

Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类加载的时机

img

其中验证、准备、解析三个部分统称为连接。加载、验证、准备、初始化、卸载这5个步骤的顺序是确定的,解析阶段则不一定,某些时候可以在初始化之后,这是为了支持Java的运行时绑定(动态绑定)。

什么时候进行加载,虚拟机规范中没有强制要求,但是初始化操作有严格的规定,5种情况下必须立刻执行(很好理解,我需要这个类的相关信息了,必须准备好):

1.new、getstatic、putstatic、invokestatic这4个字节码指令时,没有初始化,必须初始化。

2.反射调用的时候,没初始化,必须初始化

3.初始化子类的时候,要先加载其父类。

4.虚拟机启动时,要指定一个执行的主类(main方法的类),需要先初始化

5.JDK7的动态语言支持时,java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄时,对应的类没有初始化,需要初始化。

6.当一个接口定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

只有上述6种情况才会初始化,这些情况称为对一个类进行主动引用。除此外,所有引用类的方式都不会触发初始化,称为被动引用。

1、通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。

2、通过数组定义来引用类,不会触发此类的初始化。

3、常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

注意:public static final String,执行这种类型的字段时,也不会触发初始化,虽然其看起来是getstatic,但是实际上在编译环节被优化了,通过常量传播,引用这个字段的类转化成了对常量池的引用,与这个字段的类没有任何关系了。new 数组的时候也不会触发,即便数组的元素是要触发初始化的类。

接口的初始化只有第3种情况不同,并不要求其父接口全部完成了初始化,只有在真正使用了父接口的时候才会初始化。

类加载的过程

加载、验证、准备、解析和初始化五个阶段的具体动作。

加载

加载阶段,虚拟机需要完成以下3件事情:

    1.通过一个类的全限定名来获取定义此类的二进制字节流

    2.将这个字节流代表的静态存储结构转化成方法区的运行时数据结构

    3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

虚拟机规范没有严格限制上述内容,可以自由发挥,也因此出现了很多有意思的技术:

1.从ZIP包中读取,最终出现了JAR、EAR、WAR格式

2.从网络中获取,典型的就是Applet

3.运行时生成,就是动态代理技术

4.由其他文件生成,JSP应用,JSP生成对应的Class类

5.从数据库中读取

6.可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件保障程序运行逻辑不被窥探。

相对于其他过程,一个非数组类的加载(准确说是获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器来完成。

对数组类而言,情况就不一样了,数组类本身不通过类加载器创建,由虚拟机直接创建,但是又和类加载器有很大的关系。因为数据的元素类型最终要类加载器去创建。一个数组类的创建过程要遵循下面规则:

    1.如果数组的类型是引用类型,就递归采用上面的加载过程加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识。(类必须和类加载器一同确定唯一性)

    2.如果不是引用类型(int[]),会标记数组为引导类加载器关联

    3.数组类的可见性与组件类型可见性一致,如果不是引用类型,就是public

加载完成之后,类就按照虚拟机的格式存储在方法区之中,在内存中实例化一个java.lang.Class类的对象。对于HotSpot而言,这个对象放在方法区中,而不是堆中。

加载过程和连接过程是交叉的,比如验证,连接阶段可能已经开始。

验证

验证是连接的第一步,目的在于确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java语言相对安全(起码对于C/C++来说是安全的),不会访问数组边界以外的数据、不会将一个对象转型为他并未实现的类型、不会跳转到不存在的代码行之类的事情,否则将抛出异常。

Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自己的必要措施。

验证阶段很重要,是否严谨,直接决定了虚拟机是否能承受恶意代码攻击,执行角度来看,这个阶段占用了相当大的一部分。

虚拟机规范对这个阶段的限制比较笼统,列举了一些Class文件格式中的静态和结构化约束,如果验证到输入的字节流不符合格式的约束,会抛出一个java.lang.VerifyError异常或其子类异常。到了Java虚拟机规范 SE 7版,才做了大量的验证阶段要求。总体分为四个阶段的验证:

1.文件格式验证:验证魔方数、版本号、常量池类型、指向常量的索引、utf8编码、Class文件中各个部分及文件本身是否有被删除或者附加的信息。主要目的就是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。只有通过这个验证,才会到内存中的方法区进行存储,后续验证阶段就是基于方法区的内容,而不是二进制字节流了。

2.元数据验证:对字节码的信息进行语义分析,是否有父类,是否满足继承体系(单继承,不能继承final类),字段方法等是否与父类冲突。这个阶段目的就是对类的元数据进行语义校验,保证不存在不符合Java语言规范的元数据信息。

3.字节码验证:这个是最复杂的一个环节。主要目的是通过数据流和控制流分析,确定程序语义的合法性,保证类的方法在运行期间不会做出危害虚拟机的行为。例如:保证任何时候操作数栈的数据类型和指令代码序列配合工作,用long指令加载一个int类型之类的事情。保证跳转指令不会跳到方法体以外的字节码指令上。保证类型转换是有效的,子类赋给父类变量是安全的,不相干的就是危险的等等操作。这个阶段不能保证完全正确,可能会有问题。

4.符号引用验证:这个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化将在第三阶段解析中发生。符号引用验证是对类自身以外的信息进行匹配校验,主要校验:能否找到对应的引用类,是否存在相关方法属性字段,是否有权限访问(private)。如果无法通过验证,会抛出java.lang.IncompatibleClassChangeError异常的子类,如IllegalAccessError、NoSuchFieldError和NoSuchMethodError等。

验证阶段很重要,但是如果保证代码是正常的,可以关闭这个阶段缩短类加载时间,通过参数-Xverify:none来关闭。

准备

准备阶段是为类的变量分配内存,并设置类变量初始值的阶段,这些变量使用的内存,都在方法区中进行分配。注意,只包含static类变量,实例变量是在堆中的,初始值一般是数据类型的零值,比如public static int value = 123。初始值是0,而不是123,这个时候没有执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放在类构造器<clinit>()方法中,所以在初始化阶段才会赋值成123。又要注意,一般是数据类型的零值,但是还有特殊情况,比如被final修饰,存在ConstantValue属性,会在准备阶段就会赋值。</clinit>

解析

解析阶段就是将常量池中的符号引用替换成直接引用了。这里需要延伸一下知识,Java为了实现其动态性,在编译的时候都是通过符号引用来占位子,在这个阶段就要对应具体的引用对象了。

符号引用:一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候能无歧义的定位目标即可。与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中(这个特性很有用),但是能接受的符号引用必须是一致的,因为这个字面量形式明确定义在Java虚拟机规范的Class文件格式中。

直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与内存布局相关,在不同虚拟机中符号引用会被翻译出不同的直接引用,如果有了直接引用,那么引用目标一定在内存中。

虚拟机规范中没有规定解析的发生具体时间,所以是否是在加载时就完成符号引用的解析,还是到符号引用被使用时再进行解析,这个根据实际需要决定。但是规范规定了16个操作符号引用的字节码之前,要完成解析动作,为:anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic。

对一个符号引用进行多次解析请求是很常见的,为了避免这个问题,虚拟机实现可以在第一个解析成功后,缓存结果,后续使用这个结果即可。但是如果第一次失败,后面都会失败。

上述避免重复解析的规则,对于指令invokedynamic指令不成立。因为这个本来就是用来支持动态语言的,其对应的引用称为“动态调用点限定符"。动态的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。Java是静态类型语言,后面3种与JDK7种新增的动态语言支持相关,本章不进行介绍,留到动态语言调用,即虚拟机执行引擎的时候描述。

1)类或接口的解析

如果当前代码是在Test类中,将一个未解析过的符号引用N解析成一个类或接口C的直接引用,那么需要完成下面步骤:

    1.如果C不是数组类型,虚拟机会将N的全限定名传递给Test的类加载器,去加载类C,可能触发其他的加载,一旦失败解析失败。

    2.如果C是数组,N的描述符会是类似[Ljava/lang/Integer]形式,会按1的规则加载数组的元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象。这里就是加载Integer类了。

    3.上述步骤成功,在虚拟机中已经有一个有效的C类或接口了,但是还需要对符号引用进行验证,确定Test是否能够访问C,不能访问抛出IllegalAccessError

  通俗的说就是Test类有个X tmp对象。类型X没有被解析,是一个符号引用,如果不是一个数组,就要用Test的类加载器加载这个类,如果是数组,也要加载,不过要额外开辟数组对象。

2)字段解析

要解析一个未被解析过的字段符号引用,首先会对符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用过程中出任何异常,都会导致字段符号引用解析失败。解析成功,需要进行下面操作,假设字段所属类或接口是C:

    1.如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。

    2.如果不包含,C中实现了接口,从下往上递归搜索接口,如果包含简单名称和字段描述符与目标匹配,返回直接引用,查找结束

    3.如果还没找到,会从下到上搜索继承体系,如果匹配返回直接引用,查找结束

    4.否则,失败,抛出NoSuchFieldError异常。

  成功后也会进行验证,判断有没有访问权限。

通俗的说就是:类Test打印了C.value这个字段,C中没有,先从下往上的接口中查找,仍没有,从下往上的继承找,还是没找到就抛出异常。

实际上编译器会更严格一些,如果一个字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,编译器可能拒绝编译。

3)方法解析

方法解析和字段解析第一步都一样,先对所属的类或接口的符号引用进行解析,成功后会对后续的类方法进行搜索:

    1.类方法和接口方法的符号引用的常量类型定义是分开的,所以发现是个接口,直接抛出IncompatibleClassChangeError异常

    2.查找是否有简单名称和描述符都匹配的方法,找到就结束

    3.没找到,去父类查找,直到找到

    4.没找到,去接口列表查找,找到了说明C是一个抽象类,抛出AbstractMethodError异常

    5.否则抛出NoSuchMethodError

简单说就是Test中调用一个C.doSomething()方法,先找这个引用是不是一个接口,不是就要找这个方法是哪个父类的,没找到那就是接口的了,没有具体实现,要抛出异常,都没找到更错的离谱了。

4)接口解析

接口方法也需要先解析出接口方法表的方法所属的类或接口的符号引用,解析成功,用C表示这个接口,进行下面的步骤:

    1.如果C是个类,不是接口,抛出IncompatibleClassChangeError异常

    2.是接口,查找C中是否有匹配的,有就查找结束

    3.没找到,就在C的父接口递归查找,直到Object类,匹配就结束

    4.找不到,抛出NoSuchMethodError异常

接口的方法默认是public,所以没有访问权限的问题。

简单说就是Test调用C.doSomething(),这是一个接口方法,如果C是类那么抛出异常,如果C中没有这个方法,找父接口,没找到也要抛出异常。

初始化

初始化是类加载的最后一步,会真正执行类中定义的Java代码。准备阶段,变量赋予了零值,初始化阶段要赋予真正的值了。

初始化是执行类构造器<clinit>()方法的过程,这个方法是由类变量的赋值动作,和静态语句块static{}语句合并产生的。由顺序决定,静态块只能访问到定义在静态块之前的变量,之后的变量可以赋值,不能访问。这个方法与构造函数<init>()方法不同,不需要显示的调用父类构造器,虚拟机会保证执行子类的<clinit>()方法之前,父类的已经执行完毕了。由于父类的先执行,所以父类的static会先执行。</clinit></init></clinit>

此外<clinit>方法不是必须的,如果没有赋值操作且没有静态代码块,就不会生成这个方法。接口虽然没有static块,但是会由赋值操作,与类不同的地方在于接口不需要先执行父类的<clinit>()方法。只有在父接口的变量使用时,才会初始化父接口。另外接口的实现类在初始化的时候也不会执行接口的<clinit>()方法。虚拟机会保证一个类的<clinit>()方法在多线程环境被正确地加锁、同步,如果有多个线程同时初始化,只会执行一次。其他线程会阻塞,所以执行时间很久会导致多个线程阻塞。</clinit></clinit></clinit></clinit>

类加载器

之前说了虚拟机将加载二进制字节流的动作放在虚拟机外实现,让程序自己决定如何获取所需要的实现类。实现这个动作的代码模块称为”类加载器“。

类加载器是一个创新,最初是满足Java Applet的需求而开发的。但是在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为了Java技术的一块重要基石。

对于任何一个类都需要类本身和其加载器来确定唯一性,每个类加载器都有一个独立的类名称空间,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使是同一个Class,被不同的类加载器加载,也是不同的类。这里的相等包括Class的equals方法、isAssignableFrom方法和isInstance方法。

双亲委派模型

这个机制十分出名。Java提供了三种系统类加载器:

    1.启动类加载器(Bootstrap ClassLoader):这个放在java_home\lib目录中,或者-Xbootclasspath参数所指定的路径,被虚拟机识别(固定名称)。这个加载器无法被程序直接引用,用户在编写自定义加载器时,如果需要将加载请求委派给引导类加载器,直接使用null代替即可。getClassLoader方法返回null。

    2.扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,负责加载java_home\lib\ext目录中的,或者被java.ext.dirs系统变量指定的路径所在类库,开发者可以直接使用扩展类加载器。

    3.应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。这个类加载器是ClassLoader中的getSystemClassLoader方法的返回值,所以也称为系统类加载器。负责加载用户ClassPath上所指定的类库,可以直接使用,如果代码里面没有定义自己的加载器,就会默认使用这个。  img

  双亲委派模型中加载器除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这个关系不是通过继承来实现的,而是组合关系来复用父加载器的代码。

  工作过程是:当收到类加载请求,首先不会自己加载这个类,而是把请求给父类加载,最终传递到最上层的启动类加载器中,只有当父加载器反馈自己无法加载时(没有在lib下找到这个类),子类加载器才会尝试加载。

  这个模型的好处在于Java类有了一种优先级的层次关系。比如Object类,这个毫无疑问应该交给最上层的加载器进行加载,所以在各个类加载器环境中这个类都是同一个类。如果没有双亲委派模型,自己写一个java.lang.Object类,会出现很多个不通的Object类,应用程序会一片混乱。

  实现很简单,都在ClassLoader的loadClass方法之中,先检查是否被加载过,若没有加载就调用父类加载器的loadClass方法,若父类加载器为空,默认使用启动类加载器,如果加载失败,抛出异常后调用自己的findClass()方法进行加载。

破坏双亲委派模型

双亲委派模型不是一个强制性的约束模型,有被破坏的情况。

  第一次是因为兼容老版本的JDK,这个模型在1.2版本才出现,但是ClassLoader在1.0就存在了。添加了一个protected方法findClass(),之前版本继承ClassLoader唯一目的就是重写loadClass方法,因为虚拟机会去调用loadClassInternal方法,这个方法就是执行loadClass()。1.2之后就不提倡重写loadClass了,而是写findClass方法,因为loadClass的基本逻辑已经写好,父类加载失败,就会调用自己的findClass进行加载,这样保证新写出来的类加载器是符合双亲委派模型的。

  第二次破坏是因为双亲委派模型自身的缺陷,作为顶层的类通常是被底层类调用的,但是如果顶层的基本类调用了底层的用户类就麻烦大了。顶层的类加载器并不认识用户的类,这样如何去加载这个类呢?典型的例子就是JNDI服务,其目的是对资源进行集中管理和查找,需要调用应用程序的classpath的代码。比如SPI服务发现:这里。可以看到系统类需要加载自己写的类,这个就麻烦了。为了解决这个问题,设计了一个线程上下文加载器Thread Context ClassLoader。这个类加载器可以通过Thread类的setContextClassLoader方法进行设置,如果创建线程的时候没有设置,将会从父线程中继承一个,全局范围都没有设置,默认就是应用程序类加载器。设置了这个,就可以在父加载器中获取子加载器,让其进行加载类了。这是无奈之举,很多SPI加载动作都采用了这种方式,比如:JNDI、JDBC、JCE、JAXB、JBI等。

  第三次被破坏是由于追求动态性导致的。比如:代码热替换,模块热部署等。比较出名的就是OSGi这个事实上的Java模块化标准,其模块热部署的关键就是它自定义的类加载器实现机制。每个模块(Bundler)都有一个自己的类加载器,当需要更换一个Bundle时,把Bundle连同类加载器一起换掉以实现代码热替换。

  OSGi按照下面的顺序进行类搜索:

    1.java.*开头的类委派给父类加载器加载

    2.将委派列表中的类交给父类加载器加载

    3.将import列表中的类委派给Export这个类的Bundle的类加载器加载

    4.查找当前Bundle的Classpath,使用自己的类加载器加载

    5.查找类是否在自己的Fragment Bundle中,如果在,委派给Fragment Bundle进行加载

    6.查找Dynamic Import列表的Bundle,委派给对应Bundle加载

    7.类查找失败

Java模块化系统

为了实现Java模块化目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整。JDK9的模块不仅仅像之前的JAR包那样简单地充当代码的容器,除代码外,Java模块定义还包含依赖其他模块的列表、导出的包的列表(其他模块可以使用的列表)、开放的包列表(其他模块可反射访问模块的列表)、使用的服务列表、提供服务的实现列表。

可配置的封装隔离机制首先要解决JDK9之前基于类路径来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行时异常。而在JDK9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行时是否完备,如有缺失那直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。

可配置封装隔离机制还解决了原来路径上跨JAR文件的public类型的可访问性问题。JDK9中public不代表能访问全部内容,模块提供了更精细的可访问性控制,必须明确声明其中那一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的。

模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK9提出了与“类路径”相对应的“模块路径”概念。

简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在那种路径上。

只要是放在类路径上的JAR文件,无论其中是否包含了模块化信息(module-info.class),它都会被当作是传统的JAR包来对待。相应地,只要是放在了模块路径的JAR文件,即使没有使用JMOD后缀,甚至其中不包含module-info.class文件,它仍然会被当作一个模块来对待。

1、JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块下,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上的所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块是导出的包。

2、模块在模块路径的访问规则:模块路径下的具名模块只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统的JAR包的内容。

3、JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路径中,它就会变成一个自动模块。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

模块化下的类加载器

扩展类加载器(Extension ClassLoader)被平台类加载器(Platform ClassLoader)取代。同时整个JDK都基于模块化进行构建(其中原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库已满足了可扩展的需求,所以无须再保留<java_home>\lib\ext目录,之前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没用存在的价值了。</java_home>

新版本JDK中同时取消了<java_home>\jre目录,因为随时可以组合构建出程序运行所需的JRE,举例:</java_home>

只使用java.base模块中的类型,组合JRE

jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

JDK9前后类加载器继承架构对比

之前:

img

之后:

img

JDK9之后有了BootClassLoader”的存在,启动类加载器现在是在Java虚拟机内部和Java类库共同协作实现的类加载器,尽管有了BootClassLoader这样的Java类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如Object.class.getClassLoader())中仍然会返回null来代替,而不会得到BootClassLoader的实例。

JDK9后类加载器原理

JDK 9后的类加载器委派关系:

img

当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责哪个模块的加载器完成加载。

三个类加载器负责加载的模块

·启动类加载器负责加载的模块:

img

·平台类加载器负责加载的模块:

img

·应用程序类加载器负责加载的模块:

img

第八章 虚拟机字节码执行引擎

执行引擎是java虚拟机最核心的组成部件之一。虚拟机的执行引擎由自己实现,所以可以自行定制指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行

img

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元。

栈帧(Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

栈帧概念结构

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。 局部变量表的容量以变量槽(Variable Slot)为最小单位。 一个Slot可以存放一个32位以内(boolean、byte、char、short、int、float、reference和returnAddress)的数据类型,reference类型表示一个对象实例的引用,returnAddress已经很少见了,可以忽略。

对于64位的数据类型(Java语言中明确的64位数据类型只有long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。

为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:在某些情况下,Slot的复用会直接影响到系统的收集行为。

操作数栈

操作数栈(Operand Stack) 也常称为操作栈,它是一个后入先出栈。当一个方法执行开始时,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是 出栈/入栈操作。

操作数栈

在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接

字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接

方法返回地址

当一个方法被执行后,有两种方式退出这个方法:

  • 第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)
  • 另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)
    注意:这种退出方式不会给上层调用者产生任何返回值。

无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

方法调用

方法调用阶段的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍、最频繁的操作。

一切方法调用在Class文件里存储的都只是符号引用,这是需要在类加载期间或者是运行期间,才能确定为方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)

解析

“编译期可知,运行期不可变”的方法(静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址)。这类方法的调用称为“解析(Resolution)”。

在Java虚拟机中提供了5条方法调用字节码指令:
- invokestatic : 调用静态方法
- invokespecial:调用实例构造器方法、私有方法、父类方法
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方***在运行时在确定一个实现此接口的对象
- invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

分派

分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。

1、静态分派

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。

静态分派最典型的应用就是方法重载。

public class StaticDispatch {
  static abstract class Human {}
  static class Man extends Human {}     
  static class Woman extends Human {}     
  public void sayhello(Human guy) {
    System.out.println("Human guy");
  }
  public void sayhello(Man guy) {
    System.out.println("Man guy");
  }     
  public void sayhello(Woman guy) {
    System.out.println("Woman guy");
  }
  public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    StaticDispatch staticDispatch = new StaticDispatch();
    staticDispatch.sayhello(man);// Human guy
    staticDispatch.sayhello(woman);// Human guy
  } 
}

运行结果:

Human guy

Human guy

为什么会出现这样的结果呢?

Human man = new Man();其中的Human称为变量的静态类型(Static Type),Man称为变量的实际类型(Actual Type)
两者的区别是:静态类型在编译器可知,而实际类型到运行期才确定下来。
在重载时通过参数的静态类型而不是实际类型作为判定依据,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

2、动态分派

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。最典型的应用就是方法重写。

public class DynamicDisptch {

    static abstract class Human {
        abstract void sayhello();
    }

    static class Man extends Human {

        @Override
        void sayhello() {
            System.out.println("man");
        }

    }

    static class Woman extends Human {

        @Override
        void sayhello() {
            System.out.println("woman");
        }

    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayhello();
        woman.sayhello();
        man = new Woman();
        man.sayhello();
    }

}

运行结果:

man

woman

woman

3、单分派和多分派

方法的接收者、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。

Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

注:到JDK1.7时,Java语言还是静态多分派、动态单分派的语言,未来有可能支持动态多分派。

4、虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。

其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。

虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。

上图就是一个虚方法表,Father、Son、Object 三个类在方法区中都有一个自己的虚方法表,如果子类中实现了父类的方法,那么在子类的虚方法表中该方法就指向子类实现的该方法的入口地址,如果子类中没有重写父类中的方法,那么在子类的虚方法表中,该方法的索引就指向父类的虚方法表中的方法的入口地址。有两点需要注意:

  • 为了程序实现上的方便,一个具有相同签名的方法,在子类的方法表和父类的方法表中应该具有相同的索引,这样在类型变化的时候,只需要改变查找方法的虚方法表即可。
  • 虚方法表是在类加载的连接阶段实现的,类的变量初始化完成之后,就会初始化该类的虚方法表。

动态类型支持语言

JDK新增加了invokedynamic指令来是实现“动态类型语言”。

动态类型语言

静态语言和动态语言的区别:

  • 静态语言(强类型语言):稳定性
    静态语言是在编译时变量的数据类型即可确定的语言,多数静态类型语言要求在使用变量之前必须声明数据类型。
    例如:C++、Java、Delphi、C#等。
  • 动态语言(弱类型语言) : 灵活性
    动态语言是在运行时确定数据类型的语言。变量使用之前不需要类型声明,通常变量的类型是被赋值的那个值的类型。
    例如PHP/ASP/Ruby/Python/Perl/ABAP/SQL/JavaScript/Unix Shell等等。
  • 强类型定义语言
    强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。举个例子:如果你定义了一个整型变量a,那么程序根本不可能将a当作字符串类型处理。强类型定义语言是类型安全的语言。
  • 弱类型定义语言
    数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。强类型定义语言在速度上可能略逊色于弱类型定义语言,但是强类型定义语言带来的严谨性能够有效的避免许多错误。

基于栈的字节码解释执行引擎

本节我们探讨虚拟机是如何执行方法中的字节码指令的。Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行和编译执行两种选择,我们探讨一下解释执行时,虚拟机执行引擎是如何工作的。

在开始之前,先介绍一下解释执行和编译执行的含义

  1. 解释执行:代码由生成字节码指令之后,由解释器解释执行
  2. 编译执行:通过即时编译器生成本地代码执行

如下图所示,中间这条分支是解释执行,下面那条分支是编译执行

img

Java 程序在执行前先对程序源码进行词法分析和语法分析处理,把源代码转化抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++ 语言。当然也可以选择其中的一部分步骤实现一个半独立的编译器,这类代表是 Java 语言,又或者把这些步骤和执行引擎全部集中封装到一个封闭黑匣子中,如大多数的 JS 执行器。

Java 语言中,Javac 编译器完成了词法分析、语法分析、转换为抽象语法树,然后再生成字节码指令流的过程,这些动作是独立于 Java 虚拟机之外的,所以 Javac 编译器是一个半独立的编译器。

基于栈的指令集与基于寄存器的指令集

基于栈的指令集中的指令是依赖于操作数栈运行的,基于寄存器的指令是依赖于寄存器进行工作的。那么它们两者有什么区别呢?用一个简单的例子说明:1 + 1 这个例子来说明

  • 基于栈的指令如下:

      iconst_1
      iconst_1
      iadd
      istore_0

    两条 iconst_1 指令分别把两个 1 压入到工作栈中去,然后执行 iadd 两条指令,对栈顶的两个 1 进行出栈并相加的动作,然后将相加的结果 2 压入到栈中,接着执行 istore_0 将栈顶的 2 存入到局部变量表中第 0个 Slot 中去

  • 基于寄存器的指令如下:

      mov  eax,1
      add   eax, 1

    mov 指令将寄存器 eax 中的值设置为 1,然后执行 add 指令将寄存器 eax 中的值加 1,结果就保存在 eax 寄存器中

基于栈的指令的特点

  • 可移植:寄存器由硬件决定,限制较大,但是虚拟机可以在不同硬件条件的机器上执行
  • 代码相对更加紧凑:字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数
  • 编译器实现更加简单
  • 基于栈的指令缺点就是执行速度慢,因为虚拟机中操作数栈是在内存中实现的,频繁的栈访问也就意味着频繁的访问内存,内存的访问还是要比直接操作寄存器要慢的

基于栈的解释器执行过程

我们通过一段示例代码来学习基于栈的解释器执行过程,示例代码如下所示:

public class Test {
    public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }
}

执行 javac Test.java 生成 Test.class 字节码文件之后,再使用 javap -verbose Test.class 命令查看 Test.class 字节码指令如下图所示:

img

由上图中可以看到,Test#calc() 方法对应的字节码如下:

  public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
      LineNumberTable:
        line 3: 0
        line 4: 3
        line 5: 7
        line 6: 11

从上面的字节码指令中可以分析到:calc() 方法需要深度为 2 的操作数栈和 4 个 Slot 的局部变量空间,如下 7 张图描述上述代码执行过程中的代码、操作数栈和局部变量表的变化情况。

img

img

img

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述。

更准确的说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解释器和即时编译器都会对输入的字节码进行优化。

第九章 类加载及执行子系统的案例与实战

在 Class 文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不太多,Class 文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为了许多常用功能和程序实现的基础。在本章中,我们将看一下前面所学的知识在实际开发之中是如何应用的。
在案例分析部分,笔者准备了 4 个例子,关于类加载器和字节码的案例各有两个。并且这两个领域的案例中各有一个案例是大多数 Java 开发人员都使用过的工具或技术,另外一个案例虽然不一定每个人都使用过,但却特别精彩地演绎出这个领域中的技术特性。希望这些案例能引起读者的思考,并给读者的日常工作带来灵感。

tomcat正统的类加载器架构

主流的 Java Web 服务器,如 Tomcat、Jetty、WebLogic、WebSphere 或其他笔者没有列举的服务器,都实现了自己定义的类加载器(一般都不止一个)。因为一个功能健全的 Web 服务器,要解决如下几个问题:

  • 部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应当保证两个应用程序的类库可以互相独立使用。
    部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求也很常见,例如,用户可能有 10 个使用 Spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
  • 服务器需要尽可能地保证自身的安全不受部署的 Web 应用程序影响。目前,有许多主流的 Java Web 服务器自身也是使用 Java 语言来实现的。因此,服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库相互独立。
    支持 JSP 应用的 Web 服务器,大多数都需要支持 HotSwap 功能。我们知道,JSP 文件最终要编译成 Java Class 才能由虚拟机执行,但 JSP 文件由于其纯文本存储的特性,运行时修改的概率远远大于第三方类库或程序自身的 Class 文件。而且 ASP、PHP 和 JSP 这些网页应用也把修改后无须重启作为一个很大的 “优势” 来看待,因此 “主流” 的 Web 服务器都会支持 JSP 生成类的热替换,当然也有 “非主流” 的,如运行在生产模式(Producation Mode)下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。

由于存在上述问题,在部署 Web 应用时,单独的一个 ClassPath 就无法满足需求了,所以各种 Web 服务器都 “不约而同” 地提供了好几个 ClassPath 路径供用户存放第三方类库,这些路径一般都以 “lib” 或 “classes” 命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相对应的自定义类加载器去加载放置在里面的 Java 类库。现在,笔者就以 Tomcat 服务器(注:本案例中选用的是 Tomcat 5.x 服务器的目录和类加载器结构,在 Tomcat 6.x 的默认配置下,/common、/server 和 /shared 三个目录已经合并到一起了)为例,看一看 Tomcat 具体是如何规划用户类库结构和类加载器的。

  • 在 Tomcat 目录结构中,有 3 组目录(“/common/”、“/server/” 和 “/shared/”)可以存放 Java 类库,另外还可以加上 Web 应用程序自身的目录 “/WEB-INF/”,一共 4 组,把 Java 类库放置在这些目录中的含义分别如下。

放置在 /common 目录中:类库可被 Tomcat 和所有的 Web 应用程序共同使用。
放置在 /server 目录中:类库可被 Tomcat 使用,对所有的 Web 应用程序都不可见。
放置在 /shared 目录中:类库可被所有的 Web 应用程序共同使用,但对 Tomcat 自己不可见。
放置在 /WebApp/WEB-INF 目录中:类库仅仅可以被此 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图 9-1 所示。

img
灰色背景的 3 个类加载器是 JDK 默认提供的类加载器,这 3 个加载器的作用前面已经介绍过了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /common/、/server/*、/shared/ 和 /WebApp/WEB-INF/* 中的 Java 类库。其中 WebApp 类加载器和 Jsp 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 Jsp 类加载器。

从图 9-1 的委派关系中可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加载的类则与对方相互隔离。WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebAppClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是这个 JSP 文件所编译出来的那一个 Class,它出现的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 Jsp 类加载器来实现 JSP 文件的 HotSwap 功能。

对于 Tomcat 的 6.x 版本,只有指定了 tomcat/conf/catalina.properties 配置文件的 server.loader 和 share.loader 项后才会真正建立 CatalinaClassLoader 和 SharedClassLoader 的实例,否则会用到这两个类加载器的地方都会用 CommonClassLoader 的实例代替,而默认的配置文件中没有设置这两个 loader 项,所以 Tomcat 6.x 顺理成章地把 /common、/server 和 /shared 三个目录默认合并到一起变成一个 /lib 目录,这个目录里的类库相当于以前 /common 目录中类库的作用。这是 Tomcat 设计团队为了简化大多数的部署场景所做的一项改进,如果默认设置不能满足需要,用户可以通过修改配置文件指定 server.loader 和 share.loader 的方式重新启用 Tomcat 5.x 的加载器架构。

Tomcat 加载器的实现清晰易懂,并且采用了官方推荐的 “正统” 的使用类加载器的方式。如果读者阅读完上面的案例后,能完全理解 Tomcat 设计团队这样布置加载器架构的用意,那说明已经大致掌握了类加载器 “主流”的使用方式,那么笔者不妨再提一个问题来让读者思考一下:前面曾经提到过一个场景,如果有 10 个 Web 应用程序都是用 Spring 来进行组织和管理的话,可以把 Spring 放到 common 或 shared 目录下让这些程序共享。Spring 要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的,那么被 CommonClassLoader 或 SharedClassLoader 加载的 Spring 如何访问并不在其加载范围的用户程序呢?

osgi灵活的类加载器架构

Java 程序社区中流传着这么一个观点:“学习 JEE 规范,去看 JBoss 源码;学习类加载器,就去看 OSGi 源码”。尽管 “JEE 规范” 和 “类加载器的知识” 并不是一个对等的概念,不过,既然这个观点能在程序员中流传开来,也从侧面说明了 OSGi 对类加载器的运用确实有其独到之处。

OSGi(Open Service Gateway Initiative) 是 OSGi 联盟(OSGi Alliance)制定的一个基于 Java 语言的动态模块化规范,这个规范最初由 Sun、IBM、爱立信等公司联合发起,目的是使用服务提供商通过住宅网关为各种家用智能设备提供各种服务,后来这个规范在 Java 的其他技术领域也有相当不错的发展,现在已经成为 Java 世界中 “事实上” 的模块化标准,并且已经有了 Equinox、Felix 等成熟的实现。OSGi 在 Java 程序员中最著名的应用案例就是 Eclipse IDE,另外还有许多大型的软件平台和中间件服务器都基于或声明将会基于 OSGi 规范来实现,如 IBM Jazz 平台、GlassFish 服务器、JBoss OSGi 等。

OSGi 中的每个模块(称为 Bundle)与普通的 Java 类库区别并不太大,两者一般都以 JAR 格式进行封装,并且内部存储的都是 Java Package 和 Class。但是一个 Bundle 可以声明它所依赖的 Java Package(通过 Import-Package 描述),也可以声明它允许导出发布的 Java Package(通过 Export-Package 描述)。在 OSGi 里面,Bundle 之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖(至少外观上如此),而且类库的可见性能得到非常精确的控制,一个模块里只有被 Export 过的 Package 才可能由外界访问,其他的 Package 和 Class 将会隐藏起来。除了更精确的模块划分和可见性控制外,引入 OSGi 的另外一个重要理由是,基于 OSGi 的程序很可能(只是很可能,并不是一定会)可以实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对企业级程序开发来说是一个非常有诱惑力的特性。

OSGi 之所以能有上述 “诱人” 的特点,要归功于它灵活的类加载器架构。OSGi 的 Bundle 类加载器之间只有规则,没有固定的委派关系。例如,某个 Bundle 声明了一个它依赖的 Package,如果有其他 Bundle 声明发布了这个 Package,那么所有对这个 Package 的类加载动作都会委派给发布它的 Bundle 类加载器去完成。不涉及某个具体的 Package 时,各个 Bundle 加载器都是平级关系,只有具体使用某个 Package 和 Class 的时候,才会根据 Package 导入导出定义来构造 Bundle 间的委派和依赖。

另外,一个 Bundle 类加载器为其他 Bundle 提供服务时,会根据 Export-Package 列表严格控制访问范围。如果一个类存在于 Bundle 的类库中但是没有被 Export,那么这个 Bundle 的类加载器能找到这个类,但不会提供给其他 Bundle 使用,而且 OSGi 平台也不会把其他 Bundle 的类加载请求分配给这个 Bundle 来处理。

我们可以举一个更具体一些的简单例子,假设存在 Bundle A、Bundle B、Bundle C 三个模块,并且这三个 Bundle 定义的依赖关系如下。

Bundle A:声明发布了 packageA,依赖了 java.* 的包。
Bundle B:声明依赖了 packageA 和 packageC,同时也依赖了 java.* 的包。
Bundle C:声明发布了 packageC,依赖了 packageA。
那么,这三个 Bundle 之间的类加载器及父类加载器之间的关系如图 9-2 所示。

img

由于没有牵扯到具体的 OSGi 实现,所以图 9-2 中的类加载器都没有指明具体的加载器实现,只是一个体现了加载器之间关系的概念模型,并且只是体现了 OSGi 中最简单的加载器委派关系。一般来说,在 OSGi 中,加载一个类可能发生的查找行为和委派关系会比图 9-2 中显示的复杂得多,类加载时可能进行的查找规则如下:

以 java.* 开头的类,委派给父类加载器加载。
否则,委派列表名单内的类,委派给父类加载器加载。
否则,Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
否则,查找当前 Bundle 的 Classpath,使用自己的类加载器加载。
否则,查找是否在自己的 Fragment Bundle 中,如果是,则委派给 Fragment Bundle 的类加载器加载。
否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
否则,类查找失败。

从图 9-2 中还可以看出,在 OSGi 里面,加载器之间的关系不再是双亲委派模型的属性结构,而是已经进一步发展成了一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更好的灵活性的同时,也可能会产生许多新的隐患。笔者曾经参与过将一个非 OSGi 的大型系统向 Equinox OSGi 平台迁移的项目,由于历史原因,代码模块之间的的依赖关系错综复杂,勉强分离出各个模块的 Bundle 后,发现在高并发环境下经常出现死锁。我们很容易就找到了死锁的原因:如果出现了 Bundle A 依赖于 Bundle B 的 Package B,而 Bundle B 又依赖了 Bundle A 的 Package A,这两个 Bundle 进行类加载时就很容易发生死锁。具体情况是当 Bundle A 加载 Package B 的类时,首先需要锁定当前类加载器的实例对象(java.lang.ClassLoader.loadClass() 是一个 synchronized 方法),然后把请求委派给 Bundle B 的加载器处理,但如果这时候 Bundle B 也正好想加载 Package A 的类,它也先锁定自己的加载器再去请求 Bundle A 的加载器处理,这样,两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一直处于同步锁定的状态,因此它们就互相死锁,永远无法完成加载请求了。Equinox 的 Bug List 中有关于这类问题的 Bug,也提供了一个以牺牲性能为代价的解决方案——用户可以启用 osgi.classloader.singleThreadLoads 参数来按单线程串行化的方式强制进行类加载器动作。在 JDK 1.7 中,为非树状继承关系下的类加载器架构进行了一次专门的升级,目的是从底层避免这类死锁出现的可能。

总体来说,OSGi 描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需要的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在 OSGi 是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用 OSGi 作为基础架构,OSGi 在提供强大功能的同时,也引入了额外的复杂度,带来了线程死锁和内存泄露的风险。

字节码生成技术与动态代理的实现

“字节码生成” 并不是什么高深的技术,读者在看到 “字节码生成” 这个标题时也不必去向诸如 Javassit、CGLib、ASM 之类的字节码类库,因为 JDK 里面的 javac 命令就是字节码生成技术的 “老祖宗”,并且 javac 也是一个由 Java 语言写成的程序,它的代码存放在 OpenJDK 的 langtools/src/share/classes/com/sun/tools/javac 目录中。要深入了解字节码生成,阅读 javac 的源码是个很好的途径,不过 javac 对于我们这个例子来说太过庞大了。在 Java 里面除了 javac 和字节码类库外,使用字节码生成的例子还有很多,如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。我们选择其中相对简单的动态代理来看看字节码生成技术是如何影响程序运作的。

相信许多 Java 开发人员都使用过动态代理,即使没有直接使用过 java.lang.reflect.Proxy 或实现过 java.lang.reflect.InvocationHandler 接口,应该也用过 Spring 来做过 Bean 的组织管理。如果使用过 Spring,那大多数情况都会用过动态代理,因为如果 Bean 是面向接口编程,那么在 Spring 内部都是通过动态代理的方式来对 Bean 进行增强的。动态代理中所谓的 “动态”,是针对使用 Java 代码实际编写了代理类的 “静态” 代理而言的,它的优势不在于省去了编写代理类哪一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。

代码清单 9-1 演示了一个最简单的动态代理的用法,原始的逻辑是打印一句 “hello world”,代理类的逻辑是在原始类方法执行前打印一句 “welcome”。我们先看一下代码,然后再分析 JDK 是如何做到的。

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class DynamicProxyTest {

    interface IHello {
        void sayHello();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() {
             System.out.println("hello world");
        }
    }

    static class DynamicProxy implements InvocationHandler {

        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), 
                    originalObj.getClass().getInterfaces(), this);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("welcome");
            return method.invoke(originalObj, args);
        }

    }

    public static void main(String[] args) throws Exception {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
    }
}

运行结果:

welcome
hello world

上述代码里,唯一的 “黑匣子” 就是 Proxy.newProxyInstance() 方法,除此之外再没有任何特殊之处。这个方法返回一个实现了 IHello 的接口,并且代理了 new Hello() 实例行为的对象。跟踪这个方法的源码,可以看到程序进行了验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,而最后它调用了 sun.misc.ProxyGenerator.generateProxyClass() 方法来完成生成字节码的动作,这个方法可以在运行时产生一个描述代理类的字节码 byte[] 数组。如果想看一看这个再运行时产生的代理类中写了什么,可以在main() 方法中加入下面这句:
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

加入这句代码后再次运行程序,磁盘中将会产生一个名为 “$Proxy0.class” 的代理类 Class 文件(注:应该先在【项目目录】非【ClassPath 目录】下,建立和包名对应的文件夹,如图 a 所示),反编译后可以看见如代码清单 9-2 所示的内容。

package org.fenixsoft.def;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0
  extends Proxy
  implements DynamicProxyTest.IHello
{
  private static Method m3;
  private static Method m1;
  private static Method m0;
  private static Method m2;

  public $Proxy0(InvocationHandler paramInvocationHandler)
  {
    super(paramInvocationHandler);
  }

  public final void sayHello()
  {
    try
    {
      this.h.invoke(this, m3, null);
      return;
    }
    catch (Error|RuntimeException localError)
    {
      throw localError;
    }
    catch (Throwable localThrowable)
    {
      throw new UndeclaredThrowableException(localThrowable);
    }
  }

  // 此处由于版面原因,省略 equals()、hashCode()、toString() 三个方法的代码
  // 这 3 个方法的内容与 sayHello() 非常相似

 static
  {
    try
    {
      m3 = Class.forName("org.fenixsoft.def.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
      m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] { Class.forName("java.lang.Object") });
      m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
      m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
      return;
    }
    catch (NoSuchMethodException localNoSuchMethodException)
    {
      throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
    }
    catch (ClassNotFoundException localClassNotFoundException)
    {
      throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
    }
  }
}

这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从 java.lang.Object 中继承来的 equals()、hashCode()、toString() 方法都生成了对应的实现,并且统一调用了 InvocationHandler 对象的 invoke() 方法(代码中的 “this.h” 就是父类 Proxy 中保存的 InvocationHandler 实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和 Method 对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行 InvocationHandler.invoke() 中的代理逻辑。

这个例子中并没有讲到 generateProxyClass() 方法具体是如何产生代理类 “$Proxy0.class” 的字节码的,大致的生成过程其实就是根据 Class 文件的格式规范去拼装字节码,但在实际开发中,以 byte 为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程很感兴趣,可以在 OpenJDK 的 jdk/src/share/classes/sun/misc 目录下找到sun.misc.ProxyGenerator 的源码。

retrotranslator跨域JDK版本

一般来说,以 “做项目” 为主的软件公司比较容易更新技术,在下一个项目中换一个技术框架、升级到最新的 JDK 版本,甚至把 Java 换成 C#、C++ 来开发程序都是由可能的。但当公司发展壮大,技术有所积累,逐渐成为 “做产品” 为主的软件公司后,自主选择技术的权利就会丧失掉,因为之前所积累的代码和技术都是用真金白银换来的,一个稳健的团队也不会随意地改变底层的技术。然而在飞速发展的程序设计领域,新技术总是日新月异、层出不穷,偏偏这些新技术又如鲜花之于蜜蜂一样,对程序员散发着天然的吸引力。

在 Java 世界里,每一次 JDK 大版本的发布,都伴随着一场大规模的技术革新,而对 Java 程序编写习惯改变最大的,无疑是 JDK 1.5 的发布。自动装箱、泛型、动态注解、枚举、变长参数、遍历循环(foreach 循环)……事实上,在没有这些语法特性的年代,Java 程序也照样能写,但是现在看来,上述每一种语法的改进几乎都是 “必不可少” 的。就如同习惯了 24 寸液晶显示器的程序员,很难习惯在 15 寸平显示器上编写代码。但假如 “不幸” 因为要保护现有投资、维持程序结构稳定等,必须使用 1.5 以前版本的 JDK 呢?我们没有办法把 15 寸显示器变成 24 寸的,但却可以跨越 JDK 版本之间的沟壑,把 JDK 1.5 中编写的代码放到 JDK 1.4 或 1.3 的环境去部署使用。为了解决这个问题,一种名为 “Java 逆向移植” 的工具(Java Backporting Tools)应运而生,Retrotranslator 是这类工具中较出色的一个。

Retrotranslator 的作用是将 JDK 1.5 编译出来的 Class 文件转变为可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持 JDK 1.5 中新增的集合改进、并发包以及对泛型、注解等的反射操作。了解了 Retrotranslator 这种逆向移植工具可以做什么以后,现在关心的是它是怎样做到的?

要想知道 Retrotranslator 如何在旧版本 JDK 中模拟新版本 JDK 的功能,首先要弄清楚 JDK 升级中会提供哪些新的功能。JDK 每次升级新增的功能大致可以分为以下 4 类:

在编译器层面做的改进。如自动装箱拆箱,实际上就是编译器在程序中使用到包装对象的地方自动插入了很多 Integer.valueOf()、Float.valueOf() 之类的代码;变长参数在编译之后就自动转化成一个数组来完成参数传递;泛型的信息则在编译阶段就已经擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
对 Java API 的代码增强。譬如 JDK 1.2 时代引入的 java.util.Collections 等一系列集合类,在 JDK 1.5 时代引入的 java.util.concurrent 并发包等。
需要在字节码中进行支持的改动。如 JDK 1.7 里面新加入的语法特性:动态语言支持,就需要在虚拟机中新增一条 invokedynamic 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对比较稳定的状态,这种需要在字节码层面直接进行的改动是比较少见的。
虚拟机内部的改进。如 JDK 1.5 中实现的 JSR-133 规范重新定义的 Java 内存模型(Java Memory Model,JMM)、CMS 收集器之类的改动,这类改动对于程序员编写代码基本是透明的,但会对程序运行时产生影响。
上述 4 类新功能中,Retrotranslator 只能模拟前两类,对于后面两类直接在虚拟机内部实现的改进,一般所有的逆向移植工具都是无能为力的,至少不能完整地或者再可接受的效率上完成全部模拟,否则虚拟机设计团队也没有必要舍近求远地改动处于 JDK 底层的虚拟机。在可以模拟的两类功能中,第二类模拟相对更容易实现一些,如 JDK 1.5 引入的 java.util.concurrent 包,实际是由多线程大师 Doug Lea 开发的一套并发包,在 JDK 1.5 出现之前就已经存在(那时候名字叫做 dl.util.concurrent,引入 JDK 时由作者和 JDK 开发团队共同做了一些改进),所以要在旧的 JDK 中支持这部分功能,以独立类库的方式便可实现。Retrotranslator 中附带了一个名叫 “backport-util-concurrent.jar” 的类库(由另一个名为 “Backport of JSR 166” 的项目所提供)来代替 JDK 1.5 的并发包。

至于 JDK 在编译阶段进行处理的那些改进,Retrotranslator 则是使用 ASM 框架直接对字节码进行处理。由于组成 Class 文件的字节码指令数量并没有改变,所以无论是 JDK 1.3、JDK 1.4 还是 JDK 1.5,能用字节码表达的语义范围应该是一直的。当然,肯定不可能简单地把 Class 的文件版本号从 49.0 改回 48.0 就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。以枚举为例,在 JDK 1.5 中增加了 enum 关键字,但是 Class 文件常量池的 CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过 “CONSTANT_Enum_info” 之类的 “枚举符号引用” 常量。所以使用 enum 关键字定义常量,虽然从 Java 语法上看起来与使用 class 关键字定义类、使用 interface 关键字定义接口是同一层次的,但实际上这是由 Javac 编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于 java.lang.Enum、自动生成了 values() 和 valueOf() 方法的普通 Java 类而已。

Retrotranslator 对枚举所做的主要处理就是把枚举类的父类从 “java.lang.Enum” 替换位它运行时类库中包含的 “net.sf.retrotranslator.runtime.java.lang.Enum_”,然后再在类和字段的访问标志中抹去 ACC_ENUM 标志位。当然,这只是处理的总体思路,具体的实现要比上面说的复杂得多。可以想象既然两个父类实现都不一样,values() 和 valueOf() 的方法自然需要重写,常量池需要引入大量新的来自父类的符号引用,这些都是实现细节。图 9-3 是一个使用 JDK 1.5 编译的枚举类与被 Retrotranslator 转换处理后的字节码的对比图。

img

自己手动实现远程执行能力

不知道读者在做程序维护的时候是否遇到过这类情形:排查问题的过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的同一管理界面,不得不重启服务才能清理这个缓存。类似的需求又一个共同的特点,那就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是偏偏找不到可以让服务器执行临时代码的途径,这时候就会希望 Java 服务器中也有提供类似 Groovy Console 的功能。

JDK 1.6 之后提供了 Compiler API,可以动态地编译 Java 程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在 JDK 1.6 之前,也可以通过其他方式来做到,譬如写一个 JSP 文件上传到服务器,然后在浏览器中运行它,或者在服务器端程序中加入一个 BeanShell Script、JavaScript 等的执行引擎(如 Mozilla Rhino)去执行动态脚本。在本章的实战部分,我们将使用前面学到的关于类加载及虚拟机执行子系统的知识去实现在服务端执行临时代码的功能。

目标

首先,在实现 “在服务端执行临时代码” 这个需求之前,先来明确一下本次实战的具体目标,我们希望最终的产品是这样的:

  1. 不依赖 JDK 版本,能在目前还普遍使用的 JDK 中部署,也就是使用 JDK 1.4 ~ JDK 1.7 都可以运行。
  2. 不改变原有服务端程序的部署,不依赖任何第三方类库。
  3. 不侵入原有程序,即无须改动原程序的任何代码,也不会对原有程序的运行带来任何影响。
    考到 BeanShell Script 或 JavaScript 等脚本编写起来不太方便,“临时代码” 需要直接支持 Java 语言。
  4. “临时代码” 应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是 “不需要” 而不是 “不可以”,当 “临时代码” 需要引用其他类库时也没有限制,只要服务端程序能使用的,临时代码应当都能直接引用。
  5. “临时代码” 的执行结果能返回客户端,执行结果可以包括程序中输出的信息及抛出的异常等。

看完上面列出的目标,你觉得完成这个需求需要做多少工作呢?也许答案比大多数人所想的都要简单一些:5 个类,250 行代码(含注释),大约一个半小时左右的开发时间久可以了,现在就开始编写程序吧!

思路

在程序实现的过程中,我们需要解决以下 3 个问题:

  1. 如何编译提交到服务器的 Java 代码?
  2. 如何执行编译之后的 Java 代码?
  3. 如何收集 Java 代码的执行结果?

对于第一个问题,我们有两种思路可以选择,一种是使用 tools.jar 包(在 Sun JDK/lib 目录下)中的 com.sun.tools.javac.Main 类来编译 Java 文件,这其实和使用 javac 命令编译是一样的。这种思路的缺点的引入了额外的 JAR 包,而且把程序 “绑死” 在 Sun 的 JDK 上了,要部署到其他公司的 JDK 中还得把 tools.jar 带上(虽然 JRockit 和 J9 虚拟机也有这个 JAR 包,但它总不是标准所规定必须存在的)。另外一种思路是直接在客户端编译好,把字节码而不是 Java 代码传到服务端,这听起来好像有点投机取巧,一般来说确实不应该假定客户端一定具有编译代码的能力,但是既然程序员会写 Java 代码去给服务端排查问题,那么很难想象他的机器上会连编译 Java 程序的环境都没有。

对于第二个问题,简单地一想:要执行编译后的 Java 代码,让类加载器加载这个类生成一个 Class 对象,然后反射调用一下某个方法就可以了(因为不实现任何接口,我们可以借用一下 Java 中人人皆知的 “main()” 方法)。但我们还应该考虑得更周全些:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复地修改、提交、执行。另外,提交上去的类要能访问服务端的其他类库才行。还有,既然提交的是临时代码,那提交的 Java 类在执行完成后就应当能卸载和回收。

最后的一个问题,我们想把程序往标准输出(System.out)和标准错误输出(System.err)中打印的信息收集起来,但标准输出设备是整个虚拟机进程全局共享的资源,如果使用 System.setOut()/System.setErr() 方法把输出流重定向到自己定义的 PrintStream 对象上固然可以收集输出信息,但也会对原有程序产生影响:会把其他线程向标准输出中打印的信息也收集了。虽然这些并不是不能解决的问题,不过为了达到完全不影响原程序的目的,我们可以采用另外一种办法,即直接在执行的类中把对 System.out 的符号引用替换为我们准备的 PrintStream 的符号引用,依赖前面学习的只是,做到这一点并不困难。

实现

在程序实现部分,我们主要看一下代码及其注释。首先看看实现过程中需要用到的 4 个支持类。第一个类用于实现 “同一个类的代码可以被多次加载” 这个需求,具体程序如代码清单 9-3 所示。

代码清单 9-3 HotSwapClassLoader 的实现

/**
 * 为了多次载入执行类而加入的加载器 <br>
 * 把 defineClass 方法开放出来,只有外部显式调用的时候才会使用到 loadByte 方法
 * 由虚拟机调用时,仍然按照原有的双亲委派规则使用 loadClass 方法进行类加载
 *
 */
public class HotSwapClassLoader extends ClassLoader{

    public HotSwapClassLoader() {
        super(HotSwapClassLoader.class.getClassLoader());
    }

    public Class loadByte(byte[] classByte) {
        return defineClass(null, classByte, 0, classByte.length);
    }
}

HotSwapClassLoader 所做的事情仅仅是公开父类(即 java.lang.ClassLoader) 中的 protected 方法 defineClass(),我们将会使用这个方法把提交执行的 Java 类的 byte[] 数组转变为 Class 对象。HotSwapClassLoader 中并没有重写 loadClass() 或 findClass() 方法,因此如果不算外部手工调用 loadByte() 方法的话,这个类加载器的类查找范围与它的父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载 HotSwapClassLoader 类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键,下面我们来看看代码清单 9-3。

第二个类是实现将 java.lang.System 替换为我们自己定义的 HackSystem 类的过程,它直接修改符合 Class 文件格式的 byte[] 数组中的常量池部分,将常量池中指定内容的 CONSTANT_Utf8_info 常量替换为新的字符串,具体代码如代码清单 9-4 所示。ClassModifier 中设计对 byte[] 数组操作的部分,主要是将 byte[] 与 int 和 String 互相转换,以及把对 byte[] 数据的替换操作封装在代码清单 9-5 所示的 ByteUtils 中。

代码清单 9-4 ClassModifier 的实现

/**
 * 修改 Class 文件,暂时只提供修改常量池常量的功能
 *
 */
public class ClassModifier {

    /**
     * Class 文集中常量池的起始偏移
     */
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;

    /**
     * CONSTANT_Utf8_info 常量的 tag 标志
     */
    private static final int CONSTANT_Utf8_info = 1;

    /**
     * 常量池中 11 种常量所占的长度,CONSTANT_Utf8_info 型常量除外,因为它不是定长的
     */
    private static final int[] CONSTATN_ITEM_LENGTH = { -1, -1, -1, 5, 5, 9, 9,
            3, 3, 5, 5, 5, 5 };

    private static final int u1 = 1;
    private static final int u2 = 2;

    private byte[] classByte;

    public ClassModifier(byte[] classByte) {
        this.classByte = classByte;
    }

    /**
     * 修改常量池 CONSTANT_Utf8_info 常量的内容
     * @param oldStr  修改前的字符串
     * @param newStr  修改后的字符串
     * @return 修改结果
     */
    public byte[] modifyUTF8Constant(String oldStr, String newStr) {
        int cpc = getConstantPoolCount();
        int offset = CONSTANT_POOL_COUNT_INDEX + u2;

        for (int i = 0; i < cpc; i++) {
            int tag = ByteUtils.bytes2Int(classByte, offset, u1);
            if (tag == CONSTANT_Utf8_info) {
                int len = ByteUtils.bytes2Int(classByte, offset + u1, u2);
                offset += (u1 + u2);
                String str = ByteUtils.bytes2String(classByte, offset, len);
                if (str.equalsIgnoreCase(oldStr)) {
                    byte[] strBytes = ByteUtils.string2Bytes(newStr);
                    byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2);
                    classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen);
                    classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes);
                    return classByte;
                } else {
                    offset += len;
                }
            } else {
                offset += CONSTATN_ITEM_LENGTH[tag];
            }
        }
        return classByte;
    }

    /**
     * 获取常量池中常量的数量
     * @return 常量池数量
     */
    public int getConstantPoolCount() {
        return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
    }
}

代码清单 9-5 ByteUtils 的实现:

public class ByteUtils {

    public static int bytes2Int(byte[] b, int start, int len) {
        int sum = 0;
        int end = start + len;

        for (int i = start; i < end; i++) {
            int n = ((int) b[i]) & 0xff;
            n <<= (--len) * 8;
            sum = n + sum;
        }
        return sum;
    }

    public static byte[] int2Bytes(int value, int len) {
        byte[] b = new byte[len];
        for (int i = 0; i < len; i++) {
            b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff);
        }
        return b;
    }

    public static String bytes2String(byte[] b, int start, int len) {
        return new String(b, start, len);
    }

    public static byte[] string2Bytes(String str) {
        return str.getBytes();
    }

    public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) {
        byte[] newBytes = new byte[originalBytes.length + (replaceBytes.length - len)];
        System.arraycopy(originalBytes, 0, newBytes, 0, offset);
        System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length);
        System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.
                length, originalBytes.length - offset - len);
        return newBytes;
    }
}

经过 ClassModifier 处理后的 byte[] 数组才会传给 HotSwapClassLoader.loadByte() 方法进行类加载,byte[] 数组在这里替换符号引用之后,与客户端直接在 Java 代码中引用 HackSystem 类再编译生成的 Class 是完全一样的。这样的实现既避免了客户端编写临时执行代码时要依赖特定的类(不然无法引入 HackSytem),又避免了服务端修改标准输出后影响到其他程序的输出。下面我们来看看代码清单 9-4 和代码清单 9-5。
最后一个类类就是前面提到过的用来代替 java.lang.System 的 HackSystem,这个类中的方法看起来不少,但其实除了把 out 和 err 两个静态变量改成使用 ByteArrayOutputStream 作为打印目标的同一个 PrintStream 对象,以及增加了读取、清理 ByteArrayOutputStream 中内容的 getBufferString() 和 clearBuffer() 方法外,就再没有其他新鲜的内容了。其余的方法全部来自于 System 类的 public 方法,方法名字、参数、返回值都完全一样,并且实现也是直接转调了 System 类的对应方法而已。保留这些方法的目的,是为了在 System 被替换成 HackSystem 之后,执行代码中调用的 System 的其余方法仍然可以继续使用,HackSystem 的实现如代码清单 9-6 所示。

/**
 * 为 JavaClass 劫持 java.lang.System 提供支持 
 * 除了 out 和 err 外,其余的都直接转发给 System 处理
 *
 */
public class HackSystem {

    public final static InputStream in = System.in;

    private static ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    public final static PrintStream out = new PrintStream(buffer);

    public final static PrintStream err = out;

    public static String getBufferString() {
        return buffer.toString();
    }

    public static void clearBuffer() {
        buffer.reset();
    }

    public static void setSecurityManager(final SecurityManager s) {
        System.setSecurityManager(s);
    }

    public static SecurityManager getSecurityManager() {
        return System.getSecurityManager();
    }

    public static long currentTimeMillis() {
        return System.currentTimeMillis();
    }

    public static long nanoTime() {
        return System.nanoTime();
    }

    public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length) {
        System.arraycopy(src, srcPos, dest, destPos, length);
    }

    public static int identityHashCode(Object x) {
        return System.identityHashCode(x);
    }

    // 下面所有的方法都与 java.lang.System 的名称一样
        // 实现都是字节转调 System 的对应方法 
        // 因版面原因,省略了其他方法
}

至此,4 个支持类已经讲解完毕,我们来看看最后一个类 JavaClassExecuter,它是提供给外部调用的入口,调用前面几个支持类组装逻辑,完成类加载工作。JavaClassExecuter 只有一个 execute() 方法,用输入的符合 Clas 文件格式的 byte[] 数组替换 java.lang.System 的符号引用后,使用 HotSwapClassLoader 加载生成一个 Class 对象,由于每次执行 execute() 方法都会生成一个新的类加载器实例,因此同一个类可以实现重复加载。然后,反射调用这个 Class 对象的 main() 方法,如果期间出现任何异常,将异常信息打印到 HackSystem.out 中,最后把缓冲区中的信息作为方法的结果返回。JavaClassExecuter 的实现代码如代码清单 9-7 所示。

代码清单 9-7 JavaClassExecuter 的实现:

/**
 * JavaClass 执行工具
 *
 */
public class JavaClassExecuter {

    /**
     * 执行外部传过来的代表一个 Java 类的 byte 数组 <br>
     * 将输入类的 byte 数组中代表 java.lang.System 的 CONSTANT_Utf8_info 常量修改为劫持后的 
     * HackSystem 类
     * 执行方法为该类的 static main(String[] args) 方法,输出结果为该类向 System.out/err
     * 输出的信息
     * 
     * @param classByte  代表一个 Java 类的 byte 数组
     * @return  执行结果
     */
    public static String execute(byte[] classByte) {
        HackSystem.clearBuffer();
        ClassModifier cm = new ClassModifier(classByte);
        byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", 
                "org/fenixsoft/classloading/execute/HackSystem");

        HotSwapClassLoader loader = new HotSwapClassLoader();

        Class clazz = loader.loadByte(modiBytes);
        try {
            Method method = clazz.getMethod("main", new Class[] {String[].class });
            method.invoke(null, new String[] { null });
        } catch (Throwable e) {
            e.printStackTrace(HackSystem.out);
        }
        return HackSystem.getBufferString();
    }
}

验证

远程执行功能的编码到此就完成了,接下来就要检验一下我们的劳动成果了。如果只是测试的话,那么可以任意写一个 Java 类,内容无所谓,只要向 System.out 输出信息即可,取名为 TestClass,同时放到服务器 C 盘的根目录中。然后,建立一个 JSP 文件并加入如代码清单 9-8 所示的内容,就可以在浏览器中看到这个类的运行结果了。

代码清单 9-8 测试 JSP:

<%@ page import="java.lang.*" %>
<%@ page import="java.io.*" %>
<%@ page import="org.fenixsoft.classloading.execute.*" %>
<%
    InputStream is = new FileInputStream("c:/TestClass.class");
    byte[] b = new byte[is.available()];
    is.read(b);
    is.close();

    out.println("<textarea style='width:1000;height=800'>");
    out.println(JavaClassExecuter.excute(b));
    out.println("</textarea>");
%>

当然,上面的做法只是用于测试和演示,实际使用这个 JavaExecuter 执行器的时候,如果还要手工复制一个 Class 文件到服务器上就没有什么意义了。笔者给这个执行器写了一个 “外壳”,是一个 Eclipse 插件,可以把 Java 文件编译后传输到服务器中,然后把执行器的返回结果输出到 Eclipse 的 Console 窗口里,这样就可以在有灵感的时候随时写几行调试代码,放到测试环境的服务器上立即运行了。虽然实现简单,但效果很不错,对调试问题也非常有用,如图 9-4 所示。

img

第十章 前端编译与优化

所谓”编译“,通俗来讲就是把我们写的代码“翻译“成机器可以读懂的机器码。而编译器就是做这个翻译工作的。

Java 技术中的编译器可以分为如下三类:

  • 前端编译器:把 *.java 文件转变为 *.class 文件的过程。比如 JDK 的 Javac。
  • 即时编译器:Just In Time Compiler,常称 JIT 编译器,在「运行期」把字节码转变为本地机器码的过程。比如 HotSpot VM 的 C1、C2 编译器,Graal 编译器。
  • 提前编译器:Ahead Of Time Compiler,常称 AOT 编译器,直接把程序编译成与目标机器指令集相关的二进制代码的过程。比如 JDK 的 Jaotc,GNU Compiler for the Java。

其中后面两类都属于后端编译器。

Javac编译器

Javac的源码与调试

Javac 的编译过程大致可以分为 1 个准备过程和 3 个处理过程:

  1. 准备过程:初始化插入式注解处理器
  2. 解析与填充符号表过程
    1. 词法、语法分析:将源码中的字符流转变为标记集合,构造抽象语法树
    2. 填充符号表:产生符号地址和符号信息
  3. 插入式注解处理器的注解处理过程
  4. 分析与字节码生成过程
    1. 标注检查:对语法的静态信息进行检查
    2. 数据流及控制流分析:对程序的动态运行过程进行检查
    3. 解语法糖:将简化代码编写的语法糖还原为原来的样子
    4. 字节码生成:将前面各个步骤所生成的信息转化为字节码

解析与填充符号表

  • 词法分析

将源码中的字符流转变为标记(Token)集合的过程。关键字、变量名、运算符等都可作为标记。比如下面一行代码:

int a = b + 2;

在字符流中,关键字 int 由三个字符组成,但它是一个独立的标记,不可再分。

该过程有点类似“分词”的过程。虽然这些代码我们一眼就能认出来,但编译器要逐个分析过之后才能知道。

  • 语法分析

根据上面的标记序列构造抽象语法树的过程。

抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方法,每个节点都代表程序代码中的一个语法结构(Syntax Construct),比如包、类型、修饰符等。

通俗来讲,词法分析就是对源码文件做分词,语法分析就是检查源码文件是否符合 Java 语法。

  • 填充符号表

符号表(Symbol Table)是一种数据结构,它由一组符号地址和符号信息组成(类似“键-值”对的形式)。

符号由抽象类 com.sun.tools.javac.code.Symbol 表示,Symbol 类有多种扩展类型的符号,比如 ClassSymbol 表示类、MethodSymbol 表示方法等。

符号表记录的信息在编译的不同阶段都要用到,如:

  • 用于语义检查和产生中间代码;
  • 在目标代码生成阶段,符号表是对符号名进行地址分配的依据。

这个阶段主要是根据上一步生成的抽象语法树列表完成符号填充,返回填充了类中所有符号的抽象语法树列表。

注解处理器

JDK 5 提供了注解(Annotations)支持,JDK 6 提供了“插入式注解处理器”,可以在「编译期」对代码中的特定注解进行处理,从而影响前端编译器的工作过程。

比如效率工具 Lombok 就是在这个阶段进行处理的。示例代码:

import lombok.Getter;
@Getter
public class Person {
  private String name;
  private Integer age;
}

该代码编译后:

public class Person {
    private String name;

    private Integer age;

    public Person() { }
    public String getName() {
        return this.name;
    }
    public Integer getAge() {
        return this.age;
    }
}

其中两个 getter 方法就是 @Getter 注解在这个阶段产生的效果。

语义分析与字节码生成

抽象语法树能表示一个结构正确的源程序,却无法保证语义是否符合逻辑。

而语义分析就对语法正确的源程序结合上下文进行相关性质的检查(类型检查、控制流检查等)。比如:

int a = 1;
boolean b = false;
// 这样赋值显然是错误的
// 但在语法上是没问题的,这个错误是在语义分析时检查的
int c = a + b;

Javac 在编译过程中,语义分析过程可分为*标注检查和数据及控制流分析*两个步骤。

1、标注检查

检查内容:变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等。

  • 常量折叠

该过程中,还会进行一个常量折叠(Constant Folding)的代码优化。比如,我们在代码中定义如下:

int a = 1 + 2;

在抽象语法树上仍能看到字面量 "1"、"2" 和操作符 "+",但经过常量折叠优化后,在语法树上将会被标注为 "3"。

2、数据及控制流分析

主要检查内容:

  • 局部变量使用前是否赋值
  • 方法的每条路径是否有返回值
  • 受检查异常是否被正确处理等

3、解语法糖

语法糖(Syntactic Sugar):也称糖衣语法,指的是在计算机语言中添加某种语法,该语法对语言的编译结果和功能并没有实际影响,却能更方便程序员使用该语言。

PS: 就是让我们写代码更舒服的语法,像吃了糖一样甜。

Java 中常见的语法糖有泛型、变长参数、自动装箱拆箱等。

JVM 其实并不支持这些语法,它们在编译阶段要被还原成原始的基础语法结构。该过程就称为解语法糖(打回原形)。

4、字节码生成

Javac 编译过程的最后一个阶段。主要是把前面各个步骤生成的信息转换为字节码指令写入磁盘中。

此外,编译器还进行了少量的代码添加和转换工作。比如实例构造器 <init>() 和类构造器 <clinit>() 方法就是在这个阶段被添加到语法树的。</clinit></init>

还有一些代码替换工作,例如将字符串的 "+" 操作替换为 StringBuilder(JDK 5 及以后)或 StringBuffer(JDK 5 之前) 的 append() 操作。

Java语法糖的味道

泛型

泛型这个概念大家应该都不陌生,Java 是从 5.0 开始支持泛型的。

由于历史原因,Java 使用的是“类型擦除式泛型(Type Erasure Generics)”,也就是泛型只会在源码中存在,编译后的字节码文件中,全部泛型会被替换为原先的裸类型(Raw Type)。

因此,在运行期间 List<String>List<Integer> 其实是同一个类型。例如:

public class GenericTest {
  public static void main(String[] args) {
    List<Integer> l1 = new ArrayList<>();
    l1.add(1);
    List<String> l2 = new ArrayList<>();
    l2.add("2");
  }
}

经编译器擦除类型后:

public class GenericTest {
    public GenericTest() {
    }
    public static void main(String[] var0) {
        // 原先的泛型都没了
        ArrayList var1 = new ArrayList();
        var1.add(1);
        ArrayList var2 = new ArrayList();
        var2.add("2");
    }
}

类型擦除是有缺点的,比如:

  1. 由于类型擦除,会将泛型的类型转为 Object,但是 int、long 等原始数据类型无法与 Object 互转,这就导致了泛型不能支持原始数据类型。进而引起了使用包装类(Integer、Long 等)带来的拆箱、装箱问题。
  2. 运行期无法获取泛型信息。在编码阶段,无法对反省进行实例判断、无法使用泛型创建对象、无法使用泛型创建数组。

自动装箱、拆箱与遍历循环

  • 遍历代码示例
public class GenericTest {
  public static void main(String[] args) {
    List<String> list = Arrays.asList("hello", "world");
    for (String s : list) {
      System.out.println(s);
    }
  }
}

反编译版本 1:

public class GenericTest {
    public GenericTest() {
    }
    public static void main(String[] args) {
        List<String> list = Arrays.asList("hello", "world");
        // 使用了迭代器 Iterator 遍历
        Iterator var2 = list.iterator();
        while(var2.hasNext()) {
            String s = (String)var2.next();
            System.out.println(s);
        }
    }
}

反编译版本 2:

public class GenericTest {
  public static void main(String[] args) {
    // 创建一个数组
    List<String> list = Arrays.asList(new String[] { "hello", "world" });
    for (String s : list)
      System.out.println(s); 
  }
}

不同的反编译器得出的结果可能有所不同,这里找了两个版本对比分析。

从上面两个版本的反编译结果可以看出:Arrays.asList() 方法其实创建了一个数组,而增强 for 循环实际调用了迭代器 Iterator。

  • 自动拆装箱代码示例
public class GenericTest {
  public static void main(String[] args) {
    Integer a = 1;
    Integer b = 2;
    Integer c = 3;
    Integer d = 3;
    Integer e = 321;
    Integer f = 321;
    Long g = 3L;
    System.out.println(c == d);
    System.out.println(e == f);
    System.out.println(c == (a + b));
    System.out.println(c.equals(a + b));
    System.out.println(g == (a + b));
    System.out.println(g.equals(a + b));
  }
}

类似代码估计大家都见过,毕竟有些面试题就喜欢这么搞,这些语句的输出结果是什么呢?

我们先看反编译后的代码:

public class GenericTest {
  public static void main(String[] args) {
    Integer a = Integer.valueOf(1);
    Integer b = Integer.valueOf(2);
    Integer c = Integer.valueOf(3);
    Integer d = Integer.valueOf(3);
    Integer e = Integer.valueOf(321);
    Integer f = Integer.valueOf(321);
    Long g = Long.valueOf(3L);
    System.out.println((c == d)); // t
    System.out.println((e == f)); // f
    System.out.println((c.intValue() == a.intValue() + b.intValue())); // t
    System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue()))); // t
    System.out.println((g.longValue() == (a.intValue() + b.intValue()))); // t
    System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue()))); // f
  }
}

可以看到,编译器对上述代码做了自动拆装箱的操作。其中值得注意的是:

  1. 包装类的 "==" 运算不遇到算术运算时,不会自动拆箱。
  2. equals() 方法不会处理数据转型。

此外,还有个值得玩味的地方:为何 c==d 是 true、而 e==f 是 false 呢?似乎也是个考点。

这就要查看 Integer 类的 valueOf() 方法的源码了:

static final int low = -128;
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}
private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];
    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;
        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);
        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }
    private IntegerCache() {}
}

可以看到 Integer 内部使用了缓存 IntegerCache:其最小值为 -128,最大值默认是 127。因此,[-128, 127] 范围内的数字都会直接从缓存获取。

而且,该缓存的最大值是可以修改的,可以使用如下 VM 参数将其修改为 500:

-XX:AutoBoxCacheMax=500

增加该参数后,上述 e==f 也是 true 了。

条件编译

许多程序设计语言都提供了条件编译的途径,(C/C++使用预处理器指示符#ifdef),而Java没有使用预处理器。

Java可以进行条件编译,方法就是使用条件为常量的if语句,它在编译阶段就会被运行。

第十一章 后端编译与优化

后端主要指的是将Class文件转化成与本地基础设施相关的二进制机器码的过程。与普通的解释器相比,提前编译器和即时编译器的加入优化了后端的过程,虽然两者都不是一个虚拟机所必备的,确是一个虚拟机好坏的重要衡量标准之一。

即时编译器

Java虚拟机是通过解释执行的,当虚拟机发现某个方法或者代码块运行的特别频繁,就会把这些代码认定为“热点代码”,就会在运行时把这些代码转成本地机器码,并用各种手段来优化代码,完成这个任务的编译器被称为即时编译器,本节主要通过对于5个问题的问答来展开:

1、为什么HotSpot中要采取解释器和即时编译器共存的架构?

并不是所有的虚拟机都是共存的架构,但是基本上的主流虚拟机都采用了共存的架构模式。主要是因为解释器和编译器两者各有优势:

①当程序需要快速启动时,解释器可以省去编译的时间,直接执行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,随着本地代码的增多,可以获得更高的执行效率。
②程序运行过程中内存限制较大时,可以使用解释执行节约内存,反之可以通过编译器来提高效率。
③解释器可以作为编译器激进优化时后备的“逃生门”。
因此解释器和编译器相辅相成,需要相互配合工作。

2、为什么HotSpot虚拟机要实现两个(三个)不同的即时编译器?

其中的两个即时编译器是存在已久了,分别是“客户端编译器”和“服务端编译器”,简称C1和C2。第三个是JDK10出现的,长期目标是为了取代C2的Graal编译器。

3、程序何时使用解释器?何时使用编译器?

JDK6之前,分层编译功能还没有实现,选择哪个编译器完全取决于虚拟机运行的模式。JDK7后根据编译器编译、优化的规模和耗时,划分出不同的编译层次,其中包括:
第零层:纯解释执行,并且解释器不开启性能监控功能。
第一层:使用客户端编译器进行优化,但是不开启性能监控功能。
第二层:仍然使用客户端编译器,开启方法及回边次数统计等少量性能监控功能。
第三层:仍然使用客户端编译器执行,开启所有的性能监控功能。
第四层:使用服务端编译器,并且根据性能监控信息进行一些不可靠的激进优化。
实现多层编译功能后,解释器以及C1C2编译器会同时工作。

4、哪些程序代码会被编译成本地代码?如何编译本地代码?

会被即时编译器编译的是“热点代码”,这里的热点代码有两类,包括:
1.被多次调用的方法。2.被多次执行的循环体。
编译的目标对象都是整个方法体,而不会是单个的循环体。对于后面一种,尽管编译动作是由循环体所触发的,热点只是方法的一部分,执行入口会稍有不同,编译时会传入执行入口点字节码序号。这种编译方法因为编译发生在方法执行的过程中,因为被很形象地称为“栈上替换”。
对于热点代码的探测主要有两种:1.基于采样的热点探测。2.基于计数器的热点探测。
HotSpot采用的是第二种的探测方式:对于方法的的计数有热度的衰减,这段时间被称为方法统计的半衰期,对于用来循环体计数的回边计数器没有半衰期,在字节码中遇到控制流向后跳转的指令就称为“回边”。

5、如何从外部观察到即时编译器的编译过程和结果?

一般来说,Java虚拟机的即时编译过程是完全透明的,可以通过一些参数输出即时编译和某些优化的运行状况。

编译过程

客户端编译器和服务端编译器的编译过程是有区别的,对于客户端编译器而言,它是一个简单的三段式编译器,主要的关注点是在于局部性的优化,而放弃了很多耗时较长的全局优化手段。
第一阶段:平***立的前端将字节码转成HIR,HIR使用了静态单分配的形式来代表代码值,方法内联等可以在这过程中完成。
第二阶段:平台的后端将HIR转成LIR,在转化过程前就是将HIR转成优化后的HIR会完成空值检查消除和范围检查消除等方法。
最后阶段:平台的后端使用线性扫描算法在LIR分配寄存器,例如寄存器分配等生成机器代码。
以上就是客户端编译器的三段优化,服务器端的配置更加复杂一点,服务器编译采用的寄存器是一个全局图着色分配器,可以充分利用某些处理器架构的大寄存器集合。

提前编译

主要的提前编译有两个分支:
1.在程序运行前把程序代码编译成机器码的静态翻译工作。
2.把原来即时编译器在运行时要做的编译工作提前做好保存下来,下次运行到直接加载进来。

缺点:即时编译最大的弱点是需要占用程序运行时间和运算资源。

优点:1)性能分析制导优化:即时编译器在运行过程中通过性能监控功能会不断收集信息,这些信息是静态分析无法得到的,可以通过这些信息通过即时编译器集中处理。
2)激进预测性优化:可以通过信息收集做些大胆的优化尝试,就算失败还可以用解释器继续运行,但是提前编译器不能做这样得操作。
3)链接时优化:java语言天生是动态链接的,只有在运行过程中才可以在即时编译器中生成优化后的本地代码。

编译器优化技术

编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

优化技术概览

即时编译器优化技术一览

类型 优化技术
编译器策略(Compiler Tactics) 延迟编译(Delayed Compilation)
分层编译(Tiered Compilation)
栈上替换(On-Stack Replacement)
延迟优化(Delayed Reoptimization)
程序依赖图表示(Program Dependence Graph Representation)
静态单赋值表示(Static Single Assignment Representation)
基于性能监控的优化技术(Profile-Based Techniques) 乐观空值断言(Optimistic Nullness Assertions)
乐观类型断言(Optimistic Type Assertions)
乐观类型增强(Optimistic Type Strengthening)
乐观数组长度增强(Optimistic Array Length Strengthening)
裁剪未被选择的分支(Un taken Branch Pruning)
乐观的多态内联(Optimistic N-Morphic Inlining)
分支频率预测(Branch Frequency Prediction)
调用频率预测(Call Frequency Prediction)
基于证据的优化技术(Proof-Based Techniques) 精确类型推断(Exact Type Inference)
内存值推断(Memory Value Inference)
内存值跟踪(Memory Value Tracking)
常量折叠(Constant Folding)
重组(Re association)
操作符退化(Operator Strength Reduction)
空值检查消除(Null Check Elimination)
类型检测退化(Type Test Strength Reduction)
类型检测消除(Type Test Elimination)
代数化简(Algebraic Simplification)
公共子表达式消除(Common Subexpression Elimination)
数据流敏感重写(Flow-Sensitive Rewrites) 条件常量传播(Conditional Constant Propagation)
基于流承载的类型缩减转换(Flow-Carried Type Narrowing)
无用代码消除(Dead Code Elimination)
语言相关的优化技术(Language-Specific Techniques) 类型继承关系分析(Class Hierarchy Analysis)
去虚拟机化(Devirtualization)
符号常量传播(Symbolic Constant Propagation)
自动装箱消除(Auto box Elimination)
逃逸分析(Escape Analysis)
锁消除(Lock Elision)
锁膨胀(Lock Coarsening)
消除反射(De-Reflection)
内存及代码位置变换(Memory And Placement Transformation) 表达式提升(Expression Hoisting)
表达式下沉(Expression Sinking)
冗余存储消除(Redundant Store Elimination)
相邻存储合并(Adjacent Store Fusion)
交汇点分离(Merge-Point Splitting)
循环变换 (Loop Transformations) 循环展开(Loop Unrolling)
循环剥离(Loop Peeling)
安全点消除(Safe point Elimination)
迭代范围分离(Iteration Range Splitting)
范围检查消除(Range Check Elimination)
循环向量化(Loop Vectorization)
全局代码调整(Global Code Shaping) 内联(Inlining)
全局代码外提(Global Code Motion)
基于热度的代码布局(Heat-Based Code Layout)
Switch调整(Switch Balancing)
控制流图变换(Control Flow Graph Transformation) 本地代码编排(Local Code Scheduling)
本地代码封包(Local Code Bunding)
延迟槽填充(Delay Slot Filling)
着色图寄存器分配(Graph-Coloring Register Allocation)
线性扫描寄存器分配(Linear Scan Register Allocation)
复写聚合(Copy Coalescing)
常量分裂(Constant Splitting)
复写移除(Copy Removal)
地址模式匹配(Address Mode Matching)
指令窥孔优化(Instruction Peepholing)
基于确定有限状态机的代码生成(DFA-Based Code Generator)

上述的优化技术看起来很多,而且名字看起来大多显得有点“高深莫测”,实际上要实现这些优化 确实有不小的难度,但大部分优化技术理解起来都并不困难,为了消除读者对这些优化技术的陌生感,笔者举一个最简单的例子:通过大家熟悉的Java代码变化来展示其中几种优化技术是如何发挥作用的。不过首先需要明确一点,即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。

最重要的优化技术:方法内联

方法内联就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免真实的方法调用。
对于java方法来说,难点在于很多方法是虚方法,在运行前不知道调用的多态选择,为了解决这个问题,java虚拟机引入了一个名为类型继承关系分析(CHA),编译器会根据不同的情况采用不同的方法:
①如果是非虚的方法,直接进行内联即可。
②如果是虚方法且这个方法在当前程序状态下只有一个目标版本可以选择,可以通过假设进行“守护内联”。因为在后面可能加载到了新的类型会改变CHA结论,所以这种内联属于激进预测性优化,必须预留好“逃生门”。
③如果是虚方法且有多个版本可以选择,将用“内联缓存”的方式来缩减方法调用的开销,可以理解为记录下每次不同版本的方法调用,调用一次后下一次只要判断方法所采取的是什么版本就可以立刻进行内联。

最前沿的优化技术:逃逸分析

这并不是直接优化代码的手段,只是为其他优化措施提供依据的分析技术。
当一个对象在方法里面被定义后,它可能被外部方法所引用,这种被称为方法逃逸,甚至被外部线程访问到,这称为线程逃逸。从不逃逸到方法逃逸再到线程逃逸被称为对象由低到高的不同逃逸程度。
如果一个对象不会逃逸或者逃逸几率极低,则可以做以下的优化:
①栈上分配:让这个对象不在java堆中分配内存,直接在栈上分配内存,对象所占用的内存空间可以随着栈帧出栈而销毁。
②标量替换:一个数据无法分解成更小的数据来表示,那么这就是标量,否则为聚合量。对象是典型的聚合量,如果这个对象不会逃逸,可以将它拆散,根据程序访问情况,将其成员变量恢复为原始类型来访问。
③同步消除:不会逃逸就不会被其他的线程所访问,因此可以将这个变量的同步措施都取消。

语言无关的经典优化技术:公共子表达式消除

对于已经计算过的变量,在后续调用中可以调用结果,而将变量的表达式删去。
如果这种优化仅仅局限于程序基本块中,可以称为局部公共子表达式消除;优化的范围涵盖了多个基本块,可以称为全局公共子表达式消除。

语言相关的经典优化技术:数组边界检查消除

java是一个动态安全语言,每次访问数据前需要检查上下界,但是每次检查需要浪费掉很多运行时间,因此会采用隐式异常处理,只有发生错误时才会进行检查。

第十二章 Java内存模型与线程

img

概述

并发处理的广泛应用是使得Amdahl定律代替摩尔定律称为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器

多任务处理在现代计算机操作系统中几乎已经是一项必备功能,让计算机同时做几件事的重要原因是其运算速度和存储、通信速度差距太大,大量时间花费在磁盘IO、网络、数据库上,如果不希望CPU在大部分时间处于等待状态,就需要用一些手段来压榨CPU的运算能力。

服务端是Java最擅长的领域之一,Java语言JVM提供了许多工具,降低了并发的许多门槛,并且各种中间件框架都努力的替程序员处理更多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是关注服务器如何协调资源

但是无论语言、中间件、框架如何先进,开发者都不能期望它们能独立完成所有工作,还是必须要了解并发的内幕。本章主要介绍了虚拟机如何实现并发。

硬件的效率与一致性

计算机中的运算任务不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个IO操作很难消除。所以现代计算机系统都在CPU与内存之间加入一层高速缓存(Cache)作为缓冲:将要用的数据复制到缓存,让运算能快速进行,运算结束后再从缓存同步回内存,这样CPU就无需等待缓慢的内存读写。

Cache解决了内存与处理器之间的矛盾,但是引入了新的问题:缓存一致性(Cache Coherence)。多处理器系统中,每个处理器都有自己的Cache,而他们共享同一主内存(Main Memory)。当多个处理器访问同一块主存时,就可能会导致各自的缓存数据不一致。这时候就需要各个处理器访问时需要遵循一些协议,在读写时根据协议来进行操作,如MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。

img

​ 处理器、高速缓存、主存 交互关系

Java内存模型

JVM规范试图定义一种Java内存模型(Java Memory Model, JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存进内存和取出的底层细节

这里的变量(Variables)与Java变量有所区别,包括了实例字段静态字段构成数组对象的元素,不包括局部变量与方法参数,因为后者是线程私有的,不会共享。

JMM规定了所有变量都存储在主内存(Main Memory)中(这里的主内存与物理硬件的主内存可以类比,但这里只表示虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与处理器高速缓存类比)。线程的工作内存中保存了被该线程使用到的变量主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程也无法直接访问其他线程的工作内存中的变量,线程间的值传递需要通过主内存完成,线程、主内存、工作内存三者的交互关系如图所示。

img线程、主内存、工作内存 交互关系

这里的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应Java堆中的对象实例数据部分,工作内存对应虚拟机栈的部分区域。更低的层次而言,主内存直接对应于物理硬件内存,为了获取更好的运行速度,虚拟机可能会让工作内存优先存储与寄存器和Cache中,因为程序运行时主要访问读写工作内存。

内存间的交互操作

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、 不可再分的。((JSR-133文档中,已经放弃采用这8种操作去定义Java内存模型的访问协议)

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。(store后write)
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。(read以后load)
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、 b进行访问时,一种可能出现顺序是read a、 read b、 load b、 load a。

Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了load和assign操作
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、 write操作)。

这8种内存访问操作以及上述规则限定,再加上对volatile的一些特殊规定,就可以完全确定了Java程序中哪些内存访问操作在并发下是安全的。 – 等价于”先行发生原则”

对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制;

Java内存模型对volatile专门定义了一些特殊的访问规则,一个变量定义为volatile之后,它将具备两种特性:可见性,禁止指令重排序优化。

  • 保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成 – 线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
  • 针对 volatile变量的可见性的误解 – “volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是并不能得出“基于volatile变量的运算在并发下是安全的”这个结论。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的

针对long和double型变量的特殊规则

  • Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment ofdouble andlong Variables)。
  • 如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但是这种情况基本不可能。

原子性、可见性和有序性

  • Java内存模型是围绕着在并发过程中如何处理原子性、 可见性和有序性这3个特征来建立的。
  • synchronized关键字在需要这3种特性的时候都可以作为其中一种的解决方案。大部分的并发控制操作都能使用synchronized来完成。

1、原子性

  • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的。(float, double不视为特例)
  • 如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

2、可见性

  • 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
  • 除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

3、有序性

  • Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
  • Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

先行发生原则

“先行发生”(happens-before)原则是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则解决并发环境下两个操作之间是否可能存在冲突的所有问题。

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存***享变量的值、 发送了消息、 调用了方法等

Java内存模型下一些“天然的”先行***,这些先行***无须任何同步器协助就已经存在,可以在编码中直接使用。

如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、 循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、 Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
private int value = 0;

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
}
# 线程A先调用set方法,然后线程B调用get方法;那么线程B获得的值?
# 先行发生原则中的各项规则,由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中"getValue()"方法的返回结果,换句话说,这里面的操作不是线程安全的。
# fixed it: 1. 把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;2.把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行***。
 123456789101112123456789101112

一个操作“时间上的先发生”不代表这个操作会是“先行发生”,一个操作“先行发生”也不能推导出这个操作必定是“时间上的先发生” – “指令重排”。两者之间没有必然的联系。衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

Java与线程

线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。

Java中每个已经执行start()且还未结束的java.lang.Thread类的实例就代表了一个线程。Thread类的所有关键方法都是声明为Native的(没法使用平台无关的方式实现)。

实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。

1、使用内核线程实现

  • 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
  • 程序一般使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都由一个内核线程支持。
  • 轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。 而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(KernelMode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量有限。好处就是由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元。

ch12-light-weight-progress.png-210kB

2、用户线程实现

  • 狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、 同步、 销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。
  • 用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。 线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。

ch12-process-user-thread.png-91.7kB

3、混合实现

  • 将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。 用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系。

ch12-user-thread-lwt-m-n.png-191.2kB

Java线程的实现

Java线程在JDK 1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。目前的JDK版本中,操作系统支持怎样的线程模型取决于Java虚拟机的线程是怎样映射。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive ThreadsScheduling)。

  • 协同式调度: 线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。优点是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。缺点:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

  • 抢占式调度:线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。

    Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,即系统线程优先级跟Java线程的优先级一般对不上。

ch12-thread-priority.png-240.9kB

状态转换

Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态。

  • 新建(New):创建后尚未启动的线程处于这种状态。

  • 运行(Runable):Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间

  • 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。 以下方***让线程陷入无限期的等待状态:

    • 没有设置Timeout参数的Object.wait()方法。
    • 没有设置Timeout参数的Thread.join()方法。
    • LockSupport.park()方法。
  • 限期等待(Timed Waiting):处于这种状态的线程也

    不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方***让线程进入限期等待状态:

  • Thread.sleep()方法。
  • 设置了Timeout参数的Object.wait()方法。
  • 设置了Timeout参数的Thread.join()方法。
  • LockSupport.parkNanos()方法。
  • LockSupport.parkUntil()方法。
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。 在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

ch12-thread-state-transit.png-103.8kB

第十三章 线程安全与锁优化

线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

它要求线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。

Java语言中的线程安全

  • 在Java语言中,线程安全具体是如何体现的?有哪些操作是线程安全的?讨论的前提:多个线程之间存在共享数据访问。
  • Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1、不可变

  • 在Java语言中(特指JDK 1.5以后Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,只要一个不可变的对象被正确地构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最简单和最纯粹的。
  • 共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。 如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。– java.lang.String类的对象是不可变对象,调用它的substring()、replace()和concat()等方法只会返回一个新构造的字符串对象。保证对象行为不影响自己状态最简单的就是把对象中带有状态的变量都声明为final。
  • Java API中不可变的类型: String,枚举类型,以及java.lang.Number的部分子类,如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型;但同为Number的子类型的原子类AtomicInteger和AtomicLong则并非不可变的。

2、绝对线程安全

  • 绝对的线程安全完全满足Brian Goetz给出的线程安全的定义(很严格),一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至有时候是不切实际的代价。 在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
  • java.util.Vector是一个线程安全的容器,因为它的add()、get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段。
# 对Vector线程安全的测试
public class VectorTest1 {
    private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println((vector.get(i)));
                    }
                }
            });

            removeThread.start();
            printThread.start();

            //不要同时产生过多的线程,否则会导致操作系统假死
            while (Thread.activeCount() > 20);
        }
    }
}123456789101112131415161718192021222324252627282930313233343536
# 如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。 
...
9
Exception in thread "Thread-138207" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 14
    at java.util.Vector.get(Vector.java:748)
    at ch13.VectorTest1$2.run(VectorTest1.java:68)
    at java.lang.Thread.run(Thread.java:745)
0
...123456789
# 必须加入同步以保证Vector访问的线程安全性
Thread removeThread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 显示的进行同步;
        synchronized(vecotr) {
            for (int i = 0; i < vector.size(); i++) {
                vector.remove(i);
           }
        }
    }
});

Thread printThread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 显示的进行同步。
       synchronized(vecotr) {
            for (int i = 0; i < vector.size(); i++) {
                System.out.println(vector.get(i))
           }
        }
    }
});123456789101112131415161718192021222324

3、相对线程安全

  • 保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
  • Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合。

4、线程兼容

  • 对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。 Java API中大部分的类都是属于线程兼容的,包括集合类ArrayList和HashMap等。

5、线程对立

  • 线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
  • 线程对立的例子是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是由于这个原因,suspend()和resume()方法已经被JDK声明废弃(@Deprecated)了。 常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。

线程安全的实现方法

1、异步互斥

  • 互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。 而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
  • Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象
  • JVM中指定:执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
  • JVM中对monitorenter和monitorexit的行为描述:首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
  • Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。 所以synchronized是Java语言中一个重量级(Heavyweight)的操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。
  • 使用java.util.concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)来实现同步,在基本用法上ReentrantLock与synchronized很相似,都具备一样的线程重入特性;代码写法上区别 – 一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层面的互斥锁。不过,相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、 可实现公平锁,以及锁可以绑定多个条件。
    • 等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
    • 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

ch13-synchronized-vs-reetranlock.png-106.9kB

ch13-sychroinzed-vs-reetrantlock-2.png-99.6kB

  • 上图是根据JDK1.5做的测试,JDk1.6优化后,synchronized与ReentrantLock的性能基本上是完全持平;提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

2、非阻塞同步

  • 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题 – 阻塞同步(Blocking Synchronization)。 从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
  • 随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
  • 乐观并发策略需要操作和冲突检测这两个步骤具备原子性,如果这里再使用互斥同步来保证就失去意义了,所以我们只能靠硬件来完成这件事情(硬件指令集的提升),硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。这类指令常用的有:
    • 测试并设置(Test-and-Set)。
    • 获取并增加(Fetch-and-Increment)。
    • 交换(Swap)。
    • 比较并交换(Compare-and-Swap,简称CAS,新增)。
    • 加载链接/条件存储(Load-Linked/Store-Conditional,简称LL/SC,新增)。
  • 后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。 在IA64、 x86指令集中有
    cmpxchg指令完成CAS功能,在sparc-TSO也有casa指令实现,而在ARM和PowerPC架构下,则需要使用一对ldrex/strex指令来完成LL/SC的功能。
  • CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、 旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。
  • JDK 1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
  • 由于Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。
public class AtomicTest {

    public static AtomicInteger race = new AtomicInteger(0);

    public static void increase() {
        // incrementAndGet()方法的原子性;
        race.incrementAndGet();
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

# output:
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ javac ch13/AtomicTest.java 
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java ch13.AtomicTest 
200000
12345678910111213141516171819202122232425262728293031323334353637
# incrementAndGet()方法的JDK源码
/**
 * Atomically increments by one the current value.
 *
 * @return the updated value
 */
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}123456789
  • CAS无法涵盖互斥同步的所有使用场景,并且CAS从语义上来说并不是完美的,存在这样的一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。 不过目前来说这个类比较“鸡肋”,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

3、无同步方案

  • 同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
  • 可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、 用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
  • 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。– 消费队列的架构模式,Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式。
  • Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对。

锁优化

各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题

自旋锁与自适应锁

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。

在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。 如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,同时不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

自旋锁在JDK 1.6中默认开启。自旋等待不能代替阻塞,自旋锁对处理器数量有要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。 锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。(方法内的字符串+操作在JDK1.5以前转为StringBuffer的实现)

锁粗化

一般推荐将同步块的作用范围限制得尽量小 – 只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次

轻量级锁

轻量级锁是JDK 1.6之中加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中的25bit用于存储对象哈希码(HashCode),4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。

ch13-markword.png-130.8kB

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)

然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态。

ch13-light-weighted-lock-cas.png-139.9kB

如果更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。 如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

偏向锁是JDK 1.6中引入的锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。 如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。“偏”,就是偏心的“偏”、它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK 1.6的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。 同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、 Unlocking 及对Mark Word的Update等)。

有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如轻量级锁那样执行。 偏向锁、 轻量级锁的状态转化及对象Mark Word的关系如下:

ch13-biased-lock-lwl-markword.png-266.7kB

偏向锁可以提高带有同步但无竞争的程序性能。 它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。