JVM垃圾回收器和内存分配策略
JAVA中虚拟机的讲解,涉及「类加载机制,运行时区域,执行引擎,垃圾回收等」及对voliate, synchronized的JVM层面实现机制等。持续更新中…。 最新文章公众号持续更新中… 欢迎骚扰,分享技术,探讨生活。
前言:
程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭「线程私有」,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。
Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。
对象是否已死
1.引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0时就是不会再次使用的。这个方法有一种情况就是出现对象的循环引用时GC没法回收。
2.可达性分析计算:这是一种类似于二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。主流的商用程序语言Java,C#等都是靠这个思想去判定对象是否存活的。
可作为 GC Roots 的对象:
-
虚拟机栈「栈帧中的局部变量表」中引用的对象
-
方法区中类静态属性引用的对象
-
方法区中常量引用的对象
-
本地方法栈中 JNI「即一般说的 Native 方法」 引用的对象
这两种方式判断存活时都与‘引用’有关。但是 JDK 1.2 之后,引用概念进行了扩充,下面具体介绍。
下面四种引用强度一次逐渐减弱
强引用
类似于
Object obj = new Object();
创建的,只要强引用在就不回收。
软引用
SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。
弱引用
WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
虚引用
PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
对象是否真的死亡
首先必须要提到的是一个名叫 finalize() 的方法
finalize()是Object类的一个方法、一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用。
但并不提倡在程序中调用finalize()来进行自救。因为它执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在Java9中已经被标记为 deprecated ,且java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比finalize来的更加的轻量及可靠。
1.如果对象进行可达性分析之后没发现与GC Roots相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行finalize()方法。如果对象有必要执行finalize()方法,则被放入F-Queue队列中。
2.GC对F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。
方法区的回收
在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而永久代的垃圾收集效率远低于此。
永久代垃圾回收主要两部分内容:废弃的常量和无用的类。
判断废弃常量:一般是判断没有该常量的引用。
判断无用的类:要以下三个条件都满足
- 该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例
- 加载该类的 ClassLoader 已经被回收
- 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
回收算法「对象死了垃圾怎么回收」
主要有,标记清除,复制算法,标记整理,分带回收。
标记清除
标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收。
其实它就是把已死亡的对象标记为空闲内存,然后记录在一个空闲列表中,当我们需要new一个对象时,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象。
缺点:标记和清除的效率比较低下。让内存中的碎片非常多。导致了如果我们需要使用到较大的内存块时,无法分配到足够的连续内存。
复制算法
把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面。
解决前一种方法的不足,但是会造成空间利用率低下。因为大多数新生代对象都不会熬过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间。大小比例一般是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间。但是这里有一个问题就是如果存活的大于 10% 怎么办?这里采用一种分配担保策略:多出来的对象直接进入老年代。
标记整理
不同于针对新生代的复制算法,针对老年代的特点,创建该算法。主要是把存活对象移到内存的一端。
复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存
分代回收
这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。
垃圾回收器总览
收集算法是内存回收的理论,垃圾回收器是内存回收的实践。
注:连线部分是可以进行搭配使用
收集器 | 串行 并行 并发 | 新/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制 | 响应速度优先 | 单cpu环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单cpu环境下的Client模式,CMS预备方案 |
Par New | 并行 | 新生代 | 复制 | 响应速度优先 | 多cpu环境时在server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制 | 吞吐量优先 | 在后台运算而不需要太多交互任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站,B/S系统服务端应用 |
G1 | 并发 | both | 标记-整理 + 复制 | 响应速度优先 | 面向服务端应用,将来替换CMS |
到jdk8为止,默认的垃圾收集器是Parallel Scavenge 和 Parallel Old
从jdk9开始,G1收集器成为默认的垃圾收集器
目前来看,G1回收器停顿时间最短而且没有明显缺点,非常适合Web应用。
在jdk8中测试Web应用,堆内存6G,新生代4.5G的情况下,Parallel Scavenge 回收新生代停顿长达1.5秒。G1回收器回收同样大小的新生代只停顿0.2秒。
垃圾回收器具体介绍
并行:Parallel
指多条垃圾收集线程并行工作,此时用户线程处于等待状态
并发:Concurrent
指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个 CPU 上运行。
Serial 收集器
这是一个单线程收集器。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。
Serial Old 收集器
收集器的老年代版本,单线程,使用
标记 —— 整理
。
ParNew 收集器
可以认为是 Serial 收集器的多线程版本。
Parallel Scavenge 收集器
这是一个新生代收集器,也是使用
复制算法
实现,同时也是并行
的多线程收集器。
CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是 GC 的自适应调整策略(GC Ergonomics)。
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,使用
标记 —— 整理
CMS 收集器
CMS (Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。基于
标记 —— 清除
算法实现。
运作步骤:
- 初始标记(CMS initial mark):标记 GC Roots 能直接关联到的对象
- 并发标记(CMS concurrent mark):进行 GC Roots Tracing
- 重新标记(CMS remark):修正并发标记期间的变动部分
- 并发清除(CMS concurrent sweep)
优点:并行执行,低停顿
缺点:1、不停顿耗线程,耗内存,整体效率低 2、标记清除法会产生垃圾碎片 容易FGC 3、会产生浮动垃圾容易FGC
G1 收集器
面向服务端的垃圾回收器。
优点:并行与并发、分代收集、空间整合、可预测停顿。
运作步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
G1优点:
1、空间整合:g1使用Region独立区域概念,g1利用的是标记复制法,不会产生垃圾碎片
2、分代收集:g1可以自己管理新生代和老年代
3、并行于并发:g1可以通过机器的多核来并发处理 stop - The - world停顿,减少停顿时间,并且可不停顿java线程执行GC动作,可通过并发方式让GC和java程序同时执行。
4、可预测停顿:g1除了追求停顿时间,还建立了可预测停顿时间模型,能让制定的M毫秒时间片段内,消耗在垃圾回收器上的时间不超过N毫秒
最大的区别是出现了Region区块概念,可对回收价值和成本进行排序回收,根据GC期望时间回收,还出现了member set概念,
将回收对象放入其中,避免全堆扫描
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
内存分配与回收策略
本身内存分配的策略流程是这样的。
先说下对象不一定全部是在堆中分配也有可能是在栈中「JVM通过逃逸分析, 将线程私有的对象打散分配在栈上,也就是逃不出方法的对象会在栈上分配」
逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。
// 逃逸分析例子
public class EscapeAnalysisTest {
public static Object object;
public StringBuilder escape(String a, String b) {
StringBuilder str = new StringBuilder();
str.append(a);
str.append(b);
//StringBuilder可能被其他方法改变,逃逸到了方法外部。
return str;
}
public String notEscape(String a, String b) {
StringBuilder str = new StringBuilder();
str.append(a);
str.append(b);
//不直接返回StringBuffer,不发生逃逸
return str.toString();
}
//外部线程可见object,发生逃逸
public void objectEscape(){
object = new Object();
}
//仅方法内部可见,不发生逃逸
public void objectNotEscape(){
Object object = new Object();
}
}
// 栈上分配,可以降低垃圾收集器运行的频率且分配速度快,提高系统性能
// 同步消除,如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。
// 标量替换,把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,
// 一、减少内存使用,因为不用生成对象头。 二、程序内存回收效率高,并且GC频率也会减少。
//注意开启逃逸分析和标量替换 -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
在说下TLAB上分配
逃逸分析和栈上分配只是针对于单线程环境来说的,如果在多线程环境中,不可避免的会有多个线程同时在堆空间中分配对象的情况。这种情况提升性能就引入了TLAB
TLAB,全称Thread Local Allocation Buffer, 即:线程本地分配缓存。这是一块线程专用的内存分配区域。TLAB占用的是eden区的空间。在TLAB启用的情况下(默认开启),JVM会为每一个线程分配一块TLAB区域。
TLAB这是为了加速对象的分配。由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步「有冲突同步降低效率」,会使分配的效率下降。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。
局限性: TLAB空间一般不会太大(占用eden区),所以大对象无法进行TLAB分配,只能直接分配到堆上。
分配策略:
一个100KB的TLAB区域,如果已经使用了80KB,当需要分配一个30KB的对象时,TLAB是如何分配?
一,废弃当前的TLAB(会浪费20KB的空间);
二,将这个30KB的对象直接分配到堆上,保留当前TLAB「当有小于20KB的对象请求TLAB分配时可以直接使用该TLAB区域」
JVM选择的策略是:在虚拟机内部维护一个叫refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,反之,则会废弃当前TLAB,新建TLAB来分配新对象。
参数 | 作用 | 备注 |
---|---|---|
-XX:+UseTLAB | 启用TLAB | 默认启用 |
-XX:TLABRefillWasteFraction | 设置允许空间浪费的比例 | 默认值:64,即:使用1/64的TLAB空间大小作为refill_waste值 |
-XX:-ResizeTLAB | 禁止系统自动调整TLAB大小 | |
-XX:TLABSize | 指定TLAB大小 | 单位:B |
再说是否直接进入老年代
-XX:PretenureSizeThreshold
指定大于该数值的对象直接进入老年代,避免在新生代的Eden和两个Survivor区域来回复制,产生大量内存复制操作。
具体说一下对象优先在 Eden 分配
对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中。
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。
其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,Eden 和俩个Survivor 区域比例是 = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),
但是JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的「为了做清理转移年龄升级用的下边会细说」。
新生代、老年代、永久代的区别
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。而新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
新生代中一般保存新出现的对象,所以每次垃圾收集时都发现大批对象死去,只有少量对象存活,便采用了复制算法
,只需要付出少量存活对象的复制成本就可以完成收集 。
老年代中一般保存存活了很久的对象,他们存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清理”或者“标记-整理”
算法。
永久代就是JVM的方法区/元空间。在这里都是放着一些被虚拟机加载的类信息,静态变量,常量等数据。这个区中的东西比老年代和新生代更不容易回收,效率特别低,文章上部分写的有这个永久带回收。垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。
一般是下面这三种GC方式
新生代 GC (Minor GC)
发生在新生代的垃圾回收动作,频繁,速度快。「一般采用复制算法回收垃圾」
老年代 GC (Major GC / Full GC)
发生在老年代的垃圾回收动作,出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢十倍以上「可采用标记清楚法和标记整理法」。Full GC是清理整个堆空间,包括年轻代和老年代。
Minor GC 触发条件一般为:
- eden区满时,触发MinorGC。即申请一个对象时,发现eden区不够用,则触发一次MinorGC。
- 新创建的对象大小 > Eden所剩空间时触发Minor GC
Major GC和Full GC 触发条件一般为: Major GC通常是跟full GC是等价的
- 每次晋升到老年代的对象平均大小>老年代剩余空间
- MinorGC后存活的对象超过了老年代剩余空间
- 永久代空间不足
- 执行System.gc()
- CMS GC异常
- 堆内存分配很大的对象
为什么新生代要分Eden和两个 Survivor 区域
-
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC.老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。
-
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历15次Minor GC还能在新生代中存活的对象,才会被送到老年代「这里的存活判断是15次,对应到虚拟机参数为 -XX:MaxTenuringThreshold 。为什么是15,因为HotSpot会在对象头中的标记字段里记录年龄,分配到的空间仅有4位,所以最多只能记录到15」。有些大对象可以直接进老年代,
-XX:PretenureSizeThreshold
参数指定大于该数值的对象直接进入老年代避免在新生代的Eden和两个Survivor区域来回复制,产生大量内存复制操作。但是只对Serial和ParNew两个新生代收集器有用 -
设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)
长期存活的对象将进入老年代
虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器**,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1**,当年龄达到一定程度(默认 15) 就会被晋升到老年代。
关键字:引用记数
可达性分析
标记整理
标记清除
复制算法
分代回收
垃圾收集器
Eden Form To
Minor Major Full GC
逃逸分析
TLAB
年轻代 老年代
方法区回收