<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Programming on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/programming/</link><description>Recent content in Programming on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 03 Sep 2026 16:18:18 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/programming/index.xml" rel="self" type="application/rss+xml"/><item><title>The Java Memory Model, Explained Without the Headache</title><link>https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/</link><pubDate>Thu, 03 Sep 2026 16:18:18 +0000</pubDate><guid>https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/</guid><description>&lt;h4 id="what-actually-happens-to-your-data-between-two-threadsand-the-small-set-of-tools-that-let-you-controlit">What actually happens to your data between two threads — and the small set of tools that let you control it&lt;/h4>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-01.png">&lt;/p>
&lt;p>Ask ten Java developers what “the Java Memory Model” means and you’ll get two different answers.&lt;/p>
&lt;p>Half of them will draw a box called &lt;em>Heap&lt;/em> and another called &lt;em>Stack&lt;/em>. The other half will start talking about volatile and happens-before.&lt;/p>
&lt;p>Both groups are talking about something real, but only the second group is talking about the actual &lt;strong>Java Memory Model (JMM)&lt;/strong>. The first group is describing the &lt;strong>runtime data areas&lt;/strong> — where objects physically live.&lt;/p></description><content:encoded><![CDATA[<h4 id="what-actually-happens-to-your-data-between-two-threadsand-the-small-set-of-tools-that-let-you-controlit">What actually happens to your data between two threads — and the small set of tools that let you control it</h4>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-01.png"></p>
<p>Ask ten Java developers what “the Java Memory Model” means and you’ll get two different answers.</p>
<p>Half of them will draw a box called <em>Heap</em> and another called <em>Stack</em>. The other half will start talking about volatile and happens-before.</p>
<p>Both groups are talking about something real, but only the second group is talking about the actual <strong>Java Memory Model (JMM)</strong>. The first group is describing the <strong>runtime data areas</strong> — where objects physically live.</p>
<p>You need both to write correct concurrent code, so let’s go through them in order. Memory layout first, because it’s the easier one, then the JMM proper.</p>
<h3 id="part-1-where-your-datalives">Part 1: Where your data lives</h3>
<p>When the JVM starts, it carves the process memory into a few distinct regions. Each has an owner and a lifetime.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-02.png"></p>
<p><strong>Heap</strong> — every object and array you ever create lives here. It’s shared, which is exactly why concurrency is hard. Garbage collection happens here. Modern collectors split it further (young generation, old generation, or regions in G1/ZGC), but that’s a GC detail, not a memory model one.</p>
<p><strong>Metaspace</strong> — class metadata, bytecode, and static fields. It sits in native memory since Java 8, which is why PermGen errors disappeared and Metaspace OOM errors appeared instead.</p>
<p><strong>Thread stack</strong> — each thread gets its own. Every method call pushes a frame containing local variables, parameters, and the return address. Frame pops when the method returns. This is where StackOverflowError comes from. Nothing here is shared, so locals are never a concurrency problem — unless the <em>value</em> they point to is a shared object.</p>
<p><strong>PC register</strong> — the address of the instruction the thread is currently executing. One per thread.</p>
<p><strong>Native method stack</strong> — the same idea, but for native (JNI) calls.</p>
<p>One thing worth burning in:</p>
<pre tabindex="0"><code>Order order = new Order();
</code></pre><p>The variable order is a reference sitting on the <strong>stack</strong>. The Order object itself sits on the <strong>heap</strong>. Two threads can hold two separate stack references pointing to the same heap object. That&rsquo;s the whole source of the trouble.</p>
<h3 id="part-2-the-actual-java-memorymodel">Part 2: The actual Java Memory Model</h3>
<p>The runtime areas tell you <em>where</em> data sits. They tell you nothing about <em>when</em> one thread sees a write made by another.</p>
<p>That question is what the JMM answers. It’s a specification (JSR-133, in Chapter 17 of the Java Language Specification) that defines exactly what guarantees you get about reads and writes across threads.</p>
<h4 id="the-problem-it-exists-tosolve">The problem it exists to solve</h4>
<p>Modern CPUs are fast because they cheat, aggressively and correctly. Two things they do:</p>
<p><strong>1. Caching.</strong> Each core has its own L1/L2 cache. When a thread writes to a field, that write may sit in the core’s cache or store buffer for a while before it becomes visible to other cores.</p>
<p><strong>2. Reordering.</strong> The compiler, the JIT, and the CPU are all allowed to reorder instructions, as long as the result looks the same <em>for a single thread</em>. That last clause is the trap — nobody promised anything about how it looks to other threads.</p>
<p>So the JMM works with an abstraction:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-03.png"></p>
<p><em>Thread A and Thread B never see each other’s working memory. Everything they share has to travel through main memory — and the JMM decides when it does.</em></p>
<p>Every thread has a conceptual <strong>working memory</strong> (registers, cache, store buffers) holding copies of the variables it touches. Threads never talk to each other directly — they communicate only through <strong>main memory</strong>. The JMM specifies when a value must be flushed to main memory and when it must be re-read from it.</p>
<p>Here’s the classic demonstration:</p>
<pre tabindex="0"><code>public class StopFlag {
    private boolean running = true;   // no volatile
    
    public void run() {
        while (running) {
            // do work
        }
        System.out.println(&#34;stopped&#34;);
    }
    public void stop() {
        running = false;
    }
}
</code></pre><p>Thread A runs run(). Thread B calls stop(). Very often, thread A never stops.</p>
<p>Nothing is broken. The JIT sees that running is never modified inside the loop, so it&rsquo;s free to hoist the read out:</p>
<pre tabindex="0"><code>boolean r = running;
while (r) { /* work */ }   // now an infinite loop
</code></pre><p>Without a memory model rule forcing it, the JVM has no obligation to ever re-read that field. Your bug is not a race in the “two writes collided” sense — it’s a <strong>visibility</strong> failure.</p>
<h4 id="the-three-guarantees">The three guarantees</h4>
<p>Every concurrency problem in Java falls into one of these buckets.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-04.png"></p>
<p>count++ is the textbook atomicity failure — it&rsquo;s three operations (read, add, write), and two threads can interleave in the middle. Two increments, one result.</p>
<h3 id="the-base-components-of-thejmm">The base components of the JMM</h3>
<p>Here’s the toolbox. It’s smaller than most people expect.</p>
<h4 id="1-happens-beforethe-rule-that-underpins-everything">1. happens-before — the rule that underpins everything</h4>
<p>Everything else is a special case of this one relation. If action <strong>A</strong> <em>happens-before</em> action <strong>B</strong>, then:</p>
<ul>
<li>everything A wrote is visible to B, and</li>
<li>A is guaranteed to appear to occur before B.</li>
</ul>
<p>It is <strong>not</strong> just “A ran earlier in wall-clock time.” Two actions can be ordered in time and still have no happens-before relationship, which means B may see stale data.</p>
<p>The rules the JLS gives you:</p>
<ol>
<li><strong>Program order</strong> — within one thread, each statement happens-before the ones written after it.</li>
<li><strong>Monitor lock</strong> — unlocking a monitor happens-before any later lock of the <em>same</em> monitor.</li>
<li><strong>volatile</strong> — a write to a volatile field happens-before every later read of that field.</li>
<li><strong>Thread start</strong> — Thread.start() happens-before anything in the started thread.</li>
<li><strong>Thread join</strong> — everything in a thread happens-before another thread’s successful join() on it.</li>
<li><strong>Interruption</strong> — a call to interrupt() happens-before the interrupted thread detects it.</li>
<li><strong>Constructor / finalizer</strong> — the end of a constructor happens-before the start of that object’s finalizer.</li>
<li><strong>Transitivity</strong> — if A hb B and B hb C, then A hb C.</li>
</ol>
<p>That last one does most of the practical work. It’s why writing a plain field and <em>then</em> a volatile flag safely publishes the plain field.</p>
<h4 id="2-volatilevisibility-and-ordering-no-atomicity">2. volatile — visibility and ordering, no atomicity</h4>
<pre tabindex="0"><code>private volatile boolean running = true;
</code></pre><p>This single keyword fixes the StopFlag example. It gives you:</p>
<ul>
<li><strong>Visibility</strong> — a write goes to main memory immediately; a read comes from main memory, never a cache.</li>
<li><strong>Ordering</strong> — the JVM inserts memory barriers so reads and writes can’t be reordered across the volatile access.</li>
</ul>
<p>What it does <strong>not</strong> give you is atomicity:</p>
<pre tabindex="0"><code>private volatile int count = 0;
public void increment() {
    count++;   // STILL BROKEN
}
</code></pre><p>Use volatile for flags and for single-writer state. For compound operations, use something else.</p>
<h4 id="3-synchronizedall-three-guarantees">3. synchronized — all three guarantees</h4>
<pre tabindex="0"><code>private int count = 0;
public synchronized void increment() {
    count++;   // correct
}
public synchronized int get() {
    return count;   // also needs to be synchronized
}
</code></pre><p>Entering the monitor gives mutual exclusion (atomicity). Exiting flushes to main memory, entering invalidates the local copy (visibility). Barriers around the block prevent reordering out of it.</p>
<p>The most common mistake here is synchronizing the writer but not the reader. Both sides need the <em>same</em> lock, or there’s no happens-before edge between them.</p>
<p>ReentrantLock gives you the same memory guarantees, plus tryLock, timeouts, fairness, and multiple conditions.</p>
<h4 id="4-finalsafe-publication-forfree"><strong>4.</strong> <strong>final — safe publication for free</strong></h4>
<pre tabindex="0"><code>public class Config {
    private final String url;
    public Config(String url) {
        this.url = url;   // guaranteed visible after construction
    }
}
</code></pre><p>The JMM guarantees that any thread that sees a properly constructed object sees the correct values of its final fields — no synchronization required. This is why immutable objects are the easiest possible concurrency strategy.</p>
<p>The catch is “properly constructed.” If the constructor leaks this (registering a listener, starting a thread), the guarantee is void.</p>
<h4 id="5-atomics-and-varhandle">5. Atomics and VarHandle</h4>
<p>java.util.concurrent.atomic gives you lock-free compound operations built on CPU compare-and-swap instructions:</p>
<pre tabindex="0"><code>private final AtomicInteger count = new AtomicInteger();
public void increment() {
    count.incrementAndGet();   // atomic, visible, ordered
}
</code></pre><p>Under contention LongAdder usually beats AtomicLong — it spreads writes across cells and sums them on read, avoiding cache-line contention. VarHandle (Java 9+) is the modern, supported way to reach for finer-grained modes like getAcquire / setRelease when you know exactly what you&rsquo;re doing.</p>
<h3 id="three-bugs-thisexplains">Three bugs this explains</h3>
<p><strong>Unsafe publication.</strong> Building an object and handing the reference to another thread without a happens-before edge. The other thread can see a non-null reference to a half-initialized object — fields still at their defaults. Publish through a volatile field, a lock, a final field, or a concurrent collection.</p>
<p><strong>Double-checked locking without</strong> <strong>volatile.</strong> The infamous one:</p>
<pre tabindex="0"><code>private static volatile Singleton instance;   // volatile is mandatory
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}
</code></pre><p>Drop the volatile and it breaks, because instance = new Singleton() is three steps — allocate, construct, assign — and the last two can be reordered. Another thread can grab a reference to an object whose constructor hasn&rsquo;t finished.</p>
<p><strong>Check-then-act.</strong> if (!map.containsKey(k)) map.put(k, v); is two atomic operations, not one atomic operation. Use putIfAbsent or computeIfAbsent.</p>
<h3 id="the-practical-cheatsheet">The practical cheat sheet</h3>
<ul>
<li><strong>Shared and mutable?</strong> It needs synchronization. Every time.</li>
<li><strong>One thread writes a flag, others read it?</strong> volatile.</li>
<li><strong>Compound operation on one variable?</strong> An atomic class.</li>
<li><strong>Compound operation across several variables?</strong> synchronized or ReentrantLock.</li>
<li><strong>Can you make it immutable instead?</strong> Do that. final fields, no setters, no shared mutation, no memory model to reason about.</li>
<li><strong>Reading is also access.</strong> Unsynchronized reads of synchronized state are still broken.</li>
<li><strong>“It works on my machine” proves nothing.</strong> x86 has a strong memory model that hides many of these bugs. ARM (Apple Silicon, Graviton) doesn’t. Code that ran fine for years on Intel servers can fail the week you move it to ARM instances.</li>
</ul>
<h3 id="wrapping-up">Wrapping up</h3>
<p>The runtime areas — heap, stacks, metaspace — tell you where your data sits. The Java Memory Model tells you when one thread’s writes become visible to another, and it comes down to a handful of pieces: happens-before as the underlying rule, and volatile, synchronized, final, and the atomics as the tools that create it.</p>
<p>You don’t need to memorize the specification. You need one habit: whenever two threads touch the same data, ask what creates the happens-before edge between them. If you can’t name it, it isn’t there.</p>
<p><em>If this was useful, the natural next step is Brian Goetz’s</em> Java Concurrency in Practice <em>— still the best treatment of this material, twenty years on.</em></p>
]]></content:encoded></item><item><title>Your DELETE + INSERT Is Burning PostgreSQL’s CPU. Here’s What’s Actually Happening on the Page.</title><link>https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/</link><pubDate>Mon, 17 Aug 2026 18:56:51 +0000</pubDate><guid>https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/</guid><description>&lt;p>&lt;em>A deep dive into MVCC, heap pages, vacuum, and what to do when you have billions of rows to update.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-01.png">&lt;/p>
&lt;h3 id="the-symptom">The symptom&lt;/h3>
&lt;p>You have a table that stores user state. Something happens — an event arrives — and your code does the obvious thing:&lt;/p>
&lt;pre tabindex="0">&lt;code>BEGIN;
DELETE FROM user_state WHERE user_id = $1;
INSERT INTO user_state (user_id, state, score, updated_at) VALUES ($1, $2, $3, now());
COMMIT;
&lt;/code>&lt;/pre>&lt;p>It’s clean. It’s idempotent. It doesn’t require you to think about which columns changed. For a long time it works fine.&lt;/p></description><content:encoded><![CDATA[<p><em>A deep dive into MVCC, heap pages, vacuum, and what to do when you have billions of rows to update.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-01.png"></p>
<h3 id="the-symptom">The symptom</h3>
<p>You have a table that stores user state. Something happens — an event arrives — and your code does the obvious thing:</p>
<pre tabindex="0"><code>BEGIN;
DELETE FROM user_state WHERE user_id = $1;
INSERT INTO user_state (user_id, state, score, updated_at) VALUES ($1, $2, $3, now());
COMMIT;
</code></pre><p>It’s clean. It’s idempotent. It doesn’t require you to think about which columns changed. For a long time it works fine.</p>
<p>Then event volume grows, and PostgreSQL’s CPU starts climbing in a way that nothing in your query plans explains. Your queries are still index scans. Your pg_stat_statements top entries look reasonable. But CPU is pinned, replication lag creeps up, and every so often the whole database gets slow for a few minutes and then recovers on its own.</p>
<p>If you look at pg_stat_activity during one of those windows, you&rsquo;ll usually find the culprit sitting right there:</p>
<pre tabindex="0"><code>autovacuum: VACUUM public.user_state
</code></pre><p>That’s the story. But “autovacuum is expensive” is not an explanation — it’s a restatement of the symptom. To actually fix this you need to understand what a DELETE followed by an INSERT does to the physical 8 KB pages on disk, and why that specific shape of write is close to the worst case for PostgreSQL&rsquo;s storage engine.</p>
<p>Let’s go all the way down.</p>
<h3 id="part-1-postgresql-never-modifies-a-row-inplace">Part 1: PostgreSQL never modifies a row in place</h3>
<p>This is the single fact that everything else follows from.</p>
<p>PostgreSQL implements MVCC (Multi-Version Concurrency Control) by keeping <strong>multiple physical versions of every row</strong> and letting each transaction see the version appropriate to its snapshot. There is no undo log (unlike Oracle or MySQL’s InnoDB, which keep old versions in a separate rollback segment). In PostgreSQL, old row versions live <strong>in the table itself</strong>, mixed in with the live ones.</p>
<p>Concretely:</p>
<ul>
<li>INSERT writes a new tuple and stamps it with xmin = the inserting transaction ID.</li>
<li>DELETE <strong>does not remove anything</strong>. It stamps the existing tuple with xmax = the deleting transaction ID. The bytes stay exactly where they were.</li>
<li>UPDATE is internally a DELETE + INSERT: it stamps xmax on the old tuple and writes a brand-new tuple somewhere.</li>
</ul>
<p>So a “deleted” row is still fully present on disk, occupying its bytes, until some later process comes along and physically reclaims it. That process is vacuum.</p>
<p>Here’s this happening for real. A 100-row table, then delete 50 rows and insert 50 new ones:</p>
<pre tabindex="0"><code>CREATE EXTENSION pageinspect;
CREATE TABLE demo (id int PRIMARY KEY, state text);
INSERT INTO demo SELECT g, &#39;active&#39; FROM generate_series(1,100) g;
-- delete half, re-insert half (the pattern from our app)
DELETE FROM demo WHERE id &lt;= 50;
INSERT INTO demo SELECT g, &#39;active&#39; FROM generate_series(1,50) g;
SELECT lp, lp_flags, lp_len, t_xmin, t_xmax
FROM heap_page_items(get_raw_page(&#39;demo&#39;, 0))
WHERE lp BETWEEN 48 AND 54;
</code></pre><pre tabindex="0"><code> lp | lp_flags | lp_len | t_xmin | t_xmax
----+----------+--------+--------+--------
 48 |        1 |     35 |    801 |    802   &lt;- deleted, still on the page
 49 |        1 |     35 |    801 |    802   &lt;- deleted, still on the page
 50 |        1 |     35 |    801 |    802   &lt;- deleted, still on the page
 51 |        1 |     35 |    801 |      0   &lt;- live
 52 |        1 |     35 |    801 |      0   &lt;- live
 53 |        1 |     35 |    801 |      0   &lt;- live
 54 |        1 |     35 |    801 |      0   &lt;- live
</code></pre><p>The deleted tuples have t_xmax = 802 (the transaction that deleted them) and are still LP_NORMAL (lp_flags = 1). They occupy real space. The table now has 150 line pointers where the logical row count is 100.</p>
<pre tabindex="0"><code>SELECT tuple_count, dead_tuple_count, dead_tuple_percent, free_percent
FROM pgstattuple(&#39;demo&#39;);
</code></pre><pre tabindex="0"><code> tuple_count | dead_tuple_count | dead_tuple_percent | free_percent
-------------+------------------+--------------------+--------------
         100 |               50 |              21.36 |        19.09
</code></pre><p><strong>21% of that table is garbage.</strong> And that’s after one round.</p>
<h3 id="part-2-anatomy-of-an-8-kbpage">Part 2: Anatomy of an 8 KB page</h3>
<p>PostgreSQL stores everything in fixed-size blocks — 8 KB by default (SHOW block_size). Understanding the layout of one page is the key to understanding why churn is expensive.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-02.png"></p>
<p>Two structures grow toward each other from opposite ends: the <strong>line pointer array</strong> from the top, the <strong>tuple data</strong> from the bottom. The page is full when they meet.</p>
<p>The <strong>line pointer</strong> (also called an item identifier, ItemIdData) is a tiny 4-byte struct that says &ldquo;the tuple at slot N lives at byte offset X and is Y bytes long.&rdquo; It has four possible states, and you&rsquo;ll see all of them in this article:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-03.png"></p>
<p>The <strong>tuple header</strong> (HeapTupleHeader, 23 bytes padded to 24) is where MVCC lives:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-04.png"></p>
<p>That’s <strong>24 bytes of overhead per row version, plus 4 bytes for the line pointer.</strong> For a table storing a user ID, a short state string, and a timestamp, the MVCC bookkeeping can easily be 40% of the row’s on-disk size.</p>
<p>Now here’s the critical bit for our problem:</p>
<blockquote>
<p><em>A page’s free space is only usable for a</em> new <em>tuple if it is</em> <em><strong>contiguous</strong></em> <em>and the page has a free line pointer or room to add one. Dead tuples fragment the page. Only vacuum can defragment it.</em></p></blockquote>
<h3 id="part-3-why-delete--insert-is-the-worstshape">Part 3: Why DELETE + INSERT is the worst shape</h3>
<p>Compare what the two write patterns do to a page.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-05.png"></p>
<p>A DELETE + INSERT can <strong>never</strong> be a HOT update. HOT (Heap-Only Tuple) is an optimization that only applies to UPDATE, and only when two conditions hold:</p>
<ol>
<li>No indexed column changed.</li>
<li>The new version fits <strong>on the same page</strong> as the old one.</li>
</ol>
<p>When both hold, PostgreSQL writes the new version onto the same page and links it via t_ctid, leaving the index entries alone. That&rsquo;s an enormous saving: index maintenance is usually the dominant cost of a write, because B-tree inserts are random-access, and every index on the table pays it.</p>
<p>By splitting your logical update into two statements, you’ve told PostgreSQL: <em>this is a brand-new row that has nothing to do with the old one.</em> It has no choice but to write full index entries into every index, and to leave a dead tuple behind that vacuum must chase down.</p>
<p>You can see HOT working with pageinspect. In a table where updates <em>are</em> HOT-eligible, the page fills up with redirect pointers:</p>
<pre tabindex="0"><code>SELECT lp, lp_flags, lp_len, t_ctid
FROM heap_page_items(get_raw_page(&#39;user_state_hot&#39;, 0)) LIMIT 5;
</code></pre><pre tabindex="0"><code> lp | lp_flags | lp_len | t_ctid
----+----------+--------+--------
  1 |        2 |      0 |          &lt;- LP_REDIRECT
  2 |        2 |      0 |          &lt;- LP_REDIRECT
  3 |        2 |      0 |          &lt;- LP_REDIRECT
  4 |        2 |      0 |          &lt;- LP_REDIRECT
  5 |        2 |      0 |          &lt;- LP_REDIRECT
</code></pre><p>Those redirect pointers are the physical trace of updates that cost <strong>zero index writes</strong>.</p>
<h3 id="part-4-what-vacuum-actuallydoes">Part 4: What vacuum actually does</h3>
<p>Vacuum is not one operation. A standard (non-full) VACUUM of a table with indexes runs in phases, and understanding the phases explains where the CPU goes.</p>
<h4 id="phase-1heapscan">Phase 1 — Heap scan</h4>
<p>Vacuum scans the table’s pages (skipping ones the <strong>visibility map</strong> marks all-visible, which is the main reason vacuum on an append-only table is cheap and vacuum on a churned table is not). For each page it:</p>
<ul>
<li>Prunes HOT chains and marks reclaimable tuples.</li>
<li>Collects the <strong>TIDs (page number, line pointer) of dead tuples</strong> into memory.</li>
<li>Sets LP_DEAD on their line pointers.</li>
</ul>
<p>The dead TID list is bounded by maintenance_work_mem (or autovacuum_work_mem). In PostgreSQL 17+ this list is stored in an adaptive radix tree (TidStore) rather than a flat array, which cut memory usage dramatically and removed the old 1 GB cap — a genuinely large improvement for high-churn tables.</p>
<h4 id="phase-2indexvacuum">Phase 2 — Index vacuum</h4>
<p><strong>This is the expensive part.</strong> For every index on the table, vacuum performs a full scan (a btbulkdelete) and removes every index entry pointing at a dead TID.</p>
<p>Note what that means: <strong>the cost is per index, and it’s a full index scan, not a lookup.</strong> A table with five indexes pays five full index scans per vacuum cycle. This is why “just add an index” decisions compound badly on high-churn tables.</p>
<h4 id="phase-3heapvacuum">Phase 3 — Heap vacuum</h4>
<p>Vacuum revisits the pages and turns LP_DEAD line pointers into LP_UNUSED, then <strong>defragments</strong> the page: it slides the remaining tuples together so the free space becomes one contiguous block. Now that space is genuinely reusable.</p>
<p>If the dead-TID list overflowed maintenance_work_mem in phase 1, vacuum has to loop: scan more heap → vacuum all indexes again → vacuum heap again. A single vacuum of a big churned table can do the full index pass <strong>multiple times</strong>. This is the classic &ldquo;why has autovacuum been running for six hours&rdquo; answer.</p>
<h4 id="phase-4freezing">Phase 4 — Freezing</h4>
<p>Separately, vacuum replaces old xmin values with a frozen marker so transaction IDs can wrap around safely. Anti-wraparound vacuums are non-cancelable and will run whether you like it or not. PostgreSQL 18 added <em>eager freezing</em> (vacuum_max_eager_freeze_failure_rate, default 0.03) so that normal vacuums opportunistically freeze pages, spreading this cost out instead of saving it up for one giant emergency vacuum.</p>
<h4 id="phase-5truncation">Phase 5 — Truncation</h4>
<p>If there are empty pages at the <em>end</em> of the table, vacuum tries to return them to the OS. This requires an ACCESS EXCLUSIVE lock (briefly). Note the word <em>end</em>: <strong>vacuum almost never shrinks a bloated table</strong>, because the free space is scattered throughout, not conveniently at the tail. It makes the space reusable by PostgreSQL, not available to your filesystem.</p>
<p>Here’s the before/after on our demo table:</p>
<pre tabindex="0"><code>VACUUM demo;
SELECT lp_flags, count(*) 
FROM heap_page_items(get_raw_page(&#39;demo&#39;,0)) GROUP BY 1;
</code></pre><p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-06.png"></p>
<p>Free space on the page went from <strong>19.09% → 43.51%</strong>. That’s the work vacuum did. Now multiply it by every page you churned.</p>
<h3 id="part-5-the-amplification-math">Part 5: The amplification math</h3>
<p>Here is the part people underestimate. One logical “user state changed” event does not cause one write. It causes, at minimum:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-07.png"></p>
<p>Steps 7–13 are <em>invisible in your application metrics</em>. Your app measures step 1–6 latency and reports “the insert took 2 ms.” The other two-thirds of the cost lands on a background worker, asynchronously, in a burst, on the same CPU.</p>
<p>That’s the answer to “why does CPU go up but my queries look fine.”</p>
<h4 id="point-5b-the-full-page-image-trap"><strong>Point 5b: the full-page-image trap</strong></h4>
<p>After each checkpoint, the first write to any page triggers a <strong>full 8 KB page image</strong> written into WAL (full_page_writes, on by default, and required for crash safety on most filesystems). If your churn touches many distinct pages, you pay a lot of FPIs. If your churn is concentrated on a few pages, you pay far fewer. Delete+insert scatters writes across pages — the new tuple often lands in a <em>different</em> page from the one you dirtied with the xmax write. Two dirty pages instead of one, two FPI candidates instead of one.</p>
<h4 id="point-5c-the-read-side-cost-nobodymentions">Point 5c: the read-side cost nobody mentions</h4>
<p>Dead tuples also make <strong>reads</strong> slower and CPU-hungrier:</p>
<ul>
<li>An index scan follows an index entry to the heap, reads the tuple, checks visibility, discovers it’s dead, and discards it. That’s a wasted buffer access plus a visibility check. You can see this directly: EXPLAIN (ANALYZE, BUFFERS) will show a rising Buffers: shared hit count for a query returning the same number of rows.</li>
<li>Bloated tables have worse cache locality. The same 100k live rows spread over 2× the pages means 2× the shared buffers to hold them, which evicts <em>other</em> things.</li>
<li>On a read, PostgreSQL may perform <strong>opportunistic HOT pruning</strong> on the page — real CPU work done inside your SELECT.</li>
</ul>
<h3 id="part-6-measurements">Part 6: Measurements</h3>
<p>I ran a controlled comparison: 100,000 rows, ten rounds of modifying 20,000 of them, with an explicit VACUUM after each round so we&rsquo;re looking at steady state, not a runaway. Three variants:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/image-08.png"></p>
<p>Read those rows carefully, because the middle one is the trap.</p>
<p><strong>Switching from</strong> **DELETE+**<strong>INSERT to</strong> <strong>UPDATE bought essentially nothing on its own.</strong> The second variant still generated <em>more</em> WAL than the first. Why? Because the UPDATE touched updated_at, and updated_at had an index on it. An UPDATE that modifies any indexed column is not HOT-eligible, so it does exactly what delete+insert does: new heap tuple, new entries in every index, dead tuple left behind.</p>
<p>The third variant — same UPDATE, but with no index on the mutated columns and fillfactor lowered to 80 — generated <strong>less than half the WAL</strong> and made 73% of its updates HOT.</p>
<p>The cost of that win is visible too: the HOT table starts at 1042 heap pages instead of 834, because fillfactor=80 deliberately leaves 20% of every page empty. That empty space is what new row versions land in. <strong>You are trading disk space for CPU and I/O</strong>, which on modern hardware is almost always the right trade.</p>
<p><em>(These are synthetic single-client numbers on PostgreSQL 16 — treat the ratios as directional, not as a benchmark of your workload.)</em></p>
<h3 id="part-7-fixing-it-in-order-ofleverage">Part 7: Fixing it, in order of leverage</h3>
<h4 id="71-dont-write-when-nothingchanged">7.1 Don’t write when nothing changed</h4>
<p>The cheapest write is the one that doesn’t happen. A shocking amount of churn in “collect user state” systems is <strong>rewriting a row to the exact same value</strong> because an event arrived that didn’t actually change anything.</p>
<pre tabindex="0"><code>INSERT INTO user_state (user_id, state, score, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE
SET state      = EXCLUDED.state,
    score      = EXCLUDED.score,
    updated_at = now()
WHERE user_state.state  IS DISTINCT FROM EXCLUDED.state
   OR user_state.score  IS DISTINCT FROM EXCLUDED.score;
</code></pre><p>That trailing WHERE on the DO UPDATE clause is the whole trick: if nothing changed, PostgreSQL writes <strong>nothing</strong> — no new tuple, no dead tuple, no index entry, no WAL. In event-driven systems where the same state gets recomputed repeatedly, this alone often removes 50–90% of write volume.</p>
<p>Measure how much you’d save before you build anything else:</p>
<pre tabindex="0"><code>-- how many of your writes are no-ops?
SELECT count(*) FILTER (WHERE state = $2 AND score = $3) AS would_be_noop,
       count(*) AS total
FROM user_state WHERE user_id = ANY($1);
</code></pre><h4 id="72-make-your-updates-hot-eligible">7.2 Make your updates HOT-eligible</h4>
<p>Two requirements, both actionable:</p>
<p><strong>(a) Don’t index the columns that change.</strong> Audit every index against your write path:</p>
<pre tabindex="0"><code>SELECT i.relname AS index_name,
       array_agg(a.attname ORDER BY x.n) AS indexed_columns,
       s.idx_scan AS times_used
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) ON true
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
WHERE t.relname = &#39;user_state&#39;
GROUP BY i.relname, s.idx_scan
ORDER BY s.idx_scan NULLS FIRST;
</code></pre><p>An index on updated_at is the single most common HOT-killer in exactly this kind of table. Ask whether you truly need it, or whether a partial index, a coarser index (date_trunc(&lsquo;hour&rsquo;, updated_at) doesn&rsquo;t help — but a separate low-churn audit table might), or no index at all would do.</p>
<p>Unused indexes are pure loss: they cost writes and vacuum passes and return nothing. Drop them.</p>
<p><strong>(b) Leave room on the page.</strong></p>
<pre tabindex="0"><code>ALTER TABLE user_state SET (fillfactor = 80);
-- fillfactor only applies to newly written pages;
-- rewrite the table to apply it to existing data:
VACUUM FULL user_state;   -- takes ACCESS EXCLUSIVE lock
-- or, online:
-- pg_repack -t user_state
</code></pre><p>For a heavily-updated table, 70–85 is a reasonable range. Below ~70 you’re wasting a lot of buffer cache on empty space.</p>
<p>Then confirm it’s working:</p>
<pre tabindex="0"><code>SELECT relname,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd,0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = &#39;user_state&#39;;
</code></pre><p><strong>If</strong> <strong>hot_pct is near zero on an update-heavy table, that is your bug.</strong> Aim for 80%+.</p>
<h4 id="73-tune-autovacuum-for-this-table-notglobally">7.3 Tune autovacuum for this table, not globally</h4>
<p>The defaults are tuned for a small table on a small machine. autovacuum_vacuum_scale_factor = 0.2 means vacuum waits until <strong>20% of the table is dead</strong> before acting. On a 500-million-row table that&rsquo;s 100 million dead tuples before anything happens — and then one enormous, disruptive vacuum.</p>
<p>You want the opposite: vacuum <strong>more often, doing less each time.</strong></p>
<pre tabindex="0"><code>ALTER TABLE user_state SET (
  autovacuum_vacuum_scale_factor  = 0.01,   -- act at 1% dead, not 20%
  autovacuum_vacuum_threshold     = 1000,
  autovacuum_analyze_scale_factor = 0.02,
  autovacuum_vacuum_cost_delay    = 0,      -- don&#39;t throttle this table
  autovacuum_vacuum_cost_limit    = 3000
);
</code></pre><p>And globally, if your hardware is modern (NVMe, many cores), the shipped throttle is far too conservative:</p>
<pre tabindex="0"><code>autovacuum_max_workers      = 6          # default 3
autovacuum_naptime          = 15s        # default 1min
autovacuum_vacuum_cost_delay = 2ms       # default 2ms; consider 0 on fast storage
vacuum_cost_limit           = 2000       # default 200
maintenance_work_mem        = 2GB        # gives vacuum a big dead-TID store
autovacuum_work_mem         = 1GB
</code></pre><p>The cost-delay mechanism is the one most often left at a value that makes vacuum unable to keep up: vacuum accumulates “cost” (1 per buffer hit, 2 per miss, 20 per dirtied page) and sleeps cost_delay whenever it exceeds cost_limit. With the defaults, vacuum is deliberately slowed to something like a few MB/s. On a table generating hundreds of MB of dead tuples per minute, it will never catch up, and dead tuples accumulate forever.</p>
<p>PostgreSQL 18 also added autovacuum_vacuum_max_threshold (default 100,000,000), which caps the scale-factor calculation so very large tables can&rsquo;t defer vacuum indefinitely. It helps, but a per-table scale factor is still better.</p>
<h4 id="74-reconsider-the-datamodel">7.4 Reconsider the data model</h4>
<p>If your state genuinely is “replace the whole thing on every event,” ask whether it belongs in a row-per-user table at all:</p>
<ul>
<li><strong>Collapse many rows into one.</strong> If you’re storing N state rows per user and replacing them all, one row with a jsonb column can turn N deletes + N inserts into one update — and if jsonb is under the TOAST threshold and no indexed column changes, that update can be HOT. (Careful: a large jsonb that gets TOASTed is a whole separate write amplification problem. This works when the document is small, roughly under 2 KB.)</li>
<li><strong>Append-only + a materialized “current” view.</strong> Write events to a partitioned append-only table (no updates, no deletes, so almost no vacuum work — just insert-triggered vacuums for freezing), and maintain the current state separately, or compute it with a DISTINCT ON query over a well-indexed event log. Append-only tables are the cheapest thing PostgreSQL does.</li>
<li><strong>Move the hottest state out of PostgreSQL.</strong> If a piece of state changes many times per second per user and you only ever need the latest value, Redis or a dedicated KV store is the honest answer. Not everything needs to be transactional and durable.</li>
</ul>
<h3 id="part-8-the-hard-questionupdating-billions-ofrows">Part 8: The hard question — updating billions of rows</h3>
<p>This is the case where all the usual advice breaks, so it deserves its own treatment.</p>
<h4 id="why-the-naive-approach-fails-precisely">Why the naive approach fails, precisely</h4>
<pre tabindex="0"><code>UPDATE huge_table SET status = &#39;migrated&#39; WHERE status = &#39;pending&#39;;
-- 2,000,000,000 rows
</code></pre><p>Four separate disasters:</p>
<p><strong>1. Table size doubles.</strong> Every updated row writes a new version while the old one stays. A 2 TB table becomes 4 TB mid-statement. Then vacuum makes the space reusable but does <em>not</em> return it to the OS. You now permanently need 4 TB of disk for 2 TB of data.</p>
<p><strong>2. It generates terabytes of WAL</strong> in one burst. Replicas fall behind, archiving falls behind, and if you’re on a cloud provider you may fill the WAL volume and take the primary down.</p>
<p><strong>3. It holds a single transaction open for hours — and this is the one that hurts most.</strong> Vacuum can only remove tuples older than the oldest running transaction’s snapshot (the <em>xmin horizon</em>). A long transaction pins that horizon. So while your giant UPDATE runs, <strong>vacuum cannot clean up dead tuples anywhere in the entire database</strong> — not just this table. Every other table quietly bloats. This is how one migration takes down a whole cluster.</p>
<p><strong>4. If it fails at 95%, all the work is thrown away</strong> — but the dead tuples it created are not. You get the bloat with none of the benefit.</p>
<p>You can watch the horizon problem happen:</p>
<pre tabindex="0"><code>SELECT pid, age(backend_xid) AS xid_age, state,
       now() - xact_start AS duration, left(query, 60)
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
ORDER BY xact_start
LIMIT 5;
</code></pre><p>Also check for the other two horizon-pinners people forget: <strong>replication slots</strong> and <strong>prepared transactions</strong>.</p>
<pre tabindex="0"><code>SELECT slot_name, active, age(xmin) AS xmin_age,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
SELECT gid, prepared, age(transaction) FROM pg_prepared_xacts;
</code></pre><p>An inactive replication slot will pin the xmin horizon <strong>forever</strong> and no amount of vacuum tuning will save you.</p>
<h4 id="strategy-a-batched-backfill-with-keyset-pagination">Strategy A: Batched backfill with keyset pagination</h4>
<p>The workhorse. Small transactions, each one committing so the xmin horizon advances and vacuum can keep pace.</p>
<pre tabindex="0"><code>-- Track progress in a real table so the job is resumable
CREATE TABLE IF NOT EXISTS backfill_progress (
  job_name text PRIMARY KEY,
  last_id  bigint NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now()
);
</code></pre><pre tabindex="0"><code>-- One batch. Run this in a loop from your application or a scheduler.
WITH bounds AS (
  SELECT last_id FROM backfill_progress WHERE job_name = &#39;status_migration&#39;
),
batch AS (
  SELECT id FROM huge_table
  WHERE id &gt; (SELECT last_id FROM bounds)
  ORDER BY id
  LIMIT 5000
  FOR UPDATE SKIP LOCKED
),
upd AS (
  UPDATE huge_table t
  SET status = &#39;migrated&#39;
  FROM batch b
  WHERE t.id = b.id
    AND t.status IS DISTINCT FROM &#39;migrated&#39;   -- skip no-op writes
  RETURNING t.id
)
UPDATE backfill_progress
SET last_id = COALESCE((SELECT max(id) FROM batch), last_id),
    updated_at = now()
WHERE job_name = &#39;status_migration&#39;
RETURNING last_id, (SELECT count(*) FROM upd) AS rows_updated;
</code></pre><p>Rules that make the difference between this working and this being a slow disaster:</p>
<ul>
<li><strong>Keyset pagination, never</strong> <strong>OFFSET.</strong> OFFSET 10000000 re-scans ten million rows every batch. WHERE id &gt; $last uses the index.</li>
<li><strong>Batch size 1,000–10,000 rows.</strong> Big enough to amortize overhead, small enough that each transaction finishes in well under a second.</li>
<li><strong>AND col IS DISTINCT FROM $new</strong> so re-running a batch is free and no-ops don&rsquo;t write.</li>
<li><strong>Throttle deliberately.</strong> Sleep 50–200 ms between batches. You are racing autovacuum; give it room to win. Adaptive throttling — sleep longer when replication lag or dead-tuple count rises — is worth the extra code:</li>
</ul>
<pre tabindex="0"><code>-- feed this into your loop&#39;s sleep decision
SELECT (SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname=&#39;huge_table&#39;) AS dead,
       (SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn, replay_lsn)), 0)
        FROM pg_stat_replication) AS replica_lag_bytes;
</code></pre><ul>
<li><strong>Run it during a low-traffic window</strong> if you have one, but design it to be safe at any time.</li>
</ul>
<p><strong>Expected duration is the thing to be honest about:</strong> 2 billion rows at 5,000 rows/batch with a 100 ms sleep is 400,000 batches ≈ <strong>11+ hours minimum</strong>, realistically a few days. That’s fine. Plan for days, make it resumable, make it observable.</p>
<h4 id="strategy-b-partitioning--partition-swap">Strategy B: Partitioning + partition swap</h4>
<p>If your data is naturally partitionable (by time, tenant, hash of user ID), this changes the game completely, because you can rebuild a partition <strong>offline</strong> and swap it in atomically.</p>
<pre tabindex="0"><code>-- Assume: huge_table PARTITION BY RANGE (created_at)
--         with partition huge_table_2026_03
-- 1. Build the new version at full speed, outside the live table.
CREATE TABLE huge_table_2026_03_new (LIKE huge_table INCLUDING ALL);
INSERT INTO huge_table_2026_03_new
SELECT id, user_id, &#39;migrated&#39;::text AS status, payload, created_at
FROM huge_table_2026_03;
-- 2. Indexes AFTER the load - far faster than maintaining them during it.
--    No CONCURRENTLY needed: nothing is reading this table yet.
CREATE INDEX ON huge_table_2026_03_new (user_id);
ANALYZE huge_table_2026_03_new;
-- 3. Atomic swap: milliseconds of ACCESS EXCLUSIVE lock, not hours.
BEGIN;
  ALTER TABLE huge_table DETACH PARTITION huge_table_2026_03;
  ALTER TABLE huge_table ATTACH PARTITION huge_table_2026_03_new
    FOR VALUES FROM (&#39;2026-03-01&#39;) TO (&#39;2026-04-01&#39;);
COMMIT;
-- 4. Keep the old one around until you&#39;re confident, then drop it.
-- DROP TABLE huge_table_2026_03;
</code></pre><p>Why this is so much better:</p>
<ul>
<li>The bulk INSERT into an empty, unindexed table is <strong>the fastest write path PostgreSQL has</strong>. No dead tuples are created at all.</li>
<li>Zero vacuum debt: you never made garbage, so there’s nothing to collect.</li>
<li>The lock window is milliseconds.</li>
<li>Rollback is trivial: swap the old partition back.</li>
<li>You can do partitions <strong>one at a time</strong>, over weeks, with a rollback point after each.</li>
</ul>
<p>There’s a caveat worth knowing: ATTACH PARTITION validates that existing rows satisfy the partition constraint, which requires a scan — unless you add a matching CHECK constraint to the new table first, in which case PostgreSQL skips the scan.</p>
<pre tabindex="0"><code>ALTER TABLE huge_table_2026_03_new
  ADD CONSTRAINT part_check
  CHECK (created_at &gt;= &#39;2026-03-01&#39; AND created_at &lt; &#39;2026-04-01&#39;) NOT VALID;
ALTER TABLE huge_table_2026_03_new VALIDATE CONSTRAINT part_check;
-- now ATTACH is instant
</code></pre><p><strong>If you have billions of rows and no partitioning, partitioning is probably the actual project</strong> — not the update itself. Everything downstream gets easier: DROP PARTITION replaces mass DELETE (instant, and creates zero dead tuples), vacuum works per-partition in parallel, and index sizes stay manageable.</p>
<h4 id="strategy-c-rebuild-and-swap-the-wholetable">Strategy C: Rebuild and swap the whole table</h4>
<p>When the change is uniform and you can’t partition, build a new table and cut over.</p>
<p>For a bloated table with no schema change, pg_repack does this online: it builds a fresh copy, captures concurrent changes via triggers, replays them, and swaps — taking the exclusive lock only for the final instant.</p>
<pre tabindex="0"><code>pg_repack -h host -d mydb -t huge_table --jobs 4
</code></pre><p>For a schema/data change, the same idea by hand with logical replication or triggers; tools like pgroll and Percona&rsquo;s approach automate it. The pattern is always: <strong>new table, bulk load, keep in sync, atomic rename.</strong></p>
<p>Budget for double the disk space. That’s the price of every swap-based strategy.</p>
<h3 id="strategy-d-dont-update-atall">Strategy D: Don’t update at all</h3>
<p>The strategy people skip past. Ask whether the update is necessary:</p>
<ul>
<li>Add a <strong>new column with a default</strong> instead of rewriting values — since PostgreSQL 11, adding a column with a non-volatile default is a metadata-only change that rewrites nothing, no matter how big the table.</li>
<li>Write a <strong>view</strong> that computes the new value from the old one, and migrate readers to it.</li>
<li>Use a <strong>generated column</strong> if the new value is derivable.</li>
<li>Let it happen <strong>lazily</strong>: update rows as they’re naturally touched by the application, with a background job mopping up the long tail over months.</li>
</ul>
<p>A billion-row update you don’t have to run is strictly better than one you run well.</p>
<h3 id="part-9-what-to-actuallywatch">Part 9: What to actually watch</h3>
<p>Four queries. Put them on a dashboard.</p>
<p><strong>1. Is vacuum keeping up?</strong></p>
<pre tabindex="0"><code>SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_autovacuum,
       autovacuum_count,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_dead_tup &gt; 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
</code></pre><p>Sustained dead_pct above ~10% on a busy table means vacuum is losing. hot_pct near zero on an update-heavy table means you&rsquo;re paying full index cost on every write.</p>
<p><strong>2. What is vacuum doing right now?</strong></p>
<pre tabindex="0"><code>SELECT p.pid, p.relid::regclass AS table, p.phase,
       pg_size_pretty(p.heap_blks_total * 8192::bigint) AS total,
       round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total,0), 1) AS pct_scanned,
       p.index_vacuum_count,
       now() - a.xact_start AS running_for
FROM pg_stat_progress_vacuum p
JOIN pg_stat_activity a USING (pid);
</code></pre><p>index_vacuum_count &gt; 1 is the smoking gun for maintenance_work_mem being too small — vacuum had to do the index pass more than once.</p>
<p><strong>3. Is anything pinning the xmin horizon?</strong></p>
<pre tabindex="0"><code>SELECT &#39;backend&#39; AS src, pid::text AS id, age(backend_xmin) AS xmin_age
FROM pg_stat_activity WHERE backend_xmin IS NOT NULL
UNION ALL
SELECT &#39;slot&#39;, slot_name, age(xmin) FROM pg_replication_slots WHERE xmin IS NOT NULL
UNION ALL
SELECT &#39;prepared_xact&#39;, gid, age(transaction) FROM pg_prepared_xacts
ORDER BY xmin_age DESC NULLS LAST
LIMIT 10;
</code></pre><p>Anything with a large age here is preventing vacuum from cleaning up <strong>database-wide.</strong></p>
<p><strong>4. How bloated is it, really?</strong></p>
<pre tabindex="0"><code>CREATE EXTENSION IF NOT EXISTS pgstattuple;
-- accurate but does a full scan; use the sampling version on huge tables
SELECT * FROM pgstattuple(&#39;user_state&#39;);
SELECT * FROM pgstattuple_approx(&#39;huge_table&#39;);
SELECT * FROM pgstatindex(&#39;user_state_user_id_idx&#39;);
</code></pre><p>For indexes, avg_leaf_density below ~50 means the index is worth rebuilding:</p>
<pre tabindex="0"><code>REINDEX INDEX CONCURRENTLY user_state_user_id_idx;
</code></pre><h3 id="the-checklist">The checklist</h3>
<p>If you’re in the situation this article opened with, do these in order:</p>
<ol>
<li><strong>Measure first.</strong> dead_pct, hot_pct, pg_stat_progress_vacuum, xmin horizon. Don&rsquo;t tune blind.</li>
<li><strong>Kill no-op writes</strong> with ON CONFLICT DO UPDATE &hellip; WHERE &hellip; IS DISTINCT FROM. Usually the biggest single win, and the cheapest to ship.</li>
<li><strong>Convert delete+insert to</strong> <strong>UPDATE</strong> — but only after step 4, or it buys you nothing.</li>
<li><strong>Make updates HOT-eligible.</strong> Drop or move indexes on frequently-changed columns (especially updated_at). Set fillfactor to 80. Verify hot_pct climbs above 80%.</li>
<li><strong>Tune autovacuum per-table</strong>: scale factor down to 0.01, cost delay to 0, cost limit way up, maintenance_work_mem to gigabytes.</li>
<li><strong>Drop unused indexes.</strong> Every one is a write tax and an extra full scan per vacuum.</li>
<li><strong>Partition</strong> if the table is large and the data has a natural key. This makes every future problem easier.</li>
<li><strong>For mass updates</strong>: batched keyset backfill, or partition rebuild-and-swap. Never one giant statement.</li>
</ol>
<p>The underlying idea is simple enough to hold in your head: <strong>PostgreSQL is a copy-on-write storage engine that trades write amplification for concurrency.</strong> Delete+insert opts into the maximum possible amplification — no HOT, full index maintenance, guaranteed dead tuples, guaranteed vacuum work. Understanding what happens on the 8 KB page is what turns “the database is slow” into a list of specific, fixable things.</p>
<p><em>If this was useful, the two commands worth internalizing are</em> <em>pageinspect&rsquo;s</em> <em>heap_page_items() and</em> <em>pgstattuple(). Point them at your hottest table. What you find will probably explain your CPU graph.</em></p>
<h3 id="references">References</h3>
<ul>
<li><a href="https://www.postgresql.org/docs/current/routine-vacuuming.html">PostgreSQL 18: Routine Vacuuming</a></li>
<li><a href="https://www.postgresql.org/docs/current/runtime-config-vacuum.html">PostgreSQL 18: Vacuuming configuration parameters</a></li>
<li><a href="https://www.postgresql.org/about/news/postgresql-18-released-3142/">PostgreSQL 18 Released</a></li>
<li><a href="https://techcommunity.microsoft.com/blog/adforpostgresql/postgresql-18-vacuuming-improvements-explained/4459484">PostgreSQL 18 Vacuuming Improvements Explained (Microsoft)</a></li>
<li><a href="https://www.dbi-services.com/blog/postgresql-18-introduce-autovacuum_vacuum_max_threshold/">autovacuum_vacuum_max_threshold in PostgreSQL 18 (dbi services)</a></li>
<li><a href="https://pganalyze.com/blog/5mins-postgres-17-faster-vacuum-adaptive-radix-trees">Faster VACUUM with Adaptive Radix Trees in PostgreSQL 17 (pganalyze)</a></li>
<li><a href="https://boringsql.com/posts/vacuum-at-the-page-level/">VACUUM at the Page Level (boringSQL)</a></li>
</ul>
]]></content:encoded></item><item><title>From Source to Running JVM: How Gradle Builds Your Java Application</title><link>https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/</link><pubDate>Sun, 19 Jul 2026 11:14:16 +0000</pubDate><guid>https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/</guid><description>&lt;p>Build phases, task graphs, dependency resolution, and what actually happens when the JVM opens your JAR&lt;/p>
&lt;p>You type gradle build, wait a few seconds, and out comes a JAR. Most Java developers use this pipeline every day without ever looking inside it. But when a build gets slow, a dependency conflict appears, or a NoClassDefFoundError shows up in production at 2 AM, understanding the machinery underneath is the difference between guessing and fixing.&lt;/p></description><content:encoded><![CDATA[<p>Build phases, task graphs, dependency resolution, and what actually happens when the JVM opens your JAR</p>
<p>You type gradle build, wait a few seconds, and out comes a JAR. Most Java developers use this pipeline every day without ever looking inside it. But when a build gets slow, a dependency conflict appears, or a NoClassDefFoundError shows up in production at 2 AM, understanding the machinery underneath is the difference between guessing and fixing.</p>
<p>This article walks the full journey: Gradle’s three build phases, the Java plugin’s task graph, how dependency resolution actually works stage by stage, what a JAR file really is, and how the JVM reads and executes it.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-01.png"></p>
<h3 id="part-1-the-three-phases-every-gradle-build-goesthrough">Part 1: The Three Phases Every Gradle Build Goes Through</h3>
<p>Before a single line of your code is compiled, Gradle itself runs through a fixed lifecycle of three phases. This is true for every build, every plugin, every project.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-02.png"></p>
<h4 id="phase-1initialization">Phase 1 — Initialization</h4>
<p>Gradle reads settings.gradle(.kts) and answers one question: <em>which projects are part of this build?</em> For a single-module app that&rsquo;s trivial; for a multi-module monorepo this is where include(&quot;:core&quot;, &ldquo;:api&rdquo;, &ldquo;:app&rdquo;) gets evaluated and a Project object is created for each module.</p>
<h4 id="phase-2configuration">Phase 2 — Configuration</h4>
<p>Now every build.gradle(.kts) script executes top to bottom. This is the phase people misunderstand most. Your build script is <em>code that runs</em> — and it runs here, on every invocation, regardless of which task you asked for. The output of this phase is the <strong>task graph</strong>: a directed acyclic graph (DAG) of all tasks and their dependencies. Tasks are registered and wired together, but none of their actions execute yet.</p>
<blockquote>
<p><em>A</em> <em>println at the top level of build.gradle prints during Configuration — even if you run</em> <em>gradle help. Task actions run later. This trips up nearly everyone once.</em></p></blockquote>
<h4 id="phase-3execution">Phase 3 — Execution</h4>
<p>Gradle takes the task you requested, computes the minimal subgraph of tasks needed to produce it, and executes them in dependency order. Tasks whose inputs and outputs haven’t changed since the last run are marked UP-TO-DATE and skipped entirely — this is Gradle&rsquo;s incremental build engine, and it&rsquo;s why the second build is always faster than the first.</p>
<h3 id="part-2-the-java-plugin-taskgraph">Part 2: The Java Plugin Task Graph</h3>
<p>Applying the java plugin registers a standard chain of tasks. When you run gradle build, this is the graph that gets executed:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-03.png"></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-04.png"></p>
<p>The practical shortcuts worth memorizing:</p>
<pre tabindex="0"><code># fastest feedback: compile only
gradle compileJava
# package without running tests
gradle jar
# full build, skip test execution
gradle build -x test
# nuke build/ and start clean
gradle clean build
</code></pre><h3 id="part-3-how-gradle-dependency-resolution-actuallyworks">Part 3: How Gradle Dependency Resolution Actually Works</h3>
<p>This is the part of Gradle most developers treat as magic. You write three lines in a dependencies block, and somehow the right JARs appear on your classpath. Here&rsquo;s the machinery, stage by stage.</p>
<h4 id="configurations-buckets-of-dependencies">Configurations: buckets of dependencies</h4>
<p>Every dependency you declare goes into a named <strong>configuration</strong> — a bucket with specific rules about where those dependencies are visible:</p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-web:3.3.1&#39;
    api &#39;com.fasterxml.jackson.core:jackson-databind:2.17.1&#39;
    compileOnly &#39;org.projectlombok:lombok:1.18.32&#39;
    annotationProcessor &#39;org.projectlombok:lombok:1.18.32&#39;
    runtimeOnly &#39;org.postgresql:postgresql:42.7.3&#39;
    testImplementation &#39;org.junit.jupiter:junit-jupiter:5.10.2&#39;
}
</code></pre><p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-05.png"></p>
<p>The implementation vs api distinction matters in multi-module builds: implementation dependencies don&rsquo;t appear on the compile classpath of modules that depend on yours. That means fewer recompilations when they change, and a cleaner API boundary. Default to implementation; reach for api only when a type from that dependency appears in your public method signatures.</p>
<h4 id="the-resolution-stages">The resolution stages</h4>
<p>Declared dependencies are just coordinates on paper. Resolution is the process of turning them into actual files, and it happens <strong>lazily</strong> — only when a task first needs a classpath (typically compileJava during Execution). The stages:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-06.png"></p>
<p><strong>Stage 1 — Graph construction.</strong> For each declared coordinate (group:name:version), Gradle queries your repositories <em>in the order they&rsquo;re declared</em> and downloads only the <em>metadata</em> first: the POM file or Gradle Module Metadata. That metadata lists the dependency&rsquo;s own dependencies, so Gradle recursively walks outward until the full transitive graph is known. No JARs have been downloaded yet — just descriptors.</p>
<p><strong>Stage 2 — Conflict resolution.</strong> Real dependency graphs always contain the same library requested at different versions (your app wants Jackson 2.17, some transitive path wants 2.15). Gradle’s default strategy is <em>highest version wins</em> — unlike Maven, which picks the “nearest” declaration in the tree. This default is usually right, but you can override it:</p>
<pre tabindex="0"><code>configurations.all {
    resolutionStrategy {
        // hard-pin a version, overriding everything
        force &#39;com.fasterxml.jackson.core:jackson-databind:2.17.1&#39;
        // or turn conflicts into build failures
        failOnVersionConflict()
    }
}
</code></pre><p><strong>Stage 3 — Constraints and rules.</strong> Before the graph is finalized, Gradle applies platforms (BOMs), version constraints, exclusions, and dependency substitutions. This is how Spring Boot keeps hundreds of libraries version-aligned with one line:</p>
<pre tabindex="0"><code>dependencies {
    implementation platform(&#39;org.springframework.boot:spring-boot-dependencies:3.3.1&#39;)
    // now version numbers can be omitted — the BOM supplies them
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
}
</code></pre><p>Exclusions cut unwanted transitive branches at the declaration site:</p>
<pre tabindex="0"><code>implementation(&#39;org.springframework.boot:spring-boot-starter-web&#39;) {
    exclude group: &#39;org.springframework.boot&#39;, module: &#39;spring-boot-starter-tomcat&#39;
}
</code></pre><p><strong>Stage 4 — Artifact download.</strong> Only now, with the final graph settled, does Gradle download the actual JAR files — and only for modules that survived resolution. Everything lands in the shared cache at ~/.gradle/caches/modules-2, keyed by checksum, shared across all projects on your machine. A dependency is downloaded once, ever, per machine (dynamic versions like 1.+ and snapshots get re-checked periodically).</p>
<p><strong>Stage 5 — Classpath assembly.</strong> The resolved files are ordered into a classpath and handed to whichever task requested them: compileClasspath to javac, runtimeClasspath to the test JVM, and so on.</p>
<h4 id="debugging-resolution">Debugging resolution</h4>
<p>Two commands answer 90% of “why is this version on my classpath” questions:</p>
<pre tabindex="0"><code># full resolved tree per configuration
gradle dependencies --configuration runtimeClasspath
# trace one library: who pulls it in, which versions were requested,
# and why the winner won
gradle dependencyInsight --dependency jackson-databind
</code></pre><h3 id="part-4-what-a-jar-file-reallyis">Part 4: What a JAR File Really Is</h3>
<p>The output of the jar task is nothing exotic — it&rsquo;s a ZIP archive with an agreed-upon internal layout. Rename it to .zip and any archive tool will open it:</p>
<pre tabindex="0"><code>myapp.jar
├── META-INF/
│   └── MANIFEST.MF          ← metadata
├── com/example/App.class     ← bytecode, path = package name
├── com/example/util/Helper.class
└── application.properties    ← resources sit alongside classes
</code></pre><p>The manifest is a plain-text key-value file. The line that matters most for a runnable JAR:</p>
<pre tabindex="0"><code>Manifest-Version: 1.0
Main-Class: com.example.App
</code></pre><p>One important caveat: the plain jar task packages <em>only your classes</em>. Your dependencies are not inside. For a self-contained deployable artifact you need a fat JAR — with the Shadow plugin, or Spring Boot&rsquo;s bootJar, which nests dependency JARs and adds its own launcher.</p>
<pre tabindex="0"><code>plugins {
    id &#39;com.github.johnrengelman.shadow&#39; version &#39;8.1.1&#39;
}
// gradle shadowJar → one JAR containing everything
</code></pre><h3 id="part-5-how-the-jvm-reads-yourjar">Part 5: How the JVM Reads Your JAR</h3>
<p>You run java -jar myapp.jar. Here&rsquo;s the sequence, and it&rsquo;s more interesting than it looks.</p>
<h4 id="step-1the-launcher-reads-themanifest">Step 1 — The launcher reads the manifest</h4>
<p>The java launcher opens the JAR as a ZIP, reads META-INF/MANIFEST.MF, and extracts Main-Class. Note a classic gotcha: with -jar, any -cp argument on the command line is <strong>silently ignored</strong>. The JAR itself (plus manifest Class-Path entries) becomes the entire application classpath.</p>
<h4 id="step-2the-classloader-hierarchy">Step 2 — The classloader hierarchy</h4>
<p>Classes are loaded by a chain of classloaders using <strong>parent-first delegation</strong>: every load request travels up to the top of the chain first, and only comes back down if no ancestor can satisfy it.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-07.png"></p>
<h4 id="step-3lazyloading">Step 3 — Lazy loading</h4>
<p>The JVM does <strong>not</strong> load every class in the JAR at startup. A class is loaded the first time it’s actively used — instantiated, statically referenced, reflected upon. This is why a missing transitive dependency can let your app boot cleanly and then blow up with NoClassDefFoundError twenty minutes later, the first time a rarely-used code path runs.</p>
<h4 id="step-4load-link-initialize">Step 4 — Load, Link, Initialize</h4>
<p>Per the JVM specification, every class goes through three phases before it’s usable:</p>
<ol>
<li><strong>Loading</strong> — the class bytes are read from the JAR, parsed, and a Class object is created in metaspace.</li>
<li><strong>Linking</strong> — three sub-steps:
<ul>
<li>Verification: the bytecode is checked for structural and type safety (stack shapes, valid jumps, access rules).</li>
<li>Preparation: static fields are allocated and set to defaults — 0, null, false. Not your values yet.</li>
<li>Resolution: symbolic references to other classes, methods, and fields are resolved to concrete ones (often lazily).</li>
</ul>
</li>
<li><strong>Initialization</strong> — static initializer blocks and static field assignments run, in source order, exactly once, on first active use. This is the point where static { &hellip; } blocks fire.</li>
</ol>
<h3 id="step-5main-and-thejit">Step 5 — main() and the JIT</h3>
<p>Once Main-Class is loaded, linked, and initialized, the JVM invokes main(String[] args) on the main thread. Execution starts in the interpreter. As methods get hot — called thousands of times — the JIT compiler (tiered: C1 first for fast, lightly-optimized code, then C2 for aggressively optimized code) compiles them to native machine code. That&rsquo;s why JVM applications famously get <em>faster</em> after warm-up.</p>
<h3 id="the-wholepicture">The Whole Picture</h3>
<pre tabindex="0"><code>src/main/java  ──javac──▶  build/classes  ──jar──▶  myapp.jar
                                                        │
                                             java -jar myapp.jar
                                                        │
                       read MANIFEST.MF → find Main-Class
                                                        │
                 classloader delegation (parent-first)
                                                        │
                 Load → Link (verify/prepare/resolve) → Init
                                                        │
                            main() runs, classes load lazily,
                            JIT compiles hot paths as you go
</code></pre><p>None of this is knowledge you need daily. But the day your build takes four minutes instead of forty seconds, or two versions of the same library fight on your classpath, or a class mysteriously fails to load in production — this is the map you’ll be reading. Better to have seen it before that day arrives.</p>
]]></content:encoded></item></channel></rss>