JVM系列-第一节:JVM简介、运行时数据区、内存分代模型

一、什么是JVM?

JVM是Java Virtual Machine(Java虚拟机))的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

JVM是一种规范,有很多种实现,比如Oracle/Sun JDK、OpenJDK等,用的都是相同的JVM:HotSpot VM;IBM开发的一个高度模块化的JVM:J9。除此之外,还有很多其他的JVM实现。通常大家说起“Java性能如何如何”、“Java有多少种GC”、“JVM如何调优”等问题,默认说的就是HotSpot VM,所以HotSpot VM是绝对的主流。下文提到的JVM都是指HotSpot。

Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在字节码文件中的指令。JVM有两个重要作用,1.机器码翻译。JVM保证“一次编译,多次运行”,原因是不同平台有不同的JVM,比如HotSpot有windows版和linux版本,不同的平台使用不同的版本,对于程序员来说,只需要关注些代码,不用考虑代码的移植性,因为不同平台的JVM已经屏蔽了系统的差异了。2.内存管理。程序员需要使用一个对象,只需要new出来,不用关心具体是如何new出来的,不用关心对象的生命周期是怎样的,也不用关心什么时候回收对象。

Java虚拟机主要分为五大模块:类加载器、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。下面的内容主要讲其中两块,运行时数据区、垃圾收集器。

二、运行时数据区

当一个线程运行前,会把需要执行的代码的不同部分,放到运行时数据区的不同区域,当线程运行时会从不同的位置拿数据。

2.1程序计数器

程序计数器存储当前线程正在执行的字节码指令的地址和行号。为什么要记录一个线程正在执行的字节码指令的地址和行号呢?线程是java的最小执行单元,因为CUP同时执行多个线程的时候,会涉及到线程的切换,当CUP切换线程时,要记录这些信息,以便CUP再次切换到当前线程时,该线程知道从什么位置开始继续执行。每个线程都会有自己的程序计数器。

2.1栈

虚拟机栈存储当前线程运行的方法所需要的数据、指令、返回地址。

举一个简单的代码示例:

package com.wuxiaolong.jvm;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-09-06
 */
public class TestJVM {

    public static final int AGE = 30;

    public static void test () {
        int a = 1;
        int b = 2;
        int c = a + b;
        Object objc= new Object();
    }
}

找到上面TestJVM.java编译后的TestJVM.class文件,通过javap命令查看字节码的每一条指令,将指令存入TestJVM.txt文件。

$ javap -c -v ./TestJVM.class > TestJVM.txt

指令文件TestJVM.txt:

Classfile /C:/Users/WuXiaoLong/Desktop/java-summary/target/classes/com/wuxiaolong/jvm/TestJVM.class
  Last modified 2020-9-6; size 497 bytes
  MD5 checksum e2bee1c0136645a123ea37b4c6aba4a2
  Compiled from "TestJVM.java"
public class com.wuxiaolong.jvm.TestJVM
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 这里是常量池的描述      
Constant pool:
   #1 = Methodref          #2.#23         // java/lang/Object."<init>":()V
   #2 = Class              #24            // java/lang/Object
   #3 = Class              #25            // com/wuxiaolong/jvm/TestJVM
   #4 = Utf8               AGE
   #5 = Utf8               I
   #6 = Utf8               ConstantValue
   #7 = Integer            30
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/wuxiaolong/jvm/TestJVM;
  #15 = Utf8               test
  #16 = Utf8               a
  #17 = Utf8               b
  #18 = Utf8               c
  #19 = Utf8               objc
  #20 = Utf8               Ljava/lang/Object;
  #21 = Utf8               SourceFile
  #22 = Utf8               TestJVM.java
  #23 = NameAndType        #8:#9          // "<init>":()V
  #24 = Utf8               java/lang/Object
  #25 = Utf8               com/wuxiaolong/jvm/TestJVM
{
  // 静态常量AGE的描述    
  public static final int AGE;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 30
  // 这里是TestJVM类的描述
  public com.wuxiaolong.jvm.TestJVM();
    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 9: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/wuxiaolong/jvm/TestJVM;
  // 这里是test方法的描述
  public static void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code: // test方法的指令
      stack=2, locals=4, args_size=0
         0: iconst_1
         1: istore_0
         2: iconst_2
         3: istore_1
         4: iload_0
         5: iload_1
         6: iadd
         7: istore_2
         8: new           #2                  // class java/lang/Object //创建一个对象 在堆上分配了内存并在栈顶压入了指向这段内存的地址
        11: dup
        12: invokespecial #1                  // Method java/lang/Object."<init>":()V  //调用构造函数、实例化方法
        15: astore_3
        16: return
      LineNumberTable:  // test方法在java代码中的行号 
        line 14: 0
        line 15: 2
        line 16: 4
        line 17: 8
        line 18: 16
      LocalVariableTable: // test方法的本地局部变量表
        Start  Length  Slot  Name   Signature
            2      15     0     a   I
            4      13     1     b   I
            8       9     2     c   I
           16       1     3  objc   Ljava/lang/Object;
}
SourceFile: "TestJVM.java"

通过上面的指令描述文件,可以看出,TestJVM这个类的所有指令的描述。

JVM中的每一个线程拥有一个运行时栈。JVM会为每一个线程执行的方法在运行时栈中开辟一块空间,这块空间叫栈帧。每一个栈帧又分为几块,比如方法的局部变量表、操作数栈、动态链接、方法出口(返回地址)等:

在记录test方法的栈帧中,局部变量表中存在的是test方法中的四个本地变量:a/b/c/objc,对应TestJVM.txt指令描述文件的81-86行;

操作数栈中存的是局部变量对应的操作数,比如TestJVM.txt指令描述文件的62-74行指令中,第一个指令iconst_1:int型常量值1进栈,表示将int a = 1这一句代码中的操作数1放入(压栈)操作数栈里(这时栈里只有一个数1);第二个指令istore0:将栈顶int型数值存入第0个局部变量,第0个局部变量是谁?TestJVM.txt指令描述文件的83行,表明第0个局部变量a,操作数1出站存入局部变量a。其实iconst1和istore_0就是int a = 1这句代码执行的指令。其他的指令分析就不详细说了,具体每个指令什么意思可以自行网上查阅。

需要注意TestJVM.java的test方法第17行Object objc= new Object();,这是一个对象,和上面本地变量a/b/c不同,这一句涉及到的指令有new、dup、invokespecial、astore_3四个指令。其中new指的是创建一个对象,具体是在堆上分配内存,并在栈顶压入指向这段内存的地址。对象是存在堆里的,栈里只存对象在堆中的地址

动态链接指的是如果被调用的方法或对象在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。类似于TestJVM.txt的第70行和第72行中的#2和#1,对应TestJVM.txt的第12和11两行的Constant pool,动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。简单举一个例子,通常在Controller层调用Service层时,通过@Autowired注入一个Service,通常使用的时一个Service的接口而不是实现类,在Controller的一个方法中通过Service的接口中的方法调用具体的Service实现,如果Service接口有多个实现,程序在编译期并不知道具体使用哪个实现类,这个时候就会在字节码Constant pool部分生成动态链接(#),在程序的运行期最终转换为调用方法的直接引用。通过字节码指令文件可以看出Constant pool翻译成”常量池“并不准确,Constant pool中除了有常量,还有符号引用(包括类、方法、字段等的描述符)。

方法出口(返回地址)指的是,当一个方法执行完成后,要出栈,那么出栈后要去哪,正常执行的方法出栈和异常执行的方法的出栈也不一样。

注意,在线程中递归调用某个方法时,方法的每次调用都会有一个栈帧,所以线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。虽然栈的大小可以自动扩展,但动态扩展时无法申请到更大的空间时,任然会报出OutOfMemory。

栈帧大小确定的时间在编译期,不受运行期数据影响。所以局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。

2.3本地方法栈

本地方法栈和虚拟机栈类似,只是它描述的是本地方法在执行是的情况。什么是本地方法?本地方法是指方法被native关键字修饰的方法,在JDK中没有具体实现类的,具体的实现在JVM的代码中,这里就可以找到各种版本的Hotspot源码,源码是C或C++写的。之前文章中在分析CAS时就使用了Hotspot源码。

2.4方法区

方法区中存储类字节码的类信息、常量、静态变量、JIT等信息。

TestJVM.txt指令文件的第10-35行是常量池,这部分的内容就放在方法区的常量池中。

这里可以思考一下,静态变量和常量为什么不放在堆中?我感觉是因为常量和静态变量一般都是不变的,只要存储一份就可以了,放在堆中那么每次new对象都会存在相同的数据,造成空间浪费。

2.5堆

对于大多数应用来说,堆是java虚拟机管理内存最大的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。因此需要重点了解下。

堆里存储的都是new出来的对象,栈里存储该对象的引用会指向堆里该对象的内存地址。所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟(浅谈HotSpot逃逸分析),这个说法也不是那么绝对,但是大多数情况都是这样的。

三、JVM内存分代模型

在JDK1.8之前,JVM的内存分三大块,新生代、老年代、永久代。其中前两块在堆中,后一块在方法区中。在JDK1.8及以后,移除了永久代使用了Meta Space。

为什么要分代?因为不同的对象的生命周期是不一样的,将不同生命周期的对象放在不同的代,使用不同的垃圾回收算法进行回收。

3.1新生代

一般来说,对象刚new出来会放在新生代,新生代中的对象一般是生命周期比较短,一次回收能回收(Minor GC)98%以上的对象。

新生代内存分为三块Eden区、s0区、s1区,为什么是分三块

新生代分三块,主要的原因是新生代使用的垃圾回收算法使用的是复制算法。

S0和S1是两块大小相同、功能相同的区域,但是一次GC只能有一块区域起作用。当eden区第一次满了时,会触发第一次minor gc,回收eden区的对象,gc后,还有一个对象是可达的,那么就属于存活的对象,这个对象会被放到s0区域。当eden区第二次满了时,会触发第二次minor gc,这时会回收eden区和s0区域,如果这时对象b依然可达,并且对象j也可达,那么这两个对象就会进入s1区域。可以看出,每次gc时存活的对象会在s0和s1区域来回复制,这就是复制算法。每次gc后存活的对象,年龄都会加1,多次gc后年龄达到固定的阈值(默认15)后,对象会进入老年代。

新生代内存分为三块Eden区、s0区、s1区,比例是:8:1:1。为什么比例是8:1:1?因为复制算法中s0和s1只能一块区域起作用,另一块是空的,所以并不是所有的新生代都是有效的存储空间,s0和s1过大会导致可用内存变小并且eden区过小,minor gc会变得更频繁;s0和s1过小,导致较少的gc次数时,s0和s1就会满了,从而导致年龄较小的对象进入老年代。8:1:1可以看成是二八原则。

3.2老年代

新生代和老年代的内存比例是1:2。

新生代中多次回收后依然存在的对象会进入老年代。

3.3永久代和Meta Space

为了避免永久代的溢出,在JDK1.8及之后,去掉了永久代,使用了Meta Space。Meta Space这块内存属于代外分配的内存,使用的是机器的直接内存。Meta Space可以自动扩容,虽然可以自动扩容,但Meta Space也并不是越大越好,因为机器的总内存是固定的,Meta Space变大会挤压其他的内存空间的使用。

后续将继续介绍java内存模型、四种引用、GC回收算法、GC回收器、JVM优化等内容。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/759dcf6f8352bba08746bfcb3
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券