ARC might seem like just another cache eviction policy, but its real magic is how it dynamically adapts to your workload, outmaneuvering LRU when it counts.

Let’s see ARC in action. Imagine a cache with a total capacity of 100 items. We’ll track two lists: T1 (recently seen, might be evicted soon) and T2 (seen before, less likely to be evicted). We also have S1 (items seen once, candidates for promotion to T2) and S2 (items seen multiple times, already in T2).

Here’s a simplified trace of operations and the cache state:

Initial state: T1=[], T2=[], S1=[], S2=[]

  1. GET 'A': 'A' is not in cache. T1 = ['A'] S1 = ['A'] Cache state: T1=[A], T2=[], S1=[A], S2=[]

  2. GET 'B': 'B' is not in cache. T1 = ['A', 'B'] S1 = ['A', 'B'] Cache state: T1=[A, B], T2=[], S1=[A, B], S2=[]

  3. GET 'A': 'A' is in T1. Move 'A' to the end of T2. T1 = ['B'] T2 = ['A'] S1 = ['B'] S2 = ['A'] Cache state: T1=[B], T2=[A], S1=[B], S2=[A]

  4. GET 'C': 'C' is not in cache. T1 = ['B', 'C'] S1 = ['B', 'C'] Cache state: T1=[B, C], T2=[A], S1=[B, C], S2=[A]

  5. GET 'B': 'B' is in T1. Move 'B' to the end of T2. T1 = ['C'] T2 = ['A', 'B'] S1 = ['C'] S2 = ['A', 'B'] Cache state: T1=[C], T2=[A, B], S1=[C], S2=[A, B]

  6. GET 'D': 'D' is not in cache. T1 = ['C', 'D'] S1 = ['C', 'D'] Cache state: T1=[C, D], T2=[A, B], S1=[C, D], S2=[A, B]

Now, let’s introduce a scenario where ARC’s advantage becomes clear: a sudden surge of new items followed by a return to old items.

Imagine our cache capacity is 4 for simplicity in this example. T1 and T2 together are capped at 4. S1 and S2 track history.

Initial state: T1=[], T2=[], S1=[], S2=[]

  1. GET '1', '2', '3', '4': All new. T1 = ['1', '2', '3', '4'] S1 = ['1', '2', '3', '4'] Cache state: T1=[1, 2, 3, 4], T2=[], S1=[1, 2, 3, 4], S2=[]

  2. GET '5': Cache is full. LRU would evict '1'. ARC also evicts '1' from T1. Since '1' is in S1, it’s moved to S2. T1 = ['2', '3', '4', '5'] S1 = ['2', '3', '4'] S2 = ['1'] Cache state: T1=[2, 3, 4, 5], T2=[], S1=[2, 3, 4], S2=[1]

  3. GET '6', '7', '8': More new items. Evictions happen. T1 = ['5', '6', '7', '8'] S1 = ['5', '6', '7'] S2 = ['1', '2', '3', '4'] Cache state: T1=[5, 6, 7, 8], T2=[], S1=[5, 6, 7], S2=[1, 2, 3, 4]

At this point, T1 and T2 are full (capacity 4). S1 and S2 hold history.

  1. GET '1': '1' is not in T1 or T2. It’s in S2. This is a "re-reference" of an item that was previously evicted. ARC recognizes this. It knows '1' was popular enough to be in S2. To make space for '1' (which will go into T2), ARC evicts the least recently used item from T2. If T2 is empty, it evicts from T1. Here, T2 is empty. So, ARC evicts the LRU item from T1, which is '5'. '5' is moved to S2. T1 = ['6', '7', '8'] T2 = ['1'] S1 = ['6', '7'] S2 = ['1', '2', '3', '4', '5'] (Note: '1' is already there, but it gets added conceptually. ARC manages duplicates.) Cache state: T1=[6, 7, 8], T2=[1], S1=[6, 7], S2=[1, 2, 3, 4, 5]

  2. GET '2': '2' is not in T1 or T2. It’s in S2. Again, a re-reference of an evicted item. ARC evicts the LRU item from T2 to make space for '2' in T2. T2 currently has only '1'. So, '1' is evicted from T2 and '2' takes its place. The evicted '1' is moved to S2 (it’s already there). The LRU from T1 ('6') is evicted to make space for '2' to go into T1. T1 = ['7', '8', '2'] T2 = ['2'] S1 = ['7', '8'] S2 = ['1', '2', '3', '4', '5', '6'] (Conceptual additions) Cache state: T1=[7, 8, 2], T2=[2], S1=[7, 8], S2=[1, 2, 3, 4, 5, 6]

Observe what’s happening: ARC is dynamically adjusting the balance between T1 and T2. When it sees re-references to items that were previously evicted (1, 2), it prioritizes bringing them back into T2, even if it means evicting other items from T1. This is unlike LRU, which would just see '1' and '2' as new items and keep evicting them if they weren’t recently accessed.

The core idea is that ARC maintains two "ghost" lists, S1 and S2, which track items that were once in the cache but have since been evicted. S1 tracks items evicted from T1, and S2 tracks items evicted from T2. The sizes of T1, T2, S1, and S2 are dynamically managed to sum up to a total capacity c.

When an item is accessed:

  • If it’s in T1: Move it to the end of T2.
  • If it’s in T2: Move it to the end of T2 (no change in position, but it’s a cache hit).
  • If it’s not in T1 or T2:
    • If it’s in S1: This is a re-reference of a recently evicted item. ARC decides to bring it back. It evicts the LRU item from T2 and moves it to S2. Then, it moves the accessed item from S1 to T2.
    • If it’s in S2: This is a re-reference of a less recently evicted item. ARC decides to bring it back. It evicts the LRU item from T1 and moves it to S1. Then, it moves the accessed item from S2 to T2.
    • If it’s not in S1 or S2: It’s a completely new item.

The sizes of T1 and T2 are adjusted based on whether the last operation was a hit in T1 or T2, or a miss. If a miss occurred and the item was found in S1 or S2, ARC increases the size of T2 relative to T1. If a miss occurred and the item was new, ARC increases the size of T1 relative to T2. This dynamic adjustment allows ARC to quickly adapt to changing access patterns, such as bursts of new data followed by a return to older, frequently accessed data.

The crucial part that most people miss is how S1 and S2 influence the sizes of T1 and T2. When an item is found in S1 (meaning it was evicted from T1), ARC performs a "target size adjustment" to increase the size of T2 and decrease the size of T1. Conversely, if an item is found in S2, it increases T1 and decreases T2. This is how ARC learns the "recency" of items that have been evicted and decides how much space to allocate to recently seen vs. previously seen items.

This adaptability is why ARC shines in production environments where access patterns are rarely static.

Want structured learning?

Take the full Caching-strategies course →