JVM内存模型概要

JVM 从软件层屏蔽不同操作系统在底层硬件与指令的区别

内存模型

:这个图,看看即可
内存模型示意图

STW:暂停用户线程,使GC效率变高

1. 程序计数器

一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。通过该指示器,来完成诸如:分支、循环、跳转、异常处理、线程恢复等基础功能

2. Java虚拟机栈

生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(stack frame) 用于存储:局部变量表、操作数栈、动态链接、方法出口等信息
每个方法调用直至执行完毕的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈(FIFO)的过程

涉及的异常:StackOverflow、OutOfMemoryError(HotSpot虚拟机中不存在,旧的Classic虚拟机存在,所以了解即可)

2.1 局部变量表

存放数据类型:存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用类型(reference类型,引用指针)和returnAddress类型(一条字节码指令地址)
数据类型存放标准:这些数据类型,在局部变量表中的存储空间已局部变量槽(Slot)来表示,其中64位长度的long和double会占用两个变量槽,其余的数据类型只占用一个。
内存分配过程:局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的。

3. 本地方法栈

与虚拟机栈作用类似,只不过这里存放的是虚拟机使用的本地(Native)方法服务

4. Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。(书中提到的:由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段,这些已经导致了一些微妙的变化,导致Java对象实例都分配在堆上已经不是绝对的了)
无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储都只能是对象的实例,将Java堆细分的目的只是为了更好的回收内存,或者更快地分配内存。
涉及异常:OutOfMemoryError

5. 方法区

用于存储已经被虚拟机加载的:类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
历史沿袭: JDK7之前,这部分区域还被称之为永久代,在JDK7的时候已经把字符串常量池和静态变量移出。到了JDK8,完全废弃了永久代概念,改用元空间(Metasapce)来实现。
涉及异常:OutOfMemoryError

5.1 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池表(Constant Pool Table),用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《java虚拟机规范》中定义的内存区域,但是这部分内存也频繁被使用,而且也有可能导致OutOfMemoryError异常。
注:这部分跟NIO直接相关
在JDK1.4中引入的NIO(New Input/Ouput)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用来进行操作。这样能在一部分场景中显出提高性能,因为避免了在Java堆和Native堆中来回复制数据。

对象创建流程

整体流程
对象创建整体流程

大体上可以分为5个步骤:

  1. 类加载检查:
  2. 分配内存
  3. 初始化
  4. 设置对象头
  5. 执行init方法

1. 类加载检查

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。(注:这个过程比较复杂
参考另外一篇文章:虚拟机加载机制

2. 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。

划分内存的方法

  • “指针碰撞”(Bump the Pointer)(默认用指针碰撞):如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  • “空闲列表”(Free List):如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

    解决内存分配并发问题的方法

  • CAS(compare and swap):虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过XX:+/UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启XX:+UseTLAB),XX:TLABSize 指定TLAB大小。

3. 初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。
对象头整体示意图

HotSpot虚拟机的对象头包括两部分信息,

  • 第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。
  • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

数组对象与普通对象的内存结构区别在于,数组对象头里面多了一个数组长度
数组类型对象头
32位对象头示意图:
32位对象头示意图
64位对象头示意图:
64位对象头示意图


关于对象头的作用(特指markword)

Java对象头中的 Mark Word 是对象在内存布局中的关键部分,用于存储对象的运行时元数据。
其字段的设计直接服务于 多线程并发内存管理锁优化 等需求。

关键字段的作用与设计原因
  • (1) 哈希码(Hash Code)
    • 作用:存储对象的默认哈希值(Object.hashCode()),用于哈希表(如HashMap)快速查找。
    • 存在原因
      • 哈希码计算可能较耗时(尤其是大对象),在首次调用时缓存到Mark Word,避免重复计算。
      • 若对象被锁升级(如进入重量级锁状态),哈希码会被移动到Monitor对象中保存。
  • (2) 分代年龄(Age)
    • 作用:记录对象经历的垃圾回收次数(4位,最大值为15)。
    • 存在原因
      • 用于 分代垃圾回收:对象在新生代(Young Generation)存活一定次数后,晋升到老年代(Old Generation)。
      • 4位长度限制决定了 最大年龄为15,超过后强制晋升(可通过-XX:MaxTenuringThreshold调整)。
  • (3) 锁标志(Lock Flag)
    • 作用:标识当前对象的锁状态(无锁、偏向锁、轻量级锁、重量级锁、GC标记)。
    • 存在原因
      • 支持 锁升级机制(如从偏向锁到轻量级锁),减少多线程竞争时的性能开销。
      • 不同锁状态对应不同的同步策略(如CAS自旋、操作系统互斥量)。
  • (4) 线程ID(Thread ID)
    • 作用:在偏向锁状态下,记录持有锁的线程ID。
    • 存在原因
      • 偏向锁优化:同一线程多次获取锁时,无需通过CAS操作竞争,直接通过线程ID验证。
      • 减少锁竞争开销,适用于 单线程重复访问同步块 的场景(如早期阶段的对象初始化)。
  • (5) Epoch
    • 作用:偏向锁的“时间戳”,用于批量重偏向(Bulk Rebiasing)。
    • 存在原因
      • 当某一类对象(Class)的偏向锁被多个线程频繁竞争时,JVM会通过增加Epoch值,批量撤销该类所有对象的偏向锁,避免反复单个撤销的性能损耗。
      • 每次批量重偏向时,Epoch递增,对象若属于该类且旧Epoch不匹配,则直接进入无锁状态。
锁状态转换与字段动态复用

Mark Word的字段是 动态复用 的,同一内存区域在不同锁状态下存储不同数据,例如:

  1. 无锁状态:存储哈希码和分代年龄。
  2. 偏向锁状态:覆盖哈希码,存储线程ID和Epoch。
  3. 轻量级锁:存储指向栈中锁记录(Lock Record)的指针。
  4. 重量级锁:存储指向Monitor对象(管程)的指针。

这种设计通过 复用内存空间,避免了为不同锁状态分配独立字段的内存浪费。

为什么需要这些字段?

这些字段的存在是为了在 性能功能 之间取得平衡:

  1. 支持高效并发:通过锁状态标志和线程ID,实现偏向锁、轻量级锁等优化,减少线程阻塞。
  2. 减少内存占用:动态复用字段,避免为不常用的数据(如Monitor指针)单独分配空间。
  3. 适应垃圾回收:分代年龄和GC标记字段直接服务于分代回收算法。
  4. 兼容对象标识:哈希码和锁状态的分离,确保对象在加锁后仍能保留哈希码(通过Monitor存储)。
示例:偏向锁的工作流程
  1. 初始状态(无锁):对象未被任何线程锁定,Mark Word存储哈希码。
  2. 首次加锁(偏向锁):线程A通过CAS将Mark Word的线程ID设为自身ID,进入偏向锁状态。
  3. 重入锁:线程A再次访问同步代码时,检查线程ID匹配,直接执行,无需同步操作。
  4. 竞争发生:线程B尝试获取锁,发现线程ID不匹配,触发偏向锁撤销,升级为轻量级锁。
  5. 批量重偏向:若该类的对象频繁发生偏向锁竞争,JVM通过Epoch机制批量重置偏向锁。

关于锁升级相关内容可见本文章中的相关描述:synchronized


5. 执行init方法

执行init方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。