前言
上篇文章通过分析synchronized
的实现原理理解了Java对象内存模型。本文我们来看看被我们亲手创建出来的对象在JVM内部是如何存储、使用和销毁的,而想要弄清楚这些关系,我们得先弄清楚JVM的内存空间是如何划分的,以及划分出来的各个内存区域的职责,即JVM内存模型。通过理解JVM内存模型,能够帮助我们在应对线上JVM类问题更从容一些,同时也能帮助我们编写的代码更加健壮合理。
JDK1.8版本前后的JVM内存模型区别
随着JDK的不断迭代发展,到现在已经迭代到了JDK23了,目前生产环境已经普遍迁移到了JDK1.8、JDK11甚至更高,其中JVM内存在JDK1.8后也进行了不小的调整。基于此背景为了能够与时俱进,文章后续内容都是基于JDK1.8展开讨论,同时为了兼顾一些老旧系统还在使用JDK1.7,所以这里简单对JDK1.8前后的JVM内存模型对比一下。
方法区变化
JDK1.8相比JDK1.7,1.8的元空间 取代了 1.7的永久代,元空间的作用和永久代是类似的,只是元空间数据存储在本地内存中,而不是在虚拟机中。1.8为什么要废弃永久代呢?官方的解释:
移除永久代是为了融合HotSpot JVM与JRockit VM而做出的努力,因为JRockit没有永久代。
PermGen很难调整,PermGen中类的元数据信息在每次FullGC的时候可能被收集,但回收效果很不理想。因为PermSize的大小取决于JVM加载的class总数,常量池的大小,方法的大小等等因素。
JVM内存模型构成
程序计数器
Java类编译为class文件后,加载到JVM被编译成字节码指令,每一行字节码指令都对应了一个行号,(比如可以类比理解为下图中一个单例对象获取对应的class文件最左侧的行号),线程执行这些字节码指令的方式是:首先在程序寄存器中记录当前执行的指令行号,每次执行下一条指令时,会修改程序寄存器中的指令行号值,所以简单理解程序计数器就是暂存当前线程执行到了字节码的哪一行。每条线程执行的行号都是相互隔离的,即该区域内的数据是线程私有的。由于程序计数器中存储的数据所占用的空间大小不会随程序的执行而发生改变,所以JVM规定此区域不会出现OOM问题。
Java虚拟机栈
虚拟机栈和程序计数器一样,也是与线程绑定的,即虚拟机栈的空间也是线程私有的,当一个线程执行完毕之后,对应的虚拟机栈的生命周期也随之终结。每个Java方法被线程执行的时候,Java虚拟机都会同步创建一个 栈帧(栈帧可以理解成一个C里面的结构体,或者理解成Java类,其内部包含了一堆属性),每个栈帧是由局部变量表、操作数栈、动态链接、方法出口等信息组成。每个方法从调用开始到执行完毕的一个完整过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的全过程。
局部变量表: 局部变量表用于存放Java的基础数据类型(比如boolean、int、long等)、对象引用(可以类比理解成C/C++中的对象指针)和returnAddress(它指向的是一条字节码指令的地址)。
操作数栈: 操作数栈是用于存放操作数和运算结果。当执行字节码指令时,会从局部变量表或者常量池中加载数据到操作数栈上,然后进行相应的计算,最后将计算结果写入操作数栈或局部变量表中。操作数栈是一个后进先出(LIFO)栈。
动态链接: 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期间调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法出口: 方法出口指的是方法执行完毕后的退出,方法退出的方式有两种,即正常退出和异常退出。正常退出就是方法在整个执行过程中没有抛出任何异常,异常完成则是在方法执行过程中由于触发某种非预期错误,导致方法执行中断。
本地方法栈
在JDK1.8之后,和虚拟机栈合并了,这里不再赘述。
Java堆
如上图所示,Java堆是JVM内存中空间占用最大的一块区域。GC垃圾回收也主要集中在这个区域。它由年轻代和老年代组成,比例为1:2,其中年轻代又被进一步拆分为三部分,分别为 Eden、From Survivor、To Survivor,所有新产生的对象都会在Eden区分配空间进行存储,当Eden区可用大小无法满足新对象所需的空间时,就会触发GC操作,将不需要的对象进行回收,GC后腾出的空间如果还是不能满足新对象所需的空间时,就会将Eden区的存活对象全部挪到From Survivor中,当Survivor的空间也不足时,也会触发GC操作,将存活对象进一步挪到老年代(GC的具体细节过程,我们在后续文章专门展开介绍),这三块区域的比例为8:1:1,这个默认配置比例在真实生产环境基本不太合适,后续文章在GC调优文章中在详细展开。
元空间
元空间是JDK1.8以后引入的,元数据空间取代了之前版本中的永久代。其主要作用有:
存储类的元数据信息:具体信息有类定义信息,类的常量池信息,方法信息(主要包括方法名称、参数、返回类型等)
动态类加载:在运行时动态加载类,将加载后的类信息存储在在空间内
内存管理:由于这部分空间是在系统内存空间(本地内存)中申请的,在系统内存空间中可以根据需要动态调整内存大小;另外由于这部分信息GC回收的效率非常低,所以从JVM内存隔离出来,以便降低JVM自身内存的压力。
真实线上OOM问题解决思路
了解清楚JVM内存模型中各个部分的主要功能后,下面我们来看看线上出现OOM问题时,一个通用的解决思路SOP,以便在真正遇到OOM问题时可以无脑按流程先操作一波。
触发OOM异常报警 线上真实生产环境都会对服务配置OOM告警策略,接收到OOM告警后,首先确认服务对应的功能业务,找到对应的服务负责人拉群处理。
保留现场 这一步通常是自动的,一般在服务进行部署前,通过配置JVM参数来实现当OOM发生时,dump文件存储到哪里(如果不会,后面专门出篇文章来详细介绍)。
业务变更回滚 查看最近是否有业务变更上线,如果有则直接进行变更回滚,不要去浪费时间分析原因,永远记着稳定性第一位。
扩容上线 如果近期业务也没有发生过变更上线,此时一定不要觉得自己很牛,对于绝大部分人来说,此时的脑子甚至是一团浆糊,所以此时最好最简单也是最快见效的手段就是无脑扩内存,调整完毕后直接上线,尝试进行业务恢复。一般情况下对于线上运行了比较久的服务,90%场景都是可以暂时让业务正常run起来。
分析OOM文件 通过各种OOM文件分析工具,尝试分析问题出现的原因,如果公司有现成的基建工具,则优先使用这些工具进行问题快速定位,如果基建能力一般,可以通过MAT、JProfile等工具进行分析。
业务优化/JVM调优 95%以上的OOM问题都是业务变更引起的,此时进行业务逻辑优化,然后灰度上线进行验证;如果分析后发现是因为JVM参数设置不合理的,尝试进行JVM参数调优,然后灰度上线进行验证观察(如果感兴趣,后面出几篇文章来介绍JVM参数调优的文章)
后续
本文主要介绍了JVM模型的组成部分,以及各个部分的功能作用,然后介绍了一种万能的线上OOM问题通用解决思路,这也是我经常在面试过程中问到候选人的一个问题,候选者通常给的答复都是如何如何排查问题的,这种答复在真实环境中可实施程度几乎为0,甚至会造成更大面积的线上故障。
本文整体理论性偏强,下篇文章我们深入到一些case来进一步分析,如何做能让JVM更好的运行和工作。
做一个有深度的技术人