Skip to content

Commit 28c505a

Browse files
committed
Added Random Replacement cache
1 parent 13d8a28 commit 28c505a

File tree

2 files changed

+561
-0
lines changed

2 files changed

+561
-0
lines changed
Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
package com.thealgorithms.datastructures.caches;
2+
3+
import java.util.ArrayList;
4+
import java.util.HashMap;
5+
import java.util.Iterator;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Random;
9+
import java.util.concurrent.locks.Lock;
10+
import java.util.concurrent.locks.ReentrantLock;
11+
import java.util.function.BiConsumer;
12+
13+
/**
14+
* A thread-safe generic cache implementation using the Random Replacement (RR) eviction policy.
15+
* <p>
16+
* The cache holds a fixed number of entries, defined by its capacity. When the cache is full and a
17+
* new entry is added, one of the existing entries is selected at random and evicted to make space.
18+
* <p>
19+
* Optionally, entries can have a time-to-live (TTL) in milliseconds. If a TTL is set, entries will
20+
* automatically expire and be removed upon access or insertion attempts.
21+
* <p>
22+
* Features:
23+
* <ul>
24+
* <li>Random eviction when capacity is exceeded</li>
25+
* <li>Optional TTL (time-to-live in milliseconds) per entry or default TTL for all entries</li>
26+
* <li>Thread-safe access using locking</li>
27+
* <li>Hit and miss counters for cache statistics</li>
28+
* <li>Eviction listener callback support</li>
29+
* </ul>
30+
*
31+
* @param <K> the type of keys maintained by this cache
32+
* @param <V> the type of mapped values
33+
*
34+
* @author Kevin Babu (<a href="https://www.github.com/KevinMwita7">GitHub</a>)
35+
*/
36+
public final class RRCache<K, V> {
37+
38+
private final int capacity;
39+
private final long defaultTTL;
40+
private final Map<K, CacheEntry<V>> cache;
41+
private final List<K> keys;
42+
private final Random random;
43+
private final Lock lock;
44+
45+
private long hits = 0;
46+
private long misses = 0;
47+
48+
private final BiConsumer<K, V> evictionListener;
49+
50+
/**
51+
* Internal structure to store value + expiry timestamp.
52+
*
53+
* @param <V> the type of the value being cached
54+
*/
55+
private static class CacheEntry<V> {
56+
V value;
57+
long expiryTime;
58+
59+
/**
60+
* Constructs a new {@code CacheEntry} with the specified value and time-to-live (TTL).
61+
*
62+
* @param value the value to cache
63+
* @param ttlMillis the time-to-live in milliseconds
64+
*/
65+
CacheEntry(V value, long ttlMillis) {
66+
this.value = value;
67+
this.expiryTime = System.currentTimeMillis() + ttlMillis;
68+
}
69+
70+
/**
71+
* Checks if the cache entry has expired.
72+
*
73+
* @return {@code true} if the current time is past the expiration time; {@code false} otherwise
74+
*/
75+
boolean isExpired() {
76+
return System.currentTimeMillis() > expiryTime;
77+
}
78+
}
79+
80+
/**
81+
* Constructs a new {@code RRCache} instance using the provided {@link Builder}.
82+
*
83+
* <p>This constructor initializes the cache with the specified capacity and default TTL,
84+
* sets up internal data structures (a {@code HashMap} for cache entries and an {@code ArrayList}
85+
* for key tracking), and configures eviction and randomization behavior.
86+
*
87+
* @param builder the {@code Builder} object containing configuration parameters
88+
*/
89+
private RRCache(Builder<K, V> builder) {
90+
this.capacity = builder.capacity;
91+
this.defaultTTL = builder.defaultTTL;
92+
this.cache = new HashMap<>(builder.capacity);
93+
this.keys = new ArrayList<>(builder.capacity);
94+
this.random = builder.random != null ? builder.random : new Random();
95+
this.lock = new ReentrantLock();
96+
this.evictionListener = builder.evictionListener;
97+
}
98+
99+
/**
100+
* Retrieves the value associated with the specified key from the cache.
101+
*
102+
* <p>If the key is not present or the corresponding entry has expired, this method
103+
* returns {@code null}. If an expired entry is found, it will be removed and the
104+
* eviction listener (if any) will be notified. Cache hit-and-miss statistics are
105+
* also updated accordingly.
106+
*
107+
* @param key the key whose associated value is to be returned; must not be {@code null}
108+
* @return the cached value associated with the key, or {@code null} if not present or expired
109+
* @throws IllegalArgumentException if {@code key} is {@code null}
110+
*/
111+
public V get(K key) {
112+
if (key == null) {
113+
throw new IllegalArgumentException("Key must not be null");
114+
}
115+
116+
lock.lock();
117+
try {
118+
CacheEntry<V> entry = cache.get(key);
119+
if (entry == null || entry.isExpired()) {
120+
if (entry != null) {
121+
removeKey(key);
122+
notifyEviction(key, entry.value);
123+
}
124+
misses++;
125+
return null;
126+
}
127+
hits++;
128+
return entry.value;
129+
} finally {
130+
lock.unlock();
131+
}
132+
}
133+
134+
/**
135+
* Adds a key-value pair to the cache using the default time-to-live (TTL).
136+
*
137+
* <p>The key may overwrite an existing entry. The actual insertion is delegated
138+
* to the overloaded {@link #put(K, V, long)} method.
139+
*
140+
* @param key the key to cache the value under
141+
* @param value the value to be cached
142+
*/
143+
public void put(K key, V value) {
144+
put(key, value, defaultTTL);
145+
}
146+
147+
/**
148+
* Adds a key-value pair to the cache with a specified time-to-live (TTL).
149+
*
150+
* <p>If the key already exists, its value is updated and its TTL is reset. If the key
151+
* does not exist and the cache is full, a random entry is evicted to make space.
152+
* Expired entries are also cleaned up prior to any eviction. The eviction listener
153+
* is notified when an entry gets evicted.
154+
*
155+
* @param key the key to associate with the cached value; must not be {@code null}
156+
* @param value the value to be cached; must not be {@code null}
157+
* @param ttlMillis the time-to-live for this entry in milliseconds; must be >= 0
158+
* @throws IllegalArgumentException if {@code key} or {@code value} is {@code null}, or if {@code ttlMillis} is negative
159+
*/
160+
public void put(K key, V value, long ttlMillis) {
161+
if (key == null || value == null) {
162+
throw new IllegalArgumentException("Key and value must not be null");
163+
}
164+
if (ttlMillis < 0) {
165+
throw new IllegalArgumentException("TTL must be >= 0");
166+
}
167+
168+
lock.lock();
169+
try {
170+
if (cache.containsKey(key)) {
171+
cache.put(key, new CacheEntry<>(value, ttlMillis));
172+
return;
173+
}
174+
175+
evictExpired();
176+
177+
if (cache.size() >= capacity) {
178+
int idx = random.nextInt(keys.size());
179+
K evictKey = keys.remove(idx);
180+
CacheEntry<V> evictVal = cache.remove(evictKey);
181+
notifyEviction(evictKey, evictVal.value);
182+
}
183+
184+
cache.put(key, new CacheEntry<>(value, ttlMillis));
185+
keys.add(key);
186+
} finally {
187+
lock.unlock();
188+
}
189+
}
190+
191+
/**
192+
* Removes all expired entries from the cache.
193+
*
194+
* <p>This method iterates through the list of cached keys and checks each associated
195+
* entry for expiration. Expired entries are removed from both the key tracking list
196+
* and the cache map. For each eviction, the eviction listener is notified.
197+
*/
198+
private void evictExpired() {
199+
Iterator<K> it = keys.iterator();
200+
while (it.hasNext()) {
201+
K k = it.next();
202+
CacheEntry<V> entry = cache.get(k);
203+
if (entry.isExpired()) {
204+
it.remove();
205+
cache.remove(k);
206+
notifyEviction(k, entry.value);
207+
}
208+
}
209+
}
210+
211+
/**
212+
* Removes the specified key and its associated entry from the cache.
213+
*
214+
* <p>This method deletes the key from both the cache map and the key tracking list.
215+
*
216+
* @param key the key to remove from the cache
217+
*/
218+
private void removeKey(K key) {
219+
cache.remove(key);
220+
keys.remove(key);
221+
}
222+
223+
/**
224+
* Notifies the eviction listener, if one is registered, that a key-value pair has been evicted.
225+
*
226+
* <p>If the {@code evictionListener} is not {@code null}, it is invoked with the provided key
227+
* and value. Any exceptions thrown by the listener are caught and logged to standard error,
228+
* preventing them from disrupting cache operations.
229+
*
230+
* @param key the key that was evicted
231+
* @param value the value that was associated with the evicted key
232+
*/
233+
private void notifyEviction(K key, V value) {
234+
if (evictionListener != null) {
235+
try {
236+
evictionListener.accept(key, value);
237+
} catch (Exception e) {
238+
System.err.println("Eviction listener failed: " + e.getMessage());
239+
}
240+
}
241+
}
242+
243+
/**
244+
* Returns the number of successful cache lookups (hits).
245+
*
246+
* @return the number of cache hits
247+
*/
248+
public long getHits() {
249+
lock.lock();
250+
try { return hits; } finally { lock.unlock(); }
251+
}
252+
253+
/**
254+
* Returns the number of failed cache lookups (misses), including expired entries.
255+
*
256+
* @return the number of cache misses
257+
*/
258+
public long getMisses() {
259+
lock.lock();
260+
try { return misses; } finally { lock.unlock(); }
261+
}
262+
263+
/**
264+
* Returns the current number of entries in the cache, excluding expired ones.
265+
*
266+
* @return the current cache size
267+
*/
268+
public int size() {
269+
lock.lock();
270+
try {
271+
int count = 0;
272+
for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) {
273+
if (!entry.getValue().isExpired()) {
274+
++count;
275+
}
276+
}
277+
return count;
278+
} finally {
279+
lock.unlock();
280+
}
281+
}
282+
283+
/**
284+
* Returns a string representation of the cache, including metadata and current non-expired entries.
285+
*
286+
* <p>The returned string includes the cache's capacity, current size (excluding expired entries),
287+
* hit-and-miss counts, and a map of all non-expired key-value pairs. This method acquires a lock
288+
* to ensure thread-safe access.
289+
*
290+
* @return a string summarizing the state of the cache
291+
*/
292+
@Override
293+
public String toString() {
294+
lock.lock();
295+
try {
296+
Map<K, V> visible = new HashMap<>();
297+
for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) {
298+
if (!entry.getValue().isExpired()) {
299+
visible.put(entry.getKey(), entry.getValue().value);
300+
}
301+
}
302+
return String.format("Cache(capacity=%d, size=%d, hits=%d, misses=%d, entries=%s)",
303+
capacity, visible.size(), hits, misses, visible);
304+
} finally {
305+
lock.unlock();
306+
}
307+
}
308+
309+
/**
310+
* A builder for creating instances of {@link RRCache} with custom configuration.
311+
*
312+
* <p>This static inner class allows you to configure parameters such as cache capacity,
313+
* default TTL (time-to-live), random eviction behavior, and an optional eviction listener.
314+
* Once configured, use {@link #build()} to create the {@code RRCache} instance.
315+
*
316+
* @param <K> the type of keys maintained by the cache
317+
* @param <V> the type of values stored in the cache
318+
*/
319+
public static class Builder<K, V> {
320+
private final int capacity;
321+
private long defaultTTL = 0;
322+
private Random random;
323+
private BiConsumer<K, V> evictionListener;
324+
325+
/**
326+
* Creates a new {@code Builder} with the specified cache capacity.
327+
*
328+
* @param capacity the maximum number of entries the cache can hold; must be > 0
329+
* @throws IllegalArgumentException if {@code capacity} is less than or equal to 0
330+
*/
331+
public Builder(int capacity) {
332+
if (capacity <= 0) {
333+
throw new IllegalArgumentException("Capacity must be > 0");
334+
}
335+
this.capacity = capacity;
336+
}
337+
338+
/**
339+
* Sets the default time-to-live (TTL) in milliseconds for cache entries.
340+
*
341+
* @param ttlMillis the TTL duration in milliseconds; must be >= 0
342+
* @return this builder instance for chaining
343+
* @throws IllegalArgumentException if {@code ttlMillis} is negative
344+
*/
345+
public Builder<K, V> defaultTTL(long ttlMillis) {
346+
if (ttlMillis < 0) {
347+
throw new IllegalArgumentException("Default TTL must be >= 0");
348+
}
349+
this.defaultTTL = ttlMillis;
350+
return this;
351+
}
352+
353+
/**
354+
* Sets the {@link Random} instance to be used for random eviction selection.
355+
*
356+
* @param r a non-null {@code Random} instance
357+
* @return this builder instance for chaining
358+
* @throws IllegalArgumentException if {@code r} is {@code null}
359+
*/
360+
public Builder<K, V> random(Random r) {
361+
if (r == null) {
362+
throw new IllegalArgumentException("Random must not be null");
363+
}
364+
this.random = r;
365+
return this;
366+
}
367+
368+
/**
369+
* Sets an eviction listener to be notified when entries are evicted from the cache.
370+
*
371+
* @param listener a {@link BiConsumer} that accepts evicted keys and values; must not be {@code null}
372+
* @return this builder instance for chaining
373+
* @throws IllegalArgumentException if {@code listener} is {@code null}
374+
*/
375+
public Builder<K, V> evictionListener(BiConsumer<K, V> listener) {
376+
if (listener == null) {
377+
throw new IllegalArgumentException("Listener must not be null");
378+
}
379+
this.evictionListener = listener;
380+
return this;
381+
}
382+
383+
/**
384+
* Builds and returns a new {@link RRCache} instance with the configured parameters.
385+
*
386+
* @return a fully configured {@code RRCache} instance
387+
*/
388+
public RRCache<K, V> build() {
389+
return new RRCache<>(this);
390+
}
391+
}
392+
}

0 commit comments

Comments
 (0)