0%

《深入理解Java虚拟机第3版》阅读笔记

这篇笔记总结、摘选自周志明老师的《深入理解Java虚拟机第3版》,以及其他网上知识点收集,只是我自己的个人复习笔记,禁止转载,如有侵权,请联系我删除。
思维导图版本点击这里

虚拟机概述

中间语言翻译(从Java翻译到机器码)

从中间语言翻译到机器码

通过C语言翻译(初JVM)

通过将字节码class翻译成C语言,比如定义iadd这个字节码是相加的助记符,然后通过c语言来解析字节码中的iadd就是相加,并指向对应的c语言相加的c程序来做实际运算,这样子的话,这个解析字节码的C程序就是所谓的JVM,最简陋效率最低的初代JVM

直接翻译为机器码

CS:IP

CS与IPO这是物理CPU内部的两个寄存器。对于一台物理机器而言,这两个寄存器是最重要的寄存器,因为CPU在取指令时便完全依靠这两个寄存器。CS寄存器保存段地址,IP保存偏移地址。CS和IP这两个寄存器的值能够唯一确定内存中的一个地址,CPU在执行机器指令之前,便通过这两个寄存器定位到目标内存位置,并将该位置处的机器指令取出来进行运算。函数跳转的本质其实便是修改CS和IP这两个寄存器的内容,使其指向到目标函数所在内存的首地址,这样CPU便能执行目标函数了。Java虚拟机要想让物理CPU直接执行Java程序所对应的目标机器码,也得修改这两个寄存器才能实现。

在C语言中,能够使用语法糖来修改CS:IP段寄存器,这样C程序就能直接动态执行机器码。
既然都能在C语言中直接动态执行机器码了(可以简单理解为:C语言的变量里面可以复制解析出来的机器码,然后直接执行),我们只要将中间语言指令直接翻译为机器码,然后让CS:IP直接指向这段机器码执行,这也是现代JVM最重要的的基本原理之一。

本地编译

中间语言(Java)与同样属于高级语言的C语言相比,它们实现相同的功能,C语言编译后所生成的机器码,也比中间语言直接翻译成的机器码,在数量上要精简很多,所以单从这个角度来讲,C语言的执行效率当然是要更高一些。
中间语言由于其本身不能直接被CPU执行,为了能够被CPU执行,中间语言在完成同样一个功能时,需要准备更多便于自我管理的上下文环境,最后才能执行目标机器指令。准备上下文环境最终也是依靠机器码去实现,因此中间语言最终便生成了更多机器码,当然执行效率就降低了。
为了提高性能,JVM提供了一种机制能够将中间语言(字节码)直接编译为本地机器指令。例如安卓和部分JVM所实现的AOT ( ahead of time )特性便是这方面的尝试,但是这种方式并没有减少机器指令的数量级问题。
除此之外,JVM的大牛们在JIT (即时编译)、内存分配也进行了大量的优化,使JVM能够对热点代码进行大幅度指令优化,正是由于JVM可以在运行期基于上下文链路进行各种优化,因此优化后的指令质量甚至比C/C++编译岀的指令质量更高,以及部分Java程序性能甚至反超C/C++程序。如果离开了这些动态优化,Java程序的执行效率是无论如何也提不上去的。

神奇的指令

Java虚拟机其实是”读不懂”Java代码的,不过也并非所有的虚拟机都不懂得面向对象的语言,JavaScript执行引擎就是个例外一JS脚本不需要编译就能被JS引擎直接执行。
Java所谓的中间语言就是Java字节码指令集,指令集一般是计算机硬件才有的东西,而作者却在软件层面定义了一套同样的东西。但是,软件本身不具备执行程序的能力,软件最终还得依靠硬件指令才能完成逻辑计算。因此,一套好的软件指令必须不能超出硬件指令所能表达的计算能力,同时又要对硬件指令进行高度抽象与概括。换言之,如果你定义了一套与硬件指令集完全一模一样的软件指令集,那大家还用你干嘛呀,不如直接用硬件指令得了。


Java技术的未来

无语言倾向

GraalJVM

新一代即时编译器

HotSpot虚拟机中含有两个即使编译器:

  • 编译耗时短但是输出代码优化程度较低的客户端编译器(C1)。
  • 编译耗时长但输出代码优化质量较高的服务端编译器(C2)。

JDK10之后,HotSpot加入了一个全新的即时编译器,Graal编译器(可以通过相应的JVM参数配置启动,以C2代替者的身份登场,目前还是处于”实验状态”)。
Graal能够做比C2更加复杂的优化,如“部分逃逸分析”(Partial Escape Analysis),也拥有比C2更容易使用激进预测性优化(Aggressive Speculative Optimization)的策略,支持自定义的预测性假设等。

向Native迈进

最新的基本版本的JDK已经推出跨进程的、可以面向用户程序的类型信息共享(Application Class Data Sharing, AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度。

Substrate VM

Substrate VM在内存占用和启动时间比HotSpot强5到50倍:

实战:自己编译JDK

OpenJDK vs OracleJDK

实际上,JDK11之后,两者的公用源码已经占比很高了:

自动内存管理

运行时数据区域

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
每个线程都有一个独立的程序计数器,各线程之间的计数器互不影响(因为Java多线程是采用线程轮流切换来实现的,多核处理器中的单个处理器一次只能执行某个线程中的某条指令,切换之后各自的计数器能够恢复到各自接下来的执行位置)。
所以,程序计数器是线程私有的。

Java虚拟机栈

Java虚拟机栈一样是线程私有的,生命周期与线程相同。
虚拟机栈描述的是Java方法执行的线程内存模型,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
广为流传的所谓”堆”和”栈”内存划分说话中的”栈”就是指虚拟机栈,或者是指虚拟机栈中的局部变量表部分。

栈帧?

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、 short、 int、 float、 long、 double) 、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,或者叫直接指针;也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的1ong和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(“大小”是指变量槽的数量,变量槽占用32个比特还是64个比特由虚拟机决定)。

StackOverFlowError和OutOfMemoryError(Java虚拟机规范)

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将拋出StackOverflowError异常;
  • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会拋出OutOfMemoryError异常。

操作数栈?

本地方法栈

本地方法栈(Native Method Stacks)为虚拟机调用本地方法而服务,就像虚拟机栈服务于Java方法的调用一样。
虚拟机可以对本地方法栈自由实现,《Java虚拟机规范》对这没有强制约束(HotSpot将虚拟机栈和本地方法栈合并了)。

Java堆(Heap、GC堆)

Java堆是虚拟机内存管理中最大的一块(线程共享)。
当前主流虚拟机都可以通过参数扩展堆的大小(-Xmx-Xms),如果内存不足或者堆无法再扩展了,就会抛出OutOfMemoryError。

方法区(”非堆”、Non-Heap)

在JDK 7及之前,HotSpot使用永久代来实现方法区时,而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候类变量在方法区就完全是一种种对逻辑概念的表述了。
和Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量即时编译器编译后的代码缓存等数据。
虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap) ,目的是与Java堆区分开来。

永久代(Permanent Generation)的落寞,元空间(Meta-space)的登场

Java 8之前,很多人习惯将方法区叫称为”永久代”,因为仅仅只有当时的HotSpot使用永久代来实现方法区,很多其他的虚拟机并不存在永久代的概念,《Java虚拟机规范》也对方法区的实现没有约束,所以方法区不等同于永久代。

HotSpot使用永久代来实现方法区,实际上导致了Java应用更容易出现内存溢出,其-XX: MaxPermSize可以设置永久代的上限,即使不设置也有默认大小。 而J9和JRockit就没有此类内存限制,除非触碰进程可用内存上限,这个是由系统控制。

所以到了JDK 6时,HotSpot就逐步放弃永久代,开始采用本地内存(Native Memory)来实现方法区了,到了JDK 8就完全放弃永久代了,改用在本地内存中实现的元空间(Meta-space),把字符串常量池、静态变量、类型信息等都移到元空间了。
垃圾收集器在方法区的回收行为出现得是比较少的,这个区域的内存回收主要是针对常量池的回收和对类型的卸载

运行时常量池(Runtime Constant Pool)

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求。
运行时常量池相较于Class文件常量池具有动态性,运行期间可以将新的常量放入运行时常量池中,比如String.intern
因为运行时常量池属于方法区,自然也就受到OutOfMemoryError异常的约束。

直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK 1.4中新加入了NIO (New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer) 的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据,直接内存当然也是受到物理总内存的约束。

所以在设置-Xmx等参数时,除了考虑运行时数据区里的各大内存区域,还要考虑到直接内存。

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

对象的创建

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

  • 指针碰撞(Bump The Pointer):当使用Serial、ParNew等带压缩整理过程的收集器时(简单高效)。
  • 空闲列表(Free List):当使用CMS这种基于清除(Sweep)算法的收集器时(较为复杂,实际上,在空闲列表中如果还能够拿到一大块子内存的话,CMS的实现还采用了一种可以继续使用指针碰撞的方式,叫做Linear Allocation Buffer)。

并发情况下的内存分配线程安全问题

并发条件下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况,解决这个问题的方案:

  • 采用CAS配上失败重试的方式保证更新操作的原子性;
  • 把内存分配的动作按照线程划分,在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),线程在各自的本地缓冲区中分配内存,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

可以通过-XX: +/-UseTLAB参数来设定使用哪种方案。

对象的内存布局

对象在堆内存中的存储布局:

对象头(Header)

HotSpot对象头包含两类信息:

  • 存储对象自身的运行时数据:哈希码(HashCode)GC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳等,官方称之为”Mark Word”。
    MarkWord被设计成一一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向) 下对象的存储内容。如下图:
  • 类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针。

实例数据(Instance Data)

这部分是对象真正存储的有效信息,即程序代码里定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录在此。
这部分的存储顺序会受到虚拟机分配策略参数-XX: FieldsAllocationStyle参数和字段在Java源码中定义顺序的影响。
HotSpot虛拟机默认的分配顺序为longs/doubles、ints、 shorts/chars、 bytes/eooleans、oops(Ordinary Object Pointers, OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
如果HotSpot虛拟机的+XX: CompactPields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐填充(Padding)

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

对象的访问定位

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

句柄和直接指针两种方式

  • 句柄方式:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
  • 直接指针:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

实战:OutOfMemoryError异常

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError (OOM)异常的可能。

Java堆溢出

要先确认是内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。

Java内存(映像)分析工具(hprof文件分析工具)

JVM堆转储快照文件(hprof文件)查看分析时,可以指定heapDumpPath:

1
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/Domains/rcsv-fm.wd.local/server1/logs

kill -3 or -9 都不会打heap dump,kill -9 java_pid来不及做任何事情就被干掉了,-3会打印thread dump 但是不是heap dump。

Eclipse Memory Analyzer 分析hprof文件

GC Roots

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

HotSpot不区分虚拟机栈和本地方法栈,栈容量由-Xss参数设定。

  • 1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将拋出OutOfMemoryError异常 。

模拟栈溢出:

  • 使用-Xss参数减少栈内存容量。
  • 定义了大量的本地变量,增大此方法帧本地变量表的长度。
  • 通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的(但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常)。
    HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,系统的内存分配需要减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot 虚拟机抛出的都是StackOverflowError异常。

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

在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,方法区的溢出的溢出已经相对不会那么容易出现了。
HotSpot提供了一些参数用于防御元空间溢出问题:
-XX: MaxMetaspaceSize: 设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
-XX: MetaspaceSize: 指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:
MaxMetaspaceSize (如果设置了的话)的情况下,适当提高该值。
-XX: MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX: Max-MetaspaceFreeRatio, 用于控制最大的元空间剩余容量的百分比。

本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX : MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定) 一致。
越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会拋出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe:allocateMemory()。

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

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

  • 程序计数器、虚拟机栈、本地方法栈的垃圾回收:
    这3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
  • Java堆和方法区的垃圾回收:
    两个区域则有着很显著的不确定性,一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

对象已死?

垃圾收集器在对Java堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的。
但是,在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
/**
* 单纯的引用计数就很难解决对象之间相互循环引用的问题。
* @Author zhanshifeng
* @Date 2020/9/8 4:53 PM
*/
public class ReferenceCountingGC {

public Object instance;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];

public static void testGC() {
ReferenceCountingGC a = new ReferenceCountingGC();
ReferenceCountingGC b = new ReferenceCountingGC();
a.instance = b;
b.instance = a;
a = null;
b = null;

// 如果JVM单纯引用计数的话,a和b是不能被回收的,但是实际上是回收了。
System.gc();
}

public static void main(String[] args) {
testGC();
}

}

可达性分析算法(Reachability Analysis)

GC Roots

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

固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI (即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字) 持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、本地代码缓存等。

目前最新的几款垃圾收集器无一例外都具备了局部回收的特征,为了避免GCRoots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

对象的引用?

  • 强引用(Strongly Reference)是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object() ‘这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference)是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
  • 弱引用(Weak Reference)也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
  • 虚引用也称为“幽灵引用”或者“幻影引用”(Phantom Reference),它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。

生存与死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记, 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize(方法。假如对象没有覆盖finalize()方法,或者finalize(方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为确有必要执行finalize方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己一只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字) 赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合,如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

尽管如此,但是一定要避免使用finalize(),这个方法不确定性太大了,无法保证其被调用的顺序。

回收方法区

《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载), 方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾回收两部分内容:

废弃的常量和不再使用的类型

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

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • 加载该类的类加载器已经被回收。

关于对类型进行回收HotSpot虚拟机提供了一些参数做控制:

  • -verbose: class
  • -XX: +TraceClassLoading
  • -XX: +TraceClassUnLoading(需要FastDebug版的虚拟机支持)

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为:

  • “引用计数式垃圾收集”(Reference Counting GC)(直接垃圾收集)
  • “追踪式垃圾收集”(Tracing GC)(间接垃圾收集)

分代收集理论

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域。
部分收集(Partial GC)类型划分:

  • 新生代收集(Minor GC/Young GC):针对Java堆里面新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):针对Java堆里面老年代的垃圾收集,Major GC有些文献用来代表整堆收集,可能会混淆,目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):针对Java对里面的整个新生代和部分老年代的垃圾收集,目前只有G1收集器会有这种行为。

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

针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法:

  • 标记-复制算法
  • 标记-清除算法
  • 标记-整理算法

新生代与老年代

现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
但是存在一个明显的问题:对象不是孤立的,对象之间会存在跨代引用。

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

  • 跨代引用假说(Intergenerational Reference Hypothesis)
    跨代引用相对于同代引用来说仅占极少数。这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
    如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
    依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set), 这个结构把老年代划分成若干小块,标识出老年代的哪块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时 的开销,但比起收集时扫描整个老年代来说仍然是划算的。

标记-清除算法(Mark-Sweep)

最基础的垃圾收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
标记-清除算法的缺点:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

半区复制

为了解决标记清除算法面对大量可回收对象时执行效率低的问题,有人提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,另一半等同于空置不用。

新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1 : 1的比例来划分新生代的内存空间,现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。

Appel式回收

之后有人继续提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虛拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局”。
Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor(Survivor From区)。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8 : 1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%,8:1的总数是10,也就是还有10%用于其他用途,即Survivor To区),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)

这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性就成为一个沉重的负担,因此导致垃圾收集的暂停时间明显变长。

标记-整理算法(Mark-Compact)

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

  • 前者是一种非移动式的回收算法;
  • 而后者是移动式的。

移动回收后的存活对象是一项优缺点并存的风险决策。

  • 如果移动存活的对象太多,这将是一笔很大的开销,而且这种移动操作要强制暂停用户的应用程序才能进行(最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行)。
  • 如果完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
    譬如通过“分区空闲分配链表’’来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。
    内存的访问是用户程序最频繁的操作,在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。这里的吞吐量的实质是赋值器(Mutator, 可以理解为使用垃圾收集的用户程序)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。
HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

三色标记法(Tri-color Marking)

白色、黑色、灰色

并发出现对象消失问题

两种解决方案:增量更新(Incremental Update)、原始快照(Snapshot At The Begging,SATB)。

经典垃圾收集器


如果两个收集器存在连线,则它们可以搭配使用。
上图连线关系不是一成不变的,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP 214)。

Serial收集器

这是最古老,最基础的垃圾收集器,只会使用一个处理器单线程去做垃圾收集,而且它在进行垃圾收集期间,会停掉所有的用户工作线程,这是非常糟糕的用户体验。(Stop The World)
Serial/Serial Old收集器工作流程:

Serial虽然有上述这么大的弊端,但是历代JVM一直致力于Serial的优化改进,直至如今,Serial依然是HotSpot的默认新生代收集器,因为其在单线程的环境下相较而言简单高效,而且垃圾收集的时间甚至可以缩短到十几、几十毫秒。

ParNew收集器

是Serial的多线程并行版本,具体实现和Serial收集器完全一致。
ParNew收集器工作流程:

JDK5发布时推出了划时代意义的CMS收集器,是HotSpot第一款支持并发的垃圾收集器,其首次实现垃圾收集器与用户线程(基本上)同时工作。
ParNew是HotSpot JVM中第一款退役的收集器。

垃圾收集并行 vs 并发

  • 并行(Parallel) :并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发(Concurrent) :并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

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

这是一款新生代收集器,同样是基于标记-复制算法实现的,也是能够并行收集的多线程收集器。
Parallel Scavenge的目标是要达到一个可控的吞吐量。

吞吐量


高吞吐量可以更好地利用处理器资源,更快完成用户的任务。

Parallel Scavenge精确控制吞吐量的参数

  • -XX: MaxGCPauseMills:最大垃圾收集停顿时间(并非越小越好,它与吞吐量是互斥的关系)。
    这个参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过不要异想天开地认为如果把这个参数的值设置得更小点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小-些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • -XX: GCTimeRatio:直接设置吞吐量大小。
    这个参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
  • -XX: +UseAdaptiveSizePolicy:开关参数,开启之后虚拟机会根据当前系统运行情况收集性能监控信息,动态调整其它一些参数以提供最合适的停顿时间或者最大的吞吐量(自适应的调节策略)。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

Parallel Old收集器

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

CMS收集器(关注停顿时间控制)(如今已经被官方声明为不推荐使用)

CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
在较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验的场景下,CMS收集器就非常符合这类应用的需求。
CMS收集器是基于标记-清除算法实现的:

  • 初始标记(CMS initial mark)(”Stop The World”)
  • 并发标记(CMS concurrent mark)(可以与用户线程一起工作)
  • 重新标记(CMS remark)(”Stop The World”)
  • 并发清除(CMS concurrent sweep)(可以与用户线程一起工作)

CMS收集器的弊端

  • 在并发收集的阶段势必会占用用户线程的资源,CMS默认启动的回收线程数是(处理器核心数量+3) /4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
  • 无法处理浮动垃圾(在标记过程结束之后产生的垃圾),可以通过-XX: CMSInitiatingOccu-pancyFraction参数做一些调整。
  • 标记-清除算法会产生大量空间碎片。

Garbage First收集器(关注停顿时间控制)

Garbage First收集器简称G1,到了JDK 8 Update 40之后,被官方称为全功能的垃圾收集器(Fully-Featrued Garbage Collector)。

  • G1作为CMS收集器的替代者和继承人,设计者们希望做出-款能够建立起停顿时间模型(Pause Prediction Model)的收集器(可通过-XX: MaxGCPauseMillis调整,一般停顿时间为200毫秒左右,太短的停顿会造成垃圾回收不充分,最终Full GC)。
  • G1跳出垃圾收集分代理论,它可以面向堆内存任何部分来组成回收集(Collection Set, - 般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
  • 开创了基于Region的堆内存设计布局,还有一个Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象(可以通过参数-XX: G1HeapRegionSize控制),超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。

G1运作过程:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是,三者共同构成了一个“不可能三角”(一款优秀的收集器通常最多可以同时达成其中的两项)。

  • 内存占用(Footprint)
  • 吞吐量(Throughput)
  • 延迟(Latency)

Shenandoah收集器

Shenandoah收集器不是”亲儿子”,在OracleJDK12 中目前还未支持,其后面版本JDK是否支持有待考证。
这个项目的目标是实现-种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
Shenandoah和G1有三个明显的不同之处:

  • 支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发;
  • Shenandoah (目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在;
  • Shenandoah摒弃 了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(ConnectionMatrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

ZGC收集器(Z Garbage Collector)

ZGC是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器。
ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
ZGC完全成熟之后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。

选择合适的垃圾收集器

Epsilon收集器

从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。
近年来大型系统从传统单体应用向微服务化、无服务化方向发展的趋势已越发明显,Java在这方面比起Golang等后起之秀来确实有一些先天不足,使用率正渐渐下降。传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。Epsilon也是有着类似的目标,如果客户应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

收集器的权衡

选择合适的垃圾收集器的因素主要有三个:

  • 应用程序的关注点:
    • 吞吐量(如果是数据分析、科学计算类的任务、异步实时性追求不高的后台任务,目标是能尽快算出更多的结果,那吞吐量就是主要关注点)。
    • 停顿时间(如果是SLA应用、服务影响时间要求高的应用、抢购服务等,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点)。
    • 内存占用(如果是客户端应用或者嵌入式应用、内存资源紧张的机器,那垃圾收集的内存占用则是不可忽视的)。
  • 运行应用的基础设施:
    • 硬件规格,要涉及的系统架构是x86-32/64、SPARC还是ARM/Aarch64;
    • 处理器的数量多少,分配内存的大小;
    • 选择的操作系统是Linux、Solaris 还是Windows等。
  • 使用JDK的发行商是什么?
    • 版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-DK、OpenJ9抑或是其他公司的发行版?
    • 该JDK对应了《Java虚拟机规范》的哪个版本?

虚拟机和垃圾收集器日志

  • 在JDK 9以前,HotSpot并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。
  • 直到JDK9,HotSpot所有功能的日志都收归到了“-Xlog”参数上,这个参数的能力也相应被极大拓展了:
    1
    -Xlog[:[selector][:[output][:[decorators][:output-options]]]]

    参数详细用法可以参考周志明老师的书或者官方文档。

内存分配和策略回收

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:

  • 自动给对象分配内存;
  • 自动回收分配给对象的内存。

对象优先分配在Eden空间

对象优先分配在Eden空间,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
HotSpot虚拟机提供了-XX: +PrintGCDetails这个收集器日志参数。

BOILERPLATE:

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
40
41
42
43
44
45
46
47
48
49
/**
* 测试对象优先分配在Eden空间
*
* -verbose:gc
* -Xms20M
* -Xmx20M
* -Xmn10M
* -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*
* 限制Java大小为20MB,不可扩展,10MB分配给Minor,剩下10MB给Major
* Minor所在Eden空间与Survivor From空间的默认比率是8:1(Minor GC时存活对象从Eden移步到Survivor From时)
*
* @Author zhanshifeng
* @Date 2020/9/24 4:16 PM
*/
public class PrioritizedEdenAllocation {
private final static int _1MB = 1024 * 1024;

public static void test() {
byte[] allocation1 = new byte[2 * _1MB];
byte[] allocation2 = new byte[2 * _1MB];
byte[] allocation3 = new byte[2 * _1MB];
/**
* -Xmn给到Minor只有10MB,这个时候,上面已经消耗掉6个MB了,
* 剩下的4MB不足以分配给4MB大小的allocation4,
* 故Eden空间不足,出现一次Minor GC。
* GC期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor From空间(Survivor From空间只有1MB大小)
* 于是触发分配担保机制(Handle Promotion),allocation1、allocation2、allocation3被转移到Major
* 最后allocation4被成功分配达到Eden,此时Survivor空闲
*/
byte[] allocation4 = new byte[4 * _1MB]; // GC

// Heap
// PSYoungGen total 9216K, used 7988K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
// eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdcd1a0,0x00000007bfe00000)
// from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
// to space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
// ParOldGen total 10240K, used 4096K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
// 实际上这里的GC之后的老年代的10MB只使用到4MB,应该是Minor GC只回收了前面连个对象就足以分配allocation4了,所以没有回收第三个对象
// object space 10240K, 40% used [0x00000007bec00000,0x00000007bf000010,0x00000007bf600000)
// Metaspace used 3050K, capacity 4496K, committed 4864K, reserved 1056768K
// class space used 333K, capacity 388K, committed 512K, reserved 1048576K
}

public static void main(String[] args) {
test();
}
}

大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
比遇到大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数, 指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 其他参数同上
* -XX:+UseSerialGC(下面参数在Parallel Scavenge不支持)
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold(){
byte[] allocation4 = new byte[4 * _1MB]; // 直接分配到老年代了

// Heap
// def new generation total 9216K, used 1844K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
// eden space 8192K, 22% used [0x00000007bec00000, 0x00000007bedcd170, 0x00000007bf400000)
// from space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
// to space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
// tenured generation total 10240K, used 4096K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
// 下面这行可以看到老年代被占用。
// the space 10240K, 40% used [0x00000007bf600000, 0x00000007bfa00010, 0x00000007bfa00200, 0x00000007c0000000)
// Metaspace used 2981K, capacity 4496K, committed 4864K, reserved 1056768K
// class space used 328K, capacity 388K, committed 512K, reserved 1048576K
}

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。
对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 其他参数同上
* -XX:MaxTenuringThreshold=1
* -XX:+PrintTenuringDistribution
*/
public static void testTenuringThreshold() {
byte[] allocation1 = new byte[_1MB / 4];
byte[] allocation2 = new byte[4 * _1MB];
byte[] allocation3 = new byte[4 * _1MB]; // Eden控件这个时候不足4MB,进行第一次MinorGC,将allocation1移到Survivor From空间,将allocation2移到Major
System.out.println("------------");
allocation3 = null;
allocation3 = new byte[4 * _1MB]; // 手动第二次GC,allocation1年龄是2,进入Major
System.out.println("------------");
}

动态对象年龄判定

如果在Survivor空间中的一批相同年龄的对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 其他参数同上
* -XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
public static void testTenuringThreshold2() {
byte[] allocation1 = new byte[_1MB / 4];
byte[] allocation2 = new byte[_1MB / 4]; // allocation1、allocation2加起来大于survivor空间的一半
byte[] allocation3 = new byte[4 * _1MB];
byte[] allocation4 = new byte[4 * _1MB]; // Eden空间不足,第一次GC,allocation1、allocation2直接进入Major,而非Survivor
System.out.println("------------");
allocation4 = null;
allocation4 = new byte[4 * _1MB];
System.out.println("------------");// 手动第二次GC,allocation3也进入Major,所以tenured generation占用48%
// tenured generation total 10240K, used 4967K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
// the space 10240K, 48% used [0x00000007bf600000, 0x00000007bfad9f80, 0x00000007bfada000, 0x00000007c0000000)
}

空间分配担保

在发生MinorGC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这次Minor GC可以确保是安全的。如果不成立,则虛拟机会先查看-XX: HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure) ,如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,就会进行Minor GC(这是一个冒险行为)。
JDK 6 Update24之前还会做多一层判断:

  • 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
  • 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

冒险

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代,这与生活中信用贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取历史平均值来比较其实仍然是一种赌概率的解决办法,如果赌输了(Minor GC失败),那就只能Full GC,

各个JDK版本使用的默认GC收集器

JDK default garbage collectors:

  • Java 7:Parallel GC
  • Java 8:Parallel GC
  • Java 9:G1 GC
  • Java 10:G1 GC

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

基础故障处理工具(命令行)

jps

  • 类似Unix的ps命令,可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID (LVMID, Local Virtual Machine Identifier) 。
  • 对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID, Process Identifier)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖jps命令显示主类的功能才能区分了。
1
2
3
4
5
6
7
8
9
10
11
$ ./jps -l
21618 me.fengorz.kiwi.gateway.GatewayApplication
21443 me.fengorz.kiwi.eureka.EurekaApplication
21624 me.fengorz.kiwi.auth.AuthApplication
21609 me.fengorz.kiwi.admin.UpmsApplication
21642 /Users/zhanshifeng/Documents/myDocument/idea-project/microservice-kiwi/kiwi-common/kiwi-common-sdk/target/surefire/surefirebooter5682934295796544847.jar
5005
21645 sun.tools.jps.Jps
21438 me.fengorz.kiwi.config.ConfigApplication
21631 org.codehaus.classworlds.Launcher
21359

jstatd

需要远程主机提供RMI支持,JDK中提供了jstatd工具可以很方便地建立远程RMI服务器。
官方文档

jstat

jstat (JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。

JDK 8的jstat官方手册

1
2
# 每250秒查询一次进程21443的垃圾收集情况,一共查询20次
./jstat -gc 21443 250 20
1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./jstat -gcutil 21443
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
67.19 0.00 77.24 71.45 94.54 92.87 74 0.164 2 0.092 0.256
# S0、S1表示Survivor0、Survivor1
# E表示新生代Eden区,已经使用了77.24%
# O表示老年代
# M表示Meta Space(元空间)
# CCS:Compressed class space utilization as a percentage.(压缩类空间利用率百分比)
# YGC表示发生Minor GC(Young GC)的次数
# YGCT: Young generation garbage collection time.(YGC的总耗时)
# FGC表示Full GC次数
# FGCT表示FGC总耗时
# GCT表示所有GC总耗时

jinfo(Java配置信息工具)

jinfo (Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数(只能对可在运行时修改的参数生效)。

1
2
3
4
5
6
7
8
9
Usage:
jinfo <option> <pid>
(to connect to a running process)

where <option> is one of:
-flag <name> to print the value of the named VM flag
-flag [+|-]<name> to enable or disable the named VM flag
-flag <name>=<value> to set the named VM flag to the given value
-h | -help to print this help message

jmap(Java内存映射工具)

jmap (Memory Map for Java)命令用于生成堆转储快照(般称为heapdump或dump文件)。
也可以使用参数-XX:+HeapDumpOnOutOfMemoryError“暴力”拿到hump文件。

jmap的其他作用

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

1
jmap -dump:format=b,file=test.bin pid

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

jhat(JVM Heap Analysis Tool)命令可以与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。
不过一般不会在部署应用程序的服务器上直接分析堆转储快照,即使可以这样做,也会尽量将堆转储快照文件复制到其他机器上进行分析。

jstack:Java堆跟踪工具

jstack (Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。
线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁死循环请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。

通过java.lang.Thread的getAllStackTraces()获取虚拟机所有线程的StackTraceElement对象,也可以查看线程状况。

可视化故障处理工具

  • JConsole(最古老)
  • JHSDB(JDK 9之后才正式提供)
  • VisualVM
  • Java Mission Control(JMC)

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

JHSDB比老工具更好用、更强大,是一款基于服务性代理(Serviceability Agent, SA)实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java数据结构为参照物进行设计,把这些C++的数据抽象出Java模型对象,相当于HotSpot的C++代码的一个镜像。通过服务性代理的API,可以在一个独立的Java虚拟机的进程里分析其他HotSpot虚拟机的内部数据,或者从HotSpot虚拟机进程内存中dump出来的转储快照里还原出它的运行状态细节。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* @Description 测试JHSDB故障分析工具
* @Author zhanshifeng
* @Date 2020/9/26 10:11 AM
*/
public class JHSDBTest {

/**
* taticObj随着Test的类型信息存放在方法区;
* instanceObj随着Test的对象实例存放在Java堆;
* localObj则是存放在foo方法栈帧的局部变量表中。
*/
static class Test {
static ObjectHolder staticObj = new ObjectHolder();
ObjectHolder instanceObj = new ObjectHolder();

void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");// 打断点
}
}

private static class ObjectHolder {}

/**
* VM Options:
* -Xmx10m
* -XX:+UseSerialGC
* -XX:-UseCompressedOops
*
* @param args
*/
public static void main(String[] args) {
Test test = new JHSDBTest.Test();
test.foo();
}

/**
* ./jps -l
* 6019 me.fengorz.jvm.jhsdb.JHSDBTest
*
* ./jhsdb hsdb -pid 6019
*
* 通过Tools->Heep Parameters复制Eden里面对象实例的内存地址
* scanoops 0x0000000124c00000 0x0000000124e9ee58 0x0000000124eb0000
* 可以追寻到以下结果:
* 0x0000000115c93d00 me/fengorz/jvm/jhsdb/JHSDBTest$Test
* 0x0000000115c93cf0 me/fengorz/jvm/jhsdb/JHSDBTest$ObjectHolder
* 0x0000000115c93d18 me/fengorz/jvm/jhsdb/JHSDBTest$ObjectHolder
* 0x0000000115c93d28 me/fengorz/jvm/jhsdb/JHSDBTest$ObjectHolder
*
* 通过Tools->Inspector确认三个实例地址存放的具体内容
* _metadata._klass: InstanceKlass for me/fengorz/jvm/jhsdb/JHSDBTest$ObjectHolder
* _super: InstanceKlass for java/lang/Object
* _layout_helper: 16
* _access_flags: 538968096
* _subklass: null
* _next_sibling: InstanceKlass for me/fengorz/jvm/jhsdb/JHSDBTest$Test
* _vtable_len: 5
* _array_klasses: null
* _nonstatic_field_size: 0
* _static_field_size: 0
* _static_oop_field_count: 0
* _nonstatic_oop_map_size: 0
* _is_marked_dependent: 0
* _init_state: 4
* _itable_len: 2
*/
}

根据Inspector的结果可以得到对象头和指向对象元数据的指针,里面包括了:

  • Java类型的名字、继承关系、实现接口关系
  • 字段信息、方法信息、运行时常量池的指针、内嵌的虚方法表(vtable) 以及接口方法表(itable) 等。
1
2
3
hsdb> revptrs 0x0000000115c93cf0
null
Oop for java/lang/Class @ 0x0000000115c92288

通过Inspector查看0x0000000115c92288地址对应的对象实例具体内容:

至此,可以看出上面内存地址的0x0000000115c93cf0对象实例,被内存地址为0x0000000115c92288的实例对象所引用,这就正好对照了Test类里面声明了静态的ObjectHolder类型的变量staticObj。
继续跟踪第二个对象实例内存地址在哪里被引用:

1
2
hsdb> revptrs 0x0000000115c93d18
Oop for me/fengorz/jvm/jhsdb/JHSDBTest$Test @ 0x0000000115c93d00


可以看出:内存地址为0x0000000115c93d18的实例被内存地址为0x0000000115c93d00的实例所引用,这就对照了上面代码的这句:

1
ObjectHolder instanceObj = new ObjectHolder();

跟踪第三个内存地址时出现null:

1
2
hsdb> revptrs 0x0000000115c93d28
null

这是因为第三个对象实例是方法栈里面产生的:

1
2
3
4
void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");// 打断点
}

revptrs不支持跟踪栈上的指针引用,可以换成在Java Threads窗口选中main线程,然后打开上面的Stack Memory for main:

JConsole(Java监视与管理控制台)

JConsole (Java Monitoring and Management Console)是一款基于JMX (JavaManagement Extensions)的可视化监视、管理工具。它的主要功能是通过JMX的MBean (Managed Bean)对系统进行信息收集和参数动态调整。
其Memory标签相当于可视化的jstat命令,Threads标签相当于可视化的jstack命令。
Threads标签使用测试代码:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
public class ThreadTest {

public static void createBusyThread() {
new Thread(() -> {
while (true) {

}
}, "testBusyThread").start();
}

private final static Object lock = new Object();

public static void createLockThread() {
new Thread(() -> {
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "testLockThread").start();
}

static class SyncAdd implements Runnable {
int a, b;

public SyncAdd(int a, int b) {
this.a = a;
this.b = b;
}

@Override
public void run() {
synchronized (Integer.valueOf(a)) {
synchronized (Integer.valueOf(b)) {
System.out.println(a + b);
}
}
}
}

/**
* 为了做实验的机器资源安全,可以加上限制:
* -Xms500m
* -Xmx500m
*
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException {
// BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
// reader.readLine();
// createBusyThread();
// reader.readLine();
// createLockThread();

for (int i = 0; i < 500; i++) {
new Thread(new SyncAdd(1, 2)).start();
new Thread(new SyncAdd(2, 1)).start();
}
}

/**
* Name: testBusyThread
* State: RUNNABLE
* Total blocked: 0 Total waited: 0
*
* Stack trace:
* app//me.fengorz.jvm.jconsole.ThreadTest.lambda$createBusyThread$0(ThreadTest.java:15)
* app//me.fengorz.jvm.jconsole.ThreadTest$$Lambda$96/0x000000080019b840.run(Unknown Source)
* java.base@11.0.8/java.lang.Thread.run(Thread.java:834)
*/

/**
* Name: testLockThread
* State: WAITING on java.lang.Object@b847d8d
* Total blocked: 0 Total waited: 1
*
* Stack trace:
* java.base@11.0.8/java.lang.Object.wait(Native Method)
* java.base@11.0.8/java.lang.Object.wait(Object.java:328)
* app//me.fengorz.jvm.jconsole.ThreadTest.lambda$createLockThread$1(ThreadTest.java:27)
* app//me.fengorz.jvm.jconsole.ThreadTest$$Lambda$97/0x000000080019cc40.run(Unknown Source)
* java.base@11.0.8/java.lang.Thread.run(Thread.java:834)
*/

/**
* 太多SyncAdd线程产生死锁之后可以点击JConsole的Detect Deadlock探测死锁。
*
* Name: Thread-45
* State: BLOCKED on java.lang.Integer@2f98ef2a owned by: Thread-48
* Total blocked: 2 Total waited: 0
*
* Stack trace:
* app//me.fengorz.jvm.jconsole.ThreadTest$SyncAdd.run(ThreadTest.java:45)
* - locked java.lang.Integer@d0dcf29
* java.base@11.0.8/java.lang.Thread.run(Thread.java:834)
*
* ----------------
*
* Name: Thread-48
* State: BLOCKED on java.lang.Integer@d0dcf29 owned by: Thread-45
* Total blocked: 2 Total waited: 0
*
* Stack trace:
* app//me.fengorz.jvm.jconsole.ThreadTest$SyncAdd.run(ThreadTest.java:45)
* - locked java.lang.Integer@2f98ef2a
* java.base@11.0.8/java.lang.Thread.run(Thread.java:834)
*
* 可以发现两个线程在相互等待锁的释放:
* State: BLOCKED on java.lang.Integer@2f98ef2a owned by: Thread-48
* State: BLOCKED on java.lang.Integer@d0dcf29 owned by: Thread-45
*/
}

VisualVM(多合-故障处理工具)

VisualVM (A1l-in-One Java Troubleshooting Tool)是功能最强大的运行监视和故障处理程序之一,曾经在很长一段时间内是Oracle官方主力发展的虚拟机故障处理工具。VisualVM的功能有:

  • 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。
  • 监视应用程序的处理器、垃圾收jstack)。
  • dump以及分析堆转储快照(jmap、jhat)。
  • 方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。
  • 离线程序快照:收集程序的运行时配置、线程dump、 内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈。
  • 其他插件带来的无限可能性。

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

Oracle Java SE Advanced & Suite(Advanced是” Advanced Monitoring & Management of Java in the Enterprise” )与普通Oracle Java SE在功能上的主要差别是前者包含了一系列的监控、管理工具,譬如用于企业JRE定制管理的AMC (Java Advanced Management Console)控制台、JUT (Java Usage Tracker)跟踪系统,用于持续收集数据的JFR(Java Flight Recorder)飞行记录仪和用于监控Java虚拟机的JMC (Java Mission Control)。这些功能全部都是需要商业授权才能在生产环境中使用,但根据Oracle Binary Code协议,在个人开发环境中,允许免费使用JMC和JFR。
JMC的作用:

  • 作为JMX控制台,显示来自虚拟机MBean提供的数据;
  • 另一方面作为JFR的分析工具,展示来自JFR的数据。

JFR

JFR是一套内建在HotSpot虚拟机里面的监控和基于事件的信息搜集框架,与其他的监控工具(如JProfiling)相比,Oracle特别强调它“可持续在线”(Always-On)的特性。JFR在生产环境中对吞吐量的影响一般不会高于1% (甚至号称是Zero Performance Overhead),而且JFR监控过程的开始、停止都是完全可动态的,即不需要重启应用。
JFR包含以下几类信息:

  • 一般信息:关于虚拟机、操作系统和记录的一般信息。
  • 内存:关于内存管理和垃圾收集的信息。
  • 代码:关于方法、异常错误、编译和类加载的信息。
  • 线程:关于应用程序中线程和锁的信息。
  • I/O:关于文件和套接字输入、输出的信息。
  • 系统:关于正在运行Java虚拟机的系统、进程和环境变量的信息。
  • 事件:关于记录中的事件类型的信息,可以根据线程或堆栈跟踪,按照日志或图形的格式查看。

被监控端启动参数

Java Discovery Protocol (JDP)

1
2
3
4
5
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
-XX:+UnlockCommercialFeatures
-XX:+FlightRecorder

下面这个应该是向内网的安装JMC的机器广播自动发现的通知(Multicast address to send autodiscovery packets):

1
-Dcom.sun.management.jdp.address=224.0.23.178

HotSpot虚拟机插件及工具

HSDIS(JIT生成代码反编译)

HSDIS插件的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把即时编译器动态生成的本地代码还原为汇编代码输出,同时还会自动产生大量非常有价值的注释,这样我们就可以通过输出的汇编代码来从最本质的角度分析问题。
JITWatch是HSDIS经常搭配使用的可视化的编译日志分析工具,为便于在JITWatch中读取,可以使用以下参数把日志输出到logfile文件:

1
2
3
4
5
6
-XX:+UnlockDiagnosticVMOptions
-XX:+TraceClassLoading
-XX:+LogCompilation
-XX:LogFile=/tmp/logfile.log
-XX:+PrintAssembly
-XX:+TraceClassLoading

调优案例分析与实战

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

目前单体应用在较大内存的硬件上主要的部署方式有两种:

通过一个单独的Java虚拟机实例来管理大量的Java堆内存

对于用户交互性强、对停顿时间敏感、内存又较大的系统,并不是一定要使用Shenandoah、ZGC这些明确以控制延迟为目标的垃圾收集器才能解决问题(当然这是最值得考虑的方案)。
使用Parallel Scavenge/Old收集器,并且给Java虚拟机分配较大的堆内存也是有很多运行得很成功的案例的,但前提是必须把应用的Full GC频率控制得足够低,至少要低到不会在用户使用过程中发生,譬如十几个小时乃至一整天都不出现一次Full GC,这样可以通过在服务器空闲时执行定时任务的方式触发Full GC,甚至是自动重启应用服务器来保持内存可用空间在一个稳定的水平。
控制Full GC频率的关键是老年代的相对稳定,这主要取决于应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应当太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。
在许多网站和B/S形式的应用里,多数对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对较少。
使用单个Java虚拟机实例来管理大内存,还需要考虑下面可能面临的问题:

  • ZGC和Shenandoah这种低延迟的最好解决方案目前尚未完全成熟(在任意堆内存大小下都能很好地做到低延迟GC)。
  • 大内存的情况下,64位虚拟机的性能测试结果普遍略低于相同版本的32位虚拟机。
  • 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转储快照(要产生十几GB乃至更大的快照文件),哪怕成功生成了快照也难以进行分析。如果确实出了问题要进行诊断,可能就必须应用JMC这种能够在生产环境中进行的运维工具。
  • 在64位虚拟机中消耗的内存一般比32位虚拟机要大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的,可以开启(默认即开启)压缩指针功能来缓解。

同时使用若干个Java虚拟机,建立逻辑集群来利用硬件资源

在一台物理机器上建立逻辑集群的目的仅仅是尽可能利用硬件资源,并不是要按职责、按领域做应用拆分,也不需要考虑状态保留、热转移之类的高可用性需求,不需要保证每个虚拟机进程有绝对准确的均衡负载,因此使用无Session复制的亲合式集群是一个相当合适的选择。
比如使用均衡器按一定的规则算法 (譬如根据Session ID/Token ID分配)将一个固定的用户请求(或者某种类别的请求)永远分配到一个固定的集群节点进行处理即可,这样程序开发阶段就几乎不必为集群环境做任何特别的考虑。
逻辑集群方案可能会遇到的问题:

  • 节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致I/O异常。
  • 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点的连接池已经满了,而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI来解决,但这个方案有一定复杂性并且可能带来额外的性能代价。
  • 如果使用32位Java虛拟机作为集群节点的话,各个节点仍然不可避免地受到32位的内存限制,在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆最多一般只能开到1.5GB。在某些Linux或UNIX系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB(2的32次幂)内存的限制。

堆外内存导致的内存溢出

直接内存占用太多资源也会导致溢出错误,在进行GC时,虚拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就主动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,“顺便”帮它清理掉内存的废弃对象。否则就不得不一直等到抛出内存溢出异常时,先捕获到异常,再在Catch块里面通过System.gc()命令来触发垃圾收集。
但如果Java虚拟机再打开了-XX:+DisableExplicitGC开关,禁止了人工触发垃圾收集的话,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。

外部命令导致系统缓慢(比如Shell脚本)

不合适的数据结构导致内存占用过大

如果存在加载过量的资源到堆内存,比如要加载某个超大的文件资源到HashMap,有时候是非常危险的事情。

虚拟机执行子系统

Class类文件结构

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

无符号数

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

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

魔数与Class文件的版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。
Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。

常量池

紧接着主、次版本号之后的是常量池入口,由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count,计数从1开始,所以代表的真实常量数要减一)。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

字面量(Literal)

字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。

符号引用(Symbolic References)

符号引用主要包括下面几类常量(编译层面):

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、 Method Type、 Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

Java代码在进行Javac编译的时候,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。
当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。截至JDK 13,常量表中分别有17种不同类型的常量。

CONSTANT_Class_info型常量的结构:

  • tag是标志位,它用于区分常量类型;
  • name_index是常量池的索引值。

CONSTANT_Utf8_info型常量的结构:

  • tag是标志位;
  • length值代表字符串长度是多少字节;
  • bytes是长度为length字节的连续数据,是一个使用UTF-8缩略编码表示的字符串。

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以其最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。

剩余常量池的其他17种数据类型结构:


访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,其16个标志位目前只用了9个,剩下的暂时都是归零:

类索引、父类索引与接口索引集合(Class文件中由这三项数据来确定该类型的继承关系)

  • 类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,它们各自指向一个CONSTANT_Class_info类型的常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。
    由于Java语言不允许多重继承,父类索引只有一个,除了java.lang.Object之外,所以Java类的父类索引都不为0。

  • 接口索引集合是一组u2类型的数据的集合,被实现的接口将按implements关键字后的接口顺序从左到右排列在接口索引集合中。
    其入口的第一项u2类型的数据为接口计数器(interfaces_count),表示索引表的容量,如果该类没有实现任何接口,则该计数器值为0。

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段表中包含了各种修饰符,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,只能引用常量池中的常量来描述。

字段表结构


字段修饰符存放在access_flags中:

跟随access_flags标志的是两项索引值(常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符):

  • name_index;存放全限定名和简单名称;
  • descriptor_index:用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示。

    对于数组类型,使用前置的”[“字符来描述,如一个定义为java.lang.String[][]类型的二维数组将被记录成[[Ljava/lang/String;,一个整型数组int[]将被记录成[I
    对于方法的描述,按照先参数列表、后返回值的顺序,参数列表按照参数的严格顺序放在一组小括号()之内。如方法void inc()的描述符为()V,方法java.lang.String toString()的描述符为()Ljava/lang/String;,方法int indexOf(char[]source, int sourceOffset, int sourceCount,char[]target, int targetOffset,int targetCount, int fromIndex)的描述符为([CII[CII)I

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。

方法表集合

方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。
方法表结构:

方法访问标志:

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为Code的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。
如果子类没有重写父类方法,一般就只有最常见的类构造器<clinit>()方法和实例构造器<init>()方法。
在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。
特征签名是指方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围要更大一些,只要描述符不是完全一致的两个方法就可以共存。

属性表集合

Class文件、字段表、方法表都可以携带自己的属性表集合。
虚拟机规范预定义的属性:


属性表结构:

Code属性

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,接口或者抽象类中的方法就不存在Code属性。
Code属性表的结构:

  • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为"Code",它代表了该属性的属性名称。
  • attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共为6个字节,所以属性值的长度固定为整个属性表长度减去6个字节。
  • max_stack代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。
  • max_locals代表了局部变量表所需的存储空间。其单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、 float、 int、 short、 boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64位的数据类型则需要两个变量槽,方法参数(包括thissuper这种内置的参数)、显式异常处理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表来存放。
    操作数栈和局部变量表直接决定该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。
    Java虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。
  • code_length和code用来存储Java源程序编译后生成的字节码指令。
    code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那顾名思义每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及后续的参数应当如何解析。我们知道一个u1数据类型的取值范围为0x000xFF,对应十进制的0255,也就是一共可以表达256条指令。目前,《Java虚 拟机规范》已经定义了其中约200条编码值对应的指令含义。
    关于code_length, 《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度,如果超过这个限制,Javac编译器就会拒绝编译。

Java程序可分为:

  • 方法体的代码(Code属性);
  • 元数据(Metadata,包括类、方法、字段的定义及其他信息)。
异常表(try-catch-finally)(在Code属性中并不是必须存在的)


如果当字节码从第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。
《Java虚拟机规范》中明确要求Java语言的编译器应当选择使用异常表而不是通过跳转指令来实现Java异常及finally处理机制。

Exceptions属性

Exceptions属性的作用是列举出方法中可能拋出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。

number_of_exceptions项表示方法可能抛出number_of_exceptions种受查异常,每种受查异常使用一个exception_index_table项表示;
exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。

BOILERPLATE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package me.fengorz.jvm.clazz;

/**
* 测试分析Class字节码
*
* @Author zhanshifeng
* @Date 2020/10/7 12:39 PM
*/
public class TestClass {

private int m;

private int inc() {
return m + 1;
}

}

使用javap输出常量表(JDK 8编译的class):

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
$ javap -v TestClass
Warning: File ./TestClass.class does not contain class TestClass
Classfile /Users/zhanshifeng/IdeaProjects/leetcode-practice/out/production/leetcode-practice/me/fengorz/jvm/clazz/TestClass.class
Last modified Oct 7, 2020; size 395 bytes
MD5 checksum acbed0d4b1041fe8c4a659f1f3dbb206
Compiled from "TestClass.java"
public class me.fengorz.jvm.clazz.TestClass
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER // 这里可以发现两个标志位为真:类声明为public,JDK 1.2之后的ACC_SUPER都必须为真,所以flags的值等于0x0001|0x0020=0x0021
this_class: #3 // me/fengorz/jvm/clazz/TestClass 这里的类索引通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串:"me/fengorz/jvm/clazz/TestClass"
super_class: #4 // java/lang/Object 父类索引,原理和类索引一样。
interfaces: 0, fields: 1, methods: 2, attributes: 1 // 接口索引集合大小为0 // 字段计数器fields为1,代表类中有1个字段 // 方法计数器methods为2,代表类中有2个方法 // 属性表数量1个
Constant pool: // 常量池开始
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V CONSTANT_Methodref_info类型常量,即inc方法的符号引用
#2 = Fieldref #3.#19 // me/fengorz/jvm/clazz/TestClass.m:I
#3 = Class #20 // me/fengorz/jvm/clazz/TestClass "#3"被上面this_class引用
#4 = Class #21 // java/lang/Object "#4"被上面super_class引用
#5 = Utf8 m // CONSTANT_Utf8_info型常量用来描述字段m的名字
#6 = Utf8 I // 声明字段数据类型为int
#7 = Utf8 <init> // 编译器添加的实例构造器
#8 = Utf8 ()V // 代表空参数返回void的inc方法
#9 = Utf8 Code // inc方法的"Code"属性,说明此属性是方法具体的字节码指令
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lme/fengorz/jvm/clazz/TestClass;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 TestClass.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I // 对应字段m的名字和类型
#20 = Utf8 me/fengorz/jvm/clazz/TestClass
#21 = Utf8 java/lang/Object
{
public me.fengorz.jvm.clazz.TestClass();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC // 标志位,方法声明为public
Code:
// 操作数栈深度1,本地(局部)变量表容量1(这个实际上是隐藏的this关键字),
// 这里args_size之所以为1,是因为局部变量表中至少会存在一个指向当前对象实例的局部变量this,
// 局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数大小值从1开始
stack=1, locals=1, args_size=1
0: aload_0 // aload_0指令是将第0个变量槽中为reference类型的本地变量推到操作数栈顶

// 具体的方法调用,invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,
// 调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪个方法,
// 它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的符号引用。
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return // inc方法的返回
LineNumberTable:
line 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lme/fengorz/jvm/clazz/TestClass;
}
SourceFile: "TestClass.java"

字节码

字节码指令集可算是一种具有鲜明特点、优势和劣势均很突出的指令集架构,顾名思义字节码长度只能是一个字节(即0~255),这意味着指令集的操作码总数不能够超过256条;
虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,譬如要将一个16位长度的无符号整数使用两个无符号字节存储起来(假设将它们命名为byte1和byte2),那它们的值应该是:

1
(byte1 << 8) | byte2

放弃操作数长度对齐,可以节省大量的填充和间隔符号。

Java虚拟机解析器伪代码:

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

字节码与数据类型

大多数指令都包含其操作所对应的数据类型信息:

  • iload指令用于从局部变量表中加载int型的数据到操作数栈中;
  • fload指令加载的则是float类型的数据。

操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:

  • i代表对int类型的数据操作;
  • l代表long;
  • s代表short;
  • b代表byte;
  • c代表char;
  • f代表float;
  • d代表double;
  • a代表reference;
  • arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
  • 有些事与数据类型无关的指令(比如goto)。

大部分指令都没有支持整数类型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)来进行的。

不同类型的字节码指令

加载和存储指令

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

  • 将一个局部变量加载到操作栈;
  • 将一个数值从操作数栈存储到局部变量表;
  • 将一个常量加载到操作数栈;
  • 扩充局部变量表的访问索引的指令.

运算指令

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

类型转换指令

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

对象创建与访问指令

操作数栈管理指令

控制转移指令

方法调用和返回指令

异常处理指令

同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor, 更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。
当方法调用时,调用指令将会检查方法的ACC_ SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。
在方法执行期间,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

虚拟机类加载机制

类的生命周期

类的加载时机

以下这些情况,如果类型没有进行过初始化,则需要先触发其初始化阶段,这种引用称为对一个类型进行主动引用:

  • 使用new关键字实例化对象的时候;
  • 读取或设置一个类型的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候;
  • 调用一个类型的静态方法的时候;
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候;
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类;
  • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang .invoke MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

除此之外,其他引用类型的方式都不会触发初始化,称为被动引用。

被动引用

  • 通过子类引用父类的静态字段,不会导致子类初始化;
1
2
3
4
5
6
7
public class SuperClass {
static {
System.out.println("super class init!");
}

public static int value = 666;
}
1
2
3
4
5
public class SubClass extends SuperClass {
static {
System.out.println("sub class init!");
}
}
1
2
3
4
5
6
7
8
9
10
public class ClassLoadTest {
public static void main(String[] args) {
System.out.println(SubClass.value);
}

/**
* super class init!
* 666
*/
}
  • 通过数组定义来引用类,不会触及此类的初始化;
1
2
3
4
5
6
7
8
9
public class ClassLoadTest {
public static void main(String[] args) {
SuperClass[] arr = new SuperClass[10];
}

/**
* 并没有输出 super class init!
*/
}
  • 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;
1
2
3
4
5
6
7
public class Constant {
static {
System.out.println("constant init!");
}

public static final String HELLO_WORLD = "hello world!";
}
1
2
3
4
5
6
7
8
9
10
public class ClassLoadTest {
public static void main(String[] args) {
System.out.println(Constant.HELLO_WORLD);
}

/**
* 并没有输出 "constant init!"
*/
}

  • 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类的加载过程

加载

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

  • 通过一个类的全限定名来获取定义此类的二进制字节流,字节流来源可以有:
    • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、 WAR格式的基础。
    • 从网络中获取。
    • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.refect.Proxy中,就是用了ProxyGenerator .generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
    • 从数据库中读取,这种场景相对少见些。
    • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

类的加载

加载阶段可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass或loadClass方法)。

数组的加载

与类加载不一致,待记录。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

验证

  • 文件格式验证(这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,后面的三个验证阶段全部是基于方法区的存储结构上进行的)。
  • 元数据验证。
  • 字节码验证(这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为)。
  • 符号引用验证(符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源)。
    • 如果无法通过符号引用验证,Java虚拟机将会拋出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IlelalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。

如果程序运行的全部代码(包括自已编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify: none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配。
这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起起分配在Java堆中。
如果是赋值了的基本类型静态变量的情况下(也就是默认是预置为基础类型的零值):

1
public static int value = 123;

在准备阶段期间其是不会被预置为123的值的,123的赋值操作将在后面的初始化阶段中进行。
基本类型的零值:

***如果变量被声明为final static,也就是类变量进常量池的字段属性表中的ConstantValue属性时,那么其在准备阶段会被初始化为具体指定的值,如:

1
public static final int value = 123;

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是将对类引用、接口引用、方法引用、对象变量引用等赋值为其具体的实例引用。

  • 符号引用(Symbolic References)
    • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用(Direct References)
    • 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。虚拟机到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它,这个《Java虚拟机规范》没有具体约束。

一般来说,一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。

类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,其解析过程如下(出现任何异常,解析将失败):

  • 如果C不是数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。
  • 如果C是数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似是[Ljava/lang/Integer,那将会按照第一点的规则加载数组元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
  • 此时C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IlegalAccessError异常。
    • JDK 9引入模块化之后,即使是public类型也不能肆无忌惮地访问了。

字段解析

解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT__Class_info符号引用进行解析,如果解析成功,那么假设这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:

  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。
  • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。
  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。
  • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
  • 同样的,找到成功的话,还要对字段的访问权限进行校验。

方法解析

需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么假设用C表示这个类,接下来虚拟机对方法的解析和字段解析类似,同样也是自下而上的解析,如果解析成功也需要进行权限验证。

  • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang .IncompatibleClassChangeError异常。
  • 在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。
  • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。
  • 如果还没有找到,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

接口方法解析

接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会
按照如下步骤进行后续的接口方法搜索:

  • 如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang .IncompatibleClassChangeError异常。
  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 如果接口有多继承的父接口,则返回其中一个符合的直接引用,查找结束(有些虚拟机的实现会拒绝这种不确定性)。
  • 如果还没有找到,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

JDK 9之后增加了接口的静态私有方法、模块化访问约束。

初始化

初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它由Javac编译器自动生成。

<clinit>()方法

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
    1
    2
    3
    4
    5
    static {
    test = 2;
    System.out.println(test);// 这句编译不通过
    }
    static int test = 1;
  • 父类的<clinit>()方法比子类的先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
    • 因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。
  • 没有静态语句块与静态变量的赋值操作的类或接口,可以不生成<clinit>()。接口与类不同,只有当父接口中定义的变量被使用时,父接口才会调用<clinit>()。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • <clinit>()方法是线程安全的,所以其里面有很耗时的初始化操作,当多个线程同时去初始化其类时,会造成线程阻塞。这也就是为什么有些资源配置加载逻辑会放到类的静态代码块来完成。

类加载器

类与类加载器

两个完全相等的类必须是同一个类加载器加载的,”相等”包含了:
类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。
分别使用自定义实现类加载器new ClassLoader()与虚拟机默认加载器所加载的同个类的实例去做instanceof会出现不匹配的结果。

双亲委派模型

只存在两种类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分。
  • 由Java语言实现,并且全都继承自抽象类java.lang.ClassLoader


双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派的具体按照loadClass()方法的逻辑去调用父加载器加载,如果父类加载失败,会自动调用自己的findClass(方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

破坏双亲委派模型

OSGi

OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
《深入理解OSGi:Equinox原理、应用于最佳实践》这本书有对其进行详尽讲解。

Java模块化(待记录)

虚拟机字节码执行引擎

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,栈帧(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。
同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。 而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧(Current Stack Frame),与这个栈帧所关联的方法被称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量,局部变量表的容量以变量槽(Variable Slot)为最小单位,一个变量槽可以存放一个32位以内的数据类型。
对于double和long类型的变量其存在于两个连续的变量槽,由于变量表是线程私有数据,所以是线程安全的。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用剩下的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void test1() {
byte[] placeholder = new byte[1024 * 1024];
// 这里操作GC,是不会回收placeholder,因为此时placeholder还在作用域之内
System.gc();
}

public void test2() {
{
byte[] placeholder = new byte[1024 * 1024];
}
// 这里操作GC,依然不会回收placeholder
System.gc();
}

public void test3() {
{
byte[] placeholder = new byte[1024 * 1024];
}
int a = 0;
// 这里操作GC,这次placeholder真的被回收了
// 上面两个例子placeholder所占用的变量槽没有被复用,而这里的被复用了,老的placeholder对象实例失去引用自然会被回收
System.gc();
}

如果遇到一个方法,其后面的代码是较耗时的操作,而前面又定义了占用大量内存但实际上已经不会再使用的变量,手动将其设置为null,把变量对应的局部变量槽清空,会加快GC的生效,尽快将内存腾出来(不过在经过编译优化过之后,null赋值这种语句很大可能会被当做无效代码优化掉,所以并不一定要固定遵循这个规则写代码)。
局部变量表的变量不存在类变量那样的准备阶段(一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值)。
局部变量必须赋予初始值才能使用,不存在像类变量那样的基础类型默认初始值。

操作数栈

操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1, 64位数据类型所占的栈容量为2。

栈帧之间的数据共享(比如方法之间的调用):

动态连接

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

方法返回地址

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令等。

方法调用

一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。
这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。
可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法调用,这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。

分派

静态分派

虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* @Description 静态分派Demo
* @Author zhanshifeng
* @Date 2020/10/16 8:16 PM
*/
public class StaticDispath {

static abstract class Human {

}

static class Man extends Human {

}

static class Woman extends Human {

}

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

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

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

/**
* Human可理解过man或者woman的静态类型,静态类型在编译器就可知
* man和woman还具备了一个实际类型,或者叫运行时类型,这要在运行器才能得知
*
* @param args
*/
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispath staticDispath = new StaticDispath();
staticDispath.sayHello(man);
staticDispath.sayHello(woman);

// 实际类型变化,要运行时才能得知
Human mankind = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化,编译器即可得知
staticDispath.sayHello((Man) mankind);
staticDispath.sayHello((Woman) mankind);
}

/**
* hello, mankind!
* hello, mankind!
*/

重载的规则中,将在继承关系中从下往上开始搜索,越接上层的优先级越低,最后才是多态的重载。

动态分派

虚方法的查找过程:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IlegalAccessError异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

动态类型语言的支持

动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:
APL、Clojure、 Erlang、 Groovy、 JavaScript、 Lisp、 Lua、 PHP、Prolog、 Python、Ruby、Smalltalk、 Tcl, 等等。
在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

invokedynamic指令

某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让Coder有更高的自由度。
每一处含有invokedynamic指令的位置都被称作动态调用点(Dynamically-Computed Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:

  • 引导方法(Bootstrap Method,该方法存放在BootstrapMethods属性中)
  • 方法类型(MethodType)
  • 名称

引导方法是有固定的参数,并且返回值规定是java.lang invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上。

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

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

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture, ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,也就是现在主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄存器进行工作。

基于栈的解释器执行过程


三种编译

  • 前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程。
    • JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
  • Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程。
    • HotSpot虚拟机的C1、C2编译器,Graal编译器
  • 使用静态的提前编译器(常称AOT编译器/Ahead Of Time Compiler、后端编译)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
    • JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET

三种编译器与解释器的搭配模式

  • 混合模式(Mixed Mode):HotSpot默认的方案,JVM采取解释器和其中一个编译器直接配合的运行模式,编译器根据自身的版本以及宿主机器的硬件性能自动选择。
  • 解释模式(Interpreted Mode):通过-Xint参数开启,该模式初始化启动虚拟机时间快,更适合客户端应用。
  • 编译模式(Compiled Mode):通过-Xcomp参数开启,该模式需要更长的预热时间,在服务器资源充足的情况下,更适合服务端应用。

后端编译与优化

如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation, IR)的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。

热点代码

热点探测判定方式:

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

热点探测

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

HotSpot为每个方法准备了两类计数器:

  • 方法调用计数器(Invocation Counter)
  • 回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。

当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

方法调用计数器

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

方法调用计数器触发即使编译:

回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”。

高效并发

Java内存模型与线程

衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second, TPS)是重要的指标之一,它代表着一秒内
服务端平均能响应的请求总数,而TPS值 与程序的并发能力又有非常密切的关系。
对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;

硬件的效率与一致性

“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的一个重要的复杂性来源于绝大多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等。
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:
将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是它引入了一个新的问题:缓存一致性(Cache Coherence)。
在多核处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化。

Java内存模型(Java Memory Model、JMM)

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

主内存和工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

内存间的交互操作(最新的JSR-133已经被抛弃的操作/Deprecated/JSR-133已将其由8种简化为4种)

Java内存模型中定义了以下8种操作来完成主内存与工作内存之间具体的交互协议,每一种操作都是原子的、不可再分的:

  • lock (锁定) :作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取): 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型还规定了在执行上述8种基本操作时必须满足一些规则:

  • 不允许read和load、 store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程没有发生过任何assign操作就把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

volatile型变量的特殊规则

当一个变量被定义成volatile之后,它将具备两项特性:

可见性

保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
基于volatile变量的运算在并发下不一定是线程安全的,volatile变量在各个线程的工作内存中是不存在一致性问题的, 但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* @Description 基于volatile的可见性存在的高并发线程不安全问题测试
* @Author zhanshifeng
* @Date 2020/10/21 11:12 AM
*/
public class VolatileTest {

public static volatile int race = 0;

public static void increase() {
race++;
}

public static final int THREADS_COUNT = 100;

public static void main(String[] args) {
/**
* IDEA默认main主方法会开启两个线程
* java.lang.ThreadGroup[name=main,maxpri=10]
* Thread[main,5,main]
* Thread[Monitor Ctrl-Break,5,main]
*/
Thread.currentThread().getThreadGroup().list();
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
});
}
for (Thread thread : threads) {
thread.start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}

/**
* public static void increase();
* descriptor: ()V
* flags: (0x0009) ACC_PUBLIC, ACC_STATIC
* Code:
* stack=2, locals=0, args_size=0
* // 当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的
* 0: getstatic #2 // Field race:I
* // 但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据
* 3: iconst_1
* 4: iadd
* // 所以putstatic指令执行后就可能把较小的race值同步回主内存之中。
* 5: putstatic #2 // Field race:I
* 8: return
* LineNumberTable:
* line 13: 0
* line 14: 8
*/

}

以上代码计算完之后结果是随机的,可见volatile只能保证变量可见性,在实际开发使用中,还是要依赖锁机制来写代码。

如果在对某个变量是处于分别多线程单一修改和分别多线程读取的情况,这个时候适合使用volatile修饰变量

1
2
3
4
5
6
7
8
9
volatile boolean isShutdown;
public void shutdown() {
this.isShutdown = true;
}
public void run() {
while (isShutdown) {
// do something
}
}
指令重排序

使用volatile变量的另外一个作用是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一条代码“initialized=true”被提前执行(机器级别的优化)。
在volatile修饰的变量做运算前,底层编译层汇编后,在赋值操作后,会多了一个内存屏障(Memory Barrier或Memory Fence),指重排序时不能把后面的指令重排序到内存屏障之前的位置,这个就能够保证其他线程读取变量值时的一致性。
从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理,同时保证其计算结果的正确性。

volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一点,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。

使用底层规则

假定T表示一个线程,V分别表示volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

  • 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现(作用于工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改,这里将这个子规则简称为规则R)。
  • 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现(同样遵循子规则R)。

针对long和double型变量的特殊规则(64位数据类型)

如果有多个线程共享并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量’的数值(不过实际上出现的情况极其罕见)。
JDK 9起,HotSpot增加了一个实验性的参数-XX: +AlwaysAtomicAccesses (这是JEP 188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。

原子性、可见性与有序性

原子性(Atomicity)

synchronized块实际上会被编译成为两种指令:monitorenter和monitorexit来确保操作原子性。

可见性(Visibility)

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改,volatile、synchronized和final关键字都可以实现可见性。

  • 同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、 write操作)”这条规则获得的。
  • final关键字的可见性是指:
    被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。
有序性(Ordering)

先行发生规则

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由”一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
一个操作“时间上的先发生”不代表这个操作会是“先行发生”,反之也是。

1
2
3
// 同个线程中进行以下操作
int i = 1;
int j = 2;

两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这一点。

Java与线程

线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
实现线程主要有三种方式:

  • 使用内核线程实现(1: 1实现)
  • 使用用户线程实现(1: N实现)
  • 使用用户线程加轻量级进程混合实现(N: M实现)
内核线程实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口–轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

轻量级进程也具有它的局限性:

  • 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
  • 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
用户线程实现(User Thread,UT)

用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
但是用户线程实现方式也是优劣分明的,由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。
Java语言曾经使用过用户线程,但后来还是放弃了,近些年倾向于高并发的编程语言又普遍支持用户线程,如Golang、Erlang等。

混合实现

这种实现方式既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。他们之间是多对多的关系。

Java线程的实现

“主流”商用Java虚拟机的线程模型普遍都使用基于操作系统原生线程模型来实现,即采用1: 1的线程模型。
每一个Java线程都是直接映射到操作系统原生线程来实现的,而且中间没有额外的间接结构,虚拟机是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间等,都是操作系统完成的。

Java线程的调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种:

  • 协同式(Cooperative Threads Scheduling)线程调度:
    这种方式的线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。
    它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
  • 抢占式(Preemptive Threads-Scheduling)线程调度(Java的实现方式):
    这种方式的线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有Thread:yield()方法可以主动让出执行时间,但是不能想要主动获取执行权限。
    这种设计不会有一个线程导致整个进程甚至整个系统阻塞的问题,虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点(通过设置线程优先级)。
    在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行,但是线程优先级不是一项稳定的调借手段,存在不确定性。
    Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),但Windows中就只有七种优先级,因此在Windows下设置线程优先级为1和2、3和4、6和7、8和9的效果是完全相同的。
    Java线程优先级与Windows线程优先级的对应关系:

    优先级可能会被系统自行改变。例如在Windows系统中存在一个叫“优先级推进器”的功能(Priority Boosting,当然它可以被关掉),大致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它分配执行时间,从而减少因为线程频繁切换而带来的性能损耗。
状态切换

Java定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。

  • 新建(New):创建后尚未启动的线程处于这种状态。
  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
    • 没有设置Timeout参数的Object::wait();
    • 没有设置Timeout参数的Thread::join();
    • LockSupport::park()方法;
  • 限期等待(TimedWaiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
    • Thread::sleep()方法;
    • 设置了Timeout参数的Object::wait()方法;
    • 设置了Timeout参数的Thread:join()方法;
    • LockSupport:parkNanos()方法;
    • LockSupport::parkUntil()方法;
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

Java与协程

对于有栈协程OpenJDK 在2018年创建了Loom项目,这是Java用来应对,根据目前公开的信息,如无意外,日后该项目为Java语言引入的、与现在线程模型平行的新并发编程机制中应该也会采用“纤程”这个名字。
Loom团队在JVMLS 2018大会上公布了他们对Jetty基于纤程改造后的测试结果,同样在5000QPS的压力下,以容量为400的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比,前者的请求响应延迟在10000至20000毫秒之间,而后者的延迟普遍在200毫秒以下。

线程安全与锁优化

线程安全

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

不可变

Java语言中,如果多线程共享的数据是基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。
比如java.lang.String类的对象实例,它是典型的不可变对象,用户调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。
在Java类库API中符合不可变要求的类型,除了String之外,常用的还有枚举类型及java.lang.Number的部分子类,如Long和Double等包装类型、BigInteger和BigDecimal等大数据类型。但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的。

绝对线程安全

一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。在JavaAPI中标注自己是线程安全的类,大多数都不是绝对的线程安全。
比如常用的Vector、Collections.synchronizedCollection()这些在调用端不通过某些同步处理,有时候也会出现线程不安全的情况,比如:

1
2
3
4
// 如果有其他多个线程在同时调用vector的remove()、和add(),这个时候用以下的for可能会出现数组下标越界异常
for(int i = 0; i < vector.size(); i++){
vector.get(i);
}

相对线程安全

在Java中,大部分线程安全的类都是这种类型。

线程兼容

平常说一个类不是线程安全的,通常就是指这种情况。

线程对立

一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险。假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是这个原因,suspend()和resume()方法都已经被声明废弃了。

线程安全实现的方法

互斥同步

synchronized

互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一, 而在执行monitorexit指 令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
使用synchronized要注意的地方:

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

如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长,因此才说,synchronized是Java语言中一个重量级的操作,必要的情况下才使用这种操作。

可重入锁(ReentrantLock)

ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁,这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程,或者叫乐观锁。

CAS指令

非阻塞同步得益于现代处理器比较并交换(Compare-and-Swap)指令的诞生。
CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
直到JDK 9之后,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。

CAS操作的”ABA问题”

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?
这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”。

无同步方案

可重入代码(Reentrant Code)

可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。
如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求(比如常见的静态工具类的常用方法)。

线程本地存储(Thread Local Storage)

把共享数据的可见范围限制在同一个线程之内,就无须同步也能保证线程之间不出现数据争用的问题。
比如大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,
可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,
ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

锁优化

自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,共享数据的锁定状态只会持续很短的一段时间,很多时候为了这段时间去挂起和恢复线程并不值得。
当后面的线程暂时获取不到锁时,可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源。
因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,可以使用参数-XX:PreBlockSpin来自行更改。

在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善。

锁消除

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

锁粗化

原则上,推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那么同样的代码存在某些阶段或一部分场景没有过多的线程竞争(甚至完全没有的情况),那么频繁地进行互斥同步操作也会导致不必要的性能损耗。
虚拟机探测到有这样零碎的操作都对同一个对象加锁,会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

轻量级锁

HotSpot虚拟机的对象头(Object Header)分为两部分:

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。
  • 另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

Mark Word被设计成非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间,32位HotSpot兑现头的”Mark Word”详细结构图:

轻量级锁的工作过程

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word), 这时候线程堆栈与对象头的状态:

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图:

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

轻量级锁的解锁过程

解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁 vs 重量级锁

轻量级锁能提升程序同步性能的依据是对于绝大部分的锁,在整个同步周期内都是不存在竞争的这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。

偏向锁

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
“偏”的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

偏向锁启用参数-XX: +UseBiasedLocking

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”) 或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图:

偏向锁与哈希码的冲突之处

一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第
一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。

当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置。

因此,当对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。有时候使用参数`-XX: -UseBiasedLocking1来禁止偏向锁优化反而可以提升性能。

HotSpot参数大全

-Xmx

-Xms

-Xmn

分配给Minor的大小

-XX:+PrintGCDetails

告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

-verbose:**

-verbose:class

查看加载的类的情况。

–verbose:gc

打印出gc相关的信息;该信息不够高全面,等同于-XX:+PrintGC。其实只要设置-XX:+PrintGCDetails就会自动带上-verbose:gc-XX:+PrintGC

–verbose:jni

-XX:PretenureSizeThreshold

指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

-XX:+UseSerialGC

select the serial collector with the option -XX:+UseSerialGC.

-XX:MaxTenuringThreshold

设置对象晋升老年代的年龄阈值。

-XX:+HeapDumpOnOutOfMemoryError

可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件,又或者在Linux系统下通过kill -3 pid命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。

-Xverify:none

如果程序运行的全部代码(包括自已编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify: none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

-Xint

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。

-Xcomp

强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

可以通过虚拟机的“-version”命令的输出结果显示出这三种模式(mixed mode、interpreted mode、compiled mode)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
└─(14:35:28)──> java -version
java version "11.0.8" 2020-07-14 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.8+10-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.8+10-LTS, mixed mode)

└─(14:35:28)──> java -Xint -version
java version "11.0.8" 2020-07-14 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.8+10-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.8+10-LTS, interpreted mode)

└─(14:35:28)──> java -Xcomp -version
java version "11.0.8" 2020-07-14 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.8+10-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.8+10-LTS, compiled mode)

-XX:+AlwaysAtomicAccesses

JDK 9起,HotSpot增加了一个实验性的参数-XX: +AlwaysAtomicAccesses (这是JEP 188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。

-XX:PreBlockSpin

设置自旋次数限制,自旋次数的默认值是十次。

-XX: +UseBiasedLocking

偏向锁开关参数