Java内存大家都知道,但你知道要怎么管理Java内存吗?

718

前言

深入研究Java内存管理,将增强你对堆如何工作、引用类型和垃圾回收的认识。

你可能会思考,如果你使用Java编程,关于内存如何工作你需要了解哪些哪些信息?Java可以进行自动内存管理,而且有一个很好的、安静的垃圾回收器,它在后台工作,清理那些未使用的对象并释放一些内存。
因此,作为一名Java程序员,你不需要再为销毁无用对象这样的问题而烦恼了。但是,虽然这个过程在Java中是自动的,它也不能保证任何事情。由于不知道垃圾回收器和Java内存是如何设计的,有些对象即使你不再使用了,却也不符合垃圾回收的条件。

因此,了解Java中内存实际是如何工作的非常重要,因为它为你编写高性能和优化的应用程序提供了帮助,这些应用程序永远不会因内存不足而崩溃。另一方面,当你发现自己处于糟糕的境地时,你将能够很快发现内存的漏洞。

首先,让我们看看内存在Java中通常是如何组织的:

通常,内存分为两大部分:堆栈和堆。请记住,内存类型在上图中的大小与实际内存大小不成比例。与堆栈相比,堆是一个巨大数量的内存。

堆栈

堆栈内存负责保存对堆对象的引用和存储值类型(在Java中也称为基元类型),值类型保存值本身而不保存对堆中对象的引用。

此外,堆栈上的变量具有一定的可见性,也称为作用域。只有活跃作用域内的对象才能被使用。例如,假设我们没有任何全局作用域变量(字段),只有局部变量,如果编译器执行方法的主体,它只能访问方法主体内堆栈中的对象。它不能访问其它局部变量,因为这些变量超出了作用域。一旦方法完成并返回,堆栈顶部就会溢出,活跃作用域也会发生变化。

或许你注意到了在上图中显示的多个堆栈内存,这是因为Java中的堆栈内存是按线程分配的。因此,每次一个线程被创建和启动时,它都有自己的堆栈内存,并且不能访问另一个线程的堆栈内存。

堆内存将实际对象存储在内存中。这些对象被堆栈中的变量引用。例如,让我们分析下面一行代码发生了什么:
StringBuilder builder = new StringBuilder();
“new”关键字负责确保堆上有足够的可用空间,在内存中创建一个StringBuilder类型的对象,并通过堆栈中的“builder”引用它。
每个正在运行的JVM进程只有一个堆内存。因此,无论运行多少线程,这都是内存中的一个共享部分。实际上,堆结构与上图中显示的略有不同。堆本身被分成几个部分,这有助于垃圾回收进程。
最大堆栈和堆大小都没有预定义 - 这取决于正在运行的计算机。 然而,在后文中,我们将研究一些JVM配置,这些配置允许我们为正在运行的应用程序明确设定它们的大小

引用类型

如果仔细观察内存结构图片,你或许会注意到,代表对堆中对象引用的箭头的样式实际是不同的。这是因为,在Java编程语言中,我们有不同类型的引用:强引用、弱引用、软引用和虚引用。引用类型之间的区别在于它们所引用堆上的对象在不同的条件下可以被作为垃圾回收。让我们来仔细认识一下每一种引用类型。

1. 强引用>>>

这种引用类型是我们都习惯并且最受欢迎的引用类型。在上面的StringBuilder示例中,我们实际上使用了对堆中对象的强引用。当有一个强引用指向堆上的对象时,或者通过一系列强引用可以强访问该对象,则该对象不会被作为垃圾回收。

2. 弱引用>>

简单来说,在下一个垃圾回收进程之后,对堆中对象的弱引用很可能不会继续存在了。弱引用的创建示例如下:
WeakReference<StringBuilder> reference = new WeakReference<>(new StringBuilder());
弱引用的一个很好的用例是缓存方案。假设你检索了一些数据,并且还希望将其存储在内存中—这样同样的数据可以被再次请求。另一方面,你不确定何时或者是否会再次请求这些数据。因此,你可以保留对它的弱引用,万一垃圾回收器运行,它可能会破坏堆中的对象。因此,过了一会儿,如果你想要检索你引用的对象,你可能会突然得到一个空的返回值。缓存方案的一个很好的使用是回收WeakHashMap。如果我们在Java API中打开WeakHashMap类,我们会看到它的条目实际上扩展了WeakReference类,并使用它的引用字段作为映射的关键字:
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> 
{ 
V value;
......
}
一旦WeakHashMap中的一个关键字被进行了垃圾回收,整个条目就会从映射中移除。

3. 软引用>>>

这种引用类型用于对内存更敏感的方案,因为只有当应用程序内存不足时,所引用的对象才会被作为垃圾回收。因此,只要没有迫切需要释放出一些内存空间,垃圾回收器就不会去回收软引用的对象。Java保证在抛出OutOfMemoryError之前清除所有软引用的对象。Javadocs表明:“在虚拟机抛出OutOfMemoryError之前,所有对可软访问对象的软引用都会确保被清除。” 与弱引用类似,软引用的创建示例如下:
SoftReference<StringBuilder> reference = new SoftReference<>(new StringBuilder());
......
}

4. 虚引用>>>

用于算法检查后的清理操作,因为我们知道有些对象不需要再存在。仅与引用队列一起使用,因为此类引用的.get()方法将始终返回空值。这些引用类型被认为是优于终结器的。

如何引用字符串

Java中对字符串类型的处理略有不同。字符串是不可变的,这意味着每次使用字符串执行操作时,实际上都会在堆上创建另一个对象。对于字符串,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
因此,可以看出在比较了字符串类型的两个引用之后,它们实际上指向了堆中的相同对象。但是,这对于被计算的字符串无效。假设我们对上述代码的//1行进行以下更改
String localPrefix = new Integer(297).toString(); //1
输出:
Strings are different
在这种情况下,我们实际上看到堆上有两个不同的对象。如果我们考虑到计算出的字符串会被经常使用,我们可以强制JVM通过在计算的字符串末尾添加.intern()方法将计算的字符串添加到字符串池当中:
String localPrefix = new Integer(297).toString().intern(); //1
进行上述更改后输出如下:
Strings are equal

垃圾回收进程

正如前面所讨论的,根据堆栈中的变量对堆中对象的引用类型,在某个确定的时间点,该对象符合垃圾回收器的条件。
比方说,所有红色的对象都符合被垃圾回收器的条件。 你可能会注意到堆上有一个对象,它对同一堆上的其它对象进行了强引用(例如,可能是引用了自己项的列表,或者是具有两个引用类型字段的对象)。但是,由于堆栈中的引用丢失,这个对象就无法再被访问,因此它也成了垃圾。
为了更深入地了解细节,我们先提出以下几点:
1.这个过程是由Java自动触发的,何时启动以及是否启动此过程取决于Java。
2.实际上这个进程是昂贵的。当垃圾回收器运行时,应用程序中的所有线程都会暂停(取决于GC类型,稍后将对此进行讨论)。
3.这实际上是一个比垃圾回收和释放内存更复杂的进程。
尽管由Java决定何时运行垃圾回收器,你也可以直接调用System.gc( )并期望垃圾回收器在执行这行代码时运行,对吧?

这是一个错误的假设。

你只需要让Java运行垃圾回收器,但是是否运行垃圾回收器仍然取决于Java。无论如何,不建议直接调用System.gc( )。
由于这是一个非常复杂的过程,并且它可能会影响你程序的表现,它需要以一个智能的方式实现。 一个被称作“标记和扫描”的进程来完成此任务。Java分析堆栈中的变量并“标记”所有保持活跃的对象,然后清除所有不会使用的对象。
实际上,Java并没有回收任何垃圾。事实上,垃圾越多,标记为活跃的对象就越少,进程也就越快。为了使这个进程更加优化,堆内存实际由多个部分组成。我们可以通过JVisualVM(Java JDK附带的工具)可视化内存使用情况和其它一些有用的东西。您唯一需要做的就是安装一个名为Visual GC的插件,它允许您查看内存的实际结构。让我们放大一点,分解大局:
当一个对象被创建时,它被分配到Eden(1)区。因为Eden区的空间没有那么大,它很快就满了。垃圾回收器在Eden区运行,并标记出活跃的对象。
一旦一个对象在一次垃圾回收进程中存活,它就会被移动到所谓的幸存者区S0(2)中。 垃圾器第回收二次在Eden区上运行时,它会将所有幸存的对象移动到S1(3)区中。此外,当前在S0(2)区上的所有内容都将被移动到S1(3)区中。
如果一个对象在X轮垃圾回收中存活了下来(取决于JVM的实现,在我的例子中是8轮),那么它很可能会永远存活下来,并被移入到Old(4)区。
结合目前为止所说的一切,如果你看一下图中标号(6)的垃圾回收器,它每次运行时,你都可以看到对象切换到幸存者空间,并且Eden区的空间增大了。如此反复。老一代也可以被作为垃圾回收,但由于它在内存中空间是比Eden区更大的部分,因此这种情况不会经常发生。Metaspace(5)用于在JVM中存储已加载类的元数据。
所呈现的图片实际上是一个Java 8的应用程序。在Java 8之前的版本,内存的结构有点不同。元空间实际上称为PermGen. 区。例如,在Java 6中,此空间还为字符串池存储了内存。因此,如果Java 6应用程序中有太多字符串,则它可能会崩溃。

垃圾回收器类型

实际上,JVM有三种类型的垃圾回收器,程序员可以选择应该使用哪种垃圾回收器。默认情况下,Java根据底层硬件选择要使用的垃圾回收器类型。

1.串行垃圾回收器 :

 一个单线程回收器。 主要适用于数据使用量较小的小型应用程序。 可以通过指定命令行选项来启用:-XX:+ UseSerialGC

2.并行垃圾回收器 :

从命名可以看出,串行垃圾回收器和并行垃圾回收器之间的区别在于并行垃圾回收器使用多个线程来执行垃圾回收进行。并行垃圾回收器也被称作吞吐量回收器。可以通过直接指定选项来启用它:-XX:+ UseParallelGC

3.主要并发标记垃圾回收器 :

如果你还记得,在本文前面提到垃圾回收过程实际上相当昂贵,并且当它运行时,所有线程都被暂停。但是,我们有这种大多数并发GC类型,它声明它与应用程序并发工作。但是,它有“大多数”并发的原因。它不能100%同时应用于应用程序。线程暂停一段时间。尽管如此,暂停时间尽可能短,以实现最佳的GC性能。实际上,有两种类型的大多数并发GC:
3.1垃圾优先 :
应用程序合理暂停时间内的高吞吐量。 通过以下选项启用:-XX:+ UseG1GC
3.2并发标记扫描 :
应用程序暂停时间保持最短。可以通过指定选项来启用:-XX:+ UseConcMarkSweepGC。从JDK 9开始,这个垃圾回收器类型不推荐使用。。

提示和技巧

1.为了最小化内存的占用,请尽可能限制变量的作用域。请记住,每次堆栈中的顶级作用域溢出时,来自该作用域的引用都会丢失,这可能会导致相应的对象被作为垃圾回收。
2.直接对空的、废弃对象的引用,这会导致被引用的对象被作为垃圾回收。
3.避免成为终结者。 它们放慢了进程,不保证任何事情, 更喜欢进行对虚引用的清理工作。
4.当弱引用或软引用适用时,请不要使用强引用。最常见的内存缺陷是缓存方案,即使数据可能不需要,也会被保存在内存中。
5.JVisualVM还具有在某一点时间点进行堆转储的功能,因此你可以分析每一类所占用的内存量。
6.根据你的应用程序需求来配置JVM。运行应用程序时,明确指定JVM的堆大小。内存分配进程是宝贵的,因此要为堆分配一个合理的初始最大内存空间。如果你知道一开始使用较小的初始堆空间是没有意义的,JVM将扩展这个内存空间。 根据以下命令来明确内存空间:
(1)初始堆大小 -Xms512m 将初始堆大小设置为512 mb。
(2)最大堆大小 -Xmx1024m 将最大堆大小设置为1024 mb。
(3)线程堆栈大小 -Xss128m 将线程堆栈大小设置为128mb。
(4)新生代堆大小 -Xmn256m 将新生代堆大小设置为256mb。
7.如果Java应用程序崩溃并出现OutOfMemoryError,你需要一些额外的信息来检测漏洞,运行以下进程:-XX:HeapDumpOnOutOfMemory,它将在下次发生此错误时创建堆转储文件。
8.使用-verbose:gc选项获取垃圾回收输出。 每次进行垃圾回收时,都会生成一个输出

总结

从内存资源的角度看,了解内存是如何组织的,会为你编写良好、优化的代码提供优势。这样做的好处是,你可以通过提供最适合你所运行应用程序的不同配置,来优化你正在运行的JVM。如果使用正确的工具,发现和修复内存漏洞只是一件容易的事情。

最后

欢迎大家关注我的公众号【程序员追风】,文章都会在里面更新,整理的资料也会放在里面。