On This Page
Unknown Source is almost never a JVM problem. It's a build setting, and you can read the answer straight off the class file in about ten seconds.
The trace with no line numbers in it
OpenJDK 21.0.12.1, Linux, a webhook handler that blows up on an event with no body. Plain java -cp out Lab from the console, copied as-is:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "Lab$Event.body()" is null
at Lab.signature(Unknown Source)
at Lab.route(Unknown Source)
at Lab.handle(Unknown Source)
at Lab.main(Unknown Source)
Four frames, four Unknown Source. The message is perfectly good (an event's body() returned null), but you can't tell which line in signature did it, and in a real service with a 600-line class that's the difference between a one-minute fix and an afternoon. If you've searched the phrase "unknown source stack trace" you've landed here for the same reason I did.
Here's the same program compiled normally:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "Lab$Event.body()" is null
at Lab.signature(Lab.java:9)
at Lab.route(Lab.java:14)
at Lab.handle(Lab.java:20)
at Lab.main(Lab.java:26)
Same exception. Same frames. Only the parenthesised part changed, and that part isn't computed at runtime from your source. It's copied out of the class file.
In one paragraph
Each frame prints (File.java:line). The file name comes from a class-level attribute called SourceFile; the line number comes from a per-method attribute called LineNumberTable. If the class doesn't carry a SourceFile, the JVM prints Unknown Source and nothing else, whether or not it has line numbers. If it carries SourceFile but no LineNumberTable, you get (Lab.java) with no number. If the frame is a native method, you get (Native Method). All three are about what's in the bytes of the class, so the fix is always on the build side, never in the JVM flags you start with.
I didn't believe the "whether or not it has line numbers" part when I first wrote it down. More on that below.
Run it and compare
Pinned JDK: OpenJDK 21.0.12.1. No libraries. Compile with javac and run the class files; I didn't use the java Lab.java source launcher for this one, so I can't tell you what it does with debug info. Keep the blank lines in the file; the line numbers in this article depend on them.
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
public class Lab {
record Event(String id, String body) {}
static String signature(Event e) {
return e.body().trim(); // line 9: NPE when body is null
}
static String route(Event e) {
return String.valueOf(
signature(e)
.toLowerCase()
.length()); // line 16
}
static void handle(Event e) {
route(e); // line 20
}
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "plain";
switch (mode) {
case "plain" -> handle(new Event("evt_1", null)); // line 26
case "wait" -> {
Object lock = new Object();
Thread t = new Thread(() -> {
synchronized (lock) {
try { lock.wait(); } catch (InterruptedException ie) { } // line 31
}
}, "hook-worker");
t.start();
Thread.sleep(300);
for (StackTraceElement f : t.getStackTrace()) System.out.println("\tat " + f);
System.exit(0);
}
case "loader" -> {
URL jar = Path.of(args[1]).toUri().toURL();
try (URLClassLoader cl = new URLClassLoader("plugin", new URL[]{jar}, Lab.class.getClassLoader())) {
cl.loadClass("Plug").getMethod("run").invoke(null);
} catch (java.lang.reflect.InvocationTargetException ite) {
ite.getCause().printStackTrace();
}
}
}
}
}
Plug.java, used later for the jar experiment:
public class Plug {
public static void run() {
throw new IllegalStateException("plugin failed"); // line 3
}
}
Now compile the lab six ways and run plain each time. I used a separate output directory per variant, and (learned this one the slow way) don't put a colon in the directory name, because -cp out-g:none is two classpath entries.
javac -d out_default Lab.java Plug.java
javac -g -d out_g Lab.java Plug.java
javac -g:none -d out_none Lab.java Plug.java
javac -g:lines -d out_lines Lab.java Plug.java
javac -g:source -d out_source Lab.java Plug.java
javac -g:vars -d out_vars Lab.java Plug.java
for d in out_default out_g out_none out_lines out_source out_vars; do
echo "== $d"; java -cp $d Lab plain 2>&1 | sed -n 2p
done
Second line of each trace, which is enough to see the frame format:
== out_default
at Lab.signature(Lab.java:9)
== out_g
at Lab.signature(Lab.java:9)
== out_none
at Lab.signature(Unknown Source)
== out_lines
at Lab.signature(Unknown Source)
== out_source
at Lab.signature(Lab.java)
== out_vars
at Lab.signature(Unknown Source)
The row that surprised me is out_lines. I asked for line numbers, got them into the class file, and the trace still says Unknown Source. Hold that thought until the decoding section.
Variations. Plain javac with no -g at all behaves like -g:source,lines: the same trace as -g. That matches what the javac documentation says, and what a page-1 explainer on Baeldung states. -g adds local variable tables on top, which turns out to matter for something else (see the helpful-NPE bit).
If your trace differs. A Picked up JAVA_TOOL_OPTIONS line before the exception is stderr from your environment, not part of the trace. If you tried the source launcher and got line numbers on every variant, that's a different compile path from the one I tested.
Unknown Source in a stack trace: what each flag leaves behind
Look at the class files. javap -v prints the attributes; I counted them for each variant:
default LineNumberTable x5 SourceFile x1
-g LineNumberTable x5 SourceFile x1 LocalVariableTable x5
-g:none (none of them)
-g:lines LineNumberTable x5 (no SourceFile)
-g:source SourceFile x1 (no LineNumberTable)
-g:vars LocalVariableTable x5
And for a quick look at one method, javap -l shows the line table itself:
$ javap -l -cp out_default Lab
Compiled from "Lab.java"
public class Lab {
...
[... lines cut by me]
static java.lang.String route(Lab$Event);
LineNumberTable:
line 13: 0
line 14: 1
line 15: 4
line 16: 7
That Compiled from "Lab.java" line is the SourceFile attribute. If javap -l on your class prints no Compiled from line, you've found your answer.
Now the annotated version of the broken trace:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because ... (1)
at Lab.signature(Unknown Source) (2)
at Lab.route(Unknown Source)
at Lab.handle(Unknown Source)
at Lab.main(Unknown Source) (3)
(1) The message is untouched by debug flags. It names the method call that returned null (Lab$Event.body()), which is the real clue here, and it's still there. The helpful-NPE text comes from analysing bytecode, not from your line table.
(2) Class and method names always survive. They live in the constant pool and have to exist for the program to run. A frame without a line number is still a frame with a name, so you can still open Lab.signature and read it. You've lost precision, not the location.
(3) Unknown Source replaces the entire parenthesised part. There's no file name, no number.
Back to out_lines. StackTraceElement.toString() checks, in this order, whether the frame is native, whether the file name is null, and only then appends the file name and (if it's non-negative) the line. A null file name means Unknown Source and the line number never gets looked at. No SourceFile, no file name, so no line number printed even though the table was right there. I read that off the JDK 21 StackTraceElement source at jdk-21+35, through a summarising fetch tool rather than line by line, and the lab output agrees with it in all six variants.
How the JVM decides to print Unknown Source
When a Throwable is constructed, the JVM walks the stack and records, per frame, the method and a bytecode index. It doesn't store a string. Line numbers are resolved from the LineNumberTable of that method, and the file name from SourceFile of the class, when the stack trace is materialised, and StackTraceElement is the object that carries the results. Throwable.printStackTrace calls toString() on each one.
So there are three outcomes for the parenthesised part, and they're a single if chain rather than three code paths:
native method -> (Native Method)
no file name -> (Unknown Source)
file name, line < 0 -> (Lab.java)
file name, line >= 0 -> (Lab.java:9)
Native comes first. I took a thread parked in Object.wait and printed its stack from the outside with t.getStackTrace():
at java.base/java.lang.Object.wait0(Native Method)
at java.base/java.lang.Object.wait(Object.java:366)
at java.base/java.lang.Object.wait(Object.java:339)
at Lab.lambda$main$0(Lab.java:31)
at java.base/java.lang.Thread.run(Thread.java:1583)
wait0 is declared native, so there's no bytecode to map a line to. That's (Native Method), and it's a normal frame, not a failure. Rebuild with -g:none and only our own frame changes: Lab.lambda$main$0(Unknown Source). The JDK's own frames keep their line numbers because the JDK ships built with them.
The same file-name question has a second half. The prefix in front of the class name changed in JDK 9: java.base/ is the module name, and a class loader name can sit in front of that. The source says the loader name is dropped for the built-in loaders (boot, platform, app), so you normally never see one. I wanted to see one anyway. A custom URLClassLoader with a name makes it visible. I put Plug.class in a jar compiled with -g:none, took it off the classpath entirely (my first attempt left Plug.class on the classpath too, the parent loader found it first, and I spent ten minutes wondering why my stripped jar printed Plug.java:3), and loaded it through a loader named plugin:
java.lang.IllegalStateException: plugin failed
at plugin//Plug.run(Unknown Source)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at Lab.main(Lab.java:42)
plugin// is loader name, an empty module name (unnamed module), then the class. Same jar compiled with plain -g: plugin//Plug.run(Plug.java:3). I'd seen a claim that newer formats show the jar name in place of the file. I didn't see that on 21.0.12.1, and I'd treat it as unverified unless you can show it on your JDK.
This took me more than a day to untangle, mostly because every wrong guess (JVM flag, JIT, stripped JDK, jar signing) produced a plausible story. The class file turned out to be the only thing that mattered. You get to skip that.
Myths about this output
"The JVM lost the line numbers." The class never had them. You can't recover them at runtime; there's no flag on java that restores a table that isn't in the bytes.
"Unknown Source means a JDK frame or a native library." Not on 21. JDK frames print java.base/...(File.java:123). Unknown Source in your package means your class was compiled without SourceFile. When it appears on a third-party frame, the vendor's jar was built that way, or something rewrote it afterwards.
"We turned on line numbers, so we're fine." That's out_lines. -g:lines alone, a setting you can reach through a build-tool debuglevel, gets you the table and an Unknown Source trace. You need source as well. Maven's debuglevel accepts none or a comma-separated list from lines, vars, source, and appends it to -g; the compile mojo documents debug as defaulting to true. Put lines,source there, not just lines. I haven't checked Gradle's defaults against its docs for this run, so I won't state them as fact.
"The line number is wrong." Look at route. It spans lines 13 to 16 and the frame says Lab.java:14. The LineNumberTable above has entries for 13, 14, 15 and 16 at different bytecode offsets. The frame shows the line of the instruction that was executing in that frame, which here is the call to signature(e), on line 14, not the start of the statement on 13 and not the end on 16. A chained expression over many lines can point at the middle. Count by call, not by statement. Compilers that generate code for you (Lombok, Kotlin, annotation processors, I haven't run any of them for this article) sometimes map several generated lines to one source line, or to line 1, so trust line numbers a bit less in generated code.
"Obfuscation did this, so ignore it." If your release pipeline shrinks or obfuscates, stack traces from production stop pointing at your source. Tools like ProGuard and R8 have a way to keep SourceFile and LineNumberTable and a mapping file to translate traces back; from memory the keep option is -keepattributes SourceFile,LineNumberTable, and I haven't run either tool for this piece, so check your tool's docs before copying it. Android/ART is out of scope here.
The fix, and what it costs
Compile with -g, or at least -g:source,lines.
- Maven: debug info is on by default (the
debugparameter defaults totrue). If you seeUnknown Source, look for<debug>false</debug>,<debuglevel>none</debuglevel>, or-g:noneincompilerArgs. - Gradle: set
options.debug = trueoroptions.compilerArgs += "-g"explicitly if you're unsure, and verify withjavap. - Anything that repackages the jar (shaders, minifiers, obfuscators): check that class attributes survive the step. Run
javap -lon a class from the shipped artifact, not fromtarget/classes.
What it costs: class file size. For this lab's two classes (Lab and Lab$Event), -g:none was 4,850 bytes, the default 5,198 (about 7% more), and full -g 5,869 (about 21% more). Tiny classes exaggerate the ratio, and I didn't measure a real jar. I didn't measure runtime either; as far as I know the tables are only read when a trace is built or a debugger asks, but that's reasoning, not a benchmark.
What -g:vars buys you is separate. Look at what happens to the helpful NPE message when the failing expression is a local variable. Local.java:
public class Local {
static int len(String a) {
String body = a.isEmpty() ? null : a;
return body.length();
}
public static void main(String[] args) {
len("");
}
}
== -g (first lines only, rest cut by me)
java.lang.NullPointerException: Cannot invoke "String.length()" because "body" is null
at Local.len(Local.java:4)
== -g:none
java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
at Local.len(Unknown Source)
== -g:vars
java.lang.NullPointerException: Cannot invoke "String.length()" because "body" is null
at Local.len(Unknown Source)
<local1> is the NPE message falling back to a slot number when it can't find the variable's name. In the Lab case the message said the return value of "Lab$Event.body()", which needs no variable table, so it survived -g:none. Whether you get a name or a slot depends on what the null expression is. That's what stripping debug info actually costs you in this series' favourite exception.
What isn't a fix: restarting with different java flags, adding -XX:+ShowHiddenFrames, or staring at the source harder. And don't strip debug info to hide your source. Class files are trivial to decompile anyway; all you'd be doing is making your own incident reports worse.
Catching it in production
Two cheap checks that would have saved me hours:
- In CI, run
javap -l(orjavap -v | grep -c LineNumberTable) on one representative class from the final artifact and fail the build if it reports none. - In your log search, alert on the string
Unknown Sourceappearing in frames from your own packages. It's a deployment bug, and it will be there for every trace until someone fixes the build.
Aggregators fingerprint errors by frames. A change from Lab.java:9 to Unknown Source after a rebuild will look like a new error type in some tools and a different grouping in others. That's one more reason to catch it before release.
Version notes, honestly scoped. Everything above was run on OpenJDK 21.0.12.1 only. I didn't run 8, 17 or 25 for this article. The java.base/ and loader-name prefixes are JDK 9+ behaviour (JDK 8 prints plain at java.util.ArrayList.get(ArrayList.java:...)), which I know from the module system's introduction rather than from a JDK 8 run today. Unknown Source itself is far older: a search for it turns up forum threads from 2003 and 2006, and a JBS request (JDK-5040214) titled "Exception stacktraces should show linenumbers when available". I only saw its title in a search result.
Screenshot this bit
(Unknown Source)means the class has noSourceFileattribute. Line numbers aren't consulted at all in that case.-g:lineswithoutsourcestill printsUnknown Source. Ask for both, or use-g.(Lab.java)with no number meansSourceFileis present andLineNumberTableisn't.(Native Method)is a normal native frame.- Check any artifact with
javap -l: noCompiled from "X.java"line, no source name in traces. - A multi-line statement's frame shows the line of the call in progress, not the first line of the statement.
Things people paste into search
Why does my Java stack trace say Unknown Source instead of a line number?
The class was compiled without the SourceFile attribute (-g:none, or a debug level without source), or something stripped it from the jar. Check with javap -l. Verified on OpenJDK 21.0.12.1.
How do I get line numbers in Java stack traces?
Compile with -g, or -g:source,lines at minimum. In Maven, don't set debug to false or debuglevel to none; the default is already on. Then rebuild and redeploy; no java runtime flag can add them back.
What does "Native Method" mean in a stack trace?
The frame is a method declared native, so there's no bytecode and no line number to show. Object.wait0(Native Method) on JDK 21 is an example. It's normal. The frame below it is where your Java code called into it. The overall reading routine is in How to Read a Java Stack Trace.
Why is the line number in the stack trace wrong?
Usually it isn't: the frame shows the line of the call that was running, which in a multi-line statement can be the second or third line. If the jar was shrunk or obfuscated, the line may belong to the pre-processed code. For odd method names in the frames see Strange Java Stack Frames; for what happens to the Caused by lines when this applies to a chain, see Caused by in Java Stack Traces.