我刚刚接受了一次采访,被要求用Java创建内存泄漏。

不用说,我觉得自己很傻,不知道如何开始创作。

什么样的例子?


当前回答

这里有一个在纯Java中创建真正的内存泄漏(运行代码无法访问但仍存储在内存中的对象)的好方法:

应用程序创建一个长时间运行的线程(或者使用线程池更快地泄漏)。线程通过(可选的自定义)ClassLoader加载类。该类分配一大块内存(例如新字节[10000000]),在静态字段中存储对它的强引用,然后在ThreadLocal中存储对自身的引用。分配额外的内存是可选的(泄漏类实例就足够了),但这会使泄漏工作得更快。应用程序清除对自定义类或从中加载该类的ClassLoader的所有引用。重复

由于ThreadLocal在Oracle的JDK中的实现方式,这会造成内存泄漏:

每个线程都有一个私有字段threadLocals,它实际上存储线程本地值。此映射中的每个键都是对ThreadLocal对象的弱引用,因此在ThreadLocal对象被垃圾收集后,其条目将从映射中删除。但每个值都是一个强引用,因此当一个值(直接或间接)指向作为其键的ThreadLocal对象时,只要线程存在,该对象既不会被垃圾收集,也不会从映射中删除。

在本例中,强引用链如下所示:

线程对象→ threadLocals映射→ 示例类的实例→ 示例类→ 静态ThreadLocal字段→ ThreadLocal对象。

(ClassLoader在创建泄漏时并没有真正发挥作用,它只是因为这个额外的引用链而使泄漏变得更糟:example类→ 类加载器→ 它加载的所有类。在许多JVM实现中,尤其是在Java7之前,情况更糟,因为类和ClassLoader被直接分配到permagen中,根本不会被垃圾收集。)

这种模式的一个变体是,如果您经常重新部署碰巧使用ThreadLocal的应用程序,而这些应用程序在某种程度上指向自己,那么应用程序容器(如Tomcat)会像筛子一样泄漏内存。这种情况可能有许多微妙的原因,并且通常很难调试和/或修复。

更新:由于很多人一直在要求它,这里有一些示例代码显示了这种行为。

其他回答

创建一个只包含while true循环的JNI函数,并用另一个线程的大型对象调用它。GC不太喜欢JNI,并且会将对象永久保存在内存中。

另一种可能造成巨大内存泄漏的方法是保存对TreeMap的Map.Entry<K,V>的引用。

很难理解为什么这只适用于TreeMaps,但通过查看实现,原因可能是:TreeMap.Entry存储了对其同级的引用,因此,如果TreeMaps准备好被收集,但其他类保存了对其Map.Intry的引用,则整个Map将保留在内存中。


现实生活场景:

想象一下,有一个数据库查询返回一个大的TreeMap数据结构。人们通常使用TreeMaps作为元素插入顺序。

public static Map<String, Integer> pseudoQueryDatabase();

如果查询被多次调用,并且对于每个查询(因此,对于返回的每个Map),您在某个地方保存了一个条目,那么内存将不断增长。

考虑以下包装类:

class EntryHolder {
    Map.Entry<String, Integer> entry;

    EntryHolder(Map.Entry<String, Integer> entry) {
        this.entry = entry;
    }
}

应用程序:

public class LeakTest {

    private final List<EntryHolder> holdersCache = new ArrayList<>();
    private static final int MAP_SIZE = 100_000;

    public void run() {
        // create 500 entries each holding a reference to an Entry of a TreeMap
        IntStream.range(0, 500).forEach(value -> {
            // create map
            final Map<String, Integer> map = pseudoQueryDatabase();

            final int index = new Random().nextInt(MAP_SIZE);

            // get random entry from map
            for (Map.Entry<String, Integer> entry : map.entrySet()) {
                if (entry.getValue().equals(index)) {
                    holdersCache.add(new EntryHolder(entry));
                    break;
                }
            }
            // to observe behavior in visualvm
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

    }

    public static Map<String, Integer> pseudoQueryDatabase() {
        final Map<String, Integer> map = new TreeMap<>();
        IntStream.range(0, MAP_SIZE).forEach(i -> map.put(String.valueOf(i), i));
        return map;
    }

    public static void main(String[] args) throws Exception {
        new LeakTest().run();
    }
}

在每次pseudoQueryDatabase()调用之后,映射实例应该准备好进行收集,但这不会发生,因为至少有一个Entry存储在其他地方。

根据您的jvm设置,应用程序可能会在早期因OutOfMemoryError而崩溃。

您可以从这个可视化虚拟机图中看到内存是如何保持增长的。

哈希数据结构(HashMap)不会发生同样的情况。

这是使用HashMap时的图形。

解决方案?只需直接保存键/值(您可能已经这样做了),而不是保存Map.Entry。


我在这里写了一个更广泛的基准。

如果您不了解JDBC,下面是一个毫无意义的示例。或者至少是JDBC希望开发人员在丢弃Connection、Statement和ResultSet实例或丢失对它们的引用之前关闭它们,而不是依赖于实现finalize方法。

void doWork() {
    try {
        Connection conn = ConnectionFactory.getConnection();
        PreparedStatement stmt = conn.preparedStatement("some query");
        // executes a valid query
        ResultSet rs = stmt.executeQuery();
        while(rs.hasNext()) {
            // ... process the result set
        }
    } catch(SQLException sqlEx) {
        log(sqlEx);
    }
}

上面的问题是Connection对象没有关闭,因此物理Connection将保持打开状态,直到垃圾回收器返回并发现它不可访问为止。GC将调用finalize方法,但有些JDBC驱动程序没有实现finalize,至少与Connection.close的实现方式不同。由此产生的行为是,尽管JVM将由于收集不可访问的对象而回收内存,但与Connection对象关联的资源(包括内存)可能不会被回收。

因此,Connection的最终方法并不能清除所有内容。人们可能会发现,到数据库服务器的物理连接将持续几个垃圾收集周期,直到数据库服务器最终发现该连接不活动(如果存在),应该关闭。

即使JDBC驱动程序实现了finalize,编译器也可以在finalize期间抛出异常。由此产生的行为是,与现在“休眠”对象关联的任何内存都不会被编译器回收,因为finalize保证只被调用一次。

上述在对象完成过程中遇到异常的场景与另一种可能导致内存泄漏的场景有关——对象复活。对象复活通常是通过创建一个从另一个对象最终确定的对象的强引用来实现的。当对象复活被误用时,它将与其他内存泄漏源一起导致内存泄漏。

还有很多例子你可以想象出来

管理列表实例,其中您只添加到列表中,而不从列表中删除(尽管您应该删除不再需要的元素),或者打开套接字或文件,但不再需要时不关闭它们(类似于上面涉及Connection类的示例)。在关闭Java EE应用程序时不卸载Singleton。加载单例类的Classloader将保留对该类的引用,因此JVM永远不会收集单例实例。当部署应用程序的新实例时,通常会创建一个新的类加载器,而由于单例,前一个类加载器将继续存在。

也许通过JNI使用外部本机代码?

使用纯Java,这几乎是不可能的。

但这是一种“标准”类型的内存泄漏,即您无法再访问内存,但它仍然属于应用程序。相反,您可以保留对未使用对象的引用,或者打开流而不关闭它们。

我认为还没有人说过这一点:你可以通过重写finalize()方法来复活一个对象,这样finalize)就可以在某个地方存储对它的引用。垃圾回收器只会在对象上调用一次,因此在此之后,对象将永远不会被销毁。