01-JVM自动内存管理 Java内存区域与内存溢出异常

概述

C、C++开发人员自己负责内存管理;Java则由JVM去管理。

如果出现内存溢出与泄露,则java开发人员需要了解JVM去排错。

运行时数据区域

程序计数器

程序计数器可以看作当前线程执行的字节码的行号指示器。

  1. 字节码解释器通过改变计数器的值来选取下一条字节码指令,程序控制流(分支、循环、跳转等)、异常处理、线程恢复等都依赖这个计数器来完成。
  2. 为了线程切换后,可以恢复到正确的执行位置,各线程私有程序计数器,彼此互不影响,独立存储。

线程执行Java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址。

线程执行本地方法时,程序计数器为空。

程序计数器是唯一一个没有规定任何OOM情况的区域。

虚拟机栈

描述Java方法执行的线程内存模型,为虚拟机执行 Java 方法(也就是字节码)服务

  1. 每个方法执行时,JVM就会同步创建一个栈帧来存储局部变量表、操作数栈、动态连接和方法出口。一个方法的调用与执行完毕对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

  2. 对于JVM执行引擎来说,在在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类

  3. 虚拟机栈异常
    *Error:栈深度大于虚拟机栈所允许的最大深度。

    OutOfMemoryError:如果栈容量允许动态扩展,栈扩展无法申请到足够的内存。

  4. 虚拟机栈线程私有

栈帧的各部分数据结构与作用

  • 局部变量表
    一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位。
    一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。
    虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

    局部变量槽是可以复用的,会影响到垃圾收集。

    {
    	byte[] placeholder = new byte[1024 * 1024 * 64];
    }
    System.gc();
    

    由于局部变量槽内还存在引用placeholder指向数组对象,因此GC时并不会回收对象

    {
    	byte[] placeholder = new byte[1024 * 1024 * 64];
    }
    int i = 0;
    System.gc();
    

    由于程序执行到int i时离开了placeholder作用域,不会再访问placeholder了,因此变量i占用了placeholder所在的变量槽,GC时会回收数组对象。

  • 操作数栈
    一个后入先出栈,作为计算过程中变量临时的存储空间,主要用于保存计算过程的中间结果
    当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
    一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

  • 动态连接
    如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接而引用的过程称之为静态连接。 例如,super()方法。
    如果被调用的方法无法在编译期确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程中具备动态性,因此也被称之为动态连接。对应着接口回调,多态动态绑定等。
    在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池
    每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接

  • 方法返回
    正常退出方法:当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定
    异常退出方法:方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

栈帧是线程本地的私有数据,不可能在一个栈帧中引用另外一个线程的栈帧

在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量,在方法的Code属性中的max_stacks数据项中确定了该方法所的操作数栈的最大深度。

方法退出过程等同于把当前栈帧出栈,因此退出可以执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压如调用者的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后的下一条指令。

本地方法栈

为虚拟机使用到的 Native 方法服务。

线程调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。

线程私有

也会出现OOM与SOF

用于存储对象实例与数组

  1. 几乎所有对象实例与数组都是在堆上分配。
  2. java堆是垃圾收集器管理的主要区域。
  3. 虚拟机启动时创建java堆
  4. java堆是被所有线程共享的一块内存区域
  5. 堆内存进一步划分为新生代、老年代、永久代,目的是为了更好的回收内存, 或者更快的分配内存。
  6. 主流的JVM按照堆可扩展实现的,通过-Xmx与-Xms设定

若堆无内存完成分配,且堆无法再扩展时,跑出OOM。

方法区

存储已加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等

  1. 方法区是被所有线程共享的一块内存区域
  2. Java虚拟机的规范中方法区是堆中的一个逻辑部分
  3. 方法区的内存回收主要是针对常量池以及类型的卸载,但是类型卸载在实际场景中的条件相当苛刻。
  4. 如果方法区无法满足新的内存分配需求时,将跑出OOM

运行时常量池

  • 方法区的一部分,存放常量池表
  • class文件有常量池表,用于存放编译器生成的各种字面量与符号引用

HotSpot使用永久代来实现方法区,并把GC的分代收集扩展至永久带。这样设计的好处就是能够省去专门为方法区编写内存管理的代码。但是在实际的场景中,这样的实现并不是一个好的方式,因为永久代有MAX上限,所以这样做会更容易遇到内存溢出问题。

Java1.7已经将永久代中的字符串常量池和静态变量移到堆中。

Java1.8使用在本地内存中实现的元空间替代永久代,主要存储运行时常量池、类信息等。

关于直接内存

  • 直接内存不属于java运行时数据区域;各内存区域总和大于物理内存限制,会导致动态扩展时出现OOM。
  • 本地IO可以直接操作直接内存(直接内存->系统调用->硬盘/网卡),而非直接内存需要二次拷贝(非直接内存->直接内存->系统调用->硬盘/网卡)。

HotSpot虚拟机在Java堆中的对象

对象的创建

  1. JVM遇到一条字节码new指令时,首先检查该指令的参数是否可以在常量池中定位到一个类的符号引用

  2. JVM检查该符号引用代表的类是否被加载过,如果没有则执行相应的类加载过程。

  3. 类加载检查后,JVM为新生对象分配内存
    分配内存大小在类加载完成后就可以确定。
    两种内存分配方式
    指针碰撞:java堆的内存是规整的,以一个作为分界点的指针,用过的内存放在一边,空闲的内存放在另一边。
    空闲列表:java堆的内存不规整,空闲与已使用的内存空间相互交错。需要使用一张列表,记录哪些内存块是可用的。在分配时根据列表找出足够大的内存空间划分给对象,并更新列表上的记录。

    内存分配方式由内存区块是否规整决定,而内存是否规整由垃圾收集器是否带有空间压缩整理能力决定。

    并发情况下分配内存可能出现线程A分配内存时,线程B又同时使用了原来的指针来分配内存。有如下两种解决方案:

    1. 对分配内存空间的动作进行同步处理,JVM采用CAS配上失败重试的方式保证更新操作的原子性。
    2. 内存分配的动作按照线程划分在不同空间进行,即每一个线程预先分配一小块内存,称为本地线程分配缓冲TLAB。只有线程的分配缓冲区用完了,才需要同步锁定。
  4. 内存分配完成后,JVM将分配的内存空间初始化为零值。
    初始化保证了对象的实例字段不用赋初值就可以使用,使程序可以访问到这些字段的数据类型所对应的零值。

    如果使用了TLAB,则初始化在TLAB分配时顺便进行。

  5. JVM对对象进行必要的设置
    在对象头中设置对象的类型指针、哈希码、GC分代年龄等。

JVM创建完对象后,从java程序上看,对象创建才刚刚,构造函数()还没有执行,所有的字段都默认为零值。执行完()方法后,真正可用的对象才算完全构造出来。

对象的内存布局

在HotSpot虚拟机中,对象在内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。

对象头

  • 对象自身的运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
  • 类型指针:对象指向它的类型元数据的指针,通过该指针确定对象是哪个类的实例。如果对象是数组,则对象头还需要有一块记录数组长度的数据。

实例数据

对象的各种类型的字段内容。

对齐填充

任何对象大小需要是8的整数倍。如果实例数据大小不是8的整数倍,则通过对齐填充补齐。

对象的访问定位

对象创建后,有两种访问定位的方式:

  1. 句柄访问
    堆中划分出一块句柄池,栈上的引用存储的是对象的句柄地址。句柄中包含了对象的实例数据和类型数据的地址信息。

  2. 直接指针访问
    栈上的引用存储的就是对象地址,对象的内存布局需要考虑如何放置访问类型数据的相关信息。

句柄访问的优势:引用中存储的是稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针,引用本身不会改变。

直接指针访问的优势:存储的是对象地址,如果只访问对象本身的话,可以节省一次指针定位的时间开销。对象访问十分频繁,可以节省可观的执行成本。

IDEA设置JVM配置

设置全局JVM配置

01-JVM自动内存管理 Java内存区域与内存溢出异常

IDEA -> help -> Edit Custom VM options

  • Xms:堆内存最小值
  • Xmx:堆内存最大值
  • MaxMetasapceSize:最大元空间大小
  • +HeapDumpOnOutOfMemoryError:内存溢出异常时转储出当前堆内存快照,以便事后分析

设置局部JVM配置

01-JVM自动内存管理 Java内存区域与内存溢出异常

OOM&SOF

java堆溢出

package heap;

import java.util.ArrayList;
import java.util.List;

/**
 * VM Args: -Xms20m -Xmx20m  -XX:+HeadDumpOnOutOfMemoryError
 * @author cqx
 */
public class HeapOOM {
    static class OOMObject{

    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while(true){
            list.add(new OOMObject());
        }
    }
}
D:\Java\jdk1.8.0_151\bin\java.exe ...
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9132.hprof ...
Heap dump file created [28349251 bytes in 0.111 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:265)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
	at java.util.ArrayList.add(ArrayList.java:462)
	at heap.HeapOOM.main(HeapOOM.java:17)

Process finished with exit code 1

虚拟机栈溢出

package heap;
/**
 * -Xss128k
 * @auther cqx
 */

public class JavaStackSOF {
    private int stacklength = 1;
    public void stackLeak(){
        stacklength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaStackSOF oom = new JavaStackSOF();
        try{
            oom.stackLeak();
        }catch(Throwable e){
            System.out.println("stack length:"+oom.stacklength);
            throw e;
        }
    }

}
D:\Java\jdk1.8.0_151\bin\java.exe...
stack length:1591
Exception in thread "main" java.lang.*Error
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)
	at heap.JavaStackSOF.stackLeak(JavaStackSOF.java:7)

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

package heap;

import java.util.HashSet;
import java.util.Set;
/**
 * -Xms1m -Xmx1m
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        int i = 0;
        while(true){
            set.add(String.valueOf(i++).intern());
            System.out.println(set.size());
        }
    }
}

jdk1.7开始,字符串常量池移到堆内存,因此应该设置Xms和Xmx。

1.7之前应该使用-XX:PermSize=6M -XX:MaxPrimSize=6M。

jdk1.8开始,使用元空间存储永久代剩下的部分,-XX:MetaspaceSize和-XX:MaxMetaspaceSize限制大小

package heap;

import java.util.HashSet;
import java.util.Set;
/**
 * -Xms1m -Xmx1m
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        int i = 0;
        while(true){
            set.add(String.valueOf(i++).intern());
            System.out.println(set.size());
        }
    }
}

jdk1.7开始,字符串常量池移到堆内存,因此应该设置Xms和Xmx。
1.7之前应该使用-XX:PermSize=6M -XX:MaxPrimSize=6M。
jdk1.8开始,使用元空间存储永久代剩下的部分,-XX:MetaspaceSize和-XX:MaxMetaspaceSize限制大小

上一篇:Heap堆分析(堆转储、堆分析)


下一篇:浅聊JVM内存模型以及垃圾处理机制