Let's design a cache. Consider the following requirements:
Basic Functionality: Implement a cache with put(key, value)
and get(key)
methods.
Eviction Policy: Implement an LRU (Least Recently Used) eviction policy. When the cache is full, the least recently accessed item should be evicted to make space for the new item.
Thread Safety: Ensure the cache is thread-safe to handle concurrent access from multiple threads.
Generics: The cache should be able to store any type of key and value (using generics).
Time Complexity: get
and put
operations should have an average time complexity of O(1).
Example Usage:
Cache<Integer, String> cache = new Cache<>(5); // Cache with capacity 5
cache.put(1, "one");
cache.put(2, "two");
cache.get(1); // Access key 1, moves it to the most recently used
cache.put(3, "three");
cache.put(4, "four");
cache.put(5, "five");
cache.put(6, "six"); // This will evict key 2 (LRU)
System.out.println(cache.get(2)); // Output: null (because it was evicted)
Discuss the data structures you would use, the implementation details for each method, and how you would address thread safety.
Let's design a cache that supports put(key, value)
and get(key)
operations with an LRU (Least Recently Used) eviction policy, thread safety, and generics.
We'll use the following data structures:
HashMap<K, Node<K, V>> cache
: Stores key-value pairs for O(1) average-case access.DoubleLinkedList<K, V> lruList
: Maintains the order of elements based on their usage. The most recently used element is at the head, and the least recently used is at the tail.int capacity
: Specifies the maximum number of key-value pairs the cache can hold.private static class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
A simple node structure to hold the key, value, and pointers to the previous and next nodes in the doubly linked list.
private static class DoubleLinkedList<K, V> {
Node<K, V> head;
Node<K, V> tail;
public DoubleLinkedList() {
head = null;
tail = null;
}
public void addFirst(Node<K, V> node) {
if (head == null) {
head = node;
tail = node;
} else {
node.next = head;
head.prev = node;
head = node;
}
}
public void remove(Node<K, V> node) {
if (node.prev != null) {
node.prev.next = node.next;
} else {
head = node.next;
}
if (node.next != null) {
node.next.prev = node.prev;
} else {
tail = node.prev;
}
}
public Node<K, V> removeLast() {
if (tail == null) {
return null;
}
Node<K, V> last = tail;
remove(last);
return last;
}
}
A standard doubly linked list implementation to track the order of node accesses. addFirst
adds a node to the head, remove
removes a node from the list, and removeLast
removes the tail node (LRU).
import java.util.HashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache<K, V> {
private final int capacity;
private final HashMap<K, Node<K, V>> cache;
private final DoubleLinkedList<K, V> lruList;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public Cache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>(capacity);
this.lruList = new DoubleLinkedList<>();
}
public V get(K key) {
readLock.lock();
try {
Node<K, V> node = cache.get(key);
if (node == null) {
return null;
}
lruList.remove(node);
lruList.addFirst(node);
return node.value;
} finally {
readLock.unlock();
}
}
public void put(K key, V value) {
writeLock.lock();
try {
Node<K, V> node = cache.get(key);
if (node != null) {
// Key already exists, update value and move to front
node.value = value;
lruList.remove(node);
lruList.addFirst(node);
} else {
// Key doesn't exist
if (cache.size() == capacity) {
// Evict LRU node
Node<K, V> lruNode = lruList.removeLast();
cache.remove(lruNode.key);
}
// Add new node
Node<K, V> newNode = new Node<>(key, value);
cache.put(key, newNode);
lruList.addFirst(newNode);
}
} finally {
writeLock.unlock();
}
}
private static class Node<K, V> {
K key;
V value;
Node<K, V> prev;
Node<K, V> next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
private static class DoubleLinkedList<K, V> {
Node<K, V> head;
Node<K, V> tail;
public DoubleLinkedList() {
head = null;
tail = null;
}
public void addFirst(Node<K, V> node) {
if (head == null) {
head = node;
tail = node;
} else {
node.next = head;
head.prev = node;
head = node;
}
}
public void remove(Node<K, V> node) {
if (node.prev != null) {
node.prev.next = node.next;
} else {
head = node.next;
}
if (node.next != null) {
node.next.prev = node.prev;
} else {
tail = node.prev;
}
}
public Node<K, V> removeLast() {
if (tail == null) {
return null;
}
Node<K, V> last = tail;
remove(last);
return last;
}
}
}
get(K key)
cache
(HashMap). If not, return null
.Node
.lruList
.lruList
(most recently used).put(K key, V value)
cache
.
lruList
, and add it to the head.lruList
) and remove its corresponding entry from the cache
.Node
with the given key and value.cache
.lruList
.Thread safety is achieved using a ReadWriteLock
. This allows multiple threads to read from the cache concurrently, but only one thread can write to it at a time. This prevents race conditions and ensures data consistency.
ReadLock
: Used for the get
operation. Multiple readers can access the cache simultaneously.WriteLock
: Used for the put
operation. Only one writer can modify the cache at a time, blocking other readers and writers.The cache uses generics (<K, V>
) to allow it to store any type of key and value. This provides flexibility and type safety.
get(K key)
: O(1) on average, due to the HashMap lookup. Removing and adding to the doubly linked list are also O(1).put(K key, V value)
: O(1) on average. HashMap put/get, and doubly linked list add/remove operations are O(1).capacity
elements.public static void main(String[] args) {
Cache<Integer, String> cache = new Cache<>(5);
cache.put(1, "one");
cache.put(2, "two");
cache.get(1); // Access key 1, moves it to the most recently used
cache.put(3, "three");
cache.put(4, "four");
cache.put(5, "five");
cache.put(6, "six"); // This will evict key 2 (LRU)
System.out.println(cache.get(2)); // Output: null (because it was evicted)
}