JEP 458: Launch Multi-File Source-Code Programs

OwnerRon Pressler
TypeFeature
ScopeJDK
StatusClosed / Delivered
Release22
Componenttools / launcher
Discussioncompiler dash dev at openjdk dot org
EffortS
Relates toJEP 330: Launch Single-File Source-Code Programs
Reviewed byAlex Buckley, Brian Goetz
Endorsed byBrian Goetz
Created2023/03/17 10:17
Updated2023/12/05 18:43
Issue8304400

Summary

Enhance the java application launcher to be able to run a program supplied as multiple files of Java source code. This will make the transition from small programs to larger ones more gradual, enabling developers to choose whether and when to go to the trouble of configuring a build tool.

Non-Goals

Motivation

The Java programming language excels for writing large, complex applications developed and maintained over many years by large teams. Still, even large programs start small. In the early stages, developers tinker and explore and do not care about deliverable artifacts; the project's structure may not yet exist, and once it emerges, it changes frequently. Fast iteration and radical change are the order of the day. Several features to assist with tinkering and exploration have been added to the JDK in recent years, including JShell (an interactive shell for playing with snippets of code) and a simple web server (for quick prototyping of web apps).

In JDK 11, JEP 330 enhanced the java application launcher to be able to run .java source files directly, without an explicit compilation step. For example, suppose the file Prog.java declares two classes:

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

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

Then running

$ java Prog.java

compiles both classes in memory and executes the main method of the first class declared in that file.

This low-ceremony approach to running a program has a major limitation: All of the source code of the program must be placed in a single .java file. To work with more than one .java file, developers must return to compiling source files explicitly. For experienced developers, this often entails creating a project configuration for a build tool, but shifting from amorphous tinkering to formal project structure is irksome when trying to get ideas and experiments to flow smoothly. For beginning developers, the transition from a single .java file to two or more files requires an even starker phase change: They must pause their learning of the language and learn to operate javac, or learn a third-party build tool, or learn to rely on the magic of an IDE.

It would be better if developers could defer the project-setup stage until they understand more about the shape of the project, or even avoid setup altogether when quickly hacking and then throwing away a prototype. Some simple programs might remain in source form forever. This motivates enhancing the java launcher to be able to run programs that have grown beyond a single .java file, but without forcing an explicit compilation step. The traditional edit/build/run cycle becomes simply edit/run. Developers can decide for themselves when it is time to set up a build process rather be forced to do so by the limitations of the tooling.

Description

We enhance the java launcher's source-file mode to be able to run a program supplied as multiple files of Java source code.

For example, suppose a directory contains two files, Prog.java and Helper.java, where each file declares a single class:

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

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

Running java Prog.java compiles the Prog class in memory and invokes its main method. Because code in this class refers to the class Helper, the launcher finds the Helper.java file in the filesystem and compiles its class in memory. If code in class Helper refers to some other class, e.g., HelperAux, then the launcher finds HelperAux.java and compiles that, too.

When classes in different .java files refer to each other, the java launcher does not guarantee any particular order or timing for the compilation of the .java files. It is possible, for example, for the launcher to compile Helper.java before Prog.java. Some code may be compiled before the program starts executing while other code may be compiled lazily, on the fly. (The process of compiling and executing source-file programs is described in detail below.)

Only .java files whose classes are referenced by the program are compiled. This allows developers to play with new versions of code without worrying that old versions will be compiled accidentally. For example, suppose the directory also contains OldProg.java, whose older version of the Prog class expects the Helper class to have a method named go rather than run. The presence of OldProg.java, with its latent error, is immaterial when running Prog.java.

Multiple classes can be declared in one .java file, and are all compiled together. Classes co-declared in a .java file are preferred to classes declared in other .java files. For example, suppose the file Prog.java above is expanded to declare a class Helper, despite a class of that name already being declared in Helper.java. When code in Prog.java refers to Helper, the class that is co-declared in Prog.java is used; the launcher will not search for the file Helper.java.

Duplicate classes in source-code programs are prohibited. That is, two declarations of a class with the same name in either the same .java file, or across different .java files that form part of the program, are not permitted. Suppose that, after some edits, Prog.java and Helper.java end up as shown below, with the class Aux accidentally declared in both:

// 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() { ... }
}

Running java Prog.java compiles the Prog and Aux classes in Prog.java, invokes the main method of Prog, and then — due to main's reference to Helper — finds Helper.java and compiles its classes Helper and Aux. The duplicate declaration of Aux in Helper.java is not permitted, so the program stops and the launcher reports an error.

The source-file mode of the java launcher is triggered by passing it the name of a single .java file. If additional filenames are given, they become arguments to its main method. For example, java Prog.java Helper.java results in an array containing the string "Helper.java" being passed as an argument to the main method of the Prog class.

Using pre-compiled classes

Programs that depend on libraries on the class path or the module path can also be launched from source files. For example, suppose a directory contains two small programs plus a helper class, alongside some library JAR files:

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

You can quickly run these programs by passing --class-path '*' to the java launcher:

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

Here the '*' argument to the --class-path option puts all the JAR files in the directory on the class path; the asterisk is quoted to avoid expansion by the shell.

As you continue to experiment, you might find it more convenient to put the JAR files in a separate libs directory, in which case --class-path 'libs/*' will make them available. You can start thinking about producing a packaged deliverable, probably with the help of a build tool, only later, as the project takes shape.

How the launcher finds source files

The java launcher requires the source files of a multi-file source-code program to be arranged in the usual directory hierarchy in which directory structure follows package structure, starting with a root directory that is computed as described below. This means that:

For example, suppose a directory contains Prog.java, which declares classes in the unnamed package, and a subdirectory pkg, where Helper.java declares the class Helper in the package 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!"); }
}

Running java Prog.java causes Helper.java to be found in the pkg subdirectory and compiled in memory, resulting in the class pkg.Helper that is needed by code in class Prog.

If Prog.java declared classes in a named package, or Helper.java declared classes in a package other than pkg, then java Prog.java would fail.

The java launcher computes the root of the source tree from the package name and filesystem location of the initial .java file. For java Prog.java, the initial file is Prog.java and it declares a class in the unnamed package, so the root of the source tree is the directory containing Prog.java. On the other hand, if Prog.java declares a class in the named package a.b.c then it must be placed in the corresponding directory in the hierarchy:

dir/
    a/
      b/
        c/
          Prog.java

It must also be launched by running java dir/a/b/c/Prog.java. In this case, the root of the source tree is dir.

If Prog.java had declared its package to be b.c, then the root of the source tree would have been dir/a; if it had declared the package c, then the root would have been dir/a/b, and if it had declared no package, then the root would have been dir/a/b/c. The program will fail to launch if Prog.java declares some other package, e.g. p, that does not correspond to a suffix of the file's path in the filesystem.

A minor but incompatible change

If, in the above example, Prog.java declared classes in a different named package then java a/b/c/Prog.java would fail. This is a change in the behavior of the java launcher's source-file mode.

In past releases, the launcher's source-file mode was permissive about which package, if any, was declared in a .java file at a given location: java a/b/c/Prog.java would succeed as long as Prog.java was found in a/b/c, regardless of any package declaration in the file. It is unusual for a .java file to declare classes in a named package without that file residing in the corresponding directory in the hierarchy, so the impact of this change is likely to be limited. If the package name is not important then the fix is to remove the package declaration from the file.

Modular source-code programs

In the examples shown thus far, the classes compiled from .java files have resided in the unnamed module. If the root of the source tree contains a module-info.java file, however, then the program is considered to be modular and the classes compiled from .java files in the source tree reside in the named module declared in module-info.java.

Programs that make use of modular libraries in the current directory can be run like so:

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

Alternatively, if the modular JAR files are in a libs directory then -p libs will make them available.

Launch-time semantics and operation

Since JDK 11, the launcher's source-file mode has worked as if

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

is informally equivalent to

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

With the ability to launch multi-file source-code programs, source-file mode now works as if

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

is informally equivalent to

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>

where <root> is the computed root of the source tree as defined earlier and <launch class of .java file> is the launch class of the .java file as defined below. (The use of --source-path indicates to javac that classes mentioned in the initial .java file may refer to classes declared in other .java files in the source tree. Classes co-located in a .java file are preferred to classes located in other .java files; for example, invoking javac --source-path dir dir/Prog.java will not compile Helper.java if Prog.java declares the class Helper.)

When the java launcher runs in source-file mode (e.g., java Prog.java) it takes the following steps:

  1. If the file begins with a "shebang" line, that is, a line that starts with #!, then the source path passed to the compiler is empty so that no other source files will be compiled. Proceed to step 4.

  2. Compute the directory which is the root of the source tree.

  3. Determine the module of the source-code program. If a module-info.java file exists in the root then its module declaration is used to define a named module that will contain all the classes compiled from .java files in the source tree. If module-info.java does not exist then all the classes compiled from .java files will reside in the unnamed module.

  4. Compile all the classes in the initial .java file, and possibly other .java files which declare classes referenced by code in the initial file, and store the resulting class files in an in-memory cache.

  5. Determine the launch class of the initial .java file. If the first top level class in the initial file declares a standard main method (public static void main(String[]) or other standard main entry points as defined in JEP 463), then that class is the launch class. Otherwise, if another top level class in the initial file declares a standard main method and has same name as the file, that class is the launch class. Otherwise, there is no launch class, and the launcher reports an error and stops.

  6. Use a custom class loader to load the launch class from the in-memory cache, then invoke the standard main method of that class.

The procedure in step 5 for choosing the launch class preserves compatibility with JEP 330 and ensures that the same main method is used when a source program grows from one file to multiple files. It also ensures that "shebang" files continue to work, since the name of the class declared in such a file might not match the name of the file. Finally, it maintains an experience as close as possible to that of launching a program compiled with javac, so that when a source program grows to the point that it is desirable to run javac explicitly and execute the class files, the same launch class can be used.

When the custom class loader in step 6 is invoked to load a class — either the launch class or any other class that needs to be loaded while running the program — the loader performs a search that mimics the order of javac's -Xprefer:source option at compile time. In particular, if a class exists both in the source tree (declared in a .java file) and on the class path (in a .class file) then the class in the source tree is preferred. The loader's search algorithm for a class named C is:

  1. If a class file for C is found in the in-memory cache then the loader defines the cached class file to the JVM, and loading of C is complete.

  2. Otherwise, the loader delegates to the application class loader to search for a class file for C that is exported by a named module which is read by the module of the source-code program and, also, is present on the module path or in the JDK run-time image. (The unnamed module, in which the source-code program may reside, reads a default set of modules in the JDK run-time image.) If found, loading of C is completed by the application class loader.

  3. Otherwise, the loader searches for a .java file whose name matches the name of the class (or the enclosing class if the requested class is a member class), i.e. C.java, located in the directory corresponding to the package of the class. If found, all the classes declared in the .java file are compiled. If compilation succeeds then the resulting class files are stored in the in-memory cache, the loader defines the class C to the JVM using the cached class file, and loading of C is complete. If compilation fails then the launcher reports the error and terminates with a non-zero exit status.

    When compiling C.java, the launcher may choose to eagerly to compile other .java files that declare classes referenced by C.java, and store the resulting class files in the in-memory cache. This choice is based on heuristics that may change between JDK releases.

  4. Otherwise, if the source-code program resides in the unnamed module, the loader delegates to the application class loader to search for a class file for C on the class path. If found then loading of C is completed by the application class loader.

  5. Otherwise, a class named C cannot be found, and the loader throws a ClassNotFoundException.

Classes loaded from the class path or the module path cannot reference classes that are compiled in memory from .java files. That is, when class references in pre-compiled classes are encountered, the source tree is never consulted.

Differences between compilation at compile time versus launch time

There are some major differences between how the Java compiler compiles code on the source path when using javac and how it compiles code when using the java launcher in source-file mode.

The last two limitations may be removed in the future.

Alternatives