0%

Java 编程基础(七):深入理解 Java 虚拟机

一. JVM 运行时数据区域概述

1. 概述

  • Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域
  • 这些区域分为两部分,一部分是线程私有的,另一部分是线程公有

2. 示意图

JVM 运行时数据区域示意图

3. 线程私有内存

3.1 程序计数器(Program Counter Register)

  • 程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。字节码解释器工作时,通过改变程序计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器完成
  • 为了线程切换后能恢复到正确的位置,每个线程都需要有独立的程序计数器。由于每个线程的程序计数器是独立存储的,因此各线程之间的程序计数器互不影响
  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,那么这个计数器的值应为空(Undefined)。程序计数器是唯一不会出现 OutOfMemoryError 的内存区域

3.2 Java 虚拟机栈(JVM Stack)

  • 和程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同

  • Java 虚拟机栈描述的是 Java 方法执行的内存模型

    • 每个方法被执行的时候会创建一个栈帧(Stack Frame)用于存储:局部变量表操作数栈动态链接方法出口等信息
    • 局部变量表存放编译器可知的各种:基本数据类型对象引用 reference 类型返回地址 returnAddres 类型
    • 一个方法被调用直至执行完成的过程对应一个栈帧在虚拟机中从入栈到出栈的过程
  • Java 虚拟机栈会出现两种异常

    • StackOverflowError: 如果虚拟机栈不可以动态扩展,当线程请求的栈深度大于虚拟机所允许的深度时会抛出该异常

    • OutOfMemoryError: 如果虚拟机栈可以动态扩展,当无法申请到足够的内存时会抛出该异常

      以前的 Classic 虚拟机栈可以动态扩展,现在主流的 HotSpot 虚拟机的栈容量不可以动态扩展

      HotSpot 虚拟机不会因为无法动态扩展而导致 OOM,只会因为无法申请到足够的内存从而导致 OOM

      StackOverflowError 更多是在程序刚一开始运行的时候抛出,强调的是不够用,一般发生在栈内存OutOfMemoryError 更多是程序运行期间由内存泄漏累积导致抛出,强调的是被用完,一般发生在堆内存

3.3 本地方法栈(Native Method Stack)

  • 本地方法栈和虚拟机栈的作用非常相似。区别在于,虚拟机栈为虚拟机执行 Java 方法(字节码)服务;本地方法栈为虚拟机使用到的本地(Native)方法服务
  • 具体的虚拟机可以根据需要自由实现本地方法栈,有的虚拟机(比如 HotSpot)直接把本地方法栈和虚拟机栈合二为一
  • 和虚拟机栈一样,本地方法栈也会在栈深度溢出时抛出 StackOverflowError 异常;在栈扩展失败时抛出 OutOfMemoryError 异常

4. 线程共享内存

4.1 堆(Heap)

  • 对于 Java 应用程序而言,Java 堆是虚拟机管理的内存中最大的一块。Java 堆在虚拟机启动时创建,是被所有线程共享的内存区域,其目的是存放对象实例,几乎所有的对象实例(包括数组)都在堆中分配内存

    需要注意的是,由于即时编译、逃逸分析、栈上分配、标量替换等技术的发展,Java 对象实例都分配在堆上也渐渐不是那么绝对了

  • Java 堆是垃圾回收器管理的内存区域,因此也被称为 GC 堆(Garbage Collected Heap)

    • 回收内存的角度,由于现代编译器基本都采用分代垃圾回收算法,所以 Java 对还可以分为新生代老年代,新生代又可以细分成 Eden 空间From Survivor 空间To Survivor 空间

      需要注意的是,这些区域划分只是部分垃圾回收器的共同特性设计风格,而非某个 Java 虚拟机具体实现的固有内存布局

      随着垃圾回收器技术的发展,HotSpot 里面也出现了不采用分代设计的新垃圾回收器

    • 分配内存的角度,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率

    • Java 堆的作用是存储对象实例,将 Java 堆细分的目的只是为了更好地回收内存或者更快地分配内存

  • Java 堆既可以被实现成固定大小的,也可以是可扩展的。当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数 -Xmx-Xms设定)。如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM 将抛出 OutOfMemoryError 异常

4.2 方法区(Method Area)

  • 和堆一样,方法去也是被所有线程共享的内存区域。方法区用于存储已经被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。方法区还有一个“非堆(Non-Heap)”别名,目的是与 Java 堆区分开来。在 JDK 8 以前,也几乎等同于“永久代(Permanent Generation)”这个概念

  • 方法区随 JDK 版本的迭代

    • JDK 6:HotSpot 计划放弃永久代,采用本地内存(Native Memory)实现方法区
    • JDK 7:HotSpot 移除了原本放在永久代的字符串常量池静态变量
    • JDK 8:HotSpot 完全废弃了永久代(方法区),采用在本地内存中实现的元空间(Meta-space)来代替。元空间使用的是直接内存
  • 当方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常

4.3 运行时常量池(Runtime Constant Pool)

  • 运行时常量池是方法区的一部分

    • 类信息(Class 文件)中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量符号引用
    • 这个常量池表和由常量池表中符号引用翻译出来的直接引用将在类加载后存放到方法区的运行时常量池
  • 运行时常量池相对于 Class 文件常量池表的另外一个重要特征是具备动态性

    • Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件常量池表的内容才能进入方法去运行时常量池
    • 运行期间也可以将新的常量放入运行时常量池,这种特性被开发人员利用得比较多的就是 String 类的 intern() 方法
  • 因为是方法区的一部分,所以运行时常量池受到方法区内存的限制。当运行时常量池无法再申请到内存时会抛出 OutOfMemoryError 异常

5. 直接内存(Direct Memory)

  • 直接内存并不是虚拟机运行时数据区域的一部分,也不是《Java 虚拟机规范》中定义的内存区域,但是这部分内存也会被频繁使用

  • 在 JDK 1.4 中新加入了 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓冲区(Buffer)的 I/O 方式

    • 它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作
    • 这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据
  • 本机直接内存的分配不会受到 Java 堆大小的限制,但是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制。比如说,服务器管理员根据实际内存去配置 -Xmx 等虚拟机参数时,如果忽略了直接内存使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),此时就会导致动态扩展时抛出 OutOfMemoryError 异常

二. 垃圾回收判定算法及四种引用概述

1. 概述

  • 线程私有的内存区域(程序计数器、虚拟机栈、本地方法栈):它们的生命周期和当前线程保持一致。当方法结束(栈帧出栈)或者线程结束时,内存自然被回收,因此不需要过多考虑回收的问题
  • 线程共享的内存区域(Java 堆、方法区):由垃圾回收器负责管理回收
  • 如何判定对象消亡的角度出发,垃圾回收算法可以分为两大类
    • “引用计数式垃圾回收”(Reference Counting GC):也被称作“直接垃圾回收”
    • “追踪式垃圾回收”(Tracing GC):也被称为“间接垃圾回收”是当前主流 Java 虚拟机的垃圾回收算法实现

2. 引用计数(Reference Counting)算法

2.1 思路

  • 在对象中添加一个引用计数器,每增加一个引用关系,值加 1;每失效一个引用关系,值减1
  • 任何时刻计数器为 0 的对象就是不可能再被使用的

2.2 优点

  • 原理简单,判定效率高
  • 只占用了很少的内存空间进行计数

2.3 缺点

  • 有很多例外情况要考虑,必须配合大量额外处理才能保证正确工作
  • 比如,单纯的引用计数很难解决对象之间循环引用的问题

2.4 应用

  • Python 语音、在游戏脚本领域得到很多应用的 Squirrel
  • 微软 COM(Component Object Model) 技术、使用 ActonScript 3 的 FlashPlayer

3. 可达性分析(Reachability Analysis)算法

3.1 示意图

可达性分析算法示意图

3.2 思路

  • 通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain)
  • 如果某个对象到 GC Roots 之间没有任何引用链相连,用图论的术语就是从 GC Roots 到这个对象不可达时,证明该对象是不可能再被使用的

3.3 应用

  • 主流的编程语言(Java、C#、Lisp 等)的内存管理系统都是使用可达性分析算法来判定对象是否存活的

3.4 GC Roots 对象

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程中调用的方法堆栈中使用到的参数、局部变量、临时变量等
  • 在方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量
  • 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用
  • 本地方法栈中 JNI(Native 方法)引用的对象
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExceptionOutOfMemoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

4. 四种引用类型概述

4.1 强引用(Strong Reference)

  • 任何情况下,垃圾回收器都不会回收强引用关联的对象
  • 普遍存在的引用赋值,类似 Object obj = new Object();

4.2 软引用(Soft Reference)

  • 发生在内存溢出异常之前,如果回收了软引用关联的对象之后内存还不够用,此时就抛出内存溢出异常
  • JDK 1.2 提供了实现类 SoftReference

4.3 弱引用(Weak Reference)

  • 无论当前内存是否足够,都会回收弱引用关联的对象
  • JDK 1.2 提供了实现类 WeakReference

4.4 虚引用(Phantom Reference)

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

5. 两次标记过程与垃圾回收方法调用概述

5.1 两次标记过程

  • 可达性分析算法中即使判定为不可达对象,也并不代表一个对象真正死亡,只是会被第一次标记

  • 一个对象真正死亡,至少要经历两次标记过程

    1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,此时会被第一次标记
    2. 随后进行一次筛选,如果判断有必要执行 finalize() 方法(已覆盖 finalize() 方法或 finalize() 方法还没有被虚拟机调用过)则在 finalize() 方法执行过程中进行第二次标记
  • finalize() 方法执行过程中,对象可以通过重新与引用链上的任何一个对象建立关联即可“拯救”自己,然后在第二次标记时会被移除“即将回收”的集合。否则该对象会被真正回收

5.2 finalize() 方法概述

  • 如果一个对象被判定为有必要执行 finalize() 方法,那么该对象首先会被放置在一个名为 F-Queue 的队列之中。稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行对象的 finalize() 方法
  • finalize() 方法的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束,因为 finalize() 方法有可能运行失败(执行缓慢、甚至死循环导致回收系统崩溃)。 任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,那么它的 finalize() 方法不会被再次执行
  • finalize() 是在 Object 类中定义的,默认实现为空。该方法是 Java 刚诞生时为了使传统 C、C++ 程序员更容易接受 Java 所做出的一项妥协。该方法运行代价高不确定性大,无法保证各个对象的调用顺序,已被官方明确声明不推荐使用的语法。finalize() 方法能做的工作,使用 try-finally 或者其他方式可以做得更好更及时

5.3 gc() 方法概述

  • 调用垃圾回收器的方法是 gc(),该方法在 System 类和 Runtime 类中都存在
  • System 类中,gc() 是静态方法;在 Runtime 类中,gc() 是实例方法。方法 System.gc() 会调用 Runtime 类中的 gc() 方法,System.gc() 等价于 Runtime.getRuntime().gc()
  • System.gc() 的作用是提示 Java 虚拟机进行垃圾回收。该方法被调用之后,由 Java 虚拟机决定是立即回收还是延迟回收

6. 方法区内存回收概述

6.1 常量回收

  • 废弃常量的回收(包括常量池中其他类、接口、方法、字段的符号引用)与 Java 堆对象的回收非常类似,也是类似二次标记的回收过程

6.2 类型卸载

  • 判定一个类型需要回收需要同时满足的三个条件

    • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景(如 OSGi、JSP 重加载等),否则通常很难达成
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • 满足上面三个条件的类只是“被允许”回收,HotSpot 虚拟机提供了 -Snoclassgc 参数对类型回收进行控制

  • 在大量使用反射、动态代理、CGLib 等字节码框架、动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

三. 垃圾回收算法概述

1. 分代回收理论概述

  • 当前商业虚拟机的垃圾回收器,大多数都遵循了“分代回收”(Generational Collection)的理论进行设计

    • 分代回收名为理论,实质是一套符合大多数程序运行实际情况的经验法则
    • 需要注意的是,分代回收理论也有其缺陷,最新出现(或在实验室中)的几款垃圾回收器都展现出了面向全区域回收设计的思想(或者可以支持全区域不分代回收的工作模式)
    • HotSpot 虚拟机把 Java 堆划分为新生代(Young)老年代(Old)两个区域,这也是现在业界主流的命名方式
  • 分代回收理论建立在三个假说之上

    • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的,新生代中的对象有 98%熬不过第一轮回收
    • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾回收过程的对象就越难以消亡(越容易从新生代晋升到老年代)
    • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数
  • 针对不同分代的回收类型划分

    • 部分回收(Partial GC):只是部分回收 Java 堆的垃圾回收方式

      • 新生代回收(Minor GC/Young GC):只是新生代的垃圾回收方式

      • 老年代回收(Major GC/Old GC):只是老年代的垃圾回收方式

        目前只有 CMS 垃圾回收器有单独回收老年代的行为

        需要注意的是,”Major GC” 这个说法现在有点混淆,需要按上下文区分到底是指老年代回收还是整堆回收

      • 混合回收(Mixed GC):回收整个新生代以及部分老年代的垃圾回收方式

    • 整堆回收(Full GC):回收整个 Java 堆和方法区的垃圾回收方式

  • Minor GC 和 Major GC

    • Minor GC 指发生在新生代的垃圾回收操作。因为大多数对象的生命周期都很短,因此 Minor GC 会频繁执行,一般回收速度也比较快
    • Major GC 指发生在老年代的垃圾回收操作。出现了 Major GC,经常会伴随至少一次的 Minor GC。老年代对象的存活时间长,因此 Major GC 很少执行,而且执行速度会比 Minor GC 慢很多
  • 对象优先在 Eden 区分配

    • 大多数情况下,对象在新生代 Eden 区分配
    • 当 Eden 区空间不够时,发起 Minor GC
  • 大对象直接进入老年代

    • 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组
    • 大对象对于虚拟机的内存分配而言是坏消息,经常出现大对象会导致内存还有不少空间时就提前触发垃圾回收以获取足够的连续空间分配给大对象
    • 将大对象直接在老年代中分配的目的是避免在 Eden 区和 Survivor 区之间出现大量内存复制
  • 长期存活的对象进入老年代

    • Java 虚拟机采用分代回收的思想管理内存,因此需要识别每个对象应该放在新生代还是老年代
    • 虚拟机给每个对象定义了年龄计数器,对象在 Eden 区出生之后,如果经过第一次 Minor GC 之后仍然存活,将进入 Survivor 区,同时对象年龄变为 1,对象在 Survivor 区每经过一次 Minor GC 且存活,年龄就增加 1,增加到一定阈值时则进入老年代(阈值默认为 15)
  • 动态对象年龄判定

    • 为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代
    • 如果在 Survivor 区中相同年龄的所有对象的空间总和大于 Survivor 区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代
  • 空间分配担保

    • 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的
    • 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Major GC

2. 标记-清除(Mark-Sweep)算法

  • 示意图

    标记-清除

  • 思路

    • 标记并回收:首先标记所有需要回收的对象,然后统一回收所有被标记的对象(也可以反过来,标记存活的对象、统一回收未被标记的对象)
    • 标记的过程:就是判定对象是否属于垃圾的两次标记过程
  • 缺点

    • 执行效率低:面对大量可回收对象时,必须进行大量的标记和清除动作
    • 内存碎片化:标记清除之后会产生大量不连续的内存碎片,会影响内存的分配和回收
  • 应用

    • CMS 垃圾回收器:HotSpot 虚拟机里关注延迟的 CMS 垃圾回收器是基于标记-清除算法的(也会使用标记-整理算法

3. 标记-复制(Mark-Semispace Copying)算法

  • 示意图

    标记-复制

  • 思路

    • 划分及使用:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
    • 复制并清理:当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间统一清理
  • 优点

    • 避免了标记-清除算法的执行效率低的问题:内存回收时,每次只针对整个半区进行内存回收操作
    • 避免了标记-清除算法的内存碎片化的问题:内存分配时,只需要移动堆顶指针,按顺序分配即可
  • 缺点

    • 空间浪费:可用内存缩小为原来的一半
    • 复制开销:如果内存中多数对象都是存活的,会产生大量的内存复制开销
  • 应用

    • 回收新生代:现在的商用 Java 虚拟机大多都采用标记-复制算法(及优化算法)回收新生代
  • 优化:Appel 式回收策略

    • 原因:新生代中 98% 的对象都是朝生夕灭的,因此不需要按照 1:1 的比例划分新生代的内存空间
    • 思路:把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次内存分配只使用 Eden 和其中一块 Survivor。发生垃圾回收时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理 Eden 和已用过的那块 Survivor 空间
    • 应用:HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,即只有一个 10% 的 Survivor 新生代内存空间会被浪费。HotSpot 虚拟机的 Serial、ParNew 等新生代垃圾回收器都采用 Appel 式回收策略设计新生代的内存布局
    • 安全:“逃生门”安全设计,即当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保(Handle Promotion)

4. 标记-整理(Mark-Compact)算法

  • 示意图

    标记-整理

  • 思路

    • 标记过程:仍是判定对象是否属于垃圾的两次标记过程
    • 整理过程:不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理边界以外的内存
  • 优点

    • 避免了标记-复制算法的空间浪费问题:标记-整理是对象移动式的算法,不需要额外的空间进行分配担保
    • 避免了标记-复制算法的复制开销问题:不需要复制操作,对象存活率较高时,没有复制开销、效率比较高
  • 缺点

    • 操作复杂:移动存活对象并更新所有引用是一种极为负重的操作(但从整个程序的吞吐量的角度,移动对象会更划算)
    • “Stop The World”:移动对象操作必须全程暂停应用程序(最新的 ZGC 和 Shenandoah 垃圾回收器使用 Read Barrier 读屏障技术实现了整理过程与用户线程的并发执行)
  • 应用

    • Parallel Scavenge 垃圾回收器:HotSpot 虚拟机里关注吞吐量的 Parallel Scavenge 垃圾回收器是基于标记-整理算法
    • CMS 垃圾回收器:虚拟机平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在。直到内存空间的碎片化程度已经大到影响对象内存分配时,再采用标记-整理算法回收一次,以获得规整的内存空间

四. Class 类文件结构概述

1. Class 文件概述

  • Class 文件

    • 是一组以 8 个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑排列在文件之中,中间没有添加任何分隔符
    • 这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在
  • Class 文件格式:采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”

    • 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值
    • :是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有的表的命名都习惯性地以 “info” 结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上也可以视作一张表
  • Class 文件格式数据项

    类型 名称 数量
    u4 magic 1
    u2 minor_version 1
    u2 major_version 1
    u2 constant_pool_count 1
    cp_info constant_pool constant_pool_count-1
    u2 access_flags 1
    u2 this_class 1
    u2 super_class 1
    u2 interfaces_count 1
    u2 interfaces interfaces_count
    u2 fields_count 1
    field_info fields fields_count
    u2 methods_count 1
    method_info methods methods_count
    u2 attributes_count 1
    attribute_info attributes attributes_count

2. 魔数与 Class 文件的版本

  • 魔数(Magic Number)
    • 每个 Class 文件的前 4 个字节,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件,起到一个身份标识的作用
    • Java 虚拟机中 Class 文件的魔数取名为 0xCAFEBABE
  • Class 文件的版本:魔数后面的 4 个字节存储的是 Class 文件的版本号
    • 第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)
    • Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1(JDK 1.0 ~ JDK 1.1 使用了 45.0 ~ 45.3 的版本号)
    • 高版本的 JDK 只能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件。《Java 虚拟机规范》在 Class 文件校验部分明确要求及时文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件

3. 常量池

  • 常量池

    • 主、此版本号之后的是常量池入口,可以比喻为 Class 文件里的资源仓库
    • 常量池是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一。另外,它还是在 Class 文件中第一个出现的表类型数据项目
  • 常量池的内容

    • 字面量(Literal):比较接近 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等
    • 符号引用(Symbolic Reference):属于编译原理方面的概念,主要包括下面几类常量
      • 被模块导出或者开放的包(Package)
      • 类和接口的全限定名(Fully Qualified Name)
      • 字段的名称和描述符(Descriptor)
      • 方法的名称和描述符
      • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
      • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
  • 常量池的项目类型

    类型 标志 描述
    CONSTANT_Utf8_info 1 UTF-8 编码的字符串
    CONSTANT_Integer_info 3 整型字面量
    CONSTANT_Float_info 4 浮点型字面量
    CONSTANT_Long_info 5 长整型字面量
    CONSTANT_Double_info 6 双精度浮点型字面量
    CONSTANT_Class_info 7 类或接口的符号引用
    CONSTANT_String_info 8 字符串类型字面量
    CONSTANT_Fieldref_info 9 字段的符号引用
    CONSTANT_Methodref_info 10 类中方法的符号引用
    CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
    CONSTANT_NameAndType_info 12 字段或方法的部分符号引用
    CONSTANT_MethodHandle_info 15 表示方法句柄
    CONSTANT_MethodType_info 16 表示方法类型
    CONSTANT_Dynamic_info 17 表示一个动态计算常量

4. 访问标志

  • 访问标志(access_flags)

    • 用于识别一些类或者接口层次的访问信息
    • 包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final
  • 访问标志位

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 是否为 public 类型
    ACC_FINAL 0x0010 是否被声明为 final,只有类可设置(只针对类和接口而言)
    ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语义,invokespecial 指令的语义在 JDK 1.0.2 发生过改变,为了区分这条指令使用哪种语义,JDK 1.0.2 之后编译出来的类的这个标志都必须为真
    ACC_INTERFACE 0x0200 标识这是一个接口
    ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象类来说,此标志为真,其他类型值为假
    ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生
    ACC_ANNOTATION 0x2000 标识这是一个注解
    ACC_ENUM 0x4000 标识这是一个枚举
    ACC_MODULE 0x8000 标识这是一个模块

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

  • 类索引(this_class父类索引(super_class都是一个 u2 类型的数据。接口索引集合(interfaces是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定该类型的继承关系
  • 类索引:用于确定这个类的全限定名
  • 父类索引:用于确定这个类的父类的全限定名
  • 接口索引:用于描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字(如果这个索引集合表示的是一个类,则应当是 extends 关键字)后的接口顺序从做到右排列在接口索引集合中

6. 字段表集合

  • 字段表(field_info)用于描述接口或者类中声明的变量。Java 语言中的字段(Field)包括类级变量(静态变量)以及实例级变量,但不包括在方法内部声明的局部变量。Java 语言中的字段可以包括的修饰符有以下几种

    • public/private/protected:字段的作用域
    • static:是实例变量还是类变量
    • final:可变性
    • volatile:并发可见性,是否强制从主内存读写
    • transient:可否被序列化
    • 字段数据类型:基本数据类型、对象、数组
  • 字段表结构

    类型 名称 数量
    u2 access_flags 1
    u2 name_index 1
    u2 descriptor_index 1
    u2 attributes_count 1
    attribute_info attributes attributes_count
  • 字段访问标志位

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 字段是否 public
    ACC_PRIVATE 0x0002 字段是否 private
    ACC_PROTECTED 0x0004 字段是否 protected
    ACC_STATIC 0x0008 字段是否 static
    ACC_FINAL 0x0010 字段是否 final
    ACC_VOLATILE 0x0040 字段是否 volatile
    ACC_TRANSIENT 0x0080 字段是否 transient
    ACC_SYNTHETIC 0x1000 字段是否由编译器自动产生
    ACC_ENUM 0x4000 字段是否 enum
    • ACC_FINALACC_VOLATILE 不能同时选择
    • 接口之中的字段必须有 ACC_PUBLICACC_STATICACC_FINAL 标志
    • Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称

7. 方法表集合

  • 方法表的结构如同字段表一样,依次包括访问标志(access_flags名称索引(name_index描述符索引(descriptor_index属性表集合(attributes

  • 方法表的结构

    类型 名称 数量
    u2 access_flags 1
    u2 name_index 1
    u2 descriptor_index 1
    u2 attributes_count 1
    atrribute_info attributes attributes_count
    • volatile 关键字和 transient 关键字不能修饰方法
  • 方法访问标志位

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 方法是否为 public
    ACC_PRIVATE 0x0002 方法是否为 private
    ACC_PROTECTED 0x0004 方法是否为 protected
    ACC_STATIC 0x0008 方法是否为 static
    ACC_FINAL 0x0010 方法是否为 final
    ACC_SYNCHRONIZED 0x0020 方法是否为 synchronized
    ACC_BRIDGE 0x0040 方法是不是由编译器产生的桥接方法
    ACC_VARARGS 0x0080 方法是否接收不定参数
    ACC_NATIVE 0x0100 方法是否为 native
    ACC_ABSTRACT 0x0400 方法是否为 abstract
    ACC_STATIC 0x0800 方法是否为 strictfp
    ACC_SYNTHETIC 0x1000 方法是否为编译器自动产生

8. 属性表集合

  • 属性表(attribute_info):Class 文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息

  • 与 Class 文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序。《Java 虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。同时,Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内

  • Code 属性表的结构

    类型 名称 数量
    u2 attribute_name_index 1
    u4 attribute_length 1
    u2 max_stack 1
    u2 max_locals 1
    u4 code_length 1
    u1 code code_length
    u2 exception_table_length 1
    exception_info exception_table exception_table_length
    u2 attributes_count 1
    attribute_info attributes attributes_count
    • max_stack:代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度
    • max_locals 代表了局部变量表所需的存储空间。在这里,max_locals 的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位
  • Code 属性是 Class 文件中最重要的一个属性。如果可以把一个 Java 程序中的信息分为代码(Code,方法体里的 Java 代码)元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个 Class 文件里,Code 属性用于描述代码,所有的其他数据项目都用于描述元数据

五. JVM 类加载机制概述

概述

1. 概念

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

2. 特点

  • 在 Java 语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的

3. 优点

  • 为 Java 应用提供了极高的扩展性灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态加载动态连接实现

4. 缺点

  • 让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销

5. 应用

  • 动态组装应用的方式目前已广泛应用于 Java 程序之中,从最基础的 Applet、JSP 到相对复杂的 OSGi 技术,都依赖着 Java 语言运行期类加载才得以诞生

6. 总结

  • 代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

类加载时机

1. 类的生命周期示意图

类的生命周期

2. 类生命周期概述

  • 一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)验证(Verification)准备(Preparation)解析(Resolution)初始化(Initialization)使用(Using)卸载(Unloading) 七个阶段。其中,验证、准备、解析三个阶段统称为连接(Linking)
  • 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)
  • 按部就班开始并不是按部就班进行或按部就班完成的意思,这些节点通常都是互相交叉混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。需要注意的是,第一个阶段加载(Loading)在《Java 虚拟机规范》中并没有进行强制约束,可以交给虚拟机的具体实现来自由把握

3. 有且只有的需要开始初始化操作的六种情况

  1. 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有

    • 使用 new 关键字实例化对象的时候
    • 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候
    • 调用一个类型的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化

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

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

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

  6. 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化(是不是可以写一个这个版本的单例模式

4. 被动引用

  • 概念

    • 《Java 虚拟机规范》中规定的六种必须立即进行初始化场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,成为被动引用
  • Demo 1:通过子类引用父类的静态字段,不会导致子类初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    package org.fenixsoft.classloading;

    public class SuperClass {

    static {
    System.out.println("SuperClass init!");
    }

    public static int value = 123;
    }

    public class SubClass extends SuperClass {

    static {
    System.out.println("SubClass init!");
    }
    }

    public class NotInitialization {

    public static void main(String[] args) {
    System.out.println(SubClass.value);
    }
    }
    • 运行结果:只会输出 "SuperClass init!",不会输出 "SubClass init!"
    • 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化
    • 是否要触发子类的加载和验证阶段,在《Java 虚拟机规范》中并未明确规定,取决于虚拟机的具体实现。对于 HotSpot 虚拟机来说,可通过 -XX:+TraceClassLoading 参数观察到此操作是会导致子类加载
  • Demo 2:通过数组定义来引用类,不会触发此类的初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package org.fenixsoft.classloading;

    public class SubClass extends SuperClass {

    static {
    System.out.println("SubClass init!");
    }
    }

    public class NotInitialization {

    public static void main(String [] args) {
    SuperClass[] sca = new SuperClass[10];
    }
    }
    • 运行结果:没有输出 "SuperClass init!",说明并没有触发 org.fenixsoft.classloading.SuperClass 的初始化阶段
    • 但是这段代码触发了另一个名为 Lorg.fenixsoft.classloading.SuperClass 的类的初始化。对于用户来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发
  • Demo 3:常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    package org.fennixsoft.classloading;

    public class ConstClass {

    static {
    System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
    }

    public class NotInitialization {

    public static void main(String[] args) {
    System.out.println(ConstClass.HELLOWORLD);
    }
    }
    • 运行结果:没有输出 ConstClass init!
    • 虽然在代码中确实引用了 ConstClass 类的常量 HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值 hello world 直接存储在 NotInitialization 类的常量池中,以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用,实际都被转化为 NotInitialization 类对自身常量池的引用
    • 实际上 NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入口这两个类在编译成 Class 文件后就已不存在任何联系了

5. 接口的加载过程

  • 接口也有初始化过程,这一点是与类一致的,上面的代码都是用静态代码语句块 static{} 来输出初始化信息的,而接口中不能使用 static{} 语句块。但编译器仍然会为接口生成 <clinit>() 类构造器,用于初始化接口中所定义的成员变量
  • 接口与类真正有所区别的是前面讲述的六种“有且仅有”场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

类加载过程

1. 加载

  • 概述

    • “加载”(Loading)阶段是整个“类加载(Class Loading)”过程中的一个阶段。在加载阶段,Java 虚拟机需要完成以下三件事情

      • 通过一个类的全限定名来获取定义此类的二进制字节流
      • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
      • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
    • 《Java 虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与 Java 应用的灵活度都是相当大的。许多举足轻重的 Java 技术都建立在这一基础之上,比如

      • 从 ZIP 压缩包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础
      • 从网络中获取,这种场景最典型的应用就是 Web Applet
      • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中就是利用了 ProxyGenerator.generateProxyClass() 来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流
      • 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件
      • 从数据库中读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发
      • 从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探
  • 数组加载

    • 数组类的情况有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的
    • 但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。一个数组类(简称 C)创建过程遵循以下规则
      • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上
      • 如果数组的组件类型不是引用类型(例如 int[] 数组的组件类型为 int),Java 虚拟机将会把数组 C 标记为与引导类加载器关联
      • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到

2. 验证

  • 概述

    • 目标:确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
    • 理由:Class 文件不一定只能由 Java 源码编译而来,Java 虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致真个系统受攻击甚至崩溃,所以验证字节码是 Java 虚拟机保护自身的一项必要措施。从代码量和耗费的执行性能的角度,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重
    • 设置:相关验证步骤均可以通过相关虚拟机参数进行设置
  • 文件格式验证:验证字节流是否符合 Class 文件格式规范

    • 是否以魔数 0xCAFEBABE 开头
    • 主、次版本号是否在当前 Java 虚拟机接受范围之内
    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据
    • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
  • 元数据验证:对字节码描述的信息进行语义分析和语义校验

    • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
  • 字节码验证:整个验证过程中最复杂的一个阶段

    • 目标:通过数据流分析控制流分析,确定程序语义是合法的、符合逻辑的

    • 作用:对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为

      • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作(例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用时却按 long 类型类载入本地变量表中”这样的情况)
      • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
      • 保证方法体中的类型转换总是有效的(例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型、甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的)
    • 停机问题(Halting Problem):这是离散数学中的一个概念,即不能通过程序准确检查出程序是否能在有限时间之内结束运行

      • 字节码验证阶段进行了再大量再严密的检查,也依然不能保证程序绝对没有问题
      • 通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在 Bug(程序员甩锅的理论依据,go home
  • 符号引用验证:对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,即该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的可访问性privateprotectedpublic<package>)是否可被当前类访问

3. 准备

  • 概念

    • 为静态变量分配内存并设置默认值(不包括 final 修饰的情况)
  • 存储区域

    • 为静态变量分配内存是在方法区上进行,但方法区本身是一个逻辑上的区域
    • 在 JDK 7 及之前,HotSpot 虚拟机使用永久代来实现方法区,是符合这种逻辑概念的
    • 在 JDK 8 及之后,静态变量会随着 Class 对象一起存放在 Java 堆中
  • 操作对象

    • 准备阶段进行内存分配仅针对静态变量不包括实例变量
    • 实例变量将会在对象实例化时随着对象一起分配在 Java 堆
  • 特殊情况

    • 如果静态变量被 final 修饰,那么编译时 Javac 将会为该变量生成 ConstantValue 属性
    • 在准备阶段虚拟机根据 ConstantValue 的设置将变量初始化赋值为设置的值

4. 解析

  • 概念

    • Java 虚拟机将常量池内的符号引用替换为直接引用

    • 符号引用(Symbolic References)

      • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
      • 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容
    • 直接引用(Direct References)

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

    • 解析动作主要针对类或接口字段类方法接口方法方法类型方法句柄调用点限定符这 7 类符号引用进行
    • 分别对应于常量池的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_infoCONSTANT_Dynamic_infoCONSTANT_InvokeDynamic_info 8 种常量类型
    • 主要思路:递归查找对应的数据信息,并进行相应的权限验证,如果没有相关访问权限则抛出异常
  • 注意

    • 在接口方法解析的过程中,在 JDK 9 中增加了接口的静态私有方法,也有了模块化的访问约束。所以从 JDK 9 起,接口方法的访问也完全有可能因访问权限控制而出现 java.lang.IllegalAccessError 异常

5. 初始化

  • 概述

    • 类的初始化是类加载过程的最后一个步骤,在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与,连接阶段(验证、准备、解析)由 Java 虚拟机来主导控制
    • 直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 代码,将主导权移交给应用程序
  • 概念

    • 初始化阶段会根据程序员通过程序编码制定的主观计划去初始化静态变量和其他资源
    • 初始化阶段就是执行类构造器 <clinit>() 方法的过程<clinit>() 方法并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物
  • <clinit>() 方法概述

    • <clinit>() 方法是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块<static{}> 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值(可以正常编译通过),但是不能访问(编译器提示:非法向前引用)
    • <clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法)不同,它不需要显示调用父类构造器,Java 虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。因此,在 Java 虚拟机中第一个被执行的<clinit>() 方法的类型肯定是 java.lang.Object。由于父类的 <clinit>() 方法先执行,即父类中定义的静态语句块要优先于子类的变量赋值操作
    • <clinit>() 方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法
    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法
    • Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步。如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法

类加载器

1. 类与类加载器

  • 概述

    • 类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用远超类加载阶段
    • 对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间
    • 比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类必定不相等
    • 这里的“相等”,包括代表类的 Class 对象equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括了使用 instanceof 关键字做对象所属关系判定等情况
  • Demo:不同的类加载器对 instanceof 关键字运算的结果的影响

    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
    public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
    ClassLoader myLoader = new ClassLoader () {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    try {
    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
    InputStream is = getClass().getResourceAsStream(fileName);
    if (is == null) {
    return super.loadClass(name);
    }
    byte[] b = new byte[is.available()];
    is.read(b);
    return defineClass(name, b, 0, b.length);
    } catch (IOException e) {
    throw new ClassNotFoundException(name);
    }
    }
    };
    }

    Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();

    System.out.println(obj.getClass());
    System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
    }
    • 运行结果:org.fenixsoft.classloading.ClassLoaderTestfalse

2. 双亲委派模型(Parents Delegation Model)

  • 示意图

    双亲委派模型

  • 类加载器概述

    • 从 Java 虚拟机的角度:只存在两种不同的类加载器

      • 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现(只限 HotSpot),是虚拟机自身的一部分
      • 一种就是其他所有的类加载器,这些类加载器使用 Java 实现,独立存在于虚拟机外部且全部继承自抽象类 java.lang.ClassLoader
    • 从 Java 开发人员的角度

      • JDK 1.2 版本,Java 一直保持三层类加载器 + 双亲委派的类加载架构
      • JDK 9 版本,出现了模块化系统,对三层架构有了一些调整
  • 类加载器分类

    • 启动类加载器(Bootstrap ClassLoader)

      • 负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名识别,如 rt.jar、tools.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机的内存中
      • 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器去处理,那直接使用 null 代替即可
    • 扩展类加载器(Extension ClassLoader)

      • 该类加载器是在类 sun.misc.Launcher$ExtClassLoader 中实现
      • 负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库
      • 这是一种 Java 系统类库的扩展机制,JDK 的开发团队允许开发者将具有通用性的类库放置在 ext 目录里以扩展 Java SE 的功能。在 JDK 9 之后,这种扩展机制被模块化带来的天然的扩展能力所取代
    • 应用程序类加载器(Application ClassLoader)

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

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

    • 双亲委派模型很好地解决了各个类加载器协作时基础类型的一致性问题:越基础的类由越上层的加载器进行加载
    • Java 中类随着它的类加载器一起具备了一种带有优先级的层级关系,可以保证 Java 类型体系中最基础的行为,对保证 Java 程序的稳定运行极为重要
  • loadClass() 方法实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    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 = fiindBootStrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    // 如果父类加载器抛出 ClassNotFoundException,说明父类加载器无法完成加载请求
    }
    if (c == null) {
    // 在父类加载器无法加载时,再调用本身的 findClass() 方法来进行类加载
    c = findClass(name);
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }
    • 首先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器
    • 如果父类加载器加载失败抛出 ClassNotFoundexception 异常,再调用自己的 findClass() 方法尝试进行加载

3. 破坏双亲委派模型

  • 概述

    • 双亲委派模型并非强制性约束,而是 Java 设计者推荐给开发者们的类加载器实现方式
  • 双亲委派模型的 “3 次破坏”

    1. 第一次

      • 发生在双亲委派模型出现之前,即 JDK 1.2 版本之前,因为类加载器概念和抽象类 java.lang.ClassLoader 是在第一个版本就已经存在
      • 面对 JDK 1.2 版本的双亲委派模式,考虑到兼容性(避免 loadClass() 方法被子类覆盖),所以增加了一个 protectedfindClass() 方法,并引导用户编写类加载逻辑时尽可能去重写这个方法
    2. 第二次

      • 由于模型本身的设计缺陷导致。双亲委派模型很好地解决了各个类加载器协作时基础类型的一致性问题,但是如果有基础类型又要调用回用户的代码则无能为力
      • Java 的设计团队只好引入一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置。如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过的话,那这个线程上下文类加载器默认就是应用程序类加载器
      • 这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事
    3. 第三次

      • 由于用户对程序动态性的追求而导致的,这里的动态性指的是:代码热替换(Hot Swap)、模块热部署(Hot Deployment)。其中,IBM 主导的关于 Java 模块化规范的 OSGi 技术是其中典型的代表
      • OSGi 实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再是双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

Java 模块化系统

  • 关键目标

    • 在 JDK 9 中引入的 Java 模块化系统(Java Platform Module System,JPMS)是对 Java 技术的一次重要升级,为了能够实现模块化的关键目标:可配置的封装隔离机制
  • 模块定义:JDK 9 的模块不仅仅像之前的 JAR 包那样只是简单充当代码容器,除了代码,Java 的模块定义还包括以下内容

    • 依赖其他模块的列表
    • 导出的包列表,即其他模块可以使用的列表
    • 开放的包列表,即其他模块可以反射访问模块的列表
    • 使用的服务列表
    • 提供服务的实现列表
  • 解决的问题

    • 可配置的封装隔离机制首先解决了 JDK 9 之前基于类路径(ClassPath)来查找依赖的可靠性问题
    • 其次还解决了原来类路径上跨 JAR 文件的 public 类型的可访问性问题。JDK 9 中的 public 类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 的类型可以被其他哪一些模块访问,使出现 ClassNotFoundException 异常的概率大大降低

1. 模块的兼容性

  • 为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9 提出了与类路径(ClassPath)相对应的模块路径(ModulePath)概念
  • JDK 9 的可配置的封装隔离机制也仍然保证了传统程序可以访问到所有标准类库模块中导出的包

2. 模块化下的类加载器

  • 对比示意图

    对比示意图

  • 概述

    • 扩展类加载器(Extension ClassLoader)被平台类加载器(Platfor ClassLoader)取代
    • 启动类加载器、平台类加载器、应用程序类加载器全部继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理
    • 类加载的委派关系发生了变动,也许可以算是对双亲委派的第 4 次破坏。JDK 9 中的模块化系统明确规定了三个类加载器负责各自加载的模块

六. Java 内存模型概述

  • Java 内存模型(Java Memory Model, JMM):屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果
  • 特指:在 JDK 1.2 之后建立起来并在 JDK 5 中完善过的内存模型

主内存与工作内存

1. 线程、主内存、工作内存三者的交互关系

线程、主内存、工作内存三者的交互关系

2. Java 内存模型的主要目的

  • 定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存从内存中取出变量值这样的底层细节

  • 此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题

    如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中是线程私有的

  • 为了获得更好的执行效能,Java 内存模型没有限制执行引擎使用处理器的特定寄存器或缓存来和主存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施

3. Java 内存模型含义

  • 所有的变量都存储在主内存(Main Memory)中(可以类比物理硬件层主内存,但物理上它仅是虚拟机内存的一部分)

  • 每条线程还有自己的工作内存(Working Memory)(可类比处理器高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据

  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

    工作内存中主内存副本:一个对象的引用、对象中某个在线程访问到的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次

    volatile 变量依然有工作内存的拷贝,但由于它特殊的操作顺序性,看起来如同直接在主内存中读写访问一样

4. Java 内存模型与 JVM 运行时数据区域划分的区别

  • 二者并不是同一个层次的对内存的划分,基本上是没有任何关系的
  • 如果二者一定要勉强对应起来,那么从变量、主内存、工作内容的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分;工作内存对应于虚拟机栈中的部分区域
  • 从更基础的层次,主内存直接对应于物理硬件的内存;为了获取更好的运行速度,虚拟机(或是硬件、操作系统本身的优化措施)
    可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存

内存间交互操作

  • 关于主内存与工作内存之间具体的交互协议的实现细节,Java 内存模型定义了:lock(锁定)、unlock(解锁)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)8 种原子性操作(对于 doublelong 类型的变量来说,loadstorereadwrite 操作在某些平台上允许有例外)
  • 除此之外,Java 内存模型还规定了执行 8 种基本操作时必须要满足的 8 个规则(见《深入理解 Java 虚拟机(第三版)第 443 页》)

针对 volatile 型变量的特殊规则

1. 概念

  • 关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制

2. 作用

  • 保证被 volatile 修饰的变量对所有线程的内存可见性

    volatile 变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以人文不存在不一致问题)

    但是,Java 里面的运算操作符并非原子操作,这导致 volatile 变量的非原子操作运算在并发环境下一样是不安全的

    在需要保证原子性操作的运算场景中,可以通过加锁:synchronizedjava.util.concurrent 中的锁或原子类来保证原子性

  • 禁止指令重排(Instruction Reorder),即 Java 内存模型中描述的线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)

    volatile 禁止指令重排的语义在 JDK 5 中才被完全修复,此前版本的 JDK 中即使将变量声明为 volatile 也仍然不能完全避免指令重排所导致的问题,这一点也是 JDK 5 之前版本的 Java 中无法安全使用 DCL(双锁检测)来实现单例模式的原因

3. 性能

  • 大多数情况下,volatile 的同步机制性能确实要优于锁(使用 synchronizedjava.util.concurrent 包里面的锁)。但是优于虚拟机对锁实行的许多消除和优化,很难确切说 volatile 就会比 synchronized 快上多少
  • volatile 横向对比原则:读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会慢一些(因为它需要在本地代码中插入许多内存屏障(Memory Barrier 或 Memory Fence)指令来保证处理器不发生乱序执行(OutOfOrder Execution)

针对 longdouble 型变量的特殊规则

  • longdouble 的非原子性协定(Non-Atomic Treatment of double and long Variables):Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性
  • 在实际开发中,除非该数据有明确可知的线程竞争,否则在编写代码时一般不需要因为这个原因刻意把用到的 longdouble 变量专门声明为 volatile

原子性、可见性与有序性

1. 概述

  • Java 内存模型是围绕着在并发过程中如何处理原子性可见性有序性这三个特征来建立的

2. 原子性(Atomicity)

  • 大致可以认为:基本数据类型的访问、读写都是具备原子性的(例外就是 longdouble 的非原子性协定)
  • 在需要更大范围的原子性保证的场景:Java 内存模型提供了 lockunlock 操作来满足这种需求,对应 monitorentermonitorexit 字节码指令。反映到 Java 代码中就是同步块:synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性

3. 可见性(Visibility)

  • 概念:当一个线程修改了共享变量的值时,其他线程能立即得知这个修改
  • volatile 的特殊性保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,普通变量保证不了这一点
  • 除了 volatileJava 还有两个关键字 synchronizedfinal 能实现可见性
    • synchronized:由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write操作)”这条规则获得
    • final:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去(this 引用逃逸是一件很危险的事:其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见 final 字段的值

4. 有序性(Ordering)

  • Java 程序中天然的有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的

    前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics)(与上面这个的含义解释冲突了

    后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象

  • Java 提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性

    • volatile:本身就包含了禁止指令重排的语义
    • synchronized:由“一个变量在同一时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

先行发生(Happens-Before)原则

1. 概念

  • 先行发生是 Java 内存模型中定义的两项操作之间的偏序关系
  • 比如,操作 A 先行发生于操作 B,其实就是说在发送操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等

2. 内容

  • Java 内存模型下无须任何同步手段就能成立的有且仅有的先行发生规则:程序次序规则(Program Order Rule)、管程锁定规则(Monitor Lock Rule)、volatile 变量规则(Volatile Variable Rule)、线程启动规则(Thread Start Rule)、线程终止规则(Thread Termination Rule)、线程中断规则(Thread Interruption Rule)、对象终结规则(Finalizer Rule)、传递性(Transitivity)

3. 结论

  • 时间先后顺序与先行发生原则之间基本没有因果关系,衡量并发安全问题时一切必须以先行发生原则为准
-------------------- 本文结束感谢您的阅读 --------------------