How to Set the JAVA_HOME Environment Variable on Windows, Linux, and macOS
JAVA_HOME must point at the ROOT folder of a JDK — the one containing bin/javac — never at bin, and never at a JRE. On Windows set it under System Properties → Environment Variables (or with setx) and add %JAVA_HOME%\bin to Path. On Linux export it from ~/.bashrc, or /etc/profile.d/jdk.sh for everyone. On macOS put export JAVA_HOME=$(/usr/libexec/java_home -v 21 -F) in ~/.zshrc. Then open a NEW terminal and check that "$JAVA_HOME"/bin/java -version prints the version you expect.
Almost every JAVA_HOME problem is one of three things: the variable points one directory too deep, it was set in a window that has since closed, or it was set for a shell that the failing tool never runs through. Nothing about it is difficult — but the three operating systems disagree about where a "permanent" variable lives, and that is where the hour goes.
This is the whole job on Windows, Linux and macOS, with the traps named as they come up.
What JAVA_HOME actually is
It is a convention, not a Java feature. The java and javac commands do not read JAVA_HOME at all — they work out their own location from the executable you invoked. The variable exists for everything built on top of the JDK, which has no other reliable way to find one:
- Maven uses it to pick the JVM it runs on. Its install guide asks for either JAVA_HOME or
javaon your PATH, so it is not strictly required — but when it is set and wrong, Maven stops rather than falling back. - Gradle resolves the JVM for a build from JAVA_HOME, falling back to whichever
javais on PATH, unlessorg.gradle.java.homeoverrides both. - JVM server software reads it in its start scripts. Tomcat's
setclasspath.shrefuses to start without JAVA_HOME or JRE_HOME, and Kafka'skafka-run-class.shruns$JAVA_HOME/bin/javawhen the variable is set. - IDEs and CI runners read it to decide which JDK a project compiles against.
So the symptom of a wrong JAVA_HOME is never "Java is broken". It is one tool refusing to start while java -version works perfectly, which is exactly why the variable gets blamed last.
Note
Not everything reads it, and assuming otherwise costs an afternoon. Elasticsearch deliberately ignores JAVA_HOME: its launcher reads
ES_JAVA_HOMEinstead and printswarning: ignoring JAVA_HOME=...when it finds the old one set. When a tool will not see your variable, check what that tool actually reads before changing anything.
The rule that prevents most of the pain
JAVA_HOME is the JDK's root directory — the one that contains bin. Not bin itself.
A correct JAVA_HOME has this inside it:
bin/ java, javac, jar, jshell
conf/ the JDK's configuration files
lib/ the runtime libraries
release a text file naming the exact build
Two checks settle it in one line each. bin/javac must exist — if only bin/java is there, you are pointing at a JRE, and anything that compiles will fail. And the release file tells you the version you are actually pointing at, which is often not the one you think:
ls "$JAVA_HOME"/bin/javac
head -2 "$JAVA_HOME"/release
Step 1: find the JDK directory
You cannot set the variable until you know the path, and "where the installer put it" is not something to guess.
Windows
Installers do not agree on a location — Oracle's own install guide documents one of them and every other vendor picks its own. The common ones:
| Distribution | Typical path |
|---|---|
| Oracle JDK | C:\Program Files\Java\jdk-21.0.1 (the installer also creates a C:\Program Files\Java\latest\jdk-21 junction) |
| Eclipse Temurin | C:\Program Files\Eclipse Adoptium\<package> |
| Microsoft Build of OpenJDK | C:\Program Files\Microsoft\ |
where java is a poor way to find it. Oracle's installer copies java.exe and javaw.exe into C:\Program Files\Common Files\Oracle\Java\javapath\, and that directory is what ends up on your PATH — so where java answers with a launcher stub that says nothing about where the JDK lives. Open C:\Program Files and look instead; it is faster and it is honest.
Worth knowing before you do any of this by hand: the Windows installers can set JAVA_HOME for you, and they usually do not. Temurin's MSI updates PATH by default but treats JAVA_HOME as an extra feature you tick during setup (FeatureJavaHome), and the Microsoft Build of OpenJDK works the same way. If you have not installed a JDK yet, installing Java 21 on Windows walks through that checkbox and the rest of the installer — tick it there and most of the next section becomes unnecessary.
Linux
Ask the toolchain instead of hunting. javac is usually a chain of symlinks into the real JDK, and readlink -f follows it to the end:
readlink -f "$(which javac)"
## /usr/lib/jvm/java-21-openjdk-amd64/bin/javac
dirname "$(dirname "$(readlink -f "$(which javac)")")"
## /usr/lib/jvm/java-21-openjdk-amd64
The second command is the first one with bin/javac stripped off — that is your JAVA_HOME. Use javac rather than java here: on a machine with both a JRE and a JDK, resolving java can land you in the JRE.
If several JDKs are installed, sudo update-alternatives --config java (Debian, Ubuntu) or sudo alternatives --config java (Fedora, RHEL) lists them with their full paths.
macOS
macOS ships a tool whose only job is this, and it is the reason the macOS section is the shortest of the three:
/usr/libexec/java_home -V # every JDK it can see
/usr/libexec/java_home -v 21 -F # just the path for Java 21
Keep the -F on. Without it the command answers with your default JDK when the version you asked for is not installed, and says nothing about having done so — see below.
Windows: set JAVA_HOME permanently
The dialog (works everywhere)
Press Win, type "Edit the system environment variables", and open it — or run sysdm.cpl and go to Advanced → Environment Variables.
There are two panels. User variables apply to you and need no admin rights; System variables apply to every account and require them. For a development machine, the user panel is the right one — a per-user JAVA_HOME is easier to change later and cannot break another account.
Click New, name it JAVA_HOME, and paste the JDK root as the value. No quotes, even when the path contains spaces: the dialog stores the value literally, so quotes become part of it.
setx, and its sharp edges
The same thing from a terminal:
setx JAVA_HOME "C:\Program Files\Java\jdk-21"
Two things about setx bite people, and both are in Microsoft's own documentation:
- It does not affect the window you typed it in. Microsoft's reference is explicit that a value written by
setxshows up in command windows opened afterwards, never in the one you ran it from. Open a new terminal before you test — this alone accounts for a large share of "I set it and it did not work". - It truncates at 1024 characters. Anything longer is cropped, and the cropped text is what gets saved. For JAVA_HOME that is irrelevant; for
setx PATH "%PATH%;..."it is destructive, and it is why you should never edit Path withsetx.
A trailing backslash is the third, and it is pure cmd.exe: "C:\Program Files\Java\jdk-21\" ends with \", which escapes the quote and mangles the value. Leave the trailing slash off.
Add /m to write the system-wide variable instead of your own — that needs an elevated prompt.
PowerShell, for scripts
## Permanent, for the current user
[Environment]::SetEnvironmentVariable("JAVA_HOME", "C:\Program Files\Java\jdk-21", "User")
## Permanent, machine-wide — run as Administrator
[Environment]::SetEnvironmentVariable("JAVA_HOME", "C:\Program Files\Java\jdk-21", "Machine")
## This session only
$env:JAVA_HOME = "C:\Program Files\Java\jdk-21"
The first two do not change the running session either. $env:JAVA_HOME changes the session and nothing else — useful for testing a path before you commit to it.
Add %JAVA_HOME%\bin to Path
Separate step, and worth doing: it is what makes java and javac resolve to the JDK that JAVA_HOME names, instead of to whatever the last installer left in Path.
In the Environment Variables dialog, select Path, click Edit, then New, and add:
%JAVA_HOME%\bin
Move it above any other Java entry with Move Up. Keep it as the literal %JAVA_HOME%\bin rather than the expanded path — then changing JDK version is one edit rather than two.
Do this in the dialog, not with setx. Aside from the 1024-character truncation, setx expands variables before storing them, so %JAVA_HOME% would be frozen to today's path and stop tracking the variable.
Then restart the terminal, and the IDE. Windows tells running programs that the environment changed, and most of them ignore it. An IDE started before the change keeps the old environment until it is closed and reopened — including the terminal panes inside it.
Linux: pick the right file
The export line is the same everywhere. The only real decision is which file it goes in.
Just you
echo 'export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64' >> ~/.bashrc
echo 'export PATH="$JAVA_HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
On zsh, that is ~/.zshrc. On fish, the syntax differs: set -Ux JAVA_HOME /usr/lib/jvm/java-21-openjdk-amd64.
Which file, exactly, depends on how the shell was started. Bash reads ~/.bash_profile — or ~/.profile when that does not exist — for a login shell, and ~/.bashrc for an interactive one. Most distributions ship a ~/.bash_profile that sources ~/.bashrc, so ~/.bashrc covers both; if yours does not, that is exactly why a variable can be present in your desktop terminal and missing over SSH. Add it to ~/.profile as well and the difference stops mattering.
Every user on the machine
A file in /etc/profile.d/ is the maintainable way — it survives package updates that rewrite /etc/profile:
sudo tee /etc/profile.d/jdk.sh >/dev/null <<'EOF'
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export PATH="$JAVA_HOME/bin:$PATH"
EOF
sudo chmod 644 /etc/profile.d/jdk.sh
Log out and back in. Scripts in /etc/profile.d/ are sourced by login shells, so an already-open terminal will not see it.
The places your shell file never reaches
This is the part that turns into a long afternoon on a server, because the variable is genuinely set — just not where the failing process can see it.
- systemd services do not read anyone's
.bashrc. UseEnvironment=JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64in the unit file, or pointEnvironmentFile=at a file ofKEY=valuelines, thensudo systemctl daemon-reload. sudostrips the environment by default (env_reset).sudo mvn ...runs without your JAVA_HOME. Usesudo -Eto keep it, or set it in the target user's own profile.- cron runs with a nearly empty environment and does not source your profile. Set JAVA_HOME at the top of the crontab, or use absolute paths in the job.
- Docker containers get their environment from the image and
ENV, never from the host shell. Official JDK images set JAVA_HOME already —eclipse-temurin:21-jdksets it to/opt/java/openjdk. (New to containers? Docker vs Kubernetes sorts out which of the two does what.)
macOS: let java_home find the path
zsh is the default shell on macOS — it has been since Catalina — so the file is ~/.zshrc unless you deliberately kept bash (~/.bash_profile).
Do not hardcode the path. /usr/libexec/java_home ships with macOS, and its documented purpose is exactly this: to return a value suitable for JAVA_HOME.
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 21 -F)' >> ~/.zshrc
echo 'export PATH="$JAVA_HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
That survives JDK updates, because the path is resolved at shell start rather than written down once.
Two ways /usr/libexec/java_home will lie to you
Both are documented, both are quiet, and both hand you a working shell pointed at the wrong JDK.
It falls back instead of failing. Its man page is upfront about this: when nothing matches the filters you gave, it uses the default order instead. Ask for a version you do not have and it prints your default JDK, with exit status 0:
## Only JDK 17 and 25 installed
/usr/libexec/java_home -v 21
## → .../openjdk-25.0.1/Contents/Home ← not 21, and no error
/usr/libexec/java_home -v 21 -F
## → Unable to locate a Java Runtime... (exit 1)
-F (--failfast) is what makes it fail instead of guessing. Use it in every script, and in your .zshrc — a startup file that fails loudly beats a build that quietly compiles against the wrong release.
Java 8 is spelled 1.8. -v 8 does not match a Java 8 JDK; it falls through to the default, as above. -v 1.8 matches. Versions from 9 onward use the plain number.
Homebrew JDKs
Homebrew does not install into /Library/Java/JavaVirtualMachines, so java_home cannot see its JDKs until you link one in. The formula prints the exact command, and it is worth running:
brew install openjdk@21
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk \
/Library/Java/JavaVirtualMachines/openjdk-21.jdk
On Intel Macs the prefix is /usr/local instead of /opt/homebrew. After the symlink, /usr/libexec/java_home -V lists it like any other JDK.
Switching versions per shell
One function per JDK, in ~/.zshrc, is the least fragile version-switcher:
jdk() { export JAVA_HOME=$(/usr/libexec/java_home -v "$1" -F) && java -version; }
## jdk 17 → switches this shell to Java 17
It changes the current shell only, which is the point: one terminal on 17 for an old service, another on 21, no global state to remember.
Finally, the macOS-specific trap: apps launched from Finder or the Dock do not inherit shell exports. GUI processes are started by launchd, which never reads ~/.zshrc. If IntelliJ or Eclipse cannot see JAVA_HOME but your terminal can, that is why — set the JDK inside the IDE, or launch it from a terminal.
Verify it, in three commands
Do this in a new terminal. The first command proves the variable exists, the second proves it points at a real JDK, and the third catches the mismatch nobody checks for.
echo "$JAVA_HOME"
"$JAVA_HOME"/bin/java -version
java -version
On Windows: echo %JAVA_HOME% in cmd, $env:JAVA_HOME in PowerShell, then "%JAVA_HOME%\bin\java" -version.
If commands two and three print different versions, JAVA_HOME and PATH disagree. Your build tool will use one and your terminal the other, and the resulting UnsupportedClassVersionError will look like a project problem rather than an environment one. Putting $JAVA_HOME/bin (or %JAVA_HOME%\bin) first in PATH is what keeps them in step.
When JAVA_HOME is set and things still break
| What you see | What it means |
|---|---|
JAVA_HOME is set to "...", but "$JAVA_HOME/bin/java" does not exist |
The path is wrong, or it points one level too deep — you named bin instead of the folder above it. |
The java command does not exist in PATH nor is JAVA_HOME set |
The variable is genuinely not visible to that process. If a shell shows it, the process is not being started through that shell — see the systemd/sudo/cron list above. |
javac: command not found while java works |
JAVA_HOME points at a JRE, not a JDK. Check that bin/javac exists. |
echo %JAVA_HOME% prints %JAVA_HOME% |
The variable does not exist in that window. It was set after the window opened, or set with setx and never re-opened. |
The value has a stray " in it |
Quotes were typed into the Environment Variables dialog. It stores text literally — remove them. |
| Correct in the terminal, wrong in the IDE | The IDE was running when you changed it, or it was launched from Finder/Dock on macOS. Restart it. |
UnsupportedClassVersionError on a fresh build |
java -version and "$JAVA_HOME"/bin/java -version disagree. |
Which version should JAVA_HOME point at?
Point it at the newest LTS release your projects actually build with — Java 25 is the current one, Java 21 the previous and still the common floor — and override per project rather than per machine. A global JAVA_HOME that every project fights over is a worse problem than an old one.
Both major build tools support that:
- Gradle:
org.gradle.java.homeingradle.propertiesoverrides JAVA_HOME for the build. Better still, declare a toolchain in the build script and let Gradle locate or download the JDK it needs. - Maven:
~/.m2/toolchains.xmlnames JDKs by version, and the compiler plugin picks the one the project asks for.
Then JAVA_HOME becomes the sensible default rather than the thing that must be right for everything at once.
If you are still deciding what to install, or want the language underneath all this properly, the Java course on Code10x covers it topic by topic — and the Java interview questions section covers the JDK-versus-JRE distinction that this article's first rule depends on.
Elsewhere on this blog
Adjacent rather than required, in the order you are likely to want them:
- How to install Java 21 (JDK 21) on Windows — where the JDK comes from in the first place, including the installer option that sets JAVA_HOME so you never have to.
- Docker vs Kubernetes — the other half of "why does this machine not have the environment I set", once your Java runs in a container.
- What triggers a Kafka consumer group rebalance — Kafka is one of the servers whose start script reads JAVA_HOME; this is what it does after it has started.
Frequently asked questions
- Does the java command need JAVA_HOME?
- No. The java and javac commands locate their own installation and ignore the variable entirely. What reads it is everything built on top of the JDK — Maven, Gradle, Tomcat, IDEs and CI agents.
- Is it JAVA_HOME or JDK_HOME?
- JAVA_HOME. JDK_HOME is read by a few older tools and is not a general convention, so setting JAVA_HOME alone is correct for everything current. Set both only when a specific tool's documentation asks for it, and keep the two identical.
- Should JAVA_HOME include bin?
- No, and this is the single most common mistake. Tools append bin/java themselves, so a JAVA_HOME that already ends in bin becomes .../bin/bin/java and fails.
- How do I check whether JAVA_HOME is set?
- Run echo $JAVA_HOME on Linux or macOS, echo %JAVA_HOME% in Windows Command Prompt, or $env:JAVA_HOME in PowerShell — in a terminal opened after you set it. An empty result means the variable is not visible to that shell; Command Prompt prints %JAVA_HOME% straight back at you instead.
- Do I have to restart the computer after setting JAVA_HOME?
- No. A new terminal is enough on Windows, Linux and macOS. Do restart the application, the IDE included, because a running process keeps the environment it started with. On Linux a change under /etc/profile.d needs a fresh login rather than just a new tab.
- Why does my build use a different Java version than my terminal?
- Because they resolve the JVM differently. Build tools read JAVA_HOME first, while your terminal only follows PATH. Compare the output of "$JAVA_HOME"/bin/java -version with java -version, and put $JAVA_HOME/bin first in PATH so the two cannot disagree.
- Can JAVA_HOME point at a JRE?
- It will work for programs that only run Java and break the moment something compiles. Point it at a JDK — one whose bin/javac exists — and the question never comes up.
References
- Installation of the JDK on Microsoft Windows PlatformsOracle
- Installation of the JDK on Linux PlatformsOracle
- Installation of the JDK on macOSOracle
- setx — Windows Commands referenceMicrosoft
- Install the Microsoft Build of OpenJDKMicrosoft
- Installing Temurin on WindowsEclipse Adoptium
- Installing Apache MavenApache Maven
- mvn launcher scriptApache Maven
- about_Environment_Variables (PowerShell)Microsoft
- bash(1)Linux man-pages
- set — display and change shell variablesfish-shell
- crontab(5)Linux man-pages
- Use zsh as the default shell on MacApple
- UnsupportedClassVersionError (Java SE 21 API)Oracle
- setclasspath.sh (Tomcat startup scripts)Apache Tomcat
- kafka-run-class.shApache Kafka
- elasticsearch-envElastic
- Environment.SetEnvironmentVariable (.NET API)Microsoft
- systemd.exec(5)freedesktop.org
- sudoers(5)Sudo Project
- The Build EnvironmentGradle