Java 内存区域

Riicarus大约 7 分钟JavaJVMJavaJVM

Java 内存区域

概述

Java 内存区域(Java Memory Area) 做的事情主要如下:

  • 对象怎么创建? 创建了放在哪? 对象里面有什么信息? 放之后怎么找到?
  • 怎么找到不用了的对象? 怎么收集和清除他们?
  • 收集不了怎么抛异常?

运行时数据区域

Java 运行时数据区域如下:

JavaRuntimeDataArea
JavaRuntimeDataArea

程序计数器

每个线程都包含一个程序计数器, 它是线程私有的.

程序计数器是当前线程所执行的字节码的行号指示器, 字节码解释器工作时, 可以通过改变这个计数器的值来控制下一条要执行的字节码指令的位置.

执行 Java 方法和执行 native 方法时的区别:

  • 执行 Java 方法时, 记录虚拟机正在执行的字节码的指令地址;
  • 执行 native 方法时, 无意义.

程序计数器是 5 个 JVM 数据区域中唯一一个不会出现 OOM 异常的区域.

Java 虚拟机栈

虚拟机栈是 Java 方法执行的内存模型, 每个方法执行的过程, 就是它对应的栈帧在虚拟机栈中入栈到出栈的过程. Java 虚拟机栈是只服务于 Java 方法的.

可能会抛出的异常有:

  • OutOfMemoryError: 在虚拟机栈可以动态扩展的前提下, 扩展时无法申请到足够的内存.
  • StackOverflowError: 当请求的栈深度 > 虚拟机所允许的栈深度时抛出.

虚拟机的参数可以通过 -Xss 参数来设置.

本地方法栈

服务于 native 方法, 可能抛出的异常同 Java 虚拟机栈.

Java 堆

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

Java 堆可以处于物理上不连续的空间中.

可能抛出的异常是 OOM (堆中没有内存可以分配给新创建的实例, 并且堆无法再继续扩展).

虚拟机参数设置:

  • -Xmx: 最大值
  • -Xms: 最小值
  • 两者设置为相同值, 可以避免堆自动扩展.

方法区

方法去用于存储已经被虚拟机加载的类信息/常量/静态变量/JIT 编译器编译之后的代码等数据.

垃圾收集行为在此区域较少发生, 除非经常动态生成或加载大量的类, 如: Spring 或者 ClassLoader

运行时常量池也是方法区的一部分.

可能抛出的异常为 OOM (方法区无法满足内存分配需求时).

直接内存

JDK 1.4 之后的 NIO 类可以使用 native 函数库直接分配堆外内存(应该是由 Unsafe 类提供). 通过基于通道和缓冲区的 I/O 方式, 在 Java 堆中存储一个 DirectByteBuffer 对象作为堆外内存的引用, 进而直接对堆外内存进行操作. 直接内存避免了 Java 堆和 Native 堆之间数据的复制过程, 能够带来性能提升.

虚拟机参数设置: -XX:MaxDirectMemorySize, 默认和 -Xmx 值相等.

可能抛出的异常: OOM (动态扩展时, 物理内存无法提供足够的空间).

HotSpot 虚拟机堆中的对象

这里讲述 Jvm 对 Java 堆中对象的管理.

对象创建

对象创建在遇到一条 new 指令时发生.

  1. 检查这个指令的参数能否在常量池中定位到一个类的符号引用, 并检查这个符号引用代表的类是否已经被加载/解析和初始化过. 如果没有, 先把这个类加载进内存.
  2. 类加载检查通过后, 虚拟机将为新对象分配内存, 此时可以确定存储这个对象所需的内存大小.
  3. 在堆中为新对象分配可用内存.
  4. 将分配到的内存初始化.
  5. 设置对象头中的数据.
  6. 此时, 从虚拟机的角度, 对象已经创建好了, 但是从 Java 程序的角度来看, 对象创建才刚开始, 还需要执行构造函数.

在第三步中, 在堆中为新对象分配可用内存, 可能会遇到如下问题:

如何在堆中为新对象划分可用内存?

  • 指针碰撞(内存规整分配)
    • 用过的内存和没用过的内存分居一个指针的两边.
    • 内存分配过程就是将指针向没分配的内存方向移动.
  • 空闲列表(内存不规整分配)
    • 维护一个列表, 记录哪些内存块是可用的.
    • 分配内存的时候, 从列表上选取一块足够大的空间分给对象, 更新列表上的记录.

如何处理并发创建对象时, 划分内存的指针同步问题?

  • 对分配内存空间的动作进行同步处理(CAS).
  • 把内存分配动作按照线程划分在不同的空间之中进行.
    • 每个线程在 Java 堆中预先分配一小块内存, 称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB).
    • 哪个线程要分配内存就在哪个线程的 TLAB 上进行分配, TLAB 用完需要新的 TLAB 时, 才需要同步锁定.
    • 通过 -XX:+/-UseTLAB 来设置是否使用 TLAB

对象内存布局

对象 = 对象头 + 实例数据 + 对齐填充.

对象头

分为三个部分:

  1. Mark Word: 存储对象自身运行时的数据, HashCode/GC分代年龄等;
  2. Class Pointer: 类型指针, 指向它的类元数据的指针, 虚拟机通过该指针来判断对象是哪个类的实例;
  3. 如果是数据对象, 对象头中还有一块用于记录数组长度的数据.

实例数据

  1. 默认分配顺序: long/double, int, short/char, byte/boolean, oop(Ordinary Object Pointer), 相同宽度的字段会被分配在一起, 除了 oop, 其他的长度由长到短;
  2. 默认分配顺序下, 父类字段会被分配在子类字段前面.

对齐填充

在 HotSpot VM 中, 对象的起始地址必须是 8 字节的整数倍, 所以不够要补齐.

程序层面构建

以上步骤只是在 JVM 层面创建了一个对象, 程序层面的创建还需要执行对应的构造函数, 即 class 文件中的 <init>() 方法.

对象的访问

Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象, reference 数据是一个指向对象的引用.

Java 虚拟机规范中没有强制定义如何访问对象, 因此, 对象访问的具体实现由不同类型的 JVM 自行决定.

JVM 主要由如下两种对象访问实现:

  1. 句柄访问
  2. 直接指针访问

句柄访问

句柄访问方式会在 Java 堆中划分一块区域作为句柄池, 每一个句柄存放着到对象实例数据和对象类型数据的指针. 优势: 对象移动的时候(常见于垃圾收集时), 只需要改变句柄池中对象实例数据的指针, 不需要修改 reference 本身.

JVMHandlerObjectAccess
JVMHandlerObjectAccess

直接指针访问

直接指针访问方式在 Java 堆对象的实例数据中存放了一个指向对象类型数据的指针, 在 HotSpot 虚拟机中, 这个指针会被存放在对象头中.

优势: 减少了一次指针定位对象实例数据的开销, 速度更快.

JVMDirectObjectAccess
JVMDirectObjectAccess

文章部分引自: https://github.com/TangBean/understanding-the-jvmopen in new window