读书笔记,《深入理解java虚拟机》,第三章 垃圾收集器与内存分配策略

时间:2024-01-14 14:50:44
要实现虚拟机,其实人们主要考虑完成三件事情:
第一,哪些内存需要回收;
第二,什么时候回收;
第三,如何回收。

第二节,对象已死吗
   垃圾收集其实主要是针对java堆里面的数据来说的,传统的垃圾收集方法主要是基于引用计数算法,比如windows里面的com或者是actionscript3里面的flash player,以及python语言,但是引用计数算法无法解决对象之间互相影响的问题。所以Java语言没有选用这种方式。
   现在主流的商用语言,比如java,c#,都是采用可达性分析的算法来判断对象是否存活。这里就引入了GC Roots的概念。
   在java语言中,GC Roots的主要包括以下几种,
   第一种是虚拟机栈所引用的对象;
   第二种是方法区中类静态属性引用的对象
   第三是方法区常量引用的对象,
   还有一种就是本地方法中,JNI引用的对象。

   既然说到引用的话,那么这里面就提到了强引用,软引用,弱应用,虚引用,四种应用概念。其中在我的博客上,有一篇文章介绍了关于使用弱应用来构建敏感数据缓存的例子。可以参考下。

    接下来书中就介绍了,程序如何判断一个对象是生存还是死亡,以及在Java中可以使用Finalize方法来最后一次挽救对象的例子。但是由于finalize是java早期为了吸引C++程序员来使用Java,然后就引入Finalize()来适配C++中的析构方法,现在已经不建议使用这个方法了。

第三节,垃圾收集算法
   这一节主要介绍了三种常见的垃圾收集算法。
   第一种,标记-清除算法
   清除之后原来那个空间就留在那,这是一种最基础的数据收集算法,不过它有两点不足,一个是效率问题,标记和清除两个过程的效率都不高,第二个是空间问题,标记清楚之后,会产生大量不连续的内存碎片,而空间碎片太多,可能会导致以后在程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存,而不得不提前触发另外一次垃圾收集动作。

   第二种 复制算法
    它的基本思路就是把可用的内存大容量划分为大小相等的两块,然后每次要做垃圾收集的时候,就把其中一块里面仍然存活的对象复制到另外一块,从头开始连续排放,然后原来的这一块就整体清除。这种算法的优点就是实现简单,运行高效,而缺点就是,内存缩小为原来的一半,代价太高了。虚拟机在新生代中采用了这种算法,但是在实现的时候是把内存分为三块(Eden区、Survivor1区,Survivor2区),而且也不是大小相等的,而是一个大两个小。之所以在新生代中采用复制算法,是因为新生代的对象绝大部分是朝生夕死,对象存活率低,所以采用复制算法比较划算。

   第三种,标记-整理算法
   由于复制收集上法在对象存活率较高的时候需要进行较多的复制操作,效率就降低了。人们就提出了一种,标记-整理算法。先对存活的对象作标记,然后就让所有存活的对象都移动到该片内存的一端,然后再直接清除掉段边界以外的内存。这种算法比较适合老年代的内存回收,因为在老年代中的对象存活率很高。

第四节,Hotspot的的算法实现
   这里面主要提出了几个概念,枚举根节点,安全点,安全区域。   
   关于这一节可以参考我在博客中的介绍oopmap的一篇文章。里面对于为什么要有oopmap,以及oopmap对于枚举跟节点有什么帮助都说的比较清楚。

第五节,垃圾收集器
   这里面主要介绍了新生代的三种垃圾收集器,老年代的三种垃圾收集器,还有最新的G1垃圾收集器。
   除了g1收集器,其他的收集器都要把新生代和老年代的垃圾收集器一对一的配合使用。
   而G1收集器一个人就可以搞定新生代和老年代,实际上在采用g1收集器的时候,他内部并不是按照新生带到年代的这种方法来分的,他是把内存分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理上隔离的了,他们都是一部分区域的集合。
   G1收集器的运作大致可划分为以下几个步骤,初始标记,并发标记,最终标记,和筛选回收
   在本节的最后作者,给了一个小结来说明,我们该如何解读虚拟机的垃圾回收日志。其实不同的虚拟机的日志的格式是不一样的,所以要完全看懂这个日志的话,我估计还是要到甲骨文的官网上去找他们的说明文档。

第六节,内存分配与回收策略
    JAVA技术体系中所提倡的自动内存管理,最终都可以归结为自动化的解决两个问题:给对象分配内存,以及回收分配给对象的内存。
   本节主要是通过各种代码以及虚拟机的启动参数,然后再结合虚拟机的日志来说明Java的内存分配与释放策略。
   对象的内存分配在大方向上来讲就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按照线程优先在TLAB上分配,当然,这也不是绝对的,因为有的时候也会在老年带上分配。所以具体的细节还取决于使用了哪一种垃圾收集器,及其相关选项。

   第一小节,对象优先在Eden区上分配
   本节给了一个例子来说明,对象在新生代Eden区中分配,当Eden区中没有足够空间进行分配的时候,虚拟机将发起一次minor GC。这个例子中通过虚拟机选项限制Java堆的大小为20M,然后又限制新生代和老年带各10M。连续分配几个大对象,当分配到最后一个大对象的时候,新生代的内存就不够用了,此时通过看虚拟机的日志就可以看到,已经出发了一次Minor GC,有的对象已经转移到老年代上了。
   这里面有提到一点,就是MinorGC的速度一般会比MajorGC慢十倍以上

   第二小节,大对象直接进入老年代
   所谓的大对象,就是指需要大量连续内存空间的java对象,最典型的他对象就是那种很长的字符串以及数组,大对象对于虚拟机的内存分配来说是一个坏消息,那么比遇到一个大对象更坏的消息就是遇到一群朝生夕死的短命大对象,写程序的时候应当避免。因为经常出现大对象很容易导致内存还有不少空间的时候,就提前触发了一次垃圾收集以获取足够的连续空间来安置他们。
   所以本节结合虚拟机提供的一个参数 -XX:PretenureSizeThreshold,当某个对象大于这个参数值之后,就直接在老年代上分配。在例子中,作者给把这个参数设置为3M,然后写这一段代码来直接分配一个4M的大对象,通过看虚拟机日可以发现他确实是在老年代中分配的。


   第三小节,长期存活的对象将进入老年代
    虚拟机采用分代的思想来管理内存,那么回收时就必须能够识别哪些对象应放在新生代哪些对象应放在老年代,虚拟机给每个对象定义了一个对象年龄计算器。对象首先放在eden区,经过第一次Minor GC后,如果仍然能够存活,就会被转移到survivor空间。在survivor空间,年龄增长到一定程度的时候,就会晋升到老年带中。这个晋升的年龄是可以设置的,默认是15岁。在本书中,作者故意把这个默认年龄改成一岁,来做测试。

   第四小节,动态对象年龄判断
   为了更好的适应不同程序的内存状况,虚拟机并不是永远要等待对象的年龄达到阈值之后才晋升到老年代。如果在Suvivor空间中相同年龄对象的大小的总和大于那个空间的一半,那么年龄大于或等于该年龄的对象,就可以直接进入老年。

   第五节,空间分配担保。
   所谓的担保空间其实就是老年代的空间。在发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立那么Minor GC就可以保证是安全的。如果不成立,则需立即就会检查一下handlepromotionFailure 设置的值是否允许担保失败,如果允许那么会继续检查老年代最大可用的连续空间是否大于历次,、晋升到老年代对象的平均大小,如果大于,将尝试进行一次MinorGC。如果小于,或者HandlePromotionFailure被设置为不允许冒险,那这时就要改为进行一次FullGC。
    这里作者详细说明了什么是老年代的担保,以及是担保失败的风险。作者有提到,如果老年代担保失败,虚拟机就会再进行一次FullGC以回收更多的内存,但是我很怀疑其实再执行FullGC也不一定能够清理出更多的内存。此时要么就是再向系统要更多的内存,要么就是系统也内存不够了,然后虚拟机直接报OutOfMemory。