JVM总结

作者: 幽游不想吃饭 | 来源:发表于2021-02-21 16:34 被阅读0次

    JVM

    简述

    JVM将java等编程语言的class文件通过解释器或者JIT生成字节码用于和硬件设备交互。java程序通过生成在JVM虚拟机运行的字节码,JVM虚拟机通过字节码去和硬件进行交互,屏蔽了很多的操作系统平台相关信息,保证了java的跨平台运行

    Java内存区域

    Java 内存区域和内存模型是不一样的东西,内存区域是指 JVM 运行时将数据分区域存储,强调对内存空间的划分;内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式

    • 程序计数器

      • 线程私有

      • 线程是占用CPU执行的基本单位,多线程的实现是CPU通过时间片轮转方式来让线程轮询占用,当前线程的时间片使用完之后就需要让出CPU,等下次轮到自己再执行。程序计数器就是用来记录线程让出CPU时的执行地址的(线程私有的原因)

      • 如果执行的是Java方法,则程序计数器记录的是下一条指令的地址;如果执行Native方法,记录的是undefined地址

    • 虚拟机栈

      • 线程私有

      • 存放线程的局部变量、调用栈帧。每个方法执行时会在虚拟机栈生成一个栈帧,一个方法就是一个栈帧从入栈到出栈的过程

    • 本地方法栈

      • 线程私有

      • 与虚拟机栈类似,本地方法栈对应Native方法,虚拟机栈对应java方法

      • 线程共享

      • 存放对象实例

    • 方法区

      • 线程共享

      • JDK7之前:使用永久代实现;存放JVM加载的类型信息、字符串常量池和静态变量

      • JDK7:字符串常量池和静态变量移至Java堆

      • JDK8:废弃永久代概念,改用直接内存中实现元空间(Meta-space),将剩余部分(主要是类型信息)移到元空间

      • 永久代和元空间是方法区的两种实现方式

        永久代:存储包括类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。可以通过 -XX:PermSize-XX:MaxPermSize 来进行调节。当内存不足时,会导致 OutOfMemoryError 异常。JDK8 彻底将永久代移除出 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)

        元空间(Metaspace):元空间是方法区的在 HotSpot JVM 中的实现,方法区主要用于存储类信息、常量池、方法数据、方法代码、符号引用等。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统内存大小,可以通过 -XX:MetaspaceSize-XX:MaxMetaspaceSize 配置内存大小。

    类加载过程

    • loading

      类加载器。双亲委派模型:查找负责加载的类时从下至上查找,加载类时从上至下询问(bootstracp -> extension -> application -> 自定义)

      好处:1.保护java核心类不被篡改;2.防止重复加载

    • linking

      • Verification:校验类是否符合JVM规定

      • Preparation:静态变量成员赋默认值

      • Resolution:将类、方法、属性等符号引用解析为直接引用;常量池中的各种符号引用解析为指针、偏移量等内存地址的直接引用

    • Initializing

      调用类初始化代码,给对象赋初始值

    对象创建过程

    • 具体过程

      • 类加载检查,没有执行类加载则执行类加载过程

      • 给对象分配内存

      • 给对象赋默认值

      • 设置对象头,例如这个对象是哪个类的实例,如何能找到类的元数据信息,GC年龄,是否启用偏向锁等(哈希码要在执行了Object::hashCode()方法才生成)

      • 执行<Init>方法,给对象赋初始值

    • 内存分配原则

      • 分配方式

        • 指针碰撞:假设Java堆中内存绝对规整,被使用的内存和没有被使用的内存被放在两侧,中间用指针作为分界器,每次分配内存就只需要将指针向右移动对象内存大小相等的距离即可
      • 空闲列表:假如Java堆内存不规整,就需要维护一个记录未使用内存区域的列表,每次分配内存时从列表中找出一块足够大小的空间分配给对象

    • 取决于内存空间是否规整,也就是取决于使用的垃圾回收器

    • 解决分配导致的并发问题

      分配内存是个高频操作,会有线程安全的问题,并且频繁请求堆来获取内存分配太耗时也太耗资源

      • TLAB(Thread Local Allocation Buffer 本地线程分配缓冲),JVM会先从java堆中分配一定的内存空间给每个线程,线程分配内存时先从TLAB中分配,如果TLAB不够再从堆上去分配

      • CAS+重试机制

    对象结构

    • 对象头

      Mark Word:8字节(1位=8字节),存放hashcode,偏向锁标识,锁类型标识,GC分代年龄等

      Classpointer:类指针,指向类的元数据信息,表明对象所属类。默认压缩4字节,不压缩8字节

      数据长度:4字节,数组对象才有(因为通过元数据信息是知道对象长度,但是不能知道数组长度)

    • 实例数据

      String/引用数据/Oops(ordinary object pointer 普通对象指针,对象引用的句柄) 4位,int 8位

    • 对齐填充

      对齐Padding(保证对象位数是8的倍数)

    对象定位

    从栈中的引用定位到堆中具体数据

    • 句柄定位:栈存句柄的地址,堆规划一个句柄池区域,句柄池存对象数据指针和对象类型指针

    • 栈中存对象数据的地址,堆的对象数据里面划分一个区域来存指向方法区里面的对象类型的地址,定位快速(HotSpot实现)

    垃圾的定义

    JVM中如果没有任何引用指向某个对象,这个对象就被视为垃圾,会被JVM内存回收机制回收

    垃圾对象判定方式

    • 根可达算法

      从GC Roots对象出发开始查找引用,没有在引用链上的对象则被判定为垃圾对象。

    • GC Roots

      虚拟机栈,本地方法栈,运行时常量池(属于方法区),方法区里面的Reference对象(常量池对象,线程池对象,JNI)

    • HotSpot实现

      实际上不会将所有的GCRoots作为起源去遍历引用链,因为消耗太大,HotSpot使用oopMap的数据结构来直接得到哪些地方存放有对象引用,在类加载完成后,就会记录下对象内什么偏移量是什么类型的数据计算出来,而不需要一个不漏从GC Roots开始查找

    对象从分配到被回收的过程

    对象从分配到被回收的过程.png

    常见的垃圾回收算法

    • 标记清除

    • 复制

    • 标记整理

      常用的回收算法都是分代回收,即针对年轻代和老年代对象的不同采用不同的回收算法,年轻代划分一个Eden区和两个Survivor区

    安全点,安全区域

    安全点:HotSpot只有在安全点的地方才会生成oopMap,程序只有在运行到安全点的时候才能进行垃圾回收,采用主动式中断让线程自己去轮询标志,当标记为真就自己在最近的安全点上主动中断挂起。

    安全区域:由于有些程序处于sleep或者blocked状态,没法中断挂起自己。安全区域能够确保在某一段代码片段中,引用关系不发生变化,相当于扩展的安全点。

    记忆集、卡表

    • 解决什么问题

      GC时,由于有些新生代对象会被老年代对象引用(跨代引用,主要问题发生在老年代对象引用新生代对象),那么当发生YGC时,就需要把这些老年代对象加入到GC Roots,那么如果不清楚新生代对象被哪些老年代对象引用,就需要将整个老年代加入GC Roots扫描范围 ,根节点引用链时间消耗过长,会导致GC效率低

    • 原理简述

      JVM使用了记忆集来记录那些从非收集区域指向收集区域的指针集合的抽象数据结构,保证每次回收时直接把记忆集中的对象加入GC Roots即可。记忆集的实现有很多的维度,卡表是记忆集的一种实现。基于卡表会把堆空间划分为一系列的卡页组成,一个卡表对应一个卡页,HotSpot JVM的卡页大小为512字节,卡表被实现成一个简单数组。当发生跨代引用时,引用对象所在卡表会被变"脏"(dirty),每次只需要将这些卡表里面的对象加入GC Roots即可

    • 实现细节

      • 关于用卡表实现记忆集颗粒度的问题

        首先要回归我们解决问题的本质是为了防止GC时遍历整个非手机区域的对象来寻找跨代引用对象,如果我们精确到一个类,一个对象来说,其实维护成本又会很大,而卡表精确到一块内存区域的方式保证了快速GC,也防止记忆集维护成本过高

      • 卡表在什么时候变“脏”?以什么方式?

        发生在其他分代区域的对象引用本区域对象时,时间点是引用类型字段赋值的时候

        通过写屏障维护,类似于引用类型字段赋值这个操作的AOP切面,G1之前的垃圾回收器都是用的写后屏障

        G1的记忆集比较特殊,每个Region分别维护着自己的记忆集,记录下别的Region指向自己的指针,并且标记这些指针分别在哪些卡页的范围内(传统的垃圾回收器的记忆集就是只需要维护一块一块的卡表即可,不需要和G1一样每个Region分开维护,所以G1的内存占用比其他传统的垃圾回收高)。实际是一个哈希表结构,key是别的Region的起始地址,value是一个集合,存储着卡表的索引号。

    常见的垃圾回收器

    • serial+serial old 单线程

    • po+ps:parallel old、parallel scanvenge 多线程,不允许并行,JDK7,8默认

    • CMS(老年代)+pn(parallel new 就为了配合cms) 收集过程;出现的问题两种:浮动垃圾(有一个内存阈值的设置,目前默认92%,就是总内存(?)达到92%触发FGC,因为对象基本上属于经常被回收,“朝不保夕”,所以92%也没啥子问题。但是要是预留给浮动垃圾的空间不够了,直接报”并发回收失败“,然后GC就会把并发标记给关了,然后启动CMS备用方案——serial old,直接单线程来回收,严重影响效率了),内存碎片(因为是标记清除算法嘛,有设置,多久进行一次标记整理)

    • G1:初始标记(STW)、并发标记、最终标记(STW)、筛选回收(复制)

    • 并发标记阶段的算法

      并发标记消耗回收80%的时间,决定了GC快慢的因素。并发标记由于引用会变化,可能会产生漏标的情况,如果是该被回收的被标记成存活的问题不大,下次回收就行;但是如果是本来该存活的对象被标记成死亡就有问题了。

      会产生“对象消失”的情况需要满足两个条件:

      1.重新加入了一条从黑色对象指向白色对象的新引用

      2.所有灰色对象到该白色对象的直接或间接引用被删除了

      这样本身该白色对象之前被标记为死亡,在加入黑色对象引用之后,就会被标记为存活,但是只会标记一次,所以这个对象就会被回收。

      解决方法:增强更新和原始快照,两种方式都是通过写屏障实现的

      • 增量更新(CMS)

        破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,记录这些插入引用,等并发扫描结束后,再讲这些引用关系中的黑色对象为根,重新扫描一次。可以理解为黑色对象一旦新插入了指向白色对象的引用,这个白色对象就变成灰色对象,所以叫增量更新

      • 原始快照(G1、Shenandoah)

        破坏第二个条件,当灰色对象要删除指向白色对象的引用时,记录这些引用,并发扫描结束后,将这些引用关系中的灰色对象为根,重新扫描一次。可以理解为发生删除引用关系的时候,都按照刚开始扫描的那一刻的对象图快照来进行搜索,所以叫原始快照

      • ZGC:colorpointer+写屏障有时间再了解

    对象进入老年代的方式

    • 对象太大,找不到足够的内存区域进行分配

    • 分代年龄达到晋级限制,除了CMS是6,其他默认15

    • 动态年龄判定:进survivor区的某个年龄的对象中大于survivor区域内存大小的一半,那么大于等于这个年龄的对象都直接进入老年代

    • 空间分配担保:年轻代GC采用复制算法进行GC回收时,当其中一个Survivor区不足以容纳存活的对象,就需要老年代提供内存空间来存放Survivor区无法容纳的多出来的对象。这就是担保的概念。 这时候有一个问题:我们无法在对象回收前得知有多少对象会存活下来。如果每次我们为了确保老年代空间能完全容纳新生代GC后幸存的对象,就需要每次都进行Full GC,但是每次都进行Full GC耗时过长而导致停顿时间增长,用户体验很不好。 针对这种情况,Hotspot采用的方法是:在垃圾回收之前,取之前每一次回收晋升到老年代的对象容量的平均大小作为参考,老年代剩余空间大于这个值或者大于新生代对象总大小则进行Minor GC,小于这个值则进行Full GC。虽然这样仍然会出现“担保失败”的情况,但是避免了过于频繁的Full GC。

    JVM的内存模型(JMM,Java Memory Model)

    JMM.png

    每个java线程有自己的工作内存,线程只能操作自己的工作内存,只能通过save和load操作,对主内存的数据更新来实现不同线程之间的数据同步

    JVM调优

    调优包括:预调优(事前估计修改配置)、JVM运行环境问题(卡顿问题)、运行时出现的问题(OOM这些)

    1. top

    2. top HP -线程pid

    3. jmap histo 这个命令用来看占用

    你有没有遇到过OutOfMemory问题?你是怎么来处理这个问题的?处理过程中有哪些收获?

    遇到过,导出大量Excel数据导致直接挂了,先是运维报警,运行缓慢,然后就OOM,把OOM的那个服务器剥离出来(因为做了高可用,所以不影响),利用jmap histo查看对象占用,发现大多数数据是行列对象。解决方式:修改sql,改成多线程模式查询,导出时用poi的Hssfbook,有一个内存窗格的说法,保证内存中存在的对象是固定的。

    相关文章

      网友评论

          本文标题:JVM总结

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