深入解析Java的运行时数据区

前言

Java程序运行的过程中,JVM会将其所管理的内存划分成若干个区域,统称为是 运行时数据区 。其中,一些线程间共享的区域,随着JVM的启动而创建,JVM的退出而销毁;另一些线程私有的区域,则随着线程的开始而创建,线程的结束而销毁。如图所示,运行时数据区由以下几个区域所组成:程序计数器、Java虚拟机栈、本地方法栈、方法区、堆。

运行时数据区

程序计数器

根据JVM的运行模型,程序运行前,JVM会将程序编译后的字节码加载到内存中;程序运行时, 字节码解析器 会读取内存中的字节码,按照顺序将字节码的指令解析成固定的操作。在这过程中, 程序计数器 (Program Counter Register)保存当前线程正在执行的字节码地址。

从字节码运行的原理来看,单线程模型下的程序计数器貌似可有可无,字节码解析器会按照顺序将字节码翻译成固定操作,即使遇到分支跳转,也无碍程序正确运行下去。然而,现实中的程序往往是通过多线程协作来完成一个任务的,CPU会为每个线程分配一定的时间片,一个线程在其时间片耗尽之后会挂起,直到它再次获得时间片后才会重新运行。为了确保程序正确运行,线程必须从挂起的地方重新执行。 有了程序计数器,就可以保证在涉及线程上下文切换的情景下,程序依然能够正确无误地运行下去

因此,程序计数器是线程私有的,避免了线程之间的相互影响。JVM会为每个线程都分配一块非常小的内存空间用作程序计数器,这也是唯一一个Java虚拟机规范没有规定 OutOfMemoryError 的运行时区域。

如果一个线程执行的是native本地方法,它的程序计数器的值为 undefined 。因为JVM在执行native本地方法时,是通过JNI调用本地的其他语言来实现的,而非字节码。

Java 虚拟机栈

JVM会给每个线程都分配一个私有的内存空间,称为 Java虚拟机栈 (Java VM Stack)。Java虚拟机栈随着线程的创建而创建,它与传统语言(如C语言)的栈有着类似的作用,JVM只会对其执行两种操作: 栈帧 (Stack Frame)的入栈和出栈。也就是说, Java虚拟机栈是存储栈帧的后进先出队列(LIFO)

每个方法的执行过程,都会伴随着栈帧的创建、入栈和出栈。栈帧是用来存储局部数据和部分过程结果的数据结构,主要包含 局部变量表 (Local Variable Table)、 操作数栈 (Operand Stack)、 指向当前方法所属类的运行时常量池的引用 (Runtime Constant Pool Reference),如图所示为Java虚拟机栈的模型。

Java 虚拟栈模型

局部变量表(LVT)

局部变量表(LVT) 是一个索引以0开始的字节数组,存储了一个方法的所有入参和局部变量。LVT所存储的类型都是编译期可知的,包括各基础类型( bytecharshortintlongfloatdoubleboolean )、对象引用( reference 类型)和 returnAddress 类型(指向一条字节码指令的地址)。

LVT有如下几个特点:

  1. 第0个 Slot (槽位)固定存储指向方法所属对象的 this 指针。
  2. 除了 longdouble 占用了连续2个Slot之外,其他类型都只占用了1个Slot。
  3. LVT按照变量的声明顺序进行存储。

考虑以下代码来验证LVT的这些特点:

先用 javac 命令将其编译成class文件:

javac -g JvmStackLvt.java  # javac编译.java文件,输出.class文件
复制代码

再使用 javap 命令解析class文件,就可以看到 showLvt 函数的LVT。

javap 的输出结果可以看出,LVT的第0个Slot的名字为 this ,签名为 Lcom/yrunz/jdk/chapter1/JvmStackLvt 表示是指向 JvmStackLvt 类型的 this 指针,验证了 特点1 ;变量 ld 所在Slot的索引与其相邻的变量所在Slot 的索引相差2,表示 ld 占用了2个Slot,而其他的变量都只占用了1个Slot,验证了 特点2 ;LVT中变量的存储顺序也是与其声明顺序相同,验证了 特点3

操作数栈(OS)

操作数栈(OS) 用于在方法运算过程存储其中间的运算结果、方法入参和返回结果,它是一个后进先出(Last-In-First-Out,LIFO)的队列。JVM提供了对OS出栈和入栈的指令,如 load 指令属于入栈指令、 store 指令属于出栈指令。

考虑以下代码:

使用 javac 命令将其编译成class文件后,用 javap 解析得到:

因为 javap 的输出结果中并不涉及操作数栈的内容,我们可以根据指令码和LVT来推断出OS的入栈和出栈过程,如图所示。

操作数栈的入栈和出栈过程

运行时常量池引用(Runtime Constant Pool Reference)

每个栈帧内都包含一个指向当前方法所属类的运行时常量池引用,也称为 符号引用 (Symbolic Reference),用于在类加载阶段对代码进行 动态链接 。动态链接所做的就是根据符号引用所表示名字,转换成对方法或变量的实际引用,从而实现 运行时绑定 (Late Binding)。

考虑以下代码:

使用 javac 命令将其编译成class文件后,用 javap 解析得到:

可以看出, f2() 的指令码中,有两处使用了符号引用。 getfield 指令引用的符号为 #2 ,最终解析成 com/yrunz/jdk/chapter1/JvmStackCpr.a:Iinvokevirtual 指令引用的符号为 #3 ,最终解析成 com/yrunz/jdk/chapter1/JvmStackCpr.f1:()V

Java虚拟机规范规定,Java虚拟机栈可以被实现成固定大小,也可以实现成可动态地扩展和收缩。JVM通常会提供设定Java虚拟机栈容量大小或范围的参数,比如 -Xss 参数用于设定栈的大小。当线程请求分配的栈空间超过JVM指定的最大容量时,程序就会抛出 StackOverflowError 异常;而对于栈空间可动态扩展的情形,当尝试扩展却无法分配到足够内存时,程序就会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈(Native Method Stack) 的作用与Java虚拟机栈类似,区别在于后者是为Java方法服务,而本地方法栈则为native方法服务。Java虚拟机规范没有对native方法机制及其实现语言做强制规定,如果JVM不提供native方法,则无需实现本地方法栈。

本地方法栈既可以被实现成固定大小,也可以实现成可动态地扩展和收缩,因此在特定的场景下也会抛出 StackOverflowError 异常和 OutOfMemoryError 异常。

方法区

方法区(Method Area)是线程间共享的区域,在JVM启动时创建,用于存储类的元信息、静态变量、常量、普通方法的字节码等内容。方法区可以被实现成大小固定或可动态扩展和收缩,如果内存空间不满足内存分配要求就会抛出 OutOfMemoryError 异常。

对于HotSpot虚拟机而言,在JDK 1.8以前,方法区被实现为“ 永久代 ”(Permanent Generation),属于堆的逻辑组成部分,并提供了两个参数调节其大小, -XX:PermSize 用于设定初始容量, -XX:MaxPermSize 用于设定最大容量。JDK 1.8之后,HotSpot不再有“永久代”的概念,类的元信息数据迁移到被称为“ 元空间 ”(Metaspace)的新区域,而静态变量、常量等则存储于堆中。元空间没有使用堆内存,而是分配在本地内存中, 默认情况下其容量只受可用的本地内存大小限制 。类似地,HotShot虚拟机也提供了两个参数来调节其大小, -XX:MetaspaceSize 用于设定初始容量, -XX:MaxMetaspaceSize 用于设定最大容量。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)属于方法区的一部分, class 文件被加载到内存后,其中的常量池信息(包括符号引用和编译期可知的字面值常量)就被存储于此。 这些信息可通过 javap 解析 class 文件查看,考虑如下代码:

使用 javac 命令将其编译成class文件后,用 javap 解析得到:

由结果可知,class常量池信息中包含了 MethodAreaRtcp 类所有的符号引用和编译期可知的字面值常量。

什么样的字面值才算是字面值常量?

1、 字符串字面值 。编译器在编译 java 代码时,会将字符串字面值添加到常量池中,比如例子中的 "hello"" world" 在常量池中的位置分别为 #67#83

2、 final 修饰的基础类型成员变量的字面值 。注意,必须是 final 修饰 的,而且必须是 类的成员变量 的字面值才会被编译器添加到常量池中,如 i2 的值 2 在常量池中的位置为 #32i5 虽然是用 final 修饰的基础类型成员变量,但是它的值 5 是运行时函数调用的结果,因此并未出现在常量池中; i7 虽然是被 final 修饰,但是它本身是一个局部变量,因此它的值 7 并未出现在常量池中。

另外, shortbytechar 类型的字面值常量会被编译器转换为 int 类型后存入常量池中,如例子中 s1b1c1 的字面值常量对应在常量池中的位置分别为 #41#44#47 ,它们的类型都是 Integer

3、 由字面值常量相加得到的结果 。因为编译器做了优化,多个字面值常量相加后得到的结果,也会被添加到常量池中。如例子中 i6str3 ,在常量池中对应的位置为 #38#85

程序执行阶段也会有新的常量加入运行时常量池中。

在Java程序执行阶段,也会有新的常量加入到运行时常量池中,这部分并不属于class常量池,因此我们无法从 javap 的结果中找到这些常量。

1、 String.intern() 方法的返回值会加入运行时常量池中 String.intern() 方法调用时,如果常量池中已经存在与其相等的 String 对象(使用 equals 比较时返回 true ),则返回该字符串;否则将该字符串添加到常量池中,然后将其返回。如下例子验证了这一特点:

2、 基础类型的包装类也用到了常量池技术 。Java的部分基础类型的包装类( CharacterByteShortIntegerLongBoolean )也用到了常量池技术,当使用数值字面值给它们赋值时,它们就会被存储到运行时常量池中。此外,位于运行时常量池中的包装类相加得到的结果,也会被存储在常量池中。值得注意的是,只有在 [-127, 127] 的范围内,包装类才会使用到常量池技术,超过该范围的还是会在堆中存储。以下例子验证了这些特点:

堆(Heap)是运行时数据区中最大的一块区域,绝大部分的对象(包括类实例和数组)都在上面存储。堆是所有线程共享的,随着JVM的启动而创建。我们通过 new 创建出来的对象都分配于此,而且无需主动释放对象内存,统一由 垃圾收集器 (Garbage Collector,GC)来进行管理和销毁 —— 这也是Java跟C++相比区别最大的特点之一。当堆中没有足够的内存来创建对象时,就会抛出 OutOfMemoryError 异常。

堆的分代管理

JVM对堆进行了分代管理,分成 新生代 (Young Generation)和 老年代 (Old Generation),其中新生代中又分为 Eden SpaceFrom Survivor SpaceTo Survivor Space 三个区域,如图所示。

堆的分代管理

JVM将堆划分成这么多的区域,主要是为了方便垃圾收集器对对象进行管理,现代的垃圾收集器一般都采用了分代收集算法。对于HotSpot而言,新生代的回收被称为 Minor GC ,老年代的回收称为 Major GC ,而同时对新生代、老年代和方法区的回收则称为 Full GC 。如果出现 Full GC 的频率过高,就说明目前堆内存已经不太够用了。

新生代几乎是所有对象诞生的地方,这个区域上的对象有生命周期短的特点,来的快,去的也快。垃圾收集器每次对该区域进行回收时,通常都会有大量的对象被回收。新生代上都是些 “小” 对象,而且存活率低,进而复制成本较低,因此通常采用 复制算法 进行垃圾回收。如图所示,进行回收时,垃圾收集器会将 Eden SpaceFrom Survivor Space 中存活的对象复制到 To Survivor Space 中,然后将前两个区域清空。当下次回收的时机到来时,则将 Eden SpaceTo Survivor Space 中存活的对象复制到 From Survivor Space 上,然后将前两个区域清空,依次交替进行。

新生代垃圾回收

如果位于新生代中的对象经过几轮垃圾回收都存活了下来,JVM就会将这些对象转存到老年代中;另外,一些 “大” 对象创建时,如果新生代没有足够的空闲内存,JVM也会在老年代中为其分配内存。因此,老年代中的对象通常都是些生命周期较长或者占用空间较大的对象,其复制成本较大,因而垃圾收集器通常采用 标记-清除算法 进行垃圾回收。如图所示,进行回收时,垃圾收集器会对可回收的对象进行标记,然后直接清除。

老年代垃圾回收

线程本地分配缓冲区(TLAB)

由前文可知,绝大部分的对象都是创建在新生代的 Eden Space 上,在多线程同时创建对象的场景下,必然会存在分配内存的线程安全问题。如果通过加锁来解决线程冲突的问题,那么创建一个对象将会同时涉及到获得锁和释放锁的步骤,效率之低可想而知。JVM通过一种叫做 线程本地分配缓冲区 (Thread Local Allocation Buffer,TLAB)的技术解决了这个问题。

TLAB是一个线程私有的内存空间,在线程初始化阶段JVM就会将其分配在 Eden Space 上。后续,如果线程需要创建对象,JVM就会优先在该线程的TLAB上进行分配,线程之间的TLAB是相互隔离的,从而避免了使用同步锁的机制,极大地提升了效率。

线程本地分配缓冲区(TLAB)

TLAB的空间是有限的,其大小可根据JVM参数进行调节,当线程需要创建的对象在当前的TLAB中放不下时,根据具体的JVM参数配置,可以发生以下两种情况:

1、 创建新的 TLAB 。这种情况下,之前的TLAB将会 “退休” ,线程后续的对象分配都会在新的TLAB上进行。这种情况的缺点就是,容易造成内存碎片。

2、 直接在 Eden Space 上分配对象 。这种情况下,对象直接会分配在线程共享的 Eden Space 上,因此也不可避免地需要使用同步锁的机制。

总结

JVM为了方便其对Java程序的内存管理,将内存划分成了程序计数器、Java虚拟机栈、本地方法栈、方法区、堆等5个区域,组成了运行时数据区。其中前三者为线程私有,后两者为线程共享。线程共享就意味着会有线程冲突问题,程序运行时对方法区的操作并不频繁,因此可以采用同步锁机制解决。然而,因为每次的对象创建都是在堆上分配内存,如果采用同步锁机制,如此频繁的操作将会导致很大的性能损耗,JVM采用了TLAB技术解决了这个问题。

JVM为我们屏蔽了很多底层细节,Java程序员只了解使用 new 创建出来的对象会被分配在堆中、对象的内存由垃圾收集器进行回收等基础的知识貌似也可以满足日常的开发活动。但是,要想进行性能调优或是写出更高效的Java代码,就必须掌握JVM的内存管理机制相关知识。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章