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")


