原创

JVM内存结构

温馨提示:
本文最后更新于 2020年05月03日,已超过 1,503 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

JVM简介

什么是JVM?

  • Java Virtual Machine:Java虚拟机,用来保证Java语言跨平台。

  • Java虚拟机可以看做是一台抽象的计算机,如同真实的计算机那样,它有自己的指令集以及各种运行时内存区域。

  • Java虚拟机与Java语言没有必然的联系,它只是特定的二进制文件格式(class文件格式所关联)
  • Java虚拟机就是一个字节码翻译器,它将字节码翻译成各个系统对应的机器码,确保字节码文件能在各个系统正确运行。

为什么要学习JVM?

  • 面试
  • 了解依赖的运行工具的底层原理

学习资源?

官网的JVM虚拟机的规范:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

JVM体系结构?

JVM内存结构?

运行时数据区

  1. 程序计数器
  2. Java虚拟机栈
  3. 本地方法栈
  4. Java堆
  5. 方法区

1.程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

  • 在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因 此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地 址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

程序计数器的作用:保存当前执行指令的地址,一旦指令执行,程序计数器将更新到下一条指令。

程序计数器的作用演示说明
public class T1 {
    public static void main(String[] args) {
        PrintStream out = System.out;
        out.println(1);
        out.println(2);
        out.println(3);
        out.println(4);
        out.println(5);
    }
}

反编译一下

# 运行一下T1,然后在编译后的T1.class文件夹下执行:javap -v T1.class

Classfile /E:/JavaCode/MyProject/Interview/JVM/target/classes/com/lzhpo/jvm/test1/T1.class
  Last modified 2020-4-15; size 596 bytes
  MD5 checksum eac900b7e0e9af0702bd87d1aa74d920
  Compiled from "T1.java"
public class com.lzhpo.jvm.test1.T1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#22         // java/lang/Object."<init>":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Methodref          #25.#26        // java/io/PrintStream.println:(I)V
   #4 = Class              #27            // com/lzhpo/jvm/test1/T1
   #5 = Class              #28            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Lcom/lzhpo/jvm/test1/T1;
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               args
  #16 = Utf8               [Ljava/lang/String;
  #17 = Utf8               out
  #18 = Utf8               Ljava/io/PrintStream;
  #19 = Utf8               MethodParameters
  #20 = Utf8               SourceFile
  #21 = Utf8               T1.java
  #22 = NameAndType        #6:#7          // "<init>":()V
  #23 = Class              #29            // java/lang/System
  #24 = NameAndType        #17:#18        // out:Ljava/io/PrintStream;
  #25 = Class              #30            // java/io/PrintStream
  #26 = NameAndType        #31:#32        // println:(I)V
  #27 = Utf8               com/lzhpo/jvm/test1/T1
  #28 = Utf8               java/lang/Object
  #29 = Utf8               java/lang/System
  #30 = Utf8               java/io/PrintStream
  #31 = Utf8               println
  #32 = Utf8               (I)V
{
  public com.lzhpo.jvm.test1.T1();
    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 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/lzhpo/jvm/test1/T1;

  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: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: astore_1
         4: aload_1
         5: iconst_1
         6: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
         9: aload_1
        10: iconst_2
        11: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        14: aload_1
        15: iconst_3
        16: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        19: aload_1
        20: iconst_4
        21: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        24: aload_1
        25: iconst_5
        26: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        29: return
      LineNumberTable:
        line 12: 0
        line 13: 4
        line 14: 9
        line 15: 14
        line 16: 19
        line 17: 24
        line 18: 29
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      30     0  args   [Ljava/lang/String;
            4      26     1   out   Ljava/io/PrintStream;
    MethodParameters:
      Name                           Flags
      args
}
SourceFile: "T1.java"

在程序计数器中:

  1. 先把0存入程序计数器
  2. 然后CPU从程序计数器中读取内容,读取的是0
  3. 然后CPU执行对应的JVM指令,也就是执行0对应的指令
  4. 然后这一轮结束
  5. 第二轮,把3存入程序计数器....
  6. 和第一轮一样的.....

这样子就保证了我们的程序能够正常执行。

2.Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信 息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展[2],当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

栈的特点:先进后出。

  • 每个线程运行时需要的内存空间,成为虚拟机栈。
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
  • 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法。
演示理解虚拟机栈
public class T2 {
    public static void main(String[] args) {
        // 在此处debug一下
        int a = 10;
        int b = 20;
        method(a, b);
    }

    private static void method(int a, int b) {
        a += 10;
        b += 20;
        method2(a, b);
    }

    private static void method2(int a, int b) {
        int c = a + b;
        System.out.println(c);
    }
}

main方法进栈:

main方法进栈之后,method方法进栈,随后method2方法进栈:

method2先结束:

method2方法在method方法结束了之后也结束,最后才是main方法结束:

总结:method方法结束了之后,method2方法结束,method2方法结束了之后,main方法结束。

这就是很符合栈的特点先进后出,这也是虚拟机栈。

3.本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

本地方法栈的功能和特点类似于虚拟机栈,也是线程私有的。

不同的是:本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的Java方法。

如何去服务native方法?

native方法使用什么语言实现?

怎么组织像栈帧这种为了服务方法的数据结构?

虚拟机并未给出强制规定,因此不同的虚拟机是可以进行自由实现。

4.Java堆

对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所 有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

  • 在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配[1]”。
  • Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。
  • 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
  • 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大 对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
  • Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

作用:堆是用于存放对象的内存区域。

特点:堆是被所有线程共享的一块区域内存,在虚拟机启动时创建。堆的区域是用来存放对象实例的,因此也是垃圾收集器管理的主要区域。

堆在逻辑上划分别“新生代”和“老年代”,新生代分为Eden区、ServivorFrom、ServivorTo三个区。

堆一般实现成大小是可扩展的,使用“-Xms”与“-Xmx”控制堆的最小与最大内存。

堆内存溢出
package com.lzhpo.jvm.test5;

import java.util.ArrayList;

/**
 * 堆内存溢出相关
 * <pre>
 * 一开始是没有任何输出的,调整虚拟机的堆内存,再试:
 * -Xmx8m
 * -Xmx100m
 * </pre>
 * @author lzhpo
 */
public class T5 {
    public static void main(String[] args) {
        int count = 0;
        try {
            ArrayList<String> list = new ArrayList<>();
            String s = "lzhpo";
//            for (int i = 0; i < 20; i++) {
//                list.add(s);
//                s += s;
//                count++;
//            }
            // 死循环,设置再大的堆内存也没用,因为死循环,运行导致堆内存无限制增大
            while (true) {
                list.add(s);
                s += s;
                count++;
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println(count);
        }
    }
}
堆内存诊断工具
package com.lzhpo.jvm.test6;

/**
 * 堆内存诊断
 * <pre>
 * jps:查看系统中有哪些进程。
 *      - 使用方法(命令行输入):jps
 *
 * jmap工具:查看堆内存占有情况(某一时刻)
 *      - 使用方法(命令行输入):jmap -heap 进程ID
 *
 * jconsole工具:图形界面的,内置的Java性能分析器,多功能的检测工具,可以连续检测。
 *      - 使用方法(命令行输入):jconsole
 * </pre>
 * @author lzhpo
 */
public class T6 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1......");
        Thread.sleep(30000);
        // 10MB
        byte[] bys = new byte[1024 * 1024 * 10];
        System.out.println("2......");
        Thread.sleep(10000);
        bys = null;
        System.gc();
        System.out.println("3......");
        Thread.sleep(1000000);
    }
}

jconsole:

5.方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

  • 《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。

作用:存储每个类的结构。

例如运行时常量池、字段方法和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化以及接口初始化的特殊方法等。

方法区是在启动的时候创建的,它也算是属于堆的一部分,但是不同的厂商有不同的规范。

方法区内存溢出

jdk1.6和jdk1.8

package com.lzhpo.jvm.test7;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
 * 方法区内存溢出
 * <pre>
 * 设置最大元空间:
 * -XX:MaxMetaspaceSize=8m
 * 
 * 设置永久代内存溢出:
 * -XX:MaxPermSize=8m
 * </pre>
 * @author lzhpo
 */
public class T7 extends ClassLoader{
    public static void main(String[] args) {
        int count = 0;
        try {
            T7 t7 = new T7();
            for (int i = 0; i < 20000; i++, count++) {
                // 作用:生成类的二进制字节码
                ClassWriter cw = new ClassWriter(i);
                // 版本号,方法修饰符,类名,包名,父类,接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" +i, null, "java/lang/Object", null);
                byte[] code = cw.toByteArray();
                // 执行类的加载
                t7.defineClass("Class" +i, code, 0, code.length);
            }
        } finally {
            System.out.println(count);
        }
    }
}
运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

  • Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
  • 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

栈帧

对应的分别是:

  • 局部变量表:存放局部变量的列表。

一个局部变量可以保存类型为boolean、byte、char、short、float、reference和returnAddress的数据。

两个局部变量可以保存一个类型为long和double的数据。

局部变量使用索引来进行定位访问,第一个局部变量的索引值为零。

  • 操作数栈:也成操作栈,它是一个后进先出的栈。

当一个方法刚刚执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作,一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

  • 动态链接:简单的理解为指向运行常量池的引用。

在class文件里面,描述一个方法调用了其它方法,或者访问其成员变量是通过符号引用来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。

  • 方法返回地址(正常返回和异常返回):方法调用的返回,包括正常返回(有返回值)和异常返回(没有返回值),有不同的返回类型,有不同的指令。

无论方法采用哪种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮它恢复它的上层方法执行状态。

栈帧过多导致栈内存溢出

package com.lzhpo.jvm.test3;

/**
 * 栈过多导致栈内存溢出相关
 * <pre>
 * 分别设置以下虚拟机参数,然后查看情况:
 * -Xss256k:次数减少
 * -Xss10m:次数变多
 * </pre>
 *
 * @author lzhpo
 */
public class T3 {
    private static int count;

    public static void method() {
        count++;
        method();
    }

    public static void main(String[] args) {
        try {
            method();
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(count);
        }
    }
}

栈帧过大导致栈内存溢出

package com.lzhpo.jvm.test4;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Arrays;
import java.util.List;

/**
 * 栈帧过大导致栈内存溢出相关
 * <pre>
 *     Exception in thread "main" java.lang.NoSuchMethodError: com.fasterxml.jackson.core.JsonGenerator.writeStartArray(Ljava/lang/Object;I)V
 * </pre>
 * @author lzhpo
 */
public class T4 {
    public static void main(String[] args) throws JsonProcessingException {
        Dept d = new Dept();
        d.setName("yanfa");

        Emp e1 = new Emp();
        e1.setName("lisi");
        e1.setDept(d);

        Emp e2 = new Emp();
        e2.setName("wangwu");
        e2.setDept(d);

        // 两个集合放在一起
        d.setEmps(Arrays.asList(e1, e2));

        ObjectMapper mapper = new ObjectMapper();
        System.out.println(mapper.writeValueAsString(d));
    }
}

class Emp {
    private String name;

    /**
     * 解决循环引用导致栈帧过大然后栈内存溢出的问题
     * <pre>
     *     异常:Exception in thread "main" java.lang.NoSuchMethodError: com.fasterxml.jackson.core.JsonGenerator.writeStartArray(Ljava/lang/Object;I)V
     *     <br>
     *     正常:{"name":"yanfa","emps":[{"name":"lisi"},{"name":"wangwu"}]}
     *     <br>
     *     添加依赖:jackson-databind:2.3.3
     * </pre>
     */
    @JsonIgnore
    private Dept dept;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Dept getDept() {
        return dept;
    }

    public void setDept(Dept dept) {
        this.dept = dept;
    }
}

class Dept {
    private String name;
    private List<Emp> emps;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Emp> getEmps() {
        return emps;
    }

    public void setEmps(List<Emp> emps) {
        this.emps = emps;
    }
}

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

  • Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。
  • 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

  • 在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
  • 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
本文目录