Java 内存管理机制:01 内存区域详解
目录
JVM 运行时的数据区域
首先获取一个直观的认识:
总共也就这么5个区(直接内存不属于JVM运行时数据区的一部分),除了程序计数器其他的地方都有可能出现OOM(OutOfMemoryError),其中像是程序计数器和两个栈(Java 虚拟机栈&本地方法栈)都是线程私有的,所以肯定是线程隔离的。而其他2个区就是线程共享的了,也就是说,如果有多个线程要同时访问者两个区的数据,是会出现线程安全问题的。接下来,我们将对这些区域进行详细的介绍。
程序计数器
- 当前线程所执行的字节码的行号指示器,用于存放下一条需要运行的指令。
- 运行速度最快位于处理器内部。
- 是 5 个区域中唯一不会出现 OOM 的区域。
Java 虚拟机栈
- Java 方法执行的内存模型,每个方法执行的过程,就是它所对应的栈帧在虚拟机栈中入栈到出栈的过程;
- 每个方法执行的时候都会创建一个栈帧(stack frame)用于存放 局部变量表、操作栈、动态链接、方法出口。
- 线程私有,生命周期与线程相同。
- 虚拟机参数设置:
-Xss
.
本地方法栈
- 服务于 native 方法;
- 可能抛出的异常:与 Java 虚拟机栈一样。
Java 堆
- 虚拟机最大的一块区域,虚拟机启动的时候创建。
- 唯一的目的:存放对象实例,所有对象实例和数据的在堆上分配内存。
- 垃圾收集器管理的主要区域
- 虚拟机参数设置:
- 最大值:
-Xmx
- 最小值:
-Xms
- 两个参数设置成相同的值可避免堆自动扩展。
- 最大值:
- 线程共享的区域。
方法区
- 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
- 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。
- 垃圾收集行为在此区域很少发生;
- 不过也不能不清理,对于经常动态生成大量 Class 的应用,如 Spring 等,需要特别注意类的回收状况。
- 运行时常量池也是方法区的一部分;
- Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译器生成的各种字面量(就是代码中定义的 static final 常量)和符号引用,这部分信息就存储在运行时常量池中。
- 线程共享的区域。
直接内存
- JDK 1.4 的 NIO 类可以使用 native 函数库直接分配堆外内存,这是一种基于通道与缓冲区的 I/O 方式,它在 Java 堆中存储一个 DirectByteBuffer 对象作为堆外内存的引用,这样就可以对堆外内存进行操作了。因为可以避免 Java 堆和 Native 堆之间来回复制数据,在一些场景可以带来显著的性能提高。
- 虚拟机参数设置:
-XX:MaxDirectMemorySize
- 默认等于 Java 堆最大值,即
-Xmx
指定的值。
- 默认等于 Java 堆最大值,即
- 将直接内存放在这里讲解的原因是它也可能会出现 OutOfMemoryError;
- 服务器管理员在配置 JVM 参数时,会根据机器的实际内存设置
-Xmx
等信息,但经常会忽略直接内存(默认等于-Xmx
设置值),这可能会使得各个内存区域的总和大于物理内存限制,从而导致动态扩展时出现 OOM。
- 服务器管理员在配置 JVM 参数时,会根据机器的实际内存设置
HotSpot 虚拟机堆中的对象
这一小节将对 JVM 对 Java 堆中的对象的创建、布局和访问的全过程进行讲解。
对象的创建(遇到一条 new 指令时)
- 检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,先把这个类加载进内存;
- 类加载检查通过后,虚拟机将为新对象分配内存,此时已经可以确定存储这个对象所需的内存大小;
- 在堆中为新对象分配可用内存;
- 将分配到的内存初始化;
- 设置对象头中的数据;
- 此时,从虚拟机的角度看,对象已经创建好了,但从 Java 程序的角度看,对象创建才刚刚开始,构造函数还没有执行。
第 3 步,在堆中为新对象分配可用内存时,会涉及到以下两个问题:
如何在堆中为新对象划分可用的内存?
- 指针碰撞(内存分配规整)
- 用过的内存放一边,没用过的内存放一边,中间用一个指针分隔;
- 分配内存的过程就是将指针向没用过的内存那边移动所需的长度;
- 空闲列表(内存分配不规整)
- 维护一个列表,记录哪些内存块是可用的;
- 分配内存时,从列表上选取一块足够大的空间分给对象,并更新列表上的记录;
如何处理多线程创建对象时,划分内存的指针同步问题?
- 对分配的内存空间进行同步处理,采用CAS配上失败重试的方式保证操作的原子性。
- 线程分配缓冲区,把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配好内存。
对象的内存布局
- 对象在内存中存储的布局可以分为三块区域对象头、实例数据和对齐填充。
- 对象头,又可以分为两部分,第一部分存储自身运行时数据,第二部分是类型指针
- 自身运行数据主要包括哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。
- 类型指针即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 实例数据,对象真正存储的有用的有效信息,继承父类的字段也会包含。
- 对齐填充,对象起始地址必须是8的整数倍,对齐填充起到了占位符的作用。
对象的访问
Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象,reference 数据是一个指向对象的引用,不过如何通过这个引用定位到具体的对象,目前主要有以下两种访问方式:句柄访问和直接指针访问。
句柄访问
句柄访问会在 Java 堆中划分一块内存作为句柄池,每一个句柄存放着到对象实例数据和对象类型数据的指针。
优势:对象移动的时候(这在垃圾回收时十分常见)只需改变句柄池中对象实例数据的指针,不需要修改reference本身。
直接指针访问
直接指针访问方式在 Java 堆对象的实例数据中存放了一个指向对象类型数据的指针,在 HotSpot 中,这个指针会被存放在对象头中。
优势:减少了一次指针定位对象实例数据的开销,速度更快。