深入理解java虚拟机

概述

本地方法栈、虚拟机栈、程序计数器等都是随着线程的产生而产生,线程的死亡而死亡,故随着线程死亡,则会回收内存,该区域不需要我们考虑如何回收。
而java堆和方法区的内存分配和回收是需要我们关注的,因为这两部分的内存分配是不确定的,例如同一个接口的不同实现所需内存不同,同一个方法的不同逻辑分支所需的内存也是不同的,只有当运行时才需要多少内存。

对象已死

堆里面存放着java世界里几乎所有的对象实例,垃圾回收之前,需要确认哪些对象已死,哪些还活着。

引用计数算法

在对象中添加一个计数器,当有一个地方引用它时,计数器加一,当引用失效时,计数器减1,当引用计数器等于0时,即表示该对象可以被回收了。
优势:简单便于理解
缺点:无法解决对象相互引用的问题。

可达性分析算法

通过一系列GC Roots对象,根据引用关系向下向下搜索,搜索过程走过的路径称为引用链,如果某个对象没有路径直达GC Roots,则认为该对象是可以被回收的。
固定作为GC Roots的对象有:
1、在方法区中类静态属性引用的对象,譬如java类的引用类型的静态变量。
2、在方法区中常量引用的对象,譬如字符串常量池里的引用
3、在本地方法栈中JNI(通常所说的native方法)的引用。
4、java虚拟机内部的引用,如基本的数据类型对应的class对象,一些常驻的异常对象,还有系统类加载器。
5、所有被同步锁(Synchrozied)持有的对象。
6、反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

再谈引用

在jdk1.2之前的版本中,一个对象只有“被引用”和“未被引用”两种状态。
在jdk1.2之后的版本中,引用分为四种:强引用 、软引用、弱引用、虚引用。
强引用:传统意义上的引用,只要有强引用存在,就不会被当作垃圾回收掉。
软引用:描述一些还有用,但是非必须的对象。软引用对象,在内存不足时,会被放进回收范围之中进行二次回收,如果回收之后内存仍不够用,则抛出OOM。SoftReference这个类实现软引用。
弱引用:只被弱引用关联的对象,只能生存到下次垃圾回收,下次垃圾回收时,不论对象是否有用,都会被回收掉。WeakReference这个类实现弱引用。
虚引用:给对象增加虚引用,不会改变对象的生存时间,也无法通过虚引用获得这个对象,设置虚引用的唯一目的,只是在对象被删除时获得通知。PhantomReference这个类实现虚引用。

生存还是死亡

即使在可达性分析中被判定为不可达的对象,也不会立即被回收,而是要经历二次判断。
首先被标记,然后进行删选。如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则会被判定为“没有必要执行”。
如果对象确定要执行finalize()方法,则会被放入到F-Queue的队列中,由虚拟机自动建立的,低优先级的Finalizer()方法执行它们的finalize()方法,但并不承诺一定等它们运行结束。第一次执行finalize()方法之后,收集器会对F-Queue中的对象进行二次检查,如果这时队列中的对象与某一对象建立关联,即会被移出F-Queue队列,完成自我救赎。

package org.fenixsoft.jvm.chapter3;

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 *
 * @author zzm
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

回收方法区

方法区垃圾回收的回收率较低。
方法区垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型。
废弃常量:如果常量池中的一个常量不再被虚拟机中其他地方引用这个字面量,则变为可回收状态。
如果想认定一个类型不再被使用,需要满足三个方面:
1、该类的所有实例都已被回收,即堆中不存在该类及派生子类的任何实例。
2、加载该类的类加载器已被回收
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足条件的无用类仅是“可回收状态”,使用-verbose:class以及-XX:+TraceClassLoading、-XX:TraceClassUnLoading查看类加载和卸载信息。

垃圾收集算法

引用计数式垃圾收集(hotspot未涉及)
追踪式垃圾收集

分代收集理论

分代收集理论建立在三个假设上:
1、弱分代假设:绝大多数对象都是朝生夕灭的
2、强分代假设:熬过越多次垃圾回收过程的对象越是难被回收。
3、跨代引用 假设:跨代引用相对同代引用,只占少数。
设计原则:收集器将堆设计为不同的区域,将回收对象按照年龄放入不同的区域。

标记清除算法

标记所有需要回收的对象,统一回收。
缺点:
1、效率不高,如果大量对象需要被清除,则会存在大量的标记清除动作。
2、内存碎片化问题,标记清除之后会产生大量的不连续的内存空间。

标记复制算法

将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存用完了,就将这块内存上仍然存活的对象复制到另一块内存上。
缺点:
1、如果存在大量的存活对象,则会导致大量的复制操作。在弱代假设的前提下,该条可忽略。
2、可用空间减少一半
Appel式回收:
把新生代分为一块较大的Eden区,和两块较小的Survivor区,发生垃圾回收时,将Eden和Surivor中仍然存活的对象一次性的复制到另一块Survivor区。然后直接清理掉Eden区和已经使用过的Survivor区。HotSpot区默认的Eden和Survivor的比例为8:1:1。如果Eden区和Survivor区存活的对象过多,导致剩余的那块Survivor区不够存储,会使用另一块区域(一般为老年代)存储对象,直接进入老年代的对象会直接进入老年代。

标记整理算法

老年代一般不直接选用标记复制算法,针对老年代对象的死亡特征(强分代假设),设计了标记整理算法。
将所有存活的对象向内存的一端移动,然后直接丢弃掉边界之外的内存。
缺点:
1、内存回收时较复杂:需要暂停程序,改变引用的位置。
2、吞吐量较高
关注时延的话选择标记清除算法,关注吞吐量的话选择标记整理算法。

HotSpot算法实现

GC Roots节点选取

枚举根结点时必须停止用户线程,当用户线程暂停之后,虚拟机不需要一个不漏的检查完执行上下文和全局的引用位置,HotSpot使用一组被称为OopMap的数据结构直接得到哪些地方存在着对象引用。一旦类编译完成,HotSpot就会把对象内什么偏移量是什么类型的数据计算出来,对于即时编译,也会在特定位置记录栈里和寄存器里什么位置是引用。

安全点

虽然OopMap可以很快速的完成节点枚举,但是导致节点引用关系变化的操作很多,如果为每个命令都生成一个OopMap,则会导致大量的额外存储空间。HotSpot只有在特定的位置记录了OopMap,这些特定的位置称作安全点。
安全点的选举:“是否具有让程序长时间执行”,例如:方法调用、循环跳转、异常跳转。
对于安全点,另一个需要考虑的问题时,发生垃圾收集时,如何让线程都跑到最近的安全点刮起,一般有两种方式:抢断式和主动式。所谓的抢断式是指,当发生垃圾收集时,强行中断所有线程,如果发现有的线程不在安全点上,则恢复该线程,直到达到安全点,暂时还没有虚拟机采用这种方式。所谓的主动式是指,当发生垃圾回收时,不会对线程直接操作,仅仅设置一个标志位,线程执行时会轮训标志位,一旦发现标志位为真,则在最近的安全点挂起。轮训标志的地方和安全点是重合的。

安全区域

安全点的设计完美的解决了程序运行时中断响应垃圾回收的问题,但是当程序未运行时(sleep和blocked状态)时,程序无法响应中断,这世间必须引入安全区域解决这个问题。
安全区域能够确保在这部分区域中,引用关系不会发生变化,在这个区域中的任何位置开始垃圾收集都是安全的。
当程序运行到安全区域时,会标示自己已进入安全区域,这时如果发生垃圾收集,则会忽略进入安全区域的这部分程序,当程序离开安全区域时,首先会检查程序是否完成了GC Roots的选举,如果未完成,则一直等待。

记忆集与卡麦

所谓的记忆集是为了解决跨代引用带来的垃圾收集问题。记忆集:
是记录从非收集区域指向收集区域的指针集合的数据结构。在垃圾收集的场景中,收集器只需要通过记忆集判断出一块非收集区域是否存在有指向了收集区域的指针即可,不用关心指针和跨代具体情况。可供选择的精度有:
字长精度:每个记录精确到一个机器字长,该字长包含跨代指针
对象精度:每个记录精确到一个对象,该对象里有字段包含跨代指针
卡精度:每个记录精确到一块内存区域,该内存区域里有对象包含跨代指针。
HotSpot采用的是卡精度,其具体实现为卡麦,为一个字节数组。卡麦中每个元素都对应着其标示的内存区域中有一块固定大小的内存块,这块内存块被称为卡页,HotSpot中卡页的大小为512字节。
一个卡页内可能存在多个对象,只要一个对象有跨代存在,就把这个卡页标志为“脏”,GC时,只要筛选出标志为“脏”的卡页对应的对象,把其加入GC Roots即可。

写屏障

HotSpot中,通过写屏障维护卡表状态,在引用对象赋值时,产生一个环形通知,供程序执行额外动作,也就是说赋值的前后都在写屏障范围内。

并发的可达性分析

主流变成语言的垃圾收集器都是靠对象的可达性判断对象是否存活,只有存根结点开始引用,能够引用到的对象才是存活的。从根节点遍历所有对象,很容易得出一个结论:对象越多,遍历时间越长,耗时越多。为了解决对象过多时导致的遍历对象耗时过多的问题,引入三色辅助推导对象存活问题。
所谓的三色是指用三种颜色表示对象“是否访问过”,黑色表示已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过;灰色表示对象已被垃圾回收器访问过,且至少有一个引用未被扫描过;白色表示该对象还未被垃圾回收器访问过。
刚开始时,除了根结点之外都是白色节点,如果在分析结束阶段,仍然是白色节点,则证明该节点不可达,即该节点对象需要被回收。
但是三色辅助法会带来两个问题:1、原本该被回收的对象错误标记为存活,这个问题可以忍受,无非下次再回收;2、原本存活的对象被标记为可回收,这个问题不能被忍受。
出现问题的原因:如果在分析过程中,灰色节点A和白色节点B之间的引用断开,同时黑色C建立了一条指向B的引用,则这时候B应该是可达的,但是因为分析时不会再从黑色节点扫描,所以节点B会被误判为不可达,从而被垃圾回收器回收。
Wilson在1994年证明了当且仅当满足一下两个条件,才会导致“对象消失”问题:
1、赋值器插入了一条或多条从黑色对象到白色对象的引用
2、赋值器删除了全部从灰色对象到该白色对象的直接和间接引用。
因此,为了解决“对象消失问题”,只需破坏其中的至少一个条件即可。产生了两种方法:增量更新和原始快照。
增量更新:破坏的是第一个条件,当发生黑色对象新增指向白色对象的引用时,则把黑色对象记录下来,扫描结束后,再以黑色对象为根结点重新扫描一次。
原始快照:破坏的是第二个条件,当灰色对象要删除指向白色对象的引用时,则将该引用保存下来,当扫描结束时,把灰色对象当根结点再进行一次扫描。
对引用关系记录的插入和删除,虚拟机都是通过写屏障实现的。CMS是通过增量更新来做可达标记的,G1和shenandoah则是用原始快照来实现的。

经典垃圾回收器

各个垃圾回收器之间的关系如下:
经典垃圾回收器之间的关系

如果两个回收器之间存在连线,则说明它们可以搭配使用。

Serial收集器

Serial是最古老的收集器,在jdk1.3.1之前是新生代唯一的收集器,是一个单线程工作的收集器作用在新生代。收集垃圾时必须停止其他工作,直到收集完成。新生代采取复制算法。是虚拟机客运行在客户端模式下的默认的新生代垃圾回收器。

ParNew收集器

parNew收集器是Serial收集器的多线程版本,新生代采取复制算法,作用在新生代。除了Serial之外,目前只有ParNew能与CMS配合工作。ParNew是激活CMS后的默认新生代垃圾回收器。默认开启的垃圾回收线程数与处理器核心数相同。

Parallel Scavenge收集器

Parallel Scavenge收集器是一款新生代收集器,同样是基于标记复制算法。CMS等收集器比较关注用户线程的等待时间,而Parallel Scavenge收集器更关注达到一个可控的吞吐量。这里定义的吞吐量=用户代码执行时间/(用户代码运行时间+垃圾回收时间)。Parallel Scavenge收集器可以控制最大垃圾收集器停顿时间和吞吐量。

Serial Old收集器

Serial Old作用在老年代,单线程,标记-整理算法,在jdk1.5之前与Parallel Scavenge搭配使用,也可以作为CMS的备用方案,当发生Current Mod Failure时使用。

Parallel Old收集器

作用在老年代,多线程,标记-整理算法,jdk1.6才开始使用。在注重吞吐量或者处理器资源较为稀缺的场景下,可以使用Parallel Scavenge 和 Parallel Old搭配使用。

CMS收集器

是一种以获取最短收集停顿时间为目标的回收器,基于标记清除算法,整个过程分为四个步骤:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记两个步骤仍需stop the world。初始标记仅仅是标记下GC Roots能直接关联的对象,速度很快;并发标记就是从GC Roots直接关联的对象开始扫描整个图,这个过程耗时较长,但是不需要停顿用户线程;重新标记则是为了修正并发标记期间因用户线程修改了引用关系的对象的标记结果(详见增量更新)。这个阶段的停顿时间通常比初始标记长一些;并发清除即清理掉确定死亡的对象,因为不需要移动对象,所以这部分可以和用户线程同步执行,不需要停顿用户线程。
CMS的三个明显缺点:
1、对处理器资源非常敏感,默认启动的回收线程数=(处理器核心+3)/4,也就是说,当核心数量大于等于4时,垃圾回线程只占用不超过25%的处理器资源,但是当核心低于4时,CMS对用户线程的影响可能很大。
2、CMS无法处理“浮动垃圾”,有可能导致Concurrent Mod Failure错误,从而引起一次stop the world 的Full GC。CMS在并发标记和并发清除阶段,用户线程仍在运行,仍然会生成新的对象,也会生成新的垃圾,而这些新的垃圾无法在该次被回收,这些垃圾就被称为浮动垃圾。因为垃圾收集阶段用户线程仍在运行,需要为用户线程分配内存生成对象,这样就无法等老年代几乎占满再GC,JDK1.5之前,默认是68%即GC,这样可能导致GC频率过高,JDK7之后,默认是92%,这样可能导致预留空间不够,产生Concurrent Mod Failure。
3、因为CMS是基于标记清除算法,会产生大量的空间碎片,过多的空间碎片会导致明明还有很多内存,却没有连续的空间存储对象,导致Concurrent Mod Failure,导致一次Full GC。为了解决这个问题,有两个参数-XX:UseCMS-CompactAtFullCollection,默认是开启,在FullGC时进行空间碎片化整理,-XX:CMSFullGCBefore-Compaction,在第n次FullGc时,进行空间碎片化整理。

Garbage First收集器

Garbage First简称G1,开启了收集器面向局部收集的思路和基于Region的内存布局形式。G1的远大目标是取代CMS,在JDK9之后,G1取代了Parallel Scavernge和Parallel Old的组合,成为服务端模式下的默认收集器。
在G1出现之前,垃圾收集器的目标要么是整个新生代(Minor GC),要么是整个老年代(Major GC),要么是整个堆(FullGC),而G1可以是堆内存的任何部分组成回收集,衡量标准不再是属于哪个代,而是哪个内存垃圾多,回收效益大,这就是G1回收器的Mixed GC模式。
G1开创的基于Region的内存布局方案,是能实现目标的关键,G1仍然是遵循分代收集理论,但是堆内存的布局与其他收集器有明显差异:不再坚持固定大小以及固定数量的分代区域划分。而是将java堆划分为多个连续的大小相等的独立区域(Region),每个区域都可以根据需要扮演新生代的Eden区、Survivor区以及老年代。region中还有一类特殊的Humongous用来存放大对象,默认一个对象超过一个Region的一半则为大对象。单个Region大小为2的幂次,且为1MB-32MB之间。Region为单词回收的最小单元。
G1回收器会根据各个Region垃圾大小和回收经验时间维护一个回收优先级,根据用户设定允许的停顿时间,优先处理回收优先级高的区域。
G1需要占用相当于java堆容量的10%-20%空间来维持收集器工作。
G1需要解决一下问题:
1、跨Region引用
2、在并发标记阶段,如何保证用户线程和收集线程互不干扰工作。对于用户线程改变对象引用关系,CMS采用增量更新算法,而G1采用的是原始快照算法。对于回收过程中新创建对象内存分配问题,G1为每个Region分配了两个TAMS指针,把Region中的一部分区域划分出来用以分配新对象的存储,并发回收时新对象的地址需在这两个指针的位置之上,G1默认该部分区域的对象是存活的。
3、怎样建立起可靠的停顿预测模型,使用参数-XX:MaxGCPauseMills参数指定的停顿时间只是垃圾回收之前的期望值。
如果不考虑用户线程运行过程中的动作,G1收集器的步骤大致可分为4步:
1、初始标记,标记下GC Roots能直接关联的对象,同时修改TAMS指针的值,使能在可用Region中分配对象,该阶段需要Stop the world,但是是在Minor GC时同步完成,所以不产生额外的停顿。
2、并发标记,与CMS类似,从GC Roots开始进行可达性分析,找出要回收的对象,与用户线程并发执行,扫描一次之后,还需要对SATB中引用产生变化的对象进行可达性分析。
3、最终标记,stop the world,用以处理并发标记后仍然剩下的一小部分SATB记录。
4、筛选回收,stop the world,根据优先级确定需要回收的Region,将这些Region中存活的对象移到新的Region中,同时删除这些Region中所有对象。
默认的停顿目标为200毫秒。
在小内存上CMS性能比G1好,在大内存上G1性能比CMS好。6-8G。

低延迟垃圾收集器

衡量垃圾收集器的三个指标:内存占用、吞吐量、延迟。
Shenandoah和ZGC在初始标记和最终标记这些阶段有短暂的stop the world。可以实现在任意堆容量下,停顿时间不超过10毫秒。

Shenandoah收集器

只有openjdk才有,oracle jdk不支持
与G1采用相同的内存布局,但是支持并发的整理算法。Shenandoah没有实现专门的分代。Shenandoah摒弃了G1算法中的记忆集,改用名为连接矩阵的全局数据结构来记录跨Region的引用。
大致可以分为一下步骤:
1、初始标记
2、并发标记
3、最终标记
4、并发清理
5、并发回收
6、初始引用更新
7、并发引用更新
8、最终引用更新
9、并发清理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值