概述
Java内存区域可以分为两类:
- 一类是线程独享的区域,包括虚拟机栈、本地方法栈和程序计数器。这类内存随线程而生,随线程而死。内存的分配在编译器就看可确定,内存的回收随着线程结束而自然释放。无需额外的垃圾收集器干涉。
- 另一类是线程共享的区域,即Java堆和方法区。分配和回收都是动态,在运行期间才知道要创建哪些对象,在运行期间才会触发GC。所以,内存分配和垃圾收集针对的是Java堆这部分内存。
垃圾收集
垃圾收集要做的事情可以简单总结为两个步骤:
- 确定哪些内存需要被回收——对象存活判定方法
- 如何回收——垃圾收集算法(理论)、垃圾收集器(实践)
对象存活判定算法
在对堆内存进行回收之前,需要确定哪些对象已经“死去”,即不会再被使用。
注意:下面讲述的两种算法是广泛应用的判定思想。不仅仅针对Java虚拟机内存管理这一种应用。不同应用按需选择,比如,主流的Java虚拟机都没有选用引用计数法来管理内存,而是使用可达性分析算法。。
引用计数算法
1. 算法思想
为每个对象维护一个引用计数器,每当有一个地方引用该对象,计数器的值加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象就是不可能再被使用的对象。也就是该对象占用的内存需要被回收。
2. 算法弊端
引用计数算法虽然实现简单、判定效率也很高,在大部分情况下都是一个不错的算法,也有一些比较著名的应用案例。但是,主流的Java虚拟机都没有选用引用计数法来管理内存,主要原因是它很难解决对象之间相互循环引用的问题.
objA.instance = objB;
objB.instance = objA;
// 实际在程序中这两个对象已经不会再被访问;
// 但是因为他们互相引用,导致双方的引用计数都不为0,所以无法通知GC收集器回收他们。
可达性分析算法
1. 算法思想
首先确定一系列可以作为GC Roots的对象,将其作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象与GC Roots之间没有任何引用链时(GC Roots不可达),则证明这个对象是不可用的。
目前主流的商用语言,Java、C#,Lisp的主流实现中,都是通过可达性分析来判定对象是否存活的。
2. GC Roots
在Java语言中,可作为GC Roots的对象包括以下几种:
- 全局性引用:
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 执行上下文相关的对象:
- 虚拟机栈(栈帧中局部变量表)中引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
再谈“引用”
在JDK1.2之前,“引用”的定义很传统:如果引用(reference)类型的数据中存储的数值是另一块内存的起始地址(地址或者句柄)。就成这块内存代表一个引用。
这种定义过于狭隘,在这种定义下,一个对象只有被引用或者没有被引用这两种状态。
前面所说的判断对象的存活方法都很绝对,没有被引用的对象就是会被回收。但并不是所有对象都这么绝对。当我们想描述这样一类对象时:当内存空间还足够时,则能够保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。比如,很多系统的缓存功能,都符合上述场景。此时,两种绝对的状态可能无法满足这样的需求。
在JDK1.2之后,Java对“引用”的概念进行了扩充。将引用分为强引用、软引用、弱引用、虚引用。4中引用强度逐渐减弱。
- 强引用:就是我们常说的引用。Object obj = new Object(); 只要引用还存在,对象就是存活的,垃圾收集器永远不会回收这个对象。
- 软引用:用来描述一些还有用但非必需存在的对象。
- 平常的GC不会回收软引用对应的对象。在系统将要发生内存溢出异常时,才会把这些对象列进回收范围之中(即使正在被引用),专门进行一次二次回收。如果回收之后,还没有足够的内存,才会抛出内存溢出异常。(缓存功能的实现)
- 对于软引用关联的对象,即使引用存在,对象也不一定存在,因为可能已经被回收。
- SoftReference类来实现软引用。其提供一个get()方法用来获取对象的强引用(保证在使用过程中不会被回收),如果对象存在,返回该对象;如果对象不存在,(使用以前)已经被回收,则返回空。所以使用该对象之前,需要判空。
// 创建一个软引用
SoftReference<Map<String, String>> softReference = new SoftReference<Map<String, String>>(new HashMap<String, String>());
// 使用软引用
// map是强引用,成功获取之后,存活的对象不会再被回收
Map<String, String> map = softReference.get();
if(map == null){
// 重新创建
softReference = new SoftReference<Map<String, String>>(new HashMap<String, String>());
}else{
// 操作
}
- 弱引用:描述非必需对象,但是强度比软引用弱一些。
- 被弱引用关联的对象只能生存到下一次垃圾收集发生之前。无论是否有用,无论内存是否足够,都会被回收掉。WeakReference类。
- 虚引用:幽灵引用,幻影引用。最弱的一种引用关系。
- 被虚引用的对象,虚引用的存在不会对其生存时间造成影响,也无法通过虚引用来获取一个对象实例。
- 为一个对象设置虚引用关联的额唯一目的是:在这个对象被收集器回收时收到一个系统通知。
- PhantomReference类实现了虚引用。
回收方法区
Java规范中说可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低。在方法区进行一次垃圾收集可以回收的空间非常少。
方法区的垃圾收集主要回收两部分内容:
- 废弃常量:常量池没有被任何常量引用的对象,比如“abc”.
- 无用的类:一个类被判定为无用的类应该满足以下三个条件:
- 该类的所有实例已经被回收,即堆内存中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足上述三个条件的类可以被回收,但不是必然回收。无用的类是否一定回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。
垃圾收集算法
垃圾收集算法是内存回收的思想,不同算法有不同的优缺点,下文讲述的三种算法不存在好坏之分,没有万能的算法,我们要做的是对不同特点的内存使用最合适的垃圾收集算法。
Java堆可分为新生代和老年代,在程序执行的过程中,新生代和老年代所扮演的角色不同,作用也不同,我们需要根据各自的特点为他们选择合适的算法。
确定合适的算法之后,才有针对不同算法(也就是不同内存区域)的垃圾收集器诞生。所以,也就没有万能的垃圾收集器,只是根据具体应用选择最适合的罢了。
标记-清除 算法
1. 算法思想
- 标记:利用可达性分析算法标记出所有需要回收的对象;
- 清除:统一回收所有被标记的对象。
2. 算法弊端
- 效率问题:标记和清除两个过程的效率都不高
- 空间问题:容易产生大量不连续的内存碎片。无法找到连续内存分配给大对象时,会提前触发另一次GC。
复制 算法
1. 算法思想
- 将内存划分为大小相等两块,每次创建对象时是使用其中一块;
- 当这一块内存用完了,即无法为新对象分配内存时,将存活的对象复制到另外一块内存上,依次相邻存放;
- 把已使用过的内存空间一次性清理掉。
- 重复上述过程。
2. 弊端
- 解决了标记-清除算法的效率问题和内存碎片问题
- 但是将可用内存缩小为了原来的一半,代价太高。
3. 适用范围
- 新生代的特点:对象存活率低,研究表明,新生代中98%的对象是“朝生夕死”的。即存活的对象非常少,每次GC时需要复制的对象很少,不会影响程序执行效率。
- 正因为需要复制的存活的对象少,所以不需要按照1:1的比例划分内存空间。而是将新生代划分为一块比较大的Eden空间和两块较小的Survivor空间。Eden和其中一块Survivor作为可用内存,另一块Survivor用来在GC时存放复制过来的对象。接下来,这块Survivor和清空的Eden作为当前可用的内存区域,清空的Survivor用来存放下次复制过来的对象。这也是为什么有两块Survivor(存活)的原因,交替作为待复制的空闲内存(To Survivor)。
- Eden和Survivor的大小比例可以通过参数设置。HotSpot虚拟机默认:Eden:From Survivor:To Survivor=8:1:1。也就是新生代中可用内存空间是整个新生代容量的90%。
- 但是,我们无法保证每次存活的对象都不多于10%。当To Survivor空间不够用时,需要依赖其它内存区域(只有老年代)进行分配担保,即将存活对象复制到老年代中。
标记-整理 算法
1. 算法思想
- 标记:利用可达性分析算法标记出所有可回收的对象。
- 整理:将所有存活的对象都向一端移动,然后清理掉存活对象边界以外的内存。
2. 适用范围
- 老年代中的对象存活率都非常高。
- 如果采用复制算法需要较多的复制操作,以及较大的内存空间供存放复制的对象。显然老年代没有额外的空间对它进行分配担保,所以复制算法不合适。
- 标记-整理 算法更加适合老年代。
分代收集算法
当前商业虚拟机的垃圾收集都在用“分代回收”算法,这种算法并不一个具体的算法,而是一中思路,根据对象的存活周期不同,将内存划分为几块,分区域采用合适的算法。
一般把Java堆划分为新生代和老年代,新生代采用“复制算法”,老年代采用“标记-清除算法”或者“标记-整理算法”。
STW(Stop The World)
1. GC停顿
STW:GC执行时必须停顿所有Java执行线程。主要是在可达性分析过程中,对象的引用关系不可以改变,否则准确性就得不到保证。
2. 安全点
HotSpot在OopMap(记录引用信息)这个数据结构的帮助下,才可以快速且准确地完成GC Roots枚举。但是随着程序的执行,引用关系也随之变化,如果为每一条指令都生成对应的OopMap信息,GC的空间成本将会变得很高。
安全点:记录了OopMap信息的特定位置(特殊指令处)。发生GC时,正在执行的程序并非立即停顿下来,而是要执行到最近的安全点才能暂停。
产生安全点的指令特征:指令序列复用,如方法调用、循环跳转、异常跳转等,具有这些功能的指令才会产生Safepoint。
发生GC时,中断线程的两种方案:
- 抢断式中断:发生GC时,首先中断全部线程,中断地方不再安全的线程重新恢复,继续执行到安全点。目前几乎没有虚拟机采用这种方式
- 主动式中断:GC发生时,在安全点和创建对象需要分配内存的地方设置轮训标志。各个线程执行时主动去轮训这个标志,发现中断标志为真时,就自己中断挂起。
3. 安全区域
Safepoint机制保证了程序在执行过程中,短时间就会遇到可进入GC的Safepoint。
对于没在执行的程序(没有CPU执行权,即sleep或者blocked状态的线程),无法响应机制,走到“安全点”,如果置之不理,又无法保证该线程不会再GC期间被唤醒执行。影响对象的引用关系。对于这种情况,需要采用安全区域(Safe Region)来解决。
安全区域:引用关系不会发生变化的一段代码片段。在该区域的任何位置开始GC都是安全的。
实现:线程执行到Safe Region(线程停止执行,sleep等)中的代码时,首先标识自己已经进入Safe Region。此时如果JVM发起GC,就不用管进入Safe Region的线程。在线程要离开Safe Region时,会检查GC过程是否完成,如果完成了,线程就继续执行,否则就必须等待直到收到可以安全离开Safe Region的信号为止。
线程只有在执行和没在执行两种状态,执行时使用Safepoint机制,没执行的线程会处于Safe Region状态。两种机制的结合可以保证所有线程在GC期间的停顿。
垃圾收集器
Java虚拟机规范中对垃圾收集器如何实现没有任何规定。不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能有很大的差别。并且一般会提供参数供用户根据自己的应用特点和要求组合出各个内存区域所使用的的收集器(比如:-XX: UseSerialGC 使用Serial+Serial Old的收集器组合进行内存回收)。图1中的连线代表两个虚拟机可以组合搭配使用(一个负责新生代,一个负责老年代)。
Serial收集器
- 单线程:只会使用一个CPU或一条收集线程去完成垃圾手机工作。(收集慢 ,STW时间较长)。
- STW:垃圾收集期间,所用用户线程必须暂停。
- 新生代:复制算法。client模式下的默认新生代收集器。
ParNew收集器
- 多线程:Serial收集器额多线程版本,使用多条收集线程并行进行垃圾收集,线程数可以通过-XX: ParallelGCThreads参数设置。(快,STW时间缩短)
- STW:垃圾收集期间,所有用户线程暂停。
- 新生代:复制算法。server模式下,首选垃圾收集器。
Parallel Scavenge收集器
- 多线程
- STW:垃圾收集期间,所有用户线程暂停。
- 新生代:复制算法
- 实现吞吐量可控制:吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
- -XX: MaxGCPauseMills:控制最大垃圾收集时间。牺牲吞吐量和新生代空间换取。(新生代空间变小,垃圾变少,收集时间缩短,收集次数增多,吞吐量下降)。
- -XX: GCTimeRatio:设置吞吐量大小。
Serial Old收集器
- 单线程:Serial收集器的老年代版本
- STW:垃圾收集期间,所有用户线程暂停。
- 老年代:标记-整理算法。
Parallel Old收集器
- 多线程:Parallel Scavenge收集器的老年代版本。“吞吐量优先”。
- STW:垃圾收集期间,所有用户线程暂停。
- 老年代:标记-整理算法。
CMS(Concurrent Mark Sweep)收集器
- 目标:获取最短停顿时间
- 老年代:标记-清除算法。
- 垃圾收集步骤:
- 初始标记:单线程,STW停顿,标记GC Roots能直接关联到的对象
- 并发标记:收集(标记)线程与用户线程并发执行,GC Roots可达对象标记。
- 重新标记:多线程,STW停顿,修正并发标记期间发生变动的的对象标记记录
- 并发清除:收集(清除)线程与用户线程并发执行。
- 总体来说,相比于GC全程停顿来说,CMS缩短了停顿时间。
- 缺点:
- CMS收集器对CPU资源非常敏感。并发过程中,占用CPU资源,影响用户线程。
- CMS收集器无法及时处理浮动垃圾。浮动垃圾:在并发清除过程中,运行的用户线程产生的新垃圾;无法在当前GC过程中清除。并发过程中,也要为用户线程预留内存空间,当,预留空间不够时,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案,:临时启用Serial Old收集器,这样停顿时间就很长了。
- 大量内存碎片导致提前触发Full GC。也有相应的参数来解决这个问题。比如FullGC前开启内存碎片合并整理过程。
G1收集器
- 并行与并发:减少停顿时间
- 分代收集:不需要其他收集器的配合就可以独立管理整个GC堆,能够采用不同的方式分别处理新创建的对象、已经存活一段时间的对象、熬过多次GC的就对象。
- 空间整合:不会产生内存碎片。整体上看是“标记-整理算法”,局部上看是“赋值算法”。
- 可预测的停顿:G1除了追求低停顿时间外,还能建立可预测的停顿时间模型。
- 化整为零:整个Java堆被划分为多个大小相等的独立区域(Region),新生代何来年代不再是物理隔离的,都是多个Region构成的集合。每个Region要么是Eden空间、要么是Survivor空间、要么是老年代。垃圾收集时,根据不同内存的特点采用不同的策略。
- G1不是在Java对上进行全区域垃圾收集,而是每次以Region为单位进行。每个Region的垃圾堆积价值(回收所获得空间大小以及回收所需时间的经验值)越大,优先级越高。
- 垃圾收集步骤:
- 初始标记:单线程,STW停顿,标记GC Roots能直接关联到的对象
- 并发标记:收集(标记)线程与用户线程并发执行,GC Roots可达对象标记。
- 最终标记:多线程并行,STW停顿,修正并发标记期间发生变动的的对象标记记录
- 筛选回收:对各个Region区域的回收价值和成本进行排序,根据用户期望的停顿时间定制回收计划。不同类型的Region会采取不同的算法。也可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。筛选回收过程可以并发,但因为只回收一部分Region,时间不是很长,所以没有必要并发,可以暂停用户线程换取更高的收集效率。
内存分配
Java体系的自动内存管理可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配赔给对象的内存 。接下来列举出几条普遍存在的内存分配规则。
- 对象优先在Eden分配
- 大多数情况下,创建的对象在新生代Eden区中分配。当Eden没有足够空间时,虚拟机将发起一次Minor GC。
- Minor GC 和 Full GC:
- 新生代GC(Minor GC):发生在新生代的垃圾收集动作。新生代对象存活率低,MinorGC非常频繁但速度快。
- 老年代GC(Full GC/Major GC):发生在老年代的GC。出现Full GC,必须伴随至少一次的Minor GC。所以收集器之间需要配合。
- 大对象直接进入老年代
- 大对象:需要大量连续内存空间的对象
- 大对象衡量:-XX: PretenureSizeThreshold参数设置,大于设置值的对象就算大对象。
- 长期存活对象将进入老年代
- 对象年龄计数器:对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳,将被移到Survivor空间,并且年龄对象设为1。
- 在Survivor区每熬过一次Minor GC,年龄就加1。
- -XX: MaxTenuringThreshold:设置对象晋升奥年代的年龄阈值。
- 动态年龄判定
- 当Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。
- 空间分配担保
- +XX: HandlePromotionFailure:设置是否允许担保失败(复制算法中,老年代空间不足以担保来自新生代存活的对象)