From 2bd3a6f9c547bc806d186e58ec66d5de3b3e4d48 Mon Sep 17 00:00:00 2001 From: arisnguyenit97 Date: Sun, 19 May 2024 17:00:12 +0700 Subject: [PATCH] :sparkles: feat: add LRU cache using map interface #4 --- docs/001_LRUCache4j.md | 104 ++++ .../groovy/org/alpha4j/ds/LRUCache4j.java | 443 ++++++++++++++++++ .../groovy/org/alpha4j/LRUCache4jTest.java | 187 ++++++++ plugin/src/test/resources/log4j2-test.xml | 13 + 4 files changed, 747 insertions(+) create mode 100644 docs/001_LRUCache4j.md create mode 100644 plugin/src/main/groovy/org/alpha4j/ds/LRUCache4j.java create mode 100644 plugin/src/test/groovy/org/alpha4j/LRUCache4jTest.java create mode 100644 plugin/src/test/resources/log4j2-test.xml diff --git a/docs/001_LRUCache4j.md b/docs/001_LRUCache4j.md new file mode 100644 index 0000000..0b0fba1 --- /dev/null +++ b/docs/001_LRUCache4j.md @@ -0,0 +1,104 @@ +# LRUCache4j + +`LRUCache4j` is a thread-safe implementation of a Least Recently Used (LRU) cache in Java. This class extends the Map +interface and provides a cache mechanism that evicts the least recently used items when a specified capacity is reached. +The class ensures thread safety by using a ReentrantReadWriteLock around its read and write operations. + +## Features + +- **Thread-safe**: Uses ReentrantReadWriteLock to manage concurrent access. +- **Eviction policy**: Removes the least recently used item when the cache exceeds its capacity. +- **Implements Map interface**: Can be used as a drop-in replacement for a Map with LRU eviction. +- **Customizable capacity**: Initialize with a specified capacity to control the maximum number of entries. + +## Usage + +### Initialization + +To create an instance of `LRUCache4j`, simply specify the maximum number of entries it can hold: + +```java +LRUCache4j cache = new LRUCache4j<>(5); +``` + +### Basic Operations + +- Put an entry: + +```java +cache.put("key1",1); +``` + +- Get an entry: + +```java +Integer value = cache.get("key1"); +``` + +- Remove an entry: + +```java +cache.remove("key1"); +``` + +- Check if the cache contains a key or value: + +```java +boolean containsKey = cache.containsKey("key1"); +boolean containsValue = cache.containsValue(1); +``` + +- Clear the cache: + +```java +cache.clear(); +``` + +### Advanced Operations + +- Put if absent: + +```java +cache.putIfAbsent("key2",2); +``` + +- Bulk operations: + +```java +Map map = new HashMap<>(); +map. + +put("key3",3); +map. + +put("key4",4); +cache. + +putAll(map); +``` + +## Thread Safety + +`LRUCache4j` uses read-write locks to ensure thread-safe operations. The `readOperation` and `writeOperation` methods +encapsulate the lock management for read and write operations respectively. + +## Example + +```java +public class Example { + public static void main(String[] args) { + LRUCache4j cache = new LRUCache4j<>(3); + cache.put("a", "apple"); + cache.put("b", "banana"); + cache.put("c", "cherry"); + + System.out.println("Cache size: " + cache.size()); // Output: 3 + + cache.get("a"); // Access "a" to make it recently used + cache.put("d", "date"); // This will evict "b" as it is the least recently used + + System.out.println("Cache contains 'b': " + cache.containsKey("b")); // Output: false + System.out.println("Cache contains 'a': " + cache.containsKey("a")); // Output: true + } +} +``` diff --git a/plugin/src/main/groovy/org/alpha4j/ds/LRUCache4j.java b/plugin/src/main/groovy/org/alpha4j/ds/LRUCache4j.java new file mode 100644 index 0000000..c3ea592 --- /dev/null +++ b/plugin/src/main/groovy/org/alpha4j/ds/LRUCache4j.java @@ -0,0 +1,443 @@ +package org.alpha4j.ds; + +import java.util.*; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; + +/** + * This class provides a thread-safe Least Recently Used (LRU) cache API that will evict the least recently used items, + * once a threshold is met. It implements the Map interface for convenience. It is thread-safe via usage of + * ReentrantReadWriteLock() around read and write APIs, including delegating to keySet(), entrySet(), and + * values() and each of their iterators. + */ +@SuppressWarnings({"EqualsWhichDoesntCheckParameterClass", "NullableProblems", "unchecked"}) +public class LRUCache4j implements Map { + // A ReadWriteLock to ensure thread-safe access to the cache + protected final transient ReadWriteLock lock = new ReentrantReadWriteLock(); + // The underlying cache implemented using a LinkedHashMap + protected final Map cache; + // A constant used to denote the absence of an entry + protected final static Object NO_ENTRY = new Object(); + + /** + * Constructor that initializes the LRU cache with a specified capacity. + * The LinkedHashMap is used to implement the cache with access order. + * + * @param capacity the maximum number of entries the cache can hold + */ + public LRUCache4j(int capacity) { + cache = new LinkedHashMap(capacity, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } + }; + } + + @Override + public boolean equals(Object obj) { + return readOperation(() -> cache.equals(obj)); + } + + @Override + public int hashCode() { + return readOperation(cache::hashCode); + } + + @Override + public String toString() { + return readOperation(cache::toString); + } + + @Override + public int size() { + return readOperation(cache::size); + } + + @Override + public boolean isEmpty() { + return readOperation(cache::isEmpty); + } + + @Override + public boolean containsKey(Object key) { + return readOperation(() -> cache.containsKey(key)); + } + + @Override + public boolean containsValue(Object value) { + return readOperation(() -> cache.containsValue(value)); + } + + @Override + public V get(Object key) { + return readOperation(() -> cache.get(key)); + } + + @Override + public V put(K key, V value) { + return writeOperation(() -> cache.put(key, value)); + } + + @Override + public void putAll(Map m) { + writeOperation(() -> { + cache.putAll(m); + return null; + }); + } + + @Override + public V putIfAbsent(K key, V value) { + return writeOperation(() -> cache.putIfAbsent(key, value)); + } + + @Override + public V remove(Object key) { + return writeOperation(() -> cache.remove(key)); + } + + @Override + public void clear() { + writeOperation(() -> { + cache.clear(); + return null; + }); + } + + @Override + public Set keySet() { + return readOperation(() -> new Set() { + @Override + public int size() { + return readOperation(cache::size); + } + + @Override + public boolean isEmpty() { + return readOperation(cache::isEmpty); + } + + @Override + public boolean contains(Object o) { + return readOperation(() -> cache.containsKey(o)); + } + + @Override + public boolean containsAll(Collection c) { + return readOperation(() -> cache.keySet().containsAll(c)); + } + + @Override + public Object[] toArray() { + return readOperation(() -> cache.keySet().toArray()); + } + + @Override + public T[] toArray(T[] a) { + return readOperation(() -> cache.keySet().toArray(a)); + } + + @Override + public Iterator iterator() { + return new Iterator() { + private final Iterator it = cache.keySet().iterator(); + private K current = (K) NO_ENTRY; + + @Override + public boolean hasNext() { + return readOperation(it::hasNext); + } + + @Override + public K next() { + return readOperation(() -> { + current = it.next(); + return current; + }); + } + + @Override + public void remove() { + writeOperation(() -> { + if (current == NO_ENTRY) { + throw new IllegalStateException("Next not called or key already removed"); + } + it.remove(); + current = (K) NO_ENTRY; + return null; + }); + } + }; + } + + @Override + public boolean add(K k) { + throw new UnsupportedOperationException("add() not supported on .keySet() of a Map"); + } + + @Override + public boolean remove(Object o) { + return writeOperation(() -> cache.remove(o) != null); + } + + @Override + public boolean addAll(Collection c) { + throw new UnsupportedOperationException("addAll() not supported on .keySet() of a Map"); + } + + @Override + public boolean retainAll(Collection c) { + return writeOperation(() -> cache.keySet().retainAll(c)); + } + + @Override + public boolean removeAll(Collection c) { + return writeOperation(() -> cache.keySet().removeAll(c)); + } + + @Override + public void clear() { + writeOperation(() -> { + cache.clear(); + return null; + }); + } + }); + } + + @Override + public Collection values() { + return readOperation(() -> new Collection() { + @Override + public int size() { + return readOperation(cache::size); + } + + @Override + public boolean isEmpty() { + return readOperation(cache::isEmpty); + } + + @Override + public boolean contains(Object o) { + return readOperation(() -> cache.containsValue(o)); + } + + @Override + public boolean containsAll(Collection c) { + return readOperation(() -> cache.values().containsAll(c)); + } + + @Override + public Object[] toArray() { + return readOperation(() -> cache.values().toArray()); + } + + @Override + public T[] toArray(T[] a) { + return readOperation(() -> cache.values().toArray(a)); + } + + @Override + public Iterator iterator() { + return new Iterator() { + private final Iterator it = cache.values().iterator(); + private V current = (V) NO_ENTRY; + + @Override + public boolean hasNext() { + return readOperation(it::hasNext); + } + + @Override + public V next() { + return readOperation(() -> { + current = it.next(); + return current; + }); + } + + @Override + public void remove() { + writeOperation(() -> { + if (current == NO_ENTRY) { + throw new IllegalStateException("Next not called or entry already removed"); + } + it.remove(); + current = (V) NO_ENTRY; + return null; + }); + } + }; + } + + @Override + public boolean add(V value) { + throw new UnsupportedOperationException("add() not supported on values() of a Map"); + } + + @Override + public boolean remove(Object o) { + return writeOperation(() -> cache.values().remove(o)); + } + + @Override + public boolean addAll(Collection c) { + throw new UnsupportedOperationException("addAll() not supported on values() of a Map"); + } + + @Override + public boolean removeAll(Collection c) { + return writeOperation(() -> cache.values().removeAll(c)); + } + + @Override + public boolean retainAll(Collection c) { + return writeOperation(() -> cache.values().retainAll(c)); + } + + @Override + public void clear() { + writeOperation(() -> { + cache.clear(); + return null; + }); + } + }); + } + + @Override + public Set> entrySet() { + return readOperation(() -> new Set>() { + @Override + public int size() { + return readOperation(cache::size); + } + + @Override + public boolean isEmpty() { + return readOperation(cache::isEmpty); + } + + @Override + public boolean contains(Object o) { + return readOperation(() -> cache.entrySet().contains(o)); + } + + @Override + public boolean containsAll(Collection c) { + return readOperation(() -> cache.entrySet().containsAll(c)); + } + + @Override + public Object[] toArray() { + return readOperation(() -> cache.entrySet().toArray()); + } + + @Override + public T[] toArray(T[] a) { + return readOperation(() -> cache.entrySet().toArray(a)); + } + + @Override + public Iterator> iterator() { + return new Iterator>() { + private final Iterator> it = cache.entrySet().iterator(); + private Map.Entry current = (Map.Entry) NO_ENTRY; + + @Override + public boolean hasNext() { + return readOperation(it::hasNext); + } + + @Override + public Map.Entry next() { + return readOperation(() -> { + current = it.next(); + return current; + }); + } + + @Override + public void remove() { + writeOperation(() -> { + if (current == NO_ENTRY) { + throw new IllegalStateException("Next not called or entry already removed"); + } + it.remove(); + current = (Map.Entry) NO_ENTRY; + return null; + }); + } + }; + } + + @Override + public boolean add(Map.Entry kvEntry) { + throw new UnsupportedOperationException("add() not supported on entrySet() of a Map"); + } + + @Override + public boolean remove(Object o) { + return writeOperation(() -> cache.entrySet().remove(o)); + } + + @Override + public boolean addAll(Collection> c) { + throw new UnsupportedOperationException("addAll() not supported on entrySet() of a Map"); + } + + @Override + public boolean retainAll(Collection c) { + return writeOperation(() -> cache.entrySet().retainAll(c)); + } + + @Override + public boolean removeAll(Collection c) { + return writeOperation(() -> cache.entrySet().removeAll(c)); + } + + @Override + public void clear() { + writeOperation(() -> { + cache.clear(); + return null; + }); + } + }); + } + + /** + * Executes a read operation with a read lock. + * + * @param the type of result expected from the operation + * @param operation the read operation to execute + * @return the result of the operation + */ + private T readOperation(Supplier operation) { + lock.readLock().lock(); + try { + return operation.get(); + } finally { + lock.readLock().unlock(); + } + } + + /** + * Executes a write operation with a write lock. + * + * @param the type of result expected from the operation + * @param operation the write operation to execute + * @return the result of the operation + */ + private T writeOperation(Supplier operation) { + lock.writeLock().lock(); + try { + return operation.get(); + } finally { + lock.writeLock().unlock(); + } + } +} \ No newline at end of file diff --git a/plugin/src/test/groovy/org/alpha4j/LRUCache4jTest.java b/plugin/src/test/groovy/org/alpha4j/LRUCache4jTest.java new file mode 100644 index 0000000..3344d1b --- /dev/null +++ b/plugin/src/test/groovy/org/alpha4j/LRUCache4jTest.java @@ -0,0 +1,187 @@ +package org.alpha4j; + +import org.alpha4j.ds.LRUCache4j; +import org.junit.Before; +import org.junit.Test; + +import java.security.SecureRandom; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +public class LRUCache4jTest { + protected LRUCache4j lruCache; + + @Before + public void setUp() { + lruCache = new LRUCache4j<>(3); + } + + @Test + public void testPutAndGet() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + lruCache.put(3, "C"); + + assertEquals("A", lruCache.get(1)); + assertEquals("B", lruCache.get(2)); + assertEquals("C", lruCache.get(3)); + } + + @Test + public void testEvictionPolicy() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + lruCache.put(3, "C"); + lruCache.get(1); + lruCache.put(4, "D"); + + assertNull(lruCache.get(2)); + assertEquals("A", lruCache.get(1)); + } + + @Test + public void testSize() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + + assertEquals(2, lruCache.size()); + } + + @Test + public void testIsEmpty() { + assertTrue(lruCache.isEmpty()); + + lruCache.put(1, "A"); + + assertFalse(lruCache.isEmpty()); + } + + @Test + public void testRemove() { + lruCache.put(1, "A"); + lruCache.remove(1); + + assertNull(lruCache.get(1)); + } + + @Test + public void testContainsKey() { + lruCache.put(1, "A"); + + assertTrue(lruCache.containsKey(1)); + assertFalse(lruCache.containsKey(2)); + } + + @Test + public void testContainsValue() { + lruCache.put(1, "A"); + + assertTrue(lruCache.containsValue("A")); + assertFalse(lruCache.containsValue("B")); + } + + @Test + public void testKeySet() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + + assertTrue(lruCache.keySet().contains(1)); + assertTrue(lruCache.keySet().contains(2)); + } + + @Test + public void testValues() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + + assertTrue(lruCache.values().contains("A")); + assertTrue(lruCache.values().contains("B")); + } + + @Test + public void testClear() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + lruCache.clear(); + + assertTrue(lruCache.isEmpty()); + } + + @Test + public void testPutAll() { + Map map = new LinkedHashMap<>(); + map.put(1, "A"); + map.put(2, "B"); + lruCache.putAll(map); + + assertEquals("A", lruCache.get(1)); + assertEquals("B", lruCache.get(2)); + } + + @Test + public void testEntrySet() { + lruCache.put(1, "A"); + lruCache.put(2, "B"); + + assertEquals(2, lruCache.entrySet().size()); + } + + @Test + public void testPutIfAbsent() { + lruCache.putIfAbsent(1, "A"); + lruCache.putIfAbsent(1, "B"); + + assertEquals("A", lruCache.get(1)); + } + + @Test + public void testConcurrency() throws InterruptedException { + ExecutorService service = Executors.newFixedThreadPool(3); + lruCache = new LRUCache4j<>(100000); + + // Perform a mix of put and get operations from multiple threads + int max = 10000; + int attempts = 0; + Random random = new SecureRandom(); + while (attempts++ < max) { + final int key = random.nextInt(max); + final String value = "V" + key; + + service.submit(() -> lruCache.put(key, value)); + service.submit(() -> lruCache.get(key)); + service.submit(() -> lruCache.size()); + service.submit(() -> { + lruCache.keySet().remove(random.nextInt(max)); + }); + service.submit(() -> { + lruCache.values().remove("V" + random.nextInt(max)); + }); + final int attemptsCopy = attempts; + service.submit(() -> { + Iterator i = lruCache.entrySet().iterator(); + int walk = random.nextInt(attemptsCopy); + while (i.hasNext() && walk-- > 0) { + i.next(); + } + int chunk = 10; + while (i.hasNext() && chunk-- > 0) { + i.remove(); + i.next(); + } + }); + service.submit(() -> lruCache.remove(random.nextInt(max))); + } + service.shutdown(); + assertTrue(service.awaitTermination(1, TimeUnit.MINUTES)); + System.out.println("lruCache = " + lruCache); + System.out.println("lruCache = " + lruCache.size()); + System.out.println("attempts =" + attempts); + } +} diff --git a/plugin/src/test/resources/log4j2-test.xml b/plugin/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000..280400c --- /dev/null +++ b/plugin/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file