Java 程序经验小结:消除 GC 触及不到的过期对象引用

社区
1、写在开头

因为 JVM 提供了自动管理内存的能力,当我们用完了对象之后,它们会被自动回收,这也容易让我们产生“开发者不再需要考虑内存管理”的错觉了,其实不然。

2、并非万能的 JVM 内存管理

上面提到,即使 JVM 为我们提供了垃圾回收器,将没用的对象回收以节省内存使用。下面我们通过一个例子,意识到内存泄露的存在:


public class Stack {  private Object[] elements;  private int size = 0;  private static final int DEFAULT_INITIAL_CAPACITY = 16;
  // 初始化数组长度为16  public Stack() {    elements = new Object[DEFAULT_INITIAL_CAPACITY];  }
  /**   * 设置栈顶元素   */  public void push(Object e){    ensureCapacity();    elements[size++] = e;  }
  /**   * 弹出栈顶元素   */  public Object pop(){    if (size == 0){      throw new EmptyStackException();    }    return elements[--size];  }
  /**   * <p>扩容</p>   */  private void ensureCapacity() {    if (elements.length == size){      elements = Arrays.copyOf(elements, 2 * size + 1);    }  }}

复制代码

上面程序段隐藏着一个“内存泄露”的问题:随着垃圾回收器活动的增加,或者由于内存占用的不断增加,程序性能的降低会逐渐表现出来。

代码分析:

这个内存泄露的情况就是 pop() 方法,从栈弹出的对象将不会被当做垃圾回收,即使使用栈的程序不再引用这些对象,他们也不会被回收。原因就是,我们声明的栈内部(对象数组)维护着这些对象的过期引用(obsolete reference)。

Arrays.copyOf(elements, 2 * size + 1):扩容方法,底层声明一个两倍的内存空间,然后将原有的数组引用拷贝到新的内存空间里。这样导致引用永远保持存活。而弹出栈顶也仅仅是返回指针指向的元素地址,并未删除对象引用。

过期引用:指的是永远不会再被解除的引用。

在极端情况下,这种内存泄露会导致磁盘交换(Disk Paging),甚至程序失败(OutOfMemoryError 错误),即使这种情况非常少。

3、对清理过期对象引用进行优化

Java 语言的内存泄露是非常隐蔽的(无意识的对象保持,unintentional object retention)。

一个对象被无意识的保留起来,可能会导致潜在的重大影响:

  1. 垃圾回收机制不再处理这个对象

  2. 垃圾回收机制不再处理这个对象所引用的所以其它对象

因此,对 pop()方法的有了下面的优化:


/**   * 弹出栈顶元素   */public Object pop(){    if (size == 0){        throw new EmptyStackException();    }     Object result = elements[--size];     elements[size] = null; //释放对象引用     return result;  }

复制代码

清空过期引用的好处之一是,可以尽快检测出程序中的错误,如果不清理导致往后继续被错误解除引用,程序会立即抛出 NullPointException 异常。

4、常见内存泄漏三个场景

第一个内存泄露的常见原因是自行管理内存(例如,开头的 Stack 类):

  • 自己管理内存(manage is own memory),存储池(storage pool)包含了 elements 数组(对象引用单元,而不是对象本身)的元素。

  • 数组活动区域是已分配的(allocated),其余部分则是自由的(free),但是 GC 并不知道这一点,所以需要程序员自行将这个情况告知 GC。

解决方法:警惕类内存管理的场景,手动清空这些数组元素。

第二个内存泄漏的常见原因是缓存:一旦将对象引用放到缓存中,它很容易被遗忘掉,从而使得它不再有用并长期停留在缓存。

解决方法:使用 WeakHashMap 代表缓存,当缓存过期后会被自动删除。 参考《弱引用是什么》

在 Java 集合中有一种特殊的 Map 类型:WeakHashMap。WeakHashMap 继承于 AbstractMap,实现了 Map 接口。 和 HashMap 一样,WeakHashMap 也是一个散列表,它存储的内容也是键值对(key-value)映射,而且键和值都可以是 null。 不过 WeakHashMap 的键是“弱键”,里面存放了键对象的弱引用,当某个键不再正常使用时,会从 WeakHashMap 中被自动移除。当一个键对象被垃圾回收,那么相应的值对象的引用会从 Map 中删除。WeakHashMap 能够节约存储空间,可用来缓存那些非必须存在的数据。

第三个内存泄漏的常见原因是监听器与回调:如果你实现了某个 API,客户端在这个 API 中注册回调(例如,流程上需要调用其他服务接口),却没有显式取消注册,这样会导致这类回调请求会积聚。

解决方法:同样将它们的服务调用对象保存为弱引用(weak reference),例如 WeakHashMap 的键。

5、总结

上文总结了 3 种常见的 Java 内存泄露场景和对应的解决办法。

我们虽然可以依赖于 GC,让软件系统不会表现为明显的失败,但如果开发者不注意内存泄露,那么风险依旧长期存在。

而我们往往只有通过仔细检查代码,或者借助 Heap 剖析工具(Heap Profiler)才能定位发现内存泄露问题。

因此,如果在问题发生前,有意识的阻止发生便是最好不过了。

6、延伸阅读

《源码系列》

JDK之Object 类

JDK之BigDecimal 类

JDK之String 类

JDK之Lambda表达式

《经典书籍》

Java并发编程实战:第1章 多线程安全性与风险

Java并发编程实战:第2章 影响线程安全性的原子性和加锁机制

Java并发编程实战:第3章 助于线程安全的三剑客:final & volatile & 线程封闭

《服务端技术栈》

《Docker 核心设计理念

《Kafka史上最强原理总结》

《HTTP的前世今生》

《算法系列》

读懂排序算法(一):冒泡&直接插入&选择比较

《读懂排序算法(二):希尔排序算法》

《读懂排序算法(三):堆排序算法》

《读懂排序算法(四):归并算法》

《读懂排序算法(五):快速排序算法》

读懂排序算法(六):二分查找算法》

《设计模式》

设计模式之六大设计原则

设计模式之创建型(1):单例模式

设计模式之创建型(2):工厂方法模式

设计模式之创建型(3):原型模式

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论