Java代码块进阶:静态块、构造块与同步块的执行时机和线程安全实战

发布时间:2026/10/14 6:33:32
Java代码块进阶:静态块、构造块与同步块的执行时机和线程安全实战 刚学Java那会儿我一直觉得代码块是个可有可无的东西一对花括号而已写不写程序都能跑。直到某天排查线上问题打开日志看到某个配置类莫名其妙被初始化翻代码翻到同事留在静态块里的一行加载逻辑才意识到代码块这东西真正要关心的不是能不能写而是它到底在什么时候执行。这篇是Java进阶系列的第二篇专门聊代码块。语法层面它确实太基础了基础到很多人只记得有{}这回事但它牵扯出来的知识点一点都不基础类加载时机、初始化顺序、同步锁的粒度、单例模式的线程安全全都和代码块有关系。面试里那道父类子类输出顺序的经典题本质上就是在考代码块的执行时机。1. 代码块在Java里是什么先分清一对花括号的几种形态1.1 从一次诡异的类初始化说起先说开头那个线上问题。当时某个服务刚启动日志里出现了一个配置类的初始化记录但这个配置类在整个业务代码里并没有被直接调用。我顺着调用链找发现是另一个工具类在静态块里写了类似加载配置到本地缓存的逻辑而工具类又恰好被启动时的一个探活接口引用了。于是探活接口一调配置类就被顺带初始化了。这就是代码块最容易让人忽略的点代码块本身不会因为写在类里就立刻执行它的执行时机完全取决于花括号放在了什么位置、前面有没有修饰符。Java里的代码块按位置和用途分常规有四种局部代码块方法体内部直接出现的{}只影响块内变量的作用域。构造代码块类体内、方法外的{}每次创建对象时都会执行。静态代码块类体内、方法外的static {}类初始化阶段执行一次。同步代码块synchronized (lock) {}用于并发环境下的临界区控制。如果把方法体、lambda体也算进去那块的形态更多。但平时讨论Java代码块时基本就是上面四种。搞清楚这四种的触发条件和执行次数比死记语法的价值大得多。1.2 局部代码块的本质作用域的边界很多人写Java很久都没单独用过局部代码块因为它看起来确实没带来新功能。但局部代码块有个非常实用的作用它把变量的可见范围限制在了一对花括号里。看这个例子public void process() { { String temp loadData(); System.out.println(temp); } // 到这里 temp 已经不可见了 { String temp 另一个变量; System.out.println(temp); } }如果没有这两层花括号同一个方法里声明两个同名的temp变量编译器直接报错。有了局部代码块两段相互独立、又不希望变量互相干扰的逻辑可以各自拥有一个临时工作区。这种做法不常用但它很好地说明了代码块在编译器眼里是什么花括号是作用域的边界。生活里可以把它理解成临时围挡围挡立起来的时候里面的材料和工具只能在里面用拆掉围挡之后里面的东西就不能随便往外部通道扔。代码块也是一样块内声明的变量出不了块块内逻辑结束变量名也就失效了。这种边界感对读代码的人很重要看到一对花括号就知道这里面的东西是临时的、不打算往外传播的。1.3 从编译结果看代码块最终会去哪要真正理解代码块得稍微看一眼编译器的处理方式。Java里代码块并不是一个运行时结构它只是源代码层面的组织形式。用javap -c反编译一下类文件就会发现局部代码块在字节码里没有存档编译器只把它当作作用域信息用于校验变量名冲突实际生成的指令和普通顺序执行没有区别。构造代码块会被编译器展开复制进每一个构造方法位置在super()调用之后、构造器自己的语句之前。静态代码块会和静态字段的初始化语句一起合并进一个名为clinit的类初始化方法。可以近似理解成// 源代码 public class Demo { static int x; static { x 1; } } // 编译后逻辑上等价于 class Demo { static int x; static void clinit() { x 1; } }所以代码块本身没有增加额外的内存开销也没有增加什么神秘机制它只是把初始化逻辑搬到了不同的执行阶段而已。理解了这一点后面看执行顺序就不会觉得玄学了。2. 三种代码块的执行时机什么时候跑、跑几次决定你怎么用2.1 静态代码块类初始化阶段的一次性执行静态代码块在前面加上static修饰它的最大特点是只在类初始化阶段执行一次。类从加载到可用的过程中有一个阶段叫初始化阶段。触发这个阶段的条件包括new一个对象、调用类的静态方法、访问非编译期常量性质的静态字段、通过反射触发初始化、初始化某个子类时父类还没初始化等。举个反直觉的例子public class VersionConfig { static { System.out.println(VersionConfig 类被初始化了); } static final int FIXED_VERSION 1; static final int RANDOM_VERSION new java.util.Random().nextInt(); }访问VersionConfig.FIXED_VERSION时因为它是编译期常量编译器直接把这个值内联到调用方字节码里根本不会触发类的初始化。而访问RANDOM_VERSION时它的值需要运行时计算不是编译期常量此时才会触发静态块的执行。很多线上日志莫名其妙出现类的初始化记录多半就是这种原因某个类的静态块干了点容易被注意的事比如打印、加载配置、初始化外部SDK然后这个类被另一个类的静态字段或静态方法间接引用于是初始化就被带出来了。静态块不是写在哪里就存在而已它意味着一个明确且唯一的执行时机类第一次被主动使用时。2.2 构造代码块每次new都会执行的隐形构造器前缀构造代码块就是类体内没有static修饰、也没有包在方法里的花括号。它和实例变量的赋值语句一起构成实例初始化过程。关键点有两个。第一每个构造器执行前都会先执行构造代码块。不管类里有多少个重载构造器只要new对象构造代码块就会跑一次。它和构造器的关系有点像所有构造器公共的前缀代码。第二构造代码块和实例变量初始化按源代码顺序执行整体排在super()之后、构造器自己的语句之前。看这个类public class Order { private int a 1; { System.out.println(构造代码块执行当前a a); } public Order() { } public Order(int value) { this.a value; System.out.println(构造器执行a被设为 value); } }new Order(5)输出构造代码块执行当前a1 构造器执行a被设为5因为构造代码块在构造器体之前执行所以打印a时看到的是初始值1而不是参数5。构造代码块的典型用途是让类里所有构造器共享一段初始化逻辑比如统一的字段检查、公共的对象初始化。但现代Java里我更推荐用一个private void init()方法替代至少可读性更强调用关系也更直白。构造代码块真正还活跃的地方是匿名内部类这个后面专门说。2.3 局部代码块把临时变量的生命周期关进笼子里局部代码块的执行时机没有特殊性它就是方法执行到花括号位置时按顺序执行里面的语句执行完就结束。真正有意义的是它带来的生命周期边界。比如方法里有两段互不相干的逻辑第一段用了一组临时变量第二段完全不需要它们。这时用局部代码块包住第一段public void handleOrder(OrderRequest request) { { String orderNo generateOrderNo(request); validateOrderNo(orderNo); } // 后续逻辑用不到 orderNo { BigDecimal amount calculateAmount(request); saveOrder(amount); } }这样读代码的人一眼就能看出orderNo和amount各属于哪一段逻辑也避免了临时变量被后面不小心误用。至于有人说的局部代码块能帮助GC回收这个说法听听就好真正决定一个对象能否被回收的是可达性分析局部代码块只是让变量名失效了槽位上是否仍然持有引用还要看编译器和JVM的具体处理。不要指望靠它来优化内存它的主要价值是可读性和防止误用。2.4 完整初始化顺序父类子类一起new的时候先跑谁代码块最经典的考点是执行顺序。直接上代码class Base { static { System.out.println(1 Base 静态块); } { System.out.println(2 Base 构造块); } Base() { System.out.println(3 Base 构造器); } } class Derived extends Base { static { System.out.println(4 Derived 静态块); } { System.out.println(5 Derived 构造块); } Derived() { System.out.println(6 Derived 构造器); } } public class InitOrderDemo { public static void main(String[] args) { new Derived(); } }输出顺序1 Base 静态块 4 Derived 静态块 2 Base 构造块 3 Base 构造器 5 Derived 构造块 6 Derived 构造器这个顺序背后有两层逻辑。第一层是类初始化初始化一个子类之前必须先确保父类已经初始化所以先输出父类静态块再输出子类静态块。静态块的触发完全取决于类初始化和是否new对象没有直接关系。第二层是实例初始化new一个子类对象时会先调用父类构造器而父类构造器里会先执行父类的实例变量赋值和构造代码块再执行父类构造器体然后回到子类先执行子类的实例变量赋值和构造代码块再执行子类构造器体。如果类里有实例变量初始化语句顺序同样遵守源码书写顺序。比如class FieldOrder { int a 1; { System.out.println(块执行a a); } int b a 1; }创建对象时a1先执行然后构造代码块打印a1最后ba1算出2。如果你把构造代码块写在变量声明之前块里看到的a就是默认值0。这个细节很值得记很多初始化bug都是先执行了块、后赋值字段导致的。3. 同步代码块锁的不再是方法而是锁对象3.1synchronized后面跟的那个对象才是关键Java的同步代码块长这样public void update() { synchronized (lock) { // 临界区 } }lock就是监视器对象。进入同步块之前线程需要先获取这个对象的监视器锁执行完块释放锁。锁的对象决定了互斥的范围。synchronized (this)锁当前实例不同实例之间不互斥。synchronized (SomeClass.class)锁整个类对象该类的所有实例之间互斥。synchronized (lock)锁一个自己定义的对象引用。静态同步方法相当于锁SomeClass.class。如果lock是null进入同步块时会直接抛NullPointerException所以锁对象一定要保证非空且状态稳定。实际项目里我习惯用一个专门的final锁对象来锁代码块public class UserService { private final Object userLock new Object(); private final Object creditLock new Object(); public void changeUserName() { synchronized (userLock) { // 只保护用户名相关操作 } } public void changeCredit() { synchronized (creditLock) { // 只保护积分相关操作 } } }这样做的原因很直接两个不相干的业务操作不需要共享同一把锁否则它们会互相排队等于白白降低并发度。3.2 同步块和同步方法该怎么选临界区越小越好但不能破坏原子性同步代码块相比同步方法的最大优势就是粒度可控。同步方法相当于对整个方法体内所有操作加锁哪怕方法里有一段耗时的IO、一段和共享资源无关的纯计算也会被锁挡住。而同步代码块可以只包住真正需要原子性保护的那几行。看一个简单对比写法保护范围并发影响synchronized修饰整个方法方法内所有代码粒度粗无关逻辑也排队synchronized (this)包住核心逻辑当前实例范围同一实例上所有同步块互斥synchronized (业务锁)包住核心逻辑锁对象范围可以按业务拆成多把锁synchronized (new Object())无意义每把锁都是新的完全失去互斥效果但同步块也不是越小越好。如果两个操作之间存在检查后再更新的依赖比如先判断余额充足再扣款那判断和扣款必须放在同一把锁保护的范围里中间不能有缝隙。不然另一个线程可能在判断之后、扣款之前把余额改掉这就产生了竞态条件。所以选择同步块的设计思路应该是先识别共享资源和操作之间的原子性边界再决定用哪把锁、锁多大的范围。单纯追求锁的代码少不一定安全单纯加锁整个方法又可能损失性能。平衡点一般在只锁真正需要一致的共享状态。3.3 三个常见的低效甚至错误的同步写法我见过不少同步代码块写得有问题这里列三个比较典型的。第一个是锁了一个临时对象public void badLock() { synchronized (new Object()) { // 每次进入都是不同的锁对象等于没锁 } }每次调用都会新建一个Object两个线程拿到的锁对象根本不是同一个synchronized完全失效。锁对象必须是所有竞争线程共享的同一个引用。第二个是锁了一个可重新赋值的字段public class BadLock2 { private Object lock new Object(); public void resetLock() { this.lock new Object(); } public void doSomething() { synchronized (lock) { // 如果另一个线程调用了 resetLock这里可能就在锁不同的对象 } } }字段被重新赋值后之前持有旧锁的线程和之后持有新锁的线程不再互斥。锁对象必须稳定最省心的办法就是final修饰。第三个是锁字符串字面量synchronized (payLock) { // 危险 }字符串字面量在JVM里可能被常量池复用一模一样的payLock在代码里多处出现时大家拿到的是同一个String对象。看起来能互斥但可能误伤其他模块中同样使用这个字符串字面量做锁的代码。而且如果某处用了new String(payLock)它和字面量又不一定是同一个对象锁反而失效。不要用字符串做锁对象。3.4 双重检查锁里的同步代码块为什么必须配volatile同步代码块在单例模式里最经典的用法是双重检查锁public class Singleton { private static volatile Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance null) { // 第一次检查无线程竞争时不用进同步块 synchronized (Singleton.class) { // 锁类对象 if (instance null) { // 第二次检查防止重复创建 instance new Singleton(); } } } return instance; } }第一次检查是为了避免每次获取单例都进入同步块毕竟实例创建好后读一个已初始化的引用不需要加锁。第二次检查是为了防止两个线程同时通过第一次检查后一个线程创建完实例另一个线程又创建一个。这里instance必须声明为volatile否则instance new Singleton()不是原子操作。在临界区内部把引用赋值出去时构造函数里的初始化动作可能还没完全对其他线程可见另一个线程拿到一个半初始化的对象用起来就会出问题。volatile的可见性规则保证了对引用的发布安全。这个例子非常适合说明同步代码块的定位它负责提供互斥但互斥之外还需要正确的内存可见性保障。很多并发bug不是锁没加对而是加了锁却漏了volatile。4. 静态代码块里那些看着对、跑起来就翻车的写法4.1 静态块中对后声明变量的赋值合法读取报错静态代码块里有一个很反直觉的规则给后面声明的静态变量直接赋值是合法的但读取它是编译错误。public class ForwardRef { static { x 1; // 编译通过赋值不算使用 // System.out.println(x); // 编译错误illegal forward reference } static int x 2; // 后面这句会覆盖 x }这段代码执行完后x的值是2不是1。原因在于静态初始化的执行顺序是按源码顺序来的静态块先把x赋成1然后后面的字段初始化又把x赋成2。Java规定不允许读取后声明的变量是为了避免程序员依赖一个还没正式初始化的默认值。比如下面的代码static { System.out.println(value); // 编译错误 } static int value 10;如果不报错很多初学者会以为能打印出10实际上在静态块执行时value只是默认值0。编译器干脆在语法层面禁止这种前向读取避免写出依赖默认值的诡异代码。但赋值不受限制所以会出现上面x1被后面覆盖的情况。写静态块时最好把赋值的顺序理清楚别靠这种后赋值覆盖的技巧。4.2 静态块抛异常从ExceptionInInitializerError到NoClassDefFoundError静态块里如果抛出未检查异常这个异常会被包装成ExceptionInInitializerError。更麻烦的是一旦类初始化失败JVM会认为这个类处于错误状态后续任何触发初始化的操作都可能直接抛NoClassDefFoundError。一个简单例子public class BadInit { static { if (true) { throw new RuntimeException(static init failed); } } }使用Class.forName(BadInit)来触发初始化时先抛ExceptionInInitializerError等这个类被标记为初始化失败后再在同一次JVM生命周期里尝试使用它往往就会变成NoClassDefFoundError。这里我需要强调一个工程上的教训静态块是快速失败的好地方但不是放外部依赖初始化的地方。如果你在静态块里去连数据库、调远程服务、启动线程池一旦失败类就废了而且这种失败发生在类加载阶段排查问题时日志栈往往指向一个和业务操作毫无关系的调用点非常误导人。如果静态块确实要加载资源推荐在块内捕获异常并立刻转换成带有明确语义的ExceptionInInitializerError至少把哪个类初始化失败、为什么失败讲清楚而不是让一个原始IO异常裸奔到类加载过程里。4.3 静态块适合做什么不适合做什么根据执行时机和失败模型我把静态块的适用场景整理成了一张清单。适合做的加载打包进jar里的本地配置文件设置默认值。注册JDBC驱动、SPI实现等需要一次性完成的基础能力。初始化一个不依赖外部服务、只依赖当前类的常量容器。初始化线程安全、只读、确定性的缓存数据。不适合做的连接外部数据库或中间件失败后无法回滚类初始化状态。做耗时几秒甚至更久的启动计算拖慢首次类加载。读取以后可能被刷新的动态配置因为静态块只在类初始化时执行一次不会跟着配置刷新。依赖其他类的静态字段构造复杂关系容易形成类初始化顺序的隐性耦合。核心原则就一条静态块里的逻辑必须确定性、快速、失败时能快速暴露。4.4 静态代码块与单例静态内部类为什么往往更优雅静态块经常被用来实现饿汉式单例public class EagerSingleton { private static final EagerSingleton INSTANCE; static { INSTANCE new EagerSingleton(); } private EagerSingleton() { } public static EagerSingleton getInstance() { return INSTANCE; } }这种写法线程安全因为类初始化由JVM保证只执行一次。坏处是不支持懒加载只要EagerSingleton类被主动使用实例就创建了哪怕还没调用getInstance()。如果想保证线程安全又想懒加载可以用静态内部类public class LazyHolderSingleton { private LazyHolderSingleton() { } private static class Holder { static final LazyHolderSingleton INSTANCE new LazyHolderSingleton(); } public static LazyHolderSingleton getInstance() { return Holder.INSTANCE; } }它的原理就是之前说的类初始化时机Holder类只有在getInstance()里第一次被访问时才会触发初始化静态字段INSTANCE才会创建。类初始化机制天然保证了只执行一次不需要自己加锁。还有一个选择是枚举单例写法更简单也天然抵抗序列化和反射破坏。实际选哪种主要看业务是否真的需要懒加载以及是否需要继承或特殊序列化行为。如果只是需要一个普通的全局唯一对象静态内部类或枚举都是比静态块饿汉更稳的选项。5. 代码块在真实项目里的使用模式从配置加载到可维护性5.1 用静态代码块加载本地配置可用但要接受它的时机约束静态块最现实的一个用途是加载本地配置文件到static final字段。比如public class LocalSettings { static final String DEFAULT_APP_NAME; static final int DEFAULT_PORT; static { Properties props new Properties(); try (InputStream in LocalSettings.class.getResourceAsStream(/settings.properties)) { props.load(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } DEFAULT_APP_NAME props.getProperty(app.name, demo); DEFAULT_PORT Integer.parseInt(props.getProperty(port, 8080)); } }这段代码的好处是字段是final的值在类初始化阶段确定使用方拿到的是完全不可变配置一旦配置文件缺失或格式错误异常在第一次访问配置时立刻抛出来不会拖到业务中段才暴露。但要注意这种模式只适合启动时固定不变的配置。如果系统里存在动态刷新配置的需求比如某个开关在运行期会被远程推送更新那就不该用静态块应该把配置放进一个对象里通过实例方法和动态刷新逻辑管理。静态块的路子走不通因为它只执行一次。5.2 局部代码块在方法拆解中的作用给方法内部画临时分区局部代码块不需要什么高深理论但它在重构老代码时很管用。有些长方法不敢一下拆成多个私有方法因为临时变量和逻辑混在一起这时候可以用局部代码块快速画出分区先让变量生命周期明确起来再进一步做方法抽取。一个常见套路public void importData(ListRawData list) { // 校验阶段 { ListRawData validList filterInvalid(list); if (validList.isEmpty()) { return; } } // 转换阶段 { ListEntity entities convert(list); save(entities); } }这种做法让我在代码评审时更容易看懂方法内部结构阶段和阶段之间用花括号隔开块内的临时变量不会扩散到其他阶段。但说实话这只是过渡手段如果一个方法需要两个以上这种块我更倾向直接拆成两个私有方法。局部代码块的定位是让边界看得见不是替代方法设计。5.3 匿名内部类里的构造代码块双括号初始化甜头远小于代价构造代码块在普通类里已经很少主动用了但在一个场景里仍然频繁出现匿名内部类的实例初始化。典型用法是给集合初始化ListString names new ArrayListString() {{ add(啊); add(吧); }};外层花括号创建了一个ArrayList的匿名子类内层花括号是构造代码块在实例初始化时执行add。看起来非常简洁但代价不小每次执行这段代码都会生成一个新的匿名类对象类元数据增加。匿名内部类会持有外部实例的引用如果这个集合被长期保留可能造成外部对象无法回收。反编译时会看到类似Main$1的类名排查问题时不直观。如果集合被序列化这种内部类集合的序列化行为也会带来意外麻烦。我在实际代码里看到过用双括号初始化一个大HashMap并且长期挂在某类字段上的情况内存分析时发现外部对象被匿名内部类引用怎么都没法回收。从那之后我的态度很明确测试代码里随手用一下可以生产环境别这么写。Java 9以后集合直接用List.of(啊, 吧)又短又安全。5.4 我给自己定的几条代码块使用规矩把各种坑踩过一遍之后我现在写代码时会遵守几条简单的规矩这里分享出来。第一静态块的职责不超过两个要么设置静态常量要么初始化无副作用的锁对象。凡是涉及外部IO、动态配置、启动长任务的内容一律不进静态块。第二构造代码块能不用就不用。如果多个构造器确实需要共享初始化逻辑写一个private void init()方法显式调用代码路径比编译器悄悄把构造块复制进构造器更直观。真正需要构造块的匿名内部类场景也要评估是否换普通类更合适。第三同步代码块的锁对象必须final并且最好在定义锁的地方写一行注释说清楚这把锁保护的是哪组数据。没有注释的锁对象几周之后谁也不记得它当初锁的是什么。第四代码块内不写业务细节太深的逻辑。静态块和构造块本质上都属于初始化阶段里面应该只有让对象/类变得可用的代码如果发现初始化逻辑超过十行立刻把它抽成一个私有方法块内只留调用。很多人觉得代码块是不值得单独拿出来讲的语法糖但回顾我排查过的线上问题不少都出在对执行时机的错误假设上静态块被无关类提前触发、构造块在字段初始化前运行、同步块锁了不稳定的对象。代码块最复杂的地方从来不是写法而是它处在整个初始化链路的哪个位置。把执行时机和锁对象这两件事想明白了再回来看那些看起来多余的花括号你就能立刻说出每一层括号存在的理由。最后再分享一个小习惯。我在本地准备了几段用来验证初始化顺序的小Demo每次想确认某个类加载顺序或者锁对象行为时直接跑一下看输出比翻文档快得多。代码块的知识点非常容易遗忘因为你会觉得我早就知道了但真正在工作里卡住时能帮你的恰恰是这些最基础的执行顺序细节。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询