1. Two boundaries: container memory and JVM memory
With broken config, broken host, or broken profile, things are pretty straightforward: the service either does not start, or it tells you exactly what is wrong. Resource limits are trickier. The same image and the same config can behave differently simply because the container was given a different memory or CPU budget. So first, let us separate these two axes, and we will start with memory: here, the main confusion almost always centers around --memory and -Xmx.
When a Java developer first hears “container memory limit,” the brain tries to simplify: “Ah, that is just like -Xmx, only set by Docker.” And that is where the first trap of the day is waiting for us. In reality, we have two different boundaries: the outer container boundary and the internal memory organization of the JVM. If you mix up their roles, you will be treating symptoms in the wrong place.
A container memory limit is what Docker (more precisely, the runtime) allows for the entire process inside the container. The JVM, in turn, within this budget tries to distribute memory among its needs: heap (where Java objects live), service areas, thread stacks, buffers, and so on. That is why --memory and -Xmx answer different questions, even though both are “about memory.”
To make it completely clear, let us imagine this as nested dolls:
flowchart TB A["Docker container memory limit
(--memory)"] --> B["Java process (JVM)"] B --> C["Heap (Java objects)
-Xmx"] B --> D["Other memory
(JVM service memory, stacks, buffers, etc.)"]
The meaning of the diagram is simple: heap is part of the process memory, while the container limits the whole process as a single unit.
2. --memory in Docker: what it limits
When we say “let us set a memory limit for the container,” we are not doing the JVM a favor or “configuring Java.” We are telling Docker: “This container must not consume more than N memory.” This is an external constraint, and it does not depend on whether you have Spring Boot there or a calculator in Bash (yes, people write that too).
In the simplest case, the limit is set when the container starts. Docker has the --memory flag (short form -m). Example for our service:
# Start the container with a memory limit for the entire process inside the container
docker run --rm --name catalog-service \
--memory=512m \
-p 8080:8080 \
docker-java-catalog-service
It is important to understand one thing here: --memory=512m does not mean “heap will be 512m.” It means: “the container must not exceed roughly 512 megabytes of total memory consumption.” After that, life begins: the JVM inside the container will try to fit into this budget. If it fits, great. If not, the environment has the right (and the technical ability) to kill the process so fast that you will not even have time to politely say “sorry.”
If no limit is set, that does not mean “infinite.” It means “within the limits available to the system/VM where Docker is running.” On Docker Desktop (macOS/Windows), there is also a memory limit of the Docker Desktop virtual machine itself, so “unlimited” there is also quite relative. But for us today, the main point is: `--memory` is an external ceiling for the entire process.
3. -Xmx: maximum heap
Now let us return to the familiar JVM world. The -Xmx flag sets the maximum size of the heap. Heap is where your ordinary Java objects live: strings, lists, DTOs, entities, maps, everything you create with new (or indirectly through frameworks that also love new, just without your knowledge).
For a quick check of “what is my maximum heap right now,” you can look at the value of Runtime.getRuntime().maxMemory(). In Java, that is the maximum available heap the JVM is ready to use (in bytes). Mini example:
// maxMemory() returns a value in bytes — convert to megabytes for easier reading
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// This is exactly the "heap ceiling", not the container limit
System.out.println("maxHeapMb=" + maxHeapMb); // maxHeapMb=256 (example)
This value is very useful, but it is important not to overestimate its meaning. It shows the internal heap boundary, not “how much memory the container has.” The JVM may have maxHeapMb=256, while the container may have a 384m or 1g limit — those are different levels.
Why can you not think that heap is the whole memory? Because the JVM is not just heap. It also needs a few other things to live. For now, it is enough to remember a simple rule: heap is an important, but not the only, part of Java process memory.
4. Budget and headroom: --memory vs -Xmx
Now let us put the main idea of the lecture into one clear picture. You have an external container budget, and inside it the JVM must place heap and everything else. If you set -Xmx almost equal to the container limit, you leave the JVM “without change.” And the JVM, like any living organism, gets nervous without change and then falls over. Sometimes very abruptly.
Let us compare the two limits in a table to remove the confusion:
| Limit | Where it is set | What it limits | Typical beginner mistake |
|---|---|---|---|
| --memory=512m | Docker runtime (docker run, Compose) | All container memory, that is, process memory as a whole | Thinking this is the “heap size” |
| -Xmx256m | JVM (through args/env) | Only heap, where Java objects live | Thinking this is enough to avoid a memory problem |
Now about practical “headroom.” Imagine that a container is an apartment with a hard floor-area limit, and heap is your desk. You can place the desk right up against the walls so that the closet door cannot open. Formally, the desk fits. In practice, living there is inconvenient: you will start banging your knees every time you try to pass. In the JVM world, “banging your knees” usually means unpleasant memory scenarios and instability.
A working rule at the level of junior common sense sounds like this: first choose the container memory limit as the overall budget, then decide what part of that budget goes to heap, and leave headroom for the “rest of process life.” For now, we will not break down that “rest of process life” into details. What matters right now is seeing the principle itself: two boundaries, an external budget, and internal distribution.
5. Log maxHeapMb on startup
Theory becomes much calmer when there is an observable signal directly in the application logs. We do not want to guess “what heap does the JVM have”; we want to see it when the container starts, just as naturally as we are used to seeing active profiles and the port.
In Container-Ready Catalog Service, we can add a small “operational beacon” to the ops package: when the application starts, print the maximum heap in megabytes. This is not “tuning,” not “magic,” just a clear diagnostic that helps connect JAVA_TOOL_OPTIONS and JVM behavior. This small configuration is easy to extend later with new signals, rather than creating a separate startup class for every metric.
An example configuration that is easy to keep in the project (and even easier to delete later, if needed) looks like this:
package com.example.catalog.ops;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Spring configuration class: register an infrastructure bean
public class RuntimeSnapshotConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeSnapshotConfig.class);
@Bean
ApplicationRunner logRuntimeSnapshot() {
// ApplicationRunner will run once when the application starts
return args -> {
// maxMemory() is the heap ceiling the JVM is ready to allocate (value in bytes)
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// Log the metric as an "operational beacon" so we can see the effect of -Xmx at runtime
log.info("Runtime snapshot: maxHeapMb={}", maxHeapMb);
};
}
}
One thing matters here. We log only what the JVM honestly reports about heap. We are not trying in this lecture to “read the container limit from inside Java” — that is a separate world (cgroups, system files, etc.), and right now it is more important not to complicate things. We read the container limit from outside, with Docker commands, and the internal heap boundary from Java.
After that, we get a beautiful pairing: start the container, look at docker logs — see maxHeapMb. Change JAVA_TOOL_OPTIONS — see how maxHeapMb changes. And all of this without rebuilding the image. Beautiful. Almost like in a fairy tale, only without unicorns and with logs.
6. Pass -Xmx through JAVA_TOOL_OPTIONS
Now for an important practical trick that is both simple and very “container-like” in spirit. We do not want to rebuild the image just because we decided to change heap. More than that, this goes against the course’s basic idea: same image, different runtime config.
For the JVM, there is a standard mechanism: the JAVA_TOOL_OPTIONS environment variable. If it is set, the JVM automatically picks up arguments from it at startup. That means we can run the same image, but change heap like this:
# Limit the container by memory and separately set the heap boundary for the JVM
docker run --rm --name catalog-service \
--memory=512m \
-e JAVA_TOOL_OPTIONS="-Xmx256m" \
-p 8080:8080 \
docker-java-catalog-service
What is happening here by layers (and why is it important to keep this in mind)?
The container limit --memory=512m sets the external budget. The environment variable JAVA_TOOL_OPTIONS="-Xmx256m" sets the internal heap boundary. These are not competing settings, but settings at different levels. That is exactly why today we are so insistently separating their roles.
And yes, a small everyday warning: quotes and spaces. In Unix-like shells everything is fairly familiar. In PowerShell and CMD on Windows there may be escaping nuances. Within the course we are not going into the OS-specific track, but if -Xmx suddenly “did not apply,” the first thing to check is whether the environment variable really reached the container (via docker inspect), and whether the string did not break because of quotes.
7. Initial -Xmx: a safe starting point
Now you want a “magic formula.” For example: “set heap to exactly 70% of the limit, and you will be happy.” I understand the desire. It is very human. But there is no universal number: it depends too much on what the application does, how many threads there are, which libraries are connected, and which buffers are used.
Still, a starting point is needed. The rule at this level is simple: do not set -Xmx right up against the container limit. If the container was given --memory=384m, starting with -Xmx256m is usually much safer than starting with -Xmx384m.
The point is not the magic number 256. The point is headroom. As soon as metaspace, thread stacks, and buffers enter the budget, it quickly becomes clear why “give all the limit to heap” is a bad idea. If you want a very everyday image: the container limit is your entire wallet, and -Xmx is only one expense item. Giving the entire wallet to one item is easy; living like that afterward is hard.
For the first run, that rule is enough. Exact ranges appear only when you account for not just heap, but also the rest of the process memory.
8. Check the container limit
A very typical real-world scenario looks like this: a person writes a command with --memory, then looks at the application and says: “Well, it seems to work.” And then it turns out that the limit was not applied (or was applied elsewhere), and the conclusions about JVM behavior were made by eye.
To avoid that, it is useful to keep in mind two quick ways to check the limit from the outside, without going into the weeds. The first way is docker stats, which shows memory usage and the limit in one place, side by side. Example:
docker stats catalog-service
In the output, there will be a column like MEM USAGE / LIMIT. If you set --memory=512m, you should see a limit around 512MiB (the format depends on the platform, but the meaning is the same). It is a simple way to understand in 10 seconds: “Did I really start the container the way I think I did?”
The second way is docker inspect. It gives you the “truth in JSON.” For learning speed, you can extract only a small piece of state:
# Extract the container memory limit from HostConfig (the value will be in bytes)
docker inspect --format '{{json .HostConfig.Memory}}' catalog-service
Here it is important not to get confused by units. Docker stores many values in bytes. If you see a number that looks like 536870912, do not panic: that is exactly 512 * 1024 * 1024. In other words, your limit is really recorded.
And when you have both signals — docker stats/inspect from outside and maxHeapMb in logs from inside — you get what we are after: an observable system, not a pile of guesses.
9. Typical mistakes when working with --memory and -Xmx
Mistake #1: equating --memory with -Xmx.
This is the most common confusion: “I gave the container 512m, so heap is also 512m.” No. --memory limits the entire process, while -Xmx limits only heap. How to avoid it: always keep in mind the “two boundaries” and check them in two ways — the limit through Docker, heap through maxMemory().
Mistake #2: setting -Xmx almost right up against the container limit.
A combination like --memory=512m and -Xmx512m is almost a guaranteed way to create an unstable life for yourself, especially on a Spring Boot service, where in addition to your objects there is also infrastructure code, classes, threads, and much more. How to avoid it: leave noticeable headroom, especially at small limits.
Mistake #3: changing the container limit and JVM settings at the same time — and trying to figure out what actually helped.
If you changed --memory, -Xmx, the Spring Boot profile, and also “a little bit” the image version, you will not be able to honestly answer later what exactly affected the result. How to avoid it: change one layer at a time. Today — only runtime limits and JVM parameters; keep the image and code unchanged.
Mistake #4: looking only at Java logs and ignoring the container start command.
Sometimes the logs say “everything is fine,” but the container limit was not actually applied, or a different one was applied. How to avoid it: after starting the container, quickly check docker stats or docker inspect so you do not build conclusions on a false assumption.
Mistake #5: treating any memory symptom by increasing heap.
This is a Java developer reflex: “Not enough memory — add heap.” But if the problem is that the container budget is small, increasing heap may only speed up the collision with the limit. How to avoid it: first match the external budget (--memory) and the internal heap boundary (-Xmx), and only then make decisions.
GO TO FULL VERSION