JVM是每个Java开发每天都会接触到的东西, 其相关知识也应该是每个人都要深入了解的. 但接触了很多人发现: 或了解片面或知识体系陈旧. 因此最近抽时间研读了几本评价较高的JVM入门书籍, 算是总结于此. 本系列博客的主体来自 深入理解Java虚拟机(第二版) 和 实战Java虚拟机 两部书, 部分内容参考 HotSpot实战 和 深入理解计算机系统 以及网上大量的文章. 若文内有引文未注明出处的, 还请联系作者修改.
JVM 虚拟机架构(图片来源: 浅析Java虚拟机结构与机制 )
JVM会将Java进程所管理的内存划分为若干不同的数据区域. 这些区域有各自的用途、创建/销毁时间:
(图片来源: JAVA的内存模型及结构 )
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束而创建/销毁(在Hotspot VM内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死).
一块较小的内存空间, 作用是 当前线程所执行字节码的行号指示器 (类似于传统CPU模型中的PC), PC在每次指令执行后自增, 维护下一个将要执行指令的地址. 在JVM模型中, 字节码解释器就是通过改变PC值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖PC完成(仅限于Java方法, Native方法该计数器值为 undefined
).
不同于OS以进程为单位调度, JVM中的并发是通过 线程切换并分配时间片执行 来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 这类内存被称为 “线程私有”内存 .
虚拟机栈描述的是 Java方法执行的内存模型 : 每个方法被执行时会创建一个 栈帧(Stack Frame) 用于存储 局部变量表 、 操作数栈 、 动态链接 、 方法出口 等信息. 每个方法被调用至返回的过程, 就对应着一个栈帧在虚拟机栈中 从入栈到出栈的过程 (VM提供了 -Xss
来指定线程的最大栈空间, 该参数也直接决定了函数调用的最大深度).
long
和 double
占用2个局部变量空间(Slot), 其余只占用1个. 如下Java方法代码可以使用javap命令或javassist等字节码工具读到: public String test(int a, long b, float c, double d, Date date, List<String> list) { StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date); for (String str : list) { sb.append(str); } return sb.toString(); }
注: javap/javassist读到的其实是静态数据, 而局部变量表内存储的却是运行时动态加载的动态数据, 但因为 局部变量表所需的内存空间在编译期间即可完成分配 , 当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间大小不会改变, 因此可以在概念上认定这两部分内容存储的数据格式相同.
与 Java Stack作用类似 , 区别是Java Stack为执行Java方法服务, 而 本地方法栈则为Native方法服务 , 如果一个VM实现使用C-linkage模型来支持Native调用, 那么该栈将会是一个C栈(详见: JVM学习笔记-本地方法栈(Native Method Stacks) ), 但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一.
随虚拟机的启动/关闭而创建/销毁.
几乎所有对象实例和数组都要在堆上分配(栈上分配、标量替换除外), 因此是VM管理的最大一块内存, 也是垃圾收集器的主要活动区域. 由于现代VM采用 分代收集算法 , 因此Java堆从GC的角度还可以细分为: 新生代 ( Eden区 、 From Survivor区 和 To Survivor区 )和 老年代 ; 而从内存分配的角度来看, 线程共享的Java堆还还可以划分出 多个线程私有的分配缓冲区(TLAB) . 而进一步划分的目的是为了更好地回收内存和更快地分配内存.
即我们常说的 永久代(Permanent Generation) , 用于存储 被JVM加载的类信息 、 常量 、 静态变量 、 即时编译器编译后的代码 等数据. HotSpot VM把GC分代收集扩展至方法区, 即 使用Java堆的永久代来实现方法区 , 这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对 常量池的回收 和 类型的卸载 , 因此收益一般很小)
不过在1.7的HotSpot已经将原本放在永久代的字符串常量池移出:
而在1.8中, 永久区已经被彻底移除, 取而代之的是元数据区 Metaspace (这一点在查看GC日志和使用 jstat -gcutil 查看GC情况时可以观察到),与永久代不同, 如果不指定Metaspace大小, 如果方法区持续增长, VM会默认耗尽所有系统内存.
运行时常量池
方法区的一部分. Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项 常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用 , 这部分内容会存放到方法区的运行时常量池中(如前面从 test
方法中读到的 signature
信息). 但Java语言并不要求常量一定只能在编译期产生, 即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 如 String
的 intern()
方法.
直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配 堆外内存 , 然后使用 DirectByteBuffer
对象作为这块内存的引用进行操作(详见:Java I/O 扩展), 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能.
显然, 本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置), 但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现 OutOfMemoryError
异常.
new
一个Java Object(包括数组和Class对象), 在JVM会发生如下步骤:
new
指令: 首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个符号引用代表的类是否已被加载、解析和初始化过. 如果没有, 必须先执行相应的类加载过程. -XX:+/-UseTLAB
参数设定). <init>
方法尚未执行, 所有字段还都为零). 所以 new
指令之后一般会(由字节码中是否跟随有 invokespecial
指令所决定-Interface一般不会有, 而Class一般会有)接着执行 <init>
方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算完全产生出来. HotSpot VM内, 对象在内存中的存储布局可以分为三块区域:对象头、实例数据和对齐填充:
对象头包括两部分:
注意: 并非所有VM实现都必须在对象数据上保留类型指针, 也就是说查找对象的元数据并非一定要经过对象本身(详见下面句柄定位对象方式).
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁定 | 00 | 指向锁记录的指针 |
膨胀(重量级锁定) | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空(不需要记录信息) |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
longs
/ doubles
、 ints
、 shorts
/ chars
、 bytes
/ booleans
、 oops
(Ordinary Object Pointers), 相同宽度的字段总是被分配到一起 , 在满足这个前提条件下, 在父类中定义的变量会出现在子类之前. 如果 CompactFields
参数值为 true
(默认), 那 子类中较窄的变量也可能会插入到父类变量的空隙中 . 建立对象是为了使用对象, Java程序需要通过栈上的reference来操作堆上的具体对象. 主流的有 句柄 和 直接指针 两种方式去定位和访问堆上的对象:
句柄: Java堆中将会划分出一块内存来作为句柄池, reference中存储对象的句柄地址, 而句柄中包含了对象实例数据与类型数据的具体各自的地址信息:
直接指针(HotSpot使用): 该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址:
这两种对象访问方式各有优势: 使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不变. 而使用直接指针最大的好处就是速度更快, 它节省了一次指针定位的时间开销,由于对象访问非常频繁, 因此这类开销积小成多也是一项非常可观的执行成本.