PySpark / Performance Fig_10  ·  Lesson 10 / 14
Fig_10 · Garbage collection · v1.0 OSS Spark + Azure Databricks

Garbage collection

When the JVM pauses, tasks pause

The JVM reclaims unreferenced objects for you — you never free(). But every collection is stop-the-world: while it runs, every Spark task thread on that executor freezes. This lesson is about why a full GC stalls a whole stage, and the levers that make those pauses shorter and rarer.

Spark 3.2+ / DBR LTS memory.fraction 0.6 G1GC default Spark 4.0 / JDK 17 verified Jun 2026

01 · What it is

A pause you didn't schedule

Garbage collection (GC) is the JVM automatically reclaiming the memory of objects your program no longer references — in Java/Scala you never call free(), the collector does it for you. On a Spark executor, that convenience shows up as one thing you can measure: pauses.

analogy GC is the cleaning crew at a restaurant. While they sweep the whole floor, every diner steps outside and waits — and the more plates and crumbs (objects) on the floor, the longer everyone waits at the door. A Spark executor is one restaurant; its task threads are the diners.

The deep dive below breaks down the generations, the collection types, and the levers one at a time; for now just hold the shape of the problem — a pause you didn't ask for, paid for in idle CPU.

The one rule to remember GC cost is proportional to the number of Java objects, not the number of bytes. Many tiny objects (boxed values, deserialized cached rows) = lots of work for the collector = long pauses. Fewer, larger objects = cheaper GC.
What GC does

The JVM reclaims objects your code no longer references — no manual free(). On an executor that shows up as measurable pauses.

Healthy pattern

Short, frequent minor GCs clear the small Young gen — a brief blip; task threads barely notice.

Danger pattern

Long full stop-the-world GC: an over-full Old gen forces multi-second pauses that freeze every task on the executor.

02 · Why it matters

One executor's pause is the whole stage's problem

03 · How it works — deep dive

The generations, the collections, and the pause

Everything about GC comes down to the collector walking live objects while the world stops. So the useful way to understand it is: (1) how the heap is split so most collections stay cheap, (2) the two kinds of collection and the pause they share, and (3) how the executor turns a local pause into a stage-wide stall. Each idea below is explained here once — later sections show it, verify it, and help you decide, but don't re-explain it.

3.1 · The heap generations — Young and Old

analogy Eden is the prep counter (constant fast turnover); the Old gen is the walk-in freezer (things that stick around). You wipe the counter constantly and cheaply; you only defrost the freezer occasionally — and defrosting shuts the kitchen.

3.2 · Minor vs full GC — and the stop-the-world pause

GC typeCollectsFrequencyCost
Minor GCYoung gen only — Eden + Survivors (S0/S1)Frequent (Eden fills fast)Cheap — a short blip; most of Eden is already dead
Major / full GCOld gen (often the whole heap)RareExpensive — walks far more live objects; multi-second stall
Both are · stop-the-worldSuspend every task thread on that JVM—Idle CPU for the pause duration
Minor GC ≠ full GC A minor GC clears the small Young gen — frequent, brief, usually not your problem. A full GC walks the Old gen — rare, but a multi-second stop-the-world stall. High GC Time on a stage is almost always full GCs from an over-full Old gen, not the constant minor churn. This is the distinction interviewers test — hold it firmly.

3.3 · The executor's role — a local pause, a global stall

use case One executor caches more than its share, its Old gen stays near-full, and it spends seconds in full GC every few tasks. Its tasks finish late, the stage can't complete until that straggler does, and the job's wall-clock balloons — even though the other executors were idle and healthy.

Figure A · a pause across the cluster

Watch one executor freeze the stage

Three executors, each its own JVM heap, run tasks for one stage. We zoom in on Executor 1's heap — Eden (Young) and Old gen — and watch objects pile up, a minor GC clear Eden, then a full GC freeze every task thread on that executor while Executors 2 and 3 keep going. Watch the red frozen chips on Executor 1 and the driver's "waiting" note — the stage can't finish until the straggler resumes. Use Prev / Play / Next.

GC on Executor 1's heap — step by step

GC is per-JVM. A stop-the-world pause on one executor stalls only its tasks — but the stage waits on the slowest of them.

Driver schedules

stage 73 execs
stage running

Executor 1 heap

Eden (young gen)
Old gen (cache + promoted)

Executor 2 tasks

t4t5
running

Executor 3 tasks

t6t7
running
step 1 / 6
PySpark — cache-heavy stage
Executor 1 — heap & GC state
Eden:   empty
Old gen: cache blocks
GC:     idle
tasks:  running

Simplified illustration — real pause length depends on live-object count, heap size, and the collector. GC cost ∝ object count; a full GC is stop-the-world per executor.

04 · The code — and how to verify it

Four levers, each with its proof

Every technique below is paired with how to prove it worked — the Spark-UI GC Time signal or the executor's GC log. Apply, then verify; never assume.

Simple reading rule When you see a lever, ask one question first: "Is this reducing the object count (serialized cache, off-heap, fewer/larger objects), or just enlarging the heap to hide GC?" Fewer objects makes every collection cheaper; a bigger heap only defers a longer full GC.

Turn on detailed GC logging first

python# Read when the executor JVM STARTS — set it in the cluster's Spark config, not mid-session.
spark.conf.set(
    "spark.executor.extraJavaOptions",
    "-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps"
)

# VERIFY — Spark UI → Executors → stdout. You'll see one line per collection:
#   [GC (Allocation Failure) ...]   <- minor GC (Young)
#   [Full GC (Ergonomics) ...]      <- full GC; many of these = Old gen too full

Lever 1 · Serialized caching — a JVM/Scala-side win (not a PySpark switch)

PySpark honesty check There is no deserialized-vs-serialized choice from PySpark. The Python StorageLevel enum exposes only MEMORY_ONLY / MEMORY_AND_DISK / DISK_ONLY (+ _2 / DISK_ONLY_3) — no _SER levels — because the docs say "in Python, stored objects will always be serialized with the Pickle library, so it does not matter whether you choose a serialized level." So cache() is persist(MEMORY_AND_DISK); running both changes nothing. From PySpark, reach for Lever 2 or Lever 4 instead.
scala// Scala / Java (JVM-side) — where the serialized-cache GC win is real.
import org.apache.spark.storage.StorageLevel

// Deserialized JVM cache: one Java object per field per row →
// millions of long-lived objects in the Old gen → heavy full GC.
hotDf.persist(StorageLevel.MEMORY_AND_DISK)

// Serialized JVM cache: docs say it stores "only one object (a byte array)
// per RDD partition" → orders of magnitude fewer objects to track.
hotDf.persist(StorageLevel.MEMORY_AND_DISK_SER)
hotDf.count()   // materialize the cache

// VERIFY — Stages tab: GC Time drops on JVM caches; fewer "Full GC" lines in stdout.

Lever 2 · Lower spark.memory.fraction when the Old gen is full

python# Unified region = spark.memory.fraction × (heap − 300 MiB). Default 0.6.
# If cached blocks keep the Old gen near-full and you see constant full GCs,
# give the cache LESS heap so fewer long-lived objects are pinned.
spark.conf.set("spark.memory.fraction", "0.4")   # shrink cache/exec → less Old-gen pressure

# Docs verbatim: "better to cache fewer objects than to slow down task execution."
# VERIFY — Stages tab: compare GC Time before vs after; reset the conf after the A/B.

Lever 3 · Use G1GC on large heaps

python# VERSION NOTE: since Spark 4.0.0 (JDK 17 default), G1GC is ALREADY the default collector.
# On older Spark / JDK 8, ParallelGC was default and G1GC was the recommended opt-in:
spark.conf.set(
    "spark.executor.extraJavaOptions",
    "-XX:+UseG1GC -XX:G1HeapRegionSize=16m"    # bump region size for very large heaps
)
# -Xmn tunes the young-gen size when many short-lived objects churn Eden.

# VERIFY — executor stdout shows "[GC pause (G1 Evacuation Pause) ...]" with shorter
# durations; GC Time in the Stages tab falls on large-heap, allocation-heavy stages.

Lever 4 · Off-heap — keep data out of the GC's reach

python# Off-heap memory lives OUTSIDE the JVM heap → the collector never scans it → no GC pressure.
spark.conf.set("spark.memory.offHeap.enabled", "true")
spark.conf.set("spark.memory.offHeap.size", str(2 * 1024 * 1024 * 1024))  # 2 GB, bytes (> 0)
# Shrink the JVM heap accordingly, or you trade GC for OOM (Lesson 04).

# VERIFY — Stages tab: the same cache/shuffle workload shows lower GC Time —
# those bytes left the heap the collector walks.

Figure B · the pause on a timeline

Minor blip vs full stall — and the fix

This figure isolates a different thing from Figure A: not the cluster, but the shape of the pause on a single executor's timeline. Four task-thread bars run in parallel. Trigger a minor GC (a short blip) or a full GC (a long stop-the-world stall) and watch every bar freeze for the pause, then resume together. Then drag spark.memory.fraction down to shrink the Old gen — full-GC pressure eases and the next stall gets shorter. Watch the "full-GC pause" readout fall as you lower the slider.

stop-the-world pause simulator

One executor JVM, four task threads. A collection pauses all of them at once.

Young
Old gen (cache + long-lived)
task 0
task 1
task 2
task 3
stage startwall-clock →stage end
—
last collection
0 ms
full-GC pause
0%
GC time of stage
task running minor GC pause full GC (stop-the-world)
Trigger a collection. Notice the pause band spans every lane — that's stop-the-world. Then slide memory.fraction down and re-trigger the full GC to see the stall shrink.

Simplified illustration — real pause length depends on live-object count, heap size, and the collector. Lowering the fraction trades cache capacity (more recompute / spill) for shorter pauses.

05 · Decision table

Which lever, and what it costs

Read this by the lever you're about to reach for — it answers "what will this change, and what does it cost?" at a glance. (The why is §3–04; this is the which.)

LeverWhat it changesTrade-offHow to verify
Serialized caching · JVM/Scala onlyOne byte array / partition vs one object / fieldSlight (de)serialize CPU; no PySpark _SER switch — Python always picklesGC Time drops on JVM caches; fewer full-GC lines
Lower memory.fraction (0.6→0.4)Smaller cache/exec region → less Old-gen pressureLess cache (more recompute / spill)GC Time & wall-clock drop
Fewer, larger objectsPrimitive arrays / native exprs over boxed objects / row UDFsRewrite to vectorized formLower GC Time per task
G1GC (-XX:+UseG1GC)Region-based collector for big heapsDefault since Spark 4.0; opt-in beforeShorter pauses in GC logs
Off-heapData outside the JVM heap → no GC scanMust shrink JVM heap; experimental for cacheLower GC Time on cached / shuffle stages

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of the levers:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Throwing more heap at high GC timeA bigger Old gen means each full GC walks more live objects → longer pauses.Reduce the number of objects (serialize the cache, vectorize UDFs) before sizing up.
Caching everything "to be safe"Long-lived blocks pile into the Old gen and force frequent full GCs.Cache only reused DataFrames (Lesson 06), serialized or off-heap, and unpersist() when done.
Confusing GC Time with compute timeA slow task with high GC Time is stalled in stop-the-world pauses, not doing hard work.Read the GC Time column, don't guess.
Setting extraJavaOptions at runtimeIt's read only when the executor JVM boots.Set it in the cluster's Spark config.
Assuming G1GC is always onIt's the default only from Spark 4.0 / JDK 17; older runtimes ran ParallelGC.On older Spark / JDK 8, opt in with -XX:+UseG1GC.
Ignoring skew / spill as the real causeHigh GC is often a symptom of a skewed partition or execution-memory spill.Fix the skew (Lesson 08) or spill (Lesson 04) first.

08 · References

Sources