深入理解Java虚拟机

第1章 走进Java

  • 我们可以把Java程序设计语言、Java虚拟机、Java类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境,本书中为行文方便,在不产生歧义的地方常以JDK来代指整个Java技术体系 [2] 。可以把Java类库API中的Java SE API子集 [3] 和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境。

  • 虚拟机始祖:Sun Classic/Exact VM

  • 武林盟主:HotSpot VM。相信所有Java程序员都听说过HotSpot虚拟机,它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。

  • HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译(On-Stack Replacement,OSR)行为 [1] 。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。

  • 如果说HotSpot是天下第一的武林盟主,那曾经与HotSpot并称“三大商业Java虚拟机”的另外两位,毫无疑问就该是天下第二了,它们分别是BEA System公司的JRockit与IBM公司的IBM J9。

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

  • Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

概述

  • 对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始到终结的维护责任。

  • 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

运行时数据区域

  • Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

程序计数器

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里 [1] ,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

  • 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧 (Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 经常有人把Java内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。不过这种划分方式的流行也间接说明了程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其中,“堆”在稍后笔者会专门讲述,而“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。

  • “HotSpot虚拟机的栈容量是不可以动态扩展的,以前的Classic虚拟机倒是可以。所以在HotSpot虚拟机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常——只要线程申请栈空间成功了就不会有OOM,但是如果申请时就失败,仍然是会出现OOM异常的”

本地方法栈

  • 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

  • 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

Java堆

  • 对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配 [1] ”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换 [2] 优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

  • Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词

  • 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

  • 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

  • Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常

方法区

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

  • 说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存

  • 《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

运行时常量池
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

  • Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中

直接内存

  • 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

  • 在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

  • 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。”

HotSpot虚拟机对象探秘

对象创建

  • “当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,本书第7章将探讨这部分细节。”

  • “在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定将在2.3.2节中介绍),为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来”

  • 有两种内存分配方式,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

    • “假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。”

    • “但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)”

  • Java堆是所有线程共享的,同步问题是肯定要考虑的,有解决方案:

    • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;

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

    虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。”

  • 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

对象的内存布局

  • 在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 第一部分:对象头。HotSpot虚拟机对象的对象头部分包括两类信息。

    • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”

    • 另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。该类型指针不是必须的,虚拟机可以从其他地方获取对象元数据。

    • 此外,对于Java数组,对象头记录了数组的长度,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

  • 第二部分:实例数据。接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。

    HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

  • 第三部分:对齐填充。对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

  • 由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

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

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

  • 这两种对象访问方式各有优势:

    • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

    • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本

实战: OutOfMemoryError异常

Java堆溢出

  • 限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析

  • 要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

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

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

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

  • 由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

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

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

  • Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

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

  • 由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行

  • String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

本机直接内存溢出

  • 直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致”

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

概述

  • “说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言”

  • “第2章介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性。

  • 而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内存。

对象已死?

要对内存进行回收,如何确定对象死亡了呢?

引用计数算法

  • “客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。”

  • “但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题”

可达性分析算法

  • 当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。”

  • 在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

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

    • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

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

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

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

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

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

再谈引用

  • 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。”

  • Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

    • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

    • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

    • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

    • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

生存还是死亡?

  • 在可达性分析算法中判定为不可达的对象,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,还需要进行一次筛选,finalize()方法是对象逃脱死亡命运的最后一次机会

  • 如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则对象没有机会逃脱死亡命运了。

  • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

    这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

  • 稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

finalize()方法是为了让C/C++更容易接受Java所妥协的预发,运行代价高昂、不确定性,官方都不推荐,finalize()方法能做的任何工作,都可以用try-finally或者其他方式更好地完成,所以可以完全忘掉finalize()方法

回收方法区

  • 方法区也是有GC行为的,虽然《Java虚拟机规范》不要求,方法区GC的性价比挺低的

  • 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型:

    • 回收废弃常量:与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

    • 回收不再使用的类型:要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

      • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

      • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

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

    Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制”

垃圾收集算法

垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,在本节中我们暂不过多讨论算法实现,只重点介绍分代收集理论和几种算法思想及其发展过程。如果读者对其中的理论细节感兴趣,推荐阅读Richard Jones撰写的《垃圾回收算法手册》 [1] 的第2~4章的相关内容。

分代收集理论

  • 当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection) [1] 的理论进行设计,它建立在两个假说之上:

    1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

    2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

  • 这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

  • 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

  • 根据分代理论,虚拟机设计者一般会把Java堆至少分为新生代(Young Generation)与老年代(Old Generation)两个区域。 分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用

  • 假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样 (理论上允许,其实只有CMS实现了只对老年区的收集) 。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。这就增添了第三条经验法则:

    1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
  • 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

  • 定义

    • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

      • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

      • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。

    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

    • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

Young, Old 是Hotpot等主流虚拟机命名方式,IBM J9虚拟机对应的称为婴儿区(Nursery)和长存区(Tenured)

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

  • 最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,第2章已介绍

  • 主要缺点有两个:

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

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

标记-复制算法(解决待回收对象过多的性能问题,但内存利用率只有一半)

  • 标记-复制算法常被简称为复制算法。1969年提出了“半区复制”(Semispace Copying)这种算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 两个场景:

    • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
    • 如果内存中多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
  • 该算法实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法将内存缩小为了原来的一半。

  • 实际上,新生代的对象大约有98%活不过第一轮,所以不需要严格按照1:1的比例划分新生代的内存空间

  • 经过研究后,HotSpot采用了Apple式回收:将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

  • 如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

标记-整理算法

  • 标记-复制算法的问题:

    • 标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。
    • 更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用标记-复制算法
  • 针对老年代对象的存亡特征,1974年提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

  • 是否移动对象是个trade-off:

    • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
    • 如果不移动,空间碎片化问题就会非常严重,内存分配就会变得非常复杂,得通过空闲链表之类的手段解决
  • 一个解决方案是:平时多数时间采用标记-清除,暂时容忍内存碎片,直到内存碎片化程度影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间

HotSpot的算法细节实现

根节点枚举

  • 固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,但是现在方法区多达成百上千兆,逐个检查需要时间
  • 迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,如果根节点集合的对象引用关系还在不断变动,分析准确性就无法保证
  • 由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。故虚拟机不需要完整检查全局引用位置

安全点(根节点枚举的时机)

  • 在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但是显然不能为每条指令都生成对应的OopMap。实际上HotSpot也的确没有为每条指令都生成OopMap,只是安全点(Safepoint)的位置记录了信息。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
  • 如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
    • 抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
    • 而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮训操作如此频繁,所以HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度

安全区域

  • 线程跑到安全点会暂停,但是线程如果不执行的时候呢?典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
  • 当线程要离开安全区域时,它要检查虚拟机是否完成了根节点枚举,如果没完成则要等待直到收到可离开的信号为止

记忆集与卡表(缩小GC Roots扫描范围)

  • 提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

  • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。若不考虑效率和成本,可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构

1
2
3
Class RememberSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
  • 实际上,收集器只需要通过记忆集判断某一块非收集区域是否存在指向了收集区域的指针就可以了,可以选择更粗犷的记录粒度,比如以下三个记录精度

    • 字长精度: 每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
    • 对象精度: 每个记录精确到一个对象,该对象里有字段含有跨代指针。
    • 卡精度: 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
  • 卡表(Card Table)是实现记忆集的手段,HotSport的卡表就是一个字节数组。CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数

  • 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就可以将相应的跨代指针加入GC Roots中扫描

写屏障(维护卡表的手段)

  • HotSpot虚拟机通过写屏障(Write Barrier)技术维护卡表状态,在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
  • 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面
  • 每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的
伪共享
  • 除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
  • 了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏
  • 在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

并发可达性(三色标记)

  • OopMap等手段解决了根节点枚举的效率问题,但是GC Roots往下遍历对象图的时间肯定就与Java堆容量成正比例关系了
  • 为什么必须要在能保障一致性的快照上才能遍历对象图?用三色标记(Tri-color Marking)来辅助推导:
    • 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
    • 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
    • 灰色: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
  • 可达性分析的三个阶段:
    • 初始阶段:只有GC Roots是黑色,其他全是白色
    • 扫描中:从黑往白扫描,灰色是分割线
    • 扫描完成:所有剩余的白色对象就是GC Roots不可达的对象,是已消亡可回收的对象
  • 如果用户线程在收集器收集时并发修改引用关系,可能会产生错误:
    • 把要死的对象变活:没关系,下次清理就好了
    • 把要活而对象变死:严重错误!对象消失!
对象消失
  • 理论证明,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
    • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
  • 要解决并发扫描时的对象消失问题,只需破坏其中一个条件即可

  • 由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

    • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
    • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索
  • 以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

经典垃圾收集器

  • 垃圾收集器是垃圾收集算法的实践
  • 不存在万能的垃圾收集器,只有针对场景的更合适的

Serial收集器(最基础、历史最悠久)

  • 单线程工作,新生代采用复制算法暂停所有用户线程,老年代采用标记-整理算法暂停所有用户线程
  • “单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束,STOP THE WORLD
  • Serial收集器是HotSpot虚拟机运行在客户端模式下的默认新生代收集器
  • 在用户桌面与微服务应用中,虚拟机管理的(新生代)内存只有几十兆,垃圾收集的停顿时间在一百毫秒之内,用户完全可以接受
  • 简单高效、没有线程交互的开销

ParNew收集器(多线程版本)

  • ParNew收集器实质上是Serial收集器的多线程并行版本
  • 其他行为如收集算法、stop the world、对象分配规则、回收策略与Serial完全一致,复用了很多代码
  • 广泛使用于服务端HotSpot虚拟机,因为除了Serial收集器外,只有ParNew才能与CMS收集器配合工作
  • 后来ParNew合并入CMS,专门用来收集新生代

Parallel Scavenge收集器(吞吐量优先)

  • 关注点不同:CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间(用户代码时间+垃圾收集时间)的比值

  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

    • -XX:MaxGCPauseMillis参数(>0):垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
    • -XX:GCTimeRatio参数(>0 && <100):也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
  • 值得一提的是,-XX:+UseAdaptiveSizePolicy。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)

  • 自适应调节的典型流程:

    • 设置基本内存参数,如-Xmx设置最大堆
    • 使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标
    • 设置-XX:+UseAdaptiveSizePolicy

Serial Old收集器(老年代版本)

  • Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
  • 这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
  • 如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用 [1] ,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。这两点都将在后面的内容中继续讲解。

Parallel Old收集器(老年代版本)

  • Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
  • 在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合

CMS收集器(并发低停顿,适合互联网)

  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

  • 从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

    1. 初始标记(CMS initial mark)
      • 耗时短,需要stop the world
      • 但仅标记一下GC Roots能直接关联到的对象,速度很快
    2. 并发标记(CMS concurrent mark)
      • 耗时长,不需要stop the world
      • 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    3. 重新标记(CMS remark)
      • 耗时稍短,需要stop the world
      • 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见增量更新的讲解)
    4. 并发清除(CMS concurrent sweep)
      • 耗时长,不需要stop the world
      • 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的

  • 缺点:

    • 对处理器资源非常敏感

    • 无法处理浮动垃圾,有可能出现Con-current Mode Failure失败进而导致另一次完全“Stop The World”的Full GC的产生。

      因为CMS收集与用户线程并发,新的垃圾对象无法收集,这部分垃圾对象称为浮动垃圾,所以CMS收集器必须预留一部分空间给用户线程使用,但如果这部分空间不足以分配新新对象,就会出现一次并发失败(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了

    • CMS基于标记-清除算法,会有大量空间碎片,会出现老年代还有很多剩余空间,但是找不到连续空间分配大对象,不得不触发Full GC

Garbage First收集器(Mixed GC,分Region收集,Region可变)

  • Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

  • G1是一款主要面向服务端应用的垃圾收集器。正在逐步替换的CMS收集器。JDK9发布时,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器

  • Mixed GC:在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

  • 分Region收集,Region可变:G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。回收的最小单元是Region

  • Garbage First:后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,价值取决于回收时间与获得空间大小

  • 化整为零的思路看起来简单,实现起来有许多问题需要解决:

    • 跨Region引用对象如何解决?
      • 记忆集:G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。
      • 这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来复杂得多,且Region数量要远远大于分代数量,所以G1收集器所占内存更多
    • 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
      • G1通过原始快照(SATB)算法解决
      • G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
      • 与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。
    • 怎样建立起可靠的停顿预测模型?
      • 用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值
      • G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息
      • 平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态
  • G1收集器的运作过程大致可划分为以下四个步骤:

    • 初始标记 (Initial Marking):这个阶段需要停顿线程,但耗时很短

    • 并发标记 (Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。扫描完之后还要执行原始快照SATB

    • 最终标记 (Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

    • 筛选回收 (Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。

  • 毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

    • 默认是200毫秒
    • 如果用户设置太小,则每次回收的内存很小,回收跟不上分配,导致垃圾堆积,最终引发Full GC降低性能
    • 所以建议-XX:MaxGCPauseMillis设置100~300毫秒比较合理
  • 从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。

CMS与G1的比较

  • G1直观的优点:可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集

  • G1内存空间碎片更少:CMS是标记-清除算法,G1整体上来看基于标记-整理,但局部(两个Region)基于标记-复制,所以G1不会产生内存碎片,有利于程序长时间运行

  • G1的内存占用比CMS高,额外执行负载也比CMS高

    • 内存占用
      • CMS的卡表就一份,只需要处理老年代到新生代的引用,由于新生代对象朝生夕灭,引用变动频繁,所以省下来一大笔开销。
      • G1每一个Region都要维护一个卡表,新生代和老年代都需要维护
    • 额外执行负载
      • CMS用写后屏障维护卡表
      • G1除了用写后屏障,为了实现原始快照SATB算法,还需要写前屏障跟踪并发时的指针变化情况
  • 在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈

低延迟垃圾收集器

  • 衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角 [1] ”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
  • 随着硬件技术发展,内存占用不用那么在意了,吞吐量也会提高,但延迟是个很头疼的问题
  • 之前提到的经典垃圾收集器,总还是要暂停的,影响延迟性
  • 但Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,他们的在可管理的堆容量下,可以实现垃圾收集的停顿不超过十毫秒

Shenandoah

  • Shenandoah不是由Oracle开发,而是由RedHat公司开发的,所以它是一款只有OpenJDK才会包含(OpenJDK12的正式特性),而OracleJDK里反而不存在的收集器

  • Shenandoah与G1很像,也有基于Region的堆内存布局等等,也是Garbage First的回收策略,但有三个不同之处:

    • G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,Shenandoah却可以
    • Shenandoah(目前)是默认不使用分代收集的,
    • Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率
  • Shenandoah由九个阶段构成,去看论文吧

ZGC收集器

  • ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下 [2] ,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟
  • 内存布局:与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,本章为行文一致继续称为Region)具有动态性——动态创建和销毁,以及动态的区域容量大小,ZGC的Region分为大中小型Region
  • 接下来是ZGC的核心问题——并发整理算法的实现。Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的解题思路。
  • 染色指针,染色指针是一种直接将少量额外的信息存储在指针上的技术,64位的指针的高18位不能用来寻址(考虑到内存足够了以及节省成本的考虑)
  • ZGC可以将停顿时间控制在十毫秒之内!JDK11加入,但是稳定性和性能调优还在进行中,处于实验性质

选择合适的垃圾收集器

Epsilon收集器

  • 很特殊,不收集垃圾,
  • 如果你的应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

收集器的权衡

虚拟机及垃圾收集器日志

  • JDK9之后,HotSpot所有功能的日志都收归到了“-Xlog”参数上
  • 举例
    • 查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc:
    • 查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc,用通配符将GC标签下所有细分过程都打印出来
    • 查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug
    • 查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX:+Print-GCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime
    • 查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuring-Distribution,JDK 9之后使用-Xlog:gc+age=trace

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

  • 在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。

对象优先在Eden分配

  • 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

  • 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组

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

  • HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。
  • 对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

  • 为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

空间分配担保

  • 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,

    • 如果这个条件成立,那这一次Minor GC可以确保是安全的。
    • 如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
      • 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
        • 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
        • 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
  • 虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁

本章小结

  • 垃圾收集器在许多场景中都是影响系统停顿时间和吞吐能力的重要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,就是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取最好的性能。没有固定收集器、参数组合,没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。

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

注意:如果虚拟机进程是以admin身份运行的,则以下所有工具都要以sudo -u admin 开头

jps:虚拟机进程状况工具

  • JDK的很多小工具的名字都参考了UNIX命令的命名方式,jps(JVM Process Status Tool)是其中的典型。除了名字像UNIX的ps命令之外,它的功能也和ps命令类似:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)。

  • 功能很简单,但是非常高频,因为其他JDK工具大多需要这个LVMID来确定要监控哪一个虚拟机进程

  • 常用命令

    1
    2
    3
    4
    5
    6
    # jps [ options ] [ hostid ]
    sudu -u admin jps # 如果JVM是以admin身份启动的,则要用admin权限去执行jps
    sudu -u admin jps -v # 显示JVM启动命令
    sudu -u admin jps -m # 输出虚拟机进程启动时传递给主main函数的参数,比如虚拟机的日志路径
    sudu -u admin jps -l # 输出主类的全名,如果进程执行的是JAR包,则输出JAR路径
    sudo -u admin jps -v # 只输出LVMID,省略主类名称

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

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

  • 常用命令

    1
    2
    3
    4
    5
    6
    7
    # jstat [ option vmid [interval[s|ms] [count]] ]
    # 如果是本地虚拟机进程vmid=LVMID
    # 如果是远程虚拟机,则vmid=[protocol:][//]lvmid[@hostname[:port]/servername]
    # 参数interval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次
    sudo -u admin jstat -gc 2764 250 20 # 每250毫秒查询进程2764的gc情况,一共查询20次
    sudo -u admin jstat -gcutil 2764 # 主要关注gc已使用空间占总空间百分比
    sudo -u admin jstat -gccause 3996 # 输出最近gc的原因
  • gcutil输出示例

    1
    2
    3
    4
    $./jstat -gcutil 3996
    S0 S1 E O P YGC YGCT FGC FGCT GCT
    0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577

    查询结果表明:这台服务器的新生代Eden区(E,表示Eden)使用了6.2%的空间,2个Survivor区(S0、S1,表示Survivor0、Survivor1)里面都是空的,老年代(O,表示Old)和永久代(P,表示Permanent)则分别使用了41.42%和47.20%的空间。程序运行以来共发生Minor GC(YGC,表示Young GC)16次,总耗时0.105秒;发生Full GC(FGC,表示Full GC)3次,总耗时(FGCT,表示Full GC Time)为0.472秒;所有GC总耗时(GCT,表示GC Time)为0.577秒。

  • gccause输出示例

    1
    2
    3
    4

    $./jstat -gccause 3996
    S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC
    0.00 51.83 43.15 14.11 97.99 96.47 75 5.234 0 0.000 5.234 Allocation Failure No GC

    LGCC(Last GC Cause)

  • 虚拟机生成GC日志:可以通过以下几个参数要求虚拟机生成GC日志:-XX:+PrintGCTimeStamps(打印GC停顿时间)、-XX:+PrintGCDetails(打印GC详细信息)、-verbose:gc(打印GC信息,输出内容已被前一个参数包括,可以不写)、-Xloggc:gc.log。

jinfo:Java配置信息工具

  • jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了

  • 常用命令

    1
    2
    3
    4

    # jinfo [ option ] pid
    jinfo -flag CMSInitiatingOccupancyFraction 1444
    jinfo -flag GCTimeRatio 1092 -XX:GCTimeRatio=99 # 实时调整某些参数值

jmap:Java内存映像工具

  • jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。

  • 如果不使用jmap命令,要想获取Java堆转储快照也还有一些比较“暴力”的手段:譬如在第2章中用过的-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。

  • jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。

  • 常用命令

    1
    2
    3
    4
    5
    jmap –heap 4019 #显示java堆详细信息,如使用哪些回收器、参数配置、分代状况等。
    jmap –dump:format=b,file=/tmp/dump.bat #生成堆内存快照信息
    jmap –dump:live,format=b,file=/tmp/dump.bat #同上,但仅输出存活对象
    jmap –histo 4019 # 显示堆中对象统计信息,包括类、实例数量、合计容量
    jmap –histo:live 4019 # 同上,但仅输出存活对象
  • Jmap -histo的参数说明

    1
    2
    3
    4
    5
    6
    参数说明:
    [C is a char[]
    [S is a short[]
    [I is a int[]
    [B is a byte[]
    [[I is a int[][]

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

  • JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。
  • jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。
  • 不过实事求是地说,在实际工作中,除非手上真的没有别的工具可用,否则多数人是不会直接使用jhat命令来分析堆转储快照文件的

jstack:Java堆栈跟踪工具

  • jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。

  • 线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。

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

  • 常见命令

    1
    2
    3
    jstack -l 4019 # 除堆栈外,显示关于锁的附加信息
    jstack -F 4019 # 当正常输出的请求不被响应时,强制输出线程堆栈
    jstack -m 4019 # 如果调用到本地方法,可以输出C/C++堆栈

基础工具

jar, java, javac, javadoc, javah, javap, jlink, jdb, idlj, jhsdb jcmd jconsole 等等

可视化故障处理工具

  • JDK中除了附带大量的命令行工具外,还提供了几个功能集成度更高的可视化工具,用户可以使用这些可视化工具以更加便捷的方式进行进程故障诊断和调试工作。这类工具主要包括JConsole、JHSDB、VisualVM和JMC四个。

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

JConsole:Java监视与管理控制台

VisualVM:多合-故障处理工具

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

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

第6章 类文件结构

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

  • 把我们编写的程序编译成二进制本地机器码(Native Code)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。

无关性的基石(字节码——class文件)

  • 各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石
  • 此外,还有语言无关性,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语言

Class类文件的结构

  • Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没

  • 任何一个Class文件都对应着唯一的一个类或接口的定义信息 [1] ,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

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

  • 根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”:

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

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

image-20211114164627966

  • Class文件中字节序为Big-Endian,具体顺序是指按高位字节在地址最低位,最低字节在地址最高位来存储数据,它是SPARC、PowerPC等处理器的默认多字节存储顺序,而x86等处理器则是使用了相反的“Little-Endian”顺序来存储数据。

魔数与Class文件的版本

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

  • 不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆

  • 紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件

    例如,JDK 1.1能支持版本号为45.0~45.65535的Class文件,无法执行版本号为46.0以上的Class文件,而JDK 1.2则能支持45.0~46.65535的Class文件。目前最新的JDK版本为13,可生成的Class文件主版本号最大值为57.0。

常量池

  • 常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一

  • 常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count),容量计数值从1开始(非常特殊)

    设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。

  • 常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

    • 字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
    • 而符号引用则属于编译原理方面的概念,主要包括
      • 被模块导出或者开放的包(Package)
      • 类和接口的全限定名(Fully Qualified Name)
      • 字段的名称和描述符(Descriptor)
      • 方法的名称和描述符
      • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
      • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
  • Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接(具体见第7章)。也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态连接的内容,在下一章介绍虚拟机类加载过程时再详细讲解。

  • 常量池中每一项常量都是一个表,如整型字面量,浮点字面量….类中方法的符号引用,方法句柄…

  • 专门用于分析Class文件字节码的工具:javap

    1
    javap -verbose TestClass

访问标志

  • 在常量池结束之后,紧接着的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语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量

  • 字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。

  • 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述

方法表集合

  • 如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项

属性表集合

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

字节码指令简介

  • Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构(这两种架构的执行过程、区别和影响将在第8章中探讨),所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

  • 字节码指令集可算是一种具有鲜明特点、优势和劣势均很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不能够超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构

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

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

字节码与数据类型

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

image-20211114170939605

加载和存储指令

  • 加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之间来回传输,这类指令包括:

    • 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>

    • 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>

    • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>

    • 扩充局部变量表的访问索引的指令:wide

运算指令

  • 算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

  • 大体上运算指令可以分为两种:

    • 对整型数据进行运算的指令
    • 对浮点型数据进行运算的指令。
  • 整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。无论是哪种算术指令,均是使用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虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion,即小范围类型向大范围类型的安全转换):

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

    • long类型到float、double类型

    • float类型到double类型

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

对象创建与访问指令

  • 虽然类实例和数组都是对象,但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

方法调用和返回指令

  • 方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下五条指令用于方法调用:

    • invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。

    • invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。

    • invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。

    • invokestatic指令:用于调用类静态方法(static方法)。

    • invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

异常处理指令

  • 在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虚拟机两者共同协作支持,

公有设计,私有实现

  • 《Java虚拟机规范》描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统和具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看作程序在各种Java平台实现之间互相安全地交互的手段。

Class文件结构的发展

  • Class文件结构自《Java虚拟机规范》初版订立以来,已经有超过二十年的历史。这二十多年间,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了13。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于一个相对比较稳定的状态,Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动 ,所有对Class文件格式的改进,都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容。

第7章 虚拟机类加载机制

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

概述

  • 上章讲了class文件存储格式的细节,虚拟机如何加载这些class文件,都是本章将要讲解的内容

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

  • 与那些在编译时需要进行连接的语言不同(如C++),在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的

类加载的时机

  • 一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

加载

  • “加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,Java虚拟机需要完成以下三件事情:

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

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

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

  • 《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的,比如,从ZIP压缩包读取,之后发展成为了JAR、EAR、WAR格式,运行时计算生成等等

  • 相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

验证

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

  • Java语言本身是相对安全的编程语言(起码对于C/C++来说是相对安全的),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译

  • 但前面也曾说过,Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出Class文件在内的任何途径产生。所以还是要验证字节码

  • 四个验证阶段:

    1. 文件格式验证

    2. 元数据验证

    3. 字节码验证

    4. 符号引用验证

准备

  • 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

  • 从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程

初始化

  • 类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

类加载器

  • Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类与类加载器

  • 类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

  • 这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。

双亲委派模型

  • 站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

  • 绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载。

    • 启动类加载器(Bootstrap Class Loader):前面已经介绍过,这个类加载器负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中

    • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库

    • 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。

![image-20211114185217877](/Users/zhenheng.lhl/Library/Application Support/typora-user-images/image-20211114185217877.png)

  • 上图展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

  • 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

  • 双亲委派模型的好处:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
  • 这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

Java模块化系统

  • 在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

    • 依赖其他模块的列表。

    • 导出的包列表,即其他模块可以使用的列表。

    • 开放的包列表,即其他模块可反射访问模块的列表。

    • 使用的服务列表。

    • 提供服务的实现列表。

  • 为了保证兼容性,JDK 9并没有从根本上动摇从JDK 1.2以来运行了二十年之久的三层类加载器架构以及双亲委派模型。但是模块化下的类加载器发生了一些变动

    • 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代
    • 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器

image-20211114185945957

  • 在Java模块化系统明确规定了三个类加载器负责各自加载的模块,即前面所说的归属关系

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


      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      java.base                        java.security.sasl
      java.datatransfer java.xml
      java.desktop jdk.httpserver
      java.instrument jdk.internal.vm.ci
      java.logging jdk.management
      java.management jdk.management.agent
      java.management.rmi jdk.naming.rmi
      java.naming jdk.net
      java.prefs jdk.sctp
      java.rmi jdk.unsupported

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


      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      java.activation*                jdk.accessibility
      java.compiler* jdk.charsets
      java.corba* jdk.crypto.cryptoki
      java.scripting jdk.crypto.ec
      java.se jdk.dynalink
      java.se.ee jdk.incubator.httpclient
      java.security.jgss jdk.internal.vm.compiler*
      java.smartcardio jdk.jsobject
      java.sql jdk.localedata
      java.sql.rowset jdk.naming.dns
      java.transaction* jdk.scripting.nashorn
      java.xml.bind* jdk.security.auth
      java.xml.crypto jdk.security.jgss
      java.xml.ws* jdk.xml.dom
      java.xml.ws.annotation* jdk.zipfs

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


      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      jdk.aot                         jdk.jdeps
      jdk.attach jdk.jdi
      jdk.compiler jdk.jdwp.agent
      jdk.editpad jdk.jlink
      jdk.hotspot.agent jdk.jshell
      jdk.internal.ed jdk.jstatd
      jdk.internal.jvmstat jdk.pack
      jdk.internal.le jdk.policytool
      jdk.internal.opt jdk.rmic
      jdk.jartool jdk.scripting.nashorn.shell
      jdk.javadoc jdk.xml.bind*
      jdk.jcmd jdk.xml.ws*
      jdk.jconsole

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

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

概述

  • 执行引擎是Java虚拟机核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

运行时栈帧结构

  • Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息

  • 每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

  • 一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。

image-20211114233909474

局部变量表

  • 局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量
  • 在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
  • 局部变量表的容量以变量槽(Variable Slot)为最小单位,变量槽可以随着处理器、操作系统或虚拟机实现的不同而发生变化

操作数栈

  • 操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。

  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

  • 譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

动态连接

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

方法返回地址

  • 当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。

  • 另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

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

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

方法调用

  • 方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。
  • Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)
  • 这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

  • 承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。

  • 在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

分派

  • Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,
静态分派(Method Overload Resolution)与重载
  • 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

  • 看一个经典面试题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human guy) {
    System.out.println("hello,guy!");
    }

    public void sayHello(Man guy) {
    System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
    System.out.println("hello,lady!");
    }

    public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    StaticDispatch sr = new StaticDispatch();
    sr.sayHello(man);
    sr.sayHello(woman);
    }
    }
    #
    运行结果:

    hello,guy!
    hello,guy!
  • 我们把中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么

  • 虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标

动态分派与重写
  • 看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class DynamicDispatch {

static abstract class Human {
protected abstract void sayHello();
}

static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}

static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
运行结果:

man say hello
woman say hello
woman say hello

  • 并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

  • 事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。

  • 当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

  • 看看字段没有多态的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class FieldHasNoPolymorphic {

static class Father {
public int money = 1;

public Father() {
money = 2;
showMeTheMoney();
}

public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}

static class Son extends Father {
public int money = 3;

public Son() {
money = 4;
showMeTheMoney();
}

public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}

public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}

运行后输出结果为:

I am Son, i have $0
I am Son, i have $4
This gay has $2
  • 解释:输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2
单分派与多分派
  • 方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

    宗量这个定义最早应该来源于著名的《Java与模式》一书

  • 举了一个Father和Son一起来做出“一个艰难的决定”的例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public class Dispatch {

    static class QQ {}
    static class _360 {}

    public static class Father {
    public void hardChoice(QQ arg) {
    System.out.println("father choose qq");
    }

    public void hardChoice(_360 arg) {
    System.out.println("father choose 360");
    }
    }

    public static class Son extends Father {
    public void hardChoice(QQ arg) {
    System.out.println("son choose qq");
    }

    public void hardChoice(_360 arg) {
    System.out.println("son choose 360");
    }
    }

    public static void main(String[] args) {
    Father father = new Father();
    Father son = new Son();
    father.hardChoice(new _360());
    son.hardChoice(new QQ());
    }
    }
    运行结果:

    father choose 360
    son choose qq

  • 解释:

    • 在main()里调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。我们关注的首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
    • 再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这行代码时,更准确地说,是在执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
  • Java是静态多分派、动态单分派

虚拟机动态分派的实现(虚方法表)
  • 动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能
  • 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址
  • 虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间

像C++里的虚函数表

动态类型语言支持

  • JDK7发布的invokedynmaic指令,是实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。

动态类型语言

  • 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括JavaScript、Python、PHP等

  • 动态类型语言与静态类型语言谁更先进没有答案:静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。

  • 动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。

Java与动态类型

  • 在JDK7以前的指令集中,4条方法调用指令的第一个参数都是被调用的方法的符号引用,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者,所以在Java虚拟机上实现的动态类型语言会有额外的性能和内存开销

java.lang.invoke包

  • 这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle),即C++的函数指针(Function Pointer)

invokedynamic指令

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

本节探讨虚拟机是如何执行方法里面的字节码指令的

解释执行

  • 大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中的各个步骤

image-20211115011649669

  • 对于一门具体语言的实现来说,词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。
  • 也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。

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

  • Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构

  • 基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?举个最简单的例子,分别使用这两种指令集去计算“1+1”的结果

    • 基于栈的指令集会是这样子的:

    1
    2
    3
    4
    iconst_1
    iconst_1
    iadd
    istore_0

    • 两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。

    • 而如果用基于寄存器的指令集,那程序可能会是这个样子:


    1
    2
    mov  eax, 1
    add eax, 1

    • mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。
  • 优点:

    • 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供 ,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
    • 代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)
    • 编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)
  • 缺点:

    • 理论上执行速度相对来说会稍慢一些
    • 出入栈需要大量指令,所以指令数量要比寄存器架构更多
    • 栈是在内存中的,出入栈就需要频繁内存访问

基于栈的解释器执行过程

  • 对下面Java代码使用javap看看它的字节码指令
1
2
3
4
5
6
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}
  • javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间,后面的指令就不解释了
  • 再次强调上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。

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

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

Tomcat:正统的类加载器架构

image-20211115013046722

OSGi:灵活的类加载器架构

image-20211115013143706

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

Backport工具:Java的时光机器

实战:自己动手实现远程执行功能

  • 类似的需求有一个共同的特点,那就是只要在服务中执行一小段程序代码,就可以定位或排除问题,但就是偏偏找不到可以让服务器执行临时代码的途径

  • 通常有以下几种途径:

    1. 可以使用BTrace [1] 这类JVMTI工具去动态修改程序中某一部分的运行代码,这部分在第4章有简要的介绍,类似的JVMTI工具还有阿里巴巴的Arthas [2] 等。

    2. 使用JDK 6之后提供了Compiler API,可以动态地编译Java程序,这样虽然达不到动态语言的灵活度,但让服务器执行临时代码的需求是可以得到解决的。

    3. 也可以通过“曲线救国”的方式来做到,譬如写一个JSP文件上传到服务器,然后在浏览器中运行它,或者在服务端程序中加入一个BeanShell Script、JavaScript等的执行引擎(如Mozilla Rhino . 去执行动态脚本。

    4. 在应用程序中内置动态执行的功能。

第10章 前端编译与优化

概述

  • 前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;如JDK的Javac

  • Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;

  • 使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。

  • 我们可以这样认为,Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

Javac编译器

  • Javac编译器不像HotSpot虚拟机那样使用C++语言(包含少量C语言)实现,Javac编译器完全由Java语言编写

Javac的源码与调试

  • 从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。

    1. 准备过程:初始化插入式注解处理器。

    2. 解析与填充符号表过程,包括:

      • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    3. 填充符号表。产生符号地址和符号信息。插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。

    4. 分析与字节码生成过程,包括:

      • 标注检查。对语法的静态信息进行检查。

      • 数据流及控制流分析。对程序动态运行过程进行检查。

      • 解语法糖。将简化代码编写的语法糖还原为原有的形式。

      • 字节码生成。将前面各个步骤所生成的信息转化成字节码。

解析与填充符号表

  • 词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记

  • 语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

  • 完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程。

  • 符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。可以把它类比想象成哈希表中键值对的存储形式。

  • 符号表中所登记的信息在编译的不同阶段都要被用到。

    • 譬如在语义分析的过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码
    • 在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

注解处理器

  • 可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)
  • 有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为
  • Java著名的编码效率工具Lombok [2] ,它可以通过注解来实现自动产生getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法等等

语义分析与字节码生成

  • 经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查

Java语法糖的味道

  • 语法糖可以看作是前端编译器实现的一些“小把戏”,这些“小把戏”可能会使效率得到“大提升”,但我们也应该去了解这些“小把戏”背后的真实面貌,那样才能利用好它们,而不是被它们所迷惑。

泛型

  • 泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法
Java与C#的泛型
  • Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)
  • C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List<int>List<string>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。
  • 而Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型
  • Java的类型擦除式泛型在使用效果与运行效率都落后于C#的具现化式泛型,但最大的好处是几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,保证了向前兼容
泛型的历史背景
  • 泛型思想早在C++语言的模板(Template)功能中就开始生根发芽

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

  • 就纯技术的角度而论,自动装箱、自动拆箱与遍历循环(for-each循环)这些语法糖,无论是实现复杂度上还是其中蕴含的思想上都不能和泛型相提并论
  • 但是在这些语法糖使用最多,值得一提

条件编译

  • 许多程序设计语言都提供了条件编译的途径,如C、C++中使用预处理器指示符(#ifdef)来完成条件编译。C、C++的预处理器最初的任务是解决编译时的代码依赖关系(如极为常用的#include预处理命令),而在Java语言之中并没有使用预处理器,因为Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。

  • 编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段

    Java语言的条件编译


    1
    2
    3
    4
    5
    6
    7
    public static void main(String[] args) {
    if (true) {
    System.out.println("block 1");
    } else {
    System.out.println("block 2");
    }
    }

    该代码编译后Class文件的反编译结果:


    1
    2
    3
    public static void main(String[] args) {
    System.out.println("block 1");
    }

第11章 后端编译与优化

概述

  • 如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
  • 提前编译器or即时编译器不是Java虚拟机必需的组成部分,但后端编译器性能好坏至关重要

即时编译器

  • 目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器

解释器与编译器

  • 解释器与编译器共存:目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率

  • 逆优化:解释器可以作为编译器激进优化的逃生门,当激进优化的假设不成立时,可以通过逆优化(Deoptimization)退回到解释状态继续执行

  • HotSpot虚拟机有两个内置的编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器

  • 解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

  • 为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能

    • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
    • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
    • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
    • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
    • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

编译对象与触发条件

  • 热点代码主要有两类,包括:

    • 被多次调用的方法。
    • 被多次执行的循环体。
  • 对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体

  • “热点探测”(Hot Spot Code Detection)的两种方法:

    • 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。好处是实现简单高效,还可轻易获得方法调用关系,缺点是难以精确确认方法的热度,且容易受到线程阻塞等其他影响而扰乱热点探测
    • (HotSpot使用)基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。麻烦一点,但更精准。
  • 为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。

    • 方法调用计数器:

      • 阈值默认客户端1500,服务端10000,阈值可通过-XX:CompileThreshold设定

      • 不是统计方法被调用次数的绝对值,而是一段时间内的调用次数

      • 当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。

      • 可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

        image-20211117004131022

    • 回边计数器:

      • 统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”
      • 没有衰减,绝对值统计
      • 当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次进入该方法就可以直接执行标准编译过程

​ ![image-20211117004545203](/Users/zhenheng.lhl/Library/Application Support/typora-user-images/image-20211117004545203.png)

编译过程

  • 客户端编译器:
    • 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)
    • 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
    • 最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
  • 服务端编译器:
    • 能容忍很高优化复杂度的高级编译器,几乎相当于GNU C++编译器使用-O2参数时的优化强度
    • 它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等
    • 还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除
    • 还有不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等

提前编译器

优劣得失

  • 研究提前编译有两条分支
    • 一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作
    • 另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用
  • 第一条针对即时编译的最大弱点:即时编译要占用程序运行时间和运算资源
  • 第二条的本质是做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)
  • 提前编译因为没有执行时间和资源限制的压力,能够毫无顾忌地使用重负载的优化手段,但是即时编译有以下三种优势:
    • 性能分析制导优化 (Profile-Guided Optimization,PGO):程序在运行时可以监控性能从而优化即时编译的效果
    • 激进预测性优化 (Aggressive Speculative Optimization):静态优化必须保证与优化前是一致的,但是即时编译可以更激进,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。比如Java的虚方法是可以内联的
    • 链接时优化 (Link-Time Optimization,LTO):Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。使用提前编译的C/C++对于动态链接库难以优化内联
  • 无论如何,即时编译和提前编译都会是Java后端编译技术的共同主角

编译器优化技术

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

概览

  • HotSpot设计团队列出了相对比较全面的即时编译器采用的优化技术,地址
  • 比如方法内联,一可消除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础
  • 冗余访问消除(Redundant Loads Elimination)/公共子表达式消除(Common Subexpression Elimination)
  • 复写传播(Copy Propagation)
  • 无用代码消除(Dead Code Elimination)

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

  • 把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已
  • 为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,编译器在进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联就可以了,这种的内联是有百分百安全保障的;如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。不过由于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
  • 假设CHA查询的结果确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销,比起直接查虚方法表还是要快一些

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

  • 与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

  • 原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,

    • 例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,
    • 譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
    • 从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
  • 栈上分配(Stack Allocations):支持方法逃逸,但不能支持线程逃逸

    • Java堆上分配创建对象的内存空间,各线程共享且可见

    • 如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁

      C和C++语言里面原生就支持了栈上分配(不使用new操作符即可)

  • 标量替换(Scalar Replacement):要求更高,不允许方法逃逸

    • 若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。
    • 相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量
    • 假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除(Synchronization Elimination):

    • 线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
  • 因为逃逸分析的投入产出比不一定高,JDK7以前的很多版本默认禁止逃逸分析

  • 可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可以使用参数-XX:+EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks来开启同步消除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。

语言无关的经典优化技术之一:公共子表达式消除

  • 如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。

  • 如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)

  • 举例

    1
    2
    3
    4
    5
    6
    7
    8
    int d = (c * b) * 12 + a + (a + b * c);

    // 公共子表达式消除 优化后
    int d = E * 12 + a + (a + E);

    // 可能还有 代数化简(Algebraic Simplification) 优化
    int d = E * 13 + a + a;

语言相关的经典优化技术之一:数组边界检查消除

  • Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样实质上就是裸指针操作。如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,即i必须满足“i>=0&&i<foo.length”的访问条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException

  • 这对开发者很友好,但是对于虚拟机执行子系统来说,每次都要判断,是性能负担

  • 编译器通过数据流分析确定不会超出边界,这不会判断边界

  • 还有一种避开的处理思路——隐式异常处理,虽然处理异常需要用户态转到内核态的开销,但当foo大部分时候不为空时,这种优化是值得的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    if (foo != null) {
    return foo.value;
    }else{
    throw new NullPointException();
    }

    // 隐式异常优化,注意这里是进程层面的异常处理起,并非Java真正try-catch语句

    try {
    return foo.value;
    } catch (segment_fault) {
    uncommon_trap();
    }

  • 与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除(Safepoint Elimination)、消除反射(Dereflection)等

Java内存模型与线程

概述

  • 计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。
  • 除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景

线程安全

  • 定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

    from《Java并发编程实战(Java Concurrency In Practice)》

Java中的线程安全

  • 按安全程度排序

    • 不可变

      • 对于基本数据类型,final关键字修饰即可;
      • 对于对象,需要对象自行保证行为不会对状态产生任何影响。
      • 符合这类的有java.lang.String,还有枚举类以及java.lang.Number的部分子类,如Long和Double等数值包装类型

      保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的

    • 绝对线程安全

      • 在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
      • 比如java.util.Vector是一个线程安全的容器,因为它的add()、get()和size()等方法都是被synchronized修饰的,但是调用时仍需要同步手段
      • 一个线程读vector,一个线程写vector,调用时分别要加synchronized关键字同步保护,因为需要额外的同步,所以不是绝对线程安全
    • 相对线程安全(通常意义上的线程安全)

      • 需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性(如上文的Vector、HashTable等)
    • 线程兼容

      • 线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
      • 我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
    • 线程对立(尽量避免)

      • 线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。
      • 由于Java语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

线程安全的实现方法

互斥同步
  • 互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。也称为阻塞同步(Blocking Synchronization)。

  • 同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。

  • 因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

  • synchronized

    • 在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。

      • 在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。
      • 一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
    • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况

    • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出

    • 持有锁是一个重量级(Heavy-Weight)的操作,Java的线程是映射到操作系统的原生内核线程之上,用户态到内核态的转换有开销,所以synchronized是Java语言中一个重量级的操作

  • ReentrantLock

    • 重入锁(ReentrantLock)是Lock接口最常见的一种实现,与Synchronized相似,也是可重入的,主要多了三项高级功能
      • 等待可中断: 是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
      • 公平锁: 是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized的锁是非公平的,而ReentrantLock默认是非公平,一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
      • 锁绑定多个条件: 是指一个ReentrantLock对象可以同时绑定多个Condition对象,而synchronized只能绑定一个条件
    • ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于synchronized,但是在两者都满足需求的时候,仍推荐synchronized:
      • synchronized是在Java语法层面的同步,足够清晰,也足够简单
      • Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。Lock这一点必须由程序员自己来保证,而synchronized可以由虚拟机来兜底释放锁
非阻塞同步
  • 互斥同步属于一种悲观的并发策略,非阻塞同步是乐观的并发策略

  • 硬件指令集的发展,我们可以实现基于冲突检测的乐观并发策略:先尝试,如果没有线程争用就成功了,如果产生了冲突则进行补偿措施,最常用的补偿措施就是不断重试

  • 非阻塞同步(Non-Blocking Synchronization)不再需要把线程挂起,使用这种措施的代码也常被称为无锁(Lock-Free)编程

  • 硬件指令集要求操作和冲突检测具备原子性,这类指令常用的有:

    ·测试并设置(Test-and-Set);

    ·获取并增加(Fetch-and-Increment);

    ·交换(Swap);

    ·比较并交换(Compare-and-Swap,下文称CAS);

    ·加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)

  • 以AtomicInteger的incrementAngGet()方法为例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * Atomically increment by one the current value.
    * @return the updated value
    */
    public final int incrementAndGet() {
    for (;;) {
    int current = get();
    int next = current + 1;
    if (compareAndSet(current, next))
    return next;
    }
    }
    // incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己。如果失败了,那说明在执行CAS操作的时候,旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。
  • CAS看起来很美好,但其实有个逻辑漏洞,假设初次读取时是A,准备赋值仍然是A,那就能说明它中间没有改变过吗?其实不行,如果在这段期间它的值曾经被改成B,后来又被改回为A,那么CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”

  • J.U.C包为了解决ABA问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类处于相当鸡肋的位置,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。

无同步方案
  • 要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的,笔者简单介绍其中的两类。
  • 可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。在特指多线程的上下文语境里
  • 可重入代码是线程安全代码的一个真子集,这意味着相对线程安全来说,可重入性是更为基础的特性,它可以保证代码线程安全,即所有可重入的代码都是线程安全的,但并非所有的线程安全的代码都是可重入的
  • 可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。
  • 我们可以通过一个比较简单的原则来判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
  • 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
  • 可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

锁优化

自旋锁与自适应自旋

  • 背景:互斥同步对性能最大的影响是阻塞的实现,线程挂起与阻塞需要陷入内核态,而且锁的持续时间一般很多,这个开销不值得

  • 解决:可以让后面请求锁的线程”稍等一会”,看看能不能很快释放锁,只需要让后面的线程忙循环(自旋)即可

  • 自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源

    自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改

  • JDK引入了自适应的自旋,自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

    如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

锁消除

  • 虚拟机即时编译器在运行时,会对一些冗余的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

锁粗化

  • 原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

  • 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗

  • 如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

轻量级锁

  • 轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
  • 轻量级锁与HotSpot虚拟机对象头的内存空间有关,加锁与解锁过程通过CAS操实现
  • 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

  • 偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
  • 偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
  • 一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束
  • 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在这种情况下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。