JVM探索之运行时数据区

126 阅读18分钟

前言

Java程序运行的过程中,JVM会将其所管理的内存划分成若干个区域,统称为是运行时数据区。其中,一些线程间共享的区域,随着JVM的启动而创建,JVM的退出而销毁;另一些线程私有的区域,则随着线程的开始而创建,线程的结束而销毁。这篇文章就来探索运行时数据区各个部分

运行时数据区结构

运行时数据区由以下几个区域所组成:程序计数器、Java虚拟机栈、本地方法栈、方法区、堆

image.png

其中方法区和堆是线程共享而虚拟机栈、本地方法栈和PC寄存器都是线程私有的

虚拟机栈

JVM会为每个线程分配一个私有的空间,称为虚拟机栈,它随着线程的创建而创建。JVM对它的操作只有栈帧的出栈和入栈。虚拟机栈中包含多个栈帧(Stack Frame),每一个栈帧是为方法执行而创建的,栈帧中描述的是Java方法执行的内存模型。每个方法从调用开始直到完成的全过程都对应着一个栈帧。

栈帧是用来管理Java程序的运行,主要是由下面组成

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 返回地址

在活动线程中,只有一个栈帧是处于活跃状态的,也就是只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作

image.png

下面就具体来看下栈帧的组成部分

局部变量表

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

LVT有如下几个特点:

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

image.png

注意如果其中一个对象类型设为null之后,后续的声明的变量就会使用这个槽,将原来的值替换

局部变量表所需的容量在编译期间确定,在运行期间是不改变其容量。方法嵌套调用的次数由栈的容量来决定,也就是说栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,对应的栈帧就越大。因此,函数调用就会占用更多的栈空间。局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

操作数栈

它是一个后进先出的栈,在方法执行的过程中,根据字节码指令、往栈中写入或取出数据,即入栈/出栈。字节码指令将值压入操作栈,其余的字节码指令将操作数取出栈,进行操作之后再将结果压入栈。

操作数栈的主要目的是用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

与前面的局部变量表一样,操作数栈也是一个由32bit为单位的字节数组构成的,操作数栈中可支持存储的数据类型主要有:int、long、float、double、reference、returnType等类型,对于byte、short、char类型的数据会在入栈前被转为int类型放入栈中存储

但与局部变量表不同的是:局部变量表是通过下标索引去访问存储的数据,而操作数栈中则是通过标准的压栈、出栈的方式完成数据访问

因为操作数栈在运行时是位于内存中的,频繁的去对内存进行读写操作会影响执行速度,所以实际在执行过程中,虚拟机会将栈顶元素全部缓存到物理CPU的寄存器或高速缓存(L1/L2/L3)中,以此降低对内存的读写次数,从而提升执行引擎的执行效率

动态链接

在介绍动态链接之前先说说静态链接,即字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期间保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。但是,如果被调用方法在编译期间无法被确定下来,只能在程序运行时将调用方法的符号引用转换为直接引用,由于这种引用转换的过程具备动态性,被称为动态链接

栈帧中存储的是一个指向类的运行时常量池的引用,这个引用就是我们说的符号引用

方法返回地址

当一个方法开始执行后不论是正常结束还是遇到异常都要回到调用的地方,程序才能继续执行。方法在返回的时候需要在栈帧中保存一些信息,用来恢复调用该方法的上层方法的执行状态。这里可以通过方法调用者的程序计数器存放返回地址,如果是正常退出方法,上层方法会从程序计数器中保存的地址继续执行接下来的步骤。如果是异常退出的情况,返回地址就需要异常处理器来确定了

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈为虚拟机所使用到的Native方法服务。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常

PC寄存器

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

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

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

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

在创建Java堆时,本质上并不是直接在内存中划分了一块完整的空间给JVM,堆空间在物理上可以是不连续的,只需要逻辑上视为连续即可。所以一个JVM的堆空间在实际的机器内存上,可能是由机器内存中多个不同位置的空间组成的

JVM中堆空间一般是分代管理,分为新生代和老年代,由于GC的不断更新堆空间的划分也在发生变化,这里不展开细说

TLAB

对象的内存分配过程中,主要是对象的引用指向这个内存区域,然后进行初始化操作。但是堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域,怎么办。

image.png

为了解决这个并发问题,对象的内存分配过程就必须进行同步控制。但是我们都知道,无论是使用哪种同步方案(实际上虚拟机使用的可能是CAS),都会影响内存的分配效率。

Java对象的分配是Java中的高频操作,所有,人们想到另外一个办法来提升效率。这里我们重点说一个HotSpot虚拟机的方案:每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。这种方案被称之为TLAB分配,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的,但是是本地线程独享的

TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。正因为有了TLAB技术,堆内存并不是完完全全的线程共享,其eden区域中还是有一部分空间是分配给线程独享的。

值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别

image.png

也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。

还有一点需要注意的是,我们说TLAB是在eden区分配的,因为eden区域本身就不太大,而且TLAB空间的内存也非常小,默认情况下仅占有整个Eden空间的1%。所以,必然存在一些大对象是无法在TLAB直接分配。遇到TLAB中无法分配的大对象,对象还是可能在eden区或者老年代等进行分配的,但是这种分配就需要进行同步控制,这也是为什么我们经常说:小的对象比大的对象分配起来更加高效。

但是TLAB随之而来的问题也出现了:因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况。比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案:

  1. 如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则直接在堆内存中对该对象进行内存分配
  2. 如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则废弃当前TLAB,重新申请TLAB空间再次进行内存分配

上面两种方案都有各自的优缺点:如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。

为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存

TLAB功能是可以选择开启或者关闭的,可以通过设置-XX:+/-UseTLAB参数来指定是否开启TLAB分配。

TLAB默认是eden区的1%,可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

默认情况下,TLAB的空间会在运行时不断调整,使系统达到最佳的运行状态。如果需要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB来禁用,并且使用-XX:TLABSize来手工指定TLAB的大小。

TLAB的refill_waste也是可以调整的,默认值为64,即表示使用约为1/64空间大小作为refill_waste,使用参数:-XX:TLABRefillWasteFraction来调整。如果想要观察TLAB的使用情况,可以使用参数-XX+PringTLAB 进行跟踪。

TLAB的空间其实并不大,所以大对象还是可能需要在堆内存中直接分配。那么,对象的内存分配步骤就是先尝试TLAB分配,空间不足之后,再判断是否应该直接进入老年代,然后再确定是再eden分配还是在老年代分配。

注意:TLAB是HotSpot虚拟机一种优化方案,不代表其他虚拟机都有这个特性

方法区

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

在不同的Java版本中方法区的实现方式不同,在jdk1.8之前JVM通过永久代来实现方法区。1.8及之后都是通过元空间来实现。永久代/元空间和方法区的关系可以用类和接口的关系来形容。永久代/元空间是HotSpot虚拟机对JVM虚拟机规范中方法区的实现

JDK8中永久代向元空间转换的原因

  1. 字符串存储在永久代中容易出现性能问题和内存溢出

  2.  类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大容易导致老年代溢出

  3. 永久代会为GC带来不必要的复杂度,并且回收效率偏低

  4. Oracle可能会将Hotspot与JRockit合二为一

元空间的特点

  1. 每个加载器有专门的存储空间
  2. 不会单独回收每个类
  3. 元空间里的对象的位置固定
  4. 如果发现某个加载器不在存在了,会把相关的空间整个回收

常量池

常量池的概念有很多比如静态常量池、类常量池、运行时常量池、字符串常量池,下面依次对这几个概念进行解析

  • 类常量池:类常量池是每个类独有的,用于存储该类中的常量,包括字符串常量、基本类型常量以及符号引用等。在类加载时被创建并初始化,生命周期和类一致

  • 静态常量池:用于存储静态变量的值的区域,是类的一个属性,可以在类加载时被初始化,并且在整个类的生命周期中都存在。静态常量池的位置和大小都是在类加载时确定的,在类的生命周期中不会改变

  • 运行时常量池:用于存储字面量和符号引用,在类加载后会被初始化。运行时常量池的内容可以被类的方法直接使用,也可以被动态生成的代码使用。运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中

  • 字符串常量池:用于存储程序中所有的字符串常量,当程序中使用字符串常量时,它们会被添加到字符串常量池中,如果字符串常量池中已经存在相同的字符串,那么它们会共享同一个对象。字符串常量池是在类加载时被创建和初始化的,它是整个JVM中只有一个

常量池的演变

JDK6即之前的版本中静态常量池和类常量池是同一个概念,它包含了类中的各种常量、静态变量以及符号引用等信息位于永久代中

image.png

Java7开始准备移除永久代,这里将原来的类常量池进行了拆分,分为类常量池和静态常量池。同时将静态常量池和字符串常量池移到堆中

image.png

Java8彻底移除了永久代引入了元空间,这里最大的变化就是元空间不在JVM中,而是在我们计算机的本地内存中

image.png

栈、堆、方法区中制造OOM

这是之前碰到的一个面试题,如何在栈、堆和方法区中制造OOM。这里其实就是看我们是否掌握了各个区域存放的具体信息。

  • 堆中主要是我们创建的对象信息,所以只需要大量的new新的对象可以制造堆上的OOM
  • 栈保存着我们的方法调用信息,可以制造大量的方法循环调用(最好方法内参数和变量都比较大,占用的栈和栈帧也会更大)
  • 方法区中主要保存着类的信息,同样制造大量的类也会使其溢出

总结

本篇主要介绍了JVM的运行时数据区的不同的部分,以及他们各自负责的数据部分。主要需要注意Java不同版本中各个部分的变化。理解永久代、元空间与方法区的关系。这里还有一个重要的部分没有展开说那就说堆。在JVM中堆一般都是分代管理。分为新生代、老年代。但是不同的Java版本随着垃圾回收器的不同堆的空间也随之发生变化,例如在CMS新生代、老年代这种既有逻辑上的区分又有物理上的区分,但是随着G1的出现新生代和老年代只有逻辑上的区分在物理空间上其实已经没有划分了。而随着ZGC的推出更是连逻辑的划分也没有了。考虑到这部分和GC密不可分所以准备在为GC单独写一篇文章具体介绍