I probably had to write cache to store results of expensive operations or queries a dozens of times over last ten years of Java. And this week, I had to redo again. Though, there are a lot of libraries such as JCS , Terracota or Tangosol, but I just decided to write a simple implementation myself.
First, I defined an annotation that will be used to mark any class or methods cachable:
import java.lang.reflect.*; import java.lang.annotation.*; /** * Annotation for any class that wish to be cacheable. This annotation can be defined * at the class level which will allow caching of all methods or at methods or both. */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Cacheable { /** * This method allows disabling cache for certain methods. */ boolean cache() default true; /** * This method defines maximum number of items that will be stored in cache. If * number of items exceed this, then old items will be removed (LRU). This value * should only be defined at class level and not at the method level. */ int maxCapacity() default 1000; /** * @return true if block of [ if (in cache) load ] is protected with lock. This * will prevent two threads from executing the same load */ boolean synchronizeAccess() default false; int timeoutInSecs() default 300; /** * @return true if cache will be populated asynchronously when it's about to expire. */ public boolean canReloadAsynchronously() default false; }
I then defined a simple map class to store cache:
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Method; import java.util.*; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.locks.ReentrantLock; /** * CacheMap - provides lightweight caching based on LRU size and timeout * and asynchronous reloads. */ public class CacheMap<K, V> implements Map<K, V> { private final static int MAX_THREADS = 10; // for all cache items across VM private final static int MAX_ITEMS = 10000; // for all cache items across VM private final static ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREADS); static class FixedSizeLruLinkedHashMap<K, V> extends LinkedHashMap<K, V> { private final int maxSize; public FixedSizeLruLinkedHashMap(int initialCapacity, float loadFactor, int maxSize) { super(initialCapacity, loadFactor, true); this.maxSize = maxSize; } @Override protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) { return size() > maxSize; } } private final Cacheable classCacheable; private final Map<K, Pair<Date, V>> map; private final Map<Object, ReentrantLock> locks = new HashMap<Object, ReentrantLock>(); public CacheMap(Cacheable cacheable) { this.classCacheable = cacheable; int maxCapacity = cacheable != null && cacheable.maxCapacity() > 0 && cacheable.maxCapacity() < MAX_ITEMS ? cacheable.maxCapacity() : MAX_ITEMS; this.map = Collections.synchronizedMap( new FixedSizeLruLinkedHashMap<K, Pair<Date, V>>( maxCapacity / 10, 0.75f, maxCapacity)); } @Override public int size() { return map.size(); } @Override public boolean isEmpty() { return map.isEmpty(); } @Override public boolean containsKey(Object key) { return map.containsKey(key); } @Override public boolean containsValue(Object value) { return map.containsValue(value); } @Override public V put(K key, V value) { Pair<Date, V> old = map.put(key, new Pair<>(new Date(), value)); if (old != null) { return old.second; } return null; } @Override public V remove(Object key) { Pair<Date, V> old = map.remove(key); if (old != null) { return old.second; } return null; } @Override public void putAll(Map<? extends K, ? extends V> m) { for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { put(e.getKey(), e.getValue()); } } @Override public void clear() { map.clear(); } @Override public Set<K> keySet() { return map.keySet(); } @Override public Collection<V> values() { List<V> list = new ArrayList<>(); for (Pair<Date, V> e : map.values()) { list.add(e.getSecond()); } return list; } @Override public Set<Entry<K, V>> entrySet() { Set<Entry<K, V>> set = new HashSet<>(); for (final Map.Entry<K, Pair<Date, V>> e : map.entrySet()) { set.add(new Map.Entry<K, V>() { @Override public K getKey() { return e.getKey(); } @Override public V getValue() { return e.getValue().getSecond(); } @Override public V setValue(Object value) { return null; } }); } return set; } /** * This method is simple get without any loading behavior * * @param key to fetch */ @Override public V get(Object key) { Pair<Date, V> item = this.map.get(key); if (item == null) { return null; } return item.getSecond(); } /** * This method is simple get with loading behavior * * @param key to fetch */ public V get(K key, Cacheable cacheable, CacheLoader<K, V> loader) { Pair<Date, V > item = this.map.get(key); V value; ReentrantLock lock = null; try { synchronized (this) { if (cacheable.synchronizeAccess()) { lock = lock(key); } } if (item == null) { // load initial value value = reloadSynchronously(key, loader); } else if (cacheable.timeoutInSecs() > 0 && System.currentTimeMillis() - item.getFirst().getTime() > cacheable.timeoutInSecs() * 1000L) { // the element has expired, reload it if (cacheable.canReloadAsynchronously()) { reloadAsynchronously(key, loader); value = item.getSecond(); } else { value = reloadSynchronously(key, loader); } } else { value = item.getSecond(); } } finally { if (lock != null) { lock.unlock(); } } return value; } private ReentrantLock lock(Object key) { ReentrantLock lock; synchronized (locks) { lock = locks.get(key); if (lock == null) lock = new ReentrantLock(); } lock.lock(); return lock; } private V reloadSynchronously(final K key, final CacheLoader<K, V> loader) { V value = loader.loadCache(key); put(key, value); return value; } private void reloadAsynchronously(final K key, final CacheLoader< K, V> loader) { executorService.submit(new Callable<V>() { public V call() { return reloadSynchronously(key, loader); } }); } }
A couple of interfaces to load or search key/values:
import java.lang.reflect.*; public interface CacheKeyable<K> { K toKey(Method method, Object[] args); }
public interface CacheLoader<K, V> { /** * This mehod loads value for the object by calling underlying * method * @param key - key of the cache * @return value for the key */ public V loadCache(K key); }
Following is a simple class to convert method and its args into a unique key (I suppose we can use MD5 SHA1 instead here):
public class SimpleKeyable implements CacheKeyable<String> { @Override public String toKey(Method method, Object[] args) { StringBuilder sb = new StringBuilder(method.getName()); for (int i = 0; args != null && i < args.length; i++) { sb.append("."); if (isPrimitive(args[i])) { sb.append(args[i].toString()); } else { sb.append(System.identityHashCode(args[i])); } } return sb.toString(); } // checks if object is primitive type private boolean isPrimitive(Object arg) { return arg.getClass().isPrimitive() || arg.getClass().isEnum() || arg instanceof Number || arg instanceof CharSequence || arg instanceof Character || arg instanceof Boolean || arg instanceof Date; } }
A utility class for storing pair of objects:
class Pair<FIRST, SECOND> { final FIRST first; final SECOND second; Pair(FIRST first, SECOND second) { this.first = first; this.second = second; } public FIRST getFirst() { return first; } public SECOND getSecond() { return second; } }
Following code adds support for parsing cache annotation:
import java.lang.annotation.Annotation; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class CacheProxy<K, V> implements InvocationHandler { private final Object delegate; private final CacheMap<K, V> cache; private final Cacheable classcacheable; private final CacheKeyable<String> keyable; class CacheLoaderImpl implements CacheLoader<K, V> { Method method; Object[] args; CacheLoaderImpl(Method method, Object[] args) { this.method = method; this.args = args; } @Override public V loadCache(Object key) { try { return (V) method.invoke(delegate, args); } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } } } // Constructs cache proxy public CacheProxy(Object aDelegate) { this.delegate = aDelegate; this.classcacheable = getCacheable(aDelegate.getClass().getAnnotations()); this.cache = new CacheMap<>(classcacheable); this.keyable = new SimpleKeyable(); } // creates proxy object for cache public static Object proxy(Object delegate, Class<?> iface) { return Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, new CacheProxy(delegate)); } public V invoke( final Object proxy, final Method method, final Object[] args) throws Exception { Cacheable cacheable = getCacheable(method); if (cacheable != null) { K key = toKey(method, args); return cache.get(key, cacheable, new CacheLoaderImpl(method, args)); } else { return (V) method.invoke(delegate, args); } } private Cacheable getCacheable(Annotation[] ann) { for (int i = 0; ann != null && i < ann.length; i++) { if (ann[i] instanceof Cacheable) { return (Cacheable) ann[i]; } } return null; } // private Cacheable getCacheable(Method method) throws Exception { // get method from actual class because interface method will not be annotated. method = delegate.getClass().getMethod(method.getName(), method.getParameterTypes()); // first search method level annotation Cacheable cacheable = getCacheable(method.getDeclaredAnnotations()); if (cacheable == null) { cacheable = classcacheable; // if not found search class level annotation } if (cacheable != null && !cacheable.cache()) { cacheable = null; } return cacheable; } private K toKey(Method method, Object[] args) { if (delegate instanceof CacheKeyable) { return (K) ((CacheKeyable) delegate).toKey(method, args); } else { return (K) keyable.toKey(method, args); } } }
Instead of Java 1.3 proxy, we can also use aspects, e.g.
import java.util.*; import java.lang.reflect.*; import java.lang.annotation.*; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; @Aspect public class CacheAspect<K, V> { private final CacheMap<K, V> cache; private final CacheKeyable<String> keyable; class CacheLoaderImpl implements CacheLoader<K, V> { ProceedingJoinPoint pjp; CacheLoaderImpl(ProceedingJoinPoint pjp) { this.pjp = pjp; } @Override public V loadCache(Object key) { try { return pjp.proceed(); } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } } } // Constructor public CacheAspect() { this.cache = new CacheMap<>(null); this.keyable = new SimpleKeyable(); } @Around("@target(com.plexobject.Cacheable)") public Object proxy(ProceedingJoinPoint pjp) throws Exception { Object target = pjp.getTarget(); Cacheable cacheable = getCacheable(pjp); // if (cacheable != null) { Method method = ((MethodSignature)pjp.getStaticPart().getSignature()).getMethod(); Object key = toKey(target, method, pjp.getArgs()); return cache.get(key, cacheable, new CacheLoaderImpl(pjp)); 51 } else { try { return pjp.proceed(); } catch (Exception e) { throw e; } catch (Throwable e) { throw new Error(e); } } } private Cacheable getCacheable(Annotation[] ann) { for (int i=0; ann != null && i<ann.length; i++) { if (ann[i] instanceof Cacheable) { return (Cacheable) ann[i]; } } return null; } private Cacheable getCacheable(ProceedingJoinPoint pjp) throws Exception { if (pjp.getStaticPart().getSignature() instanceof MethodSignature == false) { return null; } Method method = ((MethodSignature)pjp.getStaticPart().getSignature()).getMethod(); // first search method level annotation Cacheable cacheable = getCacheable(method.getDeclaredAnnotations()); if (cacheable != null && cacheable.cache() == false) { cacheable = null; } return cacheable; } private K toKey(Object target, Method method, Object[] args) { if (target instanceof CacheKeyable) { return (K) ((CacheKeyable) target).toKey(method, args); } else { return (K) keyable.toKey(method, args); } } }
And finally a unit test to test everything:
import junit.framework.*; import java.lang.reflect.*; import java.util.*; public class CacheProxyTest extends TestCase { interface IService { Date getCacheTime(boolean junk); Date getCacheTime(int junk); Date getRealtime(boolean junk); } @Cacheable(maxCapacity=100) class ServiceImpl implements IService { @Cacheable(timeoutInSecs = 1, synchronizeAccess = true) public Date getCacheTime(boolean junk) { return new Date(); } @Cacheable(timeoutInSecs = 1, synchronizeAccess = true) public Date getCacheTime(int junk) { return new Date(); } @Cacheable(cache=false) public Date getRealtime(boolean junk) { return new Date(); } } public void testCache() throws Exception { ServiceImpl real = new ServiceImpl(); Date started = new Date(); IService svc = (IService) CacheProxy.proxy(real, IService.class); Date cacheDate = svc.getCacheTime(true); assertTrue(cacheDate.getTime() >= started.getTime()); Date realDate = svc.getRealtime(true); assertTrue(realDate.getTime() >= started.getTime()); Thread.currentThread().sleep(200); assertTrue(cacheDate.getTime() == svc.getCacheTime(true).getTime()); assertTrue(svc.getCacheTime(false).getTime() > cacheDate.getTime()); assertTrue(svc.getRealtime(true).getTime() > realDate.getTime()); cacheDate = svc.getCacheTime(0); for (int i=0; i<100; i++) { svc.getCacheTime(i).getTime(); } // after max capacity assertFalse(cacheDate == svc.getCacheTime(0)); } }