Java虚拟机系列——检视阅读(一)

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

参考

java虚拟机系列

入门掌握JVM所有知识点

2020重新出发,JAVA高级,JVM

JVM基础系列

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

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

RednaxelaFX知乎问答

RednaxelaFX博客

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

内存区域

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

image

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虚拟机堆栈的情况下,还可以提供对最大和最小大小的控制。

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

3.本地方法栈

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

4. 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没有任何引用链相连时,则证明此对象是不可用的,如下图:

image

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

image

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

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

image

现在商业虚拟机都是采用这种算法来回收新生代,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%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,“标记-整理”算法被提出,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清除,而是让所有存活对象都向一端移动,然后直接清除掉端边界以外的内存。“标记-整理”算法执行示意图如下:

image

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

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

垃圾收集器

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

image

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

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

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

image

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

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

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

image

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收集器的工作过程如下图:

image

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

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

image

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收集器的内存回收过程是与用户线程一起并发地执行的。执行图如下:

image

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收集器的运作步骤中并发和需要停顿的阶段:

image

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文件本质上就是一张表,它由如下数据项构成:

image

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

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种常量类型具体含义如下:

image

各常量项结构:
image
疑问:

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

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

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

访问标志——access_flags

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

image

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

image

你可能感兴趣的:(Java虚拟机系列——检视阅读(一))