JVM 运行时数据区
# 一、概述
JVM 在启动后就会向操作系统申请一块物理内存区域,并在逻辑上将其分为 5 大块:方法区、堆、栈、本地方法栈和程序计数器。这些逻辑上的内存分区按照一定的规范各司其职,又按照一定的管理策略相互配合,使得 JVM 才可以稳定高效运行。
# 二、方法区
# 2.1 概念
Method Area,供各线程共享的运行时内存区域,它存储了每一个类的结构信息,包括类信息、普通常量、静态常量、编译器编译后的代码等等。方法区的内存在逻辑上是连续的,但是物理实现上可以是非连续的,且大小是可以动态扩展的。
特点:
- 线程共享。
- 内存大小可扩展。
- 可能会出现 OOM。
- 存在垃圾回收。
# 2.2 方法区 & 永久代 & 元空间
在我看过的很多资料,尤其是类似于面试指南这样的资料中,都把方法区、元空间和永久代说成是同一个概念。有些资料上也说堆内存分为年轻代、老年代和永久代三部分。实际上并非如此,看一下《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》中是怎么说的:
说到方法区,不得不提一下“永久代”这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,例如 32 位系统中的 4GB 限制,就不会出问题),而且有极少数方法(例如
String::intern()
)会因永久代的原因而导致不同虚拟机下有不同的表现。当 Oracle 收购 BEA 获得了 JRockit 的所有权后,准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到 HotSpot 未来的发展,在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
总结一下:
- 方法区,是《Java 虚拟机规范》中的定义,是一种规范。既然是规范,那么不同的虚拟机可以有各自的实现,只要满足规范的要求就可以。
- 永久代,全名 Permanent Generation Space,也叫 PermGen Space。是 HotSpot 虚拟机在 JDK7 及 JDK7 之前基于《Java 虚拟机规范》对方法区的实现。
- 元空间,也叫 Metaspace。是在 JDK8 及之后,HotSpot 虚拟机废弃了永久代,取而代之的是元空间。 这是 HotSpot 虚拟机对方法区的新的落地实现。
所以,方法区是一种抽象的规范约定,而永久代和元空间则是 HotSpot 虚拟机在不同时期对方法区的具体实现。
永久代也不属于堆内存,之所以叫永久代,是因为方法区中主要存储 Class 的 Meta 信息,从 GC 的角度来看是属于永久保留区。
永久代和元空间的区别是:永久代使用的是 JVM 的堆内存,元空间并不使用 JVM 的内存而是本机物理内存。所以在默认情况下,元空间大小仅受本地内存限制。
# 2.3 组成
# 2.3.1 类(Class)信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名);
- 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.Object,都没有父类);
- 这个类型的修饰符(public,abstract,final 的某个子集);
- 这个类型直接接口的一个有序列表。
# 2.3.2 域(Field)信息
JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括: 域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)。
# 2.3.3 方法(Method)信息
JVM 必须保存所有方法的以下信息:
- 方法名称;
- 方法的返回类型;
- 方法参数的数量和类型;
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集);
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外);
- 异常表(abstract 和 native 方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
# 2.3.4 非 final 修饰的静态变量
静态变量和类关联在一起,随着类的加载而加载,它们也是类数据在逻辑上的一部分。
# 2.3.5 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,运行时常量池中包含常量池表(Constant Pool Table),用于存放编译器生成的各种字面量与符号引用。
注意,“运行时常量池”是方法区里的概念,而“常量池”是字节码文件里的概念。常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。一个 Java 源文件编译之后产生字节码文件,而字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的intern()
方法。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。
# 2.4 可能发生的错误
java.lang.OutofMemoryError: Java heap space
原因:方法区无法满足新的内存分配需求。
错误示例:
String::intern()
是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。
在 JDK 6 或更早之前的 HotSpot 虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize
和-XX:MaxPermSize
限制永久代的大小,即可间接限制其中常量池的容量。
在 JDK 7 及以上版本,限制方法区的容量对该测试用例来说是毫无意义的,因为自 JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆之中。这时候使用 -Xmx 参数限制最大堆到 6MB 就能够看到堆内存溢出。
/**
* 虚拟机参数:
* 1. JDK6之前:-XX:PermSize=6M -XX:MaxPermSize=6M
* 2. JDK7:-Xmx6M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
输出:
JDK6之前:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
JDK7之后:
// OOM异常一:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.Integer.toString(Integer.java:440)
at java.base/java.lang.String.valueOf(String.java:3058)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
// OOM异常二:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.HashMap.resize(HashMap.java:699)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at java.base/java.util.HashMap.put(HashMap.java:607)
at java.base/java.util.HashSet.add(HashSet.java:220)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 三、 堆
# 3.1 概念
Heap Area,堆内存是虚拟机内存中占比最大的一块区域,堆内存中的所有对象都是线程共享的,所以需要考虑线程安全问题。几乎所有对象的实例以及数组都在堆内存存储,所以也是垃圾收集器重点管理的区域。
堆内存虽然在逻辑上是连续的,但是在物理实现上可以存储在不连续的内存空间中,就好像需要在磁盘上存储大文件一样,逻辑上是连续的,物理实现上可以分为多个小文件块。堆大小可以设置成固定的,也可以设置成可扩展的。
-Xms
:用于设置堆的初始内存,等价于-XX:InitialHeapSize
,默认情况下为物理内存的 1/64。
-Xmx
:用于设置堆的最大内存,等价于-XX:MaxHeapSize
,默认情况下为物理内存的 1/4。
一旦堆实际所需的内促超过-Xmx
指定的参数,就会出现 OutOfMemoryError。在生产环境中一般会将-Xms
和-Xmx
设置为相同的值,主要是为了减小 JVM 垃圾回收机制清理完内存后重新分隔计算堆区大小带来的性能消耗。
堆、栈以及方法区之间的关系:
特点:
- 线程共享。
- 存在堆内存溢出。
- 存在垃圾回收。
# 3.2 组成
堆内存内部整体上分为年轻代和老年代,年轻代又分了伊甸区,幸存者 0 区和幸存者 1 区。如图所示:
- Young Generation Space(新生区,也叫新生代、年轻代)
- Eden Space(伊甸区)
- Survivor 0 Space(幸存 0 区)
- Survivor 1 Space(幸存 1 区)
- Tenure Generation Space(养老区,也叫老年代、老年区)
默认情况下,年轻代占堆内存的 1/3,老年代占堆内存的 2/3。可以通过参数-XX:NewRatio
修改年轻代和老年代的空间占比,如默认-XX:NewRatio=2
,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。可以修改为-XX:NewRatio=4
,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5。也可以直接使用-Xmn
修改年轻代大小,老年代的大小会随之改变。
在 HotSpot 中,Eden 空间和另外两个 Survivor 空间默认所占的比例是 8:1:1,也可以用参数-XX:SurvivorRatio
修改默认值。
对象在 Survivor 中每熬过一次 Young GC,年龄就增加 1 岁。第一次经历 Young GC 时就会从 Eden 区进入 Survivor 区中。当它的年龄增加到一定程度(-XX:MaxTenuringThreshold
参数指定,不同的 JVM,不同的 GC 算法是有差异的),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置。
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 Survivor 区的 50% 时(默认值是 50%,可以通过-XX:TargetSurvivorRatio=percent
来设置),取这个年龄和MaxTenuringThreshold
中更小的一个值,作为新的晋升年龄阈值。
IBM 公司的专门研究表明,新生代中 80%的对象都是"朝生夕死"的。
# 3.3 内存分配过程
# 3.3.1 整体原则
- 对象优先在伊甸区分配。大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Young GC。
- 大对象直接进入老年代。大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
- 长期存活的对象将进入老年代。在伊甸区诞生,经历第一次 GC 之后就会进入幸存区,在新生代共计经历 15 次(由
-XX:MaxTenuringThreshold
参数指定,默认 15)GC 就进入老年代。 - 动态对象年龄判定。HotSpot 虚拟机并不是永远要求对象的年龄必须达到
XX:MaxTenuringThreshold
才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold
中要求的年龄。 - 空间分配担保。在发生 Young GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Young GC 可以确保是安全的。如果不成立,则虚拟机会先查看
XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Young GC,尽管这次 Young GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次 Full GC。
# 3.3.2 具体流程
- 新创建的对象先放伊甸园区,此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Young GC), 将 Eden 区中的不再被其它对象所引用的对象进行销毁,再加载新的对象到伊甸园区。
- 然后将 Eden 中的剩余对象移动到幸存者 0 区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
- 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
- 默认经历 15 次垃圾回收就会从幸存者区到老年区。
- 当老年区内存不足时,再次触发垃圾回收(Old GC),针对养老区进行内存清理。(只有 CMS 收集器才有单独收集老年代的行为,否则会触发 Full GC。)
- 如果针对老年区进行垃圾收集之后,或 Full GC 之后内存还不够,就会触发 OOM。
# 3.4 可能发生的错误
java.lang.OutofMemoryError: Java heap space
原因:堆内存无法完成实例分配且无法再扩展内存时。
堆溢出示例:
/**
* 虚拟机参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while (true) {
list.add(new Object());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
输出:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid62658.hprof ...
Heap dump file created [27795596 bytes in 0.144 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
......
2
3
4
5
# 3.5 堆中对象的创建过程
提示
以最常见的 HotSpot 虚拟机为例。
当 Java 虚拟机接收到一套字节码 new 指令时:
- 首先会检查这个指令的参数是否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有被加载,就会先执行相应的类加载过程。
- 类检查通过后,就会为新生对象分配内存,新生对象所需的内存大小(堆内存)在类加载完就可以确定。内存分配方式有指针碰撞和空闲列表两种,具体采用哪种由 Java 堆是否规整决定。
- 指针碰撞(Bump The Pointer):如果 Java 堆内存是规整的,所有使用过的内存放在一边,未使用的内存放在另一边,中间使用一个指针作为分界点,通过指针移动来控制已分配内存和空闲内存的边界,这种方式称为指针碰撞。
- 空闲列表(Free List):如果 Java 堆内存不规整,已使用内存和空闲内存交错在一起,指针碰撞的方式就无法满足了,虚拟机需要维护一个列表,记录哪些内存是空闲的,在为新对象分配的时候就从列表中找到一块足够大的空间划分给新对象,并更新列表上的记录,这种方式称为空闲列表。
在内存分配的过程中还需要考虑线程安全的问题,如果新对象的创建是比较频繁的,在并发情况下也不是线程安全的。比如正在为对象 A 分配内存,但是指针还未来得及移动,此时恰好对象 B 从原来的指针位置也分配了内存。解决此问题有两种方案:
- 为分配内存的动作做同步处理,如采用 CAS+失败重试。
- 每个线程在 Java 堆中预先分配一部分内存(即本地线程分配缓冲,Thread Local Allocation Buffer),把为对象分配内存的动作按照线程划分在不同的空间之中进行。本地线程分配缓冲大小用完需要充分新分配时再采用同步锁定的方式。
- 内存分配结束,虚拟机需要将已分配的内存空间大小设为 0。
- 然后对新对象进行必要的设置。比如如何关联类的元数据、执行构造函数、为对象的属性赋初始值等。
# 3.6 对象一定在堆中分配吗
在《深入理解 Java 虚拟机》中关于 Java 堆内存有一段描述:随着 JIT 编辑器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象分配到堆上也渐渐变得不那么“绝对”了。
在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种情况特殊,那就是经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需再堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。
# 3.7 逃逸分析
# 3.7.1 概念
逃逸分析(Escape Analysis)是一种可以有效减少 Java 程序中同步负载和堆内存分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java HotSpot 编译器能够分析出一个新对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只是在方法内部使用,则认为没有发生逃逸。没有发生逃逸的对象可以分配到栈上,随着方法执行结束,栈空间就被移除。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生了逃逸。例如作为调用参数传递到其它方法中。
所以,在开发中能使用局部变量的,就不要在方法外定义。
# 3.7.2 逃逸分析带来的优化
1. 栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使用指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。栈上分配的好久就是随着方法的调用结束,栈空间被回收的时候局部变量也会被随之回收。
2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。这样做可以大大提高并发性能,这个取消同步的过程就叫同步省略,也叫锁消除。
3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
标量是指一个无法再分解成更小数据的数据。Java 中的原始数据类型就是标量;相对地,那些还可以分解的数据叫做聚合量。Java 中的引用类型(对象)就是聚合量,因为它们可以分解为其它聚合量和标量。
在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其包含的成员变量来替代。这个过程就是标量替换。
# 3.7.3 逃逸分析现状
关于逃逸分析的论文在 1999 年就已经发表了,但是知道 JDK1.6 才有实现,而且这项技术到现在也不是很成熟。根本原因就是无法保证逃逸优化的性能大于逃逸分析本身所消耗的性能。因为虽然逃逸分析可以做一些优化,但是逃逸分析自身也会带来一些性能消耗。一个极端的例子:如果经过逃逸分析之后发现所有的对象都是不逃逸的,那么这个逃逸分析非但没有带来优化,还带来了额外的消耗。
# 四、 虚拟机栈
# 4.1 概念
Stack Area,也叫栈内存,主管 Java 程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就 Over,生命周期和线程一致,是线程私有的。其中主要保存方法的局部变量和部分结果,并参与方法的调用和返回。虚拟机栈中的数据以栈帧(Stack Frame)的形式存储。
虚拟机栈主要存储:
- 本地变量:输入参数和输出参数以及方法内的变量。
- 栈操作:记录出栈、入栈的操作。
- 栈帧数据:包括类文件、方法等。
特点:
- 线程私有。
- 每个线程只能有一个活动栈帧,对应当前正在执行的方法。
- 不存在垃圾回收。因为虚拟机栈是由栈帧组成,在方法执行完毕后,对应的栈帧就会被弹出栈,不需要垃圾回收机制。
- 允许 Java 虚拟机栈的大小是动态的或者是固定不变的。
- 栈帧过多或栈帧过大会导致栈内存溢出。
- 访问速度仅次于程序计数器。
# 4.2 组成
# 4.2.1 栈帧概念
虚拟机栈由一个个栈帧(Stack Frame)组成,线程里执行的每一个方法都对应一个栈帧。栈帧里包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。
JVM 对虚拟机栈的操作只有两个,入栈和出栈,也就是遵循先进后出、后进先出的原则。在一个活动线程中,同一时刻只会有一个活动的栈帧。只有当前正在执行的方法的栈帧(栈顶的那个栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法为当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的字节码指令只针对当前栈帧进行操作,如果在该方法中调用了其它方法,对应的新的栈帧会被创建出来并放在栈的顶端,称为新的当前栈帧。
虚拟机栈是线程私有的,不同线程中所包含的栈帧是不允许相互引用的。
如果当前方法被其它方法调用,方法返回的时候,当前栈帧会将此方法的执行结果给下一个栈帧,然后虚拟机会丢弃当前栈帧,使下一个栈帧称为当前栈帧。
无论是方法正常返回还是抛出异常,都会导致当前栈帧被弹出。
# 4.2.2 栈帧结构
栈帧里包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。
1. 局部变量表
局部变量表(Local Variables Table)也被称为局部变量数组或本地变量表,是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,定义为一个数组,其中的数据类型包括 8 种基本数据类型、对象引用以及 returnAddress 类型。由于局部变量表是建立在线程的栈上,是线程私有的数据,所以不存在数据安全问题。局部变量表所需的容量大小是在编译期间就确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表大小的。
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求,进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。局部变量表中的变量值在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
局部变量表中的内容以变量槽(Slot)为最小单位存储。32 位以内的类型只占用一个 Slot(包括 returnAddress 类型),64 位的类型(long 和 double)占用两个 Slot。JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量都会将按照顺序被复制到局部变量表的每一个 Slot 中。如果需要访问的局部变量表中的一个 64 位的局部变量值时,只需要使用 2 个 Slot 中的前一个 Slot 索引。如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 位 0 的 Slot 处,其余参数紧接其后按顺序排列。
栈帧中的局部变量表的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的局部变量就很有可能会复用过期局部变量的 Slot,从而到达节省资源的目的。
2. 操作数栈
操作数栈(Operand Stack)也常被称为操作栈或表达式栈。主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期间就定义好了,保存在方法的 Code 属性的 max_stack 中。操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的。在方法执行过程中,根据字节码指令,才会往栈中写入或提取数据,即入栈或出栈。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。
栈顶缓存技术(Top Of Stack Caching):基于栈式的架构的虚拟机所使用的零地址指令更加紧凑,单完成一项操作的时候必然需要使用更多的入栈和出栈指令,虚拟机栈也是存在于内存中,这就意味着将需要更多的指令分派次数和内存读/写次数,频繁的执行内存读/写操作必然会影响执行速度,为了解决这一问题 HotSpot JVM 的设计者们提出了栈顶缓存技术,将栈顶元素(或栈顶周边)元素缓存到物理 CPU 的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
3. 动态链接
动态链接(Dynamic Linking)指向运行时常量池中该栈帧所属方法的引用。
静态链接 & 动态链接
- 静态链接:当一个字节码文件被装载进 jvm 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变。
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期间调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
每一个栈帧内部都包含一个指向运行时常量池中该线程所属方法的引用。引用的目的就是为了支持当前方法的代码能实现动态链接。在 Java 源文件被编译到字节码文件时,所有的变量和方法引用都作为符号引用保存在 class 文件的常量池里。 描述一个方法调用另外的其它方法时,就是通过常量池中指向方法的符号引用来表示的,那么,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
早期绑定 & 晚期绑定
静态链接和动态链接分别对应了早期绑定和晚期绑定两种绑定方式。所谓绑定,就是一个字段、方法或类由符号引用被替换为直接引用的过程,只会发生一次。
- 早期绑定:指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号转换为直接引用。
- 晚期绑定:如果被调用的方法在编译期无法确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称为晚期绑定。
4. 方法返回地址
方法在执行过程中可能会正常退出,也可能会遇到异常退出。无论哪种方式退出,都需要回到调用此方法的上层方法中。正常退出时,上层方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。这里的异常指的是方法在执行时遇到了异常,并且这个异常没有在方法内进行处理,也就是在本方法的异常表中没有搜索到匹配的异常处理器(方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常时找到处理异常的代码)。
正常退出和异常退出的区别在于:通过异常退出的方法不会给它的上层调用者任何的返回值。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 寄存器值等,让调用者方法继续执行下去。
5. 附加信息
《Java 虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。
# 4.3 可能发生的错误
- java.lang.StackOverflowError
原因:栈帧过多(无限递归)、每个栈帧过大。
- java.lang.OutOfMemoryError
原因:申请栈空间失败。
提示
如果虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够内存就会抛出 OutOfMemoryError 错误。之前的 Classic 虚拟机的栈容量可以动态扩展,可能会出现 OutOfMemoryError,但是 HotSpot 虚拟机的栈容量是不可扩展的,所以只要线程 申请栈空间成功就不会出现 OutOfMemoryError 错误,但是如果申请时就失败,仍然会出现 OutOfMemoryError 错误。
错误示例:
/**
* 虚拟机参数:-Xss160k
* 注意:不同的JVM版本和操作系统对栈的最小容量是不一样的。
*/
public class StackOverFlow {
private int stackLength = 1;
public void stackLeak(){
stackLength ++;
stackLeak(); // 递归调用
}
public static void main(String[] args) {
StackOverFlow sof = new StackOverFlow();
try {
sof.stackLeak();
}catch (Throwable e){
System.out.println("stack length:"+ sof.stackLength);
throw e;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
输出:
stack length:773
Exception in thread "main" java.lang.StackOverflowError
......
2
3
# 五、 本地方法栈
提示
HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈。
# 5.1 概念
Native 方法 & Native 接口
Native Interface(本地接口)的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C 或 C++,因为 Java 诞生的时候正是 C 和 C++ 的天下,为了立足就在内存中专门开辟了一块区域处理标记为 Native 的代码,它的具体做法是 Native Method 中登记的 native 方法,在 Execution Engine 执行时加载 Native Libraries,所以 Native 方法即调用非 Java 实现的代码。
还有更重要的一点,Java 应用有时候需要和 Java 外面的环境交互,比如底层系统或某些硬件交换信息。这时候就需要本地方法来提供这种交流机制,Java 无需了解这种交流机制的细节问题。
Native Method Stack,本地方法栈,与 Stack Area(虚拟机栈)所发挥的作用类似,区别在于虚拟机栈为 Java 方法服务,而本地方法栈为虚拟机使用的本地方法服务。
特点:
- 线程私有;
- 允许被实现为固定大小或可动态扩展内存大小;
- 本地方法使用 C 语言实现;
- 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口访问虚拟机内部的运行时数据区。
- 并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。
- 在 HostSpot 虚拟机中,直接将本地方法栈和虚拟机栈合二为一。
# 5.2 可能发生的错误
可能发生的错误也和虚拟机栈类似:
- java.lang.StackOverflowError
原因:栈帧过多(无限递归)、每个栈帧过大。
- java.lang.OutOfMemoryError
原因:申请栈空间失败。
# 六、 程序计数器
Program Counter Register,程序计数器,有些资料也称为 PC 寄存器。每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条 JVM 指令的地址),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器完成。
特点:
- 线程私有。
- 不存在垃圾回收。
- 不会发生内存溢出错误,也是唯一一个不会出现任何 OOM 的区域。
- 如果执行的是 Java 代方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是一个 Native 方法,那这个计数器是空的。
- 用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。
程序计数器为什么是私有的?
由于 CPU 的时间片轮换机制,多线程在并发执行的时候实际上需要 CPU 在多个线程之间不停切换,为了能够准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程都分配一个程序计数器,这样就不会出现指令互相干扰的情况了。
# 七、 直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,属于物理机内存,直接由操作系统管理,所以不受虚拟机内存的限制,但是会受到物理内存的限制。
在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。直接内存的容量大小可通过-XX:MaxDirectMemorySize
参数来指定,如果不去指定,则默认与 Java 堆最大值(由-Xmx
指定)一致。