CaffeineCache Api介绍以及与Guava Cache性能对比| 京东物流技术团队

一、简单介绍:

CaffeineCache和Guava的Cache是应用广泛的本地缓存。

在开发中,为了达到降低依赖、提高访问速度的目的。会使用它存储一些维表接口的返回值和数据库查询结果,在有些场景下也会在分布式缓存上再加上一层本地缓存,用来减少对远程服务和数据库的请求次数。

CaffeineCache是以Guava Cache为原型库开发和扩展的一种本地缓存,并且适配Guava Cache的Api,但是CaffeineCache的性能更好。

二、CaffeineCache的使用:

CaffeineCache官方介绍有提供一些例子,不过这些例子不能直接运行。

下面围绕比较常用的API介绍下CaffeineCache的使用,列举一些可直接执行的Demo,看起来明了一些。

1.存数据:

Caffeine提供了四种缓存添加策略:手动加载,自动加载,手动异步加载和自动异步加载。

1.1手动加载:

javascript 复制代码
        Cache<String, String> cache = Caffeine.newBuilder()
                 //过期时间 
                .expireAfterWrite(10, TimeUnit.MINUTES)
                 //最大容量 
                .maximumSize(10_000)
                .build();
        String key = "test";
        // 查找一个缓存元素, 没有查找到的时候返回null 
        String res = cache.get(key, k -> createValue(key));
        // 添加或者更新一个缓存元素 
        cache.put(key, "testValue"); 
        // 移除一个缓存元素
        cache.invalidate(key);
    }

     // 模拟从外部数据源加载数据的逻辑 
    static String createValue(String key) {
        return "value";
    }

推荐使用 get(K var1, Function<? super K, ? extends V> var2);

get方法可以在缓存中不存在该key对应的值时进行计算,生成并直接写入至缓存内,最后将结果返回,而当该key对应的值存在时将会直接返回值。

注意到createValue方法有可能会出现异常,根据官网所说:"当缓存的元素无法生成或者在生成的过程中抛出异常而导致生成元素失败,cache.get 也许会返回 null ",那么实际情况怎么样呢?我们来试一下。

typescript 复制代码
public class TestCaffeineCache {
    public static void main(String[] args) {
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)
                .build();
        String key = "test";
        String res = cache.get(key, k -> createValue(key));
        System.out.println(res);
    }
    
    // 模拟从外部数据源加载数据的逻辑 
    static String createValue(String key) {
        //模拟异常情况
        int a = 1/0;
        return "";
    }
}

运行结果:

可以看到,执行cache.get时,在生成结果的过程中如果出现异常了,cache.get不会返回null,仍会直接报错。

1.2自动加载

typescript 复制代码
public class TestCaffeineCache {
    public static void main(String[] args) {
         LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String key) {
                        return getValue(key);
                    }
                });

        String value = cache.get("key");
        System.out.println(value);
    }

    // 模拟从外部数据源加载数据的逻辑
    private static String getValue(String key) {
        // 实际情况下,这里会有从数据库、远程服务加载数据的逻辑
        return "value";
    }
}

可以用lambda简化它:

typescript 复制代码
public class TestCaffeineCache {
    public static void main(String[] args) {
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(key -> getValue(key));

        String value = cache.get("key");
        System.out.println(value);
    }

    // 模拟从外部数据源加载数据的逻辑
    private static String getValue(String key) {
        return "value";
    }
}

上面的示例中, build方法传入的CacheLoader定义了加载缓存的逻辑。调用cache.get("key")时,如果缓存中不存在对应的值,CacheLoader会调用load方法来加载和缓存值。

可以通过重写和CacheLoader.load和loadAll并手动调用,在LoadingCache创建之前提前加载一些数据。

typescript 复制代码
    public static void main(String[] args) throws Exception {
        CacheLoader loader = new CacheLoader<String,String>() {
            @Override
            public String load( String s) throws Exception {
                return getValue(s);
            }
            @Override
            public Map<String, String> loadAll(Iterable<? extends String> keys) throws Exception {
                Map currentMap = new HashMap<String,String>();
                for (String key : keys) {
                    currentMap.put(key, getValue(key));
                }
                return currentMap;
            }
        };

        loader.load("key1");
        loader.loadAll(new ArrayList( Arrays.asList("key2","key3")));
        LoadingCache<String, String> cache = Caffeine.newBuilder().build(loader);
        String value = cache.get("key1");
        String value2 = cache.get("key2");
        System.out.println(value+value2);
    }

    // 模拟从外部数据源加载数据的逻辑
    private static String getValue(String key) {
        return "value";
    }

1.3手动异步加载:

vbnet 复制代码
    public static void main(String[] args) throws Exception {

        AsyncCache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000).buildAsync();
        String key ="test";
        CompletableFuture<String> res = cache.get(key,k-> getValue(key));
        res.thenAccept(result -> System.out.println(result));
    }

    // 模拟从外部数据源加载数据的逻辑
    private static String getValue(String key) {
        return "value";
    }
    

异步加载使用的类是AsyncCache,使用方法和Cache类似。cache.get(key, k -> getValue(key))将会返回一个CompletableFuture,这一步骤会在一个异步任务中执行,而不会阻塞主线程。res.thenAccept方法将在数据加载完成后得到结果。

1.4自动异步加载:

typescript 复制代码
    public static void main(String[] args) throws Exception {
        Executor executor = Executors.newFixedThreadPool(5);
        AsyncLoadingCache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(10_000)
                //异步的封装一段同步操作来生成缓存元素
                .buildAsync(key -> getValue(key))
                //OR建一个异步缓存元素操作并返回一个future
                .buildAsync((key,executor1) -> getValue(key,executor));
        String key = "test";
        CompletableFuture<String> res = cache.get(key);
        res.thenAccept(result -> System.out.println(result));
    }

    // 模拟从外部数据源加载数据的逻辑
    private static CompletableFuture<String> getValue(String key,Executor executor) {
        return CompletableFuture.supplyAsync(() -> "value for " + key, executor);
    }
     private static String getValue(String key) { 
         return "value"; 
    }
   
     

自动异步加载使用方法和手动异步加载类似,getValue可接收一个Executor对象,用于自定义执行异步操作的线程池。

2.驱逐:

2.1基于容量:

ini 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(10_000)
                .build();

最常用的驱逐策略了,Caffeine提供多种算法根据最近使用频率和使用时间来驱逐元素 ref:Window-TinyLFU

2.2基于权重:

arduino 复制代码
class Product {
    private String name;
    private int weight;

    public Product(String s, int i) {
        name=s;
        weight=i;
    }
    public String getName() {
        return name;
    }
    public int getWeight() {
        return weight;
    }
    @Override
    public String toString(){
        return getName();
    }
}

public class TestCaffeineCache {
    public static void main(String[] args) {
        Cache<String, Product> cache = Caffeine.newBuilder()
                .maximumWeight(1000)
                .weigher((String key, Product value) -> value.getWeight())
                //使用当前线程进行驱逐和刷新
                .executor(runnable -> runnable.run())
                //监听器,如果有元素被驱逐则会输出
                .removalListener(((key, value, cause) -> {
                    System.out.printf("Key %s was evicted (%s)%n", key, cause);
                }))
                .build();
        // 向缓存中添加商品信息
        cache.put("product1", new Product("Product 1", 200));
        cache.put("product2", new Product("Product 2", 400));
        cache.put("product3", new Product("Product 3", 500));
        // 获取缓存中的商品信息
        System.out.println(cache.getIfPresent("product1"));
        System.out.println(cache.getIfPresent("product2"));
        System.out.println(cache.getIfPresent("product3"));
    }
}

.weigher((String key, Product value) -> value.getWeight()) 制定了一个权重计算器,Product对象的getWeight()方法来计算权重。

通过示例中的返回结果可以看到,当product3被put后,总容量超过了1000,product1就被驱逐了。

2.3基于时间:

附上官方的例子:

scss 复制代码
// 基于固定的过期时间驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterAccess(5, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));

// 基于不同的过期驱逐策略
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .expireAfter(new Expiry<Key, Graph>() {
      public long expireAfterCreate(Key key, Graph graph, long currentTime) {
        // Use wall clock time, rather than nanotime, if from an external resource
        long seconds = graph.creationDate().plusHours(5)
            .minus(System.currentTimeMillis(), MILLIS)
            .toEpochSecond();
        return TimeUnit.SECONDS.toNanos(seconds);
      }
      public long expireAfterUpdate(Key key, Graph graph, 
          long currentTime, long currentDuration) {
        return currentDuration;
      }
      public long expireAfterRead(Key key, Graph graph,
          long currentTime, long currentDuration) {
        return currentDuration;
      }
    })
    .build(key -> createExpensiveGraph(key));

Caffeine提供了三种方法进行基于时间的驱逐------官方的解释:

expireAfterAccess(long, TimeUnit): 一个元素在上一次读写操作后一段时间之后,在指定的时间后没有被再次访问将会被认定为过期项。在当被缓存的元素时被绑定在一个session上时,当session因为不活跃而使元素过期的情况下,这是理想的选择。

expireAfterWrite(long, TimeUnit): 一个元素将会在其创建或者最近一次被更新之后的一段时间后被认定为过期项。在对被缓存的元素的时效性存在要求的场景下,这是理想的选择。

expireAfter(Expiry): 一个元素将会在指定的时间后被认定为过期项。当被缓存的元素过期时间收到外部资源影响的时候,这是理想的选择。

写一个Demo举例expireAfterAccess和expireAfterWrite:

arduino 复制代码
    public static void main(String[] args) {
        //模拟时间,使用的com.google.common.testing.FakeTicker;
        FakeTicker ticker = new FakeTicker();
        Cache<String, String> cache = Caffeine.newBuilder()
                 //创建20分钟后元素被删除
                .expireAfterWrite(20, TimeUnit.MINUTES)
                 //没有读取10分钟后元素被删除 
                .expireAfterAccess(10, TimeUnit.MINUTES)
                .executor(Runnable::run)
                .ticker(ticker::read)
                .build();
        cache.put("key1","value1");
        cache.put("key2","value2");
        ticker.advance(5, TimeUnit.MINUTES);
        System.out.println("5分钟都不删除,访问一次key2:"+cache.getIfPresent("key2"));
        ticker.advance(5, TimeUnit.MINUTES);
        System.out.println("10分钟key1被删除,因为它已经10分钟没有被访问过了:"+cache.getIfPresent("key1"));
        System.out.println("10分钟key2没有被删除,因为它在5分钟时被访问过了:"+cache.getIfPresent("key2"));
        ticker.advance(10, TimeUnit.MINUTES);
        System.out.println("20分钟key2也被删除:"+cache.getIfPresent("key2"));
    }

这个例子设定元素创建20分钟或者没有读取10分钟后被删除。

key1和key2在创建后。5分钟时访问一次key2,十分钟时key1被删除,key2没有被删除,20分钟时key2也被删除。

运行结果正如我们期待的:

举例expireAfter:

arduino 复制代码
    public static void main(String[] args) {
        //模拟时间,使用的com.google.common.testing.FakeTicker;
        FakeTicker ticker = new FakeTicker();
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfter(new Expiry<String, String>() {
                    public long expireAfterCreate(String key, String value, long currentTime) {
                        // 在创建后的24小时后过期
                        return TimeUnit.HOURS.toNanos(24);
                    }
                    public long expireAfterUpdate(String key, String value, long currentTime, long currentDuration) {
                        // 在更新后如果值为"1234",则立马过期
                        if("1234".equals(value)){
                            return 0;
                        }
                        // 在更新后的1小时后过期
                        return TimeUnit.HOURS.toNanos(1);
                    }
                    public long expireAfterRead(String key, String value, long currentTime, long currentDuration) {
                        // 在读取后的20小时后过期
                        return TimeUnit.HOURS.toNanos(20);
                    }
                })
                .executor(Runnable::run)
                .ticker(ticker::read)
                .build();
        cache.put("AfterCreateKey","AfterCreate");
        cache.put("AfterUpdate1234Key","1234key");
        cache.put("AfterUpdateKey","AfterUpdate");
        cache.put("AfterReadKey","AfterRead");
        //AfterUpdate1234Key值更新为1234
        cache.put("AfterUpdate1234Key","1234");
        System.out.println("AfterUpdate1234Key在更新后值为1234,立马过期:"+cache.getIfPresent("AfterUpdate1234Key"));
        System.out.println("AfterReadKey读取一次:"+cache.getIfPresent("AfterReadKey"));
        //AfterUpdateKey更新一次
        cache.put("AfterUpdateKey","AfterUpdate");
        ticker.advance(1, TimeUnit.HOURS);
        System.out.println("AfterUpdateKey更新了一个小时了,被删除:"+cache.getIfPresent("AfterUpdateKey"));
        ticker.advance(19, TimeUnit.HOURS);
        System.out.println("AfterReadKey再读取一次已经删除了,因为上一次读取已经过了20小时:"+cache.getIfPresent("AfterReadKey"));
        ticker.advance(4, TimeUnit.HOURS);
        System.out.println("AfterCreateKey被删除了,距离创建已经24小时了:"+cache.getIfPresent("AfterCreateKey"));
    }

这个例子设定了元素在以下四种情况会过期:

•创建后的24小时

•更新后值为"1234"

•更新后的1小时

•在读取后的20小时

以下是运行结果

2.4基于引用:

基于引用的过期驱逐策略不常用,这里附上官方的例子和解释:

scss 复制代码
// 当key和缓存元素都不再存在其他强引用的时候驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .weakKeys()
    .weakValues()
    .build(key -> createExpensiveGraph(key));

// 当进行GC的时候进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
    .softValues()
    .build(key -> createExpensiveGraph(key));

Caffeine 允许你配置缓存,以便GC去帮助清理缓存当中的元素,其中key支持弱引用,而value则支持弱引用和软引用。AsyncCache不支持软引用和弱引用。

Caffeine.weakKeys() 在保存key的时候将会进行弱引用。这允许在GC的过程中,当key没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行key之间的比较。

Caffeine.weakValues()在保存value的时候将会使用弱引用。这允许在GC的过程中,当value没有被任何强引用指向的时候去将缓存元素回收。由于GC只依赖于引用相等性。这导致在这个情况下,缓存将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

Caffeine.softValues()在保存value的时候将会使用软引用。为了相应内存的需要,在GC过程中被软引用的对象将会被通过LRU算法回收。由于使用软引用可能会影响整体性能,我们还是建议通过使用基于缓存容量的驱逐策略代替软引用的使用。同样的,使用 softValues() 将会通过引用相等(==)而不是对象相等 equals()去进行value之间的比较。

3.移除:

3.1移除的三个方法:

可以调用以下三个方法移除缓存中的元素

less 复制代码
// 失效key
void invalidate(@CompatibleWith("K") @NonNull Object var1);
// 批量失效key
void invalidateAll(@NonNull Iterable<?> var1);
// 失效所有的key
void invalidateAll();

3.2监听器removalListener和evictionListener

当元素从缓存中被移除时,这两个监听器可以进行指定的操作,具体有什么区别呢?先上例子:

less 复制代码
        Cache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(2)
                .executor(Runnable::run)
                //驱逐,删除key时会输出
                .removalListener(((key, value, cause) -> {
                    System.out.printf("removalListener--->Key %s was evicted (%s)%n", key, cause);
                }))
                 //驱逐key时会输出 
                .evictionListener(((key, value, cause) -> {
                    System.out.printf("evictionListener->Key %s was evicted (%s)%n", key, cause);
                }))
                .build();
        // 向缓存中添加商品信息
        cache.put("product1", "product1");
        cache.put("product2", "product2");
        cache.put("product3", "product3");
        // 获取缓存中的商品信息
        System.out.println(cache.getIfPresent("product1"));
        System.out.println(cache.getIfPresent("product2"));
        System.out.println(cache.getIfPresent("product3"));
        cache.invalidateAll();

结果:

可以发现,当元素被驱逐,或者被手动移除时,removalListener都会执行指定的操作。而evictionListener只会在元素被驱逐时执行指定的操作。

4.刷新:

4.1自动刷新:

可以使用refreshAfterWrite在元素写入一段时间后刷新元素,先上代码

arduino 复制代码
    public static void main(String[] args) {
        
        FakeTicker ticker = new FakeTicker();
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .refreshAfterWrite(5, TimeUnit.SECONDS) // 在写入后5秒钟自动刷新
                .ticker(ticker::read)
                .executor(Runnable::run)
                .build(key -> getVale(key)); // 提供加载方法

        System.out.println("Initial value for key1: " + cache.get("key1"));

        // 超过自动刷新时间
        ticker.advance(7, TimeUnit.SECONDS);

        System.out.println(cache.get("key1")); // 真正执行刷新
        System.out.println(cache.get("key1")); // 输出自动刷新后的值
    }

    private static String getVale(String key) {
        // 这里简单地返回一个当前时间的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }

输出结果:

可以发现过了刷新时间后,第一次访问key1并没有返回新值,第二次访问key1时才会将刷新后的数据返回,官方的解释是元素过了刷新时间不会立即刷新,而是在在访问时才会刷新,并且没有刷新完毕,其旧值将仍被返回,直到该元素的刷新完毕后结束后才会返回刷新后的新值。

4.2手动刷新:

可以使用refresh(Key)方法进行手动刷新

typescript 复制代码
    public static void main(String[] args) {
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .build(key -> getVale(key)); // 提供加载方法

        System.out.println("Initial value for key1: " + cache.get("key1"));
        cache.refresh("key1");
        System.out.println(cache.get("key1")); // 输出自动刷新后的值
    }

    private static String getVale(String key) {
        // 这里简单地返回一个当前时间的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }

4.3刷新自定义处理:

可以使用CacheLoader.reload(K, V)来自定义刷新前后值的处理,下面这个例子重写了reload方法,将新值和旧值用"|"分开:

typescript 复制代码
    public static void main(String[] args) {
        FakeTicker ticker = new FakeTicker();
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .refreshAfterWrite(5, TimeUnit.SECONDS) // 在写入后5秒钟自动刷新
                .ticker(ticker::read)
                .build(new CacheLoader<String, String>() {
                    @Override
                    public String load(String s) throws Exception {
                        return getVale(s);
                    }
                    //将刷新前后的数据都获取出来了
                    @Override
                    public String reload(String s,String v){
                        return getVale(s)+"|"+v;
                    }
                }); // 提供加载方法
        System.out.println("Initial value for key1: " + cache.get("key1"));
        // 等待超过自动刷新时间
        ticker.advance(7, TimeUnit.SECONDS);
        cache.get("key1");
        System.out.println(cache.get("key1")); // 输出自动刷新后的值
    }

    private static String getVale(String key) {
        // 这里简单地返回一个当前时间的字符串
        return "loaded value for " + key + " at " + System.currentTimeMillis();
    }

结果:

三、性能对比:

我们知道,Caffeine的性能比Guava Cache要好,可以写一个demo简单对比一下:

1.Caffeine Demo:

ini 复制代码
package test;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

public class CaffeineCacheTest {

    public static void main(String[] args) throws Exception {
        Cache<Integer, Integer> loadingCache = Caffeine.newBuilder()
                .build();

        // 开始时间
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.put(i, i);
        }
        // 存完成时间
        Long writeFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.getIfPresent(i);
        }
        // 读取完成时间
        Long readFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.invalidate(i);
        }
        // 删除完成时间
        Long deleteFinishTime = System.currentTimeMillis();
        System.out.println("CaffeineCache存用时:" + (writeFinishTime - start));
        System.out.println("CaffeineCache读用时:" + (readFinishTime - writeFinishTime));
        System.out.println("CaffeineCache删用时:" + (deleteFinishTime - readFinishTime));
    }
}

运行结果:

2.Guava Cache Demo:

使用几乎一致的API,换成Guava Cache再试一次:

ini 复制代码
package test;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

public class GuavaCacheTest {

    public static void main(String[] args) throws Exception {
        Cache<Integer, Integer> loadingCache = CacheBuilder.newBuilder()
                .build();

        // 开始时间
        Long start = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.put(i, i);
        }
        // 存完成时间
        Long writeFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.getIfPresent(i);
        }
        // 读取完成时间
        Long readFinishTime = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            loadingCache.invalidate(i);
        }
        // 删除完成时间
        Long deleteFinishTime = System.currentTimeMillis();
        System.out.println("GuavaCache存用时:"+(writeFinishTime-start));
        System.out.println("GuavaCache取用时:"+(readFinishTime-writeFinishTime));
        System.out.println("GuavaCache删用时:"+(deleteFinishTime-readFinishTime));
    }

}

运行结果:

3.多组测试结果:

运行环境:处理器:Apple M3 ,内存:18 GB,JDK1.8

更改循环次数,多组测试结果如下(单位ms):

缓存 Caffeine Guava Cache Caffeine Guava Cache Caffeine Guava Cache Caffeine Guava Cache
次数 100 100 10000 10000 1000000 1000000 5000000 5000000
存用时 1 2 17 51 113 279 3802 2458
取用时 0 0 9 16 25 141 47 531
删用时 0 11 7 25 35 176 89 1073

可以看出Caffeine的总体性能是比Guava Cache要好的。

当然,基于本地单机的简单测试,结果受处理器,线程,内存等影响较大。可以参考下官方的测试,有更高的参考意义:官方测试。

四、总结:

本文举了很多的例子,介绍了Caffeine支持的多种基础的操作,包括存、取、删等。以及异步、监听、刷新等更多拓展的操作,能够覆盖大部分需要本地缓存的开发场景。

Caffeine的性能比Guava Cache更好,并列举了一个性能测试demo,Caffeine兼容Guava Cache的API,所以从Guava Cache迁移至Caffeine也比较容易。

最后附上Caffeine的官方网址:官方网址(中文)。

作者:京东物流 殷世杰

来源:京东云开发者社区

相关推荐
安的列斯凯奇20 分钟前
SpringBoot篇 单元测试 理论篇
spring boot·后端·单元测试
架构文摘JGWZ1 小时前
FastJson很快,有什么用?
后端·学习
BinaryBardC1 小时前
Swift语言的网络编程
开发语言·后端·golang
邓熙榆1 小时前
Haskell语言的正则表达式
开发语言·后端·golang
专职4 小时前
spring boot中实现手动分页
java·spring boot·后端
Ciderw4 小时前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·
m0_748246355 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
m0_748230445 小时前
创建一个Spring Boot项目
java·spring boot·后端
卿着飞翔5 小时前
Java面试题2025-Mysql
java·spring boot·后端
C++小厨神5 小时前
C#语言的学习路线
开发语言·后端·golang