运行时数据区域
Java 虚拟机在执行 Java 程序过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。
程序计数器
程序计数器(Program Counter Register
)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native
方法,这个计数器值则为空(Undefined
)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
Java虚拟机栈
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks
)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧(Stack Frame
)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
经常有人把Java内存区分为堆内存(Heap
)和栈内存(Stack
),这种划分方式的流行只能说明大多数程序员最关注的、域对象内存分配关系最密切的内存区是这两块。Java 内存区域的划分实际上远比这复杂。
其中所指的“栈”就是虚拟机栈,或者说是虚拟机栈中的局部变量表。
局部变量表
局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型。
基本数据类型:boolean、byte、char、short、int、float、long、double
对象引用:reference
类型,它不等同于对象本身,可能是个对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他于此对象相关的位置
returnAddress
类型:指向了一条字节码指令的地址
其中64位长度的 long
和 double
类型的数据会占用2个局部变量空间(Slot
),其余的数据类型只占用1个。Slot
是栈帧中的局部变量表的最小单位。
局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
虚拟机栈规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常。 - 如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,将抛出
OutOfMemoryError
(OOM
)异常。
操作数栈
Java 虚拟机的解释执行引擎被称为“ 基于栈的执行引擎 ”,其中所指的栈就是指-操作数栈。 操作数栈也常被称为操作栈,它是一个后入先出栈。
和局部变量表一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作 压栈和出栈 来访问的。
比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机在操作数栈中存储数据的方式和在局部变量表中是一样的。
虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
举例来说,在JVM中执行 a = b + c
的字节码执行过程中操作数栈以及局部变量表的变化如下图所示。
局部变量表中存储着 a、b、c
三个局部变量,首先将 b
和 c
分别压入栈中
将栈顶的两个数出栈执行求和操作,并将结果再次压入栈顶中,之后将栈顶的数出栈赋值给 a
看一个比较经典的例子
1 | public class IncrementTest { |
答案:
1 | i = 4 |
代码分析:
代码 i = i++
,自增操作是在局部变量中的,而不是在操作数栈中。
- 把局部变量表中的
i
的值 1 压入操作数栈中 - 把局部变量表中的
i
变量自增 1,此时i
的值为 2 - 把操作数栈中的值
1
赋值给局部变量表中的i
变量,此时i
的值又变为了 1
代码 int j = i++
,赋值操作发生在自增前。
- 把局部变量表中的
i
的值 1 压入操作数栈中 - 把操作数栈中的值
1
赋值给局部变量表中的j
变量,此时j
的值为 1 - 把局部变量表中的
i
变量自增 1,此时i
的值为 2
代码 int k = i + ++i * i++
- 把局部变量表中的
i
的值 2 压入操作数栈中 - 把局部变量表中的
i
变量自增 1,此时i
的值为 3 - 把局部变量表中的
i
的值 3 压入操作数栈中(++i
),此时i
的值为 3 - 再把局部变量表中的
i
的值 3 压入操作数栈中(i++
),此时i
的值为 3
- 把局部变量表中的
i
变量自增 1,此时i
的值为 4 - 把操作数栈中前两个弹出求乘积(3 * 3 = 9),将结果再次压入操作数栈中
- 把操作数栈中前两个弹出求和(9 + 2 = 11),将结果再次压入操作数栈中
- 将操作数栈中的值 11 赋值给局部变量表中的
k
变量,此时k
的值为 11
总结:
- 赋值 =,最后计算
- = 右边的从左到右加载值依次压入操作数栈
- 根据运算符的优先级判断先算哪个
- 自增和自减操作都是直接修改变量的值,不经过操作数栈
- 最后赋值之前,临时结果都是保存在操作数栈中的
值得提醒的是,i++
和++i
都不是原子操作,因为它并不会作为一个不可分割的操作来执行,实际上它包含三个独立的操作:
- 读取
i
的值 - 将值加
1
- 然后将计算结果写入
i
这是一个读取-修改-写入的操作序列,并且其结果状态依赖于之前的状态。
即使使用 volatile
修饰,保证了多个线程多i
的可见性,每次从局部变量表读取的都是最新的值,也不是线程安全的。
如果假设 i=9,在某些情况下,多个线程读到的值都为 9,接着执行递增操作,并且都将i
设置成 10 ,显然不是线程安全的。
动态链接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
Class 文件中存放了大量的符号引用,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析,如静态方法、私有方法等等,另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
栈帧中保存了一个引用,指向该方法在运行时常量池中的位置,通过运行时常量池的符号引用(指向堆),完成将符号引用转化为直接引用。
方法返回地址
方法执行时有两种退出情况:
- 正常退出,即正常执行到任何方法的返回字节码指令,如
return
等 - 异常退出,即某些指令导致了 Java 虚拟机抛出异常并且没有处理
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- PC计数器指向方法调用后的下一条指令。
当方法执行正常退出时,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器、跳过刚才执行的方法调用指令等。调用者的代码在被调用方法的返回值压入调用者栈帧的操作数栈后,会继续正常执行。
本地方法栈
本地方法栈(Native Method Stack
)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native
方法服务。Sun HotSpot
虚拟机直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError
和 OutOfMemoryError
异常。
Java堆
对于大多数应用来说,Java 堆(Java Heap
)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做 “ GC
堆 ”(Garbage Collected Heap
)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden
空间、From Survivor
空间、To Survivor
空间等。
从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB
)。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储的都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx
和 -Xms
控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError
异常。
TLAB
TLAB(Thread Local Allocation Buffer)是在Hotspot1.6引入的新技术,目的是提升在堆上创建对象的性能。
如果一个对象被创建到堆上时,需要在堆上申请指定大小的内存供新创建的对象使用,在这个过程中,堆会通过加锁或指针碰撞的方式防止同一块被重复申请,在JVM中,内存分配是一个非常频繁的动作,而给堆加锁或者校验碰撞指针的方式必定会影响内存创建效率,TLAB的出现就是为了优化这个问题。
1 | TLAB是线程的一块私有内存: |
1 | # 开启TLAB |
每一个TLAB空间大小都是固定的,默认的是Eden区大小的1%,既然大小是固定的,那么肯定会出现空间浪费的情况,比如TLAB大小是100kb,已经被使用了90kb,此时有一个12kb的对象来申请空间,但是TLAB的剩余空间已经不足以分配给这个对象了,此时怎么办?
是新申请一个TLAB,还是直接分配到Eden区?在设计TLAB的时候就已经考虑到这种情况了,使用变量refill_waste_limit来控制一个TLAB允许被浪费的空间大小。
方法区
方法区(Method Area
)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然
Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap
(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError
异常。
HotSpot
虚拟机
它是Sun JDK
和OpenJDK
中所带的虚拟机,也是目前使用范围最广的 Java 虚拟机。
在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,Oracle同时拥有了两款优秀的Java虚拟机:JRockit VM
和HotSpot VM
。
永久代
对于习惯在HotSpot
虚拟机上开发、部署程序的开发者来说,很多人更愿意把方法区称为“永久代”(Permanent Generation
),本质上两者并不等价,仅仅是因为HotSpot
虚拟机的设计团队把 GC
分代收集扩展至方法区,或者说,使用永久代来实现方法区而已。
目的是为了方法区也可以用堆内存的 GC
垃圾回收器,而不用重新针对方法区做 GC
操作,所以称永久代是方法区的一个存储实现。
方法区只是 JVM
的一种规范,不同的虚拟机实现的原理不一样,只有HotSpot
虚拟机才有永久代的概念。
常量池
class常量池
我们写的每一个 Java 类被编译后,就会形成一份class
文件。class
文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table
),用于存放编译器生成的各种字面量(Literal
)和符号引用(Symbolic References
)。
每个class文件都有一个class常量池。
字面量包括:
- 文本字符串
- 八大基本类型的值
- 被申明为
final
的常量
符号引用包括:
- 类和方法的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
运行时常量池
运行时常量池(Runtime Constant Pool
)是方法区的一部分。class文件常量池将在类加载后进入方法区的运行时常量池中存放。
一般来说,除了保存 Class
文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class
文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class
文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String
类的 intern()
方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError
异常。
在HotSpot
虚拟机里实现的字符串常量池(string pool
)功能的是一个StringTable
类,它是一个Hash
表,这个StringTable
在每个HotSpot
虚拟机的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable
上。
JDK版本变化
JDK1.6及以前的版本,字符串常量池是存放在永久代中。
在JDK1.7的版本中,字符串常量池从永久代移出到正常的Java 堆(Java Heap)中,原因是因为永久代空间太小,容易造成OOM。
在JDK1.8的版本中,Hotspot虚拟机废除了永久代,开始使用元空间(Metaspace)实现方法区,字符串常量池依旧保留在堆内存中,其他内容移至元空间,元空间直接在本地内存分配,而不需要占用堆内存,所以不会造成OOM现象。
值得注意的是,方法区只是Jvm的一种规范,Hotspot通过废除永久代,使用元空间实现方法区,并不存在废除方法区、方法区被元空间代替这种说法。
为什么要使用元空间取代永久代的实现?
- 字符串存在永久代中,容易出现性能问题和内存溢出
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
- 永久代会为
GC
带来不必要的复杂度,并且回收效率偏低 - 将
HotSpot
与JRockit
合二为一
1 | public static void main(String[] args) { |
使用JDK1.7 或者 1.8 能够看到,往字符串常量池中无限增加,最终 OOM
的位置是在Java 堆(Java heap
)中。
String.intern()
用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后返回引用。
看一道比较常见的面试题,在不考虑 GC
的情况下,下面的代码创建了多少个 String
对象,输出结果是什么?
1 | String str1 = new String("he") + new String("llo"); |
答案:
- 在 JDK 1.6 下输出是
false
,创建了 6 个对象 - 在 JDK 1.7 之后的版本输出是
true
,创建了 5 个对象
代码分析:
为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern()
方法也相应发生了变化:
在
JDK 1.6
中,调用intern()
首先会在字符串池中寻找equal()
相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将StringTable
的一个表项指向这个新创建的实例。在
JDK 1.7
中,由于字符串池不在永久代了,intern()
做了一些修改,更方便地利用堆中的对象。字符串存在时和JDK 1.6
一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例。
我们基于JDK1.7版本,来看个例子:
1 | String str1 = new String("abc"); |
由于字符串常量池中已存在abc
,所以返回了字符串常量池中的引用,如下图所示
再来看个例子:
1 | String str1 = new String("he") + new String("llo"); |
该结果等于true
应该是能够理解的,不理解的可以查看上文针对该代码的实例分析图
这里扩展一点,若是把str1.intern();
代码注释掉,则产生的结果为false
。
其原因在于str1
对象是通过new
对象拼接产生的,字符串常量池中并不存在字符串hello
,当调用String str2="hello";
代码时字符串常量池中产生才该字符串,所以他们并不是同一个地址引用。
直接内存
直接内存(Direct Memory
)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4
中新加入了 NIO
,引入了一种基于通道(Channel
)与缓冲区(Buffer
)的 I/O
方式,它可以使用 Native
函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx
等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError
异常。
“本篇文章主要摘自《深入理解Java虚拟机_JVM高级特性与最佳实践 第2版》”
最后更新: 2020年12月29日 11:02