深入了解Java内存管理
优锐课带你对Java内存管理的深入了解将增强你对堆的工作方式、引用类型和垃圾回收的了解。
抽丝剥茧 细说架构那些事——【优锐课】 欢迎加入Java学习资料交流qq群:907135806与我们一起探讨。
你可能会认为,如果你使用Java进行编程,那么你需要了解什么有关内存的工作原理? Java具有自动内存管理功能,这是一个不错的,安静的垃圾回收器,它在后台工作以清理未使用的对象并释放一些内存。
因此,作为Java程序员,你不再需要像销毁对象那样麻烦自己,因为不再使用它们。但是,即使该过程在Java中是自动的,也不能保证任何事情。通过不知道垃圾收集器和Java内存的设计方式,即使你不再使用垃圾回收器和Java内存,你也可能拥有不符合垃圾收集条件的对象。
因此,了解内存在Java中的实际工作方式非常重要,因为它为你提供了编写高性能且经过优化的应用程序的优势,这些应用程序永远不会因OutOfMemoryError崩溃而崩溃。另一方面,当你发现自己的状况很糟时,你将能够快速找到内存泄漏。
首先,让我们看一下内存通常是如何用Java组织的:
通常,内存分为两大部分:堆栈和堆。请记住,此图片中的内存类型大小与实际内存大小不成比例。与堆栈相比,堆是大量的内存。
堆栈
堆栈内存负责保存对堆对象的引用,并负责存储值类型(在Java中也称为原始类型),值类型本身保存值,而不是对堆中对象的引用。
另外,堆栈上的变量具有一定的可见性,也称为作用域。仅使用活动范围中的对象。例如,假设我们没有任何全局作用域变量(字段),只有局部变量,那么如果编译器执行方法的主体,则它只能访问该方法主体内的堆栈中的对象。它无法访问其他局部变量,因为它们超出了范围。方法完成并返回后,将弹出堆栈顶部,并且活动范围会更改。
也许你注意到在上图中,显示了多个堆栈存储器。这是因为Java的堆栈内存是按线程分配的。因此,每次创建和启动线程时,它都有自己的堆栈内存-并且无法访问另一个线程的堆栈内存。
堆
内存的这一部分将实际对象存储在内存中。这些由堆栈中的变量引用。例如,让我们分析以下代码行中发生的情况:
StringBuilder builder = new StringBuilder();
new关键字负责确保堆上有足够的可用空间,在内存中创建StringBuilder类型的对象,并通过堆栈上的“ builder”引用对其进行引用。
每个运行的JVM进程只有一个堆内存。因此,无论正在运行多少线程,这都是内存的共享部分。实际上,堆结构与上图所示有所不同。堆本身分为几个部分,这有助于垃圾收集过程。
未预定义最大堆栈和堆大小-这取决于运行的计算机。但是,在本文后面,我们将研究一些JVM配置,这些配置将允许我们为正在运行的应用程序显式指定其大小。
参考类型
如果仔细查看“内存结构”图片,你可能会注意到,表示对堆中对象的引用的箭头实际上是不同类型的。这是因为在Java编程语言中,我们有不同类型的引用:强引用,弱引用,软引用和幻像引用。引用类型之间的区别在于,它们引用的堆上的对象可以在不同条件下进行垃圾回收。让我们仔细看看它们中的每一个。
1. 强引用
这些是我们大家都习惯的最流行的引用类型。在上面使用StringBuilder的示例中,我们实际上对堆中的对象持有强大的引用。堆上的对象不是有垃圾收集的,而是有一个指向它的强引用,或者通过一串强引用可以很强地到达该对象。
2. 弱引用
简单来说,在下一个垃圾回收过程之后,对堆中对象的弱引用最有可能无法幸免。弱引用创建如下:
WeakReference<StringBuilder> reference = new WeakReference<>(new StringBuilder());
弱引用的一个很好的用例是缓存方案。想象一下,你检索了一些数据,并且还希望将其存储在内存中-可以再次请求相同的数据。另一方面,你不确定何时或是否再次请求此数据。因此,你可以对其进行弱引用,以防万一垃圾收集器运行,可能是它破坏了堆上的对象。因此,过一会儿,如果你要检索所引用的对象,则可能突然返回一个空值。缓存方案的一个很好的实现是集合WeakHashMap<K,V>。如果我们在Java API中打开WeakHashMap类,则会看到其条目实际上扩展了WeakReference类,并使用其ref字段作为map的键:
/**
* The entries in this hash table extend WeakReference, using its main ref
* field as the key.
*/
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
V value;
一旦从WeakHashMap提取了一个键,就将整个条目从映射中删除。
3. 软引用
这些类型的引用用于对内存更敏感的方案,因为只有当你的应用程序内存不足时,这些引用才会被垃圾回收。因此,只要没有必要释放空间,垃圾收集器就不会接触柔软可触及的对象。Java保证在抛出OutOfMemoryError之前清除所有软引用的对象。Javadocs指出:“确保在虚拟机引发OutOfMemoryError之前,已清除所有对可软访问对象的软引用。”
与弱引用类似,按如下方式创建软引用:
SoftReference reference = new SoftReference<>(new StringBuilder());
4. 幻像引用
由于我们确定对象不再活动,因此用于安排事后清理操作。仅与引用队列一起使用,因为此类引用的.get() 方法将始终返回null。这些类型的引用被认为比终结器更可取。
如何引用字符串
Java中的String类型有所不同。字符串是不可变的,这意味着每次使用字符串进行操作时,实际上都会在堆上创建另一个对象。对于字符串,Java管理内存中的字符串池。这意味着Java会尽可能存储和重用字符串。对于字符串文字,大多数情况下都是这样。例如:
String localPrefix = "297"; //1
String prefix = "297"; //2
if (prefix == localPrefix)
{
System.out.println("Strings are equal" );
}
else
{
System.out.println("Strings are different");
}
运行时,将打印出以下内容:
Strings are equal
因此,事实证明,在比较了String类型的两个引用之后,这些引用实际上指向堆上的相同对象。但是,这对于计算的字符串无效。 假设我们在上述代码的// 1行中进行了以下更改
String localPrefix = new Integer(297).toString(); //1
输出:
Strings are different
在这种情况下,我们实际上看到堆上有两个不同的对象。如果我们认为计算字符串会经常使用,则可以通过在计算字符串的末尾添加 .intern() 方法来强制JVM将其添加到字符串池中:
String localPrefix = new Integer(297).toString().intern(); //1
添加以上更改将创建以下输出:
Strings are equal
垃圾收集程序
如前所述,根据堆栈中变量保存到堆中对象的引用的类型,在某个时间点,该对象将成为垃圾收集器的合格对象。
例如,所有红色的对象都可以被垃圾收集器收集。你可能会注意到堆上有一个对象,该对象具有对堆上其他对象的强引用(例如,可以是一个对其项目有引用的列表,或者是一个具有两个引用类型字段的对象)。但是,由于堆栈中的引用丢失了,因此无法再对其进行访问,因此它也是垃圾。
为了更深入地介绍细节,我们首先要提到几件事:
• 此过程由Java自动触发,并且取决于是否以及何时启动此过程。
• 实际上这是一个昂贵的过程。 运行垃圾收集器时,应用程序中的所有线程都将暂停(取决于GC类型,这将在后面讨论)。
• 实际上,这不仅仅是一个垃圾收集和释放内存的过程,更复杂的过程。
即使Java决定了何时运行垃圾收集器,你也可以显式调用System.gc() 并期望在执行这一行代码时垃圾收集器将运行,对吗?
这是一个错误的假设。
你只是要求Java运行垃圾收集器,但还是要由Java来决定。无论如何,不建议显式调用System.gc()。
由于这是一个非常复杂的过程,并且可能会影响你的性能,因此可以通过一种聪明的方式来实现它。为此使用了所谓的“标记和扫描”过程。Java分析堆栈中的变量,并“标记”所有需要保持活动状态的对象。然后,清除所有未使用的对象。
因此,实际上,Java不会收集任何垃圾。实际上,垃圾越多,对象被标记为活动的越少,该过程就越快。为了使其更加优化,堆内存实际上由多个部分组成。我们可以使用Java JDK附带的工具JVisualVM可视化内存使用情况和其他有用的东西。唯一要做的就是安装一个名为Visual GC的插件,该插件可让你查看内存的实际结构。让我们放大一下并分解大图:
创建对象后,将在Eden(1)空间上分配该对象。由于Eden空间并不大,因此充满空间很快。垃圾收集器在Eden空间上运行,并将对象标记为活动。
一旦对象在垃圾回收过程中幸存下来,就将其移入所谓的幸存者空间S0(2)。垃圾收集器第二次在Eden空间上运行时,会将所有幸存的对象移动到S1(3) 空间。同样,当前在S0(2) 上的所有内容都移入 S1(3) 空间。
如果某个对象可以进行X轮垃圾回收(X取决于JVM的实现,在我的情况下为8),则它很可能永远存在,并被移到Old(4) 空间。
到目前为止,如果你查看所有垃圾回收器图表(6),则每次运行时,你都可以看到对象切换到幸存者空间,而Eden空间获得了空间。等等等等。老一代也可以进行垃圾回收,但是由于与Eden空间相比,它占了内存的更大部分,因此这种情况很少发生。Metaspace(5)用于在JVM中存储有关已加载类的元数据。
所显示的图片实际上是Java 8应用程序。在Java 8之前,内存的结构有些不同。元空间实际上称为PermGen.空间。例如,在Java 6中,该空间还存储了字符串池的内存。因此,如果Java 6应用程序中的字符串太多,则可能会崩溃。
垃圾收集器类型
实际上,JVM具有三种类型的垃圾收集器,程序员可以选择使用哪种类型。默认情况下,Java根据基础硬件选择要使用的垃圾收集器类型。
- Serial GC –单线程收集器。通常适用于数据量少的小型应用程序。可以通过指定命令行选项来启用: -XX:+UseSerialGC
- Parallel GC –即使是命名,Serial和Parallel之间的区别还在于Parallel GC使用多个线程来执行垃圾收集过程。这种GC类型也称为吞吐量收集器。可以通过明确指定选项来启用: -XX:+UseParallelGC
- Mostly concurrent GC –如果你还记得本文前面提到的垃圾回收过程实际上是非常昂贵的,并且在运行时,所有线程都将暂停。但是,我们有这种主要是并发的GC类型,表明它与应用程序并发工作。但是,有一个原因使其“大部分”是并发的。它不适用于该应用程序100%并发运行。在一段时间内线程被暂停。尽管如此,暂停时间应尽可能短,以实现最佳的GC性能。实际上,主要有两种并发GC:
3.1 Garbage First –高吞吐量和合理的应用程序暂停时间。通过以下选项启用: -XX:+UseG1GC
3.2 Concurrent Mark Sweep –应用程序暂停时间保持最小。可以通过指定选项来使用: -XX:+UseConcMarkSweepGC. 从JDK 9开始,此GC类型已弃用。
技巧和窍门
• 为了最大程度地减少内存占用,请尽可能限制变量的范围。请记住,每次弹出堆栈顶部的作用域时,该作用域中的引用都会丢失,这会使对象有资格进行垃圾收集。
• 明确引用空的过时引用。这将使那些引用的对象有资格进行垃圾收集。
• 避免使用终结器。他们放慢了过程,他们不保证任何事情。 首选幻像引用进行清理工作。
• 不要在使用弱引用或软引用的地方使用强引用。最常见的内存陷阱是在缓存场景中,即使数据可能不需要时也保留在内存中。
• JVisualVM还具有在特定点进行堆转储的功能,因此你可以针对每个类分析其占用的内存量。
• 根据应用程序要求配置JVM。在运行应用程序时,明确指定JVM的堆大小。内存分配过程也很昂贵,因此请为堆分配合理的初始和最大内存量。如果你知道从一开始就以较小的初始堆大小开始是没有意义的,那么JVM将扩展此内存空间。使用以
下选项指定内存选项:
o 初始堆大小 -Xms512m –将初始堆大小设置为512 MB。
o 最大堆大小 -Xmx1024m –将最大堆大小设置为1024 MB。
o 线程堆栈大小 -Xss128m –将线程堆栈大小设置为128 MB。
o 年轻代大小 -Xmn256m –将年轻代大小设置为256 MB。
• 如果Java应用程序因OutOfMemoryError而崩溃,并且你需要一些其他信息来检测泄漏,请使用–XX:HeapDumpOnOutOfMemory参数运行该过程,该参数将在下次发生此错误时创建堆转储文件。
• 使用-verbose:gc选项获取垃圾收集输出。每次进行垃圾收集时,都会生成一个输出。
结论
知道内存的组织方式可以使你从内存资源上编写良好且经过优化的代码,从而具有优势。有利的是,你可以通过提供最适合正在运行的应用程序的不同配置来调整正在运行的JVM。如果使用正确的工具,发现并修复内存泄漏只是一件容易的事。
感谢阅读!
另外近期整理了一套完整的java架构思维导图,分享给同样正在认真学习的每位朋友~
【优锐课】Java学习资料交流qq群:907135806,在接下来的学习如果过程中有任何疑问,欢迎进群探讨。也可以添加vx:ddmsiqi,有更多JVM、Mysql、Tomcat、Spring Boot、Spring Cloud、Zookeeper、Kafka、RabbitMQ、RockerMQ、Redis、ELK、Git等Java学习资料和视频课程干货分享!
上一篇: 处理器体系结构
下一篇: Jmeter后置处理器之JSON提取器