CodeGym /课程 /JAVA 25 SELF /解析内存使用中的常见错误

解析内存使用中的常见错误

JAVA 25 SELF
第 64 级 , 课程 4
可用

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 GCZGCShenandoah

3. 与集合相关的错误

在缓存中使用 HashMap,而不是 WeakHashMap
如果你在做缓存,并希望当没有“活”引用时对象能自动移除,请使用 WeakHashMap

Map<Key, Value> cache = new WeakHashMap<>();

使用普通 HashMap 时,对象会一直存活,直到手动清理缓存,这将导致内存泄漏。

忘记对元素调用 remove()
如果你把对象加入到集合中(比如监听器列表),但在不需要时没有移除,它们会一直存活,尤其是在长期存在的集合中(例如静态集合)。

4. 最佳实践:如何避免问题

始终移除监听器。
如果对象订阅了事件,当它不再需要时务必将其退订。可在 dispose() 方法或关闭窗口/页面时执行。

button.removeActionListener(myListener);

缓存尽量使用弱引用。
如果缓存对对象的保存没有强保证需求,请使用 WeakReference 或基于它的集合(如 WeakHashMap)。这样当需要时 GC 就能释放内存。

在生产环境监控内存。
使用 jvisualvmjconsole 或 APM 系统。在用户抱怨之前捕获泄漏。

怀疑泄漏时分析堆转储(heap dump)

如果应用开始比平时更“吃内存”,生成堆转储(例如通过 jmapjvisualvm),看看哪些对象占用最多空间。元凶通常几分钟就能找出。

调整 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 引用(WeakHashMapSoftReference)。否则就会发生泄漏。

错误 №6:内部类和匿名类捕获外部引用。 内部类和 lambda 可能隐式持有外部对象的引用。如果把它们保存到长寿命集合中——就会产生泄漏。

错误 №7:忽视 GC 日志。 如果你不看 GC 日志,就不会知道长暂停或频繁的 Full GC——而用户会通过卡顿知道。开启 -Xlog:gc*-XX:+PrintGCDetails

1
调查/小测验
内存和垃圾回收第 64 级,课程 4
不可用
内存和垃圾回收
内存和垃圾回收
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION