HTreeMap 为 MapDB 提供了 HashMap 和 HashSet 集合。它可以选择性地支持条目过期功能,并且能用作缓存。它是线程安全的,在并行更新时具有良好的扩展性。
HTreeMap 是线程安全的,并且通过使用多个段(每个段都有独立的 ReadWriteLock)来支持并行写入。JDK 7 中的 ConcurrentHashMap 也采用类似的工作方式。段的数量(也称为并发因子)是可配置的。
HTreeMap 是一种分段哈希树。与其他 HashMap 不同,它不使用固定大小的哈希表,并且在哈希表增长时不会对所有数据进行重哈希。HTreeMap 使用自动扩展的索引树,因此永远不需要调整大小。它占用的空间也更少,因为空的哈希槽不会消耗任何空间。另一方面,这种树结构需要更多的查找操作,访问速度较慢。其性能会随着大小而下降。
HTreeMap 可选择性地支持基于四个标准的条目过期:
(1)最大映射大小
(2)最大存储大小
(3)自上次修改后的存活时间
(4)自上次访问后的存活时间
过期的条目会被自动移除。
注意:此功能使用先进先出(FIFO)队列,并且每个段都有独立的过期队列。
HTreeMap 有多个参数。其中最重要的是 name(名称),它用于在数据库对象中标识映射,以及
serializers(序列化器),后者用于处理映射内部的数据。
方法定义:
hashMap(java.lang.String name)
hashMap(java.lang.String name, Serializer<K> keySerializer, Serializer<V> valueSerializer)
示例:
public static class CreateHTreeMapExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap<String, String> map = memoryDB
// 创建 HTreeMap
.hashMap("map")
// 指定键值序列化器
.keySerializer(Serializer.STRING)
// 指定值序列化器
.valueSerializer(Serializer.STRING)
.createOrOpen();
// 设置值
map.put("content", "hello world");
// 获取值
String value = map.get("content");
System.out.println("value=" + value);
}
}
}或者更简短的形式
public static class SimpleCreateHTreeMapExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap<String, String> map = memoryDB
// 创建 HTreeMap,指定名称和键值序列化器
.hashMap("map", Serializer.STRING, Serializer.STRING)
.createOrOpen();
// 设置值
map.put("content", "hello world");
// 获取值
String value = map.get("content");
System.out.println("value=" + value);
}
}
}也可以跳过序列化器的定义,但MapDB会使用速度较慢的通用序列化,这种做法不推荐:
public static class CreateHTreeMapDefSerializerExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap map = memoryDB
// 创建 HTreeMap
.hashMap("map")
.createOrOpen();
// 设置值
map.put("content", "hello world");
// 获取值
String value = (String)map.get("content");
System.out.println("value=" + value);
}
}
}注意:推荐使用 HTreeMap 来处理大量键值对。
在某些情况下,你可能希望使用压缩功能。可以在整个存储范围内启用压缩,但这会带来一些开销。相反,更好的做法是仅对键或值上的特定序列化器应用压缩。这可以通过使用序列化器包装器来实现:
public static class CreateHTreeMapCompressionExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap<String, String> map = memoryDB
// 创建 HTreeMap,指定名称和键值序列化器
// 序列化器支持压缩和解压缩
.hashMap("map", new SerializerCompressionWrapper<String>(Serializer.STRING),
new SerializerCompressionWrapper<String>(Serializer.STRING))
.createOrOpen();
// 设置值
map.put("content", "hello world");
// 获取值
String value = map.get("content");
System.out.println("value=" + value);
}
}
}注意:SerializerCompressionWrapper 用于包装另一个序列化器,并对其输出 / 输入数据进行压缩 / 解压缩处理。
大多数哈希映射使用由 Object.hashCode() 生成的 32 位哈希,并通过 Object.equals(other) 检查相等性。然而,许多类(byte[]、int[])没有正确实现这一点。
MapDB 使用键序列化器来生成哈希码并比较键。例如,如果将 Serializer.BYTE_ARRAY 用作键序列化器,那么 byte[] 可以直接作为 HTreeMap 中的键:
public static class CreateByteArrayKeyHTreeMapExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap<byte[], String> map = memoryDB
// 创建 HTreeMap
.hashMap("arrayKeyMap")
// 指定key的序列化器,key为字节数组
.keySerializer(Serializer.BYTE_ARRAY)
// 指定value的序列化器,value为字符串
.valueSerializer(Serializer.STRING)
.createOrOpen();
byte[] key = "content".getBytes(StandardCharsets.UTF_8);
// 设置值
map.put(key, "hello world");
// 获取值
String value = map.get(key);
System.out.println("value=" + value);
}
}
}注意:首次运行时,一定要保证名为 arrayKeyMap 的集合不存在,否则可能导致类型转换失败,以前的 Key 不一定是字节数组。
另一个问题是某些类中的 hashCode() 方法较弱,这会导致冲突并降低性能。String.hashCode() 方法较弱,但它是规范的一部分,因此无法更改。JDK 中的 HashMap 实现了许多变通方法,但这是以内存和性能开销为代价的。HTreeMap 没有此类变通方法,弱哈希会使其性能大幅下降。
相反,HTreeMap 正在解决问题的根源,Serializer.STRING 使用更强的 XXHash,产生的冲突更少。String.hashCode() 仍然可用,但需要使用不同的序列化器:
// 这将对字符串使用强度较高的 XXHash 算法
HTreeMap<String, Long> map = db.hashMap("map")
// 默认情况下,它使用强度较高的 XXHash算法
.keySerializer(Serializer.STRING)
.valueSerializer(Serializer.LONG)
.create();
// 这将使用较弱的 String.hashCode()
HTreeMap<String, Long> map2 = db.hashMap("map2")
// 使用较弱的 String.hashCode()
.keySerializer(Serializer.STRING_ORIGHASH)
.valueSerializer(Serializer.LONG)
.create();哈希映射容易受到哈希碰撞攻击。
HTreeMap 添加了哈希种子以提供保护。哈希种子在集合创建时随机生成,并与其定义一起持久化。用户也可以提供自己的哈希种子:
public static class CreateHTreeMapByMySeedExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.fileDB("file.db").make()) {
HTreeMap<String, String> map = memoryDB
.hashMap("mapBySeed", Serializer.STRING, Serializer.STRING)
.hashSeed(1024) // 设置哈希种子
.createOrOpen();
// 设置值
map.put("content", "hello world");
// 获取值
String value = map.get("content");
System.out.println("value=" + value);
}
}
}HashMap 有初始容量、加载因子等参数。MapDB 有一组不同的参数来控制其访问时间和最大大小。这些参数被归在“映射布局”这一术语下。
并发是通过使用多个段来实现的,每个段都有独立的读写锁。每个并发段都是独立的,拥有自己的大小计数器、迭代器和过期队列。段的数量是可配置的。数量过少会导致并发更新时出现拥堵,数量过多则会增加内存开销。
HTreeMap 在其哈希表中使用索引树(Index Tree),而非动态扩容的 Object[] 数组。索引树是一种类似稀疏数组的结构,它采用数组的树形层次结构。它具有稀疏性,因此未使用的条目不会占用任何空间。它不进行重哈希(即不将所有条目复制到更大的数组中),但也无法超出其初始容量。
HTreeMap 的布局由布局函数控制。它接受三个参数:
并发度,即段的数量。默认值为8,它总是向上取整为2的幂。
索引树目录节点的最大节点大小。默认值为16,它总是向上取整为2的幂。最大值为 128 个条目。
索引树中的层级数量,默认值为 4。
最大哈希表大小的计算公式为:段数 × 节点大小^层级数
默认的最大哈希表大小为 8×16^4=50万条目。
如果哈希表大小设置得过低,一旦填满就会开始发生哈希冲突,性能也会下降。HTreeMap 即使在哈希表满了之后,仍然会接受新的条目,但性能会下降。
32位哈希对哈希表大小施加了上限:40亿个条目。
另一个参数是大小计数器。默认情况下,HTreeMap 不记录其大小,map.size() 会执行线性扫描来计算所有条目的数量。你可以启用大小计数器,在这种情况下,map.size() 能立即得出结果,但插入操作会有一些额外开销。
public static class HTreeMapCounterEnableExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.memoryDB().make()) {
HTreeMap<String, String> map = memoryDB
.hashMap("mapBySeed", Serializer.STRING, Serializer.STRING)
.counterEnable() // 启用计数器
.createOrOpen();
// 设置值
for(int i = 0; i < 1000; i++) {
map.put("content-" + i, "hello world " + i);
}
// 获取值大小
final long start = System.currentTimeMillis();
long size = map.size();
System.out.println("size=" + size + ", time=" + (System.currentTimeMillis() - start));
// 启用计数器,耗时0毫秒
// 不启用计数器,耗时20毫秒作用
}
}
}最后再来说点好东西。这里有一个值加载器,它是一个函数,用于在未找到现有键时加载值。新创建的键/值对会被插入到映射中。通过这种方式,map.get(key) 永远不会返回 null。这在各种生成器和缓存中特别有用。例如:
public static class HTreeMapValueLoaderExample {
public static void main(String[] args) {
try (DB memoryDB = DBMaker.memoryDB().make()) {
HTreeMap<String, String> map = memoryDB
.hashMap("valueLoaderMap", Serializer.STRING, Serializer.STRING)
// 默认值加载器
// 当没有获取到值时,触发该加载器
.valueLoader((key) -> "default value: " + key.toUpperCase())
.createOrOpen();
// 获取值
String value = map.get("content");
System.out.println("value=" + value); // value=default value: CONTENT
value = map.get("content");
System.out.println("value=" + value); // value=default value: CONTENT
}
}
}HTreeMap 被分割成独立的段。每个段都是独立的,不与其他段共享任何状态。不过,它们仍然共享底层的 Store,这会影响并发负载下的性能。可以通过为每个段使用单独的 Store,使各段真正独立。
这被称为分片 HTreeMap,它是直接通过 DBMaker 创建的:
public static class HTreeMapShardingExample {
public static void main(String[] args) {
HTreeMap<String, byte[]> map = DBMaker
// 8 是存储的数量(并发因子)
.memoryShardedHashMap(8)
.keySerializer(Serializer.STRING)
.valueSerializer(Serializer.BYTE_ARRAY)
.create();
// 添加数据
map.put("name", "MapDB".getBytes(StandardCharsets.UTF_8));
// 获取数据
byte[] value = map.get("name");
if(value != null) {
System.out.println("value=" + new String(value, StandardCharsets.UTF_8));
} else {
System.err.println("value is null");
}
//数据库不存在,因此直接关闭映射
map.close();
}
}分片 HTreeMap 具有与由 DB 创建的 HTreeMap 相似的配置选项。但该 HTreeMap 没有关联的 DB 对象。因此,要关闭分片 HTreeMap,必须直接调用 HTreeMap.close() 方法。
HTreeMap 在满足某些条件时提供可选的条目过期功能。条目在以下情况下可能会过期:
映射中存在的条目持续时间超过了过期时间。过期时间可以从创建时、最后修改时或最后一次读取访问时开始计算。
映射中的条目数量将超过最大数量。
映射占用的磁盘空间或内存超过空间限制。
这将设置自创建、最后更新和最后访问以来的过期时间:
(1)验证 get() 后一段时间过期
public static class HTreeMapTimeExpireExample {
public static void main(String[] args) throws Exception {
try (DB db = DBMaker.memoryDB().make()) {
HTreeMap cache = db.hashMap("cache")
// 最后一次 get() 后 1 秒过期
.expireAfterGet(1, TimeUnit.SECONDS)
.create();
// 写入一个值
cache.put("key", "value");
// 获取值, 该值将在1秒后过期
cache.get("key");
// 休息3秒钟,再次获取值
Thread.sleep(1500);
System.out.println(cache.get("key")); // null
}
}
}(2)验证 create() 后一段时间过期删除
public static class HTreeMapCreateExpireExample {
public static void main(String[] args) throws Exception {
try (DB db = DBMaker.memoryDB().make()) {
// 在条目最后一次修改后 10 秒或最后一次 get() 后1秒删除条目
HTreeMap cache = db.hashMap("cache")
// 创建后 2 秒后过期
.expireAfterCreate(2, TimeUnit.SECONDS)
// 必须添加下面两个expire,不然不会过期,不解啊
.expireAfterUpdate(120, TimeUnit.SECONDS)
.expireAfterGet(120, TimeUnit.SECONDS)
.create();
// 写入一个值,该值2秒钟后过期
cache.put("key", "value");
// 休息3秒钟,再次获取值
Thread.sleep(3000);
System.out.println(cache.get("key")); // null
}
}
}(3)验证 update() 后一段时间自动过期删除
public static class HTreeMapUpdateExpireExample {
public static void main(String[] args) throws Exception {
try (DB db = DBMaker.memoryDB().make()) {
HTreeMap cache = db.hashMap("cache")
// 最后一次修改后1秒后过期
.expireAfterUpdate(2, TimeUnit.SECONDS)
// 下面必须设置,不然不会过期删除,不解啊!
.expireAfterCreate(120, TimeUnit.MINUTES)
.expireAfterGet(120, TimeUnit.MINUTES)
.create();
// 写入一个值
cache.put("key", "value");
// 更新值
cache.put("key", "value2");
// 休息3秒钟,再次获取值
Thread.sleep(3000);
System.out.println(cache.get("key")); // null
}
}
}除了上述基于创建、更新和获取的过期外,HTreeMap 还支持如下过期策略:
(1)这将创建一个具有 16GB 空间限制的 HTreeMap:
// 最大大小为16GB的堆外映射
Map cache = db
.hashMap("map")
.expireStoreSize(16 * 1024*1024*1024)
.expireAfterGet()
.create();(2)也可以限制映射的最大大小:
HTreeMap cache = db
.hashMap("cache")
// 最大条数为128
.expireMaxSize(128)
.expireAfterGet()
.create();HTreeMap 为每个段维护一个后进先出(LIFO)的过期队列,驱逐操作会遍历该队列并移除最旧的条目。并非所有映射条目都会被放入过期队列。例如,在这个示例中,新条目永远不会过期,只有在更新(值发生变化)后,条目才会被放入过期队列。
HTreeMap cache = db
.hashMap("cache")
// 最后一次更新后 1000 毫秒后过期
.expireAfterUpdate(1000)
.create();基于时间的驱逐机制总会将条目放入过期队列。但其他过期条件(大小和空间限制)也需要提示何时将条目放入过期队列。在下面的示例中,没有条目被放入队列,也没有条目会过期。
HTreeMap cache = db
.hashMap("cache")
.expireMaxSize(1000) // 不会过期,因为没有放入过期队列
.create();有三种触发器可能会将条目放入过期队列:expireAfterCreate()、expireAfterUpdate() 和 expireAfterGet()。请注意,这里没有TTL参数。
条目的过期处理是在其他方法内部完成的。如果你调用 map.put() 或者 map.get() 这可能会驱逐一些条目。但是,驱逐操作会带来一些开销,并且会减慢用户操作的速度。有一种方法是为 HTreeMap 提供一个执行器,在后台线程中执行驱逐操作。这将通过两个后台线程来驱逐条目,并且每 10 秒触发一次驱逐:
DB db = DBMaker.memoryDB().make();
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
HTreeMap cache = db
.hashMap("cache")
.expireMaxSize(1000) // 最大条目1000个
.expireAfterGet()
.expireExecutor(executor) // 执行器
.expireExecutorPeriod(10000) // 10秒执行一次
.create();
// 一旦我们完成操作,就需要停止后台线程
db.close();过期处理可以与多个分片 HTreeMap 结合使用,以获得更好的并发性。在这种情况下,每个段都有独立的存储,这提高了并行更新时的可扩展性。
HTreeMap cache = DBMaker
.memoryShardedHashMap(16)
.expireAfterUpdate()
.expireStoreSize(128*1024*1024)
.create();分片HTreeMap应与多个后台线程结合使用以进行驱逐操作。此外,随着时间的推移,存储会变得碎片化,最终空间无法回收。如果存在过多的空闲空间,可以选择安排定期压缩。压缩将回收空闲空间。由于每个存储(段)都是单独压缩的,因此压缩操作不会影响所有正在运行的线程。
HTreeMap cache = DBMaker
.memoryShardedHashMap(16)
.expireAfterUpdate()
.expireStoreSize(128*1024*1024)
//3个后台线程中的条目过期
.expireExecutor(
Executors.newScheduledThreadPool(3))
//如果40%的空间空闲,则触发存储压缩
.expireCompactThreshold(0.4)
.create();HTreeMap 支持修改监听器。它会向监听器通知 HTreeMap 中的插入、更新和删除操作。可以将两个集合链接在一起。通常,内存中的集合速度更快但大小有限,而磁盘上的集合速度较慢但大小不受限制。当一个条目从内存中过期后,修改监听器会自动将其移至磁盘。并且,如果通过 map.get() 操作未找到某些值,值加载器会将这些值加载回内存映射中。
要建立磁盘溢出,请使用以下代码:
DB dbDisk = DBMaker
.fileDB(file)
.make();
DB dbMemory = DBMaker
.memoryDB()
.make();
// 填充了从缓存中过期的数据的大型映射
HTreeMap onDisk = dbDisk
.hashMap("onDisk")
.create();
// 具有有限大小的快速内存集合
HTreeMap inMemory = dbMemory
.hashMap("inMemory")
.expireAfterGet(1, TimeUnit.SECONDS)
// 这会将溢出注册到`onDisk`
.expireOverflow(onDisk)
// 好主意是启用后台过期功能
.expireExecutor(Executors.newScheduledThreadPool(2))
.create();一旦绑定建立,从 inMemory 映射中移除的每个条目都会被添加到 onDisk 映射中。这仅适用于过期的条目,map.remove() 也会从 onDisk 中移除任何条目。
// 为了演示,手动将条目插入到两个映射中
inMemory.put("key", "map");
// 首先从 inMemory 中移除
inMemory.remove("key");
onDisk.get("key"); // -> 未找到如果调用了 inMemory.get(key) 且值不存在,那么值加载器会尝试在 onDisk 中查找映射。如果在 onDisk 中找到了该值,就会将其添加到 inMemory 中。
onDisk.put(1,"one"); //磁盘上有内容,内存中为空
inMemory.size(); //> 0
// get方法不会在内存(inMemory)中找到值,而是会从磁盘(onDisk)中获取值
inMemory.get(1); //> "one"
// inMemory 现在会缓存结果,之后它会过期并移至 onDisk
inMemory.size(); //> 1也可以清空整个主映射并将所有数据移至磁盘:
inMemory.put(1,11);
inMemory.put(2,11);
// 使内存映射(inMemory Map)中的全部内容过期
inMemory.clearWithExpire();更多关于 MapDB 的知识,请继续学习后续教程🚀