深入理解java虚拟机学习笔记

Published: by Creative Commons Licence

  • Tags:

内存分区

JVM内存分区分为:

  • 程序计数器:存储当前线程执行的字节码地址,每个线程私有,用于实现跳转等。 如果执行的是Native方法,计数器则为空。
  • 虚拟机栈:每个方法执行时,都会创建一个栈帧,栈帧中存储了局部变量表,操作数栈,动态连接,方法出口等信息。局部变量表中一个double,long类型对象占两个slot,其余类型占一个slot。如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError,如果栈申请不到足够的内存,则会抛出OutOfMemoryError。
  • 本地方法栈:与虚拟机栈类似,只不过是为本地方法服务的。由于Java虚拟机规范没有对其进行严格规定,所以很多虚拟机直接将本地方法栈和虚拟机栈合二为一。
  • Java堆:虚拟机所管理的内存最大的一块。Java堆是线程共享的一块内存区域,此区域的唯一用处就是存放对象实例,Java中几乎所有的对象都是在这里分配内存的。如果堆无法完成实例的分配且无法进一步无法扩展,则会抛出OutOfMemoryError。
  • 方法区:类似于Java堆,是各个线程共享的一块内存区域,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 直接内存:在JDK1.4中加入了NIO类,引入了基于通道与缓冲区的IO方式,它可以使用Native方法直接分配堆外内存,从而避免在堆外内存和Java堆之间来回拷贝。直接内存受物理内存限制,当动态扩展失败时,会抛出OutOfMemoryError。

内存分配

Java的实例分配的算法一般分为两种。

  • 指针碰撞:维护一块大的内存,之后每次分配都从内存头部切出一小块内存。
  • 空闲列表:将空闲的内存块放入列表中。每次分配内存的时候就从中挑选一个足够大的内存划分给对象实例。

一般指针碰撞的方式更加高效,但是对垃圾回收算法提出了更高的要求,垃圾回收器必须带有空间压缩整理的能力,比如使用Serial、ParNew等待压缩整理过程的收集器时,系统采用的分配算法是指针碰撞;而当使用CMS这种基于清理算法的收集器时,理论上只能采用空闲列表算法。

但是由于Java是支持并发的,并发的分配对象,也就意味着我们的实例分配算法需要是线程安全的。有两种解决方案:

  • 对分配内存的动作进行同步处理(实际HotSpot的实现方案)。
  • 为每个线程分配一个小块的分配缓冲区,每次线程分配对象先从自己的缓冲区中通过指针碰撞算法进行分配。分配新的缓冲区时才需要同步操作。

注意对于空闲列表,也可以使用我们上面提到的分配线程缓冲区技术,这样大部分情况下,实际上是在用指针碰撞算法。

对象内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。

对象头中包括两类数据:

  • 对象自身的运行数据(Mark Word):哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳。这部分的长度为机器的字长相同。
  • 类型指针:指向对象所属的类实例的元数据。此外如果对象是数组,则还会记录数据的长度。

实例数据中,存储所有类中定义的字段以及父类中继承得到的字段。默认的顺序是按照字段占用空间的大小从大到小排序,在满足这个前提下,等宽类型的字段父类中的字段放在子类之前。

由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8的整数倍,对齐填充是放在对象尾部的一段起占位符作用的内存。

引用

Java程序会通过栈上的reference来操作堆上的数据,由于引用类型在《Java虚拟机规范》里面只规定了它是指向对象的引用,而没有定义如何引用,所以也取决于虚拟机的具体实现。主流的方式是句柄引用和直接指针两种。

  • 句柄访问:Java堆中会划出一块内存作为句柄池,reference中存储的是句柄地址,而句柄中存储的实际对象地址。
  • 直接指针访问:reference存储的对象在堆上的地址。

句柄访问的优势是在移动对象的时候只需要修改句柄中的数据即可,而需要操作程序栈。而直接指针访问的优势是一步到位,比句柄访问少一次内存访问,性能更加快。HotSpot中使用的是直接指针访问。

JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用,软引用、弱引用、虚引用,强度递减。

  • 一般我们的变量就是强引用,被强引用的对象是不能被回收的。
  • 软引用描述的是有用但不是必要的对象。在系统发生内存溢出前,会将这些对象列入回收范围之中进行二次回收,只有二次回收还没有足够的内存,才会p抛出内存溢出异常。
  • 弱引用用来描述那些非必须的对象,它的强度要比软引用更弱。弱引用只能存活到下一次垃圾回收发生为止。当垃圾收集器工作时,不管内存是否足够,都会把仅被弱引用所引用的对象回收掉。
  • 虚引用时最弱的引用。一个对象是否有虚引用在引用它,都不影响它的生存时间,也无法通过虚引用获得一个对象实例。虚引用的用处是在这个对象被回收的时候收到一个系统通知。

内存回收

Java栈空间会随着线程的消亡而消亡,而栈帧在方法返回后就会被销毁,因此不需要回收。

真正需要内存回收的是Java堆和方法区。哪些对象应该被回收,这只有在运行期间才能得知。垃圾回收期器关心的就是这部分内存的管理。

判断垃圾

要实现垃圾回收,垃圾回收器一定要有能力确定哪些对象是垃圾。下面是几种传统的方案:

引用计数法:为每个对象增加一个计数器,如果一个reference指向某个对象,这个对象计数器就加1,而原来对象的计数器就减少1。如果一个对象的引用为0,则它就不再可能被访问,可以作为垃圾回收。引用计数法的优势是实现简单,实时回收,但是缺点是无法解决循环引用的问题(a引用b,b引用a,这两个对象永远不会被释放)。微软的COM技术,Python语言等用的就是引用计数算法来实现对象内存回收。

可达性分析法:基于图论,一个对象可以再次被访问,当且仅当从GC根可以通过引用链抵达这个对象。可以作为GC根的对象包括:

  • 栈中的引用的对象
  • 类静态变量
  • 方法区中的常量
  • 本地方法栈中JNI引用的对象
  • 所有被同步锁持有的对象
  • 本地代码缓存
  • JMXBean、JVMTI中注册的回调

上面的GC根并不完全,还可以加入一些其它对象。比如在执行分代回收的时候,虽然我们仅清理某个内存区域,但是这个内存区域中的对象可能被其它内存区域中的对象所引用,因此我们需要将其它内存区域中的对象也加入作为GC根。

实际上即使是在可达性分析中判定不可达的对象,也不是必定会被回收的。在可达性分析中被确定为不可达的对象,它将会被第一次标记,随后进行一次筛选,如果对象覆盖了finalize方法且方法未被调用过,那么对象就会被放置在一个名字为F-Queue的队列中,之后由一个虚拟机自动建立的、低调用优先级的Finalizer线程去执行它们的finalize方法。这里说的执行是虚拟机会触发这个方法开始运行,但是不保证会等待它运行完成。稍后收集器会对F-Queue中的对象进行第二次小规模的标记,如果此时对象依旧不可达,则就会被回收,否则逃过一劫。

除了上面提到的堆空间的内存回收外,实际上方法区也会进行内存回收。《Java虚拟机规范》中并不强制要求虚拟机在方法区中实现垃圾回收。且方法区即使进行回收,一般由于回收条件的严苛,也是收效甚微的。

方法区中可以回收的内容如下:

  • 废弃的常量:如果常量不再被引用,且虚拟机中没有其它字面量引用它,就可以被回收。
  • 不再被使用的类型:类及所有派生子类的所有实例都被回收,类的类加载器被回收,java.lang.Class对象不再被引用。满足上面这三个条件,类型就可以被回收。

分代回收

当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论仅设计,它建立在两个假说上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:经历过越多次垃圾回收过程的对象越难消亡。

根据这两个假说,收集器应该将Java堆按照年龄划分为若干个区域。这样对存储对象年级较小的分区,可以更加高频的进行垃圾回收(能释放出更多的空间),而对于对象年级较大的分区,则可以低频的进行垃圾回收。

设计者一般会将Java堆至少划分为新生代、老年代。每次新生代没有被回收的对象移动到老年代中去。

由于即使进行了分代,但是可能存在跨代引用,因此即使仅回收新生代,也需要遍历所有老年代的对象,这样和全局回收差别不大。因此需要下面的假说:

  • 跨代引用假说:跨代引用相对于同代引用占极少数。

根据上面这个假说,只需要在新生代建立一个全局的数据结构(该结构称为记忆集),这个结构把老年代划分成若干个小块,并标志哪些块中存在跨带引用。之后对新生代进行回收的时候,就可以跳过大部分老年代的块,仅将少部分块中的对象加入到GC根中即可。这种方式增加了一些运行时的开销,但是比起收集时扫描整个老年代来说仍然很划算。

一般垃圾回收过程根据其回收区域分成:

  • Young GC:仅回收新生代
  • Old GC:仅回收老年代
  • Mixed GC:回收老年代和新生代
  • Full GC:回收整个Java堆和方法区

回收算法

基础的回收算法是标记-清除算法,算法分成标记和清除两个阶段。第标记阶段标记所有需要回收的对象,在清除阶段回收掉所有被标记的对象。标记-清除算法的缺点如下:

  • 执行效率低:如果需要回收的对象很多,则算法执行时间很长。
  • 内存碎片化:清除完后会产生大小内存碎片,内存碎片多会导致之后分配时无法找到足够大的内存,导致另外一次GC的发生。

标记-复制算法解决了标记-清除算法的问题。其使用一种半区复制的技术,将内存分为两个等大的块,每次仅一块被使用,另外一块闲置。当使用的那块内存被耗尽的时候,就将使用的块中的所有存活对象全部拷贝到闲置的块中,在拷贝的过程中,进行内存的整理。这样的好处是实现简单,且运行高效,但是缺点是将可用内存变为了原来的一半。

现在的商用Java虚拟机大多都采用标记-复制算法去收集新生代。

IBM曾经做法专门研究发现:新生代中的$98\%$对象熬不过第一轮收集。因此有一种更优半区复制分代策略,现在称为Appel式回收的算法:将新生代分为一个较大的Eden空间和Survivor空间,每次分配内存只使用其中的一块Eden和Survivor空间。然后在垃圾回收的时候,将Eden和在用的Survivor中的存活对象复制到另外一个Survivor块中,之后清理掉原来的Eden和Survivor空间。HotSpot中默认的Eden和Survivor的大小比为8:1,因此空间的可用率是$90\%$。

但是上面的仅仅是经验,并不能保证真的每次回收存活的对象都能被放入Survivor块而不会溢出。在这种情况下就需要依赖其它内存区域(实际上大多是老年代)进行分配担保。这样的话,无法放入一块Survivor块的对象会直接晋升到老年代中去。

标记-复制算法在对象存活率较高的时候要进行较多的复制操作,且有效空间只有原来的一半(如果使用Appel式回收,则会导致分配担保)。因此在老年代一般不能选择这种算法。

针对老年代,有一种标记-整理算法。其和标记-清理算法的区别在于完成清理后它会将存活的对象移动到内存的一端,从而去除内存碎片。

老年代有大量的存活对象,移动存活对象和更新所有引用这些对象的地方将会是很大的负担。而且这种对象移动操作需要暂停用户程序才能进行。这样的停顿被虚拟机设计者描述为Stop The World。

是否移动内存,有好处也有坏处。移动内存的好处是分配内存的时候更加简单,而移动内存的坏处是使垃圾回收需要长时间暂停用户程序。移动内存会增加对象分配回收的吞吐量,但是会增加延迟。HotSpot中关注吞吐量的Parallel Scavenge收集器就是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法。

另外还有一种折中的方法,就是采用标记-清理算法,直到内存碎片非常多的时候,才采用标记-整理算法。CMS收集器实际上就是采用这种方法。

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户程序的,因此会遇到stop the world的困扰。现在耗时最长的查找引用链的过程已经可以做到与用户程序并发执行了,但是根节点枚举还是必须在一个能保障一致性的快照中才能进行。

目前主流的Java虚拟机使用的都是准确式垃圾收集,即虚拟机有能力确定一个对象是原生类型还是引用。所以所有用户线程暂停后,不需要一个不漏地检查所有的执行上下文和全局的引用位置,虚拟机有能力直接得到哪些地方存放着对象引用。HotSpot的实现方案是使用一组叫做OopMap的数据结构。一旦某个类加载完成后,HotSpot就得知对象中每个偏移是否是引用了,在即时编译的过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用,而这些信息就能帮助垃圾回收器枚举根节点。

安全点

用OopMap可以帮助垃圾收集器快速枚举根节点,但是又出现一个问题。可以导致引用关系变化的指令非常多,如果为每个指令都生成对应的OopMap,就会需要大量的额外存储空间。实际上HotSpot并没有为每条指令都生成OopMap,只有在特定的位置记录了这些信息,这些位置称为安全点。有了安全点,也意味着垃圾回收器并非可以随机暂停用户线程的,而是只有当用户线程到达安全点才能够暂停。因此安全点不能太多,占用太多的空间,也不能太少,这样垃圾回收过程就要延迟很久。一般常数量的指令消耗不了多少时间,只有在循环和调用等跳转指令发生时才可能显著增加程序执行时间,所以只有这些指令才会产生安全点。

还有一个问题,如何在垃圾回收时让所有线程都跑到最近的安全点,之后暂停。一般有两种方案:抢先式中断和主动式中断。

  • 抢先式中断:系统先让所有用户线程中断,如果发现有用户线程的中断的地方不是安全点,就恢复这条线程让其继续执行,之后重新中断,直到线程抵达安全点。
  • 主动式中断:系统仅设置线程的标志位,而线程在会主动检查标志位,如果发现被设置了,且位于安全点,就主动中断。线程只会在安全点主动检查。

一般虚拟机采用的都是主动式中断。由于线程需要自己去轮询标志位,因此要求询问操作足够高效。HotSpot在打算暂停用户线程的时候,会将0x160100的内存页设置为不可读,而在轮询的位置增加一条读这个内存页的指令。于是线程在执行到这条指令的时候,会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现暂停。

安全区域

安全点并没有完全解决问题,我们依旧不能保证所有线程很快就能抵达某个安全点,比如一些线程可能因为一些原因被阻塞了(调用了Sleep命令或者IO阻塞)。采用主动式中断,这时候线程无作为,也就无法响应虚拟机的中断请求。对于这种情况,需要引入安全区域来解决。

安全区域是指能够确保在某一段代码之中,引用关系不会再发生变化,因此在这个区域中任意地方开始垃圾回收都是安全的。

当用户线程进入安全区域,首先会标志自己进入了安全区域,这时候虚拟机就可以不必管这些处于安全区域的线程了。当用户线程离开安全区域时,首先需要检测虚拟机是否在进行根节点枚举(或者虚拟机要求所有线程挂起),如果没有,则继续执行;否则就必须一直等待知道收到允许离开安全区的信号为止。

记忆集

记忆集是用于记录从非收集区域指向安全区域的指针集合的抽象数据结构。其具体实现是将非收集区域分成小块,并为每个块维护一个标志位,表示该块中是否有到其它内存区域的引用,这样在对某个区域进行回收操作时,可以快速过滤掉大部分不存在域外引用的块。

这样的块称作卡页,而每个卡页其标志位用某个叫做卡表的数据结构维护(bitset?)。

但是卡表什么时候被更新呢,HotSpot中通过为写命令前后增加写屏障实现的,写屏障实际上是两个切面,分别为写前屏障和写后屏障。当然这自然需要额外的开销,但是比起枚举其余所有内存区域来找GC根,这种方式还是比较合算的。

并发的可达性分析

随着Java堆中对象数目的增加,对象图的大小也就越大。要实现可达性分析,必须遍历整个对象图,因此可达性分析所花的时间也在攀升。

要想解决或降低用户的暂停时间,首先来了解为啥必须在一个能保障一致性的快照上才能进行图的遍历。我们需要引入三色分析,把对象标记成下面三种颜色之一:

  • 白色:表示对象未被垃圾回收器访问过。
  • 黑色:表示对象已经被垃圾收集器访问过了,且这个对象的所有引用都已经扫描过了。
  • 灰色:表示这个对象已经被垃圾收集器访问过了,但是这个对象至少还有一个引用没有被扫描过。

实际上将某个不可达对象标记为可达并没有什么太大问题(之后的GC会重新标记),但是如果将一个可达对象标记为不可达,那就会导致程序崩溃。

Wilson在1994年在理论上证明了,当且仅当下面两个条件同时满足时,会产生对象消失问题,即一些存活对象没有被访问到(在可达性分析中始终被标记为白色)。

  • 一个黑色对象引用了这个对象
  • 所有灰色对象到这个对象的引用被删除

因此我们只需要破坏掉其中一个条件就可以保证我们的并发安全。有两种解决方案:

  • 增量更新:如果一些黑色对象加入了到白色对象的引用,就将这些引用关系记录下来,在扫描结束后,以这些引用关系中的黑色对象为根,重新进行一次扫描。
  • 原始快照:如果一些灰色对象删除到白色对象的引用,就将这些引用关系记录下了,之后在扫描结束后,重新扫描引用关系中出现的白色对象。

注意最后执行的额外扫描需要暂停所有用户线程,不然可能过程永远停止不了。上面在引用修改的时候记录引用关系是通过写屏障实现的。在HotSpot中,CMS是基于增量更新来实现并发,而G1,Shenandoah是基于原始快照实现并发。

垃圾收集器

下面列一些经典的垃圾收集器:

  • Serial收集器:新生代收集器,基于标记-复制算法实现,单线程,执行垃圾收集之前需要Stop the world。优点是占用内存少,单线程效率高。适合CPU核心少的计算机使用。。
  • ParNew收集器:Serial收集器的多线程版本,同样执行垃圾收集之前需要Stop the world。ParNew的高光是CMS指定的新生代收集器(CMS只能作用在老年代)。
  • Parallel Scavenge收集器:新生代收集器,基于标记-复制算法实现。支持多线程,Stop the world。Parallel Scavenge的设计目标是极大化回收对象和分配对象的吞吐量(所以延迟就很高)。
  • Serial Old收集器:Serial收集器的老年代版本,基于标记-整理算法。
  • Parallel Old收集器:Parallel Scavenge收集器的老年代版本,基于标记-整理算法。
  • CMS收集器:CMS全称Concurrent Mark Sweep,基于标记-清理算法,支持多线程,可以与用户线程并行。其分为四个阶段,第一个和第三个阶段需要Stop the world。CMS的有点是延迟低,且最耗时的两个过程支持与用户线程并行。缺点是由于需要与用户线程并行,因此不能等到老年代满了才开始垃圾回收(可能用户垃圾回收需要内存被用户线程抢占完了),默认老年代花费了68%的内存就会被激活,这样会导致可能执行了非必要的GC,我们可以选择提高触发的比例阈值,但是这时候如果CMS收集器无法分配到足够的内存,就会发生并发失败,而临时启用Serial Old收集器来重新进行老年代的回收。还有一个缺点就是由于基于标记-清理算法,所以会产生很多内存碎片,一种解决方案是执行若干次垃圾回收后,执行一次Full GC减少内存碎片。
    1. 初始化标记
    2. 并发标记
    3. 重新标记
    4. 并发清除
  • G1收集器(Garbage first):G1收集器目的是建立起“停顿时间模型”的收集器。停顿时间模型的含义是指在长度为$M$的时间中,消耗在垃圾收集上的时间大概率不超过$N$。G1收集器将整个Java堆分成若干个小的Region,每个Region都的角色可以是Eden空间、Survivor空间或老年代空间。同时还有一个特殊的Humongous区域,用于存储大对象,如果一个对象占用空间比一个Region都大,它就会直接进入Humongous区域,并且G1大多数行为会将Humongous作为老年代处理。G1回收器每次执行时会根据内部计算的权值挑选权值最大的部分Region进行回收。但是由于每个Region都需要维护其余Region是否引用了这个Region中的对象,因此每个Region都有独立的卡表,这导致G1收集器会占用非常多的内存(一般是堆大小的10%~20%)。G1支持在没有Stop the world的情况下完成标记工作,其采用的解决冲突的算法是原始快照。但是G1收集器没有支持与用户线程并行完成清理工作,因为一次性只回收一部分的Region,时间一般是可控的。G1实现的是标记复制算法,每次回收的时候都会将被回收Region中的存活对象拷贝到其余的空的Region中。G1垃圾回收的过程如下:
    1. 初始化标记
    2. 并发标记
    3. 最终标记
    4. 筛选回收
  • Shenandoah收集器:类似于G1收集器,但是Shenandoah支持与用户线程并发的清理过程,其次Shenandoah默认不适用分代收集,并且Shenandoah摒弃了为每个Region维护记忆集,转而使用连接矩阵的全局数据结构来记录Region的引用关系。由于Shenandoah使用的也是标记-复制技术,且由于清理过程是与用户线程同时进行的,那么如何实现的并发呢。Shenandoah使用了Brooks Pointer,一种类似于句柄的技术。其实现是在每个对象的头部增加一个转发指针,默认指向自己。并且为了避免在复制的过程中用户的写入操作,Shenandoah使用了CAS来实现所有对转发指针的访问必须同步执行,而这部分的功能也是通过读写屏障实现的。Shenandoah收集器的优势是暂停时间相对较短。
  • ZGC收集器:目标与Shenandoah收集器类似,希望实现低暂停时间。ZGC的Region称为ZPage,具有动态创建、销毁和扩容的能力,容量分成小(容量2M,对象大小不能超过256KB)中(容量32M,对象大小不超过4MB)大(容量不固定,可以动态变化)。对对象进行标记,需要额外的空间,一般收集器使用对象头的空间,而G1、Shenandoah使用BitMap数据结构,而ZGC使用的是引用对象的指针上的空间。64位机器上,指针的真正可用空间是46位,ZGC占用了46位中的前4位实现标记,一位记录是否只能通过finalize方法才能被访问到,一位记录是否进入了重分配集,两位记录对象的三色标记。也因此ZGC只支持64位机器。ZGC没有使用Brooks Pointer,而是为每个Region都维护了一个转发表。ZGC中引用具有自愈特性,每次使用某个引用访问的时候,在读屏障中会将引用更新到最新(ZGC目前仅使用了读屏障),很显然只有第一次访问的时候会显著变慢,后面的访问是非常快的。并且ZGC不需要等待清理过程结束,就可以将那些所有存活对象都已经被复制走的Region进行复用(Shenandoah、G1收集器需要等待修复引用完成后才行),于是ZGC更难遇到内存不足的情况(理论上只要有一个空的Region可用,复制就可以继续下去),当然这里还是需要保留原来Region的转发表的。由于ZGC中的指针具有自愈能力,且转发表直到下一次GC发生前都是可以保留的,因此ZGC实际上并不会更新所有引用,而是在下一次GC的并发标记阶段,顺带修复所有引用,之后每个Region的转发表就可以释放了。
  • Epsilon收集器:Epsilon收集器是一个不会进行垃圾回收的收集器,其仅有分配和管理内存的能力。Epsilon收集器适合那些生命周期非常短的应用(在堆耗尽之前就退出了)。

查看GC日志

  JDK9之前 JDK9及之后
查看GC基本信息 -XX:+PrintGC -Xlog:gc
查看GC详细信息 -XX:+PrintGCDetails -X-log:gc*
查看GC后堆方法区的容量变化 -XX:+PrintHeapAtGC -Xlog:gc+heap=debug
查看GC过程中用户线程并发及暂停时间 -XX:PrintGCApplicationConcurrentTime以及-XX:PrintGCApplicationStopTime -Xlog:safepoint

内存分配策略

大多数情况下,对象在Eden区中分配。如果Eden区没有足够空间时,虚拟机会触发一次Young GC。

对于大对象,比如很长的数组,如果把它们保留在较小的年轻代中,那么每次Young GC的复制成本就大幅提高了,因此对于大小大于阈值的对象,就会直接在老年代分配。

HotSpot虚拟机中很多收集器都采用了分代收集的设计,而如果判断一个对象是否该移动到老年代,需要使用一个计数器记录对象的年龄,这个计数器就存储在对象头中。对象通常在Eden区创建,此时对象的年龄为0。如果经过一次Young GC后不仅存活,并且可以被放进Survivor空间中,则年龄增加1。当对象的年龄增长到一定程度(默认15,通过-XX:MaxTenuringThreshold设置),就会晋升到老年代。

为了更加好地适应程序,HotSpot并不会永远要求对象只有在年龄达到MaxTenuringThreshold才会被晋升,如果在Survivor空间中某一年龄的对象占用了一半以上的空间,那么所有大于这个年龄的对象都允许晋升到老年代。

如果Young GC后,存活的对象Survivor空间放不下,这时候需要让老年代进行分配担保(允许一些新生代对象直接晋升到老年代)。在执行Young GC之前,虚拟机会检查老年代最大连续可用空间是否大于等于新生代所有对象总空间。如果成立,则会直接执行Young GC;如果不成立,则根据-XX:HandlePromotionFailure参数值(JDK 1.6 Update 24之后,这个参数弃用,永远为true)判断是否允许担保失败的情况,且需要保证老年代最大可用连续空间大于等于历史上晋升到老年代的对象的大小的平均值,如果两个条件都成立,则冒险进行一次Young GC,如果有条件不满足或冒险失败,则最终需要进行一次Full GC。

小工具

JPS

JPS全称Java Process Status Tool,用于列出正在运行的Java虚拟机进程,并显示虚拟机执行的主类。

class

每个Class文件的头四个字节被称为魔数,它的唯一作用就是标志这是一个Class文件。之所以需要魔数是因为人们可能会随意修改文件后缀名,因此魔数可以帮助虚拟器尽快发现无效的Class文件,很多语言也都有类似的魔数,Class中的魔数为固定的0xCAFEBABE

之后的四个字节是Class的版本号,第5、6字节是次版本号,而第7、8字节是主版本号。高版本的JVM可以向下兼容低版本的Class文件,而低版本的JVM是不能使用高版本的Class文件的。

在主次版本号之后存储的是常量池,由于常量池的大小是非固定的,因此一般最开始的两个字节存储的无符号整数代表常量池的项数。常量池存放两大类常量:字面量和符号引用。字面量表示的是Java中用双引号扩起的部分。而符号引用属于编译原理方面的概念,包括:

  • 被模块导出或开发的包
  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 方法句柄和方法类型
  • 动态调用点和调用常量

Java在进行Javac编译的时候,不是像C/C++那样有连接的步骤,而是虚拟机在加载Class文件的时候进行动态连接。也就是说在Class文件中不会保存各个方法、字段最终在内存中的布局信息,而这些字段和方法的引用需要经过虚拟机在运行期的转换才能得到真正的内存入口地址。在虚拟机做类加载时,将会从常量池获得对应引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

常量池结束后,接下来的两个字节是访问标记,这些标志用于识别一些类或者接口层次的访问信息,其中指示类的访问级别,是接口、抽象类、注解、枚举、模块。

字节码

Java虚拟机的指令由一个字节长度的、代表某种特定操作的数字构成(称为操作码),之后跟随若干个参数。字节码指令集可以算是一种具有鲜明特点、优势和劣势非常突出的指令集架构,由于限制了操作码长度只有一个字节,因此操作码的总数不能够超过256条。

类加载机制

一个类型从加载到虚拟机内存,到卸载出内存为止,生命周期会经历:

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

加载、验证、准备、初始化和卸载阶段会按部就班开始(但是不保证按部就班结束,在上一个阶段开始后不用等其结束就可以开始下一阶段了)。解析阶段可能发生在初始化阶段之后,这是为了支持Java语言的运行时绑定特性。

《Java虚拟机规范》中严格规定了有且只有六种情况必须立即对类进行初始化阶段(而自然加载、验证、准备阶段必须再此之前完成)。

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候。
  • 当其子类初始化的时候需要初始化父类。
  • 当虚拟机启动时会初始化主类。
  • 使用JDK7新加入的动态语言支持。
  • 如果接口中定义了default方法,则实现类初始化之前需要初始化接口类。

这里需要特别注意,通过子类的限定名来访问定义在父类中的静态方法或成员,会触发父类的初始化,而子类是否需要被初始化,在《Java虚拟机规范》中没有规定。

创建某个类型的数组也不会导致这个类型被初始化,因为创建数组是newarray指令,没有命中上面的任何一个条件。

还有一个特殊情况就是使用某个类的常量静态成员(字面量或原生类型),也不会导致该类被初始化,因为常量成员会在编译阶段通过常量传播优化,将对该类静态常量的引用直接替换为常量值本身。

加载阶段

加载阶段需要完成下面工作:

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

验证

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

准备

准备阶段是正式为类中定义的变量(静态变量)分配内存并设置变量初始值的阶段。从概念上讲,这些变量使用的内存应当在方法区中分配,但是方法区本身是一个逻辑上的区域。在JDK8之后类变量会随着Class对象一同存放在Java堆中。

注意上面提到的设置初始值并不是执行<clinit>方法,而是将所有变量赋值为0(对象为NULL)。但是这里的特例是常量成员(字面量和原生类型)在准备阶段会直接会被初始化为初始值。

解析

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

符号引用是以符号来引用目标,符号可以是任意字面量,只要能无歧义定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标也不一定是已经加载到虚拟机中的内容。符号引用定义在《Java虚拟机规范》的Class文件格式中。

直接引用是可以指向目标的指针、相对偏移量或者一个中间句柄。直接引用和虚拟机的内存布局直接相关。

解析阶段的具体发生时间《Java虚拟机规范》并没有严格定义,虚拟机可以自行决定是在类被加载时就对常量池中的符号引用进行解析,还是等一个符号引用将要被使用前才去解析它。

对方法和字段的访问,也会在解析阶段对它们的可访问性进行检查。

对一个符号引用的多次解析请求是常见的,只要保证每次解析的结果都是幂等的即可。

对于invokedynamic指令,仅允许在运行到这条指令时才进行动态解析,相应的其余解析的指令时静态的,可以在未执行代码之前就完成解析。

初始化

类的初始化阶段是类加载阶段的最后一个步骤。初始化阶段的工作是执行<clinit>方法。<clinit>方法不是由用户编写的代码,而是由编译器的生成物。编译器会收集静态成员的所有赋值操作,以及静态方法块,顺序拼接得到。在静态方法块中,只能使用出现在它之前的变量。

<clinit>方法不同与构造器<init>,它不需要调用父类的构造器,Java虚拟机会保证子类的<clinit>被执行之前,父类的<clinit>方法已经被执行过了。

Java虚拟机必须保证一个类的<clinit>方法被执行且仅执行一次,如果多个线程同时去初始化一个类,那么只有一个线程会去执行这个类的<clinit>方法,其余线程需要阻塞等待。

类加载器

类加载器虽然只实现了类的加载动作,但是它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都要保证加载器和类本身唯一(同一个加载器不能重复加载同一个类)。每个类加载器都拥有自己的一个类命名空间,同一个类文件被两个类加载器所加载,得到的两个类是不同的(因为在不同的命名空间下)。

站在虚拟机的角度看,类加载器分成两类:

  • 启动类加载器:由C++实现,是虚拟机的一部分。
  • 其余类加载器:由Java语言实现,继承于ClassLoader类。

启动类加载器不能直接被Java程序引用,因此被启动类加载器所加载的类,调用它们的getClassLoader方法的时候会返回null

其余类加载器分成:

  • 扩展类加载器:这个类加载器负责加载<JAVA_HOME>\lib\ext目录中的类库。
  • 应用类加载器:它负责加载用户类路径上的所有类库。

每个加载器都有自己的父加载器,从而构成了一个树形结构。这样的层次结构称为类加载器的双亲委派模型。每个类加载器在接到加载某个类的请求的时候,首先会把请求委托给父加载器,只有在父加载器无法完成工作的时候,加载器才会自行开始加载。

这样做的好处就是可以尽可能地复用一些类,从而减少方法区的空间占用。

奇技淫巧

条件编译

Java中可以实现条件编译,利用if命令。

static int f(int x){
  if(true){
    return x;
  }else{
    return -x;
  }
}

上面的代码编译后再反编译,可以得到:

static int f(int x) {
    return x;
}

这样就可以避免if语句的性能损耗。

可能有点用吧。

编译技术

Java中的编译器基本分成三类:

  • 前端编译器,比如JDK的javac,Eclipse JDT中的增量式编译器ECJ。
  • 即时编译器,比如Hotspot的C1和C2编译器
  • 提前编译器,比如JDK的Jaotc

前端编译器

前端编译器负责将源代码编译成中间码(class文件)。

即时编译器

目前主流的商用虚拟机里,Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某块方法或代码块运行的特别频繁,就会把这块代码视作热点代码,为了提高程序的运行效率,虚拟机会把这些热点代码编译成机器码,并进行优化。完成编译的工作的后端编译器也叫做提前编译器。

采用解释器和编译器并存的好处是,程序的编译速度、启动速度、以及程序长时间运行后的效率都能得到保障。同时如果编译器才用了激进优化,并需要回退的时候,也可以用解释器作为后备的逃生门。

HotSpot中内置了两个即时编译器,分别称为客户端编译器(C1)和服务端编译器(C2)。在JDK 10出现了另外一个准备替换C2的Graal编译器。

在分层编译技术出现之前,HotSpot中采用一个编译器和解释器合作的方式,程序使用哪个编译器,只取决于虚拟机运行的模式,用户也可以通过-server-client指定选择哪个编译器。

无论是客户端模式还是服务器模式,解释器和编译器混搭的模式叫做混合模式(mixed mode),可以使用参数-Xint让虚拟机仅运行解释器,或使用-Xcomp强制虚拟机运行于编译器模式。

为了在程序启动速度和运行效率之间获得一个平衡,HotSpot加入了分层编译的功能。分层编译分5层:

  • 第0层:程序纯解释执行,且解释器不开启性能监控。
  • 第1层:使用客户端编译器编译字节码,进行简单优化,不开启性能监控。
  • 第2层:使用客户端编译器编译字节码,仅开启回边次数统计等有限的性能监控。
  • 第3层:使用客户端编译器编译字节码,并开启所有的性能监控,包括分支跳转、虚方法调用版本等。
  • 第4层:使用服务端编译器编译字节码,使用耗时更长的优化,并允许使用激进优化。

如何确定一个代码是热点代码呢?热点代码分两类:

  • 被调用多次的方法
  • 循环多次的循环体

对于两种情况,编译的目标对象都是整个方法,而不会是一个单独的循环。第二种方法由于发生在方法执行的过程中,也被称作栈上替换(On Stack Replacement),即栈帧还在栈上,方法就被替换掉了。

要知道某段代码是否是热点代码,我们需要利用热点探测来判定一个方法或循环被调用的频次。目前主流的热点探测方式是:

  • 基于采样的热点探测:周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那么这个方法就是热点方法。热点探测的优点是简单高效,缺点是可能被外部环境影响。
  • 基于计数器的热点探测:为每个方法建立两个计数器(一个是方法计数器,一个是回边计数器),统计方法(以及方法中的循环)的执行次数,如果次数达到某个阈值就认为它是热点方法。

在OpenJ9中使用的是基于采样的热点探测,而在HotSpot中使用的是基于计数器的热点探测。HotSpot为每个方法以及循环建立独立的计数器,如果某个计数器达到阈值,就会触发即时编译。在客户端模式下,阈值默认是1500次,而在服务器模式下,阈值默认是10000次,这个阈值可以通过-XX:CompileThreshold来认为设定。

当一个方法被调用的时候,虚拟机会先检查该方法是否存在即时编译过的版本,如果有,则优先使用编译过的本地代码来执行,否则使用未编译过的版本,并将方法的计数器增加1。如果方法的方法计数器和回边计数器之和超过阈值,就会向即时编译器提交一个该方法的编译请求。默认情况下,执行引擎不会等待编译完成,而是直接按照解释方式执行字节码。当编译工作完成后,这个方法的调用入口地址就会被系统自动更新,下一次调用方法就是使用的编译后的版本。

默认情况下,方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对的频率,即一段时间内方法被调用的次数,当超过一定时间后,这个方法的调用次数不足以达到阈值,那么方法的计数器值会减少一半,这个过程称作方法调用计数器热度的衰减,而这段时间就称为这个方法统计的半衰周期。可以用-XX:-UseCounterDecay来关闭热度衰减。

回边计数器的阈值比较复杂,虽然虚拟机提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户修改,但是当前HotSpot虚拟机实际上并未使用这个参数,我们必须设置-XX:OnStackReplacePercentage来调整回边计数器的阈值。

  • 客户端模式下,回边计数器的阈值为$CompileThreshold\cdot \frac{BackEdgeThreshold}{100}$。默认是13995。
  • 服务器模式下,回边计数器的阈值为$CompileThreshold\cdot \frac{BackEdgeThreshold-InterpreterProfilePercentage}{100}$,默认值为10700。

当解释器遇到一条回边指令的时候,则会先查找将要执行的代码片段是否有已经编译好的版本。如果有,就优先使用编译后的代码,否则将回边计数器增加1,然后判断回边计数器是否达到阈值。当超过阈值的时候,解释器会提交一个栈上替换请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中循环一段时间,等待编译器编译完成。

与方法计数器不同,回边计数器不会衰减,如果计数器溢出,它还会将方法计数器也调整到溢出状态,这样下次再进入这个方法的时候就会执行标准编译过程。

默认情况下,无论是标准编译还是栈上替换,编译和解释执行是异步进行的,编译由后台的编译线程完成。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译。后台编译被禁用后,解释器会阻塞直到编译完成。

提前编译

提前编译器的问题在于不符合java一次编译到处运行的理念。

一些优化技术

内联

内联是最重要的优化技术,它可以规避调用方法的成本。

但是Java是一个提倡多态的语言,因此一个方法可能有多个实现。所以这就给内联带来了难度,比如方法接受一个接口对象,你永远不知道调用方传入对象的实现类。

Java虚拟机中提供了一种激进优化的方式,其通过引入名为类型继承关系分析(CHA)的技术,这是一种应用程序内的类型分析技术,用于提供对加载的类的继承关系的查询。当后端编译器优化某个虚方法(没有用final修饰的方法)时,它会向CHA询问这个方法是否有多于一种实现,如果没有,就可以进行内联。

但是由于Java虚拟机的类加载是动态的,因此有可能会加载到一个覆盖了原本被内联的虚函数的类,这样之前激进优化就不能再用了。此时必须通过逃生门,即丢弃已经编译的代码,并退回到解释状态下执行。

但是其实即使有多个方法的实现,也可以在一定程序上使用内联技术。就是为每个虚方法的调用维护一个缓存,一开始,缓存会被放入第一次调用这个方法的对象的信息(即点号左边的数)以及通过虚表解析出来的方法地址。如果每次对象的类型都相同,我们可以复用缓存里的方法地址(就不用查虚表了),这时候称为单态内联缓存。但是如果一旦出现不一致的情况,那么就必须跳过缓存,改用每次都查虚表的方式,这时候称为超多态内联缓存。

并发