Java虚拟机的内存空间分为5个部分

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • 方法区

jvm内存结构.jpg

从JDK 1.8起,元数据区取代了永久代,元数据区本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的差别是元数据空间不在虚拟机内,而是直接使用本地内存

程序计数器(PC 寄存器)

定义

程序计数器是一块较小的内存空间,是当前线程正在执行的那条指令的地址

Java代码编译的字节码后未经过JIT编译前,是通过字节码解释器进行翻译执行的,首先为解释器装取载入内存的字节码,然后按照顺序读取字节码指令,读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程,如果是单线程的话,程序会按照指令的顺序执行下去,那么程序计数器是多余的,但是实际上程序是由多个线程协作完成的。

JVM 多线程实际是通过CPU时间片轮转算法(线程轮流切换分配处理器时间)实现的,也就是说线程在执行过程中会耗尽时间片而被挂起,然后另外一个线程会获取到时间片执行,当挂起的线程重新获取到时间片的时候,它想要从被挂起的地方继续执行,那么就必须知道它上次执行的位置,在JVM中,就是通过程序计数器来记录某个线程的字节码执行位置,因此程序计数器是具有线程隔离的特性,也就是说每个线程工作时都有自己独立的计数器,

作用

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  • 在多线程的情况下 程序计数器记录当前线程执行的位置,从而达到切换线程时,继续执行前面未执行的代码

特点

  • 线程隔离,每个工作线程都有自己独立的计数器
  • 执行Java方法时,是有值的,切记录的是正在执行的字节码指令地址
  • 执行native方法时,计数器值是Undefined,因为native方法是Java 通过 JNI调取本地C/C++方法库的。所以该方法是由C/C++实现的,内存也是C/C++来决定的,所以无法产生相应的字节码,因此值为Undefined
  • 是一块较小的内存空间,几乎可以忽略不记,所以程序计数器也是唯一一个在JVM规范里没有规定任何OutOfMemoryError的区域

Java虚拟机栈(Java栈)

定义

Java虚拟机栈是描述Java方法运行过程的内存模型

每个Java方法在执行时,会创建一个栈帧(stack frame)的区域,用于存放方法执行过程重的一些信息,结构为“局部变量表、操作数栈、动态链接、方法出口”,经常提到的“栈内存和堆内存”中的栈内存指的就是虚拟机栈,确切的说是指虚拟机栈的栈帧中的局部变量表
JVM 栈帧.jpg

压栈出栈过程

当方法运行过程中需要创建局部变量时,就将局部变量的值存入到栈帧中的局部变量表中。

Java虚拟机栈栈顶的栈帧时当前正在执行的活动栈,也就是正在执行的方法,PC寄存器也会指向这个地址,只有当前这个活动的栈帧的本地变量可以被操作数栈使用,当这个栈帧调用另外一个方法,与之对应的栈帧也会被创建,新创建的栈帧会押入栈顶,变成新的活动栈帧。

方法结束后,当前栈帧会被移除,栈帧的返回值会变成新的活动栈帧中操作数栈的一个操作数,如果没有返回值,那么新的活动栈中的操作数栈的操作数没有变化。

由于Java虚拟机栈是线程独立的,因此不会有数据一致性的问题

局部变量表

定义一个数字数组,主要用于存储方法参数、定义在方法体内部的局部变量(包括参数),数据类型包括各种基本类型、对象引用,但是不存储对象的内容。局部变量所需要的内存空间在编译器就完成分配,在运行的时候是不会改变局部变量表的大小。

局部变量的容量以变量槽(Variable Slot)为最小单位,以下简称Slot每个变量槽最大存储32位的数据类型,对于64位的数据类型(long,double),JVM会分配两个连续的Slot来存储,局部变量表有以下几个特点:

  • 索引访问, JVM 虚拟机会为局部变量表中的每个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this,会存放在 index 为 0 的 slot 处,其余的参数表顺序继续排列。
  • Slot 复用,栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
  • GC 回收 局部变量表中的 Slot 是否还存有关于对象的引用,有的话就不会回收

操作数栈

操作数栈是一个后进先出栈。操作数栈的元素可以是任意的Java数据类型。方法刚开始执行时,操作数栈是空的,在方法的执行过程中,通过字节码指令对操作数栈进行压栈和出栈的操作,通常进行算数运算是通过操作数栈来进行的,调用其他方法的时候也是通过操作数栈来进行参数传递的。操作数栈可以理解为栈帧中计算的临时数据存储区,有以下几个特点

  • 栈顶缓存技术 由于操作数是存储在内存中,频繁的进行内存读写操作会影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,以此降低对内存的读写次数,从而提升执行引擎的执行效率
  • 每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编期期就定义好。32bit的类型占用一个栈单位深度,64bit类型占用两个栈单位深度。
  • 只能通过标准的入栈和出栈完成一个数据访问,不能用索引访问

以下通过一个加法来看看操作数栈和局部变量表的数据变化

Java代码:

1
2
3
4
5
public static int add(int a, int b){
int c = a + b;
return c;
}
add(100 , 98);

操作数栈流程如下

  • 0 : iload_0 把局部变量索引0压栈,即变量a压栈
  • 1 : iload_1 局部变量1压,即变量b压栈
  • 2 : iadd 弹出两个变量,求和,结果压栈
  • 3 : istore_2 弹出结果,并放进局部变量2
  • 4 : iload_2 局部变量2 压栈
  • 5 : ireture 返回

方法的调用

  • 静态链接 :当一个字节码文件被装载进JVM内部时,如果被调用的目标在编译器可知,且运行期间保持不变,这种情况下调用方法的符号引用转为直接引用的过程,叫做静态链接
  • 动态链接:如果被调用的方法无法在编译期被确定下来,只能在运行时将调用的方法符号引用转为直接引用,这种引用转换过程具备动态性,因此叫动态链接
  • 方法绑定
    • 早期绑定:被调用的目标方法如果在编译器可知 且运行时保持不变
    • 晚期绑定:被调用的方法在编译器无法确定,只能在程序运行期间根据实际的类型绑定相应的方法
  • 非虚方法 :如果在编译期间就确定了具体的调用版本,则这个版本在运行期间是不可变的。这样的方法称为非虚方法,静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法除此之外都是虚方法
  • 虚方法表:面向对象的编程中,会很频繁的使用动态分配,如果每次的动态分配的过程都要重新在类的方法元数据中搜索合适的目标,就可能影响到执行效率,因此为了提高性能,JVM采用类的方法区建立一个虚方法表,使用索引表来代替查找
    • 每个类都有一个虚方法表,用来存放各个方法的实际入口
    • 虚方法表会在类的加载阶段被创建和初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕
  • 方法重写的本质
    • 找到操作数栈顶的第一个执行的元素所执行的对象的实际类型,即做C。如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验
    • 如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
    • 按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
    • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

Java 中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++ 中则使用关键字 virtual 来显式定义。如果在 Java 程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字 final 来标记这个方法。

Java 虚拟机栈的特点

  • 运行速度很快,仅次于PC寄存器
  • 局部变量表随着栈帧创建而创建,它的大小在编译期间就确定,创建时只需要分配事前规定好的内存大小即可,在方法的运行过程中局部变量表的大小不会发生变化
  • 异常
    • StackOverFlowError 若Java虚拟机栈的大小不允许动态扩展。那么当线程请求栈的深度超过当前虚拟机栈的最大深度时,则抛出StackOverFlowError,也就是说出现 StackOverFlowError 时,内存空间可能还有很多。
    • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • 栈决定了函数调用的深度。这也是慎用递归调用的原因。递归调用时,每次调用方法都会创建栈帧并压栈。当调用一定次数之后,所需栈的大小已经超过了虚拟机运行配置的最大栈参数,就会抛出StackOverflowError 异常。
  • 使用 -Xss 设置栈大小,通常几百K就够用了。由于栈是线程私有的,线程数越多,占用栈空间越大

本地方法栈

定义

本地方法栈是为了JVM运行native方法准备的空间,由于Native方法是C语言实现的,所以通常称为C栈,它与Java虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型

本地方法被执行时,找本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。方法执行结束后,相应的栈帧也会出栈,并释放出内存空间,也一样会抛出StackOverFlowErrorOutOfMemoryError 错误。这个栈一般会在线程创建的时候按线程分配。

定义

堆是用来存放对象的内存空间,几乎所有的对象都存放在堆里

特点

  • 线程共享,整个Java虚拟机就只有一个堆,所有的线程都访问一个堆,而程序计数器、Java虚拟机栈、本地方法栈都是对应一个线程对应一个。
  • 在虚拟机启动时创建
  • Java虚拟机规范规定堆可以处于物理上不连续的内存空间中,但是逻辑上它们应该被视为连续的
  • 是垃圾回收的主要场所
  • 可分为新生代老年代
  • 不同的区域存放不同生命周期的对象,这样可以根据不同的区域采用不同的垃圾回收算法
  • 堆的大小既可以固定也可以扩展,因此当线程请求分配内存,但堆已满,且内存已无法再扩展时,就抛出 OutOfMemoryError 异常。
  • 而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。

新生代和老年代

  • 老年代比新生代生命周期长
  • 新生代和老年代默认比例为 1:2 ,JVM调参数 为XX:NewRatio = 1
  • 新生代 分为 1个Eden Space和2个Survivor Space
  • 几乎Java 对象都是在 Eden 区被 new 出来的,Eden 放不了的大对象,就直接进入老年代了。

方法区

定义

Java 虚拟机规范中定义方法区是堆的一个逻辑部分,主要存放以下信息

  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

特点

  • 线程共享 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的,整个虚拟机中只有一个方法区
  • 永久代 方法区中的信息,一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,把方法区称之为永久代
  • 内存回收率低。方法区中的信息一般要长期存在,回收一遍之后可能只有少量的信息无效,主要回收目标是:对常量池的回收对类型的卸载
  • Java 虚拟机规范对方法区的要求比较宽松。 和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。

运行时常量池

每一个Class文件中,都维护着一个常量池,Java虚拟机加载一个类时,被复制到方法区的运行时常量池,并且在运行期间还可以对常量池中添加新的常量

鸣谢

参考《Java 虚拟机底层原理知识总结》
参考 _Rt:《一文搞懂JVM内存结构》
参考 re-phoenix:《01-JVM内存模型:程序计数器》
参考 re-phoenix:《02-JVM内存模型:虚拟机栈与本地方法栈》