JVM内存模型概览

前提运行时数据区的划分堆内存栈内存局部变量表:操作数栈:动态链接:方法出口:本地方法栈元空间程序计数器直接内存彩蛋 – 对象的内存布局对象头实例数据对齐填充参考

前提

Java虚拟机的结构大概可以划分为四部分:

  • 运行时数据区
  • 直接内存
  • 执行引擎
  • 类装载子系统

本篇主要概述的是运行时数据区,也会简单介绍一下直接内存

运行时数据区的划分

如上图描述的,运行时数据区一共可以划分为5大部分,分别是堆内存、栈内存、本地方法栈内存、元空间和程序计数器。

其中,如上图所示,堆内存和元空间是所有线程共享的内存区域栈内存、本地方法栈和程序计数器是线程私有的内存区域。每个内存区域的介绍及作用如下:

堆内存

  1. 堆区域唯一目的就是存放对象实例,也是垃圾回收器管理的主要区域

  2. Java堆可细分为新生代和老年代,默认情况下内存分配比例为1:2,而新生代可再细分可以分为Eden区,S0、S1区,默认情况下内存大小比例为8:1:1

  3. JVM内存调优时主要就是调节堆内存的大小和比例,来减少GC次数。

  4. 根据Java虚拟机规范,如果堆中没有内存完成实例分配,并且堆也无法扩展时,将会跑出OOM异常。

栈内存

虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方式执行的内存模型:每个方法在执行的时候会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接和方法出口灯信息。示例如下图:

线程在执行每个方法都会创建一个栈帧的内存结构,用来存储方法执行时需要的内存空间。每个栈帧主要存储的数据结构及作用如下:

局部变量表:
  1. 存放编译期可知基本数据类型对象指针returnAddress类型(执行一条字节码执行的地址,如return指令)
  2. 64位长度的long和double占用两个局部变量空间
  3. 内存空间在编译期完成分配,所需的局部变量空间是完成确定的,方法运行期间不会改变局部变量表的大小
操作数栈:
  1. 类似于CPU的寄存器,JVM通过指令来对变量进行操作(入栈、计算、出栈)
动态链接:
  1. 由于Java支持继承、封装和多态的特性,所以父类(或接口)的引用可能指向子类(实现类)的实例,此时就需要把父类动态链接到子类(实现类)上
  2. 由于这个过程是运行时每次执行方法时动态确认的。
方法出口:
  1. 退出方法一般有两种情况,一是遇到了方法返回的字节码指令,二是执行过程中遇到异常
  2. 方法出口中会存放一些入栈时的运行时参数,如程序计数器等,以便栈帧结束时恢复到之前的运行时。

在Java虚拟机规范中该区域定义了两种异常情况:

  1. 栈的深度大于虚拟机允许的深度,将跑出StackOverflowError
  2. 如果虚拟机栈可以动态扩展(大部分都可以),当扩展是无法申请到足够的内存,就会跑出OOM异常

本地方法栈

本地方法栈与栈内存区域类似,主要的区别是本地方法栈为虚拟机调用Native方法时使用到的内存区域,有的虚拟机(如Sun HotSpot)直接把本地方法栈和虚拟机栈合二为一,本地方法栈与继续挤栈类似,都可能跑出SOF和OOM

元空间

元空间(在jdk1.6及之前叫方法区)存放的是虚拟机加载的类信息、常量、静态变量、JIT编辑后的代码等数据。

在jdk1.7之前,由于GC分代收集扩展至方法区,所有很多人把方法区也称为“永久代”,在jdk1.7时,JVM把运行时常量放入了堆中jdk1.8时,把整个方法区移除,取而代之的是“元空间”

Java虚拟机规定,当无法再申请到足够的内存空间时,将抛出OOM异常

程序计数器

程序计数器记录的是线程所执行的字节码的位置(可以看做是行号),此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM错误的区域

直接内存

从上面的图来看,直接内存并不是JVM运行时数据区的一部分也不是Java虚拟机规范中定义的内存区域,但是这部分内存也不频繁使用,所以放在这里一并介绍。

在jdk1.4之后引入的NIO,引入了一种基于通道(channel)与缓存区(buffer)的IO方式,它可以直接使用Native方法来分配内存,然后存储在DirectBetyBuffer对象来操作直接内存,以此来减少堆内存与Native堆中来回复制数据的损耗,带来了性能的极大提升。

直接内存既然不属于JVM的内存管理,当然就没有这部分的内存规范信息,但是这个区域在本地内存不足时,可是有可能导致OOM的。

彩蛋 – 对象的内存布局

在Java虚拟机中创建一个对象是一件很复杂的事情,需要经历多个步骤,这些在以后的文章会专门研究。本篇既然提到了JVM的运行时数据区域,那就再介绍一些对象的内存布局。

在Hot Spot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

对象头包括两部分:

  1. 存储对象自身的运行时数据,如hashcode、GC年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。在32位和64位虚拟机(未开启压缩指针)分表位32bit和64bit,官方称这部分为“Mark Word”。

    锁状态 25bit 4bit 1bit
    偏向锁标志
    2bit
    无锁态 对象的hashCode 分代年龄 0 01
    轻量级锁 指向栈中锁记录的指针 00
    重量级锁 指向重量级锁的指针 10
    GC标记 11
    偏向锁 23bit的线程ID,2bit的Epoch 分代年龄 1 01
  2. 另一部分是类型指针,即指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外,如果对象是这个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,以便虚拟机通过对象的元数据来确定Java对象的大小。

实例数据

实例数据是对象正在存储有效信息的部分,也是代码中说定义的各种类型的字段内容,无论是在父类中继承的,还是在子类中定义的,都需要记录起来。

对齐填充

对齐填充部分并不是必然存在的,它也没有特殊含义,只是用于占位符的作用。由于Hot Spot虚拟机的自动内存管理要求对象的起始地址是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍(而对象头部分的大小正好是8字节,也就是说,创建一个空对象的成本至少是8字节)。所以,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

参考

《深入理解Java虚拟机 JVM高级特性与最佳实现》 周志明

CSDN

更多JVM专题文章请查看 → 程序员小污的博客