简介: 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。然而一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误是非常困难的。
本篇文章主要介绍Java虚拟机运行时的各个部分及其作用。
建议读者先看了入门简介之后再来阅读此篇:[JVM:入门简介篇]
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
运行时数据区详细图如下:
接下来将依次介绍每一部分的作用
简介: 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。Java虚拟机的执行引擎就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
是否线程私有:由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
记录的什么:如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。
Java虚拟机的pc寄存器的内存足可保存方法的返回地址或本机方法的指针。所以此内存区域是唯一没有内存溢出OutOfMemoryError情况的区域。
为什么需要用PC寄存器存储指令执行地址?
由于CPU需要不停的切换每个线程,切换回来之后需要知道当前线程从哪里继续执行。JVM就是通过改变PC寄存器的值来明确下一条执行那个字节码指令。
简介:虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
常见异常:在Java虚拟机规范中,对这个区域规定了两种异常状况:
栈帧(Stack Frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
介绍
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。如下图所示:
作用
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位。
一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress 这8种类型。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。
下面我们来测试以下这几种类型,编写如下方法:
public void test(){
boolean boolean_type = false;
byte byte_type = 0;
char char_type = 0;
short short_type = 0;
int int_type = 0;
float float_type = 0;
String String_type = "";
long long_type = 0;
double double_type = 0;
Object ref_type = new Object();
}
将字节码文件(.class)利用javap命令进行反编译得到如下图局部变量表:
虚拟机使用局部变量表
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个Slot,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机将在类加载的校验阶段抛出异常。
注意点
1 对于实例方法(非static的方法),局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
当对上面的测试代码加上static关键字时:
public static void test(){
boolean boolean_type = false;
byte byte_type = 0;
char char_type = 0;
short short_type = 0;
int int_type = 0;
float float_type = 0;
String String_type = "";
long long_type = 0;
double double_type = 0;
Object ref_type = new Object();
}
2 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。
测试代码如下:
public void test(){
int value1 = 100;
{
int value2 = 200;
}
int value3 = 300;
}
我们可以看到局部变量表中只有value1和value3,而value2的Slot已经被value3所使用,所以在局部变量表中看不到value2.
JVM的这种设计虽然节省栈帧空间,但是会存在一些副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为(可看完我写的垃圾回收篇之后再来看这一部分内容):
设计如下代码:
public class Main {
public static void main(String[] args) {
byte[] bbb = new byte[64 * 1024 * 1024];
System.gc();
}
}
设置虚拟机参数:-XX:+PrintGCDetails
查看垃圾回收行为如下:
可以看到没有回收bbb所占的64MB内存,因为在执行System.gc()时,变量bbb还处于作用域之内,虚拟机自然不会回收bbb的内存。
如果我们改一下上面的代码:
public class Main {
public static void main(String[] args) {
{
byte[] bbb = new byte[64 * 1024 * 1024];
}
System.gc();
}
}
加入了花括号之后,从代码逻辑上讲,在执行System.gc()的时候,bbb已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,这64MB的内存没有被回收,这又是为什么呢?
在解释为什么之前,我们先对这段代码进行第二次修改:
public class Main {
public static void main(String[] args) {
{
byte[] bbb = new byte[64 * 1024 * 1024];
}
int i = 0; // 加上这一行
System.gc();
}
}
运行一下程序,发现这次内存被正确回收了:
bbb能否被回收的根本原因是:局部变量表中的Slot是否还存有关于bbb数组对象的引用。第一次修改中,代码虽然已经离开了bbb的作用域,但在此之后,没有任何对局部变量表的读写操作,bbb原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。导致其无法被回收。
简介
操作数栈(Operand Stack)是一个后入先出(Last In FirstOut,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
作用
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
操作数栈的作用要结合字节码指令的操作才能更好的理解,读者可以参考我写的字节码指令篇。这里只作简单的演示操作数栈的作用。
设计如下简单的代码:
public class Main {
public static void main(String[] args) {
int i = 0;
}
}
反编译获得其字节码指令为:
0 iconst_0 // 将常量0压入操作数栈
1 istore_1 // 将操作数栈的栈顶元素出栈并赋值给局部变量表中Solt为1的变量
2 return
栈顶缓存技术
由于操作数栈是存储在内存中的,因此频繁的读写操作必然会影响执行速度。因此提出了栈顶缓存技术,将栈顶元素全部缓存在CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
注意
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
简介
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
作用
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。
那么什么样的方法使用静态,什么样的又使用动态呢?
JVM中有以下五种调用方法的字节码指令:
关于这些方法调用的字节码指令以及方法重写的本质将在后面即将发布的字节码指令篇中介绍,这里不做过多的阐述。
当一个方法开始执行后,只有两种方式可以退出这个方法。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复
上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈
中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与程序调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
简介
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别就是虚拟机栈为虚拟机执行Java方法,而本地方法栈则为虚拟机使用到的Native方法(该方法是非Java语言实现的)。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
简介
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。
作用
Java堆是垃圾收集器管理的主要区域。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
对于内存的分配和回收将在我之后即将推出的垃圾回收篇中着重介绍,可以关注一下。
Java堆的常用参数
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
简介: 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
Java栈、Java堆、方法区的联系:
对于一个的对象创建如:Object obj = new Object();
1 new Object() 就代表在堆空间中开辟了一块内存存放这个实例
2 obj 就代表着java栈中的引用,指向对象实例
3 Object 则是代表着对象类型,实例的对象头中会包含指向此类型的指针
Hotspot虚拟机演进过程:
在 jdk7 以前,习惯把方法区称为永久代。从 jdk8 开始,用元空间取代了永久代。从本质上讲,两者并不等价。
当使用永久代(-XX:MaxPermSize)来实现方法区时,是将永久代放到java堆中统一管理的,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。但使用永久代来实现方法区,更容易遇到内存溢出问题。
而使用元空间来实现方法区时,方法区的内存是放到本地内存中实现的,这样就不受java堆内存的限制,不容易出现内存溢出的问题。
方法区大小设置:
jdk7之前:
-XX:PermSize 设置初始分配的空间
-XX:MaxPermSize 设置最大分配的空间
jdk8以及之后:
-XX:MetaspaceSize 设置初始分配的空间
-XX:MaxMetaspaceSize 设置最大分配的空间
方法区的内存回收:
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收是比较难的,尤其是类型的卸载 (关于类的卸载会在我之后发布的篇章中介绍),条件相当苛刻,但是这部分区域的回收又确实是必要的。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
概念: 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
类文件结构图如下:
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样是通过索引访问的。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性性。Java语言并不
要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,在运行期间也可能将新的常量放入池中,比如使用String类的intern()方法。
注意事项:方法区与常量池的演进
版本 | 演进 |
---|---|
jdk1.6及之前 | 有永久代(permanent generation),静态变量存放在 永久代上 |
jdk1.7 | 有永久代,但已经逐步"去永久代",字符串常量池、静 态变量移除,保存在堆中 |
jdk1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆 |
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规
范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
待更新…
待更新…
待更新…