如何在Java中创建内存泄漏?

3720

我最近参加了一次面试,其中被要求使用Java创建一个内存泄漏

不用说,我感到很笨,因为我不知道如何开始创建。

你可以给一个例子吗?


48
具有讽刺意味的是,每个非平凡的Java程序所面临的更难的问题是如何创建内存泄漏! - Peter - Reinstate Monica
3
不断往容器中添加新对象,却忘记添加删除它们的代码,或者实现部分工作的代码无法在程序运行时清理所有对象。 - Galik
5
Java服务器系统中最常见的内存泄漏是在共享状态下发生的,例如请求之间共享的缓存和服务。许多答案似乎过于复杂,忽略了这个明显而常见的领域。可能存在一种常见的泄漏模式,即具有请求作用域键(例如某种自定义缓存)的应用程序范围Map。 - Thomas W
61个回答

2587

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

  1. 应用程序创建一个长时间运行的线程(或使用线程池来更快地泄漏)。
  2. 线程通过(可选自定义的)ClassLoader加载一个类。
  3. 该类分配一大块内存(例如new byte[1000000]),将其强引用存储在静态字段中,然后将对其自身的引用存储在ThreadLocal中。分配额外的内存是可选的(泄漏类实例足以),但它会使泄漏工作得更快。
  4. 应用程序清除自定义类或其从中加载的ClassLoader的所有引用。
  5. 重复执行。

由于Oracle JDK中ThreadLocal的实现方式,这会创建内存泄漏:

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

在这个例子中,强引用链如下:

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

(ClassLoader并没有在创建内存泄漏中发挥作用,它只是使内存泄漏变得更严重,因为存在这样的额外引用链:示例类 → ClassLoader → 所有已加载的类。在许多JVM实现中,特别是Java 7之前,情况甚至更糟,因为类和ClassLoader直接分配到permgen中,并且根本不进行垃圾回收。)

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

更新:由于许多人一直在要求,这里提供了一些展示此行为的示例代码


227
ClassLoader泄漏是JEE世界中最常见的内存泄漏问题之一,通常由第三方库(如BeanUtils、XML/JSON编解码器)转换数据引起。这可能发生在库在应用程序的根ClassLoader之外加载但仍然持有对您的类的引用(例如通过缓存)。当您卸载/重新部署应用程序时,JVM无法垃圾回收应用程序的ClassLoader(因此也无法清除所有由其加载的类),因此随着重复部署,应用服务器最终会遇到问题。如果幸运的话,您可以通过ClassCastException z.x.y.Abc cannot be cast to z.x.y.Abc得到一些提示。 - earcam
68
+1: 类加载器泄漏是一场噩梦。我花了几周时间尝试弄清楚它们。可悲的是,正如@earcam所说,它们大多由第三方库引起,而且大多数分析工具都无法检测到这些泄漏。这篇博客对类加载器泄漏有一个很好、清晰的解释。http://blogs.oracle.com/fkieviet/entry/classloader_leaks_the_dreaded_java - Adrian M

1348

静态字段保存一个对象引用[尤其是一个 final 字段]

class MemorableClass {
    static final ArrayList list = new ArrayList(100);
}

(未关闭的)打开的流(文件、网络等)

try {
    BufferedReader br = new BufferedReader(new FileReader(inputFile));
    ...
    ...
} catch (Exception e) {
    e.printStackTrace();
}

未关闭的连接

try {
    Connection conn = ConnectionFactory.getConnection();
    ...
    ...
} catch (Exception e) {
    e.printStackTrace();
}

JVM垃圾回收器无法触及的区域,例如通过本地方法分配的内存。

在Web应用程序中,一些对象被存储在应用程序作用域中,直到应用程序被显式停止或删除。

getServletContext().setAttribute("SOME_MAP", map);

不正确或不适当的JVM选项,例如在IBM JDK上的noclassgc选项会导致未使用的类垃圾收集。

请参阅IBM JDK设置


217
我不同意上下文和会话属性被称为“泄漏”。它们只是长期存在的变量。而静态 final 字段更多地只是常量。也许应该避免使用大常量,但我认为称其为内存泄漏不太公平。 - Ian McLaird
100
(未关闭的)开放流(文件、网络等) 在结束时不会真正泄漏,因为在下一轮垃圾回收后进行终止处理。close() 操作将被调度执行(通常情况下 close() 不会在终结器线程中调用,因为它可能会阻塞)。虽然不关闭是一种不良实践,但不会导致泄漏。未关闭的 java.sql.Connection 也同样如此。 - bestsss
41
在大多数正常的JVM中,似乎String类只是对其“intern”哈希表内容有弱引用。因此,它确实被适当地垃圾回收,不会造成泄漏。(但我不是专家)http://mindprod.com/jgloss/interned.html#GC - mbauman

503

一个简单的方法是使用一个错误的(或不存在的)hashCode()equals()的HashSet,然后不断添加“重复”的元素。 HashSet 不会像应该做的那样忽略重复元素,相反,它只会不断增长,而且您将无法删除它们。

如果您想让这些不良键值/元素一直存在,可以使用类似于下面的静态字段:

class BadKey {
   // no hashCode or equals();
   public final String key;
   public BadKey(String key) { this.key = key; }
}

Map map = System.getProperties();
map.put(new BadKey("key"), "value"); // Memory leak even if your threads die.

89
实际上,即使元素类的hashCode和equals方法错误,您仍然可以从HashSet中移除元素;只需获取集合的迭代器并使用其remove方法,因为迭代器实际上操作的是底层条目而不是元素。(请注意,未实现hashCode/equals方法不足以触发泄漏;默认值实现了简单的对象标识,因此您可以正常获取并移除元素。) - Donal Fellows
2
@Donal 你上一个语句是不正确的。你不能获取元素,因为你没有对它的引用。唯一对Map中键值put的引用就是Map本身所持有的引用。只有当你执行 BadKey myKey = new BadKey("key"); map.put(key,"value"); 时,才能把它取出来。你是正确的,你可以使用迭代器来删除它,但是并不是所有的数据结构都能这样做。例如,如果你不在 @meriton 的答案中置空该引用,那么它将永远丢失。你无法访问支撑数组,而迭代器会停留在其短处。 - corsiKa
1
当equals/hashCode不正确时,唯一删除元素的方法是在使用不同比较方法找到匹配项时使用Iterator.remove()。 - Peter Lawrey
14
我想说的是,我不同意你对内存泄漏的定义,我的理解是,你所描述的迭代器移除技术只是像污水盆一样接住泄漏的液体,而不是真正解决了内存泄漏问题。 - corsiKa
110
我同意,这并不是一个内存“泄漏”,因为你可以只需删除对哈希集的引用,等待垃圾回收机制启动,然后瞬间!内存就会恢复。 - user541686

298

除了常见的忘记移除监听器、静态引用、哈希映射中伪造/可修改的键或只是线程无法结束其生命周期等标准情况外,下面将会出现Java泄漏的一种不明显的情况。

  • File.deleteOnExit() - 总是会泄漏字符串,如果字符串是子字符串,则泄漏会更严重(底层char[]也会被泄漏) - 在Java 7中,substring还会复制char[],因此后者不适用;@Daniel,请不必投票,谢谢。

我将专注于线程,以展示未受管理的线程的危险性,不希望甚至触及swing。

  • Runtime.addShutdownHook 如果没有移除钩子,即使使用removeShutdownHook由于ThreadGroup类关于未启动线程的错误,可能不会被收集,从而导致ThreadGroup泄漏。JGroup在GossipRouter中存在泄漏。

  • 创建但不启动Thread与上述类似。

  • 创建线程会继承ContextClassLoaderAccessControlContext,以及ThreadGroup和任何InheritedThreadLocal,所有这些引用都是潜在的泄漏,以及由类加载器加载的所有类和所有静态引用,还有ja-ja。这种影响在整个j.u.c.Executor框架中表现得特别明显,该框架具有超级简单的ThreadFactory接口,但大多数开发人员没有意识到潜藏的危险。此外,许多库确实会根据请求启动线程(行业流行的库太多了)。

  • ThreadLocal缓存;在许多情况下,它们都是有害的。我相信每个人都见过基于ThreadLocal的简单缓存,而不幸的消息是:如果线程保持运行时间比预期的上下文ClassLoaders的生命周期更长,那么它就是一个完全良好的小泄漏。除非真的需要,否则不要使用ThreadLocal缓存。

  • 当ThreadGroup没有线程,但仍保留子ThreadGroups时调用ThreadGroup.destroy()。这是一个严重的内存泄漏,会阻止ThreadGroup从其父级中移除,但所有子线程变得不可枚举。

  • 使用WeakHashMap并且value(间接)引用了key。这很难在没有堆转储的情况下发现。 扩展的Weak/SoftReference都可能将硬引用保留回受保护对象。

  • 使用java.net.URL通过HTTP(S)协议加载资源。这个问题很特别,KeepAliveCache会在系统ThreadGroup中创建一个新线程,会泄漏当前线程的上下文类加载器。当不存在活动线程时,线程在第一次请求时被创建,因此您可能会有运气或者只是泄漏。 泄漏在Java 7中已经修复,并且创建线程的代码已正确删除上下文类加载器。还存在其他几种情况(例如ImageFetcher也已修复)会创建类似的线程。

  • 在构造函数中传递new java.util.zip.Inflater()InflaterInputStream(例如PNGImageDecoder),但未调用inflater的end()。如果在构造函数中只使用new,那么没有机会...是的,在流上调用close()不会关闭手动传递的inflater。这不是真正的内存泄漏,因为它将通过finalizer释放...当它认为有必要时才会释放。在此期间,它会大量消耗本地内存,以至于可能导致Linux oom_killer无情地杀死该进程。主要问题在于Java中的finalization非常不可靠,在G1更新到7.0.2之前更糟糕。故事的寓意是:尽快释放本地资源;finalizer太差了。

  • java.util.zip.Deflater一样,这个也很糟糕,因为在Java中Deflater会占用大量内存,始终使用15位(最大值)和8个内存级别(9是最大值),分配几百KB的本地内存。幸运的是,Deflater 没有被广泛使用,并且据我所知JDK没有误用。如果您手动创建了DeflaterInflater,请始终调用end()。最后两个类的最好部分:您无法通过常规可用的分析工具找到它们。

  • (如果需要,我可以添加我遇到的一些更多时间浪费者。)

    祝你好运,保持安全; 内存泄漏是邪恶的!


    28
    创建但不启动线程......天啊,我在几个世纪前就被这个问题严重困扰过了!(Java 1.3) - leonbloy
    @leonbloy,在此之前情况更糟,因为线程直接添加到线程组中,不启动意味着非常难以避免泄漏。现在只是增加了“未启动”计数,但这阻止了线程组的销毁(虽然相对较好,但仍然存在泄漏)。 - bestsss
    谢谢!调用ThreadGroup.destroy()时,如果ThreadGroup本身没有线程,这是一个非常微妙的错误;我已经追踪了几个小时,因为在我的控制GUI中枚举线程时什么也没有显示,但是线程组和至少一个子组似乎不会消失。 - Lawrence Dol
    2
    @bestsss:我很好奇,既然关闭挂钩在JVM关闭时运行,为什么你要删除它呢? - Lawrence Dol
    @user253751:这是关于内存泄漏的背景。在这种情况下,如果JVM正在关闭,那么未能在使用后删除关闭挂钩不会造成泄漏。尽管如此,如果您仅需要在关闭时做一些临时的事情,那么不删除它并不断添加新处理程序可能会导致泄漏。 - Lawrence Dol
    如果线程的生命周期超出了预期,那么上下文类加载器的生命周期也会随之延长,这就是一个纯粹的小内存泄漏。这句话可以重新表述吗?我不理解。 - Aleksandr Dubinsky

    249

    这里的大多数示例都“过于复杂”,它们是边缘情况。在这些示例中,程序员可能犯了错误(例如未重新定义equals/hashcode),或者被JVM/JAVA的一个角落案例所困扰(例如静态类的加载)等。我认为这不是面试官想要的类型的示例,甚至不是最常见的情况。

    但是有一些更简单的情况会导致内存泄漏。垃圾收集器只释放不再被引用的内容。我们作为Java开发人员并不关心内存,我们需要时会分配内存,并让它自动释放。不错。

    但是长时间运行的应用程序往往会有共享状态。它可以是任何东西,例如静态变量、单例等。通常,非平凡的应用程序倾向于创建复杂的对象图。仅仅忘记将引用设置为null或更常见的是忘记从集合中删除一个对象就足以导致内存泄漏。

    当然,所有类型的监听器(例如UI监听器)、缓存或任何长期存在的共享状态如果没有得到正确处理就会产生内存泄漏。应该理解的是,这不是Java的边缘情况,也不是垃圾收集器的问题。这是一个设计问题。我们设计了将监听器添加到长时间存在的对象中,但当不再需要时却没有将监听器删除。我们缓存对象,但没有策略从缓存中删除它们。

    也许我们有一个复杂的图形来存储计算所需的先前状态。但是先前的状态本身又与之前的状态相连,以此类推。

    就像我们必须关闭SQL连接或文件一样。我们需要设置正确的引用为null,并从集合中删除元素。我们应该有适当的缓存策略(最大内存大小、元素数或定时器)。允许通知侦听器的所有对象必须同时提供addListener和removeListener方法。当这些通知者不再使用时,它们必须清除其侦听器列表。

    内存泄漏确实是完全可能的,并且是完全可预测的。不需要特殊的语言功能或边缘情况。内存泄漏要么表明某些内容可能缺失,要么表明存在设计问题。


    48
    我觉得有趣的是,在其他回答中,人们在寻找那些边缘案例和技巧,似乎完全错过了重点。他们可以展示保留对永远不会再次使用的对象的无用引用的代码,并且从不删除这些引用;或许有人会说这些情况不是“真正”的内存泄漏,因为仍然存在对这些对象的引用,但如果程序永远不再使用这些引用并且也永远不会将它们丢弃,那么这就与(真正的)内存泄漏完全等价,而且同样糟糕。 - ehabkost

    177
    答案完全取决于面试官的问题意图。
    在实践中,Java是否可能泄漏?当然可能,其他回答中有很多例子。
    但是可能存在多个元问题?
    - 理论上“完美”的Java实现是否容易泄漏? - 候选人是否理解理论和现实之间的区别? - 候选人是否了解垃圾收集的工作原理? - 或者了解在理想情况下垃圾收集应该如何工作? - 他们是否知道可以通过本机接口调用其他语言? - 他们是否知道如何在这些其他语言中泄漏内存? - 候选人是否知道什么是内存管理以及在Java背后发生了什么?
    我将阅读您的元问题为“在这种面试情况下我可以使用的答案”。因此,我将关注面试技巧而不是Java。我相信你更有可能重复不知道面试问题答案的情况,而不是需要知道如何使Java泄漏。因此,希望这能有所帮助。
    为面试开发人员,最重要的技能之一是学会积极倾听问题,并与面试官合作提取其意图。这不仅让您按照他们想要的方式回答问题,而且还表明您具有一些重要的沟通技巧。当在许多同样有才华的开发人员之间做出选择时,我会每次都雇用那些在回答前倾听、思考和理解的人。

    28
    每当我问这个问题时,我都希望得到一个非常简单的答案——不断增长队列,不要最终关闭数据库等,不涉及奇怪的类加载器/线程细节,这表明他们理解垃圾回收可以为您做什么和不能为您做什么。我想这取决于你正在面试的工作。 - DaveC

    149
    以下示例如果您不理解JDBC,则毫无意义。或者至少了解JDBC如何期望开发人员在丢弃或失去对ConnectionStatementResultSet实例的引用之前关闭它们,而不是依赖于实现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的最终方法并不能清理所有东西。人们可能会发现,与数据库服务器的物理Connection将持续几个垃圾回收周期,直到数据库服务器最终发现Connection不可用(如果它确实如此)并应该关闭。
    即使JDBC驱动程序实现了finalize,编译器也可能在finalization过程中抛出异常。结果是,任何与现在“休眠”对象相关联的内存都不会被编译器回收,因为finalize只保证被调用一次。
    在对象finalization期间遇到异常的上述情况与可能导致内存泄漏的另一种情况相关 - 对象复活。对象复活通常是通过从另一个对象中创建对该对象的强引用来有意进行的。当滥用对象复活时,它将与其他内存泄漏源结合起来导致内存泄漏。
    还有很多例子可以想象 - 比如
    • 管理一个List实例,只向列表中添加元素而不从中删除(尽管您应该清除不再需要的元素),或者
    • 打开SocketsFiles,但在不再需要它们时不关闭它们(类似于涉及Connection类的上述示例)。
    • 在关闭Java EE应用程序时不卸载单例。加载单例类的类加载器将保留对该类的引用,因此JVM永远不会收集单例实例。通常在部署应用程序的新实例时会创建一个新的类加载器,并且由于单例而继续存在先前的类加载器。

    120
    通常情况下,你会先达到最大的开放连接限制,然后才会达到内存限制。不要问我为什么知道这个…… - Hardwareguy

    130

    可能是最简单的潜在内存泄漏示例之一,以及如何避免它的实现是ArrayList.remove(int):

    public E remove(int index) {
        RangeCheck(index);
    
        modCount++;
        E oldValue = (E) elementData[index];
    
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index + 1, elementData, index,
                    numMoved);
        elementData[--size] = null; // (!) Let gc do its work
    
        return oldValue;
    }
    

    如果您自己实现这个功能,您会想到清除不再使用的数组元素吗(elementData[--size] = null)?这个引用可能会保持一个很大的对象存在...


    5
    这里的内存泄漏在哪里? - rds
    34
    @maniek说:“我并不是想暗示这段代码存在内存泄漏。我引用它只是为了说明有时需要使用非显而易见的代码来避免意外的对象保留。” - meriton

    76
    任何时候,如果你保留了不再需要的对象的引用,就会出现内存泄漏。请参阅处理Java程序中的内存泄漏,了解Java中内存泄漏的表现形式以及如何解决这个问题。

    @31eee384 让我们在聊天室里继续这个讨论 - ehabkost

    59

    您可以使用 sun.misc.Unsafe 类来造成内存泄漏。事实上,这个服务类在不同的标准类中被使用(例如在 java.nio 类中)。不能直接创建此类的实例,但是您可以使用反射来获取实例。

    代码无法在 Eclipse IDE 中编译 - 请使用命令 javac 进行编译(在编译过程中,您将收到警告)。

    import java.lang.reflect.Constructor;
    import java.lang.reflect.Field;
    import sun.misc.Unsafe;
    
    public class TestUnsafe {
    
        public static void main(String[] args) throws Exception{
            Class unsafeClass = Class.forName("sun.misc.Unsafe");
            Field f = unsafeClass.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            System.out.print("4..3..2..1...");
            try
            {
                for(;;)
                    unsafe.allocateMemory(1024*1024);
            } catch(Error e) {
                System.out.println("Boom :)");
                e.printStackTrace();
            }
        }
    }
    

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接