Hola呀!阅读大概需要5分钟。目标是掌握虚拟机的内存是如何划分的,对于初学者和老开发来说是必须要掌握的知识点,也是面试中频繁问到的知识点。
前言
我作为一名使用Java的开发者,别提有多爽,在虚拟机自动内存管理机制下,不需要在new
的时候关心这个对象什么时候回收,不需要担心内存泄露leak
和溢出over flow
。虽然看起来很好,但是虚拟机这个渣男可不是什么都打包票的,一旦出现泄露和溢出的问题,如果不了解虚拟机是如何使用内存的,排查问题的时候就会手忙脚乱,因此决定开这一个章节,一起巩固一下基础。
虚拟机的内存划分
虚拟机在运行Java程序的时候,会把他管理的内训区域划分成若干个不同的数据区域,分别为以下几个区域:

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

这块区域也是线程私有的,因此创建和销毁是随着线程的。可以把它理解为Java方法执行时候的内存快照。
主要存储的是局部变量表
、操作数栈
、动态链接
、方法出口
等信息,我们都知道,方法在执行的时候会创建一个栈帧Stack Frame
,说白了就是一个基础数据结构,上面所说的信息就是存储在这个结构中。
平时有些人会把虚拟机内存分为Heap
和栈内存Stack
,其实是比较粗造的,真正在面试中如果这样说的话,会容易让人觉得不专业,不严谨。堆
后面的篇幅再聊,而栈
就是指的VM Stack
。这也说明了与对象内存分配关系最密切的就是其实就是这两块,这里主要讲一下局部变量表
。
局部变量表
放的是编译期就知道各种基本数据类型,什么boolean
,byte
,对象引用
(对象起始地址或者是一个句柄,这块后面细聊)等等类型。
局部变量表
所需的内存空间,已经在编译期就完成了分配。比如64位的long
和double
类型的会分配2个Slot
,其余的都是1个。因此,在进入一个Java方法时,需要创建的一个帧Stack Frame
的大小是固定的,在方法的执行期间这个帧的大小也不会变了。
那么在实际中,这块内存容易出现的异常就是StackOverFlowError
和OutOfMemoryError
。比如如果一个线程请求的栈深度超过了虚拟机所允许的深度,那么就栈深度溢出了。如果虚拟机栈在动态扩展的时候,无法向操作系统申请到足够的内存时,就OOM了。
Native Method Stack
这块空间与VM Stack
比较像,二者的区别就是VM Stack
是为虚拟机执行Java方法(编译过后的字节码)服务,而Native Method Stack
则是为虚拟机使用到的Native
方法服务的。
注意的是在目前主流的虚拟机中,比如HotSpot
(其实国内的大多数公司都是用的这款虚拟机,少部分可能是J9或者JRockit)中,就把Native Method Stack
和VM Stack
合在一起了。这块容易遇到的内存问题也是和VM Stack
一样,都是OOM
和SOF
异常。
Heap
这块也是虚拟机管理内存中最大的一块。上面所说的区域都是线程私有的区域,而这块是线程共享的区域(难道这块的内存空间都是线程共享的吗?既然这么问了,那就当然不是)。主要作用就是存储Java类的实例,几乎所有的对象实例都是存放在块区域中(注意是几乎,不是绝对)。这一点感兴趣的可以去翻看甲骨文的虚拟机规范文档,翻译过来就是:所有的对象实例以及数组都是要在堆上分配。但是随着JIT(Just In Time
)编译器和逃逸分析
(后续的文章会详解,这里只知道有这个东西就行了)技术的发展,这个规范也不是完全的被虚拟机厂商遵守,一些栈上分配
、标量替换
等优化技术的出现,所有的对象都分配在堆上也不是那么的绝对。如果在面试中,面试官说Java类实例都是分配在堆上的,那么你就可以纠正他,至少可以显得你很严谨。

堆
是垃圾收集器管理的主要区域,有些老开发喜欢叫GC堆,但是我不喜欢,因为不够严谨。因此从垃圾分代收集算法来看,也可以把Heap
分为新生代
和老年代
(是不是很熟悉?后续技术文章再聊),比如Eden
、From Survivor
、To 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;
......
可以看到常量池
中的ref
和ref1
都是字面符号,并不是真正的String ref
和String ref1
,也就是说并不是真正的String
实例。那真正的String
可以看到是在虚拟机去解释执行的时候,才会变成String ref
和String ref1
。
这是为什么呢?因为在jdk1.7和之后的版本的HotSpot
,常量池
中的字符串都只是字面量,当程序执行时,常量池
中的字符串字面量会被加载到运行时常量池
(注意这只是逻辑上的运行时常量池,因为jdk1.7和之后已经将字符串常量池放在堆
中了)。然后看StringTable
中有没有ref
字符串实例引用,如果没有则把实例的引用值存入StringTable
,真正的字符串实例放入堆
中。
因此,ref1
和ref
都是加载的常量池
中的同一个字面量,所以他两在StringTable
中的引用的都是堆
中的同一个实例,因此用==
判断的结果是true
。
注意: String.intern()
方法可以理解为从运行时常量池
中加载字符串实例,因此==
的结果也是true。
因此,Constant Pool Table
和Runtime Constant Pool
之间的关系可以理解为这样:
Constant Pool Table
只是存放编译器生成的各种字面量,这里面的数据只会在类加载
(后面的文章中会分析类加载到底做了什么事)之后进入方法区
的运行时常量池
中存放(String
是逻辑上的运行时常量池
,实际上是堆
,已经不在方法区
了)。
Direct Memory
熟悉NIO
的都明白,ByteBuffer
因为操作的是缓冲区的数据,因此不存在一个在Java堆
和Native堆
中进行拷贝的一个等待过程,所以非常迅速。感兴趣的可以里了解下DirectByteBuffer
,相信这块一定可以挖掘出很多虚拟机的底层信息。
下一个【虚拟机系列】中,我们将共同学习类的加载
,你可以了解到虚拟机是如何加载类的,以及是对象的初始化和对象在内存中的布局。你的关注是我持续创作动力的源泉,一起学习,一起走向大厂。