参考:周志明 - 《深入立即 Java 虚拟机》- Chapter 2
1. 概述
Java 程序员将内存控制的权利交给虚拟机,当虚拟机出现内存泄漏和溢出方面的问题,排除错误需要深入理解 Java 虚拟机是如何使用内存的。
2. 运行时数据区域
Java 虚拟机在运行程序的时候会把他所管理的内存划分为若干个不同的数据区域。有的区域随着虚拟机进程的启动而存在,有的依赖于用户线程的启动和结束而建立和销毁
- 所有线程共享的数据区
- 方法区 - Method Area
- 堆 - Heap
- 线程隔离区域(线程私有)
- 虚拟机栈 - VM Stack
- 本地方法栈 - Native Method Stack
- 程序计数器 - Program Counter Register
2.1 程序计数器
是一块较小的内存空间,可以看成是当前线程所执行的字节码的行号指示器。
在任何时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程的程序计数器互不影响,独立存储,这样的内存区域称为“线程私有”的内存。
- 线程执行 Java 方法:程序计数器记录的是正在执行的虚拟机字节码指令的地址;
- 线程执行 Native 方法:值为空(Undefined);
- 该内存区域是 Java 虚拟机规范中唯一一个没有规定任何 OutOfMemoryError 的区域。
2.2 Java 虚拟机栈
描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用来存储局部变量表、操作数帧、动态链接、方法出口等信息。
- 局部变量表:存放编译器可知的8中基本数据类型、引用类型和 returnAddress类型(指向了一条字节码指令的地址);
- 64位长度的 long 和 double 占用两个局部变量空间,其余数据类型占用一个;
- 局部变量表所需的内存空间在编译期完成分配,方法运行期间,局部变量表不变。
- 该区域有两种异常状况:
- 线程请求的栈深度大于虚拟机所允许的深度,抛出 StackOverflowError 异常;
- 若动态扩展时无法申请到足够的内存,抛出 OutOfMemoryError 异常。
2.3 本地方法栈
虚拟机栈:为虚拟机执行 Java 方法服务
本地方法栈:为虚拟机使用的 Native 方法服务
异常情况同虚拟机栈。
2.4 Java 堆
- 是虚拟机管理的内存中最大的一块
- 存在的唯一目的:存放对象实例,几乎所有的对象 实例都在这里分配内存。
- 是垃圾收集管理的主要区域。- GC堆
- 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError
2.5 方法区
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是他却有一个别名 - “非堆(Non-Heap)”
- 当方法区无法满足内存分配需求时,抛出 OutOfMemoryError 异常
2.6 运行时常量池
是方法区的一部分。
Class 文件有类的版本、字段、方法、接口等描述信息,还有一个常量池信息,用于存放编译期生成的各种字面量和符号引用。
- 运行时常量池相较于 Class 文件常量池的特征:具备动态性
2.7 直接内存
不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但也可能导致 OutOfMemoryError 异常。
3. HotSpot虚拟机对象
以下讨论基于 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
3.1 对象的创建
- 虚拟机遇到一条 new 指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用。并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就先要执行相应的类加载过程。
- 类加载检查通过后,为新生对象分配内存。两种内存分配方式:
- 指针碰撞(Bump the Pointer)
假设 Java 堆中内存是绝对规整的,所有用过的内存放在一边,没有用过的(空闲的)放在另外一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表(Free List)
Java 内存中的内存不是规整的,使用过的和空闲的相互交错,指针碰撞失效。则虚拟机需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表记录。
- 如何选择?
- 由 Java 堆是否规整决定,Java 堆是否规整又由采用的垃圾收集器是否带有压缩整理功能决定;
- Serial、ParNew 等带 Compact 过程的收集器:指针碰撞
- CMS 等基于 Mark - Sweep 算法的收集器:空闲列表
- 解决并发情况下的线程安全问题:
- 对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称之为本地线程分配缓冲(Thread Local Allocation Buffer - TLAB)。哪个线程需要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过 -XX:+UseTLAB 参数来设定。
3.2 对象的内存布局
对象在内存中的布局可以分为3块区域:对象头(Mark Word 和 类型指针);实例数据(Instance Data);对齐填充(Padding)
- 对象头 -> Mark Word
存储对象自身的运行时数据,如哈希表、GC分代年龄、锁标志状态、线程持有的锁、偏向线程ID、偏向时间戳等。非固定的数据结构。
- 在32位 HotSpot 虚拟机中,如果对象处于未锁定的状态下,那么 Mark Word 的32位中的25位用于存储对象哈希码,4位用于存储对象分代年龄,2位用于存储锁标志位,1位固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的储存内容见表:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
- 类型指针:即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 对齐填充
不是必然存在的,也没有特别的含义,仅起着占位符的作用。对象头部分正好是8字节的整数倍,所以当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
3.3 对象的访问定位
对象访问方式取决于虚拟机实现。主流访问方式有使用句柄和直接指针
- 使用句柄访问的最大优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾搜集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要改变
- 使用直接指针:速度更快,节省了一次指针定位的时间开销