Caffeine 手动策略缓存 put() 方法源码解析

BoundedLocalManualCache put() 方法源码解析

先看一下BoundedLocalManualCache的类图

BoundedLocalManualCache

com.github.benmanes.caffeine.cache.BoundedLocalCache中定义的BoundedLocalManualCache静态内部类。

static class BoundedLocalManualCache<K, V> implements LocalManualCache<K, V>, Serializable

实现了LocalManualCache接口,这个接口提供了Cache接口的骨架实现,以最简的方式去实现一个LocalCache

详细查看LocalManualCache接口里定义的内容,代码也不多,直接贴到内容里:

interface LocalManualCache<K, V> extends Cache<K, V> {/** Returns the backing {@link LocalCache} data store. */LocalCache<K, V> cache();@Overridedefault long estimatedSize() {return cache().estimatedSize();}@Overridedefault void cleanUp() {cache().cleanUp();}@Overridedefault @Nullable V getIfPresent(Object key) {return cache().getIfPresent(key, /* recordStats */ true);}@Overridedefault @Nullable V get(K key, Function<? super K, ? extends V> mappingFunction) {return cache().computeIfAbsent(key, mappingFunction);}@Overridedefault Map<K, V> getAllPresent(Iterable<?> keys) {return cache().getAllPresent(keys);}@Overridedefault Map<K, V> getAll(Iterable<? extends K> keys,Function<Iterable<? extends K>, Map<K, V>> mappingFunction) {requireNonNull(mappingFunction);Set<K> keysToLoad = new LinkedHashSet<>();Map<K, V> found = cache().getAllPresent(keys);Map<K, V> result = new LinkedHashMap<>(found.size());for (K key : keys) {V value = found.get(key);if (value == null) {keysToLoad.add(key);}result.put(key, value);}if (keysToLoad.isEmpty()) {return found;}bulkLoad(keysToLoad, result, mappingFunction);return Collections.unmodifiableMap(result);}/*** Performs a non-blocking bulk load of the missing keys. Any missing entry that materializes* during the load are replaced when the loaded entries are inserted into the cache.*/default void bulkLoad(Set<K> keysToLoad, Map<K, V> result,Function<Iterable<? extends @NonNull K>, @NonNull Map<K, V>> mappingFunction) {boolean success = false;long startTime = cache().statsTicker().read();try {Map<K, V> loaded = mappingFunction.apply(keysToLoad);loaded.forEach((key, value) ->cache().put(key, value, /* notifyWriter */ false));for (K key : keysToLoad) {V value = loaded.get(key);if (value == null) {result.remove(key);} else {result.put(key, value);}}success = !loaded.isEmpty();} catch (RuntimeException e) {throw e;} catch (Exception e) {throw new CompletionException(e);} finally {long loadTime = cache().statsTicker().read() - startTime;if (success) {cache().statsCounter().recordLoadSuccess(loadTime);} else {cache().statsCounter().recordLoadFailure(loadTime);}}}@Overridedefault void put(K key, V value) {cache().put(key, value);}@Overridedefault void putAll(Map<? extends K, ? extends V> map) {cache().putAll(map);}@Overridedefault void invalidate(Object key) {cache().remove(key);}@Overridedefault void invalidateAll(Iterable<?> keys) {cache().invalidateAll(keys);}@Overridedefault void invalidateAll() {cache().clear();}@Overridedefault CacheStats stats() {return cache().statsCounter().snapshot();}@Overridedefault ConcurrentMap<K, V> asMap() {return cache();}
}

可以看到,CacheLoader接口定义了loadloadAllputputAllinvalidateinvalidateAllstatsasMap等方法,做一个简单实现。这些方法提供了缓存的基本操作,如加载缓存、添加缓存、移除缓存、获取缓存统计信息等。

Manual Cache 源码

static class BoundedLocalManualCache<K, V> implements LocalManualCache<K, V>, Serializable {private static final long serialVersionUID = 1;final BoundedLocalCache<K, V> cache;final boolean isWeighted;@Nullable Policy<K, V> policy;BoundedLocalManualCache(Caffeine<K, V> builder) {this(builder, null);}BoundedLocalManualCache(Caffeine<K, V> builder, @Nullable CacheLoader<? super K, V> loader) {cache = LocalCacheFactory.newBoundedLocalCache(builder, loader, /* async */ false);isWeighted = builder.isWeighted();}@Overridepublic BoundedLocalCache<K, V> cache() {return cache;}@Overridepublic Policy<K, V> policy() {return (policy == null)? (policy = new BoundedPolicy<>(cache, Function.identity(), isWeighted)): policy;}@SuppressWarnings("UnusedVariable")private void readObject(ObjectInputStream stream) throws InvalidObjectException {throw new InvalidObjectException("Proxy required");}Object writeReplace() {return makeSerializationProxy(cache, isWeighted);}}

定义了一个BoundedLocalCache属性,还有权重的标志位isWeighted,以及一个Policy属性。BoundedLocalManualCache的构造方法中,调用了LocalCacheFactory.newBoundedLocalCache方法,创建了一个BoundedLocalCache对象,并赋值给cache属性。policy属性则是在policy()方法中创建的。policy 是一个BoundedPolicy对象,它实现了Policy接口,用于管理缓存策略。BoundedPolicy源码紧接着就在BoundedLocalManualCache下面,这里就不贴出来了。

static final class BoundedPolicy<K, V> implements Policy<K, V>,里具体定义了了BoundedLocalCache的缓存策略,比如缓存大小,缓存权重,缓存过期时间等。

接下来我们看BoundedLocalCacheput方法

手动使用调用cache.put(k, v);会调用put(key, value, expiry(), /* notifyWriter */ true, /* onlyIfAbsent */ false);
具体的参数解释如下:

  • key:要放入缓存的键。
  • value:要放入缓存的值。
  • expiry:缓存的过期时间,默认为Duration.ZERO,表示永不过期。
  • notifyWriter:是否通知写入者,默认为true
  • onlyIfAbsent:是否只在缓存中不存在该键时才放入,默认为false

put 方法源码如下:

@Nullable V put(K key, V value, Expiry<K, V> expiry, boolean notifyWriter, boolean onlyIfAbsent) {requireNonNull(key);requireNonNull(value);Node<K, V> node = null;long now = expirationTicker().read();int newWeight = weigher.weigh(key, value);for (;;) {// 获取 prior 节点Node<K, V> prior = data.get(nodeFactory.newLookupKey(key));if (prior == null) {// 如果不存在 prior 节点,则创建新的节点if (node == null) {node = nodeFactory.newNode(key, keyReferenceQueue(),value, valueReferenceQueue(), newWeight, now);setVariableTime(node, expireAfterCreate(key, value, expiry, now));}// notifyWriter 为 true 且存在Writer时,通知Writerif (notifyWriter && hasWriter()) {Node<K, V> computed = node;prior = data.computeIfAbsent(node.getKeyReference(), k -> {writer.write(key, value);return computed;});//    如果存在 prior 节点,调用 afterWrite 方法if (prior == node) {afterWrite(new AddTask(node, newWeight));return null;// 如果onlyIfAbsent 为 true。代表只在缓存中不存在该键时才放入缓存} else if (onlyIfAbsent) {V currentValue = prior.getValue();if ((currentValue != null) && !hasExpired(prior, now)) {if (!isComputingAsync(prior)) {tryExpireAfterRead(prior, key, currentValue, expiry(), now);setAccessTime(prior, now);}afterRead(prior, now, /* recordHit */ false);return currentValue;}}// 如果 notifyWriter 为 false,直接放入缓存} else {prior = data.putIfAbsent(node.getKeyReference(), node);if (prior == null) {afterWrite(new AddTask(node, newWeight));return null;} else if (onlyIfAbsent) {// An optimistic fast path to avoid unnecessary lockingV currentValue = prior.getValue();if ((currentValue != null) && !hasExpired(prior, now)) {if (!isComputingAsync(prior)) {tryExpireAfterRead(prior, key, currentValue, expiry(), now);setAccessTime(prior, now);}afterRead(prior, now, /* recordHit */ false);return currentValue;}}}} else if (onlyIfAbsent) {// An optimistic fast path to avoid unnecessary lockingV currentValue = prior.getValue();if ((currentValue != null) && !hasExpired(prior, now)) {if (!isComputingAsync(prior)) {tryExpireAfterRead(prior, key, currentValue, expiry(), now);setAccessTime(prior, now);}afterRead(prior, now, /* recordHit */ false);return currentValue;}}// 如果 prior != null,则说明该节点已经存在,则尝试获取锁V oldValue;long varTime;int oldWeight;boolean expired = false;boolean mayUpdate = true;boolean exceedsTolerance = false;synchronized (prior) {if (!prior.isAlive()) {continue;}oldValue = prior.getValue();oldWeight = prior.getWeight();// 如果 oldValue == null,通过 expireAfterCreate 方法计算过期时间,并删除key对应的值if (oldValue == null) {varTime = expireAfterCreate(key, value, expiry, now);writer.delete(key, null, RemovalCause.COLLECTED);// 返回prior是否过期,true,则删除key对应的值} else if (hasExpired(prior, now)) {expired = true;varTime = expireAfterCreate(key, value, expiry, now);writer.delete(key, oldValue, RemovalCause.EXPIRED);// 如果 onlyIfAbsent 为 true,则不更新key对应的值,返回新的过期时间} else if (onlyIfAbsent) {mayUpdate = false;varTime = expireAfterRead(prior, key, value, expiry, now);} else {varTime = expireAfterUpdate(prior, key, value, expiry, now);}// notifyWriter 为true,如果过期或者更新了值,则通知Writerif (notifyWriter && (expired || (mayUpdate && (value != oldValue)))) {writer.write(key, value);}// 如果mayUpdate为true,计算过期时间是否超出容忍度if (mayUpdate) {exceedsTolerance =(expiresAfterWrite() && (now - prior.getWriteTime()) > EXPIRE_WRITE_TOLERANCE)|| (expiresVariable()&& Math.abs(varTime - prior.getVariableTime()) > EXPIRE_WRITE_TOLERANCE);setWriteTime(prior, now);prior.setWeight(newWeight);prior.setValue(value, valueReferenceQueue());}// 设置访问时间和过期时间setVariableTime(prior, varTime);setAccessTime(prior, now);}// 如果在创建缓存时设置了移除监听器,则通知移除监听器if (hasRemovalListener()) {if (expired) {notifyRemoval(key, oldValue, RemovalCause.EXPIRED);} else if (oldValue == null) {notifyRemoval(key, /* oldValue */ null, RemovalCause.COLLECTED);} else if (mayUpdate && (value != oldValue)) {notifyRemoval(key, oldValue, RemovalCause.REPLACED);}}// 更新权重,判断是不是第一写入,如果是,调用afterWrite方法int weightedDifference = mayUpdate ? (newWeight - oldWeight) : 0;if ((oldValue == null) || (weightedDifference != 0) || expired) {afterWrite(new UpdateTask(prior, weightedDifference));// 判断 onlyIfAbsent 是否为 true,以及是否超过容忍度,如果超过容忍度,调用afterWrite方法} else if (!onlyIfAbsent && exceedsTolerance) {afterWrite(new UpdateTask(prior, weightedDifference));} else {if (mayUpdate) {setWriteTime(prior, now);}//执行 afterRead 方法afterRead(prior, now, /* recordHit */ false);}return expired ? null : oldValue;}}

案例中通过 cache.put(k,v)调用方法,走到这个方法中,因为是第一次尝试储存key和value,所以代码中声明的 node = null,获取的prior = nullif (prior == null),创建新节点,设置创建后过期时间。notifyWriter=truehasWriter=false,执行else中方法

          prior = data.putIfAbsent(node.getKeyReference(), node);if (prior == null) {afterWrite(new AddTask(node, newWeight));return null;} else if (onlyIfAbsent) {// An optimistic fast path to avoid unnecessary lockingV currentValue = prior.getValue();if ((currentValue != null) && !hasExpired(prior, now)) {if (!isComputingAsync(prior)) {tryExpireAfterRead(prior, key, currentValue, expiry(), now);setAccessTime(prior, now);}afterRead(prior, now, /* recordHit */ false);return currentValue;}}

putIfAbsent 方法:由于data中不存在我们的key,value,返回 null,调用 afterWrite() 方法,将任务放入writeBuffer中,调用scheduleAfterWrite()方法

  void afterWrite(Runnable task) {for (int i = 0; i < WRITE_BUFFER_RETRIES; i++) {if (writeBuffer.offer(task)) {scheduleAfterWrite();return;}scheduleDrainBuffers();}

scheduleAfterWrite()方法:

  void scheduleAfterWrite() {for (;;) {switch (drainStatus()) {case IDLE:casDrainStatus(IDLE, REQUIRED);scheduleDrainBuffers();return;case REQUIRED:scheduleDrainBuffers();return;case PROCESSING_TO_IDLE:if (casDrainStatus(PROCESSING_TO_IDLE, PROCESSING_TO_REQUIRED)) {return;}continue;case PROCESSING_TO_REQUIRED:return;default:throw new IllegalStateException();}}}

看到这我其实是有点蒙了,因为笔者的异步编程基础薄弱,只看方法名字做一个不负责任的猜想,写入后安排异步任务,条件符合执行清理计划,会继续调用 scheduleDrainBuffers() 方法

scheduleDrainBuffers() 方法:

void scheduleDrainBuffers() {if (drainStatus() >= PROCESSING_TO_IDLE) {return;}if (evictionLock.tryLock()) {try {int drainStatus = drainStatus();if (drainStatus >= PROCESSING_TO_IDLE) {return;}lazySetDrainStatus(PROCESSING_TO_IDLE);executor.execute(drainBuffersTask);} catch (Throwable t) {logger.log(Level.WARNING, "Exception thrown when submitting maintenance task", t);maintenance(/* ignored */ null);} finally {evictionLock.unlock();}}}

drainStatus() 就是返回这条件的值,如果大于等于 PROCESSING_TO_IDLE 就直接返回,否则执行 tryLock() 方法,如果成功,则执行 executor.execute(drainBuffersTask); 方法,否则执行 maintenance() 方法,这个方法就是执行清理任务的方法。

传进来的drainBuffersTask是一个PerformCleanupTask,这个类实现了Runnable接口,重写了run()方法,这个方法就是执行清理任务的方法。

    @Overridepublic void run() {BoundedLocalCache<?, ?> cache = reference.get();if (cache != null) {cache.performCleanUp(/* ignored */ null);}}

继续看performCleanUp()方法:

  void performCleanUp(@Nullable Runnable task) {evictionLock.lock();try {maintenance(task);} finally {evictionLock.unlock();}if ((drainStatus() == REQUIRED) && (executor == ForkJoinPool.commonPool())) {scheduleDrainBuffers();}}

可以看到,这里也是调用了maintenance()方法,然后判断drainStatus()是否等于REQUIRED,如果等于,则调用scheduleDrainBuffers()方法。

@GuardedBy("evictionLock")void maintenance(@Nullable Runnable task) {lazySetDrainStatus(PROCESSING_TO_IDLE);try {drainReadBuffer();drainWriteBuffer();if (task != null) {task.run();}drainKeyReferences();drainValueReferences();expireEntries();evictEntries();climb();} finally {if ((drainStatus() != PROCESSING_TO_IDLE) || !casDrainStatus(PROCESSING_TO_IDLE, IDLE)) {lazySetDrainStatus(REQUIRED);}}}

maintenance() 是实际的清理方法,它首先将drainStatus()设置为PROCESSING_TO_IDLE,然后调用drainReadBuffer()drainWriteBuffer()drainKeyReferences()drainValueReferences()expireEntries()evictEntries()climb()等方法,清理读写缓冲区、过期条目、驱逐条目等。

到这里,afterWrite()基本就执行完了,写入一次(key,value),都会去判断是否需要清理,如果需要清理,就异步调用maintenance()方法进行清理。

如果是给已经存在的key设置值,put方法执行到最后会调用 afterRead()方法

  void afterRead(Node<K, V> node, long now, boolean recordHit) {if (recordHit) {statsCounter().recordHits(1);}boolean delayable = skipReadBuffer() || (readBuffer.offer(node) != Buffer.FULL);if (shouldDrainBuffers(delayable)) {scheduleDrainBuffers();}refreshIfNeeded(node, now);}

afterRead()方法会记录命中次数,然后判断是否需要延迟写入缓冲区,如果需要延迟写入缓冲区,则将节点放入读取缓冲区,如果读取缓冲区已满,则调用scheduleDrainBuffers()方法异步清理缓冲区,最后调用refreshIfNeeded()方法异步刷新节点。

refreshIfNeeded()方法会根据节点的过期时间、访问时间、更新时间等判断是否需要刷新节点,如果需要刷新节点,则调用refresh()方法刷新节点。

本例中没有设置过期时间,直接返回。

总结

本文算是比较详细的把put()方法执行流程分析了一遍,通过分析put()方法,我们可以了解到Caffeine缓存的基本原理,以及如何使用Caffeine缓存,学习如何自己实现一个本地缓存的 put()方法,怎样执行一个异步的清理任务,怎样判断是否需要清理,怎样异步刷新节点等等。

笔者也是一个小菜鸟,刚开始看一些源码,可能有些地方理解的不对,欢迎指正,谢谢!

希望本文对你有所帮助,如果有任何问题,欢迎在评论区留言讨论。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/59125.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

《Qwen2-VL》论文精读【上】:发表于2024年10月 Qwen2-VL 迅速崛起 | 性能与GPT-4o和Claude3.5相当

1、论文地址Qwen2-VL: Enhancing Vision-Language Model’s Perception of the World at Any Resolution 2、Qwen2-VL的Github仓库地址 该论文发表于2024年4月&#xff0c;是Qwen2-VL的续作&#xff0c;截止2024年11月&#xff0c;引用数24 文章目录 1 论文摘要2 引言3 实验3.…

StandardThreadExecutor源码解读与使用(tomcat的线程池实现类)

&#x1f3f7;️个人主页&#xff1a;牵着猫散步的鼠鼠 &#x1f3f7;️系列专栏&#xff1a;Java源码解读-专栏 &#x1f3f7;️个人学习笔记&#xff0c;若有缺误&#xff0c;欢迎评论区指正 目录 目录 1.前言 2.线程池基础知识回顾 2.1.线程池的组成 2.2.工作流程 2…

前端埋点与监控最佳实践:从基础到全流程实现.

前端埋点与监控最佳实践&#xff1a;从基础到全流程实现 大纲 我们会从以下三个方向来讲解埋点与监控的知识&#xff1a; 什么是埋点&#xff1f;什么是监控&#xff1f; JS 中实现监控的核心方案 写一个“相对”完整的监控实例 一、什么是埋点&#xff1f;什么是监控&am…

rom定制系列------红米k30_4G版澎湃os安卓13批量线刷固件

&#x1f49d;&#x1f49d;&#x1f49d;红米k30 4G版&#xff0c;机型代码;phoenix.此机型官方固件最后一版为稳定版13.0.6安卓12的固件。客户的软件需运行在至少安卓13的系统至少。测试原生适配有bug。最终测试在第三方澎湃os安卓13的固件可以完美运行。 &#x1f49d;&am…

钉钉平台开发小程序

一、下载小程序开发者工具 官网地址&#xff1a;小程序开发工具 - 钉钉开放平台 客户端类型 下载链接 MacOS x64 https://ur.alipay.com/volans-demo_MiniProgramStudio-x64.dmg MacOS arm64 https://ur.alipay.com/volans-demo_MiniProgramStudio-arm64.dmg Windows ht…

android——渐变色

1、xml的方式实现渐变色 效果图&#xff1a; xml的代码&#xff1a; <?xml version"1.0" encoding"utf-8"?> <shape xmlns:android"http://schemas.android.com/apk/res/android"xmlns:tools"http://schemas.android.com/tools…

微信小程序生成二维码

目前是在开发小程序端 --> 微信小程序。然后接到需求&#xff1a;根据 form 表单填写内容生成二维码&#xff08;第一版&#xff1a;表单目前需要客户进行自己输入&#xff0c;然后点击生成按钮实时生成二维码&#xff0c;不需要向后端请求&#xff0c;不存如数据库&#xf…

rhce:web服务器

web服务器简介 服务器端&#xff1a;此处使用 nginx 提供 web 服务&#xff0c; RPM 包获取&#xff1a; http://nginx.org/packages/ /etc/nginx/ ├── conf.d #子配置文件目录 ├── default.d ├── fastcgi.conf ├── fastcgi.conf.default ├── fastcgi_params #用…

解决使用netstat查看端口显示FIN_WAIT的问题

解决使用netstat查看端口显示FIN_WAIT的问题 1. 理解`FIN_WAIT`状态2. 检查应用程序3. 检查网络延迟和稳定性4. 更新和修补系统5. 调整TCP参数6. 使用更详细的工具进行分析7. 咨询开发者或技术支持8. 定期监控和评估结论在使用 netstat查看网络连接状态时,如果发现大量连接处…

01LangChain 实战课开篇——AI奇点时刻

LangChain 实战课开篇——AI奇点时刻 课程简介 课程背景&#xff1a;随着ChatGPT和GPT-4的出现&#xff0c;AI技术与实际应用之间的距离变得前所未有的近。LangChain作为基于大模型的应用开发框架&#xff0c;为程序员提供了开发智能应用的新工具。 LangChain 概述 定义&am…

【java】java的基本程序设计结构06-运算符

运算符 一、分类 算术运算符关系运算符位运算符逻辑运算符赋值运算符其他运算符 1.1 算术运算符 操作符描述例子加法 - 相加运算符两侧的值A B 等于 30-减法 - 左操作数减去右操作数A – B 等于 -10*乘法 - 相乘操作符两侧的值A * B等于200/除法 - 左操作数除以右操作数B /…

Spring Cloud Sleuth(Micrometer Tracing +Zipkin)

分布式链路追踪 分布式链路追踪技术要解决的问题&#xff0c;分布式链路追踪&#xff08;Distributed Tracing&#xff09;&#xff0c;就是将一次分布式请求还原成调用链路&#xff0c;进行日志记录&#xff0c;性能监控并将一次分布式请求的调用情况集中展示。比如各个服务节…

关于我的编程语言——C/C++——第四篇(深入1)

&#xff08;叠甲&#xff1a;如有侵权请联系&#xff0c;内容都是自己学习的总结&#xff0c;一定不全面&#xff0c;仅当互相交流&#xff08;轻点骂&#xff09;我也只是站在巨人肩膀上的一个小卡拉米&#xff0c;已老实&#xff0c;求放过&#xff09; 字符类型介绍 char…

一台手机可以登录运营多少个TikTok账号?

很多TikTok内容创作者和商家通过运营多个账号来实现品牌曝光和产品销售&#xff0c;这种矩阵运营方式需要一定的技巧和设备成本&#xff0c;那么对于很多新手来说&#xff0c;一台手机可以登录和运营多少个TikTok账号呢&#xff1f; 一、运营TikTok账号的数量限制 TikTok的官…

DNS服务器部署

一、要求 1.搭建dns服务器能够对自定义的正向或者反向域完成数据解析查询。 2.配置从DNS服务器&#xff0c;对主dns服务器进行数据备份。 二、配置 1.搭建dns服务器能够对自定义的正向或者反向域完成数据解析查询。 &#xff08;1&#xff09;首先需要安装bind服务 &#xf…

三周精通FastAPI:28 构建更大的应用 - 多个文件

官方文档&#xff1a;https://fastapi.tiangolo.com/zh/tutorial/bigger-applications 更大的应用 - 多个文件 如果你正在开发一个应用程序或 Web API&#xff0c;很少会将所有的内容都放在一个文件中。 FastAPI 提供了一个方便的工具&#xff0c;可以在保持所有灵活性的同时…

【react使用AES对称加密的实现】

react使用AES对称加密的实现 前言使用CryptoJS库密钥存放加密方法解密方法结语 前言 项目中要求敏感信息怕被抓包泄密必须进行加密传输处理&#xff0c;普通的md5加密虽然能解决传输问题&#xff0c;但是项目中有权限的用户是需要查看数据进行查询的&#xff0c;所以就不能直接…

【STM32】INA3221三通道电压电流采集模块,HAL库

一、简单介绍 芯片的datasheet地址&#xff1a; INA3221 三通道、高侧测量、分流和总线电压监视器&#xff0c;具有兼容 I2C 和 SMBUS 的接口 datasheet (Rev. B) 笔者所使用的INA3221是淘宝买的模块 原理图 模块的三个通道的电压都是一样&#xff0c;都是POWER。这个芯片采用…

《机器人SLAM导航核心技术与实战》第1季:第10章_其他SLAM系统

视频讲解 【第1季】10.第10章_其他SLAM系统-视频讲解 【第1季】10.1.第10章_其他SLAM系统_RTABMAP算法-视频讲解 【第1季】10.2.第10章_其他SLAM系统_VINS算法-视频讲解 【第1季】10.3.第10章_其他SLAM系统_机器学习与SLAM-视频讲解 第1季&#xff1a;第10章_其他SLAM系统 …

《HelloGitHub》第 103 期

兴趣是最好的老师&#xff0c;HelloGitHub 让你对编程感兴趣&#xff01; 简介 HelloGitHub 分享 GitHub 上有趣、入门级的开源项目。 github.com/521xueweihan/HelloGitHub 这里有实战项目、入门教程、黑科技、开源书籍、大厂开源项目等&#xff0c;涵盖多种编程语言 Python、…