JVM之内存模型
Contents
Java
与C++
之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
一、内存简介
1.1 内核空间与用户空间
一台计算机通常有固定大小的内存空间,但是程序并不能使用全部的空间。因为这些空间被分为操作系统内核空间和用户空间,而程序只能使用用户空间的内存。
1.2 使用内存的 Java 组件
Java 启动后,作为一个进程运行在操作系统中,那些组件会需要占用内存?
- 堆内存:Java 堆、类和类加载器
- 栈内存:线程
- 本地内存:NIO、JNI
二、运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域 有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。
2.1 程序计数器
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一 个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因 此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程 之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。
2.2 Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)
也是线程私有的,它的生命周期与线程相同。
每个 Java 方法在执行的同时都会创建一个栈帧(Stack Frame)
用于存储 局部变量表、操作数栈、常量池引用 等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 局部变量表: 在内存空间以局部变量槽(slot, 32位)来表示,存放了编译期可知的各种基本数据类型、对象引用、ReturnAddress 类型,并且空间在编译器已经分配。在方法运行期间不会改变局部变量表的大小
- 操作数栈: 基于栈的执行引擎,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据、执行运算,然后把结果压回操作数栈。
- 动态链接: 每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接引用,这部分称为动态链接。
- 方法出口: 返回方法被调用的位置,恢复上层方法的局部变量和操作数栈,如果没有返回值,则把它压入调用者的操作数栈。
注意!该区域可能抛出以下异常:
- 如果线程请求的栈深度超过最大值,就会抛出
StackOverflowError
异常;- 如果虚拟机栈进行动态扩展时,无法申请到足够内存,就会抛出
OutOfMemoryError
异常。提示:
可以通过-Xss
这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小:
1
java -Xss=512M HackTheJava
2.3 Java 本地方法栈
本地方法栈(Native Method Stacks)
与虚拟机栈所发挥的作用是非常相似的。
区别在于: 虚拟机栈为 Java 方法服务;本地方法栈为 Native 方法服务。 本地方法是由 C 语言实现的。
注意: 本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
2.4 Java 堆
对于Java应用程序来说,Java堆(Java Heap)
是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的内存区域(所以也被称为 ‘GC’ 堆)。由于现代垃圾收集器大部分都是基于分代收集理论
设计的,该算法的思想是针对不同的对象采取不同的垃圾回收算法。
因此虚拟机把 Java 堆分成以下三块:
- 新生代 Young Generation
- Eden -Eden 和 Survivor 的比例为 8:1
- From Survival
- To Survival
- 老年代 Old Generation
- 永久代 Permanent Generation
当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。具体的垃圾回收机制会在下一个篇章中介绍。
注意:Java 堆不需要连续内存,并且可以动态扩展其内存,扩展失败会抛出
OutOfMemoryError
异常。
可以通过-Xms
和-Xmx
两个虚拟机参数来指定一个程序的 Java 堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
1
java -Xms=1M -Xmx=2M HackTheJava
2.5 Java 方法区
方法区(Method Area
也被称为永久代(Hotspot 虚拟机),由各个线程共享。方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译(JIT)后的代码等数据。
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
注意:
和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出OutOfMemoryError
异常。
提示:
- JDK 1.7 之前,HotSpot 虚拟机把它当成永久代来进行垃圾回收。可通过参数
-XX:PermSize
和-XX:MaxPermSize
设置。- JDK 1.8 之后,取消了永久代,用 metaspace(元数据) 区替代。可通过参数
-XX:MaxMetaspaceSize
设置。
2.6 Java 运行时常量池
运行时常量池(Runtime Constant Pool)
是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容会在类加载后被放入这个区域。
- 字面量 - 文本字符串、声明为 final 的常量值等。
- 符号引用 - 类和接口的完全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。
除了在编译期生成的常量,还允许动态生成,例如 String
类的 intern()
。这部分常量也会被放入运行时常量池。
注意:
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出OutOfMemoryError
异常。
2.7 Java 直接内存
直接内存(Direct Memory)
并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现
在JDK 1.4中新加入了NIO(New Input/Output)
类,引入了一种基于通道(Channel)与缓冲区(Buffer) 的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
2.8 Java 内存区域对比
内存区域 | 内存作用范围 | 异常 |
---|---|---|
程序计数器 | 线程私有 | 无 |
Java 虚拟机栈 | 线程私有 | StackOverflowError 和 OutOfMemoryError |
本地方法栈 | 线程私有 | StackOverflowError 和 OutOfMemoryError |
Java 堆 | 线程共享 | OutOfMemoryError |
方法区 | 线程共享 | OutOfMemoryError |
运行时常量池 | 线程共享 | OutOfMemoryError |
直接内存 | 非运行时数据区 | OutOfMemoryError |
三、JVM 运行原理
|
|
运行以上代码时,JVM 处理过程如下:
- JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。
- JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
class
文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值。- 完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器
<clinit>
方法,编译器会在.java
文件被编译成 .class
文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为<clinit>()
方法。 - 执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。
- 此时再次创建一个 JVMCase 对象,调用 sayHello 非静态方法,sayHello 方法属于对象 JVMCase,此时 sayHello 方法入栈,并通过栈中的 student 引用调用堆中的 Student 对象;之后,调用静态方法 print,print 静态方法属于 JVMCase 类,是从静态方法中获取,之后放入到栈中,也是通过 student 引用调用堆中的 student 对象。
四、OutOfMemoryError
4.1 什么是 OutOfMemoryError
OutOfMemoryError
简称为 OOM。Java 中对 OOM 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。通俗的解释是:JVM 内存不足了。
在 JVM 规范中,除了程序计数器区域外,其他运行时区域都可能发生 OutOfMemoryError
异常
下面逐一介绍 OOM 发生场景。
4.2 Java 堆空间溢出
|
|
该错误意味着:对空间溢出
Java 堆内存已经达到 -Xmx 设置的最大值。Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集器回收这些对象,那么当堆空间到达最大容量限制后就会产生 OOM。
堆空间溢出有可能是 内存泄漏(Memory Leak) 或 内存溢出(Memory Overflow) 。需要使用 jstack
和 jmap
生成 threaddump
和 heapdump
,然后用内存分析工具(如:MAT)进行分析。
4.2.1 内存泄漏
内存泄漏是指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况
内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。内存泄漏随着被执行的次数不断增加,最终会导致内存溢出。包括以下场景:
- 静态容器 -声明静态的
HashMap
、ArrayList
等集合;通俗来讲 A 中有 B,当前只把 B 设置为空,A 没有设置为空,回收时 B 无法回收。因为被 A 引用。 - 监听器 -监听器被注册后释放对象时没有删除监听器
- 物理连接 -各种连接池建立连接,必须通过
close()
关闭连接 - 内部类和外部模块等的引用
4.2.2 内存溢出
如果不存在内存泄漏,即内存中的对象确实都必须存活着,则应当检查虚拟机的堆参数(-Xmx
和 -Xms
),与机器物理内存进行对比,看看是否可以调大.并从代码上检查是否存在某些对象生命周期过长、持有时间过长的情况,尝试减少程序运行期的内存消耗。
4.3 GC 开销超过限制
|
|
即:**超过 98% 的时间用来做 GC 并且回收了不到 2% 的堆内存时会抛出此异常。**这意味着,发生在 GC 占用大量时间为释放很小空间的时候发生的,是一种保护机制。导致异常的原因:一般是因为堆太小,没有足够的内存。 [示例]
|
|
[处理]
与 Java heap space
错误处理方法类似,先判断是否存在内存泄漏。如果有,则修正代码;如果没有,则通过 -Xms 和 -Xmx 适当调整堆内存大小。
4.4 永久代空间不足
|
|
Perm (永久代)
空间(Java 方法区,Hotspot)主要用于存放 Class 和 Meta 信息,包括类的名称和字段,带有方法字节码的方法,常量池信息,与类关联的对象数组和类型数组以及即时编译器优化。GC 在主程序运行期间不会对永久代空间进行清理,默认是 64M 大小。
根据上面的定义,可以得出 PermGen 大小要求取决于加载的类的数量以及此类声明的大小。因此,可以说造成该错误的主要原因是永久代中装入了太多的类或太大的类。
在 JDK8 之前的版本中,可以通过 -XX:PermSize 和 -XX:MaxPermSize 设置永久代空间大小,从而限制方法区大小,并间接限制其中常量池的容量。
4.5 元数据区空间不足
|
|
Java8 以后,JVM 内存空间发生了很大的变化。取消了永久代,转而变为元数据区。元数据区的内存不足,即 方法区和运行时常量池 的空间不足。
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。
当由于元空间而面临 OutOfMemoryError 时,第一个解决方案应该是显而易见的。如果应用程序耗尽了内存中的 Metaspace 区域,则应增加 Metaspace 的大小。更改应用程序启动配置并增加以下内容:
|
|
4.6 无法建立本地线程
|
|
这个错误意味着:Java 应用程序已达到其可以启动线程数的限制。
当发起一个线程的创建时,虚拟机会在 JVM 内存创建一个 Thread 对象同时创建一个操作系统线程,而这个系统线程的内存用的不是 JVM 内存,而是系统中剩下的内存。那么,究竟能创建多少线程呢?这里有一个公式:
线程数 = (MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize)
MaxProcessMemory
- 一个进程的最大内存JVMMemory
- JVM 内存ThreadStackSize
- 线程栈的大小
给 JVM 分配的内存越多,那么能用来创建系统线程的内存就会越少,越容易发生 unable to create new native thread
。所以,JVM 内存不是分配的越大越好。
通常无法创建新的本机线程需要经历以下阶段:
- JVM 内部运行的应用程序请求新的 Java 线程
- JVM 本机代码代理为操作系统创建新本机线程的请求
- 操作系统尝试创建一个新的本机线程,该线程需要将内存分配给该线程
- 操作系统将拒绝本机内存分配,原因是 32 位 Java 进程大小已耗尽其内存地址空间(例如,已达到(2-4)GB 进程大小限制)或操作系统的虚拟内存已完全耗尽
- 引发
java.lang.OutOfMemoryError: Unable to create new native thread
错误。
4.7 直接内存溢出
由直接内存导致的内存溢出,一个明显的特征是在 Head Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,就可以考虑检查一下是不是这方面的原因。 [示例]
|
|
五、StackOverflowError
对于 HotSpot 虚拟机来说,栈容量只由 -Xss
参数来决定如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError
异常。
栈溢出的常见原因:
- 递归函数调用层数太深
- 大量循环或死循环
Author 拾光
LastMod 2021-12-11