16、缓存策略:LRU与LFU缓存实现、预加载与预解码、内存缓存与磁盘缓存分级

缓存这东西,说白了就是用空间换时间。

我在做音视频播放器的时候,最头疼的就是用户快速滑动进度条。每次拖动都要重新解码,卡得让人抓狂。后来我意识到,没有缓存策略的播放器,就像没有油箱的车——跑不远。

这一章,我们来聊聊缓存那些事。我会把我在项目中踩过的坑、总结的经验,都摊开来讲。

16.1 为什么需要缓存?

音视频处理有个特点:数据量大,计算密集。一个1080p的视频帧,解码后可能占用几MB内存。如果每次播放都要重新解码,CPU直接拉满,手机发烫,用户骂娘。

缓存的核心目标就三个:

  • 减少重复计算:已经解码的帧,别解第二遍
  • 降低延迟:用户拖进度条时,能快速响应
  • 节省带宽:网络数据别反复下载

核心原则:缓存是分级管理的。热数据放内存,温数据放磁盘,冷数据直接丢弃。

16.2 LRU与LFU:两种经典的淘汰策略

缓存空间是有限的。内存就那么大,磁盘也不能无限写。所以我们需要一套规则,来决定「谁该被踢出去」。

16.2.1 LRU:最近最少使用

LRU 的思路很简单:如果数据最近被访问过,那么将来被访问的概率也高。说白了,就是「优先淘汰最久没被用过的」。

我习惯用 LinkedHashMap 来实现 LRU,Android 的 LruCache 底层就是这个。

public class LRUCache<K, V> {
    private final LinkedHashMap<K, V> map;
    private final int maxSize;

    public LRUCache(int maxSize) {
        this.maxSize = maxSize;
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > maxSize;
            }
        };
    }

    public synchronized V get(K key) {
        return map.get(key);
    }

    public synchronized void put(K key, V value) {
        map.put(key, value);
    }
}

注意那个 accessOrder = true 的参数。它决定了 LinkedHashMap 是按访问顺序排序,还是按插入顺序排序。我刚开始用的时候没注意这个,结果 LRU 变成了 FIFO,缓存命中率惨不忍睹。

我的经验:LRU 适合「访问模式相对集中」的场景。比如用户反复看同一段视频,或者来回拖动进度条。但如果用户是随机跳转,LRU 的效果就会打折扣。

16.2.2 LFU:最不经常使用

LFU 的思路不同:它统计每个数据的访问频率,优先淘汰访问次数最少的

你想想看,有些数据虽然最近被访问过,但可能只是偶然。而有些数据虽然很久没被访问,但历史上被访问过很多次。LFU 更看重「历史贡献」。

public class LFUCache<K, V> {
    private final int capacity;
    private final Map<K, V> valueMap = new HashMap<>();
    private final Map<K, Integer> freqMap = new HashMap<>();
    private final TreeMap<Integer, LinkedHashSet<K>> freqList = new TreeMap<>();

    public LFUCache(int capacity) {
        this.capacity = capacity;
    }

    public V get(K key) {
        if (!valueMap.containsKey(key)) return null;
        increaseFreq(key);
        return valueMap.get(key);
    }

    public void put(K key, V value) {
        if (capacity == 0) return;
        if (valueMap.containsKey(key)) {
            valueMap.put(key, value);
            increaseFreq(key);
            return;
        }
        if (valueMap.size() >= capacity) {
            evict();
        }
        valueMap.put(key, value);
        freqMap.put(key, 1);
        freqList.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(key);
    }

    private void increaseFreq(K key) {
        int freq = freqMap.get(key);
        freqMap.put(key, freq + 1);
        freqList.get(freq).remove(key);
        if (freqList.get(freq).isEmpty()) {
            freqList.remove(freq);
        }
        freqList.computeIfAbsent(freq + 1, k -> new LinkedHashSet<>()).add(key);
    }

    private void evict() {
        int lowestFreq = freqList.firstKey();
        K keyToEvict = freqList.get(lowestFreq).iterator().next();
        freqList.get(lowestFreq).remove(keyToEvict);
        if (freqList.get(lowestFreq).isEmpty()) {
            freqList.remove(lowestFreq);
        }
        valueMap.remove(keyToEvict);
        freqMap.remove(keyToEvict);
    }
}

注意:LFU 有个问题——「缓存污染」。如果一个数据在短时间内被大量访问,它的频率会飙升,然后长期占据缓存。我遇到过这种情况:用户快速浏览视频封面,结果封面图把真正的视频帧缓存给挤掉了。

16.2.3 LRU vs LFU:怎么选?

维度 LRU LFU
淘汰依据 最近访问时间 历史访问频率
实现复杂度
内存开销 大(需要维护频率表)
适用场景 访问模式集中 访问模式分散但稳定
缓存污染风险

我个人习惯:音视频场景下,LRU 更实用。因为用户的操作往往是连续的,最近访问的数据大概率还会被再次访问。LFU 更适合「热点数据长期稳定」的场景,比如推荐系统的 Top-N 缓存。

16.3 预加载与预解码

缓存是被动策略——数据被访问了才缓存。但预加载是主动策略——我猜你会用,所以我提前准备好

16.3.1 预加载:提前拉数据

播放器里最常见的预加载,就是「预下载下一段视频」。用户还在看第 5 秒,我已经在后台下载第 10 秒的数据了。

实现起来不复杂:

public class Preloader {
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final Map<String, byte[]> cache = new ConcurrentHashMap<>();

    public void preload(String url) {
        executor.submit(() -> {
            if (cache.containsKey(url)) return;
            byte[] data = download(url); // 网络请求
            cache.put(url, data);
        });
    }

    public byte[] get(String url) {
        return cache.remove(url); // 取走即删除,避免内存泄漏
    }
}

避坑指南:预加载不是越多越好。我曾经把整个视频文件都预加载到内存里,结果 4K 视频直接 OOM。后来我改成「只预加载关键帧的索引数据」,内存占用降了 90%。

16.3.2 预解码:提前算好帧

预加载只是把原始数据拉到内存。但解码才是真正的性能杀手。所以更激进的做法是——预解码

比如用户正在看第 100 帧,我提前把第 101-110 帧都解码好,放到缓存里。这样用户拖动到附近时,直接拿解码后的数据,零延迟。

public class PreDecoder {
    private final MediaCodec decoder;
    private final ArrayBlockingQueue<VideoFrame> decodedFrames = new ArrayBlockingQueue<>(30);

    public void startPreDecode() {
        new Thread(() -> {
            while (!Thread.interrupted()) {
                // 从解码器取出已解码帧
                MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
                int outputIndex = decoder.dequeueOutputBuffer(info, 0);
                if (outputIndex >= 0) {
                    ByteBuffer buffer = decoder.getOutputBuffer(outputIndex);
                    VideoFrame frame = new VideoFrame(buffer, info);
                    decodedFrames.offer(frame); // 放入缓存队列
                    decoder.releaseOutputBuffer(outputIndex, false);
                }
            }
        }).start();
    }

    public VideoFrame getNextFrame() throws InterruptedException {
        return decodedFrames.take();
    }
}

注意:预解码非常消耗内存。一个 1080p 的 RGBA 帧就占 8MB 左右。如果预解码 30 帧,就是 240MB。所以一定要控制预解码的数量,并且配合 LRU 策略及时淘汰。

16.4 内存缓存与磁盘缓存分级

单一缓存是不够的。我习惯把缓存分成两级:

  • L1:内存缓存——快,但容量小
  • L2:磁盘缓存——慢,但容量大

访问流程是这样的:

  1. 先查 L1,命中直接返回
  2. L1 没命中,查 L2,命中后回填到 L1
  3. L2 也没命中,从网络/解码器获取,然后同时写入 L1 和 L2

关键点:L1 存的是「解码后的帧」,L2 存的是「压缩后的数据」。因为磁盘 IO 慢,不适合存大块数据。存 H.264 的 NAL 单元或者 JPEG 图片更合适。

public class TieredCache {
    private final LruCache<String, Bitmap> memoryCache; // L1
    private final DiskLruCache diskCache;               // L2

    public TieredCache(Context context) {
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8; // 分配 1/8 内存给缓存
        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap bitmap) {
                return bitmap.getByteCount() / 1024;
            }
        };
        diskCache = DiskLruCache.open(context.getCacheDir(), 1, 1, 50 * 1024 * 1024);
    }

    public Bitmap get(String key) {
        Bitmap bitmap = memoryCache.get(key);
        if (bitmap != null) return bitmap;

        // 从磁盘读取
        byte[] data = diskCache.get(key);
        if (data != null) {
            bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
            memoryCache.put(key, bitmap); // 回填到 L1
            return bitmap;
        }
        return null;
    }

    public void put(String key, Bitmap bitmap) {
        memoryCache.put(key, bitmap);
        // 异步写入磁盘
        new Thread(() -> {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            bitmap.compress(Bitmap.CompressFormat.JPEG, 80, bos);
            diskCache.put(key, bos.toByteArray());
        }).start();
    }
}

16.5 整体架构图

下面这张图,是我在项目中实际使用的缓存架构。你可以看到数据从网络到显示,经过了哪些缓存层。

音视频缓存分级架构图 数据源(网络/文件) L2:磁盘缓存(压缩数据) L1:内存缓存(解码后帧) 解码器 / 渲染器 预加载模块 提前下载数据 写入 L2 缓存 预解码模块 提前解码帧 写入 L1 缓存 淘汰策略 LRU / LFU 缓存回填 L2 → L1

16.6 一些实战建议

最后,分享几个我在项目中总结的经验:

  • 缓存大小要动态调整:不要写死 50MB。根据设备内存、屏幕分辨率、当前播放的视频码率来动态计算。低端机少给点,高端机多给点。
  • 注意内存碎片:频繁分配和释放 Bitmap 会导致内存碎片。我习惯用 BitmapPool 复用 Bitmap,而不是每次都新建。
  • 磁盘缓存要限制写入频率:频繁写磁盘会耗电、伤闪存。我一般会做一个「延迟写入」的机制——数据先放内存,等积累到一定量或者空闲时再批量写入。
  • 缓存命中率要打日志:上线后通过日志分析缓存命中率,如果低于 60%,说明策略有问题,需要调整。

一句话总结:缓存不是万能的,但没有缓存是万万不能的。好的缓存策略,能让你的播放器从「能用」变成「好用」。

公众号:蓝海资料掘金营,微信deep3321