JVM内存模型复习
JDK8之前的内存模型:
在HotSpot JVM中,永久代用于存放类与方法的元数据以及常量池,例如 Class 与 Method。每当一个类被加载的时候都会被放入永久代。
永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,
即 java.lang.OutOfMemoryError,
为此我们不得不对虚拟机做调优。
而且一方面为了HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
因此,JDK8之后移除了永久代。
方法区移至 Metaspace(元空间),字符串常量移至Java Heap。
JDK8之后的内存模型:
包含以下几个部分
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 堆区
- 方法区
- 直接内存
程序计数器
用于记录当前线程所执行的字节码行号。Java代码在未经JIT编译之前,其生成的字节码装入内存后被解释器读取,按照顺序读取字节码指令。读取一个指令之后,将其翻译成固定的操作,并进行分支、循环、跳转等操作。
程序计数器主要用于记录每个线程字节码的执行位置,来解决线程被CPU挂起时记录执行位置的问题,故必然是线程私有的。
不过要注意在执行Native方法时,计数器值为空,原因自然是JNI调用的C++代码在程序中自行分配内存,与字节码无关,也无法统计。
程序计数器占用的内存很小,是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是私有的。
我们可以使用 -Xss来设置线程的最大栈空间。
虚拟机栈描述的是Java方法执行时的内存模型,每个线程在执行不同的方法时都会创建一个栈帧( Stack Frame 方法运行时的基础数据结构)。
其用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法的调用完成,对应着每个栈帧在虚拟机栈中的入栈和出栈。
在活动线程中,只有栈顶的栈帧才是有效的,这个栈帧称为当前栈帧,栈帧中的方法称为当前方法。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
栈帧的大小在编译时已经确定了,这取决于虚拟机的具体实现,而与运行时变量无关。
1. 局部变量表
局部变量表,可以称之为局部变量数组或者本地变量表。
其定义为一个数字数组,主要存储方法参数以及方法内部定义的局部变量。
在Java编译为class文件时已经确定了该方法所需要分配的局部变量表的最大容量。
在局部变量表里存放了编译期可知的各种基本数据类型、对象引用类型(reference)、returnAddress类型(指向一条字节码指令的地址)。
局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。
在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
1.1 变量槽(Slot)
参数的存放总是在局部变量数组的index 0开始,到数组长度-1的索引结束。
局部变量表最基本的存储单元是变量槽。
在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
byte、short、char、boolean (0 / 1) 在存储前被转换为int。long和double则占据两个slot。
JVM会为每一个Slot都分配一个访问索引,通过这个索引即可访问到局部变量表中指定的局部变量值。当一个实例方法被调用的时候,它的方法参数与方法体内部定义的局部变量
将会被按顺序复制到局部变量表的每一个Slot上。
访问64bit的long、double时则使用前一个索引即可。
普通方法的局部变量表
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处,其余的参数按照参数表顺序继续排列。
构造方法或实例方法的局部变量表
1.2 变量槽的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public void localVar1(){
{
int a = 0;
System.out.println(a);
}
int b = 0;
//此时b会复用a的槽位
}
1.3 静态变量与局部变量的对比
按数据类型来分,变量分为基本数据类型与引用数据类型。
按类中声明的位置来分,变量分为成员变量(类变量、实例变量) 与 局部变量。
-
类变量:linking的paper阶段,给类变量默认赋值,init阶段给类变量显示赋值,即静态代码块。
-
实例变量:随着对象创建,会在堆空间中分配实例变量空间,并进行默认赋值。
-
局部变量:在使用前必须进行显式赋值,不然编译不通过。
类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
但是局部变量表不同,其不具有系统初始化的过程,这意味着一旦定义了局部变量,必须要人为赋予初值。
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
2. 操作数栈
操作数栈,每一个独立的栈帧除了包含局部变量表,也包含了操作数栈,也可以称之为表达式栈。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)。
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作。
示例:
public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}
//方法的字节码
public void testAddOperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
用bipush将15压入操作数栈
执行完后,让PC + 1,指向下一行代码,即将操作数栈的元素存储到局部变量表1的位置
其实局部变量表也是从0开始的,但是因为0号位置存储的是this指针,所以直接省略。
PC+1,指向的是下一行。让操作数8也入栈,同时执行store操作,存入局部变量表中
同上
从局部变量表中,依次将数据放在操作数栈中:
将操作数栈中的两个元素执行相加操作,并存储在局部变量表3的位置:
指向return 退出方法
操作数栈的深度在编译期间已被定义好,且栈中任意一个元素可以是任意的Java数据类型,32位占用一个栈单位深度,64位占用两个。如果当前被调用的方法有返回值,则会被压入当前栈帧的操作数栈中,并更新PC寄存器下一条需要执行的字节码指令。
我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
2.1 栈顶缓存技术
栈顶缓存技术:Top Of Stack Cashing。
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
基于寄存器架构:指令更少,执行速度快。
3. 动态链接
动态链接:Dynamic Linking。每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference)保存在class文件的常量池里。
比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
绑定机制:
链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
方法返回地址
方法返回地址中存放的是:调用该方法的上级方法的PC寄存器中所存的值。
一个方法的结束,有两种方式:
1.正常执行完成
2.出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
-
一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
-
在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn(引用类型)。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用。
2. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
- 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
例如:
Exception table:
from to target type
4 16 19 any
19 21 19 any
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。
JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性。
Java堆
对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然
Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
-
字符串存在永久代中,容易出现性能问题和内存溢出
-
类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
-
永久代会为 GC带来不必要的复杂度,并且回收效率偏低。将 HotSpot 与 JRockit 合二为一。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。