运行时数据区
java虚拟机定了了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与县城一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。如图,灰色的区域为单独线程私有的,红色的为多个线程共享的。
程序计数器
PC寄存器是用来存储指向下一条指令的地址,也即将将要执行的指令代码。由执行引擎读取下一条指令。
作用
- 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
- 在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
- 字节码解释器工作时就是通过改变这个计数器的值来选取需要执行的字节码指令
- 它是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域
实例
-
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
-
多线程工作的时候,CPU会不停的切换任务,为了保证各个线程中断后能正确恢复,需要准确的记录各个线程的当前字节码指令地址,因此需要为每个线程分配一个PC寄存器。
虚拟机栈
概述
-
为了满足跨平台的需要,面对不同平台CPU架构不同的情况,java的指令是根据栈设计的(优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令)
-
栈是运行时单位,堆是储存单位。关于堆、栈、方法区的关系如下图所示;实例本身分配在堆中,描述类的信息存放在方法区中,局部变量和引用数据类型对象的引用存放在栈空间中。
-
栈的大小是动态的或固定不变的
如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常
/**
* 演示栈中的异常
*/
public class StackErrorTest {
public static void main(String[] args) {
main(args);
}
}
如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常
- 栈的内存大小
我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。 (IDEA设置方法:Run-EditConfigurations-VM options 填入指定栈的大小-Xss256k)
/**
* 演示栈中的异常
*
* 默认情况下:count 10818
* 设置栈的大小: -Xss256k count 1872
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
栈的储存结构和运行原理
运行原理
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都对应各自的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
- JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Frame)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
/**
* 栈帧
*/
public class StackFrameTest {
public static void main(String[] args) {
StackFrameTest test = new StackFrameTest();
test.method1();
//输出 method1()和method2()都作为当前栈帧出现了两次,method3()一次
// method1()开始执行。。。
// method2()开始执行。。。
// method3()开始执行。。。
// method3()执行结束。。。
// method2()执行结束。。。
// method1()执行结束。。。
}
public void method1(){
System.out.println("method1()开始执行。。。");
method2();
System.out.println("method1()执行结束。。。");
}
public int method2(){
System.out.println("method2()开始执行。。。");
int i = 10;
int m = (int) method3();
System.out.println("method2()执行结束。。。");
return i+m;
}
public double method3(){
System.out.println("method3()开始执行。。。");
double j = 20.0;
System.out.println("method3()执行结束。。。");
return j;
}
}
内部结构
局部变量表、操作数栈、动态链接、方法返回地址、一些附加信息
局部变量表
概述
- 局部变量表也被称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddressleixing
- 由于局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
变量槽
-
在局部变量表里, byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true;
long和double则占据两个slot。且对于一个64位的局部变量值,只需要第一个索引。this会存放在index为0的slot处
-
槽重复利用
栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的
private void test2() {
int a = 0;
{
int b = 0;
b = a+1;
}
//变量c使用之前以及经销毁的变量b占据的slot位置
int c = a+1;
}
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
package com.atguigu.t04.java3;
/**
* @anthor shkstart
* @create 2020-08-26 16:11
*/
public class localvarGcl {
public static void main(String[] args) {
localvarGcl ins = new localvarGcl();
}
public void localvarGc1(){
byte[] a = new byte[6*1024*1024];
System.gc();
}
public void localvarGc2(){
byte[] a = new byte[6*1024*1024];
a = null;
System.gc();
}
public void localvarGc3(){
{
byte[] a = new byte[6 * 1024 * 1024];
}
System.gc();
}
public void localvarGc4(){
{
byte[] a = new byte[6 * 1024 * 1024];
}
int c = 10;
System.gc();
}
public void localvarGc5(){
localvarGc1();
System.gc();
}
}
上述的代码,每个函数都分配了一个6MB的堆空间,并使用局部变量引用这块空间
对于1来说,申请空间后,立刻进行垃圾回收,但是由于数组被变量a引用,无法回收这块空间
对于2来说,申请空间后,先把a置为null,使得数组失去强引用,可以回收这块空间
对于3来说,申请空间后,先使局部变量a失效,虽然变量a已经离开作用域,但是变量a依然存在于局部变量表中,并且指向这块数组,无法回收这块空间
对于4来说,申请空间后,先使局部变量a失效,申明变量c,是变量c复用了变量a的字,由于此时数组已经销毁,可以回收这块空间
对于5来说,调用方法1,在1中没有释放数组,在1返回后,它的栈帧被销毁,包含的局部变量也销毁,可以回收这块空间
操作数栈
概念
-
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop);主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
-
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值
一些改善技术
- 栈顶缓存技术
将栈顶元素全部缓存在屋里CPU的寄存器中,以此降低对内存的读/写次数,提升执行疫情的执行效率 - 栈上分配技术
对于线程私有的对象,将它们打散分配在栈上,而不是堆上,使用完后可以自动销毁,提高系统性能;技术基础是逃逸分析,判断对象是否有可能被外界操控,如下分别为逃逸对象、非逃逸对象
package com.atguigu.t04.java3;
/**
* @anthor shkstart
* @create 2020-08-26 16:22
*/
public class OnStackTest {
public static class User{
public int id = 0;
public String name = "";
}
public static void alloc(){
User u = new User();
u.id = 5;
u.name = "geym";
}
public static void main(String[] args) throws InterruptedException{
long b = System.currentTimeMillis();
for (int i=0;i < 100000000;i++){
alloc();
}
long e = System.currentTimeMillis();
System.out.println(e-b);
}
}
上述代码在主函数中进行了1亿次alloc()调用进行对象创建,由于对象实例需要占据约16字符的空间,累计分配空间将达到1.5G,必然会发生垃圾回收。使用下面参数运行代码,会发现没有GC输出,说明User对象的分配过程被优化了(不适宜大对象的栈上分配)
帧数据区
动态链接
-
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的;常量池的作用,就是为了提供一些符号和常量,便于指令的识别
-
方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接(当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时)-->早期绑定
动态链接(如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用-->晚期绑定 -
虚方法
如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法其他方法称为虚方法
实例:
/**
* 解析调用中非虚方法、虚方法的测试
*/
class Father {
public Father(){
System.out.println("Father默认构造器");
}
public static void showStatic(String s){
System.out.println("Father show static"+s);
}
public final void showFinal(){
System.out.println("Father show final");
}
public void showCommon(){
System.out.println("Father show common");
}
}
public class Son extends Father{
public Son(){
super();
}
public Son(int age){
this();
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
//不是重写的父类方法,因为静态方法不能被重写
public static void showStatic(String s){
System.out.println("Son show static"+s);
}
private void showPrivate(String s){
System.out.println("Son show private"+s);
}
public void show(){
//invokestatic
showStatic(" 大头儿子");
//invokestatic
super.showStatic(" 大头儿子");
//invokespecial
showPrivate(" hello!");
//invokespecial
super.showCommon();
//invokevirtual 因为此方法声明有final 不能被子类重写,所以也认为该方法是非虚方法
showFinal();
//虚方法如下
//invokevirtual
showCommon();
//没有显式加super,被认为是虚方法,因为子类可能重写showCommon
info();
MethodInterface in = null;
//invokeinterface 不确定接口实现类是哪一个 需要重写
in.methodA();
}
public void info(){
}
}
interface MethodInterface {
void methodA();
}
-
方法重写
1 找到操作数栈的第一个元素所执行的对象的实际类型,记作C。
2.如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3.否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。
4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。 IllegalAccessError介绍 程序视图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。 -
虚方法表
1、在面向对象编程中,会很频繁期使用到动态分派,如果在每次动态分派的过程中都要重新在累的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,jvm采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
2、每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
3、那么虚方法表什么时候被创建? 虚方法表会在类加载的链接阶段被创建 并开始初始化,类的变量初始值准备完成之后,jvm会把该类的方发表也初始化完毕。
方法返回地址
方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值也如调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。方法返回地址的作用就是存放调用该方法的PC寄存器的值。
1、一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
2、在字节码指令中,返回指令包含ireturn(当返回值是boolena、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用
面试题
-
垃圾回收是否会涉及到虚拟机栈?
-
方法中定义的局部变量是否线程安全?
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程可以操作此数据,则毙是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题
*
* StringBuffer是线程安全的,StringBuilder不是
*/
public class StringBuilderTest {
//s1的声明方式是线程安全的
public static void method1(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
}
//stringBuilder的操作过程:是不安全的,因为method2可以被多个线程调用
public static void method2(StringBuilder stringBuilder){
stringBuilder.append("a");
stringBuilder.append("b");
}
//s1的操作:是线程不安全的 有返回值,可能被其他线程共享
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的 ,StringBuilder的toString方法是创建了一个新的String,s1在内部消亡了
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(()->{
s.append("a");
s.append("b");
}
).start();
method2(s);
}
}
本地方法栈
本地方法
该方法的实现由非Java语言实现,比如C。在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。标识符native可以与其他所有的java标识符连用,但是abstract除外。
使用场景:
- 与java环境外交互
- 与操作系统交互
- Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互
本地方法栈
- Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用本地方法栈,也是线程私有的。
- 允许被实现成固定或者是可动态拓展的内存大小。(在内存溢出方面是相同的)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。
- 本地方法是使用C语言实现的它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。
补充
栈中局部变量表中储存数据类型
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置,returnAddress类型(指向了一条字节码指令的地址)
局部变量表的大小编译期间确定
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
字符串常量池
相关链接:https://blog.csdn.net/qq_26222859/article/details/73135660
- 全局常量池
-
全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)
-
在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。
- class文件常量池
-
class文件中的常量池用来存放编译器生成的各种字面量和符号引用。java文件被编译成class文件之后,也就是会生成class常量池
-
字面量,即常说的常量概念,如文本字符串、被声明为final的常量池。
-
符号引用是一组符号来描述所引用的目标,符号可以任何形式的字面量。只要使用时能无歧义地定位到目标即可。而直接引用指的是直接指向方法区的本地指针
符号引用包括:类和接口的权限定名、字段名称和描述符、方法名称和描述符
符号引用如何转变为直接引用:
运行时常量池的常量:
类加载之后,常量池的内容会进入运行时常量池,这时候里面的数据也许还保持着符号引用。 (因为解析的时机由JVM自己设定) 如果在虚拟机栈的 栈帧中,我准备调用 main() 函数,那么会通过栈帧中持有的动态连接,找到运行时常量池, 然后找到main函数的常量 比如 #2
对于常量的解析:
如果这个常量没有被解析过,那么就通过这个常量进行解析过程, 其中包括,通过常量 找到 类名 和 nameAndType,通过 nameAndType 找到方法名和返回值。 这时候 我手里有 类名/方法名/方法返回值,下一步,我通过类名和方法名,通过JVM记录的方法列表,找到对应的方法体。
常量的赋值:
而这个方法体实际上是一段内存地址,那么这时候我就把这段内存地址复制给 #2,并且给 #2设定一个已经解析的 flag。 这样就完成了 符号引用到直接引用的过程。
- 运行时常量池
-
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。
-
经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
-
实例
public class HelloWorld {
public static void main(String []args) {
String str1 = "abc";
String str2 = new String("def");
String str3 = "abc";
String str4 = str2.intern();
String str5 = "def";
System.out.println(str1 == str3);
//true
System.out.println(str2 == str4);
//false
System.out.println(str4 == str5);
//true
}
}
String对象两种不同的创建方法是有差别的,方式一和方式二都是在堆中开辟一块空间储存字符串对象实例。不过方式一是在类加载期间,将该字符串对象实例的引用值存到string pool中;而方式二前面与一相同,后续运行期间会将全局常量池中的对象复制一份放在堆空间中,并把其引用交给str2。
并且运行时常量池具有动态性,运行期间也可能将新的常量池放入池中,如str4中intern()方法会查找常量池中是否存在一个相等的字符串,如果有返回该字符串的引用,没有则添加自己的字符串进入常量池。
- 总结
- 全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
- class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
- 运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。