2024年Android最新一文汇总JVM所有知识点(一)(1),2024年最新数据结构与算法面试题及答案

最后

简历首选内推方式,速度快,效率高啊!然后可以在拉钩,boss,脉脉,大街上看看。简历上写道熟悉什么技术就一定要去熟悉它,不然被问到不会很尴尬!做过什么项目,即使项目体量不大,但也一定要熟悉实现原理!不是你负责的部分,也可以看看同事是怎么实现的,换你来做你会怎么做?做过什么,会什么是广度问题,取决于项目内容。但做过什么,达到怎样一个境界,这是深度问题,和个人学习能力和解决问题的态度有关了。大公司看深度,小公司看广度。大公司面试你会的,小公司面试他们用到的你会不会,也就是岗位匹配度。

选定你想去的几家公司后,先去一些小的公司练练,学习下面试技巧,总结下,也算是熟悉下面试氛围,平时和同事或者产品PK时可以讲得头头是道,思路清晰至极,到了现场真的不一样,怎么描述你所做的一切,这绝对是个学术性问题!

面试过程一定要有礼貌!即使你觉得面试官不尊重你,经常打断你的讲解,或者你觉得他不如你,问的问题缺乏专业水平,你也一定要尊重他,谁叫现在是他选择你,等你拿到offer后就是你选择他了。

金九银十面试季,跳槽季,整理面试题已经成了我多年的习惯!在这里我和身边一些朋友特意整理了一份快速进阶为Android高级工程师的系统且全面的学习资料。涵盖了Android初级——Android高级架构师进阶必备的一些学习技能。

附上:我们之前因为秋招收集的二十套一二线互联网公司Android面试真题(含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

给对象添加一个引用计数器,每当有一个地方引用时,计数器值加一。当引用失效时,计数器值减一;任何时刻计数器为零的对象就是不可能再被使用的。 引用计数法实现简单,判断效率高,但是Java虚拟机里面没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

2.可达性分析算法

可达性分析算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当有一个对象到GC Roots没有任何引用链相连,即不可达,则证明此对象是不可用的。

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等

  • 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量

  • 方法区中常量引用的对象,比如字符串常量池(String Table)里的引用

  • 本地方法栈JNI(Native方法)引用的对象

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NPE,OOM)等,还有系统类加载器

  • 所有被同步锁(synchronized关键字)持有的对象

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

3.3 什么时候回收垃圾

不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。

  • Allocation Failure : 在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次GC

  • System.gc(): 在应用层,可以主动调用此API来建议虚拟机执行一次GC。

3.4 再谈引用

3.4.1 强引用

如果一个对象具有强引用,那垃圾收集器不会回收它。指在程序代码之中普遍存在的引用赋值,即类似“Objectobj=new Object()”这种引用关系。

3.4.2 软引用

在内存实在不足时,会对软引用进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用

3.4.3 弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一个垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

3.4.4 虚引用

一个对象是否有虚引用的存在,完全不会对齐生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

3.5 垃圾收集算法

3.5.1 标记-清除算法

标记之后原地清除。

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:

  • 效率问题:标记和清除两个过程的效率都不高

  • 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致无法分配较大对象而不得不提前触发另一次垃圾收集动作

3.5.2 标记-复制算法

平时只用一半空间,需要回收时,将存活的全部复制到另一半空间,将之前的一半空间全部清除。

标记-复制算法也称为复制算法。

为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象复制到另外一块,然后将已使用那块内存空间一次清理掉。实现简单,运行高效。但是这种算法的代价是可使用内存缩小为原来的一半。

现在虚拟机都采用复制算法来回收新生代。按照历史经验,新生代的对象98%的对象都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden区和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当然,如果Survivor空间装不下时,需要依赖其他内存(一般是老年代)进行分配担保。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是只有10%的内存会浪费。

复制算法在对象存活率比较高的时候是非常低效的,更关键的是,如果不想浪费50%的内存空间,就要有额外的空间进行分配担保,所以老年代一般不会选用复制算法。

3.5.3 标记-整理算法

标记之后,将对象全部复制到空间的一边,将复制之后占用内存的边界之外的空间全部清理。

和标记清除算法的标记过程一直,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

3.6 HotSpot的算法实现细节

3.6.1 枚举根节点

固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。

确保一致性的快照:这项分析工作必须在一个能确保一致性的快照中进行-在整个分析期间整个指向系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。

使用OopMap标记对象引用:在HotSpot中,使用一组OopMap的数据结构来标记对象引用的位置。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。

3.6.2 安全点(Safepoint)

什么是安全点:导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。实际上,HotSpot也的确没有为每条指令都生成OopMap,指数在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增加运行时的负荷。

如何选择安全点:安全点的选定是以“是否具有让程序长时间执行的特性”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。

在安全点暂停的方式:抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

3.6.3 安全区 (Safe Region)

当程序不执行的时候(比如sleep状态)就不能到达安全点,对于这种情况就需要安全区域来解决。安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的安全点。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

3.6.4 记忆集与卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

卡表:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。以这种方式实现记忆集,这也是目前最常用的一种记忆集实现形式。

3.7 垃圾收集器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。为什么有那么多的垃圾收集器:因为场景不同。

3.7.1 Serial收集器

Serial收集器是一个单线程工作的收集器,它进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束。迄今为止,使用非常广泛(客户端模式默认新生代的收集器),它简单而高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

3.7.2 ParNew收集器

ParNew收集器是Serial收集器的多线程版本,它是运行在不少服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器。除了Serial收集器收集器外,目前只有它能与CMS收集器配合工作。

CMS收集器是JDK 5发布时推出的,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

3.7.3 Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。

3.7.4 Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

3.7.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

3.7.6 CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。关注服务的响应速度,则CMS刚好。CMS是基于标记-清除算法实现的。CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿。

3.7.7 Garbage First收集器

Garbage First收集器,简称G1,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。JDK 9发布之日,G1宣告取代Parallel Scavenge加ParallelOld组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。G1基于Region堆内存布局,虽然G1也仍是遵循分代收集理论设计的,但其对内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代。收集器根据Region的不同角色采用不同的策略去处理。G1会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些Region区,也就是垃圾最大的Region区,这就是Garbage First名字的由来。

G1收集器的运作过程可划分为以下四个步骤:

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。需停顿线程,但耗时很短。

  2. 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。

  3. 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  4. 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期待的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。

3.8 内存分配与回收策略

对于内存分配,大方向上就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲区,将按线程优先在TLAB上分配。少数情况下也可以直接分配在老年代。

Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是JVM的内存分代策略。在HotSpot中除了新生代和老年代,还有永久代

分代回收的中心思想:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下下来,则将它们转移到老年代中。

3.8.1 年轻代(Young Generation)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法就是复制算法。

新生代又可以继续细分为3部分:Eden、Survivor0、Survivor1。这3部分按照8:1:1的比例来划分新生代。

大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快

  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现Major GC,经常会伴随至少一次Minor GC,Major GC的速度一般会比Minor GC慢10倍以上

3.8.2 老年代(Old Generation)

一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。

如果对象比较大(比如字符串或者大数组),并且新生代的剩余空间不足,则这个大对象直接被分配到老年代上。我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记整理的回收算法。

长期存活的对象将进入老年代:既然虚拟机采用了分代收集的思想来管理内存,那么内存回收就必须能识别哪些对象应该放在新生代还是老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区每熬过一次Minor GC,年龄就会增加一岁。当它的年龄增加到一定程度,默认是15,就将会被晋升到老年代中。

对于老年代可能存在一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代维护了一个512byte的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发送GC时,只需要检查这个card table即可,大大提高了性能。

4. Java字节码(class文件)解读


以前写过一篇Java字节码解读

5. 字节码指令简介


Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(操作数)构成。

5.1 字节码与数据类型

在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。比如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。

编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据,将boolean和char类型数据零位扩展为相应的int类型数据。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型来进行的。

5.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。这些指令如下:

  • 将一个局部变量加载到操作栈:iload、iload_、lload、lload_、fload、fload_、dload、dload_、aload、aload_

  • 将一个数值从操作数栈存储到局部变量表:istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_

  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_

  • 扩充局部变量表的访问索引的指令:wide

5.3 运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

  • 加法指令:iadd、ladd、fadd、dadd

  • 减法指令:isub、lsub、fsub、dsub

  • 乘法指令:imul、lmul、fmul、dmul

  • 除法指令:idiv、ldiv、fdiv、ddiv

  • 求余指令:irem、lrem、frem、drem

  • 取反指令:ineg、lneg、fneg、dneg

  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr

  • 按位或指令:ior、lor ·按位与指令:iand、land

  • 按位异或指令:ixor、lxor ·局部变量自增指令:iinc

  • 比较指令:dcmp g、dcmp l、fcmp g、fcmp l、lcmp

5.4 类型转换指令

类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码的显示类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。

Java虚拟机直接支持以下数值类型的宽化类型转换:

  • int类型到long、float或者double类型

  • long类型到float、double类型

  • float类型到double类型

相对的,处理窄化类型转换时,就必须显示地使用转换指令来完成:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f

在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单丢弃除最低位N字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号(把前面的符号位给舍去了)。

5.5 对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素。

  • 创建类实例的指令:new

  • 创建数组的指令:new array 、anew array 、mult ianew array

  • 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的 指令:getfield、putfield、getstatic、putstatic

  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、 daload、aaload

  • 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、 dastore、aastore

  • 取数组长度的指令:array lengt h - 检查类实例类型的指令:inst anceof、checkcast

5.6 操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令:

  • 将操作数栈的栈顶一个或两个元素出栈:p op 、p op 2

  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup 、dup 2、dup _x1、 dup 2_x1、dup _x2、dup 2_x2

  • 将栈最顶端的两个数值互换:swap

5.7 控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne

  • 复合条件分支:tableswitch、lookupswitch

  • 无条件分支:goto、goto_w、jsr、jsr_w、ret

与前面的算术运算的规则一致,对于boolean、byte、char和short类型的条件分支比较操作,都使用int类型的比较指令完成,而对于long、float和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,预算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。因此,各种类型的比较最终都会转换为int类型的比较操作。

5.8 方法调用和返回指令

方法调用:分派、执行过程。

  • invokevirt ual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派), 这也是Java语言中最常见的方法分派方式。

  • invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找 出适合的方法进行调用。

  • invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和 父类方法。

  • invokestatic指令:用于调用类静态方法(static方法)。

  • invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面 四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedy namic指令的分派逻辑 是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

5.9 异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常的情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状态时自动抛出。例如整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。

而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成。

5.10 同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来实现的。

方法级的同步是隐式的,无法通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要javac编译器与Java虚拟机两者共同协作支持。

6. 虚拟机类加载机制


Java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

6.1 类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接

类的生命周期

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,Java虚拟机规范中并没有进行强制约束。 但是对于初始化阶段,严格规范了只有下面六种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能生成这四条指令的典型Java代码场景:
  • 使用new关键字实例化对象的时候

  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入调用处的那个类的常量池的静态字段除外)的时候

  • 调用一个类型的静态方法的时候

  1. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化

  2. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

  3. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类

  4. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

  5. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

6.2 类加载的过程

Java虚拟机中类加载的全过程:加载、验证、准备、解析和初始化。

6.2.1 加载

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口

6.2.2 验证

验证是连接阶段的第一步,目的是确保class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会抛出异常、拒绝编译。但是,这些无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段大致会完成四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

6.2.2.1 1.文件格式验证

第一阶段验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理。 该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个Java类型信息的要求。通过这个阶段验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

  • 是否以魔数0xCAFEBABE开头

  • 主、次版本号是否在当前Java虚拟机接受范围之内

  • 常量池的常量中是否有不被支持的常量类型(检查常量t ag标志)

  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量

  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据

  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

6.2.2.2 2. 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范。 主要目的是对类的元数据信息进行语义校验。

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)

  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)

  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方 法重载,例如方法参数都一致,但返回值类型却不同等)

6.2.2.3 3. 字节码验证

第三阶段主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体(class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。 但即使一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。

在JDK6之后的javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名为StackMapTable的新属性,这项属性描述了方法体所有的基本块(指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序退到这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作 栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况

  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上

6.2.2.4 4. 符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作将在连接的第三阶段-解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchM ethodError等。

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类

  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段

  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当 前类访问

6.2.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

public static int value = 123;

变量value在准备阶段过后的初始值是0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:

public static final int value = 123;

编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

6.2.4 解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在class文件中它以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量出现。

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能被无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的class文件格式中

  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。

不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符”,这里的动态的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是静态的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。

6.2.5 初始化

类的初始化是类加载过程的最后一个步骤,Java虚拟机开始执行类中编写的Java代码,将主导权移交给应用程序。

进行准备阶段时,变量移交赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的,它是javac编译器的自动生成物。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}代码块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

public class Test {

static {

i = 0; // 给变量复制可以正常编译通过

System.out.print(i); // 这句编译器会提示“非法向前引用”

}

static int i = 1;

}

<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前父类的<clinit>()方法以及执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

<clinit>()方法对于类或接口来说并不是必需的,一个类中没有静态语句块,也就没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。可以利用这条特性搞单例。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞。

6.3 类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何获取所需的类。实现这个动作的代码称为“类加载器”。

同一个Java虚拟机,用不同的类加载器加载同一个class文件,那加载出来的这2个类必定是不相等(包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括instanceof。)的。

6.3.1 双亲委派模型

本节内容针对的是JDK 8及之前版本的Java来介绍的三层类加载器和双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器是虚拟机的一部分;另外一种就是其他所有的类加载器,这些类加载器独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构。

  • 启动类加载器(Bootstrap Class Loader): 这个类加载器负责加载存放在<JAVA HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径所指定的路径中存放的,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中

  • 扩展类加载器(Extension Class Loader):是在类sun.misc。Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库

  • 应用程序类加载器(Application Class Loader):由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

类加载器双亲委派模型:

类加载器双亲委派模型

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会去尝试自己去完成加载

为什么需要双亲委派模型?它的好处是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的CLassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序将会变得一片混乱。

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现非常简单。用以实现双亲委派模型的代码只有10行左右,全部在java.lang.ClassLoader的loadClass()方法之中。

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException

{

//首先,检查请求的类是否已经被加载过了

Class<?> c = findLoadedClass(name);

if (c == null) {

try {

if (parent != null) {

c = parent.loadClass(name, false);

} else {

c = findBootstrapClassOrNull(name);

}

} catch (ClassNotFoundException e) {

//如果父类加载器抛出ClassNotFoundException说明父类加载器无法完成加载请求

}

if (c == null) {

//在父类加载器无法加载时,再调用本身的findClass()方法来进行类加载

c = findClass(name);

}

}

return c;

}

核心逻辑:先检查请求加载的类型是否已经被加载过,如果没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载

7. 虚拟机字节码执行引擎


7.1 概述

执行引擎是Java虚拟机核心的组成部分之一,虚拟机是一个相对于物理机的概念,这两种机器都有代码执行的能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集合操作系统层面上的,而虚拟机的执行引擎是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件支持的指令集格式。

在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。

7.2 运行时栈帧结构

Java虚拟机以方法作为基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程

在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,与这个栈帧所关联的方法被称为当前方法。执行引擎所允许的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结果如图:

7.2.1 局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。 局部变量表的容量以变量槽为最小单位。

对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言中明确的64位的数据类型只有long和double两种。

如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。

7.2.2 操作数栈

操作数栈也常被称为操作栈,它是一个后入先出(LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

Java虚拟机的解释执行引擎被称为基于栈的执行引擎,里面的栈就是操作数栈。

7.2.3 动态连接

符合引用和直接引用在运行时进行解析和连接的过程,叫动态连接。一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,需要知道其名字。符号引用就相当于名字,这些被调用者的名字就存放在java字节码文件里。名字知道了之后,Java程序运行起来的时候,就得靠这个名字(符号引用)找到相应的类和方法。这时就需要解析成相应的直接引用,利用直接引用来准确地找到。

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转换被称为静态解析。另外一部分将在每一次运行期间都转换为直接引用,这部分就称为动态连接。

7.2.4 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”。

另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成”。

无论采用何种方式退出,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

7.3 方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一。但之前说过,class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

7.3.1 解析

所有方法调用的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都是适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令:

  • invokestatic:用于调用静态方法

  • invokespecial:调用实例构造器<init>()方法、私有方法和父类中的方法

  • invokevirtual:调用所有的虚方法

  • invokeinterface:调用接口方法,会在运行时再确定一个实现该接口的对象

  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分配逻辑是由用户设定的引导方法来决定的

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法,与之相反,其他方法被称为虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。

7.3.2 分派

本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如重载和重写在Java虚拟机之中是如何实现的?这里的实现当然不是语法上该如何写,而是虚拟机是如何正确确定目标方法的。

7.3.2.1 静态分派

先来看一段代码:

/**

  • 方法静态分派演示

*/

public class StaticDispatch {

static abstract class Human {

}

static class Man extends Human {

}

static class Woman extends Human {

}

//idea 在还没运行的时候就看出这个方法会被调用 而下面两个方法则没人使用,建议我安全删除

public void sayHello(Human guy) {

System.out.println(“hello,guy!”);

}

public void sayHello(Man guy) {

System.out.println(“hello,gentleman!”);

}

public void sayHello(Woman guy) {

System.out.println(“hello,lady!”);

}

public static void main(String[] args) {

Human man = new Man();

Human woman = new Woman();

StaticDispatch sr = new StaticDispatch();

sr.sayHello(man);

sr.sayHello(woman);

}

}

上面代码中的Human是静态类型(或者叫外观类型),后面的Man则被称为变量的实际类型(或者叫运行时类型)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

//实际类型变化

Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

//静态类型变化 在编译期完全可以明确转型的是Man还是Woman

sr.sayHello((Man)human)

sr.sayHello((Woman)human)

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

重载方法匹配优先级:

public class OverLoad {

public static void main(String[] args) {

sayHello(‘c’);

}

public static void sayHello(char c) {

System.out.println(“hello char”);

}

public static void sayHello(int i) {

System.out.println(“hello int”);

}

public static void sayHello(long l) {

System.out.println(“hello long”);

}

public static void sayHello(float f) {

System.out.println(“hello float”);

}

public static void sayHello(double d) {

System.out.println(“hello double”);

}

public static void sayHello(Serializable s) {

System.out.println(“hello serializable”);

}

public static void sayHello(Object o) {

System.out.println(“hello object”);

}

public static void sayHello(char… chars) {

System.out.println(“hello chars”);

}

}

上面这些方法都能匹配上,但是是有优先级的,依次是char > int > long > float > double > Serializable > Object > 可变长参数

尾声

以薪资待遇为基础,以发展为最终目标,要在高薪资的地方,谋求最好的发展!

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

重载方法匹配优先级:

public class OverLoad {

public static void main(String[] args) {

sayHello(‘c’);

}

public static void sayHello(char c) {

System.out.println(“hello char”);

}

public static void sayHello(int i) {

System.out.println(“hello int”);

}

public static void sayHello(long l) {

System.out.println(“hello long”);

}

public static void sayHello(float f) {

System.out.println(“hello float”);

}

public static void sayHello(double d) {

System.out.println(“hello double”);

}

public static void sayHello(Serializable s) {

System.out.println(“hello serializable”);

}

public static void sayHello(Object o) {

System.out.println(“hello object”);

}

public static void sayHello(char… chars) {

System.out.println(“hello chars”);

}

}

上面这些方法都能匹配上,但是是有优先级的,依次是char > int > long > float > double > Serializable > Object > 可变长参数

尾声

以薪资待遇为基础,以发展为最终目标,要在高薪资的地方,谋求最好的发展!

下面是有几位Android行业大佬对应上方技术点整理的一些进阶资料。

[外链图片转存中…(img-1JsWfCXf-1714972725318)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值