Java Toolchains in WSL: Separate Linux JDKs, Builds, and Windows Runtimes
Build Java projects in WSL with a Linux JDK, reproducible toolchains, explicit target releases, and verified boundaries between Linux and Windows runtimes.
Java is portable at the bytecode and language levels, but a Java development environment is not one interchangeable installation. A Windows JDK and a Linux JDK have different executables, native libraries, path conventions, environment variables, and filesystem behavior. In WSL, a Linux build should normally use a Linux JDK installed inside the distribution, while Windows-only tooling should use its own Windows JDK. Sharing source is possible, but sharing the resulting JDK directory, class cache, or build output across operating systems is not a reliable substitute for matching the build runtime.
This separation is useful even when both runtimes report the same feature version. A Linux process resolves java from the distro’s PATH and loads ELF executables and Linux shared objects. PowerShell resolves a Windows launcher and DLLs. The class files produced by a compatible Java compiler can often run on either operating system, but native JNI libraries and platform-specific dependencies cannot be assumed to cross the boundary. Make the operating system, architecture, JDK vendor, and language target visible in the project workflow.
Identify the Linux runtime before changing the build
Start by recording the executable that the Linux shell actually resolves. A terminal opened in WSL is not evidence by itself: shell aliases, project scripts, IDE configuration, or Windows interop may select another command. These read-only commands expose the resolved paths, runtime properties, and compiler version:
command -v java
command -v javac
readlink -f "$(command -v java)"
java -version
javac -version
java -XshowSettings:properties -version 2>&1 | grep -E 'java.home|os.arch|os.name|file.encoding'
If javac is missing while java exists, the selected package may be a runtime-only package rather than a JDK. Check the distro package manager’s description and install a distribution-supported JDK package. Package names and available feature releases vary by distro release, so inspect the candidate version before pinning an install command in team documentation.
From PowerShell, inventory the Windows side separately. Use Windows-native commands there, and do not set a Linux project’s JAVA_HOME to a Windows directory such as C:\Program Files\Java. Conversely, a Linux JAVA_HOME such as /usr/lib/jvm cannot configure a Windows process. A terminal launched by an IDE may have a different PATH from an interactive login shell, so compare values in the exact terminal or task runner that fails.
Keep Linux sources and build state on the Linux filesystem
For builds driven by Linux tools, keep the Git checkout under the distribution’s Linux filesystem, for example ~/src/service. Microsoft documents this as the preferred placement for Linux command-line workflows because Linux tools avoid repeated cross-filesystem operations. This matters to Java builds that scan large source trees and dependency caches: Maven or Gradle may stat many small files, create directories, replace outputs atomically, and rely on case-sensitive paths.
The Windows filesystem mounted at /mnt/c remains useful when Windows-native tools must own the project, but then choose the Windows toolchain consistently. Do not treat a Java project that happens to compile once on DrvFs as proof that file watching, case-only renames, executable launchers, or concurrent Windows/Linux edits will behave identically to an ext4 checkout. If a repository must be shared across both sides, test its actual Git and build operations before choosing that arrangement.
Keep generated directories such as target, build, and Gradle caches in their owning Linux environment. Do not copy an entire Gradle user home or Maven local repository between Windows and Linux as if it were a portable, architecture-neutral artifact store. Dependency archives may be reusable, but plugin caches, scripts, permissions, absolute paths, native components, and metadata can encode host assumptions. Let the build tool repopulate a cache when switching environments.
Make the Java release contract explicit
The compiler’s installed version and the project’s target Java release are separate facts. A recent JDK can compile for an earlier supported Java platform release with –release; simply setting source syntax or bytecode target alone is not an equivalent API compatibility check. In a simple source-only example, the selected JDK writes output to a dedicated directory:
mkdir -p out
javac --release 17 -d out src/com/example/Main.java
java -cp out com.example.Main
Use a release that the installed compiler supports and that matches the project’s deployment contract. The example is illustrative: the source file and package must exist, and the produced class will still run on a compatible Java runtime, not on an arbitrary older runtime. For Maven or Gradle projects, configure the tool’s compiler release setting in the project build rather than relying on one developer’s environment variable. Commit the wrapper and wrapper configuration used by the project where that is its established build model, and pin the wrapper distribution according to team policy.
Record both the runtime used to build and the supported runtime used to execute tests. CI should perform a clean build from the repository state and test the packaged artifact under the declared runtime matrix. A command such as java -version is only an inventory check; it does not prove that the compiler target, dependencies, test JVM, and deployment runtime agree.
Diagnose PATH, JAVA_HOME, and IDE mismatches
Java tooling commonly fails because several independently configured selectors disagree. PATH selects executables for a shell lookup. JAVA_HOME is a convention consumed by many build tools and scripts. A project wrapper can select a tool distribution, while an IDE may configure a separate project SDK and Gradle JVM. None of those values automatically updates all the others.
When a build fails, capture the following from the same process context that runs it:
printf 'PATH=%s\\nJAVA_HOME=%s\\n' "$PATH" "$JAVA_HOME"
type -a java javac
java -version
javac -version
Then compare it with the IDE’s WSL remote environment and the build tool’s own diagnostic output. If the IDE opens a Linux project but launches a Windows Gradle JVM, the workspace location alone will not correct the runtime mismatch. Configure the IDE’s remote development mode and Linux project SDK deliberately. Keep Windows IDE UI processes and Linux build processes conceptually separate even if one application window coordinates them.
Avoid placing both Windows and Linux Java directories on one PATH in the hope that resolution will choose the correct platform automatically. Ordering changes can silently select a different executable. Prefer explicit commands, distro-native paths, and an acceptance check that prints the resolved executable before an expensive build.
Treat JNI and native dependencies as operating-system artifacts
Java code that loads native libraries introduces a hard platform boundary. A Linux JNI library is a Linux shared object with an ABI, architecture, and dependency contract. A Windows DLL is not loadable by a Linux JVM, even if its filename resembles the expected library. Build native modules inside WSL for the Linux runtime and use Windows builds for Windows runtimes. Keep each output in an OS-specific build directory or artifact namespace.
When a Java application throws UnsatisfiedLinkError, inspect which JVM is running, the value of os.name and os.arch, the native library search path, and the library’s dependencies. On Linux, tools such as file and ldd can help identify architecture and missing shared libraries, but do not run ldd on untrusted binaries. A pure Java dependency can still transitively load native code, so validate the actual test and runtime paths rather than classifying dependencies by their package name.
Cross-compilation is not a blanket fix for native compatibility. A JDK compiler can emit bytecode for a target release, but native code needs a matching cross toolchain, headers, linker, and target libraries. If the deliverable contains native components, build and test on the target architecture or use a controlled cross-build process whose outputs are tested there.
Define a useful acceptance test
A dependable WSL Java setup can be reconstructed from a fresh distro and checkout. The project should identify the expected JDK feature line, build wrapper, supported application runtime, and any native prerequisites. A clean Linux build should use the Linux compiler and produce artifacts only in the Linux workspace. The test should print the selected Java runtime, run the project’s test suite, and verify that the resulting artifact starts with the expected runtime.
Include a deliberate negative check when Windows and Linux tools coexist: a Windows process should not accidentally inherit or consume Linux-only JAVA_HOME, and the WSL build should not resolve a Windows java.exe. Test native modules on each supported OS separately. If source is shared through a Windows mount, test case-sensitive file names, clean rebuilds, Gradle or Maven wrapper execution, Git status after generated output, and concurrent-editor expectations.
Finally, record the WSL and distro versions as environment context rather than presenting one machine’s package version as universal. WSL manages the Linux execution boundary; it does not make Linux services persistent after the distribution stops or turn local testing into production validation. Keep the final build evidence, test output, and artifact identity in CI or another durable project record.
Related:
- Go Builds in WSL: Host Identity, Cross-Compilation, and cgo
- Build and Debug Linux C++ with Visual Studio’s WSL Toolset
Sources: