JVM运行时数据区域


[toc]

JVM运行时数据区域

JVM运行时数据区的

  • 每个线程:独立包括程序计数器、Java栈(虚拟机栈)、本地栈(Native Method Stack)。
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)

栈是运行时的单位,而堆是存储的单位

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里

程序计数器

  1. 程序计数器是一块很小的内存区域,也是运行速度最快的存储区域。
  2. 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能。节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  3. 每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
  4. 任何时间一个线程只有一个方法执行,程序计数器会存储当前线程正在执行的Java的JVM指令地址。如果是再执行native的方法,则是未指定值
  5. 是Java虚拟机规范中没有规定OOM的情况的区域

虚拟机栈

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。
特点:

  • 虚拟机栈是线程私有的。生命周期与线程的生命周期保持一致
  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • 只有入栈和出现两个动作
  • 对于栈不存在垃圾回收问题,但是也可以会出现栈溢出并抛出异常(无限递归可以导致栈溢出)
  • 可以通过-xss来设置JVM虚拟机栈的大小

栈帧

  1. 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
  2. 在这个线程上正在执行的每个方法都各自对应一个栈帧。
  3. 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息,包括类和对象,类中的field和method
  4. 在同一线程的同一时间点,只有一个活动的栈帧,称之为当前栈帧,与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
    JVM虚拟机栈

栈帧的运行原理

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

栈帧的存储内容

  • 局部变量表(local variables):局部变量数组或本地变量表
  • 操作数栈(operation stack):
  • 动态链接
  • 方法返回地址
  • 附加信息

JVM虚拟机栈帧

局部变量表
  • 在局部变量表中,存储的基本单元式Slot(变量槽), Slot可以存放基本数据类型、引用类型和returnAddress类型的变量(指向字节码的指针)。
  • 32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(1ong和double)占用两个slot。
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,因此如果需要访问局部变量表的64bit的局部变量时,只需要使用前一个索引即可。
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处。
  • Slot是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的局部变量就有可能会服用过期的局部变量的Slot,从而节省资源。
  • 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈

每一个独立的栈帧除了包含局部变量表以外,还包含操作数栈,也可以称之为表达式栈(Expression Stack)

  • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 栈中的任何一个元素都是可以任意的Java数据类型。而32bit的类型占用一个栈单位深度
    4bit的类型占用两个栈单位深度
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
动态链接

每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

方法返回地址

存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:

  • 正常执行完成:调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址
  • 出现未处理的异常,非正常退出:返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

本地方法栈

本地方法栈用于管理本地方法的调用

本地方法(Native Method)

一个Native Method是一个Java调用非Java代码的接囗。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互。

为什么需要使用Native方法:Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时需要使用。

本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。

比如

  • 与Java环境交互
  • 与操作系统交互

本地方法栈

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个stackoverflowError 异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个outofMemoryError异常。

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存。

堆对于JVM进程来说是唯一的。线程之间是共享一个堆的
Java堆区在JVM启动的时候就被创立了,其大小也就确定了,可以通过-Xms和-Xmx调节

  • Xms:最小堆内存(堆区的起始内存),默认初始内存大小:物理电脑内存大小/64
  • Xmx:最大堆内存,默认内存大小:物理电脑内存大小/4

通常设置时会将两个参数配置相同的值,目的时为了能偶在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

堆内存的分配

  • Young Generation Space 新生代对空间 Young/New 又被划分为Eden区和Survivor区
  • Tenure generation space 老年代对空间 Old/Tenure
  • Permanent Space永久区 Perm —-> Java8后改为元空间

JVM中的对象可以分为两类

  • 一是生命周期较短的瞬时对象,这种对象的创建和消亡都十分迅速
  • 另一类生命周期非常长,极端情况下和JVM的生命周期保持一致
  • Java堆空间

新生代和老年代

年轻代又可以划分为Eden空间、Survivor0空间(from区)和Survivor1空间(to 区)
默认Eden:from:to 比例是8:1:1,新生代:老年代比例是1:2
频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收。
新生代使用复制算法进行垃圾回收,目的是为了减少内碎片。

堆的GC:

  • MinorGC/YoungGC:
    • 新生代的GC,Eden区满的时候触发,Survivor区满不会引发GC。
    • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • Major GC:
    • 老年代的GC,老年代空间不足时,会先尝试触发MinorGc。如果之后空间还不足,则触发Major GC。- STW的时间更长,如果Major GC后,内存还不足,就报OOM了
  • Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集。
    • 调用System.gc()时触发
    • 老年代空间不足
    • 方法区空间不足
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    • 由Eden区、survivor spacee(From Space)区向survivor spacel(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

堆分代思想

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。分代的理由就是优化GC性能

堆中的TLAB

TLAB:Thread Local Allocation Buffer,也就是在堆中为每个线程单独分配了一个缓冲区。

TLAB是从内存模型的角度,为Eden区继续划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够加速对象的分配,提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配

TLAB分配过程

方法区

方法区主要存放的用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。,而堆中主要存放的是实例化的对象。方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。

JDK7前,习惯将方法去称为永久代,JDK8后使用元空间取代了永久代。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

内部结构

方法区内部结构
主要包括类型信息、常量、静态变量、即时编译器编译后的代码缓存等

类型信息

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
  • 这个类型的修饰符(public,abstract,final的某个子集)
  • 这个类型直接接口的一个有序列表
域信息

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

方法信息
  • 方法名称
  • 方法的返回类型(或void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外)
non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,你也可以访问它

全局常量

全局常量就是使用 static final 进行修饰。被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。

常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。

方法区GC

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
回收废弃常量与回收Java堆中的对象非常类似。

判断不在使用类

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

直接内存

直接内存时Java堆外的,直接向系统申请的内存区间。
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
访问直接内存的速度会优于Java堆,读写性能更高

使用场景

  • 读写频繁的可能会考虑使用直接内存
  • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

存在的问题:
由于直接内存在堆外,因此不受JVM内存回收管理。同时系统的内存时有限的,JVM的内存和直接内存的总和受限于操作系统给出的最大内存。

其他

栈上分配

基本思想是对于那些线程私有的对象(指的是不可能被其他线程访问的对象),可以将它们打散分配在栈上,而不是分配在堆上。
优点:

  1. 可以在函数调用结束后自行销毁对象,不需要垃圾回收器的介入,有效避免垃圾回收带来的负面影响
  2. 栈上分配速度快

缺点:

  1. 无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程

    逃逸分析

逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。在运行时分析对象的生命周期,如果发现该对象只会被本线程使用(一般是一些局部对象),那么就将该对象在栈上分配,而不在堆中(heap)分配,以减少对象对堆的压力,减少GC的次数。
同时如何发现一个对象只有一个线程被访问,那么对于这个对象的操作可以不考虑同步

标量替换

标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等)。标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。

字符串常量池、Class常量池和运行时常量池

字符串常量池(也成为StringTable)

  • 字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例
  • 然后将该字符串对象实例的引用值存到字符串常量池
  • 字符串常量池中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的
  • 字符串常量池在每个HotSpot VM的实例只有一份,被所有的类共享

Class常量池

当java文件被编译成class文件之后,会在class文件中生成我们所说的class常量池,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(文本字符串、被声明为final的常量、基本数据类型的值)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)

运行时常量池

当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。运行时常量池也是每个类都有一个。class常量池中存的是字面量和符号引用,经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询字符串常量池。


文章作者: 彭峰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 彭峰 !
  目录