Memory Fragmentation and Compaction
**Memory fragmentation in operating system** terms is memory that is free but unusable. Put simply, fragmentation in OS comes in two kinds, and they have different causes and different cures. Internal fragmentation is waste inside a block you were given; external fragmentation is waste between blocks. Paging eliminates the second by accepting a bounded amount of the first.
Two Different Wastes
Memory fragmentation in operating system terms comes in two kinds. Internal fragmentation in OS terms is memory wasted inside an allocated region. A process asks for 5 KB, the allocator hands out whole 4 KB pages, so it gets 8 KB and 3 KB is unusable — allocated to the process, needed by nobody, unavailable to anyone else. The waste is bounded and predictable: on average half a block per allocation.
External fragmentation in OS terms is memory wasted between allocated regions. After a long run of variable-sized allocations and frees, free memory exists in scattered holes. A request for 10 MB fails even though 40 MB is free, because no single hole is large enough. Total free memory is fine; contiguity is gone.
The distinction is the one exams test and the one that decides allocator design. Internal fragmentation is the price of fixed-size blocks. External fragmentation is the price of variable-size blocks. You are choosing which one to pay, and the fifty-percent rule from Knuth's analysis says that with first-fit, roughly half as many blocks again are lost to external fragmentation as are allocated — for every 2N allocated blocks, about N are lost to holes.
- Internal — waste inside an allocated block
- External — waste in the gaps between blocks
- Internal is bounded at half a block on average
- External can fail a request while memory is free
Why Paging Made the Trade
Paging is the systematic answer to external fragmentation, and the mechanism is simple: if every block is exactly one page and every free frame is interchangeable, a request for k pages can be satisfied by any k free frames anywhere in physical memory. There is no such thing as a hole too small, because there is only one size of hole.
That eliminates external fragmentation entirely for paged memory. In exchange you accept internal fragmentation in the last page of every allocation, averaging half a page — 2 KB on a 4 KB system. For a process using tens of megabytes this is negligible, which is why the trade was worth making.
The trade reverses with large pages. Huge pages of 2 MB cut TLB misses substantially, and are widely used for databases and virtualisation, but the average internal waste becomes 1 MB per allocation. That is fine for a process mapping gigabytes and ruinous for many small ones — which is why transparent huge pages are applied selectively rather than everywhere.
- One block size makes every free frame interchangeable
- External fragmentation becomes structurally impossible
- The cost is half a page wasted per allocation
- Huge pages reverse the trade at 1 MB average waste
Compaction and Its Prerequisite
Memory compaction in operating system design means relocating allocated blocks so the free holes merge into one contiguous run. Conceptually it is defragmentation for RAM: slide everything down, and the scattered free space becomes usable.
The prerequisite is dynamic relocation. If a program's addresses were bound at compile or load time, moving it breaks every pointer it holds. Compaction is only possible where addresses are translated at runtime — a base register, or a page table — so the physical location can change while the logical address does not.
It is also expensive. Compaction means copying memory, and copying gigabytes stalls the processes involved. Systems that do it, do it selectively: the Linux kernel compacts physical memory specifically to assemble contiguous runs for huge pages and DMA buffers, triggered on demand rather than run continuously. Managed runtimes like the JVM and .NET compact during garbage collection, where the collector already has to walk and update every reference, so relocation is nearly free by comparison.
- Compaction merges scattered holes into one run
- It requires runtime address translation to be possible
- Copying live memory stalls the processes involved
- Linux compacts on demand for huge pages and DMA
A 64 KB region worked to failure, then compacted
# Contiguous allocation driven to external fragmentation, then compacted.
# blocks: list of [owner|None, size] in address order.
def alloc(blocks, name, size):
for i, (owner, sz) in enumerate(blocks):
if owner is None and sz >= size: # first fit
rest = sz - size
blocks[i:i+1] = [[name, size]] + ([[None, rest]] if rest else [])
return True
return False # no single hole big enough
def free(blocks, name):
for b in blocks:
if b[0] == name:
b[0] = None
coalesce(blocks)
def coalesce(blocks):
i = 0 # merge adjacent free runs
while i < len(blocks) - 1:
if blocks[i][0] is None and blocks[i+1][0] is None:
blocks[i][1] += blocks[i+1][1]
del blocks[i+1]
else:
i += 1
def compact(blocks):
used = [b for b in blocks if b[0] is not None] # slide everything down
hole = sum(b[1] for b in blocks if b[0] is None)
return used + ([[None, hole]] if hole else [])
def holes(blocks):
return [b[1] for b in blocks if b[0] is None]
mem = [[None, 64]]
for name, size in [("A", 12), ("B", 9), ("C", 14), ("D", 7), ("E", 10)]:
alloc(mem, name, size)
free(mem, "B")
free(mem, "D")
h = holes(mem)
print("holes %s -> %d KB free, largest %d KB" % (h, sum(h), max(h)))
print("request 26 KB ->", "ok" if alloc(list(mem), "F", 26) else "FAILS (external fragmentation)")
mem = compact(mem)
print("after compaction: largest hole %d KB" % max(holes(mem)))
print("request 26 KB ->", "ok" if alloc(mem, "F", 26) else "FAILS")
// External fragmentation demonstrated on a contiguous region.
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
struct Block {
std::string owner;
int size;
}; // owner "" means free
bool alloc(std::vector<Block>& b, const std::string& name, int size) {
for (size_t i = 0; i < b.size(); ++i)
if (b[i].owner.empty() && b[i].size >= size) {
int rest = b[i].size - size;
b[i] = {name, size};
if (rest) b.insert(b.begin() + i + 1, {"", rest});
return true;
}
return false; // fragmented, not full
}
void coalesce(std::vector<Block>& b) {
for (size_t i = 0; i + 1 < b.size();)
if (b[i].owner.empty() && b[i + 1].owner.empty()) {
b[i].size += b[i + 1].size;
b.erase(b.begin() + i + 1);
} else ++i;
}
void release(std::vector<Block>& b, const std::string& name) {
for (auto& x : b) if (x.owner == name) x.owner.clear();
coalesce(b);
}
std::vector<Block> compact(const std::vector<Block>& b) {
std::vector<Block> out;
int hole = 0;
for (const auto& x : b) {
if (x.owner.empty()) hole += x.size;
else out.push_back(x);
}
if (hole) out.push_back({"", hole}); // one contiguous run
return out;
}
int largestHole(const std::vector<Block>& b) {
int m = 0;
for (const auto& x : b) if (x.owner.empty()) m = std::max(m, x.size);
return m;
}
int main() {
std::vector<Block> mem {
{
"", 64
}
};
for (auto p : {std::pair<const char*,int>{"A",12},{"B",9},{"C",14},{"D",7},{"E",10}})
alloc(mem, p.first, p.second);
release(mem, "B");
release(mem, "D");
std::cout << "largest hole " << largestHole(mem) << " KB\n";
mem = compact(mem);
std::cout << "after compaction " << largestHole(mem) << " KB\n";
}// Contiguous allocation, fragmentation, and compaction.
import java.util.*;
class Region {
record Block(String owner, int size) {
}
// owner == null -> free
List<Block> blocks = new ArrayList<>();
boolean alloc(String name, int size) {
for (int i = 0; i < blocks.size(); i++) {
Block b = blocks.get(i);
if (b.owner() == null && b.size() >= size) {
int rest = b.size() - size;
blocks.set(i, new Block(name, size));
if (rest > 0) blocks.add(i + 1, new Block(null, rest));
return true;
}
}
return false; // no hole large enough
}
void coalesce() { // merge neighbouring holes
for (int i = 0; i + 1 < blocks.size(); ) {
if (blocks.get(i).owner() == null && blocks.get(i+1).owner() == null) {
blocks.set(i, new Block(null, blocks.get(i).size() + blocks.get(i+1).size()));
blocks.remove(i + 1);
} else i++;
}
}
void compact() { // slide allocations down
List<Block> used = blocks.stream().filter(b -> b.owner() != null).toList();
int hole = blocks.stream().filter(b -> b.owner() == null)
.mapToInt(Block::size).sum();
blocks = new ArrayList<>(used);
if (hole > 0) blocks.add(new Block(null, hole));
}
}Step through it
Running on 64 KB region, 4 KB pages; processes A(12K) B(9K) C(14K) D(7K) arrive and leave
Read all 15 Steps
- 64 KB of contiguous memory, entirely free One unbroken 64 KB region. Any request up to 64 KB succeeds right now, because the memory is not merely free — it is contiguous. That distinction is the whole subject of this page. Total free and largest free hole are the same number, 64 KB, and that is the only state in which they are guaranteed to match.
- A allocates 12 KB First fit places A at address 0. The remaining 52 KB stays as one hole. Nothing is wasted yet: A asked for 12 and received exactly 12, with no internal fragmentation because this is contiguous variable-size allocation, not paging.
- B allocates 9 KB, C allocates 14 KB B lands at 12, C at 21. The region now holds three allocations packed against each other with 29 KB free at the top. Still one hole, so still no fragmentation — allocations made in order and never freed cannot fragment anything.
- D allocates 7 KB D takes 7 KB at address 35, leaving 22 KB. Four processes, 42 KB in use, 22 KB free in a single run. This is the last moment the free memory is contiguous.
- B exits — the first hole appears in the middle B releases its 9 KB. Free memory is now 31 KB but it exists in two pieces: 9 KB at address 12, and 22 KB at address 42. Total free went up; largest hole did not. That gap between the two numbers is external fragmentation, and it has just been created.
- D exits — its hole merges with the one above it D's 7 KB is adjacent to the existing 22 KB hole, so coalescing merges them into 29 KB. This is why allocators coalesce on free: adjacent holes left separate would fragment far faster. Two holes now, 9 and 29.
- E allocates 5 KB into the small hole First fit scans from address 0, finds the 9 KB hole, and places E there. The 4 KB remainder is left behind. A tighter-fitting strategy would have chosen the same hole here, but note what it produces: a 4 KB fragment wedged between two allocations, which only a very small future request can ever use.
- C exits — 26 KB free, in the wrong shape C releases 14 KB, which coalesces with the 29 KB above it into 43 KB. Free memory is now 47 KB across two holes: 4 KB and 43 KB. Plenty of room, and the region is still perfectly usable — the failure has not happened yet.
- F allocates 40 KB, nearly filling the large hole F takes 40 of the 43 KB hole. What remains is 4 KB at address 17 and 3 KB at the very top. Total free is 7 KB in two unusable pieces. The region is now genuinely fragmented in the way that matters.
- F exits, A exits — 26 KB free and scattered F releases 40 KB and A releases 12 KB. Free memory: 12 KB at address 0, 4 KB at 17, and 43 KB at the top — wait, F's 40 coalesces with the trailing 3. Holes are 12, 4, and 43. Only E remains allocated, holding 5 KB at address 12.
- G(20K) and H(20K) allocate, then G exits G and H both fit into the 43 KB hole, leaving 3 KB. G then exits, freeing 20 KB between the 4 KB fragment and H. The free list is now 12, 4, 20, 3 — four holes, 39 KB total, largest 20 KB.
- request 26 KB — and it fails 39 KB is free. The request is 26 KB. Every hole is checked: 12, 24 (the 4 and 20 coalesce), 3. None is large enough. The allocation fails on a region that is 61% free. This is external fragmentation as an operational failure rather than a definition — nothing is leaked, nothing is lost, the memory is simply in pieces.
- compaction slides the allocations down E and H are relocated to the bottom of the region, and all the holes merge into one 39 KB run. The 26 KB request now succeeds. The prerequisite is that E and H can be moved at all, which requires runtime address translation — a base register or page table — so their internal pointers stay valid at the new physical location.
- the cost of having done that Compaction copied 25 KB of live data and stalled both processes while their memory moved. At this scale it is trivial; at gigabyte scale it is a visible pause, which is why systems compact selectively rather than continuously. Linux compacts physical memory on demand to assemble huge pages and DMA buffers. The JVM and .NET compact during garbage collection, where every reference is being walked anyway so relocation is nearly free by comparison.
- paging removes the problem instead of repairing it Split the same 64 KB into sixteen 4 KB pages and external fragmentation becomes structurally impossible: every free frame is interchangeable, so any 7 free frames satisfy a 26 KB request regardless of where they sit. No compaction, no copying, no stall. The price is internal fragmentation in the last page of each allocation — E's 5 KB occupies two pages and wastes 3 KB — averaging half a page per allocation. Bounded, predictable waste in exchange for unbounded, unpredictable failure. Every modern general-purpose OS took that trade.
Allocators That Fight Back
Where variable-size allocation is unavoidable — kernel objects, malloc inside a process — allocator design is the defence. The buddy system splits memory into power-of-two blocks, so a freed block can be merged with its 'buddy' by a single address XOR if that buddy is also free. Coalescing is O(1) and cheap enough to do on every free; the cost is internal fragmentation, since a 33 KB request consumes 64 KB.
Slab allocation, used by the Linux kernel, attacks the problem from the other side: keep separate caches per object type, so every block in a slab is the same size and fragmentation within it is impossible by construction. It also preserves initialised object state, which is why it was designed at Sun for structures like inodes that are allocated and freed constantly.
Userspace allocators combine both ideas. glibc's malloc uses size-class bins with coalescing of adjacent free chunks; jemalloc and tcmalloc add per-thread caches to avoid lock contention and use size classes deliberately spaced to bound internal waste to a known percentage. The general principle is the same throughout: make blocks uniform where you can, and merge aggressively where you cannot.
- Buddy system coalesces in O(1) by address XOR
- Slab allocation makes per-type fragmentation impossible
- Size-class bins bound internal waste to a known percentage
- Uniform blocks where possible, aggressive merging elsewhere