Java内存区域与内存溢出异常
1. 内存区域
Java虚拟机执行Java程序过程中会把它管理的内存划分为若干不同的数据区域。
程序计数器
- 线程私有。各条线程之间计数器互不影响,独立存储。生命周期同线程。
- 可以看成当前线程所执行的字节码行号指示器。字节码解释器工作时通过改变这个计数器值选取下一条需要执行的字节码指令(分支、循环、跳转、异常处理都需要依赖此计数器)。
- 多线程运行时通过此计数器在线程切换后恢复正确执行位置。
- 程序计数器是一块较小的内存空间,此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
Java 虚拟机栈
- 线程私有。生命周期同线程。
- 方法执行的内存模型。
方法执行时创建栈帧(局部变量表、操作数栈、动态链接、方法出口等)。 - 每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 局部变量表存放编译期可知的基本数据类型、对象引用和returnAddress(指向一条字节码指令的地址)类型。其中long, double占用2个局部变量空间(slot),其余占用1个slot。
- 一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。
- 局部变量所需内存空间编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定,运行期不改变其大小。
-
虚拟机栈有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常;如果虚拟机栈可以动态扩展(当前大部分 Java 虚拟机都可动态扩展),但扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
1.局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。
在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。
局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。
注意,形参也在局部变量表;如果是成员方法,this也在局部变量表
变量表从下标0开始,排序优先级为:this > 形参 > 方法中的变量
形参和方法中的变量按出现的顺序排列
2.操作数栈
操作数栈也常被称为操作栈,它是一个后入先出栈。里面存的是一个个操作数,比如_load指令会把一个值压栈,_store指令会把栈顶的值弹出。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图:
3.动态连接
动态链接是一个将符号引用解析为直接引用的过程。每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。java虚拟机执行字节码时,遇到一个操作码,操作码第一次使用一个指向另一类的符号引用,则虚拟机就必须解析这个符号引用。解析时需要执行三个基本的任务:
1.查找被引用的类(有必要的话就装载它,一般采用延时装载)。
2.将符号引用替换为直接引用,这样当再次遇到相同的引用时,可以使用这个直接引用,省去再次解析的步骤。
3.当java虚拟机解析一个符号引用时,class文件检查器的第四趟扫描确保了这个引用时合法的。
4.方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。
无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。
5. 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
本地方法栈
- 与虚拟机栈类似,为Native方法服务。
- 在 HotSpot 虚拟机中直接将本地方法栈与虚拟机栈合二为一。
Java 堆
- 所有线程共享,虚拟机启动时创建。
- 堆上分配(对象实例及数组)。
JIT编译期发展 + 逃逸分析技术 ——> 栈上分配、标量替换(后面章节会讲到,只需知道所有对象在堆上分配非绝对)。 - 从内存回收角度来看,Java 堆可分为新生代和老年代,其中新生代可进一步细分为 Eden 空间、From Survivor 空间、To Survivor 空间。
- 从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
- 如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
- 又称GC堆,垃圾收集管理的主要区域。
方法区(非堆 Non-Heap)
- 所有线程共享。
- 类信息、常量、静态变量、JIT编译后的代码等。
- 此区域垃圾收集:常量池回收和类型的卸载。(HotSpot虚拟机中为永久代)
-
永久代:HotSpot 虚拟机把 GC 分代收集扩展至方法区,或者说用永久代来实现方法区,这样就可以像管理 Java 堆一样管理这部分代码,能够省去专门为方法区编写内存管理代码的工作。
这个区域内存回收的目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收成绩比较难以令人满意,尤其是类型的卸载,条件相当苛刻。
- 方法区是一个逻辑上的概念
JDK1.8之前,方法区由永久区(Permament Space)实现,字符串常量也在永久区,FGC不会清理;JVM启动时指定永久区空间大小,不能改变
自JDK1.8开始,方法区由Meta Space实现,字符串常量位于堆,会触发FGC 被清理.可以指定大小,如果不指定的话,最大就是物理内存空间.。
运行时常量池
- 方法区的一部分。
- Class文件中除类的版本、字段、方法、接口等描述信息外,还有一项是常量池。
存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后进入方法区的运行时常量池中存放。 - 除符号引用,还会把翻译出来的直接引用存入运行时常量池中。
- 动态性。运行期也可放入新的常量。eg. String.intern()方法。
直接内存
- NIO:在 JDK 1.4 新加入了 NIO 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。从JVM内可以访问OS管理的内存(内核空间),可以提高IO效率(零拷贝)。
- 避免Java堆和Native堆来回复制数据。
2. HotSpot虚拟机对象
创建对象
查找类的符号引用(常量池) (检查符号引用代表的类是否 已被加载、解析和初始化)
分配内存(所需内存、类加载后可完全确定)
1. Java堆规整,只需移动指针:
指针碰撞(Bump the Pointer)
Serial, ParNew等带压缩功能的垃圾收集器
2. 不规整,维护列表记录哪些内存块可用:
空闲列表(Free List); CMS等基于Mark-Sweep垃圾收集
问题:非线程安全
eg. 给对象A分配内存,指针没来得及修改,对象B使用原指针分配内存
1) 同步分配内存空间的动作: CAS + 失败重试(原子性)
2) 本地线程分配缓冲(TLAB): 每个线程在Java堆中预先分配一块内存分
配动作按照线程划分在不同空间。TLAB用完分配新的TLAB时同步锁定。
将分配到的内存空间初始化为零值(不包括对象头)/使用TLAB时,提前至TLAB分配时进行
对象设置
(类信息、对象哈希码、对象GC分代年龄等信息。存放于对象头。)
对象的内存布局
3部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
- 对象头
– 第一部分,存储对象自身的运行时数据。(如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)。这部分数据长度在32位和64位虚拟机中分别为32位和64位,官方称为"Mark Word"。
– 第二部分,类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例(数组还有一块记录数组长度)。
由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间,32位虚拟机的markOop实现如下: - 实例数据:对象真正存储的有效信息。
- 对齐填充:仅起占位符的作用。HotSpot VM要求对象起始地址必须是8字节整数倍,换句话说,就是对象的大小必须是8字节的整数倍。当实例数据部分没有对齐时,通过对齐填充来补全。
对象的访问定位
- 使用句柄。
对象移动时只改变句柄中实例指针,reference不需要修改。 - 使用直接指针。速度快(节省一次指针定位的时间开销)。HotSpot使用此种方式。
实战:OutOfMemoryError异常
1)Java 堆溢出
-XX:+HeapDumpOnOutOfMemoryError: 出现OOM时Dump堆转储快照。
-Xms20m -Xmx20m 最小堆 最大堆20M
2)虚拟机栈和本地方法栈溢出
-Xss参数:设置栈容量。
StackOverflowError: 单线程时,栈帧太大 or 虚拟机栈容量太小。
如果是建立过多线程导致内存溢出,在不能减少线程数或更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
3)方法区和运行时常量池溢出(PermGen Space)
JDK1.6前,运行时常量池分配在永久代中,-XX:PermSize和-XX:MaxPermSize限制方法区大小,间接限制其中常量池容量。
JDK1.7,String.intern() 方法测试结果与JDK1.6不同。P56~P57书中例子。
产生大量类填满方法区,导致溢出。CGLib、JSP、OSGi等。
4)本机直接内存溢出
使用NIO,可导致本机直接内存溢出。
参考链接:
https://blog.csdn.net/xtayfjpk/article/details/41924283
https://blog.csdn.net/gangtou07/article/details/82775753
《深入理解java虚拟机》