今天中午遛弯的时候,又思考了一下 JVM『垃圾回收』的过程。思考的过程帮助我更深入的理解了 JVM 『垃圾回收』的原理,在此记录一下。
HotSpot JVM把年轻代分为了三部分:1个 Eden 区和2个 Survivor 区(分别叫 from 和 to )。一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在Survivor区中每熬过一次 Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
在 GC 开始的时候,对象只会存在于 Eden 区和名为 “From” 的 Survivor 区,Survivor 区 “To” 是空的。紧接着进行 GC,Eden 区中所有存活的对象都会被复制到 “To”,而在 “From” 区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 “To” 区域。经过这次 GC 后,Eden 区和 From 区已经被清空。这个时候,“From” 和 “To” 会交换他们的角色,也就是新的 “To” 就是上次 GC 前的 “From”,新的 “From” 就是上次 GC 前的 “To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到 “To” 区被填满,“To” 区被填满之后,会将所有对象移动到年老代中。
以上是 JVM 垃圾回收的大致过程,不知道你能不能立刻答上来。
请思考几个问题:
- JVM 垃圾回收为什么要有 Eden 区?
- 为什么还要有两个 Survivor 区?
- 为什么还要复制来复制去?就用一块内存,每次 gc 的时候检查这个内存块中哪些对象该回收了就回收掉不行吗?
思考过程
我们先尝试自己推导一下垃圾回收的过程。
我们知道,程序要加载进内存才能运行,程序创建的对象也需要分配内存,那么使用完的对象,其占用的内存就要及时释放,否则就会产生内存不足的问题。在 C、C++ 中,内存的申请和释放都是程序员自己手动控制的,如果是简单的对象使用,其内存释放倒也简单,但对于存在多重调用关系等复杂场景的对象,想要正确释放内存并不是那么简单的事情,稍有不慎就会产生内存泄漏的问题。
我们知道,Java 语言摒弃了 C++ 中一些难以理解且容易出错的概念,如指针、多继承、还有手动控制内存对象的申请释放等。所以 Java 就将内存申请/释放的工作交给了 JVM 去做。
所以,我们思考一下,垃圾回收的第一步是什么?
第一步,就是要能知道哪些对象的生命周期已经结束了。
第二步,将这些对象回收掉。
是不是听起来很简单,就像把大象放进冰箱的方法,第一步:将冰箱门打开。第二步:将大象放进去。
其实,『把大象放进冰箱』这两个步骤并没有错,只不过第二步『将大象放进去』还可以拆分成更详细的步骤。
说回垃圾回收,第一步:判断哪些对象是已经释放的,这一步,我们自己先思考一下,该怎么做?
比如,我们用一个 Map ,每 new 一个对象,key 是对象的哈希值,value 是对象被引用的次数。每当有其他对象引用这个对象 A,就根据对象 A 的哈希值去 Map 中找到这个对象 A,将其引用值加 1,引用结束之后将其引用值减 1,再起一个定时任务遍历这个 Map ,对于引用值等于 0 的对象就将其内存回收掉。
这逻辑听起来似乎没问题。
当然,实际中的引用关系可能很复杂,比如对象 B 引用了对象 A,对象 A 又引用了对象 C,对象 C又引用了对象 B,这种循环引用的问题,怎么处理呢?其实这个循环并不是死循环,总有一个对象要先结束它的生命周期。
上面的方案听起来,好像可以哦。那我们再考虑下一个问题:它的性能怎么样?
具体的性能指标,需要做压力测试才能知道。但上面设计中很明显的可能引起性能问题的一个点就是『定时任务』。
该『定时任务』需要多少秒/毫秒执行一次?每次执行过程中的 STW 耗时多少?
设计一个方案不难,难的是这个方案要有足够高的性能,能够满足生产环境需要。常刷算法题的同学一定深有体会。
上面我们讨论的这种判断哪些对象可以回收的方法,就是『引用计数器』法的思路。
当我们已经知道哪些对象需要回收了,下一步就是找到这些对象,然后回收掉它们占用的空间,『找到这些对象』这个过程可不是那么轻松的,如果我们创建的对象的地址在内存中是随机的,则寻找这些位置随机的对象就非常耗时。比如对于一块 16G 的内存,假设 JVM 中堆内存使用 4G ,在 4G 内存中找到这么多零碎的对象,依然是一件比较耗时的工作。
那么,我们可以考虑,将 4G 的堆内存,划分出来地址连续的一块内存(内存块 A ),专门存放新创建的对象,定时任务每隔一段时间就扫描这个内存块A,看 Map 中哪些对象该回收了。
但是,这种实现方法还是不够好。JVM 中大多数对象都是生命周期很短的,定时任务每次扫描到需要回收的对象,就进行回收同时 STW ,则 JVM 的效率得有多差呢。必须减少 STW 的次数。
所以,我们再思考一下解决方案。
我们可以划分出来一整块地址连续的内存块 B ,定时任务每次扫描到内存块 A 中要回收的对象,先不回收,先放到内存块 B 中,等到内存块 B 空间满了,再一次性将内存块 B 回收掉,这样效率岂不是高多了。
但使用定时任务,效率上还是有点低,我们能不能化被动为主动,不用定时任务扫描,而是当达到某个条件时,再去查询哪些对象该回收了。
比如说,当内存块 A 满了的时候,再查询内存块 A 中哪些对象该回收了,这样就避免了定时任务。这样也就不用再将该回收的对象复制到内存块 B 了,而是可以直接回收掉。=》这样似乎不需要内存块 B 了。但是有个问题,内存块 A 一直有对象产生,时间长了,会产生内存碎片。=》所以还是这样比较好点:每次内存块 A 满了的时候,将该回收的对象回收掉,然后将存活的对象复制到内存块 B ,这样每次垃圾回收,内存块 A 就干净了,也避免了内存碎片产生。同时每次垃圾回收,也会检查内存块 B 中的对象哪些该回收了,将该回收的对象回收掉。==》那时间长了,内存块 B 岂不也会产生内存碎片?==》好吧,我们再定义一个内存块 C ,每次垃圾回收,将内存块 B 和内存块 C 进行交互,即将 B 中还存活的对象复制到 C 中,然后交换 B 和 C 的角色。==》这种内存块复制的方式就避免了内存碎片的产生。(因为 B 中的对象可能是琐碎的,复制到 C 中之后就是地址连续的了)
内存碎片:内存碎片描述一个系统中所有不可用的空闲内存,这些碎片之所以不能被使用,是因为这些空闲内存太小且以不连续方式出现在不同的位置。
好了,最后让我们看看 JVM 内存回收的过程,是不是就更清晰了呢:
HotSpot JVM把年轻代分为了三部分:1个 Eden 区和2个 Survivor 区(分别叫 from 和 to )。一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在Survivor区中每熬过一次 Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
在 GC 开始的时候,对象只会存在于 Eden 区和名为 “From” 的 Survivor 区,Survivor 区 “To” 是空的。紧接着进行 GC,Eden 区中所有存活的对象都会被复制到 “To”,而在 “From” 区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 “To” 区域。经过这次 GC 后,Eden 区和 From 区已经被清空。这个时候,“From” 和 “To” 会交换他们的角色,也就是新的 “To” 就是上次 GC 前的 “From”,新的 “From” 就是上次 GC 前的 “To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到 “To” 区被填满,“To” 区被填满之后,会将所有对象移动到年老代中。