1. 内存使用中的常见错误
该看看自动内存管理背后的另一面了。即使你不写 C 语言、无需亲自盯着每个字节,在 Java 中也完全可能“搞砸”,让应用像馋猫吃香肠一样拼命吞内存。我们来逐一拆解最常见的错误以及如何避免它们。
遗忘的监听器(listeners)
在 Java 中经常使用“监听器”模式——对象订阅另一个对象的事件。例如,你创建了一个按钮并为其添加了点击处理器:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 处理点击
}
});
问题:如果当按钮或窗口不再需要时你忘记移除该监听器(removeActionListener),该监听器会一直留在内存中。即使你关闭了窗口并清空了对它的所有引用,监听器对象仍然持有对窗口的引用(或反之),阻止垃圾回收器释放内存。
类比:想象你搬家了却忘了退订披萨店的推广——广告还会继续寄到你的旧地址。
不清理的静态集合
静态字段的生命周期与类相同(有时直到应用结束)。如果你有一个静态集合:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
而你不断往里放对象却不删除,它们会永远驻留在内存中。即使其它地方不再有对这些对象的引用,来自静态集合的引用也会阻止 GC 回收它们。
真实示例:桌面应用中的照片缓存从不清理。运行几小时后——OutOfMemoryError。
未释放的资源(文件、流、连接)
尽管 Java 会回收内存,但它不会自动关闭文件描述符、网络连接和其他外部资源。如果忘记关闭文件或流,资源会被占着不释放,某个时刻系统会说:“不行了,文件句柄不给了!”(IOException: Too many open files)。
建议:始终使用 try-with-resources:
try (FileInputStream in = new FileInputStream("data.txt")) {
// 读取文件
} // 将自动调用 in.close()!
长时间滞留的大对象
有时你会创建一个巨大的数组或集合,使用之后却“忘记释放”。例如:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 每个 1 MB
}
// ... 忘记清理 bigList
如果这个集合存在于静态字段中,或者位于一个长期不被删除的对象里,这些内存都会一直被占用。
内部类和匿名类:捕获外部引用
Java 中的匿名类(以及内部类)会隐式持有对外部对象的引用:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r 在某处被保存
}
}
如果对象 r 进入了静态集合或缓存,它会“持有”对 Outer 实例的引用,即使后者已不再需要。结果——内存泄漏。lambda 表达式稍好一些,但如果它使用了外部类的字段,引用仍会被保留。
2. 使用垃圾回收器时的错误
强制调用 System.gc()
许多新手会想:“内存不够了——我调用 System.gc() 就好了!” 实际上这只是一种请求,并不保证 JVM 会立刻回收。频繁使用会显著降低性能,引发长暂停和卡顿。在真实应用中最好相信 JVM——它会自己决定何时回收。顺便说一句,有些 JVM 甚至可能忽略显式 GC 调用(例如使用选项 -XX:+DisableExplicitGC 时)。
忽视 GC 日志
GC 日志能显示回收发生的时间、耗时以及释放的内存量。如果不查看这些日志,就可能错过问题信号:长暂停、频繁的 Full GC、内存泄漏。
如何开启 GC 日志:
java -Xlog:gc* -jar MyApp.jar
或对于旧版 JVM:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
为场景选择了不合适的 GC
回收器的选择会影响时延与稳定性。对于低时延场景(交易所、在线游戏),并行的停顿式 Parallel GC 并非好主意:它可能在回收时“冻结”所有线程。请考虑 G1 GC、ZGC 或 Shenandoah。
3. 与集合相关的错误
在缓存中使用 HashMap,而不是 WeakHashMap。
如果你在做缓存,并希望当没有“活”引用时对象能自动移除,请使用 WeakHashMap:
Map<Key, Value> cache = new WeakHashMap<>();
使用普通 HashMap 时,对象会一直存活,直到手动清理缓存,这将导致内存泄漏。
忘记对元素调用 remove()。
如果你把对象加入到集合中(比如监听器列表),但在不需要时没有移除,它们会一直存活,尤其是在长期存在的集合中(例如静态集合)。
4. 最佳实践:如何避免问题
始终移除监听器。
如果对象订阅了事件,当它不再需要时务必将其退订。可在 dispose() 方法或关闭窗口/页面时执行。
button.removeActionListener(myListener);
缓存尽量使用弱引用。
如果缓存对对象的保存没有强保证需求,请使用 WeakReference 或基于它的集合(如 WeakHashMap)。这样当需要时 GC 就能释放内存。
在生产环境监控内存。
使用 jvisualvm、jconsole 或 APM 系统。在用户抱怨之前捕获泄漏。
怀疑泄漏时分析堆转储(heap dump)
如果应用开始比平时更“吃内存”,生成堆转储(例如通过 jmap 或 jvisualvm),看看哪些对象占用最多空间。元凶通常几分钟就能找出。
调整 JVM 参数。
- -Xmx — 最大堆大小
- -Xms — 初始堆大小
合理的限制有助于避免 OutOfMemoryError 并加快诊断。
5. 实战:内存泄漏示例及修复
示例 1:通过静态集合导致的泄漏
public class MemoryLeakDemo {
// 静态集合——生命周期极长
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 每次 1 MB
System.out.println("已添加 " + (i + 1) + " MB");
}
// OutOfMemoryError!
}
}
修复:使用局部变量,或在不需要时清空集合。
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("已添加 " + (i + 1) + " MB");
}
// tempList = null; // 可以显式置为 null
// 方法结束后对象即可被 GC 回收
}
}
示例 2:通过监听器导致的泄漏
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// 没有 removeListener 方法!
}
修复:添加删除监听器的方法,并在窗口关闭时调用。
public void removeListener(EventListener l) {
listeners.remove(l);
}
示例 3:使用 HashMap 的缓存(应改为 WeakHashMap)
Map<Object, Object> cache = new HashMap<>();
// ... 添加对象
修复:改用 WeakHashMap:
Map<Object, Object> cache = new WeakHashMap<>();
用于内存监控的 JVM 配置建议
- 开启 GC 日志:-Xlog:gc* 或 -XX:+PrintGCDetails
- 限制最大堆大小:-Xmx512m
- 如果使用缓存——关注其大小,并在可行时使用弱引用
- 尝试不同 GC:-XX:+UseG1GC、-XX:+UseZGC、-XX:+UseShenandoahGC
7. 内存相关的常见错误
错误 №1:遗忘的监听器和订阅。 如果你把监听器加到了对象上却忘了移除,监听器对象(以及它引用的一切)会一直留在内存里。GUI 和事件系统中的经典问题。请使用 removeListener/removeActionListener。
错误 №2:不清理的静态集合。 静态字段生命周期最长。如果你把对象放进去却不清理,对象会永久驻留。对“无上限”的缓存尤其阴险。
错误 №3:未释放外部资源。 留下未关闭的流、文件或连接?不仅浪费内存,还会碰到操作系统资源上限。请使用 try-with-resources 并关闭资源。
错误 №4:强制调用 System.gc()。 这不是灵丹妙药,只是对 JVM 的请求。经常会引起暂停并导致性能退化。
错误 №5:用普通集合做缓存。 如果缓存中的对象应当自动删除,请使用弱/soft 引用(WeakHashMap、SoftReference)。否则就会发生泄漏。
错误 №6:内部类和匿名类捕获外部引用。 内部类和 lambda 可能隐式持有外部对象的引用。如果把它们保存到长寿命集合中——就会产生泄漏。
错误 №7:忽视 GC 日志。 如果你不看 GC 日志,就不会知道长暂停或频繁的 Full GC——而用户会通过卡顿知道。开启 -Xlog:gc* 或 -XX:+PrintGCDetails。
GO TO FULL VERSION