用Java编译Java:从javac命令行到动态编译API全实践
发布时间:2026/9/25 16:18:18来源:尧图网络
在Java这行待久了隔三差五就会遇到有人问同一个问题“用Java编译Java的项目到底怎么搞”这话乍一听像绕口令其实是两个完全不同的需求一个是常规操作拿JDK自带的javac命令把源码编译成可运行的class文件另一个则进阶不少在程序运行期用Java代码调用JDK内置的编译器API动态去编译另一段Java源码。这两种场景我都实际经历过也各自踩过不少坑今天就一次性把“用Java编译Java”这件事的方方面面说清楚。这篇文章适合这么几类人刚开始学Java、对编译流程还停留在“点IDE绿色按钮”阶段的新手已经能写项目但想搞清楚Maven、Gradle背后到底在干什么的同学以及那些想做代码生成器、规则引擎、在线判题系统需要在运行期动态编译Java代码的人。不管你是哪种情况这篇文章都能给你一套可以直接抄作业的方案而且会告诉你为什么要这么做。1. 先把工具链备齐JDK安装与环境变量1.1 为什么javac能编译Java编译器本身又是Java写的这里先聊一个很多人忽略的事实javac这个编译工具本身就是用Java语言写出来的。OpenJDK里的javac源码就是一堆Java文件它运行在JVM之上读取你的.java文件经过分析处理后输出.class字节码文件。所以说“用Java编译Java的项目”这句话从最字面意义上就是成立的——你写Java代码Java写的编译器帮你生成Java虚拟机认识的指令。很多人分不清JDK和JRE的区别简单粗暴地记JRE是运行Java程序的环境里面只有JVM和核心类库JDK是Java开发工具包在JRE的基础上多了javac、jar、javadoc这些工具。所以你只是运行别人编译好的程序装JRE就行要编译代码必须装JDK。实际开发中我们直接装JDK就好了版本建议用LTS版本比如Java 8、Java 11、Java 17或者Java 21不要追着非LTS版本跑否则十有八九会遇到团队协作时版本不一致的问题。1.2 环境变量配置与验证装完JDK后环境变量是绕不开的一步虽然现在的IDE比如IntelliJ IDEA可以自己识别JDK路径但命令行编译、服务器部署、脚本打包这些场景环境变量没配好照样寸步难行。需要配的基本上是两个变量JAVA_HOME指向JDK安装目录比如C:\Program Files\Java\jdk-17很多工具Maven、Tomcat、Gradle启动脚本都会去读这个变量。PATH追加一条%JAVA_HOME%\bin这样你在任意目录敲javac或java都能直接调用。配好之后打开命令行窗口验证一下能正常输出版本号就算成功java -version javac -version这里说一个我实际遇到的坑很多人图省事只往PATH里写了JDK的bin目录没设JAVA_HOME日常用javac完全没问题但一旦用Maven或者跑一些需要读取JAVA_HOME定位JDK的脚本就直接报错“找不到JAVA_HOME”。所以老老实实两个变量都配上能省掉后面很多莫名其妙的问题。还有一点如果你电脑上装了不止一个JDK版本PATH里多个bin目录的先后顺序决定了实际生效的是哪个版本。排查java -version出来的版本和你预期不一致时优先检查这一块。2. 命令行编译一个Java项目的完整流程2.1 单文件和带包结构的编译先从最简单的开始。假设你有一个HelloWorld.javapublic class HelloWorld { public static void main(String[] args) { System.out.println(hello java compile); } }最简单的编译命令就是javac HelloWorld.java执行完当前目录会多出一个HelloWorld.class然后运行java HelloWorld注意这里运行的时候不带.class后缀也不要带.java后缀这两个错误我见过无数新手踩。接着是带包名的情况假如源码第一行写着package demo;目录结构是这样的src/demo/HelloWorld.java正确的做法是先用-d指定输出目录让javac根据包结构帮你自动创建目录javac -d out src/demo/HelloWorld.java然后运行java -cp out demo.HelloWorld-cp就是-classpath的简写意思告诉JVM去哪里找class文件。这里的类名必须写全限定名demo.HelloWorld而不是去out目录下敲java HelloWorld。这个点我强调一下因为很多初学者一直转不过弯来java命令后面跟的是类名不是文件路径。2.2 带第三方依赖的编译classpath是核心实际项目几乎不可能没有第三方依赖。比如你要用FastJSON解析JSON代码里import com.alibaba.fastjson.JSON;这时候光javac还不够你得把fastjson的jar包位置告诉编译器否则编译直接报“程序包com.alibaba.fastjson不存在”。假设目录结构如下lib/fastjson-1.2.83.jar src/com/example/JsonDemo.java编译命令是这样javac -cp lib/fastjson-1.2.83.jar -d out src/com/example/JsonDemo.java运行的时候-cp要同时带上输出目录和外部jar包java -cp out;lib/fastjson-1.2.83.jar com.example.JsonDemoWindows下多个路径用分号;分隔Linux和macOS用冒号:这个细节非常容易踩坑。2.3 用构建工具替代手敲命令上面这种手敲命令的方式在只有一个文件的项目里还算凑合一旦项目有几十个类、十几个依赖jar包再手敲就纯属受罪了。这也是Maven和Gradle存在的意义它们本质上是帮你管理“编译哪些文件、classpath上有什么依赖、输出到哪里”这件事的工具。Maven底层的compile插件最终干的事情还是构建出-cp参数并调用javac只是这一切被封装起来自动完成了。这里我特别建议新手理解一件事用IDE的“绿色运行按钮”和用命令行编译背后是同一套机制。IDE只是自动帮你算了classpath、自动执行了编译命令而已。我见过很多有两年工作经验的同学离开IDE连一个单文件程序都不会编译这种基础能力一旦缺失遇到排查线上问题时就会非常被动。3. 进阶用Java代码在运行期编译Java代码3.1 ToolProvider.getSystemJavaCompiler能干什么讲完常规编译流程接下来是这篇文章的重头戏在你的Java程序里直接调用编译器API去编译Java代码。前两年我做一个动态规则引擎用户可以在管理后台写一段条件判断的Java代码然后系统需要把这代码编译成class并加载执行就是用的这个方案。JDK里对应的入口是javax.tools.JavaCompiler通过ToolProvider.getSystemJavaCompiler()拿到。这个API能做什么简单说凡是能让javac干的事情它都能在代码里干编译磁盘上的文件、编译内存中的字符串、收集编译错误信息、指定编译参数。它让你拥有了把“编译”这一步嵌入到业务系统里的能力。典型的应用场景有几个在线测评系统OJ用户提交一段Java代码服务端动态编译再运行输出结果返回给前端。代码生成器根据模板生成实体类、Mapper接口的Java源码实时编译并加载。脚本热更新把需要经常改动的一段逻辑写成Java源码存数据库编译成class后放进自定义类加载器里实现热替换。低代码平台让用户在页面配规则后台把规则翻译成Java代码执行。3.2 一个能直接跑的动态编译工具类下面给一个实际可用的动态编译工具类。它的核心能力是接收一段Java源码字符串、一个类名编译生成class字节码并返回Class?对象。import javax.tools.*; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.net.URI; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class DynamicCompiler { public static Class? compileAndLoad(String className, String sourceCode) throws Exception { // 1. 拿到系统Java编译器 JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); if (compiler null) { throw new IllegalStateException(当前环境没有可用编译器请确认使用的是JDK而非JRE); } // 2. 用自定义JavaFileObject包装内存中的源码 JavaFileObject sourceFile new StringJavaFileObject(className, sourceCode); // 3. 使用自定义fileManager拦截编译产出的class文件字节码 StandardJavaFileManager standardFileManager compiler.getStandardFileManager(null, null, null); MemoryJavaFileManager fileManager new MemoryJavaFileManager(standardFileManager); // 4. 构造编译任务 JavaCompiler.CompilationTask task compiler.getTask( null, fileManager, null, Arrays.asList(-encoding, UTF-8), null, Arrays.asList(sourceFile) ); // 5. 执行编译并检查结果 Boolean success task.call(); if (success null || !success) { throw new IllegalStateException(编译失败请检查源码语法); } // 6. 从内存中取出class字节码用自定义类加载器加载 MapString, byte[] classBytes fileManager.getClassBytes(); MemoryClassLoader classLoader new MemoryClassLoader(classBytes); return classLoader.loadClass(className); } }上面用到了几个辅助类我逐个写出来组合在一起才完整。源码包装类把字符串源码变成编译器认识的输入对象class StringJavaFileObject extends SimpleJavaFileObject { private final String sourceCode; StringJavaFileObject(String className, String sourceCode) { super(URI.create(string:/// className.replace(., /) Kind.SOURCE), Kind.SOURCE); this.sourceCode sourceCode; } Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return sourceCode; } }内存文件管理器重写两个关键方法把编译产出的class文件截获到内存而不是磁盘class MemoryJavaFileManager extends ForwardingJavaFileManagerStandardJavaFileManager { private final MapString, byte[] classBytesMap new ConcurrentHashMap(); MemoryJavaFileManager(StandardJavaFileManager fileManager) { super(fileManager); } Override public JavaFileObject getJavaFileForOutput(JavaFileManager.Location location, String className, JavaFileObject.Kind kind, FileObject sibling) { return new SimpleJavaFileObject(URI.create(mem:/// className.replace(., /) kind.extension), kind) { Override public OutputStream openOutputStream() { return new ByteArrayOutputStream() { Override public void close() { classBytesMap.put(className, toByteArray()); } }; } }; } MapString, byte[] getClassBytes() { return classBytesMap; } }内存类加载器把字节数组变成可用的Class对象class MemoryClassLoader extends ClassLoader { private final MapString, byte[] classBytesMap; MemoryClassLoader(MapString, byte[] classBytesMap) { this.classBytesMap classBytesMap; } Override protected Class? findClass(String name) throws ClassNotFoundException { byte[] bytes classBytesMap.get(name); if (bytes null) { throw new ClassNotFoundException(name); } return defineClass(name, bytes, 0, bytes.length); } }使用方式也很简单比如动态编译一段实现计算接口的代码public class DynamicCompilerDemo { public static void main(String[] args) throws Exception { String className demo.DynamicAdder; String source package demo; public class DynamicAdder { public int add(int a, int b) { return a b; } }; Class? clazz DynamicCompiler.compileAndLoad(className, source); Object instance clazz.getDeclaredConstructor().newInstance(); // 通过反射调用 java.lang.reflect.Method method instance.getClass().getMethod(add, int.class, int.class); Object result method.invoke(instance, 10, 20); System.out.println(动态编译执行结果 result); } }这套代码我在实际项目里跑过编译一个小类耗时在几十毫秒级别完全够用。3.3 动态编译的坑这类方案看着简单实际工程化的时候坑不少我列几个亲身体验过的。第一个坑是JDK 9之后的模块化问题。如果你在JDK 9及以上环境运行且程序是以模块化方式打包的需要确保模块里有jdk.compiler否则ToolProvider.getSystemJavaCompiler()会返回null。普通classpath项目一般没问题但如果你自己写module-info.java一定要显式requires jdk.compiler;。第二个坑是classpath传递。动态编译出来的代码如果要引用你工程里的其他类或者第三方jar包里的类编译器默认不知道这些类的存在必须通过compiler.getTask的-classpath参数传进去。可以用System.getProperty(java.class.path)取当前程序的classpath再追加你需要的jar路径。第三个坑是类加载器隔离。用默认类加载器加载动态编译的类会和当前类的包名冲突容易出现各种奇怪的LinkageError。我上面的代码用了自定义MemoryClassLoader并且没有设置父加载器默认是系统类加载器这样动态类可以正常引用父加载器里的第三方类但又不会污染父加载器里已有的同名类定义。4. 编译原理视角javac到底干了什么4.1 编译流程拆解从源码到字节码理解javac内部做了什么对排查编译问题特别有帮助。整个流程可以分四步第一步是词法分析。源代码在编译器眼里就是一串字符词法分析器把字符流切成一个个“单词”这些带类型的关键字、标识符、数字、运算符统称为token。比如int a 10;会被切成int、a、、10、;这几个token。第二步是语法分析。拿切好的token去匹配语言规则构建一棵抽象语法树。这棵树描述了“谁是谁的子节点、谁调用了谁、谁继承了谁”。这一步如果报错通常就是括号不匹配、分号缺失这类结构问题。第三步是语义分析。语法正确不代表逻辑正确此时编译器要检查类型是否匹配、变量有没有声明、方法的参数个数对不对。很多新手遇见的“不兼容的类型”“找不到符号”就是卡在这阶段。第四步是字节码生成。前面的分析结果最终转化为JVM能执行的字节码指令写进.class文件。class文件里有魔数、常量池、方法表、属性表等结构这里面既有类型信息也有方法的实际字节码。为了更好理解打个比方写Java源码相当于你写文章词法分析是“认字分词”语法分析是“断句查病句”语义分析是“校核逻辑是否自洽”字节码生成则是“排版印刷”成机器能读的书。4.2 常见警告“源发行版17需要目标发行版17”的内在逻辑这个警告在热搜里排名很高我展开讲讲背后的原理。javac有两个互相独立的参数-source指定源码的语法版本-target指定生成的class文件版本。当你用JDK 17的javac编译又不指定这两个参数时默认都是17一切正常。但你可能会配置了maven.compiler.source1.8、maven.compiler.target1.8此时JDK 17的javac被要求用1.8的语法去解析代码、生成1.8版本的class文件于是就会提示“源发行版17需要目标发行版17”之类的警告或错误。这个“源发行版”说的其实是当前执行编译的JDK版本是17而-source指定的版本低于17javac觉得有必要提醒你一下。解决思路有三个统一JDK版本把项目JDK版本调成一致比如都用17。使用-release参数-release 8会自动同时设置-source 8、-target 8并且额外检查你使用的API是否在Java 8里存在推荐优先使用。升级项目到更高版本如果项目一直停留在Java 8建议评估升级到Java 17或21别在旧版本上无限挣扎。如果说源码错误是“写错了”那这类版本警告就是“工具与目标不匹配”搞清楚source、target、release三者关系后这类警告基本瞄一眼就知道咋回事。5. 高频编译报错与排查办法5.1 无法加载主类、程序包不存在这是命令行新手最容易遇到的两类报错。“错误: 找不到或无法加载主类”的原因通常是你运行java时给的类名不对或者-cp没指对目录。我有一次调试了很久最后发现是因为类在com.example包里我却站在out目录外面写java com.example.Main结果忘了把out加进-cp。“程序包xxx不存在”一般是两个原因一是确实没引入对应的jar包需要在classpath里加上依赖二是import语句写错包路径。排查时先检查代码里的import再检查classpath不要一上来就怀疑环境坏了。5.2 JDK版本迁移导致的编译失败一个很有代表性的报错是编译时找不到com.sun.image.codec.jpeg.JPEGCodec。这是老项目从JDK 8往上升级时常遇到的事。JDK 8及之前com.sun.*下的很多内部类可以直接用JDK 9之后模块化这些内部API被封装进模块里面不再对外暴露直接编译就会报“找不到类”。这种代码的修正方向是改用公开API替代。比如缩略图生成可以用ImageIO的公开方法实现或者引入专门处理图片的第三方库不要依赖JDK内部类。这也是为什么我建议新项目从一开始就别碰com.sun.*和sun.*里的类它们不属于公共API随时可能变动甚至移除。5.3 中文乱码和编码问题编译报“错误: 编码GBK的不可映射字符”或者编译通过但运行输出乱码基本是编译时字符集不合导致的。javac在Windows中文系统下默认按平台编码GBK读取源码而你的文件是UTF-8保存的就会出问题。统一的做法是源码文件一律UTF-8编译时显式指定编码javac -encoding UTF-8 -d out src/com/example/Main.javaMaven项目则统一设置项目编码为UTF-8。我之前遇到一个团队代码里注释偶尔乱码git blame查了半天最后就是有人用GBK存文件、有人用UTF-8没有统一编码标准导致的。这个看似小问题多人协作时特别容易爆发最好一开始就在项目里配置好强制UTF-8。5.4 Maven与Gradle编译期问题用Maven时比较常见的编译失败包括依赖下载不下来、依赖冲突、以及maven-compiler-plugin版本过老无法识别新JDK。遇到这类问题先看Maven的版本和使用的JDK是否匹配再看pom.xml里有没有指定maven.compiler.source和maven.compiler.target。我分享一个排查技巧Maven报错别只盯着最后几行往前翻几百行找到第一个[ERROR]那才是根因。后面一串报错通常是连锁反应。很多同学一看到成片的红色报错就懵其实真正的原因往往只有一条其余都是被它带出来的。5.5 编译报错速查表报错特征常见原因优先排查方向找不到或无法加载主类类名写错、classpath没带输出目录检查类全限定名和-cp参数程序包不存在缺依赖jar包或import写错检查classpath和import语句源发行版17需要目标发行版17JDK版本与source/target设置不一致统一版本或改用-release编码GBK的不可映射字符源码编码与编译编码不一致使用-encoding UTF-8找不到com.sun.*类JDK 9移除了内部API改用公开API或第三方库[ERROR] COMPILATION ERROR代码语法错误或依赖问题看第一个报错别被连锁报错带偏无效的源发行版maven-compiler-plugin与JDK版本不匹配升级插件并调整版本参数6. 编译环境里的一些小建议和冷门技巧6.1 编译速度优化项目变大后编译等待时间会变得很折磨人。几个实际的优化方向一是尽量增量编译不要每次全量编译整个项目二是给Maven或Gradle配置更高的内存避免GC频繁卡顿三是用并行编译。Gradle默认就有增量编译和构建缓存这也是它比Maven快的一个重要原因。如果你还在用Maven且项目很庞大可以试试-T参数开启多线程编译mvn compile -T 46.2 编译产物与反编译一个完整的“用Java编译Java”话题还应该提一嘴反编译。当你对着一堆.class文件发愁时用反编译工具比如IDEA自带的反编译器、CFR、Procyon能还原出大致可读的源码。编译和反编译是一体两面编译把人类可读的源码变成字节码反编译把字节码“翻译”回近似源码。理解这点后你调试不在你控制范围内的jar包时会多一条思路。我常在排查线上问题时干一件事把某个依赖jar包里的class反编译出来看看第三方库内部到底调了什么方法确认是不是版本行为差异。这比瞎猜效率高得多。6.3 配合热部署的场景前面讲的动态编译在运行期还可以和热部署配合起来。场景是这样的线上服务有个规则调整需求传统做法得改代码、重新打包、发布、重启慢且增大了故障窗口。如果用动态编译把一段修改后的代码编译成class由一个新的类加载器加载再把请求切到新对象上就能实现不重启的更新。不过这里要提醒一下热部署的本质是类加载器隔离。每次动态编译后都要新建一个类加载器来加载旧类加载器连同它加载的类才能被回收否则会出现“新旧版本混用”的怪异现象。我之前就遇到过修改一个静态变量的值结果有些请求走新类、有些走旧类排查了很久才发现是类加载器没正确隔离造成的。7. 写在最后的经验之谈如果让我总结这一路的经验第一句话是想告诉你别小看命令行编译也不要觉得有IDE就可以完全不学。编译是Java这门语言最基本的一环无论你是做后端服务还是Android开发最终跑在你机器上的都是编译产物理解这个过程会让你在排查问题时多出很多底气和思路。第二句话是Java的编译能力比很多人想象中要强大得多。javac不只是给你手动敲的命令它作为一组API暴露出来后能在运行期动态编译、动态加载、动态更新这些能力可以支撑起规则引擎、代码生成器、低代码平台这些看起来“有点高级”的系统设计。我在自己的工具库里长期保留着那份几十行代码的动态编译工具类前前后后用它在好几个不同的项目里救过场。最后再分享一个实操习惯。我每次搭新项目都会先不急着打开IDE而是先用命令行把项目编译跑通确认基础环境没问题再进IDE。这样万一后面IDE抽风我知道底层的编译链路是好的排查范围一下就缩小了。这个习惯帮我省下的时间远比刚开始多花的几分钟多得多。希望这篇关于“用Java编译Java的项目”的总结能让你也少走一些弯路。
网站建设高端定制企业官网