(二)Java虚拟机内存区域与内存溢出异常

本文深入探讨JVM在类加载后的行为,包括对象创建、内存布局、访问定位,以及内存溢出的原因与示例。理解JVM运行时数据区,如程序计数器、虚拟机栈、本地方法栈、Java堆、方法区、运行时常量池和直接内存的运作机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在类加载完之后,JVM会做什么?

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。

在这里插入图片描述简单宏观描述一下上面的步骤:

  1. 通过 java.exe运行 MyTest.class,随后被加载到JVM中,元空间存储着类的信息(包括类的名称、方法信息、字段信息…)。
  2. 然后JVM找到MyTest的主函数入口(main),为main函数创建栈帧,开始执行main函数。
  3. main函数的第一条命令是 Student stu = new Student();就是让JVM创建一个Student 对象,但是这时候方法区中没有 Student 类的信息,所以JVM马上加载 Student 类,把 Student 类的类型信息放到方法区中(元空间)。
  4. 加载完 Student 类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的 Student 实例分配内存, 然后调用构造函数初始化 Student 实例,这个 Student 实例持有着指向方法区的 Student 类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。
  5. 当使用 stu.setName("ggb");的时候,JVM根据stu引用找到 Student 对象,然后根据 Student 对象持有的引用定位到方法区中 Student 类的类型信息的方法表,获得 setName()函数的字节码的地址。
  6. setName()函数创建栈帧,开始运行 setName()函数

一、内存区域

1.运行时区域

在这里插入图片描述
1.1 程序计数器

当前线程所执行的字节码的行号指示器,内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。

另外,在多线程的场景下,一个CPU(或者一个核)在一个确定的时刻,只能执行一个线程的一条字节码指令,多线程的实现是由CUP在不同线程间切换来完成的。而CPU在线程间切换所依赖的也是程序计数器(CPU跳来跳去要确定调到某个线程的某一行上,从这一点可以看出,程序计数器是线程私有的,线程间互不影响)。

1.2 虚拟机栈

线程私有,生命周期和线程一致。描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种标量类型(boolean、byte、char、short、int、float、long、double)、对象引用(不是对象本身,仅仅是一个引用指针)、方法返回地址等。其中long和double会占用2个本地变量空间(32bit),其余占用1个。本地变量表在进入方法时进行分配,当进入一个方法时,这个方法需要在帧中分配多大的本地变量是一件完全确定的事情,在方法运行期间不改变本地变量表的大小。

(疑问:若在循环体中定义变量,JVM如何取得的局部变量表的大小? 在内层循环中定义变量到底会不会存在重复分配的问题,这涉及到编译器的优化,不过主流编译器(如vs和gcc)这一块优化都比较好,不会反复分配变量。栈中的空间在编译这个代码的时候大小就确定下来了,运行这个方法时空间就已经分配好了,不要想当然的以为声明一次就要分配一次空间,那是c语言,java可以重用这些超出作用域的空间。)

虚拟机栈这块区域规定了两种异常:
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度(比如递归层级过多,大概几千(与分配给jvm的内存有关)就报错)。
OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

1.3 本地方法栈

区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的
Native 方法服务。也会有 StackOverflowError 和 OutOfMemoryError 异常。

Native 方法:一个Native Method就是一个java调用非java代码的接口。大多数应用场景为java需要与一些底层系统如操作系统、某些硬件交换信息时的情况。

1.4 Java 堆

所有线程共享,用于存放所有线程产生的对象实例(还有数组)。 对于绝大多数应用来说,这块区域是 JVM所管理的内存中最大的一块。

堆是垃圾收集器管理的主要区域。为了更好的回收或者分配内存,堆可能会被分为多个区域,例如分代收集算法的垃圾回收器会将堆分为新生代和老年代(当然还可以继续细分:Eden、From Survivor、To Survivor等)。但不管如何划分,每个区间存储的内容是不变的,都是对象实例。

另外,堆在内存中并不是物理连续的,只要逻辑连续即可。当向堆申请内存(实例化对象),而堆中找不到这么大的空间时)会抛出OutOfMemoryError(最新虚拟机都可动态扩展,但扩无可扩时也会抛错)。

1.5 方法区

方法区( Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类信息、常量、 静态变量、 即时编译器编译后的代码等数据。 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分, 但是它却有一个别名叫做Non-Heap( 非堆) , 目的应该是与Java堆区分开来。

一些虚拟机实现上,将方法区作为堆上的“永久代”,意味着垃圾回收器可以向管理堆一样来管理这块内存(但本质上,方法区和永久代是不等价的,会产生一些问题,官方已经不推荐这么使用。例如,String.intern()方法在不同的虚拟上会因为该机制而表现不同)。

当然,方法区也确实有一些“永久”的意思,进入到该区域的数据,例如类信息,基本上就不会被卸载了。但其实也会被卸载,只是卸载的条件相当的苛刻,导致很多垃圾回收器在这部分起到的作用并不大

当方法区无法满足内存分配要求时,将抛出OutOfMemoryError异常。

1.6 运行时常量池

属于方法区一部分(1.7以后,常量池已经从方法区中分离到堆中),用于存放编译期生成的各种字面量和符号引用。编译器和运行期(String 的 intern() )都可以将常量放入池中。内存有限,无法申请时会抛出OutOfMemoryError。

在这里插入图片描述

从String.intern()深入理解这一块知识,首先要知道intern()方法是干啥的:

  1. 返回字符串对象的规范表示
  2. 字符串s调用intern时,如果常量池中有对象t满足t.equals(s)==true( t 与 s 的内容相等),则返回t的地址;否则,将s的引用(即地址)加入常量池并返回s的引用
  3. s.intern()==t.intern()的充分必要条件是s.equals(t)==true

看下面:

		String s1 = new String("GY");
		System.out.println(s1.intern() == s1);//false
		
		s1 = s1.intern();
		String s2 = "GY";
		System.out.println(s1 == s2);//true

在这里插入图片描述
在这里插入图片描述如果常量池中有字符串对象"GY",则new String(“GY”)只创建一个堆中的对象;如果常量池中没有字符串对象"GY",则先在常量池中创建"GY",再在堆中生成一个String对象,共两个。

再看下面:

		String s3 = new String("GY") + new String("HAPPY");
	    System.out.println(s3.intern() == s3);// true
	   
	    s3 = s3.intern();
	    String s4 = "GYHAPPY";
	    System.out.println(s3 == s4);//true

在这里插入图片描述在这里插入图片描述String s1=new String(“GY”)+new String("HAPPY)并没有在运行时常量池中生成“GYHAPPY"对象,调用s1.intern()时,发现常量池中没有,则将对象的引用加入到池中,返回该引用(即s1地址)。

也可以看看下面大佬写的这个:
请别再拿“String s = new String(“xyz”);创建了多少个String实例”来面试了吧

1.7 直接内存

非虚拟机运行时数据区的部分,一些native函数库可以直接分配堆外内存,例如NIO,它可以通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。由于直接内存是受机器总内存限制的,当申请不到内存的时候,同样会抛出OutOfMemoryError异常。

二、HotSpot 虚拟机对象

1.对象的创建

1.1创建对象的几种方式:
 
 1)使用new 关键字;
 2)使用反射的newInstance()方法,newInstance方法通过调用无参的构造函数创建对象;
 3)clone,调用clone时,jvm会创建一个新的对象,将前面对象的内容全部拷贝进去。用clone方法创建对象并不会调用任何构造函数;
 4)反序列化,jvm会给我们创建一个单独的对象。在反序列化时,jvm创建对象并不会调用任何构造函数。
 
1.2 我们在着重谈一谈new时都发生了什么?

当jvm遇到new指令时,第一步要做的是去常量池中找一找,看是否能找到对应类的符号引用,并且检查该符号引用代表的类是否被加载、解析、初始化过(检查类是否被加载)。

类加载检查通过之后,接下来就是分配内存,对象所需内存大小在类加载完成之后就完全确定了,所以分配对象的工作其实就是把一块确定的内存从Java堆中划出来。

堆内存是规整的时候——用过的在一边、没用过的在另一边,中间用一个指针标记,内存分配就是指针向没用过的方向挪动一下,这种方式叫做指针碰撞。这个时候若多个线程一起申请内存,就会冲突。对应的解决方法:
1)加同步,采用CAS加失败重试策略;
2)为每个线程预分配一小块内存(Thread Local Allocation Buffer,TLAB),哪个线程需要内存,就在自己的TLAB上进行分配,而只在创建线程为线程分配TLAB是用同步锁定。

堆内存不是规整的时候——用过和没用过的乱糟糟的放在一起,内存分配就需要记住哪些地方被分配了,哪些地方还是空闲的,这种分配方式叫做分配列表。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表。

对内存是否规整,是由使用的垃圾回收机制是否带有压缩整理功能决定的。

内存分配完成之后,虚拟机需要设置一下对象的数据:非对象头部分,会被初始化为零值,这个操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用;对象头部分,进行必要的设置,例如:对象类的元数据信息、哈希码、GC分代信息、锁信息等。

一个新的对象产生了,后续就在java语言层面,按照程序员的想法,执行init函数了。

2.对象的内存布局

在 HotSpot 虚拟机中,分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

一个对象在内存中由三部分组成:对象头,实例数据,对齐填充。

对象头由两部分组成:一部分存储运行时数据:哈希码、GC分代、锁状态等等;另一部分是指向类元数据的指针,说明该对象是由哪个类实例化来的。

实例数据存放的是对象真正存储的有效信息,也就是程序员自己定义的各种类型字段内容。需要注意的时,为了节省内存,相同类型的字段总是被放在一起存放的,而且子类较窄的变量有可能会插入到父类变量的空隙中。

由于对象大小必须是8字节的整数倍,所以对齐填充,就是凑整用的,可有可无。

3.对象的访问定位

两种访问定位方式:通过句柄访问、使用直接指针访问。

3.1 通过句柄访问

句柄访问,堆内划分出一块内存来作为句柄池,对象引用存储的是句柄地址,句柄中包含了对象的真实地址信息。优点:对象被移动时,无需通知引用这个它的对象,只需要更改句柄池就行了;缺点:增加了一层寻址,会慢一些。
在这里插入图片描述

3.2 使用直接指针访问

直接指针访问:对象引用的就是真实的地址信息。优点:快,节省一次指针定位时间;缺点:对象被移动时,引用它的对象也要跟着修改。

在这里插入图片描述

三、内存溢出(OOM)

1.堆溢出

堆是用来存放对象示例的,只要不断创建对象,并且保证垃圾回收器无法回收这些对象,就能产生堆的OutOfMemoryError异常。

package classLoader;

import java.util.ArrayList;
import java.util.List;

public class OutOfMemoryError {
	public static void main(String[] args) {
		// 保证创建出来的对象不被回收
		List<String> list = new ArrayList<String>();
		// 10M的PermSize在integer范围内足够产生OOM了
		int i = 0;
		while (true) {
			list.add(String.valueOf(i++).intern());
			System.out.println(list.size());
		}
	}
}

另外,在jdk1.8中,String常量池已经从方法区中的运行时常量池分离到堆中了,也就是说不断的创建String常量,也能够将堆撑爆,代码及错误信息如下:

package classLoader;

import java.util.ArrayList;
import java.util.List;

public class OutOfMemoryError {
	public static void main(String[] args) {
		List<String> list = new ArrayList<String>();
		list.add("0");
		int i = 1;
		try {
			while (true) {
				//如果是String.valueOf(i++).intern()就不会报错
				list.add(list.get(i - 1) + String.valueOf(i++));
				if (list.size() % 100 == 0) {
					System.out.println(list.size());
				}
			}
		} catch (Throwable e) {
			System.out.print(list.size());
			throw e;
		}
	}
}

在这里插入图片描述

2.栈溢出

不断递归,超过栈允许的最大深度时,就可以触发StackOverflowError。

package classLoader;

public class OutOfMemoryError {
	private Integer stackLength = 1;

	public void stackLoop() {
		stackLength++;
		stackLoop();
	}

	public static void main(String[] args) {
		OutOfMemoryError a = new OutOfMemoryError();
		try {
			a.stackLoop();
		} catch (Throwable e) {
			System.out.println("stack length: " + a.stackLength);
			throw e;
		}
	}
}

3.方法区溢出

上文讲过,方法区用于存放Class相关信息,所以这个区域的测试我们借助CGLib直接操作字节码动态生成大量的Class,值得注意的是,这里我们这个例子中模拟的场景其实经常会在实际应用中出现:当前很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,当增强的类越多,就需要越大的方法区用于保证动态生成的Class可以加载入内存。

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class JavaMethodAreaOOM {
       public static void main(String[] args) {
              while (true) {
                     Enhancer enhancer = new Enhancer();
                     enhancer.setSuperclass(OOMObject.class);
                     enhancer.setUseCache(false);
                     enhancer.setCallback(new MethodInterceptor() {
                            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                                   return proxy.invokeSuper(obj, args);
                            }
                     });
                    enhancer.create();
              }
       }
       static class OOMObject {
       }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

yelvens

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

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

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

打赏作者

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

抵扣说明:

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

余额充值