Java虚拟机01——Java内存数据区域和内存溢出异常

·  阅读 588

运行时数据区域

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

内存模型.jpeg

我们可以将上面的数据区域分为线程独有、线程共享及其他三大区域:

1.1. 线程独有的数据区域

1. 程序计数器(Program Counter Register)

  1. 当前线程所执行的字节码的行号指示器。
  2. 用于选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复需要依赖这个计数

2. 虚拟机栈(Java Stack)

  • 位于线程私有的内存中,生命周期与线程相同。
  • 描述了Java方法执行的内存模型。
  • 方法执行时使用栈帧(Stack Frame)来存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
  • 如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

3. 本地方法栈(Native Method Stack)

  • 与虚拟机栈相类似,区域在于本地方法栈为虚拟机使用到的Native方法服务。
  • 可以由虚拟机设计者自己实现。
  • 本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常

1.2. 线程共享的数据区域

1. Java堆(Heap)

  • 是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建。
  • 在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术导致某些对象并没有分配在堆上。
  • Java GC工作的主要区域。现代收集器基本都采用分代收集算法,所以Java堆中还可以细分为新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
  • 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

2. 方法区(Method Area)【Java8中去除了永久代 // TODO】

  • 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 它有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
  • HotSpot虚拟机选择把GC分代收集扩展至方法区,即使用永久代来实现方法区,因此也有人将此区域称为“永久代”;JDK 1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出,并逐步改为采用Native Memory来实现方法区的规划。
  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

3. 运行时常量池(Runtime Constant Pool)

  • 运行时常量池是方法区的一部分。
  • 用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
  • 当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

1.3. 其他区域

直接内存(Direct Memory)

  • 直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
  • 这部分内存也可能导致OutOfMemoryError异常出现。

对象的创建

Java是一门面向对象的语言,在Java程序运行的过程中无时不刻都有对象被创建。在语言层面,创建对象通常是一个new关键字,但是,在虚拟机中,创建对象包括如下流程:

类加载 --> 分配内存 --> 内存空间初始化零值 --> 对象头设置 --> init初始化

  • 虚拟机遇到一个new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  • 在类加载通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在加载后就确定。

分配内存的方式为:
  “指针碰撞”:在内存规整情况下,将指针向空闲空间挪动一段与对象大小相等的距离。
  “空闲列表”:在内存不规整情况下,虚拟机维护一个记录内存可用的列表,分配的时候从列表中找到一块空间划分给对象。
并发情况下的内存分配:
  同步:对分配内存空间的动作进行同步处理———采用CAS配上失败重试的方式,保证更新操作的原子性
  本地线程分配缓冲(TLAB):把内存分配动作按照线程划分在不同空间中。即每个线程在Java堆中预先分配一块内存TLAB,只有TLAB用完并重新分配新的TLAB时才需要同步。

  • 将分配到的内存空间都初始化零值,如int a,a默认为0,如果使用TLAB,则这个工作提前到TLAB
  • 对象头设置:对象是哪个类的实例,对象的哈希码,对象的GC分代年龄等信息。
  • 执行init方法,即执行程序定义的构造方法。

对象的内存布局

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

  • 对象头:
    1.用于存储对象自身运行时数据(Mark Word):哈希码GC分代年龄锁状态标志线程持有锁偏向线程ID偏向时间戳等。被设计成非固定的数据结构,能服用存储空间
    2.类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是Java数组,还必须有一块记录数组长度的数据。
  • 实例数据: 在程序代码中定义的各种类型的字段内容。这部分的存储会周到虚拟机分配策略的影响。HotSpot虚拟机的默认分配策略:相同宽度的字段总是被分配到一起。父类中定义的变量会出现在子类前,子类中较窄的变量也可能会插入到父类变量的空隙之中。
  • 对齐填充: 仅仅起到占位符的作用。HotSpot要求对象的起始地址必须是8字节的整数倍。当对象实例数据部分没有对齐时,就需要通过对齐填充来补充。

对象的访问定位

建立对象是为了使用对象。我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有两种:句柄和直接指针。

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

句柄.jpg

直接访问:reference指针存储的直接就是对象地址

直接地址.jpg

  使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(如垃圾收集时)时只会改变句柄中的实例数据指针,而reference本身不需要修改

  直接访问最大的好处就是速度快。节省了一次指针定位的时间开销。HotSpot虚拟机使用第二种方式进行对象的访问。

OutofMemoryError异常实战

堆溢出

-Xms 堆最小值 -Xmx 堆最大值 -XX:HeapDumpOnOutOfMemoryError可以在虚拟机出现异常时将堆存储快照

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
复制代码
public class HeadOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}
复制代码

运行结果:

image.png

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

-Xss 设置栈的大小

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflow异常
  • 如果虚拟机在扩展栈时无法申请到足够的空间,则抛出OutOfMemoryError异常
-Xss228k
复制代码
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength ++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF stackSOF = new JavaVMStackSOF();
        stackSOF.stackLeak();
    }
}
复制代码

运行结果:

image.png

实验结果表明,在单线程下,当内存无法分配的时候,虚拟机抛出的都是StackOverflow异常

测试:创建线程导致内存溢出异常

public class JavaVMStackOOM {
    private void dontStop() {
        while (true) {

        }
    }

     public void stackLeakByThread() {
        while (true) {
            new Thread(() -> {
                dontStop();
            }).start();
        }
     }

    public static void main(String[] args) {
        JavaVMStackOOM javaVMStackOOM = new JavaVMStackOOM();
        javaVMStackOOM.stackLeakByThread();
    }
}
复制代码

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

String.intern()方法返回的是常量池中的对象,如果池中没有对象,则创建对象返回引用

在JDK 1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量,测试代码:

public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}
复制代码
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改