美文网首页
《深入理解Java虚拟机》读书笔记(第三章)

《深入理解Java虚拟机》读书笔记(第三章)

作者: 迦若莹 | 来源:发表于2017-12-17 21:39 被阅读15次

    概述


    内存回收主要考虑三件事情

    • 那些内存需要回收?
    • 什么时候回收?
    • 如何回收?

    程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭,每一个栈帧中分配多少内存基本上是在类结构定下来时就已知的。因此这三个区域不需要过多的考虑回收的问题,因为当方法结束或者线程结束是,内存自然就跟着回收了。

    而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样。而且我们只有在程序的运行期间时才能知道创建哪些对象,这部分的内存分配和回收都是动态的,垃圾收集器关注的也是这部分。

    对象已死吗?


    引用计数算法

    给对象添加一个一弄计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。但是如果两个对象互相引用,引用计数算法就无法判断这两个对象是否存活。

    可达性分析算法

    通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径名称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链时,则证明次对象不可用的。

    在Java语言中,可作为GC Roots的对象包括下面几种:

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

    再谈引用

    • 强引用:new出来的对象是强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
    • 软引用:在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还是没有足够的内存,才会抛出内存溢出的异常。
    • 弱引用:被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉纸杯弱引用关联的对象。
    • 虚引用:它是最弱的一种关系,不会对生存时间构成任何影响。为一个对象设置虚引用的唯一目的就是在被垃圾回收时收到一个通知。

    生存还是死亡

    真正宣告一个对象死亡,至少要进行两次标记过程。

    如果对象经过可达性算法分析后,发现没有与GC Roots相连的引用链,它会被第一次标记和筛选。筛选的条件是:是否有必要执行finalize()方法。当且仅当该对象覆盖finalize()方法,并且没有被虚拟机调用过,才能称之为有必要执行finalize()方法。

    如果有必要执行,虚拟机会把这个对象放在一个名字叫做F-Queue的队列中执行。但是并不承诺等待finalize()方法运行结束。这是对象逃脱死亡命运的最后一次机会,稍后GC会对这些对象进行第二次小规模标记,如果这些对象没有拯救自己,那么基本上就被回收了。

    PS:finalize()方法只会被执行一次。另外,能用finalize()方法实现的功能用 try-finally或者其他的方式都能做的更好,所以针对finalize()方法可以完全不用。

    回收方法区

    方法区的回收主要是两部分类容:废弃常量和无用的类。

    判定为无用的类要满足以下三个条件:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

    虚拟机可以对满足上述3个条件的无用类进行回收,这里仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。

    垃圾收集算法


    标记-清除算法

    算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

    主要不足点有两个:

    • 效率问题,标记和清除两个过程的效率都不算高
    • 空间问题,标记清除后会产生大量不连续的内存随便,空间碎片太多可能会导致以后在程序运行的过程中需要分配较大的对象时,找不到足够的连续内存而提前触发另一次垃圾收集动作。

    后续收集算法都是基于这种思路对其不足进行改进。

    复制算法

    将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将存货的对象复制到另外一块上面,然后把已使用的内存空间一次清理掉。

    不足点:将内存缩小为原来的一半为代价。

    这种算法主要是来回收新生代(但不是1:1的比例来划分内存空间)。新生代是一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一个Survivor。当GC时,把Eden和Survivor还存活的对象一次性复制到另一个Survivor空间上,最后清理掉Eden空间和刚开用过的Survivor空间。

    标记-整理算法

    标记过程仍然与“标记-清楚”算法一样,但是后续步骤不是直接对可回收的对象进行清理,而是让所有已存活的对象都向一段移动,然后直接清理掉端便捷以外的内存。

    分代收集算法

    分代收集算法并没有新的思想。一般是把Java堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时有大批对象死去,只有少量对象存活,就采用复制算法。而老年代因为对象存活率较高,而且没有额外的空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法。

    HotSpot的算法实现


    枚举根节点(哪些内存需要回收)

    可作为GC Roots的节点主要在全局性的引用(常量货类静态属性),执行上下文(栈帧中的本地变量表)。

    可达性分析对执行时间的敏感主要是GC停顿上,GC进行时必须停顿所有的Java执行线程(Stop The World)。其实这里很好理解,就像妈妈在家里打扫房间,肯定是要求你坐在座位上别动,否则一边打扫房间,而你到处走动并且时不时的乱丢东西,那么这个打扫房间是永远扫不完的。

    但是现在很多应用方法区就数百兆大小,如果逐个检查这里面的引用,那必然会耗费非常多的时间。虚拟机有没有办法解决这个问题呢?有的。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。

    安全点(什么时候回收)

    在OopMap的协助下,HotSpot可以快速而且准确的完成GC Roots的枚举。但是也会存在一些问题。OopMap内容变化的指令非常多的,如果每一个指令都去生成对应的OopMap,那需要大量的额外空间,这样GC的空间成本将会变得很高。

    其实HotSpot也不会这么笨的,去给每条指令都生成OopMap,前面已经提到,只是在特定的位置记录了这些信息,这些位置称之为安全点(Savepoint),即程序执行时并非在所有的地方都能停顿下来GC,只有到达安全点才能停顿。

    这里的停顿有两种方式:

    • 抢先式中断:在GC发生时,首先把所有的线程全部中断。如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在机会没有虚拟机采取抢先式中断了。
    • 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志。哥哥线程执行时主动去轮询这个标志,发现中断标志为真时,就自己中断挂起。

    安全区域

    如果有些线程是“不执行状态”(比如线程Sleep,或者Bolcked),这时候线程是无法相应JVM的中断请求,“走”到安全点去中断挂起。对于这种情况,就需要安全区域(Safe Region)来解决。

    我们可以把Safe Region看作是Safepoint的扩展版。在这个区域中的任意地方,开始GC都是安全的。

    在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,当这段时间JVM要发起GC时,就不管标识自己为Safe Region的线程了。当线程要离开Safe Region时,它会检查系统是否完成了枚举根节点(或者是GC的整个过程),如果完成了,线程就继续执行。否则就等待直到收到可以离开Safe Region的信号为止。

    垃圾收集器


    Serial收集器

    新生代、单线程收集器。采用复制算法。它的“单线程”的意义并不仅仅说明它只会使用一个CPU或者一条收集线程去完成GC,更重要的是,它在GC时,必须暂停其他所有的工作线程(Stop The World),知道它收集结束。

    ParNew收集器

    新生代收集器。相当于Serial的多线程版本(并行的多线程收集器)。采用复制算法。也需要Stop The World。

    Parallel Scavenge收集器

    新生代收集器,使用多线程和复制算法,需要Stop The World,也被称为“吞吐量优先”收集器。

    Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓的吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

    高吞吐量和低停顿对虚拟机来收是两个矛盾的事。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以搞笑的利用CPU的时间,尽快完成运算任何,主要适合在后台运算而不需要太多交互的任务。

    Serial Old收集器

    相当于Serial收集器的老年代版本,单线程收集器,需要Stop The World,财务“标记-整理”算法。

    Parallel Old收集器

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

    CMS收集器

    Concurrent Mark Sweep收集器是一种获取最短回收停顿时间为目标的老年代收集器,采用“标记-清除”算法。整个过程分四个步骤:

    • 初始标记:需要Stop The World,单线程,仅仅只是标记一下GC Roots能直接关联到的对象(不会递归)。
    • 并发标记:不需要Stop The World,单线程,与用户线程一起工作,进行GC Roots Tracing的过程。
    • 重新标记:需要Stop The World,多线程工作,目的是为了修正并发标记期间,因用户程序继续运作而导致系统标记产生变动的那一部分对象的标记记录。
    • 并发清理:不需要Stop The World,单线程,清除GC Roots不可达对象。

    但是CMS收集器有一下3个明显的缺点:

    • CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会Stop The World,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量降低。
    • CMS收集器无法处理浮动垃圾,可能出现“ConCurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在继续运行,所以自然就会有新的垃圾不断产生,出现在标记过程之后产生的垃圾,在本次收集中CMS无法处理处理掉他们,只能等待下一次GC再解决。这部分称之为“浮动垃圾”。也是由于垃圾手机阶段用户线程还要继续运行,所以还需要预留足够的内存空间给用户线程使用,CMS无法像其他收集器那样等老年代几乎被填满再GC。
    • CMS采用的是“标记-清除”算法,所有可能会有大量空间碎片产生。往往老年代还有很大空间剩余,但是却没有足够大的连续空间来分配当前对象,这时候就不得不提前触发一次Full GC。

    G1收集器

    Garbage-First收集器。它具有以下特点:

    • 并行与并发:G1手机起可以通过并发的方式让Java程序继续执行。
    • 分代收集:分代的概念在G1中仍然得以保留。G1收集器不需要其他收集器那样互相配合管理整个GC堆,它自身可以用不同的方式去处理新创建的对象和已经存活一段时间的对象、熬过多次GC的就对象。
    • 空间整合:从整体上看G1收集器是基于“标记-整理”算法实现的,从局部(两个Region)之间上来看是基于“复制”算法实现的。无论如何,这意味着G1收集器不会产生空间碎片。
    • 可预测的停顿:除了追求低停顿,G1还能简历可预测的停顿时间建模,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N秒。

    G1不再像其他收集器那样使用新生代和老年代,它的内存布局余其他的收集器有很大的差别,它将Java堆划分为多个大小相等的独立区域(Region),虽然它还保留着新生代和老年代的概念,但是新生代和老年代不再是物理隔离的,它们都是一部分Region的集合。

    G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许手机的时间,优先回收价值最大的Region。

    G1收集器中,Region之间的对象引用以及其他收集器中新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remeber Set,当Reference类型的数据引用的对象处于不同的Region之中(分代的例子就是老年代中的对象引用了新生代的对象),那么就会把信息记录到被引用对象所属的Region的Remebered Set中。当GC时,在GC根节点的枚举范围中加入Remebered Set既可保证不对全堆扫描。

    如果不记维护Remebered Set的操作,G1收集器可以分为以下几个步骤:

    • 初始标记:需要Stop The World,单线程,仅仅只是标记一下GC Roots能直接关联到的对象(不会递归)。
    • 并发标记:不需要Stop The World,单线程,与用户线程一起工作,进行GC Roots Tracing的过程。
    • 最终标记:需要Stop The World,多线程工作,目的是为了修正并发标记期间,因用户程序继续运作而导致系统标记产生变动的那一部分对象的标记记录。
    • 最终筛选:需要 Stop The World(和CMS不同),多线程,清除GC Roots不可达对象。根据SUN公司透露的信息,原本这个阶段也是可以和用户程序一起并发执行的,但是因为只是回收一部分Region,GC时间可控制,而且停顿用户线程也可以大幅度提高GC效率,所以是需要Stop The World。

    内存分配与回收的策略


    对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,则按线程优先在TLAB上分配,少数情况也可能直接分配在老年代中。分配规则不是100%固定的,取决于当前使用的是哪一种垃圾收集器的组合,还有虚拟机与内存相关的参数设置。

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

    对象优先在Eden分配

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

    大对象直接进入老年代

    所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对与你及的内存分配来说,就是一个坏消息(更坏的消息,就是一群“朝生夕灭”的“短命大对象”,写程序时应该尽量避免)。经常出现大对象容易导致内存还有不少空间时,就提前出发垃圾收集以获取足够的连续空间来“安置”他们。

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

    虚拟机给每个对象定义一个对象年龄(Age)计数器。如果对象在Eden区出生,并经过第一次Minor GC后仍然存货,并且能够被Survivor容纳,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代中。

    动态对象年龄判定

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

    空间分配担保

    在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么Minor GC可以确保是安全的。如果不大于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果不允许,这是要进行一次Full GC。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行一次冒险的Minor GC。如果小于,则进行一次Full GC。

    大部分情况下,还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

    相关文章

      网友评论

          本文标题:《深入理解Java虚拟机》读书笔记(第三章)

          本文链接:https://www.haomeiwen.com/subject/xybuwxtx.html