Java内存模型
本文是在《深入理解JAVA虚拟机》第三版的基础上整理出来的,强烈建议大家都去看看!
运行时数据区
JAVA虚拟机在运行JAVA程序的过程中会把内存分成若干个数据区,这些数据区有各自不同的用途以及创建和销毁时机,有的区域伴随着JAVA虚拟机的进程启动而一直存在,有的区域则依赖用户的线程启动(创建)和停止(销毁),依据《JAVA虚拟机规范》的规定,JAVA虚拟机所管理的内存包括以下几个运行时数据区。
程序计数器
程序计数器是一块比较小的内存空间,它可以看做是每个线程执行字节码指令的行号指示器。字节码解释器工作的时候,就是通过改变这个指示器的行号来选取下一条字节码指令的。线程的分支、循环、跳转、异常处理、线程恢复等功能都是依赖这个计数器来完成的。
如果虚拟机正在执行一个JAVA方法,那么计数器记录的就是JAVA虚拟机字节码指令的地址;如果执行的是本地方法(native),那么这里记录的内容就为空(undefined)。需要说明的是,这里是唯一不会抛出OutOfMemoryError的地方。
虚拟机栈
和程序计数器一样,虚拟机栈也是线程私有的,它的生命周期和线程有一样。虚拟机栈描述的是执行JAVA方法的线程内存模型,当有JAVA方法被调用的时候,JAVA虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链表、方法出口等信息,每个JAVA方法从调用到执行完毕,其实都是对应的栈帧由入栈到出栈的过程。
说到这里,其实大家可以结合程序计数器考虑一下下面的伪代码有什么区别
1 | int a = 0; |
1 | int a = 0; |
局部变量表
我们经常说JAVA有堆内存和栈内存,这里的栈内存其实就是指的虚拟机栈,并且大多数情况下,栈内存指的是虚拟机栈的局部变量表。局部变量表存储了编译期可预知的基本数据类型(byte、char、String、int、float、double、long、boolean)、对象的引用reference(可能是一个指针也可能是一个句柄)和returnAddress类型(一个字节码指令的地址)。
上面说到了,在局部变量表中对于对象的引用可能是一个指针,也可能是一个句柄,那么这两种方式各有什么优劣呢?
我们可以看一下句柄引用的图:
句柄引用时,局部变量表保存了对象的句柄,由于垃圾回收的关系,对象在内存中的地址会经常变化(比如对象在两个幸存者区来回移动),所以当对象内存地址变化时,只需要更新句柄池中实例对象的地址指针即可,局部变量表中的reference不用更新,但是如果要获取对象实例的时候,需要先通过句柄找到对应的实例对象的指针地址,然后才能找到实例对象,这样就多了一步操作。
指针引用:
如果使用指针引用,局部变量表中保存的就是实例对象的指针地址,在获取对象实例的时候,直接就可以通过指针找到对象,相对于句柄引用,指针引用的查询效率会快很多,但是,如果实例对象被GC移动了,那么就需要去更新局部变量表中的reference。
我们常用的HotSpot虚拟机默认使用的就是指针引用。
内存异常
在虚拟机栈这片内存区域中可能会出现两种内存上的错误:StackOverflowError和OutOfMemoryError。
- StackOverflowError
如果线程请求的栈深度大于虚拟机所允许的最大深度时,将会抛出StackOverflowError。 - OutOfMemoryError
如果虚拟机栈容量允许动态扩展,当栈扩展无法申请到足够的内存时,将会抛出OutOfMemoryError。
HotSpot虚拟机的栈是不能动态扩展的,但是Classic虚拟机倒是可以。
下面我们可以通过代码在HotSpot虚拟机上演示出StackOverflowError。
1 | int i = 0; |
JAVA版本如下:
1 | java version "1.8.0_181" |
后面如果没有特殊说明,所有的代码都是在JAVA8环境下运行的。
本地方法栈
本地方法栈与虚拟机栈很相似,只不过虚拟机栈是为了虚拟机执行JAVA方法时提供服务,本地方法栈是为了虚拟机用到的本地方法(Native Method)服务。
在《JAVA虚拟机规范》中并没有对本地方法栈的实现做强制要求,有些虚拟机(HotSpot)甚至直接把本地方法栈和虚拟机栈合二为一。
由于本地方法栈和虚拟机栈的功能类似,所以在本地方法栈的内存中,同样会存在StackOverflowError和OutOfMemoryError两种内存错误的情况。
JAVA堆
JAVA堆是虚拟机所能管理的内存中的最大的一块,这里是所有线程之间共享的,依据《JAVA虚拟机规范》的描述,所有对象的实例以及数组都应该在堆上分配。
从垃圾回收的角度来看,由于大部分的垃圾回收是基于分代收集理论来实现,所以我们经常会看到JAVA堆内存又会被分为新生代、老年代,还能经常看到伊甸园区、From幸存者区、To幸存者区等等概念,这些区域的划分仅仅是基于分代收集理论的垃圾回收器的共同特性或者设计风格,并不是《JAVA虚拟机规范》的强制要求。拿HotSpot虚拟机来说,它就是基于分代收集理论设计的,因此讨论上面的几个区域可能并不会出现太大的问题,但是如果抛开了虚拟机单纯去讨论新生代、老年代,那么这种说法可能就不一定正确了,并且,新的HotSpot虚拟机也出现了不基于分代理论的设计。
在JAVA7之后,将原来方法区中的字符串常量池转移到了JAVA堆中。
我们可以通过下面的代码来验证一下:
1 | /** |
运行结果:
1 | Exception in thread "main" java.lang.OutOfMemoryError: Java heap space |
HotSpot堆模型
HotSpot虚拟机采用的是分代回收设计,堆内存可以大致划分为伊甸园区、2个幸存者区、老年代等几个区。新对象首先会出现在伊甸园区,然后会被GC转移到From幸存者区,然后会再次被转移到To幸存者区,对象在幸存者区经过几次复制之后,会被转移到老年代。
内存异常
为了在程序出现了OOM异常后我们可以对当时的现场进行分析,最好在程序启动的时候添加-XX:+HeapDumpOnOutOfMemoryError
和-XX:HeapDumpPath=/
两个参数。
- -XX:+HeapDumpOnOutOfMemoryError
如果出现了OOM异常,就把当时的内存dump下来,便于后面的分析。 - -XX:HeapDumpPath
内存dump文件存储的位置。=
后面紧跟着的就是文件存储的位置。
如果出现了OOM的异常,我们一般会考虑两种情况:内存溢出和内存泄露。
- 内存溢出
当程序申请的内存超过了最大可用内存时,这就造成了内存溢出。如果确定是内存溢出,可以结合实际硬件情况,适当调大JAVA虚拟机的最大内存(-Xmx和-Xms
参数)。
我们可以通过下面的代码模拟出内存溢出的情况:
1 | public static class HeapObject{ |
1 | java.lang.OutOfMemoryError: Java heap space |
- 内存泄露
当程序申请的内存无法被释放掉,这样就造成了内存泄露,偶尔的内存泄露不会产生太大的问题,但是如果经常出现内存泄露就必须引起重视,放任不管的话,无论多大的内存,到最后都会被消耗殆尽。
内存泄露的问题比较难排查,需要借助于一些分析工具去分析当时的内存数据,找到哪些内存不能被使用了,然后去优化相应的代码。常用的内存泄露分析工具就是memory analyzer
。
方法区
提到方法区就不能不说到永久代,经常有人把方法区和永久代混为一谈,其实他们之间还是有本质的区别的。方法区被《JAVA虚拟机规范》描述成一个逻辑部分(也叫『非堆』),HotSpot团队为了能够像管理其他区域一样管理方法区的GC,于是把分代设计思想沿用到了方法区,既用『永久代』实现了方法区,所以永久代是HotSpot团队对方法区的具体实现。
在JAVA7以前,方法区主要存储了已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译的代码缓存等数据,在JAVA8的时候,运行时常量池被转移到了堆中,并且使用元空间代替永久代来实现方法区。