登录/注册
唐某
2948
5
25
浏览量
粉丝
关注
内存区域与内存溢出异常
唐某
2020-11-10 13:41:57
65
0

自动内存管理机制

运行时数据区:Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。 其中一些数据区域是在Java虚拟机启动时创建的,仅在Java虚拟机退出时销毁。 其他数据区域是每个线程所占的空间。 线程数据区域是随着线程销毁和创建的。

PC 寄存器(程序计数器)线程私有:Java虚拟机可以同时支持许多执行线程。 每个Java虚拟机线程都有自己的pc(程序计数器)寄存器。 在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。 如果该方法不是native方法,则pc寄存器包含当前正在执行的Java虚拟机指令的地址。 如果线程当前正在执行的方法是native的,则Java虚拟机的pc寄存器的值是未定义的。 Java虚拟机的pc寄存器足够宽,可以在特定平台上保存returnAddress或native 指针。

java虚拟机栈(线程私有):每个Java虚拟机线程都有一个私有Java虚拟机栈,与线程同时创建。 Java虚拟机栈存储。 Java虚拟机栈类似于传统语言的栈,例如C:它保存局部变量和部分结果,并在方法调用和返回中起作用。 由于除了压入和弹出帧之外,永远不会直接操作Java虚拟机栈,而帧可以是堆分配的。所以 Java虚拟机栈的内存不需要是连续的。

在Java®虚拟机规范的第一版中,Java虚拟机栈和Java栈是一个意思。

此规范允许Java虚拟机栈具有固定大小或根据计算的需要动态扩展和收缩。 如果Java虚拟机堆栈具有固定大小,则可以在创建该栈时独立选择每个Java虚拟机堆栈的大小。

Java虚拟机实现可以为程序员或用户提供对Java虚拟机栈的初始大小的控制,以及在动态扩展或收缩Java虚拟机堆栈的情况下,控制最大和最小大小。

以下异常条件与Java虚拟机堆栈相关联:

  • 如果线程中的计算需要比允许的更大的Java虚拟机堆栈,则Java虚拟机会抛出StackOverflowError.
  • 如果可以动态扩展Java虚拟机堆栈,并且尝试进行扩展但可以使用的内存不足以实现扩展,或者可以使用的内存不足以为新线程创建初始Java虚拟机堆栈,则可以使用Java虚拟机抛出OutOfMemoryError.

堆(线程共有):Java虚拟机有一个在所有Java虚拟机线程之间共享的堆。堆是运行时数据区,从中分配所有类实例和数组的内存

堆是在虚拟机启动时创建的。存储在堆上的对象由自动存储管理系统(称为垃圾收集器)回收 ; 对象永远不会被用户显式释放。 Java虚拟机假设没有特定类型的自动存储管理系统,可以根据实现者的系统要求选择存储管理技术。堆可以是固定大小的,或者可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。

Java虚拟机实现可以为程序员或用户提供对堆的初始大小的控制,以及如果可以动态扩展或收缩堆,则控制最大和最小堆大小。

以下异常情况与堆相关联:

  • 如果计算需要的堆的大小超过自动存储管理系统可用的堆,则Java虚拟机会抛出OutOfMemoryError

方法区(线程共有):Java虚拟机具有在所有Java虚拟机线程之间共享的方法区域。 方法区域类似于传统语言的编译代码的存储区域或类似于操作系统进程中的“文本”段。 它存储每个类的结构,例如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括类和接口初始化以及实例初始化中使用的特殊方法

方法区域是在虚拟机启动时创建的。 虽然方法区域在逻辑上是堆的一部分,但是简单的虚拟机实现可能选择不进行垃圾收集或压缩它。 此规范不强制方法区域的位置或用于管理编译代码的策略。 方法区域可以是固定大小的,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以缩小方法区域。 方法区域的内存不需要是连续的。

Java虚拟机实现可以为程序员或用户提供对方法区域的初始大小的控制,以及在变大小方法区域的情况下,控制最大和最小方法区域大小。

以下异常条件与方法区域相关联:

  • 如果方法区域中的内存不能满足分配请求,则Java虚拟机会抛出OutOfMemoryError

运行时常量池(在栈中线程共有,在方法区中):运行时常量池在每个类或者接口的class文件中代表 constant_pool表。 它包含几种常量,从编译时已知的字面量到必须在运行时解析的方法和字段引用。 运行时常量池提供类似于传统编程语言的符号表的功能,尽管它包含比典型符号表更宽范围的数据。

每个运行时常量池都是从Java虚拟机的方法区域分配的。 当Java虚拟机创建类或接口时,将构造类或接口的运行时常量池。

以下异常条件与类或接口的运行时常量池的构造相关联:

  • 创建类或接口时,如果运行时常量池的构造需要的内存比Java虚拟机的方法区域中可用的内存多,则Java虚拟机会抛出OutOfMemoryError

本地方法栈(线程私有):Java虚拟机的实现可以使用传统的堆栈,俗称“C堆栈”,以支持native方法(用Java编程语言以外的语言编写的方法)。 native方法堆栈也可以通过以诸如C语言的语言为Java虚拟机的指令集实现解释器来使用。无法加载native方法并且本身不依赖于传统堆栈的Java虚拟机实现不需要提供本机方法栈。 如果提供,则通常在创建每个线程时为每个线程分配native方法堆栈。

此规范允许本机方法堆栈具有固定大小或根据计算的需要动态扩展和收缩。 如果本机方法堆栈具有固定大小,则可以在创建该堆栈时独立地选择每个本机方法堆栈的大小。

Java虚拟机实现可以为程序员或用户提供对本机方法堆栈的初始大小的控制,以及在不同大小的本机方法堆栈的情况下,控制最大和最小方法堆栈大小。

以下异常条件与本机方法堆栈相关联:

  • 以下异常条件相关联如果线程中的计算需要比允许的更大的本机方法堆栈,则Java虚拟机会抛出StackOverflowError
  • 如果可以动态扩展native方法堆栈并尝试native方法栈扩展但可用内存不足,或者如果可用内存不足以为新线程创建初始本机方法堆栈,则Java虚拟机会抛出OutOfMemoryError

分段代码缓存(HotSpot)

代码缓存是Java虚拟机存储生成的本机代码的内存区域。它被组织为一个连续的内存块上的单个堆数据结构。

代码缓存不是只有一个代码堆,而是分成多个段,每个段包含特定类型的编译代码。此分段可以更好地控制JVM内存占用,缩短编译方法的扫描时间,显着减少代码缓存的碎片,并提高性能。

代码缓存分为以下三个部分:

代码缓存段 描述 JVM命令行参数
Non-method 此代码堆包含非方法代码,例如编译器缓冲区和字节码解释器。此代码类型永远保留在代码缓存中。代码堆具有3 MB的固定大小,并且剩余的代码缓存在分析的和非分析的代码堆之间均匀分布。 -XX:NonMethodCodeHeapSize
Profiled 此代码堆包含使用寿命较短的轻微优化的配置文件方法。 –XX:ProfiledCodeHeapSize
Non-profiled 此代码堆包含完全优化的非分析方法,可能具有较长的生命周期。 -XX:NonProfiledCodeHeapSize

栈帧

栈帧用于存储数据和部分结果,以及执行动态链接,返回方法的值以及调度异常。

每次调用方法时都会创建一个新帧。 当方法调用完成时,无论是正常完成或者抛出异常(它会抛出未捕获的异常),栈帧都将被销毁。 帧是从创建帧的线程的Java虚拟机栈分配的。 每个帧都有自己的局部变量数组,自己的操作数栈,以及对当前方法所在类的运行时常量池的引用

可以使用附加的特定于实现的信息来扩展帧,例如调试信息。

局部变量数组和操作数堆栈的大小在编译时确定,并与用于与栈帧相关联的方法的代码一起提供。 因此,帧数据结构的大小仅取决于Java虚拟机的实现,并且可以在方法调用上同时分配这些结构的存储器。

只有一个帧(执行方法的帧)在给定控制线程中的任何点处都是活动的。 该帧被称为当前帧,并且其方法被称为当前方法。 定义当前方法的类是当前类。 局部变量和操作数堆栈的操作通常参考当前帧。

如果栈帧的方法调用另一个方法或其方法完成,则此栈帧将不再是当前栈帧。 调用方法时,会创建一个新帧,并在控制转移到新方法时成为当前帧。 在方法返回时,当前帧将其方法调用的结果(如果有)传递回前一帧。 然后当前一帧变为当前帧时丢弃当前帧。

请注意,由线程创建的帧是线程私有的,并且不能被任何其他线程引用。

本地变量表:每个帧包含一个称为局部变量的变量数组。 帧的局部变量数组的长度在编译时确定,并以类或接口的二进制表示形式提供,同时提供与帧相关的方法的代码。

单个局部变量可以包含boolean,byte,char,short,int,float,reference或returnAddress类型的值。 一对局部变量可以包含long或double类型的值。

通过索引来确定局部变量的位置。 第一个局部变量的索引为零。 索引的大小介于0和局部变量数组长度 - 1之间。

long类型或double类型的值占用两个连续的局部变量。 只能使用较小的索引来处理这样的值。 例如,存储在索引 n 的局部变量数组中的double类型实际上占用索引为n和n + 1的局部变量; 但是,索引n + 1的局部变量无法加载,它可以存储到。只能通过索引 n 获取。

Java虚拟机不要求n是偶数。 直观地说,long和double类型的值不需要在局部变量数组中进行64位对齐。 实现者可以使用为该值保留的两个局部变量自由决定表示此类值的适当方式。

Java虚拟机使用局部变量在方法调用上传递参数。 在类方法(static方法)调用中,任何参数都是从局部变量0开始的连续局部变量中传递的。在实例方法调用中,局部变量0总是用于传递对调用实例方法的对象的引用(在Java)。 随后,任何参数都在从局部变量1开始的连续局部变量中传递。

操作数栈:每个栈帧里包含一个后进先出(LIFO)堆栈,称为其操作数堆栈。 栈帧的操作数栈的最大深度在编译时确定,并与用于与栈帧相关联的方法的代码一起提供。

在上下文清晰的地方,我们有时会将当前帧的操作数栈称为操作数栈。

当创建包含操作数栈的帧时,操作数堆栈为空。 Java虚拟机提供指令以将局部变量或字段中的常量或值加载到操作数堆栈上。 其他Java虚拟机指令从操作数堆栈获取操作数,对它们进行操作,并将结果推回操作数堆栈。 操作数堆栈还用于准备要传递给方法和接收方法结果的参数。

例如,iadd指令将两个int值一起添加。 它要求要添加的int值是操作数堆栈的前两个值,由前面的指令推送到那里。 两个int值都从操作数堆栈中弹出。 它们相加后的总和被推回到操作数堆栈上。 子计算可以嵌套在操作数堆栈上,从而产生可以由包含计算使用的值。

操作数堆栈上的每个条目都可以包含任何Java虚拟机类型的值,包括long类型或double类型的值。

必须以适合其类型的方式操作操作数堆栈中的值。 例如,不可能推送两个int值,然后将它们视为long或推送两个float值,然后使用iadd指令添加它们。 少量Java虚拟机指令(dup指令和交换)作为原始值在运行时数据区域上运行,而不考虑它们的特定类型; 这些指令的定义方式使它们不能用于修改或分解单个值。 操作数堆栈操作的这些限制是通过类文件验证强制执行的。

在任何时间点,操作数堆栈都具有相关联的深度,其中long或double类型的值对深度贡献两个单位,而任何其他类型的值贡献一个单位。

动态链接:每个栈帧都包含一个指向运行时常量池的引用,持有这个引用是为了支持方法调用过程中的动态链接。Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转换为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分成为动态链接。

(jvm 规范)方法的 class 文件代码是指要调用的方法和要通过符号引用访问的变量。 动态链接将这些符号方法引用转换为具体的方法引用,根据需要加载类以解析尚未定义的符号,并将变量访问转换为与这些变量的运行时位置相关联的存储结构中的适当偏移。

方法和变量的这种后期绑定使得方法使用的其他类中的更改不太可能破坏此代码。

正常情况下完成方法调用:如果调用不会直接从Java虚拟机或执行显式throw语句引发异常,则方法调用会正常完成。 如果当前方法的调用正常完成,则可以将值返回给调用方法。 当被调用的方法执行其中一个返回指令时,就会发生这种情况,返回指令的选择必须适合于返回值的类型(如果有的话)。

在这种情况下,当前帧用于恢复调用者的状态,包括其局部变量和操作数堆栈,调用者的程序计数器适当地递增以跳过方法调用指令。 然后执行在调用方法的帧中正常继续,返回值(如果有)被推送到该帧的操作数堆栈。

发生异常的方法调用:如果在方法中执行Java虚拟机指令导致Java虚拟机抛出异常,并且该异常未在该方法中处理,则方法调用突然完成。 执行athrow指令还会导致显式抛出异常,如果当前方法未捕获异常,则会导致突然的方法调用完成。 突然完成的方法调用永远不会向其调用者返回值。

对象的表示:Java虚拟机不要求对象的任何特定内部结构。

在Oracle的一些Java虚拟机实现中,对类实例的引用是指向句柄的指针,该句柄本身是一对指针:一个指向包含对象方法的表和一个指向表示Class对象的指针 对象的类型,另一个是从堆中为对象数据分配的内存。

特殊方法


实例初始化方法

类具有零个或多个实例初始化方法,每个方法通常对应于用Java编程语言编写的构造函数。

  • 如果满足以下所有条件,则方法是实例初始化方法:
  • 它在类(不是接口)中定义。
  • 它具有特殊名称
  • 它是void 返回类型

在类中,任何名为的非void方法都不是实例初始化方法。 在接口中,任何名为的方法都不是实例初始化方法。 任何Java虚拟机指令都不能调用此类方法,并且格式检查会拒绝这些方法。

实例初始化方法的声明和使用受Java虚拟机的约束。 对于声明,方法的 access_flags 项和代码数组受到约束。 对于使用,实例初始化方法可以仅由未初始化的类实例上的invokespecial指令调用。

因为名称不是Java编程语言中的有效标识符,所以它不能直接用在用Java编程语言编写的程序中。

类初始化方法:类或接口最多只有一个类或接口初始化方法,并由调用该方法的Java虚拟机初始化。

如果满足以下所有条件,则方法是类或接口初始化方法:

  • 它的特殊名称是
  • 它是void 返回类型
  • 在版本号为51.0或更高的类文件中,该方法设置了ACC_STATIC标志并且不带参数

在Java SE 7中引入了对 ACC_STATIC 的要求,并且在Java SE 9中没有引用参数。在版本号为50.0或更低的类文件中,名为的void 方法被认为是类或接口初始化方法 无论其ACC_STATIC标志的设置或是否需要参数。

类文件中名为的其他方法不是类或接口初始化方法。 它们永远不会被Java虚拟机本身调用,不能被任何Java虚拟机指令调用,并且会被格式检查拒绝。

因为名称不是Java编程语言中的有效标识符,所以它不能直接用在用Java编程语言编写的程序中。

签名多态方法:如果满足以下所有条件,则方法是签名多态的:

  • 它在java.lang.invoke.MethodHandle类或java.lang.invoke.VarHandle类中声明。
  • 它有一个Object [] 类型的形式参数。
  • 它设置了ACC_VARARGS和ACC_NATIVE标志。

对象的创建

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

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载完成后便可以完全确定,为对象分配空间的任务等同于把一块大小确定的内存从java堆中划分出来。在使用Serial、ParNew等需要压缩的收集器时,使用“指针碰撞”方式压缩内存:假设java堆中内存是绝对规整的,所有用过的内存都放在一遍,空闲的放在另一边。中间放着指针作为分界点的指示器,当需要为对象分配内存的时候需要做的就是将指针向空闲一侧分配与对象大小相等的距离。而使用CMS 这种就Mark-Swap 算法的收集器时,通常采用空闲列表:如果java堆的内存分配并不是规整的,已使用的和未使用的内存相互交错,虚拟机就必须维护一个列表,记录那些内存块是可用的,在分配的时候从内存列表中找出一块足够大的空间划分给对象实例,并更新在表上的记录。这种方式成为空闲列表。

除如何划分可用空间外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下并不是线程安全的,可能出现正在给A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针分配内存的情况。解决这种问题有两个方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每一个线程在java堆中预先分配一小块内存,成为本地线程分配缓冲(Thread Local Allocation Buffer , TLAB) 。那个线程要分配内存就在那个线程的TLAB上分配,只有TLAB用完并更新分配时,才需要同步锁定。虚拟机是否使用TLAB,可以通过 -XX:+/- UseTLAB 参数设定。

内存分配后,虚拟机需要将分配到的内存空间都初始化零值(不包括对象头),如果使用TLAB ,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

接下来,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息,对象的哈希码、对象的GC分带年龄信息。这些信息存放在对象的对象头中(Object Header)之中。根据虚拟机当前的运行状态的不同,是否启用锁偏向等,对象头还会有不同的设置方式。

在上面的工作都完成之后,从虚拟机的角度来看,一个新的对象已经产生了。但从java程序的角度来看,对象的创建才刚刚开始——方法还没有执行,所有的字段都还为零值。所以,一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、和对齐填充(Padding)。

HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希吗,GC分代年龄标志、锁状态年龄标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32位、64位 BitMap结构所能记录的限度,但是对象头信息时与对象自身定义无关的额外存储成本,考虑到虚拟机的空间效率,Mark Work被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息。它会根据对象的状态复用自己的空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么 Mark Word 的32bit 空间中的25bit用于存储对象Hash码,4比特用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定,重量级锁定,GC标记,可偏向)见下表:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级记录的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

对象头的另外一部分是类型指针,即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪一个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息不一定要经过对象本身。另外如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java 对象的元数据信息确定java 对象的大小,但是从java 数组中的元数据中确无法确定数组的大小。

接下来的实例数据部分是对象真正存储的有效信息,也是是在程序代码中所定义的各种字段内容。无论是从父类继承下来的还是从子类定义的,都需要记录下来。这部分的存储顺序会受到虚拟机分配参数策略(FieldAllocationStyle)和字段在java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为 longs/doubles 、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,父类中定义的变量会出现在子类之前。如果CompactFields参数值为true,那么子类中较窄的变量也可能会插入到父类变量的空隙只中。

第三部分对齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求起始对象的起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

java程序需要通过栈上的reference 数据来操作堆上的具体对象。目前主流方式有句柄和直接使用指针两种。

如果使用句柄访问的话,那么java堆中将会划分出一块内存来作为句柄池,reference中存储的对象就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。

如果使用直接指针访问,那么java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象地址。

内存溢出

内存溢出原因:

对于java运行时数据区来说,都会因为申请内存失败而抛出OutOfMemoryError

java堆溢出:只要不断的创建对象,并保证GC Roots到对象之间有可达路径来避免垃圾收集器回收这些对象,那么在对象数量达到最大堆的容量限制之后就会抛出OutOfMemoryError

虚拟机栈和本地方法栈溢出:一般情况下在线程过多的时候,每个线程分配的栈深度不够的时候有可能抛出OutOfMemoryError

方法区和运行时常量池溢出:运行时生成大量类。

本机直接内存溢出:java 1.8 用Unsafe 手动分配内存,直接操作DirectByteBuffer是通过计算得知,手动抛出的。

对于CMS收集器来说:如果在垃圾收集中花费了太多时间,CMS收集器会抛出OutOfMemoryError:如果超过98%的总时间花在垃圾收集上,并且回收的堆少于2%,那么抛出OutOfMemoryError

暂无评论