0%

JVM系列之二:运行时数据区(Runtime Data Area)

前言

第三天了,还没啥需求,所以接着看JVM,现在看的是运行的第二个环节:运行时数据区。也记录一下,防止忘记。

概述

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

其中,灰色的为每个线程私有的,红色的为多个线程共享的。即:
每个线程:独立包括程序计数器、栈、本地栈。
线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
我们做JVM优化95%是针对堆空间(Heap)的优化,5%是针对方法区(Method Area)的优化。线程私有的PC、VMS、NMS几乎不在JVM优化的范围内

在Hotspot JVM里,每个线程都和操作系统的本地线程直接映射。也就是说,当一个Java线程准备好执行后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度到任何一个可用CPU上。一旦本地线程初始化成功,它就会调用Jav线程中的run()方法。

JVM系统线程

在我们使用jconsole或者其他调试工具时,都能看到后台有许多的运行。这些后台线程不包括调用public static void main(String args[]) main线程以及所有这个main线程自己创建的线程。

这些主要的后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
  • 周期任务线程:这种线程数时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持
  • 编译线程:这种线程在运行时会将字节码编译成本地代码
  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过适当的方法进行处理。

程序计数器(Process Counter Register)

操作系统计算机组成原理里已经对PC寄存器有所了解,但这里并不是我们之前所学的物理寄存器,而是对物理寄存器的一种模拟,仅用来存储当前线程正在执行的Java方法的JVM指令地址。
它的特性如下:
1、它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
2、在JVM规范里,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
3、任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
4、是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
5、字节码解释器工作时就是通过改变这个计数器等值来选取下一条需要执行的字节码指令。
6、它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

虚拟机栈 (Java Virtual Machine Stack)

概述

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器。
虚拟机栈的优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
Java虚拟机规范中关于JVMS的描述如下:

挑重点翻译一下就是:
每个Java虚拟机线程都有一个私有的虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应一次次的Java方法调用。Java虚拟机栈类似于C等传统语言的堆栈:它保存**局部变量(8中基本数据类型、对象的引用地址)和部分结果,并参与方法的调用和返回。
注意区分:
1、局部变量 VS 成员变量(也叫属性)
2、基本类型 VS 引用类型变量 (类、数组、接口)
以下异常可能与Java虚拟机栈有关:
1、
StackOverflowError。如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError.
2、
OutOfMemoryError**。如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存区创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError

内存中的“堆”和“栈”

栈是运行时单位,堆是存储的单位
即:栈解决程序的运行问题,即程序如何执行,如何处理数据。堆解决数据存储的问题,即数据怎么放、放在哪里。
在数据结构上,栈是一个FILO结构的线性表,而堆是一个特殊的完全二叉树,根节点总比子节点大(或小)的大根堆(或小根堆)。

基本内容

设置栈内存大小

通过查阅Tool Reference官方文档

我们知道可以使用-Xss(推荐)或-XX:ThreadStackSize来设置线程栈大小,不附加单位默认以B为单位。
栈大小直接影响方法可调用的深度

栈中的内容

每个线程都有自己的栈,栈中的数据都是以**栈帧(Stack Frame)的格式存在。在这个线程上正在执行的每个方法都各自对应一个栈帧
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
在一条活动的线程中,一个时间点上,只会有一个活动的栈帧。即只有当前在执行的方法的栈帧(
出于栈顶的栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)**。执行引擎运行的所有字节码指令只针对当前栈帧进行操作

栈运行原理

不同的线程中所包含的栈帧数不允许相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给另一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式,一种数正常的函数返回,使用return指令;另一种是抛出异常。无论是哪种方式,都会导致栈帧被弹出

栈帧的内部结构


每个栈帧中存储着:
1、局部变量表(Local Variables)
2、操作数栈(Operand Stack)(或表达式栈)
3、动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
4、方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
5、一些附加信息(这个不太重要,但别忽略了)

局部变量表

局部变量表也被称为局部变量数组或本地变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各种基本数据类型、对象引用(reference),以及return Address类型。
由于局部变量表是建立在线程的栈上,是线程的私有属性,因此不存在数据安全问题
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
⚠️注意:
局部变量表中持有堆中的对象的引用地址(如果局部变量中存在引用类型的话)。
在栈帧中,与性能调优关系最密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是最重要的垃圾回收根节点(GC Root),只要在局部变量表中被直接或间接引用的对象都不会被回收

操作数栈

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

这不就和汇编里的操作数栈很像嘛
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度再编译期就已经定义好了,保存在方法的Code属性中,为max_stack的值。
栈中的任何一个元素都可以是任意的Java数据类型。32bit的类型占用一个单位栈深度,62bit的类型占用两个栈单位深度。
如果被调用的方法带有返回值的话,其返回值将会压入当前栈顶顶操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
⚠️注意:基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时间必然需要使用更多的入栈和出站指令,同时也意味着需要更多的指令分派次数和内存读/写次数。由于操作数说存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提示执行引擎的执行效率。

动态链接

每一个栈帧内部都包含一个指向运行时常量池该帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如:invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中。比如:描述一个方法调用了另外其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接应用
⚠️动态链接和静态链接的区别:
在JVM中,将符号引用转化为调用方法的直接引用与方法的绑定机制相关。
如果被调用的目标方法在编译期可知,且在运行期间保持不变,就称为静态链接。否则称为动态链接
⚠️虚方法和非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时说不可变的,这样的方法称为非虚方法。静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法都是虚方法
⚠️⚠️⚠️***非虚方法和多态的重写相抵触***

方法返回地址

存放调用该方法的pc寄存器的值。无论是因为正常执行完成还是出现未处理的异常而退出是,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。

本地方法接口

简述

一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样的一个方法:该方法的实现由非Java语言实现,比如C。在定义一个Native Method时,并不提供实现体(有些像定义一个Java interface)。因为其实现体是由非java语言在外面实现的。
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。允许被设计成固定或者可动态扩展的内存大小,在内存溢出方面和JVMS一致(StackOverflowError和OutOfMemoryError)
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受JVM虚拟机限制的世界。它和虚拟机拥有同样的权限,比如本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。它甚至可以直接使用本地处理器中的寄存器。
⚠️并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈道使用语言、具体实现方式、数据结构等。因此,如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

概述

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动时即被创建,其空间大小也就确定了。时JVM管理的最大一块内存空间。根据《Java虚拟机规范》规定,堆可以处在物理上不连续的内存空间中,但逻辑上它应该被视为连续的。
现代Java大多采用分代技术,即将堆划分为新生代+老年代+永久代(JDK8以后没有了永久代,改为了元空间)。分代相关可以查看我的这一篇博客

堆空间大小的设置

Java堆区用于存储Java对象实例,大小在JVM启动时就已经设定好了,默认堆空间大小为:初始大小为物理电脑内存大小/64,最大内存大小为物理电脑内存大小/4
可以通过选项-Xms-Xmx来进行设置。
“-Xms”用于设置堆区的起始内存,等价于“-XX:InitialHeapSize”
“-Xmx”则用于表示堆区的最大内存,等价于“-XX:MaxHeapSize”
一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。
⚠️通常会将”-Xms”和“-Xmx” 两个参数配置相同的值,其目的是使Java垃圾回收机制清理完堆区后不需要重新计算堆区的大小,从而提高性能

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

在Java 虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法(方法内new的这个对象不能被外部调用)的话,那么就有可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
如果是JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析。
如果使用的是较早的版本,可以通过:
“-XX:+DoEscapeAnalysis” 显式开启逃逸分析
“-XX:+PrintEscapeAnalysis” 查看逃逸分析的筛选结果
结论:**开发中能使用局部变量的,就不要在方法外定义**

方法区

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

一张图理解,简单明了

方法区的理解

首先,方法区是个概念,具体实现是元空间(Meta Space),而且JDK7及以前的具体实现是永久代。
Java虚拟机规范中关于方法区的介绍如下:

1
2
Java虚拟机有一个在所有Java虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码存储区,或类似于操作系统进程中的“文本”段。***它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括在类和实例初始化及接口初始化中使用的特殊方法***。
方法区是在虚拟机启动时创建的。尽管方法区在逻辑上是堆的一部分,但简单的实现可能会选择不进行GC或者压缩。如果方法区中的内存不能满足分配要求,Java虚拟机将会抛出一个`OutOfMemoryError`。

但对于HotSpot JVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆区分开。所以,方法区可以看作是一块独立于Java堆堆内存空间
⚠️方法区是一个概念,永久代和元空间是方法区的具体实现
元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

方法区大小的设置

在jdk7及以前:
通过“-XX:PermSize”来设置永久代初始分配空间,默认是20.75M。“-XX:MaxPermSize”来设置永久代最大可分配空间。32位机器默认是64M,64位机器默认是82M。当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGenspace。
在jdk8及以后:
通过“-XX:MetaspaceSize”和“-XX:MaxMetaspaceSize”指定。默认值依赖于平台。windows下,-XX:MetaspzceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出OutOfMemoryError:Metaspace异常。
对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水平线,Full GC将会触发并卸载掉没用的类(即这些类对应的类加载器不再存活),然后这个高水平线将会重置。新的高水位线取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水平线调整情况会发生很多次。通过垃圾回收器的日志可用观察到Full GC多次调用。为例避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

方法区的内部结构

《深入理解Java虚拟机》中对方法区(Method Area)存储内容描述如下:
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等

类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储一以下类型信息:
1、这个类型的完整有效名称(全名=包名.类名)
2、这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
3、这个类型的直接修饰符(public、abstract、final的某个子集)
4、这个类型直接接口的一个有序列表

域(Field,也叫成员变量、属性 )信息

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

方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
1、方法名称
2、方法的返回类型(或void)
3、方法参数的数量和类型(按顺序)
4、方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集)
5、方法的字节码(bytecodes)、操作数栈和局部变量表的大小(abstract和native方法除外)
6、异常表(abstract和native方法除外)
每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

non-final的类变量

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

运行时常量池(Runtime Constant Pool)

运行时常量池就是.class文件中的常量池经类加载子系统加载到方法区的一种表现形式,是方法区的一部分。
常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量和符号引用这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM在为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才获得的方法或者字段引用。此时不再是常量池中的符号地址量,这里换成真实地址。
运行时常量池,相对Class文件常量池的另一重要特征是:具备动态性
运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

实例

首先我们写一个MethodAreaTest类,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MethodAreaTest implements Serializable,Comparable<MethodAreaTest>{
//域
int id;
private String name="method";
//构造器
//方法
protected String getNameAppend(String app){
StringBuffer sb = new StringBuffer(name);
sb.append(app);
return sb.toString();
}
public static int count(){
int result = 0;
try {
int value = 10;
result=value / result;
}catch(Exception e){
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(@NotNull MethodAreaTest o) {
return o.id;
}
public static void main(String[] args) {
System.out.println("Hello Word ");
}
}

然后通过javap进行反编译

1
javap -v -p MethodAreaTest.class  > test.txt

这时可以看到其字节码文件如下:


方法区的演进

再次强调,方法区是一个概念,《Java虚拟机规范》并未对其实现进行规范。所以,不同的虚拟机对其实现都不一样,只有HotSpot中才有过永久代。
HotSpot中方法区的演变如下:
jdk1.6及以前:有永久代(Permanent Generation),静态变量存放在永久代中
jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中。
jdk1.8及以后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

-------------------本文结束 感谢阅读-------------------