java无法加载主类解决方法(类加载过程)
java无法加载主类解决方法(类加载过程)类加载过程的第一个阶段 “加载” 何时开始,《Java 虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。初始化时机加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序开始[1]。而解析阶段不一定,它在某些情况下可以在初始化阶段之后再开始,当运行中的程序真正使用某个符号引用时再去解析它[2],这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)。[1] 例如加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分。说的是不同阶段的开始时间保持着固定的先后顺序。[2] 对于方法而言,如果方法在程序运行之前就有一个可确定的调用版本,且在运行期间不可改变,如静态方法或私有方法,适合在类加载阶段时进行解析,也称静态绑定或前期绑定。
2.3 类加载过程内容导视:
- 类的初始化时机
- 加载
- 验证
- 准备
- 解析
- 初始化
JAVA 虚拟机把描述类的数据从 class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 java 类,这个过程被称作虚拟机的类加载机制。
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
图 2.3-1 类的生命周期
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序开始[1]。而解析阶段不一定,它在某些情况下可以在初始化阶段之后再开始,当运行中的程序真正使用某个符号引用时再去解析它[2],这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
[1] 例如加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分。说的是不同阶段的开始时间保持着固定的先后顺序。
[2] 对于方法而言,如果方法在程序运行之前就有一个可确定的调用版本,且在运行期间不可改变,如静态方法或私有方法,适合在类加载阶段时进行解析,也称静态绑定或前期绑定。
2.3.1 类的初始化时机初始化时机
类加载过程的第一个阶段 “加载” 何时开始,《Java 虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。
但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行 “初始化”(而加载、验证、准备自然在此之前就开始了)。
1)遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
- 创建对象时
- 访问类变量时(被 final 修饰、已在编译时就把值放入常量池的类变量除外)
- 修改类变量时
- 调用类方法时
2)使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main 方法的那个类),虚拟机会先初始化这个主类。
5)当使用 JDK7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
例子如下:
码 2.3.1-1 通过子类访问父类的类变量
class PassiveReference1 {
public static void main(String[] args) {
int i = Sub.i;// 只会初始化 Super 类
}
}
class Super {
static {
System.out.println("父类初始化!");
}
static int i = 3;
}
class Sub extends Super {
static {
System.out.println("字类初始化!");
}
}
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,所以这点取决于虚拟机的具体实现。
对于 HotSpot 虚拟机来说,可通过 -XX: TraceClassLoading 参数观察到此操作是会导致子类加载的。
码 2.3.1-2 通过数组定义引用类
Sub[] subArr = new Sub[1];
System.out.println(subArr);// [Lcom.cqh.loader.Sub;@1b6d3586
不会触发 Sub 类的初始化阶段,但触发一个名为 “[Lcom.cqh.loader.Sub” 的类的初始化阶段,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。
这个类代表了一个元素类型为 com.cqh.loader.Sub 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 public 的 length 属性和 clone 方法)都实现在这个类里。
码 2.3.1-3 访问常量
class PassiveReference2 {
public static void main(String[] args) {
String thank = ConstantClass.THANK;
}
}
class ConstantClass {
static {
System.out.println("constant init!");
}
// 在准备阶段时已赋值,不需初始化;创建对象、调用方法赋值,则会触发初始化
static final String THANK = "Are You OK!";
}
常量的值(仅包括字面量)已在编译阶段存入调用类的常量池中,访问此变量时,不是通过 getfield 等指令访问字段(通过访问 CONSTANT_Fieldref_info 行常量,获取字段位置,进而得到值),而是通过 ldc、 iconst_<i> 等指令直接从常量池中取值(访问 CONSTANT_Integer_info、CONSTANT_String_info 等字面量类型常量获取字面量),没有直接引用到定义常量的类。
接口的加载过程
接口的加载过程与类加载过程稍有不同,接口与类有所区别的是前面讲述的六种 “有且仅有” 需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
接口中不能使用 “static{}” 语句块,但编译器仍然会为接口生成 “<clinit>” 类构造器,用于初始化接口中所定义的常量(不以字面量形式赋值时)。
2.3.2 加载“加载”(Loading)阶段是整个 “类加载”(Class Loading)过程中的一个阶段。在加载阶段,Java 虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在堆内存中生成一个代表这个类型的 java.lang.Class 对象,作为方法区这个类型的各种数据的访问入口。
运行时数据区域面向对象时再讲,Class 反射时再讲。
数组类型的加载
数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的。如果数组的组件类型[1]是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上。
如果数组的组件类型不是引用类型,Java 虚拟机将会把数组标记为与引导类加载器关联。
数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到,如 int[]。
类加载器
类加载器通过一个类的全限定名来获取描述该类的二进制字节流。
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段。
对于任意一个类型,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
比较两个类型是否 “相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
码 2.3.2-1 自定义类加载器
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("com.cqh.loader.TestClassLoader").newInstance();
System.out.println(obj.getClass());// class com.cqh.loader.TestClassLoader
System.out.println(obj instanceof TestClassLoader);// false
JDK 8及之前版本,绝大多数 Java 程序都会使用到以下 3 个系统提供的类加载器来进行加载。
启动类加载器
启动类加载器(Bootstrap Class Loader),使用 C/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 系统变量所指定的路径中所有的类库。
JDK 的开发团队允许用户将具有通用性的类库放置在 ext 目录里以扩展 JavaSE 的功能,在 JDK9,这种扩展机制被模块化带来的天然的扩展能力所取代。
应用程序类加载器
应用程序类加载器(Application Class Loader),这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。
由于应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader 方法的返回值,所以有些场合中也称它为 “系统类加载器”。
它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型
双亲委派模型(Parents Delegation Model)的工作过程:如果一个类加载器收到了类加载的请求,它会把这个请求委派给父类加载器,每个层次的类加载都是如此,因此所有的加载请求最终都到了最顶层的启动类加载器中。
只有当父类加载器无法完成这个加载请求时(搜索范围内没有找到所需的类),才会自己尝试去加载。
图 2.3.2-1 双亲委派模型
码 2.3.2-2 ClassLoader 类中双亲委派模型代码实现
private final ClassLoader parent;
/**
* 加载指定名称的类,可以编写子类覆盖 loadClass 方法破坏双亲委派模型,
* 正常情况下还是重写 findClass 方法
*/
protected Class<?> loadClass(String name boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求的类是否已经被加载过了
Class<?> c = findLoadedClass(name);
// 如果没被加载
if (c == null) {
long t0 = System.nanoTime();
// 尝试让父类加载器加载此类
try {
if (parent != null) {
c = parent.loadClass(name false);
// 如果父类加载器为空
} else {
// 使用启动类加载器加载此类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 到这里说明父类加载器无法完成加载请求,抛出了异常
}
if (c == null) {
// 再调用自己的 findClass 方法进行类加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类。
反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中就会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。 (安全机制禁止以 “java” 开头的包名,即使通过编译,运行时也会抛出 SecurityException)
JDK9 时,扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代,平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoade,现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader。
JDK9 中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。
当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
[1] 组件类型(Component Type),指的是数组去掉一个维度的类型,如 int[][] 的组件类型是 int[] 类型。
元素类型(ElementType),指的是数组去掉所有维度的类型,如 int[][] 的元素类型是 int 类型。
2.3.3 验证验证是连接阶段的第一步,这一阶段的目的是确保 class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
文件格式验证
验证字节流是否符合 class 文件格式的规范,并且能被当前版本的虚拟机处理:
- 是否以魔数 0xCAFEBABE 开头。
- 主、次版本号是否在当前 Java 虚拟机接受范围之内。
- 常量池中是否有不被支持的常量类型(检查常量 tag 标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据。
- class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。
- ...
验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。
这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同...)。
- ...
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。
字节码验证
此阶段极其复杂,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
这阶段对类的方法体(class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于 “在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中” 这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
- ...
如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。[1]
为了避免过多的执行时间消耗在字节码验证阶段中,JDK6 之后的 javac 编译器和 Java 虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到 javac 编译器里进行。
具体做法是给方法体 Code 属性的属性表中新增加了一项名为 “StackMapTable” 的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java 虚拟机就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用时,这个转化动作将在连接的第三阶段 —— 解析阶段中发生。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、<package>)是否可被当
- 前类访问。
- ...
符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
[1] 不可能用程序来准确判定一段程序是否存在 Bug。
假设现在有一个 isRun 方法,可以检测任意方法是否抛出异常;参数是被检测的方法的名称,被执行的方法正常运行返回 true,抛出异常返回 false。
现有一个 paradox 方法:
码 2.3.3-1 悖论
public void paradox(String methodName) {
// 如果被检测的方法没有抛出异常,paradox 就抛出异常
if (isRun(methodName)) {
throw new RuntimeException("与你作对!");
// 如果被检测的方法抛出异常,paradox 就正常运行
} else {
return;
}
}
当被检测的 paradox 方法没有抛出异常时,paradox 方法就会抛出异常;paradox 抛出异常时,则 paradox 应正常运行。
它抛不抛异常都是错,只能是假设错误,根本没有这样的 isRun 方法。与 “理发师只给不给自己理发的人理发” 有异曲同工之妙。
2.3.4 准备此阶段为类变量分配内存,并设置类变量初始值。(我习惯称默认初始化)
如 static int value = 123; value 在准备阶段之后的初始值为 0。
表 2.3.4-1 不同类型的初始值
数据类型 |
初始值 |
byte |
(byte)0 |
short |
(short)0 |
int |
0 |
long |
0L |
float |
0.0f |
double |
0.0d |
boolean |
false |
char |
'\u0000' |
reference |
null |
从概念上讲,类变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在 JDK7 及之后,类变量则会随着 class 对象一起存放在 Java 堆中,这时候 “类变量在方法区中” 就完全是一种对逻辑概念的表述了。
如果此时类字段的字段属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为 ConstantValue 属性所指定的初始值。如 static final int i = 5; 在准备阶段赋值 5。
2.3.5 解析解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。[1]
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。
所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
对同一个符号引用进行多次解析请求是很常见的事情,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。
Java 虚拟机需要保证在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进 Java 虚拟机内存之中。
对于 invokedynamic 指令,上面的规则就不成立了。当碰到某个前面已经由 invokedynamic 指令触发过解析的符号引用时,并不意味着这个解析结果对于其他 invokedynamic 指令也同样生效。
它对应的引用称为 “动态调用点限定符(Dynamically-Computed Call Site Specifier)”,这里 “动态” 的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行,用于动态语言支持。
相对地,其余可触发解析的指令都是 “静态” 的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用和字符串类型字面量进行,分别对应于常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info、CONSTANT_InvokeDynamic_info 和 CONSTANT_String_info。
类或接口的解析
码 2.3.5-1 CONSTANT_Class_info 型常量的结构
CONSTANT_Class_info {
u1 tag;// 常量类型的标志位
u2 name_index;// 指向 CONSTANT_Utf8_info 型常量,可按 UTF-8 解码 bytes 属性值得到全限定名
}
#8 = Class #10 // com/cqh/arr3/Sub
如果某符号引用在类 C 中,那虚拟机将会把符号引用代表的类 D 的全限定名(com.cqh.arr3.Sub)传递给 C 的类加载器去加载这个类 D。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。[2]
如果没有出现任何异常,那么 D 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认 C 是否具备对 D 的访问权限。如果发现不具备访问权限,将抛出 java.lang.IllegalAccessError 异常。
字段的解析
码 2.3.5-2 CONSTANT_Fieldref_info 型常量结构
CONSTANT_Fieldref_info {
u1 tag;// 常量类型的标志位
u2 class_index;// 指向 CONSTANT_Class_info 型常量,解析此常量得到字段所在类或接口
u2 name_and_type_index;// 指向 CONSTANT_NameAndType_info 型常量,可以得到字段名和类型
}
#13 = Fieldref #7.#14 // com/cqh/arr3/Sub.A:I
getfield #13 // Field com/cqh/arr3/Sub.A:I
先对字段所属的类或接口 C 的符号引用(class_index 索引指向的 CONSTANT_Class_info 型常量)解析,如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。
如果解析成功完成,搜索字段的步骤:
1)如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段(在字段表中查找),则返回这个字段的直接引用,查找结束。
2)否则,如果 C 实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3)否则,如果 C 不是 java.lang.Object,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4)否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证(字段表的 access_flags 属性值),如果发现不具备对字段的访问权限,将抛出 java.lang.IllegalAccessError 异常。
在实际情况中,javac 编译器往往会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但 javac 编译器就可能直接拒绝其编译为 class 文件。
码 2.3.5-3 同名字段在接口和父类中
interface Interface {
int A = 0;
}
static class Parent {
public static int A = 3;
}
static class Sub extends Parent implements Interface {}
public static void main(String[] args) {
// 对 A 的引用不明确
System.out.println(Sub.A);
}
码 2.3.5-4 同名字段在接口和父类的接口中
interface Interface {
int A = 0;
}
interface Interface1 {
int A = 1;
}
static class Parent implements Interface1 {}
static class Sub extends Parent implements Interface {}
public static void main(String[] args) {
// 对 A 的引用不明确
System.out.println(Sub.A);
}
方法解析
码 2.3.5-5 CONSTANT_Methodref_info 型常量结构
CONSTANT_Fieldref_info {
u1 tag;// 常量的标志位
u2 class_index;// 方法所在类
u2 name_and_type_index;// 方法名和形参列表
}
#10 = Methodref #7.#11 // com/cqh/arr3/Sub.some:()V
invokevirtual #10 com/cqh/arr3/Sub.some : ()V invokestatic #17 com/cqh/arr3/Sub.some2 : ()V
先对实例方法所属的类或接口 C 的符号引用解析,如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致方法符号引用解析的失败。
如果解析成功,接下来虚拟机将会按照如下步骤进行后续的方法搜索:
1)如果 C 是个接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
2)如果通过了第一步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法(方法表中查找),如果有则返回这个方法的直接引用,查找结束。
3)否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError 异常。
5)否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出 java.lang.IllegalAccessError 异常。
接口方法解析
码 2.3.5-6 CONSTANT_InterfaceMethodref_info 型常量结构
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
#9 = InterfaceMethodref #10.#11 // com/cqh/arr3/Interface.some:(Ljava/lang/String;)V
invokestatic #9 // InterfaceMethod com/cqh/arr3/Interface.some:(Ljava/lang/String;)V
先对接口方法所属的接口 C 的符号引用解析,如果解析成功,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
1)如果 C 是个类而不是接口,抛出 java.lang.IncompatibleClassChangeError 异常。
2)否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
3)否则,在接口 C 的父接口中递归查找,直到 java.lang.Object 类(接口方法的查找范围也会包括 Object 类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4)对于规则 3,由于 Java 的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的 javac 编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
码 2.3.5-7 不兼容的方法
// 类型 com.cqh.arr3.Interface1 和 com.cqh.arr3.Interface2 不兼容;
interface Interface extends Interface1 Interface2 {}
interface Interface1 {
default void some() {
System.out.println("1");
}
}
interface Interface2 {
default void some() {
System.out.println("2");
}
}
5)否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError 异常。
在 JDK9 中增加了接口的私有静态方法,也有了模块化的访问约束,所以从 JDK9 起,接口方法的访问也完全有可能因访问权限控制而出现 java.lang.IllegalAccessError 异常。
方法类型解析
方法类型表示方法句柄接受和返回的参数和返回类型。
码 2.3.5-8 CONSTANT_MethodType_info 型常量结构
CONSTANT_MethodType_info {
u1 tag;
u2 descriptor_index;// 此索引指向 CONSTANT_Utf8_info 型常量,表示方法的描述符
}
#61 = MethodType #21 // (I)V,即形参为 int 类型,返回值为 void 的一个方法。
先对此方法类型所封装的方法描述符中所有的类型符号引用进行解析,在解析过程中如果有任何异常发生,也会当作解析方法类型的异常而被抛出。
解析方法类型的结果是得到一个对 java.lang.invoke.MethodType 实例的引用,它可用来表示一个方法的描述符。
MethodType mt = MethodType.methodType(void.class int.class);// 第一个参数是返回值类型,后面的参数是形参的类型。
方法句柄解析
方法句柄类的实质是将某个具体的方法映射到 MethodHandle 上,通过 MethodHandle 直接调用该句柄所引用的底层方法,实际就是对可执行方法的引用。
码 2.3.5-9 CONSTANT_MethodHandle_info 型常量结构
CONSTANT_MethodHandle_info {
u1 tag;
u1 reference_kind;// 方法句柄的类型,代表执行的指令
u2 reference_index;// 此索引指向某项常量
}
// 方法句柄类型为 6,调用静态方法
// 调用返回值类型为 CallSite 的静态方法 LambdaMetafactory.metafactory
#56 = MethodHandle 6:#57 // REF_invokeStatic
java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;
Ljava/lang/String;Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodHandle;
Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
[3]每个被 Java 虚拟机解析的方法句柄都有一个被称为字节码行为(Bytecode Behavior)的等效指令序列(Equivalent Instruction Sequence),它由方法句柄的类型(reference_kind)值来标识。
reference_kind 项的值必须在 [1,9] 之间,它决定了方法句柄的类型。
指令序列到字段或方法的符号引用被标记为:C.x:T。C 表示字段或方法所属的类或接口,x 表示字段 f 或方法 m 的名称,T 表示字段或方法的描述符。
如 com/cqh/arr3/FieldResolution.i:I、com/cqh/arr3/FieldResolution.some:()V。
表 2.3.5-1 九种方法句柄的类型值和描述
类型 |
描述 |
字节码行为 |
1 |
REF_getField(访问对象实例变量) |
getfield C.f:T |
2 |
REF_getStatic(访问静态变量) |
getstatic C.f:T |
3 |
REF_putField(设置对象实例变量) |
putfield C.f:T |
4 |
REF_putStatic(设置静态变量) |
putstatic C.f:T |
5 |
REF_invokeVirtual(调用实例方法) |
invokevirtual C.m:(A*)T |
6 |
REF_invokeStatic(调用静态方法) |
invokestatic C.m:(A*)T |
7 |
REF_invokeSpecial(调用私有方法和实例初始化方法、super.方法) |
invokespeical C.m:(A*)T |
8 |
REF_newInvokeSpecial(创建对象并调用实例初始化方法) |
new C; dup; invokespecial C.<init>:(A*)void |
9 |
REF_invokeInterface(调用接口方法) |
invokeinterface C.m:(A*)T |
如果 reference_kind 为 1、2、3、4,则 reference_index 指向 CONSTANT_Fieldref_info 类型的常量,表示由一个字段创建的方法句柄。
码 2.3.5-10 访问字段
// 产生一个访问静态字段的方法句柄,此静态字段在 FieldResolution 类,字段名为 i,类型是 int
MethodHandle mh = MethodHandles.lookup().findStaticGetter(FieldResolution.class "i" int.class);
// 访问此静态字段的值
Object invoke = mh.invoke();
System.out.println(invoke);// 33
// 通过反射获取字段
Field field = Sub.class.getDeclaredField("i");
// 打破封装,可以访问 private 修饰的字段
field.setAccessible(true);
// 生成一个方法句柄,授予对反射字段的读取访问权限
// bindTo 方法将某引用绑定到方法句柄的第一个参数,也就是 “this”
MethodHandle methodHandle = MethodHandles.lookup().unreflectGetter(field).bindTo(new Sub());
// 访问 new Sub() 对象的实例变量 i
int i = (int) methodHandle.invoke();
System.out.println(i);// 3
// 生成请求的返回类型的方法句柄,每次调用它时都会返回给定的常量值
MethodHandle constant = MethodHandles.constant(int.class 5);
System.out.println(constant.invoke());// 5
// 访问 Sub 类的 int 类型的实例变量 i
VarHandle vh = MethodHandles.lookup().findVarHandle(Sub.class "i" int.class);
int i = (int) vh.get(new Sub());
如果 reference_kind 为 5、6、7、8,则 reference_index 指向 CONSTANT_Methodref_info 型常量,表示由类的方法或构造函数创建的方法句柄。
码 2.3.5-11 调用构造方法
// 使用无参构造(构造器返回值类型都是 void)生成一个方法句柄
MethodHandle mh = MethodHandles.lookup().findConstructor(FieldResolution.class MethodType.methodType(void.class));
// 创建 FieldResolution 类型的对象,并调用无参的实例初始化方法(init)
FieldResolution invoke = (FieldResolution) mh.invoke();
如果 reference_kind 项的值是 9,则 reference_index 指向 CONSTANT_InterfaceMethodref_info 型常量,表示由接口方法创建的方法句柄。
码 2.3.5-12 调用接口方法
/**
* 为虚方法(通常指实例方法)生成方法句柄,此方法在 Consumer 接口中,
* 方法名为 access,无返回值类型,有一个 int 类型的形参,(this 为隐藏参数)
* 并绑定到某个接口实现类对象。
*/
MethodHandle mh = MethodHandles.lookup().findVirtual(Consumer.class "access"
MethodType.methodType(void.class int.class)).bindTo(new ConsumerImpl());
// 调用实现类对象的 access 方法,传入实参 3
mh.invoke(3);
/*
如果没有调用 bindTo 方法,调用实例方法需要传入对象,放在第一个位置,
实际调用的是 obj.access(param) 方法
invoke(obj param)
调用静态方法使用 findStatic,就不需要 obj 参数
*/
码 2.3.5-13 调用私有方法
/**
* privateLookupIn 方法返回目标类 Sub.class 的 lookup 对象,以模拟所有支持的字节码行为
* 包括私有访问。
*/
MethodHandle some = MethodHandles.privateLookupIn(Sub.class MethodHandles.lookup()).findVirtual(
Sub.class "some" MethodType.methodType(void.class int.class)
).bindTo(new Sub());
some.invoke(3);
码 2.3.5-14 访问私有字段
// 访问 Sub 类的私有实例变量 i
MethodHandle some = MethodHandles.privateLookupIn(Sub.class MethodHandles.lookup()).findGetter(
Sub.class "i" int.class
).bindTo(new Sub());
System.out.println(some.invoke());
解析方法句柄的符号引用 MH(从 CONSTANT_MethodHandle_info 结构得来):
- 设 R 是 MH 中字段或方法的符号引用(从 CONSTANT_Fieldref、CONSTANT_Methodref... 结构中得来)
- C 是 R 所处的类的符号引用
- f 或 m 是 R 所引用的字段或方法的名称
- T 是 R 所引用的方法的返回值类型或字段的数据类型
- A* 是参数类型列表(如果 R 是方法)
先对 MH 中类、字段或方法的符号引用都进行解析,解析过程抛出异常,都看作是解析方法句柄抛出的异常。
解析所有符号引用后,获得一个对 java.lang.invoke.MethodType 实例的引用 o,此引用表示着方法句柄 MH。
如果方法 m 有 ACC_VARARGS 标志(接受可变长参数),则 o 是一个可变元方法句柄,否则 o 是固定元方法句柄[4]。
如果 o 是可变元方法句柄,且 m 的参数列表为空,或参数列表的最后的一个参数不是数组类型,那么方法句柄解析就会抛出 IncompatibleClassChangeError 异常(这表示创建可变元方法句柄失败)。
o 所引用的 java.lang.invoke.MethodHandle 实例的类型描述符是一个 java.lang.invoke.MethodType 的实例,它是之前由方法类型解析时产生的。
调用点限定符解析
码 2.3.5-13 CONSTANT_InvokeDynamic_info 型常量结构
CONSTANT_InvokeDynamic_info {
u1 tag;
u2 bootstrap_method_attr_index;// 此索引指向 BootstrapMethods 属性表集合的第 index 1 项(index 从 0 开始)
u2 name_and_type_index;// 此索引指向 CONSTANT_NameAndType_info 型常量,代表方法名和要实现的接口类型
}
Constant pool:
#8 = NameAndType #9:#10 // access:()Lcom/cqh/arr3/Consumer;
/*
CONSTANT_InvokeDynamic_info 型常量,两个属性分别指向:
#1:BootstrapMethods:1:
#8:方法名:access,要实现的接口名:Consumer,无形参
*/
#17 = InvokeDynamic #1:#8 // #1:access:()Lcom/cqh/arr3/Consumer;
/*
调用静态方法 LambdaMetafactory.metafactory
*/
#56 = MethodHandle 6:#57
/*
REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;
Ljava/lang/String;
Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodHandle;
Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
*/
#63 = MethodType #22 // (I)V
#67 = MethodHandle 6:#68 // REF_invokeStatic com/cqh/arr3/Test.lambda$main$1:(I)V
#68 = Methodref #12.#69 // com/cqh/arr3/Test.lambda$main$1:(I)V
{
53: invokedynamic #17 // InvokeDynamic #1:access:()Lcom/cqh/arr3/Consumer;
}
BootstrapMethods:
// 调用 LambdaMetafactory.metafactory,此方法作为引导方法,参数如下:
/*
Lookup lookup:查找上下文与调用者的访问权限,由 JVM 自动填充
String name:要实现的方法名,InvokeDynamic.NameAndType.Name:“access”
MethodType mt:调用点期望的方法参数的类型和返回值的类型,InvokeDynamic.NameAndType.Type
:“()Lcom/cqh/arr3/Consumer”,无参,返回值类型为 Consumer,代表
invokedynamic 执行完后会返回一个 Consumer 类型的实例。
MethodType mt2:函数对象要实现的方法类型,#63:(I)V,int 类型的形参,返回值类型为 void
MethodHandle mh:一个直接方法句柄 描述在调用时将被执行的具体实现方法,#67
MethodType mt3:函数接口方法替换泛型为具体类型后的方法类型 通常和 mt2 一样
不同的情况为泛型,#63
*/
1: #56 REF_invokeStatic 略
// 引导方法参数
Method arguments:
#63 (I)V
#67 REF_invokeStatic com/cqh/arr3/Test.lambda$main$1:(I)V
#63 (I)V
// 生成的实现接口的内部类
InnerClasses:
public static final #70= #66 of #68;
// Lookup=class java/lang/invoke/MethodHandles$Lookup of
// class java/lang/invoke/MethodHandles
// 实际调用的方法
private static void lambda$main$1(int);
descriptor: (I)V
flags: (0x100a) ACC_PRIVATE ACC_STATIC ACC_SYNTHETIC
Code:
stack=2 locals=1 args_size=1
0: getstatic #22 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iload_0
4: invokevirtual #28 // Method java/io/PrintStream.println:(I)V
7: return
}
每一个 invokedynamic 指令的实例叫做一个动态调用点(dynamic call site), 动态调用点依靠引导方法来链接到具体的方法。
引导方法是由编译器生成,运行期当 JVM 第一次遇到 invokedynamic 指令时 会调用引导方法来将 invokedynamic 指令所指定的名字(方法名,方法签名)和具体的执行代码(目标方法)链接起来,引导方法的返回值永久的决定了调用点的行为。
解析调用点限定符的三个步骤:
- 调用点限定符提供了对方法句柄的符号引用,它作为引导方法(Bootstrap Method)向动态调用点提供服务。解析这个方法句柄是为了获取一个对 java.lang.invoke.MethodHandle 实例的引用。
- 调用点限定符提供了一个方法描述符,记作 TD。它是一个 java.lang.invoke.MethodType 实例的引用。可以通过解析与 TD 有相同的参数及返回值的方法类型的符号引用而获得。
- 调用点限定符提供零至多个静态参数,用于传递与特定应用相关的元数据给引导方法。静态参数只要是对类、方法句柄或方法类型的符号引用,都需要被解析。
在解析调用点限定符的方法句柄符号引用,或解析调用点限定符中方法类型的描述符的符号引用时,或是解析任何的静态参数的符号引用时,任何与方法类型或方法句柄解析有关的异常都可以被抛出。
字符串类型字面量解析
解析字符串字面量时,在字符串常量池中创建一个字符串对象,然后把一个指向字符串对象的引用放置到被解析的常量池入口数据中。
字符串对象必须按照 string_index 索引指向的 CONSTANT_Utf8_info 类型常量的字符顺序组织。
码 2.3.5-14 字符串对象的比较
/*
编译时,常量池中已有 CONSTANT_String_info 型常量,
其 string_index 指向 CONSTANT_Utf8_info 型常量,
其 bytes 经解码后为 “hello”;
解析时,创建对应字符串对象,再将其引用写回常量项
s1、s2 是从同一个 CONSTANT_String_info 型常量中获取的字符串引用;
即 s1、s2 指向同一对象。
*/
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);// true
每个 Java 虚拟机必须维护一张内部列表 StringTable(内部结构是哈希表),列出程序运行中已被 “拘留(intern)” 的字符串对象的引用,保证每个字符串对象在表中只出现一次。
具体拘留过程,可参考调用 intern 方法的说明。
JDK6 及之前,某引用调用 intern 方法时,若列表中已有指向相同的字符串对象(equals)的引用,则直接返回表中引用。
码 2.3.5-15 JDK6 intern
// 新创建的值为 “hello” 的字符串对象,在堆中,s1 是其引用
String s1 = new String("hello");
// 返回列表中值为 “hello” 对象的引用,此对象在解析时已经创建,在字符串常量池中
String s2 = s1.intern();
// 两个引用指向的不是同一个对象
System.out.println(s1 == s2);// false
若列表中没有这样的引用,则在字符串常量池中创建一个值相同的 String 对象,将引用拷贝到列表中,并返回此引用。[5]
/**
* 解析时,创建了值为 “he”、“llo” 的两个字符串对象,其引用拷贝到列表中;
* 在堆中创建了一个值为 “hello” 的字符串对象
*(使用 StringBuilder 拼接,最后调用 toString 方法创建的字符串对象),
* s1 是其引用。
*
* 调用 intern 方法时,列表中无值为 “hello” 的字符串对象的引用,
* 在字符串常量池中创建值为 “hello” 的对象,将引用拷贝到列表,并返回其引用。
*
* 因此 s1、s2 指向的不是同一个对象。
*/
String s1 = new String("he") new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);// false
JDK7 及以后,字符串常量池被移到堆中,intern 方法的实现有所变化。
某引用调用 intern 方法时,之前部分一样...;若列表中没有值相同的对象的引用,将此引用拷贝到列表中,同时返回其引用。
码 2.3.5-16 JDK7 intern
// 在堆中创建了一个值为 “hello” 的对象,s1 是其引用
String s1 = new String("he") new String("llo");
// 列表中无值为 “hello” 的字符串对象的引用,将 s1 引用拷贝到列表中
String s2 = s1.intern();
// 此时 s1、s2 指向同一个对象
System.out.println(s1 == s2);// true
字符串类型字面量什么时候解析
是程序还没运行之前就解析了吗?还是等到字符串被使用前(执行 ldc 指令时)才去解析它?
码 2.3.5-17 何时解析字符串
String s1 = new String("he") new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);// true
System.out.println("hello" == s1);// true
如果在运行之前解析,则创建了值为 “he”、“llo”、“hello” 的字符串对象,对应程序中出现的所有字符串字面量,同时将其引用拷贝到列表中。
s1 指向的值为 “hello” 的字符串对象是后来创建的,s1 调用 intern 方法,此时列表中已有 “hello” 对象引用,s1 == s2 应等于 false。
但很明显不是,所以只能是字符串被使用前才去解析。
码 2.3.5-18 案例1
// s1 指向堆中的对象
String s1 = new String("hello");
// s2 指向字符串常量池中的对象
String s2 = "hello";
System.out.println(s1 == s2);// false
// 创建 String 类型的对象,引用压入栈中
0: new #7 {str} {}
3: dup {str str}
/*
执行此指令,查看第 9 项常量,发现这个 CONSTANT_String_info 型常量还未解析,
创建值为 “hello” 的字符串对象,拘留它,将其引用放入此常量,并将此常量标记为已解析,
把 ldc 操作指令替换成 ldc_quick 操作指令;
“hello” 字符串已被解析,将其引用压入操作数栈
*/
4: ldc #9 {str str "hello"}
/*
调用 String 类的 init 方法完成实例变量的初始化,
如 str.value = "hello".value 等
*/
6: invokespecial #11 {str}
9: astore_1 {} {? str}
/*
第 9 项常量已被解析过了,将此指令替换成 ldc_quick,表示已解析完毕,将 “hello” 引用压入栈
*/
10: ldc #9 {"hello"} {? str}
12: astore_2 {} {? str "hello"}
...
再回过头,执行第一句代码时,在堆中创建了一个字符串对象 str,从常量池提取第 9 项常量的数据,但此常量项还未被解析,在字符串常量池中创建 “hello” 对象,引用拘留在表中,也保存在此项常量,并返回其引用。
然后执行 init 方法,参数是 “hello” 的引用,将 “hello” 对象的各项实例变量值都赋给 str,实例初始化完成后,将 str 的引用赋给 s1。
执行第二句,从第 9 项常量获得 “hello” 的引用,赋给 s2。
s1 与 s2 指向不是同一个对象,返回 false。
码 2.3.5-19 案例2
// 底层是 StringBuilder 类调用 append 完成拼接
String s1 = new String("he") new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);
System.out.println("hello" == s1);
执行 ldc 指令时,字符串型常量未被解析,在字符串常量池中创建了值为 “he”、“llo” 的对象,引用拘留在表中;
执行 new 指令,在堆中创建了值为 “hello” 的对象 str,将其引用赋给 s1;s1 调用 intern 方法,此时表中无值为 “hello” 的引用,将 str 引用添加到表中(JDK7 及以后),并返回 str 引用赋给 s2。
所以 s1 == s2 为 true。
执行 ldc 指令,字符串型常量未被解析,创建了值为 “hello” 对象,准备将引用拘留在列表中时,列表中已有 str 引用,于是把 str 引用保存在常量中,并返回 str 引用。随后访问 “hello” 字符串就不用解析常量,直接从常量取出 str 的引用。
所以 “hello” == s1 为 true,换言之,如果没有调用 intern 方法,返回 false。
// 堆中创建的值为 “hello” 的对象,并未拘留在表中
String s1 = new String("he") new String("llo");
// 在字符串常量池中创建值为 “hello” 对象,随后访问 “hello” 都是返回此对象的引用
System.out.println("hello" == s1);// false
解析其它类型
对于 CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info 型常量而言,自身就包含它们所表示的常量值,可以直接使用,但还有一些实现可能做一些处理,比如小端存储,需要交换字节顺序。[6]
CONSTANT_Utf8_info、CONSTANT_NameAndType_info 型常量永远不会被指令直接引用,它们只有通过其它常量才能被引用,并且在那些引用常量被解析时才被解析。
[1] 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
[2] 如果 D 是一个数组类型,并且数组的元素类型为引用类型,加载此数组元素类型。接着由虚拟机生成一个代表该数组维度和元素的数组对象。
[3] Lookup
lookup 对象是创建方法句柄的工厂。方法句柄在调用时不执行访问检查,而是在创建时执行访问检查。
因此当创建方法句柄时,必须强制执行方法句柄访问限制,这些限制被强制执行的调用类被称为 Lookup 类 。
需要创建方法句柄的 Lookup 类将调用 MethodHandles.lookup 方法为自己创建一个工厂。
当创建 Lookup 工厂对象时,查找类的标识,并安全地存储在 Lookup 对象中。 查找类可以使用 Lookup 对象上的工厂方法来创建访问检查成员的方法句柄。 这包括允许查找类的所有方法,构造函数和字段,甚至是私有的。
CallSite
一个 invokedynamic 指令关联一个 CallSite,将所有的调用委托到 CallSite 的 target(MethodHandle)。
LambdaMetafactory.metafactory 返回 CallSite,再调用 target 方法获取方法句柄。
LambdaMetafactory
public static void main(String[] args) throws Throwable {
String methodName = "accept";
// 获取创建方法句柄的工厂
MethodHandles.Lookup lookup = MethodHandles.lookup();
// 返回值类型为 void,形参为 T(Object) 类型的方法类型
MethodType type = MethodType.methodType(void.class Object.class);
// 获取方法句柄,对应 ConsumerImpl 类中的 accept 方法,方法类型为 type
MethodHandle mh = lookup.findVirtual(ConsumerImpl.class methodName type);
// 实际方法类型:返回值类型为 void,形参为 Integer
MethodType actualType = MethodType.methodType(void.class Integer.class);
// 包含了要实现的接口类型,实际调用方法所处类
MethodType factoryType = MethodType.methodType(Consumer.class ConsumerImpl.class);
// 获取创建代理对象的方法句柄
/*
"toAccept":要实现的方法名
factoryType:包含要实现的接口、实际调用方法所在类,用于返回接口类型的实例
type:要实现的方法类型
mh:具体调用方法对应的方法句柄
actualType:替换泛型为具体类型后的方法类型,通常情况等同 type
*/
MethodHandle mh2 = LambdaMetafactory.metafactory(lookup "toAccept"
factoryType type mh actualType).getTarget();
// 生成实现 Consumer 接口的内部类
Consumer consumer = (Consumer) mh2.invoke(new ConsumerImpl());
consumer.toAccept(3);
}
interface Consumer<T> {
void toAccept(T i);
}
static class ConsumerImpl<T> {
public void accept(T i) {
System.out.println("ConsumerImpl.accept" i);
}
}
[4] 可变元方法句柄在调用 invoke 方法时,参数列表会有装箱动作(JLS),而调用 invokeExact 方法就像没有 ACC_VARAGS 标志一样。
MethodHandle mh = MethodHandles.lookup().findVirtual(Consumer.class "access"
MethodType.methodType(void.class int[].class)).bindTo(new ConsumerImpl());
// 调用 ConsumerImpl 类的 void access(int[]) 方法或 void access(int...) 方法
mh.invoke(new int[] {2 3 4 5});
// 接受一个可变长参数适配器,它的作用是把形参列表中的最后一个数组类型的参数转换成对应类型的可变长度的参数
MethodHandle avc = mh.asVarargsCollector(int[].class);
/*
使用 invokeExact 调用时,适配器将调用目标而不更改参数。
当使用普通的、不精确的 invoke 调用时,如果调用者类型与适配器相同,
则适配器将像 invokeExact 一样调用目标。
否则将适配器将尾随的几个参数转为数组类型类型的参数(装箱),再进行调用。
*/
avc.invoke(2 6 7 1);
// 抛出异常 java.lang.invoke.WrongMethodTypeException:预期 (int[]),结果传递 (int int int int)
// avc.invokeExact(2 6 7 1);
/*
创建一个数组收集方法句柄,它接受给定数量的尾随位置参数并将它们收集到数组类型的参数中。
只需传递给定数量的参数,当被调用时,该适配器替换 arrayLength 个尾随位置参数为数组类型的参数。
比如调用 some(String str int[] arr) 传入实参("zs" 98 97 99),尾随参数 98 97 99 封装
成一个 int 类型的数组,实际传入的实参是("zs" new int[]{98 97 99})
*/
MethodHandle ac = mh.asCollector(int[].class 3);
ac.invokeExact(8 2 6);
[5] 我所指的拷贝是其引用保存的内容,而不是拷贝引用其本身在内存中的地址。
int[] intArr = new int[5];
int i = 3;
// intArr[0] 保存的是 0x00000003
intArr[0] = i;
Object[] objArr = new Object[5];
/*
* 假设 obj 保存的是 0x12345678,不管是对象地址,还是句柄地址,反正能通过此地址
* 定位到对象。
*/
Object obj = new Object();
// objArr[0] 保存的是 0x12345678
objArr[0] = obj;
[6] 大端表示法(Big-Endian):高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
小端表示法:低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
读取数据时从低地址端到高地址端。
如:存储整数 43981,对应十六进制 0xABCD,一个格子存储不下,拆成 0xAB、0xCD 后再存储。0xAB 是高位字节,0xCD 是低位字节。
大端表示法,|0xAB|0xCD|,先读取高位 0xAB,可以直接判断正负。(最高位是符号位,二进制表示下 0 代表 正,1 代表 负)
小端表示法:|0xCD|0xAB|,在强制转换时不需要调整字节内容,直接把前 2 个字节 0xCD 赋给小容量。
CPU 做数值运算时从内存中依顺序依次从低位到高位取数据进行运算,直到最后刷新最高位的符号位,这样的运算方式会更高效。
1899
1232
= 从高位 1 1 开始计算比较难,需要考虑进位,从低位 9 2 开始计算容易。
取出两个字节后,计算 0xAB << 8 | 0xCD 得到原值。
参考文献
- https://blog.csdn.net/weixin_57907028/article/details/117367380
- https://zhuanlan.zhihu.com/p/360037797
初始化是类加载过程的最后一个阶段。执行类初始化方法 <clinit> 的过程。
<clinit> 方法是由编译器自动收集类中的所有类变量在声明时的赋值动作与静态语句块(static {})中的语句合并产生的。
编译器收集的顺序由语句在源文件中出现的顺序决定。
码 2.3.6-1 非法前向引用变量
/*
准备阶段时,设置类变量 i 默认值 0;
初始化阶段,
<clinit> 方法执行顺序:
i = 234;
i = 4;
System.out.println(i);
使用阶段
调用 main 方法(调用 main 方法前必须先初始化此类)
*/
class Clinit {
static {
i = 234;
// 可以赋值,但不能访问:“非法向前引用”
// System.out.println(i);
}
static int i = 4;
static {
System.out.println(i);
}
public static void main(String[] args) {}
}
由于初始化子类前会初始化父类,所以在子类的 <clinit> 方法执行前,父类的 <clinit> 方法已经执行完毕。
如果一个类中没有静态语句块,也没有在声明时给类变量赋值,则不会生成 <clinit> 方法。
接口中不能使用静态语句块,但接口与类一样都会生成 <clinit> 方法,执行接口的 <clinit> 方法不需要先执行父接口的 <clinit> 方法;
因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit> 方法。
码 2.3.6-2 实现类或子接口初始化不会导致父接口初始化
class Test {
public static void main(String[] args) {
// 子类初始化
System.out.println(new InterfaceImpl());
}
}
interface Interface {
public final static int i = some();
public static int some() {
System.out.println("接口初始化");
return 2;
}
}
class InterfaceImpl implements Interface {
static {
System.out.println("子类初始化");
}
}
Java虚拟机必须保证一个类的 <clinit> 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit> 方法[1],其他线程都需要阻塞等待,直到活动线程执行完 <clinit> 方法。
如果在一个类的 <clinit> 方法中有耗时很长的操作,那就可能造成多个进程阻塞。
[1] 需要注意,其他线程虽然会被阻塞,但如果执行 <clinit> 方法的那条线程退出 <clinit> 方法后,其他线程唤醒后则不会再次进入 <clinit> 方法。在同一个类加载器下,一个类型只会被初始化一次。
<init> 方法内可以有多个线程同时进入。