对《深入理解Java虚拟机》的总结(一)

这是《深入理解Java虚拟机》第二章和第三章的读书笔记。

Java内存区域

以下的这张图给出了JVM所管理的内存在运行时的数据区域:

Java虚拟机运行时数据区.png

JVM栈:它的生命周期和线程相同。它描述的是Java方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

Java堆:Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的是存放对象的实例,几乎所有的对象实例都在这里进行分配内存。是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。可分为新生代和老年代。

方法区:和Java堆一样是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。也称为永久代。

运行时常量池:是方法区的一部分。用于存放编译期生成的各种字面量和符号引用。它具备动态性。

程序计数器:它的作用可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令(分支、循环、跳转、异常处理等)。在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。

本地方法栈:JVM栈相似。本地方法栈服务于虚拟机执行Native方法,JVM栈服务于执行Java方法。

对象访问

在最简单的访问中也会涉及Java栈、Java堆、方法区这三个最重要的内存区域之间的关系。

比如在代码:Object obj = new Object();如果该语句出现在方法体中,那么Object obj这一部分的语义将会反映在本地变量表中,作为一个reference类型数据出现。new Object()这部分的语义将反映到Java堆中,形成一块存储了Object类型所有实例数据值的结构化内存,根据具体类型以及虚拟机实现的对象内存布局的不同,这块内存的长度是不固定的。另外在方法区中还存储有能找到次对象类型数据的地址信息。

主流访问对象的方式有两种:使用句柄和直接指针。

  • 使用句柄的方式:Java堆中将会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。如下图:
通过句柄访问对象.png
  • 直接指针的方式:Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。如下图:
通过直接指针访问对象.png

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

判断对象是否还活着的算法

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数值就减1;任何时刻计数器都未0的对象就是不可能再被使用的。
ps:JVM不是通过使用引用计数算法来判断对象是否存活的。

根搜索算法(GC Roots Tracing)

Java虚拟机使用该算法判断对象是否存活的。

基本思路:通过一系列的名为“GC Roots”的对象作为起始点,从这些起始点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链想连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用对象。

在Java语言中,可以作为GC Roots的对象包括:

  • 虚拟机栈(栈帧中的本地变量表)中的引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中JNI的引用的对象。

4种引用类型

引用可以分为强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)

  • 强引用就是指在程序代码中普遍存在的,类似Object obj = new Object()这类的引用。只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
  • 软引用用老描述一些还有用的,但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。JDK提供SoftReference类来实现软引用。
  • 弱引用也是用来描述非必须对象的,它比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。JDK提供WealReference类来实现弱引用。
  • 虚引用是最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的是希望能在这个对象被收集器回收的时候收到一个系统通知。JDK中使用PhantomReference类实现。

判断一个对象是生存还是死亡的算法

在根搜索算法中不可达的对象,并非是必须死亡的。要真正宣告一个对象的死亡,至少需要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法以及被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”,那么这个对象就可以死亡了。如果这个对象被判断为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalize线程去执行(虚拟机执行这个方法,但是不保证运行结束)。finalize()方法是对象逃离死亡的最后一次机会,GC将对F-Queue的对象进行第二次标记,如果对象在finalize()中重新建立了引用,那么就不会死亡,否则将会死亡。

垃圾收集算法

以下介绍了“标记-清除算法”、“复制算法”、“标记-整理算法”以及“分代收集算法”。

标记-清除算法

标记-清除算法(Mark-Sweep)是最基本的算法,分为“标记”和“清除”两个阶段。首先标记处所有需要回收的对象,在标记完成之后就统一清除掉所有被标记的对象。
主要缺点:

  • 效率问题。标记和清除的过程效率都不高
  • 空间问题。标记清除以后会产生大量不连续的空间碎片,空间碎片太多会导致程序以后的内存分配问题。
标记清除算法.png

复制算法

复制算法为了解决效率问题。它将可同内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块的内存用完了,就将还存活的对象复制到另一块上,然后再把原先那块内存空间一次清理掉。这样,就每次只对一块内存进行分配,也不用考虑内存碎片问题。

主要缺点:

  • 没存缩小为原来的一半,代价高。
  • 在对象存活率较高的时候就要执行较多的复制操作,效率将会变低。
复制算法.png

现在的商业虚拟机都采用这种算法回收新生代。
在新生代中,有一块比较大的Eden和两块比较小的Survivor空间,每次使用Eden和其中的一块Survivor;回收的时候,将Eden和Survivor中还活着的对象一次性拷贝到另一块Survivor上,清除已被使用的Eden和Survivor。当Survivor不够用的时候,使用老年代进行分担。
所以,默认的Eden和两块Survivor大小比为8:1:1。

标记-整理算法

Mark-Compact算法的标记过程和“标记-清除算法”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

标记整理算法.png

分代收集算法

当前的商业虚拟机都采用“分代收集(Generation Collection)”算法。这种算法根据对象的存活周期的不同将内存划分为几块。一般把Java的堆分为新生代老年代,这样就可以根据各个年代的特点采用最适当地收集算法。

在新生代中,每次垃圾收集时都有大量对象死去,只有少量存活,那就使用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
在老年代中,对象存活率高,没有额外空间对它进行分配担保,使用“标记-清除”算法或者“标记-整理”算法

垃圾回收器

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

collectors.jpg

Serial收集器

这个收集器是个单线程收集器。它在工作的时候必须暂停其他所有的工作线程(Stop The World),直到它收集结束。这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户的正常工作的线程停止掉,然后进行垃圾收集。

它是虚拟机运行在Client模式下的默认新生代收集器。优于其他收集器的地方是:简单而高效。

Serial.png

ParNew收集器

ParNew收集器是Serial收集器的多线程版本。在控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器一样。

它是虚拟机运行在Server模式下的默认新生代收集器。它能与CMS收集器配合工作。它默认开启的线程数和CPU的数量相同。

ParNew.png

Parallel Scavenge收集器

它也是一个新生代收集器,也是使用复制算法,是并行的多线程收集器。它的目标是达到一个可控制的吞吐量(Throughput = 运行用户代码的时间/(运行用户代码的时间+垃圾回收时间))。所以被称为“吞吐量优先”收集器。

主要适用于在后台运行而不需要太多交互的任务。

Serial Old收集器

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

它主要是被在Client模式下虚拟机使用。如果在Server模式下,它有:1)在1.5及以前版本中与Parallel Scavenge收集器搭配使用;2)作为CMS收集器的后备预案,在收集器发生Concurrent Mode Failure的时候使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

ParallelOld.png

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是基于“标记-清除”算法实现的。整个过程分为4个步骤:

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

初始标记需要进行Stop The World,它仅仅是标记一下GC Roots能直接关联到的对象,速度很快;
并发标记就是进行GC Roots Tracing的过程;这个阶段的耗时比较长;
重新标记也需要进行Stop The World,该阶段是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间会比初始标记长些而远远短于并发标记;
并发清除就是进行清除的过程;这个阶段的耗时比较长;

CMS收集器的内存回收过程是与用户线程一起并发地执行的。

CMS收集器很符合现在互联网或者B/S系统服务器的需求——重视服务的响应速度、希望系统停顿时间短。

CMS收集器的显著缺点:

  • CMS收集器对CPU资源非常敏感。CMS默认的回收线程数为:(CPU数量+3)/4。也就是当CPU在4个以上的时候,并发回收时垃圾收集器线程最多占用不超过25%的CPU资源;当CPU不足4个的时候,那么CMS对用户程序的影响就比较大。
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能会出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
  • CMS收集器采用“标记-清除”算法,在收集结束的时候可能会产生大量的空间碎片。
CMS.png

G1收集器

Garbage First收集器。基于“标记-整理”算法,可以非常显著的控制停顿。

特点:

  • 并行与并发:和CMS类似。
  • 分代收集:保留了新生代和来年代的概念,但新生代和老年代不再是物理隔离的了它们都是一部分Region(不需要连续)的集合。同时,为了避免全堆扫描,G1使用了Remembered Set来管理相关的对象引用信息。
  • 空间整合:由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片。
  • 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

步骤:

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

初始阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以用的Region中创建新对象,这个阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这一阶段耗时较长但能与用户线程并发运行。而最终标记阶段需要吧Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但可并行执行。最后筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这一过程同样是需要停顿线程的,但Sun公司透露这个阶段其实也可以做到并发,但考虑到停顿线程将大幅度提高收集效率,所以选择停顿。

G1.png

内存配置与回收策略

Java技术体系中的自动内存管理最终可以归纳为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。

堆的结构图.jpg

对象优先在Eden中分配

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

-Minor GC:新生代GC,指发生在新生代的垃圾收集动作,因为Java对象大多数都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也非常快。
-Major GC/Full GC:老年代GC,指发生在老年代的GC,出现了Major GC经常就会至少有一次Minor GC。Major GC的速度比Minor GC慢10倍以上。

大对象直接进入老年代

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

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

虚拟机给每个对象定义一个年龄计数器,对象在Eden中经历第一次Minor GC仍然存活,就被移动到Survivor空间,设置年龄为1,在Survivor空间中没经历一次Minor GC,年龄加1,当达到默认的年龄15以后,就将被放到老年代。

动态对象年龄判定

如果在Survivor空间中,相同年龄所有对象大小的总和大于Survivor空间的一般,那么年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

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

新生代Eden,Survivor A, Survivor B三块空间和老生代Old之间的流程关系:

堆工作流程.png

参考:

  1. http://www.cnblogs.com/wcd144140/p/5624063.html
  2. https://www.diycode.cc/topics/597