本章节为对JVM运行时内存区域的简介,后续将会推出对堆、栈等区域的更加详细的介绍。
三、运行时内存区域
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行 JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。比如HotSpot有方法区,而其他虚拟机就没有方法区这个分区。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
- 线程私有:程序计数器PC、虚拟机栈、本地方法栈。
- 线程共享:堆、堆外内存(方法区(永久代)或元空间(JDK8以后)、代码缓存(JIT编译产物))
首先明确:
- 存在GC垃圾回收的区域是:Java堆 与 方法区
- 存在溢出的是:
- OOM【OutOfMemoryError,堆内存溢出】:Java堆 与 方法区
- SOF【StackOverFlow,栈内存溢出】:虚拟机栈 与 本地方法栈
- 程序计数器PC既没有GC,也没有溢出
3.1 程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令,以此来控制程序的执行过程。
程序计数器会随着线程的启动而创建,其生命周期与线程的生命周期保持一致。
java是一门支持多线程的语言,并且 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(内核)只会执行一个线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,即程序计数器是为线程私有的。
![image-20201111171136583](https://i-blog.csdnimg.cn/blog_migrate/27b202339de73e1b07262dcc496ddc59.png)
PC寄存器的作用是用来存储各个线程自己的要执行的下一条指令地址,由执行引擎来读取下一条指令。
3.2 本地方法栈
![image-20201112125445273](https://i-blog.csdnimg.cn/blog_migrate/29045c71c389ce2201065a12824fe33a.png)
3.2.1 本地接口
本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,Java 诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket通信,也可以使用Web Service等等,不多做介绍。
- JAVA方法:由JAVA语言编写,被编译成字节码存在于Class文件中
- 本地方法:由其他语言编写,被编译成与处理器相关的代码
通过本地方法,JAVA程序可以直接访问底层从而可以操作系统资源。本地方法是暴露给JAVA的一个接口,JAVA可以通过 JNI 直接调用本地 C/C++ 库。
3.2.2 本地方法栈
本地方法栈和虚拟机栈很相似,区别在于本地方法栈是为Native方法服务而虚拟机栈是为虚拟机执行java方法(即字节码)服务,在本地方法栈也会抛出StackOverFlowError异常、OutOfMemoryError异常。
它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。
3.3 虚拟机栈
虚拟机栈描述的是 Java 方法执行的内存模型,方法的执行的同时会创建一个栈帧用于存储方法中的局部变量表、操作数栈、动态链接、方法的出口等信息。每个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM对Java栈的操作只有两个:
- 每个方法执行时,进栈
- 执行结束后,出栈
3.4 堆
Java堆是Java虚拟机所管理的内存中最大的一块,其唯一的目的是存放对象实例。java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建,几乎所有对象的实例都存储在堆中,所有的对象和数组都要在堆上分配内存。
java堆是垃圾收集器(GC)管理的主要区域,java堆中可以划分出多线程私有的缓冲区,但是无论怎么划分,对象的实例仍然存储在堆中。将Java堆细分为新生代、老年代等的目的只是为了更好地回收内存,或者更快地分配内存。java堆允许处于不连续的物理内存空间中,只要逻辑连续即可。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。堆中如果没有空间完成实例分配无法扩展时将会抛出OutOfMemoryError异常。
注:在JDK7之后,静态变量也存放在堆中。
3.5 方法区
方法区与堆一样是被所有线程所共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、“静态变量”、及时编译器编译后的代码等类结构信息。【JDK8以后,静态变量转入堆中】
《Java虚拟机规范》中把方法区描述为一个逻辑部分,有一个别名“非堆”,目的是与Java堆区别开来。方法区与堆的关系图如下:
方法区是一种规范,在不同虚拟机中的实现是不一样的,最典型的是永久代和元空间。
3.5.1 永久代
永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。
即在JDK7之前,SUN公司的HotSpot虚拟机设计团队将收集器的分代设计扩展至方法区,或者说使用永久区来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
3.5.2 元空间
但是,对于其他公司的虚拟机,是不存在这个永久代概念的。而到了JDK8,则完全放弃了永久代的概念,将原来永久代中剩余的内容全部放入元空间(Meta-space)中,这一部分内容主要是类型信息。
元空间与永久代之间最大的区别在于:
==永久代使用JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存。==因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入本地内存, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。
(1)替换永久代的原因
《Java虚拟机规范》中,使用元空间替换永久代是JRockit与HotSpot两大虚拟机融合工作的一部分。
但是其真正的原因有:
- 为永久代设置空间大小是很难确定的
- 对永久代进行调优是很困难的。置于方法区中的数据并不是像“永久区”这个名字一样就一直不会被回收,只是回收的条件变得非常苛刻了。
- 主要是为了降低Full GC,减少GC的操作耗时(STW)
(2)元空间参数设置
在默认设置下,很难再迫使虚拟机产生方法区的溢出异常了。不过为了让使用者有预防实际应用里出现破坏性的操作,HotSpot还是提供了一 些参数作为元空间的防御措施,主要包括:
- -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
- -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
- -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。
- -XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。
3.5.3 方法区的垃圾回收
(1)简介
《JAVA虚拟机规范》对方法区的约束非常宽松。除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。置于方法区中的数据并不是像“永久区”这个名字一样就一直不会被回收,只是回收的条件变得非常苛刻了。这区域的回收目标是针对常量池的回收和对类型的卸载,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError 异常。
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
(2)回收内容
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading 查看类加载和卸载信息
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.5.4 方法区的演进
首先明确:只有 HotSpot 才有永久代。对于 JRockit、IBMJ9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
(1)JDK6
JDK6中方法区中存放有类信息、常量、即时编译器编译后的代码、静态变量、运行时常量池等类结构信息
![image-20201116211834650](https://i-blog.csdnimg.cn/blog_migrate/11dfaf8ae6393558479ff0e61b741f70.png)
(2)JDK7
JDK7中将方法区中的静态变量与字符串常量池移入堆中。
![image-20201116212124242](https://i-blog.csdnimg.cn/blog_migrate/775989f4a1d239853d90d057ba5b4374.png)
字符串常量池StringTable移入堆中的原因:
JDK 7 中将 StringTable 放到了堆空间中,是因为永久代的回收条件很苛刻,所以其回收效率很低。对于永久代的回收只有在 Full GC 的时候才会触发,而 Full GC 是老年代的空间不足、永久代不足时才会触发,所以就导致了针对 StringTable 字符串常量池的回收效率不高。而我们开发中会有大量的字符串被创建,若字符串的回收效率低,将导致永久代内存不足。将其转入到堆里,就可以实现对字符串的及时回收。
(3)JDK8
JDK8去永久代,使用元空间替换了永久代的概念。元空间不使用虚拟机内存,而是使用本地物理内存,所以元空间的大小将受本地内存大小的限制。
![](https://i-blog.csdnimg.cn/blog_migrate/1fb3da2bfe45a4155b5cf2ba17c6c2c2.png)
3.6 运行时常量池
运行时常量池是方法区的一部分。
- 字节码文件——内含常量池——加载类的信息
- 方法区——内含运行时常量池——由常量池加载而来
3.6.1 常量池
常量池并不是JVM内存模型中的一部分,而是 Class 字节码文件的一部分,通过使用2进制数记录相关的信息。常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
一个有效的Class字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接使用字节码来进行存储,可以使用符号引用的方式将其存到常量池中,这个字节码包含了指向常量池的引用。比如:
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}
虽然上述代码只有194字节,但是里面却使用了 String、System、PrintStream 及 Object 等结构。这里就用到了常量池。将上述引用的结构作为符号引用来使用,使用动态链接的方式,在真正使用的时候再去方法区中进行加载。常量池中存储的内容为:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
3.6.2 运行时常量池
运行时常量池是方法区的一部分。
![image-20201116203729230](https://i-blog.csdnimg.cn/blog_migrate/49adc46b2de220ef8bf6a7f049cd4a8a.png)
在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM 为每个已加载的类型(类或接口)都维护着一个常量池,常量池中的数据项是通过索引访问的【符号引用】。
在类加载【主要是解析阶段,解析负责将符号引用转换为直接引用】后,运行时常量池中包含多种不同的常量:
- 编译期就已经明确的数值字面量——解析阶段
- 运行期解析后才能够获得的方法或者字段引用【运行常量池中的引用与常量池中的符号引用不同,运行时常量池中的引用为真实引用,是真实的地址】——分派阶段
运行时常量池相对于常量池的另一个重要特征是具备动态性,java语言并不要求常量一定只有编译时期才能产生,也就是说并非预置入Class字节码文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入常量池。
运行时常量池是方法区的一部分,当方法区无法分配足够内存时,将会抛出OutOfMemoryError异常。
3.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是《java虚拟机规范》中定义的内存区域,但这部分内存也被频繁的使用,而且也会导致OutOfMemoryError异常。
在JDK1.4中新加入了NIO类,引入了一种基于通道的与缓冲区的I/O方式,他可以使用Native函数直接分配堆外内存,然后通过位于堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以显著提高一些性能,因为避免Java堆和Native堆中来回的复制数据。
显然,直接内存不会受到Java堆内存的大小的影响,但是既然是内存,肯定还是受到本机内存大小以及处理器寻址范围限制。当各个内存区域的总和大于物理存储限制,从而导致动态扩展是出现OutOfMemoryError异常。