Java 内存区域与内存溢出异常

Java 内存区域

运行时数据区域

JVM 内存结构

程序计数器

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
  • 在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
  • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

Java 虚拟机栈

  • 生命周期与线程相同。
  • 每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
局部变量表
  • 存放了编译期可知的各种 Java 虚拟机基本数据类型(boolean、byte、 char、short、int、 float、long、double),对象引用(reference 类型),returnAddress 类型。
  • 数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示。局部变量表所需的内存空间在编译期间完成分配。
  • 一个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(“大小”是指变量槽的数量)。
异常
  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
  • 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。
  • HotSpot 虚拟机的栈容量是不可以动态扩展的。

本地方法栈

  • 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的。它是为虚拟机使用到的本地(Native)方法服务的。

Java 堆

  • Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,目的是存放对象实例。所有的对象实例以及数组都应当在堆上分配。
  • Java 堆是垃圾收集器管理的内存区域,从回收内存的角度和分配内存的角度来看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),这能提升对象分配时的效率。
  • Java 堆可以细分为新生代、老年代/永久代,以便更好地回收和分配内存。物理上堆内存可以不连续,但逻辑上必须连续。对于大对象(如数组),通常需要连续的内存空间。
  • Java 堆既可以被实现成固定大小的,也可以是可扩展的。扩展可通过参数 -Xmx(设置堆内存的最大值)和 -Xms(设置堆内存的初始值)设定。
  • 如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常

方法区

  • 方法区是各个线程共享的内存区域,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。这区域的内存回收主要针对常量池的回收和类型卸载。
  • 如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常

运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分,存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用存储在运行时常量池中。
  • 常量池具有动态性,不要求常量一定只在编译期产生,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
  • 当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常

直接内存

  • 在 JDK 1.4 中引入的 NIO(New Input/Output)类,可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。
  • 当各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制)时,出现动态扩展将导致 OutOfMemoryError 异常。

对象内存揭秘

对象的创建

  1. 当 Java 虚拟机遇到一条字节码 new 指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过
  2. 虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定。
    • 分配方式:
      • 假设 Java 堆中内存是规整的,指针碰撞
      • 如果 Java 堆内存并不规整,则使用空闲列表
    • Java 堆是否规整取决于所采用的垃圾收集器是否带有空间压缩整理:
      • 使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用指针碰撞,既简单又高效;
      • 使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存。
    • 并发情况下的线程安全:
      • 虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性
      • 或者,把内存分配动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB)。通过 XX:+/-UseTLAB 参数来设定是否使用 TLAB。
  3. 初始化分配到的内存空间(不包括对象头)。
  4. 对对象进行必要的设置,包括对象所属的类、如何找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)中。根据虚拟机当前运行状态的不同(如是否启用偏向锁),设置也会有所不同。
  5. 执行 <init>() 方法,按照程序员的意愿对对象进行初始化。

对象的内存布局

  1. 对象头(Header):

    • Mark Word:存储对象自身的运行时数据,如哈希码(HashCode)GC 分代年龄锁状态标志线程持有的锁偏向线程 ID偏向时间戳等。
    • 类型指针:对象指向它的类型元数据的指针。
    • 如果对象是一个 Java 数组,对象头中还必须有一块用于记录数组长度的数据。
  2. 实例数据(Instance Data):

    对象真正存储的有效信息,包括程序代码里面定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的字段都必须记录下来。存储顺序受虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响。

    • HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)。相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的 +XX:CompactFields 参数值为 true(默认即为 true),那子类之中较窄的变量也允许插入父类变量的空隙中
  3. 对齐填充(Padding):并不是必然存在的,也没有特别的含义,仅起占位符作用。HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是任何对象的大小都必须是 8 字节的整数倍。

对象的访问定位

  • 句柄:Java 堆中将可能划分出一块内存作为句柄池,reference 中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。reference 中存储的是稳定的句柄地址,在对象被移动时(如垃圾收集时),只需改变句柄中的实例数据指针,而 reference 本身不需要被修改。
  • 直接指针:Java 堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息。速度更快,因为节省了一次指针定位的时间开销。

对象内存布局

内存溢出异常

OutOfMemoryError 异常

Java 堆溢出

  1. 确认内存中导致 OOM 的对象是否是必要的,判断是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
  2. 内存泄漏:查看泄漏对象到 GC Roots 的引用链,找到泄漏对象是通过怎样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收
  3. 内存溢出:检查 Java 虚拟机的堆参数(-Xmx-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间;检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
  • 如果是建立过多线程导致的内存溢出,在不能减少线程数量或更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。

方法区和运行时常量池溢出

  • 自 JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆之中
  • HotSpot 提供了一些参数作为元空间的防御措施:
    • -XX:MaxMetaspaceSize:设置元空间最大值。
    • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整。
    • -XX:MinMetaspaceFreeRatio:控制垃圾收集后最小的元空间剩余容量百分比,可减少因元空间不足导致的垃圾收集频率。
    • -XX:MaxMetaspaceFreeRatio:控制最大的元空间剩余容量百分比。

本机直接内存溢出

  • 直接内存的容量大小可通过 -XX:MaxDirectMemorySize 参数指定,默认与 Java 堆最大值(由 -Xmx 指定)一致。
  • 一个明显的特征是在 Heap Dump 文件中不会有明显的异常情况。如果发现内存溢出后产生的 Dump 文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是 NIO),那就需要重点检查直接内存方面的原因了。