MapDB 的 HTreeMap 类

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 的布局由布局函数控制。它接受三个参数:

  1. 并发度,即段的数量。默认值为8,它总是向上取整为2的幂。

  2. 索引树目录节点的最大节点大小。默认值为16,它总是向上取整为2的幂。最大值为 128 个条目。

  3. 索引树中的层级数量,默认值为 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() 方法。

过期(Expire)

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 的知识,请继续学习后续教程🚀

  

说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号