Java内存区域与内存溢出

内容参考《深入理解JVM虚拟机》,本文JVM均指HotSpot虚拟机。

Java与C语言针对“内存管理”有很大的不同。

在C语言中,开发者需要维护对象的出生和死亡,往往需要为每个new出来的对象编写配套的delete/free代码来释放内存,否则可能发生内存泄漏或溢出。

而在Java中,内存由JVM管理,垃圾回收器GC会帮助开发者自动回收不再被引用的对象来释放内存,使得Java不太会像C语言那样容易出现内存溢出异常。
虽然这样看起来很美好,但是如果开发者不了解JVM是如何管理内存的,那么在遇到内存溢出异常时,排查错误将毫无头绪。

1、JVM内存模型

JVM在执行Java程序时,会将内存划分成若干个数据区域。
不同的数据区域,用途不同、创建及销毁时间也不尽相同。有的区域随着JVM进程的启动而创建,有的则依赖于用户进程的启动而创建。

在这里插入图片描述

1.1、程序计数器

程序计数器(Program Counter Register)是一块非常小的内存空间,几乎可以忽略不计。
它可以看做是线程所执行字节码的行号指数器,指向当前线程下一条应该执行的指令。对于:条件分支、循环、跳转、异常等基础功能都依赖于程序计数器。

对于CPU的一个核心来说,同一时刻只能跑一个线程。
对于JVM的多线程,CPU通过轮流切换分配时间片的方式来实现,为了使得线程在切换后可以快速定位到执行的指令,每个线程都需要维护一个私有的程序计数器。

如果线程在执行Java方法,计数器记录的是JVM字节码指令地址。如果执行的是Native方法,计数器值则为Undefined

程序计数器是唯一一个没有规定任何OutOfMemoryError情况的内存区域,意味着在该区域不可能发生OOM异常。

1.2、虚拟机栈

虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,生命周期和线程相同。

虚拟机栈描述的是Java方法执行的内存模型,JVM要执行一个方法时,首先会创建一个栈帧(Stack Frame)用于存放:局部变量表、操作数栈、动态链接、方法出口等信息。栈帧创建完毕后开始入栈执行,方法执行结束后即出栈。
方法执行的过程就是一个个栈帧从入栈到出栈的过程。

局部变量表:存放编译器可知的各种基本数据类型、对象引用、returnAddress类型。
局部变量表所需的内存空间在编译时就已经确认,运行期间不会修改局部变量表的大小。

在JVM规范中,虚拟机栈规定了两种异常:

  • StackOverflowError
    线程请求的栈深度大于JVM所允许的栈深度。
    栈的容量是有限的,如果线程入栈的栈帧超过了限制就会抛出StackOverflowError异常,例如:方法递归。
  • OutOfMemoryError
    虚拟机栈是可以动态扩展的,如果扩展时无法申请到足够的内存,则会抛出OOM异常。

1.3、本地方法栈

本地方法栈(Native Method Stack)也是线程私有的,与虚拟机栈的作用非常类似。
区别是虚拟机栈是为执行Java方法服务的,而本地方法栈是为执行Native方法服务的。

与虚拟机栈一样,JVM规范中对本地方法栈也规定了StackOverflowError和OutOfMemoryError两种异常。

1.4、Java堆

Java堆(Java Heap)是线程共享的,一般来说也是JVM管理最大的一块内存区域,同时也是垃圾收集器GC的主要管理区域。

Java堆在JVM启动时创建,作用是:存放对象实例
几乎所有的对象都在堆中创建,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得“所有对象都分配在堆上”不那么绝对了。

由于是GC主要管理的区域,所以也被称为:GC堆。
为了GC的高效回收,Java堆内部又做了如下划分:

  • 新生代
    • Eden区
    • Survivor区
      • From区
      • To区
  • 老年代

JVM规范中,堆在物理上可以是不连续的,只要逻辑上连续即可。通过-Xms -Xmx参数可以设置最小、最大堆内存。

1.5、方法区

方法区(Method Area)与Java堆一样,也是线程共享的一块内存区域。
它主要用来存储:被JVM加载的类信息,常量,静态变量,即时编译器产生的代码等数据。
也被称为:非堆(Non-Heap),目的是与Java堆区分开来。

JVM规范对方法区的限制比较宽松,JVM甚至可以不对方法区进行垃圾回收。这就导致在老版本的JDK中,方法区也别称为:永久代(PermGen)。

使用永久代来实现方法区不是个好主意,容易导致内存溢出,于是从JDK7开始有了“去永久代”行动,将原本放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎来元空间。

1.6、运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。

在代码中写死的常量最终会被编译到字节码中由JVM加载并保存到常量池中,除了编译时生成的常量,程序运行期间也可以产生常量,用的比较多的就是:String.intern()

1.7、直接内存

直接内存(Direct Memory)并不是JVM运行时数据区的一部分,说白了直接内存不由JVM管理,也称:堆外内存。

直接内存不受JVM的限制,但是会受限于操作系统和机器的物理内存。

JDK4中的NIO使用Native函数库直接分配堆外内存,避免了在Java堆和Native堆中来回复制数据,提高性能。

使用Unsafe类也可以直接操作堆外内存。


2、JVM对象

JVM对内存进行划分之后,内存最终是用来存放对象的。
所以开发者还必须了解对象是如何被创建的?对象内存布局是怎样的?以及对象是如何被访问到的?

2.1、对象的创建

Java程序在运行过程中,无时无刻不在创建对象。

当JVM遇到new指令时,首先去方法区中检查,类是否存在常量池中?并且检查类是否被加载、解析、初始化。
如果没有则执行类的加载过程,反之则开始分配内存。

对象所需的内存在类加载后就已经确定,分配内存只需要从堆中划分出一块空闲区域即可。
分配内存有两种方式,取决于GC算法是否带有压缩整理功能,如下:

  • 指针碰撞
    堆内存是绝对规整的,已使用的内存和未使用的内存分开放置,分界点有一个指针指示器,对象分配内存时只需要移动指针指示器即可。
  • 空闲列表
    堆内存不规整,已使用内存和未使用内存交错排列,此时指针指示器就没用了,JVM需要维护一个列表,记录哪些内存已使用,哪些未使用。对象分配内存时从未使用的记录中划分出一块足够大的即可。

对象创建是非常频繁的过程,分配内存在多线程下是不安全的,解决方案有两种:

  • 分配内存进行同步处理
    JVM采用CAS操作保证更新操作的原子性。
  • 本地线程分配缓冲(TLAB)
    内存分配的动作按照不同线程划分到不同的空间中进行。每个线程在Java堆中预先分配一块内存,哪个线程要分配内存就在该线程的TLAB上进行分配。通过-XX:+/-UseTLAB来设置是否开启。

内存分配完成后,JVM会对分配的内存进行初始化,如果开启了TLAB,初始化动作会提前至TLAB内存分配时进行。

内存初始化后,JVM需要对对象进行一些必要的设置,例如:类型指针、哈希码、GC年龄, 锁标志、偏向线程ID、偏向时间戳等,这些数据保存在对象的对象头(Object Header)中。
这些工作全部完成以后,对于JVM而言一个对象就算创建完成了。但是对于Java程序而言,显然对象此时还不可用,因为构造方法还没有执行,对象的属性还没有赋初始值。
一般来说,执行new指令后会紧接着执行构造方法。

2.2、对象的内存布局

在JVM中,对象的内存布局可以分为三部分:对象头、实例数据、对齐字节,如下图所示:
在这里插入图片描述

对象头中的Mark Word用于存储对象自身的运行时数据,在32位和64位JVM(未开启压缩指针)中分别占用4字节和8字节,即32bit和64bit。
实际上,对象运行时需要存储的数据很多,32bit和64bit是不够用的,但是对象头信息是对象自身定义数据的额外存储成本,考虑到JVM的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。
在这里插入图片描述
除了Mark Word,对象头中还记录了Klass Pointer类型指针,指向了对象的类元数据指针。JVM通过Klass Pointer来判断对象是哪一个类的实例。

并不是所有的JVM实现都会在对象头中保留类型指针,如果对象访问通过句柄实现,类型指针会直接保存在句柄中。

对齐字节:HotSpot虚拟机规定对象的大小必须是8字节的整数倍,对象头刚好是8字节的整数倍(1倍或2倍),如果实例数据不是8字节的整数倍则需要对齐字节来补齐,它没有什么实际用处,仅仅起到占位符的作用。如果实例数据刚好是8字节的整数倍,则不会有对齐字节。

如果对象是数组,那么对象头中还会记录数组的长度,因为JVM可以根据对象的元数据判断对象的大小,但是从数组的元空间中无法判断数组的大小。

2.3、对象的访问定位

创建对象是为了使用对象,Java程序通过栈上的reference对象引用来操作堆上的具体对象。

主流的对象访问方式有两种:

  • 句柄
    在Java堆中划分一块区域作为句柄池,reference存储的是句柄的地址,句柄中保存了对象实例和对象类型指针。通过句柄访问对象,对象头中可以不保存类型指针Klass Pointer。
  • 直接指针
    reference存储的直接是对象的内存地址,通过这种方式访问对象就必须在对象头中保存对象的类型指针Klass Pointer。

句柄访问
好处是:reference存储的是稳定的句柄地址,对象频繁移动时不用修改reference的值,只需要修改句柄中实例数据的地址。例如:使用复制算法进行垃圾回收时。

直接指针访问
好处是:速度快,节省了一次指针定位的开销,由于JVM对对象的访问是非常频繁的,所以访问速度快显得非常重要,所以HotSpot采用的是直接指针访问。


3、OOM异常实战

在JVM规范中,除了程序计数器,其他区域都有可能发生OutOfMemoryError。

3.1、堆溢出

Java堆用来存放对象实例,只要不断创建对象,且保证不会被GC回收就会导出OOM。

/**
 * VMArgs: -Xms10m -Xmx10m
 */
public static void main(String[] args) {
	List list = new LinkedList();
	while (true) {
		list.add(new Object());
	}
}
//java.lang.OutOfMemoryError: Java heap space

一般来说,程序抛出OOM异常存在两种情况:

  • 内存泄漏
    本应该被GC回收的对象,仍然存活。
  • 内存溢出
    对象确实需要存活,但是内存不够。

对于内存泄漏,需要分析Java堆的快照文件,检查对象为什么没有被GC回收。一般来说,将不会再用到的对象手动赋值为null可以有效帮助GC回收。

对于内存溢出,如果物理机内存不够则扩容硬件,物理内存够则可以通过-Xmx适当调整堆最大内存。

3.2、虚拟机栈和本地方法栈溢出

HotSpot虚拟机不区分虚拟机栈和本地方法栈,-Xoss参数设置本地方法栈大小将会失效,栈容量只由-Xss设定。

如下代码,将栈容量设置为160K,递归调用772次时抛出StackOverflowError异常。

class StackDemo{
	int stackLength = 0;

	void func(){
		stackLength++;
		this.func();
	}

	/**
	 * VM Args: -Xss160k
	 */
	public static void main(String[] args) {
		StackDemo stackDemo = new StackDemo();
		try {
			stackDemo.func();
		}catch (Throwable e){
			System.out.println("stackLength:"+stackDemo.stackLength);
			throw e;
		}
	}
}
/*
stackLength:772
Exception in thread "main" java.lang.StackOverflowError
 */

OS分配给每个进程的内存是有限的,如32位Windows限制为2GB,减去堆内存、方法区、程序计数器所耗内存,剩余内存被虚拟机栈和本地方法栈瓜分。在多线程程序下,线程开的越多,每个线程被分配的栈容量就越少,越容易出现异常。

3.3、方法区溢出

在JDK8中,HotSpot方法区的实现是:元空间。
通过参数-XX:MetaspaceSize -XX:MaxMetaspaceSize设置元空间内存大小。

类信息存放在方法区,要想把方法区撑满只需要不断产生大量的类即可,可以使用CGLIB动态生成类。
如下代码,设置元空间最大10M,运行不久就会导致OOM,错误信息会指明:Metaspace。

class MetaSpaceDemo{

	static class MyClass{}

	/**
	 * VM Args: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
	 */
	public static void main(String[] args) {
		while (true) {
			Enhancer enhancer = new Enhancer();
			enhancer.setSuperclass(MyClass.class);
			enhancer.setUseCache(false);
			enhancer.setCallback(new MethodInterceptor() {
				@Override
				public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
					return methodProxy.invokeSuper(o, args);
				}
			});
			enhancer.create();
		}
	}
}
/*
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
 */
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员小潘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值