Skip to content

Commit

Permalink
Add Interner type for weakKey equality caching (fixes #344)
Browse files Browse the repository at this point in the history
A weak keyed cache uses identity equivalence, as this is the only sensible
approach when caching a value. If Objects.equals was used then the user
would not be inclined to hold a reference to the canonical key and expect
any equivalent key to cause the mapping to be retained. As that is an
impossible expectation, the cache would seemingly discard prematurely.
Therefore we disallow this case to hint that users should be more thoughtful
about their desired behavior.

An interner is a Set-based cache where the key is the only item of interest.
This allows usages to resolve to a canonical instance, discard duplicates
as determined by Object.equals, and thereby reduce memory usage. A weak
interner allows the garbage collector to discard the canonical instance
when all usages have been reclaimed. This special case of a weak keyed
cache with Object.equals behavior is now supported, but hidden through
an interface to make the behavior explicit and avoid misuses.

For cases that want to cache by a canonical weak key to a value, the
two type should be used in conjunction. Instead of incorrectly trying
to combine into a single cache, use intern the key and use that for
the cache lookup.
  • Loading branch information
ben-manes committed Apr 10, 2022
1 parent d2644d8 commit a0662da
Show file tree
Hide file tree
Showing 15 changed files with 488 additions and 24 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: 'Dependency Review'
on: [pull_request]

permissions:
contents: read

jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
- name: 'Dependency Review'
uses: actions/dependency-review-action@v1
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ allprojects {
group = 'com.github.ben-manes.caffeine'
version.with {
major = 3 // incompatible API changes
minor = 0 // backwards-compatible additions
patch = 7 // backwards-compatible bug fixes
minor = 1 // backwards-compatible additions
patch = 0 // backwards-compatible bug fixes
releaseBuild = rootProject.hasProperty('release')
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import static com.github.benmanes.caffeine.cache.Specifications.keySpec;
import static com.github.benmanes.caffeine.cache.Specifications.lookupKeyType;
import static com.github.benmanes.caffeine.cache.Specifications.rawReferenceKeyType;
import static com.github.benmanes.caffeine.cache.Specifications.referenceKeyType;
import static com.github.benmanes.caffeine.cache.Specifications.referenceType;
import static com.github.benmanes.caffeine.cache.Specifications.vTypeVar;
import static com.github.benmanes.caffeine.cache.Specifications.valueRefQueueSpec;
import static com.github.benmanes.caffeine.cache.Specifications.valueSpec;
Expand Down Expand Up @@ -246,7 +246,7 @@ private MethodSpec newReferenceKeyMethod() {
return MethodSpec.methodBuilder("newReferenceKey")
.addJavadoc("Returns a key suitable for inserting into the cache. If the cache holds "
+ "keys strongly then\nthe key is returned. If the cache holds keys weakly "
+ "then a {@link $T}\nholding the key argument is returned.\n", referenceKeyType)
+ "then a {@link $T}\nholding the key argument is returned.\n", referenceType)
.addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
.addParameter(kTypeVar, "key")
.addParameter(kRefQueueType, "referenceQueue")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public final class NodeSelectorCode {

private NodeSelectorCode() {
block = CodeBlock.builder()
.beginControlFlow("if (builder.interner)")
.addStatement("return new Interned<>()")
.endControlFlow()
.addStatement("$1T sb = new $1T(\"$2N.\")",
StringBuilder.class, NODE_FACTORY.rawType.packageName());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;

import com.squareup.javapoet.ClassName;
Expand Down Expand Up @@ -46,6 +47,8 @@ public final class Specifications {
public static final ClassName nodeType = ClassName.get(PACKAGE_NAME, "Node");
public static final TypeName lookupKeyType =
ClassName.get(PACKAGE_NAME + ".References", "LookupKeyReference");
public static final TypeName referenceType = ParameterizedTypeName.get(
ClassName.get(Reference.class), kTypeVar);
public static final TypeName referenceKeyType = ParameterizedTypeName.get(
ClassName.get(PACKAGE_NAME + ".References", "WeakKeyReference"), kTypeVar);
public static final TypeName rawReferenceKeyType = ParameterizedTypeName.get(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import static com.github.benmanes.caffeine.cache.Specifications.DEAD_WEAK_KEY;
import static com.github.benmanes.caffeine.cache.Specifications.RETIRED_STRONG_KEY;
import static com.github.benmanes.caffeine.cache.Specifications.RETIRED_WEAK_KEY;
import static com.github.benmanes.caffeine.cache.Specifications.referenceKeyType;
import static com.github.benmanes.caffeine.cache.Specifications.referenceType;

import com.squareup.javapoet.MethodSpec;

Expand Down Expand Up @@ -67,6 +67,9 @@ private void addState(String checkName, String actionName, String arg, boolean f
var action = MethodSpec.methodBuilder(actionName)
.addModifiers(context.publicFinalModifiers());
if (valueStrength() == Strength.STRONG) {
if (keyStrength() != Strength.STRONG) {
action.addStatement("key.clear()");
}
// Set the value to null only when dead, as otherwise the explicit removal of an expired async
// value will be notified as explicit rather than expired due to the isComputingAsync() check
if (finalized) {
Expand All @@ -77,7 +80,7 @@ private void addState(String checkName, String actionName, String arg, boolean f
action.addStatement("$1T valueRef = ($1T) $2L.get(this)",
valueReferenceType(), varHandleName("value"));
if (keyStrength() != Strength.STRONG) {
action.addStatement("$1T keyRef = ($1T) valueRef.getKeyReference()", referenceKeyType);
action.addStatement("$1T keyRef = ($1T) valueRef.getKeyReference()", referenceType);
action.addStatement("keyRef.clear()");
}
action.addStatement("valueRef.setKeyReference($N)", arg);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package com.github.benmanes.caffeine.cache.node;

import static com.github.benmanes.caffeine.cache.Specifications.kTypeVar;
import static com.github.benmanes.caffeine.cache.Specifications.referenceKeyType;
import static com.github.benmanes.caffeine.cache.Specifications.referenceType;

import java.util.List;

Expand Down Expand Up @@ -78,7 +78,7 @@ private void addIfCollectedValue() {
if (isStrongKeys()) {
getKey.addStatement("return ($T) valueRef.getKeyReference()", kTypeVar);
} else {
getKey.addStatement("$1T keyRef = ($1T) valueRef.getKeyReference()", referenceKeyType);
getKey.addStatement("$1T keyRef = ($1T) valueRef.getKeyReference()", referenceType);
getKey.addStatement("return keyRef.get()");
}
context.nodeSubtype.addMethod(getKey.build());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,26 @@ public boolean containsValue(Object value) {
return value;
}

/**
* Returns the key associated with the mapping in this cache, or {@code null} if there is none.
*
* @param key the key whose canonical instance is to be returned
* @return the key used by the mapping, or {@code null} if this cache does not contain a mapping
* for the key
* @throws NullPointerException if the specified key is null
*/
public @Nullable K getKey(K key) {
Node<K, V> node = data.get(nodeFactory.newLookupKey(key));
if (node == null) {
if (drainStatus() == REQUIRED) {
scheduleDrainBuffers();
}
return null;
}
afterRead(node, /* now */ 0L, /* recordStats */ false);
return node.getKey();
}

@Override
public Map<K, V> getAllPresent(Iterable<? extends K> keys) {
var result = new LinkedHashMap<Object, Object>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ public interface Cache<K extends Object, V extends Object> {
* cached value for the {@code key}.
*
* @param key the key whose associated value is to be returned
* @return the value to which the specified key is mapped, or {@code null} if this cache contains
* no mapping for the key
* @return the value to which the specified key is mapped, or {@code null} if this cache does not
* contain a mapping for the key
* @throws NullPointerException if the specified key is null
*/
@Nullable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ enum Strength { WEAK, SOFT }
static final int DEFAULT_REFRESH_NANOS = 0;

boolean strictParsing = true;
boolean interner;

long maximumSize = UNSET_INT;
long maximumWeight = UNSET_INT;
Expand Down Expand Up @@ -226,6 +227,14 @@ public static Caffeine<Object, Object> newBuilder() {
return new Caffeine<>();
}

/** Returns a cache that is optimized for weak reference interning (see {@link Interner}). */
@CheckReturnValue
static <K> BoundedLocalCache<K, Boolean> newWeakInterner() {
var builder = new Caffeine<K, Boolean>().executor(Runnable::run).weakKeys();
builder.interner = true;
return LocalCacheFactory.newBoundedLocalCache(builder, /* loader */ null, /* async */ false);
}

/**
* Constructs a new {@code Caffeine} instance with the settings specified in {@code spec}.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright 2022 Ben Manes. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.benmanes.caffeine.cache;

import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import com.github.benmanes.caffeine.cache.References.LookupKeyEqualsReference;
import com.github.benmanes.caffeine.cache.References.WeakKeyEqualsReference;

/**
* Provides similar behavior to {@link String#intern} for any immutable type.
* <p>
* Note that {@code String.intern()} has some well-known performance limitations, and should
* generally be avoided. Prefer {@link Interner#newWeakInterner} or another {@code Interner}
* implementation even for {@code String} interning.
*
* @param <E> the type of elements
* @author [email protected] (Ben Manes)
*/
@FunctionalInterface
public interface Interner<E extends Object> {

/**
* Chooses and returns the representative instance for any of a collection of instances that are
* equal to each other. If two {@linkplain Object#equals equal} inputs are given to this method,
* both calls will return the same instance. That is, {@code intern(a).equals(a)} always holds,
* and {@code intern(a) == intern(b)} if and only if {@code a.equals(b)}. Note that {@code
* intern(a)} is permitted to return one instance now and a different instance later if the
* original interned instance was garbage-collected.
* <p>
* <b>Warning:</b> do not use with mutable objects.
*
* @param sample the element to add if absent
* @return the representative instance, possibly the {@code sample} if absent
* @throws NullPointerException if {@code sample} is null
*/
E intern(E sample);

/**
* Returns a new thread-safe interner which retains a strong reference to each instance it has
* interned, thus preventing these instances from being garbage-collected.
*
* @param <E> the type of elements
* @return an interner for retrieving the canonical instance
*/
static <E> Interner<E> newStrongInterner() {
return new StrongInterner<>();
}

/**
* Returns a new thread-safe interner which retains a weak reference to each instance it has
* interned, and so does not prevent these instances from being garbage-collected.
*
* @param <E> the type of elements
* @return an interner for retrieving the canonical instance
*/
static <E> Interner<E> newWeakInterner() {
return new WeakInterner<>();
}
}

final class StrongInterner<E> implements Interner<E> {
final ConcurrentMap<E, E> map;

StrongInterner() {
map = new ConcurrentHashMap<>();
}
@Override public E intern(E sample) {
E canonical = map.get(sample);
if (canonical != null) {
return canonical;
}

var value = map.putIfAbsent(sample, sample);
if (value == null) {
return sample;
}
return value;
}
}

final class WeakInterner<E> implements Interner<E> {
final BoundedLocalCache<E, Boolean> cache;

WeakInterner() {
cache = Caffeine.newWeakInterner();
}
@Override public E intern(E sample) {
for (;;) {
E canonical = cache.getKey(sample);
if (canonical != null) {
return canonical;
}

var value = cache.putIfAbsent(sample, Boolean.TRUE);
if (value == null) {
return sample;
}
}
}
}

@SuppressWarnings({"unchecked", "NullAway"})
final class Interned<K, V> extends Node<K, V> implements NodeFactory<K, V> {
volatile Reference<?> keyReference;

Interned() {}

Interned(Reference<K> keyReference) {
this.keyReference = keyReference;
}
@Override public K getKey() {
return (K) keyReference.get();
}
@Override public Object getKeyReference() {
return keyReference;
}
@Override public V getValue() {
return (V) Boolean.TRUE;
}
@Override public V getValueReference() {
return (V) Boolean.TRUE;
}
@Override public void setValue(V value, ReferenceQueue<V> referenceQueue) {}
@Override public boolean containsValue(Object value) {
return Objects.equals(value, getValue());
}
@Override public Node<K, V> newNode(K key, ReferenceQueue<K> keyReferenceQueue,
V value, ReferenceQueue<V> valueReferenceQueue, int weight, long now) {
return new Interned<>(new WeakKeyEqualsReference<>(key, keyReferenceQueue));
}
@Override public Node<K, V> newNode(Object keyReference, V value,
ReferenceQueue<V> valueReferenceQueue, int weight, long now) {
return new Interned<>((Reference<K>) keyReference);
}
@Override public Object newLookupKey(Object key) {
return new LookupKeyEqualsReference<>(key);
}
@Override public Object newReferenceKey(K key, ReferenceQueue<K> referenceQueue) {
return new WeakKeyEqualsReference<K>(key, referenceQueue);
}
@Override public boolean isAlive() {
Object keyRef = keyReference;
return (keyRef != RETIRED_WEAK_KEY) && (keyRef != DEAD_WEAK_KEY);
}
@Override public boolean isRetired() {
return (keyReference == RETIRED_WEAK_KEY);
}
@Override public void retire() {
keyReference = RETIRED_WEAK_KEY;
}
@Override public boolean isDead() {
return (keyReference == DEAD_WEAK_KEY);
}
@Override public void die() {
keyReference.clear();
keyReference = DEAD_WEAK_KEY;
}
}
Loading

0 comments on commit a0662da

Please sign in to comment.