您现在的位置是:首页 >学无止境 >JVM原理:JVM运行时内存模型(通俗易懂)网站首页学无止境

JVM原理:JVM运行时内存模型(通俗易懂)

@猪大肠 2024-10-02 00:01:04
简介JVM原理:JVM运行时内存模型(通俗易懂)

前言

做了几年开发,平时除了写代码造BUG和修复BUG之外,偶尔也会遇到反馈说程序较慢问题,要对程序性能排查与优化就得更深入学习,学习JVM可以帮助我们加深对JAVA的理解,让我们具备一定的性能排查与调优的能力,无非就是让程序别太卡或者别挂了,那挂了目前我遇到的主要是内存泄漏后导致OOM,或者内存分配不当,当机器内存不足时出发了Linux的保护机制,自动kill调占用内存最高的程序;所以我们要了解平时创建的对象、变量是如何存储的,这些知识点可以帮我们更好解决问题。

正文

文章中的JVM是以HotSpot为例:

JVM运行时数据区包括虚拟机栈、堆、方法区、程序计数器、本地方法栈。

在这里插入图片描述

虚拟机栈

虚拟机栈是线程私有的,也就是创建一个线程时,就会分配一个私有的栈,这个比较好理解,我们平时创建多线程时,每个方法里面的局部变量都是新的一份;

虚拟机栈维护了栈帧,每个方法都可以看作是一个栈帧。方法调用时会创建一个栈帧并且压栈,栈的特点是先进后出,比较符合我们程序的调用流程,比如A方法调用B方法,首先将创建栈帧A并压栈,当A方法中调用了B方法时创建栈帧B压栈,由于栈是先进后出,所以JVM在拿指令调用时,会先拿栈帧B进行调用。

自定义栈的大小
-Xss 512k

下面看下栈的结构:

在这里插入图片描述

局部变量表

局部变量表保存着方法中定义的局部变量,如基本数据类型、指向引用类型的地址指针、以及 returnAddress 类型。

局部变量表的数据随着栈帧的创建而存在,随着栈帧的销毁而销毁,这个比较好理解,每个方法的局部变量都是独立的,当方法调用完成后,局部变量就消失了,所以局部变量表是线程安全的。

栈是有空间大小的,所以当调用的方法较多时,会创建大量的栈帧,而栈帧又是占用内存空间,当创建栈帧时内存不足会导致stackoverflow栈溢出,栈溢出不会导致整个程序挂掉,但会导致当前线程挂掉。所以平常写递归要留意,不能出现死循环,不然最后就会报stackoverflow;

操作数栈

操作数栈也是栈结构,先入后出。操作数栈就是在程序计算指令执行前后用于保存临时数据的空间;

举个例子吧:

int a=1;
int b=1;
int c=a+b;

程序要执行以上的计算时

1、执行存值指令,将a=1放入操作数栈中

2、执行存值指令,将b=1放入操作数栈中

3、执行相加指令,出栈拿到a、b的值进行运算

4、将运算结果存入操作数栈中

5、执行赋值指令,将结果赋值给c,也就是存入局部变量表里

动态链接

我们编写Person类后,将其编译成class字节码文件,使用类加载器将其加载到内存中,此时会将类名、修饰符、变量名、方法名、方法返回值等类信息存入到常量池中,这里我们简单理解为维护了key-value表,如#01:0X71;

当调用方法时,会将指向方法地址的符号#01进行解析替换,此时指向方法地址的值就不在是#01,而是0X71了;所以动态链接会在程序运行时将间接地址引用转为直接地址引用;

方法返回地址

当一个返回调用结束后,返回值给上个方法,而且上个方法接收到返回值之后继续往下进行。而方法返回地址就是记录上个栈帧的位置,此时的栈帧出栈后,将返回结果存入下个栈帧的操作数栈中,然后执行赋值指令将值存入下个栈帧的局部变量表中;

这里我们知道会有两种返回方式,一种是程序正常退出,此时会返回值(如果有定义的话);一种是当前方法执行过程中抛出异常中断了方法,此时不会返回值;

本地方法栈

本地方法栈和虚拟机栈结构差不多,区别是虚拟机栈是为java方法(java写的代码)而设立的,而本地方法栈是为java代码中的native修饰的代码而设立的;并不是所有的虚拟机都有本地方法栈,如Sun HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。

本地方法存在的意义

本地方法会调用C语言或者C++类库,比如内核为了安全,要调度硬件时,不会轻易给你权限进行调度,你需要调用内核提供好的函数库接口,由它先来进行安全检查,再帮你进行调度。另一方面操作底层使用java代码难度较高,直接调用现成的库效率更高;

本地方法的调用

在这里插入图片描述

上图案例有点像调用Thread的start方法开辟新线程来执行Runnable的run方法过程;

虚拟机堆

当我们new一个对象时,会开虚拟机堆中开辟一块内存空间,引用类型和数组的数据都是存放在堆中。它是JVM中内存最大的一块空间,所有线程都可以访问它,所以它是共享,非线程安全的。

堆结构

我们程序中经常导致性能问题的地方就是堆了,由于它的空间很大,而程序长期运行必然会产生很多没有用的垃圾对象,所以JVM使用垃圾回收器对没有被引用的对象进行回收,垃圾回收过程会STW,用户线程会挂起,所以就必须减少STW的时间,垃圾回收算法后续文章会进行讲解;

由于堆空间很大,垃圾回收是需要对整个空间进行扫描的(Full GC),为了让垃圾回收得更快,这里将堆空间划分为年轻代和老年代,年轻代包括eden区、survivor0、survivor1区域;

在这里插入图片描述

Eden区

new对象时,会在Eden区开辟内存空间,当Eden区满了之后,会触发年轻代垃圾回收YGC

Survivor区域

这里有两块Survivor是因为YGC垃圾回收采用复制算法,将没有被垃圾回收的对象拷贝到另一块空间;

如Student对象刚开始在Eden区,当触发YGC之后,由于Student对象还被其它对象引用,所以不会进行回收。由于是采用复制算法,会将没有回收的对象迁移到Survivor中,当第二次YGC之后,Student对象还未被回收,将survivor0迁移到survivor1中。第三次YGC时,又将survivor1中没有被回收迁移到survivor0中。

那这些没有被垃圾回收的对象,一直占用着空间,如果占用较多时,YGC的频率就会特别高,所以这里引入了年龄的概念。每次YGC后,未被回收的将年龄+1,当年龄到达一定阈值时(默认15),迁移到老年代中,降低YGC频率。如果Survivor区满了,则直接进入老年代。

老年代Old区

老年代主要存放YGC过程中,一直没有被回收的对象。当老年代满了之后会触发FULL GC,此时老年代和年轻代都会进行垃圾回收,这个时间是比较久的,所以我们程序优化中要尽量减低FULL GC发生的频率;

常用参数指令

设置堆最小值:-Xms

设置堆最大值:-Xmx

设置年轻代大小:-Xmn

设置Eden:survivor0:survivor1比例:-XX:SurvivorRatio

晋升老年代的动态年龄: -XX:MaxTenuringThreshold

方法区

类编译后被加载到内存中后,其类修饰符、类名、父类信息、方法名称、变量名称等存入方法区的常量池中,方法区是各个线程共享的,JVM关闭时该区域空间就会被释放;

常量池

如下案例:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

JVM翻译成指令后:

// ===========================================类的描述信息===============================================
Classfile /xx/xx/xx/xx/HelloWorld.class
  Last modified 2021-10-12; size 569 bytes
  MD5 checksum 7f4f0fe4b6e6d04ddaf30401a7b04f07
  Compiled from "HelloWorld.java"
public class org.memory.jvm.t5.HelloWorld
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
    
// ===========================================常量池===============================================
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // org/memory/jvm/t5/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lorg/memory/jvm/t5/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               org/memory/jvm/t5/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
                            
// =======================================虚拟机中执行编译的方法===========================================
{
  public org.memory.jvm.t5.HelloWorld();
    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 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/memory/jvm/t5/HelloWorld;
	
  // main方法JVM指令码
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    // main方法访问修饰符描述
    flags: ACC_PUBLIC, ACC_STATIC
    // main方法中的代码执行部分
    // ===============================解释器读取下面的JVM指令解释并执行===================================             
    Code:
      stack=2, locals=1, args_size=1
         // 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         // 从常量池中符号地址为 #3 的地方加载常量 hello world
         3: ldc           #3                  // String hello world
         // 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         // main方法返回
         8: return
    // ==================================解释器读取上面的JVM指令解释并执行================================
      // 行号映射表
      LineNumberTable:
        line 9: 0
        line 10: 8
      // 局部变量表
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
 

上面类经过编译成.class文件后,再然后将class文件反编译为JVM指令码后,我们可以看到常量池中记录了类的名称、方法名等符号引用,还有#23等字面量

  • 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;如: #23 = Utf8 hello world
  • 符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。如: #3 = String #23

运行时常量池

运行时常量池是当类被加载到内存时的版本,上述案例可以看到每个类都会存在常量池,所以当常量池被加载进内存,将数据放入运行时常量池之后,也是每个类都有一份,此时符号引用会被解析成直接引用;

如上述案例中的 #3 = String #23 会变成 #3 = String hello world

运行时常量池是一个统称,它包括了字符串常量池、类名称常量、方法名称常量、静态变量池、基础数据常量池等;

方法信息

存储方法要运行的指令、局部变量表、返回类型等信息

类信息

存储类的描述信息、枚举、接口、父类等信息

域信息

域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

JDK1.7前的方法区

此类版本的方法区实现叫永久代,它存在于JVM内存之内,堆内存之外,这块区域由于有空间大小限制,当JVM加载太多的Class类时,垃圾回空间跟不上存入空间时,会报会报内存溢出异常OutOfMemoryError:PermGen space。

下面命令可用来指定空间大小:

  • -XX:PermSize来设置永久代初始分配空间。

  • -XX:MaxPermSize来设定永久代最大可分配空间。

在这里插入图片描述

JDK1.7时的方法区

此版本将字符串常量和字符串常量池放到了堆中,其它还是在永久代中

在这里插入图片描述

JDK1.7后的方法区

此版本的方法区,由metaSpace实现,存在于本地内存中,也就是JVM外的内存空间,它受限与物理内存,当物理内存不足时,会内存溢出;

在这里插入图片描述

程序计数器

每个线程都有一个程序计数器用于记录当前线程要执行的指令地址;由于程序运行一般是多线程,单CPU数量少于线程数量时,就会存在并发,每个线程会获得CPU的执行权。如执行线程A时,由于CPU分配的时间片到了,此时将当前线程挂起,线程B获取CPU的使用权,当线程B执行完毕后或者时间片用完后,需要恢复线程A的执行,此时如果想CPU从上次执行点开始往下执行的话,就需要记录之前的指令;

程序计数器记录着当前线程要执行的下条执行,当CPU从程序计数器拿到指令的引用之后,需要将下条指令的引用地址维护进来。如果是调用native方法时,程序计数器的记录值就为空。

总结

JVM理论知识点很多,平常如果很少实操这些东西是很难真正懂的,中文社区的JVM文章五花八门,很多写的都不一样。这篇文章有些按自己的理解不一定准确,但是我感觉这些知识点有利于我们解决问题也就够了,如有错误望指出修改。

内存模型了解之后,我们知道了数据存哪里了,那内存只有那么大,而程序一直运行,会不断占用内存空间,程序又是如何保证内存一直能放得下数据,那就得学习垃圾回收了。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。