前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >白话JVM垃圾回收,这是我写的第六篇JVM方面的文章

白话JVM垃圾回收,这是我写的第六篇JVM方面的文章

作者头像
吴就业
发布2020-07-10 11:51:33
4320
发布2020-07-10 11:51:33
举报
文章被收录于专栏:Java艺术Java艺术

这段时间,面过不少应聘者,让我印象最深的一句话就是:这不是运维做的事情吗?或者,这些事情都是运维做的,我不懂。

你觉得只会使用Redis的GetSet真的能用好Redis吗?你觉得不理解Mysql的事务及锁、索引真的懂Sql性能调优吗,真的看懂执行计划吗?你觉得不了解点jvm的知识,真的懂调优吗,知道配置的java启动参数都是什么用途吗?一个框架,不了原理,不了解点源码,真的能用好吗?

去年写过几篇关于JVM的,但上次再分享给同事的时候,发现很多知识点的记忆已经很模糊。技术分享,除了能锻炼自己的归纳总结能力、口语表达能力之外,也能弥补自己欠缺的地方。比如,上次同事就指出了为何try-finally的finally段的代码无论如何都会执行的原理,这是我一直都没有注意到的知识点。

三人行,必有我师!比我厉害的,或是比我差的。总能在他们身上学到一些东西,我能从同事那学到很多。取长补短,一个人不可能什么都懂,即便都是做java后端,专注点不同,也是一样。

一开始接触垃圾回收这个话题的时候,我最感兴趣的是,jvm是怎么判断一个对象是否被引用的?最早的jvm版本,其实也是用了引用计数算法。

关于引用计数算法,很多框架也会用到。比如spring 的注解式事务,关于这个知识点,这里我不详细说明,就是每执行一个mapper方法都将引用数加1,这跟mybatis框架有关。只有引用数为0时,才会真正的去释放连接,当然,是否释放由连接池决定。也就是事务提交之后。

引用计数法为什么被Jvm弃用?原因是无法解决循环引用问题,即对象A引用对象B,同时对象B又引用对象A,导致对象A和B都无法被JVM回收。

说到垃圾回收就不得不说GC Root可达性分析。关于GC Root可达性分析,需要理解两个概念,第一,哪些是GC Root节点;第二,什么是可达性分析,跟垃圾回收有什么关系。

垃圾回收器,现在常用的就CMS和G1两种。堆,就是一块jvm向系统申请的内存,并没有什么新生代老年代的化分。对于CMS垃圾收集器来说,CMS使用了分代回收算法,将整个堆空间分为年轻代和老年代,年轻代又划分为Eden区和两个幸存区。而G1垃圾收集器使用分区回收算法,将整个堆化分为一个个的Regin,但每个Regin还保留分代。

JDK1.8之后默认使用G1垃圾收集器,而1.8之前包括1.8则默认使用CMS垃圾收集器,至少我看的OpenJDK使用的是CMS。1.8还取消了永久代,使用元空间,当然,这跟堆没有关系。

关于元空间、永久代、方法区,不要搞混咯。《java虚拟机规范》中规定了方法区这个概念和它的作用,但并没有规定要如何去实现它。方法区主要用于存储类的信息、常量池、方法数据、方法代码等。

在HotSpot JVM中,JDK1.8之前,永久代就是方法区的实现,而1.8之后,废弃了永久代。元空间就是HotSpot JVM在1.8版本及之后方法区的实现。元空间与永久代最大的区别是,元空间并不在虚拟机中,而是使用直接内存。并且,字符串常量由永久代转移到堆中,jdk1.8字符串常量就已经是分配在堆中了。所以性能调优的配置一般不会考虑元空间,影响不大。

以CMS垃圾收集器为例。年轻代使用复制算法,而老年代则使用标志-整理算法。年轻代的三个区Eden区、from幸存区和to幸存区默认大小比例为8:1:1。年轻代执行的是Minor GC,年轻代的回收过程还是比较复杂的。

回收时,先将eden区存活对象复制到from区,然后清空eden区,当这个from区也存放满了时,则将eden区和from区存活对象复制到to区,然后清空eden区和这个from区,然后将to区和from区交换,保持to区为空。当to区不足以存放 eden区和from区的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC。

关于from区和to区,年轻代对象还有一个年龄的概念,每gc一次如果对象还活着,则将年龄加1,到一定年龄之后还活着,就将对象直接放入老年代,这个年龄是可以配置的,一般不去修改这个配置。

标志清除算法,即并发将需要回收的对象添加一个标志;清除,即stop the work,停止所有java线程,将被标志的对象回收。这个算法的缺点是,会产生内存碎片,这里空一点,那里空一点,如果有大一点的对象则会没有位置放得下。但大的对象都会直接进入年老代。

复制算法,需要两个区域,一个区域作为当前使用区域,另一个区域则是备用区域,当下一次进行垃圾回收时,将存活的对象复制到另一个区域去,再将当前区域一次性清理。

标志整理算法,与标志清除算法一样,都需要并发标志需要回收的对象,然后STW清除,但这里的清除不是简单的清楚,而是将所有存活的对象移动到连续的一块区域,会覆盖被标志的对象的位置,剩下的区域就是可用区域。清除算法关注垃圾对象,而整理算法关注存活对象。

不管是标志清除,还是标志整理,如何才能知道一个对象是否是垃圾对象,才是重点。现在来回答GC Root可达性分析的两个问题。

哪些是GC Root节点?

a、java虚拟机栈,栈帧中的局部变量表和操作数栈中的引用的对象;

b、方法区中的类静态属性引用的对象;

c、方法区中的常量引用的对象;

d、本地方法栈中JNI本地方法的引用对象。

可能第一个和第四个都比较难理解,第一个涉及到java栈和栈帧的概念。静态字段引用的对象是存活整个进程的生命周期的,局部变量表和操作数栈中引用的对象一定是当前方法使用到的对象。这也就明白了,为什么局部变量都是方法结束后才会被回收。

从Java栈的概念来理解,一个线程对应一个Java栈,也叫Java虚拟机栈。一个方法则对应一个栈帧,方法调用对应着栈帧的入栈和出栈。栈帧的结构分为局部变量表,操作数栈,动态链接,方法出口。动态链接:一个方法调用其它方法,需要将方法的符号引用转为其在内存中的直接引用。方法出口:正常执行完成出口,抛出异常完成出口。说实话,动态链接与方法出口我也没明白透彻。

操作数栈是用来存放代码运行所需要的变量的。怎么理解简单呢?字节码可由JIT编译为机器码运行,汇编指令同样也是需要编译为机器码才能运行的。就以汇编指令来说明,比如执行一条add指令,需要将一个变量放到寄存器,再与内存中的一个变量累加,结果存放到寄存器。一条add指令就需要两个操作数才能完成。那么理解java操作数栈也是一样的,调用一个this.A方法,假设A方法需要三个参数,那么操作数栈至少需要4个u2单位的栈深度,第一个是this,其它三个是参数。这里不做更深入的解释。

本地变量表是一块连续空间,可以理解是u2类型的数组。u2是jvm定义的两个字节无符号类型。局部变量表存放着局部变量的引用。局部变量按出现的顺序存放于局部变量表,注意,像new A().say();这种代码,你看着是没有保存A对象的,但编译为字节码后,它是会被存放在局部变量表的,还有抛出异常的catch(Exception e) 异常e也会占用局部变量表的一个位置,当抛出异常时,会将异常存放于局部变量表,抛出则是athrows指令。我们写代码时候,声明Object c=new Object,其实c不过是给我们看的变量名罢了,编译成字节码后就没有c变量的概念存在了。

局部变量引用的对象除了方法结束后会被回收,也可显示将变量赋值为null,不再引用对象。但对象什么时候被回收,这还得看垃圾收集器什么时候执行GC。如果是大对象,会在年老代中,需要等下一次Full GC;如果对象还在年轻代中,下一次Minor GC就能将对象回收。当然,这里说的对象是在方法中new的对象,并且,是非线程共享的变量。

第二个问题,什么是可达性分析。比如有一个对象A,A有name字段(字符串类型),而A是一个局部变量,垃圾收集器执行GC时,在第一阶段的并发标志,会寻找到这个局部变量作为GC Root节点,然后就是一颗二叉树的深度遍历,找出所有被引用的对象,其它未能从所有GC Root节点找到的对象,即没有任何引用的对象,就需要被标志回收。

注意,基本数据类型int,long,bool等,对方法而言,是直接存放在局部变量表的,也就是占用的是栈的空间,并非堆空间。看字节码你就理解了。

与编码相关的,还有引用类型这一知识点。Java有四种引用类型,分别是强引用、软引用、弱引用和虚引用。强引用,只要有引用存在,就不会被回收,一般的Object a=new Object(),就是a持有这个对象的强引用。而弱引用,WeakReference<Object> c=new WeakReference<>(a),当垃圾回收时,如果这个对象a不存在强引用,则会被回收掉,弱引用我用得还是比较多的,关于软引用和虚引用我几乎没用过,所以也记不得是什么回事。

关于性能调整。你是否遇到过这种现象,明明配置的最大堆内存大小为3g,但进程占用了3.5g。使用jmap查看堆的使用情况,发现堆未用满,年轻代只有了一点,老年代也只用了百分六七十(gc后的)。为何超了0.5g,堆也没有扩大,难道是内存溢出?明明是谁我也不知道。

内存溢出这种情况是不存在的,否则用java的目的是什么?除了面向对象,最开始不就厌烦c++语言的手动释放内存吗。还有,堆不够用是会抛出堆溢出异常的,当然,这个异常只会导致当前线程异常结束。如果出现内存溢出的情况,首先要找到项目中依赖的哪些框架,会使用到堆外内存,或者有jni调用的。

为何没有使用任何堆外内存,确定没有依赖会使用堆外内存的jar包,也会跑出3.5g的内存使用,前一篇文章我介绍过,也推荐了jcmd工具排查。再理解本文内容之后,是否有了更深刻的理解。首先堆只是用来存放对象的,其它的都是使用直接内存,比如Java栈,比如元空间。其它的我就不再复说。

在说垃圾回收知识点的时候,我穿插了两个知识点,Java栈和栈帧。那理解Java栈和栈帧有什么好处呢?除了更好的理解JVM 垃圾回收算法之外,对高并发进程进行调优也有帮助。

高并发场景会配置很多线程,比如配置netty的boss线程数和工作线程数、数据库连接的线程数与其他线程池的线程数的总和为1024。根据java栈知识点的理解,一个线程对应一个Java栈,默认一个Java栈的内存为1m,那1024个线程对应1024个Java栈,也就是1G的内存。如果指定-Xss=256kb,那1024个线程就可以省下四分之三的内存。当然,java栈的大小需要结合业务代码来调整,需要考虑的因素有。栈的深度,即执行一次业务一个线程内调用的方法数,准确说是次数;被调用到的方法的栈帧大小,也涉及到局部变量表的长度和操作数栈的深度。最难计算的是递归方法的调用。说到递归方法,经常会出现栈溢出异常,配置的-Xss越大,递归的层次就能越深,当然,如果是无限死递归,配多大的栈最终都会栈溢出。

文章开头说的,同事给我补充的一个知识点,是try-catch-finally块,为何说finally 块无论如何都会被执行一次。这是在讲字节码的时候说的,需要结合字节码来理解。将java代码编译后,javap查看class,你会发现,finally块里面的代码,在每个catch块后面都插入了一份finally块的代码,同时,会添加一个any类型的catch块,捕捉未被catch住的任意类型的异常。

其实异常表非常容易理解,就一个c语言的结构体。字段有from,to,,target,type。from到to的范围就是try住的代码,from是起始行号,当然,这是字节码的行号,如果想看java代码的行号,需要结合行号表来看。to是try结束的行号,type是捕捉的异常类型,target则是出现这类异常时执行的代码块的开始位置。

我也不敢说我说的全是对的,记忆模糊,如有说错的地方,还望留言纠正,或许我该再看一次《深入理解java虚拟机》这本书。

我开始再一次学Spring Clound,之前学的因为没用到印象又模糊了,再学一次或许会有不同的收获,再忘就再学呗。最近也在看程序化广告,业务与技术一同成长!朋友们,我们下期见!

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-09-04,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java艺术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档