区区final和static,竟然隐藏着这么多知识点!
这是我端午节去西湖玩的时候照的照片。那天的天气很善变,早上出门的时候是阴云密布,中午突然就变成了蓝天白云,艳阳高照,到了下午又变成倾盆大雨。
有人说,人的心情、行为等都可能受到环境影响。我不否认这个理论,但我们可以降低环境对我们的影响。天气也好,家庭出身也好,曾经的经历也好,学习、工作环境也好。这些都算是一些客观的环境因素,影响情绪的大概率不是环境本身,而是我们的态度。晴天也好,雨天也罢,你若尝试喜欢,那便是好天气。
正文分割线
是否有默认值?
前段时间群里有个小伙伴抛出来一个问题: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会在准备阶段为类的静态变量分配内存,并将其初始化为默认值。然后在初始化阶段,对类的静态变量,静态代码块执行初始化操作。
那么问题来了,既被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,一个坚持技术原创的博主,我的微信公众号是:编了个程
都看到这儿了,如果觉得我的文章写得还行,不妨支持一下。
文章会首发到公众号,阅读体验最佳,欢迎大家关注。
你的每一个转发、关注、点赞、评论都是对我最大的支持!
还有学习资源、和一线互联网公司内推哦