文章目录

  • 一、内存模型
  • 线程私有(隔离)
  • 1. 程序计数器
  • 2. Java虚拟机栈
  • a. 局部变量表
  • b. 操作数栈
  • c. 常量池引用
  • d. 返回地址
  • 3. 本地方法栈
  • 线程共享
  • 1. 堆
  • 2. 方法区
  • a. 运行时常量池
  • 本地内存
  • 1. 元数据空间
  • 2. 直接内存
  • 二、垃圾收集
  • 1. 何为垃圾(如何确定可以被回收)
  • a. 引用计数法
  • b. 可达性分析法
  • c. 方法区中的回收
  • d. 二次存活
  • e. 引用类型
  • 2. 收集算法
  • a. 标记清除
  • b. 标记整理
  • c. 标记复制
  • 3. 收集器
  • a. Serial 收集器
  • b. ParNew 收集器
  • c. Parallel Scavenge/old 收集器
  • d. CMS 收集器
  • e. G1 收集器
  • 三、内存分配
  • 1. 内存分配策略
  • 2. Full GC 的触发条件
  • 四、类加载
  • 1. 类的生命周期
  • 2. 类加载过程
  • a. 加载
  • b. 验证
  • c. 准备
  • d. 解析
  • e. 初始化
  • 3. 类加载器
  • a. 类和类加载器
  • b. 双亲委派模型


一、内存模型

Java虚拟机可以运行 java虚拟机实战_老年代


上图为《深入理解Java虚拟机》中JVM运行时数据区结构图,展示了程序执行期间使用的各种运行时数据区域。下面做一下详细介绍

线程私有(隔离)

1. 程序计数器

异常情况:该部分是唯一一个在Java虚拟机规范中没有定义任何OutOfMemoryError异常的区域。

程序计数器是一块比较小的内存空间,是当前线程所执行的字节码的行号指示器。如果执行的方法是一个Java方法,则记录当前正在执行的Java虚拟机字节码指令的地址;如果执行的方法是本地方法,那么值为空。

public class per.gmy.Test {
  public per.gmy.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        1
       2: istore_1
       3: bipush        2
       5: istore_2
       6: iload_1
       7: iload_2
       8: iadd
       9: istore_3
      10: return
}

如上面的字节码指令前的0、2、3等等数字,就是该区域存储的内容。由于是线程私有的,当发生时间片切换时,当前线程可以根据存储的行号记录程序执行到了哪里

2. Java虚拟机栈

异常情况:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

Java方法执行时,每个方法都会创建一个栈帧,其内部包括局部变量表、操作数栈、动态连接、方法返回地址等信息,栈帧结构如下所示:

Java虚拟机可以运行 java虚拟机实战_java_02

a. 局部变量表

局部变量表用于存放方法参数和方法内部定义的局部变量,其基本单位为变量槽,每个槽可以存放32位以内的数据,对于long和double则会用连续的两个槽。

局部变量表有是从0开始的索引,用来定位数据,如果执行的是实例方法,第0位索引默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。

Java虚拟机可以运行 java虚拟机实战_Java虚拟机可以运行_03

b. 操作数栈

可以理解为进行计算的地方,比如计算a+b,会将a和b的值分别push进操作数栈,然后执行add指令将ab取出计算后将结果重新压入操作数栈;如果后续没有计算,一般会将结果存入局部变量表中。
虽然不同栈帧代表不同方法应该相互独立,但虚拟机大多有优化,在该区域会存在两个栈帧共享的一部分操作数栈

c. 常量池引用

每个栈帧都包含一个指向运行时常量池中的引用,该引用是栈帧所属方法的引用,用于动态链接时能够将方法的符号引用转换为直接引用

d. 返回地址

用于记录调用该方法的调用方的地址,方便在当前方法return后,会到原来的方法

3. 本地方法栈

本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。

线程共享

1. 堆

异常情况:如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

所有对象都在这里分配内存,是垃圾收集的主要区域(“GC 堆”),现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法,可以将堆分成两块:新生代(Young Generation)、老年代(Old Generation),JDK1.8中把运行时常量池、静态变量也移到堆区进行存储。
其中新生代分为Eden空间、From Survivor空间、To Survivor空间,默认比例是8:1:1,下面是一些具体参数:

-Xmx:堆的最大内存
-Xms:堆的最小内存
-Xmn:新生代大小
-XX:NewRatio:新生代比例,默认为2,即新生代占堆的三分之一
-XX:SurvivorRatio:新生代中的survivor比例,默认为8,即每次新生代可用区域为90%
2. 方法区

异常情况:动态扩展失败会抛出 OutOfMemoryError 异常。

方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,和堆一样不需要连续的内存,并且可以动态扩展,对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

a. 运行时常量池

运行时常量池是方法区的一部分。Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()方法。

本地内存

1. 元数据空间

元数据空间是JDK1.8之后对方法区的一种实现方式,取代了之前的永久代,减轻之前的开发和运维压力,否则设置的永久代太小会抛出“java.lang.OutOfMemoryError: PermGen space”;设置过大会浪费内存

2. 直接内存

在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。

二、垃圾收集

1. 何为垃圾(如何确定可以被回收)
a. 引用计数法

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。如果两个对象互相引用,则无法对它们进行回收

b. 可达性分析法

以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

Java虚拟机可以运行 java虚拟机实战_java_04

GC Roots包括如下几种:

  • 虚拟机栈中局部变量表中引用的对象
  • 本地方法栈中 JNI 中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
c. 方法区中的回收

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

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
d. 二次存活

即使在可达性分析算法中判定为不可达的对象,也不是一定会被回收,要经历两次标记过程才会被回收,如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象此时还不具备可达性,那基本上它就真的要被回收了。

e. 引用类型

上面两种算法都在提到一个词语,“引用”,Java 提供了四种强度不同的引用类型:

  • 强引用,不会被回收,使用 new 一个新对象的方式来创建强引用。
  • 软引用,只有内存不够时会被回收,通过SoftReference sf = new SoftReference(obj);来声明obj对象的软引用。
  • 弱引用,一定会被回收,通过WeakReference wf = new WeakReference(obj);来声明obj对象的弱引用。
  • 虚引用,不会对其生存时间造成影响,当垃圾回收器准备回收一个虚引用对象时,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。
String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
//创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);
2. 收集算法
a. 标记清除

Java虚拟机可以运行 java虚拟机实战_java_05


首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

优缺点:
当堆内的对象很多时,标记和清除过程效率都不高;
会产生大量不连续的内存碎片,导致无法给大对象分配内存。

b. 标记整理

Java虚拟机可以运行 java虚拟机实战_学习_06


让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

优缺点:
不会产生内存碎片
需要移动大量对象,处理效率比较低。

c. 标记复制

Java虚拟机可以运行 java虚拟机实战_老年代_07


将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

3. 收集器

Java虚拟机可以运行 java虚拟机实战_jvm_08


上图中标志JDK9的两种组合,在JDK8中已经声明为废弃,并在JDK9中彻底取消。

a. Serial 收集器

Java虚拟机可以运行 java虚拟机实战_老年代_09


Serial意为串行,如上图所示,前半段是Serial收集器的新生代版本,后半段是Serial收集器的老年代版本,该收集器只有一个线程进行垃圾回收,并且在回收过程会暂停用户线程。

b. ParNew 收集器

Java虚拟机可以运行 java虚拟机实战_Java虚拟机可以运行_10


ParNew收集器是Serial收集器的多线程版本,如上图所示,前半段是ParNew收集器,后半段是Serial收集器的老年代版本,ParNew收集器是唯一一个能和CMS合作的新生代收集器,所以随着CMS的“没落”,ParNew收集器已经绑定性质的一起被"隐匿"了。

c. Parallel Scavenge/old 收集器

Java虚拟机可以运行 java虚拟机实战_java_11


Parallel与ParNew一样是多线程收集器,如上图所示,前半段是Parallel Scavenge收集器,后半段是Parallel old收集器,也就是Parallel Scavenge的老年代版本。

其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而该收集器的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。

Java虚拟机可以运行 java虚拟机实战_Java虚拟机可以运行_12


Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio

  • 参数XX:MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。注意该参数并不是越小越好,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的,系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来 10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿 70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • -XX:GCTimeRatio 参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19));默认值为99,即许最大1%(即 1/(1+99))的垃圾收集时间。

另外该收集器可以通过一个开关参数-XX:+UseAdaptiveSizePolicy 打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

d. CMS 收集器

Java虚拟机可以运行 java虚拟机实战_java_13


CMS意为Concurrent Mark Sweep,翻译过来就是并发的标记清除,分为以下四个流程:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:它在整个回收过程中耗时最长,不需要停顿,通过增量更新算法实现。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

CMS是对并发垃圾回收的首次尝试,有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,也就是并发阶段会由于垃圾回收的进行使正常应用程序变慢,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现并发失败(Concurrent Mode Failure),这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余很大空间,却无法找到足够大连续空间来分配当前大对象,不得不提前触发一次 Full GC。为了解决这个问题提供了一个-XX:+UseCMSCompactAtFullCollection 开关参数(默认是开启的,此参数从JDK9开始废弃),用于在CMS不得不进行FullGC时开启内存碎片的合并整理过程,不过因此等待时间会更长;还提供了另外一个参数-XX:CMSFulIGCsBefore-Compaction (此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的FuIlGC后,下一次进入 FullGC前会先进行碎片整理(默认值为0,表示每次进入FullGC时都进行碎片整理)。
e. G1 收集器

Java虚拟机可以运行 java虚拟机实战_学习_14


Java虚拟机可以运行 java虚拟机实战_Java虚拟机可以运行_15


G1(Garbage-First)收集器,意为价值优先收集器,G1 收集器不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的 Eden空间、Survivor空间或者老年代空间。

收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把 Humongous Region作为老年代的一部分来进行看待。

G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记,仅仅只是标记一下GC Roots能直接关联到的对象并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。其中TAMS是指:G1为每一个Region设计了两个名为TAMS(Topat Mark Start)的指针,把Region中的一部分空间划分出来用于并发标记过程中的新对象分配。
  • 并发标记,耗时较长,可与用户线程并行,通过原始快照(SATB)的方法实现。
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收:负责更新 Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的 Region 中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1的特点:

  • 可以指定最大停顿时间
  • 按收益动态确定会收集
  • region布局
  • 从整体来看是基于“标记整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现
  • 相比较CMS来说,内存占用比较高

三、内存分配

1. 内存分配策略
  • 对象优先在 Eden 分配
  • 大对象直接进入老年代,大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
  • 长期存活的对象进入老年代,为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中,-XX:MaxTenuringThreshold 用来定义年龄的阈值。
  • 动态对象年龄判定,虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保,在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
2. Full GC 的触发条件
  • 调用 System.gc(),只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。
  • 老年代空间不足
  • 空间分配担保失败
  • JDK 1.7 及以前的永久代空间不足
  • Concurrent Mode Failure,执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

四、类加载

1. 类的生命周期

Java虚拟机可以运行 java虚拟机实战_java_16


加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时定特性(也称为动态绑定或晚期绑定)。请注意,这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

有且仅有以下六种情况会立即进行初始化操作:

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
a. 使用new关键字实例化对象的时候。
  b. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放人常量池的静态字段除外)的时候。
  c. 调用一个类型的静态方法的时候。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果果类型没有进行过初始化,则需要先触发其初始化。
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatieREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

以上六种触发初始化的行为被称为主动引用,除此之外的都被称为被动引用,有一些常见的被动引用的情况:

  • 通过子类引用父类的静态字段不会触发子类的初始化,对于静态字段,只有直接定义的类会被初始化
  • 通过数组引用类不会触发类的初始化
  • 引用常量不会触发类的初始化
2. 类加载过程

将上一节生命周期的前五个阶段:加载、验证、准备、解析、初始化称为类加载过程

a. 加载

加载过程完成以下三件事:

  • 通过类的全限定名获取定义该类的二进制字节流。
其中二进制字节流可以从以下方式中获取:
  
  从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  从网络中获取,这种场景最典型的应用就是WebApplet。
  运行时计算生成,这种场景使用得最多的就是动态代理技术。
  由其他文件生成,典型场景是JSP应用,由JSP文件生主成对应的Class文件。
  从数据库中读取,有些些中间件服务器把程序安装到数据库中来完成在集群间的分发。
  可以从加密文件中获取,防Class文件被反编译。
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
  • 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。
b. 验证

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

c. 准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,一般为0,使用的是方法区的内存。

实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

类变量被初始化为 0 而常量被初始化为所定义的值。

public static int value = 123;//准备阶段,被初始化为0
public static final int value = 123;//准备阶段,被初始化为123
d. 解析

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

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
e. 初始化

初始化阶段才真正开始执行类中定义的 Java 程序代码。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
初始化就是执行类构造器< clinit>()方法的过程,< clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定,父类中定义的静态语句块的执行要优先于子类。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。比如下面这样会出错:

public class Test {
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // “非法向前引用”
    }
    static int i = 1;
}
3. 类加载器

类加载阶段中的"通过一个类的的全限定名来获取描述该类的二进制字节流"这个动作放到Java虚拟机外部去实现现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为"类加载器"(Class Loader)。

a. 类和类加载器

两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。
这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

b. 双亲委派模型

Java虚拟机可以运行 java虚拟机实战_jvm_17


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

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。

下面详细介绍一下三种类加载器:

  • 启动类加载器(Bootstrap Class Loader):使用 C++ 实现,是虚拟机自身的一部分;这个类加载器负责加载存放在<JAVA_HOME>/lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
  • 扩展类加载器(Extension Class Loader):这个类加载器是是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。
    根据"扩展类加载器"这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展JavaSE的功能,可以直接在程序中使用扩展类加载器来加载Class文件。
  • 应用程序(系统)类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称它为"系统类加载器"。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可可以直接在代码中使用这
    个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是
    程序中默认的类加载器。