Your Container Is Running SerialGC and Nobody Told You
You tuned G1 flags for a week. Your pod has one CPU, which means the JVM quietly picked a single-threaded collector at startup and ignored every flag you set. Here is the OpenJDK source that made that decision.
Everybody who has worked a line knows the guy who spends the whole shift adjusting his station. Squaring up the mise, wiping the rail, reorganising the squeeze bottles by color. Meanwhile the ticket rail is stacked and he hasn't fired a single pan.
That is what most JVM tuning looks like. Somebody reads a blog post about -XX:MaxGCPauseMillis, spends a week adjusting G1 flags, and ships it. The flags do nothing. Not "less than expected" — nothing, because the JVM never selected G1 in the first place and every one of those flags is being politely ignored.
Here's the container I'm writing this on. Four gigabytes of RAM. OpenJDK 21.
$ java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseParallelGC"
UseG1GC = false
UseParallelGC = false
UseSerialGC = true
SerialGC. Single-threaded, stop-the-world, the collector designed for desktop applications in 2004. Running your service.
How a Kitchen Actually Handles the Dishes
Before the source, you need the model, because every argument in this article rests on it.
A working kitchen generates garbage at a terrifying rate. Not food waste — equipment. Sauté pans, mixing bowls, cutting boards, plating spoons, quarter-pans, tongs. A busy line will burn through every clean sauté pan in the building in about twenty minutes. There are only so many of them. So somebody has to be constantly cycling dirty ones out and clean ones back in, or the line stops. Not "slows down." Stops. A cook with no clean pan cannot cook, and a cook who cannot cook is standing there while tickets pile up.
That person is the porter. In JVM terms, that person is the garbage collector, and every design decision in every collector ever written is an answer to one question: how do we get clean pans back to the line without making the line stop?
Here's the mapping, and it's tighter than most analogies deserve to be:
| Kitchen | JVM |
|---|---|
| Clean pans available | Free heap |
| Cook grabs a pan | Allocation |
| Dirty pan on the rail | Garbage — unreachable object |
| The porter cycling dishes | The collector |
| "Everyone stop, I'm clearing" | Stop-the-world pause |
| Pans used and dumped in one ticket | Young generation / Eden |
| The stockpot that lives on all service | Old generation |
| Running out of clean pans mid-ticket | Allocation Failure |
| Empty counter space to plate on | Heap headroom |
| No pans, no counter, service dies | OutOfMemoryError |
Now the collectors, which are just different staffing models for the same job.
SerialGC is one porter, and everybody stops while he works. He walks the line, collects everything, washes it, brings it back, and during that entire time nobody cooks. It's simple, it has almost no coordination overhead, and on a two-burner setup it's genuinely fine. On a twelve-station line during Saturday dinner it's a catastrophe.
ParallelGC is several porters, and everybody still stops. More throughput on the cleanup itself, same fundamental problem — the line goes quiet while it happens.
G1 is a crew working station by station, and they go where the dirt is. G1 stands for Garbage-First, and the name is the whole strategy: divide the kitchen into sections, track which sections have the most dirty equipment, and clean those first. Most sections keep working while a couple get cleared. Pauses get shorter because you're never clearing the whole building at once.
And the generational split — young versus old — is the observation any cook makes in their first week: most equipment is used once and dumped immediately. The sauté pan for one ticket is dirty ninety seconds after you grabbed it. The stockpot that went on at 6am is still going at close. So you don't treat them the same. You cycle the fast-turnover stuff aggressively and constantly, and you leave the long-lived stuff alone until you genuinely have to deal with it.
That's Eden and old generation. Objects that die young get collected cheaply and often. Objects that survive long enough get promoted — moved to the walk-in, out of the fast rotation — and collecting those is expensive and rare.
Every log line and every flag below is a knob on that staffing model. Which is why picking the wrong one, or not knowing which one you picked, makes everything downstream meaningless.
The Decision Gets Made Before Your Code Exists
This isn't a bug and it isn't a mystery. It's forty lines of C++ that run at startup, and you can read them. From src/hotspot/share/gc/shared/gcConfig.cpp in openjdk/jdk21u, GPL-2.0-only:
void GCConfig::select_gc_ergonomically() {
if (os::is_server_class_machine()) {
#if INCLUDE_G1GC
FLAG_SET_ERGO_IF_DEFAULT(UseG1GC, true);
#elif INCLUDE_PARALLELGC
FLAG_SET_ERGO_IF_DEFAULT(UseParallelGC, true);
#elif INCLUDE_SERIALGC
FLAG_SET_ERGO_IF_DEFAULT(UseSerialGC, true);
#endif
} else {
#if INCLUDE_SERIALGC
FLAG_SET_ERGO_IF_DEFAULT(UseSerialGC, true);
#endif
}
}
One branch. is_server_class_machine() returns true and you get G1. It returns false and you get Serial, full stop, regardless of how much memory you gave the thing.
This is the schedule that got posted before you clocked in. By the time your code runs, the staffing decision is already made, and no amount of shouting on the line changes who showed up.
So what makes a machine "server class"? From src/hotspot/share/runtime/os.cpp in the same repository:
bool os::is_server_class_machine() {
// First check for the early returns
if (NeverActAsServerClassMachine) {
return false;
}
if (AlwaysActAsServerClassMachine) {
return true;
}
// Then actually look at the machine
bool result = false;
const unsigned int server_processors = 2;
const physical_memory_size_type server_memory = 2UL * G;
// We seem not to get our full complement of memory.
// We allow some part (1/8?) of the memory to be "missing",
// based on the sizes of DIMMs, and maybe graphics cards.
const physical_memory_size_type missing_memory = 256UL * M;
physical_memory_size_type phys_mem = os::physical_memory();
/* Is this a server class machine? */
if ((os::active_processor_count() >= (int)server_processors) &&
(phys_mem >= server_memory - missing_memory)) {
Two conditions, joined by &&. Two or more processors, and at least 2GB minus a 256MB allowance. Fail either one and you are not a server.
My container:
$ nproc
1
$ grep MemTotal /proc/meminfo
MemTotal: 4093836 kB
Memory passed with room to spare. Processor count did not. One core, so the && short-circuits, so SerialGC, so every G1 flag in my deployment YAML is decoration.
Read that comment about missing memory again, by the way. "We allow some part (1/8?) of the memory to be 'missing', based on the sizes of DIMMs, and maybe graphics cards." There is a question mark in production HotSpot source. Somebody in the early 2000s eyeballed a fudge factor for the RAM that graphics cards were stealing, wrote a guess with a question mark next to it, and that heuristic is deciding which garbage collector runs in your Kubernetes pod in 2026. This is what all software is like once you open the doors.
Proving It in One Flag
You don't have to take the source's word for it. -XX:ActiveProcessorCount lets you lie to the JVM about how many CPUs it has. Same machine, same RAM, same everything — only the reported core count changes:
ActiveProcessorCount=1 -> UseSerialGC | MaxHeapSize=1000 MB
ActiveProcessorCount=2 -> UseG1GC | MaxHeapSize=1000 MB
ActiveProcessorCount=4 -> UseG1GC | MaxHeapSize=1000 MB
ActiveProcessorCount=8 -> UseG1GC | MaxHeapSize=1000 MB
One to two. That's the entire cliff. A single reported core and you're on a stop-the-world collector from the client-application era; two and you get G1. Nothing else about the machine moved — heap size is identical across all four runs, because the memory test passed the whole time.
This is also the emergency lever. If you're stuck on a platform that reports one CPU and you know the underlying hardware has more, -XX:ActiveProcessorCount=n overrides ergonomics directly. Use it knowing you're lying — you're telling the JVM to size its thread pools for cores it may not actually get scheduled on.
While you're in there, check what the JVM thinks about your container at all:
java -Xlog:os+container=trace -version
On this box that prints container memory limit ignored ... using host value, because there's no cgroup limit set. In a real pod it will tell you the limit it detected, and if that line says host value when you thought you'd set a limit, your container constraints aren't reaching the JVM and every heap calculation it makes is against the wrong number.
Why This Is a Container Problem Specifically
Nobody hit this in 2010. You had a physical box, it had eight cores, is_server_class_machine() returned true, you got the server collector, everyone went home.
Then we started slicing machines into pieces and handing out the pieces. A pod with cpu: "1". A small ECS task. A CI runner.
You developed the menu in a full brigade kitchen — twelve stations, three porters, a walk-in the size of a bedroom. Then you shipped it to a food truck. Same recipes, same tickets, one guy, one sink. Nobody told the recipes. Your laptop has eight cores and picks G1 — your production pod has one and picks Serial. That's not a config difference you can grep for. That's a different garbage collector between staging and prod, with different pause characteristics, different failure modes, and a completely different response to every flag you tuned.
While you're in there, check the heap too:
MaxRAMPercentage = 25.000000
From src/hotspot/share/gc/shared/gc_globals.hpp:
product(double, MaxRAMPercentage, 25.0, \
"Maximum percentage of real memory used for maximum heap size") \
range(0.0, 100.0) \
A quarter. That's the default, and here is what it means on this container — 3,998 MB of physical memory, no -Xmx set at all:
$ java Mem.java
Runtime.maxMemory(): 966 MB
Just under a gigabyte, out of four. You are paying for 4GB and the heap will never touch three-quarters of it. The other three gigabytes sit there being billed to you and used by nobody.
Then one day you OutOfMemoryError at around a gig, kubectl top cheerfully reports the pod nowhere near its limit, and you spend an afternoon convinced you have a leak. You don't. You have a heap that was sized by a percentage nobody chose.
Set it explicitly and set it as a percentage, not a fixed -Xmx:
-XX:MaxRAMPercentage=75
Percentage survives someone changing the container's memory limit. A hardcoded -Xmx does not, and the day somebody bumps the pod to 8GB is the day your heap silently stays exactly where it was.
What It Actually Costs You
Enough theory. Here's a workload allocating 600MB in 1MB chunks and keeping every sixth one alive, in a 256MB heap, under the collector my container picked for me:
$ java -Xlog:gc -Xmx256m Heap.java
[0.004s][info][gc] Using Serial
[1.155s][info][gc] GC(0) Pause Young (Allocation Failure) 18M->4M(61M) 33.642ms
[1.573s][info][gc] GC(1) Pause Young (Allocation Failure) 21M->8M(61M) 38.740ms
[1.597s][info][gc] GC(2) Pause Young (Allocation Failure) 24M->11M(61M) 15.766ms
[1.615s][info][gc] GC(3) Pause Young (Allocation Failure) 27M->13M(61M) 9.917ms
[1.631s][info][gc] GC(4) Pause Young (Allocation Failure) 29M->16M(61M) 13.300ms
Learn to read one of these lines and you can skip most GC blog posts forever. 18M->4M(61M) is heap used before the collection, after it, and total committed. 33.642ms is how long the entire application stopped. Not slowed. Stopped.
Two things in those numbers matter.
The "after" figure climbs every single cycle — 4M, 8M, 11M, 13M, 16M. That staircase is the retained sixth being promoted out of the young generation where nothing is going to collect it. In kitchen terms: every pass, a few more pans get moved to the walk-in instead of going back into rotation, and nobody is ever going back for them. Do that all service and eventually the walk-in is full of dirty equipment and the line has nothing left to cook with. That pattern is the signature of a real leak, and it is the first thing to look for in any GC log. Healthy churn returns to roughly the same floor. A staircase does not.
Don't take that on faith — it's testable, and the test is two runs of the same program. Identical allocation, 600MB in 1MB chunks. The only difference is whether anything holds a reference:
=== HEALTHY: same 600MB allocated, nothing retained ===
GC(0) Pause Young (Allocation Failure) 18M->4M(61M) 39.657ms
GC(1) Pause Young (Allocation Failure) 20M->6M(61M) 49.158ms
GC(2) Pause Young (Allocation Failure) 22M->6M(61M) 19.951ms
GC(3) Pause Young (Allocation Failure) 22M->6M(61M) 0.184ms
GC(4) Pause Young (Allocation Failure) 22M->6M(61M) 0.096ms
GC(5) Pause Young (Allocation Failure) 22M->6M(61M) 0.128ms
=== LEAKING: same 600MB allocated, 1-in-6 retained ===
GC(0) Pause Young (Allocation Failure) 18M->4M(61M) 12.529ms
GC(1) Pause Young (Allocation Failure) 21M->8M(61M) 10.273ms
GC(2) Pause Young (Allocation Failure) 24M->11M(61M) 3.050ms
GC(3) Pause Young (Allocation Failure) 27M->13M(61M) 1.418ms
GC(4) Pause Young (Allocation Failure) 29M->16M(61M) 1.953ms
GC(5) Pause Young (Allocation Failure) 32M->19M(61M) 21.197ms
The healthy run flattens at 6M and stays there — pauses collapsing to fractions of a millisecond because there's nothing left to copy. The leaking run walks 4, 8, 11, 13, 16, 19 and never comes back down.
Same allocation volume. Same collector. Same heap. The only variable is retention, and it's visible in the third or fourth line of the log. You do not need a heap dump, a profiler, or a week — you need six lines and the discipline to read the number after the arrow instead of the pause time before it.
Note also which run had the scarier pause times. The healthy one, at 39 and 49ms. Pause duration told you nothing; the floor told you everything.
And Allocation Failure sounds like something went wrong. It didn't. It means Eden filled up, which is Eden's entire job. It's only a problem when frequency times duration turns into user-visible latency — here, 10 to 38ms pauses landing every 15ms or so, which for a request-serving process is a bad afternoon.
Your GC Log Is Lying About the Pause
Everything above reads pause times out of -Xlog:gc. Those numbers are not how long your application was stopped.
A stop-the-world pause is a safepoint, and it has four phases. Getting every thread to actually stop. Cleanup. The work itself — which is the only part the GC log reports. Then leaving. Turn on -Xlog:safepoint and you can see all four:
Safepoint "GenCollectForAllocation", Time since last: 7889291 ns, Reaching safepoint: 2242 ns,
Cleanup: 8262 ns, At safepoint: 10724452 ns, Leaving safepoint: 1252298 ns, Total: 11987254 ns
Now pair each GC log line against its safepoint record from the same run:
GC log says | reaching | at sp | leaving | REAL total | under-report
3.436ms | 0.002ms | 3.509ms | 0.003ms | 3.518ms | 2%
2.657ms | 0.001ms | 2.709ms | 2.807ms | 5.518ms | 108%
1.434ms | 0.001ms | 3.336ms | 0.003ms | 3.343ms | 133%
5.278ms | 0.001ms | 5.353ms | 5.922ms | 11.278ms | 114%
2.745ms | 0.002ms | 13.759ms | 0.003ms | 13.766ms | 401%
2.331ms | 0.002ms | 27.573ms | 0.003ms | 27.579ms | 1083%
Most rows are close. Then some aren't. A pause the GC log calls 2.331ms actually stopped the application for 27.579ms — off by a factor of twelve. Another reports 5.278ms against a real 11.278ms, where the extra six milliseconds are entirely in leaving the safepoint.
Two separate things cause the gap, and they have different fixes.
Leaving safepoint takes time when threads are slow to resume — often because the OS has to reschedule them onto a busy CPU. On an oversubscribed container this is where your mystery latency lives, and no GC flag touches it.
Reaching safepoint is the one that produces the truly baffling incidents. Every thread has to arrive at a point where it's safe to stop, and a thread in a long counted loop with no safepoint poll doesn't get there promptly. Everyone else is already frozen, waiting on that one thread. In these runs it's microseconds — but when it isn't, you get a multi-second stall with a GC log claiming the collection took 4ms, and you will look at the collector for a week.
The practical instruction: if your GC log looks clean and your p99 doesn't, you are looking at the wrong log. Turn on -Xlog:safepoint, compare Total against what -Xlog:gc reported, and if they diverge the problem isn't garbage collection at all.
There's a second thing that log tells you: not every safepoint is a GC. Class redefinition, biased-locking revocation, deoptimisation, thread dumps and inline-cache buffer flushes all stop the world. Your application can be freezing regularly for reasons that will never appear in a GC log no matter how long you stare at it.
Nobody in the dining room can tell the difference between the kitchen stopping to change a gas cylinder and the kitchen stopping to take out the trash. They just know their food isn't coming.
Force G1 and You Trade One Problem for a Weirder One
Same code, same heap size, -XX:+UseG1GC:
[0.005s][info][gc] Using G1
[0.300s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 4M->2M(66M) 4.717ms
[0.511s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 4M->2M(66M) 4.247ms
[0.652s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 5M->3M(66M) 9.107ms
[0.766s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 9M->4M(66M) 5.171ms
[0.652s][info][gc] GC(4) Pause Young (Concurrent Start) (G1 Humongous Allocation) 38M->10M(66M) 10.899ms
[1.171s][info][gc] GC(6) Pause Young (Concurrent Start) (G1 Humongous Allocation) 32M->14M(66M) 7.551ms
[1.176s][info][gc] GC(8) Pause Young (Concurrent Start) (G1 Humongous Allocation) 26M->16M(66M) 0.321ms
Pauses dropped to 4–10ms. That's the win everyone talks about.
Now look at the trigger changing partway down. G1 Humongous Allocation.
G1 chops the heap into fixed-size regions — call them shelves. An object at or above half a shelf doesn't go into the normal rotation at all. It's the twenty-gallon stockpot: it doesn't fit anywhere sensible, it can't be cycled with the sauté pans, so it gets carried straight to the walk-in and parked across however many shelves it needs. In JVM terms it skips the young generation entirely and lands in old generation across contiguous regions. From src/hotspot/share/gc/g1/g1CollectedHeap.hpp:
// Returns the humongous threshold for a specific region size
static size_t humongous_threshold_for(size_t region_size) {
return (region_size / 2);
}
Half a region. And region size is derived from heap size:
G1HeapRegionSize = 1048576
MaxGCPauseMillis = 200
1MB regions at a 256MB heap. My workload allocates 1MB arrays. Every single allocation is humongous, and each one can kick off a concurrent cycle.
This is the trap, and it's a nasty one. Identical code. A well-regarded collector. A pathological allocation pattern that exists purely because of the interaction between object size and heap size. Raise -Xmx, regions get bigger, the arrays stop being humongous, and the problem evaporates without anyone touching a line of code.
That is the mechanism behind every "it only happens in staging" GC ticket you will ever be handed. The code is fine. The code was always fine. The heap is a different size over there.
Also worth noticing: the GC numbering runs 0, 1, 2, 3, 4, then 6, then 8. The gaps aren't lost log lines — they're concurrent cycles doing work without stopping anybody. Missing numbers in a G1 log are normal and people waste real time hunting them.
The Cliff at 32GB Where a Bigger Heap Holds Less
Here's one that gets people who did read the docs and did size their heap deliberately.
The JVM stores object references as 32-bit values on 64-bit hardware, scaled by the object alignment. It's called compressed ordinary object pointers — compressed oops — and it's why Java on 64-bit isn't twice the memory of 32-bit. It works up to a heap size where a 32-bit scaled reference can still address everything.
Find the exact boundary by asking:
-Xmx30g -> UseCompressedOops=true
-Xmx31g -> UseCompressedOops=true
-Xmx32g -> UseCompressedOops=false
-Xmx33g -> UseCompressedOops=false
Thirty-one gigabytes: on. Thirty-two: off. Every reference in your heap silently doubles from 4 bytes to 8.
That is not a rounding error. Three million objects with four reference fields each, held live, measured both ways:
compressedOops=on live heap with 3,000,000 nodes: 129,030,200 bytes (43.0 bytes/node)
compressedOops=off live heap with 3,000,000 nodes: 189,263,064 bytes (63.1 bytes/node)
Forty-seven percent more memory for exactly the same objects.
Now do the arithmetic that matters:
-Xmx31g (compressed) -> 774.1M nodes at 43.0 bytes each
-Xmx32g (uncompressed) -> 544.5M nodes at 63.1 bytes each
Raising your heap from 31GB to 32GB loses you a third of your capacity. You asked for more memory and got less room. To match what 31GB held, you need roughly 45.5GB — you have to jump nearly fifteen gigabytes past the boundary just to break even.
So the practical rule for large heaps: stay under 32GB, or go well past it. The dead zone between 32 and about 45 gigabytes is strictly worse than 31. If you're sizing a service that needs 35GB, either get the live set under 31 or budget 48 and stop pretending 36 was a considered number.
This is the walk-in that got upgraded to a bigger unit with thicker shelving. More cubic feet on paper, less product on the shelves, and the invoice went up.
Running Out, and the Lie the Number Tells
List<byte[]> l = new ArrayList<>();
try { while (true) l.add(new byte[1024*1024]); }
catch (OutOfMemoryError e) { System.out.println("OOM after " + l.size() + " MB retained: " + e.getMessage()); }
$ java -Xmx64m Oom.java
OOM after 58 MB retained: Java heap space
Fifty-eight megabytes against a 64MB ceiling. The JVM threw before the heap was actually full, because a collector needs working room to collect — it has to have somewhere to copy live objects to.
Every cook knows this one. A station is not full when the last inch of counter is covered. It is full well before that, because you need clear space to actually plate on. Fill every surface and you are done working, even though there is technically still a station there.
Which means if you size a heap to exactly your steady-state retained set, you will OOM. Every time. That headroom isn't waste, it's the empty counter space you need to plate.
And Java heap space is only one of several OOM messages, and the only one that means what people assume:
GC overhead limit exceeded— the collector is running constantly and reclaiming almost nothing. The heap isn't full, it's thrashing. Different problem, different fix.Metaspace— class metadata, almost always a classloader leak in something that redeploys without restarting. Completely unaffected by-Xmx. I have watched a team add memory to a pod for three days against this one.unable to create native thread— not the heap at all. You are out of OS threads or address space.
Set this on every deployment, today, before you need it:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof
An OOM without a heap dump means reproducing the exact conditions to learn anything, which in production usually means never. With a dump you open it in Eclipse MAT, read the dominator tree, and the leak is generally obvious in ten minutes.
The Two Thread Pools, and Which One Steals From You
Every GC has threads, and there are two kinds. People conflate them and then over-subscribe the machine.
Watch the defaults scale under G1:
ActiveProcessorCount=1: ParallelGCThreads=1 ConcGCThreads=1
ActiveProcessorCount=4: ParallelGCThreads=4 ConcGCThreads=1
ActiveProcessorCount=16: ParallelGCThreads=13 ConcGCThreads=3
ParallelGCThreads run during a stop-the-world pause. Your application is already frozen, so they aren't competing with anything — they're the crew that comes in after close. More of them shortens the pause.
ConcGCThreads run while your application runs. They are on the floor during service, taking CPU from your request handlers. Notice the JVM sizes them at roughly a quarter of the parallel count — 13 and 3 at sixteen cores — precisely because they're the expensive kind.
That ratio is the thing to carry away. If you're budgeting cores for an application — a game server, a request-handling service, anything where you're deciding how many threads to give your own work — ConcGCThreads is the number that comes out of your budget. ParallelGCThreads doesn't, because nothing of yours is running when they are.
Get this backwards and you'll either subtract threads you didn't need to, or, more commonly, forget the concurrent ones entirely and wonder why sixteen allocated worker threads on a sixteen-core box behave like thirteen.
Don't Copy Tuned Flags From the Internet
One more thing, because it's the most common way this article's advice gets misapplied.
Every flag configuration you find in a blog post — including any in this one — was tuned against a specific heap size, a specific allocation rate, a specific core count and a specific latency target. A 500GB Shenandoah config from somebody's benchmark rig is not "the pro settings." On your 16-core box it's noise at best.
The flags worth setting blind are the ones that cost nothing and tell you something when it breaks:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
-XX:MaxRAMPercentage=75
-Xlog:gc
Everything past that needs a measurement first. If you can't state which number you're trying to move — pause duration, allocation rate, promotion rate, throughput — you're not tuning, you're rearranging squeeze bottles.
Before You Touch a Single Flag
Three commands, in this order, in the environment that's actually broken. Not on your laptop. Your laptop is lying to you.
java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseParallelGC|UseZGC"
java -XX:+PrintFlagsFinal -version | grep -E "MaxRAMPercentage|MaxHeapSize"
java -Xlog:gc <your app>
Which collector. How much heap. What the log says. That's it. Everything past that is guessing, and most GC tuning advice on the internet is somebody else's guess about somebody else's workload on somebody else's hardware.
SerialGC isn't wrong, incidentally. On a genuinely small heap with one core it's a reasonable fit and carries less bookkeeping overhead than G1. The problem was never the collector. The problem is spending a week tuning a collector you aren't running, on a heap that's a quarter the size you think it is, chasing a leak that's actually your allocation pattern colliding with a region size you never chose.
Go look at your station before you start rearranging it.