一文读懂《Effective Java》第6条:消除GC触及不到的过期对象引用

2022-05-28 12:37:17 浏览数 (1)

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

并非万能的JVM内存管理

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

代码语言:javascript复制
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 错误),即使这种情况非常少。

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

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

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

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

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

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

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

常见内存泄漏三个场景

第一个内存泄露的常见原因是自行管理内存(例如,开头的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 的键。

总结

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

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

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

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

0 人点赞