目录
1,概述
我们的字节码文件,经过类加载器的加载(加载,链接,初始化)后,在内存的方法区中,就保存我们的运行实例的本身,也就是类的各种信息。然后执行引擎使用我们的运行时数据区去执行程序。
1.1,数据区的结构
- 本地方法栈区域没有垃圾回收机制,堆和方法区有垃圾回收机制,pc寄存器也没有垃圾回收机制。
- Oom:内存溢出异常,pc寄存器没有内存溢出情况,但是虚拟机栈区域和本地方法栈区域可能有内存溢出情况,heap area,method area也可能发生内存的溢出情况。
- Pc寄存器既没有GC也没有OOM。
- 内存是非常重要的系统资源,是硬盘和cpu的中间仓库及桥梁,承载着操作系统和应用程序的实时运行,JVM内存布局规定了java在运行过程中内存的申请,分配,管理的策略,保证了jvm的高效稳定的运行,不同的jvm对内存的划分和管理机制存在着部分的差异,上面图是hotspot虚拟机内存的划分情况。
- 在jdk8以后,方法区叫做元数据区,也可以说是非堆空间。
- java虚拟机定义了若干种程序运行期间会使用到的运行时数据取,其中有一些会随着虚拟机的启动而创建,随着虚拟机的推出而销毁,另外一些区域则是与线程一一对应的,这些与线程对应的数据区域会随着线程的开始和结束而创建和销毁。
- 这里面红色区域是每一个进程一份(也就是说多个线程共享的数据区),也就是说一个虚拟机实例一份,而我们的灰色区域,则是一个线程对应于一份。
- 线程独有:程序计数器,虚拟机栈,本地方法栈。
- 线程共享:堆,堆外内存(永久带或者元空间,代码缓存),方法区。
- 结构模型
- 对于Runtime类:一个jvm实例就对应着一个Runtime实例,一个Runtime对象就相当于一个运行时数据区。
2,线程
2.1,线程的概念
- 线程是一个程序里面的运行单元,jvm允许一个应用程序有多个线程并行执行。
- 在hotspot虚拟机里,每一个线程都与操作系统的本地线程直接映射,每当一个java线程准备好执行以后,此时一个操作系统的本地线程也同时创建,java线程执行终止后,操作系统的本地线程也会被回收。
- 操作系统负责所有线程的安排调度到任何一个可用的cpu上,一旦本线程初始化成功,就会调用java线程中的run()方法。在这里准备工作是准备一个线程所需要的资源,比如本地方法栈,程序计数器,虚拟机栈。
2.2,线程的分类
- 如果使用jconsole或者任何一个调试工具,可以看到后台有许多线程在运行,这些线程不包括调用public static void main(String []args)的main线程以及所有这个main线程自己创建的线程。(线程分为普通线程和守护线程)
- 这些主要的后台系统线程在hotspot虚拟机中是一些几个:
- 虚拟机线程:这种线程的操作是需要jvm达到安全点才会出现,这些操作必须在不同的线程中发生的原因是他们需要jvm达到安全点,这样堆才不会变化,这种线程的执行类型不包括stop-the-world的垃圾收集,线程栈手机,线程挂起以及偏向锁撤销。
- 周期任务线程:这种线程是时间周期事件的体现,他们一般用于周期性操作的调度执行。
- GC线程:这种线程对jvm里面不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译成本地代码。
- 信号调度线程:这种线程接收信号并发送给jvm,在他内部通过调用适当的方法进行处理。(守护线程)
3,程序计数器PC
3.1,程序计数器的介绍
- 寄存器是程序控制流程的指示器,分支,循环,跳转,异常处理,线程恢复等基本功能都需要依赖这个寄存器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 寄存器是唯一一个在java虚拟机规范中没有规定任何oom情况的区域。
- 寄存器是一块很小的空间,几乎可以忽略不计,也是运行最快的存储区域。
- 在jvm规范中,每一个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。(也就是用来记录当前线程执行到的位置—)
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法,程序计数器会存储当前线程正在执行的java方法的jvm指令地址,或者是在执行native方法(本地方法),则是未指定值(undefined)。
- jvm中的程序计数寄存器中,register的名字源于cpu寄存器,寄存器存储指令相关的现场信息,cpu只有把数据装在到寄存器才能够运行。
- 在这里并不是广义上的物理寄存器,或许将其翻译成pc计数器,(或者指令计数器),jvm中的pc寄存器是对物理寄存器的一种抽象模拟。
3.2,PC寄存器的作用
- pc寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码,由执行引擎在寄存器中读取下一条指令地址并且执行指令。
- 在这里,程序计数器每一个线程都有一份,每一个栈帧就对应着一个方法,程序计数器就是记录自己的线程执行到那个位置。
3.3,PC寄存器举例
public class TestPcRegister {
public static void main(String[] args) {
int i=10;
int j=20;
int k=i+j;
}
}
Classfile /D:/intellij/ideaWork/jv/target/classes/rzf/qq/com/jvm/TestPcRegister.class
Last modified 2020-10-25; size 483 bytes
MD5 checksum ffad765f0769b0cabdc305cd09685a89
Compiled from "TestPcRegister.java"
public class rzf.qq.com.jvm.TestPcRegister
minor version: 0
major version: 49
flags: ACC_PUBLIC, ACC_SUPER
Constant pool://常量池
#1 = Methodref #3.#21 // java/lang/Object."<init>":()V
#2 = Class #22 // rzf/qq/com/jvm/TestPcRegister
#3 = Class #23 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lrzf/qq/com/jvm/TestPcRegister;
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 args
#14 = Utf8 [Ljava/lang/String;
#15 = Utf8 i
#16 = Utf8 I
#17 = Utf8 j
#18 = Utf8 k
#19 = Utf8 SourceFile
#20 = Utf8 TestPcRegister.java
#21 = NameAndType #4:#5 // "<init>":()V
#22 = Utf8 rzf/qq/com/jvm/TestPcRegister
#23 = Utf8 java/lang/Object
{
public rzf.qq.com.jvm.TestPcRegister();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lrzf/qq/com/jvm/TestPcRegister;
public static void main(java.lang.String[]);//main方法
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
//左边的序号,就是偏移地址,也就是我们寄存器中存储的地址
0: bipush 10 //定义变量10
2: istore_1 //存储到索引为1的位置
3: bipush 20
5: istore_2 // 存储到索引是2的地方
6: iload_1 //取出索引为1出的数值
7: iload_2//取出索引为2处的数值
8: iadd
9: istore_3 //结果存储到索引为3的位置
10: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
3 8 1 i I
6 5 2 j I
10 1 3 k I
}
字节码指令前面的数字就是指令的偏移地址。
3.4,pc寄存器面试问题
-
使用pc寄存器存储字节码指令地址有什么用?
- 应为cpu需要不停的在各个线程之间进行切换,这个时候切换回来之后,就不知道从程序的哪一个指令开始,jvm的字节码解释器就需要通过改变pc寄存器的值来明确下一条执行什么样的字节码指令。
- pc寄存器为什么会设定为线程私有的?
- 所谓的多线程是在一个特定的时间段内只会执行某一个线程的方法,cpu会不停的做任务的切换,这样必然导致经常的中断和恢复,如何保证在这个过程中不出现差错,为了能够准确的记录各个线程正在执行的当前字节码指令的地址,最好办法是为每一个线程都分配一个pc寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现干扰的情况。
- 由于cpu时间片轮限制,多个线程在执行的过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某一个线程中的一条指令。这样必然导致经常的中断和恢复,如何保证在这个过程中不出现差错,为了能够准确的记录各个线程正在执行的当前字节码指令的地址,最好办法是为每一个线程都分配一个pc寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现干扰的情况。
- 并行vs串行
- 并行,若干个线程同时在执行,在一个时间点上同时执行,串行是若干个线程按照顺序执行
- 并发,一个cpu快速在各个线程之间进行切换执行
4,Java虚拟机栈
4.1,虚拟机栈的概述
- 栈出现的背景
-
由于跨平台性设计,java的指令都是根据栈设计的,不同平台的cpu架构不同,所以不能设计为基于寄存器的。
-
优点是跨平台,指令集比较小(8位对齐),编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
-
-
内存中的栈与堆
-
栈是运行时的单位,而堆是存储的单位,即栈解决的是程序的运行问题,程序如何执行,或者说如何处理数据,堆解决的是如何存储数据,即数据怎么存放,放在哪里。
-
- java虚拟机栈是什么?
- java虚拟机栈,早期也叫做java栈,每个线程在创建的时候都会创建一个虚拟机栈,其内部都会保存一个个的栈帧,对应着一次次的方法调用,栈是线程私有的。栈帧是栈的基本单位。
- 生命周期
- 栈的生命周期和线程的生命周期一致,随着线程的启动而创建,随着线程的停止而销毁。当主线程结束后,整个的虚拟机的栈就全部销毁掉。
- 作用:
- 主管java程序的运行,他保存方法的局部变量(8种基本数据类型(局部变量),引用数据类型只能放引用数据对象的地址(引用类型变量,属性),真正new出来的对象存放在堆空间中),部分结果,并且参与方法的调用和结果的返回。
- 变量分类:局部变量vs属性(成员变量)
- 基本数据类型变量(局部变量)vs(属性)引用类型变量(指对象)(类,数组,接口)
- 栈的特点(优点)
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- jvm直接对java栈的操作有两个
- 每个方法的执行,伴随着出栈入栈。
-
运行结束后出栈工作。
- 对于栈来说,不存在垃圾回收机制,Pc寄存器:既没有垃圾回收,也没有oom内存溢出。栈:存在oom,会栈溢出,但是不存在垃圾回收机制。
- 栈中可能出现的异常。
- ava虚拟机规范允许java栈的大小是动态变化的或者是固定不变的,
- 如果是固定大小的虚拟机栈,那么每一个线程的java虚拟机栈的大小可以再创建线程的时候独立确定,如果线程请求分配的栈容量大小超过虚拟机栈允许的最大容量,java虚拟机将会抛出StackOverFlowError异常。
- 如果java虚拟机栈的容量可以进行动态的扩展,并且在扩展的时候无法申请到足够的空间,或者在新创建线程的时候没有足够的内存去创建虚拟机栈,那么java虚拟机将会抛出一个OutOFmemoryError错误。
- 设置内存的大小。
- 们可以使用参数-Xss来设置线程的最大栈空间容量,栈的大小直接决定了函数调用最大的深度。
4.2,栈的存储单位
- 栈的内部结构
-
栈中存储什么?
-
每一个线程都有自己的栈,栈中的数据都是以栈帧(satck frame)的格式存在,可以把每一个栈帧看做是一个方法(方法和栈帧是一一对应的关系)。
-
在这个线程上正在执行的每一个方法都对应着一个栈帧。
-
栈帧是一个内存块,是一个数据集,维系着方法执行过程中的各种数据信息。
-
- 栈的运行原理。
- jvm对栈的操作有两个,出栈和入栈,遵循“先进后出,后进先出”的原则。
- 在一条活动的线程中,一个时间点上,只有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(current frame),与当前栈帧对应的方法是当前方法(current method),当以这个方法的类就是当前类(current class)。
- 执行引擎运行的所有字节码指令只针对当前当前的栈帧进行操作。
- 如果在该方法中调用其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
- 不同的线程所包含的栈帧是不允许相互引用的,即不可能在一个线程的栈帧中引用另外一个线程的栈帧。
- 如果当前方法调用了其他的方法,方法返回之前,当前栈帧会返回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧称为当前的栈帧。
- java方法有两种返回的方式,一种是正常的函数返回,使用return命令,另一种是异常的抛出,不管使用哪种方式,都会导致栈帧被弹出。
- 栈帧的基本结构
- 局部变量表(local variables):变量的声明。
- 操作数栈(operand stack):表达式栈。
- 方法返回地址(return address):方法正常退出或者异常退出的定义。
- 动态链接(dynamic linking):指向运行时常量池的方法引用。
- 一些附加信息。
- 栈帧的大小决定线程中的栈可以存放几个栈帧,而栈帧的大小又由以上的几部分决定。局部变量表影响最大。
4.3,局部变量表
- 局部变量表也叫做局部变量数组或者本地变量表。(是一个一维数组)
- 定义为一个数字数组,主要用于存放参数和定义在方法体中的局部变量,这些数据类型包括各种基本数据类型(8中基本数据类型),对象的引用(reference),以及returnaddress(返回值类型)类型。
- 由于局部变量表是建立在线程的栈上面,是线程的私有数据,因此不存在数据安全问题。(当多个线程共同操作共享数据会存在线程安全问题)
- 局部变量表的大小是在编译器编译期间确定下来的,并且保存在方法的code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小所决定,一般来说,栈越大,方法嵌套调用次数就越多,对于一个函数而言,他的参数或者定义的变量越多,使得局部变量表膨胀,他的栈帧就越大,以满足方法调用所需传递信息增大的需求,进而函数调用会占据更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表只在当前的方法调用中有效,在方法执行的时候,虚拟机会通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束后,就会随着方法栈的销毁而不存在,局部变量表也会销毁。
public class TestLocal {
public static void main(String[] args) {
int i=0;
int num=10;
String string=new String("abc");
}
}
//对于上面代码的局部变量表,有4个元素,args,i,num,string四个变量
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 args [Ljava/lang/String;
2 14 1 i I
5 11 2 num I
15 1 3 string Ljava/lang/String;
Code:
stack=3, locals=4, args_size=1
//Code的属性locals保存局部变量表最大的长度4。
- 关于slot的理解
- 参数值的存放总是在局部变量表数组的index0开始,到数组长度-1的索引结束。
- 局部变量表,最基本的存储单元是slot,(变量槽)。
- 局部变量表存储在编译期间可以确定的各种基本数据类型,引用类型,returnaddress的返回类型。
- 在局部变量表中,32位以内的变量只占用一个slot,包括returnaddress类型,64位类型(long,double)占用两个slot。
- byte,short,char在存储前被转换为int,boolean也被转换为int,0标示false,非零标示true.
- long,double占据两个slot。
- jvm会为局部变量表中的每一个slot分配一个索引,通过这个索引可以成功的访问到局部变量表中这个变量的值。
- 当一个实例方法被调用的时候,他的方法参数和方法体内部定义的局部变量将会按照顺序(变量的声明顺序)复制到局部变量表的每一个slot上面。
- 如果要访问局部变量表中一个64位的变量的值时,只需要使用前一个索引即可,即开始索引。
- 如果当前帧是由实例方法或者构造方法创建的,那么该对象引用this将会存放在index为0 的slot处(代表调用该方法的那个对象,而构造器中的this代表当前正在创建的那个对象)。其余的参数按照参数顺序进行排列存放。
- 局部变量表中也存在this变量,在静态的方法中不可以用this(类调用静态方法),因为this不存在与当前方法的局部变量表中,构造方法,普通方法可以使用this是应为在他们的局部变量表中有this的声明,String也是引用类型,占据一个slot。
- 在静态方法中不能使用this变量,是应为在静态方法中,this变量不存在于其局部变量表中。注意,局部变量表中没有方法的返回类型变量,只有方法的形参列表和方法里面的局部变量。
- 局部变量表的理解
- 我们创建的一个类经过编译,之后再反编译得到的类信息如下,本类中有6个方法:
- 首先看main()静态方法:
- Code信息
- 局部变量表信息
在这里因为main()方法是静态方法,而静态方法是属于类的,所以静态方法的局部变量表中没有this变量。也就是说在静态方法中不可以使用this变量。
- 构造方法的局部变量表
构造方法的局部变量表就相当于当前需要创建对象的本身。
- 普通方法的局部变量表
Test01()方法的局部变量表中有this变量,所以在普通方法中可以使用this变量,还有两个局部变量a和b。
- 带参数的方法
Test02()有两个形参,所以在局部变量表中有num1和num2两个形参的说明所以test02()的局部变量表长度是4.
- Double和float类型
Test04()方法中有double类型的变量,其占据两个变量槽,所以序号是1和2,但是访问时只需要用到第一个序号即可,long类型也占两个变量槽。
注意:this变量会放在索引为0的变量槽处。静态方法除外。静态方法和非静态方法的在变量方面的区别就是非静态方法中有this变量,而今静态方法中没有,并且this变量在0的位置。
- Slot的重复利用问题
- 栈中局部变量表中的槽位是可以重复利用的,如果一个变量过了其作用域,那么在其作用域后声明的新的局部变量就很有可能重复用过期的局部变量表中的槽位,从而达到节省资源的目的。
现在程序中有4个变量(args,a,b,c),但是局部变量表的长度是3,因为变量b的作用域在大括号里面,所以变量c会占据变量b所开辟的数组空间。变量b,c的索引号都是2,变量b起始地址是4,长度是4(这里的长度指的是变量作用域的长度,在指令中对应的序号),变量c的起始是12,作用域长度是1。变量b在出了大括号就被销毁,但是在数组中开辟的空间还在,因此变量c使用的是变量b开辟的slot位置。因此c的index和b的index数值上一样。
- 静态变量和局部变量的对比
- 按照数据类型分类:基本数据类型,引用数据类型。
- 按照在类中声明的位置:
- 成员变量,在使用前,默认都经历过初始化赋值,成员变量又分为类变量和实例变量。
- 类变量:在linking和prepare阶段,给变量默认赋值,initial阶段:给类变量显示赋值,即静态代码块赋值,如果没有静态代码块显式赋值,直接使用默认值即可。
- 实例变量:随着对象的创建,会在堆空间分配实例对象的空间,并进行默认赋值。
- 局部变量:在使用前,必须进行显示赋值,否则编译不会通过。因为局部变量也是存储在局部变量表中,局部变量表是一个数组,书序需要初始化。
- 成员变量,在使用前,默认都经历过初始化赋值,成员变量又分为类变量和实例变量。
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,在方法执行时,虚拟机就使用局部变量表完成方法的传递。
- 局部变量表中也是最重要的垃圾回收根节点,只要被局部变量表中直接引用或者间接引用的对象都不会被回收。
参考资料:
[1] 深入理解java虚拟机。周志明