第二十章:资产缓存
在 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)的音频数据不缓存。
WeakRefAssetCache
和 SimpleAssetCache
一样不管理克隆对象。查询缓存命中时,总会直接返回缓存的对象。
WeakRefAssetCache
的内部同样使用 ConcurrentHashMap
实现线程安全缓存,但却没有把要缓存的对象直接存入,而是通过 WeakReference
来保存对象。
WeakReference
是 Java 用来描述弱引用关系的类。弱引用的特点是,一旦程序中其他位置不再有强引用指向对象,WeakReference
所引用的对象就会被GC回收。使用 WeakRefAssetCache
意味着缓存数据可能会被 GC 回收,相当于一种另类的缓存淘汰算法。
WeakRefCloneAssetCache
WeakRefCloneAssetCache
与 WeakRefAssetCache
相似,它可以利用GC来回收内存。不同之处在于,查询缓存命中时,可能会返回对象的克隆,而 不是原对象。该缓存的内部会记录对象被克隆的次数,只有当 这些克隆对象都被GC回收 之后,WeakRefCloneAssetCache
中缓存的对象才会被GC回收。
为什么要克隆?为什么不使用 WeakRefAssetCache
或 SimpleAssetCache
呢?
例如游戏中可能会有多个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 中的 Spatial
、Texture
、Mateiral
均实现了 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克隆了原对象。