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

book:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》


请添加图片描述

2.2 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机的进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示。
请添加图片描述

2.2.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,字节码解释器通过改变程序计数器来依次读取指令。程序计数器用于记录当前线程执行的位置。

如果程序正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个没有规定任何OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个战争用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 局部变量表:存放了编译期可知的各种数据类型boolean、byte、char、short、int、float、long、double、对象引用和returnAddress类型)。数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
  • 操作数栈:存放方法执行过程中产生的中间计算结果
  • 动态链接:将符号引用转换为调用方法的直接引用

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。HotSpot虚拟机的栈容量不可动态扩展, 不会由于虚拟机无法扩展而出现OutOfMemoryError,但是如果申请时就失败,还是会出现OutOfMemoryError。

2.2.3 本地方法栈

虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。( 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。)

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

2.2.4 Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,是虚拟机所管理的内存中最大的一块。此内存区域的唯一目的就是存放对象实例

在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。

Java堆是垃圾收集器管理的内存区域,因此也被称作GC堆。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词。这些区域划分是垃圾收集器的共同特性,而非某个java虚拟机具体实现的固有内存分布。

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.2.5 方法区

和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、及时编译器编译后的代码缓存等数据。(JDK8及以后静态变量已经到堆中了。)

  • 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。

关于“永久代”(Permanent Generation)这个概念:由于HotSpot虚拟机使用“永久代”来实现方法区,故许多Java程序员都习惯把方法区称呼为“永久代”,但这种设计更容易导致内存溢出问题。在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区,到了JDK7,已经把原本放在永久代的字符串常量池、静态变量等移除,而到了JDK8,终于完全废弃了永久代的概念,改用了与JPockit、J9一样在本地内存中实现的元空间中。

垃圾收集行为比较少,甚至可以不实现垃圾收集。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

无法满足新的内存分配需求时,将抛出OutOfMemoryError异常

2.2.6 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,在类加载后存放到方法区。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性。运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

2.2

JDK 1.7 将字符串常量池移动到堆中的原因:
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

2.2.7 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但被频繁地使用,而且也可能导致OutOfMemoryError异常

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

2.3 HotSpot 虚拟机堆中的对象

这一小节将对 JVM 对 Java 堆中的对象的创建、布局和访问的全过程进行讲解。

2.3.1对象的创建

遇到一条New指令,虚拟机的步骤:

  1. 类加载检查
    • 检查这个指令的参数是否能在常量池中定位到这个类的符号引用。并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存
    • 指针碰撞(标记-整理压缩)
    • 空闲列表(标记-清除)
  3. 初始化零值
    • 将分配到的内存空间都初始化为零值(不包括对象头)
  4. 设置对象头
    • 类的元数据信息、对象的哈希码、对象的 GC 分代年龄等。
  5. 执行 init 方法
    • 此时从虚拟机的角度看,对象已经产生,但从 Java 程序的角度看,构造函数(init方法)还没有执行。

详细来说,在第二步中,为对象分配内存,就是在内存划分一块确定大小的空闲内存,但存在两个问题:

  1. 如何划分空闲内存和已被使用的内存?

    • 假设Java堆中内存是绝对规整的,空闲内存和被使用内存被分到两边,中间放置指针作为分界点的指示器,那分配内存就是把指针向空闲内存一定一段,这种方式成为“指针碰撞(Bump The Pointer)”。
    • 但如果Java堆内存不是规整的,那就没有办法简单地进行指针碰撞了,虚拟机需要维护一个列表,记录哪些内存块可以使用,在分配内存的时候,找到一块足够打的内存划分给对象实例,并更新列表上的记录,这种方式被称为“空闲列表(Free List)”。
    • 事实上,这由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Seria、parNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存。
  2. 如何处理多线程下,内存分配问题?

    • 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
    • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Theard Local Allocation Buffer, TLAB),哪个线程要分配内存就先在本地线程分配缓冲中分配,只有缓冲使用完了,分配新的缓存区时需要同步锁定。

2.3.2对象的内存布局

对象在内存中的布局可以分为 3 块区域:

  • 对象头
    • 用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等)
    • 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
  • 实例数据
    • 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
  • 对齐填充:仅仅起占位作用

2.3.3 对象的访问定位

Java程序通过栈上的reference数据来操作堆上具体对象,主流的访问方式主要有以下两种:

  • 使用句柄:Java堆可能会划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄包含了对象实例数据与类型数据各自的地址信息。结构如图2-2所示。
    • 优势:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时,只会改变句柄中的实例数据指针,而不需修改reference。

请添加图片描述

  • 使用直接指针:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身,就不需要多一次间接访问的开销。如图2-3所示。
    • 优势:使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

请添加图片描述

2.4 实战: OutOfMemoryError异常

代码请详见原版书,这里只记录了一些解决方案。

2.4.1 Java堆溢出

Java堆内存的OutOfMemoryError异常是实际应用最常见的内存溢出情况,异常堆栈信息“java.lang.OutOfMemoryError”会进一步提示“Java heap space”。

解决方法:

  • 首先通过内存映像分析工具(如Eclipse Memory Analyzer,或者IDEA的jprofiler)对Dump出来的堆转储快照进行分析。分析清楚是出现内存泄漏(Memory Leak)还是内存溢出(Memory overflow),即导致OOM的对象是否是必要的。
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们。根据泄漏对象的类型信息以及它到GC Roots 引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找到产生内存泄漏的代码具体位置。
  • 如果是内存溢出,检查Java虚拟机的堆参数(-Xmx与-Xms)设置,检查代码对象生命周期是否过长、存储结构设计不合理等情况,减少程序运行的内存消耗。

2.4.2 虚拟机栈和本地方法栈溢出

线程请求的栈深度大于虚拟机所允许的最大深度,或者栈帧太大,都将抛出StackOverflowError异常。

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

在多线程情况下,如果是多线程过多导致内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程空间。

2.4.3方法区和运行时常量池溢出

JDK6中运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”。但是,无论是在JDK 7中继续使用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇。在JDK8后,永久代完全退出,元空间作为替代者登场。在默认设置下,动态创建新类型已经很难使虚拟机出现产生方法区内存溢出。

类加载过多,会导致方法区内存溢出,在实际的应用中,如Spring框架,会使用到CGLib这类字节码技术,会产生大量的动态类,容易导致内存溢出,需要注意垃圾回收;除此之外常见的还有大量JSP或动态JSP文件应用、基于OSGi的应用等。

2.4.4本机直接内存溢出

--XX:MaxDirectMemorySize设置,如不指定,默认与Java堆最大值(-Xmx指定)一致。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值