【Java虚拟机一】虚拟机内存划分

713 阅读9分钟

Hola呀!阅读大概需要5分钟。目标是掌握虚拟机的内存是如何划分的,对于初学者和老开发来说是必须要掌握的知识点,也是面试中频繁问到的知识点。

前言

我作为一名使用Java的开发者,别提有多爽,在虚拟机自动内存管理机制下,不需要在new的时候关心这个对象什么时候回收,不需要担心内存泄露leak和溢出over flow。虽然看起来很好,但是虚拟机这个渣男可不是什么都打包票的,一旦出现泄露和溢出的问题,如果不了解虚拟机是如何使用内存的,排查问题的时候就会手忙脚乱,因此决定开这一个章节,一起巩固一下基础。

虚拟机的内存划分

虚拟机在运行Java程序的时候,会把他管理的内训区域划分成若干个不同的数据区域,分别为以下几个区域:

注:方法区和堆是所有线程共享的内存区域,而虚拟机栈、本地方法区和程序计数器都是线程隔离的区域

Program Counter Register

这块的内存区域比较小,可以理解为当前线程所执行的字节码的指示器。字节码解释器在工作的时候,通过改变这个计数器的值来选取下一条需要执行的字节码指令,比如分支、循环、跳转、异常处理,包括线程的恢复等都需要借助这块内存区域。想必都知道,虚拟机的多线程是通过线程交替切换并分配处理器的执行时间,因此,在任意一个时刻,一个处理器内核都只会执行一条线程中的指令。所以为了线程在交替切换中能恢复到正确的字节码指令位置(Java方法是对应的字节码的地址,Native方法对应的计数器值为空),因此这块区域是线程私有的。 这块内存是虚拟机规范中唯一一个没有规定任何OOM的区域。所以,敲黑板,实践中排查OOM问题时,不要把锅甩给这块内存区域。

VM Stack

这块区域也是线程私有的,因此创建和销毁是随着线程的。可以把它理解为Java方法执行时候的内存快照。 主要存储的是局部变量表操作数栈动态链接方法出口等信息,我们都知道,方法在执行的时候会创建一个栈帧Stack Frame,说白了就是一个基础数据结构,上面所说的信息就是存储在这个结构中。 平时有些人会把虚拟机内存分为Heap和栈内存Stack,其实是比较粗造的,真正在面试中如果这样说的话,会容易让人觉得不专业,不严谨。后面的篇幅再聊,而就是指的VM Stack。这也说明了与对象内存分配关系最密切的就是其实就是这两块,这里主要讲一下局部变量表局部变量表放的是编译期就知道各种基本数据类型,什么booleanbyte对象引用(对象起始地址或者是一个句柄,这块后面细聊)等等类型。 局部变量表所需的内存空间,已经在编译期就完成了分配。比如64位的longdouble类型的会分配2个Slot,其余的都是1个。因此,在进入一个Java方法时,需要创建的一个帧Stack Frame的大小是固定的,在方法的执行期间这个帧的大小也不会变了。 那么在实际中,这块内存容易出现的异常就是StackOverFlowErrorOutOfMemoryError。比如如果一个线程请求的栈深度超过了虚拟机所允许的深度,那么就栈深度溢出了。如果虚拟机栈在动态扩展的时候,无法向操作系统申请到足够的内存时,就OOM了。

Native Method Stack

这块空间与VM Stack比较像,二者的区别就是VM Stack是为虚拟机执行Java方法(编译过后的字节码)服务,而Native Method Stack则是为虚拟机使用到的Native方法服务的。 注意的是在目前主流的虚拟机中,比如HotSpot(其实国内的大多数公司都是用的这款虚拟机,少部分可能是J9或者JRockit)中,就把Native Method StackVM Stack合在一起了。这块容易遇到的内存问题也是和VM Stack一样,都是OOMSOF异常。

Heap

这块也是虚拟机管理内存中最大的一块。上面所说的区域都是线程私有的区域,而这块是线程共享的区域(难道这块的内存空间都是线程共享的吗?既然这么问了,那就当然不是)。主要作用就是存储Java类的实例,几乎所有的对象实例都是存放在块区域中(注意是几乎,不是绝对)。这一点感兴趣的可以去翻看甲骨文的虚拟机规范文档,翻译过来就是:所有的对象实例以及数组都是要在堆上分配。但是随着JIT(Just In Time)编译器和逃逸分析(后续的文章会详解,这里只知道有这个东西就行了)技术的发展,这个规范也不是完全的被虚拟机厂商遵守,一些栈上分配标量替换等优化技术的出现,所有的对象都分配在堆上也不是那么的绝对。如果在面试中,面试官说Java类实例都是分配在堆上的,那么你就可以纠正他,至少可以显得你很严谨。

是垃圾收集器管理的主要区域,有些老开发喜欢叫GC堆,但是我不喜欢,因为不够严谨。因此从垃圾分代收集算法来看,也可以把Heap分为新生代老年代(是不是很熟悉?后续技术文章再聊),比如EdenFrom SurvivorTo Survivor等。 中当然也有线程私有的内存(Thread Local Allocation Buffer,这块后续的虚拟机系列文章中会详细讲解),无论中的线程共享或者私有的区域,都是只存放的类实例和数组,这么划分是为了更好地进行GC。 实际中,这块的内存异常就是大名鼎鼎的OOM了,如果虚拟机无法对堆进行扩展的时候那么这个异常就抛出来了。

Method Area

一样,线程共享的区域,记录的是被虚拟机加载的类信息常量静态变量,以及JIT编译器编译过后的代码等。 一些老开发又喜欢把这块区域叫做Permanent Generation,其实这样叫是错误的。因为方法区永久代根本就不对等。仅因为HotSpot虚拟机使用永久代的回收算法对方法区进行GC而已,而且官方也准备放弃用永久代来实现方法区了(感兴趣的可以自行看下Native Memory)。 虚拟机也会对方法区进行GC,比如对类的卸载,常量池的回收(下文会提及)。类卸载是不是感觉很陌生?这块菲菲在后续的技术文章中会分享。所以都看到这了,不放点个关注?嗐,扯远了。 同样,方法区也会抛出OOM

Runtime Constant Pool

译过来就是运行时常量池,这块其实是属于方法区的,为啥要把这块单独从方法区中拎出来讲呢,因为这块是方法区中单独存储编译器生成的各种引用。 机智的你是不是想到了运行时常量池常量池的区别是什么?

public static void main(String[] args) {
        String ref = "ref";
        String ref1 = "ref";
        String ref2 = new String("ref");
        String ref3 = "ref1";
        String ref4 = ref3.intern();

        System.out.println("ref == ref1 = " + (ref == ref1));
        System.out.println("(ref == ref2 = " + (ref == ref2));
        System.out.println("(ref3 == ref4) = " + (ref3 == ref4));
}
Constant pool:
   #1 = Methodref          #18.#32        // java/lang/Object."<init>":()V
   #2 = String             #33            // ref
   #3 = Class              #34            // java/lang/String
   #4 = Methodref          #3.#35         // java/lang/String."<init>":(Ljava/lang/String;)V
   #5 = String             #36            // ref1
   #6 = Methodref          #3.#37         // java/lang/String.intern:()Ljava/lang/String;   ..........
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=6, args_size=1
         0: ldc           #2                  // String ref
         2: astore_1
         3: ldc           #2                  // String ref
         5: astore_2
         6: new           #3                  // class java/lang/String
         9: dup
        10: ldc           #2                  // String ref
        12: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        15: astore_3
        16: ldc           #5                  // String ref1
        18: astore        4
        20: aload         4
        22: invokevirtual #6                  // Method java/lang/String.intern:()Ljava/lang/String;
        ......

可以看到常量池中的refref1都是字面符号,并不是真正的String refString ref1,也就是说并不是真正的String实例。那真正的String可以看到是在虚拟机去解释执行的时候,才会变成String refString ref1。 这是为什么呢?因为在jdk1.7和之后的版本的HotSpot常量池中的字符串都只是字面量,当程序执行时,常量池中的字符串字面量会被加载到运行时常量池(注意这只是逻辑上的运行时常量池,因为jdk1.7和之后已经将字符串常量池放在中了)。然后看StringTable中有没有ref字符串实例引用,如果没有则把实例的引用值存入StringTable,真正的字符串实例放入中。 因此,ref1ref都是加载的常量池中的同一个字面量,所以他两在StringTable中的引用的都是中的同一个实例,因此用==判断的结果是true。 注意: String.intern()方法可以理解为从运行时常量池中加载字符串实例,因此==的结果也是true。 因此,Constant Pool TableRuntime Constant Pool之间的关系可以理解为这样: Constant Pool Table只是存放编译器生成的各种字面量,这里面的数据只会在类加载(后面的文章中会分析类加载到底做了什么事)之后进入方法区运行时常量池中存放(String是逻辑上的运行时常量池,实际上是,已经不在方法区了)

Direct Memory

熟悉NIO的都明白,ByteBuffer因为操作的是缓冲区的数据,因此不存在一个在Java堆Native堆中进行拷贝的一个等待过程,所以非常迅速。感兴趣的可以里了解下DirectByteBuffer,相信这块一定可以挖掘出很多虚拟机的底层信息。


下一个【虚拟机系列】中,我们将共同学习类的加载,你可以了解到虚拟机是如何加载类的,以及是对象的初始化和对象在内存中的布局。你的关注是我持续创作动力的源泉,一起学习,一起走向大厂。