运行时数据区主要包含:堆、方法区、栈、本地方法栈、程序计数器。其中堆、方法区是线程共享的,栈、本地方法栈、程序计数器是线程私有的。
PC 寄存器用来存储下一条指令的地址,由执行引擎中字节码解释器的读取下一条指令。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
问题一:PC寄存器为什么会被设定为线程私有?
由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任意一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
问题二:为什么使用PC寄存器存储字节码指令地址?
因为在多线程的情况下,程序是并发执行的, CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
如果线程正在执行的是 Java 方法(不是 native 的),则程序计数器记录的是正在执行的 Java 虚拟机字节码指令的地址。如果正在执行的是本地方法(native),那么计数器的值是空的(undefined)。
二、虚拟机栈基于栈的指令集架构:
① 使用了零地址指令方式分配。因为其执行过程依赖于操作数栈,栈只涉及入栈和出栈,操作的时候只基于栈顶进行操作,所以不需要地址。
② 指令集更小,编译器更容易实现。
③ 不需要硬件支持,可移植性更好,更好的实现跨平台。
基于寄存器的指令集架构:
① 基于寄存器的指令级架构以一地址指令,二地址指令,三地址指令为主。典型应用是 x86 的二进制指令集
② 完成一项操作花费的指令更少
③ 性能优秀,执行效率更高
④ 完全依赖于硬件,可移植性差
JVM是基于栈的指令集架构,所以有更好的可移植性,不能设计为基于寄存器的指令集架构。
2.1 栈的存储单位栈是运行时单位,而堆是存储单位。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应一次次的方法调用。栈是线程私有的,它的生命周期与线程相同。每个线程都有自己的栈,线程上的每个方法对应一个栈帧(Stack frame)。栈中的数据都是以栈帧的形式存在的。栈帧中包含:
局部变量表(Loacl Variables)操作数栈(Operand Stack)动态连接(Dynamic linking,指向运行时常量池的方法的引用)方法返回地址(Return Address,方法正常退出或异常退出的定义)一些附加信息
在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来了,并写入到方法表的 Code 属性中。换言之,一个栈帧需要分配多少内存,在编译期就已经确定,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
2.2 局部变量表
数字数组,主要用于存储方法参数和定义在方法体内的局部变量,存放编译期可知的各种基本数据类型(8种),对象引用(reference,并非对象本身),returnAddress类型。(数字数组,因为 byte、short、char 在存储前被转换为 int,boolean 也会被转换为 int,0表示 false,非0表示 true)
局部变量表的空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多少内存(局部变量槽的数量)是固定的,在方法运行期间不会改变局部变量表的大小。
由于局部变量表建立在线程的栈上,是线程私有数据,因此不存在数据安全问题。
在局部变量表中,最基本的存储单元是slot (变量槽),32位以内的数据类型只占用一个slot (包括 returnAddress 类型),64位的数据类型(long和double)占用两个slot。 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处。
局部变量表中的变量也是重要的垃圾回收根节点(GC Roots),只要被局部变量表中直按或间接引用的对象都不会被回收。
成员变量:在使用前都经历过默认初始化赋值
类变量:linking 的 prepare 阶段,给类变量赋默认值实例变量:随着对象的创建,在堆空间中分配实例变量空间,并赋默认值。
局部变量:在使用前必须显示赋值,否则编译不通过。局部变量存储在栈的局部变量表中。
2.3 操作数栈基于数组实现操作数栈在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) /出栈(pop)主要保存计算过程的中间结果,同时作为计算过程中的变量临时的存储空间。每一个操作数栈都会拥有一个明确的栈深度(数组长度)用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法表的 Code 属性中,为 max_ stack 的值。操作数栈虽然用的是数组实现,但并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问。我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
代码:
public void testAddOperation() {
byte i = 4;
int j = 6;
int k = i + j;
}
字节码指令:
public void testAddOperation();
Code:
0: iconst_4 // 将数值 4 压入操作数栈
2: istore_1 // 弹出数值 4 保存在局部变量表索引为 1 的位置
3: bipush 6 // 将数值 6 压入操作数栈
5: istore_2 // 弹出数值 6 保存在局部变量表索引为 2 的位置
6: iload_1 // 从局部变量表中 load 出索引 1 位置的元素(数值4)并压入操作数栈
7: iload_2 // 从局部变量表中 load 出索引 2 位置的元素(数值6)并压入操作数栈
8: iadd // 将最接近栈顶的两个元素(4和6)出栈并相加,然后将相加的结果(数值10)重新入栈
9: istore_3 // 弹出数值 10 保存在局部变量表索引为 3 的位置
10: return
栈顶缓存技术
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题 ,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
2.4 动态连接大部分的指令在执行的时候都需要进行常量池的访问,所以在帧数据区就保存着访问常量池的指针,这个就是动态连接。也可以理解为指向运行时常量池的引用。
Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转换被称为静态解析。另一部分将在每一次运行期间被转化为直接引用,这部分就称为动态连接。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。关于方法的调用在下面一节详解。
2.5 方法的调用方法调用阶段唯一的任务是确定被调用的方法的版本,即要调用哪一个方法,并不等同于方法中的代码被执行,暂时还未涉及方法内部的具体运行过程。
在 JVM 中,将符号引用换为调用方法的直接引用与方法的绑定机制相关。在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前(编译的那一刻)就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用被称为解析。
静态连接:字节码文件被装载 JVM 内部时,如果被调用的目标方法在编译期可知,且在运行期保持不变,这种情况下降调用方法的符号引用转换为直接引用称为静态连接。在 Java 语言中符合 “编译期可知,运行期保持不变” 这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本。
动态连接:调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此被称之为动态链接。
在运行时,符号引用一定会转换为直接引用,引用的转换过程是在编译期转换的就是静态连接,如果是在运行期转换的就是动态连接。
对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding) 。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态连接的方式将符号引用转换为直接引用。
晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。多态就是晚期绑定。
非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。静态方法、私有方法、实例构造器、父类方法、final修饰的方法都是非虛方法。这 5 种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用(不涉及多态的形式)。
虚拟机中提供了以下几条方法调用指令:
普通调用指令:
invokestatic:调用静态方法,解析阶段确定唯一方法版本invokespecial:调用 < init >方法、 私有及父类方法,解析阶段确定唯一方法版本invokevirtual:调用所有虚方法。final 使用的是这个指令。invokeinterface:调用接口方法
invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
动态调用指令:
invokedynamic:动态解析出需要调用的方法,然后执行。Lambda表达式。 2.6 方法返回地址
存放调用该方法的pc寄存器的值。
一个方法的结束,有两种方式:① 正常执行完成;② 出现未处理的异常,非正常退出。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法才能继续执行 。方法正常退出时,主调方法的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址,栈帧中很有可能保存这个计数器的值。而异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
1、执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
➢一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
➢在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)。lreturn,freturn,dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
2、在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。方法执行过程中抛出异常时的异常处理,存储在一个异常处理表, 方便在发生异常的时候找到处理异常的代码。
2.7 栈的相关问题1.举例栈溢出的情况?(StackOverFlowError)
往栈中添加栈帧,当栈满了的时候就会发生栈溢出,可以通过 -Xss 来设置栈的空间大小。
2.调整栈的大小能保证不出现溢出吗?
不能
3.垃圾回收会涉及到虚拟机栈吗?
不会
4.分配的栈内存越大越好吗?
不是,因为内存空间大小是固定的,栈的空间变大了,其他的内存空间会变小
5.方法中定义的局部变量是否线程安全?
具体问题具体分析。在多线程的情况下,如果这个变量是在方法内部产生,内部销毁的就是线程安全的。如果是内部产生,但是把它返回到方法外,多个线程共享这个返回值,那就是线程不安全的。比如在返回的时候调用了 s1.toString() 方法,toString() 内部又 new String(), 对于 s1 来讲,是线程安全的,对于 s1.toString() 来讲是线程不安全的。
也可以说是 没有发生逃逸的变量就是线程安全的,如果发生了逃逸就是线程不安全的。
三、本地方法栈为什么要使用 Native Method ?
1、为了扩展 Java 的使用,融合不同的编程语言,为 Java 所用。
2、为了提高效率,因为与操作系统或 CPU 打交道还是 C/C++ 实现效率比较高。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。它甚至可以直接使用本地处理器中的寄存器(执行效率高)。直接从本地内存的堆中分配任意数量的内存。
本地方法栈也属于栈:
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有做够的内存去创建对应各的本地方法栈,那么 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。



