跳到主要内容

JEP 458:启动多文件源代码程序

概括

增强java应用程序启动器,使其能够运行作为多个 Java 源代码文件提供的程序。这将使从小程序到大程序的过渡更加渐进,使开发人员能够选择是否以及何时配置构建工具。

非目标

  • 通过“shebang”机制启动多文件源代码程序并不是目标。只有单文件源代码程序才能通过该机制启动。

  • 目的不是简化源代码程序中外部库依赖项的使用。这可能是未来 JEP 的主题。

动机

Java 编程语言擅长编写由大型团队多年开发和维护的大型复杂应用程序。尽管如此,即使是大型程序也是从小处开始的。在早期阶段,开发人员修补和探索,并不关心可交付的工件;项目的结构可能还不存在,一旦出现,就会经常发生变化。快速迭代和彻底改变是当今的常态。近年来,JDK 中添加了一些有助于修补和探索的功能,包括JShell(用于处理代码片段的交互式 shell)和简单的 Web 服务器(用于快速构建 Web 应用程序原型)。

在 JDK 11 中,JEP 330增强了java应用程序启动器,使其能够.java直接运行源文件,而无需显式编译步骤。例如,假设该文件Prog.java声明两个类:

class Prog {
public static void main(String[] args) { Helper.run(); }
}

class Helper {
static void run() { System.out.println("Hello!"); }
}

然后运行

$ java Prog.java

在内存中编译这两个类并执行main该文件中声明的第一个类的方法。

这种运行程序的简单方法有一个主要限制:程序的所有源代码必须放在一个.java文件中。要使用多个.java文件,开发人员必须返回显式编译源文件。对于经验丰富的开发人员来说,这通常需要为构建工具创建项目配置,但是当试图让想法和实验顺利进行时,从无定形的修补到正式的项目结构的转变是令人厌烦的。对于初学者来说,从单个.java文件过渡到两个或多个文件需要更严峻的阶段性转变:他们必须暂停语言学习并学习操作javac,或者学习第三方构建工具,或者学习依赖IDE 的魔力。

如果开发人员能够推迟项目设置阶段,直到他们更多地了解项目的形状,或者甚至在快速破解然后丢弃原型时完全避免设置,那就更好了。一些简单的程序可能永远保留源代码形式。这促使增强java启动器能够运行超出单个.java文件的程序,但无需强制执行显式编译步骤。传统的编辑/构建/运行周期变得简单的编辑/运行。开发人员可以自行决定何时建立构建流程,而不是由于工具的限制而被迫这样做。

描述

我们增强了java启动器的源文件模式,使其能够运行作为多个 Java 源代码文件提供的程序。

例如,假设一个目录包含两个文件Prog.javaHelper.java,其中每个文件声明一个类:

// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); }
}

// Helper.java
class Helper {
static void run() { System.out.println("Hello!"); }
}

运行会在内存中java Prog.java编译该类并调用其方法。因为此类中的代码引用了 class ,所以启动器在文件系统中查找该文件并在内存中编译其类。如果类中的代码引用了其他类,例如,启动器也会找到并编译它。Prog``main``Helper``Helper.java``Helper``HelperAux``HelperAux.java

当不同文件中的类.java相互引用时,java启动器不保证文件编译的任何特定顺序或时间.java。例如,启动器可以Helper.javaProg.java.某些代码可能会在程序开始执行之前进行编译,而其他代码可能会在运行中延迟编译。 (下面详细介绍源文件程序的编译和执行过程。)

.java编译其类被程序引用的文件。这使得开发人员可以使用新版本的代码,而不必担心旧版本会被意外编译。例如,假设该目录还包含OldProg.java,其旧版本的类Prog期望该类Helper具有名为 的方法go,而不是run。运行时,的存在OldProg.java及其潜在错误并不重要Prog.java

多个类可以在一个.java文件中声明,并且全部一起编译。在一个文件中共同声明的类.java优先于在其他.java文件中声明的类。例如,假设Prog.java上面的文件被扩展以声明一个类Helper,尽管已经在 中声明了该名称的类Helper.java。当代码Prog.java引用时,使用Helper共同声明的类;Prog.java启动器将不会搜索该文件Helper.java

源代码程序中禁止出现重复的类。也就是说,不允许在同一.java文件中或在构成程序一部分的不同文件中对具有相同名称的类进行两次声明。.java假设经过一些编辑后,Prog.java结果Helper.java如下所示,并且Aux在两者中意外声明了该类:

// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); Aux.cleanup(); }
}
class Aux {
static void cleanup() { ... }
}

// Helper.java
class Helper {
static void run() { ... }
}
class Aux {
static void cleanup() { ... }
}

运行java Prog.java编译中的ProgAuxProg.java,调用main的方法Prog,然后——由于main的引用Helper——查找Helper.java并编译它的类HelperAux。不允许重复声明Auxin ,因此程序会停止并且启动器会报告错误。Helper.java

启动器的源文件模式java是通过向其传递单个文件的名称来触发的.java。如果给出了额外的文件名,它们将成为其main方法的参数。例如,java Prog.java Helper.java生成一个包含字符串的数组,该字符串"Helper.java"作为参数传递给main该类的方法Prog

使用预编译的类

依赖于类路径或模块路径上的库的程序也可以从源文件启动。例如,假设一个目录包含两个小程序和一个帮助程序类,以及一些库 JAR 文件:

Prog1.java
Prog2.java
Helper.java
library1.jar
library2.jar

您可以通过传递--class-path '*'java启动器来快速运行这些程序:

$ java --class-path '*' Prog1.java
$ java --class-path '*' Prog2.java

这里'*选项的 ' 参数--class-path将所有 JAR 文件放入类路径的目录中;星号被引用是为了避免 shell 的扩展。

当您继续试验时,您可能会发现将 JAR 文件放在单独的libs目录中更方便,在这种情况下--class-path 'libs/*'将使它们可用。您可以开始考虑生成打包的可交付成果,可能是在构建工具的帮助下,直到项目成型之后。

启动器如何查找源文件

启动java器要求将多文件源代码程序的源文件排列在通常的目录层次结构中,其中目录结构遵循包结构,从按如下所述计算的根目录开始。这意味着:

  • 根目录中的源文件必须声明未命名包中的类,并且
  • 根目录下的目录中的源文件foo/bar必须声明指定包中的类foo.bar

例如,假设一个目录包含Prog.java,它在未命名的包中声明类,还有一个子目录pkg,其中在包中Helper.java声明类:Helper``pkg

// Prog.java
class Prog {
public static void main(String[] args) { pkg.Helper.run(); }
}

// pkg/Helper.java
package pkg;
class Helper {
static void run() { System.out.println("Hello!"); }
}

运行java Prog.java会导致Helper.java在子目录中找到pkg并在内存中进行编译,从而产生pkg.Helperclass 中的代码所需的类Prog

如果Prog.java在命名包中声明类,或者Helper.java在除 之外的包中声明类pkgjava Prog.java则将失败。

启动器根据初始文件的包名称和文件系统位置java计算源树的_根_.java。对于java Prog.java,初始文件是Prog.java,它在未命名的包中声明了一个类,因此源代码树的根目录是包含Prog.java.另一方面,如果Prog.java在指定包中声明一个类a.b.c,则必须将其放置在层次结构中的相应目录中:

dir/
a/
b/
c/
Prog.java

它还必须通过运行来启动java dir/a/b/c/Prog.java。在这种情况下,源树的根是dir

如果Prog.java将其包声明为b.c,那么源树的根将是dir/a;如果它声明了包c,那么根就是dir/a/b,如果它没有声明包,那么根就是dir/a/b/c。如果Prog.java声明了一些其他包(例如p,与文件系统中文件路径的后缀不对应),程序将无法启动。

一个微小但不兼容的更改

在上面的示例中,如果Prog.java在不同的命名包中声明类,java a/b/c/Prog.java则会失败。这是java启动器源文件模式行为的变化。

在过去的版本中,启动器的源文件模式允许在.java给定位置的文件中声明哪个包(如果有):java a/b/c/Prog.java只要Prog.java在 中找到,就会成功a/b/c,无论package文件中是否有任何声明。文件在命名包中声明类而不将该文件驻留在层次结构中的相应目录中是不常见的.java,因此此更改的影响可能是有限的。如果包名称不重要,则修复方法是package从文件中删除声明。

模块化源代码程序

在到目前为止所示的示例中,从.java文件编译的类驻留在未命名的模块中。但是,如果源树的根包含一个module-info.java文件,则该程序被认为是模块化的,并且从.java源树中的文件编译的类驻留在 中声明的命名模块中module-info.java

使用当前目录中的模块化库的程序可以像这样运行:

$ java -p . pkg/Prog1.java
$ java -p . pkg/Prog2.java

或者,如果模块化 JAR 文件位于libs目录中,则-p libs将使它们可用。

启动时语义和操作

从 JDK 11 开始,启动器的源文件模式就好像

java <other options> --class-path <path> <.java file>

非正式地相当于

javac <other options> -d <memory> --class-path <path> <.java file>
java <other options> --class-path <memory>:<path> <first class in .java file>

由于能够启动多文件源代码程序,源文件模式现在就像

java <other options> --class-path <path> <.java file>

非正式地相当于

javac <other options> -d <memory> --class-path <path> --source-path <root> <.java file>
java <other options> --class-path <memory>:<path> <launch class of .java file>

其中是前面定义的<root>源树的计算根,是文件的_启动类_,如下定义。 (使用表示初始文件中提到的类可以引用源树中其他文件中声明的类。同位于一个文件中的类优先于位于其他文件中的类;例如,如果出现以下情况,则调用将不会编译:声明类。)<launch class of .java file>``.java``--source-path``javac``.java``.java``.java``.java``javac --source-path dir dir/Prog.java``Helper.java``Prog.java``Helper

java启动器以源文件模式运行时(例如,java Prog.java),它将执行以下步骤:

  1. 如果文件以“shebang”行开头,即以 开头的行#!,则传递给编译器的源路径为空,因此不会编译其他源文件。继续执行步骤 4。

  2. 计算作为源树根的目录。

  3. 确定源代码程序的模块。如果module-info.java根目录中存在文件,则其模块声明用于定义命名模块,该模块将包含从.java源树中的文件编译的所有类。如果module-info.java不存在,则从文件编译的所有类.java都将驻留在未命名的模块中。

  4. 编译初始文件中的所有类.java,以及可能.java声明初始文件中的代码引用的类的其他文件,并将生成的class文件存储在内存缓存中。

  5. 确定初始文件的_启动类_.java。如果初始文件中的第一个顶级类声明了一个标准main方法(或JEP 463中定义的public static void main(String[])其他标准入口点),则该类是启动类。否则,如果初始文件中的另一个顶级类声明了标准方法并且与该文件具有相同的名称,则该类是启动类。否则,没有启动类,启动器会报告错误并停止。main``main

  6. 使用自定义类加载器从内存缓存中加载启动类,然后调用main该类的标准方法。

步骤 5 中选择启动类的过程保留了与JEP 330 的兼容性,并确保main当源程序从一个文件增长到多个文件时使用相同的方法。它还确保“shebang”文件继续工作,因为在此类文件中声明的类的名称可能与文件的名称不匹配。最后,它保持了尽可能接近启动用 编译的程序的体验javac,以便当源程序增长到需要javac显式运行并执行class文件时,可以使用相同的启动类。

当调用步骤 6 中的自定义类加载器来加载类(启动类或运行程序时需要加载的任何其他类)时,加载器会在编译时执行模仿javac's选项顺序的搜索。-Xprefer:source特别是,如果一个类同时存在于源树(在文件中声明.java)和类路径(在.class文件中)中,则首选源树中的类。加载器对名为的类的搜索算法C是:

  1. C如果在内存缓存中找到了类文件,则加载器将缓存的类文件定义到JVM,并且加载C完成。

  2. 否则,加载器委托应用程序类加载器搜索C由命名模块导出的类文件,该模块由源代码程序的模块读取,并且也存在于模块路径或 JDK 中运行时图像。 (源代码程序可能驻留在其中的未命名模块读取JDK 运行时映像中的一组默认模块C。)如果找到,则由应用程序类加载器完成加载。

  3. 否则,加载器将搜索.java名称与类(如果请求的类是成员类则为封闭类)的名称相匹配的文件,即C.java位于与该类的包对应的目录中的文件。如果找到,.java则编译文件中声明的所有类。如果编译成功,则生成的类文件将存储在内存缓存中,加载器C使用缓存的类文件将类定义到 JVM,加载C完成。如果编译失败,启动器会报告错误并以非零退出状态终止。

    编译时C.java,启动器可能会选择立即编译.java声明所引用的类的其他文件C.java,并将生成的类文件存储在内存缓存中。此选择基于可能在 JDK 版本之间发生变化的启发式方法。

  4. C否则,如果源代码程序驻留在未命名的模块中,则加载器将委托应用程序类加载器在类路径上搜索类文件。如果找到,则由C应用程序类加载器完成加载。

  5. C否则,无法找到名为的类,并且加载器会抛出ClassNotFoundException.

从类路径或模块路径加载的类不能引用从.java文件在内存中编译的类。也就是说,当遇到预编译类中的类引用时,永远不会查阅源代码树。

编译时编译与启动时编译之间的差异

Java 编译器在使用启动器时在源路径上编译代码的方式与在源文件模式下javac使用启动器时编译代码的方式存在一些主要差异。java

  • 在源文件模式下,在文件中找到的类声明.java可以在程序执行期间按需增量编译,而不是在执行开始之前一次性编译。这意味着如果发生编译错误,则启动程序将在程序开始执行后终止。此行为与通过显式编译进行原型设计不同javac,但它在源文件模式启用的快速移动编辑/运行周期中有效地工作。

  • 通过反射访问的类的加载方式与直接访问的类相同。例如,如果程序调用Class.forName("pkg.Helper")启动器的自定义类加载器将尝试加载Helper包中的类pkg,可能会导致编译pkg/Helper.java.类似地,如果通过查询包的注释,Package::getAnnotations则源树中适当放置的package-info.java文件(如果存在)将在内存中编译并加载。

  • 注释处理被禁用,类似于--proc:none传递给时javac

  • 无法运行.java文件跨越多个模块的源代码程序。

最后两个限制将来可能会被删除。

备择方案

  • 我们可以将源代码程序限制为单个文件,并继续要求对多文件程序进行单独的编译步骤。虽然这并没有给开发人员带来更多的工作,但现实情况是,许多 Java 开发人员已经不熟悉直接使用构建javac工具,而在需要编译为类文件时更喜欢依赖构建工具。使用该java命令比使用javac.

  • 我们可以javac通过方便的默认值来编译完整的源代码树,从而使其更易于使用。然而,需要为生成的类文件设置一个目录,否则它们会污染源代码树,这会阻碍快速原型设计。开发人员经常将其.java文件置于版本控制之下,甚至在修补阶段也是如此,因此需要设置其版本控制存储库以排除javac.