凌月风的个人博客

记录精彩人生

Open Source, Open Mind,
Open Sight, Open Future!
  menu

Java笔记系列——01-Java虚拟机(2)

0 浏览

运行时数据区

  • 运行时数据区(Run-Time Data Areas),官方文档:Chapter 2. The Structure of the Java Virtual Machine (oracle.com)
    image-20220426150045737

  • Method Area(方法区)

    • 方法区是各个线程共享的内存区域,在虚拟机启动时创建,存储类的元数据信息(模板信息),即类的构造信息,运行时常量池、字段、方法信息等

      当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

    • 方法区是JVM的规范,具体的实现在不同版本虚拟机中的实现是不同的,

      • jdk1.7中方法区为Perm space(永久代)
      • jdk1.8之后方法区为Meta space(原空间)
    • 内存指向,方法区引用指向堆的情况:private static Object obj = new Object();
      image-20220426150616732

    • String 常量池

      • 在jdk1.7之前常量池在方法区,因为String回收创建是比较频繁而方法区很少发生垃圾回收,而堆中的垃圾回收比较频繁所以1.8之后存在于堆中。
      • str.intern()先检查str的值在是否在常量池中,如果不在常量池中则创建新的,否则就返回。
        image-20220426145833614

  • Heap(堆)

    • 堆存储的是类的对象信息以及数组,是线程共享区域。堆内存空间不足时,抛出OutOfMemoryError异常

    • Java对象内存分布

      • Java对象子内存中包含三个部分:对象头、实例数据、对齐填充
        image-20220426150807754
      • 对象头中的Class Pointer 中 存储了对象对应的类的元数据内存地址,堆指向方法区如图
        image-20220426152248335
      • 对齐填充
        • CPU和内存交互的最小单位是缓存行,64个字节大小。
        • CPU会每次加载64字节大小的数据到缓存中,缓存行会存在一个伪共享问题
        • 即一个CPU核心在修改缓存行数据时,如果另一个CPU也要修改缓存行内容,就会产生缓存行竞争导致缓存失效,会重新加载缓存。可以使用对齐填充去解决问题。
        • 假如X\Y\Z三块数据构成了一个缓存行,当Core 0想更改X,而Core 1修改Y就会产生竞争,因此,可以使用对齐填充,将X填充到64字节,这样牺牲空间,换取了一定的运行时间。
        • jdk8中可以使用注解@Contended,配合JVM命令-XX:RestrictContended,来使类或者变量完成对齐填充。
          image-20220607123012355
    • 创建一个对象占用的储空间,可以通过内存布局对象进行计算

      • 例如一个简单的对象new User()有4个成员变量:byte、int、long、reference
      • 对象头 :8字节 + 8字节 = 16字节
      • 实例数据 :1字节 + 4字节 + 8字节 + 8字节 = 21字节
      • 对齐填充 (方便CPU指令读取):16字节 + 21字节 = 37字节 填充3个字节后变为40字节
      • 一个几十个字节的对象已经算是一个大对象了
    • 内存模型:对象模型示意(survivor区域的划分是为了解决磁盘空间的碎片问题)
      image-20220512104217732

    • 对象创建过程(新对象总是在Eden区,超过Eden区大小的放到Old区)

      1. 查看Eden是否有足够空间,如果有直接创建,如果没有则触发Minor GC(Yong GC)

      2. 触发Yong GC后会回收Eden、Survivor区对象,然后将剩余存活对象复制到Survivor区的保留区

      3. 如果保留区没有足够空间存储存活对象,则通过担保机制将存活对象放到Old区

      4. 将新对象创建到Eden区,如果放不下,直接放到Old 区

        image-20220512104430863

  • Java Virtual Machine Stacks(JAVA虚拟机栈)

    • 虚拟机栈是线程私有的,随着线程的创建而创建。虚拟机栈中存储的是Frames,即栈帧。

    • 方法调用过程

      • main()方法先执行,但是最后结束。满足FILO(先进后出),对应的数据结构为栈。

        main(){
            a();
            System.out.println("finish main");  
        }
        void a() {
            b();
            System.out.println("finish a");
        }
        void b() {
            c() ;
            System.out.println("finish b");
        }
        void  c() {
            System.out.println("finish c");
        }
        
      • 对应的压栈出栈示意:
        image-20220426161620784

    • Frames(栈帧)

      • 每一个被线程执行的方法,都会创建一个栈帧。调用一个方法,就会向栈中压入一个 栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。当压栈过程中,栈的深度不够时则会抛出异常StackOverflowError,如果创建新的线程创建虚拟机栈 时空间不足 会抛出异常OutOfMemoryError

      • 栈帧结构

        • Local Variables(局部变量表):存储局部变量

        • Operand Stacks(操作数栈):进行算术运算的一个临时保存的地方

        • Dynamic Linking(动态链接):将符号引用转为直接引用

        • Method Invocation Completion(方法返回):分为Normal Method Invocation Completion(正常返回)、Abrupt Method Invocation Completion(异常返回)作用是为了让后续方法执行

          • 方法 a() 执行完之后会继续执行方法c()
          main(){
          	a();
          	c();
          }
          
      • 演示

        public static Integer calc(Integer op1, Integer op2) {
         op1 = 3;
         Object obj = new Object();
         return op1 + op2;
        }
        public static void main(String[] args) {
         calc(3,5);
        }
        
        • calc()方法的执行时,此时在java虚拟机中会有一个该方法对应的栈帧
        • 那么栈帧应该包含方法的所有内容,比如参数、变量、返回值等等。
          image-20220426173143118
      • 演示的例子在反汇编之后代码如下。其中的局部变量的下标是从0开始还是1开始,要看当前方法是不是static修饰,如果是static修饰则局部变量从0开始,否则从1开始。

        public static java.lang.Integer calc(java.lang.Integer, java.lang.Integer); 
        descriptor: (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; 
        flags: ACC_PUBLIC, ACC_STATIC 
        Code: 
        stack=2, locals=3, args_size=2 
        0: iconst_3 // 将int类型常量3压入[操作数栈] 
        1: invokestatic #11 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        4: astore_0 // 将int类型值存入[局部变量0] 
        5: aload_0 // 从[局部变量0]中装载int类型值入栈
        6: invokevirtual #12 // Method java/lang/Integer.intValue:()I
        9: aload_1 // 从[局部变量1]中装载int类型值入栈
        10: invokevirtual #12 // Method java/lang/Integer.intValue:()I
        13: iadd // 将栈顶元素弹出栈,执行int类型的加法,结果入栈
        14: invokestatic #11 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        17: astore_2 // 将栈顶int类型值保存到[局部变量2]中
        18: aload_2 // 从[局部变量2]中装载int类型值入栈
        19: areturn // 从方法中返回int类型的数据
        LineNumberTable: 19 
        line 11: 0 20 
        line 12: 5 21 
        line 13: 18
        
    • 栈指向堆image-20220426173734420


  • The pc Register(程序计数器)

    • 每一个线程都有各自的程序计数器。线程的运行是需要抢夺CPU资源的,当线程让出CPU资源时,会在程序计数器记录当前执行的方法的地址。
    • 如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的 是Native方法,则这个计数器为空。

  • Native Method Stacks(本地方法栈)
    • 类比于JAVA虚拟机栈。Java中有些方法是没有实现的,例如hashcode()方法,这种方法就会压入到本地方法栈中执行

执行引擎

  • 执行引擎(Execution Engine) 属于 JVM 的下层,里面包括解释器、及时编译器、垃圾回收器

  • JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。

    User.java源码文件是Java这门高级开发语言,对程序员友好,方便我们开发。

    Javac编译器将User.java源码文件编译成class文件,交给JVM运行,因为JVM只能认 识class字节码文件

  • 如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者

    最终JVM需要把class字节码指令转换为机器码,可以理解为是010101这样的机器语言,这样才能运行在不同的机器上。

  • 执行由字节码转变为机器码 ,这就是执行引擎里面的解释执行器 和编译器所要做的事情。

    image-20220512151916807

  • 编译器
    • JIT(Just-In-Time ):即时编译器。即时编译器先将字节码编译成对应平台的可执行文件,运行速度快。即时编译器会把这些热点代码编译成与本地平台关联的机器码,并且进行各层次的优化,保存到内存中。
    • HotSpot虚拟机里面内置了两个JIT:C1和C2
      • C1也称为Client Compiler,适用于执行时间短或者对启动性能有要求的程序
      • C2也称为Server Compiler,适用于执行时间长或者对峰值性能有要求的程序
      • Java7开始,HotSpot会使用分层编译的方式。也就是会结合C1的启动性能优势和C2的峰值性能优势,热点方法会先被C1编译,然后热点方法中的热点会被C2再次编译
    • 在Java9中,引入了AOT(Ahead of Time)编译器
      • 即时编译器是在程序运行过程中,将字节码翻译成机器码。而AOT是在程序运行之前,将字节码转换为机器码
      • 优势 :这样不需要在运行过程中消耗计算机资源来进行即时编译
      • 劣势 :AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序profifile的投机性优化(并非 硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序profifile来绕开这两个限制)
    • GraalVM
      • 官网:https://www.graalvm.org/
      • 在Java10中,新的JIT编译器Graal被引入。它是一个以Java为主要编程语言,面向字节码的编译器。跟C++实现的C1和C2相比,模块化更加明显,也更加容易维护。
      • Graal既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现AOT编译。 除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
      • Spring Native 和 Dubbo Native使用到了GraalVM可以将项目启动时间缩短到毫秒级别,以及占用更少的内存。

  • 解释器(Interpreter)
    • 解释器逐条把字节码翻译成机器码并执行,跨平台的保证。刚开始执行引擎只采用了解释执行。如果后来发现某些方法或者代码块被调用执行的特别频繁时,就会把这些代码认定为 热点代码,采用编译执行。

垃圾回收

  • 想要回收垃圾对象首先要找到哪些对象是垃圾对象,常用的确定垃圾对象的方法:
    • 引用计数法
      • 对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾。
      • 如果一个对象没有任何指针对其引用,它就是垃圾。
      • 可能会存在两个对象相互引用,但这两个对象都是垃圾对象的情况。
      • 因此JVM垃圾回收不推荐使用引用计数法确定垃圾对象。
    • 可达性分析:由GC Root 直接或间接可达的对象就不是垃圾对象,可以作为GC root的存在:
      • 类加载器(类对象指向的变量肯定是有用的,所以可以作为GC Root)
      • 虚拟机栈的本地变量表(在栈中变量表指向的对象肯定是在线程中使用的,所以可以作为GC Root)
      • 方法区中的静态变量或者常量(保存在方法区中的内容,一般不会有垃圾回收。所以可以作为GC Root)
      • 本地方法栈的变量等

  • 垃圾回收算法

    • 标记-清除算法

      • 先标记出垃圾对象,然后清除垃圾对象

        image-20220512114524992
      • 特点

        • 标记和清除两个过程都比较耗时,效率不高
        • 会产生大量不连续的内存碎片
        • 空间碎片太多可能会导致需要分配较大对象时,无法找到足够的连续内存而提前触发另一次垃圾收集动作
    • 标记-整理算法

      • 先标记出垃圾对象,然后清除垃圾对象,清除完成之后对空间进行整理

        image-20220512114633221 image-20220512114710513 image-20220512114648508
      • 特点

        • 相较于标记清除算法 多了整理的步骤,更加耗时
        • 解决了碎片问题
    • 标记- 复制算法

      • 划出一部分区域作为保留区域。标记出垃圾对象,然后将垃圾对象清除,将存活对象复制到保留区域
        image-20220512115610370
        image-20220512115618301

      • 特点

        • 存在大量的复制操作,效率会降低
        • 存在保留区域,牺牲了一部分空间容量空间利用率降低
    • 算法的选择

      • Young区:复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高)
      • Old区:标记清除或标记整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)

  • 垃圾收集器

    • 垃圾收集器实际上是对于垃圾回收算法的实现。垃圾回收过程会停止业务代码线程即 stop the world ,这会造成客户端响应变慢。之所以停止业务代码是因为垃圾回收过程中对象使用地址可能会发生变化,需要一定时间留给业务代码分析变化。

    • Serial Garbage Collector

      • 使用 -XX:+UseSerialGC 命令切换使用Serial系列垃圾收集器
        image-20220512120543204

      • 特点

        • 单线程垃圾收集,适合单核CPU的场景(JAVA早期适用于嵌入式设备)
        • 适用于新生代(复制算法)和老年代(标记整理算法)
        • 存在stop the world(stw)
        • 串行
      • 使用场景:CUP核数较少,内存空间有限(100M左右)

    • Parallel Garbage Collector

      • 使用 -XX:+UseParallelGC 命令切换使用Parallel系列垃圾收集器
        image-20220512122122088

      • 特点

        • 多线程收集,提高吞吐量
        • 适用于新生代(复制算法)和老年代(标记整理算法)
        • 存在stop the world(stw) ,时间100ms+
      • 使用场景:适合科学计算、后台处理等若交互场景

    • Concurrent Mark Sweep(CMS) Collector

      • JAVA语言的发展更加偏向于web开发,对于stw 的时间要求肯定是越来越严格的。使用 -XX:+UseConcMarkSweepGC 命令切换使用CMS垃圾收集器image-20220512124727069

      • 特点

        • stw 时间比较短 500ms左右
      • 适用于老年代

        • 使用的是标记清除算法 会有空间碎片问题
        • 如果老年代使用了CMS GC ,新生代会搭配 Parallel GC 使用
        • 有4个阶段:初始标记(stw)、并发标记、重新标记(stw)、并发清理。在初始标记、重新标记阶段,垃圾回收线程和业务代码线程是共同执行,减少了停顿时间
      • 场景:适合web交互场景

    • Garbage-First(G1) Garbage Collector

      • 使用 -XX:+UseG1GC 命令切换使用G1垃圾收集器
        image-20220512124712638

      • 特点

        • 尽可能满足用户设置的停顿时间目标耗时200ms左右
        • 适用于新生代和老年代
        • 使用标记整理算法、内存划分(Region),解决了空间碎片问题,也可以设置停顿时间
      • 内存划分(Region)

        • 使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有 新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

        • G1之前的CMS、serial、parallel收集器物理内存是连续的,如图:
          image-20220512142638082

        • G1的内存划分不连续,JVM启动时会划分大约2000个Region,每个Region 的大小是1M - 32M不固定,如果对象太大,一个Region放不下(超过Region大小的50%),那么就会直接放到H中。如图:
          image-20220512142714815

        • 回收顺序:优先回收垃圾对象最多的Regin

          假设每个Region 中有10个对象。

          Region1 有1个存活对象,9个垃圾对象

          Region2 有2个存活对象,8个垃圾对象

          会先回收Region1

      • 因为G1独特的内存划分,以及回收顺序,所以在筛选回收阶段可以根据用户设置的参数回收部分Region

      • 场景:适合web交互场景

    • The Z Garbage Collector

      • 使用 -XX:+UseZGC 命令切换使用 Z 系列垃圾收集器
      • 特点
        • Java11引入的垃圾收集器 不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念了 会分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题
        • 可以达到10ms以内的停顿时间
        • 支持TB级别的内存
        • 堆内存变大后停顿时间还是在10ms以内
    • 收集器对比
      image-20220512144914036

    • 新生代与老年代搭配使用的垃圾收集器示意
      image-20220512125322995


  • 收集器分类

    • 串行:只有一个垃圾收集线程。 Serial系列

    • 并行: 多个垃圾收集线程执行,垃圾收集过程中业务代码线程不执行,更关注吞吐量 。Parallel系列

    • 并发: 垃圾收集线程和业务代码线程会共同执行,关注停顿时间。 CMS、G1、Z 系列

    • 吞吐量和停顿时间是评判垃圾回收性能的重要指标

      • 吞吐量: 明确的时间内能完成的最大工作量 。
        • 公式:运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
      • 停顿时间:应用程序给客户端的响应时间
  • 垃圾回收时机

    • GC(垃圾收集)是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。

      • 当Eden区或者S区不够用了: Young GC (Minor GC )
      • 老年代空间不够用了: Old GC 或(Major GC)
      • 方法区空间不够用了: Metaspace GC
    • 调用System.gc()方法通知JVM进行一次垃圾回收,但是什么时候回收由JVM决定。因为GC消耗的资源 比较大。所以不建议手动调用该方法

    • Full GC = Young GC + Old GC + Metaspace GC

    • Old GC发生之前默认会执行Young GC(可以通过参数设置是否启用)

image/svg+xml