1 类加载器及加载过程
1.1 类加载子系统作用
-
负责从文件系统或网络中加载class文件,class文件开头有特定的标识
-
classLoader只负责class文件的加载,至于它是否可以运行,由执行引擎决定
-
加载的类信息存到内存--方法区,除了类信息,方法区还会存放运行时常量池信息,还可能包括字符串字面量和数字常量
常量池运行时加载到内存中,即运行时常量池
-
类加载器加载字节码文件到内存
1.2 类加载器角色
理解下来就是类加载器加载class文件,在方法区中生成了一个Class对象以及方法信息等,然后通过Class对象可以获取到对应的类加载器,也可以通过实例化在堆中生成对应的对象
1.3 类加载的执行步骤
-
加载
通过一个类的全限定名获取定义此类的二进制文件
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据访问入口
-
链接
-
验证
确保Class文件的字节流中包含信息符合虚拟机要求,保证加载类的正确性
主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证
-
准备
为类中静态变量分配内存,并设置静态变量初始值,即零值
不包括final修饰的static,因为final在编译的时候就分配内存,准备阶段会显式初始化
不会为实例变量分配初始化
,类变量会分配在方法区中,而实例变量会随着对象一起分配到堆中 -
解析
虚拟机将常量池中的
符号引用替换为直接引用
的过程(符号引用理解为一个标识,而在直接引用直接指向内存中的地址)解析一般会随着JVM的初始化结束之后再执行
解析动作主要针对类或接口、字段、类方法、接口方法和方法类型等
-
-
初始化
初始化阶段就是执行类构造器方法
<clinit>
的过程<clinit>
方法不需要定义,是javac编译器自动收集类中的所有静态变量赋值和静态代码块中的语句
合并而来也就是说如果一个类中没有静态变量以及静态代码块,也就不存在
<clinit>
方法JVM必须保证一个类的
<clinit>
方法在多线程下被同步加锁
1.3.1 初始化测试
public class Test01 {
private static int num = 1;
static {
num = 2;
}
public static void main(String[] args) {
System.out.println(Test01.num);
}
}
使用jclasslib查看编译之后的结果:
说明:一个类中可能没有clinit方法,但是一定存在构造器(init)方法,即使自身没有构造器,也会使用默认的无参构造器
1.4 类加载器分类
通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器
- 引导类加载器:用来加载java核心类库,无法被java程序直接引用,底层使用C/C++编写
- 扩展类加载器:用来加载java的扩展库,JVM的实现会提供一个扩展库目录,该类加载器在此目录里面查找并加载java类
- 系统类加载器:根据java应用的类路径(CLASSPATH)加载java类,一般java应用都是通过它来进行加载
- 用户自定义类加载器:通过集成java.lang.ClassLoader类的方法实现
1.4.1 测试类加载器
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
System.out.println(classLoader);
ClassLoader loader = ClassLoaderTest.class.getClassLoader();
System.out.println(loader);
ClassLoader parent = loader.getParent();
System.out.println(parent);
ClassLoader parent1 = parent.getParent();
System.out.println(parent1);
}
}
结果:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@2ff4acd0
null
获取系统类加载器就是AppClassLoader
而我们自定义的类的加载器也是AppClassLoader,AppClassLoader的父加载器是ExtClassLoader
ExtClassLoader的父加载器应该是引导类加载器,但是由于引导类加载器是通过C/C++编写,因此获取为null
也可以看出获取类加载器的方式:
ClassLoader.getSystemClassLoader()
通过ClassLoader的静态方法获取系统类加载器
ClassLoaderTest.class.getClassLoader()
通过对象的Class对象获取
getParent()
通过一个类加载器获取其父类加载器
new ClassLoaderTest().getClass().getClassLoader()
ClassLoader loader = Thread.currentThread().getContextClassLoader();
也可以通过当前线程获取线程上下文的classLoader
1.5 双亲委派机制
JVM对class文件采用的是按需加载
的方式,当需要使用该类时才会将它的class文件加载到内存生成Class对象;而加载某个类的class文件时使用的就是双亲委派模式
假设一种场景,我们自定义一个String类,且包名也是java.lang
package java.lang;
public class String {
static {
System.out.println("我是自定义的String类");
}
}
再另外一个类中创建一个String对象进行测试:
public class StringTest {
public static void main(String[] args) {
String str = new String();
System.out.println("hello");
}
}
结果中并没有执行自定义String类中的静态代码块,说明并没有使用我们自定义的String类
那么之所以没有使用自定义的String类就是由于双亲委派机制
1.5.1 双亲委派机制工作原理
- 如果一个类加载器收到类加载请求,它不会自己先去加载,它会先把这个请求传递给它的父类加载器去执行。
- 如果父类加载器还存在父类加载器,则继续向上委托,依次传递,请求最终到达顶层的启动类加载器
- 如果父类加载器可以完成类加载的任务,就成功返回
- 倘若父类加载器无法完成这个加载任务,子加载器就会去尝试加载,如果还不行,就子子加载器尝试加载,直到成功加载
1.5.2 优势
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改(如java.lang.String)
1.5.3 沙箱安全机制
上次的例子自定义的String类再添加main方法执行
public class String {
static {
System.out.println("我是自定义的String类");
}
public static void main(String[] args) {
System.out.println("test");
}
}
结果:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
说明引导类加载器发现该类在java.lang包下就准备加载,然而并不会加载自定义的String类,在原有的String类中并没有main方法就会报错
1.6 补充
在JVM中表示两个class对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader实例对象必须相同
类的使用方式:
主动使用:
- 创建类的实例
- 访问类或接口的静态变量,或对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName())
- 初始化一个类的子类(调用父类的构造器)
- Java虚拟机启动时被表明为启动类的类
- 动态语言支持
被动使用:
除去主动使用就是被动使用
主动使用和被动使用的区别:被动使用不会导致类的初始化
2 PC寄存器
2.1 扩展
2.1.1 运行时数据区概述
- 程序计数器:记录下一条字节码执行指令,实现分支循环跳转、异常处理、线程恢复等功能
- 虚拟机栈:存储局部变量表、操作数栈、动态链接、方法返回地址等信息
- 本地方法栈:本地方法调用
- 堆:所有线程共享,几乎所有对象实例都在堆中分配内存
- 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据
2.1.1.1 不同区和线程之间的关系
- 线程私有:程序计数器、虚拟机栈、本地方法栈
- 线程共享:堆、方法区
2.1.1.2 堆和栈的区别
-
物理地址
堆:对象分配物理地址不连续,性能相对栈较弱
栈:先进后出,物理地址连续,性能相对堆较好
-
内存分配
堆:在运行时分配,大小不固定
栈:在编译时分配,大小固定
-
存放内容:
堆:对象的实例和数组,更关注数据存储
栈:局部变量、操作数、动态链接、方法返回地址等信息
-
程序可见性
堆:所有线程共享,可见
栈:线程私有,只对线程可见,生命周期和线程相同
2.1.1.3 深拷贝和浅拷贝
- 浅拷贝:增加一个指针指向已有的内存地址
- 深拷贝:增加一个指针指向新开辟的一块内存空间
原内存发生变化,浅拷贝随之变化;深拷贝则不会随之发生变化
2.1.2 JVM中的线程
JVM中的每个线程都和操作系统的本地线程直接映射(类似于用户线程和内核线程的映射,一对一
)
一旦本地线程初始化成功(分配完资源,创建了TCB),它就会调用Java线程中的run()方法
2.2 PC寄存机概述
寄存器
用于存储指令相关的现场信息,CPU只有把数据装载到寄存器才能运行
JVM的PC寄存器是对物理PC寄存器的一种抽象模拟
PC寄存器用来存储指向下一条指令的地址,也就是将要执行的代码,由操作引擎读取指令
特点:
- 运行时数据区最小的一块内存区域,几乎可以忽略不计,也是运行速度最快的内存区域
- 每个线程有一个私有的程序计数器,线程之间不互相影响
- 运行时数据区唯一不会出现OOM的区域,没有垃圾回收
- 程序计数器会存储当前线程正在执行的Java方法的JVM指令地址
- 如果正在执行本地方法,则计数器的值应为空(undefined)
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
2.3 测试
public class PcRegisterDemo {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
String s = "hello";
System.out.println(i);
System.out.println(k);
}
}
对这个类的class文件进行反编译:javap -v PcRegisterDemo.class
结果:
首先方法中记录着每条指令的指令地址和操作指令,而PC寄存器记录着下一条操作指令的指令地址
执行引擎就会根据PC寄存器的指令地址去虚拟机栈中操作局部变量表等
最后转化为机器指令,让CPU去执行机器指令
2.4 相关问题
PC寄存器存储字节码指令的作用是什么?
- 线程是一个个的顺序执行流,CPU需要不停的切换各个线程,切换回来的时候就知道接着从哪开始执行
- JVM的字节码解释器需要通过
改变PC寄存器的值
来明确下一条应该执行什么样的字节码指令 - 记录下一条字节码执行的指令,实现分支循环跳转、异常处理、线程恢复等功能
PC寄存器为什么设定为线程私有?
- CPU为每个线程分配时间片,多线程在特定的时间段内只会执行某一个线程的方法,CPU会不停地进行任务切换,线程需要中断、恢复(
中断指令,外中断
) - 各个线程、PC寄存器记录的当前执行指令地址可以独立进行计算,防止互相干扰
3 虚拟机栈
栈解决程序的运行问题,即程序如何执行,或者如何处理数据
每个线程在创建时都会创建一个虚拟机栈,内部保存着一个个的栈帧
线程私有,生命周期和线程一致
主管Java程序的运行,保存方法的局部变量(8种基本数据类型、对象的引用地址
),部分结果,并参与方法的调用和返回
3.1 栈的特点
-
快速有效的存储方式,访问速度仅次于程序计数器
-
JVM直接对Java栈的操作只有两个
- 每个方法执行,伴随着进栈
- 执行结束之后的出栈
-
栈不存在垃圾回收,但是存在OOM、栈溢出
栈的大小是动态或者固定不变的
- 如果是动态扩展,可能无法申请到足够内存出现OOM
- 如果是固定,可能线程的请求栈容量超过固定值,出现StackOverflowErroe
3.2 测试栈溢出
在启动配置中配置JVM的启动参数修改栈内存的大小 :-Xss MaxStackSize(如256K)
测试程序为:
public class StackErrorDemo {
private static int count = 0;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
这里主要是通过不断的递归调用main函数来测试栈的深度
当设置栈内存为256k时:
栈的深度为2457时便出现了栈溢出
当设置栈内存为1m时:
栈的深度为11413时才出现栈溢出
3.3 栈的存储结构
每个线程都有自己的栈,栈中的数据是以栈帧的格式进行存储
栈帧:
- 在这个线程上执行的每个方法都对应着一个栈帧
- 栈帧是一个内存区快,是一个数据集,维持着方法执行过程中的各种数据信息
- JVM对虚拟机栈的操作只有压栈和弹栈,且遵循"先入先出"
- 一条活动的线程中,一个时间点上,只有一个活动的栈帧;只有当前正在执行的方法的栈顶栈帧是有效的,也就是
当前栈
,对应的方法是当前方法
,对应的类是当前类
- 执行引擎运行的所有
字节码指令只针对当前栈帧
进行操作(同样,寄存器中存储的也是当前栈的指令地址
) - 如果方法中调用了其他方法,对应的新栈帧就会被创建出来,放在顶端成为新的当前栈
3.4 栈运行原理
当前方法调用了其他方法,方法返回之际,当前栈帧会回传方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的当前栈
不同线程包含的栈帧不允许相互引用
Java方法有两种返回方式:
-
一种是正常的函数返回,使用return指令
-
一种是抛出异常( 没有处理的exception,error无法被处理 ),不管哪种方式,都会导致栈帧被弹出
从而可以理解当前栈出现异常且没有处理时,当前栈就会被弹出,异常就会交给前一个栈帧...一直这样进行下去直到main方法,如果main方法也没有处理,就停止JVM
这里的处理就是指的try...catch...
3.5 栈帧的内部结构
- 局部变量表
- 操作数栈
- 动态链接(指向运行时常量池的方法引用)
- 方法返回地址(方法正常退出或异常退出的定义)
- 一些附加信息
3.5.1 局部变量表
定义为一个数字数组
,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用以及return address类型
局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题
(因为没有线程之间数据共享)
局部变量表容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中,方法运行期间不会改变
3.5.1.1 测试局部变量表的容量
public class LocalVariblesDemo {
public static void main(String[] args) {
LocalVariblesDemo demo = new LocalVariblesDemo();
int num = 10;
demo.test01();
}
public void test01() {
String str = "hello";
System.out.println(str);
}
}
通过jclasslib反编译之后观察结果:
这里可以看出有局部变量表的容量,以及局部变量表中存储的数据信息,包括方法的参数,以及创建的局部变量
栈帧的大小主要受局部变量表大小的影响
,从而当栈内存大小确定之后,每个栈帧的大小决定了整个栈中栈帧的数量
当方法调用结束之后,随着方法栈帧的销毁,局部变量表也会随之销毁
3.5.1.2 字节码方法内部结构解析
当点击方法时可以看到
1、name是定义该方法的名字
2、描述符是定义该方法的参数,
[
代表数组,L
代表引用类型,从而说明参数是一个String数组3、访问标志也就是访问控制符的关键字
字节码也就是反编译的字节码指令地址和指令
异常表就是该方法出现的异常信息
杂项里面包括操作数栈大小、局部变量表大小以及字节码长度
Java代码的行号和字节码指令行号的对应关系
这里主要存储局部变量表的信息
name代表局部变量的名字,序号代表局部变量在局部变量表数组中的索引位置
起始PC代表的是局部变量在字节码指令中的行号,而长度代表的是局部变量从创建开始之后作用作用域范围(
可以看出如果在方法中定义的变量如果没有加代码块,起始PC+长度刚好等于字节码的长度
)
3.5.1.3 变量槽Slot
局部变量表最基本的存储单位就是Slot(变量槽,本质上也就是数组中的一个个位置)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
在局部变量表中,32位以内的类型只占用一个Slot(包括returnAddress类型和引用类型),64位类型(long和double)占用两个Slot、
- byte、short、char在存储前会被转化为int,boolean也会被转为int
怎么去理解32位以内的占一个Slot呢?
我们知道局部变量表是一个int数组,int类型占用4个字节(也就是32bit,也就是32位)
而long和double占用8个字节,从而必须要占用两个Slot
JVM会为局部变量表中的每一个Slot分配一个索引,通过这个索引即可成功访问到局部变量表中指定的局部变量
当一个实例方法被调用时,它的方法参数和方法体内定义的局部变量会按照顺序
被复制到局部变量表中的每个Slot上
这里需要注意的是如果存储的是long或者double类型,只需要使用前一个索引
如果当前帧是由构造方法或实例方法创建,那么该对象引用this会存放在index为0的Slot处,其余的参数按照参数表顺序进行存储
测试构造方法或实例方法的Slot:
init构造器方法和test01实例方法局部变量表索引为0都是变量this,从而也说明了为什么静态方法不能使用this变量
Slot的重复利用:
局部变量表中的槽位是可以重用的
,如果一个局部变量过了其作用域,那么在其作用域之后申明的新局部变量就会可能复用过期局部变量的槽位,从而节省资源
public void test02() {
{
int a = 1;
System.out.println(a);
}
int b = 3;
}
对于test02方法的局部变量表应该是什么样的呢?
索引为1的Slot先是被变量a使用,之后又被变量b使用
3.5.1.4 静态变量--局部变量
-
静态变量不需要在使用前就进行显式赋值,而局部变量在使用前必须要进行显式赋值
原因就是静态变量在链接--准备阶段会进行默认赋值
而局部变量不会进行默认赋值,所以使用前必须要进行赋值!
3.5.1.5 补充
栈帧中与性能调优关系最密切的就是局部变量表
,在方法执行时虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表直接或间接引用的对象都不会被回收
3.5.2 操作数栈
操作数栈是通过数组实现的(逻辑结构是栈,存储结构是数组
),因此操作数栈既有栈的特点(先进后出),也有数组的特点(按照顺序存放,带有索引)
操作数栈在方法执行过程中根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈
- 比如:指定复制、交换、求和等操作
操作数栈主要是用于保存计算过程的中间结果
,同时作为计算过程中变量临时的存储空间
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也被创建出来,这个方法的操作数栈是空的(空数组)
每一个操作数栈都会拥有一个明确的栈深度
用于存储数值,其所需的最大深度在编译期就确定好了
,保存在方法的Code属性中,为max_stack的值
编译期已确定,且运行时不会变化
栈中的任何一个元素都可以是Java的任意数据类型(和局部变量表的规则一致)
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问
,而只能通过标准的入栈和出栈进行操作访问(从而说明操作数栈虽然是用数组实现的,然而却需要使用栈的特性
)
如果被调用的方法带有返回值的话,其返回值也会被压入当前栈帧的操作数栈中
,并更新PC寄存器中下一条需要执行的字节码指令
Java的虚拟机解释引擎是基于栈的执行引擎,其中的栈就是指操作数栈
3.5.2.1 测试操作数栈
public class OperandStackDemo {
public static void main(String[] args) {
byte i = 15;
int j = 20;
int k = i + j;
}
}
先来分析下main方法的字节码指令:
从指令地址2、5中可以看出中可以看出byte、short、char、int最后都是以int来进行保存
指令步骤解析:
对于指令地址0和2解析:
指令地址为0时,将15放入操作数栈中,此时局部变量表为空
指令地址为2时,将15从栈顶弹出放入局部变量表中,操作数栈为空
之所以15放在局部变量表索引为1的位置,是因为这是一个实例方法,索引为0的位置是this
对于指令地址3和5解析:
指令地址为3时,将8压入栈顶
指令地址为5时,将8弹出栈顶存储在局部变量表中,操作数栈为空
对于指令地址6和7解析:
分别将局部变量表中索引为1和索引为2的局部变量压入操作数栈中
对于指令地址8和9解析:
指令地址为8时,执行引擎操作操作数栈,对两个数据进行求和,将最新的23压入栈顶
指令地址为9时,操作数栈弹出23存储到局部变量表中
3.5.2.2 i++和++i
- i++:现将i的值加载到操作数栈,再将i的值加1
- ++i:先将i的值加1,再将i的值加载到操作数栈
3.5.2.3 栈顶缓存技术
基于栈式架构的虚拟机所使用的的零地址指令更加紧凑,但完成一项操作的时候需要使用更多的入栈和出栈指令,也就意味着需要更多的指令分派次数和内存读写次数
栈顶内存技术将栈顶元素全部缓存在物理CPU的寄存器中
,依次降低对内存的读写次数,提升执行引擎的执行效率
3.5.3 动态链接
每一个栈帧内部都包含一个指向运行时常量池
的该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
在Java源文件被编译为字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中(比如一个方法调用了另外的其他方法时,就是通过常量池执行方法的符号引用来表示
),动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
注意是运行时常量池,不是class文件的常量池
3.5.3.1 测试动态链接和常量池的作用
public class DynamicLinkingTest {
int num = 10;
public void methodA() {
System.out.println("hello");
}
public void methodB() {
System.out.println("yes");
methodA();
num++;
}
}
对于methodB方法:
这里有个
invokevirtual
对应着#7
,从注释中可以看出这是在调用methodA那么
#7
到底是什么呢?
在编译之后可以看出这里存在
Constant pool
,也就是常量池,其中Methodref
代表方法引用,紧接着又指向#8调用#31,而#8又指向#32
从这里常量池的引用可以看出:
也就是
org/jiang/chapter01/DynamicLinkingTest
类中的methodA
方法,参数值为(),返回值为void
3.5.3.2 常量池理解
为什么要使用常量池呢?
运行时常量池中存储一份,可以供多个虚拟机栈进行调用,节省资源
实际上运行时常量池中存放的也是引用,真正的变量存储在heap中
常量池提供符号、常量便于指令识别(
如果把所有的类信息、方法信息在调用时直接加载到内存中,占用的空间就会特别大
)
常量池、运行时常量池和字符串常量池:
-
常量池
即class文件常量池,是class文件的一部分,用于保存编译时确定的数据
除了包含 类的版本、字段、方法、接口 等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种 字面量 (文本字符串、被声明为final的常量、基本数据类型的值)和 符号引用 (类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)
-
运行时常量池
当类加载到内存中后,JVM就会将class常量池中的内容存放到运行时常量池中
Java语言不要求常量一定只能在编译期产生,运行期也可能产生新的常量,这些常量被放在运行时常量池中
class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的string pool,以保证运行时常量池所引用的字符串与字符串常量池中所引用的是一致的
-
字符串常量池
字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到String pool中
String pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放
String pool在每个HotSpot VM的实例只有一份,被所有的类共享
3.5.4 方法的绑定机制
在JVM中将符号引用转换为调用方法的直接引用与方法的绑定机制相关
-
静态链接
当一个字节码文件被装载进JVM内部时,如果
被调用的目标方法在编译期可知且运行期保持不变
时,这种情况下将调用方法的符号引用转换为直接引用的过程为静态链接 -
动态链接
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转化为直接引用,由于这种引用转换过程具备动态性,被称为动态链接
方法的绑定是一个字段、方法或类在符号引用被替换成直接引用的过程,这仅仅发生一次
;静态链接和动态链接对应方法的绑定机制:
-
早期绑定(invokespecial)
被调用的目标方法如果在编译期可知,且运行期保持不变
,即可将这个方法与所属类型进行绑定,由于明确了被调用的目标方法究竟是哪一个,因此可以使用静态链接的方式将符号引用转换为直接引用 -
晚期绑定(invokevirtual、invokeinterface)
如果被调用的方法在编译期无法被确定下来,只能在程序运行期间
根据实际的类型绑定相关的方法,这种绑定方式称为晚期绑定
3.5.4.1 晚期、早期绑定测试
class Animal {
public void eat() {
System.out.println("动物进食");
}
}
interface Huntable {
void hunt();
}
class Dog extends Animal implements Huntable {
@Override
public void hunt() {
System.out.println("狗拿耗子");
}
@Override
public void eat() {
System.out.println("狗吃骨头");
}
}
class Cat extends Animal implements Huntable {
@Override
public void hunt() {
System.out.println("天经地义");
}
@Override
public void eat() {
System.out.println("猫吃骨头");
}
}
public class AnimalTest {
public void showAnimal(Animal animal) {
animal.eat();
}
public void hunt(Huntable h) {
h.hunt();
}
}
这里传入的参数animal和h体现了Java的多态性,在编译期间是无法具体确定参数的实际类型,只有当实际运行时才可以确定参数的类型,因此属于晚期绑定
对应的字节码指令:
invokevirtual
和invokeinterface
指令就是对方法或接口进行运用
#3
对应在运行时常量池的Huntable接口的hunt方法
而对于已经确定的类型就属于早期绑定
class Dog extends Animal implements Huntable {
public Dog() {
super();
}
@Override
public void hunt() {
System.out.println("狗拿耗子");
}
@Override
public void eat() {
System.out.println("狗吃骨头");
}
}
Dog的构造器中调用了父类的空参构造器,也就是Animal类的空参构造器,这是已经可以确定的事实,属于静态链接
对于之前学习到的Java支持封装、继承和多态等面向对象的特点,自然也就具备早期绑定和晚期绑定两种方式
Java中任意一个普通方法都具备虚函数
的特征,相当于C++语言中的虚函数(C++使用virtual来显式定义),如果在Java中不希望某个方法具有虚函数的特征,可以使用关键字final来标记
为什么使用final呢?
被final标记的方法无法被重写,从而也就不存在该类针对该方法实现的多态特点
从而说明虚函数的作用就是实现面向对象语言的继承和多态特点
3.5.4.2 虚方法和非虚方法
-
非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时不可变,这样的方法被称为非虚方法
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
-
虚方法
其他方法都是虚方法
其实我们知道区分非虚和虚方法的核心就在于:
- 类的继承关系
- 方法的重写
静态方法、私有方法、final方法明显不能被重写
实例构造器通过this传入参数调用其他的构造器本身也是确定的
通过super调用父类的方法(不是接口)本身也是确定的
3.5.4.3 方法调用指令
普通调用指令:
- invokestatic 调用静态方法,解析阶段确定唯一方法版本
- invokespecial 调用
<init>
方法,私有及父类方法,解析阶段确定唯一方法版本 - invokevirtual 调用所有虚方法
- invokeinterface 调用接口方法
其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法
动态指令:
-
invokedynamic
动态解析出需要调用的方法然后执行
直到Java8的Lambda表达式的出现,invokedynamic指令的生成在Java中才有了直接生成的方式
为了实现Java动态类型语言支持而做的一种改进
动态类型语言和静态类型语言有什么区别呢?
区别在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之就是动态类型语言
静态类型语言是判断自身的类型信息,
也就是变量本身就有类型信息
动态类型语言是判断变量值的类型信息,变量没有类型信息,
变量值才有类型信息
3.5.4.4 测试方法调用指令
class Father {
public Father() {
System.out.println("father构造器");
}
public static void showStatic(String str) {
System.out.println("father" + str);
}
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 showStatic(String str) {
System.out.println("son" + str);
}
public void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
showStatic("jiang");
Father.showStatic("xiaodi");
showPrivate("hello");
super.showCommon();
showFinal();
showCommon();
MethodInvoke mi = null;
mi.methodA();
}
public static void main(String[] args) {
Son son = new Son();
son.show();
}
}
interface MethodInvoke {
void methodA();
}
本质上就是在父类中定义了静态方法、final方法、构造器等,子类中也定义了静态方法等,然后利用子类对象调用父类和子类的方法
从字节码指令中对比指令和方法之间的关系:
前两个方法分别调用了父类和子类的静态方法,故使用invokestatic
第三个方法调用了子类的普通方法,故使用虚方法invokevirtual
第四个方法调用了父类方法,故使用invokespecial
第五个方法调用了父类的final方法,虽然这里使用的是invokevirtual,但并不是虚方法
最后一个调用了接口的方法,故使用invokeinterface
注意点:
由于子类中没有showCommon方法,其实
super.showCommon()
和showCommon()
都是调用的父类的方法但是因为第一次显式使用super调用,所以在编译期已经确定,不是虚方法;
而第二次假如Son子类重写了showCommon方法,就使用子类的方法,所以在编译期无法确定,属于虚方法
因此两个方法的调用指令不同
3.5.4.5 测试invokedynamic指令
interface Func {
boolean func(String str);
}
public class Lambda {
public void lambda(Func func) {
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> true;
lambda.lambda(func);
lambda.lambda(s -> false);
}
}
查看对应的字节码指令:
这里通过lambda表达式创建的对象就属于invokedynamic指令
lambda表达式本身我们不知道对象的类型,只有运行时才能确定对象的类型(不是多态,多态已经确定属于哪个父类或接口等)
3.5.4.6 方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做C
- 如果在类型C中找到与常量池中描述符合简单名称都相符的方法,则进行访问权限校验;如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回java.lang.IllegalAccessError异常
- 否则,按照继承关系从下至上依次对C的各个父类进行上一步的搜索和验证过程
- 如果最终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
3.5.4.7 虚方法表
在面向对象编程中,会很频繁的使用到动态分配,如果在每次动态分配的过程中都要重新在类的方法元数据中搜索合适目标的话就可以影响到执行效率,故为了提升性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现,使用索引表来代替查找
每个类中都有一个虚方法表,表中存放着各个方法的实际入口
那么虚方法表什么时候被创建呢?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方发表也初始化完毕
虚方法表理解
蓝色标记的方法比如clone()等由于没有重写,因此在虚方法表指向的就是Object类的方法
而白色标记的方法在Father类和Son类都有重写,在引用具体的对象类型时就调用不同虚方法表中的方法
注意:Father类和Son类有不同的虚方法表,表中指明了每个方法调用的到底是哪个类的该方法(是否重写过的方法)
3.5.5 方法返回地址
存放调用该方法的PC寄存器的值
PC寄存器中存储的该方法下一条指令的地址
假如A调用B,那么B的方法返回地址存储的就是在A调用B的那一步PC寄存器的值
当B执行结束时,B就将方法返回地址交给执行引擎,这时B弹栈,而执行引擎根据地址继续执行A
一个方法的结束有两种方式:
- 正常执行结束
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者的PC寄存器的值作为返回地址,及调用该方法的指令的下一条指令的地址
;而通过异常退出的,返回地址要通过异常表来确定,栈帧中一般不会保存这部分信息
3.5.5.1 正常完成出口
当执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
- 一个方法在正常调用完成后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
- 在字节码指令中,返回指令包含ireturn(当返回值为boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个rentun指令供声明为void的方法、实例初始化方法、类和接口初始化方法使用
3.5.5.2 正常返回地址测试
public class ReturnAddressTest {
public boolean booleanTest() {
return false;
}
public byte methodByte() {
return 0;
}
public short methodShort() {
return 0;
}
public char methodChar() {
return 'a';
}
public int methodInteger() {
return 0;
}
public long methodLong() {
return 0L;
}
public float methodFloat() {
return 0.0f;
}
public void methodViod() {
}
static {
int i = 10;
}
public void method2() {
try {
method1();
} catch (IOException e) {
e.printStackTrace();
}
}
private void method1() throws IOException{
FileReader reader = new FileReader("1.txt");
char[] buffer = new char[1024];
int len;
while ((len = reader.read(buffer)) != -1) {
String s = new String(buffer, 0, len);
System.out.println(s);
}
reader.close();
}
}
对应的字节码文件:
构造器、静态代码块以及返回值为void的方法使用的指令都是return
返回值为boolean、byte、int、char、short的方法使用的指令都是ireturn
返回值为long的方法使用的指令是lreturn
返回值为float的方法使用的指令是freturn
3.5.5.3 异常完成出口
在方法执行过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表
中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
异常处理表:
from
和to
代表的是字节码指令的范围
target
代表按照该位置的字节码指令进行处理
type
代表处理的异常类型
从之前例子中查看method2
方法的异常表、字节码指令:
异常表中已经标注了捕获异常的起始和终止位置,以及捕获之后跳转的位置
包括捕获的类型为IOException
从字节码中指令地址为
4
的位置可以看出当出现异常时会跳转到return
指令
3.5.5.4 总结
本质上方法的退出就是当前栈帧出栈的过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行
正常完成出口和异常完成出口的区别在于:
- 通过异常完成出口退出的不会给它的上层调用者产生任何的返回值】
4 本地方法栈
4.1 本地方法接口
一个本地方法就是一个Java调用非Java代码的接口
在定义一个本地方法时,并不提供实现体(有点类似于一个Java interface),因为其实现是由非Java语言在外部实现的
本地方法使用native关键字进行标识,可以同public、static等关键字结合使用,但是不能和abstract结合
因为抽象方法意味着需要被重写,然而本地方法并不能重写
例如,Thread类的start方法内部会调用start0方法,而start0方法就是一个本地方法
private native void start0();
4.1.1 使用Native Method的原因
-
与Java环境外交互
Java应用有时需要和Java外面的环境交互,这是本地方法存在的主要原因;例如需要和操作系统和某些硬件交换信息
-
与操作系统交互
通过使用本地方法,可以使用Java实现jre和底层系统的交互,甚至JVM的一部分就是使用C写的
-
Sun`s Java
Sun的解释器是用C写的,这使得它能像一些普通的C一样与外部交互;jre大部分是用Java实现,但也通过一些本地方法和外界交互,例如Thread类的setPriority()是用Java写的,但是调用的是该类的本地方法setPriority0()
4.2 本地方法栈理解
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈同样是线程私有
允许被实现成固定或者可动态扩展的内存大小(在内存溢出方法同虚拟机栈相同)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,虚拟机就会抛出StackOverflowError
- 如果本地方法栈可动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,就会抛出OutOfMemoryError
通常在本地方法栈中登记native方法,在执行引擎执行时加载本地方法库
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限
- 本地方法可以通过本地方法接口来访问
虚拟机内部的运行时数据区
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一
5 堆
5.1 概述
一个Java应用程序对应一个进程,一个进程对应一个JVM实例,一个JVM实例中只有一个运行时数据区(Runtime实例),一个进程中的多个线程共享一个方法区、堆空间,每一个线程用友独立的一套程序计数器、本地方法栈和虚拟机栈
Java堆区在JVM启动的时候就被创建,其空间大小也被确定了,它是JVM管理最大的一块内存空间
堆可以处于物理上不连续的内存空间中,但逻辑上应该被视为连续的(虚拟内存到物理内存的映射
)
堆区还可以被划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
几乎
所有的对象实例和数组都应当在运行时分配在堆上
数组和对象永远不可能存储在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
那么为什么是这样呢?
- 当线程执行完毕后,局部变量表中存放的引用就会销毁,然而堆中的对象实例并不会直接回收
- 如果说每次都直接回收,就需要频繁进行GC,而GC自身也是一个线程,会影响到工作线程的使用效率
- 实际上应该是当堆空间不足时,开始执行GC,此时发现某些对象实例已经不存在引用,那么就应该被回收
堆是GC执行垃圾回收的重点区域
5.1.1 设置堆初始和最大内存
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
TimeUnit.SECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
通过修改运行配置vm为
-Xms10m -Xmx10m
来设置堆初始和最大内存都为10M
通过Java中bin/jvisualvm.exe程序查看当前程序的运行情况
5.1.2 测试创建对象和数组的字节码指令
主要是通过new和newarray指令创建对象和数组
5.2 堆的细分内存结构
JDK7及以前内存逻辑上分为:
-
新生区
-
Eden区
-
Survivor区(其中分为Survivor0区和Survivor1区)
Survivor0区和Survivor1区也可以成为from和to区,但是哪个区为空哪个就被成为to区
-
-
养老区
-
永久区
JDK8及以后内存逻辑上分为:
-
新生区
- Eden区
- Survivor区(其中分为Survivor0区和Survivor1区)
-
养老区
-
元数据 Meta Space
从上图中可以看出在设置堆内存大小时实际上控制的只有新生代和老年代的内存大小,并不包括元空间
5.2.1 测试堆细分结构
启动刚才运行的小demo,然后通过jvisualvm查看
启动时设置堆内存大小为10M,这里可以看出细分结构分别占用的空间大小,新生代和老年带的总和刚好是10M
也可以在启动配置中的vm上添加-XX:+PrintGCDetails参数查看GC细节(包含内存占用情况)
结果:
5.2.2 堆空间大小设置
-Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize
-Xmx用于表示堆区的最大内存,等价于-XX:MaxHeapSize
-X是JVM的运行参数,ms是memory start
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提升性能
这样做的原因是什么呢?
如果说初始内存和最大内存设置不一样,当内存占用过高时会进行扩容,当内存降下来时,那么扩容的空间本身也对程序自身的性能造成了影响
类似于线程池,当线程数增加达到最大线程时,之后一旦出现空闲线程,且超过了最大空闲时间,就会将多余的线程销毁
另外如果设置不同也不方便对内存大小进行调整
默认情况下,初始内存大小: 物理内存大小/64
最大内存大小: 物理内存大小/4
5.2.3 测试堆内存占用情况
public class HeapSpaceInitialize {
public static void main(String[] args) {
// 返回虚拟机堆内存总容量
long memory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回虚拟机堆内存最大容量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("最大堆内存" + maxMemory + "M");
System.out.println("初始堆内存" + memory + "M");
try {
TimeUnit.SECONDS.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
通过Runtime实例获取最大堆内存和初始堆内存
在运行配置的vm中指定初始和最大堆内存都是200M
运行之后打印结果:
为什么只有192M呢,剩余的8M去哪了呢
在终端运行jps指令查看当前运行的Java进程及端口号
再使用jstat -gc 对应进程的端口号
查看内存使用情况
将新生代和老年带相加之后的确是200M,那么192M来源于哪里呢?
实际上是Survivor0区和Survivor1区其中只有一个可以用来存储对象,另外一个是空的
将其中的to区去掉之后发现刚好192M
5.2.4 新生代和老年代
存储在JVM中的Java对象可以被划分为两类:
- 生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 生命周期非常长,在某些极端的情况下还能够和JVM的生命周期一致
那么为什么要进行分代呢?
- 分代是为了优化GC的性能(不分代也完全可以)
- 若不分代,GC需要扫描整个堆空间;若分代之后,只需要对具体某一区域进行合适的GC
- 不同代根据其特点进行不同的垃圾回收算法,提高回收效率(分代收集算法)
如何自定义新生代和老年代在总内存中的占比呢?
在运行配置的vm选项中配置-XX:NewRatio=整数
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2.新生代占堆总内存的1/3
- 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占堆总内存的1/5
如何自定义新生代中Eden区和两个Survivor区呢?
- 默认情况下-XX:SurvivorRatio=8,新生代中Eden区和两个Survivor区缺省比例为8:1:1
- 可以修改-XX:SurvivorRatio调整空间比例
几乎所有的对象都是在Eden区被new出来的
绝大部分的对象销毁都在新生代进行了
可以通过选项-Xmn
设置新生代最大内存大小,不过这个参数一般使用默认值
测试新生代和老年代、Eden区和两个Survivor区内存占比
假设在不修改原有比例的情况下
这里发现:
1、新生代和老年代内存占比的确是1:2
2、然而两个Survivor区和Eden区内存占比却是1:1:6
为什么会出现这种情况呢?
是因为新生代内存分配时存在自适应分配策略,可以通过-XX:-UseAdaptiveSizePolicy关闭自适应的分配策略(将"-"改为"+"就是开启,默认开启)
自适应就意味着虽然默认的是8:1:1,然而内存分配不一定是8:1:1,因此可以通过显式配置-XX:SurvivorRatio=8
对象创建之后再新生代和老年代之间是如何转化的呢?
当新生区的对象默认生命周期超过15次GC循环之后,就需要去老年代养老
为什么新生代被分为Eden区和Survivor区呢?
- 如果没有Survivor区,Eden区进行一次MinorGC,存活对象-->老年代-->满-->MajorGC
- MajorGC消耗时间更长,影响程序执行速度和响应速度
- Survivor存在之后,就会增加进入老年代的筛选条件,减少送到老年代的对象,减少FullGC的次数
为什么要设置两个Survivor区?(
有点类似于内存分配
)
假如只有一个Survivor区:
在第一次Eden区满进行MinorGC,存活对象放到Survivor区;第二次Eden区满MinorGC--->Survivor区,会产生不连续的内存,无法存放更多的对象
假如设置三到四个Survivor区,则每个被分配的Survivor区空间较小,很快就会被填满
如果说设置两个Survivor区:
在MinorGC时可以将Eden=区和S1区存活的对象以连续存储的方式存入S2区,减少碎片化(清除阶段的复制算法)
清除阶段的复制算法
复制算法也是减少碎片化的过程(减少Eden,减少Survivor区)
5.3 对象分配过程
5.3.1 文字过程
-
new的对象先放入Eden区,此区有大小限制
-
当Eden区的空间填满之后,程序又需要创建对象,JVM的垃圾回收器将对Eden区的垃圾回收(Minor GC),将Eden区中的不再被其他对象引用的对象进行销毁,再加载新的对象放到Eden区
-
然后将Eden区中的剩余对象移动到幸存者0区
-
如果再次触发垃圾回收,上次幸存下来放在幸存者0区的对象,如果没有被回收,就会放到幸存者1区
-
如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区
-
啥时候可以去养老呢?可以设置次数,默认经历过15次GC循环之后就会进入养老区
参数:-XX:MaxTenuringThreshold=<N>设置
5.3.2 图解过程
图1表示当Eden区满了之后,将红色部分清除,绿色部分放入幸存者0区,其中绿色部分中的1表示年龄计数器,代表该对象的年龄
图二表示当Eden区再次满了之后,将红色部分清除,并且将剩余的绿色部分和幸存者0区的绿色部分都放入幸存者1区,并且令年龄加1
每次在GC之后,都会从其中一个幸存者区转移到另一个幸存者区,空的幸存者区称为to区
图三表示当Eden区再次满了之后,这时的to区是幸存者0区,那么就将Eden区的绿色部分和幸存者1区的绿色部分放入幸存者0区,所有年龄加一;然而
幸存者1区中有两个对象在转移之前年龄已经到达15,因此直接放入老年区
注意点:
当Eden区满的时候会触发Minor GC
但是当Survivor区满的时候不会触发Minor GC
也就是说只有当Eden区满的时候Survivor区会跟着Eden区进行Minor GC被动回收(并不是说Survivor区不会GC)
关于垃圾回收:
频繁在新生代回收,很少在养老区回收,几乎不再元空间回收
5.3.3 对象分配的特殊情况
个人公众号目前正初步建设中,如果喜欢可以关注我的公众号,谢谢!