JVM JMM 运行时数据区(内存结构)

1. JVM 运行时数据区

  • JDK 1.7 之前
    jvm-7-run-data-area
  • JDK 1.8 及以后
    jvm-8-run-data-area

区别主要在方法区:
JDK7 之前(包括 JDK7 ),HotSpot 使用在内存中划分出一块区域来存储类的元信息、类变量以及内部字符串(interned + String)等内容,称之为永久代(Permanent Generation),把它作为方法区来使用。从 JDK7 开始,已经把原本放在永久带的字符串常量池移出方法区。

JDK8 开始提议取消永久代,方法区作为概念上的区域仍然存在。原先永生代中类的元信息会被放入本地内存(元数据区,metaspace),将类的静态变量和内部字符串放入到 Java 堆中。

1.1. 程序计数器

程序计数器(Program Counter Register)可以看作是当前线程执行的字节码的行号指示器。 字节码解释器通过修改程序计数器中的值来获取下一条需要执行的字节码指令。 Java 的多线程是通过线程轮流切换和分配处理器执行时间来实现的。在任意一个确定的时刻,一个处理器(多核处理器代表一个内核)都只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,所以,每一条线程,都需要一个独立的内存计数器—线程私有内存。
此内存区域是唯一一个在 Java 虚拟机中没有规定任何 OutOfMemoryErrord 的区域。

1.2. Java 虚拟机栈

跟程序计数器一样是线程私有。生命周期跟线程相同。
虚拟机栈描述的是线程中 Java 方法执行的内存模型。每个方法在执行的时,都会创建一个栈帧(Stack Frame),用于储存局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,代表一个栈帧在虚拟机栈的入栈到出栈的过程。
局部变量表存放的内容,一个方法内在编译期可知的各种:

  1. 基本数据类型
  2. 对象引用(不是对象本身,可能是一个指向对象的起地址的引用指针,或一个代表对象的句柄或其他与此对象相关的位置)
  3. return Address。

64位的 long 和 double 占用2个局部变量空间。reference 类型则可能是32位也可能是64位(虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束)。其余的类型占用 1 个 slot.
局部变量表所需要的空间在编译期间完成,在运行时大小不会改变。
在 Java 虚拟机规范中,虚拟机栈会抛出两种异常:

  1. StackOverflowError,线程请求的栈深度大于虚拟机所允许的深度。
  2. OutOfMemoryError,如果虚拟机栈可动态扩展的话(当前大部分 Java 虚拟机都支持动态扩展),如果扩展的时候,申请不到足够的内存时抛出此异常。

1.3. 本地方法栈

类似虚拟机栈,区别是,虚拟机栈是提供给 Java 中的方法服务,而本地方法栈(Native Method Stack)是提供给 Native 方法调用。虚拟机规范没有强制规则,所以虚拟机都都有不同的实现,比如 Sun HotSpot 直接把本地方法栈和虚拟机栈合二为一。

本地方法栈,抛出的异常情况和虚拟机栈一样。

1.4. Java 堆

Java 堆存放对象实例,几乎所有的对象实例都在堆上分配(JIT编译器的发展和逃逸分析技术组建成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生)。

Java 堆是所有线程共享的内存区域。

Java 堆是垃圾回收的主要区域,因此又叫做 “GC” 堆(Garbage Collected Heap)。

从内存回收的角度看,现在的垃圾回收规则,都是采用的分代收集算法,所以 Java 堆还可以细分为:新声代(Eden空间,From 和 To)和老年代。
jvm-heap-structure

从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

Java 虚拟机规范规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。可以设置成固定大小,也可以设置成可扩展的,当前主流的实现都是可扩展的(通过 -Xmx 和 -Xmx 控制)。

如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

1.5. 方法区

1.5.1. JDK7 及以前

方法区跟堆一样是所有线程共享的内存区域,用于存放已经被虚拟机家在的类信息,常量,静态变量,即时编译器编译后的代码等数据。
在 HotSpot 中,方法区又被称为永久代(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说用永久代来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存。原则上来说,如何实现方法区属于虚拟机的实现细节,不受选你寄规范约束,但是使用永久代,并不是一个好主意,更容易内存溢出 OutOfMemoryError:Perm Space。

从 JDK7 开始,已经把原本放在永久带的字符串常量池移出方法区。

1.5.2. JDK8 以后

HotSpot JDK8 开始取消永久代,方法区作为概念上的区域仍然存在。原先永久代中类的元信息会被放入本地内存(元数据区,MetaSpace),元数据区不属于 JVM, 而是直接存在于本机内存。

1.6. 运行时常量池

运行时常量池是方法区的一部分。
Class 文件出了有类的版本,字段,方法,接口等描述信息,还有一项是常量池(Constant Pool Table),用户存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。JDK6 常量池放在方法区,JDK7 常量池放在堆内存,JDK8 放在元空间里面。

运行时常量池相对于 Class 文件常量池的另外一个重要特性是具备动态性。运行期间也可能将新的常量放入池中,比如用得比较多的是 String.intern() 方法。

当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK6: java.lang.OutOfMemoryError: PermGen space
JDK7: java.lang.OutOfMemoryError: Java heap space
JDK8: java.lang.OutOfMemoryError: Java heap space

1.7. 直接内存

直接内存并不属于虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,也可能导致 OutOfMemoryError 异常。

JDK 1.4 引入的 NIO(New Input/Output),基于通道(Channel)和缓冲区(Buffer)的 I/O 方式。直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。避免了在 Java 堆中和 Native 堆中来回复制数据,提高性能。

直接内存不受 Java 堆大小的限制,会受到本机总内存( RAM 以及 SWAP )限制。

1.8. 对象的创建

当虚拟机遇到一条 new 指令时:

  1. 检查这个指令的参数(Class)是否能在常量池中定位到一个类的符号引用。
  2. 检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,必须先执行该类的加载过程。
  3. 类加载检查通过后,为新生对象分配内存(从 Java 堆中划分一块内存)。一个 Class 一旦加载后,就可以确定新生对象所需的内存大小。
    • 指针碰撞(见后)
    • 空闲列表(见后)
  4. 初始化分配的内存空间都为零值。如果使用了 TLAB, 可以提前在 TLAB 时就执行。
  5. 对新生对象的对象头 (Object Header):哪个类的实例,如果找到类的元数据,对象的哈希码,GC 分代年龄等信息。
  6. 调用 init 方法(构造函数)

1.8.1. 新生对象的内存分配方式

1.8.1.1. 指针碰撞

如果 Java 堆是规整的,用过的在一边,空闲的在另一边,这样分配内存时只需要将指针向空闲的一半挪动一段与新生对象大小相等的距离,这种分配方式称为“指针碰撞”。

1.8.1.2. 空闲列表

如果 Java 堆不是规整的,用过的和空闲的内存相互交错,就不能使用指针碰撞。需要虚拟机维护一个列表,记录哪些内存时可用的。这样在为新对象分配内存时,需要在列表中找到一块足够大的内存空间划分给该对象,并更新列表的记录,这种分配方式称为“空闲列表”。

1.8.2. 分配方式如何选择

选择何种内存分配方式需要由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
所以,在使用 Serial, ParNew 等带有 Compact 过程的收集器时,系统才有“指针碰撞”的分配方式。
而使用 CMS 这种采用 标记-清理(Mark-Sweep)算法的收集器时,通常采用空闲列表。

1.9. 对象的内存布局

1.9.1. 对象头(Header)

对象头包含了储存对象自身的运行时数据(Mark Word),类型指针,如果对象是数组的话,还需要包含数组的长度数据。

1.9.1.1. Mark word

Mark Word : 对象自身的运行时数据包括:

  • 哈希码(HashCode)
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 偏向时间戳
    这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 或者 64bit.
    对象需要存储的运行时数据很多,其实已经超出了32、64位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间
    jvm-mark-word

1.9.1.2. 类型指针

类型指针是存放对象指向它的类型元数据的指针,表示该对象是哪个类的实例。但是,并不是所有的虚拟机实现都必须在对象数据中保存类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。具体参考后面的对象访问定位部分。

1.9.1.3. 数组数据

如果对象是一个数组,对象头还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。

1.9.2. 实例数据(Instance Data)

对象真正存储的有效信息,也就是代码中定义的各种类型字段的内容(包括子类定义,父类中继承的字段)。

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

1.9.3. 对齐填充(Padding)

不是必然存在,仅仅是占位符。HotSpot 要求对象的大小必须是 8 字节的整数倍。

1.10. 对象的访问定位

创建对象是为了使用对象,Java 程序需要根据栈中 reference 数据来操作堆上的具体对象。由于 Java 虚拟机规范只定义了 reference 代表一个指向对象的引用,并没有规定如何根据 reference 去定位和访问对象的具体位置,所以对象的访问方式取决于具体的虚拟机实现。目前主流的访问方式由两种:使用句柄和直接指针两种。

1.10.1. 对象访问的方式

1.10.1.1. 使用句柄

juby-access
使用句柄访问,堆中会划分出一部分内存为句柄池,reference 中存储的就是对象的句柄池地址。其中,句柄池包含了对象实例数据与类型数据各自的具体地址信息。

1.10.1.2. 直接指针

pointer-access
使用直接指针访问,reference 中存放的直接就是对象在堆中的地址,但是 Java 堆中的对象布局必须要考虑到如何存放对象类型数据的相关信息。

1.10.2. 两种访问方式的比较

使用句柄和直接指针,这两种方式各有各的好处。

使用句柄访问的最大好处就是 reference 中存放的的是稳定的句柄地址,在对象被移动(垃圾回收,非常普遍的行为)时,只需要修改句柄池中的对象实例数据的指针地址即可,而 reference 本身不需要修改。

使用直接指针访问的最大好处就是书读更快,在访问对象实例数据时,节省了一次指针定位时间的开销。在 Java 程序中,访问对象非常的频繁,,所以,当访问次数达到一定规模时也是一笔不小的系统开销。

Sun HotSpot 虚拟机,主要是使用直接指针访问对象的。但是从软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

Just for my love !!