I kept juggling JDK 8, 21 and 25 on Windows. Every time a project needed a different one, I was back in the environment variables dialog, editing JAVA_HOME and then checking PATH to make sure the right java.exe won.
So I made jfs (see what it stands for at the end), a Windows Java version manager for Java fans.
qaptivator
/
jfs
Windows Java version manager for Java fans. Switch JDKs with one command. No shims, no shell hooks, no background service.
Stop editing JAVA_HOME.
jfs (Java Fucking Sucks) is a Windows Java version manager. Point it at JDKs you already have, then switch with one command. Your actual JAVA_HOME environment variable stays unchanged. Behind the scenes, a directory junction moves. No shims, no shell hooks, no background service.
irm https://jfs.acdev.app/install.ps1 | iex
Git Bash:
curl -fsSL https://jfs.acdev.app/install.ps1 | powershell.exe -NoProfile -ExecutionPolicy Bypass -Command -
cmd:
powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://jfs.acdev.app/install.ps1 | iex"
Or run install.bat.
PS C:\Users\you> java -version
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
PS C:\Users\you> jfs…The trick
JAVA_HOME is set once, to %LOCALAPPDATA%\jfs\current. That path is an NTFS directory junction. When you run jfs use 21, jfs retargets the junction to the JDK you picked. JAVA_HOME itself never changes.
Because of that:
- every new
javaprocess follows the JDK you picked - already-open terminals don't need a restart (a
javaprocess that is already running keeps its old JDK, of course) - there are no shims, no shell hooks, and no background service
- it doesn't need admin rights or Developer Mode, because junctions (
mklink /J) don't, unlike symlinks
Usage
irm https://jfs.acdev.app/install.ps1 | iex
jfs add 21 "C:\path\to\jdk-21"
jfs add 8 "C:\path\to\jdk8"
jfs list
jfs use 21
java -version
jfs current
jfs rm 8
jfs add checks that bin\java.exe exists in the directory and records the path in a small config.json. The first JDK you add becomes active. jfs rm only forgets a version and never touches files on disk.
It does not download JDKs
This is on purpose, at least for now. Many of us already have several JDKs lying around: Gradle's cached toolchains, the ones bundled with IDEs, Adoptium installs. jfs lets you reuse those instead of downloading yet another copy. I was inspired by nvm, but jfs only switches, it doesn't install.
Things worth knowing
- Windows only. The switching mechanism is an NTFS junction.
- Local NTFS paths only. A junction can't target a network share.
-
System PATH beats user PATH. Windows searches the system
PATHfirst, so if a system directory contains its ownjava.exe, that one wins. The installer prints those directories when it finds them. An entry that is literally%JAVA_HOME%\binis fine, since it follows jfs. -
Uninstall carefully.
Remove-Item -Recursefollows thecurrentjunction and can delete the JDK it points at. Remove the junction first withcmd /c rmdir "%LOCALAPPDATA%\jfs\current".
Under the hood
It's written in Rust and MIT licensed. All state is one config.json plus the junction, living in %LOCALAPPDATA%\jfs (or wherever JFS_DIR points).
Feedback
I'd especially like to hear about:
- PATH edge cases I haven't hit
- how IDEs and Gradle daemons behave after a switch, since some tools cache
JAVA_HOMEat startup
Source, releases and issues: github.com/qaptivator/jfs
Landing page: jfs.acdev.app
(and, yes, it stands for "Java Fucking Sucks")

Top comments (0)