JVM运行时数据区
一、前言
我们知道Java程序是运行在JVM(Java虚拟机)上的,Java程序运行时会占用一定的内存,在虚拟机自动管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出的问题,看起来由虚拟机管理内存一切都很美好。不过,也正式因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将成为一项异常艰难的工作。所以,今天这篇文章我们来简答为大家介绍一下Java虚拟机在运行时的数据区都有哪些。
二、JVM运行时数据区概述介绍
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建时间和销毁的时间。有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
根据生命周期和作用域可以分为两种:线程共享的区域和线程私有的区域
其中:
- 线程共享的区域:方法区 和 堆,这些区域的生命周期和Java程序的生命周期一致。
- 线程私有的区域:虚拟机栈、本地方法栈、程序计数器,这些区域的生命周期随着用户线程的启动和结束而创建和销毁。
三、JVM运行时数据区详细介绍
(一)程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
因为操作系统的 CPU 核数就那么多,在 Java 虚拟机中的想要多线程执行就需要轮流切换执行,被分配到处理器执行时间的线程才会执行。所以就需要程序计数器记录执行位置,等线程切换后可以找到字节码上次的执行位置继续执行。各个线程都有自己的程序计数器,所以这块是线程私有的。并且程序计数器也是唯一不会发生内存溢出(OutOfMemoryError)的区域。并且程序计数器的生命周期随着线程的创建而创建,随着线程的结束而死亡。
程序计数器的主要有两个作用:
(1)字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。如顺序执行、选择、循环、异常处理。
(2)在多线程情况下,程序计数器用于记录当前线程执行的位置,从而确定当前线程被切换回来的时候知道上次执行到了哪。
程序计数器在记录当前执行的Java方法和本地方法有什么区别:
- 在执行Java方法和本地方法时,程序计数器记录的数值是有区别的。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的程序字节码指令的地址。如果执行的是本地方法,这个计数器的值则为空null。
我们思考这样的一个问题:如果本地方法记录的是空值,线程恢复后怎么才能找到正确的位置呢?(重点思考!!!!)
在Java虚拟机中,线程在启动的时候会创建一个虚拟机栈,每个线程都有一个独立的虚拟机栈。每当线程方法调用的时候,就会往栈中压入一个栈帧,栈帧中存储了当前方法需要用到的一些数据以及当前执行的字节码指令的位置等信息。当线程执行完毕一个方法后,就会弹出这个方法对应的栈帧,然后恢复上一个方法的栈帧。
当线程在执行本地方法时,虚拟机并不会使用Java堆栈帧来存储本地方法执行的相关信息,而是使用了一种名为JNI(Java Native Interface)的机制来处理。JNI允许Java程序调用本地语言编写的代码,并传递参数和返回值。在JNI的实现中,Java虚拟机会为每个本地方法的调用去创建一个独立的栈帧,本地方法栈的栈帧中包含了本地方法执行时所需要的一些数据,例如函数调用的参数、返回值等。当本地方法执行完毕后,Java虚拟机就会清除对应的本地方法栈帧。
所以当本地方法执行完毕后,Java虚拟机会根据返回的值来判断程序接下来需要执行哪一条字节码指令,并将计数器设置为对应的值,然后继续执行Java程序。因此,即使程序计数器在本地方法执行期间被设置为null,在本地方法执行完毕后,Java虚拟机还是可以通过其他方式来回复正确的程序计数器,并继续执行对应的Java程序字节码。
(二)虚拟机栈
虚拟机栈描述的是Java方法执行的线程内存模型。与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
每个Java方法被执行的时候,Java虚拟机都会同步创建一个栈帧,并将栈帧压入栈中,每个方法调用结束后,都会有一个栈帧被弹出
栈由一个个的栈帧组成,用于存储Java方法相关的局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持入栈和出栈两种操作。
所有Java方法的调用都是通过Java虚拟机栈来实现的(需要和其他运行时数据区配合比如程序计数器),而对于一些本地方法的调用则是通过本地方法栈实现的。
其中:
- 局部变量表:用于存放Java方法编译期可知的各种数据类型的参数、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
- 操作数栈:主要用于存放Java方法执行过程中所产生的中间结果及临时变量。
- 动态链接:主要是用于将符号引用转换为调用方法的直接引用。动态链接服务与一个方法调用其他方法的场景。(class文件的常量池里保存大量的符号引用比如:方法引用的符号引用,当一个方法调用其他方法,需要将常量池中指向方法的符号引用转换为其在内存地址中的直接引用。)
- 返回地址:指当前方法执行结束后返回到上一个方法继续执行的指令地址。(方法退出分为两种情况:正常退出和异常退出;正常退出的话就按照返回地址正常恢复到调用位置,异常退出需要搜索本方法异常表进行匹配(就是方法内是否 catch 异常),没有匹配到就不会将返回地址返回。)
问题思考:程序运行过程中,虚拟机栈可能会抛出哪些错误?
虚拟机栈空间虽然不是无限的,但一般正常情况下是不会出现问题的。但是如果函数调用陷入无限循环的话,就会导致栈中被压入太多的栈帧而占用太多空间,进而导致栈空间过深。当线程请求栈的深度超过当前Java虚拟机栈的最大深度时候,就会抛出StackOverFlowError错误。(StackOverFlowError是一种error异常,表示虚拟机栈空间已满,无法再进行方法的调用)
Java方法有两种返回方式:一种是return正常返回,另一种是抛出异常。不管哪种返回方式都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着调用结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了StackOverFlowError错误之外,栈还有可能出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展,如果虚拟机在动态扩展虚拟机栈的内存大小空间时且无法申请到足够的内存空间,则抛出OutofMemoryError异常。
综上所述:程序运行过程中,虚拟机栈可能会抛出两种错误(error),分别为:StackOverFlowError栈空间已满、OutOfMemoryError内存溢出
- StackOverFlowError错误:函数调用陷入无限循环时,线程请求栈的深度超过Java虚拟机栈的最大深度时,就抛出StackOverFlowError错误。
- OutOfMemoryError错误:如果栈的内存大小可以动态扩展,当虚拟机栈在动态扩展时且在虚拟机上无法申请到足够的内存空间,则抛出OutOfMemoryError错误。
(三)本地方法栈
和虚拟机栈发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行的Java方法(也就是字节码)服务,而本地方法栈则为虚拟机执行本地方法服务。
在HotSpot虚拟机中将本地方法栈和虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会去创建一个栈帧,用于存储该本地方法的局部变量表、操作数栈、动态链接、出口信息。
本地方法执行完毕后栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError错误。
(四)堆
堆是Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建(即Java程序启动时创建,生命周期和Java程序一致)。Java堆的唯一目的就是存放对象实例,几乎所有的对象实例都在堆里分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap),从垃圾回收的角度,由于现在收集器都采用分代垃圾收集算法,所以Java堆的内存还可以细分为:新生代、老年代;再细一点有:Eden、Survivor、Old等空间。进一步划分的目的是为了更好的回收内存或是更快的分配内存。
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
堆这里最容易出现的就是OutOfMemoryError错误,并且出现这种错误后的表现形式还会有如下几种:
- java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误
- java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小)
(五)方法区
方法区属于JVM运行时数据区的一块逻辑区域,方法区是线程共享的,生命周期和Java程序一致,用来存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据( 即HotSpot 著名的 JIT 即时编译技术的热点代码缓存等数据)。
问题1:方法区和永久代以及元空间是什么关系呢?
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
问题2:永久代和元空间的区别是什么?说说对永久代和元空间的理解
永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。
在 JDK1.7 及 JDK1.7 之前的版本,JVM 是通过堆中的永久代实现方法区的,容易导致内存溢出;而 1.7 版本,将原来在永久代的静态变量和字符串常量池(为了提升性能和减少内存消耗为常量字符串单独开辟的空间)移到了堆空间里;到了JDK1.8,JVM 完全放弃永久代选择使用元空间来实现方法区,直接使用本地内存(Native Memory)。
JDK1.8 版本 静态变量、字符串常量池、类信息与运行时常量池(存放类加载生成的字面量和符号引用)内存布局:
问题3:为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
1、永久代内存大小固定(整个永久代有一个 JVM 本身设置的固定大小上限),无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
2、永久代垃圾回收速率低。永久代的内部存储着各个类的相关信息,所以对永久代的某些对象进行垃圾回收时,可能需要触发Full GC进行一次全面扫描。而元空间则采用新的垃圾回收机制:指针碰撞或者空闲列表的方式进行分配和回收,可以有效的降低Full GC的频率,提高垃圾回收效率。
3、元空间可以加载类的数据更多。元空间里面存储的是类的元数据,这样加载多少类元素就不由MaxPermSize控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了。
另外,我们还需要关注如下几个概念
(一)运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到
方法区的运行时常量池中。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
(二)字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
问题:JDK1.7为什么要将字符串常量池移动到堆中?
1、提高垃圾回收效率。 主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC垃圾回收。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
2、降低了OutOfMemoryError错误的出现。 字符串常量池移入堆中后,可根据需求动态扩展,减少了OutOfMemoryError错误的出现。
(三)直接内存
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。