10
\$\begingroup\$

I wrote a wrapper for Java's ConcurrentHashMap that enables you to add a decay time to inserted values, meaning they will be automatically removed after the given time. This functionality is implemented by a cleaning thread that runs until .close() is called. I deliberately did not extend the ConcurrentHashMap because I simply wanted to implement the core functionality.

I'd be thankful for a review! A basic fully functional test is attached.

import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentDecayingHashMap<K, V> implements AutoCloseable {
    private final ConcurrentHashMap<K, ValueWrapper> map = new ConcurrentHashMap<>();
    private final Thread cleanerThread;

    public ConcurrentDecayingHashMap() {
        this(1000); // 1 second
    }

    public ConcurrentDecayingHashMap(int cleanupIntervalInMilis) {
        if (cleanupIntervalInMilis < 0) {
            throw new IllegalArgumentException("cleanupIntervalInMilis must not be negative!");
        }
        cleanerThread = new Thread(() -> {
            try {
                Collection<ValueWrapper> valueWrappers = map.values();
                Thread currentThread = Thread.currentThread();
                while (!currentThread.isInterrupted()) {
                    long currentTime = System.currentTimeMillis();
                    for (ValueWrapper valueWrapper : valueWrappers) {
                        // outer check to avoid excessive amount of map.compute calls
                        if (currentTime >= valueWrapper.timeOfDeath && valueWrapper.timeOfDeath > 0) {
                            map.compute(valueWrapper.key, (k, oldValueWrapper) -> {
                                // inner check because value could have changed after outer check
                                if (currentTime >= oldValueWrapper.timeOfDeath && oldValueWrapper.timeOfDeath > 0) {
                                    return null; // removes the value from the map
                                }
                                return oldValueWrapper; // no change
                            });
                        }
                    }
                    Thread.sleep(cleanupIntervalInMilis);
                }
            } catch (InterruptedException ie) {
                return;
            }
        });
        cleanerThread.setName("ConcurrentDecayingHashMapCleanerThread");
        cleanerThread.start();
    }

    public boolean containsKey(K key) {
        return map.containsKey(key);
    }

    /**
     * Maps the specified key to the specified value in this table. Neither the
     * key nor the value can be null. The value is held infinitely or until
     * overwritten.
     * 
     * @param key
     * @param value
     */
    public void put(K key, V value) {
        put(key, value, 0);
    }

    public V remove(K key) {
        ValueWrapper valueWrapper = map.remove(key);
        return valueWrapper == null ? null : valueWrapper.value;
    }

    /**
     * Maps the specified key to the specified value in this table. Neither the
     * key nor the value can be null.
     * 
     * @param key
     * @param value
     * @param milisUntilDestruction
     *            if <= 0 the value is held infinitely or until overwritten.
     */
    public void put(K key, V value, long milisUntilDestruction) {
        if (key == null) {
            throw new IllegalArgumentException("key must not be null!");
        }
        if (value == null) {
            remove(key);
            return;
        }
        map.compute(key, (k, oldValueWrapper) -> new ValueWrapper(key, value,
                milisUntilDestruction > 0 ? (System.currentTimeMillis() + milisUntilDestruction) : 0));
    }

    public V get(K key) {
        ValueWrapper valueWrapper = map.get(key);
        if (valueWrapper == null) {
            return null;
        }
        return valueWrapper.value;
    }

    /**
     * Holds additional information about values required for decay.
     */
    private class ValueWrapper {
        public final K key;
        public final V value;
        public final long timeOfDeath;

        public ValueWrapper(K key, V value, long timeOfDeath) {
            this.key = key;
            this.value = value;
            this.timeOfDeath = timeOfDeath;
        }
    }

    @Override
    public void close() throws Exception {
        cleanerThread.interrupt();
        map.clear();
    }
}

Here is a little test:

import java.io.PrintWriter;
import java.io.StringWriter;

public class Application {
    public static void main(String... args) {
        try (ConcurrentDecayingHashMap<String, Integer> cdhm = new ConcurrentDecayingHashMap<>(0);) {

            cdhm.put("A", 5, 1000);
            System.out.println(cdhm.get("A"));
            cdhm.put("A", 7, 500);
            System.out.println(cdhm.get("A"));
            sleepTight(300);
            System.out.println(cdhm.get("A"));
            sleepTight(300);
            System.out.println(cdhm.get("A"));

        } catch (Throwable t) {
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            t.printStackTrace(pw);
            System.out.println(sw.toString());
        }
    }

    public static void sleepTight(long duration) {
        try {
            Thread.sleep(duration);
        } catch (InterruptedException e) {
        }
    }
}
\$\endgroup\$
  • 3
    \$\begingroup\$ +1 for sleepTight(), that InterruptedException should be wrapped in NonGentleGoodNightException() though. \$\endgroup\$ – h.j.k. Nov 11 '15 at 9:52
  • \$\begingroup\$ why you use a task at all? Wouldn't it be easier to just delete the key and return null in the get function if time is expired? \$\endgroup\$ – N74 Nov 11 '15 at 10:01
  • \$\begingroup\$ On a more serious note, have you compared your implementation with others, such as Guava's CacheLoader, which offers timer-based eviction too? \$\endgroup\$ – h.j.k. Nov 11 '15 at 10:11
  • \$\begingroup\$ @N74 I guess that depends on whether dead values wasting space is likely in the given usage scenario and deemed acceptable. I didn't think about that idea though! \$\endgroup\$ – Traubenfuchs Nov 11 '15 at 10:19
  • 2
    \$\begingroup\$ @h.j.k. Looks like they are using the idea N74 mentioned. Hidden under thousands lines of convoluted enterprise quality code with unlimited flexibility. \$\endgroup\$ – Traubenfuchs Nov 11 '15 at 10:31
3
\$\begingroup\$

For the cleanup thread it sounds like a more optimal way of looking for out of date values would be a priority queue sorted by next eviction timestamp; that way only a few rather than all of the entries would need to be checked. Similarly, the timeout could be adjusted based on what the next expected time for eviction would be. If there are no entries in the map the thread could also be stopped entirely.

In put the description says that the value can't be null - but the message immediately handles the null case by calling remove. That behaviour could be clarified in the description IMO.

Apart from the demo there should also be actual unit tests to test that all the assumptions hold in a variety of circumstances.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.