运行时数据区

概述

大致结构图:

image-20230307105503822

详细内存划分图:

image-20230307105633957

Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
image-20230318152537997

Runtime类:每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框:运行时环境。

程序计数器

JVM中的程序计数寄存器(Program counter Register)中, Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。

这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

作用:

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

常见面试题:

  1. 使用PC寄存器存储字节码指令地址有什么用呢?/为什么使用PC寄存器记录当前线程的执行地址呢?

    因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

  2. PC寄存器为什么会被设定为线程私有?

    为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个Pc寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互千扰的情况.。

虚拟机栈

背景

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

概念

Java虚拟机栈(Java Virtual Machine stack),早期也叫lava栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame) ,对应着一次次的Java方法调用。

生命周期:

生命周期和线程一致。

作用:

主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

异常:

对于栈来说不存在垃圾回收问题,但是会有内存问题异常。

StackOverflowError异常:如果采用固定大小的栈,如果线程请求分配的校容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。

OutOfMemoryError异常:如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法中请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,Java虚拟机将会抛出一个OutOfMemoryError异常。

存储结构

栈的整体结构:

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(stack Frame)的格式存在。
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame) 。
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈的内部结构:

  • 局部变量表(Local variables)

    定义为一个数字数组,基本的存储单元是Slot,主要用于存储方法参数和定义在方法体内的局部变量,局部变量表所需的容量大小是在编译期确定下来的。(对象的引用this会存在index为0的slot处 )。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

  • 操作数栈(operand stack)

    一个后进先出的栈,实现方式是数组。

  • 动态链接(Dynamic Linking)

    指向运行时常量池的方法引用。(在Java源文件被编谨到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference)保存在class文件的常量池里。)

  • 方法返回地址(Return Address)

    存放调用该方法的pc寄存器的值。无论通过正常方式还是异常方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表。

  • 一些附加信息

    栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。

动态链接+方法返回地址+附加信息=桢数据区

本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈,同样也是线程私有的。

它的具体做法是Native Method stack中登记native方法,在Execution Engine执行时加载本地方法库。

本地方法:

简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如[c。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “c”告知C++编译器去调用一个c的函数。

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

概念

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。并且所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区。

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

堆是cc ( Garbage collection,垃圾收集器)执行垃圾回收的重点区域。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

可以用-Xms大小设置起始内存 和-Xmx大小来设置堆的最大内存,-XX·:+PrintGCDetails 打印堆空间细节;

开发中建议将初始堆内存和最大的堆内存设置成相同的值。

内存结构

具体空间的细分主要分为Java7以前和Java8之后:其实不分代完全可以,分代的唯一理由就是优化GC性能。

  • Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
  • Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

在Java8之后只有新生区和养老区在堆空间里面,元空间移除到方法区中了。

  • 新生区:有Eden、两块大小相同的survivor (又称为from/to,s0/s1)构成,to总为空。

    image-20230316213949606

  • 养老区:存放新生代中经历多次Gc仍然存活的对象。

新生区和养老区默认比例为1:2。

结构变化:

image-20230316203107674

TLAB:

从内存模型而不是垃圾收集的角度对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

过程:

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。
  • 可以设置参数:-XX:MaxTenuringThreshold=进行设置

image-20230317144918647

完整过程:

image-20230317155658091

只有在新生区满了才会触发YGC/Minor GC,幸存者区满了不会,只是对新生区垃圾回收的同时会对幸存者区进行回收。

垃圾收集分类:

  • 部分收集:
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/ old GC) :只是老年代的垃圾收集。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

相关参数:

  • -XX:+PrintFlagsInitial :查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)|
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。〈初始值及最大值)
  • -XX: NewRatio:配置新生代与老年代在堆结构的占比
  • -XX: SurvivorRatio:设置新生代中Eden和S0/s1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • 打印gc简要信息:-XX:+PrintGC
  • -XX: HandlePromotionFailure:是否设置空间分配担保

官方说明地址:https: //docs.oracle.com/javase/8/docs/technotes/tools/unix/java. html

扩展问题

堆是分配对象存储的唯一选择吗?

随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的 堆外存储技术。

逃逸分析的基本行为:分析对象动态作用域

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。使用栈上分配。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。分配在堆中。

相关代码优化

一、栈上分配

将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

二、栈上分配

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

三、分离对象或标量替换

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在cPU寄存器中。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

方法区

概念

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang. outofMemoryError: Metaspace,并且关闭JVM才会释放这个区域的内存。

方法区使用的是本地内存,可以使用-XX:MetaspaceSize大小设置元空间大小

内部结构

方法区用于存储已被虚拟机加载的类型信息、域信息、方法信息、运行时常量池、即时编译器(JIT)编译后的代码缓存。

JDK8方法区详细结构:

image-20230319110542138

变化点:

  1. 字符串常量池移入堆中:

    如果放在方法区中就会导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致内存不足。放到堆里,能及时回收内存。

  2. 静态变量移入堆中:静态变量的引用和实例都会放在堆中。

  3. 永久代替换为元空间:

    因为永久代的大小难以控制,如果使用系统内存就可以在不超过系统要求下不受限;并且永久代的GC问题难以调优。

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

类型信息:

对每个加载的类型(类class、接口interface、枚举enum、注解annotation) ,JVM必须在方法区中存储以下类型信息:

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

域信息:

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序:

  • 域名称、类型、修饰符

方法信息:

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称、返回类型、参数的数量和类型、修饰符
  • 方法的字节码、操作数栈、局部变量表以及大小
  • 异常表

运行时常量池:

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中,并会将符号地址转换为真实的地址,并且相较于classs文件常量池具有动态性。

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池这个字节码包含了指向常量池的引用。

垃圾回收

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

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

  1. 常量池:

    Hotspot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

  2. 类型回收:

    判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    • 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

    Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

栈、堆、方法区的交互关系

关系图:

image-20230318153016041

例子:

User user = new User();

  • 首先User对象的class文件存在方法区的元空间中
  • 然后user局部变量存在虚拟机栈中
  • 最后new出来的User对象存在堆中