JVM之垃圾收集器与内存分配策略


垃圾收集器与内存分配策略

  • 1. 对象存活判定法
    • 1.1 引用计数法
    • 1.2 可达性分析
  • 2. 引用分类
    • 2.1 强引用
    • 2.2 软引用
    • 2.3 弱引用
    • 2.4 虚引用
  • 3. 垃圾收集算法
    • 3.1 标记 - 清除算法
    • 3.2 标记 - 复制算法
    • 3.3 标记 - 整理算法
    • 3.4 分代收集算法
  • 4. GC
    • 4.1 Partial GC
      • Minor GC/Young GC
      • Major GC/Old GC
    • 4.2 FULL GC
  • 5. 垃圾收集器
    • 5.1 Serial 收集器
    • 5.2 ParNew 收集器
    • 5.3 Parallel Scavenge 收集器
    • 5.4 Serial Old 收集器
    • 5.5 Parallel Old 收集器
    • 5.6 CMS 收集器
    • 5.7 G1 收集器
  • 6. 内存分配策略
    • 6.1 对象优先在Eden分配
    • 6.2 大对象直接进入老年代
    • 6.3 长期存活的对象将进入老年代
    • 6.4 动态对象年龄判定
  • 7. 知识点补充
    • 7.1 方法区回收
    • 7.2 内存泄漏问题

1. 对象存活判定法 垃圾回收,将已经分配的内存,但不再使用的内存回收回来。但是在进行垃圾回收时,面临的一个问题便是:如何辨别对象是否存活?
对此,有两种方法用来判定对象是否存活。
1.1 引用计数法 做法:为每个对象添加一个引用计数器,用来统计该对象的引用的个数。如果一个对象的计数为0,则说明该对象已经死亡,可以被回收。
具体实现:如果一个引用指向该对象,那么该对象的计数器值加1,如果该对象的引用指向其他对象,那么该对象的计数器值减1。
缺陷:1、需要额外的空间来存储计数器;2、频繁的更新操作;3、无法处理循环引用对象。
/** * @author wangzhao * @date 2019/9/3 20:51 */ public class School {Student student; @Override protected void finalize() throws Throwable { System.out.println("School GC"); }}/** * @author wangzhao * @date 2019/9/3 20:51 */ public class Student {School school; @Override protected void finalize() throws Throwable { System.out.println("Student GC"); } }

/** * @author wangzhao * @date 2019/9/3 20:51 */ public class Test {public static void main(String[] args) {School school = new School(); Student student = new Student(); school.student = student; student.school = school; school = null; student = null; System.gc(); } }

上述代码的引用关系如下图所示:
JVM之垃圾收集器与内存分配策略
文章图片

可以看到在栈中并没有引用指向堆中的对象,意味着这两个对象可以被回收。但是如果使用引用计数法的话,那么这两个对象的计数值并不为0,所以并不能进行垃圾回收,便造成了内存泄漏。
1.2 可达性分析 可达性分析可以理解为从(GC ROOT)出发,根据引用关系向下搜索,如果某个对象到GC Roots间没有引用链相连,那么则认为那个对象已死亡,可以被回收。
JVM之垃圾收集器与内存分配策略
文章图片

可以作为GC Root有如下对象:
  1. 虚拟机栈中引用的对象
  2. 静态类型的引用变量
  3. 常量引用的对象
  4. Native方法引用的对象
  5. JVM内部的引用,基本类型对应的Class对象,一些常驻的异常对象,还有系统类加载器
  6. synchronized关键字所持有的对象
  7. 反映JVM内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等
HotSpot采用可达性分析算法判定对象是否存活。
2. 引用分类 JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这 4 种引用强度依次逐渐减弱。
引用分类的目的是,是对象尽可能长的贮存在内存中,当内存在垃圾回收后仍然非常紧张,那就可以抛弃这些对象。相当于缓存池,缓存满了可以丢弃缓存。
2.1 强引用 强引用是最传统的“引用”的定义,是指再程序代码之中普通存在的引用赋值,类似Object obj = new Object()这种引用惯性系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
2.2 软引用 软引用用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。通过SoftReference类可以实现软引用。
2.3 弱引用 软引用也用来描述那些非必须的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。通过WeakReference类可以实现软引用。
2.4 虚引用 虚引用是最弱的引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用存在的唯一目的只是为了能在对象被垃圾收集器回收时收到一个系统通知
3. 垃圾收集算法 3.1 标记 - 清除算法 标记-清除算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收。
JVM之垃圾收集器与内存分配策略
文章图片

死亡对象占据的内存会被标记为空闲内存,并记录在一个空闲列表中。当需要新建对象时,在空闲列表中寻找适合的内存给该对象。
该算法主要缺点有两个:
  1. 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;
  2. 内存空间碎片化 ,如果要给较大对象分配内存时,可能出现大量零散的内存,无法给较大对象分配内存的情况。
3.2 标记 - 复制算法 复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
JVM之垃圾收集器与内存分配策略
文章图片

该算法的优点是不会产生内存碎片,缺点是来回复制对象耗费时间、并且有一半的内存被保留
3.3 标记 - 整理算法 标记 - 清除算法的缺点是,复制对象耗费时间,并且有一部分的内存需要被保留。标记 - 整理的算法则是将所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
JVM之垃圾收集器与内存分配策略
文章图片

解决了内存碎片化的问题,代价整理算法性能开销较大。
3.4 分代收集算法 分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。
当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。
4. GC 4.1 Partial GC 部分收集,指目标不是完整收集整个Java堆的垃圾收集。
Minor GC/Young GC
指目标只是新生代的垃圾收集。
触发条件
  1. Eden区域满
  2. 新创建的对象大小 > Eden所剩空间
Major GC/Old GC
指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
4.2 FULL GC 收集整个Java堆和方法区的垃圾收集。
触发条件
  1. 调用System.gc时,系统建议执行Full GC,但是不必然执行。
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
5. 垃圾收集器 JVM之垃圾收集器与内存分配策略
文章图片

5.1 Serial 收集器 该收集器是一个单线程工作的收集器。在它进行垃圾收集时,必须暂停其他所有工作线程。
JVM之垃圾收集器与内存分配策略
文章图片

Serial适合用于客户端模式下的默认新生代收集器,其简单、高效,它是所有收集器里额外内存消耗最小的;Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
对于用户桌面的应用场景或部分微服务应用中,分配给虚拟机管理的内存一般来说不会特别答,通常几十甚至一两百兆的新生代,停顿时间可以控制在十几、几十毫秒,用户可以完全接收。
5.2 ParNew 收集器 Serial的多线程并行版本,其余并无区别。
JVM之垃圾收集器与内存分配策略
文章图片

ParNew适合用于服务端模式下的新生代收集器。
5.3 Parallel Scavenge 收集器 同样是多线程收集器,与ParNew最大的区别是可控制吞吐量
吞吐量 = 执行用户代码时间 / (运行用户代码时间 +垃圾收集时间)
JVM之垃圾收集器与内存分配策略
文章图片

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间
收集器尽可能保证内存回收花费的时间不超过设定值。GC停顿时间缩短是以牺牲吞吐量和新生代空间换取来的,而不是提高垃圾回收速度。
-XX:GCTimeRatio:设置吞吐量的大小。应当是一个大于0小于100的整数,默认是99。
-XX:+UseAdaptiveSizePolicy
当这个参数打开后,不需要手工指定新生代的大小(-Xmn)、EdenSurvivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSize Threshold)等细节参数,虚拟机会根据系统运行情况自动配置,这是区别于ParNew的另一个特征。
5.4 Serial Old 收集器 Serial Old收集器是Serial收集器的老年代版本,运行示意图同Serial。为什么不使用Seral收集器,源于老年代对象的特点,采用的是“标记-整理”算法。
5.5 Parallel Old 收集器 Parallel Old收集器是Parallel Scavenge收集器的老年代版本,运行示意图同Parallel Scavenge,源于老年代对象的特点,采用的是“标记-整理”算法。
5.6 CMS 收集器 采用标记-清除算法,追求最短停顿时间的收集器。
适合互联网网站或者基于浏览器的B/S系统。
JVM之垃圾收集器与内存分配策略
文章图片

  1. 初始标记
初始标记仅仅只是标记一下GC Roots能直接管理到的对象,速度很快。
  1. 并发标记
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时过长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  1. 重新标记
重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  1. 并发清除
并发清理阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,该阶段也是可以与用户线程同时并发的。
5.7 G1 收集器 G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
JVM之垃圾收集器与内存分配策略
文章图片

G1收集器为每个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
JVM之垃圾收集器与内存分配策略
文章图片

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用地Region中分配新对象。
  • 并发标记:进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可于用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的并发时有引用变动的对象。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成会收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。
6. 内存分配策略 6.1 对象优先在Eden分配 对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC
6.2 大对象直接进入老年代 所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
6.3 长期存活的对象将进入老年代 当对象在新生代中经历过一定次数(默认为15)的Minor GC后,就会被晋升到老年代中。
6.4 动态对象年龄判定 为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
7. 知识点补充 7.1 方法区回收 方法区的内存回收目标主要是针对 常量池的回收 和 对类型的卸载。回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:
  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
7.2 内存泄漏问题 虽然Java拥有垃圾回收机制,但同样会出现内存泄露问题,比如下面提到的几种情况:
(1) 诸如 HashMap、Vector 等集合类的静态使用最容易出现内存泄露,因为这些静态变量的生命周期和应用程序一致,所有的对象Object也不能被释放,因为他们也将一直被Vector等应用着。
private static Vector v = new Vector(); public void test(Vector v){for (int i = 1; i<100; i++) { Object o = new Object(); v.add(o); o = null; } }

在这个例子中,虚拟机栈中保存着Vector 对象的引用 vObject 对象的引用 o 。在for 循环中,我们不断的生成新的对象,然后将其添加到 Vector 对象中,之后将 o引用置空。问题是虽然我们将 o引用置空,但当发生垃圾回收时,我们创建的 Object 对象也不能够被回收。因为垃圾回收在跟踪代码栈中的引用时会发现 v引用,而继续往下跟踪就会发现 v 引用指向的内存空间中又存在指向 Object对象的引用。也就是说,尽管o 引用已经被置空,但是Object对象仍然存在其他的引用,是可以被访问到的,所以 GC无法将其释放掉。如果在此循环之后,Object对象对程序已经没有任何作用,那么我们就认为此 Java程序发生了内存泄漏。
(2) 各种资源连接包括数据库连接、网络连接、IO连接等没有显式调用close关闭,不被GC回收导致内存泄露。
【JVM之垃圾收集器与内存分配策略】(3) 监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。

    推荐阅读