当前位置: 首页 > news >正文

Guava Cache

缓存一般分为远程缓存和本地缓存。常见的远程缓存有 redis。与远程缓存相比本地缓存不需要进行网络的传输所以更加的快。本地缓存可以使用 map 实现,使用 get 查询缓存,put 设置缓存。本地缓存需要提供对应的缓存清理机制,否则会导致缓存溢出,并且需要保证操作的并发安全。

介绍

Guava Cache 是 Google Guava 库中的一个组件,用于在内存中实现本地缓存。它类似于 ConcurrentMap,但提供了更丰富的特性,如自动加载、过去策略、淘汰策略等。
Guava Cache 提供了丰富的构造参数:

参数类别参数名数据类型作用
容量控制initialCapacityint初始化容量
maximumSizelong缓存最大条目数,超过时按LRU淘汰
maximumWeightlong缓存最大权重和,需配合 weigher 使用,超过时淘汰条目
weigherweigher<K,V>定义计算缓存条目权重的函数,与maximumWeight 配合使用
并发控制concurrencyLevelint设置并发级别,并发级别是指同时可以写缓存的线程数
过期策略expireAfterWritelong,TimeUnit条目在写入后一段时间过期
expireAfterAccesslong,TimeUnit条目在最后一次访问(读或写)后一段时间过期
refreshAfterWritelong,TimeUnit条目在写入后一段时间自动刷新(仅LoadingCache支持)
引用基淘汰weakKeys将键设置为弱引用,允许GC回收
weakValues将值设置为弱引用,允许GC回收
softValues将值设置为软应用,在内容不足时GC优先回收
移除监听removalListener设置缓存条目被移除时的监听器
统计recordStats开启缓存统计功能(如命中率)

初始化

初始容量设置
我们在构建缓存时可以为缓存设置一个合理大小初始容量,由于Guava的缓存使用了分离锁的机制,扩容的代价非常昂贵。所以合理的初始容量能够减少缓存容器的扩容次数。

Cache<String, String> cache = CacheBuilder.newBuilder().initialCapacity(10) // 初始化容量.build();

最大容量
Guava Cache可以在构建缓存对象时指定缓存所能够存储的最大记录数量。当Cache中的记录数量达到最大值后再调用put方法向其中添加对象,Guava会先从当前缓存的对象记录中选择一条删除掉,腾出空间后再将新的对象存储到Cache中。

 Cache<String, String> cache = CacheBuilder.newBuilder().maximumSize(100) // 设置缓存最大容量,超过根据LRU算法回收.build();

监听器
removalListener() 设置缓存条目被移除时的监听器

package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {Cache<String,String> cache = CacheBuilder.newBuilder().maximumSize(1).removalListener(removal -> {System.out.println(removal.getKey() + "被删除了");}).build();cache.put("key1", "value1");cache.put("key2", "value2");cache.put("key3", "value3");}
}

输出:

key1被删除了
key2被删除了

缓存清除策略

基于存活时间
expireAfterWrite 写入缓存后n秒过期

 Cache<String, String> cache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS) // 设置写缓存后n秒过期.builder();

expireAfterAccess 读写缓存后n秒过期

 Cache<String, String> cache = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.SECONDS) // 设置读写缓存后n秒过期.builder();

基于最大容量容量
使用 CacheBuilder.maximumSize()方法时,当缓存数量达到该最大值时,将清除掉那些最近最少使用的缓存(LRU)
基于权重
在缓存大型文件或图片的场景下,每个缓存条目(文件内容)的大小差异很大,如果我们只限制缓存条目的数量(maximumSize)是不合理的。比如,缓存10个1MB的图片和缓存10个100MB的视频,占用的内存天差地别。
使用基于权重的回收(maximumWeight + weigher),我们可以限制缓存的总内存占用,而不是条目数量。
下面定义了一个最大权重为10的 cache,当所有条目的内存占用超过10时进行回收。

package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {// 1. 创建缓存,设置最大权重为10LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumWeight(10) // 总权重不能超过10.weigher((Weigher<String, String>) (key, value) -> value.length()) // 权重=值的长度.build(new CacheLoader<String, String>() {@Overridepublic String load(String key) {return key.toUpperCase(); // 简单的加载逻辑:转大写}});// 2. 使用缓存try {System.out.println("获取 A: " + cache.get("a"));      // 权重=1 (A)System.out.println("获取 Hello: " + cache.get("hello")); // 权重=5 (HELLO)System.out.println("获取 Test: " + cache.get("test"));   // 权重=4 (TEST)// 现在总权重=1+5+4=10,刚好满// 为了看到效果,我们手动检查哪些还在System.out.println("\n缓存中现有内容:");System.out.println("A: " + cache.getIfPresent("a"));     // 可能被回收System.out.println("Hello: " + cache.getIfPresent("hello")); // 可能被回收System.out.println("Test: " + cache.getIfPresent("test"));   // 可能还在System.out.println();// 3. 尝试再放入一个,会触发回收System.out.println("获取 World: " + cache.get("world")); // 权重=5 (WORLD)// 为了看到效果,我们手动检查哪些还在System.out.println("\n缓存中现有内容:");System.out.println("A: " + cache.getIfPresent("a"));     // 可能被回收System.out.println("Hello: " + cache.getIfPresent("hello")); // 可能被回收System.out.println("Test: " + cache.getIfPresent("test"));   // 可能还在System.out.println("World: " + cache.getIfPresent("world")); // 新加入的} catch (Exception e) {e.printStackTrace();}}
}

执行结果:

获取 A: A
获取 Hello: HELLO
获取 Test: TEST缓存中现有内容:
A: A
Hello: HELLO
Test: TEST获取 World: WORLD缓存中现有内容:
A: null
Hello: null
Test: TEST
World: WORLD

显示清除
清除单个key

 cache.invalidate("key1");

清除所有key

cache.invalidateAll();

使用弱引用储存键。在下个GC时,若键在缓存外部没有其他强引用,整个条目被回收。

Cache<String,String> cache = CacheBuilder.newBuilder().weakKeys().build();

使用弱引用存储值。在下个GC时,若值在缓存外部没有其他强引用,值将会被回收,键可能仍存在

 Cache<String,String> cache = CacheBuilder.newBuilder().weakValues().build();

使用软引用储存值。当一个对象只被软引用关联时,如果内存充足,它就不会被垃圾回收器回收,就像强引用一样。但是,当内存不足,垃圾收集器即将抛出OutOfMemoryError时,它会清理掉这些仅被软引用关联的对象,从而释放内存

Cache<String,String> cache = CacheBuilder.newBuilder().softValues().build();

写入

cache
显示put操作置入内存

package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {Cache<String,String> cache = CacheBuilder.newBuilder().initialCapacity(10).build();cache.put("key1","value1");cache.put("key2","value2");cache.put("key3","value3");}
}

LoadingCache
使用自定义 ClassLoader 加载数据,置入内存中。从 LoadingCache 中获取数据时,若数据存在则直接返回;若数据不存在,则根据 ClassLoader 的 load 方法加载数据至内存,然后返回该数据

package guava;import com.google.common.cache.*;public class GuavaCacheTest {public static void main(String[] args) {// 1. 创建缓存,设置最大权重为10LoadingCache<String, String> cache = CacheBuilder.newBuilder().maximumWeight(10) // 总权重不能超过10.weigher((Weigher<String, String>) (key, value) -> value.length()) // 权重=值的长度.build(new CacheLoader<String, String>() {@Overridepublic String load(String key) {return key.toUpperCase(); // 简单的加载逻辑:转大写}});// 2. 使用缓存try {System.out.println(cache.get("hello"));}catch (Exception e){return;}}
}

实现原理

数据结构

在这里插入图片描述

LocalMaunalCache

实现了 Cache 接口,是手动加载型缓存。需要显式调用 put() 方法向缓存中添加数据,获取数据时若不存在缓存命中,需手动处理(如返回 null 或自行加载数据)

LocalLoadingCache

实现了 LoadingCache 接口(LoadingCache 继承自 Cache),是自动加载型缓存。初始化时需传入 CacheLoader,当缓存未命中时,会自动调用 CacheLoader.load() 方法加载数据并放入缓存,无需手动 put()。

LocalMaunalCache 和 LocalLoadingCache 两者都继承自 AbstractLocalCache,共享底层的分段锁(Segment)、哈希表(table)、过期 / 淘汰策略等核心机制

LocalCache

LocalCache是 Guava Cache的核心类,本质上是一个Map

class LocalCache<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V> {// 缓存的最大容量,必须是2的幂次方且不超过1<<30static final int MAXIMUM_CAPACITY = 1 << 30;// 允许的最大段数,用于限制构造函数的参数static final int MAX_SEGMENTS = 1 << 16; // slightly conservative// 在containsValue方法中未同步的重试次数static final int CONTAINS_VALUE_RETRIES = 3;// 每个段的访问次数阈值static final int DRAIN_THRESHOLD = 0x3F;// 一次清理中可以清理的最大条目数static final int DRAIN_MAX = 16;static final Logger logger = Logger.getLogger(LocalCache.class.getName());// 用于索引到段中的掩码值,键的哈希码的高位用于选择段final int segmentMask;// 用于在段内进行索引的偏移值,有助于防止最终位于同一段中的条目也最终位于同一个桶中。final int segmentShift;// 段组数,每个段都是一个专门的哈希表final Segment<K, V>[] segments;// 并发级别final int concurrencyLevel;// 键的比较策略final Equivalence<Object> keyEquivalence;// 值的比较策略final Equivalence<Object> valueEquivalence;// 引用键的策略final Strength keyStrength;// 引用值的策略final Strength valueStrength;// 最大重量final long maxWeight;// 键值对的重量final Weigher<K, V> weigher;// 键值对在最后一次访问后保留的时间final long expireAfterAccessNanos;// 键值对在最后一次写入后保留的时间final long expireAfterWriteNanos;// 键值对在最后一次写入后成为刷新候选的时间final long refreshNanos;// 等待被移除监听器消费的键值对队列final Queue<RemovalNotification<K, V>> removalNotificationQueue;// 键值对移除监听器final RemovalListener<K, V> removalListener;// 以可测试的方式测量时间的工具final Ticker ticker;// 创建新键值对的工厂final EntryFactory entryFactory;// 累积全局缓存统计信息的计数器final StatsCounter globalStatsCounter;// 在加载操作中使用的默认缓存加载器final @Nullable CacheLoader<? super K, V> defaultLoader;}

Segment

LocalCache里维护了一个Segment数组

static class Segment<K, V> extends ReentrantLock {// 指向外部 LocalCache 实例的弱引用,用于访问缓存@Weak final LocalCache<K, V> map;// 记录当前段Segment中活跃元素的数量volatile int count;// 当前段中所有活跃元素的总权重,受this锁保护,确保线程安全@GuardedBy("this")long totalWeight;// 记录对段结构进行修改的次数,用于在批量读取操作中检测一致性,若在遍历过程中modCount发生变化,需要重试int modCount;// 扩容阈值,元素数量超过此值时触发扩容,计算公式为 capacity * 0.75 int threshold;// 哈希表,存储具体数据volatile @Nullable AtomicReferenceArray<ReferenceEntry<K, V>> table;// 最大权重。默认为无限制final long maxSegmentWeight;// 键的引用队列。用于清理被回收的键final @Nullable ReferenceQueue<K> keyReferenceQueue;// 值的引用队列。用于清理被回收的值final @Nullable ReferenceQueue<V> valueReferenceQueue;// 用于记录哪些条目被访问过,以更新访问列表的顺序final Queue<ReferenceEntry<K, V>> recencyQueue;// 记录自上次写入以来的读操作次数,用于在少量读操作上排空队列final AtomicInteger readCount = new AtomicInteger();// 按写入时间排序的元素队列,用于管理键值对的顺序@GuardedBy("this")final Queue<ReferenceEntry<K, V>> writeQueue;// 按访问时间排序的元素队列,用于管理键值对的顺序@GuardedBy("this")final Queue<ReferenceEntry<K, V>> accessQueue;// 用于累积缓存统计信息final StatsCounter statsCounter;
}

源码分析

put

调用 put 方法写入key value的链路如下:

  1. 对 key 进行哈希运算,定位分段 segment

  2. 段内加锁,保证线程安全

  3. 更新当前活动元素数量,如果超过阈值则进行两倍扩容

  4. 计算获取对应位置的 table,遍历,判断 key 是否已经存在

    1. 如果 key 已存在

      1. 如果 value为空且 value的引用仍为活跃状态,则将原键值对的移除事件写入通知队列,并更新数据
      2. 如果 value不为空且仅当key不存在时更新,则返回旧值
      3. 如果vlaue不为空,则将原键值对的移除事件写入通知队列,并更新数据
    2. 如果 key 不存在,插入数据

  5. 如果设置了 maxSegmentWeight 且当前超重,则执行清理逻辑

    1. 如果新插入的键值对的重量超过 maxSegmentWeight,则删除当前键值对
    2. 从 accessQueue 中依次删除队头元素(最久未访问),直到当前段的 totalWeight <= maxSegmentWeight
 @NullableV put(K key, int hash, V value, boolean onlyIfAbsent) {// 加锁,保证线程安全lock();try {long now = map.ticker.read();preWriteCleanup(now);// 更新当前活动元素数量int newCount = this.count + 1;// 如果当前活动元素数量大于阈值,触发扩容if (newCount > this.threshold) { // ensure capacityexpand();newCount = this.count + 1;}// 计算插入的表,并获取该表的首个元素 firstAtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);ReferenceEntry<K, V> first = table.get(index);// 遍历table,查找是否已经存在相同的键for (ReferenceEntry<K, V> e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();if (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {// 如果 key 已存在,获取 valueValueReference<K, V> valueReference = e.getValueReference();V entryValue = valueReference.get();if (entryValue == null) {// 更新修改次数++modCount;if (valueReference.isActive()) {// 如果值引用活跃的,将原数据移除事件封装成通知并放入通知队列enqueueNotification(key, hash, entryValue, valueReference.getWeight(), RemovalCause.COLLECTED);// 更新值setValue(e, key, value, now);newCount = this.count; // count remains unchanged} else {// 如果值引用不活跃,直接更新数据setValue(e, key, value, now);newCount = this.count + 1;}this.count = newCount; // 如果设置了 maxSegmentWeight 且当前超重,则执行清理逻辑:// 1. 如果新插入的键值对的重量超过 maxSegmentWeight,则删除当前键值对// 2. 从 accessQueue 中依次删除队头元素(最久未访问),直到当前段的 totalWeight <= maxSegmentWeightevictEntries(e);return null;} else if (onlyIfAbsent) {// 如果 key 不为空仅当 key 不存在时更新,则返回旧值recordLockedRead(e, now);return entryValue;} else {// 如果key不为空,且onlyIfAbsent为fasle,覆盖现有键值对,计数保持不变++modCount;// 将原数据移除事件封装成通知并放入通知队列enqueueNotification(key, hash, entryValue, valueReference.getWeight(), RemovalCause.REPLACED);// 更新值setValue(e, key, value, now);evictEntries(e);return entryValue;}}}// 如果key不存在,插入新的键值对++modCount;ReferenceEntry<K, V> newEntry = newEntry(key, hash, first);setValue(newEntry, key, value, now);table.set(index, newEntry);newCount = this.count + 1;this.count = newCount; // write-volatileevictEntries(newEntry);return null;} finally {unlock();postWriteCleanup();}}

get

调用get方法的执行链路如下:

  1. 对 key 进行哈希运算,定位分段 segment
  2. 如果当前key不存在,则加载并返回 loader 方法的返回值
  3. 如果当前key存在,检查该键值对是否有效
    1. 如果值有效,更新该键值对的最后读取时间,增加命中统计,并尝试刷新该值
    2. 如果值无效,但条目的应用正在加载,则等待加载完成

LocalMaunalCache:

public V get(K key, final Callable<? extends V> valueLoader) throws ExecutionException {checkNotNull(valueLoader);// 调用 LocalCache的get方法return localCache.get(key,new CacheLoader<Object, V>() {@Overridepublic V load(Object key) throws Exception {return valueLoader.call();}});}

LocalCache:

V get(K key, CacheLoader<? super K, V> loader) throws ExecutionException {int hash = hash(checkNotNull(key));// 通过hash定位到segment,再调用segment的get方法return segmentFor(hash).get(key, hash, loader);}

Segment:

V get(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {checkNotNull(key);checkNotNull(loader);try {// 如果 count == 0 不会存在数据if (count != 0) { // 定位键值对,并判断该键值对是否有效ReferenceEntry<K, V> e = getEntry(key, hash);if (e != null) {long now = map.ticker.read();// 获取还没过去的value,如果过期了,则返回nullV value = getLiveValue(e, now);if (value != null) {// 如果缓存过期策略为expireAfterAccess,刷新键值对的访问时间// 将该键值对添加到 recencyQueuerecordRead(e, now);// 增加命中统计statsCounter.recordHits(1);// 尝试刷新缓存return scheduleRefresh(e, key, hash, value, now, loader);}// 如果值无效但键值对的应用正在加载中,则等待加载完成ValueReference<K, V> valueReference = e.getValueReference();if (valueReference.isLoading()) {return waitForLoadingValue(e, key, valueReference);}}}// 之前没有写入过数据 || 数据已经过期 || 数据不是在加载中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();}}// 如果键值对无效返回null,有效返回value    V getLiveValue(ReferenceEntry<K, V> entry, long now) {// 被GC回收if (entry.getKey() == null) {tryDrainReferenceQueues();return null;}V value = entry.getValueReference().get();// 被GC回收if (value == null) {tryDrainReferenceQueues();return null;}// 判断是否过期if (map.isExpired(entry, now)) {tryExpireEntries(now);return null;}return value;}

scheduleRefresh

如果 entry 还没过期,则会调用此方法,尝试刷新数据V scheduleRefresh(ReferenceEntry<K, V> entry,K key,int hash,V oldValue,long now,CacheLoader<? super K, V> loader) {// 1. 配置了refreshAfterWrite// 2. 用writeTime判断是否达到刷新时间// 3. 是否在加载中,如果是则没必要再进行刷新if (map.refreshes()&& (now - entry.getWriteTime() > map.refreshNanos)&& !entry.getValueReference().isLoading()) {// 异步刷新数据V newValue = refresh(key, hash, loader, true);// 返回新值if (newValue != null) {return newValue;}}return oldValue;}// refresh    
V refresh(K key, int hash, CacheLoader<? super K, V> loader, boolean checkTime) {// 为key插入一个LoadingValueReferencefinal LoadingValueReference<K, V> loadingValueReference =insertLoadingValueReference(key, hash, checkTime);if (loadingValueReference == null) {return null;}// 通过loader异步加载数据ListenableFuture<V> result = loadAsync(key, hash, loadingValueReference, loader);// 判断Futer是否已经完成,如果是则返回结果。否则返回nullif (result.isDone()) {try {return Uninterruptibles.getUninterruptibly(result);} catch (Throwable t) {// don't let refresh exceptions propagate; error was already logged}}return null;}LoadingValueReference<K, V> insertLoadingValueReference(final K key, final int hash, boolean checkTime) {ReferenceEntry<K, V> e = null;// segment加锁lock();try {long now = map.ticker.read();// 预清理preWriteCleanup(now);AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);ReferenceEntry<K, V> first = table.get(index);// 如果key对应的entry存在.for (e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();if (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {// We found an existing entry.ValueReference<K, V> valueReference = e.getValueReference();if (valueReference.isLoading()|| (checkTime && (now - e.getWriteTime() < map.refreshNanos))) {// 如果是在加载中,或者还没达到刷新时间,则返回nullreturn null;}// new一个LoadingValueReference,然后把entry的valueReference替换掉++modCount;LoadingValueReference<K, V> loadingValueReference =new LoadingValueReference<>(valueReference);e.setValueReference(loadingValueReference);return loadingValueReference;}}// 如果key对应的entry不存在,则新建一个entry++modCount;LoadingValueReference<K, V> loadingValueReference = new LoadingValueReference<>();e = newEntry(key, hash, first);e.setValueReference(loadingValueReference);table.set(index, e);return loadingValueReference;} finally {unlock();postWriteCleanup();}}

lockedGetOrLoad
如果之前没有写如果数据 || 数据已过期 || 数据不是在加载中,则会调用lockedGetOrLoad

V lockedGetOrLoad(K key, int hash, CacheLoader<? super K, V> loader) throws ExecutionException {ReferenceEntry<K, V> e;ValueReference<K, V> valueReference = null;LoadingValueReference<K, V> loadingValueReference = null;// 是否需要创建一个新的entryboolean createNewEntry = true;// segment加锁lock();try {// re-read ticker once inside the locklong now = map.ticker.read();// 预清理preWriteCleanup(now);int newCount = this.count - 1;AtomicReferenceArray<ReferenceEntry<K, V>> table = this.table;int index = hash & (table.length() - 1);// 定位到具体的tableReferenceEntry<K, V> first = table.get(index);for (e = first; e != null; e = e.getNext()) {K entryKey = e.getKey();// 定位keyif (e.getHash() == hash&& entryKey != null&& map.keyEquivalence.equivalent(key, entryKey)) {valueReference = e.getValueReference();// 如果value在加载中则不需要重复创建entryif (valueReference.isLoading()) {createNewEntry = false;} else {V value = valueReference.get();// value为null说明已经过期且被清理掉了if (value == null) {// 写通知queueenqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.COLLECTED);// 过期但还没被清理} else if (map.isExpired(e, now)) {// 写通知queue// This is a duplicate check, as preWriteCleanup already purged expired// entries, but let's accommodate an incorrect expiration queue.enqueueNotification(entryKey, hash, value, valueReference.getWeight(), RemovalCause.EXPIRED);} else {recordLockedRead(e, now);statsCounter.recordHits(1);// 其他情况直接返回value// we were concurrent with loading; don't consider refreshreturn value;}// immediately reuse invalid entrieswriteQueue.remove(e);accessQueue.remove(e);this.count = newCount; // write-volatile}break;}}// 创建一个 entry,切set一个新的LoadingValueReferenceif (createNewEntry) {loadingValueReference = new LoadingValueReference<>();if (e == null) {e = newEntry(key, hash, first);e.setValueReference(loadingValueReference);table.set(index, e);} else {e.setValueReference(loadingValueReference);}}} finally {unlock();postWriteCleanup();}// 同步加载数据if (createNewEntry) {try {// Synchronizes on the entry to allow failing fast when a recursive load is// detected. This may be circumvented when an entry is copied, but will fail fast most// of the time.synchronized (e) {return loadSync(key, hash, loadingValueReference, loader);}} finally {statsCounter.recordMisses(1);}} else {// The entry already exists. Wait for loading.return waitForLoadingValue(e, key, valueReference);}}

总结

Guava Cache 作为一个成熟的本地缓存实现,在很多场景下都能很好地满足需求。理解其内部实现(如分段锁机制、过期策略实现等)有助于我们更好地使用它,并在出现问题时能够快速定位和解决。

http://www.dtcms.com/a/428025.html

相关文章:

  • 用 go-commons 打造更优雅的字符串处理工具
  • x86虚拟机中的时钟
  • Genome Med|RAG-HPO做表型注释:学习一下大语言模型怎么作为发文思路
  • 阳江网站建设推广迅雷2t免费空间活动
  • Python 之可变参数作为默认值的坑
  • 高数第一问:极限定义
  • Vue 3 —— L / 11-Vue3全家桶
  • 建设网站审批手续如何编辑 wordpress 主题
  • SLF4J 日志学习
  • 外贸网站推广中山网站手机模板源码下载
  • 网站后台管理模板免费下载网站建设 人性的弱点
  • nodejs动态创建sql server表
  • 做平面设计什么素材网站好使张家港网站建设优化
  • Java 进阶--函数式编程
  • 《道德经》第九章
  • 网站首页怎么做ps中国营销传播网
  • 镇江网友之家百度上如何做优化网站
  • 网站分辨率自适应代码模板网站定制网站
  • 建设网站的网站安全建设银行网站怎么修改手机号码吗
  • 网站后台可以做两个管理系统么wordpress wpenqueuestyle
  • 两种常见的ACM风格笔试题
  • 图神经网络分享系列-transe(Translating Embeddings for Modeling Multi-relational Data) (一)
  • ENVI系列教程(十九)——目标探测与识别
  • 校园超市网站开发整站优seo排名点击
  • 服务器放n个网站自己做鞋子网站
  • Spring核心 - 控制反转 IOC , 用来大量例子来解释
  • 关键词查询的分析网站有网打不开网页咋回事
  • 大型电子商务网站建设成本双流区的规划建设局网站
  • 从引流到生态:排队免单如何重构商家私域流量?
  • 云网站制作的流程图烟台网站制作厂家联系方式