1 垃圾回收
说到垃圾回收(Garbage Collection,GC),大部分人都把这项技术当做java语言的伴生产物。事实上,GC的历史要比Java更久远,1960年诞生的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。
Java语言开发者比C语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM 中的垃圾回收器会为我们自动回收。那这样说来,既然GC已经帮助我们自动回收垃圾对象了,那我们为啥还要去了解GC和内存分配呢?答案很简单:当我们需要排查各种内存溢出、内存泄漏的问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要深入GC的原理,对自动回收机制实施必要的监控和调节。
通过前面一篇博客JVM运行时内存分布,我们已经了解了java内存运行时区域的划分,其中程序计数器、虚拟机栈、本地方法栈这3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈操作。每一个栈帧中分配多少内存基本上是在类加载时就已知的,因此,这几个区域就不用过多的考虑内存回收的问题,因为方法结束或者线程结束,内存自然就跟着回收了。
而 Java堆 和 方法区 则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收是动态的,GC 所关注的主要也是这部分内存。
2 可达性分析算法
垃圾回收,顾名思义,回收的主体是“垃圾”,那什么是垃圾呢?在主流的的商用程序语言(Java、C#等)中一个对象是否是垃圾对象是通过可达性分析算法(Reachability Analysis)来判定的,这个算法的基本实现是通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用连相连,则认为GC Roots到这个对象不可达,此对象就是垃圾对象,可以被回收。
如下图所示:
对象 Object5 和 Object6 虽然相互有关联,但是他们与 GC Roots之间没有引用链,所以他们会被判定为可回收的对象。需要注意的是,上图中的Object实际上代表的是此对象在内存中的引用,包括 GC Roots 也是一组引用而并非对象。
在Java中,可作为 GC Roots 的对象有下面几种:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
触发回收的时机:
- Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。
- System.gc():在应用层,Java 开发人员可以主动调用此 API 来请求一次 GC。
如何回收:
- 标记清除算法(Mark and Sweep GC)
- 复制算法(Copying)
- 标记-压缩算法 (Mark-Compact)
3 垃圾回收算法简介
3.1 标记清除算法(Mark and Sweep GC)
从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收。过程如下图:
- Mark标记阶段:找到内存中的所有GCRoot对象,只要是和GCRoot对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
- Sweep清除阶段:当遍历完所有的 GC Root 之后,则将标记为黑色的垃圾对象直接清除。
- 优点:实现简单,不需要移动对象
- 缺点:标记和清除过程效率都不高;标记清除之后会产生大量不连续的内存碎片,内存碎片太多会导致后面程序运行时需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次GC,提高了GC的频率。
3.2 复制算法(Copying)
将现有的内存空间按容量分为大小相等两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。如图:
- 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
- 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制,降低了效率。
3.3 标记-压缩算法 (Mark-Compact)
需要先从根节点开始对所有可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。如图:
- Mark标记阶段:找到内存中的所有GCRoot对象,只要是和GCRoot对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
- Compact压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。
- 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
- 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。
4 JVM分代回收策略
Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代。在 HotSpot 中除了新生代和老年代,还有永久代。
分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。
4.1 新生代(Young Generation)
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。
新生代又可以继续细分为 3 部分:Eden、Survivor0(简称 S0)、Survivor1(简称S1)。这 3 部分按照 8:1:1 的比例来划分新生代。这 3 块区域的内存分配过程如下:
- 绝大多数刚刚被创建的对象会存放在 Eden区。
- 当 Eden 区第一次满的时候,会进行垃圾回收。首先将 Eden 区的垃圾对象回收清除,并将存活的对象复制到 S0,此时 S1 是空的。
- 下一次 Eden 区满时,再执行一次垃圾回收。此次会将 Eden 和 S0 区中所有垃圾对象清除,并将存活对象复制到 S1,此时 S0 变为空。
- 如此反复在 S0 和 S1之间切换几次(默认 15 次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代中。
4.2 老年代(Old Generation)
一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。
我们可以使用 -XX:PretenureSizeThreshold
来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。
对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个512 byte 的 card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生 GC 时,只需要检查这个 card table 即可,大大提高了性能。
5 理解GC日志
为了让上层应用开发人员更加方便的调试Java程序,JVM提供了相应的GC日志。在GC执行垃圾回收事件的过程中,会有各种相应的log被打印出来。其中新生代和老年代所打印的日志是有区别的。
- 新生代 GC:这一区域的 GC 叫作 Minor GC。因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
- 老年代 GC:发生在这一区域的 GC 也叫作 Major GC 或者 Full GC。当出现了 Major GC,经常会伴随至少一次的 Minor GC。
在有些虚拟机实现中,Major GC 和 Full GC 还是有一些区别的。Major GC 只是代表回收老年代的内存,而 Full GC 则代表回收整个堆中的内存,也就是新生代 + 老年代。
接下来就通过一个案例来分析如何查看 GC Log,分析这些 GC Log 的过程中也能再加深对 JVM 分代策略的理解。
我们先来了解下一些Java 命令的参数:
日志中的字段代表意义:
案例代码如下:
通过上面的参数,可以看出堆内存总大小为 20M,其中新生代占 10M,剩下的 10M 会自动分配给老年代。执行上述代码打印日志如下:
从日志中可以看出:程序执行完之后,a1、a2、a3、a4四个对象都被分配在了新生代的Eden区。如果我们将测试代码中的a4初始化改为a4=newbyte[2*_1MB]则打印日志如下:
在给a4分配内存之前,Eden区已经被占用6M。已经无法再分配出2M来存储a4对象。因此会执行一次MinorGC。并尝试将存活的a1、a2、a3复制到S1区。但是但是 S1 区只有 1M 空间,所以没有办法存储 a1、a2、a3 任意一个对象。在这种情况下 a1、a2、a3 将被转移到老年代,最后将 a4 保存在 Eden 区。所以最终结果就是:Eden 区占用 2M(a4),老年代占用 6M(a1、a2、a3)。
6 引用
上文中已经介绍过,判断对象是否存活我们是通过GCRoots的引用可达性来判断的。但是JVM中的引用关系并不止一种,而是有四种,根据引用强度的由强到弱,他们分别是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
其中 软引用 大家应该经常使用过。因为它能保证在内存足够时,我们创建的对象完好的存活在内存中。同时当内存不足时,则将软引用指向的对象交由GC回收。但用SoftReference也需要注意:被 软引用 对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,并且是一个 强引用。如下图所示:
如果一个对象被软引用SoftReference引用,但是软引用对象自身被强引用集合Set所引用,这就会导致SoftReference对象本身不会被GC回收掉。如果我们不断的向Set中添加对象,终将导致OOM。
java.lang.OutOfMemoryError : GC overhead limit exceeded
造成OOM的原因:虚拟机一直在不断回收软引用,回收进行的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。
注意:我们平时说软引用会在内存不足时被GC回收。这里说的内存不足不仅仅是指空间大小,还有时间的限制。GC对于存活时间大于一定数值的软引用会进行回收,而这个数值是基于对内存大小和上次GC回收时间计算出来的。
网友评论