【JVM我可以讲一个小时】

2022-09-29 10:23:56 浏览数 (1)

类加载过程,双亲委派,.class字节码文件结构

有很多博客中都会对JVM类加载过程进行表述,一般都是说先加载,后连接,连接里面包含验证,验证里面包含,文件格式验证,元数据验证,字节码验证,符号引用验证,然后就是准备,解析,然后初始化,使用,卸载。但是目前我看到的一些权威的数据并没有这样的描述,我认为这个流程,应该是,第一步,加载,第二步,验证,第三步,加载,第四步,加载,第五步,验证,第六步,准备,第七步,初始化。为啥没说解析,因为解析是在初始化之前,具体哪一步,其实我们是不清楚的,也不是固定的。

第一步,加载,一个Java源文件进行编译之后,成为一个class字节码文件存储在磁盘上面,这个时候jvm需要读取这个字节码文件,通过通过IO流读取字节码文件,这一步就是加载。类加载器将.class文件加载到JVM,首先是看当前类是不是使用自定义加载类加载的,如果不是,就委派应用类加载器加载,如果有加载过这个class文件,那就不用再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类的扩展类加载器同理也会先检查自己是不是已经加载过,如果没有再往上,看看启动类加载器。到启动类加载器,已经没有父加载器了,这时候开始考虑自己是否能加载了,如果自己加载不了,就会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException找不到类异常,这就是所谓的双亲委派机制。这就机制可以避免,同路径下的同文件名的类,比如,自己写了一个java.lang.obejct,这个类和jdk里面的object路径相同,文件名也一样,这个时候,如果不使用双亲委派机制的话,就会出现不知道使用哪个类的情况,而使用了双亲委派机制,它就委派给父类加载器就找这个文件是不是被加载过,从而避免了上面这种情况的发生。

第二步,验证,JVM读到文件也不是直接运行,还需要校验加载进来的字节码文件是不是符合JVM规范,在讲这个校验步骤之前,我觉得需要讲一下这个字节码结构,后面再讲这个校验过程会有一定的调理性。字节码结构有:魔数,副版本号,主版本号,常量池容量计数器,访问标志,类索引,父类索引,接口索引集合,字段表,方法表,属性表等。拿魔数来说,它是用来区分文件类型的一种标志,会占用开头的4个字节,之所以需要魔数来区分文件类型,是因为文件名后缀容易被修改,所以为了保证文件的安全性,将文件类型写在文件内部可以保证不被篡改。魔数后面的4位就是版本号,也是4个字节,前2个字节表示次版本号,后2个字节表示主版本号,这二个版本号是为了标注jdk的一个版本,起到一个jdk版本兼容性的一个作用,比如说高版本的jdk代码不能使用低版本的jdk运行,这个时候主次版本号就起到这个作用。版本号后二个字节就是常量池容量计数器,写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了,这是为了满足不引用任何一个常量池的项目,比如说匿名内部类,它没有类名,但是它的类名也需要存储到常量池里面,那只能指向常量池的第0号位置,又比如说Object类,它是所有类的父类,那它的父类指向的是常量池中的0的位置。常量池后面就是访问标志,用两个字节来表示,其标识了类或者接口的访问信息,比如:这个.Class文件是类还是接口,是不是被定义成public,是不是abstract,如果是类,是不是被声明成final等。访问标志后的两个字节就是类索引,通过类索引我们可以确定到类的全限定名。类索引后的两个字节就是父类索引,通过父类索引可以确定到父类的全限定名,通过这二个全限定名可以获取到类路径。父类索引后的两个字节是接口索引计数器,接口索引计数器表示接口索引集合中接口的数量。接口索引计数器后边二个字节是接口索引集合,它是按照当前类实现的接口顺序,从左到右依次排列在接口索引集合中。接口索引集合后边二个字节是字段表计数器,用来表示字段表的容量,字段表计数器后边是字段表。我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static修饰符、final修饰符、volatile修饰符等,所以也可以像类的访问标志那样,使用一些标识来标记字段。字段表作为一个表,同样他也有自己的结构,比如说访问标志,字段名索引,描述符索引,属性计数器,属性集合。在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须要有不一样的名称,但是对于字节码文件来说,如果两个字段的描述符不一致,那这二个字段重名就是合法的。字段表后边二个字节是方法表计数器,表示方法表的容量,方法表计数器后边紧跟的是方法表。和字段表类似,方法表里面也有自己的结构,比如说访问标志,方法名索引,描述符索引,属性计数器,属性集合。方法表后边紧跟的是属性表计数器,属性表计数器后边紧跟的结构为属性表。属性表的两大特点:一个是限制比较宽松,没有顺序长度要求;一个是开发者可以根据自己的需求,向属性表中添加不重复的属性。通过上面一大堆的讲解,可以发现Class文件结构是以魔数开头,以属性表结尾的。然后我们接着讲验证步骤和class文件结构有哪些关联。

验证的第一步就是文件的格式验证,验证class文件里面的魔数和主次版本号,发现它是一个jvm可以支持的class文件并且它的主次版本号符合兼容性要求,所以验证通过。然后又回到了加载,它会将class文件这个二进制静态文件转化到方法区里面,转化为方法区的时候,会有一个结构的调整,将静态的存储文件转化为运行时数据区,这个转化等于说又回到了加载,这就是我说的第三步加载。接着到了方法区的运行时数据区以后,在java堆内存里面生成一个当前类的class对象,作为方法区里面这个类,被各种访问的一个入口。比如说object类,它是所有类都继承它,访问它,所以它也需要一个被各种类访问的入口。object类先加载,加载完成之后,它经过这一系列的操作,把自己java.lang.object放到这个堆里面,要让其他的类进行访问,这个是第四步的加载。第五步又跳到了连接里面验证,验证里面的第二步元数据验证,它会对字节码描述的信息进行语义分析,比如:这个类是不是有父类,是不是实现了父类的抽象方法,是不是重写了父类的final方法,是不是继承了被final修饰的类等等。第三步,字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,比如:操作数栈的数据类型与指令代码序列是不是可以配合工作,方法中的类型转换是不是有效等等。第四步,符号引用验证:确保解析动作可以正确执行,比如说:通过符号引用是不是可以找到对应的类和方法,符号引用中类、属性、方法的访问性是不是能被当前类访问等,验证完成之后,需要做准备。也就是第六步,准备就是给类的静态变量分配内存,并赋予默认值。 我们的类里,可能会包含一些静态变量, 比如说public static final int a = 12; 得给a这个变量分配个默认值 0,再比如public static User user = new User(); 给 static的变量User分配内存,并赋默认值null。如果是final修饰的常量,就不需要给默认值了,直接赋值就可以了。然后就是解析,解析就是将符号引用变为直接引用,该阶段会把一些静态方法替换为指向数据储存在内存中的指针或者句柄,也就是所谓的直接引用,这个就是静态链接过程,是在初始化之前完成。有静态链接就有动态链接,动态链接是在程序运行期间完成将符号引用替换为直接引用,比如静态方法里面有个方法,在运行的时候,方法是存放在常量池中的符号,运行到这个符号,就是找这个符号对应的方法区,因为代码的指令是加载到方法区里面去的,最后把方法对应代码的地址放到栈帧中的动态链接里。后面就是第七步初始化了,初始化就是对类的静态变量初始化为指定的值并且会执行静态代码块。 比如准备阶段的public static final int a = 12;这个变量,就是准备阶段给static变量a赋了默认值0,这一步就该把12赋值给它了。还有static的User public static User user = new User(); 把User进行实例化。

垃圾回收器,垃圾回收算法,空间分配担保

垃圾回收器有多个,先说新生代的三个垃圾回收器,serial,parnew,parallel scavenge,然后再说老年代的serial old,parallel old,cms,最后在说一下新生代和老年代都使用的垃圾回收器G1吧。Serial是新生代下使用复制算法,单线程运行的垃圾回收器,简单高效,没有线程交互开销,专注于GC,这个垃圾回收器工作的时候会将所有应用线程全部冻结,而且是单核cpu,所以基本不会考虑使用它。ParNew是新生代下使用复制算法,多线程运行的垃圾回收器,可以并行并发GC,和serial对比,除了多核cpu并行gc其他基本相同。Parallel scavenge也是新生代下使用复制算法,可以进行吞吐量控制的多线程回收器,主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景。可以发现新生代的垃圾回收器都使用,复制算法进行gc,因为新生代中每次垃圾回收都要回收大部分对象,所以为了避免内存碎片化的缺陷,这个算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,当这一块存活区内存满后将gc之后还存活的对象复制到另一块存活区上去,把已使用的内存清掉。按照分代收集算法的思想,把应用程序可用的堆空间分为年轻代,老年代,永久代,然后年轻代有被分为Eden区和二个Survivor存活区,这个比例又可以分为8比1比1。当第一次eden区发生minor gc,会把存活的对象复制到其中的一个Survivor区,然后eden区继续放对象,直到触发gc,会把eden区和之前存放对象的Survivor区一起gc,二个区存活下来的对象,复制到另一个空的Survivor里面,这二个区就清空,然后将二个存活区角色互换。当对象在Survivor区躲过一次GC 后,年龄就会 1,存活的对象在二个Survivor区不停的移动,默认情况下年龄到达15的对象会被移到老生代中,这是对象进入到老年代的第一种情况,第二种情况就是,创建了一个很大的对象,这个对象的大小超过了jvm里面的一个参数max tenuring thread hold值,这个时候不会创建在eden区,新对象直接进入老年代。第三种情况,如果在Survivor区里面,同一年龄的所有对象大小的总和大于Survivor区大小的一半,年龄大于等于这个年龄对象的,就可以直接进入老年代,举个例子,存活区只能容纳5个对象,有五个对象,1岁,2岁,2岁,2岁,3岁,3个2岁的对象占了存活区空间的5分之三,大于这个空间的一半了,这个时候大于等于2岁的对象,需要移动到老年代里面,也就是3个2岁的,一个3岁的对象移动到老年代里面。第四种情况就是eden区存活的对象,超过了存活区的大小,会直接进入老年代里面。另外在发生minor gc之前,必须检查老年代最大可用连续空间,是不是大于新生代所有对象的总空间,如果大于,这一次的minor gc可以确保是安全的,如果不成立,jvm会检查自己的handlepromotionfailure这个值是true还是false。true表示运行担保失败,false则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minorgc,如果小于或者不允许担保失败,那就直接进行fgc了。举个例子,在minorgc发生之前,年轻代里面有1g的对象,这个时候,老年代瑟瑟发抖,jvm为了安慰这个老年代,它在minor gc之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2g,jvm就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1g的对象全部给你,你也吃的下,你的空间非常充足,这个时候,老年代就放心了。但是大部分情况下,在minor gc发生之前,jvm检查完老年代最大可用连续空间以后,发现只有500M,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure的值是不是允许担保失败,如果允许担保失败,就进行第三次检查,检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300M,现在老年代最大可用连续空间只有500M,很明显是大于的,那么它会进行一次有风险的minorgc,如果gc之后还是大于500M,那么就会引发fgc了,但是根据以往的一些经验,问题不大,这个就是允许担保失败。假设历次晋升到老年代平均对象大小是700M,现在老年代最大可用连续空间只有500M,很明显是小于的,minorgc风险太大,这个时候就直接进行fgc了,这就是我们所说的空间分配担保。Serial Old就是老年代下使用标记整理算法,单线程运行的垃圾回收器。Parallel old也是老年代下使用标记整理算法,可以进行吞吐量控制的多线程回收器,在JDK1.6才开始提供,在JDK1.6之前,新生代使用ParallelScavenge 收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器而出现的。上面的Serial Old,Parallel Old这二个垃圾回收器使用的是标记整理算法,标记整理算法是标记后将存活对象移向内存的一端,然后清除端边界外的对象。标记整理算法可以弥补标记清除算法当中,内存碎片的缺点,也消除了复制算法当中,内存使用率只有90%的现象,不过也有缺点,就是效率也不高,它不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。CMS是老年代使用标记清除算法,并发收集低停顿的多线程垃圾回收器。这个垃圾回收器可以重点讲一下,CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:初始标记,只是标记一下GC Roots,能直接关联的对象,速度很快,需要暂停所有的工作线程。并发标记,进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。重新标记,为了修正在并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程。并发清除,清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。但是很明显无法处理浮动垃圾,就是已经标记过的对象,开始进行并发清除的时候,这个时候又有垃圾对象产生,这个时候,没办法清除这部分的浮动垃圾了,还有一个问题就是容易产生大量内存碎片,这和它的算法特性相关。标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。CMS使用标记清除算法看中的就是它的效率高,只不过内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

为了解决上面那个问题,又出来了一个g1垃圾回收器,G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,每个区域又可以根据分代理论分为eden区,Survivor区,只要这个区域里面出现了一个对象,超过了这个区域空间的一半就可以把它当作大对象,g1专门开辟了一块空间用来存储大对象,这个区域的大小,可以通过jvm的参数去设置,取值范围是1~32mb之间,那么如果有一个对象超过了32mb,那么jvm会分配二个连续的区域,用来存储这个大对象。跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,保证了G1 收集器可以在有限时间获得最高的垃圾收集效率。而且基于标记整理算法,不产生内存碎片。可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。在jdk1.9的时候,被设置成默认的垃圾回收器了。

可达性分析

上面一直提到minor gc,fgc,不可达对象,那么gc的过程是什么样的呢,jvm是如何判断对象是否存活的呢,这就是我接下来要说的可达性分析:通过gc root根节点,从跟节点开始进行引用链的搜索,如果对象搜索不到,就证明这个对象是不可达的,就会在三色标记算法把这个对象标记为白色不可达,最终引发垃圾回收。这会引出几个问题,gc root有哪些?引用链是什么?对象不可达指的是啥?gc的过程中对象是否能回收?三色标记是什么?跨代引用怎么解决的?针对这些问题来解答,这个gc root是可达性分析的起点,gc root有几种,第一种,虚拟机栈里面引用的对象,也就是栈帧中的本地变量,第二种,本地方法栈里面的引用对象,第三种,方法区里面的静态属性引用的对象,第四种,方法区里面的常量引用对象,第五种,java虚拟机内部也有引用,这个也需要作为gc root,第六种,锁,锁的获取和释放,获取的话会持有对象,这些都是作为gc root的引用点。而引用链就需要提到java里面的四种引用类型,分别是强软弱虚,强引用就是最常见的Object a = new Object();这种就是最强的一个引用,只要这个关系还在,就不会被垃圾回收掉。软引用就是描述这个对象还有用,但是它不是一个必须回收的对象,只有系统即将要发送内存溢出的情况下,会把这些对象列入回收的范围里面,进行第二次垃圾回收,如果回收之后,还是没有足够的内存,才会抛出异常。弱引用,被弱引用引用的对象,只能生存到下一次垃圾回收器进行垃圾收集。虚引用,它是最弱的一种引用,可以称为幽灵引用,它的存在不会对结构造成任何的影响,没法通过虚引用找到这个对象的实例。然后就是gc的过程中对象是否能回收,当对象不可达就意味着这个对象要被回收,但是它不会立马就回收,对象不可达会把它放到一个F-Queue的队列里面,这个队列里面会启用一个低优先级的线程,去读取这些不可达的对象,然后一个一个的调用对象的finalize方法,如果对象的finalize方法被覆盖过,被调用过,这个时候虚拟机将这两种情况都视为“没有必要执行”。这个时候这个不可达对象逃过了垃圾回收,稍后会由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行F-Queue中对象的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,收集器将对F-Queue中的对象进行第二次小规模的标记。如果对象重新与引用链上的任何一个对象建立关联,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。然后就是三色标记,这三色就是白黑灰,白色表示对象不可达,黑色表示已经被访问过了,它关联的对象也扫描了,灰色就是还有一部分对象没有被扫描过。跨代引用,年轻代中有一个对象被老年代的对象引用了,这个时候进行minor gc。正常我们的思路是,年轻代里面的对象被老年代里面的对象引用的话,就进行一个遍历,遍历老年代里面的对象。但是老年代里面的对象是很多的,遍历这个是很消耗性能的,这个时候jvm引入了一个记忆集的抽象数据结构。它用于记录从非收集区域指向收集区域的一个指针集合的抽象数据结构。比如说,我们在年轻代里面进行minor gc,它里面有一个记忆集,记录了老年代引用年轻代的对象的指针。如果记忆集里面有当前对象的引用,那么这个对象就不能被回收。

jvm调优

调优的目的是在保证服务正常的情况下,GC尽可能的少执行,GC 执行的时间尽可能短,Full GC的周期尽可能的长。 jvm调优情况比较复杂,根据用户的访问量在不同时刻都有可能会导致jvm回收,在上线之前一定要做一个压测,压测的过程要观察jvm的内存空间的使用,发送gc的频率和停顿时间。在大访问下,minor gc是比较频繁的,新对象一直在创建,当并发量特别高的时候,对象还没有经过第一次垃圾回收,新的对象就填充进来了,这个时候我们要让它发生minor gc,然后在minor gc的过程中,并发量在依然持续,新的对象依然在填充,这个时候又需要重新minor gc,这个时候可以发现它发生的频率是比较高的了,我们需要调大年轻代的一个大小,这是一种解决方案。如果存在很多的持久的大对象,年老代要适当增加。尽量减少Full GC的发生,因为full gc是很费时间的,让年老代尽量缓存常用对象,JVM新生代和老年代的默认比值是 1:2也是这个道理。通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况调节年轻代和老年代的比例,但是要保证老年代有一定的增涨空间。另外需要关注大对象的创建,如果大对象超过jvm阈值会直接创建到老年代里面,容易引发full gc,这个时候就可以调节这个大对象,是不是可以在java代码层面进行拆分,让它保持在jvm阈值以下。除此之外,可以在执行java程序的时候加上PrintGCDetails参数打印jvm的GC回收的细节,当然也有一些监控和故障处理工具,比如,jps命令,可以看系统内的虚拟机进程;使用jstat命令,发现一直增长的堆内存区域;使用jmap命令,导出了一份线上堆栈,然后使用MAT进行分析,通过对GC Roots的分析,可以发现有没有大对象的创建。进程占用的内存,可以使用top命令,看RES段占用的值。如果这个值大大超出我们设定的最大堆内存,则证明堆外内存占用了很大的区域。针对一些堆外内存可以使用gdb可以将物理内存dump下来,通常能看到里面的内容。更加复杂的分析可以使用perf工具,或者谷歌开源的gperftools。那些申请内存最多的native函数很容易就可以找到。对于整体情况而已,可以先通过top命令查看整机情况,对比cpu,内存,系统负载的比例。以cpu过高来举例,使用top命令找出cpu占比最高的,使用ps -ef |grep java或者jps进一步定位,得知是怎样的一个后台程序出问题了,定位到具体的线程或者代码,打印前100行日志,jstack 进程ID | grep tid (16进制线程id小写英文) -A100。比如,jstack 5101 | grep 13ee -A100。通过报错信息定位到具体哪一行的代码。

整个图片,歇歇眼,文章大多不换行,排版基本都是一块的,一共九千多字,口速快的话,一个小时差不多可以讲完,这篇博文主要是针对面试口述而已的,备战面试。

0 人点赞