JVM虚拟机-内存分布

JVM虚拟机-内存分布

1.内存区域划分

Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为不同的数据区域。下面一张图描述了一个HelloWorld.java文件被JVM加载到内存中的过程。

a. HelloWorld.java文件首先需要经过编译器编译,生成HelloWorld.class字节码文件

b. Java程序中访问HelloWorld这个类时,需要通过ClassLoader(类加载器)将HelloWorld.class加载到JVM内存中。

c. JVM 中的内存可以划分为若干个不同的数据区域,主要分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区

img

1.1程序计数器(Program Counter Register)

Java程序是多线程的,CPU可以在多个线程中分配执行时间片段。当某一个线程被CPU挂起时,需要记录代码已经执行到的位置,方便CPU重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用。

“程序计数器”是虚拟机中一块较小的内存空间,主要用于记录当前线程执行的位置。

每个线程都会记录一个当前方法执行到的位置,当 CPU 切换回某一个线程上时,则根据程序计数器记录的数字,继续向下执行指令。

img

实际上除了上图演示的恢复线程操作之外,其它一些我们熟悉的分支操作、循环操作、跳转、异常处理等也都需要依赖这个计数器来完成。

关于程序计数器还有几点需要格外注意:

  1. 在Java虚拟机规范中,对程序计数器这一区域没有规定任何OutOfMemoryError情况(或许是感觉没有必要吧)。
  2. 程序计数器是线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  3. 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。

1.2虚拟机栈

虚拟机栈也是线程私有的,与线程生命周期同步。在Java虚拟机规范中,对这个区域规定了两种异常状况:

  1. StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。
  2. OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出。

虚拟机的初衷是用来描述Java方法执行的内存模型,每个方法被执行的时候,JVM都会在虚拟机栈中创建一个栈帧。

栈帧

栈帧(Stack Frame)适用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈桢。

一个线程包含多个栈帧,而每个栈帧都包含局部变量表,操作数栈,动态连接,返回地址等。

img

局部变量表

局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在Java编译成class文件的时候,就会在方法的Code属性表中的max_locals数据项中,确定该方法需要分配的最大局部变量表的容量。如下代码所示:

public static int add(int k) {
	int i = 1;
	int j = 2;
	return i + j + k;
}

使用javap -v反编译之后,得到如下字节码指令:

 public static int add(int);
    descriptor: (I)I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_2
         5: iload_1
         6: iadd
         7: iload_0
         8: iadd
         9: ireturn

上面的locals=3就是代表局部变量表的长度是3,也就是说经过编译之后,局部变量表的长度已经确定为3,分别保存:参数k和局部变量i,j。

注意:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈。

和局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回操作数栈中)。

动态连接

动态连接的主要目的是为了支持方法调用过程中的动态连接。

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接。

返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。
  • 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

实例讲解

我用一个简单的 add() 方法来演示, 代码如下:

public int add() {
	int i = 1;
	int j = 2;
	int result = i + j;
	return result + 10;
}

我们经常会使用 javap 命令来查看某个类的字节码指令,比如 add() 方法的代码,经过 javap 之后的字节码指令如下:

0: iconst_1	(把常量 1 压入操作数栈栈顶)
1: istore_1	 (把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置)
2: iconst_2	 (把常量 2 压入操作数栈栈顶)
3: istore_2   (把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置)
4: iload_2    (把局部变量表索引为 2 的值放入操作数栈栈顶)
5: iload_1     (把局部变量表索引为 1 的值放入操作数栈栈顶)
6: iadd       (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
7: istore_3    (把操作数栈栈顶的出栈放入局部变量表索引为 3 的位置)
8: iload_3     (把局部变量表索引为 3 的值放入操作数栈栈顶)
9: bipush   10   (把常量 10 压入操作数栈栈顶)
11: iadd      (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
12: ireturn    (结束)

从上面字节码指令也可以看到,其实局部变量表和操作数栈在代码执行期间是协同合作来达到某一运算效果的。

首先说一下各个指令代表什么意思:

  • iconst 和 bipush,这两个指令都是将常量压入操作数栈顶,区别就是:当 int 取值 -1~5 采用 iconst 指令,取值 -128~127 采用 bipush 指令。
  • istore 将操作数栈顶的元素放入局部变量表的某索引位置,比如 istore_5 代表将操作数栈顶元素放入局部变量表下标为 5 的位置。
  • iload 将局部变量表中某下标上的值加载到操作数栈顶中,比如 iload_2 代表将局部变量表索引为 2 上的值压入操作数栈顶。
  • iadd 代表加法运算,具体是将操作数栈最上方的两个元素进行相加操作,然后将结果重新压入栈顶。

1.3 本地方法栈

本地方法栈和上面介绍的虚拟机栈基本相同,只不过是针对本地(native)方法。在研发过程中如果涉及JNI可能接触本地方法栈多一些。

1.4 堆

Java堆(Heap)是JVM所管理的内存中最大的一块,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是Java垃圾收集器(GC)管理的主要区域,有时候也叫作“GC 堆”。同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

1.5 方法区

方法区(MethodArea)也是JVM规范里规定的一块运行时数据区。方法区主要是存储已经被JVM加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。

1.6 异常演示

StackOverflowError栈溢出异常

递归调用是造成StackOverflowError的一个常见场景,比如如下代码:

public class Test {

    public static void main(String[] args) {
        Test t = new Test();
        t.method();
    }

    public  void method(){
        method();
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VYJWz1DC-1585921201928)(C:\Users\大狼狗skr~\AppData\Roaming\Typora\typora-user-images\1585920587043.png)]

在method方法中,递归调用了自身,并且没有设置递归结束条件。运行上述代码时,则会产生StackOverflowError。

原因就是每调用一次method方法时,都会在虚拟机栈中创建出一个栈帧。因为是递归调用,method方法并不会退出,也不会将栈帧销毁,所以必然会导致StackOverflowError。因此当需要使用递归时,需要格外谨慎。

OutOfMemoryError内存溢出异常

理论上,虚拟机栈、堆、方法区都有发生OutOfMemoryError的可能。但是实际项目中,大多发生于堆当中。比如以下代码:

public class Test {

    public static void main(String[] args) {
        List<Test> list = new ArrayList<>();
        while (true){
            list.add(new Test());
        }
    }

}

在一个无限循环中,动态的向ArrayList中添加新的HeapError对象。这会不断的占用堆中的内存,当堆内存不够时,必然会产生OutOfMemoryError,也就是内存溢出异常。

img

总结

对于JVM运行时内存布局,我们需要始终记住一点:上面介绍的这5块内容都是在Java虚拟机规范中定义的规则,这些规则只是描述了各个区域是负责做什么事情、存储什么样的数据、如何处理异常、是否允许线程间共享等。千万不要将它们理解为虚拟机的“具体实现”,虚拟机的具体实现有很多,比如Sun公司的HotSpot、JRocket、IBMJ9、以及我们非常熟悉的 Android Dalvik 和 ART 等。这些具体实现在符合上面 5 种运行时数据区的前提下,又各自有不同的实现方式。

img

总结来说,JVM 的运行时内存结构中一共有两个“栈”和一个“堆”,分别是:Java 虚拟机栈和本地方法栈,以及“GC堆”和方法区。除此之外还有一个程序计数器,但是我们开发者几乎不会用到这一部分,所以并不是重点学习内容。 JVM 内存中只有堆和方法区是线程共享的数据区域,其它区域都是线程私有的。并且程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值