001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.util.concurrency;
020
021import java.lang.ref.ReferenceQueue;
022import java.lang.ref.WeakReference;
023import java.util.concurrent.ConcurrentHashMap;
024
025/**
026 * A concurrent cache with weak keys and weak values, inspired by maven-impl's Cache class
027 * but stripped down and optimized for hot-path performance.
028 * <p>
029 * Design compared to the full Cache:
030 * <ul>
031 *   <li><b>Zero allocation on get()</b> — uses a ThreadLocal reusable lookup key
032 *       instead of allocating a new wrapper on every read</li>
033 *   <li><b>O(1) cleanup per stale entry</b> — uses identity-based removal from
034 *       the ReferenceQueue instead of a full {@code entrySet().removeIf()} scan</li>
035 *   <li><b>Lock-free reads</b> — {@link ConcurrentHashMap#get} is a volatile read
036 *       with no lock acquisition</li>
037 *   <li><b>Lock-striped writes</b> — ConcurrentHashMap uses fine-grained locking</li>
038 * </ul>
039 * <p>
040 * Keys are held via {@link WeakReference}: when a key is no longer strongly referenced
041 * elsewhere, its entry becomes eligible for garbage collection. Values are also held
042 * via WeakReference. Stale entries are cleaned up lazily on {@link #put}.
043 *
044 * @param <K> the type of keys
045 * @param <V> the type of values
046 * @since 2.0.19
047 */
048public class ConcurrentWeakCache<K, V> {
049
050    private final ConcurrentHashMap<Object, WeakReference<V>> map;
051    private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
052
053    @SuppressWarnings("rawtypes")
054    private static final ThreadLocal<LookupKey> LOOKUP_KEY = new ThreadLocal<LookupKey>() {
055        @Override
056        protected LookupKey initialValue() {
057            return new LookupKey();
058        }
059    };
060
061    /**
062     * Creates a new cache with default initial capacity.
063     */
064    public ConcurrentWeakCache() {
065        this.map = new ConcurrentHashMap<>();
066    }
067
068    /**
069     * Creates a new cache with the given initial capacity.
070     *
071     * @param initialCapacity the initial capacity
072     */
073    public ConcurrentWeakCache(int initialCapacity) {
074        this.map = new ConcurrentHashMap<>(initialCapacity);
075    }
076
077    /**
078     * Returns the value for the given key, or {@code null} if the key is not present
079     * or the value has been garbage collected.
080     * <p>
081     * This method is lock-free and allocates no objects.
082     *
083     * @param key the key to look up
084     * @return the cached value, or {@code null}
085     */
086    @SuppressWarnings("unchecked")
087    public V get(K key) {
088        LookupKey<K> lookupKey = LOOKUP_KEY.get();
089        lookupKey.set(key);
090        try {
091            WeakReference<V> ref = map.get(lookupKey);
092            return ref != null ? ref.get() : null;
093        } finally {
094            lookupKey.clear();
095        }
096    }
097
098    /**
099     * 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}