diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/EntryLoader.java b/src/main/java/com/io/yutian/aulib/expiringmap/EntryLoader.java new file mode 100644 index 0000000..ebfa534 --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/EntryLoader.java @@ -0,0 +1,17 @@ +package com.io.yutian.aulib.expiringmap; + +/** + * Loads entries on demand. + * + * @param Key type + * @param Value type + */ +public interface EntryLoader { + /** + * Called to load a new value for the {@code key} into an expiring map. + * + * @param key to load a value for + * @return new value to load + */ + V load(K key); +} \ No newline at end of file diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationListener.java b/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationListener.java new file mode 100644 index 0000000..a79dc03 --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationListener.java @@ -0,0 +1,17 @@ +package com.io.yutian.aulib.expiringmap; + +/** + * A listener for expired object events. + * + * @param Key type + * @param Value type + */ +public interface ExpirationListener { + /** + * Called when a map entry expires. + * + * @param key Expired key + * @param value Expired value + */ + void expired(K key, V value); +} \ No newline at end of file diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationPolicy.java b/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationPolicy.java new file mode 100644 index 0000000..8f78cf2 --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/ExpirationPolicy.java @@ -0,0 +1,11 @@ +package com.io.yutian.aulib.expiringmap; + +/** + * Determines how ExpiringMap entries should be expired. + */ +public enum ExpirationPolicy { + /** Expires entries based on when they were last accessed */ + ACCESSED, + /** Expires entries based on when they were created */ + CREATED; +} \ No newline at end of file diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringEntryLoader.java b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringEntryLoader.java new file mode 100644 index 0000000..07ded5c --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringEntryLoader.java @@ -0,0 +1,17 @@ +package com.io.yutian.aulib.expiringmap; + +/** + * Loads entries on demand, with control over each value's expiry duration (i.e. variable expiration). + * + * @param Key type + * @param Value type + */ +public interface ExpiringEntryLoader { + /** + * Called to load a new value for the {@code key} into an expiring map. + * + * @param key to load a value for + * @return contains new value to load along with its expiry duration + */ + ExpiringValue load(K key); +} \ No newline at end of file diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringMap.java b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringMap.java new file mode 100644 index 0000000..43d87ee --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringMap.java @@ -0,0 +1,1422 @@ +package com.io.yutian.aulib.expiringmap; + +import java.lang.ref.WeakReference; +import java.util.AbstractCollection; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.SortedSet; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import com.io.yutian.aulib.expiringmap.internal.Assert; +import com.io.yutian.aulib.expiringmap.internal.NamedThreadFactory; + +/** + * A thread-safe map that expires entries. Optional features include expiration policies, variable entry expiration, + * lazy entry loading, and expiration listeners. + * + *

+ * Entries are tracked by expiration time and expired by a single thread. + * + *

+ * Expiration listeners are called synchronously as entries are expired and block write operations to the map until they + * completed. Asynchronous expiration listeners are called on a separate thread pool and do not block map operations. + * + *

+ * When variable expiration is disabled (default), put/remove operations have a time complexity O(1). When + * variable expiration is enabled, put/remove operations have time complexity of O(log n). + * + *

+ * Example usages: + * + *

+ * {@code
+ * Map map = ExpiringMap.create();
+ * Map map = ExpiringMap.builder().expiration(30, TimeUnit.SECONDS).build();
+ * Map map = ExpiringMap.builder()
+ *   .expiration(10, TimeUnit.MINUTES)
+ *   .entryLoader(new EntryLoader() {
+ *     public Connection load(String address) {
+ *       return new Connection(address);
+ *     }
+ *   })
+ *   .expirationListener(new ExpirationListener() {
+ *     public void expired(String key, Connection connection) {
+ *       connection.close();
+ *     }
+ *   })
+ *   .build();
+ * }
+ * 
+ * + * @author Jonathan Halterman + * @param Key type + * @param Value type + */ +public class ExpiringMap implements ConcurrentMap { + static volatile ScheduledExecutorService EXPIRER; + static volatile ThreadPoolExecutor LISTENER_SERVICE; + static ThreadFactory THREAD_FACTORY; + + List> expirationListeners; + List> asyncExpirationListeners; + private AtomicLong expirationNanos; + private int maxSize; + private final AtomicReference expirationPolicy; + private final EntryLoader entryLoader; + private final ExpiringEntryLoader expiringEntryLoader; + private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + private final Lock readLock = readWriteLock.readLock(); + private final Lock writeLock = readWriteLock.writeLock(); + /** Guarded by "readWriteLock" */ + private final EntryMap entries; + private final boolean variableExpiration; + + /** + * Sets the {@link ThreadFactory} that is used to create expiration and listener callback threads for all ExpiringMap + * instances. + * + * @param threadFactory + * @throws NullPointerException if {@code threadFactory} is null + */ + public static void setThreadFactory(ThreadFactory threadFactory) { + THREAD_FACTORY = Assert.notNull(threadFactory, "threadFactory"); + } + + /** + * Creates a new instance of ExpiringMap. + * + * @param builder The map builder + */ + private ExpiringMap(final Builder builder) { + if (EXPIRER == null) { + synchronized (ExpiringMap.class) { + if (EXPIRER == null) { + EXPIRER = Executors.newSingleThreadScheduledExecutor( + THREAD_FACTORY == null ? new NamedThreadFactory("ExpiringMap-Expirer") : THREAD_FACTORY); + } + } + } + + if (LISTENER_SERVICE == null && builder.asyncExpirationListeners != null) + initListenerService(); + + variableExpiration = builder.variableExpiration; + entries = variableExpiration ? new EntryTreeHashMap() : new EntryLinkedHashMap(); + if (builder.expirationListeners != null) + expirationListeners = new CopyOnWriteArrayList>(builder.expirationListeners); + if (builder.asyncExpirationListeners != null) + asyncExpirationListeners = new CopyOnWriteArrayList>(builder.asyncExpirationListeners); + expirationPolicy = new AtomicReference(builder.expirationPolicy); + expirationNanos = new AtomicLong(TimeUnit.NANOSECONDS.convert(builder.duration, builder.timeUnit)); + maxSize = builder.maxSize; + entryLoader = builder.entryLoader; + expiringEntryLoader = builder.expiringEntryLoader; + } + + /** + * Builds ExpiringMap instances. Defaults to ExpirationPolicy.CREATED, expiration of 60 TimeUnit.SECONDS and + * a maxSize of Integer.MAX_VALUE. + */ + public static final class Builder { + private ExpirationPolicy expirationPolicy = ExpirationPolicy.CREATED; + private List> expirationListeners; + private List> asyncExpirationListeners; + private TimeUnit timeUnit = TimeUnit.SECONDS; + private boolean variableExpiration; + private long duration = 60; + private int maxSize = Integer.MAX_VALUE; + private EntryLoader entryLoader; + private ExpiringEntryLoader expiringEntryLoader; + + /** + * Creates a new Builder object. + */ + private Builder() { + } + + /** + * Builds and returns an expiring map. + * + * @param Key type + * @param Value type + */ + @SuppressWarnings("unchecked") + public ExpiringMap build() { + return new ExpiringMap((Builder) this); + } + + /** + * Sets the default map entry expiration. + * + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @throws NullPointerException if {@code timeUnit} is null + */ + public Builder expiration(long duration, TimeUnit timeUnit) { + this.duration = duration; + this.timeUnit = Assert.notNull(timeUnit, "timeUnit"); + return this; + } + + /** + * Sets the maximum size of the map. Once this size has been reached, adding an additional entry will expire the + * first entry in line for expiration based on the expiration policy. + * + * @param maxSize The maximum size of the map. + */ + public Builder maxSize(int maxSize) { + Assert.operation(maxSize > 0, "maxSize"); + this.maxSize = maxSize; + return this; + } + + /** + * Sets the EntryLoader to use when loading entries. Either an EntryLoader or ExpiringEntryLoader may be set, not + * both. + * + * @param loader to set + * @throws NullPointerException if {@code loader} is null + * @throws IllegalStateException if an {@link #expiringEntryLoader(ExpiringEntryLoader) ExpiringEntryLoader} is set + */ + @SuppressWarnings("unchecked") + public Builder entryLoader(EntryLoader loader) { + assertNoLoaderSet(); + entryLoader = (EntryLoader) Assert.notNull(loader, "loader"); + return (Builder) this; + } + + /** + * Sets the ExpiringEntryLoader to use when loading entries and configures {@link #variableExpiration() variable + * expiration}. Either an EntryLoader or ExpiringEntryLoader may be set, not both. + * + * @param loader to set + * @throws NullPointerException if {@code loader} is null + * @throws IllegalStateException if an {@link #entryLoader(EntryLoader) EntryLoader} is set + */ + @SuppressWarnings("unchecked") + public Builder expiringEntryLoader( + ExpiringEntryLoader loader) { + assertNoLoaderSet(); + expiringEntryLoader = (ExpiringEntryLoader) Assert.notNull(loader, "loader"); + variableExpiration(); + return (Builder) this; + } + + /** + * Configures the expiration listener that will receive notifications upon each map entry's expiration. + * Notifications are delivered synchronously and block map write operations. + * + * @param listener to set + * @throws NullPointerException if {@code listener} is null + */ + @SuppressWarnings("unchecked") + public Builder expirationListener( + ExpirationListener listener) { + Assert.notNull(listener, "listener"); + if (expirationListeners == null) + expirationListeners = new ArrayList>(); + expirationListeners.add((ExpirationListener) listener); + return (Builder) this; + } + + /** + * Configures the expiration listeners which will receive notifications upon each map entry's expiration. + * Notifications are delivered synchronously and block map write operations. + * + * @param listeners to set + * @throws NullPointerException if {@code listener} is null + */ + @SuppressWarnings("unchecked") + public Builder expirationListeners( + List> listeners) { + Assert.notNull(listeners, "listeners"); + if (expirationListeners == null) + expirationListeners = new ArrayList>(listeners.size()); + for (ExpirationListener listener : listeners) + expirationListeners.add((ExpirationListener) listener); + return (Builder) this; + } + + /** + * Configures the expiration listener which will receive asynchronous notifications upon each map entry's + * expiration. + * + * @param listener to set + * @throws NullPointerException if {@code listener} is null + */ + @SuppressWarnings("unchecked") + public Builder asyncExpirationListener( + ExpirationListener listener) { + Assert.notNull(listener, "listener"); + if (asyncExpirationListeners == null) + asyncExpirationListeners = new ArrayList>(); + asyncExpirationListeners.add((ExpirationListener) listener); + return (Builder) this; + } + + /** + * Configures the expiration listeners which will receive asynchronous notifications upon each map entry's + * expiration. + * + * @param listeners to set + * @throws NullPointerException if {@code listener} is null + */ + @SuppressWarnings("unchecked") + public Builder asyncExpirationListeners( + List> listeners) { + Assert.notNull(listeners, "listeners"); + if (asyncExpirationListeners == null) + asyncExpirationListeners = new ArrayList>(listeners.size()); + for (ExpirationListener listener : listeners) + asyncExpirationListeners.add((ExpirationListener) listener); + return (Builder) this; + } + + /** + * Configures the map entry expiration policy. + * + * @param expirationPolicy + * @throws NullPointerException if {@code expirationPolicy} is null + */ + public Builder expirationPolicy(ExpirationPolicy expirationPolicy) { + this.expirationPolicy = Assert.notNull(expirationPolicy, "expirationPolicy"); + return this; + } + + /** + * Allows for map entries to have individual expirations and for expirations to be changed. + */ + public Builder variableExpiration() { + variableExpiration = true; + return this; + } + + private void assertNoLoaderSet() { + Assert.state(entryLoader == null && expiringEntryLoader == null, + "Either entryLoader or expiringEntryLoader may be set, not both"); + } + } + + /** Entry map definition. */ + private interface EntryMap extends Map> { + /** Returns the first entry in the map or null if the map is empty. */ + ExpiringEntry first(); + + /** + * Reorders the given entry in the map. + * + * @param entry to reorder + */ + void reorder(ExpiringEntry entry); + + /** Returns a values iterator. */ + Iterator> valuesIterator(); + } + + /** Entry LinkedHashMap implementation. */ + private static class EntryLinkedHashMap extends LinkedHashMap> + implements EntryMap { + private static final long serialVersionUID = 1L; + + @Override + public boolean containsValue(Object value) { + for (ExpiringEntry entry : values()) { + V v = entry.value; + if (v == value || (value != null && value.equals(v))) + return true; + } + return false; + } + + @Override + public ExpiringEntry first() { + return isEmpty() ? null : values().iterator().next(); + } + + @Override + public void reorder(ExpiringEntry value) { + remove(value.key); + value.resetExpiration(); + put(value.key, value); + } + + @Override + public Iterator> valuesIterator() { + return values().iterator(); + } + + abstract class AbstractHashIterator { + private final Iterator>> iterator; + private ExpiringEntry next; + + @SuppressWarnings({"unchecked", "rawtypes"}) + AbstractHashIterator() { + iterator = (Iterator) Arrays.asList(entrySet().toArray(new Map.Entry[0])).iterator(); + } + + public boolean hasNext() { + return iterator.hasNext(); + } + + public ExpiringEntry getNext() { + next = iterator.next().getValue(); + return next; + } + + public void remove() { + iterator.remove(); + } + } + + final class KeyIterator extends AbstractHashIterator implements Iterator { + public K next() { + return getNext().key; + } + } + + final class ValueIterator extends AbstractHashIterator implements Iterator { + public V next() { + return getNext().value; + } + } + + public final class EntryIterator extends AbstractHashIterator implements Iterator> { + public Map.Entry next() { + return mapEntryFor(getNext()); + } + } + } + + /** Entry TreeHashMap implementation for variable expiration ExpiringMap entries. */ + private static class EntryTreeHashMap extends HashMap> implements EntryMap { + private static final long serialVersionUID = 1L; + SortedSet> sortedSet = new ConcurrentSkipListSet>(); + + @Override + public void clear() { + super.clear(); + sortedSet.clear(); + } + + @Override + public boolean containsValue(Object value) { + for (ExpiringEntry entry : values()) { + V v = entry.value; + if (v == value || (value != null && value.equals(v))) + return true; + } + return false; + } + + @Override + public ExpiringEntry first() { + return sortedSet.isEmpty() ? null : sortedSet.first(); + } + + @Override + public ExpiringEntry put(K key, ExpiringEntry value) { + sortedSet.add(value); + return super.put(key, value); + } + + @Override + public ExpiringEntry remove(Object key) { + ExpiringEntry entry = super.remove(key); + if (entry != null) + sortedSet.remove(entry); + return entry; + } + + @Override + public void reorder(ExpiringEntry value) { + sortedSet.remove(value); + value.resetExpiration(); + sortedSet.add(value); + } + + @Override + public Iterator> valuesIterator() { + return new ExpiringEntryIterator(); + } + + abstract class AbstractHashIterator { + private final Iterator> iterator = sortedSet.iterator(); + protected ExpiringEntry next; + + public boolean hasNext() { + return iterator.hasNext(); + } + + public ExpiringEntry getNext() { + next = iterator.next(); + return next; + } + + public void remove() { + EntryTreeHashMap.super.remove(next.key); + iterator.remove(); + } + } + + final class ExpiringEntryIterator extends AbstractHashIterator implements Iterator> { + public final ExpiringEntry next() { + return getNext(); + } + } + + final class KeyIterator extends AbstractHashIterator implements Iterator { + public final K next() { + return getNext().key; + } + } + + final class ValueIterator extends AbstractHashIterator implements Iterator { + public final V next() { + return getNext().value; + } + } + + final class EntryIterator extends AbstractHashIterator implements Iterator> { + public final Entry next() { + return mapEntryFor(getNext()); + } + } + } + + /** Expiring map entry implementation. */ + static class ExpiringEntry implements Comparable> { + final AtomicLong expirationNanos; + /** Epoch time at which the entry is expected to expire */ + final AtomicLong expectedExpiration; + final AtomicReference expirationPolicy; + final K key; + /** Guarded by "this" */ + volatile Future entryFuture; + /** Guarded by "this" */ + V value; + /** Guarded by "this" */ + volatile boolean scheduled; + + /** + * Creates a new ExpiringEntry object. + * + * @param key for the entry + * @param value for the entry + * @param expirationPolicy for the entry + * @param expirationNanos for the entry + */ + ExpiringEntry(K key, V value, AtomicReference expirationPolicy, AtomicLong expirationNanos) { + this.key = key; + this.value = value; + this.expirationPolicy = expirationPolicy; + this.expirationNanos = expirationNanos; + this.expectedExpiration = new AtomicLong(); + resetExpiration(); + } + + @Override + public int compareTo(ExpiringEntry other) { + if (key.equals(other.key)) + return 0; + return expectedExpiration.get() < other.expectedExpiration.get() ? -1 : 1; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((key == null) ? 0 : key.hashCode()); + result = prime * result + ((value == null) ? 0 : value.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + ExpiringEntry other = (ExpiringEntry) obj; + if (!key.equals(other.key)) + return false; + if (value == null) { + if (other.value != null) + return false; + } else if (!value.equals(other.value)) + return false; + return true; + } + + @Override + public String toString() { + return value.toString(); + } + + /** + * Marks the entry as canceled. + * + * @return true if the entry was scheduled + */ + synchronized boolean cancel() { + boolean result = scheduled; + if (entryFuture != null) + entryFuture.cancel(false); + + entryFuture = null; + scheduled = false; + return result; + } + + /** Gets the entry value. */ + synchronized V getValue() { + return value; + } + + /** Resets the entry's expected expiration. */ + void resetExpiration() { + expectedExpiration.set(expirationNanos.get() + System.nanoTime()); + } + + /** Marks the entry as scheduled. */ + synchronized void schedule(Future entryFuture) { + this.entryFuture = entryFuture; + scheduled = true; + } + + /** Sets the entry value. */ + synchronized void setValue(V value) { + this.value = value; + } + } + + /** + * Creates an ExpiringMap builder. + * + * @return New ExpiringMap builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new instance of ExpiringMap with ExpirationPolicy.CREATED and an expiration of 60 seconds. + */ + @SuppressWarnings("unchecked") + public static ExpiringMap create() { + return new ExpiringMap((Builder) ExpiringMap.builder()); + } + + /** + * Adds an expiration listener. + * + * @param listener to add + * @throws NullPointerException if {@code listener} is null + */ + public synchronized void addExpirationListener(ExpirationListener listener) { + Assert.notNull(listener, "listener"); + if (expirationListeners == null) + expirationListeners = new CopyOnWriteArrayList>(); + expirationListeners.add(listener); + } + + /** + * Adds an asynchronous expiration listener. + * + * @param listener to add + * @throws NullPointerException if {@code listener} is null + */ + public synchronized void addAsyncExpirationListener(ExpirationListener listener) { + Assert.notNull(listener, "listener"); + if (asyncExpirationListeners == null) + asyncExpirationListeners = new CopyOnWriteArrayList>(); + asyncExpirationListeners.add(listener); + // If asyncListener was not added on Builder, LISTENER_SERVICE was not initialized and remain null + if (LISTENER_SERVICE == null) + initListenerService(); + } + + @Override + public void clear() { + writeLock.lock(); + try { + for (ExpiringEntry entry : entries.values()) + entry.cancel(); + entries.clear(); + } finally { + writeLock.unlock(); + } + } + + @Override + public boolean containsKey(Object key) { + readLock.lock(); + try { + return entries.containsKey(key); + } finally { + readLock.unlock(); + } + } + + @Override + public boolean containsValue(Object value) { + readLock.lock(); + try { + return entries.containsValue(value); + } finally { + readLock.unlock(); + } + } + + /** + * Returns a copy of the map's entries, which can be iterated over safely by multiple threads. + * + * @return Copied set of map entries. + */ + @Override + public Set> entrySet() { + return new AbstractSet>() { + @Override + public void clear() { + ExpiringMap.this.clear(); + } + + @Override + public boolean contains(Object entry) { + if (!(entry instanceof Map.Entry)) + return false; + Entry e = (Entry) entry; + return containsKey(e.getKey()); + } + + @Override + public Iterator> iterator() { + return (entries instanceof EntryLinkedHashMap) ? ((EntryLinkedHashMap) entries).new EntryIterator() + : ((EntryTreeHashMap) entries).new EntryIterator(); + } + + @Override + public boolean remove(Object entry) { + if (entry instanceof Map.Entry) { + Entry e = (Entry) entry; + return ExpiringMap.this.remove(e.getKey()) != null; + } + return false; + } + + @Override + public int size() { + return ExpiringMap.this.size(); + } + }; + } + + @Override + public boolean equals(Object obj) { + readLock.lock(); + try { + return entries.equals(obj); + } finally { + readLock.unlock(); + } + } + + @Override + @SuppressWarnings("unchecked") + public V get(Object key) { + ExpiringEntry entry = getEntry(key); + + if (entry == null) { + return load((K) key); + } else if (ExpirationPolicy.ACCESSED.equals(entry.expirationPolicy.get())) + resetEntry(entry, false); + + return entry.getValue(); + } + + private V load(K key) { + if (entryLoader == null && expiringEntryLoader == null) + return null; + + writeLock.lock(); + try { + // Double check for entry + ExpiringEntry entry = getEntry(key); + if (entry != null) + return entry.getValue(); + + if (entryLoader != null) { + V value = entryLoader.load(key); + put(key, value); + return value; + } else { + ExpiringValue expiringValue = expiringEntryLoader.load(key); + if (expiringValue == null) { + put(key, null); + return null; + } else { + long duration = expiringValue.getTimeUnit() == null ? expirationNanos.get() : expiringValue.getDuration(); + TimeUnit timeUnit = expiringValue.getTimeUnit() == null ? TimeUnit.NANOSECONDS : expiringValue.getTimeUnit(); + put(key, expiringValue.getValue(), expiringValue.getExpirationPolicy() == null ? expirationPolicy.get() + : expiringValue.getExpirationPolicy(), duration, timeUnit); + return expiringValue.getValue(); + } + } + } finally { + writeLock.unlock(); + } + } + + /** + * Returns the map's default expiration duration in milliseconds. + * + * @return The expiration duration (milliseconds) + */ + public long getExpiration() { + return TimeUnit.NANOSECONDS.toMillis(expirationNanos.get()); + } + + /** + * Gets the expiration duration in milliseconds for the entry corresponding to the given key. + * + * @param key + * @return The expiration duration in milliseconds + * @throws NullPointerException if {@code key} is null + * @throws NoSuchElementException If no entry exists for the given key + */ + public long getExpiration(K key) { + Assert.notNull(key, "key"); + ExpiringEntry entry = getEntry(key); + Assert.element(entry, key); + return TimeUnit.NANOSECONDS.toMillis(entry.expirationNanos.get()); + } + + /** + * Gets the ExpirationPolicy for the entry corresponding to the given {@code key}. + * + * @param key + * @return The ExpirationPolicy for the {@code key} + * @throws NullPointerException if {@code key} is null + * @throws NoSuchElementException If no entry exists for the given key + */ + public ExpirationPolicy getExpirationPolicy(K key) { + Assert.notNull(key, "key"); + ExpiringEntry entry = getEntry(key); + Assert.element(entry, key); + return entry.expirationPolicy.get(); + } + + /** + * Gets the expected expiration, in milliseconds from the current time, for the entry corresponding to the given + * {@code key}. + * + * @param key + * @return The expiration duration in milliseconds + * @throws NullPointerException if {@code key} is null + * @throws NoSuchElementException If no entry exists for the given key + */ + public long getExpectedExpiration(K key) { + Assert.notNull(key, "key"); + ExpiringEntry entry = getEntry(key); + Assert.element(entry, key); + return TimeUnit.NANOSECONDS.toMillis(entry.expectedExpiration.get() - System.nanoTime()); + } + + /** + * Gets the maximum size of the map. Once this size has been reached, adding an additional entry will expire the + * first entry in line for expiration based on the expiration policy. + * + * @return The maximum size of the map. + */ + public int getMaxSize() { + return maxSize; + } + + @Override + public int hashCode() { + readLock.lock(); + try { + return entries.hashCode(); + } finally { + readLock.unlock(); + } + } + + @Override + public boolean isEmpty() { + readLock.lock(); + try { + return entries.isEmpty(); + } finally { + readLock.unlock(); + } + } + + /** + * Returns a copy of the map's keys, which can be iterated over safely by multiple threads. + * + * @return Copied set of map keys. + */ + @Override + public Set keySet() { + return new AbstractSet() { + @Override + public void clear() { + ExpiringMap.this.clear(); + } + + @Override + public boolean contains(Object key) { + return containsKey(key); + } + + @Override + public Iterator iterator() { + readLock.lock(); + try { + return (entries instanceof EntryLinkedHashMap) ? ((EntryLinkedHashMap) entries).new KeyIterator() + : ((EntryTreeHashMap) entries).new KeyIterator(); + } finally { + readLock.unlock(); + } + } + + @Override + public boolean remove(Object value) { + return ExpiringMap.this.remove(value) != null; + } + + @Override + public int size() { + return ExpiringMap.this.size(); + } + }; + } + + /** + * Puts {@code value} in the map for {@code key}. Resets the entry's expiration unless an entry already exists for the + * same {@code key} and {@code value}. + * + * @param key to put value for + * @param value to put for key + * @return the old value + * @throws NullPointerException if {@code key} is null + */ + @Override + public V put(K key, V value) { + Assert.notNull(key, "key"); + return putInternal(key, value, expirationPolicy.get(), expirationNanos.get()); + } + + /** + * @see #put(Object, Object, ExpirationPolicy, long, TimeUnit) + */ + public V put(K key, V value, ExpirationPolicy expirationPolicy) { + return put(key, value, expirationPolicy, expirationNanos.get(), TimeUnit.NANOSECONDS); + } + + /** + * @see #put(Object, Object, ExpirationPolicy, long, TimeUnit) + */ + public V put(K key, V value, long duration, TimeUnit timeUnit) { + return put(key, value, expirationPolicy.get(), duration, timeUnit); + } + + /** + * Puts {@code value} in the map for {@code key}. Resets the entry's expiration unless an entry already exists for the + * same {@code key} and {@code value}. Requires that variable expiration be enabled. + * + * @param key Key to put value for + * @param value Value to put for key + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @return the old value + * @throws UnsupportedOperationException If variable expiration is not enabled + * @throws NullPointerException if {@code key}, {@code expirationPolicy} or {@code timeUnit} are null + */ + public V put(K key, V value, ExpirationPolicy expirationPolicy, long duration, TimeUnit timeUnit) { + Assert.notNull(key, "key"); + Assert.notNull(expirationPolicy, "expirationPolicy"); + Assert.notNull(timeUnit, "timeUnit"); + Assert.operation(variableExpiration, "Variable expiration is not enabled"); + return putInternal(key, value, expirationPolicy, TimeUnit.NANOSECONDS.convert(duration, timeUnit)); + } + + @Override + public void putAll(Map map) { + Assert.notNull(map, "map"); + long expiration = expirationNanos.get(); + ExpirationPolicy expirationPolicy = this.expirationPolicy.get(); + writeLock.lock(); + try { + for (Entry entry : map.entrySet()) + putInternal(entry.getKey(), entry.getValue(), expirationPolicy, expiration); + } finally { + writeLock.unlock(); + } + } + + @Override + public V putIfAbsent(K key, V value) { + Assert.notNull(key, "key"); + writeLock.lock(); + try { + if (!entries.containsKey(key)) + return putInternal(key, value, expirationPolicy.get(), expirationNanos.get()); + else + return entries.get(key).getValue(); + } finally { + writeLock.unlock(); + } + } + + @Override + public V remove(Object key) { + Assert.notNull(key, "key"); + writeLock.lock(); + try { + ExpiringEntry entry = entries.remove(key); + if (entry == null) + return null; + if (entry.cancel()) + scheduleEntry(entries.first()); + return entry.getValue(); + } finally { + writeLock.unlock(); + } + } + + @Override + public boolean remove(Object key, Object value) { + Assert.notNull(key, "key"); + writeLock.lock(); + try { + ExpiringEntry entry = entries.get(key); + if (entry != null && entry.getValue().equals(value)) { + entries.remove(key); + if (entry.cancel()) + scheduleEntry(entries.first()); + return true; + } else + return false; + } finally { + writeLock.unlock(); + } + } + + @Override + public V replace(K key, V value) { + Assert.notNull(key, "key"); + writeLock.lock(); + try { + if (entries.containsKey(key)) { + return putInternal(key, value, expirationPolicy.get(), expirationNanos.get()); + } else + return null; + } finally { + writeLock.unlock(); + } + } + + @Override + public boolean replace(K key, V oldValue, V newValue) { + Assert.notNull(key, "key"); + writeLock.lock(); + try { + ExpiringEntry entry = entries.get(key); + if (entry != null && entry.getValue().equals(oldValue)) { + putInternal(key, newValue, expirationPolicy.get(), expirationNanos.get()); + return true; + } else + return false; + } finally { + writeLock.unlock(); + } + } + + /** + * Removes an expiration listener. + * + * @param listener + * @throws NullPointerException if {@code listener} is null + */ + public void removeExpirationListener(ExpirationListener listener) { + Assert.notNull(listener, "listener"); + for (int i = 0; i < expirationListeners.size(); i++) { + if (expirationListeners.get(i).equals(listener)) { + expirationListeners.remove(i); + return; + } + } + } + + /** + * Removes an asynchronous expiration listener. + * + * @param listener + * @throws NullPointerException if {@code listener} is null + */ + public void removeAsyncExpirationListener(ExpirationListener listener) { + Assert.notNull(listener, "listener"); + for (int i = 0; i < asyncExpirationListeners.size(); i++) { + if (asyncExpirationListeners.get(i).equals(listener)) { + asyncExpirationListeners.remove(i); + return; + } + } + } + + /** + * Resets expiration for the entry corresponding to {@code key}. + * + * @param key to reset expiration for + * @throws NullPointerException if {@code key} is null + */ + public void resetExpiration(K key) { + Assert.notNull(key, "key"); + ExpiringEntry entry = getEntry(key); + if (entry != null) + resetEntry(entry, false); + } + + /** + * Sets the expiration duration for the entry corresponding to the given key. Supported only if variable expiration is + * enabled. + * + * @param key Key to set expiration for + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @throws NullPointerException if {@code key} or {@code timeUnit} are null + * @throws UnsupportedOperationException If variable expiration is not enabled + */ + public void setExpiration(K key, long duration, TimeUnit timeUnit) { + Assert.notNull(key, "key"); + Assert.notNull(timeUnit, "timeUnit"); + Assert.operation(variableExpiration, "Variable expiration is not enabled"); + writeLock.lock(); + try { + ExpiringEntry entry = entries.get(key); + if (entry != null) { + entry.expirationNanos.set(TimeUnit.NANOSECONDS.convert(duration, timeUnit)); + resetEntry(entry, true); + } + } finally { + writeLock.unlock(); + } + } + + /** + * Updates the default map entry expiration. Supported only if variable expiration is enabled. + * + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @throws NullPointerException {@code timeUnit} is null + * @throws UnsupportedOperationException If variable expiration is not enabled + */ + public void setExpiration(long duration, TimeUnit timeUnit) { + Assert.notNull(timeUnit, "timeUnit"); + Assert.operation(variableExpiration, "Variable expiration is not enabled"); + expirationNanos.set(TimeUnit.NANOSECONDS.convert(duration, timeUnit)); + } + + /** + * Sets the global expiration policy for the map. Individual expiration policies may override the global policy. + * + * @param expirationPolicy + * @throws NullPointerException {@code expirationPolicy} is null + */ + public void setExpirationPolicy(ExpirationPolicy expirationPolicy) { + Assert.notNull(expirationPolicy, "expirationPolicy"); + this.expirationPolicy.set(expirationPolicy); + } + + /** + * Sets the expiration policy for the entry corresponding to the given key. + * + * @param key to set policy for + * @param expirationPolicy to set + * @throws NullPointerException if {@code key} or {@code expirationPolicy} are null + * @throws UnsupportedOperationException If variable expiration is not enabled + */ + public void setExpirationPolicy(K key, ExpirationPolicy expirationPolicy) { + Assert.notNull(key, "key"); + Assert.notNull(expirationPolicy, "expirationPolicy"); + Assert.operation(variableExpiration, "Variable expiration is not enabled"); + ExpiringEntry entry = getEntry(key); + if (entry != null) + entry.expirationPolicy.set(expirationPolicy); + } + + /** + * Sets the maximum size of the map. Once this size has been reached, adding an additional entry will expire the + * first entry in line for expiration based on the expiration policy. + * + * @param maxSize The maximum size of the map. + */ + public void setMaxSize(int maxSize) { + Assert.operation(maxSize > 0, "maxSize"); + this.maxSize = maxSize; + } + + @Override + public int size() { + readLock.lock(); + try { + return entries.size(); + } finally { + readLock.unlock(); + } + } + + @Override + public String toString() { + readLock.lock(); + try { + return entries.toString(); + } finally { + readLock.unlock(); + } + } + + /** + * Returns a copy of the map's values, which can be iterated over safely by multiple threads. + * + * @return Copied set of map values. + */ + @Override + public Collection values() { + return new AbstractCollection() { + @Override + public void clear() { + ExpiringMap.this.clear(); + } + + @Override + public boolean contains(Object value) { + return containsValue(value); + } + + @Override + public Iterator iterator() { + readLock.lock(); + try { + return (entries instanceof EntryLinkedHashMap) ? ((EntryLinkedHashMap) entries).new ValueIterator() + : ((EntryTreeHashMap) entries).new ValueIterator(); + } finally { + readLock.unlock(); + } + } + + @Override + public int size() { + return ExpiringMap.this.size(); + } + }; + } + + /** + * Notifies expiration listeners that the given entry expired. Must not be called from within a locked context. + * + * @param entry Entry to expire + */ + void notifyListeners(final ExpiringEntry entry) { + if (asyncExpirationListeners != null) + for (final ExpirationListener listener : asyncExpirationListeners) { + LISTENER_SERVICE.execute(new Runnable() { + public void run() { + try { + listener.expired(entry.key, entry.getValue()); + } catch (Exception ignoreUserExceptions) { + } + } + }); + } + + if (expirationListeners != null) + for (final ExpirationListener listener : expirationListeners) { + try { + listener.expired(entry.key, entry.getValue()); + } catch (Exception ignoreUserExceptions) { + } + } + } + + /** + * Returns the internal ExpiringEntry for the {@code key}, obtaining a read lock. + */ + ExpiringEntry getEntry(Object key) { + readLock.lock(); + try { + return entries.get(key); + } finally { + readLock.unlock(); + } + } + + /** + * Puts the given key/value in storage, scheduling the new entry for expiration if needed. If a previous value existed + * for the given key, it is first cancelled and the entries reordered to reflect the new expiration. + */ + V putInternal(K key, V value, ExpirationPolicy expirationPolicy, long expirationNanos) { + writeLock.lock(); + try { + ExpiringEntry entry = entries.get(key); + V oldValue = null; + + if (entry == null) { + entry = new ExpiringEntry(key, value, + variableExpiration ? new AtomicReference(expirationPolicy) : this.expirationPolicy, + variableExpiration ? new AtomicLong(expirationNanos) : this.expirationNanos); + if (entries.size() >= maxSize) { + ExpiringEntry expiredEntry = entries.first(); + entries.remove(expiredEntry.key); + notifyListeners(expiredEntry); + } + entries.put(key, entry); + if (entries.size() == 1 || entries.first().equals(entry)) + scheduleEntry(entry); + } else { + oldValue = entry.getValue(); + if (!ExpirationPolicy.ACCESSED.equals(expirationPolicy) + && ((oldValue == null && value == null) || (oldValue != null && oldValue.equals(value)))) + return value; + + entry.setValue(value); + resetEntry(entry, false); + } + + return oldValue; + } finally { + writeLock.unlock(); + } + } + + /** + * Resets the given entry's schedule canceling any existing scheduled expiration and reordering the entry in the + * internal map. Schedules the next entry in the map if the given {@code entry} was scheduled or if + * {@code scheduleNext} is true. + * + * @param entry to reset + * @param scheduleFirstEntry whether the first entry should be automatically scheduled + */ + void resetEntry(ExpiringEntry entry, boolean scheduleFirstEntry) { + writeLock.lock(); + try { + boolean scheduled = entry.cancel(); + entries.reorder(entry); + + if (scheduled || scheduleFirstEntry) + scheduleEntry(entries.first()); + } finally { + writeLock.unlock(); + } + } + + /** + * Schedules an entry for expiration. Guards against concurrent schedule/schedule, cancel/schedule and schedule/cancel + * calls. + * + * @param entry Entry to schedule + */ + void scheduleEntry(ExpiringEntry entry) { + if (entry == null || entry.scheduled) + return; + + Runnable runnable = null; + synchronized (entry) { + if (entry.scheduled) + return; + + final WeakReference> entryReference = new WeakReference>(entry); + runnable = new Runnable() { + @Override + public void run() { + ExpiringEntry entry = entryReference.get(); + + writeLock.lock(); + try { + if (entry != null && entry.scheduled) { + entries.remove(entry.key); + notifyListeners(entry); + } + + try { + // Expires entries and schedules the next entry + Iterator> iterator = entries.valuesIterator(); + boolean schedulePending = true; + + while (iterator.hasNext() && schedulePending) { + ExpiringEntry nextEntry = iterator.next(); + if (nextEntry.expectedExpiration.get() <= System.nanoTime()) { + iterator.remove(); + notifyListeners(nextEntry); + } else { + scheduleEntry(nextEntry); + schedulePending = false; + } + } + } catch (NoSuchElementException ignored) { + } + } finally { + writeLock.unlock(); + } + } + }; + + Future entryFuture = EXPIRER.schedule(runnable, entry.expectedExpiration.get() - System.nanoTime(), + TimeUnit.NANOSECONDS); + entry.schedule(entryFuture); + } + } + + private static Entry mapEntryFor(final ExpiringEntry entry) { + return new Entry() { + @Override + public K getKey() { + return entry.key; + } + + @Override + public V getValue() { + return entry.value; + } + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } + }; + } + + private void initListenerService() { + synchronized (ExpiringMap.class) { + if (LISTENER_SERVICE == null) { + LISTENER_SERVICE = (ThreadPoolExecutor) Executors.newCachedThreadPool( + THREAD_FACTORY == null ? new NamedThreadFactory("ExpiringMap-Listener-%s") : THREAD_FACTORY); + } + } + } +} diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringValue.java b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringValue.java new file mode 100644 index 0000000..4f485e7 --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/ExpiringValue.java @@ -0,0 +1,122 @@ +package com.io.yutian.aulib.expiringmap; + +import java.util.concurrent.TimeUnit; + +/** + * A value which should be stored in an {@link ExpiringMap} with optional control over its expiration. + * + * @param the type of value being stored + */ +public final class ExpiringValue { + private static final long UNSET_DURATION = -1L; + private final V value; + private final ExpirationPolicy expirationPolicy; + private final long duration; + private final TimeUnit timeUnit; + + /** + * Creates an ExpiringValue to be stored in an {@link ExpiringMap}. The map's default values for + * {@link ExpirationPolicy expiration policy} and {@link ExpiringMap#getExpiration()} expiration} will be used. + * + * @param value the value to store + * @see ExpiringMap#put(Object, Object) + */ + public ExpiringValue(V value) { + this(value, UNSET_DURATION, null, null); + } + + /** + * Creates an ExpiringValue to be stored in an {@link ExpiringMap}. The map's default + * {@link ExpiringMap#getExpiration()} expiration} will be used. + * + * @param value the value to store + * @param expirationPolicy the expiration policy for the value + * @see ExpiringMap#put(Object, Object, ExpirationPolicy) + */ + public ExpiringValue(V value, ExpirationPolicy expirationPolicy) { + this(value, UNSET_DURATION, null, expirationPolicy); + } + + /** + * Creates an ExpiringValue to be stored in an {@link ExpiringMap}. The map's default {@link ExpirationPolicy + * expiration policy} will be used. + * + * @param value the value to store + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @see ExpiringMap#put(Object, Object, long, TimeUnit) + * @throws NullPointerException on null timeUnit + */ + public ExpiringValue(V value, long duration, TimeUnit timeUnit) { + this(value, duration, timeUnit, null); + if (timeUnit == null) { + throw new NullPointerException(); + } + } + + /** + * Creates an ExpiringValue to be stored in an {@link ExpiringMap}. + * + * @param value the value to store + * @param duration the length of time after an entry is created that it should be removed + * @param timeUnit the unit that {@code duration} is expressed in + * @param expirationPolicy the expiration policy for the value + * @see ExpiringMap#put(Object, Object, ExpirationPolicy, long, TimeUnit) + * @throws NullPointerException on null timeUnit + */ + public ExpiringValue(V value, ExpirationPolicy expirationPolicy, long duration, TimeUnit timeUnit) { + this(value, duration, timeUnit, expirationPolicy); + if (timeUnit == null) { + throw new NullPointerException(); + } + } + + private ExpiringValue(V value, long duration, TimeUnit timeUnit, ExpirationPolicy expirationPolicy) { + this.value = value; + this.expirationPolicy = expirationPolicy; + this.duration = duration; + this.timeUnit = timeUnit; + } + + public V getValue() { + return value; + } + + public ExpirationPolicy getExpirationPolicy() { + return expirationPolicy; + } + + public long getDuration() { + return duration; + } + + public TimeUnit getTimeUnit() { + return timeUnit; + } + + @Override + public int hashCode() { + return value != null ? value.hashCode() : 0; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ExpiringValue that = (ExpiringValue) o; + return !(value != null ? !value.equals(that.value) : that.value != null) + && expirationPolicy == that.expirationPolicy && duration == that.duration && timeUnit == that.timeUnit; + + } + + @Override + public String toString() { + return "ExpiringValue{" + "value=" + value + ", expirationPolicy=" + expirationPolicy + ", duration=" + duration + + ", timeUnit=" + timeUnit + '}'; + } +} diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/internal/Assert.java b/src/main/java/com/io/yutian/aulib/expiringmap/internal/Assert.java new file mode 100644 index 0000000..8b2052a --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/internal/Assert.java @@ -0,0 +1,32 @@ +package com.io.yutian.aulib.expiringmap.internal; + +import java.util.NoSuchElementException; + +/** + * @author Jonathan Halterman + */ +public final class Assert { + private Assert() { + } + + public static T notNull(T reference, String parameterName) { + if (reference == null) + throw new NullPointerException(parameterName + " cannot be null"); + return reference; + } + + public static void operation(boolean condition, String message) { + if (!condition) + throw new UnsupportedOperationException(message); + } + + public static void state(boolean expression, String errorMessageFormat, Object... args) { + if (!expression) + throw new IllegalStateException(String.format(errorMessageFormat, args)); + } + + public static void element(Object element, Object key) { + if (element == null) + throw new NoSuchElementException(key.toString()); + } +} \ No newline at end of file diff --git a/src/main/java/com/io/yutian/aulib/expiringmap/internal/NamedThreadFactory.java b/src/main/java/com/io/yutian/aulib/expiringmap/internal/NamedThreadFactory.java new file mode 100644 index 0000000..53278e8 --- /dev/null +++ b/src/main/java/com/io/yutian/aulib/expiringmap/internal/NamedThreadFactory.java @@ -0,0 +1,27 @@ +package com.io.yutian.aulib.expiringmap.internal; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Named thread factory. + */ +public class NamedThreadFactory implements ThreadFactory { + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String nameFormat; + + /** + * Creates a thread factory that names threads according to the {@code nameFormat} by supplying a + * single argument to the format representing the thread number. + */ + public NamedThreadFactory(String nameFormat) { + this.nameFormat = nameFormat; + } + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, String.format(nameFormat, threadNumber.getAndIncrement())); + thread.setDaemon(true); + return thread; + } +} diff --git a/src/main/java/com/io/yutian/aulib/util/ReflectionUtil.java b/src/main/java/com/io/yutian/aulib/util/ReflectionUtil.java index ed5f9b0..c5cd373 100644 --- a/src/main/java/com/io/yutian/aulib/util/ReflectionUtil.java +++ b/src/main/java/com/io/yutian/aulib/util/ReflectionUtil.java @@ -1,5 +1,6 @@ package com.io.yutian.aulib.util; +import org.bukkit.Bukkit; import sun.misc.Unsafe; import java.lang.reflect.Field; @@ -9,6 +10,16 @@ import java.security.PrivilegedAction; public class ReflectionUtil { + private static String nmsVersion; + + public static String getNMSVersion() { + if (nmsVersion == null) { + String name = Bukkit.getServer().getClass().getPackage().getName(); + nmsVersion = name.substring(name.lastIndexOf('.') + 1); + } + return nmsVersion; + } + public static Field getDeclaredField(Class clazz, String fieldName) { try { return clazz.getDeclaredField(fieldName);