使用Guava?Cache原理及最佳實踐
緩存的種類有很多,需要根據(jù)不同的應用場景來選擇不同的cache,比如分布式緩存如redis、memcached,還有本地(進程內(nèi))緩存如:ehcache、GuavaCache、Caffeine。
本篇主要圍繞全內(nèi)存緩存-Guava Cache做一些詳細的講解和分析。
1. Guava Cache是什么
1.1 簡介
Guava cache是一個支持高并發(fā)的線程安全的本地緩存。多線程情況下也可以安全的訪問或者更新Cache。這些都是借鑒了ConcurrentHashMap的結(jié)果,不過,guava cache 又有自己的特性 :
"automatic loading of entries into the cache"
即 :當cache中不存在要查找的entry的時候,它會自動執(zhí)行用戶自定義的加載邏輯,加載成功后再將entry存入緩存并返回給用戶未過期的entry,如果不存在或者已過期,則需要load,同時為防止多線程并發(fā)下重復加載,需要先鎖定,獲得加載資格的線程(獲得鎖的線程)創(chuàng)建一個LoadingValueRefrerence并放入map中,其他線程等待結(jié)果返回。
1.2 核心功能
- 自動將entry節(jié)點加載進緩存結(jié)構(gòu)中;
- 當緩存的數(shù)據(jù)超過設(shè)置的最大值時,使用LRU算法移除;
- 具備根據(jù)entry節(jié)點上次被訪問或者寫入時間計算它的過期機制;
- 緩存的key被封裝在
WeakReference
引用內(nèi); - 緩存的Value被封裝在
WeakReference
或SoftReference
引用內(nèi); - 統(tǒng)計緩存使用過程中命中率、異常率、未命中率等統(tǒng)計數(shù)據(jù)。
小結(jié):Guava Cache說簡單點就是一個支持LRU的ConcurrentHashMap,并提供了基于容量,時間和引用的緩存回收方式。(簡單概括)
1.3 適用場景
- 愿意消耗一些內(nèi)存空間來提升速度(以空間換時間,提升處理速度);
- 能夠預計某些key會被查詢一次以上;
- 緩存中存放的數(shù)據(jù)總量不會超出內(nèi)存容量(
Guava Cache
是單個應用運行時的本地緩存)。
- 計數(shù)器(如可以利用基于時間的過期機制作為限流計數(shù))
2. Guava Cache的使用
GuavaCache使用時主要分二種模式:LoadingCache
、CallableCache
核心區(qū)別在于:LoadingCache創(chuàng)建時需要有合理的默認方法來加載或計算與鍵關(guān)聯(lián)的值,CallableCache創(chuàng)建時無需關(guān)聯(lián)固定的CacheLoader使用起來更加靈活。
前置準備:
- 引入jar包
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>29.0-jre</version> </dependency>
- 了解CacheBuilder的配置方法
- mock RPC調(diào)用方法,用于獲取數(shù)據(jù)
private static List<String> rpcCall(String cityId) { // 模仿從數(shù)據(jù)庫中取數(shù)據(jù) try { switch (cityId) { case "0101": System.out.println("load cityId:" + cityId); return ImmutableList.of("上海", "北京", "廣州", "深圳"); } } catch (Exception e) { // 記日志 } return Collections.EMPTY_LIST; }
2.1 創(chuàng)建LoadingCache緩存
使用CacheBuilder來構(gòu)建LoadingCache實例,可以鏈式調(diào)用多個方法來配置緩存的行為。其中CacheLoader可以理解為一個固定的加載器,在創(chuàng)建LoadingCache時指定,然后簡單地重寫V load(K key) throws Exception方法,就可以達到當檢索不存在的時候自動加載數(shù)據(jù)的效果。
//創(chuàng)建一個LoadingCache,并可以進行一些簡單的緩存配置 private static LoadingCache<String, Optional<List<String>> > loadingCache = CacheBuilder.newBuilder() //配置最大容量為100,基于容量進行回收 .maximumSize(100) //配置寫入后多久使緩存過期-下文會講述 .expireAfterWrite(3, TimeUnit.SECONDS) //配置寫入后多久刷新緩存-下文會講述 .refreshAfterWrite(3, TimeUnit.SECONDS) //key使用弱引用-WeakReference .weakKeys() //當Entry被移除時的監(jiān)聽器-下文會講述 .removalListener(notification -> System.out.println("notification=" + notification)) //創(chuàng)建一個CacheLoader,重寫load方法,以實現(xiàn)"當get時緩存不存在,則load,放到緩存并返回的效果 .build(new CacheLoader<String, Optional<List<String>>>() { //重點,自動寫緩存數(shù)據(jù)的方法,必須要實現(xiàn) @Override public Optional<List<String>> load(String cityId) throws Exception { return Optional.ofNullable(rpcCall(cityId)); } //異步刷新緩存-下文會講述 @Override public ListenableFuture<Optional<List<String>>> reload(String cityId, Optional<List<String>> oldValue) throws Exception { return super.reload(cityId, oldValue); } }); // 測試 public static void main(String[] args) { try { System.out.println("load from cache once : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(4000); System.out.println("load from cache two : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load from cache three : " + loadingCache.get("0101").orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load not exist key from cache : " + loadingCache.get("0103").orElse(Lists.newArrayList())); } catch (ExecutionException | InterruptedException e) { //記錄日志 } }
執(zhí)行結(jié)果
2.2 創(chuàng)建CallableCache緩存
在上面的build方法中是可以不用創(chuàng)建CacheLoader的,不管有沒有CacheLoader,都是支持Callable的。Callable在get時可以指定,效果跟CacheLoader一樣,區(qū)別就是兩者定義的時間點不一樣,Callable更加靈活,可以理解為Callable是對CacheLoader的擴展。CallableCache的方式最大的特點在于可以在get的時候動態(tài)的指定load的數(shù)據(jù)源
//創(chuàng)建一個callableCache,并可以進行一些簡單的緩存配置 private static Cache<String, Optional<List<String>>> callableCache = CacheBuilder.newBuilder() //最大容量為100(基于容量進行回收) .maximumSize(100) //配置寫入后多久使緩存過期-下文會講述 .expireAfterWrite(3, TimeUnit.SECONDS) //key使用弱引用-WeakReference .weakKeys() //當Entry被移除時的監(jiān)聽器 .removalListener(notification -> System.out.println("notification=" + notification)) //不指定CacheLoader .build(); // 測試 public static void main(String[] args) { try { System.out.println("load from callableCache once : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(4000); System.out.println("load from callableCache two : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load from callableCache three : " + callableCache.get("0101", () -> Optional.ofNullable(rpcCall("0101"))).orElse(Lists.newArrayList())); Thread.sleep(2000); System.out.println("load not exist key from callableCache : " + callableCache.get("0103", () -> Optional.ofNullable(rpcCall("0103"))).orElse(Lists.newArrayList())); } catch (ExecutionException | InterruptedException e) { //記錄日志 } }
執(zhí)行結(jié)果:
2.3 其他用法
// 聲明一個CallableCache,不需要CacheLoader private static Cache<String, Optional<List<String>>> localCache = CacheBuilder .newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.MINUTES) .removalListener(notification -> System.out.println("notification=" + notification)) .build(); // 測試。使用時自主控制get、put等操作 public static void main(String[] args) { try { String cityId = "0101"; Optional<List<String>> ifPresent1 = localCache.getIfPresent(cityId); System.out.println("load from localCache one : " + ifPresent1); // 做判空,不存在時手工獲取并put數(shù)據(jù)到localCache中 if (ifPresent1 == null || ifPresent1.isPresent() || CollectionUtils.isEmpty(ifPresent1.get())) { List<String> stringList = rpcCall(cityId); if (CollectionUtils.isNotEmpty(stringList)) { localCache.put(cityId, Optional.ofNullable(stringList)); } } Optional<List<String>> ifPresent2 = localCache.getIfPresent(cityId); System.out.println("load from localCache two : " + ifPresent2); // 失效某個key,或者loadingCache.invalidateAll() 方法 localCache.invalidate(cityId); Optional<List<String>> ifPresent3 = localCache.getIfPresent(cityId); System.out.println("load from localCache three : " + ifPresent3); } catch (Exception e) { throw new RuntimeException(e); } }
執(zhí)行結(jié)果
通過上面三個案例的講解,相信大家對于guava cache的使用應該沒啥問題了,接下來一起學習緩存的失效機制!
3.緩存失效回收策略
前面說到Guava Cache與ConcurrentHashMap很相似,包括其并發(fā)策略,數(shù)據(jù)結(jié)構(gòu)等,但也不完全一樣。
最基本的區(qū)別是ConcurrentHashMap會一直保存所有添加的元素,直到顯式地移除,而guava cache可以自動回收元素,在某種情況下Guava Cache 會根據(jù)一定的算法自動移除一些條目,以確保緩存不會占用太多內(nèi)存,避免內(nèi)存浪費。
3.1 基于容量回收
基于容量的回收是一種常用策略。在構(gòu)建緩存時使用 CacheBuilder 的 maximumSize
方法來設(shè)置緩存的最大條目數(shù)。
當緩存中的條目數(shù)量超過了最大值時,Guava Cache 會根據(jù)LRU(最近最少使用)
算法來移除一些條目。例如:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 緩存最多可以存儲1000個條目 .maximumSize(1000) .build();
除了 maximumSize,Guava Cache 還提供了 maximumWeight
方法和 weigher
方法,允許你根據(jù)每個條目的權(quán)重來限制緩存,而不是簡單的條目數(shù)量。
這在緩存的條目大小不一致時特別有用。需要注意的是,淘汰的順序仍然是根據(jù)條目的訪問順序,而不是權(quán)重大小。 例如:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 緩存最多可以存儲的總權(quán)重 .maximumWeight(10000) .weigher(new Weigher<KeyType, ValueType>() { public int weigh(KeyType key, ValueType value) { // 定義如何計算每個條目的權(quán)重 return getSizeInBytes(key, value); } }) .build();
注意事項:
1、權(quán)重是在緩存創(chuàng)建時計算的,因此要考慮權(quán)重計算的復雜度。
3.2 定時回收
Guava Cache提供了兩種基于時間的回收策略。
- 基于寫操作的回收(expireAfterWrite)
使用 expireAfterWrite 方法設(shè)置的緩存條目在給定時間內(nèi)沒有被寫訪問(創(chuàng)建或覆蓋),則會被回收。這種策略適用于當信息在一段時間后就不再有效或變得陳舊時。 例如,下面的代碼創(chuàng)建了一個每當條目在30分鐘內(nèi)沒有被寫訪問(創(chuàng)建或覆蓋)就會過期的緩存:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .expireAfterWrite(30, TimeUnit.MINUTES) .build();
- 基于訪問操作的回收(expireAfterAccess)
使用 expireAfterAccess 方法設(shè)置的緩存條目在給定時間內(nèi)沒有被讀取或?qū)懭耄瑒t會被回收。這種策略適用于需要回收那些可能很長時間都不會被再次使用的條目。 例如,下面的代碼創(chuàng)建了一個每當條目在15分鐘內(nèi)沒有被訪問(讀取或?qū)懭耄┚蜁^期的緩存:
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .expireAfterAccess(15, TimeUnit.MINUTES) .build();
3.3 基于引用回收
Guava Cache 提供了基于引用的回收機制,這種機制允許緩存通過使用弱引用(weak references)或軟引用(soft references)來存儲鍵(keys)或值(values),以便在內(nèi)存緊張時能夠自動回收這些緩存條目。
- 弱引用鍵(Weak Keys)
使用 weakKeys()
方法配置的緩存會對鍵使用弱引用。當鍵不再有其他強引用時,即使它還在緩存中,也可能被垃圾回收器回收。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .weakKeys() .build();
弱引用鍵的緩存主要用于緩存鍵是可丟棄的或由外部系統(tǒng)管理生命周期的對象。例如,緩存外部資源的句柄,當句柄不再被應用程序使用時,可以安全地回收。
- 軟引用值(Soft Values)
使用 softValues()
方法配置的緩存會對值使用軟引用。軟引用對象在內(nèi)存充足時會保持不被回收,但在JVM內(nèi)存不足時,軟引用對象可能被垃圾回收器回收。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .softValues() .build();
軟引用值的緩存適合用于緩存占用內(nèi)存較大的對象,例如圖片或文檔數(shù)據(jù)。當應用程序內(nèi)存需求增加時,這些大對象可以被回收以釋放內(nèi)存。
注意事項:
1、基于引用的回收策略不是由緩存大小或元素的存活時間決定的,而是與JVM的垃圾回收機制緊密相關(guān),而垃圾回收的行為會受到JVM配置和當前內(nèi)存使用情況的影響,因此,引用回收策略下緩存回收具有不確定性,會導致緩存行為的不可預測性。
2、基于引用的回收策略通常不應與需要精確控制內(nèi)存占用的場景混用。在使用基于引用的回收策略時,應該仔細考慮應用程序的內(nèi)存需求和垃圾回收行為,以確保緩存能夠按照預期工作。
3.4 顯式清除
Guava Cache 提供了幾種顯式清除緩存條目的方法,允許你手動移除緩存中的某個或某些條目。
- 移除單個條目
使用 invalidate(key)
方法可以移除緩存中的特定鍵對應的條目。
cache.invalidate(key);
- 移除多個條目
使用 invalidateAll(keys)
方法可以移除緩存中所有在給定集合中的鍵對應的條目。
cache.invalidateAll(keys);
- 移除所有條目
使用 invalidateAll() 方法可以移除緩存中的所有條目。
cache.invalidateAll();
- 使用 Cache.asMap() 視圖進行移除
通過緩存的 asMap() 方法獲取的 ConcurrentMap 視圖,你可以使用 Map 接口提供的方法來移除條目。
// 移除單個條目 cache.asMap().remove(key); // 批量移除條目 for (KeyType key : keys) { cache.asMap().remove(key); } // 移除滿足特定條件的條目 cache.asMap().entrySet().removeIf(entry -> entry.getValue().equals(someValue));
注意事項:
asMap 視圖提供了緩存的 ConcurrentMap 形式,這種方式在使用時和直接操作緩存的交互有區(qū)別,如下:
1、cache.asMap()包含當前所有加載到緩存的項。因此cache.asMap().keySet()包含當前所有已加載鍵;
2、asMap().get(key)實質(zhì)上等同于 cache.getIfPresent(key),而且不會引起緩存項的加載。這和 Map 的語義約定一致。
3、所有讀寫操作都會重置相關(guān)緩存項的訪問時間,包括 Cache.asMap().get(Object)方法和 Cache.asMap().put(K, V)方法,但不包括 Cache.asMap().containsKey(Object)方法,也不包括在 Cache.asMap()的集合視圖上的操作。比如,遍歷 Cache.asMap().entrySet()不會重置緩存項的讀取時間。
- 注冊移除監(jiān)聽器
可以在構(gòu)建緩存時注冊一個移除監(jiān)聽器(RemovalListener),它會在每次條目被移除時調(diào)用。
Cache<KeyType, ValueType> cache = CacheBuilder.newBuilder() .removalListener(new RemovalListener<KeyType, ValueType>() { @Override public void onRemoval(RemovalNotification<KeyType, ValueType> notification) { // 處理移除事件 } }) .build();
在實際項目實踐中,往往是多種回收策略一起使用,讓Guava Cache緩存提供多層次的回收保障。
4、緩存失效回收時機
緩存回收策略講清楚后,那么這些策略到底是在什么時候觸發(fā)的呢?我們直接說結(jié)論:
Guava Cache基于容量和時間的回收策略,清理操作不是實時的。緩存的維護清理通常發(fā)生在寫操作期間,如新條目的插入或現(xiàn)有條目的替換,以及在讀操作期間的偶然清理。這意味著,緩存可能會暫時超過最大容量限制和時間限制,直到下一次寫操作觸發(fā)清理。
Guava 文檔中提到,清理工作通常是在寫操作期間完成的,但是在某些情況下,讀操作也會導致清理,尤其是當緩存的寫操作比較少時。這是為了確保即使在沒有寫操作的情況下,緩存也能夠維護其大小和條目的有效性。如果你需要確定緩存何時被清理,或者你想手動控制清理操作的時機可以通過「顯式清除」的方式,條目刪除操作會立即執(zhí)行。
為了更好的理解上述說的結(jié)論,我們通過上面LoadingCache緩存的使用 結(jié)合idea debug執(zhí)行分析一下。 源碼分析見下一部分。
5、源碼分析(簡短分析)
以下是guava-20.0版本的源碼分析。
- Segment中的get方法
@Override // 1、執(zhí)行LocalLoadingCache中的get方法 public V get(K key) throws ExecutionException { return localCache.getOrLoad(key); } // 2、執(zhí)行g(shù)et 或 load方法 V getOrLoad(K key) throws ExecutionException { return get(key, defaultLoader); } // 3、核心get方法 V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException { int hash = hash(checkNotNull(key)); // segmentFor方法根據(jù)hash的高位從segments數(shù)組中取出相應的segment實例,執(zhí)行segment實例的get方法 return segmentFor(hash).get(key, hash, loader); } // 4、Segment中的get方法 V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException { checkNotNull(key); checkNotNull(loader); try { // 當前Segment中存活的條目個數(shù)不為0 if (count != 0) { // read-volatile // don't call getLiveEntry, which would ignore loading values // getEntry會校驗key,所以key為弱引用被回收的場景,取到的e是null。稍后展開介紹該方法 LocalCache.ReferenceEntry<K, V> e = getEntry(key, hash); if (e != null) { long now = map.ticker.read(); // 此處有個getLiveValue(),這個方法是拿到當前存活有效的緩存值,稍后展開介紹該方法 V value = getLiveValue(e, now); if (value != null) { // 記錄該緩存被訪問了。此時expireAfterAccess相關(guān)的時間會被刷新 recordRead(e, now); // 記錄緩存擊中 statsCounter.recordHits(1); // 用來判斷是直接返回現(xiàn)有value,還是等待刷新 return scheduleRefresh(e, key, hash, value, now, loader); } LocalCache.ValueReference<K, V> valueReference = e.getValueReference(); // 只有key存在,但是value不存在(被回收)、或緩存超時的情況會到達這里 // 如果已經(jīng)有線程在加載緩存了,后面的線程不會重復加載,而是等待加載的結(jié)果 if (valueReference.isLoading()) { return waitForLoadingValue(e, key, valueReference); } } } // at this point e is either null or expired; // 如果不存在或者過期,就通過loader方法進行加載(該方法會對當前整個Segment加鎖,直到從數(shù)據(jù)源加載數(shù)據(jù),更新緩存); // 走到這里的場景: // 1)segment為空 // 2)key或value不存在(沒有緩存,或者弱引用、軟引用被回收), // 3)緩存超時(expireAfterAccess或expireAfterWrite觸發(fā)的) return lockedGetOrLoad(key, hash, loader); } catch (ExecutionException ee) { Throwable cause = ee.getCause(); if (cause instanceof Error) { throw new ExecutionError((Error) cause); } else if (cause instanceof RuntimeException) { throw new UncheckedExecutionException(cause); } throw ee; } finally { postReadCleanup(); } }
注意事項:
在cache get數(shù)據(jù)的時候,如果鏈表上找不到entry,或者value已經(jīng)過期,則調(diào)用lockedGetOrLoad()方法,這個方法會鎖住整個segment,直到從數(shù)據(jù)源加載數(shù)據(jù),更新緩存。
如果并發(fā)量比較大,又遇到很多key失效的情況就會很容易導致線程block。 項目實踐中需要慎重考慮這個問題,可考慮采用定時refresh機制規(guī)避該問題(下文會講述refresh機制)。
- 根據(jù)hash和key獲取鍵值對:getEntry
@Nullable ReferenceEntry<K, V> getEntry(Object key, int hash) { // getFirst用來根據(jù)hash獲取table中相應位置的鏈表的頭元素 for (ReferenceEntry<K, V> e = getFirst(hash); e != null; e = e.getNext()) { // hash不相等的,key肯定不相等。hash判等是int判等,比直接用key判等要快得多 if (e.getHash() != hash) { continue; } K entryKey = e.getKey(); // entryKey == null的情況,是key為軟引用或者弱引用,已經(jīng)被GC回收了。直接清理掉 if (entryKey == null) { // tryDrainReferenceQueues(); continue; } if (map.keyEquivalence.equivalent(key, entryKey)) { return e; } } return null; }
- getLiveValue方法
V getLiveValue(LocalCache.ReferenceEntry<K, V> entry, long now) { // 軟引用或者弱引用的key被清理掉了 if (entry.getKey() == null) { // 清理非強引用的隊列 tryDrainReferenceQueues(); return null; } V value = entry.getValueReference().get(); // 軟引用的value被清理掉了 if (value == null) { // 清理非強引用的隊列 tryDrainReferenceQueues(); return null; } // 在這里map.isExpired(entry, now)滿足條件執(zhí)行清除tryExpireEntries(now) if (map.isExpired(entry, now)) { tryExpireEntries(now); return null; } return value; }
源碼分析部分先寫到這里。我們掌握了,基于容量、時間的回收策略,不是實時執(zhí)行的?;厥涨謇硗ǔJ窃趯懖僮髌陂g順帶進行的,或者可以通過調(diào)用 cleanUp() 方法來顯式觸發(fā)。讀操作也可能偶爾觸發(fā)清理,尤其是在寫操作較少時。
6、刷新
了解了Guava Cache的使用和回收策略后,我們會發(fā)現(xiàn)這種用法還存在以下兩個問題:
- 緩存擊穿。數(shù)據(jù)大批量過期會導致對后端存儲的高并發(fā)訪問,加載數(shù)據(jù)過程中會鎖住整個segment,很容易導致線程block。
- 數(shù)據(jù)不新鮮。緩存中的數(shù)據(jù)不是最新的,特別是對于那些定期變化的數(shù)據(jù)無法做到定期刷新。
Guava Cache 的刷新機制允許緩存項在滿足特定條件時自動刷新。這意味著緩存項的值將被重新計算和替換,但這個過程是異步的,即刷新操作不會阻塞對緩存項的讀取請求。
刷新機制主要通過 LoadingCache的refresh方法來實現(xiàn),該方法會根據(jù)緩存的 CacheLoader重新加載緩存項的值。通過 CacheBuilder 的 refreshAfterWrite
方法設(shè)置自動刷新的觸發(fā)條件,即在寫入緩存項后的指定時間間隔。例如:
LoadingCache<KeyType, ValueType> cache = CacheBuilder.newBuilder() // 在寫入后的10分鐘后自動刷新 .refreshAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<KeyType, ValueType>() { @Override public ValueType load(KeyType key) { // 緩存項不存在時加載數(shù)據(jù)的方法 return loadData(key); } @Override public ListenableFuture<ValueType> reload(KeyType key, ValueType oldValue) throws Exception { // 異步刷新緩存項的方法 // 使用ListenableFuture來異步執(zhí)行刷新操作 return listeningExecutorService.submit(() -> loadData(key)); } });
在上述代碼中,refreshAfterWrite 設(shè)置了自動刷新的條件,而 CacheLoader 的 reload
方法定義了如何異步刷新緩存項。當緩存項在指定的時間間隔后被訪問時,Guava Cache 會調(diào)用 reload 方法來異步加載新值。在新值加載期間,舊值仍然會返回給任何請求它的調(diào)用者。
需要注意的是,reload 方法應該返回一個 ListenableFuture 對象,這樣刷新操作就可以異步執(zhí)行,而不會阻塞其他緩存或線程操作。如果 reload 方法沒有被重寫,Guava Cache 將使用 load 方法進行同步刷新。
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
spring-boot2.7.8添加swagger的案例詳解
這篇文章主要介紹了spring-boot2.7.8添加swagger的案例詳解,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-01-01Spring Boot2配置Swagger2生成API接口文檔詳情
這篇文章主要介紹了Spring Boot2配置Swagger2生成API接口文檔詳情,文章圍繞主題展開詳細的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-09-09Java 單向隊列及環(huán)形隊列的實現(xiàn)原理
本文主要介紹了Java 單向隊列及環(huán)形隊列的實現(xiàn)原理,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-10-10MyBatis saveBatch 性能調(diào)優(yōu)的實現(xiàn)
本文主要介紹了MyBatis saveBatch 性能調(diào)優(yōu)的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-07-07解決@Transactional注解事務(wù)不回滾不起作用的問題
這篇文章主要介紹了解決@Transactional注解事務(wù)不回滾不起作用的問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-02-02