一、前言
之前过年在家,空闲时间比较多,想要了解一下JVM
相关的内容,于是买了《深入理解Java虚拟机》这本书,过了一遍其中的基础知识。时隔多日,都忘得差不多了。为了重新捡起来,我决定复习一遍,并编写相关的系类博文加深印象,这是第一篇,来讲一讲JVM
最基础的内容——JVM
的内存分区。
二、正文
2.1 Java内存分布
Java
的内存主要分为五个部分:
- 程序计数器;
Java
虚拟机栈;- 本地方法栈;
- 堆内存;
- 方法区;
具体结构如下图所示:
2.2 程序计数器
首先看第一部分——程序计数器。上过计算机组成原理的可能都听过这个名词,也大概知道它的作用是指向下一条需要执行的计算机指令,而在JVM
中,程序计数器的作用也是类似。
程序计数器是JVM
中较小的一块内存空间,它的作用是记录下一条需要执行的字节码指令的地址(Java
代码编译后的字节码在未经过实时编译器编译前,其执行方式是通过“字节码解释器”进行解释执行)。在JVM
的概念模型里,就是通过改变这个计数器的值来辅助程序的执行。选择,循环,方法调用,异常处理等功能都需要通过程序计数器的辅助来完成。
JVM
的多线程和CPU
的多线程处理类似,都是使用时间片轮转的方式来执行各个线程,即每个线程轮流得到一个时间片,执行完这个时间片后,等待下一次调度,而每次只有一个线程在执行。正因为如此,线程在调度后,为了能够恢复到这个线程上一次执行的位置,就需要程序计数器的帮助,让程序计数器来告知下一条需要执行的指令的地址,对于每一个线程都是如此。所以,JVM
中的程序计数器是线程私有的,即每一个线程都有一个独立的程序计数器,辅助当前线程的运行,各个线程之间互不影响。
如果当前JVM
执行的是一个Java
方法,则程序计数器的值就是正在执行的字节码指令的地址;若当前执行的是一个本地方法(如Object
的hashcode
方法),则程序计数器的值为空(Undefined
),因为本地方法并不会被加载到JVM
的内存空间中。
2.3 Java虚拟机栈
虚拟机栈和程序计数器一样,也是线程私有的。Java虚拟栈是Java方法执行时需要依赖的内存空间,这个栈中的元素被称为栈帧。栈帧可以理解为一个对象,它记录了一个方法的局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法对应一个栈帧,而每一个方法从执行到返回,就对应着它所属的栈帧从入栈到出栈的过程,即当一个Java
方法被调用,JVM
就会创建这个方法的栈帧,并将它加入的虚拟机栈中,执行结束后才会出栈。对于JVM
来说,正在执行的是虚拟机栈栈顶的栈帧所对应的方法,这个栈帧被称为当前栈帧,栈帧对应的方法被称为当前方法。
下面我来简单介绍一下栈帧最重要的两个部分:
- 局部变量表:局部变量表是用来存放方法的参数以及在方法中定义的局部变量,它的大小在编译期间就被确定,并写入到
class
文件中。若一个变量是基本数据类型,则在局部变量表中存放的就是这个变量的具体值;若是引用类型,存放的则是这个变量所引用对象的地址。局部变量表的最小单位是变量槽(Slot
),对于double
和long
类型的变量,在局部变量中需要占用两个单位,其他类型只需要占用一个单位。 - 操作数栈:从名字可以看出,这是一个栈
Stack
,它存放的是:方法运行时需要用到的操作数。和局部变量表一样,它的最大深度也是在编译期间就被计算好,并被写入class
文件中的。在一个方法刚准备运行前,它是空的,而在运行的过程中,不断的有操作数被入栈和出栈(比如假设方法中需要计算num = 1 + 2
,则在这条语句执行时,1
和2
被入栈,然后被取出,计算出3
存入栈中,再将3
取出赋值给num
),程序的计算都需要依靠这个栈进行。操作数栈可以存放任意类型的数据(基本数据类型和引用类型,引用类型存放的是地址),而double
和long
类型将占用两个单位,其余类型占用一个单位。
在虚拟机规范中规定,若线程的虚拟机栈的深度超过了JVM
允许的最大深度,将抛出StackOverflowError
(比如调用没有出口的递归方法);若JVM
实现的虚拟机栈可以动态扩展,则如果在扩展时没有申请到足够的内存,将抛出OutOfMemoryError
异常。
2.4 本地方法栈
本地方法栈的作用和虚拟机栈相同,不同的是,虚拟机栈是辅助Java方法的运行,而本地方法栈是辅助本地方法的运行。在虚拟机规范中,并没有规定实现本地方法栈使用的语言,数据结构以及使用方式,所以JVM
可以自由地实现它。与虚拟机栈一样,这一块内存区域也可能抛出StackOverflowError
和OutOfMemoryError
异常,同时也是线程私有。
2.5 堆内存
这一块内存是JVM
中最大,可能也是最重要的一块内存,而堆存在的唯一目的就是存放实例对象,几乎所有的对象都是在堆中分配内存。和之前所说的几块内存不同,堆内存是所有的线程共享的,它在虚拟机启动时被创建。
堆内存是垃圾回收的主要区域,从内存回收的角度看,现在的垃圾回收机制一般都是分代收集,所以堆内存可以被分为新生代和老年代。新生代用来存放生命周期短的对象,这一块区域将频繁进行垃圾回收;老年代用来存放生命周期长的对象(或者在新生代内存不足时代替新生代存放对象),所以垃圾回收的频率较低,甚至不进行垃圾回收。
根据Java
虚拟机规范规定,堆可以是不连续的内存空间,只需要逻辑上连续即可。在实现虚拟机时,可以将堆实现为固定大小,也可以实现为可扩展的,当前的虚拟机一般都是采用可扩展的方式(可以通过配置参数来改变堆的大小)。如果堆中的内存被占满,实例对象无法存入,且堆无法再进行扩展时,将抛出OutOfMemaryError
异常。
2.6 方法区
和堆一样,方法区也是线程共享的,它的作用是存放类信息,常量,静态常量等内容。Java
虚拟机规范对这一块区域的限制非常宽松,方法区可以选择实现为固定的或者可扩展的,也可以选择是否实现垃圾回收。方法区中的数据,很少会发生改变,所以虚拟机规范并不强制要求在此区域实现垃圾回收。这块区域进行垃圾回收的主要目的就是卸载已经不需要使用的类,但是类卸载的条件是非常苛刻的。需要注意的一点是,在Java8之前,方法区被称为永久代,使用的是JVM的内存空间,但是从Java8开始,方法区使用的是本地内存,被称为元空间。此时能够加载多少类,就取决于本地内存的大小了。
在方法区中有一块内存区域,叫做运行时常量池,它的作用就是存储编译期字面量以及符号引用。在一个类中如果定义了常量,或使用了字面量(如直接使用字符串为变量赋值,String str = ”aaa“
),则在编译之后,会被放入到class
文件的常量池中(class
文件有一个常量池部分),同时,类编译后的符号引用也会被放入常量池。当程序运行时,class
中的内容被加载到方法区中,而其中常量池的内容会被放入运行时常量池里面,被所有的类共享。运行时常量池是一个动态的内存区域,可以在程序运行时,动态的为其中添加数据,比如String
类的intern()
方法。因为运行时常量池是方法区的一部分,所以它的大小受到方法区的限制,当无法在其中申请空间时,将抛出OutOfMemaryError
异常。需要注意的是,从Java7开始,常量池从方法区移动到了堆空间。
2.7 我们平常理解的Java内存模型
我们在学习Java
的过程中,经常会看到这样的内容:Java
的内存分为两部分,即栈内存和堆内存,栈内存中存放基本数据类型以及对象引用,而堆内存中存放对象。
以上是对Java
内存模型的片面描述,只关注了程序员最重视的部分。在上面的描述中,栈内存指的就是本地方法栈中,局部变量表的部分;而堆内存就是Java
中的堆了。
三、总结
以上内容对Java
的内存模型进行了一个简单的描述,讨论了每一个部分的基本作用,了解了上面的内容后,再遇到Java
内存方面的问题,也能更好地找出错误的所在。
四、参考
- 《深入理解Java虚拟机》