文章目录
本文基于JDK8进行分析
图形概述
忘了什么时候,在哪里听到这么一句话,一图胜千言,从后来的工作经历中验证,图形展示的内容确实会加深记忆,不容易忘记。
先上图片,运行时数据区布局概览,元空间和CodeCache(JIT编译后的代码)又称为非堆区域。
在Hotspot虚拟中,本地方法栈已合并到虚拟机栈,上图用相同颜色标记。
对运行时数据区有了整体的认知后,在来一张从线程安全角度来看运行时数据区的图片,加深下记忆,接下来本篇会从程序计数器、虚拟机栈、堆、元空间进行详细讲解,ThreadLocal后续再开一片单独讲解,或是放到JUC中讲解。
程序计数器
概念
- 可以看做当前线程所执行的字节码的行号指示器。
- 可以忽略不记的内存空间占用,运行最快的存储区域。
- 线程私有的,生命周期和线程生命周期保持一致。
- 唯一一个在JVM规范中没有规定任何OOM的区域。
- 程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器完成。
- 如果线程正在执行的是一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在指定Native方法,这个计数器值则为空(Undefined)。
示例
public static void main(String[] args) {
int a = 1;
int b = 2;
System.out.println(a + b);
}
有两种方式可以查看到程序计数器,在终端可以执行javap -v ProgramCounterRegister.class
,或是使用jclasslib插件。
讨论
- 使用程序计数器存储字节码指令地址有什么用?
CPU在取得时间片的线程间不停切换,切换回来的时候要知道从哪里继续执行。 - 程序计数器为何被设置为线程私有?
CPU不停的做任务切换,这样必然会导致经常中断或恢复,为了能准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是每个线程都有自己的程序计数器。线程间不会相互影响。
虚拟机栈(本地方法栈)
栈帧概述
- 每个线程都有自己的栈,栈中的数据都是以栈帧的形式存在,在线程上正在执行的方法都有各自对应的一个栈帧。
- 栈帧是一块内存区块,是一个数据集,保存方法执行过程中的各类数据信息。
栈帧结构
- 局部变量表(Local Variables Table)
- 操作数栈(Operand Stack)
- 动态连接(Dynamic Linking,指向运行时常量池的方法引用)
- 方法返回地址(方法正常退出或异常退出的定义)
- 附加信息
局部变量表
- 是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
- 容量大小在编译期确定,在方法运行期间不会改变局部变量表大小。
- 方法嵌套调用的次数由栈的大小决定,方法的参数和局部变量越多,局部变量表越大,进而栈帧越大,会占用更多的栈空间,导致嵌套次数减少。
- 局部变量表中的变量只在当前方法调用中有效,方法执行时,虚拟机通过局部变量表完成实参到形参的传递,方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
Variable Slot
- 局部变量表的基本存储单元是变量槽(Variable Slot),局部变量表中存放编译期可知的8种基本数据类型,以及refrence引用类型,returnAddress类型的变量。
- 32位以内的类型只占一个slot,64位类型(long、double)占用两个slot,byte、short、char存储前转换为int,boolean也被转换为int,0表示false,非0表示true。
操作数栈
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法开始执行时,一个新的栈帧会随之创建,栈帧中的操作数栈也便创建了,只不过此时操作数栈是空的。
- 在方法执行过程中,根据字节码指令,向栈中写入数据或提取数据,即入站(push)或出栈(pop)。
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 在编译期确定栈深度用于存储数值,保存在方法的Code属性中。
- 如果调用的方法有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。
JVM基于栈的字节码解释执行引擎
标题中栈指的就是操作数栈,本小节将展示基于栈的解释器执行过程,下面引用《深入理解Java虚拟机》(第三版)中的示例进行展示。
// 代码
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
字节码指令,截图中显示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间。
代码执行过程中,操作数栈、局部变量表的变化如图1至图6所示。
图1,执行程序计数器地址为0的指令,bipush指令作用是将100压入操作数栈顶。
图2,执行程序计数器地址为2的指令,istore_1指令作用是将操作数栈顶的整型值出栈并存放到局部变量表地址为1的变量槽中。
图3,执行程序计数器地址为11的指令,iload_1的作用是将局部变量表中地址为1的变量槽中的整型值100复制到操作数栈顶。
图4,和图3相同操作,将200复制到操作数栈顶。
图5,iadd的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。iadd执行完毕,100、200被出栈,计算结果300被重新入栈。
图6,和图3相同操作,将局部变量表中地址为3的变量槽中的300压入操作数栈,imul是将两个300出栈,然后做整型乘法,再将结果90000压入操作数栈,ireturn将90000返回给调用者。
动态连接
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方 法调用过程中的动态连接(Dynamic Linking)。
- 在Java源文件被编译到字节码文件中,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。当一个方法调用其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法返回地址
一个方法执行后有两种方式退出。
- 正常退出,此时可能会有返回值传递给调用者。
- 异常退出,不会给上层调用者提供任何返回值。
无论采用何种方式退出,在方法退出后都必须返回到最初方法被调用的位置,程序才能继续执行,方法返回是需要栈帧中保存一些信息,用来帮助恢复它的上层调用者的执行状态。
- 正常退出时,调用者的程序计数器的值可以作为返回地址。
- 异常退出时,返回地址通过异常处理器表确定,栈帧中不存。
附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、 性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。
栈运行原理
- 在一个活动的线程中,一个时间点上,只有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧对应的方法就是当前方法,定义这个方法的类就是当前类。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在当前方法中调用了其他方法,则会创建新的栈帧,放在栈顶,成为新的当前栈帧,在调用的方法返回时,新的当前栈帧会回传次方法的执行结果给前一个栈帧,然后虚拟机会丢弃当前栈帧,使前一个栈帧重新成为当前栈帧。
- 不同线程中所包含的栈帧不可以相互引用。
- 方法执行后,无论是正常return,还是抛出异常,栈帧都会被弹出。
本地方法栈
- 本地方法栈(Native M ethod Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机 栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。
- 常用的HotSpot虚拟机把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失 败时分别抛出StackOverflowError和OutOfMemoryError异常。
- 本地方法是C语言实现的,具体做法是在Native Method Stack中登记native方法,在执行引擎执行时加载本地方法库。
堆
说到堆就很难绕开对象创建,垃圾回收等内容,但是本小节不涉及垃圾回收的内容,后续会单独开篇讲解。
分代思想
先来个全局图。
- 堆进一步细分,可以划分为新生代和老年代,其中新生代又可以划分为Eden空间、Survivor0空间和Survivor1空间。
- 空间占比默认配置-XX:NewRatio=2,新生代和老年代堆空间占比为1:2,新生代占整个堆空间的1/3,可以修改-XX:NewRatio来改变新生代占比,但是开发中次参数一般不会去更改。
- 在HotSpot中,Eden和S0、S1的空间占比默认是8:1:1,可以通过-XX:SurvivorRatio来调整空间占比。
- 分代的目的是优化GC性能,没有分代,那所有的对象都在一起,GC时就会扫描堆的所有区域。有了分代GC时就会先把"朝生夕死"的新生代区域进行回收,研究表明在新生代中,对常规应用进行一次垃圾收集通常 可以回收70%至99%的内存空间。
设置堆内存的参数
- 通过-Xms(-XX:InitialHeapSize)设置堆初始大小,默认值为物理内存的1/64。
- 通过-Xmx(-XX:MaxHeapSize)设置堆最大内存,默认值为物理内存的1/4,堆中内存使用超过-Xmx设置的最大值,就会抛出OutOfMemoryError异常。
- 通常会将-Xmx、-Xms配置相同的值,目的是为了在垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高效率。
- 更多参数配置请参考官方文档
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
内存分配策略
- 对象优先在Eden分配。
- 大对象直接进入老年代。
- HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区 之间来回复制,产生大量的内存复制操作。
- 长期存活的对象将进入老年代。
- 动态年龄判断。
- HotSpot虚拟机并不是永远要求对象的年龄必须达到- XX:M axTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:
MaxTenuringThreshold中要求的年龄。
- HotSpot虚拟机并不是永远要求对象的年龄必须达到- XX:M axTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:
- 空间分配担保。
对象在分代空间中流转
对象通常在Eden区里诞生,如果经过第一次新生代 GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,就在两个survivor(S0、S1)中完成一次移动,年龄增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:
M axTenuringThreshold设置。
方法区(元空间)
概述
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区 分开来。
- 在JDK7及之前版本,习惯把方法区称为永久代,JDK8开始使用元空间取代了永久代,本质上方法区和永久代并不等价。
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。元空间和永久代最大的区别在于,元空间使用的本地内存,不在虚拟机设置的内存中。
设置元空间内存大小
- 通过-XX:MetaspaceSize设置元空间的初始大小,通过-XX:MaxMetaspaceSize设置元空间最大值,64位服务端JVM中MetaspaceSize默认值为21M。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没有用的类,然后这个高水位线将会被重置。新的高水位线值取决于GC后释放了多少元空间。
运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生 成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。