View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.util.concurrency;
20  
21  import java.lang.ref.ReferenceQueue;
22  import java.lang.ref.WeakReference;
23  import java.util.concurrent.ConcurrentHashMap;
24  
25  /**
26   * A concurrent cache with weak keys and weak values, inspired by maven-impl's Cache class
27   * but stripped down and optimized for hot-path performance.
28   * <p>
29   * Design compared to the full Cache:
30   * <ul>
31   *   <li><b>Zero allocation on get()</b> — uses a ThreadLocal reusable lookup key
32   *       instead of allocating a new wrapper on every read</li>
33   *   <li><b>O(1) cleanup per stale entry</b> — uses identity-based removal from
34   *       the ReferenceQueue instead of a full {@code entrySet().removeIf()} scan</li>
35   *   <li><b>Lock-free reads</b> — {@link ConcurrentHashMap#get} is a volatile read
36   *       with no lock acquisition</li>
37   *   <li><b>Lock-striped writes</b> — ConcurrentHashMap uses fine-grained locking</li>
38   * </ul>
39   * <p>
40   * Keys are held via {@link WeakReference}: when a key is no longer strongly referenced
41   * elsewhere, its entry becomes eligible for garbage collection. Values are also held
42   * via WeakReference. Stale entries are cleaned up lazily on {@link #put}.
43   *
44   * @param <K> the type of keys
45   * @param <V> the type of values
46   * @since 2.0.19
47   */
48  public class ConcurrentWeakCache<K, V> {
49  
50      private final ConcurrentHashMap<Object, WeakReference<V>> map;
51      private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
52  
53      @SuppressWarnings("rawtypes")
54      private static final ThreadLocal<LookupKey> LOOKUP_KEY = new ThreadLocal<LookupKey>() {
55          @Override
56          protected LookupKey initialValue() {
57              return new LookupKey();
58          }
59      };
60  
61      /**
62       * Creates a new cache with default initial capacity.
63       */
64      public ConcurrentWeakCache() {
65          this.map = new ConcurrentHashMap<>();
66      }
67  
68      /**
69       * Creates a new cache with the given initial capacity.
70       *
71       * @param initialCapacity the initial capacity
72       */
73      public ConcurrentWeakCache(int initialCapacity) {
74          this.map = new ConcurrentHashMap<>(initialCapacity);
75      }
76  
77      /**
78       * Returns the value for the given key, or {@code null} if the key is not present
79       * or the value has been garbage collected.
80       * <p>
81       * This method is lock-free and allocates no objects.
82       *
83       * @param key the key to look up
84       * @return the cached value, or {@code null}
85       */
86      @SuppressWarnings("unchecked")
87      public V get(K key) {
88          LookupKey<K> lookupKey = LOOKUP_KEY.get();
89          lookupKey.set(key);
90          try {
91              WeakReference<V> ref = map.get(lookupKey);
92              return ref != null ? ref.get() : null;
93          } finally {
94              lookupKey.clear();
95          }
96      }
97  
98      /**
99       * Stores a key-value pair in the cache. Both key and value are held via weak references.
100      * <p>
101      * Also performs lazy cleanup of entries whose keys have been garbage collected.
102      *
103      * @param key   the key
104      * @param value the value
105      */
106     public void put(K key, V value) {
107         expungeStaleEntries();
108         map.put(new WeakKey<>(key, queue), new WeakReference<>(value));
109     }
110 
111     /**
112      * If the key is not already present (or its value has been GC'd), stores the key-value pair
113      * and returns the given value. If the key is already present with a live value, returns the
114      * existing value without storing. Concurrent callers for the same key are guaranteed to
115      * receive the same instance (the insert-or-keep decision is atomic per key via
116      * {@link ConcurrentHashMap#merge}).
117      * <p>
118      * Also performs lazy cleanup of entries whose keys have been garbage collected.
119      *
120      * @param key   the key
121      * @param value the value to store if absent
122      * @return the existing value if present, or the given value if newly stored
123      */
124     public V putIfAbsent(K key, V value) {
125         // Fast path: lock-free lookup, zero allocation (ThreadLocal LookupKey)
126         V existing = get(key);
127         if (existing != null) {
128             return existing;
129         }
130         // Slow path: atomic insert-or-keep via merge() — locks only the target bucket,
131         // so contention is per-key, not global. Handles the case where an existing entry's
132         // value has been GC'd by atomically replacing it with the new value.
133         expungeStaleEntries();
134         WeakReference<V> newRef = new WeakReference<>(value);
135         WeakReference<V> ref = map.merge(new WeakKey<>(key, queue), newRef, (existingRef, incoming) -> {
136             V existingValue = existingRef.get();
137             return existingValue != null ? existingRef : incoming;
138         });
139         if (ref != newRef) {
140             V existingValue = ref.get();
141             if (existingValue != null) {
142                 return existingValue;
143             }
144         }
145         return value;
146     }
147 
148     /**
149      * Returns the number of entries in the cache (including possibly stale ones).
150      *
151      * @return the cache size
152      */
153     public int size() {
154         return map.size();
155     }
156 
157     /**
158      * Removes entries whose weak-reference keys have been garbage collected.
159      * Called automatically on {@link #put}. Each stale entry is removed in O(1)
160      * via identity match — no map scan required.
161      */
162     private void expungeStaleEntries() {
163         Object staleKey;
164         while ((staleKey = queue.poll()) != null) {
165             // ConcurrentHashMap.remove() checks (storedKey == staleKey) first.
166             // Since the staleKey IS the same WeakKey object that was stored in
167             // the map, identity matches and the entry is removed in O(1).
168             map.remove(staleKey);
169         }
170     }
171 
172     /**
173      * Reusable lookup key stored in a ThreadLocal for zero-allocation reads.
174      * Used only for {@link ConcurrentHashMap#get} lookups, never stored in the map.
175      * <p>
176      * Implements equals/hashCode to match {@link WeakKey} entries in the map:
177      * ConcurrentHashMap.get(lookupKey) calls {@code lookupKey.equals(storedWeakKey)},
178      * which delegates to {@code key.equals(storedWeakKey.get())}.
179      */
180     static class LookupKey<K> {
181         private K key;
182         private int hash;
183 
184         void set(K key) {
185             this.key = key;
186             this.hash = key.hashCode();
187         }
188 
189         void clear() {
190             this.key = null; // prevent leak via ThreadLocal
191         }
192 
193         @Override
194         public int hashCode() {
195             return hash;
196         }
197 
198         @Override
199         @SuppressWarnings("rawtypes")
200         public boolean equals(Object o) {
201             if (o instanceof WeakKey) {
202                 Object otherKey = ((WeakKey) o).get();
203                 return key != null && key.equals(otherKey);
204             }
205             return false;
206         }
207     }
208 
209     /**
210      * Weak-reference key stored in the map. When the referent is garbage collected,
211      * this object is enqueued in the {@link ReferenceQueue} for cleanup.
212      * <p>
213      * The cleanup path uses identity: {@code map.remove(staleWeakKey)} matches because
214      * ConcurrentHashMap checks {@code storedKey == staleWeakKey} before calling equals().
215      * Since the queued WeakKey IS the same object that's in the map, this is always true.
216      */
217     static class WeakKey<K> extends WeakReference<K> {
218         private final int hash;
219 
220         @SuppressWarnings("unchecked")
221         WeakKey(K key, ReferenceQueue<? super K> queue) {
222             super(key, (ReferenceQueue<? super K>) queue);
223             this.hash = key.hashCode();
224         }
225 
226         @Override
227         public int hashCode() {
228             return hash;
229         }
230 
231         @Override
232         @SuppressWarnings("rawtypes")
233         public boolean equals(Object o) {
234             if (this == o) {
235                 return true; // identity match — used for cleanup and normal dedup
236             }
237             K k = get();
238             if (k == null) {
239                 return false; // referent GC'd
240             }
241             if (o instanceof WeakKey) {
242                 return k.equals(((WeakKey) o).get());
243             }
244             if (o instanceof LookupKey) {
245                 return k.equals(((LookupKey) o).key);
246             }
247             return false;
248         }
249     }
250 }