Java虚拟机——内存区域与内存溢出异常

本文参考《深入理解JVM虚拟机 第三版》学习 记录

第2章 内存区域与内存溢出异常

2.2 运行时数据区域

  • Java虚拟机在执行Java程序的时候会把他所管理的内存划分为若干个不同的区域。

  • 这些区域有各自的用途,以及创建和销毁的时间。

  • Java虚拟机管理的内存包含以下几个运行时数据区域:

img

2.2.1 程序计数器(PC寄存器)
  • 程序计数器是一块较小的内存空间。

  • 可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。

  • 他是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要以来这个计数器来完成

  • Java虚拟机在任何一个确定的时刻,都会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器。(线程私有

    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址

    如果线程正在执行的是本地(Native)方法,这个计数器值则应为空

  • 此内存区域是唯一一个没有规定任何OutOfMemoryError(OOM)情况的区域。

2.2.2 Java虚拟机栈
  • Java虚拟机栈是线程私有的,它的生命周期和线程相同。

  • 虚拟机栈描述的是Java方法执行的线程的内存模型

    1. 每个方法执行时,都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

    2. 每一个方法被调用到执行完毕,都对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

  • 经常有人将Java内存区域笼统的分为栈和堆

  • 这里“栈”通常指虚拟机栈,或者更多的情况下指的是虚拟机栈中的局部变量表

    1. 局部变量表存放了 基本数据类型、引用数据类型(不是对象本身,类似指向地址的引用指针)和returnAddress类型(指向一个字节码指令的地址)。

    2. 局部变量表以变量槽存储这些数据类型,long、double占两个,其他的占一个。

    3. 局部变量表所需的内存空间在编译期间完成分配

  • 这个内存区域(Java虚拟机栈)规定了两个异常情况

    1. 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常。

    2. 如果Java虚拟机栈容量可以动态拓展,当栈拓展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.2.3 本地方法栈
  • 本地方法栈和Java虚拟机栈的区别:本地方法栈执行本地(Native)方法服务;虚拟机栈执行Java(字节码)方法服务。

  • 其他和Java虚拟机栈类似。

2.2.4 Java堆
  • 堆是虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域。

  • 唯一目的是存放对象实例

  • Java堆是 垃圾收集器 管理的内存区域,又称“GC堆”

  • 回收内存的角度看,现代垃圾收集器大部分是基于分代收集理论设计的。(所以会出现“新生代“、”老年代“、”Eden空间“这种词)

  • 分配内存的角度看,所有线程共享的Java堆可以分出多个线程私有的分配缓冲区,以提升分配时的效率。(Java堆的细分只是为了更好的回收内存和更快的分配内存)

  • Java堆可以处于物理上不连续的内存空间中,但在逻辑上他应该被视为连续的

  • Java堆可以是固定的,也可以是可拓展的。如果没有内存,也无法拓展,就会报OutOfMemoryError异常。

2.2.5 方法区(非堆)
  • 方法区也是各个线程共享的内存区域。

  • 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  • 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

  • 《Java虚拟机规范》对方法区十分宽松,不需要连续内存、也可以选择固定和可拓展,还可以不实现垃圾收集。

2.2.6 运行时常量池
  • 运行时常量池是方法区的一部分。

  • Class文件中有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用。这部分内容将在类加载后存放到方法区的运行常量池中。

  • 一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

  • 运行时常量池相对于Class文件的常量池,还具备动态性

    并非预置入Class文件中常量池的内容才能够进入方法区运行时常量池,运行期间也可以将新的常量放入池中(String#intern()方法)。

  • 运行时常量池是方法区的一部分,所以他和方法区一样,当常量池无法大再申请到内存是会抛出OutOfMemoryError异常。

区分几个常量池

Java堆中的字符串常量池(仅包含字符串常量)

  • JDK6.0以前,字符串常量池中放的都是字符串常量;

  • JDK7.0中,由于String#intern()方法发生了改变,因此字符串常量池中也可以存放 堆内的字符串对象的引用。

  • 需要说明的是:字符串常量池中的字符串只存在一份。如果此时有 s1 = "Hello,World!"和s2 = "Hello,World!"。

    只有s1会在常量池中存储,常量池中已有s2需要的字符串,直接把已存在的字符串内存地址返回给s2,s2不会再申请新的空间。

Class文件的常量池(也叫编译时常量池,包含各种类型的常量)

  • 我们写的每一个Java类被编译后,就会形成一个class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译器生成的各种字面量和符号引用。

  • 每个Class文件都有一个class常量池。

什么是字面量和符号引用:

  • 字面量包括:1.文本字符串2.八种基本类型的值3.被声明为final的常量等

  • 符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。

运行时常量池

  • 运行时常量池存在于内存(方法区)中,也就是class常量池被加载到内存之后的版本,不同的是,它的字面量可以动态的添加(String#intern() 动态性),符号引用可以被解析为直接引用。

  • 编译时期类的字面量和符号引用都被存放在class文件的常量池中,当类被加载到内存时,JVM会将class常量池中的内容存放到运行时常量池中。(class常量池--->运行时常量池)。所以运行时常量池也是每个类都有一个。

运行时常量池和字符串常量池的区别:

  • 运行时常量池中的字面量文本字符串时可修改的,字符串常量池中的字符串是不可变的。

  • 运行时常量池是对类的解析,字符串常量池只针对字符串。

  • 他们的生命周期不同,运行时常量池的生命周期与类绑定,字符串常量池的生命周期与JVM绑定。

Java虚拟机——运行时数据区域_数据时如何运行在虚拟机的-CSDN博客

2.2.7 直接内存
  • 直接内存并不是虚拟机运行时数据区的一部分,不过这部分内存也被频繁地使用。

  • 直接内存是通过 Native 方法分配在 Java 虚拟机之外的内存空间。

  • 作用:为一些基于 NIO 的 I/O 操作提供缓冲区,提高数据传输的效率;减少在 Java 堆和 Native 堆之间来回复制数据的开销。

  • 本机直接分配的内存不会受到Java堆大小的限制,不过会收到本机总内存的限制。

2.3 HotSpot虚拟机对象探秘

  • HotSpot虚拟机在Java堆中对象分配、布局和访问的过程。

2.3.1 对象的创建

这里讨论的对象仅限于普通的Java对象,不包括数组和Class对象等

(定位符号引用,检查类加载

  1. 当Java虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用。也就是说能不能找到这个类。

  2. 检查这个符号引用代表的类是否已被加载、解析和初始化(类加载的几个阶段)过。

  3. 如果没有,则应该先执行相应的类加载过程

内存分配,已确定的内存大小,规整:指针碰撞,不规整:空闲列表,空间压缩整理能力)

  1. 类加载检查完毕后,虚拟机将为新生对象分配内存。

  2. 对象所需的内存大小在类加载完成后便能完全确定,所以为对象分配内存的任务相当于把一块确定大小的内存从Java堆中划出来。

  3. 如果堆中内存是规整的,那么中间放一个分界点的指示器,一边是分配过的内存,另一边是空闲的内存。分配内存只需要将这个指针向空闲方向移动一段距离即可。这种分配方式称为指针碰撞。

  4. 如果堆中的内存不是规整的,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配时找到一块足够大的内存划分给对象的实例,并更新列表上的记录,这种分配方式称为空闲列表。

  5. Java堆是否规整由所采用的垃圾收集器是否带有空间压缩整理的能力决定。

解决多线程修改对象的内存分配异步问题

解决对象修改的异步问题有两种可选方案:

  1. 一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。

  2. 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。

对象(默认信息)初始化(默认值,信息设置(保存在对象头中))

  1. 内存分配完成后,虚拟机将分配到的内存空间都初始化为零值。这样保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能够访问到字段的数据类型的零值。(设为默认值

  2. 接下来,Java虚拟机对对象进行必要的设置,例如对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码等信息。

  3. 设置的这些信息存放在对象的对象头中,对象头的设置方式会跟着虚拟机当前运行状态不同、是否启用偏向锁等产生变化。

从虚拟机的视角,对象已经产生了。

对象初始化(按照程序员意图)设置程序中指定的初始资源和状态信息等,执行对象的构造函数。

  1. 但是从Java程序的角度,对象的构造函数还没有执行,即Class文件中的<init>()方法。

  2. 所有字段都默认为零值,对象需要的资源和状态信息还没有按照预定的意图构造好。

  3. 初始化完成后,一个真正的对象才构造出来。

2.3.2 对象的内存布局
  • 在HotSpot虚拟机里,对象在堆内存中的存储布局分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头部分包括两类信息:

    1. 一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在为32比特或64比特,官方称为“Mark Word”。

      考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存存储尽量多的数据,根据对象的状态复用自己的内存空间

    2. 另一类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。(并不是所有虚拟机的实现都需要类型指针,换句话说,查找对象的元数据信息不一定要经过对象本身)

    3. 此外,如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据,虚拟机可以推断出数组对象的大小。

  • 对象实例数据部分

    1. 实例数据部分是对象真正存储的有效信息(即代码中字段的内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来)

    2. 这部分的存储顺序会受到 虚拟机分配策略参数字段在Java源码中定义的顺序 的影响。

    3. HotSpot虚拟机默认的分配顺序为longs/doubles,ints,shorts/chars,bytes/booleans,oops(自定义对象)。

    4. 从上面默认的分配策略中可以看到:

      1. 相同宽度的字段总会被分配到一起存放。

      2. 父类的字段在子类的字段前面。

      3. 在默认情况下(虚拟机分配策略参数为true),子类允许插入到父类变量的空隙中。

  • 对象的对齐填充部分

    1. 他仅仅起着占位符的作用,没有特别的含义。

    2. HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。

    3. 对象头部分已经被设计成8字节的倍数,对齐填充针对实例数据部分进行填充补全。

2.3.3 对象的访问定位
  • Java程序会通过栈上reference数据来操作堆上的具体对象,reference类型是一个指向对象的引用。

  • 对象的访问方式由虚拟机的实现具体决定,主流访问方式有使用句柄直接指针两种:

    1. 句柄访问:Java堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与对象类型数据各自具体的地址信息,如图。

      优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集是移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

    2. 直接指针:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象的地址,如图。

      优势:速度更快,节省了一次指针定位的时间开销。

    3. HotSpot主要采用第二种方式(直接访问)进行对象访问。

2.4 OutOfMemoryError异常

除了 程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)。

2.4.1 Java堆溢出
  • Java堆内存的OOM异常是实际应用中最常见的内存溢出异常情况。

  • 当Java堆溢出时,异常堆栈信息"java.long.OutOfMemoryError"会跟进一步提示"java heap space"。

  • 原因:一般与内存泄漏或者堆的大小设置不当导致(对象太多)。

  • 解决:

    1. 首先通过 内存映像分析工具 对Dump出来的堆转储快照进行分析。

    2. 如果是内存泄漏,需要通过内存监控分析工具查找内存泄漏的地方。

    3. 如果是堆的大小不当,可以通过调整虚拟机参数-Xms,-Xmx等修改堆的大小。

2.4.2 虚拟机栈和本地方法栈溢出
  • HotSpot虚拟机中不区分虚拟机栈和本地方法栈

  • 因此对于HotSpot来说,指定-Xoss(指定本地方法栈大小没有意义),栈容量大小应该用-Xss参数来设定。

  • 两种异常情况:

    1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

    2. 如果虚拟机的栈内存允许动态拓展,当拓展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

2.4.3 方法区和运行时常量池溢出(永久代/元空间溢出)
  • 因为运行时常量池时方法区的一部分,所以可以一块儿看。

  • 方法区在JDK8以前是使用“永久代”,之后才改成了使用“元空间”实现。

  • 在JDK6或之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。

  • 当方法区溢出时在OutOfMemoryError异常后面会跟随提示“PermGen space”。

  • 原因:一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况。因为这些情况会产生大量的Class信息存储于方法区。

  • 解决:可以通过更改方法区的大小来解决,使用类似-XX:PermSize=64m -XX:MaxPermSize=256m的形式修改。

  • 另外,过多的常量尤其是字符串也会导致方法区溢出。

2.4.4 本机直接内存溢出
  • 直接内存的容量大小可通过 -XX:MaxDirectMemorySize参数来指定,如果不指定,则默认与Java堆的最大值一致。

  • 有直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中(日志)不会看见有什么明显的异常情况。

  • 解决办法:

    • 调整 JVM 参数,增大直接内存大小(-XX:MaxDirectMemorySize)。

    • 排查代码中是否存在直接内存泄漏问题,优化内存使用。

  • 8
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值