Java和基于Java虚拟机的语言在开发者中非常受欢迎,有多种原因。丰富的生态系统,大量的开源框架,可以很容易地融入和使用,这只是其中之一。其次,在我看来,是有强大的垃圾收集器的自动内存管理。Java的垃圾收集器,或简称**GC,**负责清理未使用的碎片。这意味着你所有的对象、变量和其他任何占用堆内存且不再使用的东西最终都会被清理掉。
然而,并不是所有的事情都像乍看之下那么光明。整个Java虚拟机生态系统--也就是Java--也很容易受到内存泄漏的影响。让我们来研究一下什么是Java内存泄漏,如何检测我们的软件是否受到内存泄漏的影响,以及如何处理它们。
定义。什么是Java中的内存泄漏
内存泄漏是指一个或多个对象不再被使用,但同时它们又不能被持续工作的垃圾收集器清除的情况。
我们可以将内存中的对象分为两大类。
- 被引用的对象是那些可以从我们的应用程序代码中接触到的对象,并且正在或将要被使用。
- 未引用的对象是那些从应用程序代码中无法到达的对象。
垃圾收集器最终会从堆中删除未引用的 对象,为新的对象腾出空间,但它不会删除被引用的对象,因为它们被认为是重要的。这样的对象会使Java堆的内存越来越大,并促使垃圾收集器做更多的工作。这将导致你的应用程序变慢,甚至最终抛出OutOfMemory异常而崩溃。
内存泄漏的症状
有一些症状可以让你怀疑你的Java应用程序正在遭受内存泄漏。让我们来讨论最常见的症状。
- 当应用程序运行时出现JavaOutOfMemory 错误。
- 当应用程序运行时间较长时,性能下降,而不是在应用程序刚开始时就出现。
- 应用程序运行的时间越长,垃圾收集时间越长。
- 跑出连接数。
导致Java内存泄漏的原因
这很残酷,但我不得不说--是我们这些开发者造成了内存泄露。它们是由我们的应用程序的代码没有正确编写造成的。幸运的是,有几种类型的Java内存泄露是众所周知的,通过在编写Java代码时给予一定程度的关注,我们可以确保它们不会出现在我们的代码中。
内存泄露的类型
让我们看看最常见的内存泄漏的原因。
静态字段持有对象引用
Java内存泄漏的最简单的例子之一是通过静态字段引用的对象没有被清除。例如,一个持有对象集合的静态字段,我们永远不会清除或丢弃。
这种行为的一个简单例子可以用下面的代码来证明。
public class StaticReferenceLeak {
public static List<Integer> NUMBERS = new ArrayList<>();
public void addBatch() {
for (int i = 0; i < 100000; i++) {
NUMBERS.add(i);
}
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1000000; i++) {
(new StaticReferenceLeak()).addBatch();
System.gc();
Thread.sleep(10000);
}
}
}
addBatch方法向名为NUMBERS的集合添加100000个整数。当然,如果我们需要这些数据,这是完全可以的。但是在这种情况下,我们永远不会删除它。尽管我们在main方法中创建了StaticReferenceLeak对象,并且不持有对它的引用,但我们可以很容易地看到,垃圾收集器无法清理内存。相反,它不断地增长。
如果我们没有看到StaticReferenceLeak类的实现细节,我们会认为对象使用的内存会被释放,但情况并非如此,因为NUMBERS集合是静态的。如果它不是静态的就不会有问题,所以在使用静态变量时要特别小心。
如何避免它。为了避免和潜在地防止这种类型的Java内存泄漏,你应该尽量减少静态变量的使用。如果你必须拥有它们,就要格外小心,当然,当不再需要时,要从静态集合中删除数据。
未封闭的资源
访问位于远程服务器上的资源、打开文件并处理它们等等并不罕见。这样的代码需要在我们的代码中打开一个流、连接或文件。但我们必须记住,我们不仅要负责打开资源,还要负责关闭它。否则,我们的代码会泄漏内存,最终导致OutOfMemory错误。
为了说明这个问题,让我们看一下下面的例子。
public class UnclosedResources {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1000000; i++) {
URL url = new URL("http://www.google.com");
URLConnection conn = url.openConnection();
InputStream is = conn.getInputStream();
// rest of the code goes here
}
}
}
上述循环的每一次运行都会导致URLConnection实例被打开和引用,从而导致资源的缓慢耗尽--内存。
如何避免它。防止所描述的情况其实很简单--要么记得使用finally块,要么使用作为更新的Java版本的一部分的try-with-resources代码块。
使用具有不正确的equals()和hashCode()实现的对象
Java内存泄漏的另一个常见的例子是使用具有自定义equals()和hashCode()方法的对象,这些方法没有被正确地实现(或者根本不存在),使用散列法来检查重复的集合。这种集合的一个例子是HashSet。
为了说明这个问题,让我们看一下下面的例子。
public class HashAndEqualsNotImplemented {
public static void main(String[] args) {
Set<Entry> set = new HashSet<>();
for (int i = 0; i < 1000; i++) {
set.add(new Entry("test"));
}
System.out.println(set.size());
}
}
class Entry {
public String entry;
public Entry(String entry) {
this.entry = entry;
}
}
在我们深入解释之前,问自己一个简单的问题。通过System.out.println(set.size())的调用,代码将打印出什么数字?如果你的答案是1000,那么你是对的。这是因为我们没有正确实现equals方法。这意味着Entry对象的每个实例都会被添加到HashSet中,无论从我们的角度来看这是否是一个重复的对象。这可能会导致OutOfMemory异常。
如果我们用正确的实现方式来改变我们的代码,那么代码将导致打印出1作为我们HashSet的大小。为了给你一个例子,这里是由JetBrains IntelliJ实现的**equals()和hashCode()**方法的代码。
public class HashAndEqualsNotImplemented {
public static void main(String[] args) {
Set<Entry> set = new HashSet<>();
for (int i = 0; i < 1000; i++) {
set.add(new Entry("test"));
}
System.out.println(set.size());
}
}
class Entry {
public String entry;
public Entry(String entry) {
this.entry = entry;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Entry entry1 = (Entry) o;
return Objects.equals(entry, entry1.entry);
}
@Override
public int hashCode() {
return Objects.hash(entry);
}
}
如何避免它。作为一条经验法则,在创建类时要正确实现**equals()和hashCode()**方法。大多数现代IDE会帮助你实现它们。
引用外部类的内部类
在我看来,一个非常有趣的情况是,内部的私有类保持对其父类的引用。考虑一下下面的情况。
public class OuterClass {
// some large arrays of values
private InnerClass inner;
public void create() {
inner = new InnerClass();
// do something with inner and keep it
}
class InnerClass {
// some logic of the inner class
}
}
假设OuterClass包含对大量对象的引用,占据了大量的内存,即使它不再被使用,也不会被垃圾回收。这是因为InnerClass对象将有一个对OuterClass的隐式引用,这使得它不符合垃圾收集的条件。
如何避免它。这是关于内类的要求,以及它是否应该访问外类中的数据。如果不是,把内层类变成静态的就可以解决这个问题。你也可以考虑一下,首先是否真的需要这个内部的私有类,也许可以使用不同的架构模式。
ThreadLocals
ThreadLocal是Java世界中的一种结构,它允许我们将处理范围只隔离给当前线程,从而在某些情况下实现线程安全。你可以保留关于当前用户的信息、与用户绑定的执行上下文,或者任何需要在线程间隔离的信息。
当你开始从一个更广泛的角度思考时,问题就出现了。现代应用服务器或Servlet容器使用线程池来控制可并发运行的线程数量,从而重复使用相同的线程。在这种情况下,线程会被重复使用,而且不会被垃圾回收,因为对线程的引用一直保存在线程池本身。
这不是ThreadLocal本身的问题,而是在一般情况下,现代技术栈内部发生的复杂情况。你应该期待这一点,并记住分配给ThreadLocal的值将被保留,因此需要清理,因为否则内存将在ThreadLocal内被使用。
如何避免它。首先,清理一切。ThreadLocal提供了remove()方法,可以删除当前线程对这个变量的值,有效地清除数据。你甚至可以考虑在最终块中清除ThreadLocal中的数据,这样即使在代码执行过程中发生异常,最终块也会被执行,从而将数据从内存中删除。
Java内存泄漏检测
有多种诊断Java内存泄漏的方法,但单一的方法不能防止或检测所有的事情。你需要选择那些对你的使用情况有好处的、可以在你的软件开发周期内使用的方法。
冗长的垃圾收集
要知道你的内存发生了什么,最简单的方法之一是观察Java虚拟机的垃圾收集工作。垃圾收集执行其工作所需的时间越长,你的应用程序就越有可能出现内存问题。这可能首先不是内存泄漏,但是verbose垃圾收集可以帮助你确定有问题。
开启实时垃圾收集记录很容易,你只需要在JVM的启动参数中添加**-verbose:gc**参数,就可以了。
内存分析器
内存剖析器或一般的Java虚拟机剖析器,如Java VisualVM、YourKit、JProfiler和Mission Control,是允许你深入了解Java虚拟机内部发生了什么的应用程序。它们的主要功能之一是内存分析,可以深入了解哪些对象存储在堆上,哪些是引用,哪些数据保存在内存中,等等。在应用程序运行期间对其进行剖析可以指出问题,当涉及到缩小原因时,内存剖析器非常有帮助,因为你可以很容易地发现内容和对象引用。
VisualVM
堆转储
虽然使用内存剖析器是非常好的,并提供了很多关于你的应用程序如何使用堆内存的见解,但并不总是能够使用剖析器,特别是在生产环境中。这就是
heap dumps的用武之地。堆转储是你的Java虚拟机的堆内存的快照,可以按需生成,例如当应用程序因OutOfMemory错误而崩溃时。

要启用内存不足错误时的堆转储,你可以在你的JVM应用程序启动参数中添加-XX:+HeapDumpOnOutOfMemoryError标志,只要你的应用程序抛出OutOfMemory错误,就会生成堆转储。
一旦生成了堆,你将需要一个工具来分析它。大多数可以进行内存剖析的工具也可以加载堆转储并提供分析。还有其他一些工具,如Eclipse MAT。请记住,要打开一个大的堆转储来分析1对1,你可能需要与堆大小相似的内存量。
不过有一件事要记住--转储内存所需的时间。虽然这对于堆大小较小的应用程序来说可能不是问题,但具有大堆的JVM可能需要大量的时间来写入。你还需要确定你有足够的自由存储空间来容纳堆。可用的磁盘空间应该高于你的应用程序的最大堆大小。
代码评论
Github代码审查
在将代码纳入主分支之前进行代码审查和批准是检测代码问题的手动方式。虽然不是专门针对内存泄露的,但由一个人或一群人审查代码的过程将在许多方面受益。如果接近并做对了,它将有助于提高一般的代码质量,并发现静态代码分析中人类可以检测到的内存泄漏问题。
代码基准测试
对你的Java代码在修改前后的性能进行基准测试是另一种有可能发现Java内存泄漏的方法。比较代码在较长时间内的性能,有助于发现潜在的性能下降,这可能是由低效的设计和实现、错误或内存泄漏造成的。
IDE内存泄漏警告
一些集成开发环境在配置正确的情况下会对潜在的内存泄漏提供警告。例如,让我们看一下Eclipse。我们可以检查像资源泄漏和潜在资源泄漏这样的选项,并把它们设置为不被忽略,像这样设置为错误级别。
在这种情况下,有可能导致内存泄漏的代码会被IDE显示出来。
像这样的错误可以帮助你在开发过程中发现问题,也是防止泄漏的方法之一,可以通过自动代码分析发现。
Java虚拟机监控工具
除了我们目前所介绍的工具和技术,还有一个系列的产品可以帮助你识别应用程序的问题--可观察性工具。商业的和开源的解决方案,如果整合得当,可以给你和你的公司提供强大的洞察力,了解你的应用程序是如何工作的,它是如何使用资源的,其中之一就是堆内存。
观察你所选择的可观察性平台的趋势,可以给你提供关于你的应用程序如何表现的信息。注意到随着时间的推移速度减慢,以及内存使用量的任何增加和更高的垃圾收集器活动,都指向潜在的内存泄漏问题。
Sematext Cloud就是这样的观察平台之一,下一节将专门向你展示如何使用它来诊断Java内存泄露。如果你想看看外面的最佳选择,我们还写了一篇关于Java监控工具的文章。
用Sematext查找和分析Java内存泄露问题
当你需要一个很好的内存泄露分析工具时,Sematext Cloud提供两个主要功能。
Sematext Cloud JVM监控
第一个是JVM监控,它提供了对JVM内部实时情况的洞察力。对内存使用情况、JVM池大小、JVM池利用率和堆内存的查看有助于你了解模式。当在一个较长的时间段内查看这些JVM指标时,你可以很容易地发现内存随时间增长,这是在你的应用程序中发生内存泄漏的潜在迹象。JVM监控还提供了基本的垃圾收集器指标,这也是非常有用的。
Sematext Cloud JVM垃圾收集器日志集成
当谈到垃圾收集器时,Sematext Cloud提供了垃圾收集器日志集成,它可以解析来自Java虚拟机的垃圾收集器日志,让你深入了解垃圾收集器的工作,而不需要自己分析文件。
你将能够看到关于垃圾收集器工作的详细信息,如时间或收集前后使用了多少内存。这样的信息可以帮助发现潜在的内存泄漏,随着时间的推移,越来越多的时间会被用于收集垃圾,收集的内存也会越来越小。
结论
处理你的Java应用程序中的内存泄漏问题需要在编写代码时有一定的知识和谨慎的经验。但即使是深思熟虑的编码和有效的代码审查,问题也会发生,你应该能够快速有效地缩小它们,使它们不影响你的用户。Sematext Cloud是你的Java虚拟机应用监控的完美工具--一个可以处理所有问题的工具。
你可以深入了解你的应用程序的工作情况,如内存、垃圾收集器、JVM线程等。此外,Sematext Cloud为您提供了一个选项,可以运送您的垃圾收集器日志,以获得宝贵的洞察力,帮助您识别内存的潜在问题。自己运行一个JVM应用程序,Sematext Cloud有一个14天的免费试用期,让你尝试它的所有功能
The postHow to Detect Memory Leaks in Java:常见原因和避免它们的最佳工具出现在Sematext上。