区区final和static,竟然隐藏着这么多知识点!

语言: CN / TW / HK

这是我端午节去西湖玩的时候照的照片。那天的天气很善变,早上出门的时候是阴云密布,中午突然就变成了蓝天白云,艳阳高照,到了下午又变成倾盆大雨。

有人说,人的心情、行为等都可能受到环境影响。我不否认这个理论,但我们可以降低环境对我们的影响。天气也好,家庭出身也好,曾经的经历也好,学习、工作环境也好。这些都算是一些客观的环境因素,影响情绪的大概率不是环境本身,而是我们的态度。晴天也好,雨天也罢,你若尝试喜欢,那便是好天气。

正文分割线


是否有默认值?

前段时间群里有个小伙伴抛出来一个问题:Java中final声明的变量,在初始化前,会有默认零值吗?

我看到这个问题时,下意识地想:我不知道,我猜应该无,但写个代码验证不就可以了吗?Show me the code!

我们先来看这样一段代码,尝试在初始化前打印一下,看能不能看到这个final变量的默认值。

class A {    private final static int a;    static {        // 这里编译会报错 // System.out.println(a);        a = 2;        System.out.println(a);   } }

这里第一次打印a变量,在编译的时候就会报错,因为没有初始化a变量。换言之,编译器尽量保证在使用一个final变量之前,这个变量已经进行了初始化

这符合Java对final的设计,final变量一旦赋值,就不再允许修改。所以如果我们这么写也是不行的:

class A {    private final static int a = 0;    static {        System.out.println(a);        // 这里编译会报错        a = 2;        System.out.println(a);   } }

到这里我们可能会有一个猜测:final变量没有零值。

我们又回过头来看看Java的类加载机制。Jvm会在准备阶段为类的静态变量分配内存,并将其初始化为默认值。然后在初始化阶段,对类的静态变量,静态代码块执行初始化操作。

Java类加载机制

那么问题来了,既被final修饰又被static修饰的变量,也会在准备阶段初始化为默认值,然后在初始化再赋值吗?

先说结论:答案是不一定,有些情况会,有些情况不会

内联优化

我们先看直接声明就初始化这种情况:

class A {    private final static int a = 7;    public static int getA() {        return a;   } }

编译再反编译一下:

```

我的文件名是Demo.java

javac Demo.java javap -p -v -c A ```

可以看到这个getA()的反编译结果,编译器已经知道了a=7,并且由于它是一个final变量,不会变,所以直接写死编译进去了。相当于直接把return a替换成了return 7

public static int getA();   descriptor: ()I   flags: ACC_PUBLIC, ACC_STATIC   Code:      stack=1, locals=0, args_size=0         0: bipush        7         2: ireturn     LineNumberTable:       line 21: 0 ​

这其实是一个编译器的优化,专业的称呼叫“内联优化”。其实不只是final变量会被内联优化。一个方法也有可能被内联优化,特别是热点方法。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。

一般来说,内联的方法越多,生成代码的执行效率越高。但是对于即时编译器来说,内联的方法越多,编译时间也就越长,程序达到峰值性能的时刻也就比较晚。有一些参数可以控制方法是否被内联:

内联相关参数

回到最开始的问题,这种能被编译器内联优化的final变量,是会在编译成字节码的时候,就赋值了,所以在类加载的准备阶段,不会给这个变量初始化为默认值。

骗过编译器

那如果编译器在编译期的时候,不知道final变量的值是多少呢?比如给它一个随机数:

class A {    private final static int a;    private static final Random random = new Random();    static {        // 这里编译会报错 // System.out.println(a);        a = random.nextInt();        System.out.println(a);   } }

变量a会不会有一个“默认值”呢?如何去验证这件事呢?验证的思路就是在a赋值之前就打印出来,但编译器不允许我们在赋值前就使用a。那怎么办呢?好办,想办法骗过编译器就行了,毕竟它也不是那么智能嘛。怎么骗?直接上代码。

class A {    final static int a;    static final Random random = new Random();    static {        B.printA();        a = random.nextInt();        B.printA();   } } ​ class B {    static void printA() {        System.out.println(A.a);   } } ​ public class Demo {    public static void main(String[] args) {        // 打印两次,一次为0,一次为一个随机数        A a  = new A();   } }

这段代码很简单,从打印结果我们能看出来,这样就能验证final修饰的变量a,在被初始化前,是被赋值了默认值0的。

反射能修改吗

研究到这,我又有一个问题了:反射能修改final变量的值吗?根据上面的理论,我推测:

  • 如果是被内联优化的变量,那反射改的已经不是原来那个变量了,而是一个“副本”,所有用到这个变量的地方都被直接编译成了常量,所以看起来改不了,或者说改了也用不了。
  • 如果没有被内联优化,那理论上来说应该可以修改。

虽然理论上是这样,但务实的我还是想用代码验证一把,于是我去网上抄了一段代码:

这里注意要用反射final修饰符去掉,可以说是很hack了

``` package com.tianma.sample;

import java.lang.reflect.Field; import java.lang.reflect.Modifier;

public class ChangeStaticFinalFieldSample {

static void changeStaticFinal(Field field, Object newValue) throws Exception {        field.setAccessible(true); // 如果field为private,则需要使用该方法使其可被访问

Field modifersField = Field.class.getDeclaredField("modifiers");        modifersField.setAccessible(true);        // 把指定的field中的final修饰符去掉        modifersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

field.set(null, newValue); // 为指定field设置新值   }

public static void main(String[] args) throws Exception {        Sample.print();                Field canChangeField = Sample.class.getDeclaredField("CAN_CHANGE");        Field cannotChangeField = Sample.class.getDeclaredField("CANNOT_CHANGE");        changeStaticFinal(canChangeField, 2);        changeStaticFinal(cannotChangeField, 3);                Sample.print();   } }

class Sample {    private static final int CAN_CHANGE = new Integer(1); // 未内联优化    private static final int CANNOT_CHANGE = 1; // 内联优化

public static void print() {        System.out.println("CAN_CHANGE = " + CAN_CHANGE);        System.out.println("CANNOT_CHANGE = " + CANNOT_CHANGE);        System.out.println("------------------------");   } } ```

打印结果:

``` CAN_CHANGE = 1 CANNOT_CHANGE = 1


CAN_CHANGE = 2 CANNOT_CHANGE = 1


```

跟猜想是一致的,非常完美~

类加载与单例bug

了解到这,我就突然想起了很早很早之前遇到的一个关于单例模式的问题,也跟类加载有点关系,有点意思。

下面这段话出自于《码出高效 - Java开发手册》,是阿里的孤尽大佬写的。

饿汉单例

这个是一个典型的饿汉单例模式。从我学设计模式的时候,学到的就是饿汉模式非常安全,除了不能懒加载,没啥大的缺点。但书上却说”某些特殊场景“下,返回的单例对象可能为空,这就勾起了我的好奇心了。

我当时绞尽脑汁,写了一些代码去验证这种为空的场景,还真让我给找到了一种。既然饿汉是利用的类加载,那我们知道类加载会在准备阶段先初始化为默认值,然后在初始化阶段再赋值是吧。那关键就在于我们什么情况下会在这个变量初始化前调用getInstance()方法?

循环依赖的时候会,咱们来看看下面这段代码

class A {    public A() {        try {            B b = B.getInstance();            System.out.println(b);            Thread.sleep(1000);            System.out.println("AAA");       } catch (InterruptedException e) {            e.printStackTrace();       }   } } ​ class B {    private static A a = new A();    private static B instance = new B(); ​    public B() {        try {            Thread.sleep(1000);            System.out.println("BBB");       } catch (InterruptedException e) {            e.printStackTrace();       }   } ​    public static B getInstance() {        return instance;   } } ​ public class Demo {    public static void main(String[] args) {        A a = new A();   } }

这段代码有点绕。是一个典型的循环依赖,A依赖B,B又依赖了A。打印结果和过程如下:

```

B在初始化的时候调用A的构造器

null AAA

B初始化

BBB

Demo调用A初始化

B@372f7a8d AAA ```

经过大佬同事的点拨,我们有一个更简单的代码来表述这种场景:

class Config {    public static Config config = Config.getInstance();    private static Config instance = new Config(); ​    public static Config getInstance() {        return instance;   } } ​ public class Demo {    public static void main(String[] args) {        System.out.println(Config.config); // null        System.out.println(Config.getInstance()); // 有值   } }

这段代码很好解释,我们在对Config类进行初始化的时候,先执行第一行代码,由于第二行代码还没执行,所以这个时候Config.getInstance()返回的是null,写进了这个静态变量里。所以无论你后面怎么调用Config.config这个变量,它都会是null

所以底层原理其实都是一样的:使用这个单例的时候,它还没被初始化。我不知道孤尽大佬表达的“某些特殊场景”是不是这个意思,但确实上述场景下,它是可能为空的。

总结

写了这么多,这里总结一下整篇文章涉及到的知识点。

  • final变量是有可能被编译期内联优化的;方法也可能会被JIT内联优化
  • final变量如果没被内联优化,还是会有默认值,可以用“骗过编译器”的方式拿到;
  • 反射可以修改final变量,但如果被内联优化了,那就没啥作用了;
  • 饿汉式单例模式,也可能利用类加载的机制拿到null对象

这些知识看起来是很“底层”的东西,有些同学看了后可能会觉得了解这些没啥用。但其实了解一些底层知识可以给我们代码起一些指导作用,遇到问题也能有一个好的思路去分析,最关键的是,在群里摸鱼的时候,又多了一个谈资,不是吗?

求个支持

我是Yasin,一个坚持技术原创的博主,我的微信公众号是:编了个程

都看到这儿了,如果觉得我的文章写得还行,不妨支持一下。

文章会首发到公众号,阅读体验最佳,欢迎大家关注。

你的每一个转发、关注、点赞、评论都是对我最大的支持!

还有学习资源、和一线互联网公司内推哦