JAVA虚拟机:
一、如上图所示,JAVA虚拟机运行时主要由以下三个部分构成:
A. 本地库接口
负责把描述类的数据从Class文件加载到内存,并对数据进行校验、装换解析、以及初始化,最终形成可以被虚拟机直接使用的Java类型。
在Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。
B. 运行时数据区
1)方法区。
2)堆。
3)虚拟机栈。
4)本地方法栈。
5)程序计数器。
C. 执行引擎
输入的是字节码文件,处理过程是字节码解析的过程,输出的是执行结果。
二、JVM生命周期:
JVM实例对应了一个独立运行的Java程序,它是进程级别。
JVM执行引擎实例则对应了属于用户运行程序的线程,它是线程级别。
A. 启动:
启动一个Java程序时,一个JVM实例就产生了,任何一个拥有 public static void main(String[] args) 函数的class都可以作为JVM实例的运行起点。
B. 运行:
main() 作为该程序初始线程的起点,任何其它线程均由该线程启动,JVM内部有两种线程(守护线程 和 非守护线程),main()属于非守护线程,守护线程通常由JVM自己使用,Java程序也可以标明自己创建的线程是守护线程。
C. 消亡:
当程序中的所有非守护线程终止时,JVM才退出,若安全管理器允许,程序也可以使用Runtime类或者System.exit()退出。
知识拓展
守护线程:
是指为其它线程服务的线程,在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
JVM退出时,不关心守护线程是否已结束。
创建守护线程,方法跟普通线程一样,只是在调用start()方法前,调用 setDaemon(true) 把该线程标记为守护线程。
非守护线程:
是指用户线程。
三、本地库接口:
被所有线程共享,虚拟机把描述类的数据从class文件通过本地库接口加载到内存,并对数据进行校验、装换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
A. 类加载器:
1. 启动类加载器
启动类加载器(Bootstrap ClassLoader):是用来加载Java的核心类,负责加载 $JAVA_HOME 中 jre/lib/rt.jar 里所有的class,用C++实现,不是ClassLoader的子类。
2. 扩展类加载器
扩展类加载器(extensions ClassLoader):负责加载jre的扩展目录,lib/ext或者由 java.ext.dirs 系统属性指定的目录中的JAR包的类,用Java语言实现,父类加载器为null。
3. 系统类加载器
系统类加载器(System ClassLoader):负责加载启动参数中指定的classpath中的jar包以及目录,在Sun JDK中对应的类名为Application ClassLoader。
4. 用户自定义类加载器
用户自定义类加载器(User-Defined ClassLoader):自定义的ClassLoader,负责加载非classpath中的jar以及目录,必须是ClassLoader类的子类。
4.1):继承java.lang.ClassLoader。
4.2):重写父类的findClass方法。
B. 类加载机制:
1. 双亲委派
双亲委派的工作原理:
1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。
3)如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。
双亲委派机制的优势:
1)避免类的重复加载:当父类已经加载了该类时,就没有必要子ClassLoader再加载一次。
2)防止核心API库被随意篡改:假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已经被加载,便不会重新加载。
2. 全盘负责
所谓全盘负责,就是当一个类加载器加载某个Class时,该Class所依赖和引用其它Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
3. 缓存机制
所谓缓存机制,就是当一个类加载器加载某个Class时,类加载器先从缓存区中搜索该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。
这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
C. 类在内存中的生命周期:
加载 -> 链接(验证、准备、解析) -> 初始化 -> 使用 -> 卸载
1. 加载
加载过程就是负责找到二进制字节码并加载至 JVM 中。
1)通过一个类的全限定名(类名、类所在的包名)来获取定义此类的二进制字节流。
1)将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构。
1)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
1)加载 class 文件的方式:
a) 从本地系统中直接加载。
b) 通过网络下载 class 文件。
c) 从 zip,jar 等归档文件中加载 class 文件。
d) 从专有数据库中提取 class 文件。
e) 将 Java 源文件动态编译为 class 文件。
2. 链接
链接过程负责对二进制字节码的格式进行校验,初始化装载类中的静态变量以及解析类中调用的接口、类。
1)验证
是链接的第一步,确保 class 文件的字节流中包含的信息符合当前虚拟机的要求;
a) 文件格式验证:验证字节流是否符合 class 文件格式的规范。
b) 元数据验证:对字节码描述的信息进行语义分析,以保证其符合Java语言规范的要求。
c) 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
d) 符号引用验证:确保解析动作能正确执行。
2)准备
准备阶段是正式为类变量(static成员变量)分配内存并设置类变量默认值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。
只对static修饰的静态变量进行内存分配、赋默认值(零值)。
对final的静态字面值常量直接赋初始值。
3)解析
将常量池中的符号引用替换为直接引用(内存地址)的过程。
符号引用:用一组符号来描述目标,可以是任何形式的字面量。
直接引用:可以是直接指向目标的指针、相对偏移量 或是一个能间接定位到目标的句柄,如指向方法区某个类的一个指针。
什么是符号引用 ?
在Java中,一个Java类将会编译成一个class文件,在编译时Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。
例:org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假如是这个)来表示Language类的地址。
3. 初始化
为类的静态变量 赋初值。
注意:只有对类的主动使用才会导致类的初始化。
D. 类加载时机:
1. 创建类的实例,也就是 new 一个对象。
2. 访问某个类或接口的静态变量,或者对该静态变量赋值。
3. 调用类的静态方法。
4. 反射。
5. 初始化一个类的子类(会首先初始化子类的父类)。
6. JVM启动时标明的启动类,即文件名和类名相同的那个类。
四、运行时数据区:
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存分配为若干个不同的数据区域。这些区域有着各自的用途。详见如下:
A. 方法区(Method Area)
主要存放 类信息(class)、类中的静态变量、常量、类中的方法信息、即时编译器编译后的代码。
被所有线程共享,超过其允许的大小时会溢出。在一定条件下会被 GC 回收。
B. 堆(Heap)
主要存放对象实例以及数组值(new 创建的对象)。可以认为Java中所有通过new创建的对象内存都在此分配。
被所有线程共享,在堆上进行对象内存的分配均需进行加锁。Heap中的对象内存需要等待 GC 进行回收。
C. 虚拟机栈(JVM栈)
主要存放当前线程中的局部基本类型变量、操作数据栈、动态链接、部分的返回结果。
基本类型的对象在JVM栈上仅存放一个指向堆的reference(reference中存储的是对象的句柄地址 或 是对象的地址)。
线程私有(生命周期和线程一致),死循环可以使栈溢出。
知识拓展:
1)基本类型变量:boolean、byte、char、short、int、float、long、double。
2)操作数据栈: 例:i=0; j=1; i+j=1; 相加以后存储到局部变量表。
3)动态链接: 例:在一个类中注入某个service, service.do()。
4)返回的结果: 例:正常的return,异常的抛出。
D. 本地方法栈(Native Method Stack)
主要存放 native 方法调用的状态。(native: Java调用非Java代码的接口)。
线程私有。
本地方法栈和虚拟机栈所发挥的作用是相似的,虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈为虚拟机中使用到的native方法服务。
E. 程序计数器(Program Counter Register)
PC寄存器主要存放每个线程下一步将执行的 JVM 指令(字节码指令的地址)。
如该方法为native方法,则PC寄存器中不存储任何信息(Undefined)。
线程私有,此内存区域是唯一 一个在Java虚拟机规范中没有规定OutOfMemoryError的区域。
知识拓展:
Java中最小的执行单位是线程,因为虚拟机是多线程的,每个线程是抢夺CPU时间片运行,程序计数器就是存储这些指令去做什么,比如循环、跳转、异常处理等等。
每个线程都有属于自己的程序计数器,而且互不影响,独立存储。
五、对象在内存中的模型:
A. 对象在内存中的创建:
1. 遇到new指令时
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用。
并且检查这个符号引用代表的类是否已经被加载、解析、初始化,如果没有、执行相应的类加载。
2. 分配内存
加载检查通过后,虚拟机将为新生对象分配内存(内存大小在类加载完成之后便可确认)。
在堆的空闲内存中划分一块区域。
3. 分配内存时线程的安全性
对分配内存的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的机制保证了更新操作的原子性)。
把分配内存的动作按照线程划分在不同的空间之中进行(即每个线程在Java堆中预先分配一小块私有的缓冲区(TLAB))。
4. 填充对象头
内存空间分配完成后会初始化为0(不包括对象头),接下来就是填充对象头。
把对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC等信息存入对象头。
5. 执行init方法
执行new指令后执行init方法后才算一份真正可用的对象创建完成。
B. 对象在内存中的布局:
1. 对象头(Header)
1) 第一部分存储对象自身运行时的数据(如哈希码、锁状态标志、线程持有的锁等),(32bit虚拟机占32bit,64bit虚拟机占64bit)。
2) 第二部分存储的是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例;(如果是Java数组,对象头中还必须有一块用于记录数组长度的数据)。
2. 实例数据(Instance Data)
实例数据才是对象真正存储的有效信息,即程序中所定义的各种类型的字段内容。
3. 对齐填充(Padding)
不是必然存在的,仅仅是起到占位符的作用,保证对象大小是某个字节的整数倍。
C. 对象的访问定位
创建对象就是为了在程序中使用,Java程序需要通过JVM栈上的reference数据来操作堆上的具体对象。
1. 通过句柄访问
Java堆中划分出一块内存来作为句柄池,JVM栈上的reference中存储的是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
优点:
reference中存储句柄地址是稳定的,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
2. 通过直接指针访问
JVM栈上的reference中存储的直接就是对象地址。
优点:
速度快,节省了指针定位的时间成本。
D. 堆中内存空间,新生代、老年代
堆的大小可以通过 -Xmx 和 -Xms 来控制。
堆被划分两个部分:新生代区(Young),老年代区(Old)。
新生代区分三个板块: 一个Eden区 和 两个Survivor 区(survivor区分为 From Survivous区 和 To Survivous区)。
默认大小:Young : Old = 1 : 2,可以通过参数 -XX:NewRatio设定。
即 新生代 = 1/3 的堆空间大小,老年代 = 2/3 的堆空间大小。
1. 新生代区
默认大小:Eden : From : To = 8 : 1 : 1,可以通过参数 -XX:SurvivorRatio设定。
新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。
1)Eden 区域:
Java代码中通过new创建的对象,默认都存放在Eden区。
除非这个new对象太大、或者是超过了设定的阈值 -XX:PretenureSizeThresold,这样的对象会被直接分配到Old区。
2)Survivous区域:
此区域被分为 S0 和 S1 两个区域,理论上这两个区域一样大。
JVM每次只会使用Eden和其中的一块Survivous区域来为对象服务,所以无论什么时候,总是有一块Survivous区域是空闲着的。
2. 老年代区
用于存放新生代中经过多次垃圾回收仍然存活的对象(默认15次)。
当老年代被写满 或 调用 System.gc() 时,会引发 FULL GC。
若发生一次FULL GC,来找出所有存活的对象是非常耗时的,因此,尽量避免FULL GC的发生。
在发生FULL GC时,意味着JVM会安全的暂停所有正在执行的线程,来回收内存空间。
在这个时间内,除了回收垃圾的线程外,其它所有有关JAVA的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢、卡机等状态。
E. JVM的垃圾回收器(GC)
因 堆(Heap)、方法区域(Method Area)是线程共享的,所以GC关注的就是这两个区域的内存回收。
1. GC的基本原理:
将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器。
1)对新生代对象的回收称为 minor GC。
2)对旧生代对象的回收称为 Full GC。
3)程序中主动调用 System.gc() 强制执行的GC称为Full GC。
2. 判断对象是否已“死”:
在进行内存回收之前要做的事情就是判断哪些对象是“死”的,哪些对象是“活”的。
1)引用计数法:
给对象添加一个引用计数器,每当有一个地方引用它,计数器值就加1。
相反的,当引用失效的时候,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
优点:引用计数收集器可以很快的执行,交织在程序运行中,对程序需要不被长时间打断的实时环境比较有利。
缺点:无法检测出循环引用。
2)可达性分析:
通过一系列的“GC Roots”的对象作为起始点,从这些结点出发所走过的路径称为引用链,当一个对象到“GC Roots”没有任何引用链相连的时候说明对象不可用。
可作为GC Roots 的对象:
a) 虚拟机栈(栈帧中的本地变量表)中引用的对象。
b) 方法区中类静态属性引用的对象。
c) 方法区中常量引用的对象。
d) 本地方法栈中JNI引用的对象(即一般说的 native 方法)。
3. 四种引用:
1)强引用
默认情况下,对象采用的均为强引用。
这个对象的实例没有其它对象的引用,GC才会回收。
2) 软引用
在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。
3) 弱引用
对象只能生存到下一次垃圾收集之前,在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
4) 虚引用
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
4. 对象“死亡”(被回收)前的两次标记:
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
1)第一次标记
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。
2)第二次标记
第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法;
在 finalize() 方法中没有重新与引用链建立关联关系的,将会被第二次标记。
3)回收
只有第二次标记成功的对象才将被真正的回收。
5. 垃圾回收算法:
1) 标记–清除算法
采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。(会造成内存碎片)
2) 复制算法
开始时把堆分成一个对象面和多个空闲面,程序从对象面为对象分配空间。
当对象满了,基于copying算法的垃圾收集就从根集合(GC Roots)中扫描活动对象,并将每个存活对象复制到空闲面。
这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
3) 标记–整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记。
但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。
解决了内存碎片的问题。
4) 分代回收
分代回收算法是目前大部分JVM的垃圾收集器采用的算法;
它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域,一般将堆区划分为新生代和老年代,然后根据各个年代的特点指定相应的算法。
新生代:每次垃圾回收都有大量对象死去,只有少量存活,选用 复制算法 比较合理。
老年代:老年代中对象存活率较高,没有额外的空间分配对它进行担保,使用 标记-清除 或者 标记-整理 算法回收。
6. 垃圾回收器
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、Parallel Old、CMS
服务端收集器:G1
并行收集:指多条垃圾收集线程并行工作,但此时用户线程扔处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的、可能会交替执行)。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值。(例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%)
1)Serial收集器
新生代单线程收集器,使用 标记-清除算法 实现,是client级别默认的GC方式。
优点是简单高效。
Serial收集器进行垃圾回收时,必须暂停其它所有的工作线程,直到它运行结束(Stop The World)。
应用场景:适用于Client模式下的虚拟机。
2)ParNew收集器
Serial收集器的多线程版本。
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一 一个能与CMS收集器配合工作的。
3)Parallel Scavenge收集器
是一个新生代收集器,使用 复制算法 实现,同时也是并行的多线程收集器。
该收集器的目标是达到一个可控制的吞吐量,故也称为吞吐量优先收集器。
应用场景:主要用于高吞吐量以及CPU资源敏感的场合。
4)Serial Old收集器
Serial收集器的老年代版本,使用 标记-整理算法 实现,单线程收集器。
应用场景:主要也是使用在Client模式下的虚拟机中,也可以在Server模式下使用。
5)Parallel Old收集器
Parallel Scavenge收集器的老年代版本,使用 标记-整理算法 实现,多线程收集器。
应用场景:主要用于高吞吐量以及CPU资源敏感的场合。
6)CMS收集器
是一种以获取最短回收停顿时间为目标的收集器,使用 标记-清除 算法实现。
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。
7)G1收集器
面向服务端的垃圾回收器。
特点:
并行与并发,分代收集,空间整合,可预测的停顿。
a) G1收集器大致可分为如下步骤:
b) 初始标记:仅标记GC Roots能直接到的对象。(需要线程停顿,但耗时很短)。
c) 并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)。
d) 最终标记:为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。需要线程停顿,但可并行执行。
e) 筛选回收:对各个Region的回收价值和成本进行排序,根据所期望的GC停顿时间来制定回收计划。(可并发执行)
F. JVM堆中数据GC时变化(JVM堆内存是GC回收的主战场)
1. 第一次GC
当JVM不断创建对象的过程中,当Eden区域被占满,此时会开始做 Young GC,也叫 Minor GC(对新生代对象的回收)。
1)第一次GC时,Survivous 中 S0 和 S1 区域都为空,将其中一个作为 To Survivous(用来存储Eden区域执行GC后不能被回收的对象)。比如:将S0作为To Survivous,则S1为From Survivous。
2)将Eden区域经过GC不能被回收的对象存储到 To Survivous(S0)区域,但如果To Survivous(S0)被占满了,Eden中剩下不能被回收的对象只能存放到 Old 区域。
3)将Eden区域空间清空,此时From Survivous(S1)区域也是空的。
4)S0和S1互相切换标签,S0为From Survivous,S1为To Survivous。
2. 第二次GC
当第二次Eden区域被占满时,此时开始做GC。
1) 将Eden区 和 From Survivous(S0)中经过GC未被回收的对象迁移到To Survivous(S1),如果To Survivous(S1)区放不下,则将剩下的不能回收对象放入 Old 区。
2) 将Eden区域空间 和 From Survivous(S0)区域空间清空。
3) S0和S1互相切换标签,S0为To Survivous,S1为From Survivous。
3. 第三次GC、第四次GC、……、第n次GC
依次类推,始终保证S0和S1有一个是空的,用来存储临时对象。
反反复复多次没有被淘汰的对象,将会被放入Old区域中。
默认15次。(由参数 –XX:MaxTenuringThreshold = 15决定)。
G. 方法区域(永久代)的内存回收:
1. 判断废弃常量:
一般是判断没有该常量的引用。
2. 判断无用类:
1) 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
2) 加载该类的ClassLoader已经被回收。
3) 该类对应的java.lang.Class对象没有任何地方被引用。
H. JVM参数选项
1. -Xms
初始堆大小。如:-Xms256m
2. -Xmx
最大堆大小。如:-Xmx512m
3. -Xmm
新生代大小。通常为Xmx的1/3。
4. -Xss
每个线程的堆栈大小,JDK1.5+ 每个线程堆栈大小为1M。
5. -XX:NewRatio
新生代与老年代的比例,如:-XX:NewRatio = 2,则新生代占整个堆空间的1/3,老年代占2/3。
6. -XX:SurvivorRatio
新生代中Eden与Survivor的比值;默认值为8,即Eden占新生代空间的8/10,另外两个Survivor各占1/10。
7. -XX:PermSize
永久代(方法区域)的初始大小。
8. -XX:MaxPermSize
永久代(方法区域)的最大值。
9. -XX:+PrintGCDetails
打印GC信息。
10. -XX:+HeapDumpOnOutOfMemoryError
让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照。
11. -XX:-PrintGCTimeStamps
打印收集器的时间戳。
12. -Xloggc:filename
设置GC记录的文件。
六、执行引擎:
A. 概述:
执行引擎是虚拟机中最核心的部分之一,虚拟机自己实现引擎,自己定义指令集和执行引擎的结构体系。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。
从外观来看,所有的Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的过程,输出的是执行结果。
B. 栈帧(JVM栈的栈元素)
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧中包含:(1)局部变量表 (2)操作数栈 (3)动态链接 (4)方法返回地址 (5)额外的附加信息
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。
对于执行引擎来说,在所有活动的线程中,只有位于栈顶的栈帧才是有效的,称为 当前栈帧。
与这个栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1. 局部变量表
用于存放方法参数和方法内部定义的局部变量,单位为 槽(Slot),每个槽可以存放一个变量(Boolean,byte,char,short,int,float,reference,returnAddress); long、double需要两个槽。
2. 操作数栈
Java虚拟机引擎称为“基于栈的执行引擎”,栈就是操作数栈。
当一个方法开始执行时,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和读取内容,也就是入栈和出栈操作。
3. 动态链接
每个栈帧都有一个指向运行时常量池中该栈帧所属方法的引用,目的是:支持动态链接。
Class文件中有大量的符号引用,字节码中的方法调用指令,以常量池中指向方法的符号引用为参数。
静态链接:在类加载阶段或者第一次使用时候会转化为直接引用。
动态链接:在运行期间转化为直接引用。
4. 方法返回地址
当一个方法开始执行后,只有两种方法退出: (1)遇到返回关键字 (2)遇到异常
实际操作:当前栈帧出栈,恢复上层方法的局部变量表和操作数栈,返回值压栈(操作数栈)。
5. 附加信息
允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中。
C. 方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即:调用哪一个方法)。
Class文件的编译过程中不包含传统编译中的链接步骤,一切方法调用在Class文件中存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(即:直接引用)。
方法调用可以分为 解析调用 和 分派调用 两种。
1. 解析调用
所有方法在Class文件中都是一个字符串的常量引用,在类加载期间会将其转换为直接引用。
解析:方法在真正运行之前必须有一个确定版本,并在运行期是不可变的。
Java虚拟机提供了5条方法调用字节码指令,分别如下:
a)invokestatic :调用静态方法。
b)invokespecial :调用实例构造器<init>方法、私有方法和父类方法。
c)invokevirtual :调用所有的虚方法。
d)invokeinterface :调用接口方法,会在运行时再确定一个实现此接口的对象。
e)invokedynamic :动态解析要调用的方法。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一调用版本.
符合这个条件的有静态方法、私有方法、构造器方法、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。
2. 分派调用
Java作为一门面向对象的程序语言有一个基本特征:多态。
方法分派调用的过程正是多态特征的一些基本体现,分派调用将会解释 Java 中的“重载”与“重写”在虚拟机中是如何实现的。
1)静态分派
依赖静态类型来进行方法执行版本的分派动作。
发生时期:编译阶段; 调用指令:invokestatic; 应用:方法重载;
例如:以下代码
运行测试:
![](https://oscimg.oschina.net/oscnet/up-970742d47c80c4ea225e7febfd8064f3d75.png)
运行结果:
输出 Pepole hello 原因分析:
Pepole stu = new Student(); 其中 Pepole 为静态类型,Student 为实际类型。
两者的区别是:静态类型编译期间可知,实际类型运行期可知。
重载是通过编译期间的静态类型确定调用哪个函数,所以Javac会根据静态类型Pepole来确定调用的函数为sayhello(Pepole ple)。
2)动态分派
运行期根据实际类型确定方法执行版本的过程称为动态分派。
发生时期:运行期间; 调用指令:invokevirtual; 应用:方法重写
例如:以下代码
运行测试:
输出结果:
输出结果分析:
invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
invokevirtual指令在运行期间确定接受者的实际类型,会将同一个方法解析到不同的函数上。这就是Java重写的本质。
3.)单分派与多分派
方法接收者与方法参数统称为方法的宗量,根据分派基于多少种宗量,可以分为单分派和多分派两种。
单分派:根据一个宗量对目标的方法选中。
多分派:根据多个宗量进行目标方法的选中。
例如:以下代码
运行测试:
运行结果:
运行结果分析:
静态分配:
在编译阶段,也就是静态分配过程中,选择目标的依据有两点:(1)静态类型是Father还是Son; (2)方法的参数是QQ还是360;
这次选择结果的最终产物是产生两条invokevirtual指令。
两条指令的参数分别为常量池中指向Father.hardChoice(_360)及Father.hardChoice(QQ)方法的符号引用。
因为是根据两个宗量进行选择,所以静态分配为 多分派。
动态分配:
在运行阶段,也就是动态分配过程中。
在执行son.hardChoice(new QQ())这句代码时,由于静态分配过程中已经知道函数必须是hardChoice(QQ arg),其中参数的类型编译器不会关心,只会在意方法的接收者到底是Father还是Son。
因为只有一个宗量作为选择依据,所以动态分配为 单分派。
4)动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,出于性能的考虑,大部分虚拟机的真正实现都不会进行如此频繁的搜索。
作为优化,虚拟机会为类在方法区建立一个虚方法表,使用虚方法表索引代替元数据查找来提高性能,如下图所示:
虚方法表中存储了各个方法的实际入口地址,如果在子类中没有重写该方法,那么子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口;如果子类重写了父类的方法,则子类方法表中地址将会指向重写之后的方法入口地址。
图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头;但Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
初始化时间:虚拟方法表一般在类加载链接期间进行初始化,准备好初值之后虚拟机会将类方法初始化完毕。
D. 基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码时都有解释执行和编译执行两种。
解释执行:通过解释器执行。
编译执行:通过即时编译器产生本地执行代码。
1. 解释执行
通过解释器执行。
对于应用程序而言,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图所示的步骤。
中间那条分支就是解释执行的过程;而下面那条分支是传统编译原理中程序代码到目标机器代码的生成过程。
如今,基于物理机和Java虚拟机的语言大多都遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析、语法分析处理,把源码转化为抽象语法树。
对于一门具体的语言实现来说,词法分析、语法分析以及优化器和目标代码生成器都可以独立于执行引擎,形成一个完整意义上的编译器去实现,这类代表是C/C++语言。
也可以选择将一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。
也可以把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。
因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
2. 基于栈的指令集和基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,他们依赖操作数栈进行工作。
与之相对的另外一种常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,通俗来讲,就是现在我们主流PC机中直接支持的指令集架构,这些指令集依赖寄存器进行工作。
例:计算 “1 + 1” 的结果:
1)基于栈的指令集:
两条 iconst_1 指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
2)基于寄存器的指令集:
mov指令把EAX寄存器的值设为1,然后add指令把这个值加1,结果就保存在EAX寄存器里面。
3)基于栈的指令集 与 基于寄存器的指令集 的区别:
目前所有主流物理机的指令集都是基于寄存器的指令集架构。
基于栈的指令集:
优点:可移植,因为它不直接依赖于寄存器,所以不受硬件的约束。
缺点:执行速度相对会稍慢一些。原因有两点:
(1)基于栈的指令集需要更多的指令数量,因为出栈和入栈本身就产生了相当多的指令。
(2)因为执行指令时会有频繁的入栈和出栈操作,频繁的栈访问也就意味着频繁的内存访问,相对于处理器而言,内存始终是执行速度的瓶颈。
来源:oschina
链接:https://my.oschina.net/penguins/blog/4276367