文章目录
0x00 前言
JVM在运行时会有一块专用的内存空间,称之为运行时数据区域。本文通过对这块内存区域的各个部分进行讲解,从而加深对于JVM的理解。
运行时数据区可分为两大类,一类是所有线程共享的区域,包含方法区和堆;另一类是每个线程私有的,包含程序计数器(Program Counter,PC)、虚拟机栈和本地方法(native method)栈。
需要注意的是,在具体的JVM实现—Hotspot中,只存在一个栈,并没有遵守JVM规范去区分虚拟机栈和本地方法栈。
0x10 程序计数器
JVM层面的PC与机器中的PC寄存器的作用是一致的,它是一块专门的内存区域,其主要目的是保存每个线程下一条即将要执行的字节码指令的地址,每当要执行指令时就从PC中获取指令地址,然后取指、译码、执行。通过PC可以实现程序控制流(if、for等等)以及用于线程上下文切换时的信息保存。
0x20 栈
栈的作用是以后进先出的顺序保存程序运行中产生的栈帧(frame)。而在JVM中,每个栈帧包含了三部分:局部变量表、操作数栈、帧数据。
0x21 局部变量表
对于成员方法,局部变量表以this指针、方法参数以及方法内变量的顺序将所有变量放在一起,对于静态方法,则不存在this指针。我们可以从字节码文件中找到LocalVariableTable,里面记录了每个局部变量的编号、作用范围(起始PC~起始PC+长度)和序号(局部变量表内的位置)。
局部变量表中每个位置称为一个槽(slot),通常来说每个slot占用4字节(不同JVM可能有不同的实现),所以int变量i占用一个slot,而long变量j会占用2个slot。
在编译阶段,编译器会对内存使用做出一些优化,比如说下图中,变量a、b分别占用了slot-3和slot-4,然而由于没有后续继续使用变量a、b,因此之后声明的变量i、j直接在局部变量表中覆盖了a、b,从而节省了内存。
0x22 操作数栈
每个栈帧中包含的操作数栈用于保存计算产生的中间变量。以下图为例,在执行i+1时,会将i的值和常量1放入操作数栈中,再执行相加操作,根据字节码指令,可以在编译期间就推断出操作数栈的最大长度。
0x23 帧数据
帧数据包含了运行时除了局部变量和临时变量外所需的其它信息。
(一)动态链接表。其作用是将字节码文件中的符号引用转换为运行时常量池中的内存地址。
(二)方法出口。保存了前一个栈帧中的下一条指令的地址,当本方法结束时,将该地址赋值给PC,继续执行前一个方法。
(三)异常表。记录了异常捕获的代码片段(即try关键字包含的代码块),当出现异常时,会执行catch代码块中的代码。
0x30 堆
JVM中的堆用于保存动态分配的对象,即new关键字产生的对象。这些对象可以被所有线程共享,所以堆内存中的对象可能会产生数据竞争等问题。
堆内存中有三个重要参数,(1)used表示当前已经使用的内存大小(2)total表示当前已经申请的内存大小(3)max表示能够使用的内存大小上限。当使用内存不断增加时,会向JVM申请更多的内存,即不断增大total。
total和max的值可以在启动java程序时被指定。这里有一个小trick,就是在启动时将total和max设置为一样的值,这样Java程序运行过程中就不需要频繁地申请内存,可以提升程序性能。
0x40 方法区
方法区是一个逻辑概念,并不是一块特定的内存空间。
方法区包含了类的元信息,运行时常量池和字符串常量池等相关内容。
0x41 方法区的信息存放在哪?
在JDK 1.6及其之前,方法区中的信息都被存放在一块称为永久代(PermGen Space)的内存空间,此时方法区的内存大小会受到JVM的限制。
在JDK 1.7中,永久代中的字符串常量池都移动到堆中。
在JDK 1.8及其之后,方法区不再由永久代实现,而是采用了本地内存,此时方法区的内存不受到JVM堆内存限制,方法区所在内存被称为元空间(Meta Space),元空间保存了类信息和运行时常量池,而字符串常量池仍然处于堆空间中。本地内存不属于JVM运行时数据区域。
在使用JDK 1.8时,为了避免方法区上分配过多内存,通常会在启动Java程序时指定元空间的最大内存使用量。
0x42 字符串常量池
字符串通常保存在两个地方:(1)堆(2)字符串常量池。通常通过new关键字或者变量相加的方式得到的字符串会保存在堆中,而程序中定义的字符串字面值会保存在常量池中(当然字符串常量池本身就属于堆,这里我们只是做了更进一步的细分)。
在下面两个代码片段中,左侧d=a+b需要在运行时才能确定d的值,因此分配在堆上,而右侧d=“1”+“2”在编译时就可以确定,因此分配在字符串常量池中。我们可以得到结论,如果一个字符串对象在编译时就可以确定其具体的值,那么它会被分配到字符串常量池中,否则它就会在运行时被分配到堆中。
在JDK 1.7及其之后,intern()方法会将堆中的字符串的引用保存到字符串常量池中。如下图代码所示,首先在堆上创建了字符串s1,然后调用intern()将其引用放到字符串常量池中,在使用==运算符比较时,由于都是指向了堆上的对象,所以返回true。
字符串“java”会在程序启动时被自动放到字符串常量池中,而s2是在堆中新建了字符串,调用s2.intern()时由于堆中已经存在了"java",所以不会将堆中对象的引用再次放到字符串常量池中,因此使用==运算符比较时,两者地址不同,返回false。
0x43 方法区的内存回收
方法区中的类信息可以被GC回收,但是条件十分苛刻,实际上也没有必要去回收这些类信息,除非能够完全确定该类在之后不会被使用。
回收条件如下:
- 该类所有对象以及子类对象都以及被回收
- 加载该类的类加载器被回收
- 该类的class对象没有被引用
因此在程序中在编写的类不会被回收,因为它们是由应用类加载器加载的,而应用类加载器不会被回收。
0x50 本地内存
在JDK 1.8及其之后,本地内存包括方法区和直接内存。
直接内存不属于JVM运行时区域,但它仍然是用户空间的内存,与运行时数据区域处于同一个虚拟地址下。其不受到JVM堆内存限制。直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存的优势之一是IO性能更高,如果不使用直接内存,则需要先将文件从磁盘读取到内核内存,然后再拷贝到JVM的堆外内存,最后拷贝到堆内。而使用直接内存,会在用户空间创建一个与OS内核共享的缓冲区,文件首先从磁盘读到该共享缓冲区内(堆外),而堆内只需要引用直接内存的地址,就可以进行读写,这样做减少了数据拷贝的次数,提高IO性能。