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

点击↑上方↑蓝色“编了个程”关注我~

7e9d8e93b607997bd8269d8ffb922ee1.png

这是Yasin的第 66 篇原创文章

0c1626d7d67a4ca27c837bd8466791df.png

a8aed993fe1f5198cdcfa4e9e387f291.png

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

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

正文分割线


是否有默认值?

前段时间群里有个小伙伴抛出来一个问题: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会在准备阶段为类的静态变量分配内存,并将其初始化为默认值。然后在初始化阶段,对类的静态变量,静态代码块执行初始化操作。

9ac9bb7b897ebf230a0c2968d0f619d5.png
^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大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。

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

ae854ebd08baa8b6989a83c29adcf0e7.png
^内联相关参数^

回到最开始的问题,这种能被编译器内联优化的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开发手册》,是阿里的孤尽大佬写的。

0f2a2967907b61b2ed44663ce34a791a.png
^饿汉单例^

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

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

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

6c3d6b8ad16bc2156f56633273f404d0.png

eef18b327a691a50b282e80f9e5d6306.png

关于作者

我是Yasin,一个爱写博客的技术人

微信公众号:编了个程(blgcheng)

个人网站:https://yasinshaw.com

欢迎关注这个公众号d493f2370db08796573a786bf5f2653c.png

11fc24fcfae5b66ceb7541d38bbcbda0.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值