Java虚拟机之自动内存管理
1 运行时数据区域

运行时数据区域可以划分为由所有线程共享的方法区、堆和线程隔离的虚拟机栈、本地方法栈、程序计数器。
1.1 程序计数器(Program Counter Register)-线程隔离
程序计数器是一块较小的内存空间,它是当前线程所执行的字节码的行号指示器。
Java虚拟机的多线程是通过多线程轮流切换、分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法(Native),这个计数器值则应为空。
该内存区域是唯一一个不存在OutOfMemoryError情况的区域
1.2 Java虚拟机栈(Java Virtual Machine Stack)-线程隔离
Java虚拟机栈是线程私有的,它的生命周期与线程相同。每个Java方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存放了编译期Java虚拟机基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)
局部变量表中的存储空间以局部变量槽(Slot)来表示,long和double类型的数据占用两个变量槽,其余数据类型占用一个。
局部变量表所需内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间时完全确定的,方法运行期间不会改变局部变量表的大小(即变量槽的数量)
如果线程请求的栈深大于虚拟机所允许的深度,将抛出StackOverflowError;如果Java虚拟机栈容量可以动态扩展,当扩展时无法申请到足够的内存,会抛出OutOfMemoryError。
1.3 本地方法栈(Native Method Stacks)-线程隔离
虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用本地(Native)方法服务。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError异常和OutOfMemoryError异常
1.4 堆(Java Heap)-线程共享
Java堆是虚拟机所管理的内存中最大的一块,Java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。
The heap is the runtime data area from which memory for all class instances and arrays is allocated.
所有的对象实例以及数组都应当在堆上分配
-《Java虚拟机规范》
Java堆的作用是存放对象实例,几乎所有的对象实例都在这里分配内存。随着即时编译技术的发展,Java对象实例分配在堆上也变得不是那么绝对了。
Java堆也称GC堆,是垃圾收集器管理的区域。大部分垃圾收集器都是基于分代收集理论设计的,所以往往我们会将Java堆划分为新生代、老年代等不同区域,以便更好地回收内存。
虽然Java堆是线程共享的,但是我们也可以为各个线程划分出私有的分配缓冲区,以便更快地分配堆内存
Java堆既可以被实现成固定大小的,也可以是可扩展的(一般是可扩展的,通过-Xms和-Xmx设定堆大小)。当Java堆内存不足以完成实例分配时,将会抛出OutOfMemoryError。
1.5 方法区(Method Area)-线程共享
方法区也是各线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java虚拟机规范》将此区域描述为堆的一个逻辑部分。
HotSpot团队将垃圾收集器的分代收集理论扩展至方法区,使用永久代来实现方法区,省去了编写专门的针对方法区的内存管理代码。这种设计使方法区容易遇到内存溢出问题,同时会使HotSpot虚拟机在少数情况下与别的虚拟机有不同的表现。
后续HotSpot舍弃了永久代的设计,移出永久代里的字符串常量池、静态变量放至堆中。采用本地内存来实现方法区,更名永久代为元空间,在该区域保存类型信息(主要)、代码缓存、运行时常量池等数据
字符串常量池和运行时常量池有什么区别呢?
在还存在永久代的设计方案里,字符串常量池是被包括在运行时常量池中的,JDK1.7后字符串常量池就被移至堆中。
运行时常量池作为方法区的一部分,存放着Class文件中的常量池表(Constant Pool Table)这项信息。这张表里存放着编译期生成的各种字面量与符号引用(包括由符号引用翻译出来的直接引用)。
字符串常量池类似一个缓存区。考虑到大量频繁地创建字符串,会影响到程序的性能,因此专门为字符串常量开辟了字符串常量池,用户创建字符串常量时,首先查询常量池中是否存在该字符串,如果存在直接返回引用;不存在时,创建新的字符串常量并放入池中。
方法区的内存,也可以固定大小或者可扩展大小,该区域甚至可以选择不实现垃圾收集。这块区域的垃圾收集目标主要是针对常量池的回收和对类型的卸载。如果方法区无法满足新的内存分配需求,会抛出OutOfMemoryError异常
1.6 直接内存(Direct Memory)
直接内存不属于虚拟机运行时数据区的一部分。
某些机制(如,NIO)可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,避免在Java堆和Native堆中来回复制数据
直接内存不受Java堆大小的限制,但仍受本机总内存大小的限制。当程序员根据实际内存情况配置各内存区域大小参数(如,-Xmx)时,遗漏直接内存的使用空间,就容易导致动态扩展时出现OutOfMemoryError
2 HotSpot虚拟机对象
2.1 对象的创建
创建对象(不包括数组和Class对象)通常是通过字节码new指令。
创建流程包括:
- 检查这个指令的参数是否能在常量池(方法区)中定位到一个类的符号引用
- 检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程
- 为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定
- 将分配到的内存空间都初始化为零值
- 对对象进行必要的设置,即在对象的对象头(Object Header)中存放一些信息(如,GC分代年龄信息、是否启用偏向锁、这个对象是哪个类的实例等)
new指令之后会接着执行invokespecial指令,即<init>()方法,按照程序员的意愿对对象进行初始化(Java编译器会在遇到new关键字的地方同时生成new指令与invokespecial指令)
其中第3步,在堆上为对象分配空间有两种方式:
- 垃圾收集器带有空间压缩整理的能力,则Java堆是绝对规整的,则可通过指针碰撞(Bump the Pointer,指针向空闲空间方向挪动一段与对象大小相等的距离)的方式分配内存
- 其他垃圾收集器(如CMS这种基于标记-清除算法的收集器),需要采用较为复杂的空闲列表来分配内存
分配内存还需要考虑到多线程并发的情形,我们可以通过两种方式实现线程安全:
- 堆分配内存空间的动作进行同步处理(如CAS方式)
- 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB,Thread Local Allocation Buffer,可以通过配置参数设定是否设置分配缓冲)
2.2 对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
对象头中包括两类信息
2.3 对象的访问
3 内存溢出
4 参考文献
- 参考书籍:《深入理解Java虚拟机》
- 深刻理解运行时常量池、字符串常量池 - 掘金 (juejin.cn)