JVM 全面深入
时间:2022-10-17 07:00:01
- 总结
- java如何操作程序
编译:.java编译成源文件.class字节码文件
打包:.class字节码文件打包成jar包或者一个war包
运行:使用java -jar等待命令操作程序,启动一个jvm进程
类加载:使用类加载器.class加载字节码文件jvm中
执行:jvm字节码执行引擎开始执行main方法
- 什么情况下触发类加载?
在代码中使用此类。
jvm启动,先找main该方法的类别jvm然后执行内存main方法。
main在执行方法的过程中,加载该类。
- 类加载过程
加载阶段:
- 这样的二进制字节流通过类的全限定名获得定义
- 将字节流所代表的静态存储结构转换为元数据区运行时的数据结构
- 在内存中生成代表这一类的代表java.lang.Class作为元数据区各种数据的访问入口。
在加载子类之前,应加载和初始化父类
只负责加载,操作由执行引擎执行
链接阶段:
验证:根据jvm加载的规范、校准和验证.class文件是否符合规范,防止jvm文件被篡改
准备:非给类final修饰的static默认情况下,变量分配内存空间进行初始化。
解析:将常量池内的符号引用替换成直接引用
直接引用是直接指向目标的指针,如System.out.println(“haha”)
初始化阶段:
执行clinit构造方法,javac编译器,器static变量和static按顺序合并构造器代码。
假如这类有父类,jvm父类将先执行clinit方法。
Jvm会保证clinit加载方法,同步加锁,保证类只加载一次。
- 对象的创建过程
根据new参数在常量池中定位一类符号
如果没有符号没有被引用,说明类还没有被添加,那么类被添加、分析和初始化
jvm虚拟机作为中分配内存
将分配的内存,初始化为0值,默认初始化
调用对象的构造方法
将局部变量表中的变量指向这个对象
- 对象在内存中的内存布局
- 对象头
markwork部分
锁状态标志:对象的加锁状态分为无锁、偏向锁、轻量级锁、重量级锁几种标记。
持有锁的线程: 持有当前对象锁定的线程ID。
对象HashCode
GC分代年龄: 对象每经过一次GC还存活下来了,GC年龄就加1。
类指针: 可通过对象找到类元信息,用于定位对象类型。
数组长度: 当对象是数组类型的时候会记录数组的长度。
- 实例数据
对象实例数据才是对象的自身真正的数据
主要包括自身的成员变量信息,同时还包括实现的接口、父类的成员变量信息。
- 对齐填充
内存大小是8字节的倍数
如果对象自身的信息大小没有达到申请的内存大小,那么这部分是对剩余部分进行填充。
- Object o=new Object()占了多少内存
如果jvm默认开启了UseCompressedClassPointers类型指针压缩
那么首先new Object()占用16个字节
markword占8字节 + classpointer占4字节 + 实例数据占0字节 + 补齐4字节
然后Object o有一个引用,这个引用默认开启了压缩,所以是4个字节(每个引用占用4个字节)
所以一共占用20个字节(byte)
如果jvm没开启CompressedClassPointers类型指针压缩
那么首先new Object()占用8(markword) + 8(class pointer)+ 0(instance data)+0(补齐为8的倍数)16个字节,然后加引用(因为jvm默认开启UseCompressedClassPointers类型指针压缩,所以默认引用是占4字节,但这里没启用压缩,所以为8字节)占的8个字节=24个字节
- 类加载器
启动类加载器
Bootstrap ClassLoader,c++编写,负责加载我们机器上安装的java lib目录下的核心类(rt.jar、resources.jar),用于提供jvm自身需要的类
扩展类加载器
ExtClassLoader,java编写,继承自ClassLoader,加载lib\ext目录下的类,允许开发者通过引用来操作加载器。
应用程序类加载器
AppClassLoader,java编写,继承自ClassLoader,加载classpath环境变量指定的路径中的类。该类是程序中默认的类加载器。通过ClassLoader.getSystemClassLoader()方法可以获取到该加载器。
自定义类加载器
根据个人需求定制类加载器,可以加载指定路径的class文件
为什么要自定义类加载器?隔离加载类、修改类的加载的方式、扩展加载源、防止源码泄漏
- 获取类的加载器
clazz.getClassLoader() 获取当前类的ClassLoader
Thread.currentThread().getContextClassLoader() 获取当前线程上下文的ClassLoader
ClassLoader.getSystemClassLoader() 获取AppClassLoader
ClassLoader.getSystemClassLoader().getParent() 获取ExtClassLoader
- 双亲委派机制
- 为什么要用双亲委派机制
避免类的重复加载
保护核心api,防止被篡改
比如自己程序中写一个java.lang.String,类加载器应该加载谁呢?
通过双亲委派模式,就会委派到BootStrapClassLoader,先加载java内置的java.lang.String
- 什么是双亲委派机制
如果一个类加载器收到了类加载请求,它不会自己先去加载,而是把这个请求委托给父类加载器去执行
如果父类加载器还存在其父类记载器,则递归向上委托,直到请求最终到达顶层的BootstrapClassLoader。
如果父类加载器可以完成类加载,就成功返回;如果父类记载器加载失败,则下推递归交给子类加载器去加载。
特别:Tomcat打破了双亲委派机制,每个webapp类加载器,只加载当前应用的类,不会上朔到父类加载器。
- Tomcat类加载器
Tomcat自定义了Common、Catalina、Shared等类加载器,其实就是用来加载Tomcat自己的一些核心基础类库的。
然后Tomcat为每个部署在里面的Web应用都有一个对应的WebApp类加载器,负责加载我们部署的这个Web应用的类
至于Jsp类加载器,则是给每个JSP都准备了一个Jsp类加载器。
而且大家一定要记得,Tomcat是打破了双亲委派机制的
每个WebApp负责加载自己对应的那个Web应用的class文件,也就是我们写好的某个系统打包好的war包中的所有class文件,不会传导给上层类加载器去加载。
- jvm内存区域
- 为什么需要划分内存区域
类加载后放到内存哪里?
方法运行局部变量放哪里?
代码里面创建的对象放到哪里?
所以必须要划分不同的区域,存取这些数据。
- 存放类的方法区
jdk1.8之前叫方法区,之后叫元数据区
jvm加载.class类文件,会加载到这里
- 程序计数器(PC寄存器)
Pc寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
字节码执行引擎执行.class文件的时候,会记录字节码指令的执行位置,每个线程都有一个程序计数器,代表当前线程执行到的代码位置。
为什么需要pc寄存器?
因为cpu需要不停的切换各个线程,这时候,切换回来以后,就得知道接着从哪开始继续执行。
Jvm的字节码解释器就需要通过改变pc寄存器的值来明确下一条应该执行什么样的字节码指令。
线程切换上下文需要保存字节码指令的位置,恢复线程执行,又从哪个字节码指令的位置继续执行代码。
比如main线程执行main方法,main线程的程序计数器就是指向当前字节码指令执行到的位置信息。
- 虚拟机栈
每执行一个方法就形成一个栈帧,最后执行的方法执行完就释放栈帧。
每个栈帧里,有局部变量表、操作数栈、动态链接、方法出口
每个线程有自己的独立使用的虚拟机栈
- 出现的背景
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
- 栈和堆
栈是运行时的单位,堆是存储的单元。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
- Java虚批机桟是什么?
Java虚似机栈(Java Virtual Machine Stack) ,早期也叫Java栈。
毎个线程在创建吋都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame) ,对应着一次次的Java方法凋用。
- 生命周期
生命周期和线程一致。
- 栈的特点(优点)
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM直接对Java栈的操作只有两个:
每个方法执行,伴随着进栈(入栈、压栈)
执行结束后的出栈工作
对于栈来说不存在垃圾回收问题,栈存在OOM,不存在GC (因为只有进栈出栈的操作)
- 栈中可能出现的异常
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虛拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutofMemoryError异常。
- 栈帧的内部结构
毎个栈帧中存储着:
局部变量表(Local variables)
操作数栈(operand stack) (或表达式栈)
动态链接(Dynamic Linking) ( 或指向运行吋常量池的方法引用)
方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
一些附加信息
- 局部变量表(Local variables)
局部变量表也被称之为局部变量数组或本地变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference) ,以returnAddress类型。
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 关于Slot的理解
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。局部变量表,最基本的存储单元是Slot (变量槽)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot。
byte、short 、char在存储前被转换为int,boolean 也被转换为int,0表示false ,非0表示true。
long 和double 则占据两个Slot。
Jvm会为局部变量表中的每一个slot分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
当一个示例方法被调用的时候,它的方法参数和方法体内部定义的变量将会按照顺序被复制到局部变量表中的每一个slot上。
如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(long、double)
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
- Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期的局部变量的槽位,从而达到节省资源的目的。
- 变量的说明
变量的分类:基本数据类型、引用数据类型
按照在类中声明的位置分:
成员变量
类的静态成员变量:linking阶段的准备阶段默认初始化,初始化阶段显示初始化赋值
类的实例成员变量:随着对象的创建,会在空间中分配空间,默认初始化。
局部变量
在使用前,必须要进行显示赋值,否则编译不通过。
- 局部变量表的补充说明
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。
在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
- 操作数栈
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。它的底层数据结构是数组。比如执行复制、交换、求和等操作。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push) 和出栈(pop)操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
- 动态链接
指向运行时常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking) 。比如: invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference) 保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
大部分字节码指令在执行时都会进行常量池的访问
Constant pool常量池在运行时期存到方法区(运行时常量池)
通过引用去调用,几份一起调用对应地址都一样,不然浪费
比如多态,编写的父类,运行的子类
为什么需要常量池呢?
常量池的作用,就是为了提供一些符号和常量,便于指令的识别。
- 方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关(在编译期间确定还是运行期间确定)。
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding) 。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目。标方法究竟是哪–个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
早期晚期绑定
随着高级语言的横空出世,类似于Java-样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格.上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性
既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虛函数(C+ +中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字final来标记这个方法。
final就是不能被重写了,在编译期就确定了。
非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器 、父类方法都是非虚方法,其他方法称为虚方法。
子类对象的多态性的使用前提:1.类的继承关系。2.方法的重写
虚拟机中提供了以下几条方法调用指令
普通调用指令:(1、2非虚方法)
invokestatic: 调用静态方法,解析阶段确定唯一方法版本
invokespecial: 调用方法、私有及父类方法,解析阶段确定唯一方法版本
invokevirtual: 调用所有虚方法
invokeinterface: 调用接口方法
动态调用指令:
invokedynamic: 动态解析出需要调用的方法,然后执行(JDK7新增)
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
关于invokedynamic指令
JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。
但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指 令的生成,在Java中才有 了直接的生成方式。
Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
动态类型语言和静态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
方法重写的本质
Java语言中方法重写的本质:
1.找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
2.如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3.否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
IllegalAccessError介绍:
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虛方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
- 方法返回地址
主要针对于正常退出的情况
存放调用该方法的pc寄存器的值。
一个方法的结束,有两种方式:
正常执行完成
出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。交给执行引擎,去执行后续的操作
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于
通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
当一个方法开始执行后,只有两种方式可以退出这个方法:
1、执行引擎遇到任意-一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
在字节码指令中,返回指令包含ireturn (当返回值是boolean、byte、 char、short和int类型时使用)、lreturn、 freturn、 dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
2、在方法执行的过程中遇到了异常(Exception) ,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
- 一些附加信息
栈帧中还允许|携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
- 栈的相关面试题
举例栈溢出的情况?(StackOverflowError)
通过-Xss设置栈的大小,超过了,就栈溢出
调整栈大小,就能保证不出现溢出吗?
不能,理论上只能保证出现的时间更晚一点,栈的深度更深一点
分配的栈内存越大越好吗?
挤占栈空间,能运行的线程数变少
垃圾回收是否会涉及到虚拟机栈?
不会,虚拟机栈直接出栈
- 本地方法栈
native方法的执行,也需要存放栈帧信息。比如hashcode()、Thread.start0()
- 堆区
方法里面new一个对象,这个对象就会存放在堆,方法对应的栈帧,局部变量表有一个引用会指向这个对象。
- 核心概念
一个JVM实例只存在一个堆内存,堆也是Jav