第二十章:资产缓存

在 jMonkeyEngine 中,资产缓存(AssetCache)对开发者是个黑盒。绝大多数时间你都不会意识到它的存在,直到有一天程序突然发生内存溢出,抛出一个 OutOfMemoryError

例如:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

众所周知,Java程序运行于虚拟机(JVM)之上,会自动进行垃圾回收(GC),不需要程序员来管理内存。但是GC正常工作有个前提,就是开发者不要自己管理内存

由于3D游戏会使用大量多媒体资产文件,加载它们会产生大量I/O操作,造成性能下降。为了提高性能,多数游戏引擎都会使用缓存,而使用缓存就意味着 程序自己管理内存 。如果缓存使用不当,GC的工作就会受到影响,极有可能造成内存泄露、内存溢出等问题。

本文的目标是:

  • 介绍 jME3 中的资产缓存机制;
  • 介绍 AssetManager、AssetCache等常用API;

本文的目标不是:

  • 解释类、对象、引用的概念;
  • 解释Java虚拟机的内存布局;
  • 解释Java虚拟机的垃圾回收(GC)原理;
  • 解释什么是强引用、软引用、弱引用、虚引用。

Java虚拟机的内存管理是一个很大的话题,本文无法展开深入讨论,重点将聚焦在jME3资产缓存的用法。如果读者对JVM、GC算法等话题感兴趣,我推荐一本书:《深入理解Java虚拟机》。

缓存基本原理

不知大家平时是否观察过便利店,店家一般会把香烟摆在门口的柜台里,因为它们最好卖;夏天,便利店通常会把装冰淇淋、冷饮的冰柜摆在门口,也是因为它们最好卖。

把常用的东西放在触手可及的地方,这就是缓存(Cache)的基本思想。

哈希表

缓存一般使用哈希表(Hash Table)作为数据结构,采用键值对(Key-Value)的方式进行读写。使用时哈希表时,先要根据 Key 来计算一个 Hash 值,然后再根据 Hash 值映射到内存地址。Hash算法的时间复杂度是 O(1),速度比I/O要快很多,这是缓存能够提高性能的原因。

Java 中的 HashMap、HashSet、Hashtable 等数据结构都是基于Hash算法实现的,因此常被用于缓存数据。

缓存命中率

用户访问缓存时,如果缓存中已经保存了要被访问的数据,称为命中;如果缓存中没有要访问的数据,称为未命中。 如果缓存未命中,程序就需要去读取文件数据。

缓存命中率 = 命中次数 / (命中次数 + 未命中次数),命中率 是评价缓存加速效果的重要指标。

为了提高命中率,很容易想到的做法是把数据都塞进缓存中。但如果大部分数据都不会被再次访问,这样做显然会造成内存浪费,实际上也没有无限大的内存供程序使用。如果你真的这么做了,很快就会发生内存溢出。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

正常的做法,要限制缓存的大小,并要评估哪些数据是常用的。尽量把 热点数据 放进缓存;把那些不常用的数据从缓存中移除,称为 缓存淘汰

缓存淘汰算法

LFU (Least frequently used, 最不经常使用) 是一种常用的缓存淘汰算法。这种算法的思想很简单:假设公司有10个推销员,到了季度末招聘新人时,客户数最少的那个推销员会被淘汰,让出空位给新人;客户数最多的推销员,肯定是老板眼里的红人(热点)。实现这种算法需要引入一个计数器,统计每个对象被访问的次数。

LRU (Least recently used,最近最少使用) 与 LFU 非常类似,但这种算法不记录对象的访问次数,而是记录对象被访问的时间。还是那个例子:假设公司有10个销售员,销售员A是个新人,最近一季度获得了10位客户;销售员B是个老人,累计获得了100个客户,但最近一个季度没有获得任何客户。老板虽然很感激销售员B为公司做出的贡献,但还是决定开除他/她。

FIFO (First in first out, 先进先出) 是另一种常用算法。它的思路也很简单,就是淘汰老人,一般可以使用队列来实现。

jME3的缓存

jME3 中有三种不同的资产缓存(AssetCache),都是基于哈希表的。AssetCache 内部使用 AssetKey 作为查询的“键”,可以缓存任意类型的数据。

  • SimpleAssetCache
  • WeakRefAssetCache
  • WeakRefCloneAssetCache

出于多线程开发的考虑,jME3的缓存实现必须是线程安全(thread-safe)的,这三种缓存都符合这个要求。如果你要自己实现缓存管理,也必须是线程安全的。

这三种缓存都没有限制内存的大小,也没有实现前面介绍过的缓存淘汰算法。如果你的程序从来都只加载资产,从来不清理缓存,就 有可能 发生内存溢出。但这也不是必然的,如果你的程序较小,使用的资产本身并不多,就不会发生这种情况。此外,jME3利用Java的GC机制实现了另一种缓存淘汰算法。

SimpleAssetCache

SimpleAssetCache 是 AssetKey 默认的缓存方式。它的内部非常简单,直接使用 ConcurrentHashMap 实现了缓存,以保证线程安全。

由于 ConcurrentHashMap 内部以强引用方式报保存数据,而且没有使用任何缓存淘汰算法,使用这种缓存会导致GC完全失效。如果有大量数据被缓存,不及时清理,就会发生内存溢出。

SimpleAssetCache 不管理克隆对象。使用 AssetKey 查询 SimpleAssetCache 命中时,总会直接返回缓存的对象。

WeakRefAssetCache

jME3 使用 WeakRefAssetCache 来缓存音频数据,但流模式(Stream)的音频数据不缓存。

WeakRefAssetCacheSimpleAssetCache 一样不管理克隆对象。查询缓存命中时,总会直接返回缓存的对象。

WeakRefAssetCache 的内部同样使用 ConcurrentHashMap 实现线程安全缓存,但却没有把要缓存的对象直接存入,而是通过 WeakReference 来保存对象。

WeakReference 是 Java 用来描述弱引用关系的类。弱引用的特点是,一旦程序中其他位置不再有强引用指向对象,WeakReference 所引用的对象就会被GC回收。使用 WeakRefAssetCache 意味着缓存数据可能会被 GC 回收,相当于一种另类的缓存淘汰算法。

WeakRefCloneAssetCache

WeakRefCloneAssetCacheWeakRefAssetCache 相似,它可以利用GC来回收内存。不同之处在于,查询缓存命中时,可能会返回对象的克隆,而 不是原对象。该缓存的内部会记录对象被克隆的次数,只有当 这些克隆对象都被GC回收 之后,WeakRefCloneAssetCache 中缓存的对象才会被GC回收。

为什么要克隆?为什么不使用 WeakRefAssetCacheSimpleAssetCache 呢?

例如游戏中可能会有多个NPC共用同一个模型。使用 WeakRefCloneAssetCache 可以使 NPC 模型文件只读取一次,然后被多次克隆使用。游戏运行时,每个NPC都有自己的动画、空间变换等状态。如果使用同一个对象,就会导致所有NPC的看起来都是完全一样的,并且重叠在空间中的同一个位置。

材质的使用方式也类似:同一种材质会被多个模型,但每个模型的材质参数都是不一样的,需要创建不同的材质实例。

WeakRefCloneAssetCache 不能单独使用。缓存对象必须实现 com.jme3.asset.CloneableSmartAsset 接口,否则无法正确克隆对象。此外,一般还要配合 com.jme3.asset.CloneableAssetProcessor使用,这个类负责创建克隆对象。

使用缓存

设置缓存类型

加载资产时到底会使用何种缓存?

AssetKey 中有一个 getCacheType() 方法,该方法返回了缓存类型。若返回 null,表示不使用缓存。

AssetKey 中的默认实现是这样的:

public Class<? extends AssetCache> getCacheType(){
    return SimpleAssetCache.class;
}

因此,直接使用 AssetKey 加载资产会使用强引用的方式缓存。

在AudioKey中,该方法是这样实现的:

public Class<? extends AssetCache> getCacheType() {
    if ((stream && streamCache) || !stream) {
        // Use non-cloning cache
        return WeakRefAssetCache.class;
    } else {
        // Disable caching for streaming audio
        return null;
    }
}

可以看到,流模式的音频数据是不缓存的。非流模式的数据,会采用弱引用的方式缓存。

在 ModelKey、TextureKey、MaterialKey中,该方法是这样实现的:

public Class<? extends AssetCache> getCacheType(){
    return WeakRefCloneAssetCache.class;
}

public Class<? extends AssetProcessor> getProcessorType(){
    return CloneableAssetProcessor.class;
}

jME3 中的 SpatialTextureMateiral 均实现了 CloneableSmartAsset 接口。AssetManager在加载这些对象时,会利用 CloneableAssetProcessor 来创建克隆对象,并通知 WeakRefCloneAssetCache 记录克隆的次数。

AssetCache接口

AssetCache 是 jME3 定义的资产缓存接口,其中定义了6个方法。jME3默认的三种缓存都实现了这个接口。

package com.jme3.asset.cache;

import com.jme3.asset.AssetKey;

public interface AssetCache {
    public <T> void addToCache(AssetKey<T> key, T obj);
    public <T> T getFromCache(AssetKey<T> key);
    public boolean deleteFromCache(AssetKey key);
    public void clearCache();
    public <T> void registerAssetClone(AssetKey<T> key, T clone);
    public void notifyNoAssetClone();
}

AssetCache 的前四个方法,用于添加、查找、删除、清空缓存;后两个方法用于处理缓存的克隆操作。AssetManager 中定义了与前四个方法同名的接口,开发时一般会直接调用AssetManager中提供的方法。

下面的代码演示了如何使用缓存。

package net.jmecn.assets;

import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetManager;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.system.JmeSystem;

/**
 * 测试缓存
 * @author yanmaoyuan
 *
 */
public class TestCache {

    public static void main(String[] args) {
        // 初始化AssetManager
        AssetManager assetManager = JmeSystem.newAssetManager();
        assetManager.registerLocator("./", FileLocator.class);
        assetManager.registerLoader(TextLoader.class, "txt");

        // 定义AssetKey
        AssetKey<String> key = new AssetKey<String>("assets/hello.txt");

        // 加载资源,此时AssetManager会自动缓存数据。
        String str1 = assetManager.loadAsset(key);
        System.out.println("str1:" + str1);

        // 查找缓存
        String str2 = assetManager.getFromCache(key);
        System.out.println("str2:" + str2);

        // 删除缓存
        boolean success = assetManager.deleteFromCache(key);
        System.out.println( success ? "删除成功" : "删除失败");

        // 删除后再次查找缓存
        String str3 = assetManager.getFromCache(key);
        System.out.println("str3:" + str3);
    }

}

运行结果:

str1:Hello jMonkeyEngine!

str2:Hello jMonkeyEngine!

删除成功
str3:null

通过上面的代码,可以说明缓存的基本用法。

  • 开发者无需手动调用 addToCache(key, obj) 方法,loadAsset(key) 方法会自动缓存数据。
  • getFromCache(key) 方法用于查找缓存。若命中缓存,就能得到缓存数据;若未命中,则会得到null。
  • deleteFromCache(key) 方法用于删除缓存。若key对应的缓存存在,并且被成功删除,就会返回 true;否则将返回false。
  • 必须定义 AssetKey,否则无法查找、删除缓存数据。

代码中没有演示 clearCache() 的用法,它的作用是清空所有缓存。

实际开发时,往往连 getFromCache(key) 方法都用不上,因为 loadAsset(key) 方法会自动查找缓存。

看下面的代码:

public static void main(String[] args) {
    // 初始化AssetManager
    AssetManager assetManager = JmeSystem.newAssetManager();
    assetManager.registerLocator("./", FileLocator.class);
    assetManager.registerLoader(TextLoader.class, "txt");

    // 定义AssetKey
    AssetKey<String> key = new AssetKey<String>("assets/hello.txt");

    // 加载资源,此时AssetManager会自动缓存数据。
    String str1 = assetManager.loadAsset(key);
    System.out.println("str1:" + str1);

    // 再次加载,此时AssetManager将会从缓存中读取数据。
    String str2 = assetManager.loadAsset(key);
    System.out.println("str2:" + str2);

    // 判断是否为同一个对象
    System.out.println("str1 == str2 : " + ( str1 == str2 ));
}

运行结果:

str1:Hello jMonkeyEngine!

str2:Hello jMonkeyEngine!

str1 == str2 : true

这个结果说明 loadAsset(key) 方法执行时,会先查询缓存。如果缓存命中,就不会发生I/O操作。

需要注意的是,AssetKeky 默认采用了 SimpleAssetCache,导致返回的缓存对象就是原对象,因此最后一行输出为 str1 == str2 : true

不使用缓存

如果你不想使用缓存,可以覆盖 AssetKey 中的 getCacheType() 方法,使其返回 null

public static void main(String[] args) {
    // 初始化AssetManager
    AssetManager assetManager = JmeSystem.newAssetManager();
    assetManager.registerLocator("./", FileLocator.class);
    assetManager.registerLoader(TextLoader.class, "txt");

    // 定义AssetKey
    AssetKey<String> key = new AssetKey<String>("assets/hello.txt") {
        // 禁用缓存
        public Class<? extends AssetCache> getCacheType(){
            return null;
        }
    };

    // 加载资产
    String str1 = assetManager.loadAsset(key);
    System.out.println("str1:" + str1);

    // 加载资产
    String str2 = assetManager.loadAsset(key);
    System.out.println("str2:" + str2);

    // 判断是否为同一个对象
    System.out.println("str1 == str2 : " + ( str1 == str2 ));
}

运行结果:

str1:Hello jMonkeyEngine!

str2:Hello jMonkeyEngine!

str1 == str2 : false

但是这么做的话,就不能再使用 getFromCache(key) 方法了,因为根本就没有缓存。如果强行调用,将会产生异常。

演示代码:

public static void main(String[] args) {
    // 初始化AssetManager
    AssetManager assetManager = JmeSystem.newAssetManager();
    assetManager.registerLocator("./", FileLocator.class);
    assetManager.registerLoader(TextLoader.class, "txt");

    // 定义AssetKey
    AssetKey<String> key = new AssetKey<String>("assets/hello.txt") {
        // 禁用缓存
        public Class<? extends AssetCache> getCacheType(){
            return null;
        }
    };

    // 加载资源,此时AssetManager会自动缓存数据。
    String str1 = assetManager.loadAsset(key);
    System.out.println("str1:" + str1);

    // 查询缓存
    String str2 = assetManager.getFromCache(key);
    System.out.println("str2:" + str2);

    // 判断是否为同一个对象
    System.out.println("str1 == str2 : " + ( str1 == str2 ));
}

运行结果:

str1:Hello jMonkeyEngine!

Exception in thread "main" java.lang.IllegalArgumentException: Key assets/hello.txt specifies no cache.
    at com.jme3.asset.DesktopAssetManager.getFromCache(DesktopAssetManager.java:209)
    at net.jmecn.assets.TestCache.main(TestCache.java:37)

弱引用智能拷贝

如果想要使用 WeakRefCloneAssetCache,加载的对象类型必须实现 CloneableSmartAsset 接口,并且在 AssetKey 中指定缓存类型为 WeakRefCloneAssetCache.class,后处理类型为 CloneableAssetProcessor.class。这样AssetManager就会使用弱引用智能拷贝功能。

代码如下:

package net.jmecn.assets;

import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetManager;
import com.jme3.asset.AssetProcessor;
import com.jme3.asset.CloneableAssetProcessor;
import com.jme3.asset.CloneableSmartAsset;
import com.jme3.asset.cache.AssetCache;
import com.jme3.asset.cache.WeakRefCloneAssetCache;
import com.jme3.system.JmeSystem;

/**
 * 测试缓存
 * @author yanmaoyuan
 *
 */
public class TestCache {
    
    /**
     * 测试用Key
     * @author yanmaoyuan
     *
     */
    private static class DummyKey extends AssetKey<DummyAsset> {
        public DummyKey(String name) {
            super(name);
        }
        @Override
        public Class<? extends AssetCache> getCacheType(){
            // 指定使用弱引用、克隆缓存。
            return WeakRefCloneAssetCache.class;
        }
        @Override
        public Class<? extends AssetProcessor> getProcessorType(){
            // 指定使用智能拷贝。
            return CloneableAssetProcessor.class;
        }
    }

    /**
     * 测试用资产类型
     * @author yanmaoyuan
     *
     */
    private static class DummyAsset implements CloneableSmartAsset {
        private byte[] data;
        public DummyAsset(byte[] data) {
            this.data = data;
        }
        // 实现克隆方法
        @Override public Object clone() {
            return new DummyAsset(data.clone());
        }
        // 实现 CloneableSmartAsset 接口
        private AssetKey key;
        @Override public void setKey(AssetKey key) { this.key = key; }
        @Override public AssetKey getKey() { return key; }
    }
    
    public static void main(String[] args) {
        // 初始化AssetManager
        AssetManager assetManager = JmeSystem.newAssetManager();

        // 创建AssetKey
        DummyKey key = new DummyKey("dummy");
        
        // 创建资产数据
        DummyAsset asset = new DummyAsset(new byte[1024]);
        System.out.println(asset);
        
        // 存入缓存
        assetManager.addToCache(key, asset);
        
        // 加载资产
        DummyAsset asset2 = assetManager.loadAsset(key);
        System.out.println(asset2);
        
    }

}

运行结果:

net.jmecn.assets.TestCache$DummyData@372f7a8d
net.jmecn.assets.TestCache$DummyData@4f023edb

可以看到,loadAsset(key) 方法返回的并不是刚开始创建的对象,说明AssetManager克隆了原对象。