1. 先给三种“被赋值”的目标拍张照
很多人写 Java 写了一两年,说起普通成员变量、静态变量、static final 常量的区别,能说出“静态的属于类、非静态的属于对象”“加了 final 就不能改”。但一旦问到底层赋值顺序,比如“static int a = 3 到底是哪一步赋的 3”“final 常量到底有没有默认值 0 这一步”,很多人就开始犹豫了。
这个基础问题其实没有想象中那么“基础”。它会牵扯出 JVM 类加载、对象初始化、字节码指令,甚至多个常见线上 Bug 的排查方向。我在这篇文章里把三条赋值路径完整拆开讲,尽量用实验和反编译结果说话。适合正在学 Java 的初级开发者,也适合准备面试、或者想搞清楚初始化顺序问题的中级开发者。
1.1 实例字段、类字段和常量的“存储位置”完全不同
普通成员变量,真正规范的名字是“实例字段(instance field)”。它存放在 Java 堆中,每个对象一份。也就是说,你 new 十个对象,就有十份这样的普通成员变量。
静态变量,规范叫“类变量(class variable)”。它在 JVM 的方法区或者 JDK 8 以后的元空间中,只有一份,由所有实例共享。严格说,它不属于“对象的内存布局”,而是属于类本身。
static final 常量稍微特殊一点。它仍然在类变量这个位置上,但它一旦被确定,很多情况下已经不再是“运行时赋值”了,而是编译器在编译阶段就把值算好、写进了使用方的字节码里。这里埋了一个巨大的坑,后面单独用一章说。
1.2 “声明、初始化、赋值”要分开理解
在 Java 语法里,int a; 是声明,a = 1; 是赋值,int a = 1; 是声明加初始化。很多人把“初始化”和“赋值”混为一谈,但理解变量赋值全过程时,这两个词必须拆开。
普通成员变量和静态变量即使不手动赋值,也会有一个隐式的“默认值赋值”步骤。JVM 在分配内存时,会把基本数据类型清零,引用类型置为 null,布尔型置为 false。这种“清零”行为不是 Java 语言层面写出来的,而是 JVM 对象分配逻辑的一部分。
一个很反直觉的事实是:int a = 1 这种语句,在字节码层面并不是“分配内存时直接放 1”,而是先分配内存、清零,再在执行某个初始化方法时,用一条赋值指令把 1 写进去。这个“先清零,再手动赋值”的动作,就是理解赋值全过程的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实例字段的真正赋值顺序:默认值、显式赋值、初始化块、构造器依次登场
2.1 我说“a = 10”的时候,到底在给谁赋值
普通成员变量最常见的写法是:
java复制public class OrderDemo {
int a = 10;
}
这行代码看起来很简单,但实际发生时是这样的:
- JVM 执行
new指令,在堆上为 OrderDemo 对象分配内存,把里面的a先写成 0。 - 还没开始执行构造器代码之前,JVM 会执行“实例初始化方法
<init>”。 - 在
<init>方法里,先把Object的构造器调用完,然后再执行字段初始化和初始化块。 - 字段显式赋值
int a = 10,在这个阶段被转换成一条putfield指令执行,把 10 写入a。 - 最后才轮到构造器里的代码。
所以,如果你在构造器里再写一句 a = 20;,那 a 的赋值过程就是“0 -> 10 -> 20”,最终结果是 20。
这里的零值阶段很容易被忽略,但它是真实存在的,也是一个重要的认知升级:并不是声明字段时写了 = 10,那 10 就是它的初始值;对象内存布局里最先出现的是 0,之后才被显式赋值为 10。
2.2 写一个小实验,把顺序打印出来
我建议你亲自跑一遍下面这段代码,光看不如自己验证:
java复制public class OrderDemo {
int a = setValueAndLog("字段显式初始化:a = 10", 10);
int b;
{
b = 20;
System.out.println("初始化块:b = 20");
}
public OrderDemo() {
a = 30;
b = 40;
System.out.println("构造器:a = 30, b = 40");
}
private int setValueAndLog(String message, int value) {
System.out.println(message);
return value;
}
public static void main(String[] args) {
new OrderDemo();
}
}
运行结果是:
text复制字段显式初始化:a = 10
初始化块:b = 20
构造器:a = 30, b = 40
这里有一个值得注意的细节:a = setValueAndLog(...) 调用了一个实例方法。Java 允许在字段初始化表达式里调用实例方法,这看起来没什么问题,但对象还没有完全构造完成,方法内部如果去读取其他实例字段,可能读到默认值,也可能读到后续构造器里改过的新值。这个顺序问题在生产环境里非常容易造出隐蔽 Bug,尽量别这么写。
2.3 多个普通成员变量之间的赋值顺序
如果类里写了多个字段显式初始化,情况是这样的:
java复制public class MultiFieldDemo {
int a = 1;
int b = a + 1;
int c = b + 1;
}
由于字段初始化表达式按书写顺序执行,所以 b 能正常拿到 a 的 1,结果变成 2;c 再拿 b 的 2,结果变成 3。
如果你把顺序反过来:
java复制public class MultiFieldDemo {
int c = b + 1; // 编译报错:非法的前向引用
int b = a + 1;
int a = 1;
}
Java 编译器会直接拒绝这种前向引用。因为它不是在方法里按流程执行逻辑,而是在构造对象的固定阶段执行“字段初始化列表”,编译器无法接受你读取一个还没初始化的字段。想绕过这个限制用别的方式读,会读到一个已经被清零的值,这通常不是你想要的。
3. 静态变量的赋值时机:准备阶段和初始化阶段,不是一个阶段
3.1 类加载里的“准备”和“类初始化”是怎么回事
静态变量看起来比普通成员变量简单,但它背后涉及类加载机制,比实例字段复杂在另一些点上。
JVM 类加载过程有几个阶段,其中与赋值关系最大的是“准备阶段”和“初始化阶段”。
准备阶段,JVM 为静态变量分配内存,并设置默认值。举例:
java复制public class StaticDemo {
static int a = 10;
}
在这个阶段,a 的值是 0,不是 10。为什么?因为准备阶段的职责是“把静态变量所需内存准备好,并且给它一个逻辑上的零值”,大部分 JVM 会在此时做的是分配空间和默认值,并不执行 Java 字节码中给 a 赋 10 的那条复制语句。
到了“初始化阶段”,JVM 才会执行类构造方法 <clinit>。static int a = 10 的赋值逻辑就被编译进了 <clinit> 里,这一步真正执行 putstatic 指令,把 10 写到 a。
所以更准确的表述是:
- JVM 准备阶段:
a从“未分配”变成“有内存”,值被默认赋为 0。 - 类初始化阶段:
a从 0 变成 10。
面试里常见的那道题“static int x = 5 在准备阶段值是多少”,答案不是 5,而是 0。
3.2 静态变量和静态块也是按顺序执行的
如果类里既有静态变量初始化,又有静态块,那么它们的执行顺序完全看书写顺序。
java复制public class StaticOrderDemo {
static int a = 1;
static {
b = a + 1;
System.out.println("静态块:a 已经是 " + a + ",b 被赋成 " + b);
}
static int b = 0;
static int c = b;
}
这段代码最终输出什么?关键在 static int b = 0 写在静态块后面。执行到静态块时,b 还存在默认值 0,然后 static int b = 0 这行又把 b 覆盖成 0,最后 static int c = b 时 b 已经是 0,所以 c 也是 0。
如果我把代码顺序调整成:
java复制public class StaticOrderDemo {
static int a = 1;
static int b = 0;
static {
b = a + 1;
}
static int c = b;
}
那么在静态块执行时,字段初始化已经让 b 变成 0,块里再把 b 改成 2,接下来 c = b 等于 2。
对比一下就会知道,静态变量之间赋值没有魔法,完全是“从上到下”的顺序。
3.3 父子类静态变量赋值的先后:先父后子
类初始化过程中,JVM 有一个强制规则:初始化一个类之前,必须先初始化它的父类。这意味着父类静态变量的赋值动作一定发生在子类静态变量赋值之前。
看这个实验:
java复制class Parent {
static int a = 1;
static {
System.out.println("Parent 初始化,a = " + a);
}
}
class Child extends Parent {
static int b = a + 1;
static {
System.out.println("Child 初始化,b = " + b);
}
}
public class StaticParentDemo {
public static void main(String[] args) {
System.out.println(Child.b);
}
}
输出一定是:
text复制Parent 初始化,a = 1
Child 初始化,b = 2
2
正因为 Child 的类初始化会先触发 Parent 的类初始化,所以 Child.b 的初始化表达式里才能安全引用 Parent.a。这个规则在对象初始化层面也适用:子类构造器会先调用父类构造器,只是对象初始化走的是 <init>,类变量赋值走的是 <clinit>。
4. static final 常量的分水岭:编译期替换还是类初始化阶段赋值
4.1 真正的编译期常量,根本不会出现在类初始化方法里
普通静态变量的赋值铁定进 <clinit>,但加了 final 后就要分情况讨论。比如:
java复制public class ConstantsDemo {
public static final int A = 100;
public static final String NAME = "code";
// 这个不是编译期常量
public static final Integer BOXED_A = Integer.valueOf(100);
// 这个也不是编译期常量
public static final long TIME = System.currentTimeMillis();
}
这里的 A 和 NAME 是编译期常量。它们的特点是:值在编译阶段就能确定,并且满足 JLS 规定的“常量表达式”规则。
对于编译期常量,Java 编译器不会给它们生成类似普通静态变量的赋值语句。JVM 在准备阶段完成后,类的字段表中会带一个 ConstantValue 属性,直接把这个常量值保存在类文件里。甚至在许多实现中,引用方根本不会真正触发对 ConstantsDemo 的类加载和初始化,而是直接把常量值内联到调用方的代码里。
这也是“常量”和“普通静态变量”一个非常明显的区分点:读取普通静态变量,是运行时真的去访问那个类字段;读取编译期常量,则像在代码里写了一个字面量,编译器早就把值替换好了。
4.2 怎么判断一个 static final 字段到底是不是编译期常量
常见判断标准如下:
- 类型必须是基本类型,或者
String。 - 初始化的值必须是常量表达式,不能是方法调用,不能是
new出来的对象,也不能含有随机数或系统时间等运行时输入。
符合标准的例子:
java复制public static final int MAX = 100;
public static final int MAX_PLUS_ONE = MAX + 1;
public static final String TITLE = "示例";
public static final String FULL_NAME = "前缀" + "后缀";
不符合标准的例子:
java复制public static final Integer AGE = Integer.valueOf(30);
public static final long START_TIME = System.currentTimeMillis();
Integer.valueOf(30) 虽然参数是 30,但它是一个“方法调用”,编译器不会把它当常量表达式,所以 AGE 不具备 ConstantValue 属性。真正运行时,它会出现在 <clinit> 里,通过指令调用 Integer.valueOf 把结果写回静态字段。
我用 javap 看到过真实的反编译结果,非编译期常量的 static final 字段在类初始化方法中和普通静态变量一样是 putstatic 指令。这一点非常重要:不是说所有加了 static final 的变量都是零成本、不触发类初始化的。只有编译期常量才有这种待遇。
4.3 编译期常量的“内联替换”,有时候会坑到上线
这里有个很经典的坑,我用一句话描述:
模块 A 有一个常量:
java复制public class Config {
public static final int TIMEOUT = 3000;
}
模块 B 里有一行代码:
java复制System.out.println(Config.TIMEOUT);
如果模块 B 是在模块 A 编译之后才编译的,那么 B 的字节码里很可能直接写死 3000,而不是保存一个“去 Config 类里读取字段”的引用。后续如果模块 A 把超时时间改成 5000,只单独替换模块 A 的 jar,没有重新编译模块 B,那么线上跑的还是 3000。
看起来“改一下常量就能生效”,实际不会,还需要全量编译。很多做组件化或多人并行开发的团队都踩过去。
所以,如果某个数据是配置,后期可能经常调整,不要用 public static final 到处给外部模块引用。更稳妥的办法是定义为普通静态字段,或者通过配置中心下发。如果一个字段确实是亘古不变的技术常量,比如协议版本号里永远不会变的固定值,那用 static final 没问题。
4.4 非编译期 static final:赋值被延迟到类初始化阶段
再认真看一下这段:
java复制public static final Integer BOXED_A = Integer.valueOf(100);
当另一个类和这段代码在同一个类加载器环境里首次读取 BOXED_A 时,JVM 会确保 ConstantsDemo 先完成类初始化。因为在 <clinit> 中,才会执行 Integer.valueOf(100) 并把它写入 BOXED_A。
这里就诞生了一个和编程直觉很不一样的场景:只是读一个 static final 字段,也可能触发整个类里所有静态块的执行,甚至静态块里很耗时的初始化和连接逻辑。如果是普通静态字段,触发类初始化不必多说;但如果一个字段用了 final,很多人会先入为主地以为它一定不触发初始化,这就容易误判。
把编译期常量和运行期常量的判断规则记住,就不会被这种问题绊住。
5. 用 javap 复盘赋值全过程:直接在字节码里找 putfield、putstatic
5.1 构造一个能展示三种赋值的实验类
前面的内容讲了很多概念,我建议你直接做一个反编译实验。首先准备一个类:
java复制public class AssignmentDemo {
int normalField = 1;
static int normalStaticField = 2;
static final int CONST_FIELD = 3;
static final Integer BOXED_FIELD = Integer.valueOf(4);
public AssignmentDemo() {
normalField = 100;
}
}
编译:
bash复制javac AssignmentDemo.java
然后反编译看字节码:
bash复制javap -c -v AssignmentDemo.class
javap 是 JDK 自带的工具,不需要额外安装。-c 会显示方法体里的字节码,-v 会显示类文件里更完整的字段表、常量池等信息。
5.2 在字节码中寻找实例字段的赋值指令
关注 <init> 方法。AssignmentDemo 的构造函数会被编译成一个实例初始化方法 <init>,字节码大致是:
text复制public AssignmentDemo();
descriptor: ()V
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_1
6: putfield #2 // Field normalField:I
9: aload_0
10: bipush 100
12: putfield #2 // Field normalField:I
15: return
这里面能清晰看到好几个信息:
- 第一件事是调用父类
Object.<init>。 - 然后执行
putfield normalField,值是iconst_1,对应代码里的int normalField = 1。 - 最后又执行
putfield normalField,值是bipush 100,对应构造器里的normalField = 100。
从字节码看,普通成员变量要先经过 aload_0 把当前对象引用压栈,再通过 putfield 指令写入指定偏移处的字段。这里没有出现任何“默认值赋值”的指令,因为默认值的零是 JVM 分配对象时直接做掉的,不需要生成到字节码里。
5.3 在字节码里看静态变量和常量字段的差别
再看 AssignmentDemo 的类初始化方法 <clinit>,普通静态字段的赋值会出现在这里:
text复制static {};
descriptor: ()V
Code:
0: iconst_2
1: putstatic #3 // Field normalStaticField:I
4: iconst_4
5: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
8: putstatic #5 // Field BOXED_FIELD:Ljava/lang/Integer;
11: return
这里发生了两件事:
normalStaticField = 2,执行putstatic。BOXED_FIELD,因为是分配包装对象,所以执行了Integer.valueOf,再把返回值写进静态字段。
再看 CONST_FIELD 的字段表部分,它会多出一个 ConstantValue:
text复制public static final int CONST_FIELD;
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 3
注意,在 <clinit> 字节码里你找不到 putstatic CONST_FIELD,因为它的值是直接放在字段表里的 ConstantValue 属性里。准备阶段一结束,这个字段就已经是字面量 3。
这一章揭示了赋值全过程最本质的区别:普通成员变量的赋值是对象创建时通过 <init> 执行的;普通静态变量是在类初始化时通过 <clinit> 执行的;而编译期 static final 常量更特殊,它的赋值早于 <clinit>,或者说属于常量池层面直接写死的值,根本不走复制指令。
6. 我实际开发中踩过的赋值类问题
6.1 局部变量没有默认值,成员变量有,这是最容易理解错的地方
普通成员变量可以有默认值,但方法里的局部变量没有任何默认值。比如:
java复制public class LocalVariableDemo {
public void test() {
int a;
System.out.println(a); // 编译错误,可能尚未初始化变量 a
}
}
原因是,JVM 对局部变量不执行类似对象字段那样的零值初始化。使用前必须明确赋值,否则编译器会报错。
这条规则虽然基础,但它很容易在处理复杂逻辑时冒出来。比如在 if/else 里给局部变量赋值,分支不全时就会出现“可能尚未初始化变量”。很多初学者会把局部变量和成员变量的赋值机制搞混,以为只要声明了就自动是 0 或 null,但在局部变量这里不是这样。
6.2 在构造器里给静态变量赋值,是隐蔽的坏味道
静态变量理论上可以在任何方法中被赋值,包括普通成员方法、构造器甚至静态代码块。但从设计角度,我不建议在构造器里给静态变量赋值。
如果一个静态变量被多个构造器反复覆盖,每次 new 对象都会改掉类级别的状态。这会让对象行为和调用顺序耦合:同一个类创建了两次,第二次可能把第一次通过构造器设置的静态值覆盖掉。除非刻意实现某种缓存逻辑,否则这种写法很容易让代码变得不可预测。
如果一定需要给静态变量做复杂初始化,优先使用静态块或者静态工厂方法。静态块可以保证在类初始化的早期、任意实例被创建之前完成。需要依赖外部参数才能初始化的静态字段,建议用显式的静态初始化方法并在类加载后统一调用,避免散落在构造器里。
6.3 final 实例字段的赋值约束
不是只有 static final 才有讲究,普通的 final 成员变量同样有很强的赋值约束。
final 字段可以是“声明时初始化”,也可以是“构造器里初始化”。如果声明时没给值,它会变成一个空白 final(blank final),强制你必须在每个构造器结束前给它赋值。
看起来自由度高了一点,但这也带来一个约定:在构造器里发生异常提前返回,或者在某个重载构造器里用了 this() 但没把块覆盖完整,编译器都会不通过。
设计不可变对象时,我一般倾向用构造器参数直接初始化所有 final 字段,并配合静态工厂方法,保证入参校验也集中在一起,不在字段初始化表达式里写太多复杂逻辑。
6.4 static final 对象与枚举字段的赋值特殊性
前面反复提到编译期常量只适用于基本类型和 String。如果 static final 指向的是一个对象,比如枚举常量,它的赋值方式就和普通静态字段一致,也是在类初始化阶段进行的。
以枚举为例,写下一个枚举类型:
java复制public enum Color {
RED("红"),
GREEN("绿");
private final String label;
Color(String label) {
this.label = label;
}
}
Java 编译器在为枚举生成字节码时,RED 和 GREEN 本质上是枚举类里的 public static final Color 字段。这些字段显然不是编译期常量,因为 Color 是引用类型。它们在枚举类的 <clinit> 方法里通过 new 创建实例并赋值给枚举字段。
如果你反编译枚举类,会看到类似结构:
text复制static {};
code:
0: new // class Color
3: dup
4: ldc // String 红
6: invokespecial // Method "<init>":(Ljava/lang/String;ILjava/lang/String;)V
9: putstatic // Field RED:LColor;
...
所以枚举的“命名常量”也是经历了类初始化阶段,才被赋值完成的。这在排查枚举类初始化顺序时很有用,尤其是当枚举对象的某个字段依赖另一个枚举字段时,务必以源码声明顺序为准。
6.5 JDK 版本更新对 static final 字段的影响
还有一个容易被忽略的点:不同 JDK 对静态字段的存储位置不太一样。JDK 7 及更早版本中,类变量存储在方法区;JDK 8 以后,类元数据迁移到元空间,普通静态变量的存储位置也随之变化。这个变化对赋值时机影响不大,但如果你使用反射去读取字段偏移量,或者使用某些非标准 API,可能会受到版本影响。
不过在赋值过程这个层面,Java 语言规范和 JVM 规范给出的语义非常稳定。普通成员变量、静态变量、static final 常量的赋值链路,从 JDK 8 到 JDK 21,行为基本保持一致。这也说明这个话题真正值得吃透,因为它有很强的稳定性,不会过几年就失效。
7. 如果只留一条经验:把“赋值”从时间线上拆分理解
我自己带新人的时候,最开始会让他们把记忆点收敛成一张逻辑图:
- 对象创建时,先有内存分配和零值,再按顺序执行显式字段赋值、初始化块,最后才执行构造器里的赋值。
- 类第一次被使用前,先为静态变量分配内存并置零,然后在类初始化阶段,按书写顺序执行静态变量赋值和静态块。
- static final 如果是编译期常量,值在类文件字段表直接写好,不进入
<clinit>;如果是引用类型或方法调用结果,就还是走类初始化阶段。
这张图一旦想清楚,后面理解 Spring 的 Bean 初始化顺序、MyBatis 里的静态工具类初始化、以及各种“静态对象为什么是 null”的问题,都会顺利很多。
我在实际项目里排查过一个非常经典的 Bug:某个工具类里用 static final Map 保存了一份路由配置,但是路由配置在另一个地方会被改掉,结果每次上线后第一次请求偶尔读到空值。后来发现是那个 Map 被定义成了编译期无法确定内容的静态 final 对象,类初始化时还没来得及填充,另一个模块触发读取顺序不对。
这种问题的根源,本质上就是没有区分“static final 类型的常量表达式”和“static final 类型的对象引用”,把两类完全不同的赋值时机混为一谈。搞懂这一课,很多诡异的问题都能从根上想明白。
最后再贡献一条实用技巧:遇到字段初始化相关的问题,不要只靠肉眼读代码,直接在编译后的 class 文件上跑一遍 javap -c -v,看 putfield、putstatic、ConstantValue 三类信息,谁在什么时候赋值,一目了然。这个方法我用了很多年,几乎每一次都能快速定位到问题。
