Caffeine - Performance - Design

设计

访问有序队列

哈希表中的所有条目都有序排列在双向链表中。一个条目可以在O(1)时间复杂度内从HashMap中获取,并且获取其相邻条目。

访问顺序在条目被创建、更新或读取时定义。最近最少使用的条目排在表头,最近最多使用的条目排在表尾。这样可以为基于容量的剔除(maximumSize)和基于空闲时间的剔除(expireAfterAccess)提供支持。挑战是每一个访问都需要变更列表, 而列表本身无法实现高效并发。

写有序队列

写入顺序是在条目被创建和更新时定义的。与访问有序队列类似,写有序队列操作的时间复杂度也为O(1)。这个队列被用来实现基于存活时间的过期(expireAfterWrite)。

分层的计时器轮

一个时间感知优先队列,使用哈希和双向链表在时间复杂度O(1)内执行操作。这个队列被用来实现可变的过期(expireAfter(Expiry))。

读缓冲区

典型的缓存实现会在每一个操作上加锁以实现访问队列中条目的重新排序。一个替代选择是将每一个重新排序操作保存在一个缓冲区并且批量的应用变化。这可以被视为页面替换策略的预写日志。当缓冲区写满后会尝试获取锁并执行挂起操作,但是如果缓冲区已被保存,则线程可以立即返回。

读缓冲区是通过带状环形缓冲区实现的。条带用来减少竞争,通过线程特定的哈希选择条带。环形缓冲是一个固定大小的数组,这样会非常高效并且最大程度减少了垃圾回收开销。条带的数量可以基于竞争检测算法动态增长。

写缓冲区

与读缓冲区类似,这个缓冲区用来重演写事件。读缓冲区是允许事件丢失的,因为这些事件是仅用来优化剔除策略的命中率。写操作不能被丢失,因此它必须采用高效的有界队列实现。由于每次填充时都要事先清空写缓存,它通常保持为空或很小。

写缓冲实现为一个可扩展的环形数组,它可以调整大小直到一个最大值。当调整大小时,会分配产生一个新的数组。之前的数组包含一个前供消费者遍历的转发连接,然后允许释放旧的数组。通过使用这种分块机制,缓冲区的初始大小较小,读写的成本较低,并且产生的垃圾较少。当缓冲区已满并且无法增长时,生产者会在短时间内持续轮询重试并尝试调度维度工作。这样允许消费者线程通过重演剔除策略上的写操作,优先耗尽缓存。

锁摊销

传统的缓存通过对每个操作加锁执行少量的工作,Caffeine会批处理工作并且将成本摊销到多个线程中。这样对锁定的工作成本进行摊销,而不是将竞争锁作为成本。成本摊销委派给已配置的执行器执行,如果任务被拒绝或使用了调用者运行策略时,它有可能被用户线程执行。

批处理的一个优点是,基于锁的排他性,缓冲区在给定的时间只能被单个线程耗尽。这允许使用更有效的基于多生产者/单消费者的缓冲区的实现。通过利用CPU缓存效率,它可以更好地与硬件特性保持一致。

条目状态转换

当缓存不被排他锁保护时,一个挑战是操作可能 以错误的顺序 记录和重演。 因为竞争,创建-读取-更新-删除序列可能无法以同样的顺序存储到缓冲区中。如果这样做将需要粗粒度锁,而这会降低性能。

与并发的数据结构中的典型情况一样, Caffeine使用原子状态转换解决这个难题。 一个条目有存活、退休、死亡三个状态。 存活状态意味着条目在哈希表 和 访问/写 队列中同时存在。 当条目从哈希表移除时被标记为退休 并且需要从队列中移除。 当从队列移除后,条目被视为死亡,并且可以进行垃圾回收。

宽松的读和写?

Caffeine格外小心,以使每一个不稳定操作都正确执行。 不同于 从语言的易失性读写 思考的方式,内存屏障提供了一个面向硬件的视图。通过了解发射了哪些屏障 以及 对硬件 和 数据可见性的影响 ,有可能实现更好性能。

当在锁保护下进行排他访问时,由于锁获取的内存屏障提供了数据可见性,Caffeine可以使用宽松读取。当无论如何都会发生数据争用时,这也是可以接受的,如检查当条目是否在读取时过期来模拟缓存丢失。

Caffeine以与读取类似的方式使用宽松写。 如果条目在锁保护下进行排他写,那么写操作会背负解锁时发出的内存屏障。有时候也倾向于写偏序,例如在读取条目时更新访问时间戳。

剔除策略

Caffeine使用Window TinyLfu策略提供接近最优的命中率。访问队列被分为两空间:入场窗口和主空间,如果被TinyLfu策略接收,则从入场窗口转移到主空间。 TinyLfu估计 窗口受害者 和 主空间受害者的访问频率, 选择保留历史使用率最高的条目。 频率计数保存在一个 4 位 CountMinSketch中, 每个条目需要8个字节才能精确。 这种配置可以基于O(1)的时间复杂度进行基于频率和新近度的剔除,并且占用较小的内存。

自适应性

进场窗口和主空间的大小是 基于工作负荷特性 动态决定的。 如果基于新近度则需要设置较大的入场窗口,如果基于访问频率则设置较小的入场窗口。Caffeine使用爬山算法(hill climibing)采样命中率,进行调整,配置以达到最佳平衡。

快速路径

当缓存使用量低于最大容量的50%时,剔除策略并未完全启动。频率草图尚未初始化以减少内存占用,因为缓存可能被人为的分配了高阈值。除非有另外的功能要求,否则不记录访问,以免读缓冲区的争用和耗尽时访问重播。

HashDos防护

当keys有相同的哈希值,或者被哈希到统一位置时,冲突可能被利用使性能降级。哈希表通过从链表降级到红黑树来解决此问题。

对TinyLFU的攻击是利用冲突人为的提高剔除策略受害者的预估频率。这将导致所有新到达的条目被频率过滤器拒绝,从而使缓存失效。一种解决是引入少量的抖动使决策具有不确定性。这是通过随机接纳 ~1% 的具有中等频率的被拒绝候选人来完成的。

代码生成

有很多不同的配置项,导致仅在启用特定的功能子集时,才需要大多数字段。 如果默认显示所有字段,可能会增加缓存和每个条目的开销,从而造成浪费 。 通过代码生成最佳实现, 可以减少运行时内存的开销,但需要更大的磁盘二进制文件。

该技术有用于算法优化的潜力。也许在构建缓存时,用户可以指定最适合其使用的特性。移动应用可能更喜欢并发下的高命中率,而服务器应用程序则希望以牺牲内存为代价来换取更高的命中率。与其试图为所有使用场景找到最佳平衡,意图可能会驱动算法选择。

包装的HashMap

缓存实现通过包装ConcurrentHashMap来添加需要的功能。缓存和哈希表的并发算法都非常复杂。通过将它们分开,可以很容易地利用哈希表设计上的改进,并且避免了粗粒度锁同时覆盖表和剔除关注点。

这种方法的成本是额外的运行时开销。与通过包装值以容纳额外的元数据不同,这些字段将直接内联到表条目上。缺少封装可能会提供通过单个表操作的快捷路径,而不是多个映射调用和短暂对象实例(例如lambda)。

先前的项目探索了这两种方法:使用了装饰模式的ConcurrentLinkedHashMap和Guava的分支哈希表。不足为奇的是,由于最终设计的巨大工程复杂性,分支的动机很少出现。

参考

What is a Data Race?
Race Condition(竞争条件)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值