JVM学习笔记

学习《深入理解Java虚拟机》并进行总结。

深入理解Java虚拟机

Java内存区域

运行时数据区域

程序计数器

  1. 程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
  2. Java虚拟机的多线程是通过线程切换轮流分配处理器,所以为了切换后可以恢复到正确的执行位置,每条线程需要有一个独立的程序计数器,所以程序计数器是线程私有的。
  3. 线程执行的是Java方法,程序计数器记录的是正在执行的虚拟机字节码指令地址,而如果是Native方法,则是空(undefined)。是唯一没有规定OOM的区域。

Java虚拟机栈

  1. 虚拟机栈描述了Java方法执行时的内存模型,每个方法在在执行的同时都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、动态链接、方法接口 等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈出栈的过程。
  2. 是线程私有,生命周期与现成相同。
  3. 当线程请求的栈深度大于虚拟机要求的最大深度和虚拟机动态扩展无法申请到足够内存时会发生OOM

本地方法栈

与虚拟机栈作用类似,不过本地方法栈是使用Native方法服务执行Native方法。

Java堆

Java堆是所有线程共享的一块内存区域,是虚拟机所管理的内存区域最大的一块,在VM启动时创建。

  1. 除了现在的一些栈上分配和标量替换等优化技术,按规范来说所有对象和数组都应该在堆上分配
  2. 现代收集器都采用分代收集算法,Java堆可细分为新生代和老年代,更细致的可分为Eden空间等,目的是更好的回收内存
  3. 从内存分配的角度,线程共享的Java堆中可能分出多个线程私有的分配缓存区,目的是更快的分配内存
  4. Java堆出于逻辑连续的内存空间中,物理上可不连续,如磁盘空间一样

方法区

  1. 方法区用于存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final常量、静态变量、编译器即时编译后的代码等数据。
  2. 一般的,方法区上执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(HotSpot,JDK1.7之前),但这也不代表着在方法区上完全没有垃圾收集。此区域回收目标主要针对常量池的回收和对类型的卸载
  3. JDK hotspot也取消了永久代的概念,取而代之的是元空间,存储在物理内存。另外,将常量池和静态变量放到Java堆里

运行时常量

  1. 运行时常量是方法区的一部分
  2. Class文件中除了有类的版本、字段、方法、接口等描述的信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

直接内存

  1. 直接内存不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。
  2. 在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。比如NIO中基于通道和缓冲区的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

Hotspot虚拟机

对象的创建

为新生对象分配内存的分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能决定。

  1. 指针碰撞(Bump the Pointer):当Java堆中内存是规整的,那就只需要一个指针作为分界点,只需要把指针向空闲区域挪动一段与对象大小相等的距离分配内存。Serial、ParNew等带有Compact过程的收集器可以使内存规整
  2. 空闲列表(Free List)分配方式:内存不规整时,需要一个列表维护有哪些内存块是可用的,分配的时候再列表中找到一块足够大的控件。类CMS这种基于Mark-Sweep算法的收集器
  3. 创建对象是频繁的,需要考虑并发线程安全的问题。a)对分配内存空间的动作进行同步处理—VM采用CAS配上失败重试的方式保证更新操作的原子性。b)本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配动作按线程划分在不同空间中进行,即每个线程在Java堆中预先分配一小块内存。
  4. 内存分配完成后,虚拟机需要将分配到的内存控件都初始化为零值。

对象的内存布局

对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、和对齐填充(Padding)

  1. 对象头部包含两部分

    • Mark Word,存储对象自身的运行时数据(如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳);由于对象头与对象自身定义的数据存储大小无关,考虑到VM的空间效率,Mark Word被设计成非固定的数据结构以便在极小的空间内存储尽量多的信息,他会根据对象的状态复用自己的存储空间。
    • 类型指针,即对象指向它的类元数据的指针,VM通过这个指针来确定这个对象是哪个类的实例。
  2. 实例数据是对象真正存储的有效信息,也是程序代码中定义的各种类型的字段内容。
  3. 对齐填充,并不必然存在,没有特别含义,仅仅起占位符的作用,8byte对齐。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象,对象访问方法取决于VM实现而定,目前主流访问方式有使用句柄和直接指针2种:

  1. 使用句柄访问 Java堆中划分出一块内存作为句柄池,reference中存储对象的句柄地址,句柄中包含对象实例数据与类型数据各自的具体地址信息

  2. 使用直接指针访问 Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,reference中存储对象地址

  3. 两种访问方式各有优势

    • 使用句柄访问最大的好处是reference中存储的是稳定的句柄地址,在对象被移动(GC时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改
    • 使用直接指针访问方式的最大好处是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
    • HotSpot虚拟机采用指针访问方式进行对象访问

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

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行。

判断对象是否存活

  1. 引用计数算法 给对象添加一个引用计数器,当对象增加引用时计数器加1,引用失效时减1,引用不为0则说明对象仍然存活。但是这种方法的弊端时如果存在两个对象循环引用的情况,尽管没有其他引用再指向这两个对象,引用计数器也不会为0,导致无法对他们进行回收。
     public class ReferenceCountingGC {
         public Object instance = null;
    
         public static void main(String[] args) {
             ReferenceCountingGC objectA = new ReferenceCountingGC();
             ReferenceCountingGC objectB = new ReferenceCountingGC();
             objectA.instance = objectB;
             objectB.instance = objectA;
         }
     } 
    
  2. 可达性分析 通过GC Roots作为起始点进行搜索,能够达到的对象是存活的,不可达的对象是可被回收的。

    JVM使用这个算法来判断对象是否可被回收,Java的GC Roots一般为一下内容:

    • 虚拟机栈中引用的对象
    • 本地方法栈中引用的对象
    • 方法区中静态属性引用的对象
    • 方法区中的常量引用的对象
  3. 引用类型 上述两种方法都需要通过引用来判断对象是够可被回收。 Java有四种强度不同的引用类型。
    • 强引用 被强引用的对象不会被垃圾收集器回收,使用new一个新对象即为创建强引用。
    • 软引用 被软引用关联的对象,只有在内存不够的情况下才会被回收,使用SoftReference类创建软引用。
        Object obj = new Object();
        SoftReference<Object> sf = new SoftReference<Object>(obj);
        obj = null;  // 使对象只被软引用关联
      
    • 弱引用 被弱引用关联的对象只能存活到下一次垃圾收集发生之前,使用WeakReference类来实现弱引用。 WeakHashMap的Entry就是继承自WeakReference,主要用来实现缓存。 Tomcat中的ConcurrentCache采取的分代缓存,经常使用的对象放入eden中,使用ConcurrentHashMap实现,而不常用的对象放入longterm中,使用WeakHashMap,保证不常使用的对象容易被回收。
    • 虚引用 一个对象是否有虚引用,不影响其生存时间,也无法通过虚引用取得一个对象实例,为对于选拔个设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。使用PhantomReference实现虚引用。
  4. 方法区的回收 方法区主要存放永久代对象(JDK1.7以后取消了永久代),永久代的回收率比新生代差很多,因此在方法区上进行回收性价比不高。

    主要是对常量池的回收和对类的卸载。 类的写在条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

    • 该类所有实例都已经被回收
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.land.Class对象没有在任何地方被引用,也就无法在任何地方通过反射访问该方法。

    大量使用反射、动态代理、cglib等bytecode框架,需要虚拟机具备类卸载功能。

  5. finalize() 有点类似c++的析构函数,但是该方法不确定性大,无法保证各个对象的调用顺序,不要使用。 当一个对象可被回收时,如果需要执行finalize()方法,那么就有可能通过该方法让对象重新被引用,从而实现自救。

    垃圾收集算法

  6. 标记-清除(mark-sweep) 分为两个阶段,先将存活的对象进行标记,然后清理掉未被标记的对象。 不足: * 标记和清除过程效率都不高 * 会产生大量不连续的内存碎片,导致无法给大对象分配内存
  7. 标记-整理(mark-compact) 让所有存活的对象都向一端移动,然后直接清理边界以外的内存。
  8. 复制(copying) 将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了还存活的对象赋值到另一块上,再把使用过的内存空间进行清理。 主要不足是只使用了内存的一半,但是由于内存使用完时存活的对象往往占少数,所以可以不把内存划分为相等的两块。现在的商业虚拟机就是采取这种算法收集新生代,一块较大的Eden空间和两块较小的Survior空间
  9. 分代收集 分代收集算法可以把对象存活周期将内存划分为几块,不同块采用适当的收集算法。 一般Java堆分为新生代和老生代。
    • 新生代:使用复制算法
    • 老生代:使用标记-清理或者标记-整理算法

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与并行(多线程):单线程指的是垃圾收集器只使用一个线程进行收集,而并行使用多个线程。
  • 串行与并发:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并发指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
  1. Serial 收集器 以串行的方式执行,并且只有一个线程进行垃圾收集工作。 它的优点是简单高效,对于单个CPU来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。 它是client模式下的默认新生代收集器。
  2. ParNew 收集器 是 Serial 收集器的多线程版本,是server模式下的新生代首选收集器,因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
  3. Parallel Scavenge 收集器 与 ParNew 一样是并行的多线程收集器。 其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。
  4. Serial Old 收集器 是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
    • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
    • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
  5. Parallel Old 收集器 是 Parallel Scavenge 收集器的老年代版本。

    在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

  6. CMS 收集器 CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。 特点是:并发收集,低停顿。 分为以下四个流程:
    • 初始标记:仅仅只是标记以下GC Roots能直接关联到的对象,速度很快,需要停顿。
    • 并发标记:进行GC Roots Tracing的过程,它在整个回收过程中耗时嘴仗,不需要停顿。
    • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    • 并发清除:不需要停顿。 在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。 但是CMS具有以下缺点:
    • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
    • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。可以使用 -XX:CMSInitiatingOccupancyFraction 来改变触发 CMS 收集器工作的内存占用百分,如果这个值设置的太大,导致预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    • 标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
  7. G1 收集器 G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。 Java 堆被分为新生代、老年代和永久代,其它收集器进行收集的范围都是整个新生代或者老生代,而 G1 可以直接对新生代和永久代一起回收。 G1 把新生代和老年代划分成多个大小相等的独立区域(Region),新生代和永久代不再物理隔离。 通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

    每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

    如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

    • 初始标记
    • 并发标记
    • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
    • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

    G1具有以下特点:

    • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
  8. 比较

    | 收集器 | 单线程/并行 | 串行/并发 | 新生代/老年代 | 收集算法 | 目标 | 适用场景 | | :—: | :—: | :—: | :—: | :—: | :—: | :—: | | Serial | 单线程 | 串行 | 新生代 | 复制 | 响应速度优先 | 单 CPU 环境下的 Client 模式 | | Serial Old | 单线程 | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单 CPU 环境下的 Client 模式、CMS 的后备预案 | | ParNew | 并行 |串行 | 新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境时在 Server 模式下与 CMS 配合 | | Parallel Scavenge | 并行 | 串行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 | | Parallel Old | 并行 | 串行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 | | CMS | 并行 | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或 B/S 系统服务端上的 Java 应用 | | G1 | 并行 | 并发 | 新生代 + 老年代 | 标记-整理 + 复制算法 | 响应速度优先 | 面向服务端应用,将来替换 CMS |

    内存分配与回收策略

对象的内存分配,也就是在堆上分配。主要分配在新生代的 Eden 区上,少数情况下也可能直接分配在老年代中。

Minor GC和Full GC

  • Minor GC:发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:发生在老年代上,老年代对象和新生代的相反,其存活时间长,因此 Full GC 很少执行,而且执行速度会比 Minor GC 慢很多。

内存分配策略

  1. 对象优先在Eden分配 大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
  2. 大对象直接进入老年代 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。 经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
  3. 长期存活的对象进入老年代 为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
  4. 动态对象年龄判定 虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 区中相同年龄所有对象大小的总和大于Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
  5. 空间分配担保 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

Full GC的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 区空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

  1. 调用System.gc() 此方法的调用是建议虚拟机进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存。
  2. 老年代空间不足 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出 Java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间以及不要创建过大的对象及数组。
  3. 空间分配担保失败 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果出现了 HandlePromotionFailure 担保失败,则会触发 Full GC。
  4. JDK1.7以前的永久带空间不足 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。
  5. Concurrent Mode Failure 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是 CMS GC 时当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

类文件结构

通过一个简单的java程序来简析java类文件结构。

class Parent {

    public int x = 5;
    public void f(){
        System.out.println("Parent");
    }
}
class Sub extends Parent{
    public int x = 6;
    public void f() {
        System.out.println("Sub");
    }
}

public class Main {

    public static void main(String[] args) {
        Parent par = new Sub();
        System.out.println(par.x);
        par.f();
        getStr(par.x, "str");

        int x = 200, y = 300, z = 400;
        int m = (200 + 300) * z;
    }

    public static String getStr(int value, String str) {
        return str;
    }
}

使用javac编译生成的class文件(这里指Main.class,编译会生成三个class文件)内容如下:

CA FE BA BE 00 00 00 34 00 37 0A 00 0F 00 1F 09
00 0E 00 20 06 40 18 00 00 00 00 00 00 09 00 0E
00 21 07 00 22 0A 00 06 00 1F 09 00 23 00 24 09
00 25 00 26 0A 00 27 00 28 0A 00 25 00 29 08 00
2A 0A 00 0E 00 2B 07 00 2C 07 00 2D 01 00 01 61
01 00 01 49 01 00 01 62 01 00 01 44 01 00 0D 43
6F 6E 73 74 61 6E 74 56 61 6C 75 65 01 00 06 3C
69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F
64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54
61 62 6C 65 01 00 04 6D 61 69 6E 01 00 16 28 5B
4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E
67 3B 29 56 01 00 06 67 65 74 53 74 72 01 00 27
28 49 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72
69 6E 67 3B 29 4C 6A 61 76 61 2F 6C 61 6E 67 2F
53 74 72 69 6E 67 3B 01 00 0A 53 6F 75 72 63 65
46 69 6C 65 01 00 09 4D 61 69 6E 2E 6A 61 76 61
0C 00 15 00 16 0C 00 10 00 11 0C 00 12 00 13 01
00 03 53 75 62 07 00 2E 0C 00 2F 00 30 07 00 31
0C 00 32 00 11 07 00 33 0C 00 34 00 35 0C 00 36
00 16 01 00 03 73 74 72 0C 00 1B 00 1C 01 00 04
4D 61 69 6E 01 00 10 6A 61 76 61 2F 6C 61 6E 67
2F 4F 62 6A 65 63 74 01 00 10 6A 61 76 61 2F 6C
61 6E 67 2F 53 79 73 74 65 6D 01 00 03 6F 75 74
01 00 15 4C 6A 61 76 61 2F 69 6F 2F 50 72 69 6E
74 53 74 72 65 61 6D 3B 01 00 06 50 61 72 65 6E
74 01 00 01 78 01 00 13 6A 61 76 61 2F 69 6F 2F
50 72 69 6E 74 53 74 72 65 61 6D 01 00 07 70 72
69 6E 74 6C 6E 01 00 04 28 49 29 56 01 00 01 66
00 21 00 0E 00 0F 00 00 00 02 00 01 00 10 00 11
00 00 00 12 00 12 00 13 00 01 00 14 00 00 00 02
00 03 00 03 00 01 00 15 00 16 00 01 00 17 00 00
00 31 00 03 00 01 00 00 00 11 2A B7 00 01 2A 05
B5 00 02 2A 14 00 03 B5 00 05 B1 00 00 00 01 00
18 00 00 00 0E 00 03 00 00 00 12 00 04 00 13 00
09 00 14 00 09 00 19 00 1A 00 01 00 17 00 00 00
66 00 02 00 06 00 00 00 36 BB 00 06 59 B7 00 07
4C B2 00 08 2B B4 00 09 B6 00 0A 2B B6 00 0B 2B
B4 00 09 12 0C B8 00 0D 57 11 00 C8 3D 11 01 2C
3E 11 01 90 36 04 11 01 F4 15 04 68 36 05 B1 00
00 00 01 00 18 00 00 00 1E 00 07 00 00 00 17 00
08 00 18 00 12 00 19 00 16 00 1A 00 20 00 1C 00
2D 00 1D 00 35 00 1E 00 09 00 1B 00 1C 00 01 00
17 00 00 00 1A 00 01 00 02 00 00 00 02 2B B0 00
00 00 01 00 18 00 00 00 06 00 01 00 00 00 21 00
01 00 1D 00 00 00 02 00 1E                                                                                   

使用javap反汇编生成的class文件(javap -v Main):

Classfile /E:/IdeaProject/bytecode/src/Main.class
  Last modified 2018-6-21; size 713 bytes
  MD5 checksum 32216214f6d0f37b59c028c4c7c0f1d5
  Compiled from "Main.java"
public class Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #15.#31        // java/lang/Object."<init>":()V
   #2 = Fieldref           #14.#32        // Main.a:I
   #3 = Double             6.0d
   #5 = Fieldref           #14.#33        // Main.b:D
   #6 = Class              #34            // Sub
   #7 = Methodref          #6.#31         // Sub."<init>":()V
   #8 = Fieldref           #35.#36        // java/lang/System.out:Ljava/io/PrintStream;
   #9 = Fieldref           #37.#38        // Parent.x:I
  #10 = Methodref          #39.#40        // java/io/PrintStream.println:(I)V
  #11 = Methodref          #37.#41        // Parent.f:()V
  #12 = String             #42            // str
  #13 = Methodref          #14.#43        // Main.getStr:(ILjava/lang/String;)Ljava/lang/String;
  #14 = Class              #44            // Main
  #15 = Class              #45            // java/lang/Object
  #16 = Utf8               a
  #17 = Utf8               I
  #18 = Utf8               b
  #19 = Utf8               D
  #20 = Utf8               ConstantValue
  #21 = Utf8               <init>
  #22 = Utf8               ()V
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               main
  #26 = Utf8               ([Ljava/lang/String;)V
  #27 = Utf8               getStr
  #28 = Utf8               (ILjava/lang/String;)Ljava/lang/String;
  #29 = Utf8               SourceFile
  #30 = Utf8               Main.java
  #31 = NameAndType        #21:#22        // "<init>":()V
  #32 = NameAndType        #16:#17        // a:I
  #33 = NameAndType        #18:#19        // b:D
  #34 = Utf8               Sub
  #35 = Class              #46            // java/lang/System
  #36 = NameAndType        #47:#48        // out:Ljava/io/PrintStream;
  #37 = Class              #49            // Parent
  #38 = NameAndType        #50:#17        // x:I
  #39 = Class              #51            // java/io/PrintStream
  #40 = NameAndType        #52:#53        // println:(I)V
  #41 = NameAndType        #54:#22        // f:()V
  #42 = Utf8               str
  #43 = NameAndType        #27:#28        // getStr:(ILjava/lang/String;)Ljava/lang/String;
  #44 = Utf8               Main
  #45 = Utf8               java/lang/Object
  #46 = Utf8               java/lang/System
  #47 = Utf8               out
  #48 = Utf8               Ljava/io/PrintStream;
  #49 = Utf8               Parent
  #50 = Utf8               x
  #51 = Utf8               java/io/PrintStream
  #52 = Utf8               println
  #53 = Utf8               (I)V
  #54 = Utf8               f
{
  public int a;
    descriptor: I
    flags: ACC_PUBLIC

  public Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_2
         6: putfield      #2                  // Field a:I
         9: aload_0
        10: ldc2_w        #3                  // double 6.0d
        13: putfield      #5                  // Field b:D
        16: return
      LineNumberTable:
        line 18: 0
        line 19: 4
        line 20: 9

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=6, args_size=1
         0: new           #6                  // class Sub
         3: dup
         4: invokespecial #7                  // Method Sub."<init>":()V
         7: astore_1
         8: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: aload_1
        12: getfield      #9                  // Field Parent.x:I
        15: invokevirtual #10                 // Method java/io/PrintStream.println:(I)V
        18: aload_1
        19: invokevirtual #11                 // Method Parent.f:()V
        22: aload_1
        23: getfield      #9                  // Field Parent.x:I
        26: ldc           #12                 // String str
        28: invokestatic  #13                 // Method getStr:(ILjava/lang/String;)Ljava/lang/String;
        31: pop
        32: sipush        200
        35: istore_2
        36: sipush        300
        39: istore_3
        40: sipush        400
        43: istore        4
        45: sipush        500
        48: iload         4
        50: imul
        51: istore        5
        53: return
      LineNumberTable:
        line 23: 0
        line 24: 8
        line 25: 18
        line 26: 22
        line 28: 32
        line 29: 45
        line 30: 53

  public static java.lang.String getStr(int, java.lang.String);
    descriptor: (ILjava/lang/String;)Ljava/lang/String;
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=2
         0: aload_1
         1: areturn
      LineNumberTable:
        line 33: 0
}
SourceFile: "Main.java"

文件结构

“Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。” 无符号数属于基本的数据结构,以u1,u2,u4,u8来分别代表1个字节,2个字节,4个字节和8个字节的无符号数; 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都习惯性的以“_info”结尾。

ClassFile {
    u4             magic;//魔数
    u2             minor_version;//次版本号
    u2             major_version;//主版本号
    u2             constant_pool_count;//常量池容量计数
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;//访问标志
    u2             this_class;//类索引
    u2             super_class;//父类索引
    u2             interfaces_count;//接口索引数
    u2             interfaces[interfaces_count];//接口索引集合
    u2             fields_count;//字段数
    field_info     fields[fields_count];
    u2             methods_count;//方法数
    method_info    methods[methods_count];
    u2             attributes_count;//属性数
    attribute_info attributes[attributes_count];
}

魔数、主次版本号

CA FE BA BE是魔数,用于确定这个文件是否为一个能被虚拟机接受的Class文件,也就是进行格式识别。 00 00 00 34是次版本(两个字节)+主版本(两个字节),也就是JDK1.8.0。

常量池

后续所接的00 37是常量池入口,代表常量池容量计数值,即55,由于常量池的索引计数从1开始(预留0是用来表示不使用任何一个常量池项目),所以有38项常量,从上述反编译代码中Constant Pool也可以看到确实是54项。

常量池主要有存放量大类常量:字面量(Literal)和符号引用(Symbolic References)。其中字面量比较接近于Java语言层面的常量概念,如文本字符串,声明为final的常量值。而符号引用则属于编译原理方面你的概念,包括了这三类常量:类和接口的全限定名;字段的名称和描述符;方法的名称和描述符。但是由于Java代码在javac编译的时候,没有“连接”的过程,而是在虚拟机加载class文件时动态连接。也就是说,字段、方法的符号引用要再运行期转换才可以得到真正内存入口地址。

常量池中每一项常量都是一个表,表的结构各不相同,但是有一个共同特点,就是表的的第一位是u1类型的标志位。

每一种常量都有它的结构,下面举几个例子为例:

  1. 以#7为例:#7 = Methodref #6.#31 方法的符号引用的结构是:
     CONSTANT_MethodHandle_info {
         u1 tag; //标志 10
         u1 reference_kind; //指向声明方法的类描述符的CONSTANT_Class_info的索引项
         u2 reference_index;//指向名称及类型描述符CONSTANT_NameAndType的索引项
     }
    

    前六个常量占了27个字节,所以#7常量的十六进制字节是0A 00 06 00 1F。 其中0A是标志位,00 06是该方法的类的索引项,也就是#6 Sub类,00 1F是该方法名称及类型的索引项,即”<init>”:()V。 可见这个常量项指的是Sub类的默认初始化方法。

  2. 以#21为例:#21 = Utf8 <init> utf-8字符串的结果是:
     CONSTANT_Utf8_info {
         u1 tag;//标志 01
         u2 length;//UTF-8编码的字符串占用的字节数
         u1 bytes[length];//长度为length的UTF-8编码的字符串
     }
    

    可得#21的十六进制字节码是01 00 06 3C 69 6E 69 74 3E。 其中01是标志位,00 06是字符串的字节数6,后面即为”<init>”所对应的ASCII码。

访问标记

在常量池结束之后,紧跟着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息。

两个字节一共可以有16个标志位,当前只定义了其中8个,没有使用到的标志位要求为0。

这个例子为00 21,即是public修饰的普通类。

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

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,接口索引集合是一组u2类型的集合,这三项数据可以确定这个类的继承关系。

  • 类索引用于确定这个类的全限定名(即常量池中的常量项)。
  • 父类索引用于确定这个类的父类的全限定名,由于Java不允许多继承,所以只会有一个父类,并且除了Object类一定会有父类。
  • 接口集合索引用来描述这个类实现了哪些接口,会按照implements语句(本身是接口的话,则是extends)的顺序排列。接口集合索引的入口同样是接口计数器。

这个例子的访问标记后为00 0E 00 0F 00 00,即该类时#14 Main,父类是#15 Object,没有实现接口。

字段表集合

字段表用于标书接口或者类中声明的变量。字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

这个例没有在Main类中定义了两个字段,所以字段表集合的入口计数器为00 02

计数器之后的字段表结构如下:

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

第一个字段是public int a,字节码是00 01 00 10 00 11 00 00。对应的关系是00 01标志位表示public,00 10表示字段的名称是#16常量a,00 11表示字段类型是#17常量I。 第一个字段是private final double b,字节码是00 12 00 12 00 13 00 01 00 14。对应的关系是00 12标志位表示private和final,00 12表示字段的名称是#18常量b,00 13表示字段类型是#19常量D,并有一个属性,属性为00 14表示的#20的ConstantValue属性。

方法表集合

方法表与字段表是差不多的,结构也与字段表一致。

这个例子包含隐含的init()方法一共有三个方法,入口计数器为00 03。 第三个方法表的字节码是00 09 00 1B 00 1C 00 01 00 17 也就是access_flags为00 09,是public方法并且是static的;name_index为00 1B,是getStr方法名;descriptor_index为00 1B,也就是描述符是(ILjava/lang/String;)Ljava/lang/String;,描述符描述了方法参数I整型和String字符串和返回值String字符串;attributes[0]是00 17,是Code属性。

属性表集合

在Class文件、字段表、方法表中都可以携带自己的属性表集合,用于描述某些场景专有的信息。

对于每个属性,它的名称需要从一个常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。

属性表的结构是:

attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

从常量池里可知这个例子有三个属性,CodeConstantValueLineNumberTable

  1. Code属性 Java程序方法体中的代码经过javac编译后,变为字节码存储在Code属性内,Code属性出现在方法表的属性集合中。 Code属性的结构如下:
     Code_attribute {
         u2 attribute_name_index; //指向CONSTANT_Utf8_info型常量的索引
         u4 attribute_length;     //属性值的长度
         u2 max_stack;            //操作数栈深度的最大值
         u2 max_locals;           //局部变量表所需的存储空间
         //code_length和code用来存储java源程序编译后生成的字节码指令
         u4 code_length;          //字节码长度
         u1 code[code_length];    //存储字节码指令的一系列字节流
         u2 exception_table_length;
         {   u2 start_pc;
             u2 end_pc;
             u2 handler_pc;
             u2 catch_type;
         } exception_table[exception_table_length];
         u2 attributes_count;
         attribute_info attributes[attributes_count];
     }
    

    getStr方法为例,属性集合表中的第一个属性00 17表示Code属性,00 00 00 1A表示属性的长度为26。 00 01表示最大操作数深度为1,00 02表示所需的局变量空间为2,00 00 00 02表示代码的字节码长度为2,之后的内容为代码的字节码:2B对应指令为aload_1也就是把第2个局部变量加载到操作数栈,B0对应的指令为areturn,也就是返回引用类型。之后00 00表示没有异常,00 01表示有一个属性,00 18表示这个属性是#24 LineNumberTable。

  2. LineNumberTable 继续上面的分析,可知还有10字节表示LineNumberTable属性。 LineNumberTable用于描述Java源码行号与字节码行号的对应关系,LineNumberTable属性表的结构如下:
     LineNumberTable_attribute {
         u2 attribute_name_index;            //属性名索引
         u4 attribute_length;                //属性长度
         u2 line_number_table_length;
         {   u2 start_pc;                 //字节码行号
             u2 line_number;              //java源码行号
         } line_number_table[line_number_table_length];
     }   
    

    00 00 00 06表示该属性表长度为6,与上述整个属性表的长度去掉表示长度字节相符。00 01表示有一个line_number表,00 00 00 21表示第0行字节码对应的是第33行Java源码。

字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(Opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(Operands)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。

如果操作数的长度超过一个字节,将会以大端的顺序存储,也就是高位在前。由于限制了Java虚拟机操作码的长度为一个字节,没有办法使包含了数据类型的操作码支持所有的运行时数据类型,所以Java虚拟机对于特定的操作只提供了有限的类型支持,可以在必要的时候将不支持的类型转换为可被支持的类型。

将字节码操作按用途可以分为9类。

加载和存储指令

加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传输。

  • 将一个局部变量加载到操作栈的指令包括有:iload、lload、fload、dload、aload,也有把操作数隐含在操作码中的指令如iload_1
  • 将一个数值从操作数栈存储到局部变量表的指令包括有:istore、lstore、、fstore、dstore、astore、
  • 将一个常量加载到操作数栈的指令包括有:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1
  • 扩充局部变量表的访问索引的指令:wide

运算指令

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

类型转换指令

类型转换指令可以将两种Java虚拟机数值类型进行相互转换,这些转换操作一般用于实现用户代码的显式类型转换操作,或者用来处理Java虚拟机字节码指令集中指令非完全独立独立的问题。

Java直接支持数值的宽化类型转换,也就是无需显式的转换指令,如int到long、float和double,long到float、long等。窄化类型转换指令包括有:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

  • 创建类实例的指令:new
  • 创建数组的指令:newarray,anewarray,multianewarray

操作数栈管理指令

Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:pop、pop2、dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2和swap。

控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一条指令继续执行程序。

  • 条件分支: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

方法调用和返回指令

以下四条指令用于方法调用:

  • invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic指令用于调用类方法(static方法)。

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

异常处理指令

在程序中显式抛出异常的操作会由athrow指令实现,除了这种情况,还有别的异常会在其他Java虚拟机指令检测到异常状况时由虚拟机自动抛出。

同步指令

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

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中,虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有管程,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放管程。

同步一段指令集序列通常是由Java语言中的synchronized块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。

类加载机制

类的加载,连接,初始化都是在运行期完成。

类加载的时机

类的生命周期:

包括以下七个阶段:

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)

类初始化时机

虚拟机规范严格规定有且只有5种情况必须立即对类进行“初始化”

  1. 遇到new、getstatic、putstatic和invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。这4条指令的场景是:使用new实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法。
  2. 使用java.lang.relect包的方法对类进行反射调用的时候,如果类没有进行过初始化,需要出发初始化
  3. 当初始化一个类时,如果其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 虚拟机启动时,用户需要制定一个执行的主类(包含main()方法),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

  1. 通过子类引用父类的静态字段,不会导致子类的初始化
         System.out.println(SubClass.value); // value是父类SuperClass的静态字段
    
  2. 通过数组定义来引用类,不会触发此类的初始化
         SuperClass[] sca = new SuperClass[10];
    
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
         System.out.println(ConstClass.HELLOWORLD);
    

类加载过程

加载

在加载阶段,虚拟机需要完成一下3件事情:

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

其中二进制字节流可以从以下方式中获取:

  1. 从 ZIP 包读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
  2. 从网络中获取,这种场景最典型的应用是 Applet。
  3. 运行时计算生成,这种场景使用得最多得就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  4. 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  5. 从数据库读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  1. 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一部分是基于二进制字节流进行的,通过了这个验证阶段,字节流会进入内存的方法区存储,所以后面的验证阶段全部以方法区的存储结构进行。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。比如是否有父类,是否继承了不允许继承的父类。
  3. 字节码验证:通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。为了避免这个阶段消耗大量时间,JDK1.6后给方法体的Code属性增加了“StackMapTable”的属性,描述了方法体的所有的基本块(按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态。
  4. 符号引用验证:发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

准备

准备阶段正式为变量分配内存冰设备类变量初始值的阶段。这里进行的内存分配仅包括类变量(即static修饰的变量),使用方法区的内存进行分配。 实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在 Java 堆中。(实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次)。

初始化值通常指的是数据类型的零值,下述情况在准备阶段会被初始化为0,而不是123。

public static int value = 123;

如果类变量是常量,那么会按照表达式来进行初始化,而不是赋值为 0。

public static final int value = 123;

解析

将常量池的符号引用替换为直接引用的过程。

初始化

在初始化阶段才是真正开始执行类中定义的Java程序代码,初始化阶段即虚拟机执行类构造器 <clinit>() 方法的过程。 在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

<clinit>() 方法具有以下特点:

  1. 是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
     public class Test {
         static {
             i = 0;                // 给变量赋值可以正常编译通过
             System.out.print(i);  // 这句编译器会提示“非法向前引用”
         }
         static int i = 1;
     }
    
  2. 与类的构造函数(或者说实例构造器、 <init>())不同,不需要显式的调用父类的构造器。虚拟机会自动保证在子类的 () 方法运行之前,父类的 () 方法已经执行结束。因此虚拟机中第一个执行 \() 方法的类肯定为 java.lang.Object。
  3. . 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。例如以下代码:

     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);  // 输出结果是父类中的静态变量 A 的值,也就是 2。
     }
    
  4. <clinit>() 方法对于类或接口不是必须的,如果一个类中不包含静态语句块,也没有对类变量的赋值操作,编译器可以不为该类生成<clinit>() 方法。
  5. 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>() 方法。
  6. 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

类加载器

实现类的加载动作。在 Java 虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类。

类与类加载器

两个类相等:类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

类加载器分类

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  1. 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在 \lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。
  2. 扩展类加载器(Extension ClassLoader)这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 /lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader)这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

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

下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。

  1. 工作过程 一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。
  2. 好处 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。

    例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 的类并放到 ClassPath 中,程序可以编译通过。因为双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。正因为 rt.jar 中的 Object 优先级更高,因为程序中所有的 Object 都是这个 Object。

  3. 实现

    以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

     public abstract class ClassLoader {
         // The parent class loader for delegation
         private final ClassLoader parent;
    
         public Class<?> loadClass(String name) throws ClassNotFoundException {
             return loadClass(name, false);
         }
    
         protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
             synchronized (getClassLoadingLock(name)) {
                 // First, check if the class has already been loaded
                 Class<?> c = findLoadedClass(name);
                 if (c == null) {
                     try {
                         if (parent != null) {
                             c = parent.loadClass(name, false);
                         } else {
                             c = findBootstrapClassOrNull(name);
                         }
                     } catch (ClassNotFoundException e) {
                         // ClassNotFoundException thrown if class not found
                         // from the non-null parent class loader
                     }
    
                     if (c == null) {
                         // If still not found, then invoke findClass in order
                         // to find the class.
                         c = findClass(name);
                     }
                 }
                 if (resolve) {
                     resolveClass(c);
                 }
                 return c;
             }
         }
    
         protected Class<?> findClass(String name) throws ClassNotFoundException {
             throw new ClassNotFoundException(name);
         }
     }
    

自定义类加载器实现

FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。

java.lang.ClassLoader 类的方法 loadClass() 实现了双亲委派模型的逻辑,因此自定义类加载器一般不去重写它,而是通过重写 findClass() 方法。

public class FileSystemClassLoader extends ClassLoader {

    private String rootDir;

    public FileSystemClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
}

运行时栈帧结构

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的栈元素。

每一个栈帧包括了局部变量表、操作数栈、动态连接、方法返回地址等。编译代码时,栈帧需要多大的局部变量表,多深的操作数栈都已经确定了,并写入到了方法表的Code属性中。因此一个栈帧分配多少内存,与程序运行期无关,仅仅取决于具体的虚拟机实现。

局部变量表:用于存放方法参数和方法内部定义的局部变量。在方法code属性的max_locals数据项中确定了方法的局部变量表的最大容量。

操作数栈:操作数栈的最大容量定义在code属性的max_stacks数据项中。

方法调用

方法调用唯一的任务就是确定被调用方法是哪一个,不涉及方法内部的具体运行过程。一切方法调用在class文件里面存储的都是符号引用,而不是直接引用。这个特性给Java带来了动态扩展能力,在类加载甚至运行期间才能确定目标方法的直接引用。

类加载的解析阶段,会将一部分符号引用转化为直接引用。(主要包括静态方法和私有方法)

Java虚拟机里面有5条方法调用字节码指令:

  1. invokestatic:调用静态方法;
  2. invokespecial:调用实例构造器<init>方法、私有方法、父类方法;
  3. invokevirtual:调用所有的虚方法;
  4. invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行发方法 invokestatic、invokespecial指令调用的方法,可以在解析阶段确定。(静态方法、私有方法、实例构造器、父类方法这4类方法类加载时符号引用就解析为直接引用,称为非虚方法,其他方法称为虚方法)

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

解释执行

现在主流的虚拟机都包含了即时编译器,很难再笼统地说Java是“解释执行”,具体地讨论某种具体的实现版本和执行引擎运行模式时,谈解释执行还是编译执行才是有意义的。

其中下面的分支就是传统编译原理程序代码到目标机器代码的生成过程,中间的分支就是解释执行的过程。在Java语言中,Javac编译器完成了 程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。

基于栈的指令级与基于寄存器的指令集

Java编译器输出的基本上(部分字节码指令会带参数)是一种基于栈的指令集架构,也就是大部分指令都是零地址指令,依赖操作数栈进行工作。对于“1+1”这个操作,基于栈的指令集会由两条iconst_1指令连续把两个常量1压入栈,iadd指令把栈顶的两个值出栈、相加,然后吧结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。而基于寄存器的指令级则是,通过mov指令把EXA寄存器的值设为1,然后add指令再把这个值加1。

基于栈的指令级的主要优点就是可移植,寄存器由硬件直接提供,程序直接依赖寄存器不可避免收到硬件的约束,但是栈架构指令集的主要缺点是执行速度相对来说会慢一点,并且虽然詹家沟指令级的代码紧凑,但是完成相同功能,所需要的指令数量往往会更多。

基于栈的解释器执行过程

通过四则运算来表现在虚拟机的实际执行过程:

通过javap命令可以查看它的字节码指令:

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,下面的过程描述了代码执行过程中操作数栈和局部变量表的变化情况。

不过这个过程只是一种概念模型,虚拟机一般会进行一些优化来提供性能。

Updated on Chengjian Zhou

odroid搭建Android实验环境过程

Published on September 18, 2018

Java并发编程知识总结

Published on August 26, 2018