天天看点

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考

深入理解Java虚拟机

  • 第3章 垃圾回收器与内存分配策略
    • 3.2 对象已死?
      • 3.2.1 引用计数法
      • 3.2.2可达性分析算法
      • 3.2.3 再谈引用
      • 3.2.4 生存还是死亡
      • 3.2.5 回收方法区
    • 3.3 垃圾收集算法
      • 3.3.1 分代收集理论
        • GC分类
      • 3.3.2 标记-清除算法
      • 3.3.3 标记-复制算法
      • 3.3.4 标记-整理算法
      • 概念补充:
      • 对象分配的过程:
        • 为对象分配内存:TLAB
  • 参考

第3章 垃圾回收器与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来。

GC需要完成的3件事情:

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

前面提到的Java内存运行时区域的各部分,其中程序计数器、虚拟机栈、本地方法在3各区域随线程而生,随线程而灭,栈中的栈帧随着方法进入和退出而出栈入栈,而每个栈帧分配多少内存基本在类结构确定下来时就已知。所以,这三个区域的内存分配和回收都具备确定性,不需要考虑去回收问题,线程结束时自然就回收了。

本章主要关注于Java堆和方法区这两个具有显著不确定性的区域。

3.2 对象已死?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

3.2.1 引用计数法

Java 堆 中每个具体对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

优点:

引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。

缺点:

难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。

3.2.2可达性分析算法

可达性分析算法又叫根搜索算法,该算法的基本思想就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些起始点开始根据引用链往下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 对象之间没有任何引用链的时候(不可达),证明该对象是不可用的,于是就会被判定为可回收对象。

如下图所示: Object5、Object6、Object7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考

GC Roots的对象包括固定的几种和一些运行时临时加入的。

固定的GC Roots有:

1)在虚拟机栈中引用的对象,如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量

2)在方法区中类静态属性引用的对象,如Java类引用类型静态变量

3)在方法区中常量引用对象,如字符串常量池里的引用

4)在本地方法栈中JNI,如基本数据类型对应的Class对象,还有系统类加载器

5)所有被同步锁(synchronized关键字)持有的对象

6)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等。

一个判断的小方法:由于Root采用的是栈方法存放变量和指针,所以如果一个指针保存了堆里面的对象,但是自己又不存放在堆内存中, 则它是一个Root。

动态的加入则是考虑了分代收集和局部回收,所以收集某个区域对象时,要将其它区域的对象加入到GC Roots中。

判断可达性分析时,要确保根节点的美剧要在一个一致性的快照中进行,也就是说要避免根节点集合的对象引用关系在枚举期间不断变化,所以垃圾收集过程必须停顿所有用户线程,即“stop the world”,类似的停顿在垃圾收集时的标记过程也会发生,只是后者时间更短。

3.2.3 再谈引用

无论是通过引用计数器还是通过可达性分析来判断对象是否可以被回收都设计到“引用”的概念。JDK1.2之后, Java 中根据引用关系的强弱不一样,将引用类型划为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用:Object obj = new Object()这种方式就是强引用,只要这种强引用存在,垃圾收集器就永远不会回收被引用的对象。JDK1.2之前的传统引用。
  • 软引用:用来描述一些有用但非必须的对象。在 OOM 之前垃圾收集器会把这些被软引用的对象列入回收范围进行二次回收。如果本次回收之后还是内存不足才会触发 OOM。在 Java 中使用 SoftReference 类来实现软引用。
  • 弱引用:同软引用一样也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 Java 中使用 WeakReference 类来实现。
  • 虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在完全不影响对象的生存时间,也无法通过虚引用来获取一个对象的实例。一个对象使用虚引用的唯一目的是为了在被垃圾收集器回收时收到一个系统通知。在 Java 中使用 PhantomReference 类来实现。

3.2.4 生存还是死亡

一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后被判定为不可达对象,那么它将被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。对象没有覆盖 finalize() 方法或者该对象的 finalize() 方法曾经被虚拟机调用过,则判定为没必要执行。

finalize()第二次标记:如果被判定为有必要执行 finalize() 方法,那么这个对象会被放置到一个 F-Queue 队列中,并在稍后由虚拟机自动创建的、低优先级的 Finalizer 线程去执行该对象的 finalize() 方法。但是虚拟机并不承诺会等待该方法结束,这样做是因为,如果一个对象的 finalize() 方法比较耗时或者发生了死循环,就可能导致 F-Queue 队列中的其他对象永远处于等待状态,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,如果对象要在 finalize() 中挽救自己,只要重新与 GC Roots 引用链关联上就可以了。这样在第二次标记时它将被移除「即将回收」的集合,如果对象在这个时候还没有逃脱,那么它基本上就真的被回收了。对象的finalize()方法只会执行一次。

所以根据finalize的存在,是否可回收就可以分为三个判断状态:

  • 可触及的:从根节点出发能到达该点。
  • 可复活的:对象的所有引用被释放,但是对象可能在finalize中复活。
  • 不可触及的:对象的finalize被调用,并且没有复活,则进入不可触及状态。不可触及对象不可能复活,因为对象的finalize()方法只会执行一次。

3.2.5 回收方法区

在 Java 虚拟机规范中没有要求方法区实现垃圾收集,而且方法区垃圾收集的性价比也很低。

方法区(永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。

废弃常量的回收和 Java 堆中对象的回收非常类似,这里就不做过多的解释了。

类的回收条件就比较苛刻了。要判定一个类是否可以被回收,要满足以下三个条件:

  • 该类的所有实例已经被回收;
  • 加载该类的 ClassLoader 已经被回收;
  • 该类的 Class 对象没有被引用,无法再任何地方通过反射访问该类的方法

3.3 垃圾收集算法

根据如何判断对象消亡,垃圾收集器算法可分为“引用计数式垃圾收集”和“追踪式垃圾收集”,而本文主要讨论的HotSpot VM是采用的可达性分析算法判断,所以这里讨论的垃圾收集算法都是追踪式垃圾收集”。

这部分可以参考博客图片:(10条消息) 深入理解Java虚拟机-垃圾回收器与内存分配策略_ThinkWon的博客-CSDN博客

3.3.1 分代收集理论

1)弱分代假说:绝大多树对象都是朝生夕灭的。

2)强分代假说:熬过越多次垃圾收集过程的对象就越难被回收。

依据这两个假说,奠定了多款常用垃圾收集器的一直设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过的垃圾收集过程次数)分配到不同的区域中存储。

GC分类

针对HotSpot的实现,其将GC按回收区域分为两种类型:一种是部分收集(Partail GC),一种是整堆收集(Full GC)。

  • 部分收集(Partial GC):
    • 新生代收集(“Minor GC/Young GC”,收集新生代中的可回收对象)
    • 老年代收集(“Major GC/Old GC”,收集老年代中的可回收对象,有时候回合Full GC混淆使用。目前只有CMS收集器支持单独收集老年代)
    • 混合收集(“Mixed GC”,收集整个新生代及部分老年代的可回收对象,只有G1收集器支持)
  • 整堆收集(“Full GC”)收集整个Java堆和方法区中的可回收对象。

但是这两个理论尚不够完整,未考虑到对象之间的跨代引用,于是有了第三个假说。

3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

依据该假说,只需要在新生代上建立一个记忆集(remembered set)将老年代划分成若干小块,标识出哪一块会存在跨代引用,之后Minor GC时,指把该小块内存里包含了跨代引用的老年代对象加入到GC Roots中扫描,再使用可达性分析算法标记回收对象。

3.3.2 标记-清除算法

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

标记阶段:标记出可以回收的对象。

清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

注意:这里的清除是指下次分配空间时,如果要回收的对象空间大小足够,则用该部分空间分配给新的对象。

3.3.3 标记-复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

商用虚拟机大多优先采用这种收集算法回收新生代。为了缓解内存严重浪费问题, 针对具有“朝生夕灭”特点的对象(新生代具有这样特点)提出更优化的半区复制分代策略,叫做“Appel式回收”。其将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只是用Eden和其中的一块Survivor。垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和刚用过的那块Survivor空间。HotSpot虚拟机默认的Eden和Survivor比例是8:1,即每次新生代中可用的内存空间为整个新生代空间的90%,因为有2个Survivor空间,则刚好是8:1:1。当然不能保证每次都只有不多于10%的对象存活,所以需要有个逃生门的安全设计,当Survivor空间不足以容纳一次Minor GC之后存货的对象时,就要依赖于其它区域(老年代)做担保分配,即通过分配担保机制直接进入老年代。

3.3.4 标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。移动存活的对象,那么在移动对象的这个时候程序全部暂停一下,即“stop the world”现象。

总之,是否移动对象都存在弊端,移动则内存回收时更复杂,不移动则内存分配时更复杂。有一些则是采用二者综合,即先采用标记-清除算法,知道额你存空间的碎片化程度大到影响对象分配,则采用一次标记-整理算法收集一次,活动规整的内存空间。

概念补充:

新生代(Young generation)

绝大多数最新被创建的对象会被分配到这里,由于大部分对象在创建后会很快变得不可达,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为 minor GC。

新生代 中存在一个Eden区和两个Survivor区。新对象会首先分配在Eden中(如果新对象过大,会直接分配在老年代中)。在GC中,Eden中的对象会被移动到Survivor中,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代。

可以设置新生代和老年代的相对大小。这种方式的优点是新生代大小会随着整个堆大小动态扩展。参数 -XX:NewRatio 设置老年代与新生代的比例。例如 -XX:NewRatio=8 指定 老年代/新生代 为8/1. 老年代 占堆大小的 7/8 ,新生代 占堆大小的 1/8(默认即是 1/8)。

例如:

-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8
           

老年代(Old generation)

对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这里。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代要少得多。对象从老年代中消失的过程,可以称之为major GC(或者full GC)。

永久代(permanent generation)

像一些类的层级信息,方法数据 和方法信息(如字节码,栈 和 变量大小),运行时常量池(JDK7之后移出永久代),已确定的符号引用和虚方法表等等。它们几乎都是静态的并且很少被卸载和回收,在JDK8之前的HotSpot虚拟机中,类的这些**“永久的”** 数据存放在一个叫做永久代的区域。

永久代一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小。但是JDK8之后取消了永久代,这些元数据被移到了一个与堆不相连的称为元空间 (Metaspace) 的本地内存区域。

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考

对象分配的过程:

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑Gc执行完内存回收后是否会在内存空间中障生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会I放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。·可以设置参数: -XX:MaxTenuringThreshold=进行设置。
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发cC:Major Gc,进行养老区的内存清理。
  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生ooM异常
深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考

为对象分配内存:TLAB

TLAB(Thread Local Allocation Buffer)本地线程分配缓冲。

·堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,·由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。

(具体划分内存空间有指针碰撞和空闲列表两种方式,看2.3.1节对象的创建,这里只介绍解决空间分配的线程安全问题)

解决方案有两种:

  • 一种是同步加锁,为避免多个线程操作同一地址,需要使用加锁等机制,但是这种方式很影响分配速度。
  • 一种是本地线程分配缓存(TLAB)。从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。哪个线程要分配内存就在哪个线程的本地缓冲区种分配。只有本地缓存区用完了才需要同步锁定来分配新的缓存区。

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

·据我所知所有openJDK衍生出来的JVM都提供了TLAB的设计。

小结

深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考
深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略01第3章 垃圾回收器与内存分配策略参考

参考

笔记主要内容来自《深入理解Java虚拟机 第3版》,里面的一些插图来自尚硅谷的宋红康老师的ppt。