Running Multiple Java Apps with Different JDK Versions on One Server
Got microservices on different Java versions? Need to run them on one box? Here's the no-BS guide.
TL;DR
- Install multiple JDKs via package manager (they coexist fine)
- Use absolute paths to Java binary in systemd services
- Don't rely on
JAVA_HOMEor system default - Plan memory — multiple JVMs add up fast
The Problem
Linux has one java in PATH. Running java -jar app.jar uses whatever that default is.
Your apps need Java 11, 17, and 21. Now what?
Solution: Don't use the default. Point each app to its specific Java binary.
Step 1: Install Multiple JDKs
Ubuntu/Debian
apt update
apt install -y openjdk-11-jre-headless \
openjdk-17-jre-headless \
openjdk-21-jre-headless
Amazon Linux / RHEL
yum install -y java-11-amazon-corretto-headless \
java-17-amazon-corretto-headless \
java-21-amazon-corretto-headless
Verify
ls /usr/lib/jvm/
Paths you'll see:
| Distro | Java 11 | Java 17 | Java 21 |
|---|---|---|---|
| Ubuntu | /usr/lib/jvm/java-11-openjdk-amd64 |
/usr/lib/jvm/java-17-openjdk-amd64 |
/usr/lib/jvm/java-21-openjdk-amd64 |
| Amazon Linux | /usr/lib/jvm/java-11-amazon-corretto |
/usr/lib/jvm/java-17-amazon-corretto |
/usr/lib/jvm/java-21-amazon-corretto |
Step 2: Systemd Services with Explicit Java Paths
Java 11 App
# /etc/systemd/system/legacy-api.service
[Unit]
Description=Legacy API (Java 11)
After=network.target
[Service]
User=appuser
WorkingDirectory=/opt/apps/legacy-api
ExecStart=/usr/lib/jvm/java-11-openjdk-amd64/bin/java \
-Xms256m -Xmx512m \
-jar /opt/apps/legacy-api/legacy-api.jar
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Java 17 App
# /etc/systemd/system/main-api.service
[Unit]
Description=Main API (Java 17)
After=network.target
[Service]
User=appuser
WorkingDirectory=/opt/apps/main-api
ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java \
-Xms512m -Xmx1g \
-XX:+UseG1GC \
-jar /opt/apps/main-api/main-api.jar
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Java 21 App (with ZGC)
# /etc/systemd/system/new-api.service
[Unit]
Description=New API (Java 21)
After=network.target
[Service]
User=appuser
WorkingDirectory=/opt/apps/new-api
ExecStart=/usr/lib/jvm/java-21-openjdk-amd64/bin/java \
-Xms512m -Xmx1g \
-XX:+UseZGC \
-XX:+ZGenerational \
-jar /opt/apps/new-api/new-api.jar
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable All
systemctl daemon-reload
systemctl enable --now legacy-api main-api new-api
Step 3: Using Environment Files (Cleaner)
For multiple apps, use .env files:
# /opt/apps/main-api/.env
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
JAVA_OPTS=-Xms512m -Xmx1g -XX:+UseG1GC
APP_PORT=8082
# /etc/systemd/system/main-api.service
[Service]
User=appuser
EnvironmentFile=/opt/apps/main-api/.env
ExecStart=/bin/bash -c 'exec $JAVA_HOME/bin/java $JAVA_OPTS -Dserver.port=$APP_PORT -jar /opt/apps/main-api/main-api.jar'
Restart=always
⚠️ Why bash wrapper? Systemd doesn't do shell-style variable expansion.
${JAVA_HOME}inExecStartis passed as literal string. Wrapping inbash -cfixes this.
Bonus: ASG User Data Script
For Auto Scaling Groups — generate services dynamically at boot:
#!/bin/bash
set -e
# Install JDKs
yum install -y java-11-amazon-corretto-headless \
java-17-amazon-corretto-headless \
java-21-amazon-corretto-headless
# Config: name|java_version|s3_path|port|heap
APPS=(
"legacy-api|11|s3://bucket/legacy-api.jar|8081|512m"
"main-api|17|s3://bucket/main-api.jar|8082|1g"
"new-api|21|s3://bucket/new-api.jar|8083|1g"
)
mkdir -p /opt/apps
for app_config in "${APPS[@]}"; do
IFS='|' read -r name java_ver s3_path port heap <<< "$app_config"
mkdir -p /opt/apps/${name}
aws s3 cp ${s3_path} /opt/apps/${name}/${name}.jar
JAVA_BIN="/usr/lib/jvm/java-${java_ver}-amazon-corretto/bin/java"
cat <<EOF > /etc/systemd/system/${name}.service
[Unit]
Description=${name} (Java ${java_ver})
After=network.target
[Service]
User=ec2-user
ExecStart=${JAVA_BIN} -Xms256m -Xmx${heap} -Dserver.port=${port} -jar /opt/apps/${name}/${name}.jar
Restart=always
[Install]
WantedBy=multi-user.target
EOF
done
systemctl daemon-reload
for app_config in "${APPS[@]}"; do
name=$(echo $app_config | cut -d'|' -f1)
systemctl enable --now ${name}
done
Memory Planning
Multiple JVMs = plan carefully.
t3.large (8 GB) example:
OS reserved: ~1 GB
legacy-api: -Xmx512m
main-api: -Xmx1g
new-api: -Xmx1g
worker: -Xmx512m
scheduler: -Xmx256m
----------------------------
Total heap: ~3.3 GB
With overhead: ~5-6 GB
Buffer: ~2 GB ✅
JVMs use more than heap (metaspace, threads, native). Leave buffer.
Verify Runtime Versions
# Quick check
ps aux | grep java
# Detailed
for pid in $(pgrep -f "java.*jar"); do
echo "=== PID: $pid ==="
cat /proc/$pid/cmdline | tr '\0' ' '
echo
done
Output:
=== PID: 1234 ===
/usr/lib/jvm/java-11-openjdk-amd64/bin/java -Xms256m -jar /opt/apps/legacy-api.jar
=== PID: 1235 ===
/usr/lib/jvm/java-17-openjdk-amd64/bin/java -Xms512m -jar /opt/apps/main-api.jar
Quick Reference
| Task | Command |
|---|---|
| List JDKs | ls /usr/lib/jvm/ |
| Check default | java -version |
| Change default | update-alternatives --config java |
| Find process Java | ls -l /proc/<PID>/exe |
When NOT to Use This
Use containers instead when:
- ✅ Need independent scaling per service
- ✅ High deploy frequency
- ✅ Team prefers immutable infra
EC2 multi-JVM approach = cost savings, more ops overhead.
Wrapping Up
Running multiple Java versions on one server:
- Install all versions via package manager
- Use absolute paths in systemd services
- Never rely on system defaults
- Plan memory — JVMs add up fast
No SDKMAN complexity. No Docker overhead. Just explicit paths and systemd.
Questions? Drop them below. 👇
Top comments (0)