对 "类加载的时机"的思考
1 - 引言
在阅读书时看到里面的一道代码题目,书中给出了题目的解答。自己对于这个题目拓展的想了几个变式,结果有所差异,为了寻找产生差异的原因又深入了解了一番。
2 - 类初始化时机
2.1 - 原题
在书中 "类加载的时机",其代码清单7-1有这么一段代码:
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int VALUE = 1;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class Main {
public static void main(String[] args) {
System.out.println(SubClass.VALUE);
}
}
输出的结果是:
书中给出了这个结果的解答:
上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段, 只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
所以 main() 方法里调用 SubClass.VALUE 时实际上调用了 SuperClass.VALUE。而 SuperClass 之前还未被加载过,就触发了加载的过程, 在初始化的时候调用了 SuperClass 里的 static 静态代码块。
2.2 - 变式一
这里把上面代码稍作修改。
public class SuperClass {
static {
System.out.println("SuperClass init");
}
// public static int VALUE = 1;
public final static int VALUE = 1; // 添加一个 final 修饰
}
在其他代码不变的情况下,把 SuperClass.VALUE 增加一个 final修饰符,这时候输出结果是:
和原来的结果不同,"SuperClass init!"和"SubClass init!"都没有输出出来。
对于这个结果,我一开始猜测的是由于 VALUE 字段被 final 修饰,且又是基本数据类型,所以JVM做了一些优化,不通过 SuperClass.VALUE 而是直接引用这个字段的值。
后来看了一下IDEA反编译 Main.class的源码:
// Main.class
public class Main {
public Main() {
}
public static void main(String[] args) {
System.out.println(1);
}
}
Main类在编译的时候直接把 SubClass.VALUE 优化成了值"1"。这和一开始的猜测还是有些出入,Main类不是被JVM在运行时优化的,而是在编译器就直接被优化了。
对于这种情况编译器是依据什么原理优化的,在后面在深入展开,先继续看下一种变式。
2.3 - 变式二
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
// public static int VALUE = 1;
// public final static int VALUE = 1;
public final static Integer VALUE = 1; // 把VALUE改成Integer包装类
}
这次把之前 int 类型的 VALUE 改成 包装类Integer,看一下运行的结果。
这次的结果又输出了"SuperClass init!"。确实,包装类其实就是一种被 final 修饰的普通类,不能像基本数据类型那样被编译器优化,所以就要调用 SubClass.VALUE 而初始化 SuperClass。
2.4 - 变式三
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
// public static int VALUE = 1;
// public final static int VALUE = 1;
// public final static Integer VALUE = 1;
public final static String VALUE = "1"; // 把VALUE改成String
}
这次把 SubClass.VALUE 从 Integer 改成 String,看一下运行的结果:
现在的结果和前面变式一的结果一样了,这让我有点疑惑的。String 和 Integer 不都是包装类吗,为什么可以和基本数据类型一样不会触发 SuperClass 的初始化,难道 String 有什么特殊处理吗?
我还是先去看了一下IDEA反编译的 Main.class 的源码:
// Main.class
public class Main {
public Main() {
}
public static void main(String[] args) {
System.out.println("1");
}
}
确实和变式一的情况一样,编译器直接在编译阶段就把 String 类型的 VALUE 值直接优化了。
3 - 编译器优化技术---条件常量传播
对于上文中变式一和变式三的代码运行结果,只输出了 VALUE 的值而没有输出"SuperClass init!",首要原因就是编译器优化技术。
编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。
OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表。地址:
https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex
官方列出了很多的编译器优化技术,其中 条件常量传播(conditional constant propagation) 就是造成上文变式一和变式三输出结果的原因。
常量传播是现代的编译器中使用最广泛的优化方法之一,它通常应用于高级中间表示(IR)。它解决了在运行时静态检测表达式是否总是求值为唯一常数的问题,如果在调用过程时知道哪些变量将具有常量值,以及这些值将是什么,则编译器可以在编译时期简化常数。
3.1 - 优化常量
简单来说就是编译器会通过一定的算法发现代码中存在的常量,然后直接替换指向它的变量值。例如:
public class Main {
public static final int a = 1; // 全局静态常量
public static void main(String[] args) {
final int b = 2; // 局部常量
System.out.println(a);
System.out.println(b);
}
}
编译器编译之后:
// Main.class
public class Main {
public static final int a = 1;
public static void main(String[] args) {
int b = true;
System.out.println(1);
System.out.println(2);
}
}
3.2 - 优化常量表达式
甚至一些常量的表达式,也可以预先直接把结果编译出来:
public class Main {
public static void main(String[] args) {
final int a = 3 * 4 + 5 - 6;
int b = 10;
if (a > b) {
System.out.println(a);
}
}
}
编译之后:
// Main.class
public class Main {
public static void main(String[] args) {
int a = true;
int b = 10;
if (11 > b) {
System.out.println(11);
}
}
}
3.3 - 优化字符串拼接
还可以编译字符串的拼接,网上经常有一些题目问生成了多少个 String 对象,在JVM虚拟机的层面一顿分析,其实都不正确,编译器直接在编译的时候就优化掉了,根本到不了运行时的内存池。
public class Main {
public static void main(String[] args) {
final String str = "hel" + "lo";
System.out.println(str);
System.out.println("hello" == str);
}
}
编译后的源码,看到 str 直接被替换成了"hello"字符串,且 "hello" == str 为true,所以全程就一个String对象生成。
// Main.class
public class Main {
public static void main(String[] args) {
String str = "hello";
System.out.println("hello");
System.out.println(true);
}
}
小拓展: 很多地方都说多个字符串拼接不能用"+"直接拼接,要用StringBuilder之类的。实际上,即使用"+"也会被编译器优化成StringBuilder的,有兴趣可以自己尝试一下。
3.4 - 编译器条件常量传播带来的风险
虽然编译器优化代码可以提升运行时的效率,但是也会带来一定的风险
3.4.1 - 常量反射失效
虽然一些被 final 修饰的字段编译器会认定其为常量而进行优化,但是Java有反射机制,通过一些奇淫技巧可以更改这些值。但是由于被编译器优化了,可能导致被修改的值不能像预期那样生效。如:
public class Main {
public static final String VALUE = "A";
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Class<Main> mainClass = Main.class;
Field value = mainClass.getField("VALUE");
value.setAccessible(true);
// 去除A的final修饰符
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(value, value.getModifiers() & ~Modifier.FINAL);
value.set(null, "B");
System.out.println(VALUE); // 实际还是输出 "A"
}
}
这段代码虽然一通操作把 VALUE 的值从"A"改成"B"了,但是编译器在编译的时候早就把 System.out.println(VALUE); 替换成 System.out.println("A");
,最后运行结果会和预期不同。
3.4.2 - 部分编译
如果常量和其引用的对象不在一个文件中,当修改常量之后只重新编译常量所在文件,那么未重新编译的文件就会使用旧值。如:
// Constant.java
public class Constant {
public final static String VALUE = "A";
}
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Constant.VALUE);
}
}
假如把 Constant.VALUE 的值修改成"B"然后通过 javac Constant.java 单独编译Constant.java文件,但是 Main 里面输出的值依旧会是"A"。
4 - 常量、静态常量池、动态常量池
4.1 - 常量
常量是指在程序的整个运行过程中值保持不变的量。在Java开发的时候通常指的是被 final 修饰的变量。但从虚拟机的角度看"常量"的定义会有所不同。
在虚拟机中,常量会被存放于常量池中,而常量池中会存放两大类常量: 字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
而符号引用则属于编译原理方面的概念,主要包含类、字段、方法信息等,这里就不展开描述了。
4.2 - 静态常量池
(静态)常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。
(静态)常量池里面存储的数据项目类型如下表:
静态常量池编译之后就写定在class文件里了,可以直接查看字节码来观察其组成结构,如以下代码:
public class Main {
final static String A = "A";
final static int B = 1;
final static Integer C = 2;
public static void main(String[] args) {
System.out.println(A);
System.out.println(B);
System.out.println(C);
}
}
编译之后通过 javap -verbose Main.class 命令查看反编译之后的字节码:
可以发现代码中的 String 和 int 型数据被存储在静态常量池中,Integer就没有。因为前者对应常量池中的"CONSTANT_String_info"和"CONSTANT_Integer_info"类型,而后者相当于普通的对象,只被存储了对象信息。
这就解释了上文中变式一、变式三与变式二结果不同的原因。
4.3 - 动态常量池
运行时常量池(动态常量池)相对于Class文件常量池(静态常量池)的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
<div id="refer-anchor"></div>