Java关键字大概是初学者最早认识、又最容易被低估的一批知识。很多朋友学完数据类型和流程控制之后,就把关键字抛到脑后,觉得不过是一堆"有特殊含义的单词"。但真到了面试和啃源码的时候才发现,static、final、volatile、synchronized这几个词随便拎一个出来都能问出半小时的问题,甚至能一路追问到JVM内存模型、类加载流程和对象初始化顺序。这组进阶教程的第一篇放在关键字上,我不想重复教科书里的术语表,而是把面试和日常开发中最容易出问题、最值得深挖的关键字逐个拆开讲。内容适合已经能用Java写业务代码、但还想把基础打扎实的开发者。
1. 48个关键字,真正需要你花时间的没几个
先回答一个基础问题:Java到底有多少个关键字?很多资料里说法不一致,有说48个、50个、53个的,其实都不算错,关键是看你怎么统计。
1.1 先分清三个概念:关键字、保留字、限制性标识符
Java规范里定义的keyword共有50个,其中const和goto是两个保留字(reserved word)。它们之所以存在,是为了和C/C++保持语言习惯上的兼容——虽然Java语法里根本用不到它们,但你不能拿const或goto当变量名、类名或方法名,编译器直接拒绝。所以严格说"Java有48个可用关键字、2个保留字、总共50个关键字"是更准确的说法。
除了关键字,还有一类叫限制性标识符(restricted identifier)。典型代表是Java 9之后的下划线_和Java 10的var。var不是关键字,官方刻意把它定位成"保留类型名",用途是局部变量类型推断。但因为var在声明局部变量时有特殊语义,你不能拿它当类名、接口名、枚举名,这种情况很容易被人拿来当面试陷阱题。理解这三个概念的差异,至少能在面试时把"var是关键字吗"这种问题答得滴水不漏。
1.2 按语义把50个关键字分分类
与其死记单个单词,不如按用途分组。我习惯把关键字分成六类:
| 分类 | 关键字 | 备注 |
|---|---|---|
| 类与对象 | class、interface、enum、extends、implements、new、this、super、instanceof | enum也是class的语法糖,但它确实是关键字 |
| 访问控制 | public、protected、private | 三个权限修饰符 |
| 方法修饰 | static、final、abstract、native、synchronized、strictfp、default、transient、volatile | 其中default也可以用在接口方法里 |
| 类型定义 | byte、short、int、long、float、double、char、boolean、void | void严格说是关键字,不是类型 |
| 流程控制 | if、else、switch、case、default、for、while、do、break、continue、return | default在switch和接口里都有份 |
| 异常与包 | try、catch、finally、throw、throws、package、import | 模块化相关的module、requires等Java 9之后陆续有出现 |
你会注意到,绝大多数关键字在日常开发里出现的频率极低。真正值得在进阶阶段花时间研究的,其实就是static、final、volatile、synchronized、transient、instanceof这几个。别被数量吓到,这就像你学英语不需要先背完整本词典一样,先把高频核心词用透,剩下的遇到再查就够。
2. static:面试命中率最高的关键字,没有之一
static被问得最多,因为它横跨类加载、内存管理、设计模式多个知识点。很多人只知道"静态的东西属于类不属于对象",但深一层的问题就答不上来。
2.1 static修饰成员的本质:生命周期从对象变成类
普通成员变量属于某个具体的对象,每个对象各有一份副本。static成员变量属于类本身,在类加载阶段就已经分配好空间,程序运行期间全局只有一份。打个比方:普通变量像每个人口袋里的零钱,各花各的;static变量像公司前台桌上的公用笔记本,谁来谁写,写的是同一本。
static方法也一样,它在类加载时就已经绑定到类对象上,调用时不需要先创建实例。但也正因为这样,static方法内部拿不到this引用,自然不能直接访问实例字段和实例方法。你要是在static方法里想用实例成员,必须先new一个对象出来,这是很多新手最早踩的坑。
2.2 初始化顺序:静态块到底什么时候执行
静态代码块在类加载阶段执行,而且整个生命周期只执行一次。看下面这个例子:
public class Parent { static { System.out.println("父类静态块"); } { System.out.println("父类实例块"); } public Parent() { System.out.println("父类构造器"); } } public class Child extends Parent { static { System.out.println("子类静态块"); } { System.out.println("子类实例块"); } public Child() { System.out.println("子类构造器"); } } // 执行 new Child() 时的输出顺序: // 父类静态块 // 子类静态块 // 父类实例块 // 父类构造器 // 子类实例块 // 子类构造器这个输出顺序在面试出现的频率极高。背后的逻辑是:创建对象前必须先加载类,加载类时先处理父类再处理子类;加载完成后才进入对象创建阶段,对象创建阶段依然遵循父类优先的初始化链。如果你连续创建两个Child对象,静态块不会再次执行,但实例块和构造器会各执行两次。
顺带提醒一点:静态块里不能访问实例字段,因为类加载阶段根本没有对象存在。如果强行在静态块里引用实例变量,编译直接报错。反过来,实例代码可以访问静态字段,因为对象一定存在于类加载完成之后。
2.3 静态内部类和内存泄漏的关系
static还能修饰内部类。内部类分两种:成员内部类和静态内部类。区别很微妙但很重要:
public class Outer { private String name; public class Inner {} public static class StaticInner {} }成员内部类Inner在编译后会隐式持有外部类Outer.this的引用,也就是说创建一个Inner对象,会连带着让一个Outer对象无法被GC回收。如果你在线程池或回调任务里长期持有某个内部类实例,外部类即使已经没用了,也会被一起钉在内存里,这就是典型的内存泄漏场景。
静态内部类StaticInner则不持有外部类引用,它是完全独立的类,只是借用了外部类的命名空间。所以在写回调、监听器、Handler这类容易长期存活的对象时,优先用静态内部类,必要时配合WeakReference来弱引用外部对象。这个习惯能帮你避免很多线上OOM问题。
2.4 静态导入的坑
static import可以让你直接写方法名而不带类名,比如import static java.lang.Math.max;导入后直接写max(1, 2)。看着省事,但两个坑非常明显:第一,如果两个类都有同名静态方法,会编译冲突;第二,阅读代码的人很难判断max到底来自哪个类,可读性急剧下降。我的建议是只在常量类和非常明确的小工具类上使用静态导入,业务代码里别为了省几个字符牺牲可读性。
3. final的三种身份:别再只说"不可变"
final可能是被误解最严重的关键字。很多人一看到final就脱口而出"不可变",但final和不可变之间隔着好几层细节。
3.1 final变量:编译期常量和运行时常量的区别
final修饰变量,表示引用不可变。对基本类型来说就是值不能改,对引用类型来说就是不能再指向新对象,但对象内部状态怎么变它管不着:
final List<String> list = new ArrayList<>(); list.add("hello"); // 合法,对象的内部状态可以变 list = new ArrayList<>(); // 非法,引用不能重新赋值更值得研究的是编译期常量。当一个字段是static final且直接用字面量赋值时,编译器会把它当作编译期常量处理。比如:
public static final int SIZE = 1024;引用这个常量的代码在编译阶段就会被直接替换成数字1024。这意味着什么?如果你把SIZE改了,但程序里某个依赖它的旧jar包没有重新编译,旧包跑的还是老值。这个现象在大型多模块项目里出现过很多次,改常量没生效,还以为环境有问题。
真正安全的做法是:对外暴露的配置类常量尽量保持稳定,需要动态变化的配置不要写成编译期常量,改用运行时读取或者Spring配置注入。
3.2 final方法和final类的设计考量
final方法不能被子类重写,但可以被重载。从JVM角度看,final方法的调用目标在编译期就确定了,不需要走虚方法表的动态分派,JIT编译器也可以更大胆地做内联优化。所以final方法在性能敏感的底层代码里有一定价值,但对绝大多数业务代码来说,性能差异微乎其微,更像是一种设计声明:这个方法不允许子类改变行为。
final类的典型代表是String、Integer这些基础类型。String设计成final,一方面防止有人写出带破坏性逻辑的String子类,另一方面保证了字符串常量池的安全性和hashCode缓存的有效性。但这引出一个重要概念:final类不等于自动不可变。String内部是用final char[]存数据的,数组本身可变,只是String类所有公开修改方法都会创建新对象,旧数组从不出现在外部,这才是"不可变"的真正含义。如果自己设计不可变类,内部有可变集合字段时,要么做防御性拷贝,要么返回Collections.unmodifiableList包装,否则外部照样能钻空子改数据。
3.3 final字段与JMM可见性规则
final和并发也有关。JMM(Java内存模型)里有一条专门针对final字段的规则:在构造器中给final字段赋值之后,只要这个对象没有发生this逸出(比如构造器里直接把this传给其他线程),其他线程无需同步就能看到final字段的最终值。这是很多并发资料里没讲透的点,也是不可变对象天然线程安全的重要理论基础。
this逸出是这条规则的反例。如果在构造函数里启动线程并把this传递出去,新线程可能在构造函数跑完之前就看到一个半初始化状态的对象,这时候final规则也不保护你。所以写并发代码时,构造器内别启动线程,也别把this传给外部,这是基本原则。
4. volatile与并发关键字:可见性、有序性和原子性的三角关系
并发相关的关键字里,volatile和synchronized是绝对主角。它们解决的是不同层面的问题,混在一起用才对。
4.1 volatile解决的核心问题:可见性和有序性
先看一个经典例子:
public class VolatileDemo { private static boolean running = true; public static void main(String[] args) throws Exception { new Thread(() -> { long count = 0; while (running) { count++; } System.out.println("线程结束: " + count); }).start(); Thread.sleep(1000); running = false; } }这个代码在不少机器上会出问题:主线程把running改成false,子线程可能永远感知不到,循环一直空转。原因在于JMM里线程可以有自己的工作内存,主线程对共享变量的修改不会立刻刷新到子线程可见的位置。给running加上volatile修饰后,每次读写都直接和主内存交互,子线程很快就能看到更新。
volatile还提供有序性保证。编译器、JIT、CPU都可能对指令做重排序,volatile变量读写的周围会插入内存屏障,限制重排序范围。底层在x86上一般只需要StoreLoad屏障,在ARM这类弱内存模型上需要的屏障更多,但Java层的语义是一致的:volatile写之前的普通写不会跑到写之后,volatile读之后的普通读不会跑到读之前。
4.2 volatile不能替代synchronized的三个理由
volatile最大的局限是不保证原子性。拿最典型的count++举例,一行代码在字节码层面是读值、加一、写回三组操作,两个线程同时执行时可能互相覆盖,最后结果偏小。所以计数器场景得用AtomicInteger、LongAdder,或者直接用synchronized。
还有一类复合操作也不安全。比如先判断flag再执行某个逻辑,判断和执行的间隙里其他线程可能改了状态。volatile只管单个变量的可见性和有序性,管不了"读-判断-写"这种流程的原子性。这时候只能上锁。
另外volatile没有互斥能力。两个线程同时写同一个volatile变量,结果是谁后写谁生效,这个覆盖顺序取决于线程调度,业务如果需要强一致结果,volatile无法保证。
我见过不少同学把volatile当作"轻量级锁"来用,这是最危险的理解。正确的归类是:volatile解决的是变量的可见性和重排序问题,锁解决的是临界区的互斥问题,两者是互补关系,不是替代关系。
4.3 synchronized的锁升级路径和DCL单例
synchronized的底层机制在HotSpot里经历大量优化。早期它是纯重量级锁,进临界区要操作系统互斥量,性能很差。现在的实现是分阶段升级的:无锁状态下先尝试偏向锁,只有一个线程反复进入时记录线程ID,省掉CAS开销;一旦出现竞争,偏向锁撤销,升级到轻量级锁,线程通过自旋尝试获得锁,在用户态完成;自旋次数超限或竞争线程过多后,才升级到重量级锁,线程被阻塞挂起。在较新的JDK版本里偏向锁已经默认禁用,因为现代应用的锁竞争普遍比过去激烈,偏向锁撤销的维护成本反而偏高。
synchronized和volatile配合最经典的案例是双重检查锁单例:
private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }为什么这里必须有volatile?因为instance = new Singleton()不是原子操作,它大致分三步:分配内存、初始化对象、引用指向内存。第二步和第三步可能被重排序,如果没有volatile,线程A可能先完成引用赋值但还没执行构造方法,线程B正好读到这个引用,拿到一个半初始化的单例。volatile禁止了初始化和引用赋值之间的重排,DCL才变得安全。
如果你去翻JUC包源码,会发现AbstractQueuedSynchronizer(AQS)里大量使用volatile修饰状态字段,并发框架把volatile的可见性用到了极致。这是volatile在真实工程中最有说服力的应用。
5. 那些你不常写、但面试官总爱问的关键字
除了static、final、volatile、synchronized这几个大热门,还有一批冷门关键字,平时写代码用得少,但面试时总被拿出来当试探深浅的工具。
5.1 instanceof模式匹配:一个关键字顶掉三段代码
从Java 16开始,instanceof支持模式匹配,用完就能直接绑定变量,不用再手动强转:
if (obj instanceof String s) { System.out.println(s.length()); } else if (obj instanceof Integer i) { System.out.println(i + 1); }这段代码的核心价值不是少写两行强制转换,而是避免了"强转后发现类型不对"的运行时隐患。注意匹配变量的作用域被限制在分支内部,出了分支就用不了,这是刻意设计的,防止你拿着一个类型不确定的变量到处用。结合Record和sealed类,instanceof模式匹配可以写出很优雅的代数数据类型风格代码,这是Java向现代语言特性演进的一个标志。
5.2 transient、native、strictfp、assert各有各的分量
transient是序列化场景的关键字。Java标准序列化机制会遍历对象图,把非transient字段写进字节流。被transient修饰的字段会被跳过,反序列化后得到默认值。如果你有密码、密钥这类敏感字段,或者字段值可以从其它字段重新计算出来,就应该标上transient。更彻底的控制是用writeObject和readObject方法手动定义序列化逻辑,transient只是第一步。
native标识方法由本地代码实现。典型例子是Object.hashCode在部分平台上的实现、Thread.currentThread这类底层调用。JVM会通过JNI把native方法转到本地方法库执行,调用时线程会进入本地方法栈。面试时一般问到"JNI是什么、本地方法栈和虚拟机栈的区别"就到底了,如果你能顺手说出UnsatisfiedLinkError是native方法签名找不到实现时的典型异常,就已经超出大多数人的准备范围。
strictfp这个关键字可以算是"时代的眼泪"。早期CPU的浮点运算中间结果可能采用更高精度,导致不同平台上浮点结果不一致,strictfp强制严格IEEE 754语义。但JDK 17之后,浮点运算默认就是严格模式,strictfp变成了空操作,你甚至可以把它从记忆里删掉了,面试时如果被问到这个,如实说明它已经失去实际作用即可。
assert也是大量开发者只闻其名不见其用的关键字。默认情况下断言是关闭的,必须加-ea参数才生效,所以拿它做业务参数校验是完全不靠谱的。断言适合在开发和测试阶段检查内部不变量,比如"这个分支理论上不可能走到",生产环境它静默消失,不影响性能也不影响逻辑。
5.3 var不是关键字,但比关键字更容易坑人
Java 10推出的局部变量类型推断让var成了高频话题。很多人以为var是动态类型,其实它只是编译期的语法糖,本质上一个var声明的局部变量在编译后会被替换成真实类型,运行时没有任何动态性。
var的坑主要在可读性和作用域判断上。一个var result = service.getResult();,你一眼根本看不出result是什么类型,必须鼠标悬停到IDE上才看得见。在lambda表达式或者复杂的泛型嵌套场景里,var会让类型信息变得极其隐晦。我的经验是:局部变量完整类型名特别长时才值得用var,比如Map<String, List<OrderItem>>这种,能用var减少视觉噪音;方法返回值、参数、字段一律不用var。这个习惯能让代码在简洁和清晰之间保持平衡。
还有一个冷知识:Java 9之后,下划线_不再允许作为单字符变量名,这也是限制性标识符的典型案例。所以看到老代码里用_做循环变量,在新版本编译环境下会直接报错。
5.4 实操:在IDEA里快速定位jar包和源码中的关键字
学习关键字不能光看文档,你会需要在真实代码里观察它怎么用。这里分享几个IDEA的实用技巧:
- 想在整个依赖库里搜某个关键字,打开Project窗口里的External Libraries,选中目标jar包,然后用Ctrl+Shift+F搜索,默认范围就限定在jar包内,不会被自己的代码干扰。
- 搜索范围要覆盖项目和全部依赖时,把搜索的Scope切换成"All Places",这样源码、依赖jar、反编译class都能搜到。
- 看某个类里哪些成员是static或synchronized,打开这个类后按Ctrl+F12弹出结构视图,输入"synchronized"可以快速定位所有同步方法,输入static批量查看静态成员。
- 更硬核的方式是使用Search Structurally(结构搜索),它可以按语法模式查找代码结构,比如搜索"某个方法内部调用了System.out.println且方法被synchronized修饰",适合做代码审计和规范检查。
这些技巧在阅读Spring、JDK源码时非常实用,比一层层点开类文件高效得多。
写到这里,我最大的感触是:关键字不是靠背的,是靠运行场景印在脑子里的。理解static就去看类加载顺序,理解volatile就写一个死循环示例自己跑一遍,理解final就试着设计一个真正安全的不可变类。建议你把本篇里的代码示例都复制到本地跑一次,加上-XX:+PrintGCDetails之类参数观察行为变化,实际动手跑一遍之后记下来的东西,比反复看十遍理论笔记都扎实。这组进阶笔记后面会顺着语言机制继续往下写,但无论讲到哪里,关键字始终是所有分析的基石。