美文网首页
JVM深度理解(二):对象回收机制

JVM深度理解(二):对象回收机制

作者: mirchle | 来源:发表于2021-12-29 21:53 被阅读0次

    第一部分的问题,我们最后再来解释,我们先了解下jvm的组成部分。首先我们先搞清楚,每个区存储的是些啥


    截图.png

    线程私有(这些区域生命周期随着线程结束而结束,也就释放,不需要gc)

    程序计数器:Java 虚拟机的多线程是通过线程轮流切换并分配处理器的时间来完成的,一个时间,一个处理器只会执行一个线程的任务,当处理器在一个线程没执行完时,就切换到其他线程时,就会在程序记录器中记录执行到的行号。

    我们写一个很简单的测试类Test3,然后将其反编译成字节码文件看下。其中圈起来的0,1,2,5即是行号(图中标的有误),程序计数器会记录执行到的具体行号。


    截图2.png 截图3.png

    虚拟机栈:虚拟机栈描述的是Java方法执行的内存模型。

    每个方法被执行的时候都会创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。【栈先进后出,下图栈1先进最后出来】

    每一个栈帧就表明该方法又调用了另一个方法

    局部变量表:1.局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

    并且在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。

    2.局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)「String是引用类型」,

    对象引用(reference类型) 和 returnAddress类型(它指向了一条字节码指令的地址) (针对方法内部的局部变量)

    操作栈:要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中

    通过代码讲个例子,简单的3行java代码,对应了9行的字节码文件

    第一步 byte i = 15,首先会将15压入操作数栈,对应字节码bipush 15,然后出栈,将其存在局部变量表的1索引处,对应istore_1。(这点也和方法局部变量存储在局部变量表对应上了)

    第二步 int j = 8,同理,会将8先压入操作数栈,然后出栈,存在局部变量表的索引2处。

    第三步 int k = i+j,首先将局部变量表索引1,2处的i,j读出来,对应的是iload1,iload2命令。然后将操作数栈中的i和j出栈,进行相加,将相加结果压回操作数栈,对应iadd命令。最后将计算结果出栈,存到局部变量表索引3的位置,对应istore_3.

    截图4.png

    动态链接:每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接( Dynamic Linking)。比如示例中test方法调用了test2方法,但是字节码文件仅仅是一个invokevirtual #2,#2就是org/redisson/mybatis/Test.3.test2方法的引用。

    截图5.png

    方法出口:记录当本方法执行完之后,回到主方法时改从哪一行执行。

    本地方法栈:结构和虚拟机栈类似,不过是描述的调用native方法的执行的内存模型。

    线程共享的区域

    堆: Java堆是GC回收的“重点区域”。堆中基本存放着所有对象实例,也就是我们大部分new的对象存在的地方。

    方法区:存放着类的版本、字段、方法、接口和常量池(存储字面量和符号引用)。

    截图6.png 截图7.png

    了解了jvm各个区域存储的情况后,我们继续了解下jvm的内存回收回收机制。

    截图8.png

    首先明确一个问题,什么样的对象需要被回收。jvm会通过一些算法判断对象是否存活,那些不存活的对象就是gc回收的目标。

    1.引用计数算法 早期判断对象是否存活大多都是以这种算法,这种算法判断很简单,简单来说就是给对象添加一个引用计数器,每当对象被引用一次就加1,引用失效时就减1。当为0的时候就判断对象不会再被引用。 优点:实现简单效率高,被广泛使用与如python何游戏脚本语言上。 缺点:难以解决循环引用的问题,就是假如两个对象互相引用已经不会再被其它其它引用,导致一直不会为0就无法进行回收。 2.可达性分析算法 目前主流的商用语言[如java、c#]采用的是可达性分析算法判断对象是否存活。这个算法有效解决了循环利用的弊端。 它的基本思路是通过一个称为“GC Roots”的对象为起始点,搜索所经过的路径称为引用链,当一个对象到GC Roots没有任何引用跟它连接则证明对象是不可用的。

    在Java语言中,可作为GC Roots的对象包含以下几种:

    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象(可以理解为:引用栈帧中的本地变量表的所有对象)
    2. 方法区中静态属性引用的对象(可以理解为:引用方法区该静态属性的所有对象)
    3. 方法区中常量引用的对象(可以理解为:引用方法区中常量的所有对象)
    4. 本地方法栈中(Native方法)引用的对象(可以理解为:引用Native方法的所有对象)

    (1)首先第一种是虚拟机栈中的引用的对象,我们在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。

    (2)第二种是我们在类中定义了全局的静态的对象,也就是使用了static关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为GC Roots是必须的。

    (3)第三种便是常量引用,就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots。最后一种是在使用JNI技术时,有时候单纯的Java代码并不能满足我们的需求,我们可能需要在Java中调用C或C++的代码,因此会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots。

    截图9.png

    垃圾收集算法

    1.标记/清除算法

    2.复制算法

    3.标记/整理算法

    jvm采用分代收集算法对不同区域采用不同的回收算法。(这里的分代收集和上面三种算法不是并列的关系,而是说jvm不同处于不同代的对象,采用以上不同的收集算法,下面会详细描述)

    新生代采用复制算法(标记出还在引用的对象,复制到另一块区域,将回收区域全部清空)

    新生代中因为对象都是"朝生夕死的",【深入理解JVM虚拟机上说98%的对象,不知道是不是这么多,总之就是存活率很低】,适用于复制算法【复制算法比较适合用于存活率低的内存区域】。它优化了标记/清除算法的效率和内存碎片问题,且JVM不以5:5分配内存【由于存活率低,不需要复制保留那么大的区域造成空间上的浪费,因此不需要按1:1【原有区域:保留空间】划分内存区域,而是将内存分为一块Eden空间和From Survivor、To Survivor【保留空间】,三者默认比例为8:1:1,优先使用Eden区,若Eden区满,则将对象复制到第二块内存区上。但是不能保证每次回收都只有不多于10%的对象存货,所以Survivor区不够的话,则会依赖老年代年存进行分配】。

    GC开始时,对象只会存于Eden和From Survivor区域,To Survivor【保留空间】为空。

    GC进行时,Eden区所有存活的对象都被复制到To Survivor区,而From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(默认15是因为对象头中年龄战4bit,新生代每熬过一次垃圾回收,年龄+1),则移到老年代,没有达到则复制到To Survivor。

    新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从eden到 survivor,最后到老年代。

    ps:当然不是所有的对象都是通过这种方式进行回收的

    截图10.png

    老年代采用标记/清除算法或标记/整理算法

    “标记-清除”算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

    它的主要缺点有两个:

    (1)效率问题:标记和清除过程的效率都不高,都是遍历实现的;

    (2)空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,碎片过多会导致大对象无法分配到足够的连续内存,从而不得不提前触发GC,甚至Stop The World。

    根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这个算法整体效率会降低,但是可以让老年代的剩余空间尽可能连续,当有需要连续空间的大对象过来时,可以降低full gc的频率。

    来个例子

    可以通过jstat -gc pid查看应用对应区域的大小,其实s0c 也就是from区,s1c也就是to区,Ec 也就是eden区大小,正如之前说所说的为8:1:1,而老年代与新生代大概为2:1

    可以看出,老年代占整个堆内存的2/3大概。

    截图11.png

    gc发生时间

    minorgc:新生代空间不足,触发minor GC

    fullgc:如果创建一个大对象,eden区中空间不足,直接保存到老年代中,当老年代空间不足时候,直接触发fullgc.

    以下fullgc场景仅作补充,实际发生情况较少。

    1. 调用 System.gc()

    只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

    2. 空间分配担保失败

    使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第五小节。

    3. JDK 1.7 及以前的永久代空间不足

    在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。1.8之后去掉了永久代,将类信息放置在本地内存空间(元空间)

    当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

    为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

    4. Concurrent Mode Failure(CMS收集器产生的内存碎片后,老年代无法放入连续大对象)

    执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

    截图12.png

    其中GC就是指minorgc,可以看出minorgc时间很短,0.03秒,对系统影响较小。

    而FullGC,则会触发著名的stop the world(stop the world简单来说就是gc的时候,停掉除gc外的java线程。),时间为0.6s,对系统影响较大。

    OOM故障分析

    可以通过打开如下参数,让程序第一次 oom时生成dump快照

    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof

    然后可以通过mat打开快照,查看具体的大对象。

    相关文章

      网友评论

          本文标题:JVM深度理解(二):对象回收机制

          本文链接:https://www.haomeiwen.com/subject/mpjbqrtx.html