一个流传已久的传说

在中文 Java 圈子里有这样一段传说:Google(或某家硅谷大厂)有一种「Java 条件编译」技术,可以在同一份源码里写「测试开关打开时才走的代码」,非生产流水线编译时整段代码都在,可以照常调试;上了生产、关掉开关,被 if 包围的那段 Java 代码就会被编译器当成死代码,从字节码里直接抹掉,线上绝对跑不到。

这段传说混杂了三个独立的问题:

  1. Java 是否真的存在「if 块在生产编译时从字节码消失」这种机制?
  2. 这是 Google 独有的私货,还是标准 Java 的能力?
  3. 如果代码真的被编译器去除,那调试还能怎么做?

回答它们需要三种证据:JLS §14.21§13.4.9§15.28 的规范条文给出语言契约;一组可复现的 javap 字节码实验给出实证;feature switch 工业谱系(javac 死代码消除、BuildConfig.DEBUG + R8、Manifold 预处理器、AspectJ 类加载织入、OpenFeature 运行时开关、HotSpot C2 运行时死分支折叠)给出工程参照系。

文中代码示例基准 Java 8,字节码以 javac --release 8 产出为准。提到的 JLS 章节号均指 Java Language Specification, Java SE 8 Edition

JLS §14.21:if (false) 的特权

第一个事实可能反直觉:在 Java 里,while (false) { ... }编译错误,但 if (false) { ... } 完全合法。

实测(OpenJDK 21,--release 8):

1
2
3
UnreachableLiteral.java:3: 错误: 无法访问的语句
while (false) {
^

这不是 javac 的实现偏好,而是 JLS §14.21 Unreachable Statements 写死的规则。规范在那一节专门为 if 语句开了一个口子,原文写道(节录):

The rationale for this differing treatment is to allow programmers to define “flag variables” such as:

    static final boolean DEBUG = false;

and then write code such as:

    if (DEBUG) { x=3; }

The idea is that it should be possible to change the value of DEBUG from false to true or from true to false and then compile the code with no other changes to the program text.

翻译这段话的关键不在于「JLS 允许 if (false)」,而在于规范本身明文承认了 Java 的「条件编译」用途:让程序员通过翻转一个 static final boolean 常量,仅靠重新编译(不改其他代码)就在「带调试块」和「不带调试块」两种产物之间切换。所以那个传说里「Java 有条件编译机制」的核心并非民间附会,而是 JLS 明确兜底的语言契约。

但规范的承诺有一个严格前提:if 的条件表达式必须是编译时常量表达式(compile-time constant expression)。这条规则来自 JLS §15.28 Constant Expression 和 §13.4.9 final Fields and Constants 的联合定义——简化讲,常量表达式只能由字面值、其他常量、final 基本类型/String 变量在编译期已知的运算组成。最常见的承载体就是 static final boolean DEBUG = ...;

[PATTERN] 编译时常量是 javac 死代码消除的唯一钥匙if (someBoolean)if (DEBUG) 在源码层看着一样,编译后字节码可能差出整段——差别不在 if,而在条件表达式有没有被识别为 JLS §15.28 意义上的常量。

字节码实证:一组四组对照

光看 JLS 条文不够,必须用 javap -c 实证。下面是一组可复现的对照实验。

实验一:DEBUG = false,同文件 static final

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CondCompile {
static final boolean DEBUG = false;

public static void main(String[] args) {
if (DEBUG) {
System.out.println("debug-only branch");
int x = computeExpensive();
System.out.println(x);
}
System.out.println("always runs");
}

static int computeExpensive() {
return 42;
}
}

javac --release 8 CondCompile.java && javap -c CondCompile

1
2
3
4
5
6
public static void main(java.lang.String[]);
Code:
0: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #15 // String always runs
5: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

关键观察:

  • 整个 if完全消失——既没有 getstatic DEBUG,也没有 ifeq 跳转。字节码里只剩 "always runs" 这一句的指令序列。
  • computeExpensive() 方法在 class 文件里还在(仍然被 javap 列出),但没有任何调用方。这一点很重要:javac 只做最朴素的死代码消除,它不删除「无调用方」的方法。要把死方法也剪掉,得靠后面会讲的 ProGuard / R8 / GraalVM Native Image 的 tree-shaking。

实验二:DEBUG = true

把上面的 false 改成 true 重新编译,main 的字节码变成:

1
2
3
4
5
6
7
8
9
public static void main(java.lang.String[]);
Code:
0: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #15 // String debug-only branch
5: invokevirtual #17
8: getstatic #9
11: ldc #23 // String always runs
13: invokevirtual #17
16: return

if无条件展开了——既没有 getstatic DEBUG,也没有跳转指令,编译器把整个 if 当作恒真分支直接内联。这跟实验一其实是一对镜像:truefalse 都触发死代码消除,只是「死」的那一支不同。

实验三:丢掉 final

1
2
3
4
5
6
7
8
9
10
public class CondCompileNonFinal {
static boolean DEBUG = false; // 注意:没有 final

public static void main(String[] args) {
if (DEBUG) {
System.out.println("debug-only branch");
}
System.out.println("always runs");
}
}

javap 输出:

1
2
3
4
5
6
7
8
9
10
11
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field DEBUG:Z
3: ifeq 14
6: getstatic #13 // Field java/lang/System.out
9: ldc #19 // String debug-only branch
11: invokevirtual #21
14: getstatic #13
17: ldc #27 // String always runs
19: invokevirtual #21
22: return

if完整保留。原因严格按 JLS §15.28:少了 finalDEBUG 就不再是编译时常量表达式,if (DEBUG) 退化为一次普通的运行时分支判断。<clinit> 里还会出现 iconst_0; putstatic DEBUG 的初始化指令。

这条对照实验回答了一个高频追问:「把 DEBUG 改成 false 编译完直接发布,安全吗?」——只在 DEBUGstatic final 时安全。staticfinal 任一缺失,那段代码都还在字节码里,运行时通过反射、Java Agent 改字段值,照样能让分支再次激活。

实验四:跨类常量

把开关挪到另一个类,Flags.java

1
2
3
public class Flags {
public static final boolean DEBUG = false;
}

调用方:

1
2
3
4
5
6
7
8
public class CondCompileExternal {
public static void main(String[] args) {
if (Flags.DEBUG) {
System.out.println("debug-only branch");
}
System.out.println("always runs");
}
}

javap -c CondCompileExternal 显示 main没有任何对 Flags 类的引用if 块同样被抹掉了——这一行为来自 JLS §13.1 The Form of a Binary:编译时常量在每个使用方的字节码里直接内联为字面值,类文件甚至不需要持有对源类的符号引用。

这给出了第二个模式:

[PATTERN] 编译时常量内联是单向锁。常量值在每个使用方的字节码里被「拓印」一份——一旦发布,改 Flags.DEBUG 而不重编所有使用方,旧的字面值会以「幽灵值」的形式继续存在。

§13.4.9 的幽灵值陷阱

实验四暴露的副作用,是 JLS §13.4.9 final Fields and Constants 著名的二进制兼容性条款:

If a field is a static final variable whose value is a constant expression, then deleting the keyword final or changing its value will not break compatibility with pre-existing binaries by causing them not to run, but they will not see any new value for the usage of the field unless they are recompiled.

翻译关键意思:把 Flags.DEBUGfalse 改成 true只重新发布 Flags.class 不会让使用它的代码看到新值,因为常量值早在编译期就被复制粘贴进每一个使用方的字节码里。

实战中这条规则吃过亏的场景包括:

  • 把项目里 Constants.VERSION = "1.0" 改成 "2.0",只重发了 Constants 模块,调用方的日志里仍然打印 "1.0"
  • 多模块 Maven 项目里 api 模块定义 static final int TIMEOUT = 30service 模块依赖 api 已经编译完成。apiTIMEOUT 改成 60 重新发布,但 service 没重编,运行时 service 仍然按 30 走。
  • 用条件编译做 kill switch 时,更危险:以为「改一行重编一个文件」就能关掉 debug 块,结果调用方早已分发到几十个微服务,每个都需要重编重发才能真正生效。

应对这一陷阱,Effective Java 给出的经验是:不要把可能改变的值声明为编译时常量。需要跨模块共享的「值」如果未来可能调整,应当用方法返回:

1
2
3
4
public class Flags {
private static boolean debug = false;
public static boolean isDebug() { return debug; }
}

但这样做的代价是 if (Flags.isDebug()) 不再是常量表达式,§14.21 的死代码消除随之失效——分支会出现在字节码里,调用 isDebug() 会成为运行时开销。这是一组无法两全的权衡:编译时常量带来更小的字节码与零运行时成本,代价是修改它必须全量重新编译所有依赖

Google 的那个传说,到底有几分真

回到最初的问题。importnew 之类技术站上流传的「Google Java 条件编译」,真身大致可以拆成三层:

第一层:JLS §14.21 + §15.28 的标准能力被夸大成「Google 独家」

if (DEBUG) + static final boolean 的死代码消除,是 任何 符合规范的 Java 编译器都必须支持的能力——OpenJDK javac、Eclipse ECJ、Google 的 Error Prone(基于 javac)行为完全一致。没有「Google 的 Java 编译器有额外能力」这种事,传说里这一层属于以讹传讹。

第二层:Android 客户端的 BuildConfig.DEBUG 是真实的工业实践

Android Gradle 插件在编译时为每个模块生成一份 BuildConfig.java

1
2
3
4
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("false");
// ...
}

debug 构建变体里 DEBUG = truerelease 变体里 DEBUG = false——正是 §14.21 直接落地的 Google 工程实践。配合 ProGuard / R8,效果比 javac 单兵作战更猛:R8 不仅消除死分支,还会顺手做 tree-shaking(删未调用方法)、log 调用 strip(-assumenosideeffects class android.util.Log { *; }),让 Log.d(TAG, ...) 在 release 包里整句蒸发——这条规则即使 Log.d 不在 if (DEBUG) 里也生效,R8 在静态分析认定无副作用后直接干掉。

第三层:GWT 的 deferred binding 是另一种「条件编译」

Google Web Toolkit 把 Java 编译成 JavaScript,它有一套 <set-property> / <replace-with> 机制,编译时按目标浏览器、locale、是否 debug 等条件生成多份不同的 JavaScript 包,运行时按 user agent 选取。这是「为不同环境编译出不同产物」意义上的条件编译,但工作面在 JS 而非 JVM 字节码,跟传说里「Java 字节码消失」是两回事。

把这三层捋清楚就能回答:传说部分为真,但归属错误。真正属于 JVM 字节码层面的「条件编译」是 JLS §14.21 这条全语言通用的规则,不是 Google 私货;Google 在 Android / GWT 上叠加的工业增强(R8、deferred binding)确实更激进,但靠的是公开标准化的工具链,而不是某种秘密的 javac 分支。

调试,那个绕不开的问题

第三个问题:如果代码真的从字节码里消失了,调试还能怎么做?

回答这个问题之前必须区分两种「死代码消除」:

  • 编译时死代码消除(compile-time DCE)——javac / R8 在生成字节码时就把分支抹掉。线上 class 文件里没有这段代码,JVM 永远不可能执行它。
  • 运行时死代码消除(runtime DCE)——HotSpot C2 编译器在 JIT 阶段,根据 profile 把「实际从未走过的分支」编译成 uncommon trap。如果分支条件后来变了,JVM 会去优化(deoptimize)回退到解释模式重新执行。

两者的调试体验是反过来的:

  • 编译时消除的代码,无论怎么 attach 调试器、怎么改运行时配置、怎么调字节码增强,那段源码对运行的 JVM 来说就是不存在。debug 包里能 step into 的代码,到 release 包里直接没字节码。
  • 运行时消除的分支,仍然实打实编在字节码里。JIT 把它折叠是为了性能,但解释模式仍能跑到,调试器照样能下断点。

工业实践里,针对编译时消除导致「线上调不进 debug 分支」的痛点,主流做法是接受这种取舍,并通过其他手段补足:

  1. 发布两套包,但只跑一套。Android 的 debug-apk 给测试与内部环境,release-apk 给生产;服务端类似,可以用 Maven profile 出 app-dev.jarapp-prod.jar。线上出问题时把 debug-apk 推给受影响用户复现,而不是「在生产 JVM 里临时打开 debug 开关」。
  2. 把开关从编译期挪到运行期。如果调试性 / 灰度性 / A-B 实验性诉求多于「极致剥离调试代码」的诉求,应该直接放弃 static final 死代码消除,改用运行时 feature flag——下一节会展开。
  3. 保留可观测性的开销if (log.isDebugEnabled()) 这类 SLF4J 守卫,本质就是放弃了 §14.21 死代码消除(isDebugEnabled() 不是编译时常量),换取「线上随时调日志级别」的能力。其代价是一次方法调用 + 一次分支判断,但避免了字符串拼接的代价。这种 trade-off 在服务端绝大多数场景都是划算的。

[PATTERN] 编译时优化的反面是运行时灵活性。把开关塞进 static final,得到的是「字节码里干净到一行不剩」;把开关挪到方法调用,得到的是「线上可调、可观测、可灰度」。这两件事不可能同时拥有,下一节会把这条权衡展开成具体的方案谱系。

Feature Switch 的完整谱系

「让一段代码按某种条件参与/不参与运行」这个诉求,在工业界远不只是 if (DEBUG) 一种解法。按「开关在哪个阶段生效」排开,至少有六层方案:

第 1 层:构建时源码切换

最朴素的做法是两份源码,构建时挑一份。Maven 用 profiles + build-helper-maven-plugin 切换 source root;Gradle 直接用 sourceSets 划分 src/dev/javasrc/prod/java;Google 自家的 Bazel 用 select()BUILD 文件里按 --config 切目标依赖。

特点:

  • 控制粒度最粗——整个类、整个目录级别切换。
  • 字节码完全不同,线上 debug 与 release 是两份不同的产物。
  • 没有 §13.4.9 的常量内联陷阱,但有「两份代码维护成本」。

第 2 层:编译期标志位 + if (CONST)

就是本文主线讨论的 static final boolean DEBUG + javac 死代码消除,外加 Android 的 BuildConfig.DEBUG

特点:

  • 粒度细到 if 块级别。
  • 一份源码、一份构建配置切换。
  • 受 §13.4.9 跨类常量内联陷阱约束。

第 3 层:注解处理器(APT)/ 编译器插件

Lombok 通过编译器插件改 AST,效果像「源码层面的代码生成」。社区里专门做 Java 条件编译的工具是 Manifold,它在 javac 阶段引入类 C 预处理器(下面这段不是合法 Java 源码,而是 Manifold 提供的预处理指令扩展):

1
2
3
4
#if EE
// 仅企业版编译时进入
enterpriseFeature();
#endif

特点:

  • 写法接近 C #ifdef,可读性高。
  • 不依赖 static final 常量,开关来源更灵活(环境变量、构建参数)。
  • 引入额外编译依赖与 IDE 插件,团队接受成本不低。

第 4 层:字节码/类加载期织入

AspectJ Load-Time Weaving、ByteBuddy + Java Agent、Spring 的 AOP 代理,本质都是在类被加载到 JVM 时改字节码。可以做到「上线后通过启动参数决定要不要插入某段逻辑」。

特点:

  • 开关时机在「编译之后、运行之前」。
  • 调试体验最复杂——javap 看到的字节码与运行时实际执行的字节码不一致。
  • 适合横切关注点(日志、监控、事务、安全),不适合业务分支。

第 5 层:运行时 Feature Flag

把开关搬到运行时是当前服务端的主流做法。可选方案按演进顺序:

  • 手卷配置文件 + Spring @Value:最朴素,需要重启或 @RefreshScope 才能生效。
  • Togglz:Java 生态最早的成熟 feature flag 框架,定义 enum Features 即可,支持持久化到 JDBC / Redis。
  • Unleash / LaunchDarkly / Flagsmith:托管的 feature flag 服务,提供按用户、按百分比、按地域的灰度规则。
  • OpenFeature:CNCF 的厂商中立标准,提供统一 SDK,背后可以接 LaunchDarkly、flagd、Split.io 等各种 provider。2024 年后正在迅速成为新项目的默认选型。

特点:

  • 开关时机在每次方法调用。
  • 改值秒级生效,无须重新编译、重新发布。
  • 调试体验最好——线上随时能复现「关闭某个 flag」的状态。
  • 代价是每次判断都是真实的运行时调用,性能开销远大于死代码消除。

第 6 层:JIT 死代码消除

HotSpot C2 在运行时根据 profile,如果某条分支统计上从未走过,会把它编译成 uncommon trap:方法体里那段实际上是一个「假装在执行」的占位指令,被分支预测器优化掉。一旦分支条件变化触发 trap,JVM 会去优化退回解释模式重新编译。

特点:

  • 完全运行时,不需要程序员显式标注。
  • 对「冷分支」尤其有效——例如 if (Flags.isDebug()) 在 release 环境长期为 false,C2 实际上也会把它折叠成几乎零成本。
  • 解释了一个反直觉现象:把 static final 改成方法调用 isDebug()长期看性能损失远比想象的小——因为 JIT 在补编译时常量做不到的事。

这一层的存在改变了第 2 层和第 5 层的 trade-off:在大多数业务场景,运行时 feature flag 的真实成本被 JIT 摊薄到可忽略,而获得的灵活性远超「字节码层面剥离调试代码」带来的省字节。

决策光谱:选哪一层?

把上面六层放在同一张坐标轴上,差异其实有规律可循:

层级 开关生效时机 改值代价 字节码可见性 典型场景
1. 构建时源码切换 编译前 重新构建 完全不同 跨平台移植、商业版/社区版分发
2. if (CONST) 死代码消除 编译期 重新编译所有使用方 死分支被抹除 Android DEBUG 块、内嵌调试代码
3. APT / 预处理器 编译期 重新编译 死代码被处理器删除 Lombok、Manifold
4. 字节码织入 类加载期 重启或动态 attach 加载后的字节码不同于 .class AOP、Java Agent、监控插桩
5. 运行时 Feature Flag 每次调用 秒级,无须重启 字节码包含完整两条分支 灰度、A-B 实验、kill switch
6. JIT 死代码折叠 运行时自动 不可手工控制 字节码不变,机器码可能不同 长期冷分支性能优化

[PATTERN] Feature switch 不是一个技术,而是一个更新粒度光谱。从「编译前」到「运行时」每往后挪一层,灵活性增加一档、性能成本增加一档、调试可观察性也增加一档。选哪一层不取决于「哪个最优雅」,而取决于这三个问题的答案:

  1. 改一次值,能容忍多大代价? 重启服务能接受吗?重新编译能接受吗?重新分发到所有使用方能接受吗?
  2. 改完之后,能容忍多久才生效? 秒级、分钟级、还是下个发布周期?
  3. 线上出问题时,能不能复现「开关另一种状态」的行为? 这决定了排障难度的上限。

if (DEBUG) + static final 在第 1、2 题答「重新编译可以接受、下个发布周期可以接受」、第 3 题答「不需要复现」的场景下最优——典型是 Android client 的 release 包剥离日志、嵌入式 Java 程序剥离调试桩。一旦其中任何一题答案变了,方案就必须往后挪层。

把传说还回 JLS

回到开篇的传说,可以这样总结:

  • 「Java 有种条件编译可以让 if 块从字节码消失」——真。这是 JLS §14.21 + §15.28 + §13.4.9 联合规定的标准行为,任何符合规范的 Java 编译器都必须支持。
  • 「这是 Google 独家技术」——伪。这是 Java 语言契约,不是任何一家厂商的私货。Google 在 Android 上的 BuildConfig.DEBUG + R8 是这条规则的工业放大,但底层依据是公开的 JLS。
  • 「上了生产,被 if 包围的代码绝对不会跑」——条件成立时为真。条件是:开关必须是 static final 编译时常量,且所有使用方都用启用 false 的版本重新编译过。少一个条件,「绝对不会跑」就不成立。
  • 「这影响调试吗」——是。工业界的应对不是「让调试和死代码消除共存」,而是「构建两套产物,分别承担调试与发布的职责」,或者干脆放弃编译期开关、改用运行时 feature flag。

§14.21 那段 rationale 写下了 Java 对条件编译的设计承诺:让程序员能通过翻转一个常量,仅靠重新编译,就在两种行为之间无副作用切换。这条承诺从 Java 1.0 至今没有变过,但兑现它的前提是同时满足 §15.28 的常量表达式条件与 §13.4.9 的全量重编要求,否则得到的就不是「条件编译」,而是隐藏的运行时分支。

模式速查表

编号 模式 适用判据
PATTERN 1 编译时常量是 javac 死代码消除的唯一钥匙 想让 if 块从字节码消失,条件必须是 JLS §15.28 意义上的常量表达式
PATTERN 2 编译时常量内联是单向锁 改常量值必须全量重编所有使用方,否则旧值会以「幽灵值」继续存在(§13.4.9)
PATTERN 3 编译时优化的反面是运行时灵活性 字节码干净 vs 线上可调,二者互斥;选边前先答清「改值代价、生效延迟、可复现性」三题
PATTERN 4 Feature switch 不是一种技术,而是一个粒度光谱 从构建时到 JIT 共有六层,每往后一层灵活性 + 成本 + 可观测性都增加一档
PATTERN 5 JIT 是常量内联的运行时补丁 static final 换成方法调用,长期看性能损失很小,因为 C2 会把冷分支折叠

参考