JVM & Database Tuning Reference¶
How LDM sizes Liferay's JVM and database connections, how that compares with Liferay's published guidance, and why it differs where it does.
Written for LDM-#1448 so the differences are recorded decisions rather than accidents. Where a difference is deliberate the reason is given; where it is a defect it links to the issue tracking it.
What LDM actually produces¶
ComposerService.get_default_jvm_args() sizes from the smaller of host RAM
and the Docker daemon's reported memory — the daemon that will run the
container, which on a remote target is not the orchestrating machine (LDM-#1133).
Measured on a 32 GB host:
-Xms4096m -Xmx16384m
-XX:MaxMetaspaceSize=1024m -XX:MetaspaceSize=1024m
-XX:NewSize=5406m -XX:MaxNewSize=5406m
-XX:TieredStopAtLevel=1
And under --lean, which is also applied automatically when
GITHUB_ACTIONS=true:
JVM: LDM versus Liferay¶
| Setting | Liferay recommends | LDM | Why |
|---|---|---|---|
-Xms / -Xmx |
equal, "to prevent dynamic adjustments" | min ≈25% of RAM, max ≈50% | Deliberate. Liferay's advice targets a dedicated server. LDM runs Liferay, a database and often Elasticsearch on one laptop; reserving half the machine at startup would starve the rest. The cost is some heap resizing, which is cheapest exactly where memory is contended. |
-XX:NewSize / MaxNewSize |
half the heap | 33% of max heap | Undocumented divergence. Liferay's figure assumes a steady production workload; a dev cycle is dominated by redeploys and OSGi churn rather than short-lived request objects. Not measured either way — see "Open questions". |
-XX:MetaspaceSize / MaxMetaspaceSize |
768m, both equal | equal, tiered 384m–1024m by RAM | Matches the shape. Both set, both equal, scaled to the machine. |
-XX:ReservedCodeCacheSize |
96m — "too small a code cache (48m is the default) reduces performance" | unset; 240 MB ergonomic default now applies | Resolved by LDM-#1464. See below. |
-XX:InitialCodeCacheSize |
64m | unset | Follows from the above. |
-XX:SurvivorRatio / TargetSurvivorRatio / MaxTenuringThreshold |
16 / 50 / 15 | unset | Not evaluated. Generation-tuning advice written for CMS on Java 8; its applicability under G1 on Java 21 is unestablished. |
| Garbage collector | G1 on Java 11+; CMS/ParNew deprecated | unset → G1 | Correct. Java 21 defaults to G1; setting it explicitly would add nothing. |
| Maximum heap | "avoid more than 32g" | capped at 32g | Matches. |
--add-opens=jdk.zipfs/... |
required for fragment .zip export |
present | Matches (composer.py:581). |
The code cache: measured, and resolved¶
Since LDM-#1464 the default is 240 MB on every platform. What follows is the finding that led there, kept because it explains the reasoning.
The JVM's default ReservedCodeCacheSize is not constant — it depends on a
flag LDM sets itself. Measured on eclipse-temurin:21:
LDM adds -XX:TieredStopAtLevel=1 on darwin and windows, and --lean sets
it unconditionally. So on LDM's primary developer platforms the code cache is
48 MB — precisely the figure Liferay's guidance singles out as harmful.
| Platform | TieredStopAtLevel=1 |
Effective code cache |
|---|---|---|
| macOS / Windows | yes, by default | 48 MB |
| Linux | no | 240 MB |
--lean, any platform |
yes | 48 MB |
This also explained LDM-422/423: the reindex path removes
TieredStopAtLevel=1 and sets ReservedCodeCacheSize=512m to prevent
VirtualMachineError. That treated the symptom on one code path; the cause was
that the flag silently dropped the cache everywhere it was applied.
What settled it. The flag was added on the stated grounds that it "speeds up
bundle resolution significantly". Measured over five full starts per arm on
macOS 26.6.2 / Colima with 2026.q1.7-lts:
| warm median | mean | spread | |
|---|---|---|---|
TieredStopAtLevel=1 |
130.5s | 131.2s | 129–135s |
| without it | 130.5s | 131.2s | 129–135s |
Identical. The benefit was not reproducible; the cost was. The default was removed in LDM-#1464, so the JVM's 240 MB ergonomic value now applies.
--lean still sets the flag — that profile trades throughput for footprint on
purpose — and anyone who wants it back gets it with
jvm_tiered_stop_at_level=true.
Database connection pool¶
Liferay uses HikariCP. Verified by extracting portal.properties from
liferay/dxp:2026.q1.7-lts:
jdbc.default.connectionTimeout=30000
jdbc.default.idleTimeout=600000
jdbc.default.maximumPoolSize=180
jdbc.default.maxLifetime=0
jdbc.default.minimumIdle=10
jdbc.default.registerMbeans=true
LDM used to write jdbc.default.maxActive, minIdle and maxIdle — DBCP
names, which appear nowhere in those 12,085 lines and were therefore ignored.
db_max_active and friends were settable and had no effect at all; every
project ran on Liferay's built-in defaults.
Fixed in LDM-#1454. LDM now writes:
| Config key | Liferay property | LDM default | Liferay default |
|---|---|---|---|
db_max_active |
jdbc.default.maximumPoolSize |
15 | 180 |
db_min_idle |
jdbc.default.minimumIdle |
2 | 10 |
db_idle_timeout |
jdbc.default.idleTimeout |
600000 | 600000 |
The smaller pool is deliberate: 180 is sized for a production server, and LDM targets a laptop running a single project. This was a behaviour change rather than a rename — correcting the names gave those values effect for the first time, moving the pool from 180 to 15.
db_max_idle is gone: HikariCP has one pool size and no maximum-idle setting,
governing idle connections through idleTimeout instead. An existing
~/.ldmrc carrying it still loads, and LDM warns once naming the replacement.
Not exposed at all¶
From the Liferay tuning page, for completeness rather than as a commitment:
- Tomcat thread pool —
maxThreads,minSpareThreads(50, up to 250),maxConnections=16384,connectionTimeout,URIEncoding=UTF-8. LDM has noserver.xmlhandling. - JSP engine —
development=false,mappedFile=falsefor non-dev use.
Open questions¶
NewSizeat 33% rather than 50%. Neither figure has been measured against an LDM workload. Worth benchmarking before changing.- Survivor-space tuning. Liferay's values are Java 8 / CMS advice; whether they help under G1 on Java 21 is unknown.
- Whether
-Xms/-Xmxshould converge for users who do run LDM on a dedicated machine. That is a good argument for the profiles in LDM-#1449.
All three are boot-time or throughput questions, and none has an answer in this file because none has been measured. Measuring boot time is the instrument for the first two.
Overriding any of this¶
An unset value keeps the adaptive calculation. Changing the heap does not
discard metaspace sizing, the platform compiler decision or the reindex
scale-up. That is the difference between these and --jvm-args, which replaces
LDM's defaults entirely (LDM-#1449).
| Setting | CLI flag | Config key | Renders as |
|---|---|---|---|
| Initial heap | --jvm-heap-min |
jvm_heap_min |
-Xms |
| Maximum heap | --jvm-heap-max |
jvm_heap_max |
-Xmx |
| Metaspace | --jvm-metaspace |
jvm_metaspace |
-XX:MetaspaceSize / MaxMetaspaceSize |
| Young generation | --jvm-new-size |
jvm_new_size |
-XX:NewSize / MaxNewSize |
| Compiler level | --jvm-tiered-stop-at-level |
jvm_tiered_stop_at_level |
-XX:TieredStopAtLevel=1 |
Sizes accept a bare number of megabytes or a JVM suffix — 2048, 512m, 8g.
A value that cannot be read is ignored with a warning and the calculated
value kept, rather than failing the container at start.
Precedence¶
Most specific wins:
--jvm-heap-max 8g CLI flag
project meta per-project
~/.ldmrc per-user ) ldm config
/etc/ldmrc per-machine )
--lean profile
adaptive calculation base
Profiles¶
--lean is a named set of overrides rather than a fixed string, so it leaves
anything it does not mention adaptive:
It is also applied implicitly when GITHUB_ACTIONS=true.
Seeing what is actually in effect¶
ldm info shows the resolved arguments and which layer supplied each one
(LDM-#1458):
JVM Arguments:
-Xms2048m calculated
-Xmx8192m project meta
-XX:MaxMetaspaceSize=512m calculated
-XX:MetaspaceSize=512m calculated
-XX:NewSize=1013m calculated
-XX:TieredStopAtLevel=1 calculated
Worth having because the cascade's strength is also its difficulty: an unset key stays adaptive, so a value can change because of a file you are not looking at. Without attribution, "why is my heap this size?" means reading three config files and knowing the adaptive tiers.
The blunt instrument¶
--jvm-args still replaces everything, and is documented in
Advanced CLI. Reach for it only when you want none of the
above.
When the kernel kills Liferay¶
If the machine cannot give the container what the heap settings ask for, the kernel SIGKILLs the JVM. LDM names this rather than letting it read as a slow boot:
It is worth stating why that message had to be added. The JVM is killed while
the entrypoint survives, so the container stays up and simply never becomes
healthy -- and docker inspect reports ExitCode: 0, because the entrypoint
then exits cleanly. Nothing in the container's state contradicts a timeout, so
LDM used to print "Timed out waiting for Liferay to become healthy" after
eight minutes while OOMKilled: true sat one docker inspect away. The two
readings send you to opposite places: one says wait longer or look at the
application, the other says this machine does not have enough memory
(LDM-#1773).
Waiting longer will not help. Either raise Docker's memory allocation, or reduce what the stack asks for:
jvm_heap_max/--jvm-heap-maxcaps the portal's own heap (see Overriding any of this).- Check whether a second Elasticsearch is running. An imported package can
carry
operationMode=EMBEDDEDin its restored environment, which starts a sidecar ES inside the Liferay container alongside the sharedliferay-search-globalnode. That sidecar defaults to-Xmx2g, so a stack sized for a 3 GB ceiling is really asking for 5 GB (LDM-#1773).
LDM reports the kill on the node the project actually runs on, not the operator's laptop.
Where a single slow boot spent its time¶
scripts/boot_timing.sh below compares versions. When the question is
instead "this one boot took ten minutes -- doing what?", run the command with
--benchmark:
LDM watches the container log for the phases of a Liferay startup and reports the gap between them, so the longest pole is visible rather than inferred:
Startup phases:
Tomcat Initializing + 1s (at 1s)
Database Schema Initializing + 5s (at 6s)
OSGi Framework Starting + 23s (at 29s)
OSGi Framework Started + 0s (at 29s)
Portal Startup Progress + 11s (at 41s)
Verify Processes Running + 1m 3s (at 1m 44s)
Tomcat Server Ready +3m 47s (at 5m 31s)
Site Initializer Running + 6s (at 5m 37s)
after the last milestone +4m 15s
That is a real 9m57s boot of 2026.q3.7 on a Windows 11 host with 7.6 GB
allocated to Docker Desktop. It reads as two blocks worth attention: 3m 47s
between the verify processes starting and Tomcat reporting ready, and 4m 15s
after the site initializer began. Neither produces any log output while it
runs, which is why a boot like this looks like a hang.
The timings are written to <project>/.liferay-docker/startup-status.json
under milestone_seconds on every boot, whether or not --benchmark was
passed, so a slow start can be decomposed afterwards without reproducing it.
Check the allocation before concluding anything about LDM. ldm doctor
--detailed reports Docker CPUs and Docker Memory; a boot time is not
comparable between machines without them. The example above is not evidence
about a Liferay version or a search mode -- it is one host with one allocation.
Measuring boot time¶
Several of the open questions above end in "worth benchmarking". The
instrument for that is scripts/boot_timing.sh.
It reports two numbers per run, and they are not interchangeable:
| Metric | What it is | What it answers |
|---|---|---|
tomcat_ms |
Liferay's own figure, read from its log | Is the application slower? |
healthy_s |
Container start to Docker reporting healthy | Is the stack slower? |
Only the second gates anything. depends_on: condition: service_healthy
blocks dependent containers on healthy_s, so that number -- not Tomcat's --
decides when a client extension is allowed to start. The two move
independently: the healthcheck's interval, retries and start_period are
LDM's to write (ldm_core/handlers/composer.py), so a change there moves
healthy_s and leaves tomcat_ms untouched. That is a real difference, not
noise, which is why both are reported.
The script pre-pulls the image outside the timed window, creates a fresh
project per run so no OSGi state or database carries over, deletes it
afterwards, and passes --no-wait so LDM's own readiness polling is not part
of what is measured -- healthy_s is Docker's verdict, not ldm wait's.
Reading the result¶
Compare medians and ranges, on one machine, against one other version. If the ranges overlap, you have not measured a difference.
Worked example from 2026-10-02, the A/B that closed out a suspected v2.26.0
startup regression. One Fedora 44 workstation, Liferay 2026.q3.5, three runs
each:
| Version | tomcat_ms median |
range | healthy_s median |
range |
|---|---|---|---|---|
| v2.25.0 | 137,664 | 137,256-142,155 | 199 | 198-199 |
| v2.26.2-pre.1 | 138,473 | 138,364-139,725 | 203 | 198-203 |
That is +0.6% and +2.0%, and the healthy_s ranges overlap at 198s. The
decisive line is not the median at all: v2.25.0's slowest run, 142.2s, is
slower than v2.26.2's slowest, 139.7s. The hypothesis under test was that
v2.26.0's move of the routes mount into /opt/liferay/routes -- a path
Liferay reads and writes during startup, where the previous location was not
-- had added boot-time I/O. A plausible mechanism, and not what was happening.
Two cautions that example illustrates:
- It measures the machine it runs on. Bind mounts are cheap on native Linux and expensive through Docker Desktop's translation layer or CI network storage. A null result here does not transfer to those.
- One run proves nothing. The report that prompted the investigation was two runs 84 seconds apart -- 24% -- and both were the same build. Within-version variance of that size swamps anything these two releases did to each other.
Last Updated: 2026-10-14 | Last Reviewed: 2026-10-14