【JVM篇】Java内存区域与OOM

目录

1、概述

2、运行时数据区域 

3、程序计数器

4、Java虚拟机栈

5、本地方法栈

6、Java堆

7、方法区

8、运行时常量池

9、直接内存


1、概述

内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。本文对JVM的数据区域及其对应的OOM原因进行了较为详细的分析。

2、运行时数据区域 

下图是JVM定义的各种运行时数据区域。 

【JVM篇】Java内存区域与OOM_第1张图片

可见,JVM运行时数据区域分为两类:

①线程私有:程序计数器、虚拟机栈、本地方法栈。

②线程共享:堆、方法区。

本文接下来将针对以上几个区域进行讲解。

3、程序计数器

---概括---:

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作用来记录当前线程执行的字节码的行号指示器。通俗点来说就是记录了当前线程执行的代码,字节码解释器便是通过改变程序计数器的值来选取下一条所需执行的代码。

这个描述是不是和我们计组学的寄存器很像?对!程序计数器就是在硬件层面通过寄存器来实现的!

---作用---:

在多线程运行的情况下,由于一个处理器(对于多核CPU来说是一个内核)只会执行一个线程,因此大多数情况是该线程执行完应有的时间片后会被挂起,因此等轮到它又被执行时,应当知道上一次执行的到哪了才可进行恢复。

从上述描述我们可以知道,程序计数器是用来记录“当前线程”当前所执行的代码,因此很好理解为什么是私有的。如果是公有的话,那所有线程共用一个程序计数器,可是程序计数器只能记录一行,那这行到底是记录哪个线程的?那其它线程怎么办?

其主要规则如下:

如果线程正在执行的是一个Java方法,那么程序计数器记录的是正在执行的虚拟机字节码的地址。如果线程正在执行的是一个本地方法,那么程序计数器则为空。

---是否会发生OOM及其原因---:

程序计数器是《Java虚拟机规范》中唯一一个没有规定任何OOM(OutOfMemoryError)情况的内存区域。

其实这也很好理解,因为程序计数器只记录了当前执行的指令,所以不会发生OOM。

4、Java虚拟机栈

---概括---:

Java虚拟机栈描述的是Java方法执行时,线程的内存模型。每个方法被执行时,都会在当前线程的虚拟机栈创建一个栈帧(Stack Frame)用于存放局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法的开始与结束可以理解成其在当前线程的虚拟机栈中对应的栈帧进行入栈和出栈的过程。

例如,假设有两个线程执行以下代码:

public class Test {
    public static void main(String[] args) {
        methodA();
    }
    public static void methodA(){
        System.out.println("方法A");
        methodB();
    }
    public static void methodB(){
        System.out.println("方法B");
        methodC();
    }
    public static void methodC(){
        System.out.println("方法C");
    }
}

其中,线程一执行到了方法C,而线程二执行到方法A。那么对应的虚拟机栈模型图如下:

【JVM篇】Java内存区域与OOM_第2张图片

从上图我们也可以看出,如果线程共用一个虚拟机栈,那么每个栈帧属于谁?而且规定仅有最顶上的栈帧才可运行,那这不就乱套了?可见,虚拟机栈也是线程私有的。

---是否会发生OOM及其原因---:

在《Java虚拟机规范》中,规定了Java虚拟机栈这个数据区域有两种错误:

①StackOverflowError:

这个大家根据英文意思来翻译就是:“栈溢出错误”。

大家观察上图可以发现,笔者画的虚拟机栈是有限深度的,因此当我们栈帧过多,当超过栈的上限时,便可能会溢出,发生这个错误。

通常这个错误大家平时写代码如果基本功不扎实很容易遇到,比如写递归时,忘记写退出条件,那么当递归到一定深度时,便会抛出这个异常。

【JVM篇】Java内存区域与OOM_第3张图片

还有一种情况便是一个栈帧“过长”,放入时直接超过了虚拟机栈的上线,也会发生StackOverflowError。

【JVM篇】Java内存区域与OOM_第4张图片

②OutOfMemoryError:

在Java虚拟机栈中,如果栈的内存空间允许动态扩展,那么当栈无法申请到更大的内存空间时,会抛出“OutOfMemoryError”异常。

在《Java虚拟机规范》中明确允许了Java虚拟机可以自由选择是否支持Java虚拟机栈动态扩展。我们平时常用的HotSpot虚拟机是不支持动态扩展的,因此如果使用的是HotSpot虚拟机是不会发生“OutOfMemoryError”,仅可能发生“StackOverflowError”。

5、本地方法栈

---概括---:

本地方法栈的作用和虚拟机栈的作用很相似,发生StackOverflowError和OutOfMemoryError的原因也一样,唯一的区别就是本地方法栈存放是执行Native(本地)方法。《Java虚拟机栈规范》并没有强制规定该数据区域使用的编程语言、数据结构、是否需要实现。因此有的虚拟机(如HotSpot)将其和虚拟机栈融为一体了。

由于本地方法栈和虚拟机栈内容几乎一样,因此我们这里不做过多讲解,大家参考上文即可。 

6、Java堆

---概括---:

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

可见,Java堆肯定是线程共享的,唯一目的是用来存放对象实例和数组,也是虚拟机所管理的内存中最大的一块区域。同时这也是垃圾回收的主要区域,为什么说是主要区域,因为实际上“方法区”也是可以进行垃圾回收的,只不过条件比较苛刻而已。

---是否会发生OOM及其原因---:

稍微对这块区域有点了解的小伙伴肯定知道这块区域是会发生OOM的,因为我们上述说了,所有对象实例和数组都是分配在堆上,那么随着创建的对象实例和数组越来越多,肯定会发生OOM。例如我们执行下方代码:

public class Test {
    public static void main(String[] args) {
        Listlist=new ArrayList<>();
        while(true)list.add(new int[100]);
    }
}

注意,随着现在我们硬件的发展,Java堆空间肯定也越来越大,同时Java堆是可以选择可扩展和固定的,目前我们主流的虚拟机为了防止轻易发生OOM,都选择了实现可扩展的Java堆,因此我们想通过上述代码测试Java堆的OOM,应当通过设置虚拟机参数-Xmx和-Xms,将这两个参数设置相同,表示固定大小,然后设置的稍微小一点即可。

当然,如果Java堆内存不足以完成实例的内存分配且无法扩展时,也会抛出OutOfMemoryError。

7、方法区

---概括---:

方法区可以理解成一种抽象的逻辑区域,主要是用于存储已被虚拟机加载的类的相关信息。

方法区也是同Java堆一样,是线程共享的区域。《Java虚拟机规范》中将方法区描述成了堆的一个逻辑部分,但是也给它取了个别名“非堆”,目的也是为了将其和堆区分开来。既然提到方法区,那我们就不得不提到方法区的两个实现“永久代”和“元空间”。

---永久代---:

在JDK6及之前,方法区的实现称为“永久代”,它仍是属于JVM管理内存的一部分。其主要用来存储已被JVM加载的类型信息、常量、静态变量、JIT编译器编译后的代码缓存等数据。

为了验证在JDK6之前方法区的实现是“永久代”,这里我们使用JDK6来编译、运行下述代码:

public class Test {
    public static void main(String[] args) {
        Setstrings=new HashSet<>();
        //JDK6及之前字符串常量池仍属于永久代
        int i=0;
        while (true){
            //intern()的作用是
            // 如果字符串常量池中没有这个常量则将此常量放入字符串常量池
            // 有的话则从字符串常量池中取
            //strings的作用是防止内存不足时,将没用到的常量进行回收
            strings.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError:PermGen space

    at java.lang.String.intern(Native Method)

    at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPool.java: 20)

大家可以发现,这里抛出一个"OutOfMemoryError"异常,同时表明了出现异常的地方:“PermGen space” 翻译过来就是“永久代”。可见,在JDK6及之前方法区的实现是“永久代”,当我们不断创建新的字符串进运行时常量池,便会发生OOM异常。

---元空间---:

其实在JDK6开始呢,虚拟机开发人员就发现了如果永久代的一些弊端:比如会更容易发生OOM。因此在JDK6开始便逐渐有了“去永久代”的行为,直到JDK8,虚拟机开发者们便直接在“直接内存”来实现“方法区”,这个称为“元空间”。

其中,直接内存是指我们电脑的内存。同时,虚拟机开发者们将“静态变量”、“字符串常量池”等放入了“Java堆”中,而不是存在“方法区”中了。我们用上述相同代码,但是在JDK8环境下来验证这一点:

public class Test {
    public static void main(String[] args) {
        Setstrings=new HashSet<>();
        //JDK8及之后字符串常量池属于Java堆
        int i=0;
        while (true){
            //intern()的作用是
            // 如果常量池中没有这个常量则将此常量放入常量池
            // 有的话则从常量池中取
            //strings的作用是防止内存不足时,将没用到的常量进行回收
            strings.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError:Java heap space

可见,此时字符串常量池是在Java堆中了。

虽然方法区在JDK8及之后完全用“元空间”来代替,使用的是“直接内存”,但是我们电脑内存肯定也是有限的,会有用完的一天。所以用元空间实现的方法区,虽然用的是“直接内存”,但是随着我们程序越搞越大,动态生成的类越来越多(比如spring运行过程中就是会动态生成类),当没有足够的内存进行存储时,也会抛出“OutOfMemoryError”异常。

8、运行时常量池

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

注意,看到这里大家可能会将“运行时常量池”和“字符串常量池”混为一谈,实际上字符串常量池是运行时常量池的一部分。

上面我们提到 “运行时常量池”是方法区的一部分,因此肯定也会受到方法区的内存限制,因此当申请不到需要的内存时,也会抛出“OutOfMemoryError”异常。

9、直接内存

直接内存是指我们电脑中的内存,不属于虚拟机运行时数据的一部分,也不是《Java虚拟机规范》中定义的数据区域。

Java从JDK1.4开始提供了 NIO类,它可以通过Native函数库直接分配堆外内存,即本机内存。虽然直接内存不受Java堆大小限制,但是我们电脑内存也是有限的,因此如果我们过度申请,总会用完的,届时也会抛出“OutOfMemoryError”异常。

你可能感兴趣的:(JVM,jvm,java,开发语言)