关于java:深入理解Java虚拟机中一题引发的思考

2次阅读

共计 5987 个字符,预计需要花费 15 分钟才能阅读完成。

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.VALUEInteger 改成 String,看一下运行的后果:

当初的后果和后面变式一的后果一样了,这让我有点纳闷的。StringInteger 不都是包装类吗,为什么能够和根本数据类型一样不会触发 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 命令查看反编译之后的字节码:

能够发现代码中的 Stringint 型数据被存储在动态常量池中,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 虚拟机》中一题引发的思考

正文完
 0