JVM的内存模型

  • Post author:
  • Post category:其他




JVM 内存模型

我们这边所讲的JVM内存模型是指JAVA8的内存模型,JVM的内存模型包含了:

堆区、方法区、虚拟机栈、本地方法栈、程序计数器,关于JVM的内存模型,各大博客网站都有非常详细的讲解,这边我主要根据自己的理解做一个简单记录,具体看下图:

在这里插入图片描述

其中运行数据区是JVM最重要的一块区域,运行数据区中的方法区和堆区是线程共享的区域

而虚拟机栈和程序计数器是线程私有的区域,那么虚拟机栈到底有多少个栈,

一个线程一个虚拟机栈,当有线程请求进来时,会创建虚拟机栈桢来处理我们的请求。



方法区

我们所有的clas文件都会被加载到方法区中,方法区有两个概念元空间和永久代,所以我们这边要把方法区、永久代和元空间这三个概念弄清楚;其中方法区一种规范,而永久代和元空间是方法区的具体实现;通俗一点说就是方法区就是我们定义的规范接口,而永久代和元空间就是接口的实现;

java8虚拟机以后,永久代被元空间所取代,元空间存储了class文件的所有信息,这些信息是静态的信息,如字面量,包含了属性、方法、属性签名等,我们来看下两者区别:

永久代:JAVA8以前的实现,放在堆区,用于存放类信息以及InstanceKlass实例以及运行时常量池;

元空间:JAVA及以后的实现,直接使用OS内存,也就是直接内存,不分享堆区的内存,只用于存放我们的class文件元信息和运行时常量池。

方法区与堆区有直接的关系,当我们向jvm请求需要某一个类的对象时,jvm首先确保这个类在方法区中元空间中,然后从元空间将这个类加载进来,然后创建这个对象的内存,从而在堆区分配对象的存储空间。

为什么java8用元空间取代了永久代?

为什么会取代永久代,其实这个说法有很多,不过我自己的理解有几点:

1.因为永久代是共享堆区的空间,但是大家都知道java中的反射、动态代理,比如CGLIB是可以动态创建类的,可以这样说,CGLIB可以无限创建,这样的话,对于我们堆区来说,GC算法的实现就比较复杂并且很容易出现OOM异常;

2.硬件的发展,现在的系统越来越庞大;

3.方法区中的元空间只用于存储class的元信息的存储,基本上不会出现OOM异常,我们关注点就主要在堆区,一般我们的方法区元空间设置的内存大小为物理内存的64分之一。

元空间的最小值为21807104 20.75M

元空间的最大值为4294901760

上面的值是默认值,我们也可以通过参数进行调整

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

jvm调优的原则一般是元空间最大值和最小值设置为一致即可。



虚拟机栈

JVM是软件模拟的虚拟机,基于栈运行

虚拟机栈中又有很多栈帧,栈帧又被分成了其他区域。理解虚拟机栈的核心就是理解栈帧中的这几个区域

1、局部变量表:用于存放执行方法的局部变量

2、操作数栈:用于记录我们指令操作数的栈

3、动态链接:执行方法对应的JVM对象在元空间中的内存地址

4、返回地址:返回地址是用于保存现场,比如我们在main方法中调用另外一个方法add,那么add的栈帧需要记录我们的main方法的位置信息,当我们的add方法执行完毕过后方便 返回到main方法的指定位置,我们来看下具体的代码:

public class ByteCode extends Test_01 {


    private static int count = 1;

    public static void main(String[] args) {
        System.out.println(count);
        ByteCode bc = new ByteCode();
        System.out.println(bc.add());

        System.out.println("finish");
    }

    public int add(){
        int a =2,b=4;
        return a+b;
    }
}

我们看上面的ByteCode,我们再来看下这个代码编译过后的字节码main方法信息:

 0 getstatic #2 <java/lang/System.out>
 3 getstatic #3 <com/bml/jvm/ByteCode.count>
 6 invokevirtual #4 <java/io/PrintStream.println>
 9 new #5 <com/bml/jvm/ByteCode>
12 dup
13 invokespecial #6 <com/bml/jvm/ByteCode.<init>>
16 astore_1
17 getstatic #2 <java/lang/System.out>
20 aload_1
21 invokevirtual #7 <com/bml/jvm/ByteCode.add>
24 invokevirtual #4 <java/io/PrintStream.println>
27 getstatic #2 <java/lang/System.out>
30 ldc #8 <finish>
32 invokevirtual #9 <java/io/PrintStream.println>
35 return

我们看下21,21是调用add方法,而add方法在jvm中是放入了常量池中方法应用了,所有调用普通方法invokevirtual进行调用,但是我们这个方法调用过后是要返回到main方法中,所以add方法调用完毕过后就到了指令24了,所以返回地址就是用来保存我们返回地址信息。



虚拟机栈执行流程

比如我们执行一个java的main方法时,虚拟机栈是如何运行的,比如下面的这段程序:

public class ByteCode extends Test_01 {


    private static int count = 1;

    public static void main(String[] args) {
        System.out.println(count);
        ByteCode bc = new ByteCode();
        System.out.println(bc.add());

        System.out.println("finish");
    }

    public int add(){
        int a =2,b=4;
        return a+b;
    }
}

在这里插入图片描述

在解释JVM中的虚拟机栈如何运行前,我们先解析两个名词,局部变量表和操作数栈。



局部变量表

方法中的局部变量信息:

在这里插入图片描述

比如我们的int变量c和stirng字符串ss,在局部变量表中都指向了常量池中,所以局部变量表就是指我们的方法中的局部变量表,看上图有没有看到我们的额局部变量的index都是0,1,2,3,但是index=0是

什么变量呢?其实index=0是我们在方法中常用的this指针,所以每一个方法的局部变量表的index=0都是this指针,下面我会解析this指针是什么时候进行赋值的。

局部变量表存放的是方法参数和方法内部定义的局部变量,如果方法中的参数是基本数据类型,那么局部变量表存放的就是基本数据类型对应的值,如果方法中的参数是引用类型,那么局部变量表存放的就是引用的内存地址。



操作数栈

操作数栈其实就是取操作数指令,比如push(压栈)、pop(弹栈)

比如add方法的字节码:

0 iconst_2
1 istore_1
2 iconst_4
3 istore_2
4 iload_1
5 iload_2
6 iadd
7 ireturn

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或者提取数据,即入栈/出栈

如果被调动的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配

另外,我们说Java虚拟机的解释引擎室基于栈的执行引擎,其中的栈指的就是操作数栈。

操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时的存储空间

操作数栈就是JVM执行引擎的以各工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

每个操作数栈在编译器就定义好了栈的深度

64位的类型占用两个单位的栈深度。

结合上面的代码块和本图,当我们的程序在执行main方法时,虚拟机栈的内部运行如下:

1.创建mian方法需要的栈帧;

2.将main方法的操作数栈指针赋值给线程的操作数栈(当前线程的操作数栈当前指针,线程刚创建时为空);

3.将main方法的局部变量表指针赋值给线程的局部变量表指针(当前线程的局部变量表开始指针,线程刚创建时为空)

当程序运行到第10行代码也就是调用add方法时,jvm是如何运行的??

1.创建add方法的栈帧;

2.在add方法的栈帧中保存main方法的字节码的下一行程序计数器(24);

3.线程的局部变量表开始指针(main的)保存到add方法栈帧中;

4.线程的操作数栈开始指针(main的)保存到add的方法栈中;

5.将add方法栈的局部变量表指针赋值给当前线程的局部变量表指针;

6.将add方法栈的操作数栈表指针赋值给当前线程的操作数栈指针;

7.当add方法执行完成过后,根据保存到add方法栈中的局部变量表和操作数栈指针所指向的程序计数器以及main方法的局部变量表、操作数信息返回到main方法字节码指令的位置,在上面的程序中的程序计数器24的位置。



this指针

我们都知道在我们的非static方法中可以使用this来访问我们本来的对象和属性,那么this对象是什么时候生成的,this=new 对象();那么this指针是在什么时候给我们创建的呢?刚刚在上面讲解局部变量表的时候我说this指针用于在局部变量表的第一位,为什么呢?

我们来看下add方法的局部变量表

在这里插入图片描述

当鼠标点击#19的时候,会自动跳转到

在这里插入图片描述

那么是在什么时候创建的这个对象呢?

我们修改下add方法,因为进入add方法的时候jvm在创建main方法所在类的对象的时候就已经创建了this对象了,我们这边测试下创建一个新对象的时候也会创建this对象

public int add(){
    Test_01 t = new Test_01();
 int a =2,b=4;
 return a+b;
}

再看字节码

0 new #11 <com/luban/jvm/Test_01>
 3 dup
 4 invokespecial #1 <com/luban/jvm/Test_01.<init>>
 7 astore_1
 8 iconst_2
 9 istore_2
10 iconst_4
11 istore_3
12 iload_2
13 iload_3
14 iadd
15 ireturn

看程序计数器的0,3,4,其中

0=创建一个不完全对象Test,这个时候还没有调用Test_01的构造方法;

3=dup duplicate 复制

1、复制栈顶元素

2、压入栈

4=通过Test_01的默认构造创建的,jvm中的init方法就是调用的构造方法;

1、执行invokespecial字节码指令,完成运行方法的环境构建this指针赋值

2、执行构造方法

这段字节码执行完,这个对象就是完全对象

所以我们的this对象就是在调用invokespecial方法的时候创建的



程序计数器

我们平时编写的类和方法是静态的,是以静态的方式存储在方法区中,当我们的程序进行调用的时候才会从方法区中加载进来动态运行,创建虚拟机栈动态运行我们所调用的方法,然后进行一系列加载,连接,初始化,使用,卸载,我们看上图的字节码指令中的0,3,6,9,12,16,17…35就是程序计数器,程序计数器也叫PC寄存器,我们知道程序运行时,多线程是通过线程间的切换来获得CUP的执行时间,因此,在任意的一个时刻,一个CPU的内核只会执行一条线程中的指令,所以,为了使每个线程在切换后能正确恢复到切换前的执行位置,每个线程都要有一个它自己的程序计数器;它具有的特点有:

线程私有

JVM规范中唯一没有规定OutOfMemoryError情况的区域

如果正在执行的是Native 方法,则这个计数器值为空

首先,为什么是线程私有?

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,也就是说,在同一时刻一个处理器内核只会执行一条线程,处理器切换线程时并不会记录上一个线程执行到哪个位置,所以为了线程切换后依然能恢复到原位,每条线程都需要有各自独立的程序计数器。

为什么没有规定OutOfMemoryError?

如上文,程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。

为什么执行Native方法,值为空?

Native方法大多是通过C实现并未编译成需要执行的字节码指令,也就不需要去存储字节码文件的行号了。



JVM堆

在这里插入图片描述

根据一些大厂的规范,堆的最小值为OS系统的1/64,最大值为OS系统内存的1/4,最大不超过OS的1/2;

在堆中,新生代占中堆大小的1/3,老年代占2/3;而新生代又分为了Eden区,From区和to区,新生代的比例是8:1:1;

在实际的项目过程中,为了防止内存抖动或者产生伸缩区,最大堆和最小堆一般设置一样大

我们来思考一个问题,什么对象会进行老年代?

1.对象经过15次GC过后还存活的对象会进入老年代,为什么是15次,有大佬认真分析过原因吗?

虚拟机规范讲解的是可以是15次,不能超过15次,可以是15以及以下的次数,可以通过启动参数配置;为什么是15次,其实认真看了虚拟机规范就知道我们的对象年龄不能超过4位,也就是最大为4bit,所以就能解析通了为什么不能配置超过15次,因为15的二进制正好是1111,正好是4bit,超过15就不是4位了,所以为什么只能小于等于15;

2大对象直接进入老年代,何谓大对象,就是放入堆中的对象大小超过了Eden区的一半;

3.空间担保,为了保证Eden区空间可用性;

其他还有那些情况请大佬补充。



各个区之间的关系

1.虚拟机栈指向方法区

通过动态链接建立关系

部分符号引用在类加载阶段(解析)的时候就转化为直接引用,这种转化为静态链接

部分符号引用在运行期间转化为直接引用,这种转化为动态链接

其他堆的知识点我认为各大博客讲解的非常详细,我这边只是记录我自己对jvm内存模型的理解。

2.虚拟机栈指向堆区

Test test = new Test()

3.方法区指向堆区

引用类型的静态属性

4、堆区指向方法区

对象的内存布局

Klass pointer

指向该对象的instanceKlass实例



总结

虚拟机栈:当调用一个方法时,也就是启动一个线程时,会创建一个虚拟机栈,并且创建对应的虚拟机栈桢,栈桢中保存了局部变量表指针,操作数指针、方法出口信息(PC寄存器,保存返回的位置);方法的执行根据程序计数器的指令来进行,当我们在方法中调用另外一个方法时,另外的方法创建的栈桢,将方法的局部变量、操作数赋值给当前线程的局部变量和操作数指针,当前方法的栈桢中保存了调用者方法的局部变量和操作数信息,用于返回时,让jvm知道我要返回到那个位置上去。

方法的局部变量表的内存大小是在程序编译期间就一定定好的,所以在方法调用的时候,局部变量表是不会出现内存溢出的。

程序计数器:程序计数器就类似于PC寄存器,用来规定我们的程序的执行流程,多线程是通过线程间的切换来获得CPU的执行时间的,所以在某一个时刻一个cpu只能执行一条指令,所以当我们的线程切换过后,为了能使程序回到切换前的位置上,jvm用程序计数器来保存我们的以一条指令的位置。

常量池:常量池分为运行时常量池和静态常量池,java8将静态常量池放入了堆中,运行时常量池还在方法区中的元空间里面;当我们的class文件被装载到方法区过后,就会创建运行时常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到常量池中。

常量池分为:

1.class文件中的常量池

Constant pool(在硬盘上),就是我们编译过后产生的,如下:

Constant pool:
   #1 = Methodref          #5.#23         // java/lang/Object."<init>":()V
   #2 = Class              #24            // com/luban/Test
   #3 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #27.#28        // java/io/PrintStream.println:(Ljava/lang/Object;)V
   #5 = Class              #29            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Lcom/luban/Test;
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               args
  #16 = Utf8               [Ljava/lang/String;
  #17 = Utf8               a
  #18 = Utf8               [I
  #19 = Utf8               tests
  #20 = Utf8               [Lcom/luban/Test;
  #21 = Utf8               SourceFile
  #22 = Utf8               Test.java
  #23 = NameAndType        #6:#7          // "<init>":()V
  #24 = Utf8               com/luban/Test
  #25 = Class              #30            // java/lang/System
  #26 = NameAndType        #31:#32        // out:Ljava/io/PrintStream;
  #27 = Class              #33            // java/io/PrintStream
  #28 = NameAndType        #34:#35        // println:(Ljava/lang/Object;)V
  #29 = Utf8               java/lang/Object
  #30 = Utf8               java/lang/System
  #31 = Utf8               out
  #32 = Utf8               Ljava/io/PrintStream;
  #33 = Utf8               java/io/PrintStream
  #34 = Utf8               println
  #35 = Utf8               (Ljava/lang/Object;)V

也就是说我们class文件中定义的一些常亮,比如final修饰的常亮也会放入class常量池

2.运行时常量池

运行时常量池是InstanceKlass中的一个属性

存在内存中(方法区中的元空间)

可以通过String.iner…方法将字符串放入运行时常量池中

3.字符串常量池

String pool

也就是StringTable

字符串常量池存在于堆区中



版权声明:本文为scjava原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。