对 "类加载的时机"的思考

共 11646字,需浏览 24分钟

 ·

2021-03-08 22:00

作者:zzzzbw
来源:SegmentFault 思否社区



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>




点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,扫描下方”二维码“或在“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


浏览 28
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报