Java虚拟机详解(二)------运行时内存结构
首先通过一张图了解 Java程序的执行流程:
关于这幅图涉及到的:
①、class文件
②、类加载器
③、运行时数据区
④、执行引擎
⑤、垃圾回收器
这都是接下来将要介绍的重点。
本篇博客我们将首先介绍什么是运行时数据区。
PS:下面介绍的是根据 Java虚拟机规范 定义的运行时数据区,上一篇博客我们讲过根据虚拟机规范实现的虚拟机有很多个,而不同的虚拟机其运行时数据区定义也会有所不同。比如默认的 HotSpot 在实现 JDK1.7 虚拟机规范时,其常量池的定义不在方法区中,而是移到了堆中;到了 HotSpot JDK1.8 中,则彻底移除了持久代(方法区)而使用Metaspace(元数据区)来进行替代等等,关于这些区别本篇博客也会在文章末尾进行相应的说明。
1、运行时数据区结构图
①、Java虚拟机规范定义的运行时数据区
②、HotSpot JDK1.8定义的运行时数据区
注意:HotSpot实现的运行时数据区和Java虚拟机规范定义的还是有所不同的,
①、将Java虚拟机栈和本地方法栈合二为一;
②、元数据区取代了方法区,并且元数据区不在Java虚拟机中,而是在本地内存中。
③、运行时常量池由方法区中移到了堆中
2、程序计数器
程序计数器(Program Conputer Register)这是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
①、线程私有
Java虚拟机支持多线程,是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任一确定的时刻,一个处理器只会执行一条线程中的指令,因此为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。因此线程启动时,JVM 会为每个线程分配一个PC寄存器(Program Conter,也称程序计数器)。
②、记录当前字节码指令执行地址
如果当前线程执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,则这个计数器值为空(Undefined)。
③、不抛 OutOfMemoryError 异常
程序计数器的空间大小不会随着程序执行而改变,始终只是保存一个 returnAdress 类型的数据或者一个与平台相关的本地指针的值。所以该区域是Java运行时内存区域中唯一一个Java虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
3、虚拟机栈
Java虚拟机栈(Java Virtual Machine stack),这块区域也是线程私有的,与线程同时创建,用于存储栈帧。Java 每个方法执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
①、线程私有
随线程创建而创建,声明周期和线程保持一致。
②、由栈帧组成
线程每个方法被执行的时候都会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
③、抛出 StackOverflowError 和 OutOfMemoryError 异常
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
4、本地方法栈
本地方法栈(Native Method Stacks)作用和虚拟机栈类型,虚拟机栈执行的是Java方法,本地方法栈执行的是 Native 方法,本地方法栈也会抛出抛出 StackOverflowError 和 OutOfMemoryError 异常。
注意:由于虚拟机规范并没有对本地方法栈中的方法使用语言、使用方式和数据结构强制规定,因此具体的虚拟机可以自由实现它。上图我们也给出在 HotSpot 虚拟机中,本地方法栈和虚拟机栈合为一体了。
5、Java堆
Java堆是Java虚拟机所管理内存最大、被所有线程共享的一块区域,目的是用来存放对象,基本上所有的对象实例和数组都在堆上分配(不是绝对)。Java堆也是垃圾回收器管理的主要区域。
①、线程共享
堆存放的对象,某个线程修改了对象属性,另外一个线程从堆中获取的该对象是修改后的对象,为什么堆要设计成线程共享呢?
我们可以假设堆是线程私有的,很显然一个系统创建的对象会有很多,而且有些对象会比较大,如果设计成线程私有的,那么如果有很多线程同时工作,那么都必须给他们分配相应的私有内存,我相信内存很快就撑爆了,很显然将堆设计为线程共享是最好不过了,不过凡事都具有两面性,线程共享的设计这也带来了多线程并发资源冲突问题,关于这个问题由于不是本系列博客的主旨,这里就不做详细介绍了。
②、存放对象
基本上所有的对象实例和数组都要在堆上进行分配,但是随着 JIT 编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换等优化技术会导致对象不一定在堆上进行分配。
③、垃圾收集
Java堆也被称为“GC堆”,是垃圾回收器的主要操作内存区域。当前垃圾回收器都是使用的分代收集算法,所以Java堆还可以分为:新生代和老年代,而新生代又可以分为 Eden 空间、From Survivor 空间、To Survivor空间。这是为了更好的回收内存,关于垃圾回收算法在后续博客会详细介绍。
④、抛出 OutOfMemoryError 异常
根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,实现时既可以实现成固定大小,也可以是扩展的。如果在堆中没有完成实例分配,并且堆也无法扩展,将抛出OutOfMemoryError 异常。
6、方法区
方法区(Method Area)用来存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区也称为“永久代”,这是因为垃圾回收器对方法区的垃圾回收比较少,主要是针对常量池的回收以及对类型的卸载,回收条件比较苛刻。经常会导致对此内存未完全回收而导致内存泄露,最后当方法区无法满足内存分配时,将抛出 OutOfMemoryError 异常。
PS:在Java虚拟机规范中把方法区描述为堆的一个逻辑部分(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4),在很多虚拟机中(JRockit、IBM J9等虚拟机不存在永久代的概念)。
在JDK1.8 的 HotSpot 虚拟机中,已经去掉了方法区的概念,用 Metaspace 代替,并且将其移到了本地内存来规划了。
7、运行时常量池
在Java虚拟机规范中,运行时常量池(Runtime Constant Pool)用于存放编译期生成的各种字面量和符号引用,是方法区的一部分。但是Java虚拟机规范对其没有做任何细节的要求,所以不同虚拟机实现商可以按照自己的需求来实现该区域,比如在 HotSpot 虚拟机实现中,就将运行时常量池移到了堆中。
①、存放字面量、符号引用、直接引用
通常来说,该区域除了保存Class文件中描述的引用外,还会把翻译出来的直接引用也存储在运行时常量池,并且Java语言并不要求常量一定只能在编译器产生,运行期间也可能将常量放入池中,比如String类的intern()方法,当调用intern方法时,如果池中已经包含一个与该String确定的字符串相同equals(Object)的字符串,则返回该字符串。否则,将此String对象添加到池中,并返回此对象的引用。关于该方法的介绍可以看我这篇博客。
②、抛出 OutOfMemoryError 异常
运行时常量池是方法区的一部分,会受到方法区内存的限制,当常量池无法申请到内存时,会抛出该异常。
8、直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,它也不是Java虚拟机规范定义的内存区域。我们可以看到在 HotSpot 中,就将方法区移除了,用元数据区来代替,并且将元数据区从虚拟机运行时数据区移除了,转到了本地内存中,也就是说这块区域是受本机物理内存的限制,当申请的内存超过了本机物理内存,才会抛出 OutOfMemoryError 异常。
直接内存也是受本机物理内存的限制,在JDK1.4中新加入的 NIO(new input/output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer 对象作为这块内存的引用操作,这样避免了在Java堆和Native堆中来回复制数据,显著提高性能。