Pengzna's blog 👋

Mar 04, 2024

GC 视角下的代码优化——如何写出"GC 友好"的代码

广义上的 GC 调优,通常是指通过 JVM options 参数进行调优。然而,除了调参,我们更应该注意结合 GC 的知识,从代码上就写的对 GC 友好,从而”未雨绸缪“,防患于未然。因此,总结一些代码经验十分有必要。

结合笔者前期对 GC 的系统调研,以及笔者和 Apache IoTDB 社区同学的代码实践,参考网络大神资料,总结一些 ”GC 友好“代码 tips。

复用对象:池化

为了减少对象分配开销,提高性能、同时尽量减少对象的创建销毁,减小 GC 压力,可以采取对象池的方式来缓存对象集合,作为复用的手段。

但是无脑池化,可能效果有限,因为:

  1. 对于对象本身:

如果对象很小,那么分配的开销本来就小,对象池只会增加代码复杂度。

如果对象比较大,那么晋升到 Old Generation 后,对 GC 的压力就更大了。

  1. 从线程安全的角度考虑,通常池都是会被并发访问的,那么同步带来的开销,未必比重新创建一个对象小。(除非池本身的实现很优秀,比如通过 CAS 等手段尽可能减小了同步开销)
  2. 对象池中的对象由于在运行期长期存活,大部分会晋升到 Old Generation,因此无法通过 YoungGC 回收。

因此对于池化,需要注意两点:

  1. 合适的场景才池化:即当池中的每个对象的创建、回收开销较大时,缓存复用才有意义,例如每次 new 都会创建一个连接,或是依赖一次 RPC。
    1. 如线程池、RPC client 池、TCP 连接池、数据库 session 池等
  2. 最好引入定时对象清理(KeepAliveTime)机制:清理池子中长时间不用的长活对象,释放资源。否则可能导致老年代空间被堆积占用。

IoTDB 在 clientManager 已经对池化对象的定时清理有过实践:使用 org.apache.commons.pool2KeyedObjectPool,并设置定时驱逐参数即可。

JDK 原生的线程池之所以引入 KeepLiveTime 定时清理闲置线程也是同样的道理。

案例:IoTDB 社区同学对大 Text 写入场景的 Byte[] 对象进行了池化,获得了较好的收益。

善用各类引用

java.lang.ref.Reference有几个子类,用于处理和 GC 相关的引用。JVM 的引用类型简单来说有几种:

  • Strong Reference,只要强引用存在,GC 将永远不会回收被引用的对象,哪怕内存不足时,JVM 也会直接抛出 OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显式的将强引用赋值为 null。

  • Soft Reference,在内存足够的时候,软引用对象不会被回收,只当临近 OOM 时,系统才会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出OutOfMemoryError。这种特性常常被用来实现缓存

  • Weak Reference,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。

在 Java 集合中有一种特殊的 Map 类型:WeakHashMap, 在这种 Map 中存放了键对象的弱引用,当一个键对象被垃圾回收,那么相应的值对象的引用会从 Map 中删除。WeakHashMap 能够节约存储空间,可用来缓存那些非必须存在的数据。

当实现缓存时,可以考虑优先使用WeakHashMap,而不是HashMap,当然,更好的选择是使用框架,例如 Guava Cache。

  • Phantom Reference,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收。主要用于识别对象被 GC 的时机,通常用于做一些清理工作,实际代码开发用的不多

以上的这些未必可以对代码有多少性能上的提升,但是熟悉这些方法,是为了帮助我们写出更卓越的代码,和 GC 更好地合作。

指定容器初始化大小

Java 容器的一个特点就是可以动态扩展,所以通常我们都不会去考虑初始大小的设置,容器会自动扩容。

但是扩容意味着代价,例如一些基于数组的数据结构:StringBuilderStringBufferArrayListHashMap等,在扩容的时候都需要做 ArrayCopy,对于不断增长的结构来说,经过若干次扩容,会存在大量无用的老数组,而回收这些数组的压力,全都会加在 GC 身上。

这些容器的构造函数中通常都有一个可以指定大小的参数,如果对于某些大小可以预估的容器,建议加上这个参数。

如果采用默认无参构造函数,创建一个 ArrayList,不断增加元素直到 OOM,那么在此过程中会导致:

  • 多次数组扩容,重新分配更大空间的数组
  • 多次数组拷贝
  • 内存碎片(压力会最终给到 GC)

手动把对象置 null?

大部分情况下没有作用,JVM 会帮我们分析出不可达的对象并自动回收,JIT Compiler 会自动分析 local 变量的生命周期。此举使得代码的可读性下降了不少。

以下几种有限的情况可能需要手动处理:

  1. 某个巨大的常驻内存的集合缓存对象,里面某些数据在业务完成后必须要手动释放,否则该集合对象可能会越来越大。
  2. 某个对象生命周期较长,比如它在一个巨大的方法体内,或它横跨了多个方法的调用栈,被一直引用,在业务不需要的时候将其引用及时置为 null,可以帮助 GC 提前释放掉该对象的内存。在内存占用达到瓶颈时可以优化 GC。

案例:在 IoTDB 的 InsertRecord 接口里,对 request 里业务不需要的序列化数据提前置 null,让 GC 提前释放这部分内存,有助于降低内存占用的峰值,在内存比较紧张的极端瓶颈场景下(GC time 占比 30%+)有效减轻了 GC 压力

异常时在 cath{} 或 finally{} 回收资源

异常处理的 catch 模块或者 finally 模块中资源释放的代码是必须的,如果异常时没有在 catch{} 或 finally{} 中释放某些资源(如 close 文件等),可能会造成对象资源的长期占用。

不要手动挡:System.gc() 以及 finalize()

在某些高级组件的高级功能中可能会调用这两个方法,平时我们的编码中基本上用不上,所以还是让 JVM 自己去处理吧。

  1. System.gc() 会触发 FGC,相当于自杀。
  2. 重写 finalize() 的对象不会被 GC 回收,直到 finalize() 被执行,即使它已经成为「垃圾对象」。因此 finalize() 不善用的话容易产生对象堆积。

NOTE:除非是在对代码非常熟悉的情况下,手动 System.gc 来释放堆外内存,否则平时不建议手动 System.gc

缩小对象作用域

尽可能缩小对象的作用域,即生命周期。

如果可以在方法内声明的局部变量,就不要声明为实例变量。

除非你的对象是单例的或不变的,否则尽可能少地声明 static 变量。

OLDER > < NEWER