Java虚拟机系列——检视阅读

Java虚拟机系列——检视阅读

参考

java虚拟机系列

入门掌握JVM所有知识点

2020重新出发,JAVA高级,JVM

JVM基础系列

从 0 开始带你成为JVM实战高手

Java虚拟机—垃圾收集器(整理版)

RednaxelaFX知乎问答

RednaxelaFX博客

Class类文件讲解不够透彻,需要找份新的资料。

内存区域

Java虚拟机在执行Java程序过程中会把它所管理的内存划分为若干个(主要5个部分)不同的数据区域。这些区域有自各的用途,以及创建及销毁时间,有的区域(方法区、堆、直接内存、java代码缓存)随着虚拟机进程的启动而存在,有些区域(虚拟机栈、本地方法栈、程序计数器)则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(第2版)》规定,Java虚拟机管理的内存区域包括以下几个运行时数据区域,下如图

1.程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过该计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换CPU时间片的方式来实现的,所以在任何一个时刻,一个处理器(对于多核处理器来说是一个内核)只会行一条线程中的指令。因此为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,为线程所私有。

如果当前线程执行的是一个Java方法,这个计数器指向在执行的虚拟机字节码的地址;如果执行的是一个Native方法,这个计数器的值为空(UndefinedD),计数器必须要能容纳方法的返回地址或者具体平台的本地指针。此区域是唯一一个在Java虚拟机器中没有规定任何OutOfMemoryError的区域。

2.Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型;每个方法被执行时都会在虚拟机栈中创建一个栈桢(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回地址这四种信息。每一个方法被调用直至其执行完成就像是一个栈桢在虚拟机栈中入栈与出栈的过程。

虚拟机规范中说明了,Java虚拟机栈可以被实现为固定大小,或者根据计算的需要动态扩展和收缩。如果Java虚拟机栈的大小是固定的,则可以在创建该虚拟机栈时独立地选择每个Java虚拟机堆栈的大小。Java虚拟机实现可以为程序员或用户提供对Java虚拟机栈初始大小的控制,在动态扩展或收缩Java虚拟机堆栈的情况下,还可以提供对最大和最小大小的控制。

虚拟机规范在这个区域规定了两种异常状况:如果线程请求栈深度超过虚拟机允许的深度,虚拟机将会抛出一个*Error错误;如果虚拟机栈可以动态扩展(当前大部分虚拟机都可以动态扩展,只不过Java虚拟机规范允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存或者在创建一条新的线程时没有足够的内存创建一个初始大小的虚拟机栈时,Java虚拟机将抛出OutOfMemoryError错误。

3.本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈的作用非常相似,其区别不过是虚拟机栈执行的是Java方法,而本地方法栈执行的是Native方法。虚拟机规范中对本地方法栈中的方法使用的语言,使用方式与数据结构并没有强制规定,具体的虚拟机可以*实现它。甚至的有虚拟机(如Sun HotSpot)直接把本地方法栈与虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区也会抛出*Error与OutOfMemoryError错误。

  1. Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内在区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。虚拟机规范中的描述是:所有类的实例与数组对象都要在堆中分配。

Java堆是垃圾收集器作用的主要区域,因此很多时候也被称为GC堆(Garbage Collected Heap)。如果从内存回收的角度看,由于现在的收集器基本都是采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点,新生代还可为分为Eden空间、From Survivor空间、To Survivor空间。如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的仍然是对象实例,进一步划分其目的只是为了更好的回收内存或者更快的分配内存。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要是逻辑上连续的即可。即可以实现成固定大小的,也可以实现成动态扩展与收缩的,不过当前主流的虚拟机都是可以进行动态扩展与收缩的(通过-Xmx与-Xms控制)。如果在堆中没有足够的内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemory错误。

5.方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在虚拟机启动的时候方法区被创建。

对于HotSpot虚拟机,方法区又被称为"永久代(Permanent Generation)",本质上两者并不等价,仅仅是因为HotSpot虚拟机把GC分代收集扩展到了方法区,或者说永久来实现方法区而已。对于其它虚拟机(如BEA JRockit, IMB J9)来说是不存在永久代的概念的,就是HotSpot现在也有放弃永久代并"搬家"至Native Memory来实现方法区的规划了(在JDK8中已经去除了永久代,JDK7中就开始将一些原本存储在方法区中的数据移至Java堆中:运行时常量池)。

与Java堆一样,方法区不需要连续的内存和可以选择固定大小与可扩展收缩外,还可以选择不实现垃圾收集,因为方法区的垃圾收集效果不理想。当方法区无法满足内在分配要求时,将抛出OutOfMemory异常。

6.运行时常量池

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

运行时常量池相对于class文件常量池的一个重要特征就是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入class方法中的常量池中的内容才能进入到方法区的运行时常量池,程序运行期间也可以将新的常量放入常量池中,例如String类的intern()方法。

运行时常量池是方法区的一部分,自然也会受到方法区内存大小的限制,当常量池无法再申请到内存时会抛出OutOfMemory异常。

7.直接内存——堆外内存,受服务器实际内存大小限制

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError错误出现。垃圾进行收集时,虚拟机虽然会对直接内存进行回收,但却不能像新生代与老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等到老年代满了后FullGC时,然后"顺便"清理掉直接内存中废弃的对象。

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

疑问:

Q: 虚拟机规范中说明了,Java虚拟机栈可以被实现为固定大小,或者根据计算的需要动态扩展和收缩。如果Java虚拟机栈的大小是固定的,则可以在创建该虚拟机栈时独立地选择每个Java虚拟机堆栈的大小。怎么实现呢?

Q: 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。虚拟机规范中的描述是:所有类的实例与数组对象都要在堆中分配。几乎所有的对象实例都在这里分配内存,还有的是在运行时常量池里的是么?静态变量和静态实例在这里存放对象实例。

Q:如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。什么叫线程私有的分配缓冲区TLAB?

Q:方法区可以选择不实现垃圾收集,具体命令是什么?

判断对象是否存活

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

1.引用计数算法——Reference Counting

很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。

引用计数算法(Reference Counting)的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法。但是Java语言中没有选用引用计数算法来管理内存,其中最主要的一个原因是它很难解决对象之间相互循环引用的问题。

例如:在testGC()方法中,对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外这两个对象再无任何引用,实际上这两个对象都已经不能再被访问,但是它们因为相互引用着对象方,因此它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 *1024;
/**
* 只是为了占点内存
*/
private byte[] bigSize = new byte[2 * _1MB]; public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA; objA = null;
objB = null; //假设在这里发生GC,那么objA与objB是否会被回收
System.gc(); }
}

运行结果为:[Tenured: 4237K->141K(6148K), 0.0052656 secs] 4237K->141K(7108K)

[GC [DefNew: 234K->64K(960K), 0.0009447 secs][Tenured: 2125K->2189K(4096K), 0.0048757 secs] 2282K->2189K(5056K), [Perm : 365K->365K(12288K)], 0.0058659 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System) [Tenured: 4237K->141K(6148K), 0.0052656 secs] 4237K->141K(7108K), [Perm : 365K->365K(12288K)], 0.0052973 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
Heap
def new generation total 960K, used 18K [0x23b10000, 0x23c10000, 0x23ff0000)
eden space 896K, 2% used [0x23b10000, 0x23b14818, 0x23bf0000)
from space 64K, 0% used [0x23c00000, 0x23c00000, 0x23c10000)
to space 64K, 0% used [0x23bf0000, 0x23bf0000, 0x23c00000)
tenured generation total 6148K, used 141K [0x23ff0000, 0x245f1000, 0x27b10000)
the space 6148K, 2% used [0x23ff0000, 0x240136b8, 0x24013800, 0x245f1000)
compacting perm gen total 12288K, used 365K [0x27b10000, 0x28710000, 0x2bb10000)
the space 12288K, 2% used [0x27b10000, 0x27b6b578, 0x27b6b600, 0x28710000)
ro space 8192K, 63% used [0x2bb10000, 0x2c023b48, 0x2c023c00, 0x2c310000)
rw space 12288K, 53% used [0x2c310000, 0x2c977f38, 0x2c978000, 0x2cf10000)

在运行结果中可以看到GC日志中包含"4237K->141K",老年代从4273K(大约4M,其实就是objA与objB)变为了141K,意味着虚拟并没有因为这两个对象相互引用就不回收它们,这也证明虚拟机并不是通过通过引用计数算法来判断对象是否存活的。大家可以看到对象进入了老年代,但是大家都知道,对象刚创建的时候是分配在新生代中的,要进入老年代默认年龄要到了15才行,但这里objA与objB却进入了老年代。这是因为Java堆区会动态增长,刚开始时堆区较小,对象进入老年代还有一规则,当Survior空间中同一代的对象大小之和超过Survior空间的一半时,对象将直接进行老年代。

2.根搜索算法——GC Roots Tracing——可达性分析法

根搜索算法(GC Roots Tracing)判断对象是否存活的,也叫可达性分析法,可达性指的是该对象到GC Roots是否有引用链可达,不可达则表示该对象是可以被回收的。

在主流的商用程序语言中(Java和C#),都是使用根搜索算法(GC Roots Tracing)判断对象是否存活的。这个算法的基本思路就是通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,如下图:

上图中Object1、Object2、Object3、Object4到GC Roots是可达的,表示它们是有引用的对象,是存活的对象不可以进行回收;Object5、Object6、Object7虽然是互相关联的,但是它们到GC Roots是不可达的,所以他们是可以进行回收的对象。

在Java语言里,可作为GC Roots对象的包括如下4种:

  • 虚拟机栈(栈桢中的局部变量表)中的引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中JNI的引用的对象(Java Native Interface : Java本地接口 )

疑问:

Q:新生代进入老年代的情况都有哪些?穷举出来。

A: 有以下几种情况:

  1. 新生代存活年龄达到15
  2. 大对象数组直接进入老年代
  3. 当Survior空间中同一代的对象大小之和超过Survior空间的一半时,对象将直接进行老年代。
  4. Minor GC后存活的对象总的大小超过Survior空间直接进入老年代。
  5. 其他

Q: 类静态属性引用的对象为什么是在方法区中,是存放在方法区中的运行时常量池中么?如果是JDK1.8的话,那么运行时常量池就是在堆中了,就应该说是堆中的类静态属性引用的对象?

Q: 方法区中的常量引用的对象是指static修饰的类字段么?方法区中的类静态属性引用的对象指的是static final 修饰的类字段么?什么叫常量,什么叫类静态属性?为什么GC Roots对象强调的是方法区中常量和类静态属性引用的对象呢?而不是堆中的?

A:不是,方法区中的常量引用的对象是指final修饰的类字段,方法区中的类静态属性引用的对象指的是static修饰的类字段。

常量表示不可变的变量,这种也叫常量,从语法上来讲也就是,加上final,使用final关键字来修饰某个变量,然后只要赋值之后,就不能改变了,就不能再次被赋值了。

垃圾收集算法——回收垃圾算法

当对象判定为"已死"状态,虚拟就要采取一定的手段将这些对象从内存中移除,即回收垃圾,回收过程有采用一定的算法。如下是一些主要的垃圾收集算法:

1.标记-清除算法——Mark-Sweep

该算法是最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所有说它是最基础的算法是因为后续的收集算法都是基于这种思路并对其缺点进行改进得到的。它的缺点主要有两个:

  1. 一个是效率问题,标记和清除过程效率都不高。
  2. 另外一个是空间问题,标记清除后会产生大量不连线内存碎片,内存碎片太多导致当程序运行进需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集操作。标记-清除算法的执行过程如下图:

2.复制算法——Coping——新生代回收采用——Eden、Survivor空间分配

为了解决效率问题,“复制”收集算法出现了,它将可用内存按容量分为大小相等的两块,每次使用其中一块。当这一块内存使用完了(Eden+Survivor:90%),就将还存活着的对象复制到另外一块上(Survivor:10%),然后再把已使用过的内存空间一次性清理空。这样使得每次都是对其中一块进行内存回收,内存分配时也不用考虑内存碎片的问题,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,代价太高了一点(根据研究新生代中的对象98%是朝生夕死的,所以内存分配一般为8:1:1)。复制算法执行过程如下图:

现在商业虚拟机都是采用这种算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,生命周期很短,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间与两块较小的Survivor空间,每次使用Eden与其中一块Survivor空间。当回收时,将Eden与Survivor中还存活的对象一次性地拷贝到另外一块Survivor空间中,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor空间比例为8:1,也就是每次新生代中可用内存为整个新生代容量的90%(80%+10%),只有10%的新生代内存是“浪费”的。当然,98%的对象可回收只是一般场景下的数据,但没有办法保证每回收都只有不多于10%对象存活,当Survivor空间不足时,需要依赖其它内存(老年代)进行分配担保。

3.标记-整理算法——Mark-Compact——老年代(方法区)回收采用

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

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

4.分代收集算法——Generational Collection

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

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对象垃圾收集器应该如何实现并没有任何规定,因此不同的厂商,不同版本的虚拟机所提供的收集器可能会有很的差别,并且一般会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。下面是Sun HotSpot虚拟机1.6版本Update22包含的所有收集器:

上图中,如果两个收集器之间存在连线,就说明它们可以搭配使用。

1.Serial收集器——单线程复制算法

Serial收集器是最基本、历史最悠久的收集器,曾经(在JDK1.3.1之前)是虚拟机新生代的唯一选择。这是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾工作,更重要的是它进行垃圾回收时,必须(STW)暂停其它所有工作线程(Sun将这件事情称之为“Stop The World”),直到它收集结束。下面是Serial/Serial Old收集器的运行过程:

到目前为止,Serial收集器是虚拟机运行在Client模式下的默认重新代收集器。它简单而高效(与其它收集器的单线程相比),对于限定单个CPU环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在桌面应用场景中,分配给虚拟机的内存一般来说不会太大,收集几十兆甚至一两百兆的新生代,停顿时间完全可以控制在几十毫秒最多一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

2.ParNew收集器——多线程复制算法

ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包含Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。ParNew收集器的工作过程如下图:

3.Parallel Scavenge收集器——多线程复制算法——控制吞吐量

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制收集算法,又是并行的多线程垃圾收集器。其特点是与其它收集器的关注点不同,CMS等收集的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时候与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收回时间)。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量可以高效率地利用CPU时间,尽快的完成程序任务,主要适合在后台运算而不需要太多交互的任务。Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis及直接设置吞吐量大小的-XX:GCTimeRatio。Parallel Scavenge收集器还有一个参数-XX:UseAdaptiveSizePolicy,这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代大小、Eden与Survivor区的比例,晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调用使这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应调用策略(GC Ergonomics)。

4.Serial Old收集器——单线程标记-整理算法

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。这个收集器的主要意义也是被client模式下的虚拟机使用。如果在server模式下,它主要有两大用途:

一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用;

另外一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。

Serial Old收集器的工作过程如下图:

5.Parallel Old收集器——多线程标记-整理算法

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和”标记-整理“算法。这个收集器是在JDK1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器之外别无选择(因为它无法与CMS配合使用 )。注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

Parallel Old收集器的工作过程如下图:

6.CMS收集器——标记-清除算法——以获取最短回收停顿时间为目标

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,CMS收集器就非常符合这应用的需求。

CMS收集器是基于”标记-清除“算法实现的,它的运作过程相对前面几种收集器来说要复杂一点,

整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)——STW——标记与GC Roots能直接关联到的对象
  • 并发标记(CMS concurrent mark)——可达性分析(根搜索过程)
  • 重新标记(CMS remark)——STW——修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

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

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。

但它有三个显著缺点:

  1. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时((4+3)/4=1),并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%。
  2. CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  3. CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程(申请Serial Old 收集器进行标记整理),内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理:Serial Old 收集器)。

7.G1收集器

Java Hotspot G1 GC的一些关键技术

G1收集器是垃圾收集器理论进一步发展的产物,它与前面的CMS收集器相比有两个显著改进:一是G1收集器是基于“标记-整理”算法实现,也就是说它不会产生内存碎片,这对于长时间运行的应用系统来说非常重要。二是它可以非常精确地控制停顿,即能让使用都明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

G1收集器可以实现在基本不牺牲吞量的前提下完成低停顿的内存回收,这是由于它能够极力地避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将Java堆(包含新生代与老年代)划分为多个大小固定的独立区域,并且跟踪这些区域里的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的由来)。区域划分及优先级的区域回收,保证了G1收集器在有限时间内可以获得最高的收集效率。

G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式取处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  • 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ:Real Time specification for Java)的垃圾收集器的特征了。

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

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

G1收集器的运作大致可划分为以下几个步骤:类似与CMS收集过程。

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

G1收集器的运作步骤中并发和需要停顿的阶段:

8.zgc

新一代垃圾回收器ZGC的探索与实践

9.shenandoah(谢南多厄)

疑问:

Q: 为什么CMS垃圾收集器不能与Parallel Scavenge收集器组合使用呢?

Q: GC优化?

从实际案例聊聊Java应用的GC优化

Q: G1将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分Region(不需要连续)的集合。是什么样的模型?怎么理解?

图解G1?

Q: 互联网网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,因此一般的网络服务项目都是更注重相应速度甚于吞吐量的是么?也因此更多的垃圾收集器会选择ParNew + CMS + Serial Old 的组合?

Q: 重新标记(CMS remark)是为了修正并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这时候是将并发标记期间有些GC Roots 已经不再存在了,如虚拟机栈中的局部变量表引用的对象?这部分GC Roots所引用的对象便可以回收了是么?重新标记还是去标记与GC Roots能直接关联到的对象是么?

Q: 各种垃圾收集器的控制参数设置?

如Serial、ParNew 的控制参数设置:

-XX:SurvivorRatio、

-XX:PretenureSizeThreshold、

-XX:HandlePromotionFailure

Parallel Scavenge的控制参数设置:

分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis及直接设置吞吐量大小的-XX:GCTimeRatio。Parallel Scavenge收集器还有一个参数-XX:UseAdaptiveSizePolicy,这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代大小、Eden与Survivor区的比例,晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调用使这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应调用策略(GC Ergonomics)。

Q: 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收回时间)。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量可以高效率地利用CPU时间,尽快的完成程序任务,主要适合在后台运算而不需要太多交互的任务。为什么说Parallel Scavenge收集器适合在后台运算而不需要太多交互的任务?

A: Parallel Scavenge收集器的目的是实现高吞吐,而高吞吐必然会牺牲一点延迟,Parallel Scavenge收集器适合服务器端的新生代收集器。

重要参考:为什么 JDK 8 默认使用 Parallel Scavenge 收集器?

Q:延伸问题: Java 作为服务端时更关注是低延迟还是高吞吐?为什么CMS 就从来没有作为默认垃圾收集器使用过? 或者说为什么 JDK 8默认垃圾收集器没有选择 ParNew + CMS + Serial Old 的组合 。

A:Java 作为服务端时更关注是高吞吐。而CMS没有作为默认垃圾收集器使用的原因是:

CMS的缺点有:

  1. CMS在GC时会对CPU有比较大的压力,形成典型的CPU Spike(CPU毛刺)。
  2. CMS仅针对老年代,还需要一个年轻代的收集器。CMS又和Parallel Scavenge不兼容,只能和ParNew凑合,然而ParNew又不如Parallel Scavenge先进。
  3. CMS没法处理浮动垃圾,并发标记过程中死亡的对象只能留到以后的GC处理。
  4. Mark-Sweep算法对内存碎片无能为力,内存碎片太多,触发了Concurrent Mode Failure还不是得去请Serial Old来收拾烂摊子,结果就是STW。

结合目前的发展:

  1. G1这种革命性的GC日趋成熟,可以管理整个堆区,比CMS强太多,更不用说ZGC和Shenandoah。
  2. CMS的实现复杂(CMS的参数有70多个,而G1只有26个),维护的难度可想而知。

cms并不是一个非常成功的gc策略,要协调CMS时需要调整的参数太多,相比之下g1要好太多 ,G1没那么多参数要协调。虽然cms对比比g1可以达到低延迟的效果,参数协调好了的话,可以做到major gc一次都不出现,只触发minor gc,然后minor可以压缩到10ms以内。但CMS协调好的这种效果被zgc所实现,而zgc又不需要你协调任何参数,jvm会帮你把这一切搞定,zgc承诺10ms以内完成gc,而且实测,几个t的内存,gc停顿普遍在1ms左右,少数达到2ms,长期目标是所有zgc都在1ms以内完成,所以你可以认为zgc是cms的完美替代品,更简单,性能更好,所以cms被淘汰。

JDK 15开始,zgc和shenandoah也将成为正式的gc策略,使用这两个垃圾收集器不需要协调什么,只需要知道怎么开这两个gc策略就行了。

zgc适合客户端编程,尤其是对latency敏感的场合使用,比如我们写javafx时候,就会开zgc。

shenandoah(谢南多厄 )适合服务端等更在意throughput的场合使用,比如用es4x的时候,就用shenandoah。

Q: 延伸问题:concurrent mode failure ?

参考:concurrent mode failure

A:concurrent mode failure是CMS垃圾收集器特有的错误,CMS的垃圾清理和用户线程是并行进行的,如果在并行清理的过程中老年代的空间不足以容纳应用产生的垃圾(也就是老年代正在清理,从年轻代晋升了新的对象,或者直接分配大对象年轻代放不下导致直接在老年代生成,这时候老年代也放不下),则会抛出“concurrent mode failure”。

concurrent mode failure影响:会使老年代的垃圾收集器从CMS退化为Serial Old,所有应用线程被暂停,停顿时间变长。

可能原因及方案:

原因1:CMS触发太晚

方案:将-XX:CMSInitiatingOccupancyFraction=N调小(调小老年代的空间 )

原因2:空间碎片太多

方案:开启空间碎片整理,并将空间碎片整理周期设置在合理范围;

-XX:+UseCMSCompactAtFullCollection (空间碎片整理)

-XX:CMSFullGCsBeforeCompaction=n

原因3:垃圾产生速度超过清理速度

晋升阈值过小;

Survivor空间过小;

Eden区过小,导致晋升速率提高;

存在大对象;

Q: jdk7、8、9默认垃圾回收器?

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

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

jdk1.9 默认垃圾收集器G1

解释:UseParallelGC 即 Parallel Scavenge + Parallel Old 。

-XX:+PrintCommandLineFlagsjvm参数可查看默认设置收集器类型

-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

内存分配与回收策略

Java技术体系中的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。对象的内存分配往大的方向上讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲(-XX:+UseTLAB,默认已开启),将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中(如大对象像比较大的数组或字符串这类),分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。

下面是几条主要的最普遍的内存分配规则:

1.对象优先在Eden分配

大多数情况下,对象在新生代的Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟将发起一次Minor GC,如果GC后新生代中存活的对象无法全部放入Survivor空间,则需要通过分配担保机制提前进入到老年代中,前提是老年代中不能容纳所有存活对象,即只能容纳部分。则未能进入到老年代的存活对象将继续分配在Eden区中,如果Eden区也还未能容纳剩余的存活对象虚拟机抛出OutOfMemoryError错误。虚拟机提供了-XX:+PrintGCDetails参数用于输出收集器日志参数。

Minor GC与Full GC的区别:

a.新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。

b.老年代GC(Major GC/Full GC):指发生在老年代的GC(正常情况下是全堆的收集,会伴随一次Minor GC),出现了Major GC,经常会伴随至少一次Minor GC(但非绝对,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比MinorGC慢10倍以上。

2.大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组。大对象对虚拟机的内存分配来说是一个坏消息,经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个 -XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存拷贝。

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

虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能被Survivor区容纳的话,将被移到Survivor区中,并将对象年龄设置为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁。当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

4.动态对象年龄判定

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

5.空间分配担保

在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代剩余空间的大小,如果大于,则改为直拉进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则要改为进行一次Full GC。

新生代使用复制收集算法,但为了提高内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor空间无法容纳的对象直接进入老年代。

取平均值进行比较仍然是一种动态概率的手段,也就是说如果某次Minor GC存活的对象突增,远高于平均值的话,依然会导致担保失败(HandlePromotionFailure)。如果出现了HandlePromotionFailure,那只好在失败后重新发起一次Full GC。虽然担保失败时绕圈子是最大的,但是大部情况下还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

疑问:

Q: JAVA的TLAB是什么?

Q: 对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲(-XX:+UseTLAB,默认已开启),将按线程优先在TLAB上分配。这句话怎么理解?

Q: 如果GC后新生代中存活的对象无法全部放入Survivor空间,则需要通过分配担保机制提前进入到老年代中,前提是老年代中不能容纳所有存活对象,即只能容纳部分。则未能进入到老年代的存活对象将继续分配在Eden区中,如果Eden区也还未能容纳剩余的存活对象虚拟机抛出OutOfMemoryError错误。

在空间分配担保这节中,是这样描述的:新生代使用复制收集算法,但为了提高内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor空间无法容纳的对象直接进入老年代。

那么问题来了:当GC后新生代中存活的对象无法全部放入Survivor空间时,分配担保机制对这些存活的对象在新生代和老年代是如何分配内存的,为什么不是全部进入老年代?到底是哪种方式呢?是先优先填满Survivor空间,剩余的进入老年代?

Q: 当空间分配担保没有打开的时候,JVM进行Minor GC时是如何判断什么时候进行Minor GC什么时候进行Major GC 的呢?在每次晋升到老年代的平均大小小于老年代剩余空间的大小时,老年代空间大小剩余空间占比多少的时候呢?

class类文件结构概述

class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格紧凑地排列在class文件中,中间没有任何分隔符。当遇到需要占用8位字节以上的的数据项时,则会按照高位在前的方式分隔成若干个8位字节进行存储。

根据Java虚拟机规范的规定,class文件格式采用一种类似于C语言结构体的伪结构来存储,这种伪结构只有两种数据类型:无符号数和表。无符号数属于基本数据类型,以u1、u2、u4、u8来分别代码1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用于描述数字、索引引用、数量值,或者按照UTF-8编码构成的字符串值。表是由多个符号数或其它表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构,整个class文件本质上就是一张表,它由如下数据项构成:

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的集合。

Q: 无符号数可以用于描述数字、索引引用、数量值,或者按照UTF-8编码构成的字符串值。索引引用指的是什么?对象的引用么?

class类文件魔数,版本,常量池

魔数——magic——是否为一个能被虚拟机接受的class文件

每个class文件的头4个字节称为魔数(Magic Number),其值为:0xCAFEBABE,它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的class文件。使用魔数而不是扩展名来进行识别主要是基于安全的考虑,因为文件的扩展名可以随意地被改动。

版本号——minor_version、major_version

紧接着魔的4个字节存储的是class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。java的版本是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号上加1(JDK1.0-1.1使用了45.0-45.3的版本号),高版本的JDK能向下兼容以前版本的class文件,但不能运行以后版本的class文件,即使文件格式并未发生变化。JDK1.2对应主版本号为46,JDK1.3为47,依此类推。

常量池——constant_pool

紧接着主次版本号之后的是常量池入口,常量池是class文件结构中与其它项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一,同时它还是class文件中第一个出现的表类型数据项目。

由于常量池中常量的数据是不固定的,所以在常量池的入口需要放置一个u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java语言习惯不一样的是,这个容量计数是从1而不是0开始的。将第0项常量空出来的目的是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的意思。class文件结构中只有常量池的容量计数是从1开始,对于其它集合类型,包括接口索引集合,字段表集合,方法表集合的容量计算都是从0开始的。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串,被声明为final的常量值等。而符号引用则属性编译原理方面的概念,包含了下面三类常量:

  1. 类和接口的全限定名(Fully Qualified Name)
  2. 字段的名称和描述符(Descriptor)
  3. 方法的名称和描述符

常量池中的每一项常量都是一个表,共有11种结构各不相同的表结构数据,这11种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位,代表当前这个常量属性哪种常量类型,11种常量类型具体含义如下:

各常量项结构:

疑问:

Q: 常量池是class文件结构中与其它项目关联最多的数据类型,是与其他类还是项目管理,这句话什么意思?叫项目这种说法不正确吧?

Q: 这个容量计数是从1而不是0开始的。将第0项常量空出来的目的是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的意思。什么意思?

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

访问标志——access_flags

常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或接口层次的访问信息,包括:这个class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final,等等。具体的标志以及标志的含义如下表:

access_flags中一共有32个标志位可以使用,当前只定义了其中的8个,没有使用到的标志位要求一律为0。

类索引,父类索引,接口索引集合——确定这个类的继承关系

类索引(this_class)和父类索引(super_class)都是u2类型的数据,而接口索引(interfaces)是一组u2类型的数据集合,class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因了除了java.lang.Object之外,所有Java类的父类索引都不为0。接口索引集合用来描述这个实现实现了哪些接口,这些被实现的接口将按照implements语句后的接口顺序从左到右排列在接口的索引集合中。

类索引,父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名称字符串。对于接口索引集合,入口的第一项为u2类型的数据,表示接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,那么该计数器值为0,后面接口的索引表不再占用任何字节。

字段表集合

字段表(field_info)用于描述接口或类中声明的变量。字段(field)包括了类级变量或实例变量,但不包括方法内部声明的变量。描述一个字段的信息有:字段的作用域(public,private,protected修饰符),是类级变量还是实例级变量(static修饰符),可变性(final),并发可见性(volatile修饰符,是否强制从主内存读写),是否可序列化(transient修饰符),字段数据类型(基本数据类型,对象,数组),字段名称。这些信息中,各个修改符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字,字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。下面是字段表的最终格式。

字段修饰符放在access_flags项目中,它与类的access_flags项目是非常相似的,都是一个u2的数据类型,其中可以设置的标志位和含义如下表:

跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称及字段的描述符。现在需要解释一下“简单名称”,“描述符”及前面出现过多次的“全限定名”这三种特殊字符串的概念。

全限定名称和简单名称很好理解,如“org/fenixsoft/clazz/TestClass"就是一个类全限定名,仅仅是把类名中的”.“替换成了”/“而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加上一个“;”号表示全限定名结束。简单名称就是指没有类型和参数修饰的方法或字段名称。

相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是来用描述字段的数据类型,方法的参数列表(包括数量,类型及顺序)和返回值。根据描述符规则,基本数据类型(byte,char,double,float,int,long,short,boolean)及代表无返回值的void类型都使用一个大写字符来表示,而对象类型则用字符L加对象全限定名来表示,如下图:

对于数组类型,每一维度使用一个前置的 [ 字符来描述,如一定义为java.lang.String类型的二维数组,将被记录为:“[[java/lang/String;”,一个整型数组“int[]”将被记录为“[I”。

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序在一组小括号“()”之内。如方法void int()描述符为:”()V“,方法java.lang.String toString()描述符为:“()java/lang/String;”

字段表都包含的固定数据项目到descriptor_index为止就结束了,但是在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述0至多项额外的信息。字段表集合中不会列出超类或父接口中继承而来的字段,但有可能列出原来Java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须使用不一样的名称(即名称必须不同,编译器限制),但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的(即只有描述符不同就是合法的)。

疑问:

Q: 字段(field)包括了类级变量或实例变量,但不包括方法内部声明的变量。类级变量是指static修饰的变量么?

A: 是的。描述一个字段的信息有:字段的作用域(public,private,protected修饰符),是类级变量还是实例级变量(static修饰符),可变性(final),并发可见性(volatile修饰符,是否强制从主内存读写),是否可序列化(transient修饰符),字段数据类型(基本数据类型,对象,数组),字段名称。

方法表集合

方法表的结构与字段表一样,依次包含了访问标志(access_flags),名称索引(name_index),描述符索引(descriptor_index),属性表集合(attributes)几项,如下表所示:

因为volatile关键字和transient关键字不能修改方法,所以方法表的访问标志中没有了ACC_VOLATILE与ACC_TRANSIENT标志。与之相对的,synchronized, native, strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED,ACC_NATIVE,ACC_STRICTFP,ACC_ABSTRACT标志。对于方法表,所有标志位及取值如下表:

方法里面的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性表中,属性表是class文件格式中最具扩展性的一种数据项目。

与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现父类的方法。但同样的,可能**会出现由编译器自动添加的方法,最典型的便是类构造器“”方法和缺省实例构造器“”方法。

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

对于JVM的Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个class文件中。

疑问:

Q: 如果是默认没有访问范围修饰符的方法,如下,这方法访问标志里的值是什么样的呢?

 void test(){
/...
}

属性表集合——不理解

讲解得并不是很好,待翻看深入理解java虚拟机的书看,还有看看R大关于这方面的讲解,感觉书上说的也不是很好,每次看到这边都不是很理解

在Class文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与Class文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确地解析Class文件,《Java虚拟机规范(第二版)》中预定义了9种虚拟机实现应当能识别的属性,具体如下表所示:

对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量表来表示,而属性值的结构则是完全自定义的,只要说明属性值所占用的位数长度即可。一个符合规则的属性表应该满足如下表定义的结构:

1.Code属性

Java程序方法体里的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合中,但并非所有方法都必须存在这个属性表,譬如接口或抽象类中的抽象方法就不存在Code属性,如果方法有Code属性表存在,那么它的结构如下表:

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

max_stack代表了操作数栈(Operand Stacks)的最大深度。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Frame)中的操作数栈深度。

max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是Slot,Slot是虚拟机为局部变量表分配内存所使用的最小单位。对于byte,char,float,int,shot,boolean,reference和returnAddress等长度不超过32位的数据类型,每个局部变量占1个Slot,而double与long这两种64位的数据类型而需要2个Slot来存放。方法参数(包括实例方法中的隐藏参数“this”),显示异常处理器的参数(Exception Handler Parameter,即try-catch语句中catch块所定义的异常),方法体中定义的局部变量都需要使用局部表来存放。另外,并不是在方法中使用了多个局部变量,就把这些局部变量所占的Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所在的Slot就可以被其他局部变量所使用,编译器会根据变量的作用域来分类Slot并分配给各个变量使用,然后计算出max_locals的大小。

code_length和code用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。既然名为字节码指令,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可相应地找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应该如何理解。

关于code_length还有一件值得注意的事情,虽然它是一个u4类型的长度值,理论上最大值可以达到2的32次方减1,但虚拟机规范中限制了一个方法不允许超过65535条字节码指令(64KB),如果超过这个限制,Javac编译器就会拒绝编译。一般来讲,只要我们写Java代码时不是刻意地编写超长的方法,就不会超过这个最大值限制。但是,在编译复杂的JSP文件中,可以会因为这个原因导致编译失败。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里的Java代码)和元数据(Metadata,包括类、字段、方法定义及其它信息)两部分,那么在整个Class文件里,Code属性用于描述代码,其它的所有数据项目就都用于描述元数据。

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

异常表它包含4个字段,这些字段的含义为:如果字节码从第start_pc到end_pc行之间(不包含第end_pc)行出现了类型为catch_type或其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任何的异常情况都需要转向到handler_pc行行进行处理。异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常及finally处理机制。注:字节码的“行”是一种形象的描述,指的是字节码相对于方法体开始的偏移量,而不是Java源代码的行号。

2.Exceptions属性

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,而不是Code属性表中的异常属性表。Exceptions属性表的作是列举出方法中可能抛出的受查检异常(Checked Exception),也就是在方法描述时在throws关键字后面列举的异常。它的结构如下表:

此属性表中的number_of_exceptions项表示访求可能抛出number_of_exceptions种受检查异常,每一种受检查异常使用一个exception_index_table项表示,attribute_name_index为指向常量池中CONSTANT_Class_info型常量表的索引,代表了该受检查异常的类型。

3.LineNumberTable属性

LineNumberTable属性用于描述Java源代码行号与字节码行号(字节码偏移量)之间的对应关系。它并不是运行时必须的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性表,对程序运行产生的最主要的影响就是在抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候无法按照源码来设置断点。LineNumberTable属性表结构如下表:

line_number_table是一个数量为line_number_table_length,类型为line_number_info的集合,line_number_info表包括了start_pc和line_number两个u2类型的数据项,前者是字节码行号,后者是Java源码行号。

4.LocalVariableTable属性

LocalVariableTable属性表用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它不是运行时必须的属性,默认也不会生成到Class文件之中,可以使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其它人引用这个方法时,所有参数名称都丢失,IDE可能会使用诸如arg0、arg1之类的占位符来替换原有的参数名称,这对程序运行没有影响,但是会给代码编写带来较大的不便,而且在调试期间无法根据参数名称从运行上下文件中获取参数值。LocalVariableTable属性表结构如下:

其中local_variable_info项目代表了一个栈帧与源码中的局部变量的关联,结构如下:

index是这个局部变量在栈帧局部变量表中的Slot位置。当这个变量的数据类型是64位时(double和long),它占用的Slot为index和index+1两个位置。

在JDK1.5引入了泛型之后,LocalVariableTable属性增加了一个“姐妹”属性:LocalVaiableTypeTable,这个新增加的属性结构与LocalVariableTable属性非常相似,仅仅是把记录字段描述符的descript_index替换成了字段的特征签名(Singnature),对于非泛型类型来说,描述符的参数化类型被擦除掉了,描述符就不能准确地描述泛型类型了,因此出现了LocalVariableTypeTable属性。

5.SourceFile属性

SourceFile属性用于记录这生成这个Class文件的源码文件名称。这个属性也是可选的,可以使用-g:none或-g:source选项来取消或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件是一致的,但有一些特殊情况(如内部类)例外。如果不生成这项属性,当招聘异常时,堆栈中半不会显示出错误代码所属性文件名。这个属性是一个室长的属性,结构如下:

sourcefile_index数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,常量值是源文件的文件名。

6.ConstantValue属性

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量才可以使用这项属性。在Java程序里类类似“int x = 123“和”static int x = 123”这样的变量定义非常常见,但虚拟机对这两种变量赋值的方法和时刻有所不同。对于非static类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;对于类变量,则有两种式可以选择:赋值在类构造器方法中进行,或者使用ConstantValue属性来赋值。目前Sun Javac编译器的选择是:如果同时使用final和static来修改一个变量,并且这个变量的数据类型是基本类型或java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型或字符串,则选择在类构造器中进行初始化。ConstantValue属性表结构如下:

ConstantValue属性是一个定长属性,它的attribute_length数据项值必须为2。constantvalue_index数据项代表了常量池中一个字面常量的引用,根据字段类型不同,字面量可以是CONSTANT_Long_info,CONSTANT_Float_info,CONSTANT_Double_info,CONSTANT_Integer_info和CONSTANT_String_info常量中的一种。

7.InnerClasses属性

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

数据项number_of_classes代表需要记录多少个内部类信息,每一个内部类的类的信息都由一个inner_class_info表进行描述。inner_class_info表结构如下:

inner_class_info_index和outer_class_info_index都是指向常量池中CONSTANT_Class_infon常量的索引,分别代表了内部类和宿主类的符号引用。inner_name_index是指向常量池中CONSTANT_Utf8_info型常量的索引,代表这个内部类的名称,如果是匿名内部类,则这项值为0。inner_class_access_flags是内部类的访问标志,类型于类的access_flags,它的取值范围如下表:

8.Deprecated及Synthetic属性

Deprecated及Synthetic属性都属性于标志类型的布尔值属性,只存在有和没有的区别,没有属性值的概念。

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

Synthetic属代表此字段或方法并不是由Java源码直接产生的,而是由编译器自行添加的,在JDK1.5之后,标识一个类,字段或方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位,其中最典型的就是Bridge Method。所有非用户代码生产的类,方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造器“”方法和类构造器“<clinit”方法。

Deprecated及Synthetic属性表结构如下:

其中attribute_length数据项的值必须为0,因为没有任何属性值需要设置。

在JDK1.5和JDK1.6中一共增加了10项属性,具体如下:

引用类型和对象是否死亡

在JDK1.2以前,Java中的引用定义得很传统:如果reference类型的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但太过狭隘,一个对象在这种定义下只有被引用或者没有引用两种状态,对于如何描述一个“食之无味,弃之可惜”的对象就显得无能为力;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)四种,这四种引用强度依赖逐渐减弱。

1.强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类引用,只要强引用存在,垃圾收集器永远不会回收掉被引用的对象。

2.软引用用来描述一些还有用,但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中,并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出错误。在JDK1.2之后,提供了SoftReference来实现软引用。

3.弱引用也是用来描述非必须对象的,但是它的强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。在JDK1.2之后,提供了WeakReference来实现弱引用。

4.虚引用它是最弱的一种引用关系。一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference来实现虚引用。

在根搜索算法中不可达的对象,也并非是“非死不可的”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finallize()方法或该对象的finalize()已经被调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果一个对象被判断为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的,低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在fianlize()方法中执行缓慢,或者发生了死循环,将很可能会导致F-Queue队列中的其它对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救自己--只要重新与引用链上的任何一个对象建立关联既可,那在第二次标记时它将被移出“即将回收”集合;如果对象这时候还没有逃脱,那它就将被回收了。

类加载时机

类从被加载到虚拟机内存中开始,到缷载出内存为止,它的整个生命周期为:加载(Loading),验证(Verification),准备(Preparation),解析(Resolution),初始化(Initialization),使用(Using),缷载(Unloading)七个阶段。其中验证,准备,解析三个阶段统称为连接(Linking)阶段,这七个阶段的发生顺序如下图:

加载,验证,准备,初始化和缷载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。注意这里写的是按部就班地“开始”(意思是保证开始的顺序是确定的,但各个阶段间是互相交叉地混合式进行的,所以只能是开始的顺序是确定的),而不是按部就班地“进行”或“完成”,因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程调用或激活另外一个阶段。

在什么情况下需要开始类的加载过程的第一个阶段:加载。虚拟机规范中并没有进行强制约束。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有四种情况必须立即对类进行“初始化”(而加载,验证,准备阶段自然需要在此之前开始):

  1. 遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

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

a.通过子类引用父类中的静态字段,不会初始化子类

package com.xtayfjpk.jvm.chapter7;

/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
* @author zj
*
*/
public class SuperClass {
public static int value = 123; static {
System.out.println("SuperClass Init");
}
} package com.xtayfjpk.jvm.chapter7; public class SubClass extends SuperClass { static {
System.out.println("SubClass Init");
}
} package com.xtayfjpk.jvm.chapter7; public class NotInitialization { /**
* @param args
*/
public static void main(String[] args) {
//通过子类引用父类的静态字段,不会导致子类初始化
System.out.println(SubClass.value);
} }

上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会初始化,因此通过子类来引用父类中的静态字段,只会触发父类初始化而不不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并没有规定,这点取决于虚拟机的具体实现。对于Sun HotSpot虚拟机来说,可以通过-XX:+TraceClassLoading参数看到此操作是否会导致子类的加载(事实上SubClass被加载了)。

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

package com.xtayfjpk.jvm.chapter7;

public class NotInitialization {

    /**
* @param args
*/
public static void main(String[] args) { //通过数组定义来引用类,不会触发此类的初始化
SuperClass[] sca = new SuperClass[10]; } }

上述代码运行后(SuperClass重用上一例子代码),并没有输出“SuperClass init!”,说明并没有触发SuperClass的初始化阶段。但是这段代码触发另一个名为“[Lcom.xtayfjpk.jvm.chapter7.SuperClass;”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成,直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

c.引用常量池中的常量,不会触发定义常量类的初始化(static final修饰的字段)

package com.xtayfjpk.jvm.chapter7;

public class ConstClass {

    public static final String HELLOWORLD = "hello world";

    static {
System.out.println("ConstClass Inited");
} } package com.xtayfjpk.jvm.chapter7; public class NotInitialization { /**
* @param args
*/
public static void main(String[] args) { //常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,不会导致该类初始化
System.out.println(ConstClass.HELLOWORLD);
} }

上述代码运行后,也没有输出“ConstClass init!”,这是因为虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但是编译阶段将此常量值“hello world”存储到了NotInitialization类的常量池中,对象常量ConstClass.HELLOWORLD的引用实际都转化为NotInitialization类对自身常量池的引用。也就是说实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

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

疑问:

Q: 加载,验证,准备,初始化和缷载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。那使用阶段不是确定的么?是指不一定使用?

类加载过程

一、加载

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

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

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

c.在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

虚拟机规范的这三点要求实际上并不具体,因此虚拟机实现与具体应用的灵活度相当大。例如“通过一个类的全限制名来获取定义此类的二进制字节流”,并没有指明二进制字节流要从一个Class文件中获取,准确地说是根本没有指明要从哪里获取及怎样获取。虚拟机设计团队加载阶段搭建了一个相当开放的,广阔的舞台,Java发展历程中,许多举足轻重的Java技术都建立在这一基础上,例如:

a.从ZIP包中读取,这很常见,最终成为日后JAR,EAR,WAR格式的基础

b.从网络中获取,这种场景最典型的应用就是Applet

c.运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generatProxyClass来为特定接口生成 *$Proxy的代理类的二进制字节流。

d.由其它文件生成,典型场景:JSP应用。

e.从数据库中读取,这种场景相对少见些,有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

f. .......

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

二、验证

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

尽管验证阶段是非常重要的,并且验证阶段的工作量在虚拟机的类加载子系统中占了很大一部分。如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError错误或者其子错误。具体应当检查哪些方面,如何检查,何时检查,虚拟机规范都没有明确说明,所以不同的虚拟机对验证的实现可能会有所不同,但大致上都会完成四个阶段的验证过程:文件格式验证,元数据验证,字节码验证和符号引用验证。

三、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配(注意是类变量即static修饰的字段,不是示例变量)。需要强调的是:首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配是Java堆中。其次是这里所说的初始值“通常情况“下是数据类型的零值,假设一个类变量定义为:

public static int value = 123;

那么变量value在准备阶段过后的初始值是0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器”“方法中的,所以把value赋值为123的动作将在初始化阶段才会被执行。

上面提到的”通常情况“下初始值为零值,但是,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会初始初始化为ConstantValue属性所指定的值,假设上面类变量value被定义为:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性表,在准备阶段虚拟会就会根据ConstantValue的设置将value赋值为123。(静态常量即static final 修饰的字段直接赋值)

四、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,在Class文件中它以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现。直接引用与符号引用的关联是:

a.符号引用(Symbolic References)以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。

b.直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标就必须已经在内存中存在。

虚拟机规范中并未规定解析阶段发生的具体时间,只要求在执行了newarray,heckcast,getfield,etstatic,instanceof,invokeinterface,invokespecial,nvokestatic,invokevritual,multianewarray,new,putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时才去解析它。

同一个符号引用可能会进行多次解析请求,虚拟机实现可能会对第一次解析的结果进行缓存从而避免解析动作重复进行。无论是否真正执行了多次解析操作,虚拟机需要保证的都是在同一个实体中,如果一个符号引用被成功解析过,那么后续的解析请求就应当一直成功;同样的,如果第一次解析失败了,其它指令对这个符号引用的解析请求也应该收到相同的异常。

解析动作主要针对的是类或接口,字段,方法,接口方法四类符号引用进行的,分别对应于常量池的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info及CONSTANT_InterfaceMetodref_info四种常量类型。下面是这四种引用的解析过程。

1.类或接口的解析过程

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析过程需要包括以下3个步骤:

a.如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D类的加载器去加载这个类C。在加载过程中,由于元数据验证,字节码验证的需要,又将可能触发其它相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。

b.如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似”[Ljava.lang.Integer"的形式,那么会按照第a点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组和元素的数组对象。

c.如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限,如果发现不具体访问权限,将抛出java.lang.IllegalAccessError错误。

2.字段解析——多态

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属性的类或接口的符号引用。如果在解析这个类或接口符号引用过程中出现了任何异常,都会导致字段符号引用解析失败。如果解析成功完成,那么这个字段所属性的类或接口用C表示,虚拟机规范要求如下步骤对C进行后续字段的搜索:

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

b.否则,如果C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称答字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。

c.否则,如果C不是java.lang.Object的话,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

d.否则,查找失败,招抛出java.lang.NoSuchFieldError错误。

3.类方法解析

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

a.类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现了class_index中索引的C是个接口,那么直接就抛出java.lang.IncompatibleClassChangeError错误。

b.如果通了第a步,在类C中查找是否有简单名称和描述符与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

c.否则,在类C的父类中递归查找是否有简单名称和字段描述符都与目标匹配的方法,则返回这个方法的直接引用,查找结束。

d.否则,在类C实现的接口列表及它们的父接口中递归查找否有简单名称和字段描述符都与目标匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError错误。

e.否则,宣告查找失败,抛出java.lang.NoSuchMethodError错误。

最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证;如果发现不具务对此方法的访问权限,将抛出java.lang.IllegalAccessError错误。

4.接口方法解析

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

a.与类方法解析相反,如果在接口方法表中发现了class_index中索引的C是个类而不是接口,那么直接就抛出java.lang.IncompatibleClassChangeError错误。

b.否则,在接口C中查找是否有简单名称的描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

c.否中,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

d.否则,宣告查找失败,抛出java.lang.NoSuchMethodError错误。

由于接口中的所有方法都默认是public的,所以不存在访问权限问题,因此接口方法的符号解析应该不会抛出java.lang.IllegalAccessError错误。

五、初始化

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

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其它资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。()方法执行过程可能会影响程序运行行为的一些特点与细节,如下:

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

疑问:

Q: JVM的符号引用替换为直接引用什么意思?

A: 参考

在JVM中类加载过程中,在解析阶段,Java虚拟机会把类的二进制数据中的符号引用替换为直接引用。

1.符号引用(Symbolic References):

  符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

2.直接引用:

直接引用可以是:

(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)

(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)

(3)一个能间接定位到目标的句柄

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

双亲委派模型

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

从Java开发人员的角度看,类加载器还可以划分得更细一些,如下:

  1. 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将放置在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定路径中的,并且是虚拟机能识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放置在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接使用。
  2. 扩展类加载器(Extension ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader):这个类加载器由sum.misc.Launcher.$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也被称为系统类加载器。它负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序由这三种类加载器互相配合进行加载的,如果有需要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如下图:

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

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

双亲委派模型的破坏

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前--即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在 的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。

JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。

双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。

但是,如果基础类又要调用用户的代码,那该怎么办呢。

这并非是不可能的事情,一个典型的例子便是JNDI(Java 命名与目录接口 )服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?

为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

疑问:

Q: 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将放置在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定路径中的,并且是虚拟机能识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放置在lib目录中也不会被加载)类库加载到虚拟机内存中.什么叫能被虚拟机识别的类库,都有哪些?

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。

一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:

1.局部变量表——存放方法参数和方法内部定义的局部变量

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。

在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。

局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

2.操作数栈——用于执行运算的后入先出栈

操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。

当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来进行参数传递的。

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部*部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图:

3.动态连接——保存栈帧所属方法的引用

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

4.方法返回地址——调用方法的程序计数器的值作为返回地址

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

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

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

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

  1. 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

疑问:

Q: 32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。意思是说,如果是64位虚拟机,对于像long这种数据类型,它的实际所占的栈容量为2,则占有2* 8 =16个字节。是么?那32位虚拟机是8个字节?

Q: 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。也就是说每个栈帧都会持有这个栈帧对应调用方法的引用,这样在线程切换或者方法的连续调用中当要切换或者调用到这个栈帧时可以根据程序计数器(偏移量)结合这个引用,再次找到这个方法在内存中上次执行到的位置,继续执行代码。 是这样么?

方法调用——多态

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

一、方法解析

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

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

与之相对应,在Java虚拟机里提供了四条方法调用字节码指令,分别是:

  1. invokestatic:调用静态方法
  2. invokespecial:调用实例构造器方法,私有方法和父类方法。
  3. invokevirtual:调用虚方法。
  4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

只要能被invokestatic与invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。

Java中的非虚方法除了使用invokestatic与invokespecial指令调用的方法之后还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接收都进行多态选择,又或者说多态选择的结果是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。

二、分派

1.静态分派——重载

下面是一段程序代码:

package com.xtayfjpk.jvm.chapter8;

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

执行结果为:

hello guy...

hello guy...

但为什么会选择执行参数为Human的重载呢?在这之前,先按如下代码定义两个重要的概念:Human man = new Man();

上面代码中的“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么?如下面的代码:

//实际类型变化
Human man = new Man();
man = new Woman(); //静态类型变化
sd.sayHello((Man)man);
sd.sayHello((Woman)man);

解释了这两个概念,再回到上述代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sd”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意定义了两个静态类型相同,实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型在编译期是可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但是很多情况下,这个重载版本并不是“唯一的”,往往只能确定一个“更适合的”版本。这种模糊的结论在0和1构成的计算机世界中算是个比较“稀罕”的事件,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

2.动态分派——重写

动态分派与重写(Override)有着很密切的关联。如下代码:

package com.xtayfjpk.jvm.chapter8;

public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
} public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

这里显然不可能是根据静态类型来决定的,因为静态类型都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原是是这两个变量的实际类型不同。那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢,我们使用javap命令输出这段代码的字节码,结果如下:

public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #16 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man
3: dup
4: invokespecial #18 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
11: dup
12: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
24: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
27: dup
28: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
36: return

0-15行的字节码是准备动作,作用是建立man和woman的内存空间,调用Man和Woman类的实例构造器,将这两个实例的引用存放在第1和第2个局部变量表Slot之中,这个动作对应了代码中这两句:

Human man = new Man();
Human woman = new Woman();

接下来的第16-21行是关键部分,第16和第20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将执行的sayHello()方法的所有者,称为接收者(Receiver),第17和第21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令(都是invokevirtual)还是参数(都是常量池中Human.sayHello()的符号引用)都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirutal指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下步骤:

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

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3.单分派与多分派

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

在编译期的静态分派过程选择目标方法的依据有两点:一是静态类型;二是方法参数,所以Java语言的静态分派属于多分派类型。在运行阶段虚拟机的动态分派过程只有接收者的实际类型一个宗量作为目标方法选择依据,所以Java语言的动态分派属于单分派类型。所在Java语言是一门静态多分派,动态单分派语言。

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

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要在运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真的进行如此频繁的搜索。面对这种情况,最常用的优化手段就是在类的方法区中建立一个虚方法表(Virtual Method Table,也称vtable,与此对应,在invokeinterface执行时也会用到接口方法表,Interface Method Table,也称itable),使用虚方法表索引来代替元数据据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会被替换为指向子类实现版本的地址入口。

疑问:

Q: 而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派与多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派与动态多分派情况。什么叫宗量数?

内存模型JMM——定义程序中各个变量的访问规则

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variable)与Java编译中所说的变量略有区别,它包括了实例字段,静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不存在竞争的问题。为了获得比较好的执行效率,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器调整代码执行顺序这类权限。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,(内存交互规则:)1、线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,而不能直接读写主内存中的变量。2、不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图:

内存间交互操作

一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成。

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

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。Java内存模型只是要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是说read与load之间、store与write之间是可以插入其它指令的,如果对主在内中的变量a,b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了执行上述八种基础操作时必须满足如下规则:

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

疑问:

Q: unlock(解锁):作用于主内存变量,它把一个处理锁定的状态的变量释放出来,释放后的变量才可以被其它线程锁定,unlock之前必须将变量值同步回主内存。unlock之前必须将变量值同步回主内存是指调用unlock之前必须先调用store,然后调用write操作,还是说unlock之前会自动调用store,然后调用write操作?

volatile变量的特殊规则——保证变量对所有线程的可见性及禁止指令重排序优化

当一个变量定义成volatile之后,它将具备两种特性:第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其它线程是可以立即得知的,变量值在线程间传递均需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见。

关于volatile变量的可见性,很多人误以为以下描述成立:“volatile对所有线程是立即可见的,对volatile变量所有的写操作都能立即返回到其它线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据并不能得出“基于基于volatile变量的运算在并发下是安全的”这个结论。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不致的情况,因此可以认为不存在一致性问题),但是Java里的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

由于volatile变量只能保证可见性,在不符合以下条件规则的两处场景中,仍然需要通过加锁来保证原子性。

这两种场景下volatile变量可以保证安全:

  1. 运算结果不依赖变量的当前值,或者能确保只有单一的线程改变变量的值。
  2. 变量不需要与其它的状态变量共同参与不变约束。

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

Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后
//将initialized设置为true来通知其它线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true; //假设以下代码在线程B中执行
//等线程A待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized) {
sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

上面为一段伪代码,其中描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码”initialized = true“被提前执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。

Java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store、write操作时需要满足如下的规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load操作。线程T对变量V的use操作可以认为是与线程T对变量V的load和read操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量V之前都必须先从主内存刷新最新值,用于保证能看到其它线程对变量V所作的修改后的值。
  2. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store操作;并且,只有当线程T对变量V执行的后一个动作是store操作的时候,线程T才能对变量V执行assign操作。线程T对变量V的assign操作可以认为是与线程T对变量V的store和write操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量V的修改。
  3. 假定操作A是线程T对变量V实施的use或assign动作,假定操作F是操作A相关联的load或store操作,假定操作P是与操作F相应的对变量V的read或write操作;类型地,假定动作B是线程T对变量W实施的use或assign动作,假定操作G是操作B相关联的load或store操作,假定操作Q是与操作G相应的对变量V的read或write操作。如果A先于B,那么P先于Q。这条规则要求valitile修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。

原子性、可见性、有序性

volatile只能保证可见性和有序性;

synchronized可以保证原子性,可见性以及有序性。

Java内存模型是围绕着并发过程中如何处理原子性、可见性、有序性这三个特征来建立的,下面是这三个特性的实现原理:

1.原子性(Atomicity)——原子性变量操作包括read、load、use、assign、store和write六个

由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块---synchronized关键字,因此在synchronized块之间的操作也具备原子性。

2.可见性(Visibility)——volatile、synchronized、final

可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每使用前立即从内存刷新。因为我们可以说volatile保证了线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。

3.有序性(Ordering)

Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

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

先行发生原则:Happen-Before:——线程执行节点的不确定便不能保证先行发生原则

如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很啰嗦,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(Happen-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依赖。

先行发生原则是指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存*享变量的值、发送了消息、调用了方法等。它意味着什么呢?如下例:

//线程A中执行
i = 1; //线程B中执行
j = i; //线程C中执行
i = 2;

假设线程A中的操作”i=1“先行发生于线程B的操作”j=i“,那么我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,结出这个结论的依据有两个,一是根据先行发生原则,”i=1“的结果可以被观察到;二是线程C登场之前,线程A操作结束之后没有其它线程会修改变量i的值。现在再来考虑线程C,我们依然保持线程A和B之间的先行发生关系,而线程C出现在线程A和B操作之间,但是C与B没有先行发生关系,那么j的值可能是1,也可能是2,因为线程C对应变量i的影响可能会被线程B观察到,也可能观察不到,这时线程B就存在读取到过期数据的风险,不具备多线程的安全性。

下面是Java内存模型下一些”天然的“先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

  1. 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生“呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与先生发生原则之间基本没有什么关系,所以衡量并发安全问题一切必须以先行发生原则为准。

疑问:

Q: final关键字的可见性是指:被final修饰的字段是构造器一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见final字段的值。final关键字保证可见性的原因是因为其不可变吧?怎么理解这句话?

Hotspot JVM的常用选项

本文将介绍Hotspot JVM的常用选项。

选项的分类

Hotspot JVM提供以下三大类选项:

标准选项:这类选项的功能是很稳定的,在后续版本中也不太会发生变化。运行java或者java -help可以看到所有的标准选项。所有的标准选项都是以-开头,比如-version, -server等。

X选项:比如-Xms。这类选项都是以-X开头,可能由于这个原因它们被称为X选项。运行java -X命令可以看到所有的X选项。这类选项的功能还是很稳定,但官方的说法是它们的行为可能会在后续版本中改变,也有可能不在后续版本中提供了。

XX选项:这类选项是属于实验性,主要是给JVM开发者用于开发和调试JVM的,在后续的版本中行为有可能会变化。

XX选项的语法

如果是布尔类型的选项,它的格式为-XX:+flag或者-XX:-flag,分别表示开启和关闭该选项。

针对非布尔类型的选项,它的格式为-XX:flag=value

在了解这些约定的规范后,我们就可以来看看一些比较常用的选项了。

指定JVM的类型:-server,-client

Hotspot JVM有两种类型,分别是server和client。它们的区别是Server VM的初始堆空间会大一些,默认使用的是并行垃圾回收器。Client VM相对来讲会保守一些,初始堆空间会小一些,使用串行的垃圾回收器,它的目标是为了让JVM的启动速度更快。

JVM在启动的时候会根据硬件和操作系统会自动选择使用Server还是Client类型的JVM。

在32位Windows系统上,不论硬件配置如何,都默认使用Client类型的JVM。

在其他32位操作系统上,如果机器配置有2GB及以上的内存同时有2个以上的CPU,则默认会使用Server类型的JVM

64位机器上只有Server类型的JVM。也就是说Client类型的JVM只在32位机器上提供。

你也可以使用-server和-client选项来指定JVM的类型,不过只在32位的机器上有效,原因见上面一条。

详细内容请参见:http://docs.oracle.com/javase/7/docs/technotes/guides/vm/server-class.html

指定JIT编译器的模式:-Xint,-Xcomp,-Xmixed

我们知道Java是一种解释型语言,但是随着JIT技术的进步,它能在运行时将Java的字节码编译成本地代码。以下是几个相关的选项:

-Xint表示禁用JIT,所有字节码都被解释执行,这个模式的速度最慢的。

-Xcomp表示所有字节码都首先被编译成本地代码,然后再执行。

-Xmixed,默认模式,让JIT根据程序运行的情况,有选择地将某些代码编译成本地代码。

-Xcomp和-Xmixed到底谁的速度快,针对不同的程序可能有不同的结果,基本还是推荐用默认模式。

-version和-showversion

-version就是查看当前机器的java是什么版本,是什么类型的JVM(Server/Client),采用的是什么执行模式。比如,在我的机器上的结果如下:

$ java -version

java version "1.7.0_71"

Java(TM) SE Runtime Environment (build 1.7.0_71-b14)

Java HotSpot(TM) 64-Bit Server VM (build 24.71-b01, mixed mode)

表示我机器上java是运行在mixed模式下的Server VM。

-showversion的作用是在运行一个程序的时候首先把JVM的版本信息打印出来,这样便于问题诊断。个人建议Server类型的程序都把这个选项打开,这样可以发现一些配置问题,比如程序需要JDK1.7才能运行,而有的机器上装有多个JDK的版本,打开这个选项可以避免使用了错误版本的Java。

查看XX选项的值: -XX:+PrintCommandLineFlags, -XX:+PrintFlagsInitial和-XX:+PrintFlagsFinal

与-showversion类似,-XX:+PrintCommandLineFlags可以让在程序运行前打印出用户手动设置或者JVM自动设置的XX选项,建议加上这个选项以辅助问题诊断。比如在我的机器上,JVM自动给配置了初始的和最大的HeapSize以及其他的一些选项:

$ java -XX:+PrintCommandLineFlags -version

-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedOops -XX:+UseParallelGC

java version "1.7.0_71"

Java(TM) SE Runtime Environment (build 1.7.0_71-b14)

Java HotSpot(TM) 64-Bit Server VM (build 24.71-b01, mixed mode)

相关另外两个选项:-XX:+PrintFlagsInitial表示打印出所有XX选项的默认值,-XX:+PrintFlagsFinal表示打印出XX选项在运行程序时生效的值。

内存大小相关的选项

-Xms 设置初始堆的大小,也是最小堆的大小,它等价于:-XX:InitialHeapSize

-Xmx 设置最大堆的大小,它等价于-XX:MaxHeapSize。

比如,下面这条命令就是设置堆的初始值为128m,最大值为2g。

java -Xms128m -Xmx2g MyApp

如果堆的初始值和最大值不一样的话,JVM会根据程序的运行情况,自动调整堆的大小,这可能会影响到一些效率。针对服务端程序,一般是把堆的最小值和最大值设置为一样来避免堆扩展和收缩对性能的影响。

-XX:PermSize 用来设置永久区的初始大小

-XX:MaxPermSize 用来设置永久区的最大值

永久区是存放类以及常量池的地方,如果程序需要加载的class数量非常多的话,就需要增大永久区的大小。

-Xss 设置线程栈的大小,线程栈的大小会影响到递归调用的深度,同时也会影响到能同时开启的线程数量。

OutofMemory(OOM)相关的选项

如果程序发生了OOM后,JVM可以配置一些选项来做些善后工作,比如把内存给dump下来,或者自动采取一些别的动作。

-XX:+HeapDumpOnOutOfMemoryError 表示在内存出现OOM的时候,把Heap转存(Dump)到文件以便后续分析,文件名通常是java_pid.hprof,其中pid为该程序的进程号。

-XX:HeapDumpPath=: 用来指定heap转存文件的存储路径,需要指定的路径下有足够的空间来保存转存文件。

-XX:OnOutOfMemoryError 用来指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本。

比如,下面的命令可以使得在发生OOM的时候,Heap被转存到文件/tmp/heapdump.hprof,同时执行Home目录中的cleanup.sh文件。

$ java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:OnOutOfMemoryError ="sh ~/cleanup.sh" MyApp

个人觉得几个选项还是非常有用的,它可以使得你有相关的信息来分析OOM的根源。

新生代相关的选项

在介绍新生代相关的选项前,先简要介绍下Hotspot VM的Heap分代的背景知识。很多面向对象程序在运行时都具有如下两点特征:

新创建的对象通常不会存活很长时间,也就是夭折了。

很少有老对象引用到新对象。

基于这里两点,把新老对象分别放在不同的区域(分别叫做新生代和老生代)可以针对新老对象的特点使用不同的回收算法,同时在回收新对象的时候不用遍历老对象,从而提高垃圾回收的效率。

在Hotspot JVM中,它进一步地将新生代分成了三个区域,一个稍大的区域Eden和两个较小但大小相等的Survivor区域(分别叫做From和To)。一般来讲,新对象首先分配在Eden区域,当Eden区域满的时候,会执行一次Minor GC。MinorGC使用的是标记-复制算法。垃圾回收器会首先标记Eden和From区域中还存活的对象,然后把它们全部移动到To区域,这样Eden和From区域的空间就可以全部回收了,最后再将指向From和To区域的指针交换一下。

下图展示了MinorGC的流程,绿色区域表示空闲空间,红色表示活动对象,黄色表示可以回收的对象。

简要总结一下,对象在新生代的生命周期是,它首先在Eden区域诞生,如果对象在MinorGC时还存活的话,就移动到Survivor区域。在后续的MinorGC的时候,如果对象还继续存活的话,就在两个Survivor区域将倒腾。那对象什么时候会被移动到老生代呢?有以下条件:

1、Survivor区域中存活对象占用Survivor空间达到了指定的阈值。

2、对象在Survivor空间每倒腾一次其年龄就加1,如果一个对象的年龄达到了一个阈值,也会被移动到老生代。

3、大对象会在创建的时候就会被直接放到老生代。

由此可见,新生代的空间大小很重要:如果新生代空间过小,就会导致对象很快就被移动到老生代,从而使得某些原本可以及时回收的对象存活的时间过长,而且老生代回收的代价更大。那相反,如果新生代空间过大,就会使得某些存活时间长的对象在新生代倒腾了很多次,影响到新生代回收垃圾的效率。这就需要根据应用的特点,找到一个合适的值。Hotspot提供了如下一些选项来调节新生代的参数:

-XX:NewSize和-XX:MaxNewSize分别用来设置新生代的最小和最大值。需要注意的是,新生代是JVM堆的一部分,新生代的空间大小不能大于老生代的大小,因为在极端的情况下,新生代中对象可能会被全部移到老生代,因此-XX:MaxNewSize最大只能设为-Xmx的一半。

-XX:NewRatio用来设置老生代和新生代大小的比例,比如-XX:NewRatio=2表示1/3的Heap是新生代,2/3的Heap是老生代。使用这个选项的好处是新生代的大小也能随着Heap的变化而变化。

  • -XX:SurvivorRatio用来设置新生代中Eden和Survivor空间大小的比例,需要注意的是有两个Survivor。比如-XX:SurvivorRatio=8表示Eden区域在新生代的8/10,两个Survivor分别占1/10。调节Survivor空间的时候也注意要折中,如果Survivor空间小的话,那么很有可能在一次MinorGC的时候Survivor空间就满了,从而对象就被移到了老生代;如果Survivor空间大的话,那么Eden区域就小了,从而导致MinorGC的发生得更频繁。

    总得来说,调节新生代的目标是:1)避免对象过早地被移到了老生代 2)也要避免需要长期存活的对象在新生代呆的时间过长,这会提高MinorGC发生的频率以及增加单次MinorGC的时间。这需要针对程序的运行情况做一些分析。接下来就介绍了一个参数来分析新生代对象年龄的分布。

-XX:+PrintTenuringDistribution

-XX:+PrintTenuringDistribution让JVM在每次MinorGC后打印出Survivor空间中的对象的年龄分布。比如:

Desired survivor size 75497472 bytes, new threshold 15 (max 15)

  • age 1: 19321624 bytes, 19321624 total
  • age 2: 79376 bytes, 19401000 total
  • age 3: 2904256 bytes, 22305256 total

    从第一行中可以看出JVM期望的Survivor空间占用为72M,对象被移到老年代中的年龄阈值为15。其中期望的Survivor空间大小为Survivor空间大小 x -XX:TargetSurvivorRatio的值。

接下来的一行,表示年龄为1的对象约19M,年龄为2的对象约79k,年龄为3的对象约为2.9M,每行后面的数值表示所有小于等于该行年龄的对象的总共大小,比如最后一行就表示所有年龄小于等于3的对象的总共大小为约22M(等于所有年龄对象大小的和)。因为目前Survivor空间中对象的大小22M小于期望Survivor空间的大小72M,所以没有对象会被移到老年代。

假设下一次MinorGC后的输出结果为:

Desired survivor size 75497472 bytes, new threshold 2 (max 15)

  • age 1: 68407384 bytes, 68407384 total
  • age 2: 12494576 bytes, 80901960 total
  • age 3: 79376 bytes, 80981336 total
  • age 4: 2904256 bytes, 83885592 total

    上次MinorGC后还存活的对象在这次MinorGC年龄都增加了1,可以看到上次年龄为2和3的对象(对应在这次GC后的年龄为3和4)依然存在(大小未变),而一部分上次对象年龄为1的对象在这次GC时被回收了。同时可以看到这次新增了约68M的新对象。这次MinorGC后Survivor区域中对象总的大小为约83M,大于了期望的Survivor空间的大小72M,因此它就把对象移到老年代的年龄的阈值调整为2,在下次MinorGC时一部分对象就会被移到老年代了。(动态对象年龄判断:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于或等于该年龄的对象就直接进行老年代,无须等到MaxTenuringThreshold中要求的年龄。)

相关的调整选项有:

-XX:InitialTenuringThreshold 表示对象被移到老年代的年龄阈值的初始值

-XX:MaxTenuringThreshold 表示对象被移到老年代的年龄阈值的最大值

-XX:TargetSurvivorRatio 表示MinorGC结束了Survivor区域中占用空间的期望比例。

这些参数的调节没有统一的标准,但是有两点可以借鉴:

如果Survivor中对象的年龄分布显示很多对象在经历了多次GC最终年龄达到了-XX:MaxTenuringThreshold(年龄阈值的最大值)才被移到老年代,这可能说明-XX:MaxTenuringThreshold设置得过大,也有可能是Survivor的空间过大。

如果-XX:MaxTenuringThreshold的值大于1,但是很多对象年龄都不大于1,那就得关注一下期望的Survivor空间。如果每次GC后Survivor中对象的大小都没有超过期望的Survivor空间大小,则说明GC工作得很好。反之,则说明可能Survivor空间小了,使得新生成的对象很快就被移到了老年代了。

吞吐量优先收集器的相关选项

衡量JVM垃圾收集器的两个基本指标是吞吐量和停顿时间。吞吐量是指执行用户代码的时间占总的时间的比例,总的时间包括执行用户代码的时间和垃圾回收占用的时间。在垃圾回收的时候执行用户代码的线程必须暂停,这会导致程序暂时失去响应。停顿时间就是衡量垃圾回收时造成的用户线程暂停的时间。这两个指标是在一定程度是相互矛盾的,不可能让一个程序的吞吐量很高的同时停顿时间也短,只能以优先选择一个目标或者折中一下。因此,不同的垃圾回收器会有不同的侧重点。

在Hotspot JVM中,侧重于吞吐量的垃圾回收器是Parallel Scavenge,它的相关选项如下:

-XX:+UseParallelOldGC 表示新生代和老生代都使用并行回收器,其中的Old表示老年代的意思,而不是旧的意思。

-XX:ParallelGCThreads=n 表示配置多少个线程来回收垃圾。默认的配置是如果处理器的个数小于8,那么就是处理器的个数;如果处理器大于8,它的值就是3+5N/8。也可以根据程序的需要去设置这个值,比如你的机器有16核,上面有4个Java程序,那么设置将这个值设置为4比较合理,因为JVM不会去探测同一机器上有多少个Java程序。

-XX:UseAdaptiveSizePolicy 表示是否开启自适应策略,打开这个开关后,JVM自动调节JVM的新生代大小,Eden和Survivor的比例等参数。用户只需要设置期望的吞吐量(-XX:GCTimeRatio)和期望的停顿时间(-XX:MaxGCPauseMillis)。然后,JVM会尽量去向用户期望的方向去优化。

此外,如果机器只有一个核的话,采用并行回收器可能得不偿失,因为多个回收线程会争抢CPU资源,反而造成更大的消耗。这时,就最好采用串行回收器,相关的参数是-XX:+UseSerialGC

CMS收集器

CMS收集器(ConcurrentMarkandSweep),是一个关注系统停顿时间的收集器。它的主要思想是把收集器分成了不同的阶段,其中某些阶段是可以用户程序并行的,从而减少了整体的系统停顿时间。它主要分成了以下几个阶段:

初始标记 initial mark

并发标记 concurrent mark

重新标记 remark

并发清理 concurrent clean

并发重置 concurrent reset

凡是名字以并发开头的阶段都是可以和用户线程并行的,其他阶段也是要暂停用户程序线程。

CMS虽然能减少系统的停顿时间,但是它也有其缺点:

从它的名字可以看出,它是一个标记-清除收集器,也就说运行了一段时间后,内存会产生碎片,从而导致无法找到连续空间来分配大对象。

CMS收集器在运行过程中会占用一些内存,同时系统还在运行,如果系统产生新对象的速度比CMS清理的速度快的话,会导致CMS运行失败。

当上面的任何一种情况发生的时候,JVM就会触发一次Full GC,会导致JVM停顿较长时间。

它的相关选项如下:

-XX:+UseConcMarkSweepGC 表示老年代开启CMS收集器,而新生代默认会使用并行收集器。

-XX:ConcGCThreads 指定用多少个线程来执行CMS的并发阶段。

-XX:CMSInitiatingOccupancyFraction 指定在老生代用掉多少内存后开始进行垃圾回收。与吞吐量优先的回收器不同的是,吞吐量优先的回收器在老生代内存用尽了以后才开始进行收集,这对CMS来讲是不行的,因为吞吐量优先的垃圾回收器运行的时候会停止所有用户线程,所以不会产生新的对象,而CMS运行的时候,用户线程还有可能产生新的对象,所以不能等到内存用光后才开始运行。比如-XX:CMSInitiatingOccupancyFraction=75表示老生代用掉75%后开始回收垃圾。默认值是68。

-XX:+ExplicitGCInvokesConcurrent 如果在代码里面显式调用System.gc(),那么它还是会执行Full GC从而导致用户线程被暂停。采用这个选项使得显式触发GC的时候还是使用CMS收集器。

-XX:+DisableExplicitGC 一个相关的选项,这个选项是禁止显式调用GC

GC日志相关的选项

分析GC问题不可避免地要查看GC日志,下面是一些GC日志相关的选项:

-XX:+PrintGC,等同于-verbose:gc 表示打开简化的GC日志,相关输出如下:

[GC 425355K->351685K(506816K), 0.2175300 secs]

[Full GC 500561K->456058K(506816K), 0.6421920 secs]

其中以GC开头的行表示发生了一次Minor GC,后面的数字表示收集前后Heap空间的占用量,圆括号里面表示Heap大小,最后的数字表示用了多少时间。比如:上面的例子中,表示在这次GC新生代空间占用从425355K降到了351685K,总的新生代空间为506816K,这次GC耗时0.22秒。

通过这个选项只能看到一些基本信息,而且所有收集器的输出在这个模式下都是一样的。

-XX:+PrintGCDetails 这个选项会打印出更多的GC日志,不同的收集器产生的日志会不一样。因此,在后续的文章中再介绍不同收集器的日志格式。

-XX:+PrintGCTimeStamps and -XX:+PrintGCDateStamps 这两个选项把GC的时间戳显示在GC的日志中。

其中,-XX:+PrintGCTimeStamps打印GC发生的时间相对于JVM启动的时间,-XX:+PrintGCDateStamps表示打印出GC发生的具体时间。

比如,以下是-XX:+PrintGCTimeStamps的输出

0,185: [GC 66048K->53077K(251392K), 0,0977580 secs]

0,323: [GC 119125K->114661K(317440K), 0,1448850 secs]

0,603: [GC 246757K->243133K(375296K), 0,2860800 secs]

以下是-XX:+PrintGCDateStamps打开后的输出

2014-12-26T17:52:38.613-0800: 3.395: [GC 139776K->58339K(506816K), 0.1442900 secs]

-Xloggc: 表示把GC日志写入到一个文件中去,而不是打印到标准输出中。

需要注意的是:这些和GC日志相关的选项可以在JVM已经启动后再开启,可以通过jinfo这个工具去设置。具体可以参见jinfo的帮助文件。这样就可以在需要诊断问题的时候再开启GC日志。

疑问:

Q: Xint 中int的详细名词是什么?有没有所有参赛详细名词的全称,方便记忆?

Q: -showversion的作用是在运行一个程序的时候首先把JVM的版本信息打印出来,这样便于问题诊断。

-showversion是配置在哪里,怎么使用?

Q: -XX:+PrintGC,等同于-verbose:gc 表示打开简化的GC日志。这个怎么使用,放在配置参数上?

jvm新生代中为什么要有Survivor区,且必须是2个

一、为什么会有年轻代

我们先来屡屡,为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。你先想想,如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

二、年轻代中的GC

新生代大小=eden大小+1个survivor大小

新生代大小(PSYoungGen total 9216K)=eden大小(eden space 8192K)+1个survivor大小(from space 1024K)

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8(Eden):1(一个survivor),为啥默认会是这个比例,接下来我们会聊到。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片,因为新生代对象大部分是朝生夕死的,复制的对象很少,效率高。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

三、一个对象的这一辈子

我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就*去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我15岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

四、为什么要有Survivor区

先不去想为什么有两个Survivor区,第一个问题是,设置Survivor区的意义在哪里?

如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。

好,那我们来想想在没有Survivor的情况下,有没有什么解决办法,可以避免上述情况:

方案 优点 缺点

增加老年代空间 更多存活对象才能填满老年代。降低Full GC频率 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长

减少老年代空间 Full GC所需时间减少 老年代很快被存活对象填满,Full GC频率增加

显而易见,没有Survivor的话,上述两种解决方案都不能从根本上解决问题。

我们可以得到第一条结论:Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历15次Minor GC还能在新生代中存活的对象,才会被送到老年代。

五、为什么要设置两个Survivor区

设置两个Survivor区最大的好处就是解决了碎片化,下面我们来分析一下。

为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:

刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。

我绘制了一幅图来表明这个过程。其中色块代表对象,白色框分别代表Eden区(大)和Survivor区(小)。Eden区理所当然大一些,否则新建对象很快就导致Eden区满,进而触发Minor GC,有悖于初衷。

碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存。。。画面太美不敢看。。。这就好比我们爬山的时候,背包里所有东西紧挨着放,最后就可能省出一块完整的空间放相机。如果每件行李之间隔一点空隙乱放,很可能最后就要一路把相机挂在脖子上了。

那么,顺理成章的,应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到15次,该对象就会被送到老年代中。下图中每部分的意义和上一张图一样,就不加注释了。

上述机制最大的好处就是,整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片。

那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,我认为两块Survivor区是经过权衡之后的最佳方案。

上一篇:Java多线程系列目录(共43篇)(转)


下一篇:Java多线程系列目录(共43篇)