Skip to content

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:

-Xms1536m -Xmx2048m
-XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m
-XX:TieredStopAtLevel=1

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:

java                          ->  240 MB  (ergonomic)
java -XX:TieredStopAtLevel=1  ->   48 MB  (default)

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 no server.xml handling.
  • JSP engine — development=false, mappedFile=false for non-dev use.

Open questions

  1. NewSize at 33% rather than 50%. Neither figure has been measured against an LDM workload. Worth benchmarking before changing.
  2. Survivor-space tuning. Liferay's values are Java 8 / CMS advice; whether they help under G1 on Java 21 is unknown.
  3. Whether -Xms/-Xmx should 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:

lean: heap_min 1536m, heap_max 2048m, metaspace 512m,
      no NewSize, TieredStopAtLevel=1

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:

❌  Liferay was killed by the kernel for running out of memory -- it did not
    simply take too long.

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-max caps the portal's own heap (see Overriding any of this).
  • Check whether a second Elasticsearch is running. An imported package can carry operationMode=EMBEDDED in its restored environment, which starts a sidecar ES inside the Liferay container alongside the shared liferay-search-global node. 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 run myproject --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.

scripts/boot_timing.sh --runs 3

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