Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 “高墙”,墙外面的人想进去,墙里面的人却想出来。——《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》周志明
Java 虚拟机作为运行 Java 程序而抽象出来的计算机,具有内存管理的能力,像内存分配、垃圾回收等相关的内存管理问题,Java 虚拟机都可以帮我们解决,这点上,作为一个 Java 程序员要比 C++ 程序员幸福。但另一方面,如果 Java 程序员对 Java 虚拟机管理内存的知识不了解,那么内存方面一旦出现问题,就不知如何排查错误和改正。
这段时间看周志明先生的《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,下面就对 Java 虚拟机管理内存部分做一个系统的整理。
1、内存划分
内存是计算机中运行系统和软件的场所,将内存划分成不同的区域只是人为添加的概念,目的为了更好的描述 Java 虚拟机对内存的管理。下图中的运行时数据区域 即是 Java 虚拟机所管理的内存区域,可以看到这部分区域被划分成五个部分。
1.1 程序计数器
在 CPU 的寄存器中有指令计数器,而在 Java 虚拟机内存管理中也有类似的程序计数器。程序计数器占用很小的一块内存空间,并且每条线程中都有独立的程序计数器。指令计数器记录的是 CPU 将要执行的下一条指令的地址,而程序计数器也是类似的。在线程执行的 Java 方法时,程序计数器记录的是 Java 虚拟机正在执行的字节码指令的地址,而在线程执行 Native 方法时,程序计数器为空,因为此时 Java 虚拟机调用是和操作系统相关的接口(JNI),接口的实现不是 Java 语言,而是 C语言和 C++。
此区域是唯一一个在 Java 虚拟机规范中没有规定出现 OutOfMemoryError 情况的区域,对 OutOutOfMemoryError 的讲解会在后面说到。
1.2 Java 虚拟机栈
我们常将程序运行的内存划分为堆和栈,这样的划分其实不准确的,或者说是很粗糙的。在 Java 虚拟机中,栈又分为 Java 虚拟机栈和本地方法栈。同程序计数器一样,Java 虚拟机栈也是每条线程私有的。虚拟机栈描述的是 Java 方法执行时的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法开始被调用到执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈到出栈的过程。栈这种数据结构,就不多说了,特点是先进先出(FIFO),栈中的数据元素就是栈帧。下图是栈帧的这种数据在 Java 栈中的结构图。
局部变量表中存放的是编译期可知的各种基本数据类型,包括 Java 的八大基本数据类型和对象引用(reference)类型(这种类型不在这里详细说了)。一个方法需要在帧中分配多大的局部变量空间是在编译期就可以确定,方法在运行期间局部变量表的大小是不会改变的,也即是局部变量表所需的内存空间在编译期也已经确定。
在 Java 虚拟机规范中,对这个区域规定了两种异常出现的情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverflowError 异常。
- 如果虚拟机栈在动态扩展时无法申请到足够的内存,抛出 OutOfMemoryError 异常。
1.3 本地方法栈
本地方法栈和虚拟机栈作用是相似的,他们之间的区别无非是虚拟机在 Java 虚拟机栈中执行的是 Java 方法,虚拟机在本地方法栈使用的是 Native 方法。不同的 Java 虚拟机,对栈区域的实现是不同的,比如主流的 HotSpot 虚拟机就把 Java 虚拟机栈和本地方法栈合二为一了。
与 Java 虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
1.4 Java 堆
Java 堆是 Java 虚拟机所管理的内存中最大的一块,所有的线程都共享此区域。此区域的唯一目的就是存放 Java 对象(实例),可以将该区域看做是 Java 对象的出生地,几乎所有的对象都在这里分配内存(不同的编译器有所不同)。Java 堆也是垃圾收集器管理的主要区域,所以也被称做 “GC堆”。Java 堆在物理上可以处于不连续的内存空间,只要在逻辑上是连续的就可以了,就像磁盘空间存放文件一样。Java 堆的大小既可以是固定的,也可以是可扩展的,在主流的 java 虚拟机中是按照可扩展来实现的。关于 Java 堆的详细介绍将在后面说明。
在 Java 堆中,如果没有足够的内存空间完成对象实例的分配,并且堆也无法再扩展,将会抛出 OutOfMemoryError 异常。
1.5 方法区
同 Java 堆一样,方法区也是各个线程共享的区域,它用于存储已经被虚拟机加载过的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java 虚拟机规范中把方法区描述为堆的一个逻辑部分,也叫做 “非堆”,也是为了和 Java 堆区分开来。方法区和 Java 堆一样,也不需要连续的内存空间,在 Java 虚拟机的实现中,也是可以选择固定大小或者可扩展,并且还可以选择不实现垃圾回收,因为这个区域用到回收的地方很少,但是在实际开发中的经验教训告诉我们,对方法区进行垃圾回收也是很有必要的,这个区域同样会出现内存泄漏的问题。
在方法区中,有一部分被称为是运行时常量池。常量池除了用于存放在编译期生成的各种字面量和符号引用外,还存储了直接引用。运行时常量池具有动态性,常量并不一定实在编译期才会被放入该常量池,在运行期间也可能有新的常量进入池中,如调用 String 类的 intern() 方法会将该字符串对象放入字符串常量池中。
对字面量和符号引用不清楚的小伙伴可以看下面两篇扩展阅读哦。
方法区中并不是像字面意思那样存放方法的,它很像一个 Java世界的信息中心,类,常量、变量的信息都在这里。——个人理解
当方法区无法满足内存分配时,抛出 OutOfMemoryError 异常。
1.6 直接内存
直接内存并不在 Java 虚拟机管理的内存区域内,也不是 Java 虚拟机规范中定义的内存区域。直接内存是 Java 程序不经过 Java 虚拟机分配,直接使用外部主机的物理内存,这在一些场景中(如文件复制)可以提高性能,但是在使用过程中,也要注意主机内存大小的限制(包括物理和系统级的限制),否则也会抛出 OutOfMemoryError 异常。
2、堆的使用
Java 堆的使用,也即是对象创建时如何使用这部分内存。从语言层面上看,对象的创建只需一个 new 关键字,但是在 Java 虚拟机(这里指的是主流的 HotSpot 虚拟机)中,一个对象(这里讨论的对象不包括数组和 Class 对象)的创建过程却并简单。
2.1 给对象分配内存
当虚拟机遇到一条 new 指令时,首先会检查指令中参数对应类的信息,也即能否在方法区的常量池中找到这个类的符号引用,并检查符号引用对应的类是否已经被加载、解析(符号引用替换成直接引用)和初始化。如果还没有,那必须先执行类加载过程。在类加载过程后还会进行检查过程,检查通过后,虚拟机才会为新生的对象在 Java 堆中分配内存区域。注意这里对象还没产生,而只是给对象出生准备了必备资源。
这里简要介绍一下类加载的过程。
类加载过程是包含三个过程的,分别是类的加载,类的连接、类的初始化。
类的加载是由类加载器来完成的。
类的连接又分为验证、准备、解析三个阶段。验证阶段检查被加载的类是否有正确的内部结构,准备阶段为类的类变量分配内存并设置默认值,解析阶段将类的二进制数据中符号引用替换成直接引用。
类的初始化并不是初始化对象,而是初始化类的类变量值。类变量初始化方式也有直接在声明类变量时指定和使用静态代码块指定两种。
对象所需的内存大小在类加载完成后就可以确定下来,为对象分配内存空间相当于把一块确定大小的内存从 Java 堆中分出来。这个划分不一定是规整的,也即不是简单的将已经使用过的内存(已经分给对象的)放在一边,空闲的内存放在另一边,已经使用过的内存和空闲的内存很可能是相互交错的,所以这就需要一个列表来维护,来记录哪些内存块是可用的,哪些内存块已经被对象占用了,在这个过程中也很可能会产生一些内存碎片。根据 Java 堆中的内存是否规整,有“指针碰撞”和“空闲列表”两种分配方式,而 Java 堆是否规整,又取决于所采用的垃圾收集器是否具有内存压缩整理功能。对象内存的划分除了需要考虑内存空间外,还需要考虑引用对象的指针。在给一个对象A分配内存时,正在使用的指针P,绝对不能同时被另一个对象B使用来分配B对象所需的内存,也即是内存的分配必须是原子性的,这样才能保证在并发情况下的线程安全。当然解决这个问题,也存在不同的方法,一种是对分配内存空间的动作加锁,来达到同步处理的目的,另一种是把内存分配的动作按照线程划分,每个线程在 Java 堆中都先分配一小块内存区域,也被称为是“本地线程分配缓冲”(TLAB),各线程给对象分配内存时在该线程的 TLAB 上分配,各个线程互不影响。
2.2 初始化对象
给对象分配完内存后,虚拟机还需要将分配到的内存空间初始化为零(不包括对象头)。这一步操作可以保证对象实例字段在没有赋初值的情况下,就可以访问到这些字段的零值。对象的对象头中存放着这个对象是属于哪个类的实例,以及怎样找到类的元数据、对象的哈希码,对象在垃圾收集器中分代的年龄等信息。经过虚拟机初始化对象的字段值后,我们才可以说一个对象诞生了。并且对于我们程序员来说,对象的初始化还需要根据我们在程序中编写的构造函数初始化值。
2.3 对象在内存中存储
在 HotSpot 虚拟机中,对象在内存中的存储也是很有规律的,存储的布局可以分为三块区域:对象头区域、实例数据区域和对齐填充区域。
对象头在前面已经说了,对象头的一部分用于存储对象自身运行时的数据,另外部分存储的是类型指针,即对象指向的类元数据的指针,虚拟机通过这个指针来确定对象是属于哪个类的。
实例数据区域是对象存储的真正有效的信息,即在程序中定义的各种类型的字段数据。这部分数据有一部分是从父类中继承下来的,也有在子类中定义的,总之都要被记录下来。
对齐填充并不一定是必然存在的,因为 HotSpot 虚拟机内存管理的要求是给对象分配内存的大小必须是 8 字节的整数倍,所以不够的部分需要填充。又因为对象头部分正好是 8 字节的倍数,所以对齐填充补全的是实例数据区域。对齐填充的数据并没有特殊的含义,仅仅是起到填充占位符的作用。
2.4 访问对象
我们创建对象的目的是为了使用对象,那么如何访问一个对象呢?在前面对内存的划分中说到,Java 虚拟机栈的局部变量表,存放的数据除了基本的数据类型外,还有对象引用(reference)类型。引用类型比较特殊,在 Java 虚拟机规范中,只是规定这是指向一个对象的引用,但并没有规定这个类型的实现,也即是如何定位对象,访问对象在堆中的具体位置。在不同的虚拟机中,对象的访问方式也是不同的,主流的访问方式有使用句柄和直接指针两种。
使用句柄:
如果使用句柄访问方式,将会在Java 堆中划分出一块内存区域作为句柄池。如上图所示,reference 中存储的是对象的句柄地址,而句柄中包含的才是对象实例和对象类型各自的具体地址。所以可以看出,使用句柄是一种间接使用指针访问对象的方式。
直接指针:
如果使用直接指针访问方式,Java 堆就必须考虑如何放置访问对象有关的信息,reference 中直接存储的是对象地址。
两种访问方式各有优势,使用句柄访问方式的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的对象类型数据指针,而 reference 本身不需要被修改。而使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的的时间开销。在 HotSpot 虚拟机中采用的是第二种访问方式,但使用句柄方式来访问的情况在软件开发中也很常见。
小结:
Java 堆就像是游戏玩家出生的新手村,承载着一个对象来到 Java 世界的梦想,资源的分配,初始的装备,以及对象的区分和查找,每一步都和对象以后的命运息息相关。
3、内存异常
虽然说有 Java 虚拟机帮助我们管理内存,但在管理过程中仍然有内存异常发生的可能。具体的说就是,除了前面内存划分中说到的程序计数器外,其他区域都有 OutOfMemoryError 异常发生的可能。
我们可以给 Java 虚拟机设置参数来模拟这些异常的发生,不同的 Java 虚拟机运行结果可能也不同,这里使用的是 Oracle 公司的虚拟机。
特别说明:下面如果没有特殊说明,默认使用的是 JDK8 的虚拟机。
3.1 Java 堆内存异常
Java 堆是用于存储对象实例的,所以只要不断地创建对象,来把 Java 堆区域填满,并且保证垃圾回收机制不能清除这些对象,就可以模拟出 Java 堆内存的异常。
模拟程序代码如下:
1 | import java.util.ArrayList; |
这里使用 MAT 内存分析器插件来对内存异常进行分析,IDE 使用的是 Eclipse,当然 IDEA 也可以安装。该插件在 Eclipse 种的安装教程可以参看这篇文章《mat之一–eclipse安装Memory Analyzer》。
在 Eclipse 的 Debug 配置页面,设置 JVM 的参数。
JVM Debug 参数如下:
-verbose:gc -Xms20M -Xmx20M
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump
-Xms、-Xmx 后面的参数分别是 Java 堆的最小值、Java 堆的最大值,这里都是 20M。-XX后面可以添加一些额外的设置,PrintGCDetails 表示打印出垃圾收集时的详细信息,HeapDumpOnOutOfMemoryError 代表发生OutOfMemoryError 异常时记录内存快照,HeapDumpPath 后面是存放内存快照的文件夹位置。
Debug 结果如下:
从上图中可以看到 Java堆区域(Java heap space)出现了 OutOfMemoryError 的异常,并且在我们指定的文件夹中生成了内存异常时的快照文件。在使用 MAT 内存分析器工具之前,我们还要知道内存泄露和内存溢出的区别,我在前面没有将 OutOfMemoryError 异常翻译成内存泄露异常或内存溢出异常,而是使用原本的英文,也是为了准确性考虑,因为内存泄露和内存溢出都只是导致异常出现的原因,该事情的结果才是产生 OutOfMemoryError 异常。
内存泄露和内存溢出的区别:
- 内存泄露是指程序在申请内存后,无法释放已申请的内存空间,内存泄露会导致内存资源耗光,通俗的说就是对象占着内存空间无法归还给系统。
- 内存溢出是指程序申请内存使用时,发现内存空间并不能满足使用,很常见的例子就是在存一个大数时超过了该数据类型的最大值,通俗的是说就是程序使用内存空间时发现无法满足自己的要求。
知道了内存泄露和内存溢出的区别,我们再来用 MAT 工具分析内存快照,首先调出 MAT 视图,然后在 “File” 选项中选择 “Open Heap Dump” 打开内存快照文件。
打开后快照文件后,可以清晰的看出内存异常问题存在的地方(Problem Suspect)。
点击 “Details” 可以查看具体的细节。
可以看到 OOMObject 占用的内存空间很大,查看该对象是否有到 GC roots 的引用链,导致垃圾收集器无法回收对象占用的内存空间。这里可以看到是由于内存空间被占用后无法回收导致的 OutOfMemoryError 异常,异常产生的原因是内存泄露。
3.2 栈内存异常
在 HotSpot 虚拟机中并不区分 Java 虚拟机栈和本地方法栈,栈的容量可以通过 -Xss 参数来设定。
在 Java 虚拟机规范中描述了两种栈会出现的异常:
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverflowError 异常。
- 如果虚拟机栈在动态扩展时无法申请到足够的内存,抛出 OutOfMemoryError 异常。
栈的深度是由栈的内存空间决定的,请求的栈越深,也即是已使用的栈的空间占用越大,所以上面规定的两种异常是有重叠之处的,一种异常也可能会导致另外一种异常的发生,到底是栈的内存空间太小引起的内存异常还是已使用的栈的内存空间太大引起的内存异常?
减少栈内存的容量和定义大量的局部变量来增加栈帧中局部变量表的长度,在理论上都是可以产生 StackOverflowError 异常和 OutOfMemoryError 异常的。
但是下面的代码只能产生 StackOverflowError 异常。
1 | // 栈 StackOverflowError 异常 |
Debug 的参数为:-verbose:gc -Xss128k -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump
Debug 结果如下,只产生了 StackOverflowError 异常。
而在多线程环境中测试,可以才模拟出 OutOfMemoryError 异常。
特别提醒:此代码运行时会导致系统假死,具有一定的风险性,请在运行前保存好其他文件。
代码如下:
1 | // 栈 OutOfMemoryError 异常 |
由于在做这项危险的测试时,电脑系统死掉了,所以笔者并没有得出实际结果,根据《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,这里给出理论结果,也可以在虚拟机系统中尝试运行此代码,但也可能会出现外部系统假死的情况,读者可以自己尝试。
3.3 方法区内存异常
方法区中有运行时常量池,如果向常量池中添加大量的内容,就可以导致方法区内存异常,可以通过 -XX:Permsize 和 -XX:MaxPermSize 来限制方法区的大小,进而限制常量池的容量。常量池在编译期可以放入常量,在运行时也可以再添加新的常量。因为不存在内存被占用无法回收,所以这里的异常不是内存泄露导致的,而是内存溢出。
代码如下:
1 | import java.util.ArrayList; |
经过实际测试,发现 JDK6 会出现下面内存异常的情况,而在 JDK7 和 JDK8 中,发现垃圾回收器会不断的回收常量池的旧常量所占用的内存,以便新的常量可以进入,从而避免了常量池内存异常的发生,这说明新版本的 JDK 对这部分做了优化。
方法区用于存放类的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等。使方法区内存异常的大致思路是产生大量的类填满方法区,直到方法区内存溢出。由于实验操作起来比较麻烦。可以通过字节码增强技术,直接操作字节码文件来动态的生成大量的类,笔者并没有做测试,所以这里也是使用书中的运行结果。
3.4 直接内存异常
直接内存的大小可以通过 -XX:MaxDirectMemorySize 来指定,如果不指定默认是和 Java 堆的最大值(-Xmx)一样,可以通过使用 Unsafe 类来申请内存,由于该类的使用有限制,只有引导类的加载器才会返回对象实例,所以只能通过反射来获取 Unsafe 类的实例,但是在 Eclipse 中导入该类的包会报错,解决方案见参考文章。
参考文章:
eclipse中解决import sun.misc.Unsafe报错的方法
代码如下:
1 | import java.lang.reflect.Field; |
Debug 参数:-verbose:gc -Xmx20M -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails
由于在 Eclipse 中使用 JDK6 和 JDK7 运行该程序时会直接闪退,无法得到输出的异常,所以直接在控制台中使用 JDK8 编译运行该程序,运行结果如下:
注意:模拟内存异常是一件危险的事情,所以务必在测试前保存好各种文件,以免造成文件内容丢失。
4、垃圾回收
在前面我们模拟了内存异常,其实 Java 虚拟机已经为避免内存异常做出了最大努力(垃圾回收机制),但还是无法避免上面情况的发生。垃圾收集简称为 “GC”,垃圾回收机制主要解决下面三个问题:
- 哪些内存区域需要回收?
- 什么时候回收?
- 如何回收?
在前面对内存的划分中,程序计数器、虚拟机栈和本地方法栈都是随线程生和死的,栈中的栈帧随着方法的调用有序的进栈和出栈,每个栈帧上分配的内存大小在类结构确定时就已知了,所以这些区域内存的分配和回收都是具有确定性的,很容易回收。当方法调用结束或者线程结束,占用的内存就可以被回收。而在 Java堆和方法区中,每个类需要的内存都可能不一样,一个方法中多个分支需要的内存也可能不一样。这些都只有在运行期才能知道创建哪些对象,所以这部分内存的分配和回收都是动态的,垃圾回收也主要是对这部分的内存进行回收。
理论上来说,如果一个对象已死,那么此时它占用的内存就应该被回收。但是判断一个对象的生死,并不是那么容易的。就像我们也常说的,一些人虽然还活着但就像死了一样,而一些人虽然死了但仍然活在我们心中。在程序中生死可不能这么模棱两可,所以必须要有判断对象生死的方法。常见的判断算法有下面两种:
1、引用计数算法
给每个对象添加一个引用计数器,当有一个地方引用该对象时,就将引用计数器的值加1,当引用失效时,就将引用计数器的值减1,当计数器的值为0时,就说明不存在对该对象的引用了,那这个对象就没有存活的意义了,也即是说这个对象是死的,可被回收。
缺点:这种判断方法虽然看起来简单高效,但是不能解决对象之间循环引用的问题。例如 A 对象和 B对象都是同一个类的对象实例,A 中字段 instance 引用对象 B,B 中字段 instance 引用对象 A。如果垃圾收集器想要回收 A 对象,那么 A 的引用计时器值要为 0,也就是要清除 B 中字段 instance 对 A 的引用,这就需要清除 B 对象,而 B 对象又被 A 中的字段 instance 引用着,需要清除 A 对象,想要回收 A 对象,A 的 引用计数器值要为 0 ……这样就形成了循环,A 和 B 都不能被回收。不知道你有没有懵逼,反正垃圾收集器已经懵逼了。
2、可达性分析算法
从一个根节点开始向下搜索对象,搜索所走的路径称为是引用链,当一个对象从根节点开始找不到一条引用链时,就说明这个对象无法被使用了,或者说是对象已死可被回收。这个根节点叫做 GC Roots,是一个特殊的对象,且绝对不能被其他对象引用,不然也会像引用计数算法那样有循环引用的问题,GC Roots 对象包括虚拟机栈(栈帧本地变量表)中引用的对象、方法区中静态属性引用的对象、方法区常量引用的对象、本地方法栈中(Native 方法)引用的对象。
上面这两种判断方法都和“引用”有关,引用计数算法需要计算对象的引用数量,可达性分析算法需要判断对象是否有可达的引用链。引用也是一个很模糊的概念,为了更加清晰的描述 Java 中的对象引用,在 JDK1.2 后,Java 将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference) 4 种,并且除了强引用外都有与之对应的 Java 类,分别是引用的基类 Reference、软引用类 SoftReference、弱引用类 WeakReference、虚引用类 PhantomReference。
强引用很常见,类似 Object obj = new Object()
这种引用就是强引用。
软引用是用来描述一些有用但非必须的对象引用,当内存紧张的时候,才会把这些对象列为回收目标,进行二次回收,如果回收之后还是没有足够的内存,那么就会出现内存异常。
弱引用也是用来描述一些非必须的对象引用,但是引用的强度要比软引用弱,不管内存是否充足,被弱引用关联的对象都将在下一次垃圾收集时被回收。
虚引用又称为是幽灵引用或者是幻影引用,是最弱的引用关系。一个对象是否有虚引用,完全不会对该对象的生存造成影响,也无法用虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是,在这个对象被垃圾收集器回收时收到一个系统的死亡通知,通俗的说也就是让对象死的明明白白吧。
生存还是死亡?这是个问题。
在可达性分析算法中,即使是不可达的对象,也并非是要立即执行 “死刑”,它们暂时处于 “死缓”。就像 C++ 中,对象死亡要调用析构函数一样,Java 中对象在死亡时也有一个类似的 finalize() 方法,但这两者的作用并不相同。不可达的对象第一次会被标记并进行一次筛选,筛选的条件就是这个 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法被虚拟机调用了而这个对象还没有会回收,finalize() 方法都不会执行。如果判定需要执行 finaliz() 方法,这个对象就会被放到一个队列中,由低优先级的单独线程(刽子手)执行对象中的 finalize() 方法。如果在 finalize() 方法中,该对象被引用链上其他的可达对象关联了,那么这个对象就可以被移出这个 “即将回收” 队列,从而死里逃生。这个 finalize() 方法可以说是对象逃脱死亡命运的最后一次机会,如果还没有逃脱,这个对象就真正要被垃圾回收器执行死刑了。但是这个机会每个对象只有一次,第一次是可以逃脱的,第二次再次进入这个队列无论如何也逃脱不了被回收的命运。
虽然 Java 虚拟机规范中没有要求对方法区进行垃圾回收,但是一些虚拟机(如 HotSpot 虚拟机)仍然实现了方法区的垃圾回收,在 HotSpot 虚拟机中称方法区为 “永久代”,其实都是一个意思,方法区的垃圾主要是废弃的常量和无用的类。我们知道方法区中有一些常量池,如字符串常量池,如果系统中不存在引用常量池中常量的引用,那么在内存紧张的时候,这些常量就应该被废弃回收,常量池中的其他类(接口)、方法、字段、符号引用也是如此。
判断常量是否应该被废弃的方法比较简单,而判断一个类是无用的类,则需要满足下面三个条件:
- 该类的所有实例都已经被回收了,也即 Java堆内存中没有该类的对象实例。
- 加载该类的类加载器(ClassLoader)已经被回收了。
- 该类对应的 java.lang.Class 对象在任何地方都没有被引用,也即无法通过反射访问该类。
但满足了上述这些条件,也不是说这个类就要被非回收不可,我们是可以通过设置虚拟机参数进行控制的。
至此,哪些内存区域需要回收和什么时候回收就说完了,下面就是如何去回收了。垃圾回收是一个具体的过程,里面涉及到一些收集算法,几种常见的垃圾回收算法思想如下:
1、标记-清除算法
如同它的名字那样,该算法分为 “标记” 和 “清除” 两个过程,首先标记出需要回收的对象,如下图中的灰色区域,然后再将标记出的区域内容清除。标记过程需要遍历,这里面也涉及到广度优先搜索和深度优先搜索,这里就不多说了。
不足之处:一个是效率问题,搜索的效率;另外一个是空间问题,标记清除后会产生内存碎片,不利于给大对象分配内存空间。
标记-清除算法是最基础的收集算法,后续的收集算法都是对它的改进。
2、复制算法
为了解决标记-清除算法的效率问题,复制算法将内存容量划分为两个等量的部分,每次只使用一块,当一块使用完后,就将还存活的对象复制到另一块内存区域,并把刚才使用的那块内存区域一次性清除,这样每次都只需要对一半内存区域进行垃圾回收。
不足之处:这种做法看似简单除暴,但实现简单,运行高效,确实可以解决产生内存碎片的问题,但牺牲了一半可用内存空间的代价也未免太大。另外在对象存活率较高的时候,就需要进行大量的复制操作,效率将会变低,所以对于存活时间长的对象一般不使用这种收集算法。
3、标记-整理算法
标记-整理算法和标记-清除算法,标记过程相同,不同的是,标记-整理算法是将存活的对象向同一端移动,然后再清除这之外的内存区域,而不是对可回收的内存直接清除。
4、分代收集算法
分代收集算法中没有新的算法思想,只是根据对象存活的周期长短又对 Java堆内存进行了划分,一般是把 Java堆分为新生代(Young)和老年代(Old)。新生代和老年代的默认内存大小比例是 1 : 2,也即是新生代占据 1/3 的堆内存空间,老生代占据 2/3 的堆内存空间,这个比例值是可以通过 -XX:NewRatio 参数来动态设置的。而新生代又被划分为 Eden、From Survivor、To Survivor 三个区域,Eden 占据 8/10 的新生代内存空间,并且Java 虚拟机每次只会使用 Eden 区和一个 Survivor(From Survivor 和 To Survivor中的一个) 区,总有一个 Survivor 区是空闲着的。新生代区域用来存放那些朝生夕死的 Java 对象,这些对象存活时间很短,很容易就会被垃圾收集器回收,所以新生代使用复制算法会比较好,而老生代区域用来存放大对象(如对象数组),这些对象不容易被回收,存活时间比较长,使用标记-清除算法和标记-清理算法是比较好的。针对不同区域内对象存活时间的长短,使用合适的收集算法,可以最大发挥出算法优势。
5、垃圾收集器
前面的算法都是理论知识,而垃圾收集器是这些算法具体实现。
不同版本的 JDK 选择的垃圾收集器也可能不同,我们可以在命令行中查看已安装的 JDK 的默认垃圾收集器,我这里是在 Windows 的 cmd 中输入 java -XX:+PrintCommandLineFlags -version
,执行结果如下图。
我的机器上装的是 JDK8,ParallelGC 就是该版本 JDK 默认采用的垃圾收集器组合,包括 PS Scavenge(新生代收集器) 和 PS MarkSweep(老生代收集器),也可以通过下面的代码打印出正在使用的垃圾收集器。
1 | import java.lang.management.GarbageCollectorMXBean; |
这里也给出一张垃圾收集器组合相关的常用参数表。
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在 Client 模式下的默认值,使用 Serial + Serial Old 收集器组合回收内存 |
UseParNewGc | 使用 ParNew + Serial Old 收集器组合回收内存 |
UseConcMarkSweepGC | 使用 ParNew + CMS + Serial Old 收集器组合回收内存 |
UseParallelGC | 虚拟机运行在 Server 模式下的默认值,使用 PS Parallel + Serial Old(PS MarkSweep)收集器组合回收内存 |
为什么不是一个垃圾收集器,而是垃圾收集器组合呢?在前面,我们提到分代收集算法将堆内存区域再次划分,综合了其他算法的优点,所以主流的虚拟机也是根据不同年代的内存区域,使用不同的算法实现的垃圾收集器,下面将逐一介绍这些垃圾收集器。
在介绍收垃圾收集器之前,我们先想象一个打扫卫生的场景,如果一边有人打扫,一边又有人扔垃圾,那么卫生能打扫干净吗?答案肯定是不能的。那么怎样才能打扫干净呢?你可能会说打扫的时候不能有人再扔垃圾了,没错就是这样,在 Java 虚拟机中,垃圾收集器就像打扫卫生的人,可能有一个也可能有多个,对应也就是单线程和多线程,其他线程就像是扔垃圾的人。垃圾收集器打扫卫生肯定也是需要时间的,在这个时间不能有其他线程 “扔垃圾”,也即是暂停其他线程使用直到垃圾收集结束。在 Java 虚拟机中,这种事情叫做 “Stop The Word”,简称 STW。计算机运行速度很快,如果把 STW 的时间缩到很短,人们根本察觉不出来。理解了这些,下面就来看看每种垃圾回收器的介绍。
1、Serial 收集器
从名字上看是串行的意思,这个收集器是一个单线程的新生代收集器。Serial 采取 “复制算法” 实现,如果是在单 CPU 环境下,Serial 收集器没有线程交互的开销,理论上是可以获得最高的单线程执行效率,STW 的时间也可以控制在几十到几百毫秒内,这个时间是完全可以接受的。
2、Serial Old 收集器
Serial Old 收集器 是 Serial 收集器的老年代版本,同样也是一个单线程收集器,使用了 “标记-整理算法”。由于 PS MarkSweep 收集器与 Serial Old 收集器的实现非常接近,所以在许多官方资料中都是直接以 Serial Old 代替 PS MarkSweep,Ps MarkSweep 收集器可以看做是 Serial Old 收集器的别名。
3、ParNew 收集器
ParNew 收集器实际上就是 Serial 收集器的多线程版本,收集算法、STW、对象分配的规则、回收策略等都与 Serial 收集器完全一样,两者相同的代码很多。ParNew 收集器虽然有多线程优势,但在单 CPU 和多 CPU 环境下,效果并不一定会比 Serial 好,至少在单 CPU 环境下是肯定不如的 Serial 的。看起来人多力量大,但由于线程交互开销的时间,效果并不如人意,多线程的好处在于更高效率地利用 CPU ,提高 CPU 的吞吐量,让 CPU 空闲的时间减少。
4、Parallel Scavenge收集器
Parallel Scavenge收集器和 ParNew 收集器很像,也是一个新生代收集器,也是使用复制算法,并且还是并行的多线程的收集器。相比于 ParNew 收集器,Parallel Scavenge收集器可以更加精准的控制 CPU 的吞吐量和 STW 的时间,对于交互不多的任务可以更快地完成。
5、Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记-整理算法”。在 Parallel Old 收集器出现之间,选择了 Parallel Scavenge 收集器作为新生代的收集器,就只能选择 Serial Old 收集器作为老生代收集器,这样肯定就是对多 CPU 的浪费,所以 Parallel Scavenge收集器 + Parallel Old 收集器,对于多 CPU 环境吞吐量要求高的环境,算是强强联合。
6、CMS 收集器
CMS (Concurrent Mark Sweep)收集器从英文名字上看就是基于 “标记-清除算法” 实现的,并且还有并发的特点,它是一种以缩短 STW 的时间为目标的收集器,对于一些重视服务响应速度的网站,肯定是 STW 越短,用户体验越好,但是缺点是会在垃圾收集结束后产生大量的空间碎片,这点从使用的算法上也可以看出来。
7、G1 收集器
G1 收集器是目前最前沿的收集器,它是基于 “标记-整理算法” 实现的,所以不会产生内存碎片,并且也可以精准地控制 STW 的时间。G1 收集器对于新生代和老年代都是适用的,优先回收垃圾最多的区域。
几种垃圾收集器的组合如下图所示。
6、内存分配与回收策略
未完待续。。。
觉得文章还不错,可以关注 编程心路 微信公众号,在编程的路上,我们一起成长。
本文链接: http://wenshixin.gitee.io/blog/2018/08/28/Java虚拟机内存管理/
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!