《剑指JVM》读书笔记之JVM运行时数据区

笔记整理JVM运行时数据区相关知识点

一、JVM的整体结构

image-20231115103153294

该架构可以分成三层:

  • 最上层:类装载器子系统。javac编译器将编译好的字节码文件,通过Java类装载器执行机制,把对象或字节码文件存放在JVM内存划分区域。

  • 中间层:运行时数据区(Runtime Data Area)。主要是在Java代码运行时用于存放数据的区域,包括方法区、堆、Java栈、程序计数器、本地方法栈。

  • 最下层:执行引擎层。执行引擎包含解释器、JIT(Just In Time)编译器和垃圾回收器(Garbage Collection,GC)

二、JVM的架构模型

Java编译器输入的指令流是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。基于栈式架构的优点是跨平台、指令集小、编译器容易实现,缺点是性能较差,实现同样的功能需要更多的指令。

三、Java代码执行流程

Java源文件经过编译器的词法分析、语法分析、语义分析、字节码生成器等一系列过程生成以“.class”为后缀的字节码文件。Java编译器编译过程中,任何一个节点执行失败都会造成编译失败。字节码文件再经过JVM的类加载器、字节码校验器、翻译字节码(解释执行)或JIT编译器(编译执行)的过程编译成机器指令,提供给操作系统进行执行。

JVM的主要任务就是将字节码装载到其内部,解释/编译为对应平台上的机器指令执行。JVM使用类加载器(Class Loader)装载class文件,虽然各个平台的JVM内部实现细节不尽相同,但是它们共同执行的字节码内容却是一样的。类加载完成之后,会进行字节码校验,字节码校验通过,JVM解释器会把字节码翻译成机器码交由操作系统执行。

早期,我们说Java是一门解释型语言,因为在Java刚诞生,即JDK1.0的时候,Java的定位是一门解释型语言,也就是将Java程序编写好之后,先通过javac将源码编译为字节码,再对生成的字节码进行逐行解释执行。现在更多地认为其是一门半编译半解释型的语言,因为Java为了解决性能问题,采用了一种叫作JIT即时编译的技术,也就是将执行比较频繁的整个方法或代码块直接编译成本地机器码,以后执行这些方法或代码时,直接执行生成的机器码即可。换句话说,在HotSpot VM内部,即时编译器与解释器是并存的,通过编译器与解释器的协同工作,既可以保证程序的响应时间,同时还能够提高程序的执行性能。目前市面上大多数主流虚拟机都采用此架构。Java代码的具体执行流程

image-20231115103222717

四、JVM运行时数据区内存详细布局

image-20231120102207379

线程私有与共享

  • 线程共享:堆、方法区
  • 线程私有:程序计数器、虚拟机栈、本地方法栈

线程私有与共享结构图

image-20231120102626679

1 程序计数器

程序计数器用来存储下一条指令的地址,也就是将要执行的指令代码。由执行引擎读取下一条指令。

程序计数器是一块很小的内存空间,几乎可以忽略不计。它也是运行速度最快的存储区域。在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

以算术3-4为例, 下面是class文件翻译成的机器指令,由CPU进行运算。

image-20231120103209979

使用程序计数器存储字节码指令地址有什么用?为什么使用程序计数器记录当前线程的执行地址?

因为CPU需要不停地切换各个线程,切换回来以后,就需要知道接着从哪里开始继续执行。JVM的字节码解释器通过改变程序计数器的值,来明确下一条应该执行什么样的字节码指令。

2 虚拟机栈

2.1 概述

Java虚拟机栈(Java Virtual Machine Stack)早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部由许多栈帧(Stack Frame)构成,每个栈帧对应着一个Java方法的调用。其生命周期与线程一致。

虚拟机栈有如下几个特点

(1)栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

(2)对于栈来说不存在垃圾回收问题,但存在内存溢出。

(3)栈是先进后出的,每个方法执行,伴随着压栈操作;方法执行结束后,伴随着出栈操作。

我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。栈内存也非越大越好, 物理内存一定, 每个栈内存加大 会影响线程数。

2.2 栈的存储单位

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

栈帧存储的内容

  • 局部变量表(Local Variables)。
  • 操作数栈(Operand Stack)(或表达式栈)。
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)。
  • 方法返回地址(Return Address)(或方法正常退出或异常退出的定义)。
  • 一些附加信息。例如,对程序调试提供支持的信息。
2.3 局部变量表

局部变量表也称为局部变量数组或本地变量表。局部变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference)以及returnAddress类型。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用

方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个方法而言,它的参数和局部变量越多,使得局部变量表越膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而调用方法就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会销毁。

Slot变量槽

局部变量表最基本的存储单元是slot(变量槽)。局部变量表中存放编译期可知的各种基本数据类型(8种)、引用(reference)类型、return Address类型的变量。

在局部变量表里,32位以内的类型(包括reference、returnAddress类型)只占用一个slot,64位的类型(long和double)占用两个slot。byte、short、char、boolean在存储前被转换为int。

JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

值得注意的是,在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

2.4 操作数栈

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

操作数栈也是栈帧中重要的内容之一,它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

操作数栈在方法执行过程中,根据字节码指令往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数从栈中取出,比如,执行复制、交换、求和等操作。使用它们后再把结果压入栈。

2.5 栈顶缓存技术

由于操作数是存储在内存中的,因此频繁地执行内存读、写操作必然会影响执行速度。为了提升性能,HotSpot虚拟机的设计者提出了栈顶缓存(Top-of-Stack Cashing,ToS)技术。所谓栈顶缓存技术就是当一个栈的栈顶或栈顶附近元素被频繁访问,就会将栈顶或栈顶附近的元素缓存到物理CPU的寄存器中,将原本应该在内存中的读、写操作分别变成了寄存器中的读、写操作,从而降低对内存的读、写次数,提升执行引擎的执行效率

2.6 动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)

在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。动态链接的目的就是在JVM加载了字节码文件,将类数据加载到内存以后,当前栈帧能够清楚记录此方法的来源。将字节码文件中记录的符号引用转换为调用方法的直接引用,直接引用就是程序运行时方法在内存中的具体地址。

如下图中Thread区域代表着一个个的线程,Stack Frame区域代表着栈中的一个栈帧,Current Class Constant Pool Reference区域为动态链接,method references区域代表着方法的引用地址,即直接引用。动态链接指向运行时常量池中的方法的引用地址,运行时常量池指的是class文件中常量池表在程序运行时在内存中的形式。

image-20231120110807742

2.7 方法返回地址

方法返回地址存储的是调用该方法的程序计数器的值。一个方法的结束有两种可能,分别是正常执行完成结束和出现异常导致非正常结束。

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置程序计数器值等,让调用者的方法继续执行下去。

3 本地方法栈

Java虚拟机实现可能会使用到传统的栈(通常称为C Stack)来支持本地方法(使用Java语言以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。

本地方法栈和Java虚拟机栈发挥的作用是类似的,它们直接的区别是Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法栈是线程私有的。本地方法栈的大小允许被实现成固定大小的或者是可动态扩展的。在内存溢出方面,它与Java虚拟机栈也是相同的。

在Java中,本地方法栈和虚拟机栈是如何关联的呢?

当调用线程的start()方法的时候,在当前线程中开辟一个start()方法的栈帧并压入栈,在start()方法中又调用了start0()方法。start0()方法是一个本地方法,所以start0()方法需要通过本地方法栈调用,可以使用动态链接的方式直接指向本地方法,由执行引擎来执行该本地方法。类似的案例还有Java应用中连接MySQL数据库或者Redis数据库等。

4 堆

堆是Java内存管理的核心区域。堆在JVM启动的时候被创建,其空间大小也随之被确定。堆是JVM管理的最大一块内存空间,其大小是可以根据参数调节的,它可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的。

4.1 堆与栈的关系

堆与栈的关系堆中存放的是对象,栈帧中保存的是对象引用,这个引用指向对象在堆中的位置。

4.2 JVM堆空间划分

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。堆也是GC(Garbage Collector,垃圾收集器)执行垃圾回收的重点区域。现代垃圾收集器大部分都基于分代收集理论设计,这是因为堆内存也是分代划分区域的,堆内存分为新生代(又叫年轻代)和老年代。

4.3 设置堆内存大小与内存溢出

通过JVM参数“-Xms”和“-Xmx”来进行设置。

“-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize。

“-Xmx”用于表示堆区的最大内存,等价于-XX:MaxHeapSize。

通常会将“-Xms”和“-Xmx”两个参数配置相同的值。否则,服务器在运行过程中,堆空间会不断地扩容与回缩,势必形成不必要的系统压力。所以在线上生产环境中,JVM的Xms和Xmx设置成同样大小,避免在GC后调整堆大小时带来的额外压力。

初始内存大小占据物理内存大小的1/64。

最大内存大小占据物理内存大小的1/4。

4.4 新生代与老年代

存储在JVM中的Java对象可以被划分为两类,分别是生命周期较短的对象和生命周期较长的对象。

  • 生命周期较短的对象,创建和消亡都非常迅速。
  • 生命周期较长的对象,在某些极端的情况下甚至与JVM的生命周期保持一致。

Java堆区分为新生代和老年代,生命周期较短的对象一般放在新生代,生命周期较长的对象会进入老年代。在堆内存中新生代与老年代在堆结构的占比可以通过参数“-XX:NewRatio”配置。默认设置是“-XX:NewRatio=2”,新生代占整个堆的1/3

在HotSpot虚拟机中,新生代又分为一个Eden区和两个Survivor区,这三块区域在新生代中的占比也是可以通过参数设置的。Eden区和两个Survivor区默认所占的比例是8:1:1。可以参数“-XX:SurvivorRatio”设置

4.5 GC相关

GC相关后续详细讲解。

4.6 为对象分配内存:TLAB

程序中所有的线程共享Java中的堆区域,但是堆中还有一部分区域是线程私有,这部分区域称为线程本地分配缓存区(Thread Local Allocation Buffer,TLAB)。

TLAB表示JVM为每个线程分配了一个私有缓存区域,这块缓存区域包含在Eden区内

(1)从内存模型角度来看,新生代区域继续对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域。

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

(3)所有Open JDK衍生出来的JVM都提供了TLAB的设计。

为什么有TLAB呢?原因如下。

(1)堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。

(2)由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。

(3)为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。在程序中,开发人员可以通过选项“-XX:+/-UseTLAB”设置是否开启TLAB空间。

默认情况下,TLAB空间的内存非常小,仅占有整个Eden区的1%,我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden区的百分比大小。

一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden区中分配内存。加上了TLAB之后的对象分配过程如图所示。

image-20231120135603900

详细请参考:https://zhuanlan.zhihu.com/p/349173209

4.7 堆空间的参数设置小结

前面讲到了堆空间中几个参数对内存的影响,比如Xms和Xmx用来设置堆内存的大小,此外还有很多其他的参数。下面解说几个常用的参数设置。

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值。

  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)。

  • -Xms:初始堆空间内存(默认为物理内存的1/64)。

  • -Xmx:最大堆空间内存(默认为物理内存的1/4)。

  • -Xmn:设置新生代的大小(初始值及最大值)。

  • -XX:NewRatio:配置新生代与老年代在堆结构的占比。

  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例。

  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄。

  • -XX:+PrintGCDetails:输出详细的GC处理日志。打印GC简要信息:① -XX:+PrintGC;② -verbose:gc。

  • -XX:HandlePromotionFailure:是否设置空间分配担保,在发生Minor GC之前,JVM会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,则此次Minor GC是安全的;如果小于,则JVM会查看-XX:HandlePromotionFailure设置值是否允许担保失败。

    (1)如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的。如果小于,则改为进行一次Full GC。

    (2)如果HandlePromotionFailure=false,则改为进行一次Full GC。

    JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

4.8 堆是否为分配对象存储的唯一选择
4.8.1 对象不一定存储在堆中

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

4.8.2 逃逸分析概述

逃逸分析是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java HotSpot编译器能够分析出一个新对象引用的使用范围,从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象的动态作用域。

当一个对象在方法中被定义后,若对象只在方法内部使用,则认为没有发生逃逸。

当一个对象在方法中被定义后,若它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

4.8.3 逃逸分析优化结果

使用逃逸分析,编译器可以对程序做如下优化。

(1)栈上分配。将堆分配转化为栈分配。针对那些作用域不会逃逸出方法的对象,在分配内存时不再将对象分配在堆内存中,而是将对象分配在栈上,这样,随着方法的调用结束,栈空间的回收也会回收掉分配到栈上的对象,不再给垃圾收集器增加额外的负担,从而提升应用程序整体性能。

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

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

4.8.4 逃逸分析之栈上分配

JIT(Just In Time)编译器在编译期间根据逃逸分析的结果,发现如果一个对象没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

4.8.5 逃逸分析之同步省略

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

4.8.6 逃逸分析之标量替换

标量(Scalar)是指一个无法再分解成更小数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫作聚合量(Aggregate),Java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。

在JIT编译器的编译阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量。这个过程就是标量替换。(也就是不需要new对象了)

5 方法区

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

从最简单的代码角度出发,当前声明的变量是Student类型的student,把整个Student类的结构加载到方法区,把变量student放到虚拟机栈中,new的对象放到Java堆中。

image-20231120142215996

在虚拟机栈局部变量表中存放的是各个变量,其中reference区域就相当于student变量,引用类型reference指向了堆空间中对象的实例数据,在堆的对象实例数据中有一个到对象类型数据的指针,这个指针指向了方法区中对象类型的数据。

image-20231120142302522

5.2 方法区的理解
5.2.1 概述

在JVM中,方法区是可供各个线程共享的运行时内存区域。方法区与传统语言中的编译代码存储区或者操作系统进程的正文段的作用非常类似,它存储了每一个类的结构信息,例如,运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法

Java虚拟机规范中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpot虚拟机而言,方法区还有一个别名叫作Non-Heap(非堆),目的就是要和堆区分开。所以,方法区可以看作是一块独立于Java堆的内存空间。

对于方法区的理解我们要注意以下几个方面。

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

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

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

以下情况都可能导致方法区发生OOM异常:加载大量的第三方jar包、Tomcat部署的工程过多(30~50个)或者大量动态地生成反射类。关闭JVM就会释放这个区域的内存。

5.2.2 JDK中方法区的变化

在JDK 7及以前,习惯上把方法区称为永久代。但是JDK 8移除了永久代,取而代之的是在本地内存中实现的元空间(Metaspace)。

JDK 7及之前的HotSpot虚拟机把垃圾收集扩展到永久代,这样HotSpot虚拟机就可以像管理堆一样管理永久代,不需要单独针对方法区写内存管理代码了。现在看来,让虚拟机管理永久代内存并不是很好的想法,因为永久代很容易让Java程序发生内存溢出(超过-XX:MaxPermSize上限)。

5.3 设置方法区大小与OOM

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

  1. JDK 7及以前的方法区相关设置

(1)通过-XX:PermSize参数设置永久代初始分配空间。默认值是20.75MB。

(2)通过-XX:MaxPermSize参数设置永久代最大可分配空间。32位机器默认是64MB,64位机器模式是82MB,可以使用jinfo命令查看相关参数设置,如图8-10所示。当JVM加载的类信息容量超过了该值,会报异常OutOfMemoryError:PermGen space。

  1. JDK8及以后方法区相关设置

元空间大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代JDK7中的永久代的初始值和最大值。默认值依赖于具体的系统平台,取值范围是12~20MB。

假设-XX:MetaspaceSize默认值为20MB,这是初始的高水位线,一旦方法区内存使用触及这个水位线,Full GC将会被触发并卸载没用的类(包括这些类对应的类加载器也不再存活)。垃圾收集后,高水位标记可能会根据类元数据释放的空间量自动提高或降低,如果释放的空间很少,那么在不超过MaxMetaspaceSize时,该值会被提高,以免过早引发下一次垃圾收集。如果释放空间过多,那么该值会被降低。如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

5.4 方法区的内存结构

Java源代码编译之后生成class文件,经过类加载器把class文件中的内容加载到JVM运行时数据区。class文件中的一部分信息加载到方法区,比如类class、接口interface、枚举enum、注解annotation以及运行时常量池等类型信息。

image-20231120143459900

5.4.1 类型信息、域信息和方法信息介绍

1 类型信息

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

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

2 域信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)。

3 方法信息

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

  • 方法名称。
  • 方法的返回类型(或void)。
  • 方法参数的数量和类型(按顺序)。
  • 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集)。
  • 方法的字节码(bytecodes)、操作数栈深度、局部变量表大小(abstract和native方法除外)。
  • 异常表(abstract和native方法除外),异常表会记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
5.4.2 类变量与常量

static修饰的成员变量为类变量或者静态变量,静态变量和类关联在一起,随着类的加载而加载。类变量被类的所有实例共享,即使没有类实例时也可以访问它。

在JDK 7之前类变量也是方法区的一部分,JDK 7及以后的JDK类变量放在了堆空间。此外,使用final修饰的成员变量表示常量,使用static final修饰的成员变量称为静态常量,静态常量和静态变量的区别是静态常量在编译期就已经为其赋值。

5.4.3 常量池

方法区内部包含了运行时常量池。class文件中有个constant pool,翻译过来就是常量池。当class文件被加载到内存中之后,方法区中会存放class文件的constant pool相关信息,这时候就成为了运行时常量池。

常量池内存储的数据类型包括数量值、字符串值、类引用、字段引用以及方法引用。

如果使用常量池存储这些结构的符号引用和常量,在Java文件中直接调用这些引用和常量即可,这样便可以节省很多空间。如果没有常量池这样的设计,就需要手动在Java代码中体现这些完整的类结构,这样就会导致Java文件占用空间变大。企业开发中,随着Java文件的增多和代码量的增加,就会导致Java文件非常庞大,冗余度过高。综上,常量池的作用就是提供一些符号和常量,便于指令的识别。

5.4.4 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。常量池表是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

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

运行时常量池相对于class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OOM异常。

5.5 方法区的演进

永久代为什么被元空间替换?

(1)为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生永久代的OOM。比如某个集成了很多框架的Web工程中,因为功能繁多,在运行过程中要不断动态加载很多类,可能出现OOM。

而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制

(2)将元数据从永久代剥离出来放到元空间中,不仅实现了对元数据的无缝管理,而且因为元空间大小仅受本地内存限制,也简化了Full GC,并且可以在GC不暂停的情况下并发地释放元数据

5.6 方法区的垃圾回收

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

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。判定一个常量是否“废弃”还是相对简单的,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件。

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

JVM被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用“-verbose:class”“-XX:+TraceClassLoading”以及“-XX:+TraceClassUnLoading”查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要JVM具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

【本文完】

  • 25
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值