JVM在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域,这些不同的数据区域共同构成Java的内存结构。Java内存结构和内存模型(JMM)是两个不同的概念,本文讨论的是Java的内存结构。
一、运行时数据区域
1.程序计数器
程序计数器是当前线程所执行的字节码的行号指示器。每条线程都要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,属于线程私有的内存。程序计数器是唯一一个在Java虚拟机规范里没有规定任何OutOfMemoryError情况的区域,因为它只保存一个固定大小的地址。
2.Java虚拟机栈
虚拟机栈也是线程私有的,每个线程都有独立的栈,栈的生命周期与线程相同。虚拟机栈是描述Java方法执行的内存模型:Java方法在开始执行时都会创建一个栈桢,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个Java方法从调用到执行完成的过程,就是对应栈桢从该线程栈中从入栈到出栈的过程。
局部变量表存放编译期可知的8种基本数据类型和对象引用reference,其所需内存空间在编译期就完成分配,当程序运行时进入一个方法时,这个方法需要在栈桢中分配多大的局部变量表空间时完全确定的,方法运行期间不会改变局部变量表的大小。
3.本地方法栈
线程私有,虚拟机栈为Java方法服务,本地方法栈为Native方法服务。
4.Java堆
Java堆是所有线程共享的区域,唯一目的就是存放对象实例。从垃圾回收的角度看,Java堆可以细分为新生代和老年代,从内存模型(JMM)的角度看,可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。
5.方法区
方法区也是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。根据HotSpot规范,从垃圾回收的角度,方法区被划为永久代,现在已经改成了元空间(在本地内存Native Memory)。方法区是JVM的规范,永久代/元空间是HotSpot实现方法区的技术细节。
运行时常量池是方法区的一部分。注意Class文件中也有常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。在类加载的链接-解析阶段,会将符号引用替换为直接引用(句柄或直接指针)。
运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,即常量并不一定只有在编译期(Class文件生成的时期)产生,也就是并非预置入Class文件常量池中的内容才能进入运行时常量池,运行时也能将新的常量放入池中。比如String的intern()方法:
- JDK1.6中,intern()将首次遇到的String实例复制到字符串常量池,然后返回这个复制实例的引用。返回的是指向字符串常量池的引用。
- JDK1.7后,intern()将首次遇到的String实例的引用保存到字符串常量池中,并返回这个引用。返回的是指向Java堆的引用。
字符串常量池原本作为运行时常量池的一部分,存在于方法区,在JDK1.7之后被剥离出来,移动到Java堆中。
二、对象探秘
1.对象创建
通过new关键字来创建对象,JVM遇到new指令时,先去检查对象所属类是否已经加载(包含加载、链接和初始化三个阶段),如果没有,那么需要先执行类加载过程。
类加载完毕后将在Java堆中为对象分配内存,对象所需内存大小在类加载完成后就完全确定了,因此只需要在堆中划分出一块确定大小的空闲内存分配给对象。
内存分配有指针碰撞和空闲列表两种方式:
- 当java堆中内存绝对规整,已占用内存在一边,空闲内存在另一边,采用指针碰撞方法(指针顺移,直到碰撞到已占用内存块)。
- 当Java堆内存不规整,已使用和未使用的内存块交错排布,采用空闲列表方法(维护一个列表,记录哪些内存块可用,为对象分配一个足够大的内存块)。
Java堆是否规整取决于JVM采用的GC算法:
- 如果采用标记-清理算法,堆内存是不规整的,采用空闲列表方法。
- 如果采用的是复制算法、标记-整理算法,堆内存规整,采用指针碰撞方法。
内存分配可能存在线程安全问题,解决方案:
- 对每次分配内存空间(移动指针)的动作进行同步,保证更新操作的原子性。
- 为每个线程,在Java堆中分配一块本地线程分配缓冲TLAB(线程工作内存),TLAB用完后再同步到主存。相比于第一种方法的优点是效率更高,因为不用频繁的锁,缺点是占用空间,而且不保证即时可见性。
内存分配完成后,执行默认初始化,将对象的内存空间初始化为0值。
然后设置对象的对象头(Object Header),保存对象是哪个类的实例、如何找到类的元数据信息、对象的HashCode、对象的分代年龄等信息。对象的内存布局可以分为对象头、实例数据和对齐填充三部分。
最后进行对象的指定初始化,即按照程序猿的意愿(code),初始化对象的各属性值。
2.对象的访问定位
对象的访问通过引用reference,引用方式包括句柄和直接指针两种:
- 使用句柄访问,Java堆中会划分出一块内存作为句柄池,reference中存储句柄地址,句柄中包含了对象地址和类型信息的地址。因此访问对象和类型信息都需要两次指针定位。
- 使用直接指针访问,reference中直接存储对象地址,通过对象头中的类型信息指针访问类型信息。因此访问对象只需要一次指针定位,访问类型信息需要两次定位。
句柄访问的优势是reference中存储稳定的句柄地址,当对象被移动(GC时)时只需要改变句柄中的对象地址,而reference本身不需要更改(对象的引用可能很多,而对象的句柄只有一个,因此只需要改一次)
直接指针访问的优势是访问对象的速度更快,因为节省了一次指针定位的时间开销。
三、内存泄露异常(OutOfMemoryError,OOM)
JVM的内存区域中,除了程序计数器之外,其他几个内存区域都可能会发生OOM异常,虽然叫异常,但是英文全称是OutOfMemoryError,属于Java异常体系中的错误Error。
1.Java堆溢出
堆溢出包括内存泄漏(Memory Leak)和内存溢出(Memory Overflow)两种情况:
- 内存泄漏:该回收的对象没有被回收。查看泄漏对象到GC Roots的引用链,定位泄漏代码,找到泄漏对象是通过怎样的路径关联到GC Roots,导致垃圾回收器无法自动回收它们的。
- 内存溢出:内存中的对象还确实都应该活着。检查是否存在某些对象生命周期过长、持有状态时间过长的情况,或者考虑调大堆内存。
2.虚拟机栈和本地方法栈溢出
栈内存包括两种异常StackOverflowError和OutOfMemoryError(OOM):
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的最大深度(往死里递归)。
- OutOfMemoryError:虚拟机扩展栈时无法申请到足够的内存空间。
单线程情况下,一般都是抛出StackOverflowError;多线程的情况下,不断建立新的线程,从而分配新的栈,最终导致没有足够的内存去分配新的栈,导致OutOfMemoryError。
如果是因为建立多线程导致的OOM,可以考虑减少堆容量(这样栈空间就更大了)或者减少栈容量来换取更多的线程(栈空间一定,就能分配数量更多的栈了)。精髓是通过“减少内存”的手段来解决内存溢出问题。
3.方法区溢出
JDK8以前,永久代是HotSpot对方法区的具体实现。方法区的溢出有以下可能原因:
- JDK7之前,频繁使用String.intern()方法(会将String实例从堆中复制到字符串常量池)。
- 运行期间生成了大量代理类,导致方法区被撑爆,无法卸载。
- 应用长时间运行,没有重启。
JDK8之后,元空间替换了永久代实现,元空间使用本地内存,字符串常量池也从方法区移到了堆中,都能帮助避免方法区泄漏。