Java运行时数据区域有哪些?
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 方法区
- 堆
其中程序计数器、虚拟机栈、本地方法栈是每个线程私有的,方法区和堆是线程间共享的。
程序计数器(Program Counter Register)
可以看作是当前线程所执行的字节码的行号指示器。
程序计数器的作用
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
为什么必须每个线程都要单独弄一个程序计数器?
因为多线程是通过轮流切换占用CPU时间片来实现的,线程数量大于CPU数量时,就会有线程处于等待状态,等到可以占用CPU时间片了,会恢复线程的执行,这时就必须要回到线程等待前的指令执行位置,以便接下来继续执行后面的指令,每个线程运行的指令又不一样,所以必须对每个线程都要保存当前执行的指令位置。
虚拟机栈(VM Stack)
虚拟机栈对应Java中的方法执行的内存模型。
栈中的每个元素称为栈帧,每个方法执行的时候都会创建一个栈帧,存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从开始执行到执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表(Local Variable Table)
存放方法参数和方法内的局部变量。
局部变量表中存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,可能指向一个对象的起始地址的指针,也可能是指向一个对象的句柄)和returnAddress类型(指向一条字节码指令的地址)
局部变量表的基本存储单位是变量槽(Variable Slot),每个槽的大小是4个字节,64位的long和double会占用局部变量表的两个槽位(slot),其余数据类型占用一个槽位。
局部变量表所需的内存空间在编译阶段完全确定,因为数据类型的大小是确定的,方法运行期间不会改变局部变量表的大小。字节码中方法的Code属性的max_locals数据项中确定了需要分配的局部变量表的最大容量,在编译时写入。
局部变量表存储顺序:变量表从索引0开始,依次存放方法所属的对象引用(如果为静态方法则没有)、方法参数变量(按照顺序声明)、方法内局部变量(按照顺序声明)。对于byte、short、char这三种数据类型需要转换为int类型存储在局部变量表中。
一个代码示例:
1 | public class IntegerDemo { |
局部变量表为:
1 | LocalVariableTable: |
没有局部变量表会怎么样?
参考《深入理解Java虚拟机(第2版)》190页 6.3.7 属性表集合。
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间关系。
注:LocalVariableTable属性不是必须的,在javac编译时,可通过-g:none或-h:vars来取消或关闭这项信息。如果没有生成这项信息,最大的影响就是当别人引用这个方法时,所有的参数名称都将失去,IDE将会使用诸如arg0、arg1之类的占位符来代替原有的参数名,这对程序没什么影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获取参数值。
操作数栈(Operand Stack)
用于保存计算过程中的中间结果,作为计算的临时数据存储区。
大多数指令都要从这里弹出数据,执行运算后将结果再压回操作数栈。
操作数栈最大深度在编译时也是写入到字节码中方法的Code属性的max_stacks数据项中。
操作数栈的基本单位是4个字节,32位数据类型占用一个单位,64位数据类型占用两个单位,对于byte、short、char这三种数据类型需要转换为int类型再存入栈中。
java的指令是基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,指令依赖操作数栈进行工作。
另外一种常用的指令集架构是基于寄存器的指令集。
基于栈的指令集 优点(反过来就是 基于寄存器的指令集 缺点):
- 可移植性强,直接依赖硬件寄存器将会受到硬件条件的约束
- 代码相对紧凑,因为指令没有操作数
- 编译器实现更简单,因为不用考虑空间分配,栈大小固定,编译时可知
基于栈的指令集 缺点(反过来就是 基于寄存器的指令集 优点):
- 速度稍慢,因为栈实现在内存中,频繁访问栈意味着频繁访问内存,访问内存是比访问寄存器慢很多的
- 指令数量多,因为访问数据频繁,入栈和出栈这两个指令就很多
参考《深入理解Java虚拟机(第2版)》270页 8.4.2 基于栈的指令集与基于寄存器的指令集。
动态链接(Dynamic Linking)
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
动态连接是一个将符号引用解析为直接引用的过程。当java虚拟机执行字节码时,如果它遇到一个操作码,这个操作码第一次使用一个指向另一个类的符号引用,那么虚拟机就必须解析这个符号引用。
在解析时,虚拟机执行两个基本任务:
- 查找被引用的类(如果必要的话就装载它)。
- 将符号引用替换为直接引用,这样当它以后再次遇到相同的引用时,它就可以立即使用这个直接引用,而不必花时间再次解析这个符号引用了。
参考:
- 《深入理解Java虚拟机(第2版)》8.2.3 动态链接
方法返回地址(Return Address)
方法退出的过程实际上等同于把当前栈帧出栈。
因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令
参考《深入理解Java虚拟机(第2版)》 8.2.4 方法返回地址
虚拟机栈会有什么潜在的问题?
- 某个线程中方法嵌套执行的太多了,超过虚拟机栈允许的最大深度,将会抛出StackOverflow(栈溢出)异常。
- 一个典型的场景是递归方法,递归深度过大,会引起栈溢出,某些语言下可以采用尾递归优化。
- 当线程不断增多,不停的申请虚拟机栈,内存可能不够用了,会引发OutOfMemoryError异常,即内存溢出。
- 一个典型的场景是,程序中的同时运行的线程不停的增多。
本地方法栈(Native Method Stack)
虚拟机栈对应的是java方法的执行过程,本地方法栈对应native方法的执行过程。
堆(Heap)
创建一个对象实例便存储在堆,所有线程共享。
物理上可以不连续,逻辑上是连续的即可。
是虚拟机管理的内存区域最大的一块,是虚拟机垃圾回收的主要区域。
现代垃圾回收收集器基本都采用分代回收,堆被划分为新生代和老年代,新生代又分为Eden区、From Survivor区、To Survivor区。划分特定区域是为了更高效的进行垃圾回收。
对象都是在堆上分配的吗?
创建新对象实例也可能分配在TLAB和栈上。
对象不在堆上分配主要的原因还是堆是共享的,在堆上分配有锁的开销。无论是TLAB还是栈都是线程私有的,私有即避免了竞争(当然也可能产生额外的问题例如可见性问题),这是典型的用空间换效率的做法。
参考:
方法区(Method Area)
存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
各线程共享的区域。
方法区里的东西放在堆里不行吗?
因为方法区存储的类信息、常量等数据都是生命周期比较长的,要放在堆,也只能放在老年代,但可能生命周期比老年代的对象还要长。
故而单独开辟一个空间,单独管理,提高垃圾回收的效率。
方法区什么时候垃圾回收?
回收废弃常量和无用的类。
无用类三条判断方法:
- 堆中没有该类的实例
- 该类的类加载器已被回收
- 没有任何地方引用Class对象,也没有反射调用
这也是类卸载的判断。
参考《深入理解Java虚拟机》3.2.5 回收方法区 68页。
大量使用反射,动态代理,cglib等字节码框架都需要类卸载机制,保证方法区不溢出。
直接内存
《深入理解 Java 虚拟机 第三版》2.2.7 小节 关于 Java 直接内存的描述。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。
在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置
-Xmx
等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。