1.什么是Java中的内存泄漏?
内存泄漏的标准定义是在以下情况下发生的情况:应用程序不再使用对象,但是垃圾收集器无法将它们从工作内存中删除-因为仍在引用它们。结果,应用程序消耗了越来越多的资源-最终导致致命的OutOfMemoryError问题(OOM)。
为了更好地理解该概念,以下是一个简单的视觉表示:
如我们所见,有两种类型的对象-引用对象和未引用对象。垃圾收集器可以删除未引用的对象。引用的对象将不会被收集,即使应用程序实际上不再使用它们也是如此。
检测内存泄漏可能很困难。许多工具会执行静态分析来确定潜在的泄漏,但是这些技术并不是完美的,因为最重要的因素是正在运行的系统的实际运行时(runtime)行为。
因此,让我们通过分析一些常见情况,重点研究一下防止内存泄漏。
2. Java堆泄漏
在本初始部分中,我们将重点介绍经典的内存泄漏情况-在这种情况下,将连续创建Java对象而不释放它们。
理解这些情况的一种有利技术是通过以下方式使重现内存泄漏更容易: 设置较小的堆大小。因此,在启动应用程序时,我们可以调整JVM以适应我们的内存需求:
-Xms<size>
-Xmx<size>
这些参数指定初始Java堆大小以及最大堆大小。
2.1 保持对象引用的静态字段
可能导致Java内存泄漏的第一种情况是使用静态字段引用大对象。
让我们看一个简单的例子:
private Random random = new Random();
public static final ArrayList list = new ArrayList(1000000);
@Test
public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException {
for (int i = 0; i < 1000000; i++) {
list.add(random.nextDouble());
}
System.gc();
Thread.sleep(10000); // to allow GC do its job
}
我们将ArrayList作为一个静态字段创建-即使在完成使用该字段的计算之后,在JVM进程的生存期内它也永远不会被JVM内存收集器收集。我们还调用了Thread.sleep(10000)以使GC可以执行完整收集并尝试回收所有可以回收的内容。
让我们运行测试并使用分析器分析JVM:
注意,从一开始,当然所有内存都是空闲的。
然后,只需2秒钟,迭代过程便会运行并完成-将所有内容加载到列表中(自然地,这取决于运行测试的计算机)。
此后,将触发一个完整的垃圾回收周期,并继续执行测试,以允许该周期时间运行并完成。如您所见,该列表不会被回收,内存消耗也不会减少。
现在,我们来看完全相同的示例,只是这次,ArrayList不会被静态变量引用。相反,它是一个先创建、使用然后丢弃的局部变量:
@Test
public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException {
addElementsToTheList();
System.gc();
Thread.sleep(10000); // to allow GC do its job
}
private void addElementsToTheList(){
ArrayList list = new ArrayList(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(random.nextDouble());
}
}
该方法完成工作后,我们将在下图大约50秒处观到主要的GC收集:
请注意,GC现在能够回收JVM使用的某些内存。
怎么预防呢?
既然您已经了解了内存泄漏的现象,那么当然有防止这种情况发生的方法。
首先,我们需要密切注意我们的static用法;将任何集合或大对象声明为静态的,会将其生命周期与JVM本身的生命周期联系起来,并使整个对象图无法收集。
2.2。在长字符串上调用String.intern()
第二组经常导致内存泄漏的场景涉及字符串操作-特别是String.intern()API。
让我们看一个简单的例子:
@Test
public void givenLengthString_whenIntern_thenOutOfMemory()
throws IOException, InterruptedException {
Thread.sleep(15000);
String str
= new Scanner(new File("src/test/resources/large.txt"), "UTF-8")
.useDelimiter("\\A").next();
str.intern();
System.gc();
Thread.sleep(15000);
}
在这里,我们只是简单地尝试将大文本文件加载到运行的内存中,然后使用.intern()来返回规范形式。
.intern() API将str字符串放置JVM内存池中(无法收集该字符串),将导致GC无法释放足够的内存:
我们可以清楚地看到,在最初的15秒内JVM稳定了,然后我们加载文件并由JVM执行垃圾回收(第20秒)。
最后,str.intern()调用将导致内存泄漏-稳定线表示堆内存使用率很高,它将永远不会释放。
怎么预防呢?
请记住,内部字符串对象存储在PermGen空间—如果我们的应用程序打算执行很多操作大字符串,我们可能需要增加永久代的大小:
-XX:MaxPermSize=<size>
第二种解决方案是使用Java 8-PermGen间由元空间替代-这样在字符串上使用intern不会导致任何OutOfMemoryError:
2.3 未关闭的流
忘记关闭流是一种非常普遍的情况,当然,大多数开发人员都可能涉及到这种情况。当Java 7中自动关闭所有类型的流的功能引入时,该问题已部分解决。见:try-with-resource子句。
为什么只是部分解决?因为的try-with-resources语法是可选的:
@Test(expected = OutOfMemoryError.class)
public void givenURL_whenUnclosedStream_thenOutOfMemory()
throws IOException, URISyntaxException {
String str = "";
URLConnection conn
= new URL("http://norvig.com/big.txt").openConnection();
BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
while (br.readLine() != null) {
str += br.readLine();
}
//
}
让我们看看从URL加载大文件时应用程序的内存表现:
如我们所见,堆使用率随着时间的推移逐渐增加-这是由于不关闭流而导致的内存泄漏的直接影响。
让我们对这种情况进行更深入的研究,因为它不像其他情况那样一目了然。从技术上讲,未关闭的流将导致两种类型的泄漏-底层资源泄漏和内存泄漏。
底层l资源泄漏仅仅是操作系统级资源的泄漏-例如文件描述符,打开的连接等。这些资源也是可能泄漏的,就像内存一样。
当然,因为JVM也使用内存来跟踪这些基础资源,这就是为什么资源泄漏也会导致内存泄漏。
怎么预防呢?
我们始终需要记住手动关闭流,或者利用Java 8中引入的自动关闭功能:
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
// further implementation
} catch (IOException e) {
e.printStackTrace();
}
在这种情况下,缓冲读取器会在结束时自动关闭try语句,无需显式关闭最后块。
2.4 未关闭的连接
这种情况与前一种情况非常相似,主要区别是处理未关闭的连接(例如,数据库连接,FTP服务器连接等)。同样,不正确的实现可能造成很多危害,从而导致内存问题。
让我们看一个简单的例子:
@Test(expected = OutOfMemoryError.class)
public void givenConnection_whenUnclosed_thenOutOfMemory()
throws IOException, URISyntaxException {
URL url = new URL("ftp://speedtest.tele2.net");
URLConnection urlc = url.openConnection();
InputStream is = urlc.getInputStream();
String str = "";
//
}
URLConnection保持打开状态,结果可以预见的是,内存泄漏:
请注意,垃圾收集器无法执行任何操作来释放未使用但引用的内存。第1分钟后情况立即就清楚了-GC操作数迅速减少,导致堆内存使用增加,从而导致OutOfMemoryError问题。
怎么预防呢?
答案很简单-我们需要始终以规定的方式关闭连接。
2.5 将没有 hashCode()和 equals()的对象添加到哈希集中
一个可能导致内存泄漏的简单但非常常见的示例是使用缺少hashCode()或equals()实现的哈希集。
具体来说,当我们开始将重复的对象添加到Set-这只会不断增长,而不会像预期的那样忽略重复项。添加后,我们也将无法删除这些对象。
让我们创建一个不带任何equals或hashCode的简单类:
public class Key {
public String key;
public Key(String key) {
Key.key = key;
}
}
现在,让我们看一下场景:
@Test(expected = OutOfMemoryError.class)
public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory()
throws IOException, URISyntaxException {
Map<Object, Object> map = System.getProperties();
while (true) {
map.put(new Key("key"), "value");
}
}
这个简单的实现将在运行时导致以下情况:
注意上图中显示的内存泄漏情况。
怎么预防呢?
在这种情况下,解决方案很简单-提供hashCode()和equals()实现。
这里值得一提的工具是Project Lombok—这通过注释提供了许多默认实现,例如@EqualsAndHashCode。
3.如何在您的应用程序中查找内存泄漏源
诊断内存泄漏是一个漫长的过程,需要大量的实践经验、调试技能和详细的应用程序知识。
让我们看看除了标准配置文件之外哪些技术可以为您提供帮助。
3.1 详细跟踪垃圾回收状况
识别内存泄漏的最快方法之一是启用Verbose垃圾回收。
通过添加-verbose:gc参数作为应用程序JVM配置的参数,我们启用了非常详细的GC跟踪。摘要报告显示在默认错误输出文件中,该文件应有助于您了解如何管理内存。
3.2 做分析
第二种技术是我们在整篇文章中一直使用的技术-这就是分析。最受欢迎的分析器是Visual VM-这是一个开始摆脱命令行 JDK工具并进入轻量级配置文件的好地方。
在本文中,我们使用了另一个分析器-YourKit——与Visual VM相比,它具有一些其他的更高级的功能。
3.3 检查代码
最后,与处理内存泄漏的特定技术相比,这是一种更通用的良好实践。
简而言之-彻底检查代码,定期进行代码检查,并充分利用静态分析工具来帮助您理解代码和系统。
结论
在本教程中,我们实际研究了JVM上的内存泄漏如何发生。了解这些情况如何发生是处理它们的第一步。
然后,使用技巧和工具来真正了解运行时发生的内存泄漏也很关键。静态分析和认真的代码检查也解决很多问题,而运行时将向您显示无法在代码中立即识别的更复杂的内存泄漏。
最后,众所周知,泄漏很难发现和重现,因为许多泄漏仅在高负荷下发生,而高负荷通常在生产中发生。在这里,您不仅需要进行代码级别的分析,还需要研究两个主要方面-再现和早期检测。
最好、最可靠的重现内存泄漏方法是借助好的性能测试套件尽可能接近地模拟生产环境的使用模式。
可以在GitHub上找到本教程的完整实现。这是一个基于Maven的项目,因此可以直接将其导入并按原样运行。