JVM(码出高效笔记整理)

  涂世广

字节码

  1. 中间码(也叫字节码 Bytecode)实现一次编译到处运行。
  2. Java 指令有200多个,一个字节(8位)可以存储256种不同的指令,一个这样的字节就是字节码。
  3. 代码执行过程中,JVM 将字节码解释执行,屏蔽对底层操作系统的依赖,也可以将字节码编译执行,如果是热点代码,会通过 JIT 动态地编译为机器码,提高执行效率。
  4. JVM 在字节码上设计了操作码助记符,用特殊单词标记数字,如 POP 代表 0101011,即 0x57。

8772451970874ad6b26468c8401dd508-image.png
词法解析:通过空格分割出单词、操作符、控制符等,将其形成 token 信息流传递给语法解析器。
语法解析:将 token 流按照 Java 语法规则组装成一棵语法树。
语义分析:检查关键字使用是否合理,类型是否匹配,作用域是否正确

c3b040f6ac6e4ea7ad6d7da94e610459-image.png
字节码必须通过类加载过程加载到 JVM 环境后才可以执行。执行有三种模式:
1. 解释执行
2. JIT 编译执行
3. JIT 编译与解释混合执行(主流 JVM 默认执行模式)
混合执行模式优势:解释器在启动时先解释执行,省去编译时间。随着时间推进,JVM 通过热点代码统计分析,识别高频的方法调用、循环体、公共模块等,将热点代码通过 JIT 动态编译转换成机器码,直接交给 CPU 执行。 JIT 的作用是将 Java 字节码动态地编译成可以直接发送给处理器指令执行的机器码。

类加载过程

Java类加载过程

52387cc6f43e49a5b260eb5f11b32be1-image.png
Load、Link 和 Init, 即加载、链接、初始化。

  1. Load:读取类文件产生二进制流,并转化为特定的数据结构,初步校验 cafe babe 魔法数、常量池、文件长度、是否有父类,然后创建对应的 java.lang.Class 实例。

  2. Link:

    • 验证:更详细的校验,比如 final 是否合规、类型是否正确、静态变量是否合理等。
    • 准备:同时被final和static修饰,设置为属性所指定的值。static 修饰的变量只设定数据类型对应的默认值(如0、0L、null、false等)(实例变量会在对象实例化时随着对象一块分配在 Java 堆中)。
    • 解析:解析类和方法确保类与类之间的相互引用正确性,完成内存结构布局。
  3. Init:static 修饰的变量设定属性所指定的值,执行类构造器方法对类进行初始化(对类变量进行初始化),如果赋值运算是通过其他类的静态方法来完成的,会马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值。

    JVM初始化类:
    * 假如这个类还没有被加载和连接,则程序先加载并连接该类
    * 假如该类的直接父类还没有被初始化,则先初始化其直接父类
    * 假如类中有初始化语句,则系统依次执行这些初始化语句

    只有当对类的主动使用的时候才会导致类的初始化,类的主动使用有:
    * 创建类的实例,也就是new的方式
    * 访问某个类或接口的静态变量,或者对该静态变量赋值
    * 调用类的静态方法
    * 反射(如Class.forName(“com.shengsiyuan.Test”)
    * 初始化某个类的子类,则其父类也会被初始化
    * Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

    类变量进行初始化:
    * 声明类变量是指定初始值
    * 使用静态代码块为类变量指定初始值

双亲委派模型

  1. 任何程序都要加载到内存中才能与 CPU 进行交流。字节码 class 文件同样需要加载到内存中,才可以实例化类。
  2. ClassLoader 使用 Parents Delegation Model (双亲委派模型)提前加载class类文件到内存中。
  3. 类加载载是—个将class字节码文件实例化成Class对象并进行相关初始化的过程。在这个过程中,JVM会初始化继承树上还没有被初始化过的所有父类,并且会执行这个链路上所有未执行过的静态代码块、静态变量赋值语句等。某些类在使用时,也可以按需由类加载器进行加载。
  4. 类加载器
     第—层:Bootstrap,它是在 JVM 启动时创建,通常由与操作系统相关的本地代码实现。加载存放在 JDK\jre\lib 下的包,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的 java. 开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器是无法被Java程序直接引用的。
     第二层:Platform ClassLoader(JDK9 版本)或者是 Extension ClassLoader(JDK9之前),平台类加载器,用以加载一些扩展的系统类,比如XML、加密、压缩相关的功能类等;该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如javax.开头的类。
     第三层:Application ClassLoader,应用类加载器,该类加载器由 sun.misc.Launcher$AppClassLoader 来实现。主要是加载用户定义的 CLASSPATH 路径下的类。
    第二、三层类加载器为 Java 语言实现,用户也可以自定义类加载器。
    bdbc8e8532c3485e9c84ff4a3a2bcda7-image.png
      低层次的当前类加载器,不能覆盖更高层次类加载器已经加载的类。低层次的类加载器想加载—个未知类,要向上逐级询问这个类是否已经加载。当所有高层次类加载器都确认自己没有加载过此类并且不可以加载此类,才可以让当前类加载器加载这个未知类。左侧绿色箭头向上逐级询问是否已加载此类,直至Bootstrap ClassLoader,然后向下逐级尝试是否能够加载此类,如果都加载不了,则通知发起加载请求的当前类加载器,准予加载。

内存布局

823eab49d1a74be8896a561e611715af-image.png
JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。

Heap(堆区)

  1. Heap是 OOM 故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间既可以固定大小,也可以在运行时动态地调整,通过设置 -Xms256M -Xmx1024M,其中 -X 表示它是 JVM 运行参数,ms 是 memory start 的简称,mx 是 memory max 的简称,分别代表最小堆容量和最大堆容量。在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力, 所以在线上生产环境中,JVM 的 Xms 和 Xmx 设置成—样大小,避免在 GC 后调整堆大小时带来的额外压力。

  2. 堆分成新生代和老年代。对象产生之初在新生代,步入暮年时进入老年代,老年代也接纳在新生代无法容纳的超大对象。新生代 = 1个 Eden 区 + 2个 Survivor 区(默认情况按照 Eden:S0:S1 = 8:1:1 分配内存空间)。绝大部分对象在 Eden 区生成,当 Eden 区装填满的时候,会触发 YGC(Young Garbage Collection )。垃圾回收时,在 Eden 区实现清除策略,没有被引用的对象直接回收,依然存活的对象移送到 Survivor 区。Survivor 区分为 S0 和 S1 两块内存空间,每次 YGC,将存活的对象复制到未使用的那块空间,将当前正在使用的空间全部清除,交换两块空间的使用状态。如果YGC要移送的对象大于 Survivor 区容量的上限,直接已送给老年代。假如一些没有进取心的对象也不能一直在 Survivor 区来回交换,每个对象都有计数器,每次 YGC 都会加 1 。-XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阈值时,对象从新生代晋升至老年代。如果该参数配置为 1,那么从新生代的 Eden 区直接移至老年代。默认值是15,可以在 Survivor 区交换 14 次之后,普升至老年代。
    72de2674c72b4fd896c4ce5645faadf0-image.png
      如果 Survivor 区无法放下,或者超大对象的阈值超过上限,则尝试在老年代中进行分配,如果老年代也无法放下,则会触发 FGC(Full Garbage Collection)。如果依然无法放下,则抛出 OOM。堆内存出现 OOM 的概率是所有内存耗尽异常中最高的。出错时的堆内信息对解决问题非常有帮助,所以给 JVM 设置运行参数 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 遇到 OOM 异常时能输出堆内信息,特别是对相隔数月才出现的 OOM 异常尤为重要。

Metaspace(元空间)

  1. 元空间在本地内存中分配。在 JDK8 里,Perm 区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、 方法、常量等都移动至元空间内,比如 Object 类元信息、静态属性 System.out、整型常量 10000000 等。
  2. JDK8 中,元空间的前身 Perm 区已经被淘汰。 在 JDK7 及之前的版本中,只有 Hotspot 版本的 JVM 才有 Perm 区,译为永久代,它在启动时固定大小,很难进行调优,并且 FGC 时会移动类元信息。在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。 比如某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多的类,经常出现致命错误 "Exception in thread ’dubbo client x.x connector’java.lang.OutOfMemoryError: PennGenspace " 为了解决该问题,需要设定运行参数 -XX:MaxPermSize=l280m, 如果部署到新机器上,往往会因为 JVM 参数没有修改导致故障再现。永久代在垃圾回收过程中还存在诸多问题。 所以 JDK8 使用元空间替换永久代。在JDK8及以上版本中,设定 MaxPermSize 参数,JVM 在启动时并不会报错,但是会提示 Java HotSpot 64Bit Server VM warning: ,gnoring option MaxPem心ze=2560m; support was removed i11 8.0。

JVM Stack(虚拟机栈)

  1. JVM 是基于栈结构的运行环境。栈结构移植性更好,可控性更强。JVM 中的虚拟机栈是描述 Java 方法执行的内存区域,线程私有。栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而 StackOverflowError 表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。JVM 能够横扫千军,虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵。

  2. 虚拟机通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,会跳转到另一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。栈帧在整个JVM体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等。

  • 局部变量表
    存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,在 index[0] 位置上存储方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。

  • 操作栈
    一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于操作栈的执行引擎。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。

  • 动态链接
    每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。

  • 方法返回地址
    方法执行时有两种退出情况 第—,正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等,第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出的三种方式:

    • 返回值压入上层调用栈帧。
    • 异常信息抛给能够处理的栈帧。
    • PC 计数器指向方法调用后的下一条指令。
  • Native Method Stacks(本地方法栈)
    线程对象私有,虚拟机栈主内,本地方法栈主外。这个内外是针对 JVM 来说的,本地方法栈为 Native 方法服务。线程开始调用本地方法时,会进入—个不再受 JVM 约束的世界。本地方法可以通过 JNI (Java Native Interface) 来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对于内存不足的情况,本地方法栈还是会抛出 native heap OutOfMemory。最著名的本地方法是 System currentTimeMillis(), JNI 使 Java 深度使用操作系统的特性功能,复用非 Java 代码。但是在项目过程中,如果大量使用其他语言来实现 JNI,就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解耦,这样即使本地方法崩溃也不至千影响到NM的稳定。当然,如果要求极高的执行效率、偏底层的跨进程操作等,可以考虑设计为 JNI 调用方式。

  • Program Counter register(程序计数寄存器)
    Register 的命名源于 CPU 的寄存器,CPU 只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何—个确定的时刻,一个处理器或者多核处理器中的—个内核,只会执行某个线程中的—条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。从线程共享的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程内部私有的。
    9209a5edea694fd48b329f148af7fd24-image.png

垃圾回收

  1. GC Roots 判断对象是否存活。如果一个对象与 GC Roots 之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,判决这些对象死缓,是可以被回收的。可以作为 GC Roots 有静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法引用的对象等。

  2. 垃圾回收算法:

    • 标记-清除算法:该算法会从每个 GC Roots 出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。但是这种算法会带来大量的空间碎片,导致需要分配一个较大连续空间时容易触发 FGC 。
    • 标记-整理算法:类似计算机的磁盘整理,首先会从 GC Roots 出发标记存活的对象,然后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清理掉,这样就不会产生空间碎片的问题。
    • Mark-Copy 算法:为了能够并行地标记和整理将空间分为两块,每次只激活其中一块,垃圾回收时只需把存活的对象复制到另一块未激活空间上,将未激活空间标记为己激活,将己激活空间标记为未激活,然后清除原空间中的原对象。堆内存空间分为较大的 Eden 和两块较小 的 Survivor,每次只使用 Eden 和 Survivor 区的一块。这种情形下的“ Mark”Copy” 减少了内存空间的浪费。空间整合能力好。
  3. 垃圾回收器(Garbage Collector)种类:

    • Serial 回收器:应用于 YGC 的垃圾回收器,采用串行单线程的方式完成 GC 任务,会导致 STW (Stop The World),即垃圾回收的某个阶段会暂停整个应用程序的执行。FGC 的时间相对较长,频繁 FGC 会严重影响应用程序的性能。
      a48609b93efa4916a577c9d1557cee2b-image.png

    • CMS 回收器( Concurrent Mark Sweep Collector ) :回收停顿时间比较短、目前比较常用的垃圾回收器。它通过初始标记(Initial Mark )、并发标记(Concurrent Mark)、重新标记(Remark)、并发清除(Concurrent Sweep)四个步骤完成垃圾回收工作。初始标记和重新标记阶段依然会引发 STW,并发标记和并发清除两个阶段可以和应用程序并发执行,也是比较耗时的操作,但并不影响应用程序的正常执行。CMS 采用的是标记一清除算法,会产生大量的空间碎片。CMS 可以通过配置 -XX:+UseCMSCompactAtFullCollection 参数解决这种问题,强制 JVM 在 FGC 完成后对老年代进行压缩,执行一次空间碎片整理,但是空间碎片整理阶段也会引发 STW。为了减少 STW 次数,CMS 还可以通过配置一 XX:+CMSFullGCsBeforeCompaction=n 参数,在执行了 n 次 FGC 后,JVM 再在老年代执行空间碎片整理。

    • G1 ( Garbage-First Garbage Collector ) 垃圾回收器:通过去 -XX:+UseGIGC 参数启用。和 CMS 相比,GI 具备压缩功能 ,能避免碎片问题,GI 的暂停时间更加可控。性能总体还是非常不错的。
      be0ea33e2ec24945be3722dfe570337b-image.png

  GI 将 Java 堆空间分割成了若干相同大小的区域,即 region,包括 Eden、 Survivor、 Old、 Humongous 四种类型。其中,Humongous 是特殊的 Old 类型,专门放置大型对象。这样的划分方式意昧着不需要一个连续的内存空间管理对象。 GI 将空间分为多个区域,优先回收垃圾最多的区域。GI 采用的是 Mark-Copy ,空间整合力好,不会产生大量的空间碎片。GI 的一大优势在于可预测的停顿时间,能够尽可能快地在指定时间内完成垃圾回收任务。在 JDK11 中,已经将 GI 设为默认垃圾回收器。