1. 简介
在编程领域,缓存是不可或缺的一部分,从处理器到应用层,其应用无处不在。从根本上讲,缓存是利用空间换取时间的一种策略,通过优化数据存储方式,提高后续数据访问速度。
对于Java开发者来说,有很多常用的缓存解决方案,例如EhCache和Memcached等。这些解决方案的核心目标是提高系统吞吐量,减轻数据库等持久层的压力。
根据其部署和应用范围,缓存可以分为本地缓存和分布式缓存两种类型。Caffeine是一种非常优秀的本地缓存解决方案,而Redis则广泛用于分布式缓存场景。
Caffeine是一个基于Java 1.8的高性能本地缓存库,源自Guava的改进。自Spring 5开始,Caffeine已成为默认的缓存实现,取代了原先的Google Guava。官方资料显示,Caffeine的缓存命中率已接近理论最优值。实际上,Caffeine与ConcurrentMap在功能上有许多相似之处,都支持并发操作,且数据的存取时间复杂度为O(1)。然而,二者在数据管理策略上存在显著差异:
- ConcurrentMap会保留存入的所有数据,除非用户显式地移除;
- 而Caffeine则根据预设的配置自动剔除“不常用”的数据,确保内存的合理使用。
因此,更恰当的理解是:Cache是一种具备存储和移除策略的Map。
2. 核心特性
- 高性能:Caffeine采用了多种优化技术,包括基于链表和哈希表的数据结构、优化的内存访问模式以及针对并发访问的优化算法,以减少缓存的内存占用和提高缓存访问速度。这使得它在读写操作上有着卓越的表现。
- 内存管理:Caffeine实现了自适应的内存管理,能够根据缓存的使用情况动态调整内存分配。它还支持不同的缓存过期策略,有效控制内存使用。
- 过期策略:Caffeine支持多种缓存过期策略,如基于时间、基于大小、基于引用等,同时也允许用户自定义过期策略。
- 简洁而强大的API:Caffeine提供了简洁而强大的API,使得缓存的创建和使用变得相对简单。
- 缓存加载器:Caffeine提供了CacheLoader接口,使得异步加载和刷新缓存项变得更容易。
- 监听器和事件:可以使用监听器跟踪缓存的变化,对缓存进行事件监听和处理。
3. Caffeine使用
3.1 手动创建
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine; import java.util.concurrent.TimeUnit; public class CaffeineManualExample { // 创建一个指定最大容量的缓存,缓存项在写入后10分钟过期 private static final Cache<String, String> CACHE = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); public static void main(String[] args) { // 向缓存中添加数据 CACHE.put("key1", "value1"); CACHE.put("key2", "value2"); // 从缓存中获取数据 String value1 = CACHE.getIfPresent("key1"); System.out.println("Value for key1: " + value1); // 等待一段时间后,缓存项过期,再次尝试获取将返回null try { Thread.sleep(TimeUnit.MINUTES.toMillis(15)); } catch (InterruptedException e) { e.printStackTrace(); } String value2 = CACHE.getIfPresent("key2"); System.out.println("Value for key2 (after expiration): " + value2); // 输出:null }
}
3.2 自动创建
在Spring Boot应用中,你可以使用@Cacheable
和@CacheEvict
注解来自动创建缓存。首先,确保你的Spring Boot项目已经添加了Spring Boot Cache Starter的依赖。
常用注解
- @Cacheable :表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回null时,将不进行缓存操作。
- @CachePut :表示执行该方法后,其值将作为最新结果更新到缓存中,每次都会执行该方法。
- @CacheEvict :表示执行该方法后,将触发缓存清除操作。
- @Caching :用于组合前三个注解。
常用注解属性
- cacheNames/value :缓存组件的名字,即cacheManager中缓存的名称。
- key :缓存数据时使用的key。默认使用方法参数值,也可以使用SpEL表达式进行编写。
- keyGenerator :和key二选一使用。
- cacheManager :指定使用的缓存管理器。
- condition :在方法执行开始前检查,在符合condition的情况下,进行缓存。
- unless :在方法执行完成后检查,在符合unless的情况下,不进行缓存。
- sync :是否使用同步模式。若使用同步模式,在多个线程同时对一个key进行load时,其他线程将被阻塞。
下面是一个简单的例子:
- 添加依赖:在
pom.xml
中添加Spring Boot Cache Starter的依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
- 配置缓存:在
application.properties
或application.yml
中配置Caffeine作为缓存提供者
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=60m # 指定缓存最大容量为500,缓存项在写入后60分钟过期。
- 使用注解:创建一个服务类,并使用
@Cacheable
和@CacheEvict
注解来标记方法
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service; @Service
public class ExampleService { @Cacheable(value = "exampleCache") // 将方法结果缓存到名为"exampleCache"的缓存中。 public String getData() { // 模拟耗时操作,如数据库查询。这里只是返回一个字符串。 return "data"; }@CacheEvict(value = "exampleCache", key = "#id") // 从名为"exampleCache"的缓存中移除指定键(#id)的缓存项。 public void evictData(String id) { // 该方法目前没有实际功能,只是为了演示如何使用@CacheEvict。 }
}
3.3 Caffeine的异步获取
Caffeine的异步获取功能允许你在缓存数据加载时执行异步操作,从而减少应用程序的等待时间。你可以使用CacheLoader
接口来定义异步加载缓存项的逻辑。当缓存中没有对应的数据时,Caffeine会自动触发CacheLoader
的实现类来异步加载数据。一旦数据加载完成,它将被存入缓存并返回给调用者。使用异步获取功能可以解决应用程序在等待数据加载时阻塞的问题,提高整体性能。
使用示例:
- 定义一个实现
CacheLoader
接口的类,实现load
方法:
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.LoadingCache; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit; public class AsyncDataLoader implements CacheLoader<String, String> { @Override public CompletableFuture<String> load(String key) throws Exception { // 模拟耗时操作,如数据库查询。这里只是返回一个字符串。 String data = "Async data for key: " + key; return CompletableFuture.completedFuture(data); // 返回异步加载的结果。 }
}
- 创建一个基于
AsyncDataLoader
的LoadingCache
实例:
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache; import java.util.concurrent.TimeUnit; public class CaffeineAsyncExample { public static void main(String[] args) { // 创建异步加载缓存的实例,指定缓存的容量和过期时间等配置。 LoadingCache<String, String> cache = Caffeine.newBuilder() .maximumSize(100) // 缓存最大容量为100个键值对。 .expireAfterWrite(10, TimeUnit.MINUTES) // 缓存项在写入后10分钟过期。 .build(new AsyncDataLoader()); // 使用自定义的AsyncDataLoader作为加载器。 // 从缓存中获取数据,如果缓存中没有数据,则会触发异步加载。 String data = cache.get("key1"); // 返回的数据可能是null,因为此时数据可能正在异步加载中。 System.out.println("Data for key1: " + data); // 输出可能为null,因为数据可能还没有加载完成。 }
}
在上面的示例中,我们定义了一个AsyncDataLoader
类来实现CacheLoader
接口。该类中的load
方法用于异步加载数据。然后,我们使用Caffeine的LoadingCache
构建器创建了一个基于AsyncDataLoader
的缓存实例。当从缓存中获取数据时,如果缓存中没有对应的数据,Caffeine会自动调用AsyncDataLoader
的load
方法进行异步加载。请注意,获取到的数据可能为null,因为此时数据可能正在异步加载中。你可以根据实际需求对异步加载的数据进行处理。
3.4 驱逐策略
Caffeine 的驱逐策略决定了当缓存满了之后,哪些缓存项会被移除。了解驱逐策略对于正确使用缓存和优化缓存性能非常重要。
Caffeine 支持多种驱逐策略,以下是其中一些:
- 最近最少使用(LRU):这是最常见的驱逐策略,它将最长时间未被使用的缓存项驱逐出缓存。当缓存满了并且需要添加新的缓存项时,它会移除最长时间未被使用的缓存项。
- 先进先出(FIFO):这种策略按照缓存项的插入顺序进行驱逐。当缓存满了并且需要添加新的缓存项时,它会移除最早插入的缓存项。
- 基于大小的驱逐:这种策略根据缓存项的大小进行驱逐。当缓存满了并且需要添加新的缓存项时,它会移除最小的缓存项。这对于需要存储大量数据的场景非常有用,例如图片缓存。
- 基于时间的驱逐:这种策略根据缓存项的过期时间进行驱逐。当缓存满了并且需要添加新的缓存项时,它会移除最早过期的缓存项。
- 基于引用计数的驱逐:这种策略使用引用计数来跟踪缓存项的引用次数。当缓存满了并且需要添加新的缓存项时,它会移除引用计数最低的缓存项。这可以帮助避免热点数据占据整个缓存空间。
- 基于权重的驱逐:这种策略根据缓存项的权重进行驱逐。当缓存满了并且需要添加新的缓存项时,它会移除权重最小的缓存项。这可以帮助在缓存中保留更有价值的数据。
在 Caffeine 中,你可以通过 Cache
构造函数的第二个参数来指定驱逐策略。例如,如果你想使用 LRU 驱逐策略,你可以这样做:
Cache<KeyType, ValueType> cache = Caffeine.newBuilder() .maximumSize(100) // 设置最大容量为100 .evictionPolicy(Eviction.LRU) // 设置驱逐策略为LRU .build();
3.5 刷新机制
在 Caffeine 中,缓存数据在达到一定条件时会进行刷新,以保持缓存的时效性。下面将对 Caffeine 的刷新机制进行详细讲解。
- 缓存项过期
Caffeine 支持设置缓存项的过期时间,当缓存项过期后,Caffeine 会自动将其从缓存中删除。默认情况下,Caffeine 使用 JVM 的当前时间作为过期时间的起始点,即从缓存项创建或最后一次修改时开始计时。当缓存项过期后,Caffeine 会触发一次刷新操作,重新加载缓存项的值。
- 懒加载
Caffeine 支持懒加载机制,即只有在访问已过期的缓存项时,才会触发刷新操作。这种机制可以减少不必要的缓存刷新操作,提高缓存的效率。当访问已过期的缓存项时,Caffeine 会异步地执行刷新操作,同时返回缓存项的旧值。一旦刷新操作完成,缓存项的值将被更新,下次访问时将返回新值。
- 主动刷新
除了基于过期时间和懒加载的自动刷新外,Caffeine 还支持主动刷新机制。通过调用 Caffeine 实例的 refresh
方法,可以强制刷新指定的缓存项。这种机制适用于在某些情况下需要立即获取最新数据的情况。需要注意的是,主动刷新可能会对缓存性能产生一定的影响,因此应该谨慎使用。
- 并发控制
在高并发环境下,多个线程可能同时访问已过期的缓存项,导致多个线程同时执行刷新操作。为了避免这种情况,Caffeine 提供了并发控制机制。通过设置 concurrencyLevel
参数,可以指定每个缓存项允许的最大并发刷新操作数。当超过这个限制时,后续的刷新请求将被阻塞或失败,直到有空闲的刷新槽位。这种机制可以有效地防止过多的线程同时执行刷新操作,提高缓存的性能和稳定性。
下面是一个使用 Caffeine 刷新机制的示例:
首先,定义一个实现了 Refreshable
接口的类,该接口有一个 refresh
方法用于刷新缓存项:
public class Data implements Refreshable<Data> { private String value; private long timestamp; public Data(String value) { this.value = value; this.timestamp = System.currentTimeMillis(); } public String getValue() { return value; } public long getTimestamp() { return timestamp; } @Override public Data refresh(long ttlMillis) { // 在这里实现刷新逻辑,例如从数据库或其他数据源获取最新数据 // 并更新 timestamp 和 value。 // 如果 ttlMillis 大于 0,表示设置一个时间限制,超过该时间后缓存项将被驱逐。 return this; }
}
然后,在 Caffeine 缓存配置中,使用 refreshAfterWrite
方法指定刷新时间间隔:
Cache<String, Data> cache = Caffeine.newBuilder() .maximumSize(100) // 设置最大容量为100 .expireAfterWrite(10, TimeUnit.MINUTES) // 设置写入后10分钟过期 .refreshAfterWrite(5, TimeUnit.MINUTES) // 设置写入后5分钟刷新一次 .build(key -> new Data(key)); // 自定义加载函数,将键转换为 Data 对象
最后,在需要使用缓存的地方,从缓存中获取数据,并在必要时刷新缓存项:
String key = "exampleKey";
Data data = cache.get(key);
if (data != null && System.currentTimeMillis() - data.getTimestamp() > 5 * 60 * 1000) { // 判断是否超过5分钟未刷新 data = data.refresh(30 * 60 * 1000); // 调用 refresh 方法刷新缓存项,设置30分钟TTL(可选) cache.put(key, data); // 将刷新后的数据重新放入缓存中(可选)
}
通过上述示例,你可以使用 Caffeine 的刷新机制来自动更新缓存中的数据。需要注意的是,刷新逻辑的具体实现取决于你的业务需求,可能需要从数据库或其他数据源获取最新数据。
4. 总结
Java Caffeine 是一个高效的缓存库,它通过灵活的配置和多种驱逐策略提供了强大的缓存功能。在 Java 应用程序中,Caffeine 可以帮助我们提高数据访问速度,减轻数据库负载,并改善应用程序的性能。
总的来说,Java Caffeine 是一个功能强大、易于使用的缓存库。通过合理地使用 Caffeine,我们可以提高应用程序的性能和响应速度,并降低对数据库的依赖。在处理大量数据和高并发请求时,Caffeine 可以发挥出其优势,成为 Java 开发者的有力助手。