JVM运行原理_杂记 - 图2

机器码和字节码 首先,我们知道一段程序要想在电脑上运行,必须“翻译”成电脑能够听懂的,由0,1组成的二进制代码,这种类型的代码即称为机器码,机器码是计算机可以直接执行的、速度最快的代码。
在Java中,编写好的程序即通常的.java文件需要经过编译器编译成.class文件,这段.class文件是一段包含着虚拟机指令、程序和数据片段的二进制文件,即字节码,为什么叫字节码?因为这种类型的代码以一个字节8bit为最小单位储存。 参考: [1] Java虚拟机——字节码、机器码和JVM [OL].https://zhuanlan.zhihu.com/p/44657693

解释器和JIT

JVM运行原理_杂记 - 图3
在JDK1.0时代,java虚拟机完全是解释执行的。什么是解释执行呢?解释器每次读一行代码,就将字节码转换成JVM可执行的指令,即”边读边译”。结果显而易见,效率低下,尤其是同样的代码每次都需要重新翻译。
为了解决这个问题,大部分主流JVM都包含了即时编译。什么是即时编译呢?就是将源代码(或字节码)直接编译成符合本地物理机可执行的机器语言。即时编译器的好处在于对代码进行深度优化,同时提高效率(只编译一次,之后直接执行机器码)

  • 相同点
    • JIT和解释器都是将字节码”解释“成本地物理机可执行的机器码,执行得到结果
  • 不同点
    • 虽然JIT和解释器并行处在同个流程位置,看似是一种”解释器”。但JIT本指并不是一个真的解释器,JIT编译器自身并不混合解释和编译,它就是编译器。将字节码编译成本地物理机可执行的机器码。
    • 编译后得代码是字节码体量是数十倍,但之后不需要像解释器一样重复解释,即空间换时间。

参考:
[1] Java三种编译方式[OL].https://blog.csdn.net/wxw520zdh/article/details/59482134

编译流程

源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 语义分析 -> 标注了属性的抽象语法树
- 代码生成 -> 目标代码

执行流程

目标代码
- 操作系统/硬件-> 执行结果

内存模型(hotspot)

image.png
程序计数器、堆、栈、本地方法栈、方法区;
1.7之后,方法区的实现从永久代变成了元空间,即元数据空间取代了永久代;元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存
1.7后,字符串常量池从永久代中剥离出来,存放在堆中。
元数据区取代了1.7版本及以前的永久代。元数据区和永久代本质上都是方法区的实现。方法区存放虚拟机加载的类信息,静态变量,常量等数据.

方法区

  • 小于jdk1.7
    • 方法区实现为永久代,在堆上;
    • 存储被虚拟机加载的类信息、常量、静态变量、即时编译后的代码缓存
  • jdk1.7
    • 将静态变量、字符串常量池移动到了堆上(实现本质还是在堆上)
  • jdk1.8

    • 废弃了永久代,改用元空间,将jdk1.7剩余在永久代上的东西移动到元空间(类信息)

      常量池(JDK1.8)

      Java中的常量池分为三种类型:
  • 类文件中常量池(The Constant Pool)——元空间

  • 运行时常量池(The Run-Time Constant Pool)——元空间
  • String常量池 —— 堆

类常量池、运行时常量池、字符串常量池之间关系

类常量池、运行时常量池都存储在方法区,字符串常量池1.7之后迁移到堆上;
在编译过程中,会把类元信息放到方法区,类元信息中有一部分就是类常量池,主要存放字面量和符号引用,字面量的一部分是文本字符,在类加载时候,会将字面量和符号引用解析为直接引用存储在运行时常量池;
对于文本字符的字面量,会在解析时找字符串常量池,查这个文本字符串对象对应的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的引用,而不是字符串本身;

运行时常量池和字符串常量池:

  1. 《深入理解Java虚拟机》中作者认为字符串常量池是运行时常量池逻辑的一部分
  2. 字符串常量池是全局唯一的,而运行时常量池是每个类一个

运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中。每个class都有一个运行时常量池,类在解析之后将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

字面量与符号引用

常量池中存的主要就是字面量和符号引用。
字面量,包括被final修饰的常量(基本类型)、文本字符串。
符号引用是因为在编译过程中不知道类的地址,因为类可能没有被加载过,只能用全路径类名作为符号引用,在类加载完之后,再替换成直接引用(实际内存地址)

字符串常量池详解

image.png
字符串常量池是个StringTable,里面存储的是驻留字符串的引用(不是实例本身)。也就是说某些普通的字符串实例被这个 StringTable 引用之后就等同被赋予了“驻留字符串”的身份。
这个StringTable 在每个 HotSpot VM 的实例里只有一份,被所有的类共享。
类的运行时常量池里的 CONSTANT_String 类型的常量,经过解析(resolve)之后,同样存的是字符串的引用;解析的过程会去查询 StringTable,以保证运行时常量池所引用的字符串与 StringTable 所引用的是一致的。

参考:
[1] 从字符串到常量池,一文看懂String类[OL].https://mp.weixin.qq.com/s?src=11&timestamp=1632796113&ver=3341&signature=xtEx7OaYnj5TGKERCsKOPK99Gm9qbhX9l3vU7AwSE9gi2CCSfWBtfagiUMq2kyjMIz2xBc6MOsrARiu21ORrML8G4BMnoOtK0c5iJjgK8olx88Jk3I5pohximj2wmh&new=1

类加载过程

类加载过程有三步,加载、连接、初始化,而连接可分为验证、准备、解析三个细化过程。

  1. 加载:JVM(Java虚拟机)通过类加载器把我们源代码经过编辑器(javac)编译生成的class文件,加载到方法区内存之中的过程。
  2. 验证:验证JVM所加载的类的二进制流是否符合JVM的要求,以及会不会对JVM产生安全影响。
  3. 准备:准备阶段主要是为类变量分配内存和赋初始值(初始化“零值”),这个初始值并不是真实值,而是数据类型的默认值,比如int类型的类变量,会在准备阶段赋值为0,String会赋值为null等,当然了,如果类变量有constantValues属性,也就是被final修饰的话,就会被赋真实值。
  4. 解析:解析阶段是虚拟机将常量池中的符号引用转换成堆内存中的直接引用。符号引用是一组特定的符号来描述所引用的对象,就像是全限定名一样,可以唯一定位到指定的类。直接引用是可以直接指向目标对象的指针、偏移量或者是可以间接指向目标的句柄,如果有了直接引用,那么引用的目标必定存在堆内存中。解析的主要是类和接口、字段、类方法、接口方法。
  5. 初始化:什么时候初始化?大概有五种情况会触发初始化,第一,虚拟机收到特定指令时,new()、getstatic、putstatic、invokestatic时;第二,通过反射调用类时,执行了invoke方法;第三、动态语言,比如使用动态代理生成类时;第四、虚拟机加载一个类时,发现其父类还未加载,则先加载其父类;第五,当虚拟机启动时,用户需要指定一个执行主类,虚拟机会先初始化这个主类。

    类加载器

    JVM预定义的三种类加载器分别是启动类加载器(bootstrap classloader)、扩展类加载器(extension classloader)、应用类加载(application classloader)。
    自定义 classloader >> application classloader >> extension classloader >> bootstrap classloader
    Bootstrap Class Loader加载JAVA_HOME/lib目录、被-Xbootclasspath指定的路径的类库;
    Extension Class Loader加载JAVA_HOME/lib、ext目录、被java.ext.dirs系统变量所指定的路径中所有的类库。
    Application Class Loader加载用户路径(ClassPath)上的所有类库;

    双亲委派模型

    某个类加载器收到加载一个类的请求时,首先会把这个类加载任务委托给父类,父类继续向上委托,直到bootstrap classloader为止,如果父类在指定的加载路径下可以找到所要加载的类,父类会加载这个类,并给子类返回加载的结果,子类就不会继续加载,如果父类在指定的加载路径下找到要加载的类,子类就会加载。
    好处:具备了优先级的类加载过程,可以防止出现多个java.lang.Object等,保证系统稳定运行及安全。

类对象

对象创建过程

1、遇到new指令,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、连接和初始化过。 如果没有,那必须先执行相应的类加载过程。
2、在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定。
为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。有两种方式(跟垃圾收集器使用的垃圾收集算法息息相关)
指针碰撞: 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
空闲列表: 如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表
分配内存遇到的并发问题:CAS 和TLAB(线程私有缓冲区)预分配
3、内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为 “零值”。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
4、设置对象头,初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5、执行构造方法(),这里构造方法会入操作数栈,经过构造方法的操作后,再出栈
6、把堆内存生成的地址返回到接受者

参考:
[1] 对象创建过程 [OL].https://www.jianshu.com/p/51dccdea9b63 作者:得力小泡泡

JVM运行原理_杂记 - 图6
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

对象内存模型

JVM运行原理_杂记 - 图7

对象头

markword

image.png
第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。

klass

对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.

数组长度

如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度

实际数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

对其补充

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象访问方式

句柄访问方式


JVM运行原理_杂记 - 图9

直接指针访问方式(HotSpot选择方式)
JVM运行原理_杂记 - 图10

垃圾收集

记忆集(Remembered Set)

image.png
记忆集为了解决跨代引用带来非收集器区全部扫描的问题,例如防止扫描整个老年代(如上图情况所示)。在GC ROOT枚举范围内加入Rset,保证不进行全局扫描。
G1的话是每个region都维护一个Rset,存储着其他region引用该region内对象的引用地址所在的区域。
记忆集根据存储的精度不同,有不同的实现,最常见的是卡表(card table),他的精度仅是记录一块内存区域,该区域有对象存在跨代指针引用;

写屏障

  1. 对“引用类型字段赋值”的一个切面,此处用于维护卡表(何时变脏,谁弄的)

注意区分内存屏障,内存屏障是保证并发有序性的

并发可达性分析

三色标记

  • 黑色表示对象已经被垃圾收集器访问过(可达性分析访问过),且该对象的所有引用都已经扫描过了
  • 灰色表示对象已经被垃圾收集器访问过(可达性分析访问过),但至少存在一个引用没有被扫描,表示这个对象正在被枚举
  • 白色表示对象没有被垃圾收集器扫描过(可达性分析访问过),如果枚举完仍是白色则被标记为垃圾

    增量更新和原始快照【2】

    扫描和修改引用的并发过程中会存在“对象消失”的问题。CMS使用增量更新,G1使用原始快照,都是基于写屏障实现的。对象消失原理,原先是B.f = F,在枚举过程中先扫描完A,B此时断开与F的引用关系B.f = null,然后A和F建立引用A.f = F,那么F就会被认为是垃圾(白色)。

增量更新:在并发标记过程中,新增了引用如A.f = F,记录下该关系后,在重新标记(CMS第三阶段)会再从A对象开始枚举,保证A引用F。
原始快照:在并发标记过程中,记录被置空的对象引用B.f=null中的F,在最终标记(G1第三阶段)直接置为黑色,默认为非垃圾

垃圾收集算法

  • 复制算法
  • 标记整理
  • 标记清除
  • 分代收集
    • 将堆分为新生代和老年代,新生代用复制老年代用【标记-清除/标记-整理】算法。

      垃圾收集器

      JVM运行原理_杂记 - 图12

image.png

  1. 我们平时提及Minor GCYoung GCMajor GC,它们之间的关系是怎样的呢?<br /> 如下图1所示,一图胜千言,这是JDK8之前的,**JDK8上没有最右边的Perm区**。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/2109626/1615379428685-83a3e1b0-2f8f-4719-93e5-fd9386890de8.png#height=331&id=Mx8uW&margin=%5Bobject%20Object%5D&name=image.png&originHeight=662&originWidth=1692&originalType=binary&ratio=1&size=287841&status=done&style=none&width=846)<br />_参考:_<br />_[1] _[GC之Minor/Young/Major GC的区别](https://www.cnblogs.com/tiancai/p/12630585.html)_ [OL]._

在JDK1.7中, 已经把原本放在永久代的字符串常量池移出, 放在堆中. 为什么这样做呢? 因为使用永久代来实现方法区不是个好主意, 很容易遇到内存溢出的问题. 我们通常使用PermSize和MaxPermSize设置永久代的大小, 这个大小就决定了永久代的上限, 但是我们不是总是知道应该设置为多大的, 如果使用默认值容易遇到OOM错误. 类的元数据, 字符串池, 类的静态变量将会从永久代移除, 放入Java heap或者native memory. 其中建议JVM的实现中将类的元数据放入 native memory, 将字符串池和类的静态变量放入java堆中. 这样可以加载多少类的元数据就不在由MaxPermSize控制, 而由系统的实际可用空间来控制. 为什么这么做呢? 减少OOM只是表因, 更深层的原因还是要合并HotSpot和JRockit的代码, JRockit从来没有一个叫永久代的东西, 但是运行良好, 也不需要开发运维人员设置这么一个永久代的大小. 当然不用担心运行性能问题了, 在覆盖到的测试中, 程序启动和运行速度降低不超过1%, 但是这一点性能损失换来了更大的安全保障.

总结原因: (1)字符串存在永久代中,容易出现性能问题和内存溢出 (2)永久代大小不容易确定. PermSize指定了大小容易造成OOM(内存用完) (3)给 GC(垃圾回收机制) 带来不必要的复杂度,且回收效率低

JVM运行原理_杂记 - 图14

CMS

CMS不符合模块职责分离的设计原则,内存管理、执行、编译、监控等存在耦合关系。

过程

  1. 初始标记:stw,标记GC ROOT直接关联对象
  2. 并发标记:通过GC ROOT标记所有可达对象
  3. 重新标记:stw,对并发标记过程中产生的垃圾对象进行修正
  4. 并发清理:清除标记对象

优缺点

优点:

  • 支持并发收集、低停顿

缺点:

  • CPU敏感,并发标记过程中与用户线程竞争cpu资源,当资源不足时候,会出现卡顿;
  • 无法处理浮动垃圾,并发清理过程产生新的垃圾无法在此次过程中回收
  • 清理后产生大量碎片

G1

JVM服务端模式下,JDK9默认为G1,取代了原来的ps+po,另外CMS已经被申明不推荐使用。
G1基于region的堆内存模型,面向堆内任何内存组成一个回收集(Collection Set,Cset)进行回收,衡量标准不再是属于哪个分代,而是哪个块垃圾数量最多,回收效益最高,这就是G1的Mixed GC模式。
G1的region根据需要扮演新生代的Eden、Survicor、老年代空间,根据扮演角色不同采用不同策略处理。
Region中有个特殊类型,Humongous区域,专门用来存储大对象。只要对象大小超过Region大小(-XX:G1HeapRegionSize)的一半就算是大对象,G1一般把Humongous Region作为老年代的一部分来看待。
G1是一个可预测时间停顿的(-XX:MaxGCPauseMillis,默认200ms),G1跟踪各个region里面垃圾堆的价值大小,按回收价值(回收大小和时间)优先级处理,在时间停顿范围内最大程度提高回收效率

YGC

和ParallelGC一样,当Eden满了之后,触发YGC,存活的对象移动到1个或多个Region(S区)或晋升到Old区角色的Region。YGC也是一个stw过程,如果没有设置-XX:mxn,根据目标停顿时间等因素,自动动态调整新生代大小。

Mixed GC

Mixed GC是完全STW的,它是G1一种非常重要的回收方式,它根据用户设置的停顿时间目标,可以选择回收所有年轻代,以及部分老年代Region集合。回收后存活的对象根据分代角色,分别压缩到不同region内
**

  • Global Concurrent Marking(全局并发标记):为Mixed GC选取多少个老年代region服务的
  1. 初始标记:stw,标记GC ROOT直接关联对象
  2. 并发标记:通过GC ROOT标记所有可达对象
  3. 最终标记:stw,修正并发标记过程中对象引用改变
  4. 清理阶段:回收完全空闲的Region,计算每个region的内存货对象的统计信息;
  • 2.Evacuation Pauses:该阶段是负责把一部分Region里的活对象拷贝到空Region里面去,然后回收原本的Region空间。

优缺点

优点:

  • 可预测时间停顿的
  • 按收益动态确定回收集(CSet)
  • 整体基于标记-整理

缺点:

  • 额外负载相比CMS较高,维护的卡表CMS的更复杂、以及维护的STAB比增量更新复杂

Mixed GC、Full GC

Mixed GC过程类似于CMS的,主要分为初始标记、并发标记、最终标记、清理。
如果对象分配过快,mixed GC来不及回收,导致老年代被填满,就会触发Full GC,G1的Full GC算法是单线程的serial old gc,会导致长时间暂停,所以尽量避免。

另外,Full GC的对象是整个堆,Mixed GC是young区和部分old区。

垃圾收集日志【1】

Minor GC

  1. [GC (Allocation Failure)
  2. [PSYoungGen: 8525K->352K(9216K)] 98695K->98486K(130048K), 0.0092873 secs]
  3. [Times: user=0.00 sys=0.00, real=0.01 secs]

Allocation Failure表示此次分配失败了
PSYoungGen表示此次GC的是新生代
8525K->352K(9216K)表示回收前新生代实际占用8525K,回收后占用352K,整个新生代大小9216K
98695K->98486K(130048K)表示实际使用堆大小98695K,回收后实际使用堆大小98486K,整个堆大小130048K
user=0.00表示用户耗时(用户态消耗的CPU时间)0.00s
sys=0.00表示系统耗时(内核态消耗的CPU时间)0.00s
real=0.01表示实际耗时0.01s(多线程下,前两个是叠加时间,所以是可能real小于user、sys的)

Full GC

  1. [Full GC (Ergonomics)
  2. [PSYoungGen: 8051K->7817K(9216K)]
  3. [ParOldGen: 244969K->244969K(245760K)] 253020K->252786K(254976K),
  4. [Metaspace: 29386K->29386K(1077248K)], 0.0525381 secs]
  5. [Times: user=0.13 sys=0.00, real=0.05 secs]

PSYoungGen: 8051K->7817K(9216K):新生代GC前实际占用8051K,GC后占用7817K,总大小9216K
ParOldGen: 244969K->244969K(245760K):老年代GC前实际占用244969K,GC后占用244969K,总大小245760K
253020K->252786K(254976K):堆GC前实际占用253020K,GC后占用252786K,总大小254976K
Metaspace: 29386K->29386K(1077248K):元空间GC前占用29386K,GC后占用29386K,总大小1077248K

参考

【1】不可思议,竟然还有人不会查看GC垃圾回收日志?:https://mp.weixin.qq.com/s?src=11&timestamp=1632971071&ver=3345&signature=I8jpfSz3KBQfPvnlXF1MFqxGAeuw0ardIf2f0wOwZf-6ty55ZDMI77v52tEvT738n1Uw93etueyZPo6kq35GVEobq-d2ZQXgns2JPv3RSx87CYmsDynwFKQG7LXZbc&new=1
【2】JVM垃圾收集之三色标记算法详解:https://mp.weixin.qq.com/s?src=11&timestamp=1633065656&ver=3347&signature=Dg4fajUBbNCrzp5oLRG88mK1ECP-qg3L-TlcOjeM9mL156sUZsVQ9tAfqAoh5oZh6mQTOYnPrQLbE28zfsdKM2WvkKm8vVg9x643Zd5bpgYWAZota7A8y5ZHNitRRlHj&new=1