1 – 引言
在浏览『深刻了解 Java 虚拟机(第 3 版)』时看到外面的一道代码题目,书中给出了题目的解答。本人对于这个题目拓展的想了几个变式,后果有所差别,为了寻找产生差别的起因又深刻理解了一番。
2 – 类初始化机会
2.1 – 原题
在『深刻了解 Java 虚拟机(第 3 版)』的 7.2 章节 “ 类加载的机会 ”,其代码清单 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!”。对于动态字段,只有间接定义这个字段的类才会被初始化,因而通过其子类来援用父类中定义的动态字段,只会触发父类的初始化而不会触发子类的初始化。1
所以 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…。1
官网列出了很多的编译器优化技术,其中 条件常量流传(conditional constant propagation) 就是造成上文变式一和变式三输入后果的起因。
常量流传是古代的编译器中应用最宽泛的优化办法之一,它通常利用于高级两头示意(IR)。它解决了在运行时动态检测表达式是否总是求值为惟一常数的问题,如果在调用过程时晓得哪些变量将具备常量值,以及这些值将是什么,则编译器能够在编译期间简化常数。2
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(2);
}
}
编译器编译之后:
// Main.class
public class Main {
public static final String A = "1";
public static void main(String[] args) {
String b = "2";
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 的常量值等。1
而符号援用则属于编译原理方面的概念,次要蕴含类、字段、办法信息等,这里就不开展形容了。
4.2 – 动态常量池
(动态)常量池能够比喻为 Class 文件里的资源仓库,它是 Class 文件构造中与其余我的项目关联最多的数据,通常也是占用 Class 文件空间最大的数据我的项目之一,另外,它还是在 Class 文件中第一个呈现的表类型数据我的项目。
(动态)常量池外面存储的数据我的项目类型如下表:
1
动态常量池编译之后就写定在 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()办法。
5 – 结语
《深刻了解 Java 虚拟机》的确是一本很好的书,重复读过几次,每次都有新的播种,这次更是因为本人在打源码的时候的 ” 不小心失误 ” 有了意料之外的播种。
参考
- [1] 深刻了解 Java 虚拟机(第 3 版)
- [2] 编译优化之 – 常量流传入门
- [3] Java 中容易混同的概念:Java 8 中的常量池、字符串池、包装类对象池
- [4] 编译期常量与运行时常量
-
- –
原文地址:《深刻了解 Java 虚拟机》中一题引发的思考