JVM 学习笔记

《深入理解JVM虚拟机》读书笔记

reference 的两种实现

​ 创建对象自然是为了后续使用该对象,我们的 Java 程序会通过栈上的 reference 数据来操作堆上的具 体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种:

使用句柄

如果使用句柄访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就 是对象的句柄地址,而句柄中包含了 对象实例数据类型数据 各自具体的地址信息

image-20201201093128961

使用句柄方式的优势在于,在对象被移动(GC 垃圾回收时)只会改变句柄中实例数据指。

使用指针

如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是 对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

image-20201201093229312

使用指针的方式在于减少开销,对象访问可以通过一次寻址直接获取。

垃圾回收算法

​ 程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭,栈 中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基 本上是在类结构确定下来时就已知的.当方法结束或者线程结束时,内存自然就跟随着回收了。

​ 在 JAVA 堆和方法区中具有很大的不确定性,所以垃圾回收其(GC)主要工作于这部分内存中

引用的分类

四种引用强度依次递减

强引用 Strongly Reference

最强定义的引用类型,在代码中的赋值就是强引用:Object obj=new Object() 只要强引用关系存在,GC 就不会回收该部分的内存

软引用 Soft Reference

描述的是一些有用但是非必须的对象,在抛出 OOM 异常之前会将软引用给回收一次。

弱引用 Weak Reference

当垃圾回收期工作时,不管有没有内存溢出都会被回收

虚引用 Phantom Reference

虚引用是最弱的一个引用,无法通过虚引用获得一个对象实例,为对象添加虚引用的唯一目的是为了对象被回收时受到系统通知。

引用计数法

在对象中放入一个内置的计数器,当有地方引用该对象则该计数器加一,如果放弃引用该对象则计数器减一。

引用计数法被很少的使用是由于其有一个缺陷,无法解决循环引用的问题:

对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已

经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

/*** testGC()方法执行后,objA和objB会不会被GC呢? * @author zzm */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /*** y,以便能在GC日志中看清楚是否有回收过 */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null; // 假设在这行发生GC,objA和objB是否能被回收? System.gc(); 
    }
}

image-20201201142449502

可达性分析算法

通过一系列的GC Roots的根对象作为起始节点集,从GC Roots开始从上往下搜素,可以达到的节点都是可达的节点,不可达的节点就是可以被GC回收的区域,

image-20201201142719155

GC Roots包含一下对象:

  • 虚拟机栈中引用的对象,局部变量,方法参数
  • 方法区中静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 方法区中常量引用的对象,如字符串常量池
  • 所有被同步锁(synchronized关键字)持有的对象

等等…

垃圾回收算法

​ 从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。JVM中的垃圾回收算法都属于追踪式垃圾收集

分代收集理论

​ 由于内存中的对象的生命周期并不一致,有的对象生命周期较长有的对象只可能刚创建的时候使用一下,所以这会对垃圾收集带来一些困难。如果将所有的对象放在一起进行回收,那么过于频繁GC的频率会时JVM占用过多的系统资源,所以通过对对象的内存进行分级可以对垃圾回收带来相当可观的优化。

​ 所以JVM中的堆内存对象一般分为两个区:

  • 新生代区域
  • 老年代区域

对应不同区域进行不同的回收方式如:

  • Minor GC 只回收新生代区域的内存
  • Major GC 回收老年代区域
  • Full GC 收集整个堆和方法去的垃圾
  • Mixed GC 回收新生代和老年代的垃圾。只有G1收集器有这个功能

对于不同区域的特征可以采用不同的回收算法如:标记清除,标记复制,标记整理。

标记清除算法

image-20201202090851734

算法分为标记和清除两个步骤,标记就是通过GcRoot的图论找不到不可达的对象,当全部区域标记完就直接回收被标记到的内存区域。

标记清除法有两大弊端:

  1. 当要回收的对象较多时,需要进大量的标记和清除操作。
  2. 回收完的内存碎片化严重
标记复制算法

image-20201202091316675

​ 将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

该算法的弊端就是浪费了一半的内存空间

HotSpot虚拟机的Serial,ParNew等垃圾回收器都是使用【标记复制算法】来实现垃圾回收的,具体做法是:将新生代划分为一块较大的eden区域和两块较小的survuivor区域,比例为8:1:1.每次垃圾回收时将Eden区和survivor区中的幸存者复制到另一个survivor区中,再清除掉其他两个内存空间。当survivor空间不足时,会将上一次幸存的对象放入老年代中。

标记整理算法

image-20201202092051163

​ 标记整理算法的前部分操作和标记复制清除算法一样,增加了一步移动对象步骤。将幸存对象向一端移动。再找到边界范围清除边界以外的所有对象。

​ 移动存活对象并更新对象的引用是极其吃资源的操作,对象的移动操作必须暂停应用程序的运行。标记整理和标记清除算法相比较来说的话,标记清除算法实现简单无需应用程序停机,但是过多的内存碎片化会需要用空闲内存分配链表来解决内存分配问题,这会对内存开辟和寻址带来更大的开销。另一方面,

如果使用标记整理算法那么会导致用户线程的停止但是会增大系统的吞吐量,减少内存碎片化。两个算法侧重点不一样。

常见的垃圾回器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。《Java虚拟机规 范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器都可能会有很大差别,不同的虚拟机一般也都会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器

image-20201202143001464

SerialNew/Old收集器

image-20201202143855022

最早的垃圾收集器 特性:

  • 新生代采用标记复制回收算法,停止用户线程
  • 老年代采用标记整理回收算法,通知用户线程
  • 适用于系统资源不够用,单核CPU的场景下

Serial Old垃圾回收期可以和Parallel Scavaenge收集器工作

ParNew收集器

image-20201202144210991

实际上是Serial收集器的多线程版本,可以使用多个GC线程来回收内存空间。提供多个可调整的参数:(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等),回收算法和Serial一致

Parallel Scavaenge收集器

Parallel Scavenge是一款新生代垃圾回收器,基于标记复制算法实现,PS收集器的关注点在于系统的吞吐量上 吞 吐 量 = 运 行 用 户 代 码 时 间 / ( 运 行 代 码 时 间 + 运 行 垃 圾 ) 吞吐量=运行用户代码时间/(运行代码时间+运行垃圾) =/(+) 。停顿时间越短越适合需要用户交互的快速响应的服务,高吞吐量适用于高效率运用后台资源,适用于后台运算而不需要太多交互的分析型任务。

参数配置:

  • -XX:MaxGCPauseMillis 最大垃圾收集停顿时间(second)
  • -XX:GCTimeRatio 设置吞吐量大小(%)
  • -XX:+UseAdaptiveSizePolicy 垃圾收集的自适应的调节策略开关 (自动调整eden和survivor的比例)

Parallel Old收集器

Parallel Scavenge/Parallel Old收集器运行示意图

Parallel Scavenge/Parallel Old收集器运行示意图

Parallel Old收集器是Paralle Scavaenge的老年代版,基于标记整理算法,支持并发收集。

CMS收集器

​ CMS(Concurrent Mark Sweep) 收集器是一款以获取最短的回收暂停时间为目标的老年代垃圾回收器,经常应用于互联网网站或者是基于B/S系统的服务端上。

image-20201202154546503

运行过程:

  1. 初始标记,只是标记从GC Root能直接访问到的对象,此过程耗时很短,需要停止用户线程。
  2. 并发标记,从初始标记出来的对象开始遍历整个对象图的过程,耗时较长,无需停止用户线程。
  3. 重新标记,从并发标记出来的对象图中继续遍历一次,耗时中等,需要停止用户线程。
  4. 并发清除(标记清除算法),无须停止用户线程

优点:并发收集,停顿时间短。

不足:回收算法是标记清除算法,所以会产生很多的内存碎片,当碎片率达到设定的阈值时会进行Full GC,这个时候会停止用户线程

G1收集器

G1是一款主要面向服务端应用的垃圾收集器。在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

image-20201202161413590

特征:

​ G1将所有的内存区间按照一定的等分,划分为若干个Region,每个Region都可以根据需要被看待成Eden区,Survivor区,老年区。收集器在回收时 根据Region对象的不同角色选择不同的策略去处理。大对象保存在Humongous区域中,G1一般认为这些大对象的处理策略和老年代大致相同。

为什么可以建立停顿时间预测模型?

将Region作为单词回收的最小单位,G1维护了一张垃圾回收优先级表,表中垃圾的大小和回收所需时间的估计值的比值为优先级的大小,G1会首先回收优先级高的垃圾,所以G1的回收效率较高。,G1收集器的运作过程大致可划分为以下四个步骤:

  1. 初始标记,和CMS的初始标记一样,只标记GC Root可直达的对象,需要停止用户线程。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。
  2. 并发标记,遍历初始标记的对象图将可达对象标记出来,耗时较长但是不用停止用户线程。
  3. 最终标记,对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的垃圾对象
  4. 筛选回收,根据Region中的信息统计数据,对所有的可回收Region根据优先级从高到低排序,根据用户期望的停顿时间去执行清理计划。

image-20201202162502366

内存回收策略

对象的内存分配,从概念上讲,应该都是在堆上分配。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。对象分配的规则并不是固定的,《Java虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定。

GC日志解读

设置启动参数,开启GC日期输出

-XX:+PrintGCDetails # 开启垃圾回收时的日志
-XX:+PrintGCDateStamps # 打印详细时间戳(以基准时间的形式)z
-XX:+PrintGCTimeStamps # 打印日期(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-Xloggc:../logs/gc.log # 日志文件的输出路径
新生代回收日志

2014-07-18T16:02:17.606+0800: 611.633[1]: [GC[2] 611.633: [DefNew[3]: 843458K->2K[4](948864K[5]), 0.0059180 secs[6]] 2186589K->1343132K[7](3057292K[8]), 0.0059490 secs[9]] [Times: user=0.00 sys=0.00, real=0.00 secs]10

  1. 2014-07-18T16:02:17.606+0800: 611.633: 发生GC的时间
  2. GC:发生垃圾回收的类型,GC=MinorGC,Full GC=Full GC
  3. DefNew:垃圾回收器的名称,单线程 (single-threaded), 采用标记复制 (mark-copy) 算法
  4. 843458K->2K:回收前的空间剩余->回收后的空间剩余
  5. (948864K):该空间(年轻代)的总大小
  6. 0.0059180 secs:垃圾回收消耗的时间
  7. 2186589K->1343132K:整个堆的空间垃圾回收前后的变化
  8. (3057292K):总可用堆空间
  9. 0.0059490 secs:总的GC持续的时间
  10. [Times: user=0.00 sys=0.00, real=0.00 secs] GC 事件的持续时间
    1. user 垃圾收集线程消耗的时间
    2. sys 系统调用的消耗时间
    3. real 应用程序暂停时间
老年代回收日志

2014-07-18T16:19:16.794+0800: 1630.821: [GC 1630.821: [DefNew: 1005567K->111679K(1005568K), 0.9152360 secs]1631.736: [Tenured:
2573912K->1340650K(2574068K), 1.8511050 secs] 3122548K->1340650K(3579636K), [Perm : 17882K->17882K(21248K)], 2.7854350 secs] [Times: user=2.57 sys=0.22, real=2.79 secs]

区别于新生代的日志就是多出来的:

[Tenured[1]:2573912K->1340650K[2](2574068K)[3], 1.8511050 secs[4]] 3122548K->1340650K[5](3579636K)[6], [Perm[7] : 17882K->17882K[8](21248K)[9]], 2.7854350 secs[10]]

  1. Tenured :表示回收的区域为老年代
  2. 2573912K->1340650K:老年代数据回收前剩余的空间->回收后剩余的空间
  3. (2574068K):老年代总空间
  4. 3122548K->1340650K:堆空间的总大小回收前后的对比
  5. (3579636K):堆空间的总大小
  6. Perm:方法区空间
  7. 。。。。。剩下的类推

GC日志回收的标准格式为:

[回收的区域 : 回收前该区域空间剩余大小 -> 回收后该空间剩余大小(该空间总大小)回收该空间消耗的时间 secs]

回收区域有:

  • DefNew 新生代
  • Tenured 老年代
  • Perm 永久代

JVM回收流程

对象新建和GC关系的大致流程图:

image-20201203154304407

​ 新创建的对象会优先放入到新生代区(eden),当对象超过某个设定的阈值时就会将大对象[1]直接放入到老年代中;每个对象都会保存一个年龄计数器,每次gc后未被回收时就会将计数器加一,当对象的年纪加到JVM设定的阈值[2]的时候就会被放入到老年代中。

​ 若MinorGC后发现Eden区和Survivor from区存活对象的大小比Survivor to的大小要大时,则直接将存活的对象直接放入到老年代中;检查是否有某个年龄其所有对象内存的总和占比新生代超过百分之五十,如果有则这批对象直接进入老年代。

​ 在Minor GC之前,虚拟机会必须检查老年代中的最大连续空间是否新生代所有对象的总空间。如果条件成立,那么Minor GC是肯定安全的,如果不成立那么JVM虚拟机会检查是否允许担保失败[3],如果允许则直接允许Minor Fc,反之虚拟机允许 Full GC.

  1. 大对象的定义:-XX:PretenureSizeThreshold
  2. 老年代年龄设置:以-XX:MaxTenuringThreshold
  3. 担保失败设置:XX:HandlePromotionFailure

JVM调试工具

一般对于JVM数据统计会涉及到:虚拟机允许日志,垃圾收集日志,线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等

Jps

列出当前计算机正在运行的JVM进程并标识他的主类,以及进程中的虚拟机的唯一ID:LVMID(Local Vitural Machine Identifier)一般来说LVMID和PID是一致的

jps命令格式

jps [options] [hostid]
选项作用
-q只输出LVMID,忽略主类信息
-m输出虚拟机进程时传递给主类的参数
-l输出主类的全名,如果允许的为jar包 ,输出jar包路径
-v输出虚拟机允许时的JVM参数
示例

image-20201203171246013

可以看出当前机器有两个LVMID分别是368和10816

Jstat

是用于监视虚拟机各种运行状态信息的命令行工具,它可 以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,

命令格式

jstat [ option vmid [interval[s|ms] [count]] ]

VMID分为本地虚拟进程和远程虚拟进程,如果为本地服务则直接填入本地的PID就好了

如果是远程虚拟进程VMID就为[protocol:][//]lvmid[@hostname[:port]/servername]

示范

image-20201203172244797

从左到右分别为,Survivor Survivor1Eden Old MetaData MinorGc次数 FullGc次数 FullGc消耗时间 Gc总消耗时间

Jinfo

可以查看JVM启动时的参数,Jps -v也有一样的功能,但是Jinfo更加的强大他可以显示默认的配置参数

命令格式

jinfo [option] pid
示范

image-20201203172915161

Jmap

生成堆的快照(一般称为heapdump或dump文件),jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。 (windows系统受限,主要在Unix系统下使用)

命令格式

jmap [options] LVMID

options:

选项作用
- dump生成堆快照。格式为-dump:format=b,file=
- finalizerinfo显示在FQueue中等待被执行finalizer方法的对象列表
- heap显示堆的详细详细,回收器种类,参数配置,分代状况
- histo显示堆中的对象统计详细,例如,类,实例数量
- permsat显示方法区息
- F强制dump
- heap 显示堆详情

jmap -heap <pid>

输出结果:

image-20201203181143972

​ 可以看出该PID的JVM虚拟机底层的垃圾回收器是用ParNew和CMS搭配使用的,而JDK1.7和JDK1.8默认的垃圾回收器的搭配应该是Paraller Scavenage和Paraller Old,再通过上面的Jinfo命令可以看出是在程序运行的时候指定了JVM参数: XX:+UseParNewGC -XX:+UseConcMarkSweepGC -

- dump 生成堆快照

image-20201204091239338

将生成的快照文件放入到解析工具中进行解析(VisualVm):

image-20201204092516327

Jstack

生成JVM中的线程快照,一般用于定位线程长时间停顿的现象如线程的死锁,死循环。

命令格式

jstack [options] VMID

options选项值

选项作用
-F强制输出内存堆栈
-l除堆栈外,显示锁相关信息
-m显示本地方法栈
示范

image-20201204094618626

分析jstack日志

线程状态有:

  1. 死锁,Deadlock
  2. 执行中,Runnable
  3. 等待资源,Waiting on condition (线程在等待池(WaitSet)中,等待其他线程notify然后进入EnrtySet队列)
    1. 读取资源且资源采取资源锁的方式
    2. 等待其他线程的执行
    3. 可能在等待网络读写
  4. 等待获取监视器,Waiting on monitor entry (争夺对象来获取锁的权限)
  5. 暂停,Suspended
  6. 对象等待中,Object.wait () 或 TIMED_WAITING
  7. 阻塞,Blocked
  8. 停止,Parked
案例1 等待获取锁资源的线程

image-20201204105518613

分析:

  1. Blocked 阻塞状态,等待获取资源,只有synchronized同步代码块会有这样的状态
  2. waiting to lock <0x00000000acf4d0c0> :等待<0x00000000acf4d0c0> 地址的对象
  3. waiting for monitor entry:申请进入临界区,但是目标OBJ被其他线程占有,所以在monitor entry Set队列中等待
public static void blocked() {
    final Object lock = new Object();
    // 开创新线程 并获取到锁对象 sleeping
    new Thread() {
        public void run() {
            synchronized (lock) {
                System.out.println("i got lock, but don't release");
                try {
                    Thread.sleep(1000L * 1000);
                } catch (InterruptedException e) {
                }
            }
        }
    }.start();
	// 主线程代码 --- 阻塞态
    try { Thread.sleep(100); } catch (InterruptedException e) {}
    synchronized (lock) {
        try {
            Thread.sleep(30 * 1000);
        } catch (InterruptedException e) {
        }
    }
}
案例2 暂停线程
image-20201204100520570

分析:

  1. TIMED_WAITING(paring) 等待状态,超时退出
public static void timedWaiting() {
    final Object lock = new Object();
    synchronized (lock) {
        try {
            // 只等待30s
            lock.wait(30 * 1000);
        } catch (InterruptedException e) {
        }
    }
}
案例3 等待其他线程唤醒的线程

image-20201204100723043

分析:

  1. 状态为 TIME_WAITING(on object monitor) ,线程进入了边界区后执行力wait方法,使线程进入waitSet队列中等待
public static void waiting() {
    final Object lock = new Object();
    synchronized (lock) {
        try {
            lock.wait();
        } catch (InterruptedException e) {
        }
    }
}
案例5 正常运行线程

image-20201204100832187

案例6 睡眠线程
image-20201204101623515

JVM配置参数一览

类文件结构

​ Java语言中的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合表达,这决定了字节码指令所能提供的语言描述能力必须比Java语言本身更加强大才行。因此,有一 些Java语言本身无法有效支持的语言特性并不代表在字节码中也无法有效表达出来,这为其他程序言实现一些有别于Java的语言特性提供了发挥空间。

image-20201208142311231

类二进制文件主要的分布如图所示:

image-20201208170320396

表和集合的定义

​ 根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数 据,这种伪结构中只有两种数据类型:“无符号数”和“”。后面的解析都要以这两种数据类型为基础

  • 无符号数,基本的数据类型,以U1,U2,U4,U8为代表分表表示:1个字节,2个字节,4个字节 ,8个字节
  • 表是由多个无符号数构成的一种复合结构,所有的表命名都会有一个_info结尾

当需要描述同一个类型但数量不定的数据时,都会在前缀加一个容量计数器。这一形式称之为某一数据项的集合

常量池

​ 类二进制文件中的前两个字节是魔术数用以表述文件的类型,Class文件的魔术数是“CAFEBABE”;后两个字节表示JAVA的版本号。紧跟着版本号的区域就是常量池区域。常量池中的常量数量是不定的所以在常量池入口放一个U2类型的计数器代表常量的个数。image-20201208150048557

​ 如图所示,00X89的大小为22,说明又22-1个常量。常量池中存储两类常量:字面量和符号引用:

  • 字面量:文本字符串,final类型的常量。
  • 符号引用:包名,类的全限定名,字段的名称和描述符,方法的名称和描述符,方法的句柄和方法类型…编译原理方面的概念

常量池中每一个常量都是一个表结构,这些表结构都有一些特点:起始位都是一个u1类型的标志位代表常量的类型,常量类型有:

image-20201208151809987

​ 每个不同类型的常量都有不同的结构所以分析起来很繁琐,可以使用javap来输出class文件的字节码:

C:\>javap -verbose TestClass 
Compiled from "TestClass.java" 
public class org.fenixsoft.clazz.TestClass extends java.lang.Object 
SourceFile: "TestClass.java" 
minor version: 0 
major version: 50 
Constant pool: 
const #1 = class #2; // org/fenixsoft/clazz/TestClass 
const #2 = Asciz org/fenixsoft/clazz/TestClass; 
const #3 = class #4; // java/lang/Object 
const #4 = Asciz java/lang/Object; 
const #5 = Asciz m; 
const #6 = Asciz I; 
const #7 = Asciz <init>; 
const #8 = Asciz ()V; 
const #9 = Asciz Code; 
const #10 = Method #3.#11; // java/lang/Object."<init>":()V 
const #11 = NameAndType #7:#8;// "<init>":()V 
const #12 = Asciz LineNumberTable; 
const #13 = Asciz LocalVariableTable; 
const #14 = Asciz this; 
const #15 = Asciz Lorg/fenixsoft/clazz/TestClass;; 
const #16 = Asciz inc; 
const #17 = Asciz ()I; 
const #18 = Field #1.#19; // org/fenixsoft/clazz/TestClass.m:I 
const #19 = NameAndType #5:#6; // m:I 
const #20 = Asciz SourceFile; 
const #21 = Asciz TestClass.java; 

可以看出确实有21项常量。

访问标志

​ 在常量池后面紧接着是一个u2类型的访问表示位,用以识别类或者接口的访问信息:

image-20201208153235481

类索引、父类索引与接口索引集合

​ 类索引和父类索引都是一个U2类型的数据,而接口索引是一组u2类型的集合,类文件以这几个信息来确认类的继承关系,类索引用于确认这个类的全限定名,父类索引用以确认父类的全限定名,接口集合是用以确认改类实现了哪些接口。

​ 类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。

image-20201208154033109

​ 上图就是类索引查找全限定名的过程。

字段集合

​ 表结构:

image-20201208155043348

  • access_flags 字段的访问权限 public,private,default,protect

  • name_index 字段简单名的常量索引位置

  • descriptor_index 字段描述符的所有位置;描述符就是描述字段的数据类型,如果修饰方法的则表示描述方法的参数列表和返回值

  • attributes_count 属性计数器

  • attributes 属性表

    字段表集合中不会列出从父类或者父接口继承而来的字段。可能出现在java代码中没有出现的字段,比如在内部类中为了保持内部类对外部类的访问所以编译器会自动在内部类中加上指向外部类的字段。

image-20201208162240499

方法表集合

image-20201208161904827

方法表集合的结构和字段表是一样的,java方法中的方法体是存储在属性表集合中的Code属性中。

image-20201208162253810

虚拟机类加载机制

​ Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成 的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销, 但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动 态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络 或其他地方上加载一个二进制流作为其程序代码的一部分。这种动态组装应用的方式目前已广泛应用于Java程序之中,从最基础的Applet、JSP到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才得以诞生。

image-20201208171009665

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


类加载过程:

加载

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

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

数组加载具有特殊性:

​ 数组类本身不通过类加载器创建,它是由Java虚拟机直接在 内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:

  1. 如果数组元素是非基本数据类型则递归的去加载元素类,,数组将被标识在加载该组件类型的类加载器的类名称空间上
  2. 如果数组是基本数据类型,则JVM虚拟机将数组和引导类加载器关联

验证

​ 验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

​ 主要验证一下几个项目:

  1. 文件格式验证 ,判断文件是否是类二进制文件并且该文件是否能被当前版本的虚拟机解析
  2. 元数据验证 ,判断元数据的语义是否正确
  3. 字节码验证 ,判断class文件的code属性字节码是否存在语义错误
  4. 符号引用验证 ,该阶段是发生于解析阶段,将符号引用转换成直接引用。

准备

​ 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。JVM虚拟机规定类变量是放在方法区中的。在JDK1.7以前方法区是由永久代实现的,而在1.7以后则是随着Class类对象存放在堆中。

​ 开辟内存空间以后会给变量附上初始值(默认值),如图所示:

image-20201209101950752

解析

​ 解析是将常量池中的符号引用转换成直接引用的过程。符号引用就是类似于类信息集合(CONSTANT_Class_info),方法信息集合(CONSTANT_Fieldref_info),字段信息集合(CONSTANT_Methodref_info)。

直接引用:直接指向目标的指针,相对偏移量或者是间接定位到目标的句柄。直接引用和虚拟机的内存布局有关的,同一个符号引用在不同机器上面的直接引用的地址可能不一样。

符号引用:用一组符号来描述所引用的对象,符号引用可以是任何的字面量只要能对位到目标就可以。符号引用和虚拟机的内存布局没有关系,目标不一定是已经加载到内存的对象。

​ 解析动作主要针对类和接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符这七种符号引用,分别对应常量池中的:

CONSTANT_Class_infoCON-STANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_infoCONSTANT_MethodHandle_infoCONSTANT_Dyna-mic_infoCONSTANT_InvokeDynamic_info

类或接口的解析

​ 假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

  1. 如果C不是一个数组,虚拟机会将N的全限定名传递给D的类加载器去加载这个这个类,加载的过程中可能会加载此类的父类或实现的接口
  2. 如果C是一个数组且数据元素是引用类型,那么虚拟机就会执行第一步的加载
  3. 进行符号引用验证,判断类D对类C谁否有访问权限。如果无访问权限则报异常:java.lang.IllegalAccessError
字段解析

​ 在解析字段之前会首先对该字段所属的类进行解析,如果解析此类或接口失败时,字段解析也就失败了。如果解析成功后把解析成功的类叫做C,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:

  1. 如果C包含了简单名称和字符描述符都与目标字段相匹配的字段时,直接这个字段的直接引用,结束。
  2. 1中没有搜素到且C实现了若干的接口,则遍历各个接口和他的父接口,如果查询到符合的字段则返回,结束。
  3. 搜素哦C的父类中是否有此字段,查询到则返回,结束。
  4. 查询失败,抛出NoSuchFieldError异常
方法解析

​ 方法解析和字段解析的套路是一样的,先要加载对应的类到内存中再通过如下顺序搜素:

  1. 在类中通过简单名和描述符去搜素对应的方法
  2. 在类的父类中递归的查询方法
  3. 在类的接口和其父类上搜素,如果找到了说明其是抽象方法会抛出异常
  4. 查询失败,没有找到

初始化

​ 初始化阶段就是执行类构造器<clinit>()方法的过程,将类属性赋初始值。<clint()>方法是JVM自动根据类字节码中的静态属性从上到下搜集而成的一个赋值方法。

静态语句只能访问到他之前的变量,对于他之后的变量只能设置不能访问:

public class Test { 
    static { 
    i = 0; // 给变量复制可以正常编译通过 
    System.out.print(i); // 这句编译器会提示“非法向前引用” 
    }
    static int i = 1; 
} 

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

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

static class Parent { 
    public static int A = 1; 
    static { 
    	A = 2; 
    } 
}
static class Sub extends Parent { 
    public static int B = A; 
	public static void main(String[] args) { 
    	System.out.println(Sub.B);//2
    }
}

JVM在多个线程中多个相同类同时初始化时会加锁来保证线程安全。

触发初始化的情况

有且只有以下六种情况会出发初始化操作:

  1. 实例化对象时,使用类的静态方法和静态字段时
  2. 使用反射处理该类时
  3. 初始化子类时父类未被初始化时,初始化父类
  4. 虚拟机启动的主类需要初始化
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 如果某接口中定义了默认方法,他的子类初始化时 接口也要初始化

类加载器

定义:

​ 能通过类的全限定名将描述该类的二进制文件读取到JVM虚拟机中的代码叫做类加载器。

类加载器能唯一标定类:

一个类被加载到内存中,那么他的Class对象上也会被打上加载他的类加载器的信息,所以类的全限定名和类加载器保证了一个类的唯一性。换句话说只有两个类是来自同一个class二进制流且被同一个类回收器回收的才能被称之为相同。(equals,isInstance)

双亲委派机制

​ 类加载器的种类:

  • 启动类加载器 :称之为BootstrapClassLoader,采用C++实现,是虚拟机的一部分。他是用来加载存放在<JAVA_HOME>/lib目录下面的Class文件的,由于启动类加载器是用c++编写的所以他是不存在于JVM内存中。null值就是启动类加载器的值。 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类

  • 扩展类加载器 : ExtensionClassLoader,负责加载<JAVA_HOME>/lib/ext中的类库,这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能

  • 应用类加载器:ApplicationClassLoader,用来加载ClassPath路径的类库,一般情况下就是程序中默认的加载器

image-20201209161651363

​ 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载 器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

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

​ 这样做的意义:类随着类加载器具有了优先级的层次关系,如Object类最终只能被最上层的启动类加载器所加载那么就能保证所有Object类都是相同的,如果不使用双亲委派机制那么用户甚至可以创建一个Object这样程序会变得很混乱而且不安全。

双亲委派的代码:

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);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}
获取类加载器
  • 获取当前类的加载器,objet.getClass ().getClassLoader ();
  • 获取系统的类加载器,ClassLoader.getSystemClassLoader()
  • 获取线程上下文的加载器:Thread.currentThread().getContextClassLoader();

破坏双亲委派

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI 接口中的代码经常需要加载具体的实现类。那么问题来了, SPI 的接口是 Java 核心库的一部分,是由 ** 启动类加载器 (Bootstrap Classloader) 来加载的;**SPI 的实现类**是由系统类加载器 (System ClassLoader)** 来加载的。引导类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader 无法委派 AppClassLoader 来加载类。

而线程上下文类加载器破坏了 “双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。

为什么要破坏双亲委派?

​ 在JNDI中JDK负责开发SPI抽线的接口,而各类厂商需要根据接口开发自己的实现类。JDNI接口一半都是放置在JDK目录下的rt.jar包中的是由启动类加载器去加载的,而实现类是各类尝试封装完成的,按照需要引入到ClassPath目录中去。这个时候类加载器就加载不到这些实现类了。

如何解决?

​ 通过引入ServiceLoader类和ThreadContextClassLoader线程上下文类加载器配合实现,ServiceLoader负责扫描所有的ClassPath路径下的Jar包中的META-INFO/services中的信息:

image-20201210101851106

​ 文件名为SPI接口的全限定名,而文件中的内容每行代表的是这个接口的实现类,由于ServiceLoader是由启动类加载器所加载的无法去加载实现类,所以需要逆着双亲委派的构造去使用应用类加载器去加载,找个时候就通过ThreadConetxtClassLoader加载器获取当前线程的ThreadLocal中的加载器去加载。

为什么一定要使用TCCL呢???

​ 为什么一定要用TCCL来实现呢,在Jdk中是有方法可以直接获取到系统类加载器的对象:ClassLoader.getSystemClassLoader(),直接在rt.jar中的某个包中的托管类(如DriverManager)获取到系统类加载器去加载就可以了啊,但是考虑到很多情况我们不清楚最底层的加载器到底是哪个,在JAVASE中应用类加载器就是最底层的,但是在Tomcat容器中除了JDK预设的三个类加载器之外还有很多自定义的加载器如:CommonClassLoader,SharedClassLoader,WebAppClassLoader等,所以没办法在一个类中定义好该用哪个类去加载。找个适合JDK就默认使用ThreadLocal中保存的类加载器去加载,找个类加载器是可以被用户程序去设置的。

SPI 机制简介
SPI 的全名为 Service Provider Interface,主要是应用于厂商自定义组件或插件中。在 java.util.ServiceLoader 的文档里有比较详细的介绍。简单的总结下 java SPI 机制的思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml 解析模块、jdbc 模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 Java SPI 就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似 IOC 的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。
SPI 具体约定
Java SPI 的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在 jar 包的 META-INF/services/ 目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该 jar 包 META-INF/services/ 里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk 提供服务实现查找的一个工具类:java.util.ServiceLoader

Tomcat中存在的类加载器

Tomcat 加载器的实现清晰易懂,并且采用了官方推荐的 “正统” 的使用类加载器的方式(满足双亲委派机制)。

在 Tomcat 目录结构中,有三组目录(“/common/”,“/server/” 和 “shared/”)可以存放公用 Java 类库,此外还有第四组 Web 应用程序自身的目录 “/WEB-INF/”,把 java 类库放置在这些目录中的含义分别是:

  • 放置在 common 目录中:类库可被 Tomcat 和所有的 Web 应用程序共同使用。
  • 放置在 server 目录中:类库可被 Tomcat 使用,但对所有的 Web 应用程序都不可见。
  • 放置在 shared 目录中:类库可被所有的 Web 应用程序共同使用,但对 Tomcat 自己不可见。
  • 放置在 / WebApp/WEB-INF 目录中:类库仅仅可以被此 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,如下图所示

image-20201210141610433

​ Common类加载器就是加载/common/**目录下的jar包,所有的WebApp和Server都可以访问到。

​ CatalinaClassLoader是用来加载/server/**目录下的jar包,只允许tomcat服务访问的

​ SharedClassLoader 和 WebAppClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /shared/* 和 /WebApp/WEB-INF/* 中的 Java 类库。

Spring中的类加载器

​ 如果部署在Tomcat容器中有10个WebApp服务则应该将Spring的jar包放入到common或者是shared目录下面让web应用共享这些jar包,但是这又带来一个问题,由于Spring的类加载器是SharedClassLoader或者是CommonClassLoader但是WebApp多是在/webapp/WEB-INFO/目录下,这让Spring的类加载器无法去加载。找个时候是通过ThreadContextClassLoader来实现的加载,在初始化的时候向Thread中的ClassLoader放入WebAppClassLoader作为默认加载器,这样就能让Spring通过TCCL来加载/webapp/WEB-INFO/目录下的类。

​ 在 web.xml 中定义的 listener 为 org.springframework.web.context.ContextLoaderListener,它最终调用了 org.springframework.web.context.ContextLoader 类来装载 bean,具体方法如下

public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
	try {
		// 创建WebApplicationContext
		if (this.context == null) {
			this.context = createWebApplicationContext(servletContext);
		}
		// 将其保存到该webapp的servletContext中		
		servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
		// 获取线程上下文类加载器,默认为WebAppClassLoader
		ClassLoader ccl = Thread.currentThread().getContextClassLoader();
		// 如果spring的jar包放在每个webapp自己的目录中
		// 此时线程上下文类加载器会与本类的类加载器(加载spring的)相同,都是WebAppClassLoader
		if (ccl == ContextLoader.class.getClassLoader()) {
			currentContext = this.context;
		}
		else if (ccl != null) {
			// 如果不同,也就是上面说的那个问题的情况,那么用一个map把刚才创建的WebApplicationContext及对应的WebAppClassLoader存下来
			// 一个webapp对应一个记录,后续调用时直接根据WebAppClassLoader来取出
			currentContextPerThread.put(ccl, this.context);
		}
		
		return this.context;
	}
	catch (RuntimeException ex) {
		logger.error("Context initialization failed", ex);
		throw ex;
	}
	catch (Error err) {
		logger.error("Context initialization failed", err);
		throw err;
	}
}

总结出线程上下文类加载器的适用场景:

  1. 当高层(从类加载器的角度来看)定义了抽象接口让底层去实现具象且高层去加载底层的类时,必须要破坏双亲委派规则使用TCCL来加载底层类
  2. 当使用本类托管加载时又不清楚加载本类的加载器时,为了隔离不同的调用者遂使用TCCL加载。

虚拟机执行引擎

​ JAVA虚拟机以方法为基本执行单位,栈帧结构用以支持虚拟机进行方法调用。栈帧中存储了方法的局部变量表,操作数栈,动态连接,和方法返回的地址信息。一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。

栈帧结构概念

局部变量表

​ 用以存放方法的参数和方法内部定义的局部变量。当方法被调用时,JAVA虚拟机会使用局部变量表来完成参数值到参数变量列表的转换过程,实参->形参。如果执行的是实例方法则局部变量表的第0位是所属对象实例的引用。在方法中通过this来访问到此实例的内容。

​ 局部变量和类变量不同,类变量在准备阶段就已经初始化了而s初始化阶段更是给了类变量赋值,而局部变量是没有这个过程的。而实例变量也不会报错这是以为在实例化的时候会给实例变量初始化赋初值。

局部变量报错:

image-20201214171745367

操作数栈

​ 与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈 / 压栈来访问。操作数栈可理解为 java 虚拟机栈中的一个用于计算的临时数据存储区。存储的数据与局部变量表一致含 int、long、float、double、reference、returnType,操作数栈中 byte、short、char 压栈前 (bipush) 会被转为 int。

加法的过程:

public int test() {
        //int 类型
        int a = 5 + 10;      // 验证直接相加在编译阶段已合并完结果
        int b = a + 3;        // 探究变量与常量的相加过程
}

javap -verbose Hello.class 反编译:

 Code:
      stack=4, locals=13, args_size=1
         0: bipush      15    //1 15 压入操作数的栈顶 (编译过程中 5+10 合并成 15,并且由于 15 在 - 128-127 范围,即用 bipush)  压栈
         2: istore_1          //2  从栈顶弹出并压入局部变量表访问索引为 1 的 Slot                  弹栈入局部变量表
         3: iload_1           //3  将局部变量表中访问索引为 1 的 Slot 重新压入栈顶                 局部变量表入栈
         4: iconst_3          //4  数值 3 压入操作数的栈顶 (范围 - 1~5,即用指令 iconst)          压栈
         5: iadd              //5  将栈顶的前两个弹出并进行加法运算后将结果重新压入栈顶         		前两弹栈相加
         6: istore_2          //6   从栈顶弹出并压入局部变量表访问索引为 2 的 Slot                 弹栈入局部变量表
         7: iload_2           //7  将局部变量表中访问索引为 2 的 Slot 重新压入栈顶                 局部变量表入栈

动态连接

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

方法返回地址

​ 退出方法有两种方式,第一种,方法成功执行完毕。另一种,在方法执行期间遇到了异常且没用任何的异常处理器来处理他,那么就会退出且不会有任何的返回值。一般来说正常返回的时候都是使用主调方法区的PC计数器当作返回地址,而异常情况下则使用异常处理器表来确定的。

​ 方法返回需要做的操作:

  1. 恢复上传方法的局部变量表和操作数栈
  2. 返回值压入到调用者栈帧中的操作数栈中

方法调用规则

​ 在Class文件加载到内存中会经历一个解析的步骤,在解析中会将部分的符号引用转换为直接引用,其他的引用都是通过动态解析来完成寻址的。在JAVA中主要有静态方法和私有方法两大类是可以直接引用的,以为静态方法和私有方法都是在编译时就可知的。符合此条件的方法有:

  • 静态方法
  • 私有方法
  • 实例构造器
  • 父类方法
  • final修饰的方法 (无法被子类重写)

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

  • invokestatic。用于调用静态方法。

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

  • invokevirtual。用于调用所有的虚方法。 (所有动态解析的方法都是虚方法)

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

静态分派

静态分派又称为Method Overload Resolution是在解析期间完成的。

/**
* 方法静态分派演示 
* @author zzm 
*/ 
public class StaticDispatch { 
    static abstract class Human {}
    static class Man extends Human {}
    static class Woman extends Human {}
    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); 
    }
} 
/**
*	返回结果:
*	hello,guy! 
*	hello,guy!
**/

Human woman = new Woman();

​ Human称之为静态类型,Woman称之为动态类型。静态类型是编译器可知的而实际类型只有在运行时才能确定。编译器在重载时是根据静态类型来选择重载响应的方法,因为上面的man和woman的静态类型都是Human所以最后重载出来选择的方法就是Human的sayHello方法。

静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

动态分派

​ 动态分派的体现就是重写(Override)

/**
* 方法动态分派演示 
* @author zzm 
*/ 
public class DynamicDispatch {
    static abstract class Human {
   		protected abstract void sayHello();
    }
    static class Man extends Human {
        @Override
        protected void sayHello() {
        System.out.println("man say hello");
        }
    }
    static class Woman extends Human {
        @Override 
        protected void sayHello() {
        System.out.println("woman say hello"); 
        }
    }
    public static void main(String[] args) {
        Human man = new Man(); 
        Human woman = new Woman(); 
        man.sayHello(); 
        woman.sayHello(); 
        man = new Woman(); 
        man.sayHello(); 
    }
}
/**
*	返回结果:
*	man say hello
*	woman say hello
*	woman say hello
**/

​ 这个例子中可以看到,并没有根据静态类型去选择执行Human的方法而是根据对象的实际类型来执行方法的。通过分析字节码可以看出:两个对象的.sayHello方法的字节码是一样的:

image-20201215104326425

通过分析invokevirtual指令可以知道运行时解析的过程:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 抛出异常

所以可以得出推论:

​ 多态性的根源是取决于invokevirtual指令产生的动态分派,所以只有对方法有效。字段是没有invokevirtual指令的。字段永远不参与多态哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

/**
* 字段不参与多态 
* @author zzm 
*/ 
public class FieldHasNoPolymorphic { 
    static class Father { 
        public int money = 1; 
        public Father() { 
            money = 2; 
            showMeTheMoney(); 
        }
        public void showMeTheMoney() { 
            System.out.println("I am Father, i have $" + money); 
        } 
        }
        static class Son extends Father { 
            public int money = 3; 
            public Son() { 
            	money = 4;
                showMeTheMoney(); 
            }
            public void showMeTheMoney() { 
                System.out.println("I am Son, i have $" + money); 
            } 
    }
    public static void main(String[] args) { 
    	Father gay = new Son(); 
    	System.out.println("This gay has $" + gay.money); 
    } 
} 
/**
*	返回结果:
*   I am Son, i have $0
*	I am Son, i have $4
*	This gay has $2
**/

​ 在实例化Son的时候会先实例化他的父类,而在实例化父类的时候会触发showMeTheMoney(); 方法此时属于虚方法调用所以会更具重载的特性去执行子类的showMeTheMoney方法而此时子类的money未初始化所以是0;

​ 在此类初始化后再执行子类自身的showMeTheMoney方法,此时值为4

​ 直接调用gat.money因为字段不参与动态分派所以他只与静态分派有关系故访问到的字段应该是父类的

静态分派+动态分派示例

/**
* 单分派、多分派演示 
* @author zzm 
*/ 
public class Dispatch { 
    static class QQ {} 
    static class _360 {} 
    public static class Father { 
        public void hardChoice(QQ arg) { 
            System.out.println("father choose qq"); 
        }

        public void hardChoice(_360 arg) { 
            System.out.println ("father choose 360"); 
        } 
    }
    public static class Son extends Father { 
        public void hardChoice(QQ arg) { 
            System.out.println("son choose qq"); 
        }
        public void hardChoice(_360 arg) { 
            System.out.println("son choose 360"); 
        } 
    }
    public static void main(String[] args) { 
        Father father = new Father(); 
        Father son = new Son(); 
        father.hardChoice(new _360()); 
        son.hardChoice(new QQ()); 
    } 
} 
/***
**	返回结果
**	father choose qq
**	son choose 360
**/

分析:

​ 首先编译阶段进行静态分派解析,可以知道 father.hardChoice(new _360()); 分派的方法是参数为360的,son.hardChoice(new QQ()); 分派的方法是QQ的。在运行时动态的分派可以知道调用father.hardChoice(new _360())的对象就是Father类的实例,所以打印:father choose qq,son.hardChoice(new QQ()); 根据分析也可以得知son就是Son类的实例。

动态分派的实现

​ 动态分派的逻辑是invokevirtual找寻方法地址的过程,这个过程可能需要多次访问元数据所以很影响效率。为了去提升动态分派的效率JVM引入了两个表结构去优化这一过程。分别是虚方法表(vtable)和接口方法表(itable),结构如图所示

image-20201215113258312

​ 虚方法表中存放着各个方法的实际入口地址,如果子类没有重写父类的方法则子类和父类的方法的入口地址是一样的,且子类和父类中的方法顺序是一致的。这样按照索引就能定位要执行的方法地址。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值