我们从前面的文章中知道了,我们创建的对象实例会存放在堆中,也就是对象实例会在堆里分配内存,所以堆也是垃圾收集的主要区域。但仅仅知道这些是不够的,今天我们就来具体看一看堆以及堆中的垃圾回收。

这里在网上找到了一个比较好的堆相关内容的图,我们就根据这个图来一步步了解堆:

<figcaption> 图片来源于网络 </figcaption>

我们可以看到,堆主要分成了两部分:

  1. 新生代(Young Generation)
  2. 老年代(Old Generation)

新生代(Young Generation)又可以分成

  1. Eden 区
  2. Survivor 0 区
  3. Survivor 1 区

其中,Eden 区、Survivor 0 区 也被称为 From 区和 To 区。(至于为什么要这样分?不要急,后面会说,这里先有个分区概念就行。)

我们来先看整体再看细节

整体上看新生代与老年代

整体上可以看到,堆主要分成了两部分:

  1. 新生代(Young Generation)
  2. 老年代(Old Generation)

对象实例会在新生代里诞生,当新生代区域内存不够时,会进行 垃圾回收 ,被称为 Minor GC,会将新生代区域里一些已经不再使用的对象实例给回收掉,释放出内存。因为新生代的对象实例大部分存活时间都很短,因此 Minor GC 会频繁执行,且执行的速度一般也会比较快;而那些还需要继续使用的对象实例,也就是经过 Minor GC 之后还在的对象实例,年龄会加 1

而当新生代区域里的某个对象实例年龄到达一定程度时,比如 15(默认是 15 ),这个对象实例会被移动到老年代区域。

或者可以形象的理解为,一个新兵在战场(新生代)上,扛过了 15 次 战争(Minor GC)之后还存活着,那么就可以功成名就的去后方指挥部(老年代)当将军了。

不过,老年代区域的内存也是有限的,当老年代区域的内存不够时,也会进行 垃圾回收,被称为 Major GC 或者 Full GC;并且,进行 Full GC 时,经常会伴随着至少一次的 Minor GC(但并非绝对会出现)。因为老年代对象其存活时间长,因此 Full GC 很少执行,且执行速度会比 Minor GC 慢很多。

你可能会有疑问,当老年代发生 Full GC 之后,老年代区域的内存依旧不够,会怎么样?会报 java.lang.OutOfMemoryError: Java heap space 异常,简称 OOM ,也就是内存溢出。至于解决的办法,后面的内容里会说,这里先知道一下就行。

做个堆整体上内容的小总结: 堆整体上分成了新生代和老年代, 对象实例在新生代里诞生;新生代内存不够时,会进行垃圾回收;当一个对象实例经过一定次数的垃圾回收之后还没有被回收,就会进入老年代;当老年代内存不够时,也会进行垃圾回收,而如果老年进行垃圾回收之后,内存依旧不够,就会出现 OOM 异常。

  • 整体上我们已经了解了,但细节上还有一些问题:
  1. 为什么新生代要划分成 Eden 区、From 区、To 区?
  2. 垃圾回收是怎么进行的?
  3. 怎么知道一个对象实例是否该回收?
  4. … …

带着这些疑问,我们再从细节上看一下堆的新生代

细节上看新生代

为什么新生代要划分成 Eden 区、From 区、To 区?垃圾回收的过程?

新生代为什么这样划分,其实我们来了解一下新生代 Minor GC 的过程就知道了(其实也是顺便了解一下复制算法)。

Minor GC 过程:复制→清空→互换

  1. 复制
    首先,当 Eden 区内存满的时候会触发第一次 Minor GC,然后把 GC 之后还活着的对象实例拷贝到 From 区;
    当 Eden 区再次触发 GC 的时候,会扫描 Eden 区和 From 区,对这两个区域进行 GC;经过这次 GC 回收后还存活的对象,则会直接复制到To区域,并且把这些对象的年龄 +1(如果有对象的年龄已经达到了老年的标准,则会复制到老年代区)。

  2. 清空
    第二次及之后的 Minor GC 是把 GC 之后 Eden 区和 From 区还存活的对象复制到 To 区;复制之后,会把 Eden 区和 From 区都清空掉。

  3. 互换
    然后会将此时的清空后的 From 区会与 To 区交换位置,也就是 From 区变成 To 区,To 变成 From 区;这样做是为了保证每一次 To 区都是空的,当下一次 GC 时,就又可以把 From 区的对象实例复制到 To 区了。

所以,你应该为什么要将新生代划分成 Eden 区、From 区、To 区了吧?
Eden 区里是为新产生的对象实例准备的,而 From 区、To 区是为了每次的复制与交换准备的。
(这里提一句,每次 GC 之后会 From 区与 To 区都会交换,那么这两个区的内存大小应该满足什么样的关系?聪明的你应该想到了,为了满足每次的交换动作, From 区与 To 区的内存应该是要一样大的! )

怎么知道一个对象实例是否可以回收 / 怎么判断对象实例是否死亡?

1. 引用计数算法

我们可以给对象实列添加一个引用计数器,每当有一个地方引用这个对象实列时,计数器加 1 ,当这个地方不再引用它时,也就是引用时效时,计数器减 1 ;引用计数器为 0 的对象实列就是可以被回收的对象,也就是死亡的对象。

但引用计数器算法可能会出现一个问题:循环引用的情况下,使用引用计数器算法进行垃圾回收会出问题。
循环引用指的是 A 对象引用了 B 对象, B 对象引用了 A 对象;如此一来,引用计数器永远都不会为 0 ,就会导致无法对它们进行回收。

也正因为循环引用导致的这个问题,Java 虚拟机没有使用引用计数器算法来进行垃圾回收。

2. 可达性分析算法

以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

在 Java 技术体系里面,固定可以作为 GC Roots 的的对象包含如下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用调用的方法堆栈中使用的参数、局部变量、临时变量等。
  2. 在方法区中类静态变量属性引用的对象,比如 Java 类的引用类型静态变量。
  3. 在方法区中常量引用的对象,比如字符串常量池里的引用。
  4. 在本地方法栈中JNI(也就是 Native 方法)引用的对象。
  5. Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如空指针异常,OOM)等,还有系统加载器。
  6. 所有被同步锁(synchronized 关键字)持有的对象。
  7. 反映 Java 虚拟机内部清空的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了上面这些固定的 GC Roots ,还可以根据用户所选用的垃圾收集器以及当前回收的内存区域不同,加入其他“临时性”的对象。

垃圾怎么回收的?(垃圾回收算法有哪些?)
  1. 复制算法
    这个算法,我们在讲新生代的垃圾回收时,说的就是这个算法。
    简单来说就是:将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,将剩下还存活的对象复制到另一块去,然后再把使用的空间进行一次清理。
    好处是:效率高,不会产生大量内存碎片(内存碎片:对象在内存中不连续,这一个那一个,是内存碎片化了)。
    坏处是:只使用了一半的内存,浪费了内存。

  2. 标记-清除算法
    这个算法其实就和它的名字一样 标记-清除:首先将需要回收的对象标记一下,当内存不够触发垃圾回收时,就会将所有标记了的对象清除掉。
    这个算法很简单,但是问题也很明显,因为标记对象的位置不确定,所以会产生大量内存碎片,并且标记清除过程的效率不高。

  3. 标记-整理算法
    标记-整理算法,也同样会对需要回收的对象进行标记,但后续不是直接清除标记过的对象,而是让所有存活对象(也就是未标记对象)全部向内存的一端移动,然后再清理掉端边界以外的内存。
    相比与标记-清除算法,标记-整理算法不会产生内存碎片;但是同样,效率不高,因为需要移动大量对象,所以处理效率自然不高。

  4. 分代收集算法
    分代收集算法其实不是什么新算法,而是将上面说的三种扬长避短,根据不同的情况使用不同的算法。
    分代收集算***根据对象存活周期将内存划分为不同的几个部分,一般就是我们前面说的分成 新生代和老年代。

  • 新生代:复制算法
  • 老年代:标记-清除算法 或者 标记-整理算法

关于 JVM 堆相关的内容就写到这里了,想了解更深更细致的内容,推荐大家可以去看看 周志明大神写的 《深入理解Java虚拟机》。


注:如果猿兄这篇博客有任何错误和建议,欢迎大家留言,不胜感激!

JVM 系列文章相关推荐:

  1. JVM 体系结构概述
  2. JVM 类加载器类型及类加载机制
  3. JVM 堆内存与垃圾回收
  4. 堆内存调优入门。(暂未更新)
  5. ……(持续更新)
  6. JVM 相关面试题及解答。(暂未更新)

持续更新,点个关注,不再迷路

这里是 猿兄,为你分享程序员的世界。

非常感谢各位优秀的程序员们能看到这里,如果觉得文章还不错的话,求点赞👍 求关注💗 求分享👬,对我来说真的 非常有用!!!