深入理解Java虚拟机 - Java内存区域与内存溢出异常
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来。
-
C/C++ 开发人员
- 既拥有最高权力:每一个对象的所有权;
- 又从事最基础的工作:担负着每一个对象声明从开始到终结的维护责任; -
Java 开发人员
- 虚拟机自动内存管理机制完成对象的创建和销毁;
- 优点:操作简单,不容易发生内存泄露和内存溢出问题;
- 缺点:一旦出现内存泄露和溢出问题,需要了解虚拟机内存管理的机制来排查错误。
一、运行时数据区域
JVM包含两个子系统和两个组件:
两个子系统为Class loader(类装载)、Execution engine(执行引擎);
两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)
Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
Execution engine(执行引擎):执行classes中的指令。
Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。 这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:
程序计数器 |
程序计数器(Program Counter Register) 当前线程所执行字节码的行号指示器,字节码解释器通过改变计数器的值来选取下一条需要执行的字节码指令;例:像分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
类比计算机组成原理中的PC指的是PC寄存器,用来存放计算机执行的指令的所在内存区域的地址;与计算机PC不同的是,在Java虚拟机中,PC只是一块较小的内存空间,而不是寄存器。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间来实现的,为了保证线程切换之后,能够恢复到正常的指令执行位置,因此每条线程都需要一个独立的程序计数器,保证各线程之间互不影响;所以程序计数器内存区域属于 “线程私有” 内存;
执行方法不同,工作内容不同:
1. 执行Java方法:计数器记录的是正在执行的虚拟机字节码指令地址;
2. 执行Native:计数器值则为空(Undefined)
注:该内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈 |
Java虚拟机栈(Java Virtual Machine Stacks) 虚拟机栈描述的是Java方法执行的内存模型;每个方法执行的同时都会创建一个 “栈帧”,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈到出战的过程。
它与程序计数器一样,都是线程私有的,生命周期与线程相同
在Java虚拟机规范中,对这个区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOvrflowError 异常;
- 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
本地方法栈 |
本地方法栈(Native Method Stack) 的作用与虚拟机栈非常相似,唯一的区别是本地方法栈是用来执行Native方法的。
Java堆 |
Java堆(Java Heap) 是Java虚拟机所管理的内存中最大一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建;其唯一目的是存放对象实例(几乎所有的对象实例都在这里分配内存)。
Java堆还可以进一步分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等,这些更细致的分区是在Java堆垃圾收集器进行垃圾管理的时候需要考虑的,在此不深入细究。
Java堆的大小可以是固定的,也可以是不固定的,可以通过-Xmx和-Xms控制,前者是最大值,后者是最小值,在两者相同时,堆的大小就是固定的。在内存中如果没有足够的空间来分配,将会抛出OutOfMemoryError异常。
方法区 |
方法区(Method Area) 方法区和Java堆一样,是各个线程共享的内存区域,用来存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
这个区域也是属于需要进行垃圾回收的区域,主要是回收常量池和对类型的卸载,一般来说,回收的效果不会太理想,但是却是必须的。
根据Java虚拟机的规范规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池 |
运行时常量池(Runtime Constant Pool) ;该区域是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,有时候直接引用也会放入,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池有一定的动态性,对String类有所了解的同学应该明白,在运行期间通过String类的intern()方法可以动态往常量池里动态添加常量。
当常量池无法再申请到内存时,会抛出OutOfMemoryError异常。
直接内存 |
直接内存(Direct Memory) 不属于Java虚拟机运行时数据区的一部分,而是属于操作系统管理的区域。这部分的使用很频繁,利用的好,可以大大提升程序的运行效率,比较优秀的使用例如基于NIO的Netty框架等。
为什么使用直接内存可以提升性能呢,因为可以避免在Java堆和Native堆中来回复制数据的开销。
这部分的内存使用不会收到Java堆大小的限制,但会收到本机的内存大小限制。因此,在操作这部分内存时需要谨慎,一旦出问题,可能会影响到本机的其它服务。 当各个内存区域总和大于物理内存限制,抛出OutOfMemoryError异常。
二、HotSpot虚拟机对象探秘
对象创建的主要流程:
1. 虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。
2. 类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。
3. 划分内存时还需要考虑一个问题-并发,也有两种方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。
4. 然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行 init 方法。
对象的访问定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。
指针: 指向对象,代表一个对象在内存中的起始地址。
句柄:可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。
文章内容参考
https://blog.****.net/ThinkWon/article/details/103827387/