public class, a class name that must match the file, a static method, a void return type, a String[] args parameter nobody uses, and System.out, which is a field on a class that is itself a field on a class. None of that is what the beginner came to learn. Java 25 finalizes JEP 512, and the first program becomes three lines: void main(), one call, and a closing brace.
This article explains what that change is, what the compiler and the java launcher quietly do to make it work, and — because the days people lose are in the failures — every error message you will see when you get it slightly wrong. Every claim below was compiled and run on real JDKs, and each code block links to the file it came from in the companion repository.
Versions. Everything here ran on JDK 25.0.4.1 (Temurin, the current LTS) and again on JDK 27+35 (Temurin, GA 15 September 2026). JEP 512 is final in 25, so no--enable-previewand no--sourceflag. The same file on JDK 21 is rejected with “unnamed classes are a preview feature” — that transcript is in the first section. One behaviour changed between 25 and 27 (a private constructor on an instance-main class); it has its own callout below.openjdk.org/jepsreturned HTTP 403 to the tooling used for this article, so behaviour is taken from the JDK binaries themselves rather than from the JEP text.
The first Java program used to need six ideas before it printed anything
Here is the program every Java tutorial has opened with for decades, next to the Java 25 version. Both print the same line.// The same program the way every tutorial before Java 25 wrote it, for the side-by-side in the post.
public class Classic {
public static void main(String[] args) {
System.out.println("Hello, world");
}
}
Source: Classic.java. Each word on the second and third lines is a real concept, and each one has a reason to exist in a large program. public and static tell the launcher it may call the method without creating an object. String[] args carries command-line arguments. System.out is the standard output stream. A beginner cannot use a single one of those words correctly yet, so they copy them, and the copying is the first thing that teaches them programming is incantation.
// The smallest complete Java 25 program. No class, no public, no static, no String[] args.
void main() {
IO.println("Hello, world");
}
Source: Hello.java. There is no class, no modifier, no parameter. The rest of this article is the answer to “where did all that go?”: it did not disappear, the compiler and the launcher supply it.
Two features, one JEP. JEP 512 bundles two ideas that are useful separately. Compact source files let a.javafile contain methods and fields with no enclosing class. Instance main methods letmainbe non-static, non-public and parameterless, in any class, compact or not. You can use the second without the first, and the article demonstrates both.
Going deeper on this section
- Companion repo: jep512 README (how to regenerate every transcript with one script)
- Official reference: JEP 512: Compact Source Files and Instance Main Methods
- Related on this site: Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped
java Hello.java: one command, no separate compile step
You do not needjavac to run this. Since Java 11 the java launcher can take a .java file, compile it in memory, and run it. With JEP 512 that becomes the natural way to start:
$ java src/Hello.java (JDK 25: OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS))
Hello, world
$ java src/Hello.java (JDK 27: OpenJDK Runtime Environment Temurin-27+35 (build 27+35))
Hello, world
$ ls src/*.class
ls: cannot access 'src/*.class': No such file or directory
Output: 01-source-launcher.txt. The same file runs on JDK 25 and JDK 27, and afterwards there is no .class file next to it — the compiled form only ever existed in memory. That is why this suits a first lesson: one file, one command, nothing to clean up.
Now the failure a learner on an older JDK will hit, which is worth recognising because half the tutorials on the internet were written before Java 25:
$ java src/Hello.java (JDK 21: OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04))
src/Hello.java:2: error: unnamed classes are a preview feature and are disabled by default.
void main() {
^
(use --enable-preview to enable unnamed classes)
1 error
error: compilation failed
Same file, JDK 21, and the compiler refuses. On JDK 21 the feature existed only as a preview under a different name, unnamed classes, so the message is accurate for that JDK but misleading for a beginner: it does not say “upgrade to 25”. If a student pastes this error into a search box, the answer they get will be about a flag. The answer for them is a newer JDK.
What to tell students in 2026. Install JDK 25 (the LTS), createHello.javawithvoid main(), runjava Hello.java. If the error says “preview feature”, the JDK is older than 25. Do not teach--enable-previewas the fix; it would make a working program depend on a flag for a feature that no longer needs one.
Going deeper: what “source-file mode” does and does not do
Source-file mode compiles the file in memory and starts the first class it finds. It is not a separate language mode: the source is the same Java, and the error messages come from the same javac (each failure above ends with the launcher’s own line, error: compilation failed). Nothing is written to disk, so there is no incremental compilation and no classpath cache; each run recompiles. For a scratch file that is milliseconds. For anything with a dependency graph you want a build tool.
The launcher will also find other source files next to the one you name, so a small program can grow past one file without a build tool. That is demonstrated near the end of this article, along with the one kind of file it cannot find.
Going deeper on this section
- Companion repo: 01-source-launcher.txt (all three JDKs in one transcript)
- Official reference: the
javacommand reference, JDK 25 (source-file mode)
What the compiler quietly builds around your void main()
A method cannot exist alone on the JVM; every method belongs to a class. So whenjavac sees a file with methods and fields at the top level and no class around them, it builds the class itself. You can see exactly what it built by compiling normally and asking javap:
$ javac -d out src/Hello.java && java -cp out Hello
Hello, world
$ javap -p -cp out Hello
Compiled from "Hello.java"
final class Hello {
Hello();
void main();
}
$ javap -v -cp out Hello | grep -E 'major|flags'
major version: 69
flags: (0x0030) ACC_FINAL, ACC_SUPER
Output: 02-javac-and-javap.txt. Four things to read off it. The class is named Hello, taken from the file name. It is final. It has a constructor you never wrote (Hello();). And main is package-private, not public and not static. The major version: 69 line is just Java 25’s class-file version; it is here to show the result is an ordinary class file, loadable by any JVM 25.
javap printed. A tiny reflection program confirms it from inside the JVM rather than from a disassembler:
$ java Reflect (asks the loaded class)
name : Hello
final : true
public : false
package : '' (unnamed)
superclass : java.lang.Object
declared : [void Hello.main()]
constructors: [Hello()]
Source: Reflect.java, output above from 02-javac-and-javap.txt. The package name is empty (the unnamed package), the superclass is Object, and the only declared method is main. Nothing else was injected, which is reassuring: a compact source file is not a different kind of class, it is an ordinary class you did not have to spell out.
Going deeper: what this means for the rest of your program
Because the wrapper class is an ordinary class, a compact file can hold more than main. Any method you add is an instance method of that class, any field is an instance field, and any record, enum or interface you declare becomes a member of it. The next section does exactly that. The class being final and in the unnamed package has a practical consequence covered in the failure section: nothing outside the file can name it.
The file name matters because the compiler takes the class name from it. That is why the transcript above says Hello, not something generic, and why a compact file called Hello.java compiles to Hello.class.
Going deeper on this section
- Companion repo: Reflect.java (reads the class back through reflection)
- Official reference:
javapcommand reference
Everything on the top level is a member, and java.base is already imported
Once you know the wrapper is a real class, the top level makes sense: whatever you write there is a member of it. A slightly larger program uses fields, a helper method, a nested record, and collections — with no imports at all:// A compact source file is a whole class body: fields, methods, and nested types all sit at the top level.
// Nothing here is imported. List, Map, TreeMap, Collectors and BigDecimal all live in java.base.
String greeting = "hello";
int calls = 0;
record Point(int x, int y) {
double distance() { return Math.sqrt(x * x + y * y); }
}
String shout(String s) {
calls++;
return s.toUpperCase() + "!";
}
void main() {
List<String> words = List.of("banana", "apple", "cherry");
Map<String, Integer> lengths = new TreeMap<>();
for (String w : words) lengths.put(w, w.length());
IO.println(shout(greeting));
IO.println(words.stream().sorted().collect(Collectors.joining(", ")));
IO.println(lengths);
IO.println(new BigDecimal("1.10").add(BigDecimal.ONE));
IO.println(new Point(3, 4) + " is " + new Point(3, 4).distance() + " from the origin");
IO.println("calls so far: " + calls);
}
Source: Members.java. Run it:
$ java src/Members.java
HELLO!
apple, banana, cherry
{apple=5, banana=6, cherry=6}
2.10
Point[x=3, y=4] is 5.0 from the origin
calls so far: 1
Output: 03-members-and-imports.txt. List, Map, TreeMap, Collectors and BigDecimal come from three different packages (java.util, java.util.stream, java.math) and none was imported. All three belong to the java.base module, and a compact source file behaves as if it began with a whole-module import of it — the dashed line in the diagram above.
The boundary of that rule is java.base. HttpClient lives in a different module, java.net.http, so it is not covered:
$ java broken/NotInJavaBase.java (JDK 25)
broken/NotInJavaBase.java:2: error: cannot find symbol
HttpClient client = HttpClient.newHttpClient();
^
symbol: class HttpClient
location: class NotInJavaBase
broken/NotInJavaBase.java:2: error: cannot find symbol
HttpClient client = HttpClient.newHttpClient();
^
symbol: variable HttpClient
location: class NotInJavaBase
2 errors
error: compilation failed
Output: 06-compile-errors.txt, from NotInJavaBase.java. The message is the ordinary “cannot find symbol”, twice (once for the type, once for the static call). It does not say “this module is not imported”, which is the thing a beginner needs to be told. There are two fixes and both work:
// java.net.http is a separate module from java.base, so HttpClient is NOT implicitly imported.
// A normal single-type import fixes it, exactly as in any other Java file.
import java.net.http.HttpClient;
void main() {
HttpClient client = HttpClient.newHttpClient();
IO.println("default HTTP version: " + client.version());
}
Source: NeedsImport.java, the normal single-type import. The other is one line for the whole module:
// The other fix: import the whole module (JEP 511, final in Java 25). One line covers java.net.http.
import module java.net.http;
void main() {
HttpClient client = HttpClient.newHttpClient();
IO.println("default HTTP version: " + client.version());
}
Source: NeedsModuleImport.java. Both print the same thing:
$ java src/NeedsImport.java (java.net.http is outside java.base: a normal import fixes it)
default HTTP version: HTTP_2
$ java src/NeedsModuleImport.java (or import the whole module)
default HTTP version: HTTP_2
Output: 03-members-and-imports.txt. import module is JEP 511, also final in Java 25, and it is what the compact file was doing for java.base implicitly all along.
Going deeper: main(String[] args) is still allowed
The parameter is optional, not banned. A compact file that wants its command-line arguments declares void main(String[] args), and the launcher passes them through as before (WithArgs.java; output in 03-members-and-imports.txt, which shows args.length = 2 for two arguments). The choice between the two forms, and what happens if you declare both, is the subject of a later section.
Going deeper on this section
- Companion repo: Members.java, WithArgs.java
- Related on this site: the module-import article in this series (
import module java.base;, JEP 511) covers ambiguity when two modules export the same simple name - Official reference: JEP 511: Module Import Declarations
IO: print, println and readln, and why println alone does not work
The other half of “no ceremony” is the output line.java.lang.IO is a new final class in Java 25 with three static methods, and because it is in java.lang it needs no import anywhere — not only in compact files:
$ java src/IoInAnyClass.java
IO.println works in a normal class with no import
Output: 04-io-helper.txt, from IoInAnyClass.java, an ordinary public class with a public static void main(String[] args) that calls IO.println. So the helper is not tied to the new syntax; it is a shortcut you can use in a class you write the old way.
It is also a very thin one. Reading the class with javap:
$ javap -p java.lang.IO
Compiled from "IO.java"
public final class java.lang.IO {
private static java.io.BufferedReader br;
private java.lang.IO();
public static void println(java.lang.Object);
public static void println();
public static void print(java.lang.Object);
public static java.lang.String readln();
public static java.lang.String readln(java.lang.String);
static synchronized java.io.BufferedReader reader();
}
Output: 08-io-class.txt. IO has a private constructor that throws (“no instances”), println and print take an Object, and there are two readln overloads. The bytecode listing in the same file shows println calling System.out.println, print calling flush() afterwards, and readln calling BufferedReader.readLine and wrapping any IOException in an IOError. It is System.out with a shorter name, not a new I/O system.
Reading input is where beginners feel the difference. The Scanner version needs an import and a new Scanner(System.in); readln is one call that also prints the prompt:
// java.lang.IO has three static methods: print, println and readln.
void main() {
String name = IO.readln("Your name? ");
String age = IO.readln("Your age? ");
IO.println("Hello " + name + ", " + age + " is a good age to learn Java.");
IO.print("no newline after this, ");
IO.println("newline after this");
}
Source: ReadLn.java. Feeding it two lines on standard input:
$ printf 'Ankur\n30\n' | java src/ReadLn.java
Your name? Your age? Hello Ankur, 30 is a good age to learn Java.
no newline after this, newline after this
Output: 04-io-helper.txt. Two things are visible. The prompts Your name? and Your age? run together on one line because piped input is never echoed back with a newline, whereas at a keyboard you would press Enter after each answer; and print flushes, so the prompt is on screen before the read blocks. The run-together prompts are an artefact of piping, not a bug.
The first thing every learner will try, and it fails. Writingprintln("hello")with noIO.prefix does not compile. The class is available but its static methods are not statically imported into the compact file, so the compiler looks for a methodprintlnon the wrapper class and finds none.
$ java broken/NoIoPrefix.java (JDK 25)
broken/NoIoPrefix.java:2: error: cannot find symbol
println("hello");
^
symbol: method println(String)
location: class NoIoPrefix
1 error
error: compilation failed
Output: 06-compile-errors.txt, from NoIoPrefix.java. If you want the bare form, import static java.lang.IO.*; is an ordinary static import and makes it work:
// The bare println form works only if you import IO's static methods yourself.
import static java.lang.IO.*;
void main() {
println("println without the IO. prefix, after import static java.lang.IO.*");
}
Source: StaticImportIO.java, and its output is in 04-io-helper.txt. Whether to teach that is your call; a lesson that opens with a static import has reintroduced some of the ceremony the JEP removed.
Going deeper: what readln returns when input has run out
When standard input is closed or empty, readln returns null (it is BufferedReader.readLine underneath), and a program that concatenates the result prints the word “null”. The third transcript in 04-io-helper.txt runs ReadLn.java with empty input and prints Hello null, null. In a teaching exercise that reads until a blank line, check for null explicitly. The missing-prefix error above is reproduced on both JDK 25 and JDK 27 in the same file (the transcript shows the two runs); if you write a lesson plan around bare println, re-run the two-line test on the JDK your students actually have.
Going deeper on this section
- Companion repo: 08-io-class.txt (the full
javapofjava.lang.IO) - Official reference:
java.lang.IOAPI documentation
Instance main: which main does the launcher pick?
Compact files rely on the second half of the JEP:main no longer has to be public static void main(String[] args). It may be an instance method, it may drop the parameter, and it does not have to be public. The launcher creates an object of the class with a no-argument constructor and calls main on it. That works in a normal class too:
// Instance main does not need a compact file. An ordinary class works too, and then it can use its own fields.
class InstanceMainInClass {
private final String greeting = "hello from an instance field";
void main() {
IO.println(greeting);
IO.println("this class is " + getClass().getName() + ", final? " + java.lang.reflect.Modifier.isFinal(getClass().getModifiers()));
}
}
Source: InstanceMainInClass.java. It can use its own instance field, which the compact file also does (the greeting field in Members.java):
$ java src/InstanceMainInClass.java
hello from an instance field
this class is InstanceMainInClass, final? false
Output: 03-members-and-imports.txt. That leaves a real question once a class can have more than one plausible main: which one runs? The article’s script generates every pair that can legally coexist in one class and asks the launcher, on both JDKs, and in both source-file mode and after compiling:
== OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
SS_i0 declares: static void main(String[] a) void main() -> ran: static main(String[])
S0_iS declares: static void main() void main(String[] a) -> ran: instance main(String[])
SS_S0 declares: static void main(String[] a) static void main() -> ran: static main(String[])
IS_I0 declares: void main(String[] a) void main() -> ran: instance main(String[])
-- same four classes, compiled first and launched as a class (java -cp out NAME)
SS_i0 -> ran: static main(String[])
S0_iS -> ran: instance main(String[])
SS_S0 -> ran: static main(String[])
IS_I0 -> ran: instance main(String[])
Output: 05-launch-order.txt. The second row is the informative one. A class with static void main() and an instance main(String[]) runs the instance one. In other words, the launcher chooses the main(String[]) form first, static or not, and only falls back to main() when there is no main(String[]). When both forms have the same signature they cannot coexist at all (Java does not allow a static and an instance method with identical signatures), so that comparison never arises. JDK 27 gives identical results for all eight lines, which is in the same file.
The one behaviour that differs between 25 and 27. A class whose only zero-argument constructor isprivate, with an instancevoid main(), launches on JDK 25 and is rejected on JDK 27. This was found while writing the launch-order script and is not something the JDK 27 release notes fetched for this article mention (they do list an unrelated launcher fix for package-private mains), so treat it as observed behaviour to re-check on your JDK rather than a documented guarantee.
$ java broken/PrivateCtor.java (OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS))
instance main() reached through a PRIVATE zero-argument constructor
$ java broken/PrivateCtor.java (OpenJDK Runtime Environment Temurin-27+35 (build 27+35))
error: no non-private zero argument constructor found in class PrivateCtor
remove private from existing constructor or define as:
public PrivateCtor()
Output: 07-private-constructor-25-vs-27.txt, from PrivateCtor.java. The JDK 27 message is better than most launcher errors: it names the problem and gives the fix. If you are writing a singleton-style class that also has an instance main, make the constructor non-private and the difference disappears on both.
Going deeper: the other launcher errors, verbatim
Two more launcher-level failures, both reproduced in 06-compile-errors.txt. A private main is not found at all, and the message names both accepted forms:
$ java broken/PrivateMain.java (JDK 25)
error: can't find main(String[]) or main() method in class: PrivateMain
From PrivateMain.java. And an instance main in a class whose only constructor takes an argument fails because the launcher has no way to build the object:
$ java broken/CtorWithArgs.java (JDK 25)
error: can't find no argument constructor in class: CtorWithArgs
From CtorWithArgs.java. Neither is a compile error; the class compiles fine and the failure is raised by the launcher when it tries to start it, which is why they read differently from the javac-style messages elsewhere in this article.
Going deeper on this section
- Companion repo: run.sh (section 05 generates the four launch-order classes)
- Official reference: Consolidated JDK 27 release notes
The ways it goes wrong, with the compiler’s own words
A compact source file follows stricter rules than an ordinary one, and the messages are not all equally helpful. Each of these is reproduced in 06-compile-errors.txt, from a file under broken/.| You wrote | What the tool said | What it means |
|---|---|---|
Methods but no main | compact source file does not have main method in the form of void main() or void main(String[] args) | The wrapper class is only created if a launchable main exists; helper-only files are rejected |
void main(int count) | the same message as above | Only the two forms count; any other parameter list is not a main |
A package demo; line | compact source file should not have package declaration | The class always lives in the unnamed package |
Another file doing new Target() on a compact file | Target is abstract; cannot be instantiated | Compact classes cannot be instantiated or referred to from elsewhere |
$ javac -d out broken/Other.java broken/Target.java (JDK 25; Target.java is a compact file)
broken/Other.java:2: error: Target is abstract; cannot be instantiated
Target t = new Target();
^
broken/Other.java:3: error: cannot find symbol
t.main();
^
symbol: method main()
location: variable t of type Target
2 errors
Output: 06-compile-errors.txt. The wording surprised me: javap earlier showed the emitted class as final, not abstract, and this message says the opposite. I did not chase the discrepancy; the practical takeaway is what the transcript shows, that a second file cannot construct or call into a compact file, whether compiled with javac or started with the source launcher.
A compact file is a dead end for reuse. It has no package and nothing can refer to it, so it cannot be a library class, a Spring bean, or a test target. It is a program, not a component. The moment a second class needs to call into it, it stops being a good fit and should become an ordinary class.
Going deeper on this section
- Companion repo: broken/ (ten small source files, one per failure above)
- Official reference:
javaccommand reference
Growing past one file without a build tool
Students outgrow a single file quickly. The source launcher lets a program spread over several files in the same directory: name the one withmain, and it will compile any other source file it needs from beside it.
void main() {
IO.println(Greeter.greet("multi-file source launcher"));
}
Source: App.java, calling a normal class in the same directory (Greeter.java). The output shows the second file being found:
$ java multi/App.java (App.java + Greeter.java in one directory)
Hello, multi-file source launcher (from a second file)
Output: 03-members-and-imports.txt. Note what it found: an ordinary class. The earlier failure showed the reverse direction, a second compact file, is not reachable. So the graduation path for a learner is short: keep void main() in the entry file, put the growing pieces in ordinary classes beside it, and only when they need packages, dependencies or tests move to Maven or Gradle.
Should you write production code this way? No. Compact source files are for teaching, exercises, scripts and quick experiments, and the language shipped them precisely so that those uses stop paying for ceremony. A service, a library or anything with tests wants a named class in a named package. Instancemainin an ordinary class is a smaller, safe win, but the launch-order and constructor rules above are also a reason to keep production entry points plainpublic static void main(String[] args), which behaves identically on every JDK you will meet.
Going deeper on this section
- Companion repo: multi/ (App.java plus Greeter.java)
- Related on this site: Java Stream Gatherers (JEP 485) and Scoped Values vs ThreadLocal in Java 25, two other Java 25 features from the same companion repository
Further reading
- Companion repository for this article: javademos, jep512 module
- Official reference: JEP 512: Compact Source Files and Instance Main Methods
- Official reference: JEP 511: Module Import Declarations
- Official reference: JEP 445: Unnamed Classes and Instance Main Methods (Preview), the JDK 21 form of this feature
- Official reference:
java.lang.IOAPI documentation - Related on this site: Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped
No Comments yet!