Skip to content
academia.sh

Lesson 01 / 12

Build Tool Model

The phase sequence a build tool derives from a declaration is fixed and unaffected by the declaration's writing order: three declared items yield four phases, and both a forward and a reversed writing of the same declaration derive the same four phases. Two class paths coming out of the same declaration — one for compiling, one for running — are separated by an annotation type kept at the source level: it never appears in the class file's constant pool, and it is never required on the runtime class path either.

Published

In this lesson

  • Separates the phase sequence derived from a declaration from the declaration's writing order
  • Counts and shows that the number of declared items and the number of derived phases are not the same measure
  • Actually runs and separates the compile class path from the runtime class path that come out of the same declaration
Contents

The closing lesson of the JVM, Concurrency and Performance course measured a single question: which thread runs a step could not be read from the call syntax — it could only be seen by running it. The same line was the same syntax whether it ran on one thread or another; what decided the running side sat outside the source text.

This lesson asks the same question one layer out. A Java program does not live against a single class path, it lives against two separate class paths: the one it was compiled against and the one it runs on. The source text — even the compiled class file itself — does not say what these two paths need to contain; what says it is a declaration standing outside the source. What the compiler needs to see a type and what the runtime needs to find that same type are not the same set, and the tool that produces this distinction is this lesson’s and this topic’s question.

The tool’s name is not written down — which product, which syntax is used is not this lesson’s question. What is asked is this: how much of a few lines of declaration does a build tool derive on its own, and why does what it derives split into two separate lists that never appear in the source at all?

All three topics in this course are fields usually referred to by third-party tool names; this is why the build tool itself is built here as a behavior, not a product. What is measured is the rule that derives a phase sequence from a declaration and the two class paths that come out at the end of those phases — not which product accepts which flag. The standard library itself provides the real, running part of this model: the compiler is called from inside the program with ToolProvider, and it actually compiles.

What a Build Tool Derives

What a declaration derives is not a single list, it is two separate things: a phase sequence (what happens in what order) and the class paths built within those phases (which directories each phase looks at). These two things must not be confused with each other. The “compile sources” phase runs a single compile task: it calls the compiler once. The “build compile class path” phase runs a task that goes through every dependency in the declaration one by one — it adds every dependency whose scope is compile or both to the list, and skips every dependency whose scope is runtime only. The same phase, “build runtime class path,” filters the same dependency list this time from the opposite side. The phase itself is one thing, but the compile task within it does a varying amount of work depending on the declaration’s length — this also explains why the two numbers seen separately in the measurement below (declared item, derived phase) need not turn out equal.

The Phase Sequence Derived from a Declaration

What is given to a build tool is not a step-by-step procedure, it is a declaration: which source directory is to be compiled, which dependencies are to be used with which dependency scope. The tool itself derives a build lifecycle from this declaration — a phase sequence that runs in a fixed order. Each phase runs one or more tasks; only the phases themselves are measured here, not the content of the tasks.

The Declaration class below holds a dependency only by its name and its scope; the scope takes one of three values: required only at compile time, required only at run time, or both.

  • BT1. The build tool is built here as a model; no real product’s name, target name, command-line flag, or declaration syntax is used. The phase names and the declaration’s shape are this lesson’s own decision.
  • BT2. Declared item count is a total in which every line of the declaration — the source directory counts as one, each dependency counts as one — counts as a single item. A dependency’s scope does not change the item count.
  • BT3. The derived phase sequence consists of a fixed four phases and is unaffected by the dependency count or by the declaration’s writing order; the order is the tool’s own rule, not the declaration’s preference.
// Declaration.java — the declaration given to a build tool: source directory name and dependency scope labels
import java.util.*;

public class Declaration {
    record Dependency(String name, String scope) {}

    final String sourceName;
    final List<Dependency> dependencies;

    Declaration(String sourceName, List<Dependency> dependencies) {
        this.sourceName = sourceName;
        this.dependencies = dependencies;
    }

    int declaredItemCount() {
        return 1 + dependencies.size();
    }
}
// Phases.java — the phase sequence derived from the declaration: order does not look at the declaration's writing order
import java.util.*;

public class Phases {
    static List<String> derive(Declaration d) {
        List<String> sequence = new ArrayList<>();
        sequence.add("clean");
        sequence.add("compile sources");
        sequence.add("build compile class path");
        sequence.add("build runtime class path");
        return sequence;
    }
}

Phases.derive produces four phases without ever looking at the declaration’s content: cleaning, compiling sources, building the compile class path, building the runtime class path. Whether the declaration’s dependency count is one, two, or ten, the length of the returned sequence does not change — because what is measured is not the dependencies themselves, it is what the tool derives from them.

Two Numbers, Two Class Paths from the Same Declaration

The measurement starts with two separate declarations: the same two dependencies, one written in forward order, the other in reverse order. Tag is marked as required only at compile time, Service as required only at run time. Tag is an annotation type kept at the source level by its retention policy: the compiler uses it in type checking, but it leaves no trace at all in the class file. Access to Service is built the exact opposite way: Program never names it directly at all, it only looks it up by a name string — which is why it never needs to be on the compile class path at all.

  • BT4. Tag is an annotation type kept at the source level by its retention policy; the compiler uses it in type checking and leaves no reference to it in the class file.
  • BT5. Access to Service is built not through a direct type reference but through a call that looks it up by name; this is why it never enters the compile class path at all.
  • BT6. The compile class path and the runtime class path are built as two separate directory lists; there is no directory shared between them at all — Tag’s directory is given only at compile time, Service’s directory only at run time.
// ToolModel.java — the phase sequence derived from a declaration is independent of declaration order, and one declaration yields two class paths
import java.io.*;
import java.lang.classfile.*;
import java.lang.classfile.constantpool.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class ToolModel {

    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 source) 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());
        }
        arg.add(source.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 boolean constantPoolContainsName(Path classFile, String name) throws IOException {
        ClassModel model = ClassFile.of().parse(classFile);
        for (PoolEntry e : model.constantPool()) {
            if (e instanceof ClassEntry ce && ce.asInternalName().equals(name)) return true;
        }
        return false;
    }

    public static void main(String[] args) throws Exception {
        var tag = new Declaration.Dependency("Tag", "compile");
        var service = new Declaration.Dependency("Service", "runtime");

        Declaration forward = new Declaration("source", List.of(tag, service));
        Declaration reverse = new Declaration("source", List.of(service, tag));

        List<String> phasesForward = Phases.derive(forward);
        List<String> phasesReverse = Phases.derive(reverse);

        System.out.println("-- phase sequence, independent of declaration order --");
        System.out.println("phases derived from the forward declaration: " + phasesForward);
        System.out.println("phases derived from the reverse declaration: " + phasesReverse);
        System.out.println("are the two phase sequences the same        : " + phasesForward.equals(phasesReverse));
        System.out.println("declared item count                         : " + forward.declaredItemCount());
        System.out.println("derived phase count                         : " + phasesForward.size());

        Path root = Files.createTempDirectory("tool");
        Path tagD = root.resolve("tag"), serviceD = root.resolve("service"), programD = root.resolve("program");

        Path sTag = write(root.resolve("source"), "Tag",
                "import java.lang.annotation.*;\n"
              + "@Retention(RetentionPolicy.SOURCE)\n"
              + "public @interface Tag { String value(); }");
        Path sService = write(root.resolve("source"), "Service",
                "public class Service { public static String run() { return \"service ran\"; } }");
        Path sProgram = write(root.resolve("source"), "Program",
                "@Tag(value = \"example\")\n"
              + "public class Program {\n"
              + "  public static void main(String[] a) throws Exception {\n"
              + "    Class<?> s = Class.forName(\"Service\");\n"
              + "    String r = (String) s.getMethod(\"run\").invoke(null);\n"
              + "    System.out.println(\"service (reflection): \" + r);\n"
              + "  }\n"
              + "}");

        compile(tagD, List.of(), sTag);
        compile(serviceD, List.of(), sService);

        List<Path> compileClassPath = List.of(tagD);
        boolean programCompiled = compile(programD, compileClassPath, sProgram);
        System.out.println();
        System.out.println("-- one declaration, two class paths --");
        System.out.println("did Program compile against the compile class path (Tag only)        : " + programCompiled);
        System.out.println("does Program.class's constant pool hold a reference to Tag            : "
                + constantPoolContainsName(programD.resolve("Program.class"), "Tag"));

        List<Path> runtimeClassPath = List.of(programD, serviceD);
        URL[] url = new URL[runtimeClassPath.size()];
        for (int i = 0; i < url.length; i++) url[i] = runtimeClassPath.get(i).toUri().toURL();
        try (URLClassLoader loader = new URLClassLoader(url, ToolModel.class.getClassLoader().getParent())) {
            Class<?> cls = Class.forName("Program", true, loader);
            System.out.println("does Program run against the runtime class path (no Tag, has Service):");
            cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
        }
    }
}
-- phase sequence, independent of declaration order --
phases derived from the forward declaration: [clean, compile sources, build compile class path, build runtime class path]
phases derived from the reverse declaration: [clean, compile sources, build compile class path, build runtime class path]
are the two phase sequences the same        : true
declared item count                         : 3
derived phase count                         : 4

-- one declaration, two class paths --
did Program compile against the compile class path (Tag only)        : true
does Program.class's constant pool hold a reference to Tag            : false
does Program run against the runtime class path (no Tag, has Service):
service (reflection): service ran

The first half of the output shows that declaration order does not affect the phase sequence: the phase sequences derived from the forward and the reverse declaration are exactly the same; the equals comparison confirms it correctly. Declared item count is 3 (one source directory, two dependencies), derived phase count is 4 — the two numbers are independent of each other, neither determines the other. What the tool derives depends not on the declaration’s length but on its own rule.

The second half measures the actual claim. Program is compiled against a compile class path holding only Tag, and the compile succeeds — the compiler used Tag in type checking but left no trace in the class file; the name Tag never appears in the constant pool at all; this line is confirmed by reading the class file, not by guessing. Then the same compiled Program is run against an entirely different runtime class path: on this path, Tag does not exist at all, but Service does. The program runs without error and finds Service through reflection. The set of names the compiler saw (Tag, Program) and the set of names the runtime found (Service, Program) do not overlap — from the same single declaration, from the same single compiled class, two separate class paths came out.

These two results must be read together, on the same line. The constant pool check gives negative evidence: Tag is not there, therefore the runtime class path has no need to contain it — this rests directly on the class file itself, not on a guess. The runtime trial gives positive evidence: Service really is found on the runtime class path and really is called. The two pieces of evidence together show that the compile class path and the runtime class path are two separate sets coming out of the same declaration, and that neither is required to be a subset or a superset of the other.

Limiting Measurement: Order Is a Rule, Not a Preference

The first line of the measurement above sets a limit: the phase order turning out independent of declaration order is not an observation, it is a consequence of the tool’s model. Phases.derive never looks at the declaration’s dependencies list at all; it only knows that the declaration exists. This is why the phase sequence does not change even when the same dependency set is given with its writing order reversed — if order were a preference, two declarations would have produced two separate sequences.

This distinction matters, because the relationship between a declaration and a phase sequence is not a mapping, it is a rule application. Deleting or adding a line in the declaration does not change the derived phases’ content, it only changes the scope of the compile tasks inside those phases — for example, the phase that builds the compile class path now contains one more dependency. The phase order itself stays fixed.

The same rule is also independent of the declaration’s length. The declaration in the measurement carried two dependencies; if the declaration had carried five, ten, or none at all, Phases.derive would still return four phases, because the method’s body never reads the dependency list at all. This shows that the tool is not an interpreter, it is a rule applier: no matter how much the input changes, the phase set stays the same, and the only thing that changes is how much data the compile task running inside each phase works with. The six types measured in the next lesson will also pass through this same fixed phase set — both the “build compile class path” and “build runtime class path” phases will see the same six dependencies, but each will select a different subset from among them.

The next lesson deepens this distinction: a dependency’s scope determines not only the list inside that one phase but also the content of the two class paths — and that determination will give the full measure of the two separate sets (one required at compile time, one at run time) seen in this lesson.

Summary

  • A build tool derives a fixed phase sequence from a declaration; the order is independent of the declaration’s writing order and is the tool’s own rule.
  • Declared item count (the number of lines in the declaration) and derived phase count are separate concepts; neither determines the other — here 3 declared items yield 4 phases.
  • Two separate class paths come out of the same declaration: the compile class path and the runtime class path; which one a type is required on depends on how the type is used in the source (a direct reference or a lookup by name).
  • An annotation type kept at the source level by its retention policy (Tag) is an example of a dependency the compiler sees but the class file never carries at all; this is confirmed directly by reading the constant pool.

Next Step

This lesson showed, with two examples — one at compile time, one at run time — which class path a dependency is required on. But when six separate dependency types, including a direct dependency and a transitive dependency, a single interface and two of its implementations, are all present together, the picture becomes far more complicated. The next lesson measures dependency scope itself: of six types, how many are required only at compile time, how many only at run time, how many at both — and of the four defects that appear once the two class paths diverge, how many make no sound at all.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close