因为 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)。
一个对象被无意识的保留起来,可能会导致潜在的重大影响:
- 垃圾回收机制不再处理这个对象
- 垃圾回收机制不再处理这个对象所引用的所以其它对象
因此,对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)才能定位发现内存泄露问题。
因此,如果在问题发生前,有意识的阻止发生便是最好不过了。