From ad972292987cb57e9eb7e65bbd7e910eb5965e3f Mon Sep 17 00:00:00 2001 From: John DeRegnaucourt Date: Sun, 23 Jun 2024 12:25:37 -0400 Subject: [PATCH] lock based LRUCache added --- .../java/com/cedarsoftware/util/LRUCache.java | 311 ++++++++---------- .../com/cedarsoftware/util/LRUCache2.java | 285 ++++++++++++++++ 2 files changed, 419 insertions(+), 177 deletions(-) create mode 100644 src/main/java/com/cedarsoftware/util/LRUCache2.java diff --git a/src/main/java/com/cedarsoftware/util/LRUCache.java b/src/main/java/com/cedarsoftware/util/LRUCache.java index fca33a64..621f4e33 100644 --- a/src/main/java/com/cedarsoftware/util/LRUCache.java +++ b/src/main/java/com/cedarsoftware/util/LRUCache.java @@ -1,31 +1,17 @@ package com.cedarsoftware.util; import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; /** * 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. *

- * LRUCache is thread-safe via usage of ConcurrentHashMap for internal storage. The .get(), .remove(), and .put() APIs - * operate in O(1) without blocking. When .put() is called, a background cleanup task is schedule to ensure - * {@code cache.size <= capacity}. This maintains cache size to capacity, even during bursty loads. It is not immediate, - * the LRUCache can exceed the capacity during a rapid load, however, it will quickly reduce to max capacity. - *

* LRUCache supports null for key or value. *

* @author John DeRegnaucourt (jdereg@gmail.com) @@ -45,132 +31,127 @@ * limitations under the License. */ public class LRUCache extends AbstractMap implements Map { - private static final ScheduledExecutorService defaultScheduler = Executors.newScheduledThreadPool(1); private static final Object NULL_ITEM = new Object(); // Sentinel value for null keys and values - private final long cleanupDelayMillis; private final int capacity; - private final ConcurrentMap> cache; - private final AtomicBoolean cleanupScheduled = new AtomicBoolean(false); - private final ScheduledExecutorService scheduler; - private final ExecutorService cleanupExecutor; - private boolean isDefaultScheduler; - - private static class Node { - final K key; - volatile Object value; - volatile long timestamp; - - Node(K key, Object value) { + private final ConcurrentHashMap> cache; + private final Node head; + private final Node tail; + private final Lock lock = new ReentrantLock(); + + private static class Node { + K key; + V value; + Node prev; + Node next; + + Node(K key, V value) { this.key = key; this.value = value; - this.timestamp = System.nanoTime(); - } - - void updateTimestamp() { - this.timestamp = System.nanoTime(); } } - /** - * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the - * capacity, however, it will quickly reduce to that amount. This time is configurable and defaults to 10ms. - * @param capacity int maximum size for the LRU cache. - */ public LRUCache(int capacity) { - this(capacity, 10, defaultScheduler, ForkJoinPool.commonPool()); - isDefaultScheduler = true; + this.capacity = capacity; + this.cache = new ConcurrentHashMap<>(capacity); + this.head = new Node<>(null, null); + this.tail = new Node<>(null, null); + head.next = tail; + tail.prev = head; } - /** - * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the - * capacity, however, it will quickly reduce to that amount. This time is configurable via the cleanupDelay - * parameter. - * @param capacity int maximum size for the LRU cache. - * @param cleanupDelayMillis int milliseconds before scheduling a cleanup (reduction to capacity if the cache currently - * exceeds it). - */ - public LRUCache(int capacity, int cleanupDelayMillis) { - this(capacity, cleanupDelayMillis, defaultScheduler, ForkJoinPool.commonPool()); - isDefaultScheduler = true; + private void moveToHead(Node node) { + removeNode(node); + addToHead(node); } - /** - * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the - * capacity, however, it will quickly reduce to that amount. This time is configurable via the cleanupDelay - * parameter and custom scheduler and executor services. - * @param capacity int maximum size for the LRU cache. - * @param cleanupDelayMillis int milliseconds before scheduling a cleanup (reduction to capacity if the cache currently - * exceeds it). - * @param scheduler ScheduledExecutorService for scheduling cleanup tasks. - * @param cleanupExecutor ExecutorService for executing cleanup tasks. - */ - public LRUCache(int capacity, int cleanupDelayMillis, ScheduledExecutorService scheduler, ExecutorService cleanupExecutor) { - this.capacity = capacity; - this.cache = new ConcurrentHashMap<>(capacity); - this.cleanupDelayMillis = cleanupDelayMillis; - this.scheduler = scheduler; - this.cleanupExecutor = cleanupExecutor; - isDefaultScheduler = false; + private void addToHead(Node node) { + node.next = head.next; + node.next.prev = node; + head.next = node; + node.prev = head; } - @SuppressWarnings("unchecked") - private void cleanup() { - int size = cache.size(); - if (size > capacity) { - Node[] nodes = cache.values().toArray(new Node[0]); - Arrays.sort(nodes, Comparator.comparingLong(node -> node.timestamp)); - int nodesToRemove = size - capacity; - for (int i = 0; i < nodesToRemove; i++) { - Node node = nodes[i]; - cache.remove(toCacheItem(node.key), node); - } - } - cleanupScheduled.set(false); // Reset the flag after cleanup - // Check if another cleanup is needed after the current one - if (cache.size() > capacity) { - scheduleCleanup(); - } + private void removeNode(Node node) { + node.prev.next = node.next; + node.next.prev = node.prev; + } + + private Node removeTail() { + Node node = tail.prev; + removeNode(node); + return node; } @Override public V get(Object key) { Object cacheKey = toCacheItem(key); - Node node = cache.get(cacheKey); - if (node != null) { - node.updateTimestamp(); - return fromCacheItem(node.value); + Node node = cache.get(cacheKey); + if (node == null) { + return null; + } + if (lock.tryLock()) { + try { + moveToHead(node); + } finally { + lock.unlock(); + } } - return null; + return fromCacheItem(node.value); } + @SuppressWarnings("unchecked") @Override public V put(K key, V value) { Object cacheKey = toCacheItem(key); Object cacheValue = toCacheItem(value); - Node newNode = new Node<>(key, cacheValue); - Node oldNode = cache.put(cacheKey, newNode); - if (oldNode != null) { - newNode.updateTimestamp(); - return fromCacheItem(oldNode.value); - } else if (size() > capacity) { - scheduleCleanup(); + lock.lock(); + try { + Node node = cache.get(cacheKey); + if (node != null) { + node.value = (V)cacheValue; + moveToHead(node); + return fromCacheItem(node.value); + } else { + Node newNode = new Node<>(key, (V)cacheValue); + cache.put(cacheKey, newNode); + addToHead(newNode); + if (cache.size() > capacity) { + Node tail = removeTail(); + cache.remove(toCacheItem(tail.key)); + } + return null; + } + } finally { + lock.unlock(); } - return null; } @Override public V remove(Object key) { Object cacheKey = toCacheItem(key); - Node node = cache.remove(cacheKey); - if (node != null) { - return fromCacheItem(node.value); + lock.lock(); + try { + Node node = cache.remove(cacheKey); + if (node != null) { + removeNode(node); + return fromCacheItem(node.value); + } + return null; + } finally { + lock.unlock(); } - return null; } @Override public void clear() { - cache.clear(); + lock.lock(); + try { + head.next = tail; + tail.prev = head; + cache.clear(); + } finally { + lock.unlock(); + } } @Override @@ -186,100 +167,76 @@ public boolean containsKey(Object key) { @Override public boolean containsValue(Object value) { Object cacheValue = toCacheItem(value); - for (Node node : cache.values()) { - if (node.value.equals(cacheValue)) { - return true; + lock.lock(); + try { + for (Node node = head.next; node != tail; node = node.next) { + if (node.value.equals(cacheValue)) { + return true; + } } + return false; + } finally { + lock.unlock(); } - return false; } @Override public Set> entrySet() { - Set> entrySet = Collections.newSetFromMap(new ConcurrentHashMap<>()); - for (Node node : cache.values()) { - entrySet.add(new AbstractMap.SimpleEntry<>(fromCacheItem(node.key), fromCacheItem(node.value))); - } - return entrySet; - } - - @Override - public Set keySet() { - Set keySet = Collections.newSetFromMap(new ConcurrentHashMap<>()); - for (Node node : cache.values()) { - keySet.add(fromCacheItem(node.key)); + lock.lock(); + try { + Map map = new LinkedHashMap<>(); + for (Node node = head.next; node != tail; node = node.next) { + map.put(node.key, fromCacheItem(node.value)); + } + return map.entrySet(); + } finally { + lock.unlock(); } - return Collections.unmodifiableSet(keySet); } + @SuppressWarnings("unchecked") @Override - public Collection values() { - Collection values = new ArrayList<>(); - for (Node node : cache.values()) { - values.add(fromCacheItem(node.value)); + public String toString() { + lock.lock(); + try { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + for (Node node = head.next; node != tail; node = node.next) { + sb.append((K) fromCacheItem(node.key)).append("=").append((V) fromCacheItem(node.value)).append(", "); + } + if (sb.length() > 1) { + sb.setLength(sb.length() - 2); // Remove trailing comma and space + } + sb.append("}"); + return sb.toString(); + } finally { + lock.unlock(); } - return Collections.unmodifiableCollection(values); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Map)) return false; - Map other = (Map) o; - return entrySet().equals(other.entrySet()); } @Override public int hashCode() { - int hashCode = 1; - for (Node node : cache.values()) { - Object key = fromCacheItem(node.key); - Object value = fromCacheItem(node.value); - hashCode = 31 * hashCode + (key == null ? 0 : key.hashCode()); - hashCode = 31 * hashCode + (value == null ? 0 : value.hashCode()); - } - return hashCode; - } - - @Override - @SuppressWarnings("unchecked") - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("{"); - for (Node node : cache.values()) { - sb.append((K) fromCacheItem(node.key)).append("=").append((V) fromCacheItem(node.value)).append(", "); - } - if (sb.length() > 1) { - sb.setLength(sb.length() - 2); // Remove trailing comma and space - } - sb.append("}"); - return sb.toString(); - } - - // Schedule a delayed cleanup - private void scheduleCleanup() { - if (cleanupScheduled.compareAndSet(false, true)) { - scheduler.schedule(() -> cleanupExecutor.execute(this::cleanup), cleanupDelayMillis, TimeUnit.MILLISECONDS); + lock.lock(); + try { + int hashCode = 1; + for (Node node = head.next; node != tail; node = node.next) { + Object key = fromCacheItem(node.key); + Object value = fromCacheItem(node.value); + hashCode = 31 * hashCode + (key == null ? 0 : key.hashCode()); + hashCode = 31 * hashCode + (value == null ? 0 : value.hashCode()); + } + return hashCode; + } finally { + lock.unlock(); } } - // Converts a key or value to a cache-compatible item private Object toCacheItem(Object item) { return item == null ? NULL_ITEM : item; } - // Converts a cache-compatible item to the original key or value @SuppressWarnings("unchecked") private T fromCacheItem(Object cacheItem) { return cacheItem == NULL_ITEM ? null : (T) cacheItem; } - - /** - * Shut down the scheduler if it is the default one. - */ - public void shutdown() { - if (isDefaultScheduler) { - scheduler.shutdown(); - } - } -} \ No newline at end of file +} diff --git a/src/main/java/com/cedarsoftware/util/LRUCache2.java b/src/main/java/com/cedarsoftware/util/LRUCache2.java new file mode 100644 index 00000000..aae25336 --- /dev/null +++ b/src/main/java/com/cedarsoftware/util/LRUCache2.java @@ -0,0 +1,285 @@ +package com.cedarsoftware.util; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 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. + *

+ * LRUCache is thread-safe via usage of ConcurrentHashMap for internal storage. The .get(), .remove(), and .put() APIs + * operate in O(1) without blocking. When .put() is called, a background cleanup task is schedule to ensure + * {@code cache.size <= capacity}. This maintains cache size to capacity, even during bursty loads. It is not immediate, + * the LRUCache can exceed the capacity during a rapid load, however, it will quickly reduce to max capacity. + *

+ * LRUCache supports null for key or value. + *

+ * @author John DeRegnaucourt (jdereg@gmail.com) + *
+ * Copyright (c) Cedar Software LLC + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * License + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +public class LRUCache2 extends AbstractMap implements Map { + private static final ScheduledExecutorService defaultScheduler = Executors.newScheduledThreadPool(1); + private static final Object NULL_ITEM = new Object(); // Sentinel value for null keys and values + private final long cleanupDelayMillis; + private final int capacity; + private final ConcurrentMap> cache; + private final AtomicBoolean cleanupScheduled = new AtomicBoolean(false); + private final ScheduledExecutorService scheduler; + private final ExecutorService cleanupExecutor; + private boolean isDefaultScheduler; + + private static class Node { + final K key; + volatile Object value; + volatile long timestamp; + + Node(K key, Object value) { + this.key = key; + this.value = value; + this.timestamp = System.nanoTime(); + } + + void updateTimestamp() { + this.timestamp = System.nanoTime(); + } + } + + /** + * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the + * capacity, however, it will quickly reduce to that amount. This time is configurable and defaults to 10ms. + * @param capacity int maximum size for the LRU cache. + */ + public LRUCache2(int capacity) { + this(capacity, 10, defaultScheduler, ForkJoinPool.commonPool()); + isDefaultScheduler = true; + } + + /** + * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the + * capacity, however, it will quickly reduce to that amount. This time is configurable via the cleanupDelay + * parameter. + * @param capacity int maximum size for the LRU cache. + * @param cleanupDelayMillis int milliseconds before scheduling a cleanup (reduction to capacity if the cache currently + * exceeds it). + */ + public LRUCache2(int capacity, int cleanupDelayMillis) { + this(capacity, cleanupDelayMillis, defaultScheduler, ForkJoinPool.commonPool()); + isDefaultScheduler = true; + } + + /** + * Create a LRUCache with the maximum capacity of 'capacity.' Note, the LRUCache could temporarily exceed the + * capacity, however, it will quickly reduce to that amount. This time is configurable via the cleanupDelay + * parameter and custom scheduler and executor services. + * @param capacity int maximum size for the LRU cache. + * @param cleanupDelayMillis int milliseconds before scheduling a cleanup (reduction to capacity if the cache currently + * exceeds it). + * @param scheduler ScheduledExecutorService for scheduling cleanup tasks. + * @param cleanupExecutor ExecutorService for executing cleanup tasks. + */ + public LRUCache2(int capacity, int cleanupDelayMillis, ScheduledExecutorService scheduler, ExecutorService cleanupExecutor) { + this.capacity = capacity; + this.cache = new ConcurrentHashMap<>(capacity); + this.cleanupDelayMillis = cleanupDelayMillis; + this.scheduler = scheduler; + this.cleanupExecutor = cleanupExecutor; + isDefaultScheduler = false; + } + + @SuppressWarnings("unchecked") + private void cleanup() { + int size = cache.size(); + if (size > capacity) { + Node[] nodes = cache.values().toArray(new Node[0]); + Arrays.sort(nodes, Comparator.comparingLong(node -> node.timestamp)); + int nodesToRemove = size - capacity; + for (int i = 0; i < nodesToRemove; i++) { + Node node = nodes[i]; + cache.remove(toCacheItem(node.key), node); + } + } + cleanupScheduled.set(false); // Reset the flag after cleanup + // Check if another cleanup is needed after the current one + if (cache.size() > capacity) { + scheduleCleanup(); + } + } + + @Override + public V get(Object key) { + Object cacheKey = toCacheItem(key); + Node node = cache.get(cacheKey); + if (node != null) { + node.updateTimestamp(); + return fromCacheItem(node.value); + } + return null; + } + + @Override + public V put(K key, V value) { + Object cacheKey = toCacheItem(key); + Object cacheValue = toCacheItem(value); + Node newNode = new Node<>(key, cacheValue); + Node oldNode = cache.put(cacheKey, newNode); + if (oldNode != null) { + newNode.updateTimestamp(); + return fromCacheItem(oldNode.value); + } else if (size() > capacity) { + scheduleCleanup(); + } + return null; + } + + @Override + public V remove(Object key) { + Object cacheKey = toCacheItem(key); + Node node = cache.remove(cacheKey); + if (node != null) { + return fromCacheItem(node.value); + } + return null; + } + + @Override + public void clear() { + cache.clear(); + } + + @Override + public int size() { + return cache.size(); + } + + @Override + public boolean containsKey(Object key) { + return cache.containsKey(toCacheItem(key)); + } + + @Override + public boolean containsValue(Object value) { + Object cacheValue = toCacheItem(value); + for (Node node : cache.values()) { + if (node.value.equals(cacheValue)) { + return true; + } + } + return false; + } + + @Override + public Set> entrySet() { + Set> entrySet = Collections.newSetFromMap(new ConcurrentHashMap<>()); + for (Node node : cache.values()) { + entrySet.add(new AbstractMap.SimpleEntry<>(fromCacheItem(node.key), fromCacheItem(node.value))); + } + return entrySet; + } + + @Override + public Set keySet() { + Set keySet = Collections.newSetFromMap(new ConcurrentHashMap<>()); + for (Node node : cache.values()) { + keySet.add(fromCacheItem(node.key)); + } + return Collections.unmodifiableSet(keySet); + } + + @Override + public Collection values() { + Collection values = new ArrayList<>(); + for (Node node : cache.values()) { + values.add(fromCacheItem(node.value)); + } + return Collections.unmodifiableCollection(values); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Map)) return false; + Map other = (Map) o; + return entrySet().equals(other.entrySet()); + } + + @Override + public int hashCode() { + int hashCode = 1; + for (Node node : cache.values()) { + Object key = fromCacheItem(node.key); + Object value = fromCacheItem(node.value); + hashCode = 31 * hashCode + (key == null ? 0 : key.hashCode()); + hashCode = 31 * hashCode + (value == null ? 0 : value.hashCode()); + } + return hashCode; + } + + @Override + @SuppressWarnings("unchecked") + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + for (Node node : cache.values()) { + sb.append((K) fromCacheItem(node.key)).append("=").append((V) fromCacheItem(node.value)).append(", "); + } + if (sb.length() > 1) { + sb.setLength(sb.length() - 2); // Remove trailing comma and space + } + sb.append("}"); + return sb.toString(); + } + + // Schedule a delayed cleanup + private void scheduleCleanup() { + if (cleanupScheduled.compareAndSet(false, true)) { + scheduler.schedule(() -> cleanupExecutor.execute(this::cleanup), cleanupDelayMillis, TimeUnit.MILLISECONDS); + } + } + + // Converts a key or value to a cache-compatible item + private Object toCacheItem(Object item) { + return item == null ? NULL_ITEM : item; + } + + // Converts a cache-compatible item to the original key or value + @SuppressWarnings("unchecked") + private T fromCacheItem(Object cacheItem) { + return cacheItem == NULL_ITEM ? null : (T) cacheItem; + } + + /** + * Shut down the scheduler if it is the default one. + */ + public void shutdown() { + if (isDefaultScheduler) { + scheduler.shutdown(); + } + } +} \ No newline at end of file