拿饭网对JVM的解析和总结

  张一帆   2021年05月11日


  • Java虚拟机

  java虚拟机(java virtual machine,JVM),一种能够运行java字节码的虚拟机。作为一种编程语言的虚拟机啊,实际上不知是专用于java语言,只要生成的编译文件匹配JVM对加载编译文件格式要求,任何语言都可以由JVM编译运行。

  java源代码被编译成字节码-一个.class的文件。编译的时候class文件会被VM翻译。

一次编译,各地运行


  • JVM的基本结构

  JVM由三个主要的子系统构成

  • 类加载子系统
  • 运行时数据区(内存结构)
  • 执行引擎

  • JVM架构

  首先,先我们看一看架构图:

JVM架构

  我们,先说一下class loader subsystem。首先,loading一共有三个class loader类型:

  • loading
  1. Bootstrap Class Loader – 加载JDK内部类,比如rt.jar和类似java.lang.*这样的核心类。
  2. Extensions Class Loader – 从JDK的扩展目录加载类。
  3. System Class Loader – 从系统设定的classpath加载类,可以在程序启动时通过-cp或者-classpath指令进行设置。
  • linking

  其次,linking是链接一个类或接口时包括验证和准备这个类或接口的直接超类、直接超接口及必要时的元素类型。JVM需要满足以下点才能够连接:

  1. 类或接口在被链接之前会被完全加载。
  2. 在初始化类或接口之前,会对其进行完全的验证和准备。
  3. 不论因为直接还是间接的代码中包含的连接错误能够被侦察到。
  • Initialization

  类或接口的初始化包括执行它的类或接口初始化方法或调用类的构造函数。因为Java虚拟机是多线程的,所以类或接口的初始化需要小心地同步,因为其他一些线程可能试图同时初始化相同的类或接口。同时,这是Class Loading的最后一个阶段,这里所有静态变量都将被赋值为原始值,并且静态块将被执行。

  我们学习JVM最重要的部分就是运行时数据区。如图:

JVM运行时数据区域

1.程序计数器:每个线程都有独立的PC寄存器,以保存当前执行指令的地址,一旦指令被执行,PC寄存器将被下一条指令更新。

2.虚拟机栈:java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧压入栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。

(1)栈帧:栈帧存储方法的相关信息,包好局部变量表、发回执、操作数栈、动态链接。


a.局部变量表:包含了方法执过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。

b.返回值:如果有的话,压入调用者栈帧中的操作数栈中,并且把pc的值指向方法调用指令后面的一条指令地址。

c.操作数栈:操作变量的内存模型。操作数栈的最大深度在编译的时候已经确定。

d.动态链接:每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

(2)线程私有

3.本地方法栈:本地方法堆栈保存本地方法信息。对于每个线程,将创建一个单独的本机方法堆栈。

4.方法区:所有类级别的数据都将存储在这里,包括静态变量。每个JVM只有一个方法区域,它是一个共享资源。

5.堆:所有对象及其对应的实例变量和数组都将存储在这里。每个JVM也有一个堆区域。由于方法和堆区域为多个线程共享内存,存储的数据不是线程安全的。

(1)java堆是虚拟机管理的内存中最大的一块

(2)java堆是所有线程共享的区域

(3)在虚拟机启动时创建

(4)此内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。存放new生成的对象和数组。

(5)java堆是垃圾收集器管理的内存区域。


  • 不同变量存储的位置

  我们可以把内存理解成可以单独操作字节的字节数组。在内存中的每个字节或者位置,都像Java中的数组一样可以访问。在32位机中,每个内存槽最大支持32位,也就是一个字符或者4个字节。

内存模型2

  正如上图所示,4000,4004,4008等等数字代表每个内存槽的地址。这些数字代表32位。因为每个内存槽持有32位,或者说4个字节,每个内存槽的增量是4.

  java中有两种类型变量域-全局变量和本地变量。全局变量可以在程序中任何地方访问到,但是本地变量只能通过创建了它的方法才能访问到。这两种变量域分别存储在方法区和虚拟机栈中。

内存模型2

内存模型3

  • 虚拟机栈
  public void doSomething() {
   int v = 0;
   System.out.println(v);
}

  上面代码中的变量v是在方法中创建的,所以他保存在线程的虚拟机栈中。

  • 方法区
  public class Example {
   int globalVar = 3;
   public int showVar() {
       return globalVar;
   }
}


  上面的代码中,globalVar保存在方法区中。因为他可以被方法获取到,所以他是可以被整个系统获取到的。

  public class Person {
    int pid;
    String name;
    public Person(int id, String name) {
       this.pid = id;
       this.name = name;
    }
}

public class Driver {
    public static void main(String[] args) {
        int id = 1;
        String pName = "Rick";
        Person p = new Person(id, pName);
    }
}

  在上面的代码中我们可以看到,我们创建了Person的实例并储存在变量p中,本质上,我们使用堆区来动态分配内存,我们需要使用new关键字创建这样一个实例。换句话说,我们确定不了这个实例的确切大小。无论实例有多少字节,如果有足够的内存(可能会有),该实例将被创建,并且只保留创建它所需的字节量。

  java其实帮助我们解决了很多动态分配内存的棘手问题,相比C语言来说,C语言必须自己去清除内存。所以说,java的垃圾回收是幕后的,简单到只需要我们触发关键词就可以完成清除内存的目的。


  • 虚拟机栈

  首先,我们先看一段代码:

  
public class Demo {

    public int math() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.math();
    }
}

/*
java -c Demo.java后,会生成Demo.class
javap -c Demo,进行反编译,结果如下,让我们来分析一下这段程序就可以了解大概虚拟机栈的原理了。
*/

Compiled from "Demo.java"
public class Demo {
  public Demo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int math();
    Code:
       0: iconst_1//push
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class Demo
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method math:()I
      12: pop
      13: return
}



  经过反编译后,我们可以看到虚拟机的代码了。那么,分别是什么意思呢?我们可以参考手册来分析,分析的过程我在图片中给出了。

编译过程分析

  在图中的最后,我们看到ireturn语句,他的意思就是将结果返回。具体返回到那里呢,程序会去程序计数器中查找上一个栈帧的地址,结果就是返回给这个栈帧的。原理是,这段代码是首先调用的main函数,然后调用的math方法。所以,程序计数器会保存着这个关系,同样每段代码的顺序也会被程序计数器记录住。也就说,线程中程序运行到了哪里和从哪运行的都会在这个地方记录着。如果我们考虑多线程的情况,在一个线程运行过程中cpu的时间片到了,于是cpu把下一个时间片分给了新的线程。这个时候程序计数器也会记录住前一个线程运行到了哪里,新的线程从原先旧的线程中何时开始的,结束完成后还需要跳回旧线程中继续执行代码。

如果我们把程序改成这样:

  public class Demo {

    public int math() {
        int a = 1;
        int b = 2;
        Object obj = new Object(); //加入个实例
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.math();
    }
}


我们通过javap -v Demo来看看,可以看到输出的信息比上边的多了很多。

  Classfile Demo.class
  Last modified 2021-5-11; size 599 bytes
  MD5 checksum b0253c6a0b3c51e6d1a72e8cc76da634
  Compiled from "Demo.java"
public class Demo
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#28         // java/lang/Object."<init>":()V
   #2 = Class              #29            // java/lang/Object
   #3 = Class              #30            // Demo
   #4 = Methodref          #3.#28         // Demo."<init>":()V
   #5 = Methodref          #3.#31         // Demo.math:()I
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               LDemo;
  #13 = Utf8               math
  #14 = Utf8               ()I
  #15 = Utf8               a
  #16 = Utf8               I
  #17 = Utf8               b
  #18 = Utf8               obj
  #19 = Utf8               Ljava/lang/Object;
  #20 = Utf8               c
  #21 = Utf8               main
  #22 = Utf8               ([Ljava/lang/String;)V
  #23 = Utf8               args
  #24 = Utf8               [Ljava/lang/String;
  #25 = Utf8               demo
  #26 = Utf8               SourceFile
  #27 = Utf8               Demo.java
  #28 = NameAndType        #6:#7          // "<init>":()V
  #29 = Utf8               java/lang/Object
  #30 = Utf8               Demo
  #31 = NameAndType        #13:#14        // math:()I
{
  public Demo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LDemo;

  public int math();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=5, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: new           #2                  // class java/lang/Object
         7: dup
         8: invokespecial #1                  // Method java/lang/Object."<init>":()V
        11: astore_3
        12: iload_1
        13: iload_2
        14: iadd
        15: bipush        10
        17: imul
        18: istore        4
        20: iload         4
        22: ireturn
      LineNumberTable:
        line 9: 0
        line 10: 2
        line 11: 4
        line 12: 12
        line 13: 20
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   LDemo;
            2      21     1     a   I
            4      19     2     b   I
           12      11     3   obj   Ljava/lang/Object;
           20       3     4     c   I

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #3                  // class Demo
         3: dup
         4: invokespecial #4                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #5                  // Method math:()I 
        12: pop
        13: return
      LineNumberTable:
        line 17: 0
        line 18: 8
        line 19: 13
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      14     0  args   [Ljava/lang/String;
            8       6     1  demo   LDemo;
}
SourceFile: "Demo.java"


  因为上边的输出太多,本来我想写在代码旁边用注释的形式备注,但是因为顺序是跳跃的,所以用这种方法不太好解释。所以,我还是准备写在下边,因为这样的形式可以保证时间线清晰。

  首先,我们发现在代码编译期,就有了Constant pool常量池了。那常量池是什么呢?他是java文件被编译成.class文件时,存储这些class文件的资源库。其主要存放两大类常亮:字面量(文本字符串、声明为final的常量值等)和符号引用(有三类:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

  除了常量池以外,还有个运行时常量池。运行时常量池是方法区的一部分,class文件中的常量池在类加载后进入方法区的运行时常量池存放。他具有动态性,为什么这么说呢?因为class文件一旦编译后,class常量池就确定了。但是运行时常量池在运行期间有可能有新的常量存入池中。

  在main()中,我们看到9: invokevirtual #5这行,这代表我们调用了constant pool中的#5。#5代表这是一个方法引用,在虚拟机栈的栈帧的局部变量表中,就会生成一个ref并指向方法区中的Demo.math(),通过符号引用就实现了直接引用。其他的同理。


  • 本地方法栈

  本地方法栈和虚拟机方法栈看起来感觉很像是不是?没错,其实就是很像,只是本地方法栈是由本地系统控制的方法栈。作为java程序员你并不能看到这个栈实际发生了什么,因为系统属于底层,底层是由C写的。那java什么时候会触发系统级的指令呢?让我们看下thread的实现吧。

      //src.zip/java/lang/Thread
    public class Thread implements Runnable {
    
        …………
        
        private native void start0();
        
        public static native Thread currentThread();
        
        public static native void yield();
        
        …………
    }

  我们可以看到很多个native修饰的方法,java中会用这个关键词来修饰那些调用底层C方法的方法。本地方法栈就是为了这些方法而存在的。意思就是通过系统级方法来实现java想要实现的功能,所以本地方法栈被设计到了JVM中。


  • JMM Java内存模型

  Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。

  Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。

内存模型1

  计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。

  Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存中取出变量这样的底层细节。Java内存模型中规定了所有的比那两都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

  不同的线程之间是无法直接访问互相的内存中的变量的,线程间的通讯一般有两种方式:1.通过消息传递。2.通过内存共享。java线程间的铜线采用的是共享内存的方式,线程,主内存和工作内存的交互关系如下图所示:

内存模型2


  • 重排序和happens-before规则

  在执行程序时为了提高性能,编译器和处理器尝尝会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令集并行的重排序。现代处理器采用了指令集秉性技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲器,这使得加载和存储操作看上去可能是在乱序执行。

  从java源代码到最终实际执行的指令序列,会分别经理下面三种重排序:

源代码 -》 编译器优化重排序 -》 指令集重排序 -》 内存系统重排序 -》 最终执行的指令

  JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

  happens-before是从JDK5开始的,java内存模型提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。

  如果一个操作执行的结果需要对另一个操作可见,那么这两个操作数之间必须存在happes-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

  这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的

  如果A happens-before B,那么Java内存模型将向程序员保证-A操作的结果将对B可见,且A的执行顺序排在B之前。

  重要的happens-before规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意后续操作。
  2. 监视器规则:对一个监视器锁的解锁,happes-before于随后对这个监视器锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happes-before B,且B happens-beforeC,那么A happens-before C。

  下图是happes-before与JMM的关系:

JMM内存模型

  volatile可以说是JVM提供的最轻量级的同步机制,当一个变量定义为volatile之后,它将具备两种特性:

  1. 保证此变量对所有线程的可见性。而普通变量不能做到这一点,普通变量1的值在线程间传递均需要通过主内存来完成。volatile虽然保证了可见性,但是java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。而synchronized关键字则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得线程安全的。

  2. 禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果。而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。


  • 堆的内存划分及GC垃圾回收

  在我之前写过关于堆及GC的文章,请参考拿饭网对java垃圾回收大杂烩


  • JVM优化

  1. 一般来说survior区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免full GC,使用-Xmn设置年轻代的大小。
  2. 对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致full GC。通过设置参数:-XX:petenureSizeThreshold=1000000,单位为B,标明对象大小超过1M时,在老年代分配内存空间。
  3. 一般情况下,年轻对象放在eden区,当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值(64位机,最多15),就被放到老年代。这个阈值可以通过-XX:MaxTenuringThreshold设置。如果想让对象留在年轻代,可以设置比较大的阈值。(通过查看openjdk能够得知对象头文件中有4位决定阈值。所以,这个是跟机器位数有关系的。)
  4. 设置对小堆和最大堆:-Xmx-Xms稳定的堆大小对垃圾回收是有利的,获得一个稳定的对大小的方法是将这两个设置设成一样的数字,这样的话可以使GC的次数减少。
  5. 一个不稳定的堆并非毫无用处。在系统不需要使用大内存的时候,压缩堆空间,使得GC每次应对一个较小的堆空间,加快单次GC次数。基于这种考虑,JVM提供两个参数,用于压缩和扩展堆空间。(1)-XX:MinHeapFreeRatio 参数用于设置堆空间的最小空闲比率。默认值是40,当堆空间的空闲内存比率小于40,JVM便会扩展堆空间。(2)-XX:MaxHeapFreeRatio 参数用于设置堆空间的最大空闲比率。默认值是70, 当堆空间的空闲内存比率大于70,JVM便会压缩堆空间。(3)当-Xmx和-Xmx相等时,上面两个参数无效。
  6. 通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。(1)-XX:+UseParallelGC:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。(2)-XX:+UseParallelOldGC:设置老年代使用并行垃圾回收收集器。
  7. 尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统性能。-XX:+LargePageSizeInBytes设置内存页的大小。
  8. 使用非占用的垃圾收集器。-XX:UseConcMarkSweepGC老年代使用CMS收集器降低停顿。
  9. -XX:SurvivorRatio=3,代表年轻代中的分配比例:survivor:eden = 2:3。
  10. JVM性能调优工具:(1)jps(2)jstack(3)jmap(4)jhat(5)jstat(6)visualVM

参考文章: