本文章你能知道的内容:
- 运行时Java内存模型
- Java堆
- 对象什么时候回收
- 垃圾回收算法
- 垃圾回收器
Java内存模型
要了解Java垃圾回收机制,首先知道运行时Java内存模型是怎么样的。如下图
1.1 线程私有内存区
- 1.1.1 程序计数器
- 1.1.2 Java 虚拟机栈
1.2 线程共享内存区
- 1.2.1方法区:方法区也是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 1.2.1.1运行时常量池
- 1.2.2Java 堆:一般new的对象实例和数组都是在堆中的,而GC主要回收的内存也是这块堆内存,后面单独领出来来讲。
1.3 直接内存
- 不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范定义的内存区域(Fresco图片加载库,就是运行这块内存超越虚拟机内存使用)。
Java 堆
Java Heap 是 Java 虚拟机所管理的内存中最大的一块,它是所有线程共享的一块内存区域。几乎所有的对象实例和数组都在这类分配内存。
Java 堆内存由垃圾回收器的自动内存管理系统管理。
堆内存分为两大部分:新生代和老年代,默认比例为1:2,可修改。
老年代主要存放应用程序中生命周期长的存活对象。
新生代又分为三个部分:一个Eden区和两个Survivor区,默认比例为8:1:1(可修改)
Eden区存放新生的对象。
Survivor存放每次垃圾回收后存活的对象。
堆内存
为什么?
- 为什么要分新生代和老年代?
- 新生代为什么分一个Eden区和两个Survivor区?
带着这些疑问,我们就要先了解Java虚拟机的GC垃圾回收(GC主要是针对堆的对象回收,不对方法区和栈回收,栈的回收由专门)。在讲算法前先了解怎么判断对象是否回收。
对象回收规则
判断对象是否回收主要有以下两种垃圾确认算法
-
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
引用计数算法- 优点:简单,高效,Objective-c用的就是这种算法。
- 缺点:很难处理循环引用,比如图中相互引用的两个对象则无法释放。但是也有解决办法,想知道自行搜索。
-
可达性分析法(又称引用链法)
为了解决上面的循环引用问题,Java采用了一种新的算法:可达性分析算法。
从GC Roots(每种具体实现对GC Roots有不同的定义)作为起点,向下搜索它们引用的对象,可以生成一棵引用树,树的节点视为可达对象,反之视为不可达。
可达性分析算法
Java定义的GC Roots对象:
虚拟机栈(帧栈中的本地变量表)中的对象。
方法区:类静态属性的对象
方法区:常量的对象。
本地方法栈中JNI的对象
GCRoot对象集合其实是在垃圾回收的时候创建的集合对象,用完就集合会回收掉。
如果出现循环引用了,只要没有被GC Roots引用了就会被回收,完美解决!
清除算法
知道了什么时候回收对象,那我们再看具体怎么垃圾回收。
-
标记清除算法
标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
清除法- 优点:是简单,容易实现。
- 缺点:容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
-
标记复制算法
复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
复制算法- 优点:实现简单,运行高效且不容易产生内存碎片,适用于存活对象很少。回收对象多
- 缺点:内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半,如果存活对象很多,那么Copying算法的效率将会大大降低。
-
标记压缩 (整理)算法
该算法标记阶段和标记清除法一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
压缩算法- 优点:不会出现内存碎片问题,适用于存活对象多,回收对象少的情况使用
- 缺点:整理时间长,容易导致卡顿。
分代回收算法
分代回收算法其实不算一种新的算法,而是根据复制算法和压缩算法的使用场景综合使用。
标记复制算法:适用于存活对象很少。回收对象多
标记压缩算法: 适用用于存活对象多,回收对象少
分代算法就是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Old Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收所以采用标记整理法。而新生代的特点是每次垃圾回收时都有大量的对象需要被回收所以采用复制算法(改良的复制算法,不是按1:1分配)。
- Eden空间和两块Survivor空间的工作流程
// 分配了一个又一个对象
放到Eden区
// 不好,Eden区满了,只能GC(新生代GC:Minor GC)了
把Eden区的存活对象copy到Survivor A区,然后清空Eden区(本来Survivor B区也需要清空的,不过本来就是空的)
// 又分配了一个又一个对象
放到Eden区
// 不好,Eden区又满了,只能GC(新生代GC:Minor GC)了
把Eden区和Survivor A区的存活对象copy到Survivor B区,然后清空Eden区和Survivor A区
// 又分配了一个又一个对象
放到Eden区
// 不好,Eden区又满了,只能GC(新生代GC:Minor GC)了
把Eden区和Survivor B区的存活对象copy到Survivor A区,然后清空Eden区和Survivor B区
// ...
// 有的对象来回在Survivor A区或者B区呆了比如15次,就被分配到老年代Old区
// 有的对象太大,超过了Eden区,直接被分配在Old区
// 有的存活对象,放不下Survivor区,也被分配到Old区
// ...
// 在某次Minor GC的过程中突然发现:
// 不好,老年代Old区也满了,这是一次大GC(老年代GC:Major GC)
Old区慢慢的整理一番,空间又够了
// 继续Minor GC
// ...
// ...
在回答上面提出的问题?
为什么要分新生代和老年代?
综合使用算法,最优采用分代算法所以分为新生代和老年代两块区域。具体为什么1:2?应该是根据实践测试得出的结果,也可以调整。
回答第二个问题为什么新生代内存需要有两个Survivor区,这里讲的比较详细
什么时候触发GC?
GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。
GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。
GC_CONCURRENT: 当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发GC操作来释放内存。
GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。
垃圾回收器
在GC机制中,起重要作用的是垃圾收集器,垃圾收集器是GC的具体实现,Java虚拟机规范中对于垃圾收集器没有任何规定,所以不同厂商实现的垃圾 收集器各不相同,也有可能采用多个垃圾回收器并存方式,HotSpot 1.6版使用的垃圾收集器如下图(图来源于《深入理解Java虚拟机:JVM高级特效与最佳实现》,图中两个收集器之间有连线,说明它们可以配合使用)
- Serial收集器
- ParNew收集器
- Parallel Scavenge收集器
- CMS收集器(应用非常广泛,Android使用这个回收器)
- G1收集器(面向服务端应用)
- ZGC:JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
CMS收集器(老年代回收器)
- 初始标记:STW(Stop the World),标记所有GC Roots对象,创建并添加到GCRoot集合中
- 并发标记:使用三色标记法并发GCROOTS整个引用链,(存在与用户线程混用数据变更且遍历全部引用链时间比较长)
- 重新标记:STW,增量修正并发标记期间因用户程序运行而导致标记变动的那一部分对象的,使用三色标记法
- 并发清除:清除不能到达GC Roots的对象
- 重置线程:更新GC使用过的数据,如:清除GCRoot集合等
三色标记法
上面标记过程使用的是三色标记法
白色:该对象引用链全部扫描完,没有被GCRoot引用。(对象垃圾)
灰色:该对象引用链部分扫描完(用户线程使用有变更),但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
黑色:该对象引用链全部扫描完,且标记该对象下GCROOT引用过。(程序所需要的对象,不回收)