暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

深入理解编译期常量

奔波儿灞取经 2021-10-09
628



什么是编译期常量

我们知道,我们从写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();
}

然后通过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,说明不是编译期常量

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

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

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

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

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

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

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

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

编译期常量与类加载

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

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

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

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

    // 定义静态代码块,来验证是否触发了类加载
    static {
        System.out.println("a is " + a);
    }
}

然后我们来验证:

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

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

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

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

    // 定义静态代码块,来验证是否触发了类加载
    static {
        System.out.println("a is " + a);
    }
}

然后运行代码,如下:

a is 10

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

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

    static {
        System.out.println("a is " + a);
    }
}

结果如下:

a is 1633683641015

可见,也触发了类加载。

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

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

有疑问的可以看Java类加载机制

「<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 编译期常量不存在赋值语句,只存在初始化语句。

Reference

[1]

https://juejin.cn/post/7008476801634680869: https://juejin.cn/post/7008476801634680869

[2]

https://juejin.cn/post/7012210233804079141: https://juejin.cn/post/7012210233804079141


文章转载自奔波儿灞取经,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论