0%

深入理解JVM虚拟机笔记

本文是《深入理解JVM虚拟机》的读书笔记和摘要

内存管理

运行时数据区域

根据 Java1.7 版本的虚拟机规范,Java虚拟机包括以下几个运行时数据区。

一、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等等都依赖于计数器完成。

每个线程拥有独立的计数器,互相不影响,独立存储。

执行 Java 方法的时候计数器记录的是虚拟机字节码指令的地址,如果执行的是 Native 方法,那么计数器则为空(Undefined)。该内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

二、虚拟机栈

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

常规把Java内存区分为堆内存和栈内存的方法过于粗糙,实际上的区域划分更加复杂。

局部变量表存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)、和returnAddress类型(指向一条字节码指令的地址)。

64 位长度的 long 和 double 类型会占用两个局部变量空间(Slot),其余的数据类型占用一个。进入一个方法的时候,局部表量表的大小就是确定的,运行期间不会在改变。

Java 虚拟机规范中对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverFlowError异常。如果虚拟机栈可以动态扩展,扩展时无法申请足够内存,会抛出OutOfMemoryError异常。

总空间一定的情况下,局部变量表内容越多,栈帧越大,栈深度越小。进行大量递归的时候就有可能导致栈溢出。

三、本地方法栈

本地方法栈(Native Method Stack)和虚拟机栈作用很相似,两者区别是后者为 Java 方法(字节码)服务,前者则为虚拟机使用到的 Native 方法服务。有的虚拟机就干脆合二为一(Sun HotSpot虚拟机),本地方法栈可能抛出的异常也是上面那两个。

四、Java 堆

对于多数应用来说,Java 堆(Java Heap)是Java虚拟机管理的最大一块内存。Java 堆被所有线程共享,在虚拟机启动的时候创建。此内存区域的唯一目的就是存放对象实例。Java 虚拟机规范中规定所有的对象实例和数组都要在堆上分配,但是随着发展,现在变得并不这么绝对。

Java 堆内存是垃圾收集管理器管理的主要区域,所以也成为 GC 堆。从内存回收的角度来看,现代收集器都采用分代收集算法,所以 Java 堆还可以细分为:新生代和老年代。再细致一点有 Eden 空间、From Survivor 空间、To Survivor 空间。

Java 堆可以处在物理不连续的内存空间上,只要逻辑连续即可。

五、方法区

方法区(Method Area)也是所有线程共享的内存区域,用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译的代码等数据。也称作 Non-Heap 非堆内存。

Java 虚拟机规范堆方法区的限制非常宽松,可以选择不实现垃圾收集,但是这部分区域的回收确实是有必要的。

平时,说到永久带(PermGen space)的时候往往将其和方法区不加区别。这么理解在一定角度也说的过去。因为,《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。

同时,大多数用的 JVM 都是 Sun 公司的 HotSpot。在 HotSpot 上把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区。

在 JDK1.8 及以后版本,永久带被移除,新出现的元空间(Metaspace)替代了它。元空间属于 Native Memory Space

在 1.8 中,可以使用如下参数来调节方法区的大小

  • XX:MetaspaceSize 元空间初始大小
  • XX:MaxMetaspaceSize 元空间最大大小
    超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: Metadata space

六、运行时常量池

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

Java 虚拟机堆 Class 文件的每一部分格式都有严格要求,要符合要求才能被虚拟机认、装载和执行。但是对于运行时常量池,Java 虚拟机规范没有做任何细节要求。另外运行时常量池的一个重要特征就是具有动态性,运行期间可一个将新的常量放入池中 ,这种特性被开发人员利用得比较多的便是 String 类的intern()方法。

七、直接内存

直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是也被频繁使用,也有可能导致OutOfMemoryError异常出现。

在JDK1.4中新加入的NIO类,引入了基于通道与缓冲区的 I/O 模式,可以使用 Native 函数库直接分配堆外内存,然后通过存储在 Java 堆中的DirectByteBuffer对象作为内存的引用进行操作。

这部分内存不受到Java堆大小的限制,但是仍然收到本机内存空间和处理器寻址空间的限制,也有可能出现OutOfMemoryError异常。

对象创建过程

类加载

虚拟机遇到一条 new 指令的时候,会首先检查指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有,就限制性类加载过程。这个部分后续讨论。

分配内存

类加载检查完成之后,虚拟机会对新的对象分配内存。对象所需的内存大小在类加载完成之后就可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从堆内存中划分出来,这里有两种划分方式:

  • 如果内存绝对规整,分配内存就是把指针向后移动与对象大小相等的距离。这种方法叫做指针碰撞(Bump the Pointer)
  • 如果堆中的内存不是规整的,已经使用的和空闲的内存相互交错,分配的时候就需要找到一块足够大的空间划分给对象实例,并维护一个列表记录地址。

堆内存是否规整是由垃圾收集器是否带有压缩整理功能决定的。

除此之外,还需要考虑的是对象创建中线程安全的问题,假如两个线程同时移动内存指针,就有可能出现错误,解决这个问题也有两个方案:

  • 使用 CAS 搭配失败重试的方式保证更新操作的原子性。
  • 或者把内存的分配动作按照线程划分在不同的空间之中进行,即每个线程在堆中预先分配一小块内存,成为本地内存分配缓冲区(Thread Local Allocation Buffer,TLAB),虚拟机是否启用 TLAB 可以用参数-XX:+/-TLAB来设定。

初始化信息

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一操作保证了对象的实例字段在 Java 代码中可以不赋初值就直接使用。

然后,虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)中,根据虚拟机当前的运行状态不同,比如是否启用偏向锁等,对象头会有不同的设置方式。

从虚拟机的视角,一个新的对象已经产生,但是从 Java 程序的视角来看,对象创建才刚刚开始,init 方法还没执行,所有字段还都为零。所以,执行 new 指令之后会接着执行 init 方法,把对象按照程序员意愿进行初始化。

对象的内存布局

在 HotSpot 虚拟机中,对象可以分为三个区域:对象头(Header)、实例数据(Instance Date)和对齐填充(Padding)。

对象头

HotSpot 虚拟机的对象头包括两部分信息:

  • 对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据在 32 位和 64 位虚拟机上分别为 32bit 和 64bit,官方称它为“Mark Word”。当对象需要存储的运营时数据很多时,它会根据对象的状态复用自己的存储空间。
  • 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 如果对象是一个 Java 数组,那在对象头中还必须有一块记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的原数据确定 Java 对象的大小,但是从数组的原数据中却无法确定。

实例数据

实例数据是对象真正存储的有效信息,也就是在程序代码中所定义的各种类型的字段信息,无论从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序收到虚拟机分配策略参数和在源码中定义的顺序影响。

对齐填充

这部分不是必然存在,没有特殊的含义,仅仅起着占位符的作用。HotSpot VM 的自动内存管理系统要求对象其实地址必须是8字节的整数倍,因此当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。

对象的访问定位

对象建立之后,Java 程序需要通过栈上的 reference 数据来操作堆上的具体内容。由于 reference 类型在 Java 虚拟机规范中之规定了一个指向对象的引用,并没有定义这个引用应该如何定位、访问堆中对象的具体位置,所以对象访问方式取决于虚拟机如何实现,当前主流有使用句柄和直接指针两种。

  • 使用句柄访问的话,Java 堆中会单独划分一部分区域作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

  • 如果使用直接指针访问,那么 Java 对对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。

这两种方式各有优势,使用句柄的好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

使用直接指针的最大好处是速度更快,节省了一次指针定位的开销。HotSpot 使用的是第二种方式。

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

概述

垃圾收集出现的时间远比 Java 要早,从出现起,垃圾收集就需要考虑三个问题:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

在 Java 内存运行时区域里,程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出有条不紊的进行出栈和入栈操作。每个栈帧中分配的内存基本是在类结构确定下来的时候就是已知的。Java 堆和方法区不一样,只有在运行时才知道创建那些对象,所以这部分内存的分配和回收都是动态的,垃圾收集器所关注的也是这部分内存。

哪些内存需要回收

引用计数器法

引用计数器法是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1,引用失效的时候,计数器 -1,任何时刻计数器为 0 的对象就不可能再被使用。

主流的 Java 虚拟机中没有选用这个方法来管理内存的,最主要的原因是很难解决对象循环引用的问题。

可达性分析

主流的商用程序语言的主流实现中,都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路是通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称之为引用链(Reference Chain),当一个对象到达 GC Roots 没有任何引用链相连时,证明此对象不可用。

引用类型

无论通过计数器,还是通过可达性分析,判断对象是否存活都与“引用”有关。在 JDK1.2 之后,Java对引用状态进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用就是指在代码中普遍存在的,类似于Object obj=new Object()这类引用,只要强引用还存在,垃圾收集器就永远不会回收被引用的对象。
  • 软引用用来描述一些还有用但是不必须的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之内进行二次回收。如果这次回收之后还是没有足够内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述非必须对象达到,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾收集器工作的时候,无论当前内存是否充足,都会回收掉被弱引用关联的对象。
  • 虚引用也称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是在这个对象被垃圾回收的时候收到一个系统通知。

生存还是死亡

即使在可达性分析算法中不可达的对象,也不是必定被回收,他们暂时处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程:第一次在可达性分析中发现没有引用链,会被标记并且进行筛选,筛选条件条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”。

如果有必要执行,对象会被放置在一个叫 F-Queue 的队列中,稍后由一个虚拟机建立的、低优先级的 Finalizer 线程去执行。这个“执行”是指虚拟机触发这个方法,但是不承诺等待执行完毕。finalize()方法是对象逃脱死亡的最后一次机会,如果对象要在其中拯救自己,那么与任何一个引用链上的对象建立关联即可。如果建立的关联,比如将自己赋值给了某个类变量,那么第二次标记的时候就会被移除“即将回收”的集合。

垃圾收集算法

由于垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法有各不相同,所以只介绍几种算法的思想和发展过程。

标记-清除算法

最基础的算法是“标记-清除”(Mark-Sweep)算法,如名字一样,算法分为“标记”和“清除”两个阶段:首先标记需要回收的对象,然后统一回收。他是最基础的收集算法,但是主要有两点不足:

  • 效率问题,标记和清除效率都不高
  • 空间问题,标记清除之后会有大量的不连续内存碎片,空间碎片太多导致后续分配大对象内存的时候,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。

复制算法

复制算法提高了效率,他可以将内存按照容量划分为大小相等的两块,每次使用其中一块。当一块的内存用完之后,把存活的对象移动到另一块,然后把已经使用的空间一次性清理掉。这个方式实现简单运行高效,但是代价是内存缩小为原来的一半。

现在的商业虚拟机都是采用这种收集算法来回收新生代,由于 98% 的对象都是“朝生夕死”的,所以不需要 1:1 的比例划分内存空间,而是划分一块较大的 Eden 和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。回收的时候,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 中,然后清理掉 Eden 和使用的 Survivor 空间。HotSpot 虚拟机中默认的 Eden和 Survivor 空间比例划分是 8:1。但是,98% 的对象可回收只是一般场景下的数据,我们不能保证每次回收都是这样,所以当 Survivor 空间不够使用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

当一块 Survivor 空间中没有足够的空间存放上一次新生代收集下来的存活对象的时候,这些对象会直接通过分配担保机制进入老年代。

标记-整理算法

复制收集算法在对象存活率较高的时候就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费 50% 的空间,就需要额外空间进行担保,所以老年代不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”算法,标记过程与“标记-清除”一样,但是后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当代商业虚拟机的垃圾收集都是采用分代收集(Generational Collection)算法,根据对象存活周期将不同内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集都有大量的对象死去,只有少量存活,那就选用复制算法。而老年代存活率高、没有额外空间进行担保,就必须使用“标记-清理”或者“标记-整理”算法来回收。

HotSpot算法实现

枚举根节点

可以作为 GC Roots 的节点主要在全局的引用(例如常量或者静态属性)与执行上下文(栈帧中的本地变量表)中,如果逐个检查里面的引用,会耗费很多时间。

另外,可达性分析对执行的敏感还体现在 GC 的停顿上,这项分析工作必须在能确保一致性的快照中进行,不可以出现分析过程中对象还在不断变化的情况。所以这导致 GC 进行时需要停顿所有的Java执行线程(Sun将这个事情称为“Stop The Word”)

由于目前主流的 Java 虚拟机使用的都是准确式 GC,所以当执行系统停顿下来之后,不需要一个不漏的检查玩所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用。在 HotSpot 的实现中,是通过使用一组称为 OopMap 的数据接口来达到这个目的。在类加载完成的时候,HotSpo t就把对象内偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下帧和寄存器中哪些位置是引用的。

安全点

在 OopMap 的帮助下,HotSpot 可以快速的完成GC Root的枚举,但是随着引用变化,不可能每次指令都生成新的 OopMap。实际上只有“特定的位置”才会暂停开始 GC,这个位置叫做安全点(Safepoint)。Safepoint 的选定既不能太少以至于 GC 等待时间过长,也不能太频繁以至于增大运行时负荷。

对于 Safepoint 另一个需要考虑的问题是如何在 GC 发生时让所有线程都跑在最近的安全点上再停下来。这里有两个方案:

  • 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动配合,GC 发生的时候先中断全部线程,如果发现有的线程中断地方不在安全点上,就恢复线程,让他跑到安全点上。现在几乎没有虚拟机实现抢先式中断来暂停线程。
  • 主动式中断(Voluntary Suspension):当 GC 需要中断线程的时候,不直接对线程操作,而是仅仅设置一个标志,各个线程主动轮询这个标志,发现标志为真的时候主动中断挂起。轮讯标志的地方和安全点是重合的。

安全区域

程序执行的时候,通过 Safepoint 可以解决如何进入 GC 的问题,但是程序“不执行”的时候呐?比如线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法相应 JVM 的中断请求,JVM 也不太可能等待线程被重新分配 CPU 时间。对于这种情况,需要安全区域(Safe Region)来解决。

安全区域是指在一段代码中,引用关系不会发生变化,在这个区域中的任何位置进行 GC 都是安全的。当线程执行到 Safe Region 中的代码时,会标识自己已经进入了 Safe Region,这样发起 GC 的时候就不用管进入 Safe Region 状态的线程了。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

常见的垃圾收集器有以下几种:

他们分别适用于不同分代,连线说明可以配合使用。

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Serial Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Serial Old(老年代)

jdk1.9 默认垃圾收集器G1

jdk10 默认垃圾收集器G1

Serial 收集器

Serial 收集器是最基本、历史最悠久的收集器。它是一个单线程收集器,不仅仅是说只是用一个 CPU 进行垃圾收集工作,更重要的是进行垃圾收集的时候,必须暂停 其他所有工作线程直到收集结束。

Serial 依然是 Client 模式下新生代默认的收集器,他也有着优于其他收集器的地方:简单而高效(单线程环境下)。

ParNew 收集器

ParNew 其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样。

ParNew 是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,有一个重要的原因是除了 Serial 收集器外,它是唯一能与 CMS 收集器配合工作。ParNew 在单 CPU 环境中不会比 Serial 有更好的效果,甚至由于线程切换的开销可能性能更低。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,也是使用复制算法的收集器,又是并行的多线程收集器。

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间和总的消耗时间的比值。虚拟机总共运行了 100 分钟,99 分钟用来运行代码,1 分钟垃圾收集,那么吞吐量就是 99%。

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

MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能的保证内存回收花费的时间不超过设定值。但是这个值不是越小越好,GC 停顿时间缩短是以牺牲吞吐量和新生代空间换取的,停顿时间降下来很可能吞吐量也下降了。

GCTimeRatio参数的值应当是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,他同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是给 Client 模式下的虚拟机使用。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以最短回收停顿时间为目标的收集器。从名字就可以看出,CMS 收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几个收集器来说更复杂一点,整个过程分为 4 个步骤:

  • 初始标记(initial mark)
  • 并发标记(concurrent mark)
  • 重新标记(remark)
  • 并发清除(concurrent sweep)

其中,初始标记和重新标记仍然需要“Stop The World”。初始标记仅仅标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段是进行 GC Roots Tracing 的过程,而重新标记则是为了修正并发标记期间用户程序继续运作而导致的标记变动,这个阶段的停顿时间一般会比初始阶段稍长,但是远比并发标记阶段耗时短。

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

但是,CMS 同样有以下明显的缺点:

  • CMS 收集器对 CPU 资源非常敏感,默认启动了(CPU数量+3)/4个线程执行回收,也就是当 CPU 在4个以上并发回收时收集线程占用不少于25%的CPU资源,并且随着 CPU 数量增加而下降。CPU 数量少的时候对用户的影响就很大了。

  • CMS 无法处理浮动垃圾(Floating Garbage),可能出现Concurrent Mode Failure失败而导致另一个 Full GC 产生。即 CMS 在并发清除过程中产生的新的垃圾,只能在下一次GC时再处理。

  • 最后一个缺点,由于 CMS 基于“标记-清除”算法实现,意味着收集结束可能会有大量的空间碎片产生。空间碎片过多会对大内存分配产生麻烦,导致分配内存的时候不得不提前触发 Full GC。

G1 收集器

G1 是 JDK9 版本之后JVM默认的垃圾收集器,它具有以下特点:

  • 并行和并发:G1 可以充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop The World 停顿的时间。
  • 分代收集:分代概念仍然在 G1 中保留,但是 G1 可以不需要与其他收集器配合独自管理整个 GC 堆。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体上看是基于“标记-整理”算法实现,但是从局部(两个Region)来看是基于“复制”算法实现的。无论如何,这两种算法一位置 G1 运作期间不会产生内存碎片,收集完成后可以提供规整可用的内存。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿之外,还能建立可预测的停顿时间模型,让使用者明确指定在一个长度为 M 毫秒的时间片断内,消耗在垃圾收集上的时间不超过 N 毫秒。

G1 之前的收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆内存的布局就与其他收集器有很大差别,他将整个 Java 堆划分为多个大小相等的独立区域(Region)虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离,都是一部分 Region(不需要连续)的集合。

G1 收集器之所以能建立可预测的时间模型,因为它有计划地避免在整个Java堆中进行全区域的垃圾收集。G1 追踪各个 Region 里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的时间优先回收价值最大的 Region。

在 G1 收集器中,Region 之间的对象应用以及其他收集器中的新生代及老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序对 Reference 类型的数据进行写操作的时候,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中,如果是,就通过 CardTable 把相关引用信息记录到被引用的对象所属的 Region 的 Remembered Set 中。内存回收的时候,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

除了维护 Remembered Set 的操作,G1 收集器的运作大致可以分为以下几个步骤:

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

可以看出这几个步骤的运作过程和CMS有很多相似之处。初始标记阶段只是标记一下 GC Roots 能直接关联到的对象,并且修改TAMS(Next Top at Mark Star)的值,让下一阶段用户程序并发运行的时候,能在正确可用的 Region 中创建对象,这个阶段需要停顿线程,但是耗时很短。并发标记阶段是从 GC Roots 开始进行可达性分析,这个阶段耗时较长但是可以和用户程序并发执行。最终标记阶段则是修正并发标记阶段产生的标记变动,合并到 Remembered Set 中。这个阶段需要停顿线程,但是可以并行执行。最后筛选阶段首先对各个 Region 的回收价值和成本排序,根据用户指定的 GC 停顿时间来制定回收计划。

GC日志

一般情况可以通过两种方式来获取 GC 日志,一种是使用命令动态查看,一种是在容器中设置相关参数打印 GC 日志。

可以通过 jstat 命令查看当前正在运行的 Java 进程的 GC 状态,命令如下:

1
jstat -gc java进程号 毫秒单位的时间间隔

结果含义:

S0C:年轻代中第一个survivor(幸存区)的容量 (字节) 
S1C:年轻代中第二个survivor(幸存区)的容量 (字节) 
S0U:年轻代中第一个survivor(幸存区)目前已使用空间 (字节) 
S1U:年轻代中第二个survivor(幸存区)目前已使用空间 (字节) 
EC:年轻代中Eden(伊甸园)的容量 (字节) 
EU:年轻代中Eden(伊甸园)目前已使用空间 (字节) 
OC:Old代的容量 (字节) 
OU:Old代目前已使用空间 (字节) 
PC:Perm(持久代)的容量 (字节) 
PU:Perm(持久代)目前已使用空间 (字节) 
YGC:从应用程序启动到采样时年轻代中gc次数 
YGCT:从应用程序启动到采样时年轻代中gc所用时间(s) 
FGC:从应用程序启动到采样时old代(全gc)gc次数 
FGCT:从应用程序启动到采样时old代(全gc)gc所用时间(s) 
GCT:从应用程序启动到采样时gc用的总时间(s) 
NGCMN:年轻代(young)中初始化(最小)的大小 (字节) 
NGCMX:年轻代(young)的最大容量 (字节) 
NGC:年轻代(young)中当前的容量 (字节) 
OGCMN:old代中初始化(最小)的大小 (字节) 
OGCMX:old代的最大容量 (字节) 
OGC:old代当前新生成的容量 (字节) 
PGCMN:perm代中初始化(最小)的大小 (字节) 
PGCMX:perm代的最大容量 (字节)   
PGC:perm代当前新生成的容量 (字节) 
S0:年轻代中第一个survivor(幸存区)已使用的占当前容量百分比 
S1:年轻代中第二个survivor(幸存区)已使用的占当前容量百分比 
E:年轻代中Eden(伊甸园)已使用的占当前容量百分比 
O:old代已使用的占当前容量百分比 
P:perm代已使用的占当前容量百分比 
S0CMX:年轻代中第一个survivor(幸存区)的最大容量 (字节) 
S1CMX :年轻代中第二个survivor(幸存区)的最大容量 (字节) 
ECMX:年轻代中Eden(伊甸园)的最大容量 (字节) 
DSS:当前需要survivor(幸存区)的容量 (字节)(Eden区已满) 
TT: 持有次数限制 
MTT : 最大持有次数限制 

GC 参数

JVM 的 GC 日志的主要参数包括如下几个:

  • -XX:+PrintGC 输出GC日志
  • -XX:+PrintGCDetails 输出GC的详细日志
  • -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
  • -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2017-09-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

在生产环境中,根据需要配置相应的参数来监控JVM运行情况

内存分配和回收策略

Java 的内存管理可以归结为内存的自动分配和自动回收,上面讲了内存回收的策略,下面再说一下内存分配。

对象优先在Eden分配

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

虚拟机提供了-XX:+PrintGCDetail这个参数,告诉虚拟机在发生垃圾内存收集行为的时候打印内存回首日志,并且在进程退出的时候输出当前内存各个区域的分配情况。

大对象直接进入老年代

所谓大的对象是指需要大量连续空间的 Java 对象,最典型的是很长的字符串和数组。

虚拟机提供了-XX:PretenureSizeThreshold参数,另大于这个设置值的对象直接在老年代分配。目的是避免 Eden 区及两个 Survivor 区之间大量的内存复制。

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

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

动态对象年龄判定

为了适应不同程序的内存状况,不是只有对象年龄大于 MaxTenuringThreshold 才能晋升老年代。如果 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有的对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于晋升到老年代对象的平均大小。如果大于就尝试进行一次 Minor GC,尽管这次 GC 是有风险的。如果小于,或者不允许冒险,就改为进行一次 Full GC。

虚拟机性能监控和故障处理

命令行工具

名称 主要作用
jps JVM Process Status Tool,显示制定系统内所有的 HotSpot 虚拟机进程
jstat JVM Statistics Monitoring Tool,用于收集 HotSpot 虚拟机各方面的运行数据
jinfo Configuration Info for Java,显示虚拟机配置信息
jmap Memory Map for Java,生成虚拟机的内存转储快照(heapdump文件)
jhat JVM Heap Dump Browser,用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果
jstack Stack Track for Java,显示虚拟机的线程快照

jps:虚拟机进程状况工具

jps 除了名字像 ps 命令之外,功能也很类似:可以列出正在运行的虚拟机进程,并且显示虚拟机执行主类(Main Class,Main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。虽然功能简单,但是它是最常用的工具,因为其他 JDK 工具需要输入它查询出来的 LVMID 来确定监控的是哪个虚拟机进程。在本地虚拟机进程来说,LVMID 和 ps 命令或者 Windows 中任务管理器中查到的进程号是一致的。但是如果启动了多个虚拟机进程,就要依赖 jps 命令根据主类来区分了。

选项 作用
-q 只输出 LVMID,省略主类的名称
-m 输出虚拟机级进程启动的时候传给主类 main() 函数的参数
-l 输出主类的全名,如果进程执行的是 Jar 包,输出 Jar 包路径
-v 输出虚拟机进程启动时 JVM 参数

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

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

jstat 命令格式为:

1
jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

对于命令格式中的 VMID 与 LVMID 需要说明的是,如果是本地虚拟机进程,VMID 与 LVMID 是一致的,如果远程的虚拟机进程, VMID 格式应当是:

1
[protocol]:[//]lvmid[@hostname[:port]/servername]

参数 interval 和 count 代表查询间隔和次数,如果省略这两个参数,说明只查询一次。

选项 option 代表着用户希望查询的虚拟机信息,主要分为 3 类:类装载、垃圾收集、运行期编译状况。

选项 作用
-class 监视类装载、卸载数量、总空间以及类装载所耗费的时间
-gc 监视Java堆状况,包括 Eden 区、两个 Survivor 区、老年代、永久带等的容量、已用空间、GC 时间合计等信息
-gccapacity 监视内容与-gc基本相同,但是输出主要关注 Java 堆各个区域使用的最大、最小空间
-gcutil 监视内容与-gc基本相同,但是输出主要关注已使用空间占总空间的百分比
-gccause -gcutil功能一样,但是会额外输出导致上一次 GC 产生的原因
-gcnew 监视新生代 GC 状况
-gcnewcapacity 监视内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间
-gcold 监视老年代 GC 状况
-gcoldcapacity 监视内容与-gcold基本相同,输出主要关注使用到的最大、最小空间
-gcpermcapaciry 输出到永久带使用到的最大、最小空间。注意,由于永久带在 JDK1.8 之后已经被元空间替代,所以 1.8 之后都没有了这个选项
-compiler 输出 JIT 编译器编译过的方法、耗时等信息
-printcompilation 输出已经被 JIT 编译的方法

jinfo:Java 配置信息工具

jinfo(Configuration Info for Java)的作用是实时地查看和调整虚拟机各项参数。

用法如下:

1
Usage:
2
    jinfo [option] <pid>
3
        (to connect to running process)
4
    jinfo [option] <executable <core>
5
        (to connect to a core file)
6
    jinfo [option] [server_id@]<remote server IP or hostname>
7
        (to connect to remote debug server)
8
9
where <option> is one of:
10
    -flag <name>         to print the value of the named VM flag
11
    -flag [+|-]<name>    to enable or disable the named VM flag
12
    -flag <name>=<value> to set the named VM flag to the given value
13
    -flags               to print VM flags
14
    -sysprops            to print Java system properties
15
    <no option>          to print both of the above
16
    -h | -help           to print this help message

比如使用-flag查看 JVM 参数
jinfo -flag MaxMetaspaceSize 18348,得到结果-XX:MaxMetaspaceSize=536870912,即MaxMetaspaceSize为512M
jinfo -flag ThreadStackSize 18348,得到结果-XX:ThreadStackSize=256,即Xss为256K

jinfo 也可以调整 JVM 参数:

如果是布尔类型的 JVM 参数:jinfo -flag [+|-]<name> PID,enable or disable the named VM flag
如果是数字/字符串类型的 JVM 参数:jinfo -flag <name>=<value> PID,to set the named VM flag to the given value

那么怎么知道有哪些 JVM 参数可以动态修改呐?可以用下面这个命令:

1
Linux环境:java -XX:+PrintFlagsInitial | grep manageable
2
Window环境:java -XX:+PrintFlagsInitial | findstr manageable

jmap:Java 内存映像工具

jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为 heapdump 或者 dump 文件)。如果不适用 jmap 命令,想要获取 Java 堆转储快照,还有一些比较暴力的手段:比如使用-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在 OOM 异常出现之后自动生成dump文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[ctrl]+[Break]键让虚拟机生成 dump 文件,又或者在 Linux 系统下通过Kill -3命令发送进程退出信号“吓唬”一下虚拟机,也能拿到 dump 文件。

除此之外,jmap 还可以查询 finalize 执行队列,Java 堆和永久带的详细信息,如空间使用率、当前使用的哪种收集器等。

jmap 命令格式:

jmap [option] vmid

option 选项的合法值与具体含义如下:

选项 作用
-dump 生成 Java 堆转储快照。格式为:-dump:[live, ]format=b,files=<filename>,其中 live 子参数说明是否只 dump 出存活的对象
-finalizerinfo 显示在 F-Queue 中等待 finalizer 线程执行 finalizer 方法的对象。只在 Linux/Solaris 平台下有效
-heap 显示 Java 堆详细信息,如使用哪种回收器、参数配置、分代状况等。只在 Linux/Solaris 平台下有效。
-histo 显示堆中对象统计信息,包括类、实例数量、合计容量
-permstat 以 ClassLoader 为统计口径显示永久带内存状态。只在 Linux/Solaris 平台下有效
-F 当虚拟机进程没有对-dump选项响应时,可以使用这个选项强制生成 dump 快照。只在 Linux/Solaris 平台下有效
-clstats 打印类加载器的统计信息
-J 传递参数给 jmap 的 JVM

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

Sun JDK 提供 jhat(JVM Heap Analysis Tool)命令与 jmap 搭配使用,来分析 jmap 生成的堆转储快照。jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 文件的分析成果后,可以在浏览器中查看。

执行命令jhatp 文件名,屏幕显示Server is ready的提示后,打开浏览器 localhost 的 7000 端口就可以看到分析结果。

jstack:Java 堆栈跟踪工具

jstack(Stack Track for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为 threaddump 或者 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成快照的主要目的是定位线程出现长时间停顿的原因,如线程死锁、死循环、请求外部资源导致的长时间等待等。

jstack 命令的格式:

jstack [option] vmid

选项 作用
-F 当正常输出的请求不被相应时,强制输出线程堆栈
-l 除堆栈外,显示关于锁的附加信息
-m 如果调用本地方法的话,可以显示 C/C++ 的堆栈

java.lang.Thread类的getAllStackTraces()方法可以获取所有线程的StackTraceElement对象。使用这个方法可以用简单的几行代码完成 jstack 的大部分功能。

Jconsole:Java 监视与管理控制台

JConsole(Java Monitoring and Management Console)是一种基于JMX 的可视化管理工具。

通过 JDK/bin 目录下的jsonsole应用就可以启动 JConsole,启动后将自动搜索本机运行的所有虚拟机进程。双击选择一个进城之后即可开始监控。

监控页面一目了然,不需要做太多说明。JConsole 可以监控包括堆内存、线程、类、CPU 等各个使用情况,

VisualVM:多合一故障处理工具

VisualVM(All-in-One Java Troubleshooting Tool)是目前为止随 JDK 发布的功能最强大的故障处理工具,并且在可预见的未来都是官方主力发展的虚拟机故障处理工具。

VisualVM 可以做到:

  • 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。
  • 监视应用程序的 CPU、GC、堆、方法去以及线程的信息(jps、jstack)。
  • dump 以及分析堆转储快照(jamp、jhat)。
  • 方法记得性能运行性能分析,找出被调用最多、运行时间最长的方法。
  • 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送给开发者处进行 Bug 反馈。
  • 其他 Plugins

Intellij IDEA 已经集成了 VisualVM,详细的使用方法先留个坑,后续再填。

类文件结构

Class 文件的结构

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

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

类加载机制

Class 文件中描述的各种信息,最终都要加载到虚拟机之中才能运行和使用。而虚拟机如何加载这些 Class 文件?Class 文件中的信息进入到虚拟机后会发生什么变化?这些都是本章需要讲解的内容。

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,就是虚拟机的类加载机制。在 Java 语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

共七个阶段。其中验证、准备、解析三个部分统称连接(Linking),这七个阶段的发生顺序如图所示:

在图中,加载、验证、准备、初始化和卸载这 5 个阶段是顺序的,类的加载必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。注意在,这里写的是“开始”,而不是按部就班的“进行”或者“完成”。强调这点的原因是这些阶段通常都是交叉混合进行的,通常会在一个阶段执行的过程中调用、激活下一个阶段。

虚拟机规范严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  • 遇到 new、getstatic、putstatic、invokestatic这 4 条字节码指令的时候,如果类没有进行过初始化,则先需要触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用new关键字实例化对象的时候、读取或者设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候。如果类没有进行初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则先需要出发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这 6 种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定于:“有且只有”,这 6 种场景中的行为成为对一个类进行主动引用。除此之外,所有的引用类的方式都不会触发初始化,称为被动引用。

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类中定义的静态字段,只会出发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中尚未明确规定,这点取决于虚拟机的具体实现。对于 Sun HotSpot 虚拟机来说,可以通过-XX:TraceClassLoading参数观察到此操作会导致子类的加载。

使用类的常亮也不会对这个类进行初始化,而是在编译阶段通过常量传播优化,将该常量存储到使用这个常亮的类的常量池中,后续对该常量的调用都会转化为该类堆自身常量池的引用。

接口的加载过程和类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这一点与类是一致的。接口中不能使用static{}代码块,但是编译期仍然会为接口生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。接口与类真正有区别的是前面所讲说的 5 种“有且只有”需要开始初始化场景中的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正用到父接口的时候(如引用接口定义的常亮)才会初始化。

类加载的过程

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

加载

加载是“类加载”(Class Loading)的一个阶段,在加载阶段,虚拟机需要完成以下 3 件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

虚拟机规范的这三点都不具体,因此虚拟机实现和具体引用的灵活度都是相当大的。所以出现过很多种加载方式:

  • 从 ZIP 包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用就是 Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是利用ProxyGenerator.generateProxyClass来为特定接口生成形式为$Proxy的代理类二进制字节流。
  • 从其他文件中读取,典型场景是 JSP 引用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库中读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群见的分发。

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

对于数组类来说情况有所不同,数组类本身不是通过类加载器区创建,它是由 Java 虚拟机直接创建的。但是数组类和类加载器仍有很密切的关系,因为数组类的元素类型(Element Type,值得是数组去掉所有维度的类型)最终仍要靠类加载去去创建,一个数组类创建过程就遵循以下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用上面的加载过程去加载这个组件类型,数组将在加载该组件类型的类加载器的类空间上被标识。
  • 如果数组的组件类型不是引用类型(如int[]数组),Java 虚拟机将会把数组标记为与引导类加载器关联。
  • 数组的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public

加载阶段完成后,虚拟机外部的二进制字节流文件就按照虚拟机所需的格式存储在方法去之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确在 Java 堆中,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件中的字节流中包含的信息符合虚拟机要求,并且不会危害虚拟机自身的安全。

Java 语言本身是相对安全的语言,使用纯粹的 Java 代码无法做到诸如访问数组边界以外的数据、将对象转型为它未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器会拒绝编译。验证阶段大致上会完成以下 4 个阶段的检验动作:

  • 文件格式验证:主要验证字节流是否符合 Class 文件格式的规范,并且能够被当前版本的虚拟机处理。
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
  • 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行教研分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的,但是如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
  • 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生就。符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出java.lang.IncompatibleClassChangeError异常的子类。

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码已经被反复使用和验证过,那么在实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。假设一个类变量的定义为:

1
public static int value=123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这个时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。下表列出了 Java 中所有基本数据类型的零值。

有一些特殊情况:如果类字段的字段属性表中存在ConstantValue属性,那么准备阶段变量 value 就会被初始化为ConstantValue属性所指的值,假设上面类变量 value 的定义变为:

1
public static final int value=123;

编译时 Javac 将会为 value 生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将 value 赋值为 123。

解析

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

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。
  • 直接饮用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,如果有了直接饮用,那引用的目标一定已经在内存中存在了。

初始化

类初始化阶段是加载过程的最后一步,在前面的过程中,除了加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序定制的主观计划区初始化类变量和其他字段,或者从另外一个角度表达:初始化阶段是执行类构造器<clinit>()方法的过程。

  • <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序所决定。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
  • <clinit>()方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object
  • 由于父类的<clinit>()方法先执行,所以意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
  • <clinit>()方法对于类或借口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会先生成<clinit>()方法。但是接口与类不同的是,执行接口的<clinit>()方法不需要先执行父类的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁、同步。如果多个线程去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

类加载器

类与类加载器

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

双亲委派模型

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

从 Java 开发人员的角度来看,类加载器还可以划分的更细致:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机是别的类库加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader实现。由于这个类加载器是 ClassLoader 中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器。

我们的应用程序都是由这 3 种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如图所示:

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

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器需要加载这个类,最终都是委派个处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器去自行加载的话,如果用户自己编写了一个java.lang.Object的类,也放在程序的ClassPath下,那么系统就会出现多个不同的Object类,Java 类型基础的行为也不能保证了。

双亲委派模型对于保证 Java 程序的稳定运行非常重要,但是实现却很简单,实现双亲委派模型的代码都集中在java.lang.ClassLoaderloadClass()方法中,逻辑清晰易懂:先检查是否已经被加载过,如果没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

双亲委派模型

上文提到过双亲委派模型并不是一个具有强制性约束的模型,而是 Java 设计者推荐给开发者们的类加载器实现方式。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到 Java 模块化出现为止,双亲委派模型主要出现过 3 次较大规模“被破坏”的情况。

  • 第一次破坏是由于双亲委派机制是 JDK 1.2 才出现的,所以面对已经存在的用户自定义类加载器的代码,需要进行兼容,所以进行了妥协。
  • 第二次被破坏是由这个模型自身的缺陷导致的,有时候基础类型需要调用用户编写的类,比如 JNDI。JNDI 服务引入线程上下文类加载器加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
  • 第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(HotDeployment)等。

参考目录:

jinfo命令详解