深入理解编译期常量

10/8/2021 JavaJVM

# 什么是编译期常量

我们知道,我们从写java代码开始,到代码执行的时候,中间一共经历四个阶段:

  • 1 新建.java文件 并写代码,这称为编辑期
  • 2 将.java文件编译为.class文件,这称为编译期
  • 3 将.class文件加载到内存 并 生成.class类,这称为加载期
  • 4 通过.class类去创建对象、执行代码,这称为运行期

其中,除了第一个阶段我们能直接干预,剩余三个阶段,都是jvm自己执行的(当然也有黑科技可以人工干预)。

也就是说,第二阶段是 非人工干预的 第一阶段。在这个阶段就能确定的值,我们就称为编译期常量

编译期常量是指: 在编译期就能确定的"常量"

既然编译期常量在第二阶段的编译期就能确定其值,那么即使后面第三阶段和第四阶段不走,对它也没有影响,而类加载就发生在第三阶段,所以: 编译期常量不会触发类加载

那么,怎么确定一个变量是否是编译期常量呢?

有两种方法:

  • 1 通过查看编译后的.class文件,来看此变量是否被ConstantValue修饰,被修饰的就是编译期常量,否则就不是。

比如,我们写如下代码:

public class Hello {
    public final int a = 10000;
    public static final int b  = 10000;
    public final long c = System.currentTimeMillis();
    public static final long d = System.currentTimeMillis();
}
1
2
3
4
5
6

然后通过javac Hello.java得到Hello.class文件,再使用javap -verbose Hello.class来查看字节码(这里只截取部分):

public final int a;
    descriptor: I
    flags: (0x0011) ACC_PUBLIC, ACC_FINAL
    ConstantValue: int 10000  // 有ConstantValue,说明是编译期常量

  public static final int b;
    descriptor: I
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 10000 // 有ConstantValue,说明是编译期常量

  public final long c;
    descriptor: J
    flags: (0x0011) ACC_PUBLIC, ACC_FINAL // 没有ConstantValue,说明不是编译期常量

  public static final long d;
    descriptor: J
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL // 没有ConstantValue,说明不是编译期常量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

有人说了,我用ide写了代码,难不成挨个使用javac去查看是否是编译期常量?肯定不用,我们可以用第二种方法直接判断是否是编译期常量!

  • 2 如果一个变量被final修饰,并且它的值是常量,那么就是编译期常量。

这里有两点,第一就是必须用final修饰,第二是值必须是常量。 比如:

public int a = 10; // 没用final修饰,不是编译期常量
public final int b = System.currentTimeMillis(); // 值不是常量,所以不是编译期常量
1
2

这里有人会问了,我们之前学java的时候,都是说用final修饰的就是常量,为啥这里就不是了呢?

这里有个概念问题,就是常量编译期常量是不一样的。用final修饰的肯定是常量,但这是针对运行期的,准确的说是运行期常量,因为他的特点就是: 运行期不可变!

编译期常量除了在运行期不可变,在编译期也是不可变的,因为在编译期就确定了值。

也就是说: 编译期常量一定是运行期常量,而运行期常量不一定是编译期间常量。或者说: 编译期常量 = 运行期常量 + 值是常量。这个很好理解,被final修饰的就是运行期常量,如果值也是常量,那么就是编译期常量。

# 编译期常量与类加载

现在我们来证明: 编译期常量不会触发类加载

  • 1 从理论上来说,编译期常量的值是在类加载之前确定的,前面的步骤不依赖于后面的步骤,所以不会触发类加载。现在我们用实例证明。

  • 2 从实例证明,我们知道,一个类被加载的时候,会执行它的静态代码块(有疑问的可以回去翻书),那么我们写如下代码:

public static class Hello {
    // a是编译期常量
    public static final long a = 10;

    // 定义静态代码块,来验证是否触发了类加载
    static {
        System.out.println("a is " + a);
    }
}
1
2
3
4
5
6
7
8
9

然后我们来验证:

public static void main(String[] args) {
    // 直接引用即可
    long a = Hello.a;
}
1
2
3
4

我们运行代码,发现没有打印任何信息,这就证明,根本就没有触发类的初始化。

现在,我们将a的final修饰符去掉,如下:

public static class Hello {
    // a 不再 是编译期常量
    public static long a = 10;

    // 定义静态代码块,来验证是否触发了类加载
    static {
        System.out.println("a is " + a);
    }
}
1
2
3
4
5
6
7
8
9

然后运行代码,如下:

a is 10
1

可以看到,触发了类加载。我们继续,这次不去掉final,而是将a的值改为时间戳,让他不再是常量,如下:

public static class Hello {
    public static final long a = System.currentTimeMillis();

    static {
        System.out.println("a is " + a);
    }
}
1
2
3
4
5
6
7

结果如下:

a is 1633683641015
1

可见,也触发了类加载。

其实,如果一个变量中有类变量赋值语句 或者 static代码块,就会生成一个<clinit>方法,这个方法将会在类加载阶段的 初始化子阶段 执行。

  • 注意这里的类变量,指的是static修饰的变量,非static修饰的变量叫做对象变量。
  • 注意这里的赋值语句,而不是初始化语句,请仔细体会。
public int a = 10; // 这是赋值语句,因为存在二次赋值的情况
public final int a = 10; 这是初始化语句
1
2

有疑问的可以看Java类加载机制 (opens new window)

<clinit>方法是由jvm收集类中所有类变量的"赋值语句"和"static块"得到的

那么,非static的呢?非static的变量是属于对象一级的,也就是说,肯定要先new出来对象,才能使用,而new对象就会触发类加载,所以这个问题是没有任何意义的。

# 编译期常量的使用

  • 1 APT技术

如果你从事Android开发,并且你使用了Arouter框架,那么你应该知道,Arouter的@route注解,它的path必须是一个编译期常量。

如果你从事Java开发,并且使用了Spring框架,那么你应该知道,Controller的Mapping注解的path,也必须是一个编译期常量。

有疑问的可以试一下,这里不再废话。

那么为什么呢?因为APT技术工作在编译期,所以必须依赖 同时期 或者 更靠前时期的值,而更靠前时期就是编辑期了,所以只能依赖编译期常量。

  • 2 其他运行在编译期的技术

这个比较宽泛,比如插桩,修改字节码等,都是同样的道理。

# 总结

  • 1 被static修饰的是类一级的,非static修饰的是对象一级的。
  • 2 被final修饰,并且值是常量的,才是编译期常量。
  • 3 类的编译期常量不会触发类加载。
  • 4 对象一级的要先创建对象才能使用,所以肯定会触发类加载(不管是不是编译期常量)。
  • 5 编译期常量不存在赋值语句,只存在初始化语句。
Last Updated: 1/29/2022, 2:35:56 PM