
A fresh Ubuntu 26.04 server is up, SSH works, and the application team wants Java. It sounds like a thirty-second job. Then someone asks which version, whether it should be a JRE or a JDK, why javac is missing, and why Tomcat still picks Java 17 after the install. Anyone who has handled a few production rollouts knows the install command is the easy part. The mess starts with version drift, a wrong JAVA_HOME, a JVM that ignores its container limits, or a service that dies at 3 a.m. because nobody sized the heap.
Ubuntu 26.04 LTS, codenamed Resolute Raccoon, makes the basic install simple. The release notes state that OpenJDK moved from version 21 to 25, while the LTS versions 8, 11, 17 and 21 stay available. Ubuntu’s own availability table lists 8, 11, 17, 21, 25 and 26 for this release, with 25 as the default. So the package manager gives you a modern, supported runtime without any third-party repository.
This guide covers the install and what comes after it. You will learn how to pick the right package, run several Java versions side by side, set JAVA_HOME properly, wire Java into systemd, tune memory and garbage collection, harden the service, and debug the errors that show up most often in real environments. The commands are tested against the Ubuntu and Debian family, and the AlmaLinux and Rocky equivalents appear where they differ.
Which Java Do You Need on Ubuntu 26.04?
Before typing anything, settle three questions. They save more time than any command.
JDK, JRE, or headless?
The JRE runs compiled Java programs. The JDK adds the compiler (javac), jar, jcmd, jstack, jmap and the other diagnostic tools. Ubuntu also splits each into a normal and a -headless variant. Headless builds leave out the AWT and Swing graphical libraries, so they are smaller and pull in fewer dependencies.
A practical rule for servers:
- Run a prebuilt application such as Tomcat, Jenkins, Kafka, Elasticsearch or a Spring Boot JAR: install
openjdk-25-jre-headless. - Build code on the box, or want to debug a live process: install
openjdk-25-jdk-headless. - Develop on a desktop and need GUI tooling: install the full
openjdk-25-jdk.
Many admins install the full JDK on production out of habit. A better habit is to keep the JDK because jcmd and jstack are lifesavers during an incident. Having them already on disk at 2 a.m. beats trying to install packages while the site is down. The attack surface of a JDK is no larger in any meaningful way, as long as you do not ship compilers into a locked-down image where policy forbids them.
Which version?
Java has a six-month release cadence, but only some releases are long-term support. The LTS line is 8, 11, 17, 21 and 25. Ubuntu 26.04 also carries non-LTS builds such as 26, and its release notes mention previews of 26 and 27. Do not run non-LTS versions on production. They lose upstream updates within months.
The decision usually looks like this:
| Situation | Recommended version |
|---|---|
| New project, no legacy constraints | OpenJDK 25 |
| Mature app certified on 21 | OpenJDK 21 until vendor support for 25 is confirmed |
| Older enterprise stack (Tomcat 9, old Spring) | OpenJDK 17 or 11 |
| Legacy tools that refuse to start on anything new | OpenJDK 8 (isolate it) |
Always read the application’s supported-runtime documentation. A framework that “works” on a newer JVM but is unsupported leaves you alone when something breaks.
Preparing the Server
Start with a clean package index and a quick look at the system. This takes a minute and prevents confusing results later.
sudo apt update
sudo apt upgrade -y
cat /etc/os-release
uname -r
On a stock install, uname -r should report a 7.0 series kernel, since Ubuntu 26.04 ships Linux 7.0 for both the GA and HWE stacks. If the machine is a minimal cloud image, you may find universe disabled in rare cases. OpenJDK 25 lives in main for 26.04 according to Launchpad’s package listing, but enabling universe is harmless and helps with some add-on packages:
sudo apt install -y software-properties-common
sudo add-apt-repository universe
sudo apt update
Check what is already installed, because a surprising number of servers arrive with a JRE pulled in as a dependency of something else:
java -version
dpkg -l | grep -i -E 'openjdk|temurin|zulu|corretto'
If java returns “command not found,” nothing is installed. If it returns something unexpected, note it. You will need that information in the version-switching section.
Installing Java on Ubuntu 26.04 LTS with APT
Method 1: the default JDK
The shortest route is the metapackage. On 26.04, default-jdk resolves to OpenJDK 25.
sudo apt install -y default-jdk
java -version
javac -version
You should see output containing openjdk version "25.x.x". The exact patch level will differ by the date you install. Launchpad shows the 26.04 security and updates pocket carrying builds such as 25.0.4.1 from September 2026, with newer uploads following. That is the strength of the distro package: security fixes arrive through normal apt upgrade, with no manual tracking.
The default-jdk approach has a hidden downside. If Ubuntu changes the default in a future release, the metapackage follows. On a production host that you want pinned, name the version explicitly.
Method 2: pin a specific version (preferred for servers)
sudo apt install -y openjdk-25-jdk-headless
Or for runtime-only boxes:
sudo apt install -y openjdk-25-jre-headless
Naming the package makes the dependency obvious to the next admin reading your Ansible role or shell history. It also keeps an unattended upgrade from ever jumping major versions underneath your app.
Confirm exactly where the binary lives, because the path matters later:
readlink -f "$(command -v java)"
Typically that prints something like /usr/lib/jvm/java-25-openjdk-amd64/bin/java on x86_64, or the arm64 variant on Graviton and Ampere machines. OpenJDK 25 is TCK certified on AMD64, ARM64, S390X and PPC64EL according to the release notes, so ARM servers are a fully supported target.
Method 3: install several versions side by side
Real infrastructure rarely has one Java. A build server may need 17 for one repository and 25 for another. Apt handles this cleanly:
sudo apt install -y openjdk-17-jdk-headless openjdk-21-jdk-headless openjdk-25-jdk-headless
ls /usr/lib/jvm/
Each lands in its own directory under /usr/lib/jvm. Nothing conflicts. Which one java points to is controlled by the alternatives system, covered next.
Switching Between Java Versions
Debian and Ubuntu use update-alternatives to manage symlinks for java, javac and friends. Check the current state:
update-alternatives --list java
sudo update-alternatives --config java
The second command shows a numbered menu. Pick the entry you want. Repeat for the compiler if the JDK is involved:
sudo update-alternatives --config javac
Here is a trap that bites teams repeatedly. Switching java does not switch javac or jar. You can end up compiling with 25 and running with 17, which produces UnsupportedClassVersionError. Keep both aligned, or better, skip the system default for application work and point each service at its own JVM explicitly (see the systemd section).
For scripts, a non-interactive switch works:
sudo update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java
For developer machines and CI runners that hop between versions all day, SDKMAN is popular, and it installs under the user’s home directory without root. On servers, though, system packages with explicit paths are easier to audit.
Setting JAVA_HOME Correctly
Many tools (Maven, Gradle, Tomcat’s catalina.sh, Jenkins) look for JAVA_HOME. Others use whatever java is on the PATH. Setting both consistently avoids “works in my shell, fails in cron” problems.
System-wide via /etc/profile.d
sudo tee /etc/profile.d/java.sh > /dev/null <<'EOF'
export JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v javac || command -v java)")")")"
export PATH="$JAVA_HOME/bin:$PATH"
EOF
sudo chmod 644 /etc/profile.d/java.sh
source /etc/profile.d/java.sh
echo "$JAVA_HOME"
This derives the path dynamically, so a switch through update-alternatives carries through to new shells. A hardcoded line like /usr/lib/jvm/java-25-openjdk-amd64 is more predictable, which some teams prefer for compliance reasons. Either is fine, but pick one and document it.
Why /etc/environment is a weaker choice
Many tutorials tell you to edit /etc/environment. It is read by PAM and does not expand variables or run commands, so the dynamic trick above fails there. A static value works, but you must keep it current manually. For login shells, profile.d is cleaner.
The cron and systemd gotcha
Neither cron nor systemd services read /etc/profile.d. If a scheduled job fails with “JAVA_HOME is not set,” that is why. Set the variable inside the crontab or, for services, in the unit file as shown below.
Running a Java Application as a systemd Service
This is where most production setups either shine or quietly rot. Running java -jar app.jar & inside a tmux session is not a deployment strategy.
Create a dedicated unprivileged user first:
sudo useradd --system --create-home --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo mkdir -p /opt/myapp /var/log/myapp
sudo chown -R myapp:myapp /opt/myapp /var/log/myapp
Then the unit file at /etc/systemd/system/myapp.service:
[Unit]
Description=My Java Application
After=network-online.target
Wants=network-online.target
[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64"
Environment="JAVA_OPTS=-Xms1g -Xmx1g -XX:+ExitOnOutOfMemoryError"
ExecStart=/usr/lib/jvm/java-25-openjdk-amd64/bin/java $JAVA_OPTS -jar /opt/myapp/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp /var/log/myapp
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Load and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
journalctl -u myapp -f
A few choices here deserve explanation. SuccessExitStatus=143 tells systemd that a SIGTERM shutdown (the JVM exits with 128+15) is normal and not a failure. ExitOnOutOfMemoryError makes the JVM die cleanly on heap exhaustion so systemd can restart it, rather than limping along half-dead and serving errors. LimitNOFILE matters because the default file descriptor limit of 1024 collapses under real connection counts. The ProtectSystem=strict block makes the whole filesystem read-only to the process except the paths you list. If the app needs to write elsewhere, you will see “Read-only file system” errors, and the fix is to extend ReadWritePaths, not to remove the hardening.
Installing a Different Distribution: Temurin and Others
The distro OpenJDK is a solid default, but some organizations standardize on a vendor build such as Eclipse Temurin, Amazon Corretto, or Azul Zulu. Reasons include a vendor support contract, identical binaries across Ubuntu, Alpine and RHEL-family hosts, or a longer maintenance window for old versions.
Adoptium publishes an APT repository. The general pattern, which you should confirm against Adoptium’s current documentation for the 26.04 codename, looks like this:
sudo apt install -y wget gpg apt-transport-https
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | \
sudo gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg
echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(. /etc/os-release && echo $VERSION_CODENAME) main" | \
sudo tee /etc/apt/sources.list.d/adoptium.list
sudo apt update
sudo apt install -y temurin-25-jdk
Note the signed-by keyring approach. The old apt-key add method is deprecated and should not appear in modern guides. If the repository does not yet publish a suite for the 26.04 codename, apt update will complain, and a tarball install under /opt is the fallback.
For a manual tarball install, which gives total version control at the cost of manual patching:
sudo mkdir -p /opt/java
sudo tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_*.tar.gz -C /opt/java
sudo update-alternatives --install /usr/bin/java java /opt/java/jdk-25*/bin/java 2500
You own security updates in that scenario. Put a calendar reminder on it, or better, automate it.
Equivalent Commands on AlmaLinux, Rocky, and Debian
Since mixed fleets are common, here is the quick translation table.
| Task | Ubuntu / Debian | AlmaLinux / Rocky (dnf) |
|---|---|---|
| Update index | sudo apt update |
sudo dnf makecache |
| Install JDK 21 headless | sudo apt install openjdk-21-jdk-headless |
sudo dnf install java-21-openjdk-headless java-21-openjdk-devel |
| Switch version | update-alternatives --config java |
alternatives --config java |
| List installed | dpkg -l | grep openjdk |
rpm -qa | grep openjdk |
Package availability for a given version differs per distro release, so verify with apt-cache policy openjdk-25-jdk or dnf list available 'java-*-openjdk*' before assuming.
Troubleshooting Common Problems
“Unable to locate package openjdk-25-jdk”
Usually an outdated package index, a typo, or a mirror issue. Run sudo apt update, then apt-cache policy openjdk-25-jdk. If no candidate appears, check /etc/apt/sources.list.d/ubuntu.sources for disabled components and confirm you are actually on 26.04 with lsb_release -a. On a release that does not carry the package, the answer is a vendor repository or tarball.
“java: command not found” after install
Either only a library package was installed, or the shell has a cached path. Run hash -r, then ls /usr/lib/jvm, and update-alternatives --list java. If the alternatives list is empty, reinstall: sudo apt install --reinstall openjdk-25-jre-headless.
“javac: command not found”
You installed a JRE. Add the JDK package. This is the single most common newcomer error.
UnsupportedClassVersionError
The message mentions “class file version 69.0” (Java 25) or similar, “this version of the Java Runtime only recognizes class file versions up to 61.0.” The JAR was built for a newer Java than the one running it. Check which java the process actually uses:
ps -ef | grep java
sudo ls -l /proc/<PID>/exe
Fix by pointing the service at the correct JVM or by compiling with --release 17 to target an older runtime.
JAVA_HOME is set but points to a missing directory
After removing a JDK package, stale JAVA_HOME values linger. Run echo $JAVA_HOME; ls $JAVA_HOME and correct the profile script. Also check ~/.bashrc and ~/.profile for old exports that override the system one.
OutOfMemoryError: Java heap space (or Metaspace)
Capture evidence before restarting. Add -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/myapp/ to the options, then analyze the dump with Eclipse MAT. A bigger heap only delays a leak. For Metaspace errors, look for classloader leaks, common after repeated redeploys on Tomcat without restarts.
Process killed with no Java error (exit 137)
That is the kernel OOM killer. Confirm:
sudo dmesg -T | grep -i -E 'killed process|out of memory'
journalctl -k | grep -i oom
The heap fit, but total process memory did not. Reduce -Xmx, cap thread counts, or add RAM. Remember that metaspace and direct buffers sit outside the heap.
“Too many open files”
Raise LimitNOFILE in the unit file, not ulimit in a shell, because systemd services ignore /etc/security/limits.conf. Verify the live value with cat /proc/<PID>/limits.
Port already in use
sudo ss -ltnp | grep :8080
Find the owner, stop it, or change the application port. A zombie JVM from a failed restart is the usual culprit.
Service starts, then fails under systemd but works manually
Compare environments. Systemd provides a minimal one. Check journalctl -u myapp -n 100 --no-pager, confirm paths are absolute, verify the service user can read the JAR, and review ProtectSystem restrictions. Running sudo -u myapp /usr/lib/jvm/java-25-openjdk-amd64/bin/java -jar /opt/myapp/app.jar often reproduces the problem instantly.