此博客内容均取自网上热度比较高的三位作者的笔记:CyC2018、JavaGuide、一份名为《java核心知识整理》的笔记(作者的笔记中没有留个人信息)
1.Java内存区域
1.1 概述
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。不过正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误、修正问题将会是一个非常艰巨的任务。
1.2 运行时数据区域(Java 的内存分区)
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域,分别是:程序计数器(PC)、Java虚拟机栈(VMS)、本地方法栈(NMS)、Java堆(Heap)、方法区(MA)。其中方法区中有一个比较重要的区域叫做运行时常量池。其中程序计数器、虚拟机栈和本地方法栈都是线程独有的,而堆和方法区是线程之间共有的。
JDK. 1.8 和之前的版本略有不同,下面会介绍到。
1.2.1. 程序计数器(Program Counter Register, PC)(线程私有)
记录虚拟机正在执行的字节码指令的地址(如果正在执行的是本地方法则为空,之所以为空是因为本地方法都是C或者C++语言编写的,执行过程不受JVM的监控)。
作用: 1. 实现代码的流程控制
2. 方便线程切换后恢复现场
注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2.2. Java虚拟机栈(Java Virtual Machine Stack)(线程私有)
是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)每个方法都有一个栈帧,每个栈帧都有一个操作数栈。
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。局部变量表所需的内存空间在编译时期完成分配。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK1.5+ 默认为 1M:
Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。
- StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。
1.2.3. 本地方法栈(Native Method Stacks)(线程私有)
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务,保存的是本地方法要执行所需的必要参数。本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。本地方法执行是在os中执行的,并非在JVM中执行的,所以使用的是os的程序计数器而非JVM的程序计数器。本地方法栈只是存储了线程要运行这个方法的必要信息,比如出口,入口,动态链接,局部变量表,操作数栈等。(待考证)
在HotSpot虚拟机中将本地方法栈和虚拟机栈合二为一。
参考:JVM的本地方法栈
1.2.4. Java堆(线程共享)(新生代+老年代+永久代)(新生代+老年代)
所有对象都在这里分配内存,是垃圾收集的主要区域("GC 堆")。
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区 、 From Survivor 区 和 To Survivor 区 )和老年代。进一步划分的目的是更好地回收内存,或者更快地分配内存。
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),之后每进行一次垃圾回收,它的年龄就加一,在s1 和 s0之间来回倒腾,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
堆可以不需要连续内存(现在堆内存的结构可能是一块块的堆内存通过链表连接起来,哪块需要回收就回收哪块),并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。
1.2.5. 方法区(Method Area)(线程共享)
即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久代的内存回收的主要目标是针对常量池的回收和类型的卸载, 但是回收效果一般不太好)。
和堆一样不需要连续的内存,并且可以选择固定大小或者动态扩展,动态扩展失败或者固定内存剩余空间无法满足新的内存分配需求时,也会抛出 OutOfMemoryError 异常。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中(只受本机可用内存的影响),而不是虚拟机内存中。
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。
1.2.6. 运行时常量池
运行时常量池是方法区的一部分。Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。《Java虚拟机规范》对这块区域没有做任何细节的要求。
运行时常量池相对于Class文件常量池的另一个重要特征是,除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面的这个字符串实例的引用。JDK7之后运行时常量池是放在堆中的,所以不用拷贝,直接引用首次出现的字符串常量即可。
1.2.7. 直接内存
在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
1.3 HotSpot虚拟机对象探秘
通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
1.3.1 对象的创建过程
下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。
Step1:类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
Step2:分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB(Thread Local Allocation Buffer): 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值(默认初始化)
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
Step4:设置对象头(设置放置在对象头中的信息)
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行init方法(构造初始化)
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
1.3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。为了最大化利用空间,相同宽度的字段总是被分配到一起存放。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
1.3.3对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
- 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
- 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是
reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference
本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
1.4 重点补充内容
1.4.1 String类和常量池
String 对象的两种创建方式
// 先检查字符串常量池中是否有 "abcd", 如果有则直接返回字符串常量池中 "abcd"的引用
String str1 = "abcd";
// 先会在字符串常量池中查找 ”abcd"这个字面量,然后利用该对象在堆中创建一个String对象,并返回堆中这个对象的引用
String str2 = new String("abcd"); // 在堆中创建一个新对象
String str3 = new String("abcd");
System.out.println(str1 == str2); // false
System.out.println(str2 == str3); // false
System.out.println(str1.intern() == str1); // true
这两种不同的创建方法是有差别的。
- 第一种方式是在常量池中拿对象;
- 第二种方式是直接在堆内存空间创建一个新的对象。
记住一点:只要使用 new 方法,便需要创建新的对象。
String 类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
- 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:在JDK6之前(包含JDK6),intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用, 所以 intern() 方法返回的引用和原对象返回的引用不是同一个;在JDK7之后,由于字符串常量池也存在于堆中,intern()方法则会在常量池中记录一下首次出现的实例引用,所以 intern() 方法返回的引用和原对象返回的引用是同一个。
2、垃圾回收
本节常见面试题
问题答案在文中都有提到
- 如何判断对象是否死亡(两种方法)。
- 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
- 如何判断一个常量是废弃常量
- 如何判断一个类是无用的类
- 垃圾收集有哪些算法,各自的特点?
- HotSpot 为什么要分为新生代和老年代?
- 常见的垃圾回收器有哪些?
- 介绍一下 CMS,G1 收集器。
- Minor Gc 和 Full GC 有什么不同呢?
垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域的内存分配和回收都是具有确定个性的,且它们都属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。
垃圾回收要思考的三个问题:①那些内存需要回收 ②什么时候回收 ③如何回收?
2.1 如何确定垃圾
2.1.1 引用计数法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
public class ReferenceCountingGC {
public Object instance = null;
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB能否被回收
System.gc();
}
}
2.1.2 可达性分析算法
通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
GC Roots 一般包含以下内容:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法栈中 JNI 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
2.1.3 不可达的的对象并非非死不可
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个F-Queue队列中进行第二次标记,除非这个对象在执行Finalize()方法时与引用链上的任何一个对象建立关联,否则就会被真的回收。
2.1.3.1 finalize()方法(扩展内容)
类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。不过自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。
2.1.4 方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。方法区的垃圾收集主要回收废弃的常量和不再使用的类型。
对废弃的常量的回收:如果一个常量不存在任何引用,且发生内存回收的时候垃圾收集器判断有必要回收这个变量的内存,那么这个常量就会被清理出常量池。
判断一个类型是否属于“不再使用的类”需要满足以下三个条件,并且满足了条件也不一定会被卸载:
① 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
② 加载该类的 ClassLoader 已经被回收。
③ 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
2.1.5 Java中的四种引用类型(JDK1.2之后扩充了3种)
2.1.5.1 强引用
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。被强引用关联的对象不会被回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
例如:使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();
2.1.5.2 软引用
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
2.1.5.3 弱引用
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<>(obj);
obj = null; // 使对象只被弱引用关联
2.1.5.4 虚引用
虚引用需要 PhantomReference 类来实现,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null; // 使对象只被虚引用关联
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
2.2 垃圾回收算法
2.2.1 标记清除算法(Mark-Sweep)
最基础的垃圾回收算法,分为两个阶段,标记和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图
该算法的缺点是:
① 执行效率不稳定
② 内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。(最主要的问题)
2.2.2 标记复制算法(也称为复制算法,coping)
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
2.2.3 标记整理算法(Mark-Compact)
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除边界外的对象。
这种算法的缺点是需要大量移动对象,处理效率比较低。
2.2.4 分代收集算法
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老年代(Tenured/Old Generation)和新生代(YoungGeneration)。老年代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
2.2.4.1 新生代复制算法
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即只需要付出少量的复制成本就可以完成收集,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。
2.2.4.2 老年代标记整理算法
老年代因为对象存活率高、没有额外空间对它进行分配担保,每次只回收少量对象,因而采用 Mark-Compact 算法。
1. JAVA 虚拟机提到过的处于方法区的永生代(Permanent Generation)(又称为方法区),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 FromSpace 进行清理。
4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老年代。
5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老年代中。
2.2.5 分区收集算法(G1使用)
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。
2.3 GC垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。直到现在为止还没有出现最好的垃圾收集器,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
Java 堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收 算法 ;年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,目前HotSpot 虚拟机的垃圾收集器如下:(连线表示垃圾收集器可以配合使用)
2.3.1 Serial垃圾收集器(单线程、复制算法)
Serial 收集器是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 Java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
2.3.2 ParNew 收集器 (Serial + 多线程并行)
它是 Serial 收集器的多线程版本。(除了多线程外,其他和Serial收集器几乎完全一样)
ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。在JDK9以前,ParNew 是很多运行在 Server 模式下的 Java 虚拟机新生代的默认垃圾收集器。除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。
2.3.3 Parallel Scavenge 收集器(多线程复制算法、高效)
与 ParNew 一样是多线程收集器。
其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户
程序的时间占CPU 总消耗时间的比值(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
2.3.4 Serial Old 收集器(单线程、标记整理算法)
是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:
① 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
② 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
2.3.5 Parallel Old 收集器(多线程标记整理算法)(JDK6才有的)
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
2.3.6 CMS 收集器(多线程并发标记清除算法)(并发低停顿收集器)
Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他老年代使用标记-整理算法不同,它使用多线程的标记-清除算法,因为老年代对象存活周期长,整理和复制算法需要移动或者复制大量的对象,非常耗时间。较短的垃圾收集停顿时间可以提高交互性较强的程序的用户体验。
CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
① 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,单线程,需要停顿。
② 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,但不需要停顿。使用增量更新算法保证收集线程与用户线程互不干扰地运行。
③ 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
④ 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来看CMS收集器的内存回收线程和用户线程是一起并发执行地。
缺点:
① 对处理器资源非常敏感,吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
② 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
③ 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
2.3.7 G1 收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。这款垃圾收集器的目标是在低延迟可控的情况下获得尽可能高的吞吐量。
Garbage first 垃圾收集器是目前垃圾收集器理论发展的前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:
1. 内存规整:从整体看基于标记-整理算法,从局部看机遇标记复制算法,不产生内存碎片。
2. 可预测的停顿:可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的多个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收价值收益最大的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。
G1 相对于 CMS 的弱项:垃圾收集产生的内存占用和运行时产生的额外执行负载都比 CMS 高。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
① 初始标记:标记一下GC Roots 能直接关联到的对象,并且修改 TAMS(top at mark start)指针(指针之间的内存用于并发期间存储新分配的对象),此阶段借用进行Minor GC 的时候同步完成,所以有停顿,但是没有额外停顿。
② 并发标记:递归扫描整个堆的对象图,进行可达性分析,耗时长,并发执行,最后还要处理 SATB(原始快照)中记录的并发时有引用变动的对象。
③ 最终标记:短暂停顿,处理并发阶段结束后仍遗留下来的最后那少量的 STAB 记录。
④ 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
3. 内存分配与回收策略
3.1 JVM运行时堆内存划分
Java 堆从 GC 的角度还可以细分为: 新生代( Eden 区 、 From Survivor 区 和 To Survivor 区 )和老年代。
3.1.1. 新生代
是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。因为采用标记复制算法,如果使用一般空间来分配对象,另一半空间来存放垃圾回收时存活的对象,就太浪费存空间了,所以垃圾收集器的设计者们把这个1:1的比例调成了9:1,即使用9/10来进行对象内存分配,1/10空间来保存存活对象,但是因为这两块空间是轮流坐庄的,每次垃圾回收会角色都会互换,互换之后就变成了1:9, 也就是使用1/10的空间来给对象分配内存,9/10的空间来存储存活的对象,这显然不合理,所以设计者们就把新生代划分成了8:1:1的三块,而非两块, 一个较大的eden区, 2个比较小的servivor区,每次分配对象都是某一个servivor区加上eden区配合使用,剩下的一个servivor用来存放存活的对象,这样用来分配对象的空间和用来存储存活对象的空间比例时刻都是9:1,很巧妙
3.1.1.1 Eden 区
Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触MinorGC,对新生代区进行一次垃圾回收。
3.1.1.2. ServivorFrom
上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
3.1.1.3. ServivorTo
保留了一次 Minor GC 过程中的幸存者。
3.1.1.4 老年代
主要存放应用程序中生命周期长的内存对象。
3.2 回收策略
3.2.1 回收方式
3.2.1.1 部分收集(Partial GC):
指目标不是整个Java堆的垃圾收集,其中又分为
① 新生代收集(Minor GC/ Young GC):回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
② 老年代收集(Major GC / Old GC): 回收老年代
③ 混合收集(Mixed GC): 指目标是收集整个新生代和部分老年代的垃圾收集,目标只有 G1 收集器会有这种行为。
3.2.1.2 整堆收集(Full GC)
收集整个Java堆和方法区的垃圾收集,回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC慢很多。
3.2.2 部分回收方式的过程介绍
3.2.2.1. MinorGC 的过程(复制->清空->互换)
MinorGC 采用复制算法。
1. eden 、 servicorFrom 复制到 ServicorTo,年龄+1
首先,把 Eden和 ServivorFrom区域中存活的对象复制到 ServicorTo区域(如果有对象的年龄以及达到了老年的标准,则复制到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不够空间就放到老年区);
2. 清空 eden 、 servicorFrom
然后,清空 Eden 和 ServicorFrom 中的对象;
3. ServicorTo 和 ServicorFrom 互换
最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom区。
3.2.2.2 Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
1. 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
2. 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
3. 空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
4. JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
5. Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
3.3 内存分配策略
1. 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5. 空间分配担保
老年代会对servivor区进行空间担保,也就是存活的对象太多或者太大的时候,可以临时存放在老年代,这个特殊的对象通过一个引用来与servivor建立关联,所以因为有这样一种空间分配当宝机制,在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,说明满足担保条件,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。