Skip to content
academia.sh

Lesson 04 / 12

Packaging and Executable Output

The same archiving call produces both a self-sufficient archive and an archive dependent on an outside class: the first is sufficient on its own on the runtime class path, the second drops with a NoClassDefFoundError on its own and only runs once the missing directory is added. The entry point, too, lives not in the source but in the archive's own manifest: an undeclared archive produced from the same classes has no such information at all.

Published Updated

In this lesson

  • Packages class files into a single portable product by actually calling the archiver
  • Shows by reading it that entry point information lives not in the source but in the archive's manifest
  • Measures that an archive does not include its dependencies, by running a self-sufficient and a dependent archive side by side
Contents

The previous lesson measured that the compile-time counterpart of a module boundary is a class path and loader decision: the boundary depended neither on the source’s directory structure nor on its package name, only on which class files were given to the consuming side. But that whole measurement still worked through scattered directories — the runtime class path was a list of separately built, standalone directories. This lesson turns that list into a single portable product: the compiled classes are placed into an archive, and the archive itself can now travel as a single item of the runtime class path.

In the first lesson, the phase sequence derived from a declaration was limited to four phases: clean, compile sources, build the compile class path, build the runtime class path. Packaging is a natural extension of this sequence — a fifth step that, after the source is compiled and the two class paths are built, compresses part of the runtime class path into a single portable file. This step’s input is always the same: a directory holding previously compiled class files. Its output is something never seen so far: a single file that can be opened and read on its own, and that can carry an entry point within itself — executable output.

Archiving and the Manifest

An archive is a single file into which the class files of a directory have been packaged; the archiver (a part of the standard library, a tool provider called from inside the program) really does this packaging. The only input given to the archiver is which directory is to be packaged and, optionally, the name of an entry point; the archiver produces an archive file and a manifest from these two.

  • BT20. The archiver call packages the directory holding the class files exactly as it is; no dependency directory is added to this call. What is packaged is only the content of the given directory.

The archive method below runs this single compile task with three parameters: which archive file is to be produced, which directory is to be packaged, and, optionally, which class counts as the entry point. No information beyond these three enters the archiving call — in particular, no dependency list, no other class path. This restriction is deliberate: the second half of this lesson measures exactly the consequence of this restriction.

// PackageHelper.java — shared operations for writing sources, compiling, and archiving
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class PackageHelper {
    static Path write(Path dir, String name, String source) throws IOException {
        Files.createDirectories(dir);
        Path p = dir.resolve(name + ".java");
        Files.writeString(p, source);
        return p;
    }

    static boolean compile(Path output, List<Path> classPath, Path... sources) throws IOException {
        Files.createDirectories(output);
        List<String> arg = new ArrayList<>(List.of("-d", output.toString()));
        if (!classPath.isEmpty()) {
            StringJoiner sj = new StringJoiner(File.pathSeparator);
            for (Path p : classPath) sj.add(p.toString());
            arg.add("-classpath");
            arg.add(sj.toString());
        }
        for (Path s : sources) arg.add(s.toString());
        ToolProvider compiler = ToolProvider.findFirst("javac").orElseThrow();
        StringWriter silent = new StringWriter();
        int result = compiler.run(new PrintWriter(silent), new PrintWriter(silent), arg.toArray(new String[0]));
        return result == 0;
    }

    static void archive(Path jarPath, Path classesDir, String mainClass) throws IOException {
        Files.createDirectories(jarPath.getParent());
        List<String> arg = new ArrayList<>(List.of("--create", "--file", jarPath.toString()));
        if (mainClass != null) { arg.add("--main-class"); arg.add(mainClass); }
        arg.add("-C"); arg.add(classesDir.toString()); arg.add(".");
        ToolProvider archiver = ToolProvider.findFirst("jar").orElseThrow();
        StringWriter silent = new StringWriter();
        archiver.run(new PrintWriter(silent), new PrintWriter(silent), arg.toArray(new String[0]));
    }

    static String run(List<Path> classPath, String mainClass) throws Exception {
        URL[] url = new URL[classPath.size()];
        for (int i = 0; i < url.length; i++) url[i] = classPath.get(i).toUri().toURL();
        try (URLClassLoader loader = new URLClassLoader(url, PackageHelper.class.getClassLoader().getParent())) {
            Class<?> cls = Class.forName(mainClass, true, loader);
            cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
            return "ok";
        } catch (java.lang.reflect.InvocationTargetException e) {
            return e.getCause().getClass().getSimpleName();
        } catch (Throwable t) {
            return t.getClass().getSimpleName();
        }
    }
}

archive does only two things: it packages the given directory, and if one is given, it writes an entry point name into the manifest. run does the same operation as in the previous lessons — with one difference: an item on the class path can now be an archive file, not just a directory; URLClassLoader accepts an archive file’s location the same way it accepts a directory.

The manifest sits inside the archive as a separate record — alongside the class files, but not one of them. A manifest can carry many fields; this lesson uses only one: Main-Class. This field carries a class name (a string), it contains no executable code at all. The side reading the manifest — here our own code, in a real setup a launcher — takes this string, finds the class carrying that name inside the archive, and calls its main method. The entry point itself is still an ordinary Java class; the only thing that changes is where its name is read from.

The Entry Point Lives Not in the Source, but in the Manifest

An archive can carry dozens of classes, and only one of them (or none) may contain a main method. The side that wants to run the archive itself — a launcher, or in this lesson our own code — has to know which class to start from, but it does not find this information by scanning the archive’s class files one by one. Where it looks is fixed: a single field in the archive’s manifest.

  • BT21. Entry point information is read only by looking at the manifest’s Main-Class field; this information is never searched for in any of the source files.
// ExecutableOutput.java — the archiver is really called, the entry point lives in the manifest, an archive does not include dependencies
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.jar.*;

public class ExecutableOutput {
    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("package");
        Path source = root.resolve("source");

        Path sStandalone = PackageHelper.write(source, "Standalone",
                "public class Standalone { public static void main(String[] a) { } }");
        Path standaloneD = root.resolve("standaloneD");
        PackageHelper.compile(standaloneD, List.of(), sStandalone);

        Path jarWithEntryPoint = root.resolve("withEntryPoint.jar");
        PackageHelper.archive(jarWithEntryPoint, standaloneD, "Standalone");

        Path jarWithoutEntryPoint = root.resolve("withoutEntryPoint.jar");
        PackageHelper.archive(jarWithoutEntryPoint, standaloneD, null);

        System.out.println("-- entry point is not in the source, it is in the archive's manifest --");
        try (JarFile jf = new JarFile(jarWithEntryPoint.toFile())) {
            String main = jf.getManifest().getMainAttributes().getValue("Main-Class");
            System.out.println("entry point of the archive with a manifest    : " + main);
        }
        try (JarFile jf = new JarFile(jarWithoutEntryPoint.toFile())) {
            Manifest m = jf.getManifest();
            String main = (m == null) ? null : m.getMainAttributes().getValue("Main-Class");
            System.out.println("entry point of the archive without one        : " + main);
        }
        System.out.println("does the archive with a manifest run from itself: "
                + PackageHelper.run(List.of(jarWithEntryPoint), "Standalone"));

        System.out.println();
        System.out.println("-- limiting measurement: an archive does not include its dependencies --");

        Path sHelper = PackageHelper.write(source, "Helper",
                "public class Helper { public static String help() { return \"helper\"; } }");
        Path sDependent = PackageHelper.write(source, "Dependent",
                "public class Dependent { public static void main(String[] a) { Helper.help(); } }");
        Path dependentD = root.resolve("dependentD");
        PackageHelper.compile(dependentD, List.of(), sHelper, sDependent);

        Path dependentOnlyDir = root.resolve("dependentOnly");
        Files.createDirectories(dependentOnlyDir);
        Files.copy(dependentD.resolve("Dependent.class"), dependentOnlyDir.resolve("Dependent.class"));
        Path dependentJar = root.resolve("dependent.jar");
        PackageHelper.archive(dependentJar, dependentOnlyDir, "Dependent");

        Path helperD = root.resolve("helperD");
        Files.createDirectories(helperD);
        Files.copy(dependentD.resolve("Helper.class"), helperD.resolve("Helper.class"));

        String standaloneResult = PackageHelper.run(List.of(jarWithEntryPoint), "Standalone");
        String dependentAlone = PackageHelper.run(List.of(dependentJar), "Dependent");
        String dependentPlusHelper = PackageHelper.run(List.of(dependentJar, helperD), "Dependent");

        System.out.printf("%-48s %s%n", "Standalone archive, runtime class path only the archive:", standaloneResult);
        System.out.printf("%-48s %s%n", "Dependent archive, runtime class path only the archive:", dependentAlone);
        System.out.printf("%-48s %s%n", "Dependent archive, once Helper dir is added:", dependentPlusHelper);
    }
}
-- entry point is not in the source, it is in the archive's manifest --
entry point of the archive with a manifest    : Standalone
entry point of the archive without one        : null
does the archive with a manifest run from itself: ok

-- limiting measurement: an archive does not include its dependencies --
Standalone archive, runtime class path only the archive: ok
Dependent archive, runtime class path only the archive: NoClassDefFoundError
Dependent archive, once Helper dir is added:     ok

The first three lines measure where the entry point stands. Two archives are produced from the same Standalone class: one with an entry point (--main-class Standalone), the other with no entry point given at all. When the manifest-bearing archive’s manifest is read, the Main-Class field carries the value Standalone; the manifest-less archive’s manifest has no such field at all, the value read is null. Both archives carry the exact same class file, coming from the exact same compile — the only difference between them is a single line in the manifest. The source file itself carries no such thing as entry point information at all; Standalone.java neither says “I am the entry point” nor carries any such marker. This information is written only at archiving time, into the archive’s own manifest — and, as the third line shows, the archive can run on its own using this manifest.

This is one layer beyond the entry point concept established in the Java Fundamentals course. There, the entry point’s signature (the shape of a main method) was fixed; here the question asked is different: in an archive holding more than one class, which class is the entry point? The answer to this question no longer lives in the source, it lives in the manifest of the archive being worked on. A manifest-less archive is also a valid archive — it carries class files, it can be opened, its contents can be read — but the answer to “which class to start from” is not in that archive. This gap only becomes visible when the manifest is read; the archive itself never says so explicitly anywhere, it just stands as a field that returns null.

Limiting Measurement: An Archive Does Not Include Its Dependencies

  • BT22. The Standalone archive never mentions any outside type; the Dependent archive needs the Helper type outside itself at call time. Both are produced with the same archiving call.
  • BT23. The Dependent archive contains only its own class file; Helper’s class file is deliberately left out, so that the need for the rest of the runtime class path can be measured.
  • BT24. The runtime error arises not from the archive itself but from the class path the archive was placed on alone; the same archive placed on a different class path does not produce the same error.
  • BT25. A manifest-less archive is also a valid archive and carries class files; only the entry point information is missing, and this absence is only visible once the manifest is read.

The last three lines run two archives side by side. The Standalone archive, when placed alone on the runtime class path, runs without trouble — because Standalone.main never mentions any outside type at all, everything it needs is in its own class file. The Dependent archive, in the same way, drops with a NoClassDefFoundError when placed alone on the runtime class path: Dependent.main calls Helper.help(), but Helper’s class file is not inside this archive — only Dependent.class was deliberately packaged during archiving. The same Dependent archive runs without trouble once the directory holding Helper is also added to the runtime class path; the archive itself did not change, only the class path placed alongside it did.

These three lines confirm a single rule: an archive does not include its dependencies. The archiver call only packages the given directory; no class file outside that directory is added automatically. Whether an archive is “self-sufficient” or not depends not on the archive’s own content but on which types the source code mentions — and this is a gap between something known at compile time (Dependent required Helper on the compile class path) and something that can be forgotten at packaging time (Helper’s class file never entered the archive at all). A compile having succeeded is no guarantee that the archive will be self-sufficient.

This gap belongs to the same family as the silent defect measured in the Dependency Management lesson, but in reverse form. There, a type not being required on the runtime class path meant it had been copied — the constant’s value was embedded at the call site and the source file could disappear. Here, a type not entering the archive means it is still required, only packaging forgot it. In both cases, the error surfaces not at the moment itself but at a later step: there, when the constant changes; here, when the archive is placed alone on the runtime class path. The difference is the class of the defect: Dependent’s missing dependency is not silent, it drops openly with a NoClassDefFoundError — because the call to Helper is a method call, too dynamic to be embedded at compile time, not a constant.

This also clarifies the archiving step’s own responsibility: an archive should not be expected to gather its dependencies “automatically.” Which class files enter an archive, just like which dependency enters which class path, is a decision determined by the input given to the packaging call — and giving that decision incompletely can turn into a runtime error that never shows up at compile time at all.

The summary of what has been measured across four lessons is completed here. A fixed phase sequence was derived from a declaration; two of those phases built two separate class paths by filtering every dependency in the declaration according to a scope label; the boundary between a module’s public and internal surfaces was determined by which directory entered one of these two paths; and now one of these paths — the runtime class path — has been packaged into a single portable file. In none of the four steps did the source text itself change; what changed was always the declaration, always the class path.

Summary

  • An archive is what results from the class files in a directory being packaged by the archiver into a single file; it can travel as a single item of the runtime class path.
  • Entry point information lives not in the source file but in the Main-Class field of the archive’s own manifest; a manifest-less archive is valid but its entry point information is missing.
  • An archive does not include its dependencies: the archiving call only packages the given directory, no class file outside that directory is added automatically.
  • A self-sufficient archive (one that mentions no outside type) works when placed alone on the runtime class path; a dependent archive drops with a runtime error under the same condition and only works once the missing class path is completed.
  • A compile having succeeded is not a guarantee that an archive will be self-sufficient; a type required on the compile class path may never have entered the archive at all during packaging.

Next Step

A single question was followed across this topic’s four lessons: a declaration, a dependency, a module boundary, an archive — the same two-way class path distinction came out every time. The runtime class path has now become a portable product, and what goes into it is a decision. The next topic gives the same decision for a third path — the path a test sees is not the path production sees.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close