Java虚拟机内存模型

作者:xcbeyond
疯狂源自梦想,技术成就辉煌!微信公众号:《程序猿技术大咖》号主,专注后端开发多年,拥有丰富的研发经验,乐于技术输出、分享,现阶段从事微服务架构项目的研发工作,涉及架构设计、技术选型、业务研发等工作。对于Java、微服务、数据库、Docker有深入了解,并有大量的调优经验。

一、前言
Java虚拟机,简称JVM(Java Virtual Machine),是Java语言中最为核心的一个东西,Java程序运行离不开它,因为它的存在,使得Java拥有“一次编译,多次运行”的特点。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。

JVM是Java中最难以理解、而且非常重要的知识点,也常常用来衡量一个人Java基本功是否牢靠,更是在面试中被问及最多、最频繁的知识点之一。本文将从Java虚拟机内存模型开始入手,一步步来了解它。

Java虚拟机内存模型是Java程序运行的基础,为了使Java应用程序正常运行,JVM将其内存数据分为程序计数器、虚拟机栈、本地方法栈、堆和方法区,如下图所示:

(在JDK1.8开始,已经去掉了方法区的概念,用元空间(Metaspace)进行了代替.)

程序计数器用于存放下一条运行的指令;虚拟机栈和本地方法栈用于存放函数方法调用堆栈信息;Java堆用于存放Java程序运行时所需的对象等数据;方法区用于存放程序的元数据信息。

其中,一部分是线程私有的,而另一部分却是线程共享的。

线程私有:程序计数器、虚拟机栈、本地方法栈
线程共享:堆、方法区
二、程序计数器
程序计数器是一块很小的内存空间,用于存放下一条运行的指令,它是线程私有的,可以认作为当前线程的行号指示器。

由于Java是支持线程的语言,当线程数量超过CPU数量时,线程之间根据时间片轮询抢夺CPU资源。对于单核CPU而言,每一时刻,只能有一个线程在运行,而其他线程必须被切换出去。为此,每一个线程都必须用一个独立的程序计数器,来记录下一条要运行的指令。各个线程之间的计数器互不影响,独立工作。

如果当线程正在执行一个Java方法,则程序计数器记录正在执行的Java字节码地址,如果当前线程正在执行一个Native方法,则程序计数器为空。

三、虚拟机栈(栈)
栈保存的是方法的局部变量、部分结果,并参与方法的调用和返回,即:栈帧数据。

1.栈帧
每个方法被执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接方法、返回地址等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈(方法调用)到出栈(方法返回)的过程。

栈帧结构如下图所示:
 

如果方法调用时,方法的参数和局部变量相对较多,那么栈帧中局部变量表就会比较大,栈帧就很很大,因此,单个方法调用所需的栈空间大小也会很大。(在程序开发时,尽量避免这种情况,尤其是递归方法中要避免递归调用的深度)

以下代码片段中,通过逐步设置递归方法调用的深度,将会抛出栈溢出异常(StackOverflowError)。






public class StackTest {
    // 递归次数
    private final int count = 100000;

    /**
     * 递归方法
     * @param num
     */
    public void recursionMethod(int num) {
        num++;
        if (num < count) {
            recursionMethod(num);
        }
    }

    @Test
    public void stackDepthTest() {
        recursionMethod(0);
    }
}

2.栈溢出、内存溢出
Java虚拟机规范中允许栈的大小是动态的或者是固定的,定义了两种异常与栈空间相关:StackOverflowError和OutOfMemoryError。如果线程在计算过程中,请求的栈深度大于最大可用的栈深度,则会抛出StackOverflowError异常,如果栈能够动态扩展,而在扩展过程中,没有足够的内存空间来支持栈的扩展,则会抛出OutOfMemoryError异常。

其中,可以使用JVM参数-Xss来调整设置栈的大小,从而决定了方法调用可以达到的深度。

针对上述代码StackTest中,在递归次数为100000时,将-Xss参数调整为-Xss512M后,未抛出异常。
 

3.jclasslib工具
篇外话,但觉得还是有必要提出来,在研究JVM时,总是会去研究一些字节码指令、Class类文件结构、大小等数据,而jclasslib工具恰恰满足这些,有了它更有助于我们对Java、JVM有更深入的了解。

大家可根据自己的喜好,选择安装,有单机软件版、IDE插件可供使用,在此,我选择的是在idea中安装了jclasslib插件,方便使用。此工具将伴随着你在JVM的世界里翱翔,一探JVM究竟。

以上述代码为例进行说明,如下图所示,在idea中通过jclasslib插件查看StackTest.class文件,展开方法recursionMethod后,查看Code属性的Misc页签中,当前方法的最大局部变量表的容量为2。因为在该方法中只有一个int类型的参数,所以共占2字。
 

关于jclasslib工具的更多使用技巧,在不断的使用中去摸索吧。

四、本地方法栈
本地方法栈和虚拟机栈的功能很相似,虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法并不是用Java实现的,而是使用C实现的。本地方法栈保存的是native方法的信息,当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单地动态链接并直接调用native方法。

在Hot Spot虚拟机中,是不区分本地方法栈和虚拟机栈的。因此,本地方法栈一样也会抛出异常StackOverflowError和OutOfMemoryError。

五、堆
堆可以说是Java运行时内存中最为重要的部分,几乎所有的对象和数组都是在堆中分配空间的。堆分为新生代和老年代两部分,新生代用于存放刚刚产生的对象和年轻的对象,如果对象一直没有被收回,生存得足够长,老年对象就会被移入老年代。

新生代又可以进一步细分为eden、survivor space0(s0或者from space)和survivor space1(s1或者to space)。eden称之为伊甸园,即对象的出生地,大部分对象刚刚创建时,通常会存放在这里。s0和s1为survivor空间,直译为幸存者,就是指存放其中的对象至少经历了一次垃圾回收,并得以幸存。如果在幸存区的对象到了指定年龄仍未被回收,则有机会进入老年代。

换言之,堆空间简单分为新生代和老年代,新生代用于存放刚产生的新对象,老年代则存放年长的对象(存放时间较长,经过垃圾回收次数较多的对象)。

堆空间结构如下图所示:
 

六、方法区
方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。主要保存的信息是类的元数据,即类的类型信息、常量池、域信息、方法信息,如static修饰的变量加载类的时候就被加载到方法区中。

类型信息包括类的完整名称、父类的完整名称、类型修饰符(public/protected/private)和类型的直接接口类表;常量池包括这个类方法、域等信息所引用的常量信息;域信息包括域名称、域类型和域修饰符;方法信息包括方法名称、返回类型、方法参数、方法修饰符、方法字节码、操作数栈和方法帧栈的局部变量区大小以及异常表。总之,方法区内保存的信息,大部分都来自于class文件。

在Hot Spot虚拟机中,方法区也成为永久区,是一块独立于Java堆的内存空间。虽然叫做永久区,但是永久区中的对象,同样也是可以被GC回收的。只是对于GC的表现和Java堆空间略不相同。对永久区GC的回收,通常主要从两个方面分析:一是GC对永久区常量池的回收,二是永久区对类元数据的回收。

方法区也成为永久区,主要存放常量和类的定义信息。

(在JDK1.8的HotSpot虚拟机中,已经去掉了方法区的概念,用 Metaspace代替,并且将其移到了本地内存来规划了。)