Shahzad Bhatti

August 7, 2007

Annotation based Caching in Java

Filed under: Computing — admin @ 3:33 pm

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:

 1 import java.lang.reflect.*;
 2 import java.lang.annotation.*;
 3
 4 /**
 5  * Annotation for any class that wish to be cacheable. This annotation can be defined
 6  * at the class level which will allow caching of all methods or at methods or both.
 7  * 
 8  */
 9 @Target({ElementType.TYPE, ElementType.METHOD})
10 @Retention(RetentionPolicy.RUNTIME)
11 public @interface Cacheable {
12     /**
13      * This method allows disabling cache for certain methods.
14      */
15     boolean cache() default true;
16
17     /**
18      * This method defines maximum number of items that will be stored in cache. If
19      * number of items exceed this, then old items will be removed (LRU). This value
20      * should only be defined at class level and not at the method level.
21      */
22     int maxCapacity() default 1000;
23
24     /**
25      * @return true if block of [ if (in cache) load ] is protected with lock.  This
26      * will prevent two threads from executing the same load
27      */
28     boolean synchronizeAccess() default false;
29
30     int timeoutInSecs() default 300;
31
32     /**
33      * @return true if cache will be populated asynchronously when it's about to expire.
34      */
35     public boolean canReloadAsynchronously() default false;
36 }
37
38

I then defined a map class to store cache:

  1 import java.util.Collections;
  2 import java.util.LinkedHashMap;
  3 import java.util.Iterator;
  4 import java.util.Map;
  5 import java.util.HashMap;
  6 import java.util.Date;
  7 import java.util.Collection;
  8 import java.util.Set;
  9 import java.util.HashSet;
 10 import java.util.List;
 11 import java.util.ArrayList;
 12 import java.util.concurrent.Callable;
 13 import java.util.concurrent.ExecutorService;
 14 import java.util.concurrent.Executors;
 15 import java.util.concurrent.locks.*;
 16
 17 import org.apache.commons.logging.Log;
 18 import org.apache.commons.logging.LogFactory;
 19
 20 /**
 21  * CacheMap - provides lightweight caching based on LRU size and timeout
 22  * and asynchronous reloads.
 23  * 
 24  */
 25 public class CacheMap implements Map {
 26     private final static Log log = LogFactory.getLog(CacheMap.class);
 27     private final static int MAX_THREADS = 10; // for all cache items across VM
 28     private final static int MAX_ITEMS = 1000; // for all cache items across VM
 29     private final static ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREADS);
 30
 31     class FixedSizeLruLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
 32         private final int maxSize;
 33
 34         public FixedSizeLruLinkedHashMap(int initialCapacity, float loadFactor, int maxSize) {
 35             super(initialCapacity, loadFactor, true);
 36             this.maxSize = maxSize;
 37         }
 38
 39         @Override
 40         protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
 41             return size() > maxSize;
 42         }
 43     }
 44
 45     private final Cacheable classCacheable;
 46     private final Map<Object, Pair<Date, Object>> map;
 47     private final Map<Object, ReentrantLock> locks;
 48
 49     public CacheMap(Cacheable cacheable) {
 50         this.classCacheable = cacheable;
 51         int maxCapacity = cacheable != null && cacheable.maxCapacity() > 0 && cacheable.maxCapacity() < MAX_ITEMS ? cacheable.maxCapacity() : MAX_ITEMS;
 52         this.map = Collections.synchronizedMap(
 53                 new FixedSizeLruLinkedHashMap<Object, Pair<Date, Object>>(
 54                         maxCapacity/ 10, 0.75f, maxCapacity));
 55         this.locks = new HashMap<Object, ReentrantLock>();
 56     }
 57
 58
 59     public int size() {
 60         return map.size();
 61     }
 62
 63     public boolean isEmpty() {
 64         return map.isEmpty();
 65     }
 66
 67     public boolean containsKey(Object key) {
 68         return map.containsKey(key);
 69     }
 70
 71     public boolean containsValue(Object value) {
 72         return map.containsValue(value);
 73     }
 74
 75     public Object put(Object key, Object value) {
 76         return map.put(key, new Pair(new Date(), value));
 77     }
 78
 79     public Object remove(Object key) {
 80         return map.remove(key);
 81     }
 82
 83
 84     public void putAll(Map m) {
 85         Iterator it = m.entrySet().iterator();
 86         while (it.hasNext()) {
 87             Map.Entry e = (Map.Entry) it.next();
 88             put(e.getKey(), e.getValue());
 89         }
 90     }
 91
 92
 93     public void clear() {
 94         map.clear();
 95     }
 96
 97
 98     public Set keySet() {
 99         return map.keySet();
100     }
101
102
103     public Collection values() {
104         List list = new ArrayList();
105         for (Pair<Date, Object> e : map.values()) {
106             list.add(e.getSecond());
107         }
108         return list;
109     }
110
111     public Set entrySet() {
112         Set set = new HashSet();
113         for (final Map.Entry<Object, Pair<Date, Object>> e : map.entrySet()) {
114             set.add(new Map.Entry() {
115                 public Object getKey() {
116                     return e.getKey();
117                 }
118                 public Object getValue() {
119                     return e.getValue().getSecond();
120                 }
121                 public Object setValue(Object value) {
122                     Object old = e.getValue();
123                     e.getValue().setSecond(value);
124                     return old;
125                 }
126             });
127         }
128         return set;
129     }
130
131     /** 
132      * This method is simple get without any loading behavior
133      */
134     public Object get(Object key) {
135         Pair<Date, Object> item = this.map.get(key);
136         if (item == null) return null;
137         return item.getSecond();
138     }
139
140     public Object get(Object key, Cacheable cacheable, CacheLoader loader) throws Exception {
141         Pair<Date, Object> item = this.map.get(key);
142         Object value = null;
143         ReentrantLock lock = null;
144         try {
145             synchronized(this) {
146                 if (cacheable.synchronizeAccess()) {
147                     lock = lock(key);
148                 }
149             }
150             //
151             if (item == null) {
152                 // load initial value
153                 value = reloadSynchronously(key, loader);
154                 log.debug("Loading synchronously " + key + " --- " + value);
155             } else if (cacheable.timeoutInSecs() > 0 && System.currentTimeMillis() - item.getFirst().getTime() >
156                         cacheable.timeoutInSecs() * 1000) {
157                 // the element has expired, reload it
158                 if (cacheable.canReloadAsynchronously()) {
159                     reloadAsynchronously(key, loader);
160                     value = item.getSecond();
161                     log.debug("Reloading asynchronously " + key + " --- " + value);
162                 } else {
163                     value = reloadSynchronously(key, loader);
164                     log.debug("Reloading synchronously " + key + " --- " + value);
165                 }
166             } else {
167                 value = item.getSecond();
168                 log.debug("Reloading from cache " + key + " --- " + value);
169             }
170         } finally {
171             if (lock != null) {
172                 lock.unlock();
173                 log.debug("Unlocking " + key);
174             }
175         }
176         return value;
177     }
178
179     private ReentrantLock lock(Object key) {
180         ReentrantLock lock = null;
181         synchronized (locks) {
182             lock = (ReentrantLock) locks.get(key);
183             if (lock == null) lock = new ReentrantLock();
184         }
185         log.debug("Locking " + key);
186         lock.lock();
187         return lock;
188     }
189
190     private Object reloadSynchronously(final Object key, final CacheLoader loader) throws Exception {
191         Object value = loader.loadCache(key);
192         put(key, value);
193         return value;
194     }
195     private void reloadAsynchronously(final Object key, final CacheLoader loader) {
196         executorService.submit(new Callable() {
197             public Object call() throws Exception {
198                 return reloadSynchronously(key, loader);
199             }
200         });
201     }
202 }
203
204

A couple of interfaces to load or search key/values:

1 import java.lang.reflect.*;
2
3
4 public interface CacheKeyable {
5     Object toKey(Method method, Object[] args);
6 }
7
8
 1
 2 public interface CacheLoader {
 3     /**
 4      * This mehod loads value for the object by calling underlying
 5      * method
 6      * @param key - key of the cache
 7      * @return value for the key
 8      */
 9     public Object loadCache(Object key) throws Exception;
10 }
11

A simple class to convert method and its args into a unique key (I suppose we can use MD5 or other digest scheme alternatively):

 1 import java.lang.reflect.*;
 2 import java.util.Date;
 3 import org.apache.commons.logging.Log;
 4 import org.apache.commons.logging.LogFactory;
 5
 6 public class SimpleKeyable implements CacheKeyable {
 7     private final static Log log = LogFactory.getLog(SimpleKeyable.class);
 8     public Object toKey(Method method, Object[] args) {
 9         StringBuilder sb = new StringBuilder(method.getName());
10         for (int i=0; args != null && i<args.length; i++) {
11             sb.append(".");
12             if (isPrimitive(args[i])) {
13                 sb.append(args[i].toString());
14             } else {
15                 sb.append(String.valueOf(System.identityHashCode(args[i])));
16             }
17         }
18         return sb.toString();
19     }
20     //
21     protected boolean isPrimitive(Object arg) {
22         return arg.getClass().isPrimitive() || arg.getClass().isEnum() ||
23                 arg instanceof Number || arg instanceof CharSequence ||
24                 arg instanceof Byte || arg instanceof Character ||
25                 arg instanceof Boolean || arg instanceof Date;
26     }
27 }
28

A utility class for storing pair of objects:

 1 /**
 2  * This class is used to store pair of two objects
 3  * 
 4  */
 5 public class Pair<FIRST, SECOND> implements java.io.Serializable {
 6     private static final long serialVersionUID = 1L;
 7     public FIRST first;
 8     public SECOND second;
 9
10     public Pair(FIRST first, SECOND second) {
11         this.first = first;
12         this.second = second;
13     }
14
15     public FIRST getFirst() {
16         return first;
17     }
18
19     public void setFirst(FIRST first) {
20         this.first = first;
21     }
22
23     public SECOND getSecond() {
24         return second;
25     }
26     public void setSecond(SECOND second) {
27         this.second = second;
28     }
29 }
30

Finally a class to tie everything together:

 1 import java.util.*;
 2 import java.lang.reflect.*;
 3 import java.lang.annotation.*;
 4 import java.util.concurrent.locks.*;
 5 import org.apache.commons.logging.Log;
 6 import org.apache.commons.logging.LogFactory;
 7
 8 public class CacheProxy implements InvocationHandler {
 9     private final static Log log = LogFactory.getLog(CacheProxy.class);
10     private final Object delegate;
11     private final CacheMap cache;
12     private final Cacheable classcacheable;
13     private final CacheKeyable keyable;
14
15     class CacheLoaderImpl implements CacheLoader {
16         Method method;
17         Object[] args;
18         CacheLoaderImpl(Method method, Object[] args) {
19             this.method = method;
20             this.args = args;
21         }
22         public Object loadCache(Object key) throws Exception {
23             return method.invoke(delegate, args);
24         }
25     }
26
27     //
28     public CacheProxy(Object aDelegate) {
29         this.delegate = aDelegate;
30         this.classcacheable = getCacheable(aDelegate.getClass().getAnnotations());
31         this.cache = new CacheMap(classcacheable);
32         this.keyable = new SimpleKeyable();
33     }
34
35     public static Object proxy(Object delegate, Class iface) {
36         return Proxy.newProxyInstance(iface.getClassLoader(),
37                 new Class<?>[] {iface}, new CacheProxy(delegate));
38     }
39
40
41
42     public Object invoke(
43                 final Object proxy,
44                 final Method method,
45                 final Object[] args) throws Exception {
46         Cacheable cacheable = getCacheable(method);
47         if (cacheable != null) {
48             Object key = toKey(method, args);
49             log.debug("Loading value from cache for " + key);
50             return cache.get(key, cacheable, new CacheLoaderImpl(method, args));
51         } else {
52             log.debug("Skipping cache for " + method);
53             return method.invoke(delegate, args);
54         }
55     }
56
57     private Cacheable getCacheable(Annotation[] ann) {
58         for (int i=0; ann != null && i<ann.length; i++) {
59             if (ann[i] instanceof Cacheable) {
60                 return (Cacheable) ann[i];
61             }
62         }
63         return null;
64     }
65     //
66     private Cacheable getCacheable(Method method) throws Exception {
67         // get method from actual class because interface method will not be annotated.
68         method = delegate.getClass().getMethod(method.getName(), method.getParameterTypes());
69         // first search method level annotation 
70         Cacheable cacheable = getCacheable(method.getDeclaredAnnotations());
71         if (cacheable == null) {
72             cacheable = classcacheable; // if not found search class level annotation 
73         }
74         if (cacheable != null && cacheable.cache() == false) {
75             cacheable = null;
76         }
77         return cacheable;
78     }
79
80
81     private Object toKey(Method method, Object[] args) {
82         if (delegate instanceof CacheKeyable) {
83             return ((CacheKeyable) delegate).toKey(method, args);
84         } else {
85             return keyable.toKey(method, args);
86         }
87     }
88 }
89

Instead of Java 1.3 proxy, we can also use aspects, e.g.

 1 import java.util.*;
 2 import java.lang.reflect.*;
 3 import java.lang.annotation.*;
 4 import org.apache.commons.logging.Log;
 5 import org.apache.commons.logging.LogFactory;
 6 import org.aspectj.lang.annotation.Aspect;
 7 import org.aspectj.lang.annotation.Pointcut;
 8 import org.aspectj.lang.annotation.Around;
 9 import org.aspectj.lang.ProceedingJoinPoint;
10 import org.aspectj.lang.reflect.MethodSignature;
11
12 @Aspect
13 public class CacheAspect {
14     private final static Log log = LogFactory.getLog(CacheAspect.class);
15     private final CacheMap cache;
16     private final CacheKeyable keyable;
17
18     class CacheLoaderImpl implements CacheLoader {
19         ProceedingJoinPoint pjp;
20         CacheLoaderImpl(ProceedingJoinPoint pjp) {
21             this.pjp = pjp;
22         }
23         public Object loadCache(Object key) throws Exception {
24             try {
25                 return pjp.proceed();
26             } catch (Exception e) {
27                 throw e;
28             } catch (Throwable e) {
29                 throw new Error(e);
30             }
31         }
32     }
33
34
35     //
36     public CacheAspect() {
37         this.cache = new CacheMap(null);
38         this.keyable = new SimpleKeyable();
39     }
40
41     @Around("@target(com.amazon.otb.cache.Cacheable)")
42     public Object proxy(ProceedingJoinPoint pjp) throws Exception {
43         Object target = pjp.getTarget();
44         Cacheable cacheable = getCacheable(pjp);
45         //
46         if (cacheable != null) {
47             Method method = ((MethodSignature)pjp.getStaticPart().getSignature()).getMethod();
48             Object key = toKey(target, method, pjp.getArgs());
49             log.debug("Loading value from cache for " + key);
50             return cache.get(key, cacheable, new CacheLoaderImpl(pjp));
51         } else {
52             log.debug("Skipping cache for " + pjp.getStaticPart().getSignature());
53             try {
54                 return pjp.proceed();
55             } catch (Exception e) {
56                 throw e;
57             } catch (Throwable e) {
58                 throw new Error(e);
59             }
60         }
61     }
62
63     private Cacheable getCacheable(Annotation[] ann) {
64         for (int i=0; ann != null && i<ann.length; i++) {
65             if (ann[i] instanceof Cacheable) {
66                 return (Cacheable) ann[i];
67             }
68         }
69         return null;
70     }
71
72
73     //
74     private Cacheable getCacheable(ProceedingJoinPoint pjp) throws Exception {
75         if (pjp.getStaticPart().getSignature() instanceof MethodSignature == false) return null;
76
77         Method method = ((MethodSignature)pjp.getStaticPart().getSignature()).getMethod();
78
79         // first search method level annotation 
80         Cacheable cacheable = getCacheable(method.getDeclaredAnnotations());
81         if (cacheable != null && cacheable.cache() == false) {
82             cacheable = null;
83         }
84         return cacheable;
85     }
86
87     private Object toKey(Object target, Method method, Object[] args) {
88         if (target instanceof CacheKeyable) {
89             return ((CacheKeyable) target).toKey(method, args);
90         } else {
91             return keyable.toKey(method, args);
92         }
93     }
94 }
95

And finally unit test:

 1 import junit.framework.*;
 2 import java.lang.reflect.*;
 3 import java.util.*;
 4
 5 import org.apache.commons.logging.Log;
 6 import org.apache.commons.logging.LogFactory;
 7
 8
 9
10 public class CacheProxyTest extends TestCase {
11     private final static Log log = LogFactory.getLog(CacheProxyTest.class);
12     interface IService {
13         Date getCacheTime(boolean junk);
14         Date getCacheTime(int junk);
15         Date getRealtime(boolean junk);
16     }
17     @Cacheable(maxCapacity=100)
18     class ServiceImpl implements IService {
19         @Cacheable(timeoutInSecs = 1, synchronizeAccess = true)
20         public Date getCacheTime(boolean junk) {
21             return new Date();
22         }
23         @Cacheable(timeoutInSecs = 1, synchronizeAccess = true)
24         public Date getCacheTime(int junk) {
25             return new Date();
26         }
27         @Cacheable(cache=false)
28         public Date getRealtime(boolean junk) {
29             return new Date();
30         }
31     }
32
33
34
35     //
36     public void testCache() throws Exception {
37         ServiceImpl real = new ServiceImpl();
38         Date started = new Date();
39         IService svc = (IService) CacheProxy.proxy(real, IService.class);
40
41         Date cacheDate = svc.getCacheTime(true);
42         assertTrue(cacheDate.getTime() >= started.getTime());
43         Date realDate = svc.getRealtime(true);
44         assertTrue(realDate.getTime() >= started.getTime());
45
46         Thread.currentThread().sleep(200);
47         assertTrue(cacheDate.getTime() == svc.getCacheTime(true).getTime());
48         assertTrue(svc.getCacheTime(false).getTime() > cacheDate.getTime());
49         assertTrue(svc.getRealtime(true).getTime() > realDate.getTime());
50
51         cacheDate = svc.getCacheTime(0);
52         for (int i=0; i<100; i++) {
53             svc.getCacheTime(i).getTime();
54         }
55         // after max capacity
56         assertFalse(cacheDate == svc.getCacheTime(0));
57     }
58 }
59

No Comments

No comments yet.

RSS feed for comments on this post. TrackBack URL

Sorry, the comment form is closed at this time.

Powered by WordPress