JVM 运行时数据区域与对象创建布局

java 运行时数据区域

根据《Java 虚拟机规范》规定,Java 虚拟机锁管理的内存包含以下以下几个运行时数据区域,如下图所示:
java runtime memory

程序计数器

程序计数器是线程私有的,各线程独立存储,以便线程切换后能恢复到正确的执行位置。可以看作是当前线程所执行的字节码的行号指示器

  • 如果正在执行的是一个 Java 方法,该计数器记录的是正在执行的虚拟机字节码指令的地址。
  • 如果正在执行的是一个 Native 方法,则该计数器值为空。

==此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域==

Java 虚拟机栈

Java 虚拟机栈是==线程私有的==。生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)。每一个方法从调用到执行都对应着一个栈帧在虚拟机栈中入栈和出栈的过程。

注:栈帧(Stack Frame )是方法运行时基础数据结构,其中存储了局部变量表、操作数栈、动态链接、方法出口等信息。

平时所讲的栈内存,就是现在讲的 Java 虚拟机栈,或者说是虚拟机栈中局部变量表部分

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 SOF(Stack Over Flow) 异常
  • 如果扩展时,无法申请到足够的内存,就会抛 OOMError 异常

本地方法栈

发挥的作用与虚拟机栈相似。只不过一个是针对 Java 方法,一个是针对本地方法。

Java 堆

对于大多数应用而言,Java 堆是 Java 虚拟机所管理的内存中最大的一块。同时它也是==所有线程共享==的一块内存区域。Java 虚拟机规定,Java 堆可以是物理上不连续的内存空间,只要逻辑上连续即可。

按照虚拟机规范中的描述,所有对象实例以及数组都要在堆上分配。不过随着 JIT 编译器的发展和逃逸分析技术的逐渐成熟,未来或许会有所改变。

从内存分配角度来看,线程共享的 Java 堆中可能会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不管怎么划分,其中存储的都是对象,进行划分的目的只是为了更好地回收/分配内存。

方法区

方法区有一个别名:non-heap,与 Java 堆区分开来。它是==所有线程共享==的,用于存储已被虚拟机加载的类信息、常量、静态常量、即时编译器编译后的代码等数据。

Hotspot 虚拟机设计团队选择把 GC 分代收集扩展到方法区,或者说使用永久代来实现方法区。因为这样就不用专门为方法区编写内存管理的代码的工作。对于其他虚拟机而言是不存在永久代这个概念的。

使用永久代来实现方法区的好处在于可以直接像管理 Java 堆那样管理这部分内存,而不需要再专门为方法区编写内存管理代码。坏处在于这样更容易导致 OOM,因为永久代有 -XX:MaxPermSize 的上限。在 JDK 1.8 HotSpot 虚拟机的实现中已经将整个永久代移除,取而代之的是一个叫元空间(Metaspace)的区域。

当方法区无法满足内存分配需求时,将抛出 java.lang.OutOfMemoryError: PermGen space

运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。JDK 1.8 前运行时常量池是方法区的一部分。

虚拟机规范中对 Class 文件中的每一个部分的格式都有严格规定。但是对于运行时常量池,并没有做任何细节的要求。一般而言,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的的另一特征在于具备动态性。java 运行期间也可以把新的常量放入池中。比如使用 String.intern() 将堆中的字符串加入常量池中。

JDK1.7 中把原本放在永久代的字符串常量池移到 Java 堆中。常量池位置的不同影响到了 String 的 intern()方法的表现。不同版本的 JDK 使用「==」去比较 String 字符串的结果会有不同。具体情况可参考这篇文章——Java 技术——你真的了解 String 类的 intern()方法吗

Metaspace(元空间)

介绍元空间之前先说点题外话。其实,移除永久代的工作从 JDK1.7 就开始了。JDK1.7 中,存储在永久代的部分数据就已经转移到了 Java Heap 或者是 Native Heap。譬如符号引用(Symbols)转移到了 native heap、字面量(interned strings)转移到了 java heap、类的静态变量(class statics)转移到了 java heap。但 JDK1.7 并没有完全移除将永久代完全移除。直到 JDK1.8 才将永久代完整地移除。

元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  • -XX:MetaspaceSize初始空间大小达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize 时,适当提高该值。
  • -XX:MaxMetaspaceSize最大空间,默认是没有限制的。

如果本地空间的内存用尽了会收到java.lang.OutOfMemoryError: Metadata space 的错误信息。
JDK1.8 中持久代相关的 JVM 参数 -XX:PermSize-XX:MaxPermSize 将会被忽略掉。

为什么移除持久代?

  • 它的大小只能在启动时指定,运行时无法修改——很难进行调优。-XX:MaxPermSize,设置成多少好呢?
  • HotSpot 的内部类型也是 Java 对象:它可能会在 Full GC 中被移动,同时它对应用不透明,且是非强类型的,难以跟踪调试,还需要存储元数据的元数据信息(meta-metadata)。
  • 简化 Full GC:每一个回收器有专门的元数据迭代器。
  • 可以在 GC 不进行暂停的情况下并发地释放类数据。
  • 使得原来受限于持久代的一些改进未来有可能实现

元空间的内存分配模型

  • 绝大多数的类元数据的空间都从本地内存中分配
  • 用来描述类元数据的类(klasses)也被删除了
  • 分元数据分配了多个虚拟内存空间
  • 给每个类加载器分配一个内存块的列表。块的大小取决于类加载器的类型; sun/反射/代理对应的类加载器的块会小一些
  • 归还内存块,释放内存块列表
  • 一旦元空间的数据被清空了,虚拟内存的空间会被回收掉
  • 减少碎片的策略

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。

JDK 1.4 新添加的 NIO(New Input/Output) 可以使用 Native 函数库直接分配堆外存,然后通过一个存储在 Java 堆中的 DireByteBuffer 对象作为这块内存的引用进行操作。这样避免了在 Java 堆和 Native 堆中来回复制数据,因此在一些场合能够显著提高性能。

本机直接内存不受 Java 堆大小的限制,但是受到本机总内存的大小以及处理器寻址空间的限制。

HotSpot 虚拟机中对象创建、初始化与布局

对象的创建

类加载检查、判断

遇到一条 new 指令,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载。

分配内存

类加载检查通过后,将为新生对象分配内存。也就是划分出一块内存区域。

  • 如果 java 堆中的内存是绝对规整的,那么会使用指针碰撞的分配方式
  • 如果 java 堆中的内存不规整的,虚拟机必须维护一个列表,记录哪些内存块可用,在分配时从列表中,找到一块足够大的空间划分给对象。即,使用空闲列表的分配方式。

选择哪种分配方式由 java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  • 在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞
  • 而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表

线程安全问题
另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案

  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;
  • 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
    • 哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定
      • 虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来设定。

对象的初始化

此处的初始化指的是将分配到的内存空间初始化为零值。(不包括对象头)。如果使用 TLAB,该过程也可以提前至 TLAB 分配时进行。初始化为零值的操作保证了对象的实例字段在 Java 代码中可不赋初值就使用。

对象头的设置:

  • 对象是哪个类的实例
  • 如何才能找到类的元数据信息
  • 对象的哈希码
  • 对象的 GC 分代年龄等信息。

从虚拟机的视角来看,一个新的对象已经创建完毕。

  • 但从 Java 程序视角来看,对象的创建才刚刚开始—— 方法还没有执行、所有字段都还为零.
    • 一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

HotSpot 中,对象在内存中存储的布局可以分为 3 块区域

  • 对象头 Header
  • 实例数据 Instance Data
  • 对齐填充 Padding (并不是必然存在)

1. 对象头

对象头包括两部分信息:第一部分为存储对象自身得运行时数据,第二部分为类型指针。

  1. 存储对象自身的运行时数据。如:
    • 哈希码(HashCode)
    • GC 分代年龄、
    • 锁状态标志、
    • 线程持有的锁、
    • 偏向线程 ID、
    • 偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为「Mark Word」。
    • 对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
  2. 类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象是哪个类的实例。
    • 如果对象是一个数组,那么对象头中还需要有一块用于记录数据长度的数据
      • 因为从数据的元数据无法确定数组的大小
    • 注意:并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身
      • 比如:通过句柄访问对象

2. 实例数据

接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

  • 无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
  • 这部分的存储顺序受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响
    • HotSpot 虚拟机默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),
      • 可以看出,相同宽度的字段总是被分配到一起
      • 在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
      • 如果 CompactFields 参数值为 true(默认为 true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

3. 对齐填充

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用

由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是 8 字节的整数倍

对象的访问定位

由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的

主流的访问方式有两种:1. 使用句柄,2. 使用直接指针

使用句柄访问:

  • Java 堆中将会划分出来一块内存作为句柄池,reference 中就是存储了对象的句柄地址,而句柄中包含了对象实例数据类型数据各自的具体地址信息。
    visit obj with handle

直接指针访问:

  • 采用这种方式,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,而对象中包含了对象类型数据的地址信息
    visit obj with pointer

对比

  • 使用句柄访问优点:是 reference 中存储的是稳定的句柄地址,在对象被移动时,只会改变句柄中的实例数据指针,而 reference 本身不需要被修改
  • 使用直接指针的最大好处就是速度更快,节省了一次指针定位需要的时间开销,由于 Java 对象访问十分频繁,这类开销积小成多后也是一项非常可观的执行成本。
    • Sun HotSpot 虚拟机使用的就是这种访问方式。

OutOfMemoryError 异常简析

Java 堆溢出

不断创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象。在对象总内存达到 -XmX:heapSize指定的值之后就会 OOM。

一般出现这种情况都是通过 MAT 工具来分析。确定是出现了内存泄漏,还是的最大堆空间的设置不合理。

  • 如果是内存泄漏,可进一步通过工具来查看对象到 GC Roots 的引用链,找出内存泄漏的原因
  • 如果不存在内存泄漏,即对象确实必须存活着,那就要检查虚拟机的堆参数(-Xmx 与 -Xms)与机器物理内存比较看是否还可以调大;同时也可以通过检查代码中是否存在一些对象的生命周期过长、持有状态时间过长的情况,尝试·减少程序运行期的内存消耗。

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

在 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈。栈容量只由 -Xss 参数设定。

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

导致 SOF 的常见原因有递归、定义大量的局部变量等。

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

可以通过在运行时产生大量的去填满方法区。

由于 JDK1.7 把字符串常量池从方法区移到堆中,在不同的版本的 JDK中String.intern()方法的表现不一。

  • JDK1.6 中调用 String.intern() 方法时,会把首次遇到的字符串复制到永久代中
  • JDK1.7 中调用 String.intern()方法时,不会再复制实例,只是在常量池中记录首次出现的实例引用

本机直接内存溢出

可以通过 -XX:MaxDirectMemorySize 指定,如果不指定,默认与 Java 堆最大值(-Xmx指定)一样

参考资料与学习资源推荐

若本文中有不正确的结论、说法,请大家提出,共同探讨,共同进步,谢谢!

Show Comments
0%