ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java关键字进阶:static、final、volatile、synchronized原理与实战

Java关键字进阶:static、final、volatile、synchronized原理与实战 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、instanceofenum也是class的语法糖但它确实是关键字访问控制public、protected、private三个权限修饰符方法修饰static、final、abstract、native、synchronized、strictfp、default、transient、volatile其中default也可以用在接口方法里类型定义byte、short、int、long、float、double、char、boolean、voidvoid严格说是关键字不是类型流程控制if、else、switch、case、default、for、while、do、break、continue、returndefault在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 ListString 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和并发也有关。JMMJava内存模型里有一条专门针对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包源码会发现AbstractQueuedSynchronizerAQS里大量使用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比如MapString, ListOrderItem这种能用var减少视觉噪音方法返回值、参数、字段一律不用var。这个习惯能让代码在简洁和清晰之间保持平衡。还有一个冷知识Java 9之后下划线_不再允许作为单字符变量名这也是限制性标识符的典型案例。所以看到老代码里用_做循环变量在新版本编译环境下会直接报错。5.4 实操在IDEA里快速定位jar包和源码中的关键字学习关键字不能光看文档你会需要在真实代码里观察它怎么用。这里分享几个IDEA的实用技巧想在整个依赖库里搜某个关键字打开Project窗口里的External Libraries选中目标jar包然后用CtrlShiftF搜索默认范围就限定在jar包内不会被自己的代码干扰。搜索范围要覆盖项目和全部依赖时把搜索的Scope切换成All Places这样源码、依赖jar、反编译class都能搜到。看某个类里哪些成员是static或synchronized打开这个类后按CtrlF12弹出结构视图输入synchronized可以快速定位所有同步方法输入static批量查看静态成员。更硬核的方式是使用Search Structurally结构搜索它可以按语法模式查找代码结构比如搜索某个方法内部调用了System.out.println且方法被synchronized修饰适合做代码审计和规范检查。这些技巧在阅读Spring、JDK源码时非常实用比一层层点开类文件高效得多。写到这里我最大的感触是关键字不是靠背的是靠运行场景印在脑子里的。理解static就去看类加载顺序理解volatile就写一个死循环示例自己跑一遍理解final就试着设计一个真正安全的不可变类。建议你把本篇里的代码示例都复制到本地跑一次加上-XX:PrintGCDetails之类参数观察行为变化实际动手跑一遍之后记下来的东西比反复看十遍理论笔记都扎实。这组进阶笔记后面会顺着语言机制继续往下写但无论讲到哪里关键字始终是所有分析的基石。
返回列表