小言_互联网的博客

JVM内存结构详解

310人阅读  评论(0)

一、你了解JVM内存结构吗

在这之前需要知道

内存寻址过程

地址空间划分

  • 内核空间是用于连接硬件,调度程序联网等服务
  • 用户空间,才是java运行的系统空间

我们知道JVM是内存中的虚拟机,主要使用内存进行存储,所有类、类型、方法,都是在内存中,这决定着我们的程序运行是否健壮、高效。

JVM内存结构图——JDK1.8

  • 线程私有:程序计数器、虚拟机栈、本地方法栈
  • 线程共享:MetaSpace、Java堆
    下面我们会对图中五个部分进行详细说明

1.1、程序计数器

  • 当前线程所执行的字节码行号指示器(逻辑)
  • 通过改变计数器的值来选取下一条需要执行的字节码指令
  • JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令,为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程计数器不会互相影响。所以,程序计数器和线程是一对一的关系即(线程私有
  • 对Java方法计数,如果是Native方法则计数器值为Undefined,Native方法是由非Java代码实现的外部接口
  • 程序计数器是为了防止内存泄漏
    在后边的举例中我们可以看到程序计数器的作用。

1.2、虚拟机栈(JVM Stack)

  • Java方法执行的内存模型
  • 生命周期和线程是相同的,每个线程都会有一个虚拟机栈,栈的大小在编译期就已经确定了
  • 栈的变量随着变量作用域的结束而释放,不需要jvm垃圾回收机制回收。
  • 包含多个栈帧
    • 栈帧包含
      • 局部变量表
        • 包含方法执行过程中的所有变量(所有类型)
      • 操作数栈
        • 入栈、出栈、复制、交换、产生消费变量
      • 动态连接
      • 返回地址

在Java虚拟机栈中,一个栈帧对应一个方法,,方法执行时会在虚拟机栈中创建一个栈帧,而且当前虚拟机栈只能有一个活跃的栈帧,并且处于栈顶,当前方法结束后,可能会将返回值返回给调用它的方法,而自己将会被弹出栈(即销毁),下一个栈顶将会被执行。

举例说明:

ByteCodeSample.java

package com.mtli.jvm.model;

/**
 * @Author: Mt.Li
 * @Create: 2020-04-26 17:47
 */
public class ByteCodeSample {
   
    public static int add(int a , int b) {
   
        int c= 0;
        c = a + b;
        return c;
    }
}

对其进行编译生成.class文件

javac com/mtli/jvm/model/ByteCodeSample.java

然后用javap -verbose 进行反编译

javap -verbose com/mtli/jvm/model/ByteCodeSample.class

生成如下:

Classfile /E:/JavaTest/javabasic/java_basic/src/com/mtli/jvm/model/
ByteCodeSample.class
  Last modified 2020-4-26; size 289 bytes
  MD5 checksum 2421660bb241239f1a67171bb771521f
  Compiled from "ByteCodeSample.java"
public class com.mtli.jvm.model.ByteCodeSample
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 描述类信息
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<ini
t>":()V
   #2 = Class              #13            // com/mtli/jvm/model/Byt
eCodeSample
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               add
   #9 = Utf8               (II)I
  #10 = Utf8               SourceFile
  #11 = Utf8               ByteCodeSample.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               com/mtli/jvm/model/ByteCodeSample
  #14 = Utf8               java/lang/Object
 // 以上是常量池(线程共享)
{
   
  public com.mtli.jvm.model.ByteCodeSample();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/O
bject."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0
// 以上是初始化过程
  public static int add(int, int);
    descriptor: (II)I  // 接收两个int类型变量
    flags: ACC_PUBLIC, ACC_STATIC // 描述方法权限和类型
    Code:
      stack=2, locals=3, args_size=2 // 操作数栈深度 、 容量  、参数数量
         0: iconst_0
         1: istore_2
         2: iload_0
         3: iload_1
         4: iadd
         5: istore_2
         6: iload_2
         7: ireturn
      LineNumberTable:
        line 10: 0 // 这里的第0行对应我们代码中的第10行
        line 12: 2
        line 13: 6
}
SourceFile: "ByteCodeSample.java"

执行add(1,2)

以下是程序在JVM虚拟机栈中的执行过程


图不是很清楚,我来说一下过程,最下边的是程序计数器(前边提到的),最上边是操作指令,中间是局部变量表和操作数栈(位置从0开始)

  • 最开始,我们int c = 0,所以操作数栈顶初始值为0,局部变量表存储变量值。
  • istore_2 就是出栈的意思,将0放入变量表2的位置
  • iload_0 就是入栈,将1复制并压入操作数栈
  • 然后将位置在1的值“2”压入栈
  • 在栈中执行add方法,得到“3”
  • 将栈顶“3”取出到变量表的2位置
  • 再次将“3”压入栈,准备return
  • 方法返回值

执行完之后,当前线程虚拟机栈的栈帧会弹出,对应的其他方法与当前栈帧的连接释放、引用释放,它的下一个栈帧成为栈顶。

1.1.1、java.lang.StackOverflowError问题

我们知道,一个栈帧对应一个方法,存放栈帧的线程虚拟栈是有深度限制的,我们调用递归方法,每递归一次,就会创建一个新的栈帧压入虚拟栈,当超出限度后,就会报此错误。

举例说明:

package com.mtli.jvm.model;

/**
 * @Description:斐波那契
 * F(0)=0,F(1)=1,当n>=2的时候,F(n) = F(n-1) + F(n-2),
 * F(2) = F(1) + F(0) = 1,F(3) = F(2) + F(1) = 1+1 = 2
 * 0, 1, 1, 2, 3, 5, 8, 13, 21, 34...
 * @Author: Mt.Li
 * @Create: 2020-04-26 18:33
 */
public class Fibonacci {
   
    public static int fibonacci(int n) {
   
        if(n>=0){
   
            if(n == 0) {
   return 0;}
            if(n == 1) {
   return 1;}
            return fibonacci(n-1) +fibonacci(n-2);
        }
        return n;

    }

    public static void main(String[] args) {
   
        System.out.println(fibonacci(0));
        System.out.println(fibonacci(1));
        System.out.println(fibonacci(2));
        System.out.println(fibonacci(3));
        System.out.println(fibonacci(1000000));
        // java.lang.StackOverflowError
    }
}

结果:

解决方法是限制递归次数,或者直接用循环解决。

还有就是,由JVM管理的虚拟机栈数量也是有限的,也就是线程数量也是有限定。

由于栈帧在方法返回后会自动释放,所有栈是不需要GC来回收的。

1.3、本地方法栈

  • 与虚拟机栈相似,主要作用于标注了native的方法

1.4、元空间(MetaSpace)

元空间(MetaSpace)在jdk1.7之前是属于永久代(PermGen)的,两者的作用就是记录class的信息,jdk1.7中,永久代被移入堆中解决了前面版本的永久代分配内存不足时报出的OutOfMemoryError,jdk1.8之后元空间替代了永久代

  • 元空间使用本地内存,而永久代使用的是jvm的空间

1.4.1、MetaSpace相比PermGen的优势

  • 字符串常量池存在永久代中,容易出现性能问题和内存溢出(空间大小不如元空间)
  • 类和方法的信息大小难以确定,给永久代的大小指定带来了困难
  • 永久代会为GC带来不必要的复杂性
  • 方便HotSpot与其他JVM如Jrockit的集成

1.5、Java堆(Heap)

  • 对象实例的分配区域,实例在此处分配内存
  • java堆可以处于不连续的物理空间中,只要逻辑上是连续的即可
  • 是GC管理的主要区域,按照GC分代回收的方法,java堆又分为新生代老生代快速了解GC

二、JVM三大性能调优参数 -Xms -Xmx -Xss的含义

  • -Xss:规定了每个线程虚拟机栈(堆栈)的大小(一般情况下256k足够)
  • -Xms:堆的初始值
  • -Xmx:堆能达到的最大值

三、Java内存中堆和栈的区别——内存分配策略

需要先了解

  • 静态存储:编译时确定每个数据目标在运行时的存储空间需求,不允许有可变的程序存在,比如循环
  • 栈式存储:数据区需求在编译时未知,运行时模块入口前确定。存储局部变量,定义在方法中的都是局部变量,所以,方法先进栈,创建栈帧等操作,方法一旦返回,即变量离开作用域,则栈帧释放,变量也会释放。(生命周期短)
  • 堆式存储:编译时或运行时模块入口都无法确定,动态分配。堆存储的是数组和对象,存储结构复杂,所需空间更多,哪怕是实体中的一个属性数据消失,这个实体也不会消失。(生命周期长)

区别

  • 管理方式:栈自动释放,堆需要GC
  • 空间大小:栈比堆小
  • 碎片相关:栈产生的碎片远小于堆
  • 分配方式:栈支持静态和动态分配,而堆仅支持动态分配
  • 效率:栈的效率比堆高,堆更灵活
  • 联系:引用对象、数组时,栈里面定义变量保存堆中目标的首地址

四、元空间、堆、线程独占部分间的联系——内存角度

我们来看下面这个例子:

以下是各个部分包含的内容:

  • 元空间里面存着类的信息,比如方法、变量
  • java堆中存放对象实例
  • 线程独占:用来保存变量的值即变量的引用、对象的地址引用,记录行号,用来记录代码的执行

五、不同JDK版本之间的intern()方法的区别——JDK6 VS JDK6+

说到这里我们不得不提一下String.intern()方法在jdk版本变更中的不同

String s = new String("a");
s.intern();

JDK6:当调用intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用。否则,将此字符串对象添加到字符串常量池中,并且返回该字符串对象的引用。

JDK6+:当调用intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用。否则,如果该字符串对象已经存在于Java堆中,则将堆中对此对象的引用添加到字符串常量池中,并且返回该引用;如果堆中不存在,则在池中创建该字符串并返回其引用

我们看一个例子:

jdk1.8

public class InternDifference {
   
    public static void main(String[] args) {
   
        String s = new String("a"); // 会先根据字面量在池中创建一个"a"字符串,
        // 然后new的时候,在堆中创建一个"a",s——>堆中"a"
        s.intern(); // intern的时候,因为该对象已经存在于堆中,于是,想要将堆中对此对象的引用添加到池中
        // 但是池中此时已经存在"a",且不是堆中这个对象的引用,是独立存在的,intern无法创建引用进去
        String s2 = "a"; // 池中有"a",便直接引用池中的"a",s2——>池中"a"
        System.out.println(s == s2); // 两者的指向不同,故结果为false

        String s3 = new String("a") + new String("a"); // 两个括号中的字面量原本都是要在池中创建"a"的,但是上面的
        // 例子中,已经在池中创建了"a",故这里只会在堆中创建aa对象,s3指向堆中的对象
        s3.intern(); // intern时,发现池中并没有aa,于是将堆中对此对象的引用添加到字符串常量池中,然后池中就会有堆中"aa"对象的引用
        String s4 = "aa"; // 这里字面量创建的时候,发现池中有aa,则s4直接指向池中的"aa"
        System.out.println(s3 == s4); // 但是池中的"aa"实际上是对堆中对象的引用,所以两者实际上都是指向堆中对象,结果为true
    }
}

// 结果
false
true

jdk1.6

public class InternDifference {
   
    public static void main(String[] args) {
   
        String s = new String("a"); // 和上边一样,这里用引号声明了"a"字符串,会在常量池中先创建"a"
        // 然后new String()会在堆中创建对象
        s.intern(); // intern原本是想要将"a"直接复制一份到常量池中,但是池中已经有了,池中的也是独立存在的,故不能放副本
        String s2 = "a"; // s2字面量创建的时候发现池中有"a",故直接引用常量池中的"a"
        System.out.println(s == s2); // 根据上边的分析,结果肯定为false

        String s3 = new String("a") + new String("a"); // 和之前一样,先是想要创建声明的"a",但是池中已经有了上边的"a"
        // 故不会创建,只能直接new String创建生成"aa",s3指向堆中对象
        s3.intern(); // intern时发现池中没有"aa",故会放一个副本到常量池中(注意不是对堆中对象的引用)
        String s4 = "aa"; // 这里创建的时候发现池中已经有"aa",于是直接引用池中"aa"
        System.out.println(s3 == s4); // s3引用堆中的,s4引用池中的,故为false
    }
}

// 结果
false
false

如有不对,请大家下方评论区指正,谢谢


转载:https://blog.csdn.net/weixin_42173451/article/details/105805231
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场