jvm笔记

第二章 Java内存区域与内存溢出异常

1. 运行时数据区域

包括 方法区、堆(前两者都是共享的)、虚拟机栈、本地方法栈、程序计数器(三者是线程私有)。

方法区:与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息常量静态变量,别名称为Non-Heap

堆:Java Heap是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,此内存区域的唯一目的是存放对象实例,同时Java堆是垃圾收集管理的主要区域,很多时候也被称为 GC堆

虚拟机栈:描述的是Java方法执行的内存模型,即每个方法会在执行的同时创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。经常有人把Java内存分为堆内存和栈内存,这里的栈内存就是指的虚拟机栈。

本地方法栈:与虚拟机栈发挥的功能基本一致,区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。

程序计数器:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

运行时常量池:方法区的一部分。常量池(用于存放编译期生成的各种字面量和符号引用)。

直接内存:并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。「NIO 中会使用」 对它的使用还是不是特别了解…

堆外内存:https://blog.csdn.net/y3over/article/details/88791958

2. OutOfMemoryError异常

包括 Java堆溢出虚拟机栈和本地方法栈溢出方法区和运行时常量池溢出本机直接内存溢出

  • 堆溢出,主要就是不停的 new 对象即可;「-Xms」
  • 栈溢出,主要是定义大量的本地变量,然后递归,使得爆栈,这样会有 StackOverflowError,如果想要有 OOM,最好的方法就是不停的创建线程,因为系统分配给栈的内存是有限的,比如一般总的是 2G,然后减去堆和方法区的容量,就是栈的容量了,如果不断地 new 线程的话,那么总有一个线程会内存不够,此时就能够弹出 OOM 了;「」
  • 方法区中的运行时常量池的溢出,一般也就是使用 String.inter() 方法,这个方法是一旦池中没有需要的常量,就定义一个放到池中去,否则就拿出这个常量的地址,我不断的定义常量放入常量池,那么就很容易 OOM;「PermGen Space」
  • 方法区溢出,最基本的思路就是运行时产生大量的类去填满方法区直到溢出,这里借助的是 GGLib 直接操作字节码生成大量的动态类。「这个地方我不太会…」「-XX:PermSize」
  • 本机直接内存溢出「-XX:MaxDirectMemorySize」

3. 补充:虚拟机部分参数

-vmargs -Xms128M -Xmx512M -XX:PermSize=64M -XX:MaxPermSize=128M
-vmargs 说明后面是VM的参数,所以后面的其实都是JVM的参数了
-Xms128m JVM初始分配的堆内存
-Xmx512m JVM最大允许分配的堆内存,按需分配
-XX:PermSize=64M JVM初始分配的非堆内存
-XX:MaxPermSize=128M JVM最大允许分配的非堆内存,按需分配

第三章 垃圾收集器与内存分配策略

问题:

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

Q:哪些内存需要回收?

  • 其中,程序计数器、虚拟机栈、本地方法栈三个区域随线程而生、随线程而灭,每一个栈帧分配多少内存基本上是在类结构确定下来时就已知的(尽管 JIT 编译期会在运行期间进行一些优化,但大体可以理解为编译期间就可知),所以这几个区域的分配和回收都具备确定性,不需要过多考虑回收的问题,但是 Java 堆和方法区 就不一样了,这里是公共区域,不是线程私有的,所以只有在运行期间才能确定内存的占用分配情况,这部分的内存的分配和回收都是动态的,所以 GC 就是指的这一块的内存。

  • 很多人认为方法区(或者说 HotSpot 虚拟机中的永久代)是没有垃圾收集机制的,的确在 java 虚拟机规划中确实没有要求要在方法区实现垃圾收集,因为性价比很低,在堆中,尤其是新生代中,常规应用一次垃圾收集可以回收 70% - 95% 的空间,而方法区的垃圾收集效率远低于此。

    但是,其实还是有必要进行垃圾收集的,尤其是在大量使用反射、动态代理、GGLib等 bytecode 框架的场景是需要具备类卸载的功能防止方法区 OOM 的。永久代的垃圾收集主要回收两部分:废弃常量和无用的类,废弃常量的收集比较简单,就是没人用了,就会被请出常量池,但是无用的类这个条件就很严苛了,只有同时,注意使用时满足下面 3 个条件才能算是“无用的类”:

    • 该类所有实例被回收;
    • 加载该类的 ClassLoader 已经被回收;
    • 该类对应的 java.lang.Class 对象没有被使用,且无法在任何地方通过反射访问该类的方法。「这个好像非常的严格…」

Q:什么时候回收?

  1. 第一步肯定是确定这个对象是否还“活着”。那么如何确定呢?有哪些方法呢?
  • 第一种方法虽然不是 Java 中使用的,但是也有一些著名案例使用过。那就是计数算法:给对象添加一个计数器,引用了一次就加一,引用失效就减一,在任何时刻计数器都为0的对象就是不可能再被使用的,就可以回收。但是这里漏掉了一种情况,就是类似于死锁的情况,一个引用互相持有另外一个引用,这样虽然计数器的值都为 1 ,但是二者其实已经不可能再被访问了,理应被回收,但这个算法做不到;

  • 第二种方法就是 Java 和 C# 用的方法了,为根搜索算法,算法的基本思路就是:如果 GC Roots 对象到这个对象不可达,则这个对象不可用。

    大家可能会问了,什么是 GC Roots 对象,又如何定义不可达?

    • 可以做 GC Roots 的,其实就是部分对象:
      • 栈中本地变量表 or native 方法栈中引用的对象;
      • 方法区(non-heap)中的类静态属性引用的变量;
      • 方法区(non-heap)中的常量引用的对象;
    • 那么又如何定义不可达呢?即以 GC Roots 的对象为起始点,从这些节点向下搜索,所走过的路径称为引用链,当GC Roots 和这个对象不存在引用链,那就是不可达。
  1. 现在我们已经清楚了如何判断一个对象是 “活着” 还是 “死亡” 了,那是不是对象 “活着” 它就不会被回收呢,对象 “死亡” 我们就要将其回收呢,答案是否定的(如果是肯定的我还说个毛啊哈哈哈哈)。注意,这里的两句话都是不对的。
  • 首先是 “活着” 的对象不是一定不被回收的,要“活着”,也要看它怎么活,怎么个活法,就是对应着这个链路的强度,在 jdk1.2 以后,引用分为了四类,这个我在 多线程(四)— ThreadLocal 一文中的内存泄漏也有提及。以下的顺序,就是引用从强到弱的顺序。

    • 强引用。听这个名字就知道这是上等人,这就是我们在程序代码中普遍存在的,类似于 “Object obj = new Object()” ,只要对象的强引用还在,他就一定能好好的活着,不会被 GC 回收;「Reference类」
    • 软引用。就是一些有用但是又不是必要的,类似于工具人,被软引用关联的对象,在系统即将要发生OOM时,会将这些对象进行回收,回收之后发现还是内存不够,才会抛出 OOM 异常。「softReference类」
    • 弱引用。这个更没用,他的生存周期很短,只能活在下一次 GC 工作之前,当 GC 开始干活时,被弱引用关联的对象就得滚蛋了。「WeakReference」这里再唠叨一下,ThreadLocalMap 中的 key 对 ThreadLocal 的引用就是弱引用,目的就是为防止内存泄漏添加一个保障,当 ThreadLocal 失去了强引用后,会被 GC 处理,而此时 key 为 null,可以触发ThreadLocalMap中清除脏 Entry 的方法防止内存泄漏。
    • 虚引用。这个又被称为幽灵引用或者幻影引用(妈的我有点怕鬼..),一个对象被这个虚引用关联,等于没关联,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,唯一的作用就是可以在这个对象被 GC 回收时收到一个系统通知,就是阎王爷来收尸前派来告诉你要死的那几个小兵的作用(把我自己说怕了…)。「PhantomReference」
  • 再来解决第二个问题,那就是“死亡” 的对象,也不一定就要被回收,他只是处于缓刑阶段,还可以通过上诉来活着哈哈。(这一章是真的有意思啊) 要真正宣告一个对象死亡,至少要经历两次标记过程:第一次标记就是标记它不可达,即“死亡”的状态,同时他有一次“上诉”的机会,很明显上诉成功就不会死,上诉失败那肯定死定了。那,如何才能拥有这次难得的机会呢?那就是这个被标记为“死亡”的对象有覆盖 finalize() 方法,并且第一次调用,注意,他只有一次“上诉”的机会。当满足上诉机会的时候,即复写 finalize() 并且是第一次调用,它会被放置到 F-Queue中,并在稍后由一条由虚拟机自动创建的、低优先级的 Finalizer 去触发这个 他复写的 finalize()「注意,这里的 Finalizer由于优先级比较低,所以我们需要在线程中等待他一段时间,以确保它已经执行了。同时,注意,这个 Finalizer 很严苛,它不会允许 finalize() 一直执行,是有时间限制的,就跟上诉一样,是有期限的,过了这个期限就没用了。」,那转机肯定就在这个 finalize() 中了,只要“死亡”的对象在这个时候将自己与引用链上的任何一个对象建立关联,比如自己赋值给某个类成员变量,这就意味着上诉成功并且得到解放啦!当然如果没抓住这次机会,那肯定就死定了。

    注意这里 finalize() 只有一次机会哦。在周老师的书中,也建议我们避免使用它,虽然挺好玩的…但是 finalize() 没啥用,而且运行代价高昂,不确定性大,它能做的工作 try-finally 完全可以做的更好。

Q:如何回收?

这个问题比较宽泛,我们做一件事,首先需要理论的指导,然后再去实践。在如何回收这件问题上,我们先要提出一些垃圾收集(回收)的算法,然后再利用这个方法论去具体实现内存的回收——-垃圾收集器。

垃圾收集算法。其实就是以一种方法为基础,然后两种方法在某个领域(新生代 or 老年代)更专业,最后一种方法集大成。

  • 最基础的方法,标记-清除算法。分为两步,即“标记” 和 “清除”:首先标记出要回收的对象,然后再标记完成后统一回收掉。缺点:一个是效率问题,标记和清除都是需要较多时间,效率低,一个是空间问题,标记清除之后容易产生大量不连续的内存碎片,非常容易造成下一次想分配对象时找不到连续内存而不得触发再一次的 GC ;
  • 复制算法。主要是用在新生代中,因为新生代 98% 的对象都是死得快的…所以我们可以将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间「为什么要设立 Survivor 区,为何是两块,为何不是一块、三块、四块?设立 Survivor 区主要是考虑到防止频繁 full gc,而设立两块,则是为了解决minor gc内存碎片的问题,设立太多反而会造成 Survivor空间太小,导致full gc 频繁,所以两块 Survivor 是最好的。」,然后每次使用 Eden 和 一块 Survivor,另外一块 Survivor 就可以用来存垃圾收集完之后还存活着的对象,这样清理也方便,效率得到解决,同时也不会有空间上的问题,因为可以在复制的时候直接移动堆顶指针按顺序分配就行了。但是吧,这也有个很明显的缺点,就是 Survivor 这块内存被浪费了,并且我们并不能保证每次回收之后的对象大小不超过 Survivor,当 Survivor 空间不够时,我们还需要用到一个策略:分配担保。即当 Survivor 空间不够时,这些对象会直接通过分配担保策略进入到老年代;
  • 标记-整理算法。因为老年代存活率很高,肯定就不能用复制算法了,这里的标记-整理算法,就是对标记-清除的升级,在清理之前,由于不能像复制算法一样直接清理,只能是让所有存活的对象都向一端移动,然后再清理掉边界以外的内存; 「G1 就用了这种算法。」
  • 分代收集算法。只是根据对象的存活周期的不同将内存划分为新生代和老年代,然后新生代使用复制算法,老年代使用标记-清理 or 标记-整理。

垃圾收集算法的实现——垃圾收集器

既然垃圾收集算法,是按照新生代和老年代分别去设计的,那自然垃圾收集器肯定也是按照新生代和老年代去实现。

垃圾收集器

新生代的收集器包括

  1. Serial
  2. ParNew
  3. Parallel Scavenge

2.老年代的收集器包括

  1. Serial Old
  2. Parallel Old
  3. CMS

3.回收整个Java堆(新生代和老年代)

  1. G1收集器

具体的搭配如上图所示。下面具体讲一下用的场景和优缺点。

新生代收集器:

  1. Serial串行收集器-复制算法

    Serial收集器是新生代单线程收集器,优点是简单高效,是最基本、发展历史最悠久的收集器。它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。但依然是虚拟机运行在Client模式下默认新生代收集器,对于运行在Client模式下的虚拟机来说是一个很好的选择。

  2. ParNew收集器-复制算法

    也是新生代并行收集器,其实就是Serial收集器的多线程版本。主义只能做到并行,不能并发。除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial 收集器完全一样。

    并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;

    并发:用户线程和多垃圾收集线程同时执行(但不一定是并行的,可能是交替执行)

  3. Parallel Scavenge(并行回收)收集器-复制算法
    新生代并行收集器,追求高吞吐量,高效利用 CPU。该收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),所以该收集器也被常称为 “吞吐量优先”收集器。

    它和 ParNew 的主要区别有两个:

    • 关注点不一样,一个是为了停顿时间更短,这适合用户交互,一个则是为了最高的利用 CPU 时间,适合后台运算;
    • Parallel Scanvenge 有 GC 自适应的调节策略,无需自己设置新生代大小、Eden 和 Survivor的比例等等,只需要把基本的内存数据(如堆大小、吞吐量)设置好即可。

    好像都是优点,但是其有一个致命的缺陷,就是它不能跟 CMS 搭配,所以CMS 还是一般和 ParNew 联合使用。

老年代收集器:

  1. Serial Old 收集器-标记 - 整理算法

    Serial Old是Serial收集器的老年代版本,它同样是一个单线程(串行)收集器,使用标记整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用

    如果在Server模式下,主要两大用途:

    (1)在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用;

    (2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

  2. Parallel Old 收集器-标记 - 整理算法

    Parallel Old 是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在1.6中才开始提供。

  3. CMS 收集器-标记 - 清除算法

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。适用于互联网站的服务器端。提高响应速度。

    它的运作过程相对前面几种收集器来说更复杂一些,整个过程分为4个步骤:

    1. 初始标记。标记 GC Roots 能直接关联到的对象,速度非常快;
    2. 并发标记,收集器线程和用户线程同步进行;
    3. 重新标记,修正并发标记期间的变化;
    4. 并发清除。

    所以优点很明显:1. 并发收集;2. 低停顿,所以又称为 “并发低停顿收集器”;

    缺点也有:

    1. 对 CPU 资源敏感,默认启用 (CPU 数量 + 3)/4,cpu少的话那影响很大;
    2. 无法处理浮动垃圾「清理阶段可能会有新的垃圾产生」,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活。
    3. CMS是基于“标记-清除”算法实现的收集器,手机结束时会有大量空间碎片产生。空间碎片过多,可能会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发FullGC

新生代和老年代垃圾收集器:

G1 收集器-分代收集算法 — 兼顾 吞吐量 & 低停顿

  1. 独特的分代垃圾回收器,分代GC: 分代收集器, 同时兼顾年轻代和老年代;

  2. 使用分区算法, 不要求eden, 年轻代或老年代的空间都连续;

  3. 并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源;

  4. 空间整理: 回收过程中, 会进行适当对象移动, 减少空间碎片;

  5. 可预见性: G1可选取部分区域进行回收, 可以缩小回收范围, 减少全局停顿。

G1收集器的阶段分以下几个步骤:

1、初始标记(它标记了从GC Root开始直接可达的对象);

2、并发标记(从GC Roots开始对堆中对象进行可达性分析,找出存活对象);

3、最终标记(标记那些在并发标记阶段发生变化的对象,将被回收);

4、筛选回收(首先对各个Region的回收价值和成本进行排序,根据用户所期待的GC停顿时间指定回收计划,优先回收垃圾最多的Region,因此称为 Garbage First)。

经常使用的参数:

-XX:+UseSerialGC:在新生代和老年代使用串行收集器
-XX:+UseParNewGC:在新生代使用并行收集器
-XX:+UseParallelGC :新生代使用并行回收收集器,更加关注吞吐量
-XX:+UseParallelOldGC:老年代使用并行回收收集器
-XX:ParallelGCThreads:设置用于垃圾回收的线程数
-XX:+UseConcMarkSweepGC:新生代使用并行收集器,老年代使用CMS+串行收集器
-XX:ParallelCMSThreads:设定CMS的线程数量
-XX:+UseG1GC:启用G1垃圾回收器

上面三个问题,基本解决了垃圾收集方面的问题了,那回过头来,对象又是如何分配内存的呢?

  1. 对象优先在新生代 Eden 区中分配,当 Eden 区没有足够的空间进行分配时,虚拟机会发起一次 Minor GC,将 GC 后还存活的对象转移Survivor区,如果Survivor区溢出了会转移到老年代中。

    新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大都具有死得快的特性,所以 Minor GC 会非常的频繁,速度也很快;

    老年代GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC,当然也并非绝对,在 ParallelScaVenge 就有直接进行 Major GC 的策略选择过程。Major GC 的速度比 Minor GC 慢 10倍以上。

  2. 大对象会直接进入老年代,因为就算安排进新生代的 Eden 区,Eden 区及两个 Survivor区之间会进行大量的内存拷贝,而且极有可能 Survivor 装不下进而触发分配担保机制转移到老年代,与其这样,不如直接将大对象直接进入老年代。

上文一直在讲新生代和老年代,那二者到底是如何界定呢?

虚拟机给每个对象都定义了一个对象年龄计数器,如果对象在新生代的 Eden 区域出生并且经过第一次 Minor GC 后仍然留在 Survivor 中,那么他的对象年龄变为 1 岁,从此之后,该对象在 Survivor 每熬过一次 Minor GC ,年龄就会增加一岁,默认会到 15 岁才晋升到老年代中,阈值可以通过参数 -XX:MaxTenuringThreshold设置。

当然,为了适应不同程序的内存情况,虚拟机并不会总是按照对象年龄大于MaxTenuringThreshold才晋升到老年代,如果 Survivor 空间中相同年龄的对象的总大小已经超过了 Survivor 的空间的一半,则年龄大于或等于该年龄的对象直接就可以晋升到老年代。

那为何要有这种动态年龄计算呢?

如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:

a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。

b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。

在本章的最后,再来详细叙述一下空间分配担保

  1. 在发生 Minor GC时,虚拟机 会检测一下之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小(因为在实际回收之前是无法确定本轮的对象大小是多少,只能按以往的经验值估计)

  2. 如果大于,说明,崩了,只能进行 Full GC了;

  3. 如果小于,则再查看 HandlePromotionFailure(处理晋升失败)的设置(默认打开,不然Full GC 太频繁了,慢的一批),

  4. 如果允许,则只会进行 Minor GC,如果Minor GC后担保失败,那就没办法了只能选择Full GC,否则如果不允许失败,那就只能进行 Full GC。
谈到这么多次 Full GC,Minor GC那我想问一个问题,如果 Full GC or Minor GC 频繁,有哪些原因导致?

Full GC 频繁:

先来分析一下,什么时候会触发 Full GC:

  1. 程序执行了System.gc() //建议jvm执行fullgc,并不一定会执行
  2. 执行了jmap -histo:live pid命令 //这个会立即触发fullgc…这个可以不讲
  3. 在执行minor gc的时候进行的一系列检查 // 三次 fullgc 的可能
  4. 使用了很多短命的大对象 // 大对象直接进入到老年代
  5. 在程序中长期持有了对象的引用 //对象年龄达到指定阈值也会进入老年代

首先 1 2 这两情况我们不考虑,第 4 5 种情况也不用分析了,重点是第 3 种情况,minor gc 后导致 full gc,我觉得原因可能有:

  1. Eden 设置过小 or Survivor 设置过小,导致频繁触发 minor gc or 担保策略触发,这种情况下一次 full gc后,剩余的对象应该很少。
  2. 可能是老年代内存设置过小,导致很容易 full gc。这种情况下,一次 full gc 后,在老年代中内存应该不会减少很多,回收率很低。

所以总的来说,先看一次回收 full gc 后的结果,然后再进行判断。

Minor GC 频繁:

那就只能是 Eden 设置的太小了。

JVM 调优的基本思路:

如果是 CMS

  1. 通过看JDK自带的图形化工具,检查线程的状态。了解到一个Java进程有多少线程,每个线程什么状态,是不是在等着锁:进程的CPU和内存占用了多少;
  2. 如果发现程序跑得很慢,就用图形化界面去看 GC 日志;
  3. 总的来说就是要去合理设置年轻代和老年代的大小,这是一个迭代的过程,可以采用 JVM 默认的配置通过压力测试去分析 GC 日志;
  4. 如果发现经常 Minor GC,并且回收率并不高,则说明 Eden 区设置的过小;
  5. 如果发现经常 Full GC,先考虑是不带自己写的代码有问题,比如执行了 System.gc()、使用了过多全局变量、使用了很多短命的大对象,然后如果排查不出来;
  6. 再考虑参数调优,如果发现 Full GC 后内存占有率下来了,说明清除了大量垃圾,可以考虑调大年轻代 Eden or Survivor,如果没下来,可能是 内存泄漏 or 老年代太小。

如果是 G1,则直接调大堆的内存应该就行了。因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。

img

第六章 类文件结构

编译过程

这一章其实还是比较简单但是枯燥的,主要是讲了 Class 文件字节码结构。

将 .java 文件变成 .class 文件,就是我们常说的编译过程,这一过程是靠 javac 这个编译器完成的。Java 和 C/C++ 不一样,C/C++ 是通过直接将源代码编译成目标机器码(CPU直接执行的指令集合),而 Java 则是在中间加了一层字节码,也就是把 java文件转换成 class 字节码,然后 JVM 能够识别字节码,也就是能够加载 .class 文件(进而再通过 JVM 将字节码转换为机器码,这一步也可没有)。

img

————————————————
版权声明:本文为CSDN博主「麦田」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/itmyhome1990/article/details/78847266

Class 文件组成

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。Class 文件是一组以 8 位字节为基础单位的二进制流。

根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表

无符号数属于基本的数据类型,以u1、 u2、 u4、 u8来分别代表1个字节、 2个字节、 4个字节和8个字节的无符号数,无符号数可以用来描述数字、 索引引用、 数量值或者按照UTF-8编码构成字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。 表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表,它由如下表所示的数据项构成。

img

如上图所示,整个 Class 文件 按如下排列:魔数(4) – 次版本号(2) – 主版本号(2) – 常量池常量数(2) – 常量池表(不确定长度) – 访问标志(2) – 类索引(2) – 父类索引(2) – 实现的接口数(2) – 实现的接口集合(2) – 实现的字段数(2) – 字段集合表(不确定长度) – 方法数(2) – 方法集合表(不确定长度) – 属性数(2) – 属性集合表(不确定长度)。

具体组成部分

1. 魔数

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。

2. Class文件的版本

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(Major Version)。 Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

3. 常量池

常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、 声明为final的常量值等。 而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符

常量池中的每一项又对应着一个表,总共11种数据类型的结构,注意 tag = 2 这个值没有:

image-20200306154209266

4. 访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

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

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。 类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。 由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

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

对于接口索引集合,入口的第一项——u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。 如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

6. 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。其实也就是类的静态变量或者是实例成员变量。 字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。 我们可以想一想在Java中描述一个字段可以包含什么信息?可以包括的信息有:字段的作用域(public、 private、 protected修饰符)、 是实例变量还是类变量(static修饰符)、 可变性(final)、 并发可见性(volatile修饰符,是否强制从主内存读写)、 可否被序列化(transient修饰符)、 字段数据类型(基本类型、 对象、 数组)、 字段名称。 上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。 而字段叫什么名字、 字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

7. 方法表集合

和字段表基本一样,仅在访问标志和属性表集合的可选项有些区别。就是看方法有没有被 synchronized、abstract、final等修饰。

8. 属性表集合

属性表集合中包含了大量的数据信息,上面的所有类型都有十分严格顺序,长度,大小。而属性表中就没有那么严格了,我们编写的最多的Code就存放在属性表集合中的CODE表中,一共有21项比如还包含:Exception表等等。具体的每一个项都是有意义的,有点多简单的介绍一下主要的:

1、Code 属性

Java方法体里面的代码经过Javac编译之后,最终变为字节码指令存储在Code属性内,Code属性出现在方法表的属性集合中,但在接口或抽象类中就不存在Code属性。

2、Exception属性

列举出方法中可能抛出的受查异常。

3、LineNumberTable属性

描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。主要是如果抛出异常时,编译器会显示行号,就是这个属性的作用。

4、LocalVariableTable属性

描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。用处在于当别人使用这个方法是能够显示出方法定义的参数名。

5、SourseFile属性

记录生成这个Class文件的源码文件名称,抛出异常时能够显示错误代码所属的文件名。

6、ConstantValue属性

通知虚拟机自动为静态变量赋值,只有被static字符修饰的变量(类变量)才可以有这项属性。

7、InnerClass属性

用于记录内部类与宿主类之间的关联。

8、Deprecated和Synthetic属性

这两个都是标志类型的布尔属性,Deprecated表示不再推荐使用,注解@deprecated
Synthetic表示此字段或方法是由编译器自行添加的。

Demo

为了更好的理解 Class 的组成部分,我决定写一个 demo,让我们一步步来分析一下。

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package JVM;

public class SubClass extends SuperClass {
@Override
public void A(){
super.A();
System.out.println(2222);
}

public static void main(String[] args) {
test_heap_outOfMemory t = new test_heap_outOfMemory();
t.C();
SubClass subClass = new SubClass();
subClass.A();
}
}

Byte Code viewer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// class version 52.0 (52)
// access flags 0x21
public class JVM/SubClass extends JVM/SuperClass {

// compiled from: SubClass.java

// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL JVM/SuperClass.<init> ()V
RETURN
L1
LOCALVARIABLE this LJVM/SubClass; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

// access flags 0x1
public A()V
L0
LINENUMBER 6 L0
ALOAD 0
INVOKESPECIAL JVM/SuperClass.A ()V
L1
LINENUMBER 7 L1
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
SIPUSH 2222
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L2
LINENUMBER 8 L2
RETURN
L3
LOCALVARIABLE this LJVM/SubClass; L0 L3 0
MAXSTACK = 2
MAXLOCALS = 1

// access flags 0x9
public static main([Ljava/lang/String;)V
L0
LINENUMBER 11 L0
NEW JVM/test_heap_outOfMemory
DUP
INVOKESPECIAL JVM/test_heap_outOfMemory.<init> ()V
ASTORE 1
L1
LINENUMBER 12 L1
ALOAD 1
INVOKEVIRTUAL JVM/test_heap_outOfMemory.C ()V
L2
LINENUMBER 13 L2
NEW JVM/SubClass
DUP
INVOKESPECIAL JVM/SubClass.<init> ()V
ASTORE 2
L3
LINENUMBER 14 L3
ALOAD 2
INVOKEVIRTUAL JVM/SubClass.A ()V
L4
LINENUMBER 17 L4
RETURN
L5
LOCALVARIABLE args [Ljava/lang/String; L0 L5 0
LOCALVARIABLE t LJVM/test_heap_outOfMemory; L1 L5 1
LOCALVARIABLE subClass LJVM/SubClass; L3 L5 2
MAXSTACK = 2
MAXLOCALS = 3
}

jsclasslib

我们使用 jsclasslib 这个可视化界面来看一下具体的 class 信息。

image-20200309170821767

首先可以看到, jdk的版本的主版本号是 1.8,次版本号是 0,常量池的总数是34,访问标志,即该类是 public or default,然后左边对应的有常量池、接口表、字段表、属性表、方法表。

详细分析可以看这个:https://juejin.im/post/5b5ac6d76fb9a04f8d6bc7d6#heading-29

第七章 虚拟机类加载机制

参考:https://juejin.im/post/5ae2d580f265da0b851c95ea#heading-17

1. 概述

在第六章,讲到了 Class 文件,那我们是如何将 Class 文件加载到虚拟机,然后运行和使用呢?

1.1 虚拟机类加载机制的概念

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

1.2 Java语言的动态加载和动态连接

另外需要注意的很重要的一点是:java语言中类型的加载连接以及初始化过程都是在程序运行期间完成的,这种策略虽然会使类加载时稍微增加一些性能开销,但是会为java应用程序提供高度的灵活性。java里天生就可以动态扩展语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。比如,如果编写一个面向接口的程序,可以等到运行时再指定其具体实现类。

注意,下文的 Class 文件其实是指一串二进制的字节流,可以没有必要是文件形式,同时,每个 Class 文件都可能代表一个类或接口。

2. 类加载时机

类的整个生命周期包括:

加载(Loading) —— 验证(Verification) —— 准备(Preparation) —— 初始化(Initialization) ——- 使用(Using) —— 卸载(Unloading) ,其中 验证、准备、解析 统称为连接(Linking)

需要注意的是,只有加载、验证、准备、初始化、卸载这 5 个阶段的顺序是确定的,当然确定的也只是开始的时间,因为这些阶段都是互相交叉地混合进行,通常会在一个阶段执行的过程中去对调用和激活另外一个阶段。而解析阶段,可以在初始化后再开始,这就是为了支持 Java 的运行时绑定。

img

虚拟机 jvm 并没有规范什么时候开始加载,但是规定了初始化的时间,有且只有4种情况才会立即对类进行“初始化”(自然加载、验证、准备都要在之前开始):

  • 使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段的时候(注意:final修饰并且在编译前间就已经将结果放置在了常量池中的静态字段除外),以及调用一个类的静态方法的时候;

  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则需要先触发其初始化。「反射的过程我在另外一篇文章已经讲过了。」

  • 当初始化一个类的时候,如果发现其父类没有被初始化就会先初始化它的父类。

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

以上 4 种称之为对类的主动使用,会触发相应的初始化,而比较典型几种被动使用,不会触发初始化的情况有:

  • 通过子类访问父类的 static 变量,不会引起子类的初始化;
  • 定义引用数组,不会初始化类;
1
2

Obj[] arrays = new Obj[10];
  • 上面说的,用 final 修饰并且在编译期间(生成.class文件期间)就已经将结果写入了常量池中。

当然,对于接口来说,与类相比在初始化阶段也略有不同(主动使用只有3种情况),就是在初始化一个接口时,如果发现父亲接口没有初始化,并不会去初始化父亲接口,而是等用到了才会去初始化。

3. 类加载过程

接下来详细讲一下类加载的全过程,也就是加载、验证、准备、解析、初始化这 5 个阶段的过程。

3.1 加载

“加载”“类加载” 过程的一个阶段,切不可将二者混淆。

加载阶段由三个基本动作组成:

  1. 通过类型的完全限定名,产生一个代表该类型的二进制数据流(根本没有指明从哪里获取、怎样获取,可以说一个非常开放的平台了);
  2. 解析这个二进制数据流为方法区内的运行时数据结构;
  3. 在 Java 堆中创建一个表示该类型的java.lang.Class类的实例,作为方法区这个类的各种数据的访问入口。

通过类型的完全限定名,产生一个代表该类型的二进制数据流的几种常见形式:

  • 从zip包中读取,成为日后JAR、EAR、WAR格式的基础;
  • 从网络中获取,这种场景最典型的应用就是Applet;
  • 运行时计算生成,这种场景最常用的就是动态代理技术了;
  • 由其他文件生成,比如我们的JSP。

3.2 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

虚拟机如果不检查输入的字节流,并对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。这个阶段是否严谨,直接决定了java虚拟机是否能承受恶意代码的攻击。

从整体上看,验证阶段大致上会完成4个阶段的校验工作:文件格式验证、元数据验证、字节码验证、符号引用验证

  • 文件格式验证。主要是确保输入的字节流能够正确的解析并存储于方法区之中,这阶段的验证基于字节流进行,该验证结束后,字节流就进入到了内存中的方法区,进行存储,所以后续三个验证阶段全是基于方法区的存储结构;

  • 元数据验证。现在其实 jdk1.8 之后没有方法区了,改名叫做元数据区。这个阶段是对字节码描述的信息进行语义分析,保证是 java规范的信息;

  • 字节码验证。验证中最复杂的,主要工作是进行数据流和控制流分析,保证类的方法在运行时不会做出危害虚拟机安全的行为;

  • 符号引用验证。这个验证会发生在虚拟机将符号引用转化为直接引用的时候,也就是在解析过程中的发生,可以看作是对类自身以外的信息进行匹配性的校验,确保解析动作能够正常执行。

    验证的内容主要有:

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类;
    • 在指定类中是否存在符号方法的字段描述及简单名称所描述的方法和字段;
    • 符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

3.3 准备

image-20200306211519183

这一块我有一些不太理解,为什么在初始化阶段,字节码就被编译成指令存放在构造器\() 方法中了????啥时候编译成指令的啊,这个构造方法又是什么时候产生的。。。。。

回答:编译成指令,是在源代码通过 javac 编译成 class 文件时生成的,在第六章有讲,在方法表中,有存放属性表,而属性表里有一个 Code 属性,里面就是存储的 Java 代码编译后的字节码指令。

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。(备注:这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中)。

初始值通常是数据类型的零值:

对于:

1
public static int value = 123;

那么变量value在准备阶段过后的初始值为0而不是123,这时候尚未开始执行任何java方法,把value赋值为123的动作将在初始化阶段才会被执行。

一些特殊情况:

对于:

1
public static final int value = 123;

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

基本数据类型的零值:

image-20200306212331780

3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

那么符号引用与直接引用有什么关联呢?

3.4.1 看两者的概念

符号引用(Symbolic References): 符号引用以一组符号来描述所引用的目标,符号可以是符合约定的任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,引用的目标必定已经在内存中存在

虚拟机规范没有规定解析阶段发生的具体时间,虚拟机实现可以根据需要来判断到底是在类被加载时解析「一般是静态方法和私有方法两大类,符合“编译期可知,运行期不可变”,因为这两类方法不可能通过继承或者其他方式进行重写。第八章有详细讲。」还是等到一个符号引用将要被使用前才去解析。

3.4.2 对解析结果进行缓存

同一符号引用进行多次解析请求是很常见的,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析结果进行缓存,来避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果 第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常。

3.4.3 解析动作的目标

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。前面四种引用的解析过程,对于后面三种,与JDK1.7新增的动态语言支持息息相关,由于java语言是一门静态类型语言,因此没有介绍 invokedynamic 指令的语义之前,没有办法将他们和现在的java语言对应上。

3.5 初始化

类初始化阶段是类加载的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。

初始化阶段,主要还是去执行类构造器 \() 方法的过程「啥时候有这个构造方法的书中说后面会说。。。。好的吧。。。。继续往下看」也就是进行第二次的赋值,其中 \() :

  • \() 是由编译器(javac???)自动收集类中的所有类变量的赋值动作和静态语句块(也就是所有 static 变量 和 静态语句块) 合并产生的,顺序按照先后顺序。同时注意哦,static 静态代码块,可以赋值后面定义的静态变量,不过貌似没毛用。。。。
1
2
3
4
5
6
7
8
public class SuperClass {

static {
System.out.println("SubClass init!");
A = 2; // 反正它也不会被 used,因为会被覆盖掉
}
public static int A = 1;
}
  • \() 这个方法很牛叉,为啥呢,因为它会默认先调用父类的 \() ,压根就不需要去显示的调用,所以第一个被执行的 \() 方法的类肯定是java.lang.Object;

  • 既然父类要先执行,那肯定静态语句块肯定也是优先执行的,这个让我想到了之前的被动调用类的例子,如果子类调用父类的 static 变量,那么子类是不会被初始化的;

  • \() 对于类或者接口不是必须的,如果没有 static 变量 和 静态语句块,那么就不会有这个方法,当然接口本身就不可能有 静态语句块,同时要注意的是,执行接口的 \() 方法不需要去先执行 父接口的 \() ,父接口只有被调用的时候才去执行,这点跟接口主动调用原则保持一致。

  • \() 线性安全,是通过加锁保持同步的。

image-20200306215139668

4. 类加载器

回顾一下,类加载的第一步 “加载” 总共进行了三个基本动作:

  1. 通过类型的完全限定名,产生一个代表该类型的二进制数据流(根本没有指明从哪里获取、怎样获取,可以说一个非常开放的平台了);
  2. 解析这个二进制数据流为方法区内的运行时数据结构;
  3. 在 Java 堆中创建一个表示该类型的java.lang.Class类的实例,作为方法区这个类的各种数据的访问入口。

我们知道,第一个动作,很牛逼,只要得到类型的完全限定名,就能产生一个代表该类型的二进制数据流,那这个是如何做到的呢?就是通过类加载器,注意哦,这个是放到了 jvm 外部去实现的,也就是我们可以自己决定如何去获取需要的类,只要你把二进制数据流没毛病的转化到方法区的运行时数据结构即可。

4.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。如果两个类来源于同一个Class文件,只要加载它们的类加载器不同,那么这两个类就必定不相等。其实很容易理解,类加载器不同,那产生的二进制流必然不同。

4.2 类加载器介绍

从 Java 虚拟机的角度分为两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),使用C++语言实现,是虚拟机自身的一部分;
  • 其他类加载器。由Java语言实现,独立于虚拟机之外,并且全都继承自java.lang.ClassLoader类。(这里只限于HotSpot虚拟机)。

从 Java 开发人员的角度看,大部分Java程序都会使用到以下3种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):

    这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。

  • 扩展类加载器(Extension ClassLoader):

    这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader):

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

我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

4.3 双亲委派模型

要求除了顶层的启动类加载器以外,所有的类加载器都应该有父类加载器,这里的父子关系不是通过继承来的,而是通过组合关系

Tip: 组合和继承的区别与联系

继承与组合都是面向对象中代码复用的方式。

  • 继承在编码过程中就要指定具体的父类,其关系在编译期就确定,而组合的关系一般在运行时确定。
  • 继承强调的是 is-a 的关系,而组合强调的是has-a 的关系。
  • 其实很好理解啦,继承就是要在类声明时显式 extends,而组合不用,组合可以简单的理解为在一个对象(类)中用到了另外一个对象(类)。
  • 一般优先考虑组合,因为java里不允许多重继承。

哈哈哈,我又有问题了,为什么 java 不支持多重继承呢?

我觉得主要是有一种情况很容易造成二义性,就是 A 是最顶层的,然后 B 和 C 继承了 A ,同时复写了 A 的 一个方法,如果 D 此时多重继承 B 和 C,那调用同样的方法的时候就会产生二义性了…

image-20200306222704205

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系

注意:双亲委派模型是 Java 设计者们推荐给开发者们的一种类加载器实现方式,并不是一个强制性 的约束模型。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外。

4.4 破坏双亲委派模型

三次大破坏。

第一次破坏是因为类加载器和抽象类java.lang.ClassLoader在JDK1.0就存在的,而双亲委派模型在JDK1.2之后才被引入,为了兼容已经存在的用户自定义类加载器,引入双亲委派模型时做了一定的妥协:在java.lang.ClassLoader中引入了一个findClass()方法,在此之前,用户去继承java.lang.Classloader的唯一目的就是重写loadClass()方法。JDK1.2之后不提倡用户去覆盖loadClass()方法,而是把自己的类加载逻辑写到findClass()方法中,如果loadClass()方法中如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型规则的

第二次破坏是因为模型自身的缺陷,现实中存在这样的场景:基础的类加载器需要求调用用户的代码,而基础的类加载器可能不认识用户的代码。为此,Java设计团队引入的设计是 “线程上下文类加载器(Thread Context ClassLoader)”。这样可以通过父类加载器请求子类加载器去完成类加载动作。已经违背了双亲委派模型的一般性原则。

第三次破坏 是由于用户对程序动态性的追求导致的。这里所说的动态性是指:“代码热替换”、“模块热部署”等等比较热门的词。说白了就是希望应用程序能够像我们的计算机外设一样,接上鼠标、U盘不用重启机器就能立即使用。OSGi是当前业界“事实上”的Java模块化标准,OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。

「好叭,这一块我不太懂…」

第八章 虚拟机字节码执行引擎

img

1. 概述

执行引擎是虚拟机中最核心的组成部分之一,“虚拟机”可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在 Java 虚拟机规范中,置顶了虚拟机字节码执行引擎的概念模型,这个概念模型成为了各个虚拟机执行引擎的统一外观,但是内部实现各不相同,有的是通过解释器解释执行,有的则是通过即时编译器(JIT)执行,但从外观上看,都是输入字节码文件,输出执行结果。

2. 运行时栈帧

栈帧(Stack Frame)是用于支持虚拟机进行 方法调用和方法执行 的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素,每一个栈帧都包含了 局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。栈帧的大小早就写入到了 class 文件中的 code 属性中了,并且对于执行引擎来说,只有栈顶的栈帧才是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。

2.1 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。 当然,局部变量表的大小早在 Code 属性的 max_locals 数据项中就定义了。

image-20200307150512463

局部变量表以 变量槽(Slot) 为最小单位,默认一个 Slot 为32位,如果是64位的数据结构会为其分配连续两个 Slot 空间。

对于局部变量表,有三点需要提醒:

  1. 方法执行时,虚拟机使用局部变量表完成从操作栈栈顶的参数值到参数变量列表的传递过程,如果是实例方法则第一个参数默认是对象实例的引用,也就是this,剩下的参数从 1 开始;
  2. Slot 是可以重用的;
  3. 局部变量并没有像类变量一样存在“准备阶段”(类加载的连接过程的第二步),所以如果要使用局部变量肯定要先赋初值。

2.2 操作数栈

操作数栈,也被称为操作栈,同局部变量表一样,容量也是在 Code 属性的 max_stacks 数据项之中早就写入了,同样的,栈中的基本单位也是 Slot(32位),32位数据类型占栈容量为 1,64 位的占 2 ,方法的执行过程,就是入栈出栈的过程,所以,Java 虚拟机的解释执行引擎称为 “基于栈的执行引擎”。

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是在大多数虚拟机中会做出优化—— 令两个栈帧出现一部分重叠,这样在进行方法调用的时候就可以共用一部分数据,无需进行额外的参数复制传递了。

image-20200307151906523

2.3 动态连接

动态连接就是用来存储部分未转化为直接引用的符号引用,将其转化为直接引用。在之前的类加载过程中的解析部分我们谈到过,Class 文件的常量池存储了大量的符号引用,因为 java 不像 C++ 一样在编译之前有连接的过程,所以 java 在中间穿插了连接过程,第一次将符号引用转化为直接引用是在类加载中的解析,这种转化称为静态解析,第二次则是在每一次的运行期间转化为直接引用,称为 动态连接。

至于哪些符号引用是静态解析完成,哪些是动态连接,会在接下来的方法调用一节中谈到。

2.4 方法返回地址

存储的就是 用来帮助恢复上层方法的执行状态 的一些信息,如果方法是正常退出的,那么一般会存储调用者(上层方法)的 PC 计数值,来作为返回地址。而方法异常退出的话,返回地址一般就是通过异常处理器表来确定了,这个时候,方法返回地址这一块一般不会保存任何信息。

这里提到了两种退出方式:

  1. 正常完成出口(Normal Method Invocation Completion,话说翻译不应该是正常的完成方法调用???)。此时退出方法的操作应该是:恢复上层方法的局部变量表和操作数栈,如果有返回值就压入调用者的栈帧中的操作数栈中,然后调整PC计数值。
  2. 异常完成出口(Abrupt Method Invocation Completion,我觉得翻译成异常的方法调用会更好…)。此时退出方法的操作应该和上者一样,当然了,这里肯定是不会有返回值的。

2.5 附加信息

可以增加例如调试相关的信息,一般在实际开发中,会把 动态连接、方法返回地址和附加信息归为一类,称之为栈帧信息。

3. 方法调用

方法调用的唯一任务就是把该方法调用的方法确定,暂时不涉及到方法内部的具体运行过程,为什么这个要单独拎出来讲呢?因为在程序运行过程中,进行方法调用时最普遍、最频繁的操作,并且由于动态连接的原因,所以方法调用还是挺复杂的…

3.1 解析

之前在类加载过程中已经见过一次 解析 了,没错,这两个解析是一个意思。之前没具体讲的原因是,这里又要讲一遍…

在类加载的解析阶段,会将其中的一部分符号引用转换为直接引用,这部分要具备下面这个条件:方法在程序运行之前就有一个可确定的调用模板,并且这个方法的调用版本在运行期间是不可以改变的,也就是说必须在javac编译时就需要确定下来,符合“编译期可知,运行期不可变的要求”。这类方法调用就称之为解析。

符合这个要求的有:

  1. 静态方法,与类型直接相关,不会被重写;
  2. private修饰的方法,不会被外部访问而导致重写;
  3. 实例化构造器\方法,也同样符合“编译期可知,运行期不可变”,不会被重写;
  4. 父类方法。这个地方是难点,可能理解上会有点歧义,这里讲的是指在重写的方法中调用 super.方法名(),在这里调用父类的这个方法,可以确保不会被重写,是固定的调用了这个方法。如下例所示:
1
2
3
4
5
6
7
8
9
10
package JVM;

public class SuperClass {
protected void A() {
System.out.println("aaaaaa");
}
protected void B() {
System.out.println("bbbbbb");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package JVM;

public class SubClass extends SuperClass {
@Override
public void A(){
super.A(); // invokespecial
System.out.println(2222);
}

public static void main(String[] args) {
test_heap_outOfMemory t = new test_heap_outOfMemory();
t.C();
SubClass subClass = new SubClass();
subClass.A(); // invokevirtual,因为可能被重写
// subClass.B() 也是 invokevirtual,因为也可能被重写
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// class version 52.0 (52)
// access flags 0x21
public class JVM/SubClass extends JVM/SuperClass {

// compiled from: SubClass.java

// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL JVM/SuperClass.<init> ()V
RETURN
L1
LOCALVARIABLE this LJVM/SubClass; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

// access flags 0x1
public A()V
L0
LINENUMBER 6 L0
ALOAD 0
INVOKESPECIAL JVM/SuperClass.A ()V
L1
LINENUMBER 7 L1
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
SIPUSH 2222
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L2
LINENUMBER 8 L2
RETURN
L3
LOCALVARIABLE this LJVM/SubClass; L0 L3 0
MAXSTACK = 2
MAXLOCALS = 1

// access flags 0x9
public static main([Ljava/lang/String;)V
L0
LINENUMBER 11 L0
NEW JVM/test_heap_outOfMemory
DUP
INVOKESPECIAL JVM/test_heap_outOfMemory.<init> ()V
ASTORE 1
L1
LINENUMBER 12 L1
ALOAD 1
INVOKEVIRTUAL JVM/test_heap_outOfMemory.C ()V
L2
LINENUMBER 13 L2
NEW JVM/SubClass
DUP
INVOKESPECIAL JVM/SubClass.<init> ()V
ASTORE 2
L3
LINENUMBER 14 L3
ALOAD 2
INVOKEVIRTUAL JVM/SubClass.A ()V
L4
LINENUMBER 17 L4
RETURN
L5
LOCALVARIABLE args [Ljava/lang/String; L0 L5 0
LOCALVARIABLE t LJVM/test_heap_outOfMemory; L1 L5 1
LOCALVARIABLE subClass LJVM/SubClass; L3 L5 2
MAXSTACK = 2
MAXLOCALS = 3
}

如上例所示,谈到的 invokespecial、invokestatic 就是解析阶段调用的指令,具体的调用字节码指令如下:

  1. invokestatic:调用静态方法;

  2. invokespecial:调用实例构造器方法 \、私有方法、父类方法;

  3. invokevirtual:调用所有的虚方法;

  4. invokeinterface:调用接口方法,会在运行时确认一个实现此接口的对象;

  5. invokedynamic:JDK1.7新加入的一个虚拟机指令,相比于之前的四条指令,他们的分派逻辑都是固化在JVM内部,而 invokedynamic 则用于处理新的方法分派:它允许应用级别的代码来确定执行哪一个方法调用,只有在调用要执行的时候,才会进行这种判断,从而达到动态语言的支持。(Invoke dynamic method)

只要被 invokestatic、invokespecial调用的方法都是可以在解析阶段确定唯一的调用版本的,所以他们在类加载的时候就会把符号引用转换为对该方法的直接引用,这四类方法(静态方法、实例构造器方法、私有方法、父类方法)统称为非虚方法「当然其实非虚方法,还有一类,那就是用 final 修饰的方法」,其他方法统称为虚方法(final 修饰的方法除外)。其实 final 修饰的方法,也是在编译期间就可以确定的,但是它比较特殊,并不是用的上述两个指令完成调用,而是使用了 invokevirtual…

至于为什么,我觉得这位朋友说的挺好的的:

作者:RednaxelaFX
链接:https://www.zhihu.com/question/45131640/answer/98820081
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Q:为什么java中调用final方法是用invokevirtual指令而不是invokespecial?

A:

不知道当时高司令和团队成员实际是出于什么原因这样设计的。这里我只能做些推断。

被final修饰的non-private方法,确实跟private方法一样可以静态判定调用目标。我们说这两种方法都可以被statically resolved。

它们最大的一个差别是:

  • 一个private方法是只可能在一个类里声明并定义的,它不可能覆写(override)任何基类的方法。
  • 而一个final non-private方法则可以覆写基类的虚方法,并且可以被基类引用通过invokevirtual调用到。

前者很明显,举例说明一下后者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
> 
> class Base {
> void foo() { System.out.println("Base"); }
> }
>
> class Derived extends Base {
> @Override
> final void foo() { System.out.println("Derived"); }
> }
>
> public class Test {
> public static void main(String[] args) {
> Derived d = new Derived();
> d.foo(); // (1) 题主的问题是为什么这个情况下仍然用invokevirtual
> Base b = d; // (2) Base类型引用指向Derived类型实例
> b.foo(); // 通过invokevirtual调用到final Derived.foo()
> }
> }
>
>

所以,原始设计者或许是出于一致性的考虑,选择让这些final方法也用invokevirtual来调用,而不是用invokespecial。另外正如评论区所说,这一致性也带来了更好的二进制兼容性——如果上例中Test与Derived分离编译,在Test编译时Derived.foo()是final,而后来Derived去掉了foo()的final并且单独重新编译了的话,已编译的Test代码仍然可以正确执行——甚至如果Derived进一步有子类覆写了foo()也没问题。

二进制兼容性(binary compatibility)和分离编译(separate compilation)可是Java的卖点,然而同时也是毒瘤…(叹气

关于性能

事实上此处在Class文件里使用invokevirtual来实现对final方法的调用并不会影响一个实现得好的JVM的性能。
HotSpot VM会对上述(1)的情况在解释器里可以把invokevirtual改写为一个行为跟invokespecial相似的内部字节码指令,叫做fast_invokevfinal,不需要查vtable就可以调用到目标方法(不过目前在x86上HotSpot并没有使用这个优化)。SPARC上的例子看这里:jdk8u/jdk8u/hotspot: d109bda16490 src/cpu/sparc/vm/templateTable_sparc.cpp
而在JIT编译器里这种invokevirtual也会被看作跟invokespecial一样来处理。一个简单的例子可以看C1这里:jdk8u/jdk8u/hotspot: d109bda16490 src/share/vm/c1/c1_GraphBuilder.cpp

静态解析的方法调用称为解析调用,还有一种调用方式,称为 分派调用(Dispatch)

3.2 分派调用

即将要讲的分派调用,其实囊括了解析调用的步骤解析与分派并不是排他互斥的关系,而是不同层次上筛选和确定目标方法的过程,二者是联合起来一起使用的,这里的分派更多的是揭示多态性的特征。「好叭,这一块其实我是有点不赞同的,我觉得就是有点互斥的感觉啊,一个是用在非虚函数,一个用在了虚函数。」

3.2.1 静态分派 — 重载

也就是我们常说的多态中的重载过程。虚拟机(准确的说是编译期)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且由于静态类型在编译期间就可知,所以在编译期间,javac 编译期就根据参数的静态类型决定了使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作,就称为静态分派,最典型的就是方法重载,因此确定静态分派的动作实际上并不是由jvm来完成的。另外,编译期虽然能够确定出方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往只能去确定一个“更加合适的”版本,甚至有的时候会因为类型不明确,选择过多(实现的接口很多),而导致报错。

当然,这里得明确一下静态类型和实际类型的概念。

Human human = new Man()

固定对象.方法(human)

  1. 静态类型:变量 human 的静态类型为 Human
  2. 动态类型:变量 human 的动态类型为 Man
简单记忆,重载就是同一个类下同名方法参数不同,所以决定权在参数,如果参数个数相同,那决定权就是参数的类型,而参数的类型在编译期间就已经确定了,是由其静态类型决定的。

3.2.2 动态分派 — 重写

重写跟重载很不一样,重写是类对接口或父类方法的重写,这个时候对象都不一样了,所以会用到实际类型。

People human = new Human();

human.方法()

简单记忆,重写就是一个类覆盖了接口或者父类的方法,此时的变量是一个对象,不是上文的参数,所以此时肯定就是由动态类型决定了。

3.2.3 单分派和多分派

宗量:方法的接收者与方法的参数统称为方法的宗量。

静态分派是多分派类型,动态分派是单分派类型…「好绕啊…不太懂…」

总而言之,静态多分派、动态单分派。。。。

3.2.4 动态单分派的实现优化

由于动态单分派是非常频繁的动作,所以基于性能的考虑,以空间换时间,最常用的“稳定优化”手段就是在方法区中建立一个 虚方法表。如果子类重写了父类方法,则两者对象的方法的入口地址不一样,如果子类没有重写,则都指向父类的方法的入口地址。

除了建立虚方法表,还会使用 “内联缓存”基于 “类型继承关系分析” 技术的守护内联两种非稳定的优化方式。

4. 基于栈的字节码解释执行引擎

上一节讲完了如何调用方法,这一节讲如何执行方法内的字节码指令。执行方法内的字节码指令有两种方法:通过解释器解释执行 和 通过 JIT 等即时编译器编译执行,下面讲一下第一种:基于栈的字节码解释执行。

image-20200307233611564

Java 编译器输出的指令流,基本上就是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,因为都依赖操作数栈进行工作。而主流的基于寄存器的指令集一般都是二地址指令集。

基于栈的指令集最大优点就是可移植性,编译器实现更简单、代码更紧凑…但是缺点就是慢,而且要频繁的入栈出栈,意味着频繁访问内存,速度就下来了,虽然可以采用栈顶缓存,但是毕竟治根不治本…

第九章 类加载及执行子系统的案例与实战

在 Class 文件格式和执行引擎操作这部分里,基本都是有虚拟机直接控制,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成类加载器这两部分的功能。

  • 类加载器的增强

主要是 Tomcat服务器的类加载器 和 OSGi 这个灵活的类加载器架构

  • 字节码生成技术与动态代理的实现

包括 javac、AOP、动态代理、反射等都用到了字节码生成技术。

第十章 早期(编译期)优化

1. 概述

编译期,在 java 里分为三类,

  • 一类是从 .java 文件 转变为 .class 文件的过程,这是前端编译的过程,我们之前提到的编译器都是指这个。使用到的前端编译器一般就是 javac。
  • 还有指虚拟机的后端运行期编译,把字节码转变成机器码的过程。使用到的后端运行期编译器有 JIT编译器(Just In Time Compiler)。
  • 还有一类很少见,称为静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把 *.java 文件编译成本地机器代码的过程。

对于 Javac 编译器来说,对代码的运行效率没有优化措施,但是对程序员的编码风格和编码效率做了很多优化,对于 JIT 来说,对程序优化做了很多优化。

2. Javac 编译器

2.1 编译过程

编译过程分为三个过程:

  1. 解析与填充符号表的过程;
  2. 插入式注解处理器的注解处理过程;
  3. 语义分析与字节码生成过程。

2.2 解析与填充符号表

解析步骤

  • 由 parseFiles() 方法完成,包括了词法分析、语法分析;
  • 词法分析就是将源代码的字符流转变为标记(Token)集合,词法分析过程由 com.sun.tools.javac.parser.Scanner 类实现;
  • 语法分析就是根据 Token 序列来构造抽象语法树的过程,抽象语法树(AST,Abstract Syntax Tree)是一种用来描述程序代码语法结构的树形表示方法。

填充符号表

符号表(Symbol Table)是一组由符号地址和符号信息构成的表格,类似于键值对形式。

符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号表进行地址分配时,符号表是地址分配的依据。由 com.sun.tools.javac.comp.Enter 类实现。

2.3 注解处理器

类似于编译器的插件。插入式注解处理器的初始过程是在 initPorcessAnnotations() 方法中完成的,而执行过程是在 processAnnotations() 方法中完成的。

2.4 语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树,语法树能表示一个结构正确的源程序的抽象,但是却无法保证源程序是符合逻辑的。而语义分析的主要任务就是对结构上正确的源程序进行上下文有关性质的审查。

  1. 语义分析的具体步骤如下:

    • 标记检查:检查诸如变量使用前是否已经被声明、变量与赋值之间的数据类型能够匹配等等;
    • 数据及控制流分析:四队上下文逻辑更进一步的验证,可以监察处诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
  2. 解语法糖:语法糖,也称糖衣语法,指的是在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是会更方便程序员使用。通常来说语法糖能够增加程序的可读性,减少程序出错的机会。由 desugar() 方法触发。

  3. 字节码生成,这是编译过程的最后一个阶段,把前面各个步骤生成的信息(语法树、符号表)转换成字节码写到磁盘去,还进行了少量的代码添加和转换工作,例如之前一直没有解决的 类构造器 \ 还要 实例构造器 \方法「这里的实例构造器不是默认的构造器」就是在这个阶段被添加到语法树之中去的。

3. Java 语法糖的味道

3.1 泛型

本质是参数化类型,也就是说所操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口、方法的创建中,分为泛型类、泛型接口、泛型方法。

java 里面的泛型是伪泛型,并不是像 C++ 一样真正的泛型,Java 里面的泛型实现方式称为 类型擦除。

1
2
3
4
5
6
7
8
9
10
import java.util.List;

public class Test {
public static void method(List<Integer> list){
System.out.println("Integer 的泛型");
}
public static void method(List<String> list){
System.out.println("String 的泛型");
}
}

这样的写法在 idea 中并不能通过,会提示两个方法一样,无法重载,原因就是在编译时就会将两者的泛型擦除。

3.2 自动装箱、拆箱、遍历循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

/**
* 自动装箱、拆箱、遍历循环
*/
public class Test {
public static void main(String[] args) {
// 自动装箱、拆箱、遍历循环的语法糖
List<Integer> list1 = Arrays.asList(1,2,3,4);
// 或者写成 List<Integer> list = [1,2,3,4];
int sum1 = 0;
for(int i : list1){
sum1 = sum1 + i;
}
System.out.println(sum1);

// 编译之后的原样
List list2 = Arrays.asList(new Integer[]{
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4),
});
int sum2 = 0;
Iterator localIterator = list2.iterator();
while(localIterator.hasNext()){
sum2 = sum2 + ((Integer) localIterator.next()).intValue();
}
System.out.println(sum2);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 自动装箱的陷阱
*/
class Test2{
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true
System.out.println(e == f); // false
System.out.println(c == (a + b)); // true
System.out.println(c.equals(a + b)); // true
// System.out.println(g == d); // 报错,因为 == 不会自动拆箱,只有遇到了算术运算才会拆箱
System.out.println(g == (a + b)); // true
System.out.println(g.equals(a + b)); // false,虽然 equals()会自动拆箱,但是不会处理数据转型的问题
}
}

Tip: Integer 的缓存问题,之前刷 LeetCode 就已经遇到过了。这里再提及一次!

具体的 Integer 和 int 的区别见 “零碎知识点” 这篇文章!

第十一章 晚期(运行期)优化

1. 概述

即时编译器(Just In Time Compiler),也称为 JIT 编译器,它的主要工作是把热点代码编译成与本地平台相关的机器码,并进行各种层次的优化,从而提高代码执行的效率。它并不是必需的。

那么什么是热点代码呢?我们知道虚拟机通过解释器(Interpreter)来执行字节码文件,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。

即时编译器编译性能的好坏、代码优化程度的高低是衡量一款商用虚拟机优秀与否的关键指标之一,它也是虚拟机最核心且最能体现技术水平的部分。

2. HotSpot虚拟机内的即时编译器

2.1 解释器与编译器

HotSpot 虚拟机包含解释器和编译器。它们是怎么搭配工作的呢?当程序启动的时候,解释器首先发挥作用,它能直接运行字节码文件;随着时间的推移,越来越多的热点代码被编译器编译成机器码,从而获取更高的执行效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,当编译器的激进优化手段不成立时,如加载了新类后类型继承结构出现变化等,可以通过逆优化(Deoptimization)退回到解释状态继续由解释器执行。解释器与编译器的交互如图所示:

image-20200308154137313

HotSpot中的编译器又分为两种,C1 编译器(Client Compiler)和 C2 编译器(Server Compiler),HotSpot 虚拟机会选择哪个编译器是由虚拟机运行于 Client 模式还是 Server 模式决定的。

默认情况下,虚拟机采用解释器和一种编译器搭配的方式工作,但是在分层编译策略下,C1 编译器和 C2 编译器将会同时工作,分层编译根据编译器编译、优化的规模和耗时,划分出不同的编译层次:

  • 第0层:程序解释执行,解释器不开启性能监控功能,触发 C1 编译。
  • 第1层:C1 编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要解释器将开始性能监控。
  • 第2层:C2 编译,将字节码编译成本地代码,启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

tips:

  1. 使用 “-client” 强制虚拟机运行于 Client 模式。
  2. 使用 “-server” 强制虚拟机运行于 Server 模式。
  3. 使用 “-Xint” 强制虚拟机只使用解释器执行程序,编译器不工作。
  4. 使用 “-Xcomp” 强制虚拟机只使用编译器执行程序,解释器作为编译器的“逃生门”。
  5. 使用 “-XX:+TieredCompilation” 开启分层编译。虚拟机 Server 模式下默认开启。

2.2 编译对象与触发条件

“热点代码” 分为两类:

  1. 被多次调用的方法;「普通的编译请求」「方法调用计数器」
  2. 被多次执行的循环体。「OSR编译请求」「回边计数器」

要测试其成为“热点代码”,有两种比较主流的热点探测方式:

  1. 基于采样的热点探测,周期性探测各个线程栈顶,虽然简单高效,但是容易受到线程阻塞的影响而扰乱探测;
  2. 基于计数器的热点探测,为每个方法(甚至是代码块)建立计数器,统计执行次数,如果执行次数达到一定的阈值,就把这部分代码编译成机器码。

HotSpot 采用的是基于计数器的热点探测,为每个方法准备了两个计数器:方法调用计数器(Invocation Counter)和 回边计数器(Back Edge Counter)

在默认设置下,方法计数器统计的并不是方法被调用的绝对次数,而是一定时间内的执行次数,超过了时间如果还没有达到阈值,就会将方法计数器的值减去一半,这个过程称为 方法计数器的 热度衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作实在虚拟机进行垃圾收集顺带进行的。而回边计数器适用于统计方法中循环体代码的执行次数,之所以叫“回边”,可以理解为一个循环体结束一次又回到开头(死空循环不算回边),显然建立回边计数器统计的目的是为了触发OSR(On Stack Replacement,栈上替换)编译。回边计数器没有计数热度衰减的过程,统计的就是方法体内循环体执行的绝对次数。

image-20200308155619886

image-20200308155641033

3. 优化技术

3.1 方法内联

方法内联的重要性要优于其他优化措施,它的主要目的有两个,一是去除方法调用的成本,二是为其他优化建立良好的基础。

方法内联的行为很简单,就是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。在分派调用中讲过。

3.2 公共子表达式消除

如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。我们来举个例子来模拟下它的优化过程:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
int a = 1;
int b = 1;
int c = 1;
int d = (c * b) * 12 + a + (a + b * c);
// 1. 提取公共子表达式
int E = c * b;
d = E * 12 + a + (a + E);
// 2. 代数化简
d = E * 13 + a * 2;
}

3.3 数组边界检查消除

当我们尝试对数组越界访问的时候,Java 会向我们抛一个 java.lang.ArrayIndexOutOfBoundsException,这对软件开发者来说是一件很好的事情,即使没有专门编写防御代码,也可以避免大部分的溢出攻击,但是对虚拟机来说,意味着每一次的数组访问都带有一次隐含的条件判定操作,即数组边界检查,那么有没有办法消除这种检查呢?

虚拟机一般是在即时编译期间通过数据流分析来确定是否可以消除这种检查,比如 foo[3] 的访问,只有在编译的时候确定 3 不会超过 foo.length - 1 的值,就可以判断该次数组访问没有越界,就可以把数组边界检查消除。

3.4 逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个对象不会逃逸到方法或者线程之外,则可以为这个变量进行一些高效的优化:

1) 栈上分配

如果确定一个对象不会逃逸出方法之外,假如能使用栈上分配这个对象,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。然而遗憾的是,目前的 HotSpot 虚拟机还没有实现这项优化。

2)同步消除

如果确定一个对象不会被其他线程访问到,那么这个变量就不存在线程间的争抢,对这个变量实施的同步措施也可以消除掉。

3)标量替换

标量:无法被进一步分解的数据,比如原始数据类型(int、long以及 reference 类型)
聚合量:可以被持续分解的数据,典型的就是 Java 中对象,它们还可以被分解成成员变量等。

标量替换指的是如果把一个 Java 对象拆散分解,根据程序访问的情况,将其使用到的成员变量恢复到原始类型来访问。

如果能确定一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。

tips:

  1. -XX:+DoEscapeAnalysis 手动开启/关闭逃逸分析,默认开启,C2 编译器有效
  2. -XX:+PrintEscapeAnalysis 查看逃逸分析的结果(debug 虚拟机支持)
  3. -XX:+EliminateAllocations 手动开启/关闭标量替换,默认开启
  4. -XX:+PrintEliminateAllocations 查看标量替换情况(debug 虚拟机支持)
  5. -XX:+EliminateLocks 手动开启/关闭同步消除,默认开启

第十二章 Java内存模型与线程

1. 硬件的效率与一致性

虚拟机与物理计算机的并发有一些相似之处。

1.1 缓存一致性协议

为了解决多个处理器的运算任务可能涉及到同一块主内存,而他们的缓存数据又不一致的问题,引入了缓存一致性协议,volatile 就是用到了 MESI 这个缓存一致性协议。

1.2 指令重排序优化

为了保证处理器利用率最大化,处理器会对指令重排序,而 jvm 中的 JIT 编译器 也同样如此,有类似的指令重排序优化,在 volatile 中为了保证 happens-before 是插入 内存屏障 防止 指令重排序的。

2. Java 内存模型

java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果。

2.1 主内存和工作内存

Java 内存模型规定所有的共享变量都应该存储在主内存中,每条线程有自己的工作内存,工作内存中保存了线程独有的变量,线程之间是隔离的,值传递必须通过主内存操作。

image-20200308161846493

2.2 内存间的交互操作

关于主内存和工作内存如何交互,JMM 定义了8种原子操作:

  1. lock:作用于主内存的变量,将变量标识为线程独占的状态。类似独占锁。
  2. unlock:作用于主内存的变量,将变量的锁释放。
  3. read:作用于主内存变量,将变量从主内存读到工作内存中。
  4. load:作用于工作内存变量,read操作完后需要load来复制一份变量副本放到工作内存中。
  5. use:作用于工作内存变量,把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时就会执行这个操作。
  6. assign:作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令就执行这个操作。
  7. store:作用于工作内存的变量,把一个工作内存中的变量传送到主内存中,以便随后的 write 使用。
  8. write:作用于主内存的变量,把 store 操作得到的变量写入到主内存中的变量。

2.3 对于 volatile 型变量的特殊规则

Volatile 只具有可见性和有序性,并不能保证原子性。

在以下两条规则的运算场景下,volatile 才具有原子性:

  1. 运算结果不依赖于变量的当前值,或者能够确保只有单一的线程修改变量的值;
  2. 变量不需要与其他的状态变量共同参与不变约束。

可见性,是指一条线程修改了当前变量的值,其他线程能立即感知到这个新值。而普通变量必须通过主内存变量传递来完成。

有序性,是指禁止了指令重排序优化,普通变量仅仅保证在该方法的执行过程中所有依赖赋值的结果都能够得到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。volatile通过插入内存屏障,保证了有序性,但是这就带来了volatile的写操作会比较慢一些。

有个典型的例子就是单例模式下的双重检验锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {  
private volatile static Singleton instance;
private Singleton () {}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

称其为双重检查锁,是因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了

而 instance = new Singleton() 这句,并非是一个原子操作,事实上在 JVM 中这句话做了下面 3 件事:

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将 instance 对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错

所以需要将 instance 变量声明成 volatile。

2.4 对于 long 和 double 型变量的特殊规则

JMM 要求lock、unlock、read、load、use、assign、store、write 这 8 个操作具有原子性,但是对于 64 位的数据类型(long 和 double),有一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read、write 这四个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定,所以可能多个线程共享一个未声明为 volatile 的 long 或者 double 变量时,读取到的值会很奇怪,不过现在基本所有商用的虚拟机都实现了 64 位数据的读写操作作为原子操作来对待。

2.5 原子性、可见性、有序性

JMM 的三个特征:原子性、可见性、有序性。

原子性:有 JMM 直接保证的原子性变量操作包括 read、load、use、assign、store、write,long 和 double 的非原子性协定例外…如果想要更大范围的原子性保证,有 lock 和 lock 保证,底层提供了 monitorenter、monitorexit 两个字节码指令保证具有原子性。

可见性:是指一条线程修改了当前变量的值,其他线程能立即感知到这个新值。而普通变量必须通过主内存变量传递来完成。除了 volatile,还可以实现可见性的关键字有 synchronized 和 final。

  • volatile 实现可见性的原因就是遵循了 MESI 缓存一致性协议,可以保证变量修改完后立即同步到主内存,而其他的线程中的工作内存的变量值作废,需要从主内存再重新读取。
  • Synchronized 实现可见性的原因是 “对一个变量执行 unlock 操作之前,会确保先把该变量同步回主内存中(执行 store 和 write 指令)”。
  • final 实现可见性的原因是被 final 修饰好的字段在构造器中一旦被初始化完成,其他线程就能看见 final 字段的值,并且无法修改,所以就能保证可见性了。

有序性:如果在本线程观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”和“工作内存与主内存同步延迟”现象,Java 提供了 volatile 和 synchronized 两个关键字保证线程的有序性。volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一时刻只允许一条线程对其进行加锁操作”这条规则获得的。

2.6 先行发生(happens-before)原则

happens-before 原则非常重要,它是判断是否存在竞争,线程是否安全的主要依据。指令重排序的前提是要遵守 happens-before 原则。

先行发生是 JMM 中定义的两项操作之间的偏序关系,如果说 A 先行发生于 B,其实就是说在操作 B 之前,操作 A 产生的影响能够被操作 B 观察到。「Tip:时间上的先后顺序和先行发生原则没有太大的关系,所以在衡量安全问题的时候需要以 happens-before 原则为准」

天然的 happens-before原则有:

  1. 程序次序规则,一个线程内的代码自上而下运行;
  2. 管程锁定规则,unlock必须在lock之后,当然肯定操作的是同一个同步对象;
  3. volatile变量规则,对一个 volatile 变量的写操作总是先行发生于后面对这个变量的读操作;
  4. 线程启动规则,任何线程的运作都必须在 start 之后;
  5. 线程终止规则,任何线程的结束都必须在该线程任何操作之后;
  6. 线程中断规则,interrupt() 调用之后才会有中断时间的发生;
  7. 对象终结规则,构造函数先于 finalize() 发生;
  8. 传递性, A 先于 B,B 先于 C,则 A 先于 C。

3. Java 与线程

3.1 线程的实现

实现线程主要有三种方式:

  1. 使用内核线程实现;
  2. 使用用户线程实现;
  3. 使用用户线程加上轻量级进程混合实现。

3.1.1 使用内核线程实现

内核线程(Kernel Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器来对线程进行调度。而程序一般不会使用内核线程,会通过一个中间件—- 轻量级进程,来和内核线程1:1映射。

3.1.2 使用用户线程实现

用户线程就是系统内核感受不到的线程,所以用户的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。但是这个实现起来很困难,基本都放弃使用了。进程和内核线程 1:N。

3.1.3 混合使用

多对多的线程模型,轻量级进程和用户线程 M :N。

3.1.4 Java 线程的实现

我们使用的 jdk 是采用的内核线程实现的,轻量级进程和内核线程1:1。

3.2 Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要的调度方式有两种,分别是 协同式(Cooperative Threads-Scheduling)线程调度 和 抢占式(Preemptive Threads-Scheduling)线程调度。

Java 使用的当然是抢占式线程调度,因为协同式线程调度虽然实现简单但是线程执行时间不可控,如果线程编写有问题不通知系统进行系统切换那么程序就会一直堵塞。虽然说 Java 线程调度是系统自动完成的,但是我们可以设置线程优先级,优先级越高越容易被系统选择,不过其实线程优先级也不靠谱,因为 java 的线程是被映射到系统的原生线程上实现,所以最终的调度还是 os 说了算。

3.3 状态转换

Java 的线程有 5 种状态:

  1. 新建(New):创建后尚未启动的线程处于这种状态 ;
  2. 运行(Runnable):包括了操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待 cpu 分配时间片;
  3. 等待(Waiting):处于这种状态的进程不会被分配 cpu 执行时间,它们要让线程显示的唤醒。以下方法会让线程进入到这个状态:
    • Object.wait() 「可设置时间」
    • Thread.join() 「可设置时间」
    • LockSupport.park()、LockSupport.parkNanos()、LockSupport.parkUntil()
  4. 阻塞(Blocked):与等待状态的区别是,“阻塞状态”在等待获取到一个排它锁,这个时间将在另外一个线程放弃这个锁的时候方式,而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在 synchronized 的时候,线程进入同步区域,就会进入阻塞状态;
  5. 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

image-20200308201230299

第十三章 线程安全与锁优化

1. 线程安全

线程安全的定义:

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,(单次)调用这个对象的行为就能够获取到正确的结果,那么这个对象就是线程安全的。

按照线程安全的“安全程度”,由强至弱来排序,可以将 Java 语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程独立。

1.1 不可变

即可看不可改的数据,final 修饰。

1.2 绝对线程安全

集合中的 Vector、ConcurrentHashMap并非绝对的线程安全,因为如果不使用迭代器去删除元素的话,遍历的时候依然会出现问题。

1.3 相对的线程安全

单次调用不需要额外的加同步措施,这就是相对的线程安全,类似于 HasTable、Vector、Collections 的 synchronizedCollection()等等。

1.4 线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过调用端正确的使用同步手段来保证对象在并发环境中安全的使用,如 ArrayList、HashMap。

1.5 线程对立

线程对立是不管调用端是否采取了同步措施,都无法在多线程环境下并发使用的代码。这种代码就不要写了…比如 System.setIn()、System.setOut()。

2. 实现线程安全的方法

2.1 互斥同步

最基本的手段有 synchronized、ReentrantLock。这里也提到了 ReentrantLock几个特性:

  1. 有等待可中断,当持有锁长期不释放时,等待的线程可以选择放弃等待,改为处理其他事情;
  2. 有公平锁和非公平锁的选择;
  3. 可以绑定多个Condition,也就是可以有多个条件队列。

2.2 非阻塞同步

基于冲突检测的乐观并发策略,通俗的来说就是先进行操作,如果没问题就成功了,如果有其他线程争用数据,产生了冲突,就再进行其他措施,这种乐观的并发策略不需要将线程挂起,因此这种同步操作称为非阻塞同步。

2.3 无同步方案

ThreadLocal —- 见我的 多线程(四)—- ThreadLocal 一文

3. 锁优化

  • 自旋锁和自适应自旋锁;
  • 锁消除。可以不用用到锁的地方,可以不加锁;
  • 锁粗化。如果有一系列操作反复的加锁和解锁,就会锁同步范围扩大;

  • 轻量级锁。本意是在没有多线程竞争的前提下,比如两个线程交替执行,不会发生竞争,此时就可以使用轻量级锁。

  • 偏向锁。可以提高带有同步但无竞争的程序性能,适合单线程。如果变量总是被多个线程访问,那么偏向模式是没有意义的并且会浪费开销和时间。

要理解轻量级锁和偏向锁,就需要谈到虚拟机中对象的内存布局。虚拟机中的对象头由两部分组成,第一部分是用来存储对象自身的运行时数据,例如哈希码(HashCode)、GC 分代年龄(GC Age)等等,一般是64位,官方称之为 Mark Word,它是实现轻量级锁和偏向锁的关键,对象头除了 Mark Word,还有一部分是用来存储指向方法区对象类型数据的指针,如果是数组对象的话,还有额外的一部分用来存储数组长度。

image-20200308230216870

如图所示,是 32 位的虚拟机的 Mark Word。

现在讲一下 synchronized 的锁升级过程:

  1. 最开始的对象是无锁模式,Mark Word 的前 25 位记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
  2. 当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,剩下的 2 位 是 epoch,代表偏向的时间戳,表示进入偏向锁状态。
  3. 当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
  4. 当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
  5. 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,称之为 Lock Record,里面保存指向对象锁Mark Word的副本,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
  6. 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,解轻量级锁锁也是用 CAS,将栈帧上的Lock Record替换回去,替换成刚就同步完成了。如果失败则继续执行步骤7。
  7. 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

https://blog.csdn.net/lkforce/article/details/81128115

总结

至此,周志华先生的这本《深入理解 Java 虚拟机》第三版 就已经全部复习完一遍了,收获很多,大概满打满算花了 3 天「2020.03.05 ~ 2020.03.08」的时间啃完,接下来还得再好好看看,有些地方有点囫囵吞枣,比如说第六章的 Class 文件结构,并没有自己去利用 idea 中的 Jclasslib 认真去分析,还有很多地方虽然都是自己敲字敲出来的,但是敲出来了就给忘了…时间还是太仓促了…总而言之,继续加油吧!

Thank you for your accept. mua!
-------------本文结束感谢您的阅读-------------