Serguey Asael Shinder: Runtime.totalMemory is the heap the JVM has taken, not the memory you are using
A lot of health checks and log lines report JVM memory like this:
Runtime rt = Runtime.getRuntime();
log.info("memory: {} MB", rt.totalMemory() / (1024 * 1024));
The number looks like "how much memory the application uses". It is not. The three methods on java.lang.Runtime answer three different questions, and the javadoc for Java 25 says so in their return descriptions:
totalMemory()returns "the total amount of memory currently available for current and future objects". The javadoc adds that "the value returned by this method may vary over time, depending on the host environment".freeMemory()returns "an approximation to the total amount of memory currently available for future allocated objects". Callinggc()"may result in increasing the value returned by freeMemory".maxMemory()returns "the maximum amount of memory that the virtual machine will attempt to use", and "if there is no inherent limit then the value Long.MAX_VALUE will be returned".

Put together: totalMemory() is what the JVM currently holds for objects, used or not. The part occupied by objects is the difference:
long used = rt.totalMemory() - rt.freeMemory(); // occupied by current objects
long committed = rt.totalMemory(); // currently available for current and future objects
long ceiling = rt.maxMemory(); // may be Long.MAX_VALUE
Why the distinction bites. totalMemory() includes memory set aside for objects that do not exist yet, and the javadoc says it may vary over time with the host environment. A dashboard built on it can show growth that is not a leak, and it cannot show a leak that still fits inside memory the JVM has already taken. freeMemory() is explicitly an approximation and moves after a collection, so the difference is a snapshot, not a precise measure; it is still the one that tracks what your objects occupy.
And the ceiling. Code that computes a percentage as used * 100 / rt.maxMemory() assumes a finite limit. The javadoc allows Long.MAX_VALUE when there is none, and the division then reports roughly zero percent forever. Check for that value before you divide.
This is also why heap sizing changes such as JEP 545, now targeted to JDK 28, matter for monitoring: when a collector starts with a small heap and grows it, totalMemory() climbing in the first minutes is the design working, not a symptom.