《深入理解Java虚拟机》1:Java内存区域与内存溢出异常

一、JVM的常见问题

1.1.基本问题

  • 介绍下Java内存区域(运行时数据区域)
  • Java对象的创建过程(5步)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)
  • 说一说Java内存区域与虚拟机的关系

二、运行时数据区域

JDK1.8之前:                                                  JDK1.8:

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区(JDK1.8之前)
  • 元空间(JDK1.8)
  • 直接内存(非运行时数据区的一部分)

2.1、程序计数器

(1)什么是程序计数器

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

(2)程序计数器作用(2个)

  • 字节码解释器 通过改变程序计数器来依次读取指令,从而实现代码的流程控制。如:顺序执行、选择、循环、异常处理。
  • 在多线程情况下,用于记录当前线程的执行位置,从而当线程被切换回来的时候能知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现OutOfMemoryError (内存溢出)的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.2、Java虚拟机栈

(1)什么是虚拟机栈

描述的是Java方法执行的内存模型,每次方法的调用都是通过栈传递的。Java内存可以分为堆内存和栈内存,Java虚拟机栈由一个个栈帧组成,每个栈帧中都有:局部变量表、操作数栈、动态链接、方法出口信息。

(2)局部变量表存放了啥

存放了编译器可知的各种 数据类型(boolean,byte,char,short,int,float,long,double)、对象引用。

(3)Java虚拟机栈会出现两种异常:StackOverFlowError(栈溢出) 和 OutOfMemoryError(内存溢出)

StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么当前线程请求栈的深度超过当前Java虚拟机栈的最大深度时候,就会抛出该异常。

OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展,就会抛出该异常。

(4)方法/函数如何调用?

Java中的栈可类比数据结构中的栈,主要保存的内容是栈帧,每一次函数/方法调用都会有一个对应的栈帧被压入Java栈。每一个函数/方法调用结束后低有一个栈帧被弹出。

Java方法/函数有两种返回方式:

  • return
  • 抛出异常

不管哪种,都会导致栈帧被弹出

2.3、本地方法栈

(1)什么是本地方法栈 / 作用

和虚拟机栈作用类似。区别是:虚拟机栈为虚拟机执行java方法/函数(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地方法服务。在HotSpot虚拟机中和Java虚拟机栈合二为一。

本地方法被执行的时候在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完后相应栈帧也会出栈并释放内存空间。主要和C语言、JNI打交道(用于Java语言调用外部语言,方法使用native修饰)。

也会出现StackOverFlowError(栈溢出) 和 OutOfMemoryError (内存溢出)两种异常。

2.4、堆

(1)什么是堆

Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。几乎所有对象实例和数组都在这分配内存。是垃圾收集器主要管理区域,也称GC堆。由于GC一般采用分代垃圾收集算法,所以Java堆还可以分为:新生代、老年代。

(2)堆版本变化

JDK1.8之前:

  • 新生代(Young Ceneration)
  • 老生代(Old Generation)
  • 永生代(Permanent Generation)

JDK1.8:去除了HotSpot 的永久代,取而代之的是元空间,元空间使用的是直接内存

(3)简单了解下分配内存流程

大部分情况下,对象首先在Eden区域分配内存,再一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象额年龄加1(Eden区——>Survivor区后对象的初始年龄变为1),当它的年龄达到默认15岁,就会晋升到老年代中。

这个阀值的理解:

对象晋升到老年代阈值可以通过-XX:MaxTenuringThreshold 来配置。Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当积累的某个年龄大小超过了Survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阀值。

2.5、方法区

(1)什么是方法区

与堆一样线程共享,用于存储已经被JVM加载的类信息、常量、静态变量、即编译器编译后的代码等数据。类似于堆的永久代,可以理解为堆的一个逻辑部分。

(2)方法区与永久代的关系

方法区是Java虚拟机规范的定义,是一种规范。永久代是HotSpot的概念,是一种实现。

(3)为什么要将永久代(PermGen)替换为元空间(MetaSpace)?

永久代有个JVM本身设置固定大小上限,无法调整。而元空间使用的是直接内存,受本机可用内存限制,不会OutOfMemoryError(内存溢出)

(4)运行时常量池

是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息,也收到方法区内存的限制,所以常量池无法再申请到内存时会抛出OutOfMemoryError(内存溢出)

JDK1.7及其之后的版本JVM已经将运行时常量池从方法区枪了出来,在java堆(Heap)中开辟了一块区域存放运行时常量池

2.6、直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。但是很常用并且也会OutOfMemoryError ,JDK1.4加入的NIO类,引入了一种基于通道与缓存区的I/O方式,它可以直接使用Native函数库直接分配堆外内存,可以避免java堆和Native堆之间来回复制数据的性能开销。直接内存 的分配不会受到Java堆的限制。

 

三、HotSpot虚拟机对象深入理解

上面讲的是Java内存区域情况,下面将的是HotSpot虚拟机在Java堆 中对象分配、布局、访问的详细过程

什么是HotSpot VM

是 JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

 

3.1、对象的创建过程(重要)

(1)第一步:类加载检查

虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化过。如果没有,那就必须先执行相应的类加载过程。

(2)第二步:分配内存

类加载检查通过后虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后就可以确定,所以为对象分配空间的任务 就是把一块确定大小的内存从Java堆中划分出来。

内存分配的两种方式——指针碰撞、空闲列表:

选哪一种,取决于Java堆内存是否规整,Java堆内存是否规整取决于GC收集器算法是“标记-清除”还是“标记-整理”。复制算法内存也是规整的

内存分配的并发问题 / 虚拟机采用什么方式保证线程安全

  • CAS+重试机制。保证更新操作的原子性;
  • TLAB。为每一个线程预先在Eden区分配一块内存,JVM在给线程中对象分配内存时,首先在TLAB分配。当对象大于TLAB中的剩余内存或TLAB的内存耗尽时,再采用上面的CAS+重试机制 进行内存分配。

(3)第三步:初始化零值

内存分配完后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)(比如新电脑分区内存初始为0)。保证了对象的实例字段可以不赋初始值就可以直接使用。

(4)第四步:设置对象头

初始化零值后,虚拟机要对 对象进行必要设置,如图该对象是哪个类的实例、对象的哈希码、对象的GC分代信息等。这些信息都存在对象头。

(5)第五步:执行init方法

上面步骤都完成后,行虚拟机角度一个新对象产生了,但是Java程序员角度对象创建刚开始,init对象还没执行,所有字段都还是零。所以程序员角度执行完new后会接着执行init方法,把对象按照程序员的意愿去初始化。这样一个完整对象才算创建出来

3.2、对象的内存布局

Hotspot 虚拟机中,对象在内存中的布局可以分为3个区域:对象头、实例数据、对齐填充。

(1)对象头(包含2部分信息)

  • 第一部分 用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等);
  • 另一部分是 类型指针,即对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

(2)实例数据

是对象真正存储的有效信息,也是程序中所定义的各种类型的字段内容。

(3)对齐填充(可忽略)

可忽略的,仅仅起了占位的作用。比如对象的字节大小必须是8字节的整数倍,对象实例数据部分没有对齐时就 对齐填充,简单点说就是,凑数的。

 

四、内存溢出异常

4.1、JVM参数配置

(1)常见参数配置

  • -XX:+PrintGC      每次触发GC的时候打印相关日志
  • -XX:+UseSerialGC      串行回收
  • -XX:+PrintGCDetails  更详细的GC日志
  • -Xms               堆初始值
  • -Xmx               堆最大可用值
  • -Xmn               新生代堆最大可用值
  • -XX:SurvivorRatio  用来设置新生代中eden空间和from/to空间的比例.
  • -XX:NewRatio       配置新生代与老年代占比 1:2
  • 含以-XX:SurvivorRatio=eden/from=den/to
  • 总结:在实际工作中,我们可以直接将初始的堆大小与最大堆大小相等,
  • 这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率。
  • -XX:SurvivorRatio     用来设置新生代中eden空间和from/to空间的比例.

(2)堆内存大小配置

使用示例:  -Xmx20m -Xms5m 

说明: 当下Java应用最大可用内存为20M, 初始内存为5M

//配置堆内存大小
public class Test001 {
	public static void main(String[] args) {
		byte[] b = new byte[25 * 1024 * 1024];
		System.out.println("分配了25M空间给数组");
		System.out.println("最大内存" + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "M");
		System.out.println("可用内存" + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M");
		System.out.println("已经使用内存" + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M");
	}
}

(3)设置新生代比例参数

使用示例:-Xms20m -Xmx20m -Xmn1m -XX:SurvivorRatio=2 -XX:+PrintGCDetails -XX:+UseSerialGC

说明:堆内存初始化值20m,堆内存最大值20m,新生代最大值可用1m,eden空间和from/to空间的比例为2/1

byte[] b = null;
for (int i = 0; i < 10; i++) {
b = new byte[1 * 1024 * 1024];
}

(4)设置新生代与老年代比例参数

使用示例: -Xms20m -Xmx20m -XX:SurvivorRatio=2 -XX:+PrintGCDetails -XX:+UseSerialGC

-XX:NewRatio=2

说明:堆内存初始化值20m,堆内存最大值20m,新生代最大值可用1m,eden空间和from/to空间的比例为2/1

新生代和老年代的占比为1/2

4.2、内存溢出异常OutOfMemoryError

内存溢出与内存泄漏区别:

Java内存泄露: 内存没及时释放长时间占用,导致系统无法再提供内存资源(内存资源耗尽),,最终导致内存溢出;

                         (哪些场景:I/O没有释放、static关键字使用过多、常量定义过多)

Java内存溢出:要求分配的内存超出了系统给的,系统不能满足需求,所以产生溢出(满足不了)。

(1)Java堆溢出

错误原因: java.lang.OutOfMemoryError: Java heap space 堆内存溢出,堆大小不够

解决办法:设置堆内存大小 // -Xms1m -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

// -Xms1m -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
List<Object> listObject = new ArrayList<>();
for (int i = 0; i < 10; i++) {
System.out.println("i:" + i);
Byte[] bytes = new Byte[1 * 1024 * 1024];
listObject.add(bytes);
}
System.out.println("添加成功...");

(2) 虚拟机栈溢出

错误原因: java.lang.StackOverflowError  栈内存溢出,递归调用导致栈深度不够

栈溢出 产生于递归调用,循环遍历是不会产生的,但是循环方法里面产生递归调用, 也会发生栈溢出。

解决办法:设置线程最大调用深度 ,-Xss5m 设置最大调用深度。

public class JvmDemo04 {
	 private static int count;
	 public static void count(){
		try {
			 count++;
			 count(); //递归调用
		} catch (Throwable e) {
			System.out.println("最大深度:"+count);
			e.printStackTrace();
		}
	 }
	 public static void main(String[] args) {
		 count();
	}
}

 

...待更新

 

 

下一篇:垃圾收集器与内存分配策略

  参考资料:深入理解Java虚拟机(第2版) : JVM高级特性与最佳实

  参考资料:连接

 

### 若对你有帮助的话,欢迎点赞!评论!转发!谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值