深入理解Java虚拟机总结一垃圾收集器与内存分配策略(二)
- 垃圾回收概述
- 如何判定对象为垃圾对象
- 垃圾回收算法
- 垃圾收集器详解
- 内存分配策略
垃圾回收概述
如何判定对象为垃圾对象
- 引用计数法: 在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,计数器的值就-1;
简单的验证
注意要查看JVM的内容,要在运行的时候添加相关的参数:
下面进行下图的过程:
代码如下:
public class ReferenceCountingGC {
private static class Clazz{
public Object val;
}
public static void main(String[] args) {
Clazz A = new Clazz();
Clazz B = new Clazz();
//内部相互引用
A.val = B;
B.val = A;
//断开外部的引用
A = null;
B = null;
//这里可以回收,说明不是使用的引用计数法
System.gc(); //通知垃圾回收器回收
//使用的parallel 垃圾收集器 (没有使用引用计数法)
}
}
看到运行结果:
在构造函数中添加内存(创建一个20MB的字节数组):
public class ReferenceCountingGC {
private static class Clazz{
public Object val;
public Clazz() {
byte[] bytes = new byte[20 * 1024 * 1024]; //20MB
}
}
public static void main(String[] args) {
Clazz A = new Clazz();
Clazz B = new Clazz();
//内部相互引用
A.val = B;
B.val = A;
//断开外部的引用
A = null;
B = null;
//这里可以回收,说明不是使用的引用计数法
System.gc(); //通知垃圾回收器回收
}
}
运行效果:
- 第二种判定垃圾对象的方法: 可达性分析法,这个可以解决上面引用计数法不能判定堆内部垃圾对象的问题;
如图: 上面的四个对象存活,下面的三个对象为垃圾对象:
引用分类
测试:
import java.lang.ref.WeakReference;
import java.util.WeakHashMap;
/**
* 强与弱引用
*/
public class Refclassify {
public static void main(String[] args) {
System.out.println("---------------测试强引用-----------------");
//强引用: 字符串常量池 (不能回收)
String str = "abc";
//弱引用管理 str 对象
WeakReference<String>wr = new WeakReference<String>(str);
System.out.println("GC运行前: " + wr.get());
str = null; // 断开引用
//通知回收
System.gc();
System.runFinalization();
System.out.println("GC运行后: " + wr.get());
System.out.println("---------------测试弱引用-----------------");
//注意这里是new String
String str2 = new String("abc");
WeakReference<String>wr2 = new WeakReference<String>(str2);
System.out.println("GC运行前: " + wr2.get());
str2 = null;
System.gc();
System.runFinalization();
System.out.println("GC运行后: " + wr2.get()); //弱引用管理--> 被回收
System.out.println("-------------WeakHashMap----------------");//键为弱类型,GC运行被回收
WeakHashMap<String,String>map = new WeakHashMap<>();
map.put("a","a1");
map.put("b","b1");
//下面两个会被回收 如果map中占用内存很大,希望运行后被回收,就可以使用这个
map.put(new String("c"),"c1");
map.put(new String("d"),"d1");
System.out.println(map.size());
System.gc();
System.runFinalization();
System.out.println(map.size());
}
}
运行结果:
生存还是死亡(finalize()方法)
总结:
- 对象可以在被GC时 自我拯救;
- 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次;
/**
* 此代码演示了两点:
* (1) 对象可以在被GC时 自我拯救;
* (2) 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次;
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed !");
FinalizeEscapeGC.SAVE_HOOK = this; //最后的自救 --> 把自己(this关键字)赋值给某个类变量或者对象的成员变量
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
/**
* 第一次成功拯救自己
*/
SAVE_HOOK = null; //没用的对象
System.gc(); // 通知回收
//在finalize()中拯救对象 --> 因为finalize()方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if(SAVE_HOOK != null){
System.out.println("yes,I'm still alive !");
}else {
System.out.println("no ,I'm dead !");
}
/**
* 下面的代码和上面的完全相同,但是这次却自救失败,
* 因为任何一个对象的finalize()方法都只会被系统自动调用一次
* 如果进行下一次回收,它的finalize()方法不会再次执行;
*/
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
System.out.println("yes,I'm still alive !");
}else {
System.out.println("no ,I'm dead !");
}
}
}
运行结果:
回收方法区
垃圾回收算法
标记清除算法
标记清除算法执行过程如下:
复制算法
两块的复制过程:
拓展,关于新生代,老年代和持久代,具体看这篇博客。
- ① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。新生代内分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。
- ② 老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。
- ③ 永久代(Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。
标记整理算法
分代收集算法
垃圾收集器详解
各个垃圾收集器的联系
下图展示了7中垃圾收集器,如果有连线表示可以同时使用,上面是新生代,下面是老年代:
注意,这些收集器都有下面的原则:
- 单线程与多线程收集的不同:单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
- 串行与并发:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并发指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
以下收集器图片均来自这篇博客。
Serial收集器
概括:
- 是单线程且串行的收集器;
- 它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率;
- 现在依然是虚拟机运行在 Client 模式下的默认新生代收集器;(因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。)
ParNew收集器
概括:
- 它是 Serial 收集器的多线程版本;
- 是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作:
在JDK1.5 时期,HotSpot 推出了 CMS 收集器(Concurrent Mark Sweep),它是 HotSpot 虚拟机中第一款真正意义上的并发收集器(收集线程和用户线程同时执行)。不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
-
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
-
Parallel Scavenge 收集器以及后面提到的 G1 收集器都没有使用传统的 GC 收集器代码框架,而另外独立实现,其余集中收集器则共用了部分的框架代码。
并发和并行在垃圾收集器中的概念:
Parallel Scavenge收集器
概括:
- 新生代复制算法,多线程收集器、吞吐量优先的收集器;
- 吞吐量指 CPU 用于运行用户代码的时间占总时间的比值 ;(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间) );
Serial Old收集器
概括:
- Serial Old是Serial 收集器的老年代版本,它同样是一个单线程收集器,使用"标记整理算法";
- 这个收集器的主要意义也是在于给Client模式下的虚拟机使用;
- 在Server模式下,还有两个用途: (1) . 在JDK1.5版本之前和Parallel Scavenge 收集器搭配使用;(2). 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用;
Paralell Old收集器
概括:
- Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 ”标记-整理“ 算法;
- 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器;
CMS收集器
概括:
- CMS(Concurrent Mark Sweep),Mark Sweep 即是 标记 - 清除 算法。主要优点:并发收集、低停顿,也称之为并发低停顿收集器(Concurrent Low Pause Collection);
- 整个过程分为四个步骤: ① 初始标记(CMS initial Mark) (标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。); ② 并发标记(CMS concurrent mark)(时间最长);③重新标记(CMS remark)(需要停顿);④并发清除(CMS concurrent sweep);
- 可以注意到上面只有②和④过程是并发的,因为这两个也是最占时间的,所以这就是CMS的优点;
- 缺点:①吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高(对CPU资源非常敏感)。②无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。③标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
- 为了解决上面的由于标记清除算法产生的空间碎片的问题:
①CMS 提供了一个开关参数-XX:+UseCMSCompactAtFullCollection(默认开启),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的。
②参数 -XX:CMSFullGCsBeforeCompaction 用于设置执行多少次不压缩的 Full GC后,跟着来以此带压缩的,(默认值为0)。
G1收集器(参考: 文章一、文章二)
G1收集器运作步骤:
-
初始标记
-
并发标记
-
最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
-
筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
G1收集器的特点:
首先,G1的设计原则就是简单可行的性能调优,其次,G1将新生代,老年代的物理空间划分取消了。
-
取而代之的是,G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。
-
在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。(在java 8中,持久代也移动到了普通的堆内存空间中,改为元空间。)
-
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
各种垃圾收集算法的对比:
使用垃圾收集器常用的相关参数:
内存分配策略
Java技术体系中所说的自动内存管理归结为解决了两个问题:
- 给对象分配内存;
- 回收分配给对象的内存;
回收内存就是前面所讲的回收算法以及垃圾收集器,而对象分配内存,大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接的栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程有限在TLAB上分配。
下面是几条普遍的内存分配规则:
- 对象优先在Eden分配;
- 大对象直接进入老年代;
- 长期存活对象将进入老年代;
- 空间分配担保;
- 动态对象年龄判定;
下面分配看这几个分配规则。
对象优先在Eden分配
先看一个测试代码:
public class TestAllocation {
public static void main(String[] args){
byte[] b1 = new byte[2 * 1024 * 1024]; // 2MB
byte[] b2 = new byte[2 * 1024 * 1024];
byte[] b3 = new byte[2 * 1024 * 1024];
byte[] b4 = new byte[4 * 1024 * 1024];
}
}
先配置运行参数:
运行结果以及分析:
概括来说:
- 一般直接分配到Eden区域,但是如果Eden区域不够,就进行Minor GC和分配担保;
- 所以原来的6MB(b1、b2、b3)进入了分配担保区(老年代中),然后新的b4继续放入Eden区域;
另外,注意Minor GC和Full GC的区别:
大对象直接进入老年代
概括:
- 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
- 大的对象不能一直放在新生代的Eden区域,因为这个区域是经常需要GC的部分,所以会降低效率,所以大的对象要放到老年代;
- 有一个默认的大小,当对象的大小超过这个值的时候,会进入老年代,也可以通过
-XX:PretenureSizeThreshold来设置这个值;大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
在上面的例子的运行参数(堆大小为20MB)的环境下测试:
通过参数来修改这个默认值:
则此时7MB也会进入老年代:
长期存活对象将进入老年代
- 为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
- -XX:MaxTenuringThreshold 用来设置年龄的阈值(到了这个年龄就进入老年代)。
- 例子和上面差不多,只有设置参数不同,这里不重复做了。
空间分配担保
- 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
- 如果不成立的话虚拟机会查看 HandlePromotionFailure(-XX:+HandlePromotionFailure(默认是开启的)) 设置值是否允许担保失败;
- 如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC(尽管有风险);
- 如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
动态对象年龄判定
- 虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代;
- 如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
关于逃逸分析以及栈上分配
堆的分配已经不是唯一:
- 逃逸分析:分析对象的作用域;
- 如果对象的作用域只是在方法体内(没有发生逃逸),就可以不需要在堆上分配内存,而是可以在栈上分配内存;
看几个逃逸和不逃逸的例子
public class StackAllocation {
public StackAllocation instance;
/**方法返回 StackAllocation对象,发生逃逸*/
public StackAllocation getInstance(){
return instance == null ? new StackAllocation() : instance;
}
/**为成员属性赋值,也发生了逃逸*/
public void setInstance(){
this.instance = new StackAllocation();
}
/**引用成员变量,也发生了逃逸*/
public void use(){
StackAllocation s = getInstance();
}
/**对象的作用域仅在方法中有效,没有发生逃逸*/
public void use2(){
StackAllocation s = new StackAllocation();
}
}