以前就知道javac的逻辑是用java实现的,当时猜测javac应该是个shell脚本,脚本的内容大概就是通过java命令执行对应的java文件来实现javac的逻辑。
但后来偶然一次机会发现,javac并不是shell脚本,而是二进制文件。
但javac不是用java实现的吗?这里怎么是二进制文件呢?
带着这些疑问,花了两天时间,把openjdk构建过程的脚本通读了一遍,这才解开了这些疑问,这里写下来分享下。
下文涉及到的源码所属的OpenJDK版本为
➜ hg id b5f7bb57de2f jdk-12+31
OpenJDK的构建是用Autoconf和GNU Make来实现的,主体的构建脚本都在OpenJDK根目录的make文件夹下。该文件夹下的autoconf目录里的文件是用来实现Autoconf部分,该文件夹下的其他文件是用来实现make部分。
make文件夹下有个launcher目录,该目录下的各种makefile文件就是用来构建jdk里的各种命令的,比如javac、jcmd、jshell等。
我们来看下该目录的内容
➜ tree make/launcher
make/launcher
├── LauncherCommon.gmk
├── Launcher-java.base.gmk
├── Launcher-java.rmi.gmk
├── Launcher-java.scripting.gmk
├── Launcher-java.security.jgss.gmk
├── Launcher-jdk.accessibility.gmk
├── Launcher-jdk.aot.gmk
├── Launcher-jdk.compiler.gmk
├── Launcher-jdk.hotspot.agent.gmk
├── Launcher-jdk.jartool.gmk
├── Launcher-jdk.javadoc.gmk
├── Launcher-jdk.jcmd.gmk
├── Launcher-jdk.jconsole.gmk
├── Launcher-jdk.jdeps.gmk
├── Launcher-jdk.jdi.gmk
├── Launcher-jdk.jfr.gmk
├── Launcher-jdk.jlink.gmk
├── Launcher-jdk.jshell.gmk
├── Launcher-jdk.jstatd.gmk
├── Launcher-jdk.pack.gmk
├── Launcher-jdk.rmic.gmk
└── Launcher-jdk.scripting.nashorn.shell.gmk
其中Launcher-jdk.compiler.gmk这个makefile就是用来构建javac命令的,我们重点看下这个文件。
首先看下该文件里有关构建javac命令的相关逻辑
# make/launcher/Launcher-jdk.compiler.gmk
(eval (call SetupBuildLauncher, javac, \
MAIN_CLASS := com.sun.tools.javac.Main, \
JAVA_ARGS := --add-modules ALL-DEFAULT, \
CFLAGS := -DEXPAND_CLASSPATH_WILDCARDS, \
))
该部分逻辑调用了SetupBuildLauncher方法,传入了一些参数,比如第一个参数javac,就是说要构建的二进制文件名为javac。
我们再来看下SetupBuildLauncher方法
# make/launcher/LauncherCommon.gmk
SetupBuildLauncher = $(NamedParamsMacroTemplate)
define SetupBuildLauncherBody
...
ifneq ($$($1_MAIN_CLASS), )
$1_LAUNCHER_CLASS := -m $$($1_MAIN_MODULE)/$$($1_MAIN_CLASS)
endif
...
$1_JAVA_ARGS_STR := '{ $$(strip $$(foreach a, \
$$(addprefix -J, $$($1_JAVA_ARGS)) $$($1_LAUNCHER_CLASS), "$$a"$(COMMA) )) }'
$1_CFLAGS += -DJAVA_ARGS=$$($1_JAVA_ARGS_STR)
...
$$(eval $$(call SetupJdkExecutable, BUILD_LAUNCHER_$1, \
NAME := $1, \
EXTRA_FILES := $(LAUNCHER_SRC)/main.c, \
CFLAGS := ...
-DLAUNCHER_NAME='"$(LAUNCHER_NAME)"' \
-DPROGNAME='"$1"' \
$$($1_CFLAGS), \
...
))
...
endef
这个方法稍微复杂些,我们先说下各个变量的值,再大致说下方法逻辑。
$1为上个方法传过来的参数,值为javac
$1_MAIN_CLASS为上个方法传过来的参数,值为com.sun.tools.javac.Main
$1_MAIN_MODULE的值为jdk.compiler
$1_LAUNCHER_CLASS 的值为jdk.compiler/com.sun.tools.javac.Main
1_JAVA_ARGS_STR 的值我们只要知道包含1_LAUNCHER_CLASS的值就好
$(LAUNCHER_SRC)的值为src/java.base/share/native/launcher
$(LAUNCHER_NAME)的值为openjdk
这个方法的大体逻辑就是,经过一系列变量赋值之后,调用SetupJdkExecutable方法,把 (LAUNCHER_SRC)/main.c 文件编译成 javac 命令,编译时参数为 -DLAUNCHER_NAME=’openjdk’ -DPROGNAME=’javac‘ -DJAVA_ARGS=(1_JAVA_ARGS_STR) 等。
该二进制文件在编译时,也以json形式输出了一份完整的命令内容,文件的位置为 ./build/linux-x86_64-server-release/make-support/compile-commands/support_native_jdk.compiler_javac_main.o.json,文件的关键内容为
/usr/bin/gcc ... -DLAUNCHER_NAME='\"openjdk\"' -DPROGNAME='\"javac\"'
... -DJAVA_ARGS='{ \"-J--add-modules\", \"-JALL-DEFAULT\", \"-J-ms8m\", \"-m\", \"jdk.compiler/com.sun.tools.javac.Main\", }'
... -c -o /home/yt/workspace/jdk/build/linux-x86_64-server-release/support/native/jdk.compiler/javac/main.o /home/yt/workspace/jdk/src/java.base/share/native/launcher/main.c
由上可见,该命令内容和我们从代码中分析的一样。
至此我们可以知道,javac命令确实是二进制文件,其对应的c文件为 src/java.base/share/native/launcher/main.c,当我们在执行javac命令时,调用的就是这个c文件中的main方法。
我们再通过一个小实验进一步确定下。
从源码中我们可以知道,在运行src/java.base/share/native/launcher/main.c的main方法时,我们可以加一个环境变量,使其输出程序名及参数等信息。
执行命令如下
➜ _JAVA_LAUNCHER_DEBUG=1 bin/javac --version
----_JAVA_LAUNCHER_DEBUG----
Launcher state:
...
program name:javac
launcher name:openjdk
...
fullversion:12-internal+0-adhoc.yt.jdk
Java args:
jargv[0] = -J--add-modules
jargv[1] = -JALL-DEFAULT
jargv[2] = -J-ms8m
jargv[3] = -m
jargv[4] = jdk.compiler/com.sun.tools.javac.Main
...
由上我们可以看到,javac命令的 program name为javac,launcher name为openjdk,而 Java args 是个数组,值为上面输出的内容。
那这些输出的数据又是哪来的呢?根据源码我们可以找到以下头文件
// src/java.base/share/native/launcher/defines.h
#ifdef JAVA_ARGS
#ifdef PROGNAME
static const char* const_progname = PROGNAME;
#else
static char* const_progname = NULL;
#endif
static const char* const_jargs[] = JAVA_ARGS;
...
#else /* !JAVA_ARGS */
...
static const char* const_progname = "java";
static const char** const_jargs = NULL;
...
#endif /* JAVA_ARGS */
#ifdef LAUNCHER_NAME
static const char* const_launcher = LAUNCHER_NAME;
#else /* LAUNCHER_NAME */
static char* const_launcher = NULL;
#endif /* LAUNCHER_NAME */
该文件中的这些变量就是上面输出的数据,而这些变量的值的来源正是我们在编译时指定的 -DLAUNCHER_NAME、-DPROGNAME、-DJAVA_ARGS参数。
而由于 src/java.base/share/native/launcher/main.c 文件引用了这个头文件,所以在这个文件中,也就能拿到这些变量的值了。
我们再总结下整个过程
javac命令的入口函数为src/java.base/share/native/launcher/main.c文件中的main方法。
在编译该文件时,通过指定 -DPROGNAME=”javac” -DJAVA_ARGS='{ … “-m”, “jdk.compiler/com.sun.tools.javac.Main”, }’ 等参数,使javac命令在编译期就确定了其要执行的包含main方法的java类为 jdk.compiler/com.sun.tools.javac.Main。
在运行javac时,javac获取该java类,调用它的main方法,然后把我们传给javac命令的参数,传给该java类的main方法。
最后,通过该Java类的main方法以及其他相关内容,实现javac命令的总体逻辑。