文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

如何理解Java容器中Map的源码分析

2023-06-05 04:17

关注

本篇文章为大家展示了如何理解Java容器中Map的源码分析,内容简明扼要并且容易理解,绝对能使你眼前一亮,通过这篇文章的详细介绍希望你能有所收获。

如果没有特别说明,以下源码分析基于 JDK 1.8。

一、HashMap

为了便于理解,以下源码分析以 JDK 1.7 为主。

1. 存储结构

内部包含了一个 Entry 类型的数组 table。

transient Entry[] table;

Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。 即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突, 同一个链表中存放哈希值相同的 Entry。

如何理解Java容器中Map的源码分析

static class Entry<K,V> implements Map.Entry<K,V> {    //包含了四个字段    final K key;    V value;    //next指向下一个节点,说明是链表结构    Entry<K,V> next;    int hash;    Entry(int h, K k, V v, Entry<K,V> n) {        value = v;        next = n;        key = k;        hash = h;    }    public final K getKey() {        return key;    }    public final V getValue() {        return value;    }    public final V setValue(V newValue) {        V oldValue = value;        value = newValue;        return oldValue;    }    public final Boolean equals(Object o) {        if (!(o instanceof Map.Entry))                    return false;        Map.Entry e = (Map.Entry)o;        Object k1 = getKey();        Object k2 = e.getKey();        // k1==k2 比较的是 hashcode 值,        // k1.equals(k2)比较的是k1和k2的内容 equals 未重写,则等价于 k1 == k2        if (k1 == k2 || (k1 != null && k1.equals(k2))) {            Object v1 = getValue();            Object v2 = e.getValue();            if (v1 == v2 || (v1 != null && v1.equals(v2)))                            return true;        }        return false;    }    public final int hashCode() {        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());    }    public final String toString() {        return getKey() + "=" + getValue();    }}

2. 拉链法的工作原理

HashMap<String, String> map = new HashMap<>();map.put("K1", "V1");map.put("K2", "V2");map.put("K3", "V3");

应该注意到链表的插入是以头插法方式进行的,例如上面的不是插在后面,而是插入在链表头部。

查找需要分成两步进行:

3. put 操作

public V put(K key, V value) {    if (table == EMPTY_TABLE) {        inflateTable(threshold);    }    // 键为 null 单独处理    if (key == null)            return putForNullKey(value);    int hash = hash(key);    // 确定桶下标    int i = indexFor(hash, table.length);    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value    // 时间复杂度显然和链表的长度成正比。    for (Entry<K,V> e = table[i]; e != null; e = e.next) {        Object k;        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {            V oldValue = e.value;            e.value = value;            e.recordAccess(this);            return oldValue;        }    }    modCount++;    // 插入新键值对    addEntry(hash, key, value, i);    return null;}

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

private V putForNullKey(V value) {    //HashMap 使用第 0 个桶 table[0] 存放键为 null 的键值对。    for (Entry<K,V> e = table[0]; e != null; e = e.next) {        if (e.key == null) {            V oldValue = e.value;            e.value = value;            // 更新值            e.recordAccess(this);            return oldValue;            // 返回旧值        }    }    modCount++;    //void addEntry(int hash, K key, V value, int bucketIndex)    addEntry(0, null, value, 0);    return null;}

使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。

//TODO:使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。void addEntry(int hash, K key, V value, int bucketIndex) {    if ((size >= threshold) && (null != table[bucketIndex])) {        resize(2 * table.length);        hash = (null != key) ? hash(key) : 0;        bucketIndex = indexFor(hash, table.length);    }    createEntry(hash, key, value, bucketIndex);}void createEntry(int hash, K key, V value, int bucketIndex) {    Entry<K,V> e = table[bucketIndex];    // 头插法,链表头部指向新的键值对    table[bucketIndex] = new Entry<>(hash, key, value, e);    size++;}
Entry(int h, K k, V v, Entry<K,V> n) {    value = v;    next = n;    key = k;    hash = h;}

4. 确定桶下标

很多操作都需要先确定一个键值对所在的桶下标。

int hash = hash(key);int i = indexFor(hash, table.length);

①. 计算 hash 值

final int hash(Object k) {    int h = hashSeed;    if (0 != h && k instanceof String) {        return sun.misc.Hashing.stringHash42((String) k);    }    h ^= k.hashCode();    // This function ensures that hashCodes that differ only by    // constant multiples at each bit position have a bounded    // number of collisions (approximately 8 at default load factor).    h ^= (h >>> 20) ^ (h >>> 12);    return h ^ (h >>> 7) ^ (h >>> 4);}
public final int hashCode() {    return Objects.hashCode(key) ^ Objects.hashCode(value);}

②. 取模

令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010x-1     : 00001111y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y   : 10110010x   : 00010000y%x : 00000010

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模: hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

static int indexFor(int h, int length) {    return h & (length-1);}

就等价于

static int indexFor(int h, int length) {    return h % length;}

但是效率会更高。

5. 扩容-基本原理

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。

为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。 HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

如何理解Java容器中Map的源码分析

static final int DEFAULT_INITIAL_CAPACITY = 16;//保证 capacity 为 2 的 n 次方,那么就可以将indexFor方法中操作转换为位运算static final int MAXIMUM_CAPACITY = 1 << 30;//保证 capacity 为 2 的 n 次方,那么就可以将 indexFor 方法中操作转换为位运算static final float DEFAULT_LOAD_FACTOR = 0.75f;transient Entry[] table;transient int size;int threshold;final float loadFactor;transient int modCount;

从下面的添加元素代码中可以看出,当需要扩容时,令 capacity 为原来的两倍。

void addEntry(int hash, K key, V value, int bucketIndex) {    Entry<K,V> e = table[bucketIndex];    table[bucketIndex] = new Entry<>(hash, key, value, e);    if (size++ >= threshold)            resize(2 * table.length);    //令 capacity 为原来的两倍}

扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

void resize(int newCapacity) {    Entry[] oldTable = table;    int oldCapacity = oldTable.length;    if (oldCapacity == MAXIMUM_CAPACITY) {        threshold = Integer.MAX_VALUE;        return;    }    Entry[] newTable = new Entry[newCapacity];    transfer(newTable);    table = newTable;    threshold = (int)(newCapacity * loadFactor);}void transfer(Entry[] newTable) {    Entry[] src = table;    int newCapacity = newTable.length;    for (int j = 0; j < src.length; j++) {        Entry<K,V> e = src[j];        if (e != null) {            src[j] = null;            do {                Entry<K,V> next = e.next;                int i = indexFor(e.hash, newCapacity);                e.next = newTable[i];                newTable[i] = e;                e = next;            }            while (e != null);        }    }}

6. 扩容-重新计算桶下标

在进行扩容时,需要把键值对重新放到对应的桶上。HashMap 使用了一个特殊的机制,可以提升重新计算桶下标的效率。

假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:

capacity     : 00010000new capacity : 00100000

对于一个 Key,

7. 计算数组容量

HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000mask |= mask >> 2    11111110mask |= mask >> 4    11111111

mask+1 是大于原始数字的最小的 2 的 n 次方。

num     10010000mask+1  100000000

以下是 HashMap 中计算数组容量的代码:

static final int tableSizeFor(int cap) {    int n = cap - 1;    n |= n >>> 1;    n |= n >>> 2;    n |= n >>> 4;    n |= n >>> 8;    n |= n >>> 16;    //得到n的掩码    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}

8. 链表转红黑树

从 JDK 1.8 开始,一个桶存储的链表长度大于 8 时会将链表转换为红黑树。

9. 与 HashTable 的比较

10. 与 HashSet 的比较

HashSet 底层就是基于HashMap实现的。 (HashSet 的源码非常非常少,因为除了 clone() 方法、writeObject()方法、readObject()方法是 HashSet 自己不得不实现之外, 其他方法都是直接调用 HashMap 中的方法。)

如何理解Java容器中Map的源码分析

二、LinkedHashMap

1.存储结构

继承自 HashMap,因此具有和 HashMap 一样的快速查找特性。

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

内部维护了一个双向链表,用来维护插入顺序或者 LRU 顺序。

transient LinkedHashMap.Entry<K,V> head;transient LinkedHashMap.Entry<K,V> tail;

accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序。

final boolean accessOrder;

LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。

void afterNodeAccess(Node<K,V> p) { }void afterNodeInsertion(boolean evict) { }

2.afterNodeAccess()

当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。

void afterNodeAccess(Node<K,V> e) {    // move node to last    LinkedHashMap.Entry<K,V> last;    if (accessOrder && (last = tail) != e) {        LinkedHashMap.Entry<K,V> p =                    (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;        p.after = null;        if (b == null)                    head = a; else                    b.after = a;        if (a != null)                    a.before = b; else                    last = b;        if (last == null)                    head = p; else {            p.before = last;            last.after = p;        }        tail = p;        ++modCount;    }}

3.afterNodeInsertion()

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

void afterNodeInsertion(Boolean evict) {    // possibly remove eldest    LinkedHashMap.Entry<K,V> first;    if (evict && (first = head) != null && removeEldestEntry(first)) {        K key = first.key;        removeNode(hash(key), key, null, false, true);    }}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {    return false;}

4.LRU 缓存

以下是使用 LinkedHashMap 实现的一个 LRU 缓存:

public class LRUCache<K,V> extends LinkedHashMap<K,V>{    private static final int MAX_ENTRIES = 3;    LRUCache(){        super(MAX_ENTRIES,0.75f,true);    }        @Override        protected Boolean removeEldestEntry(Map.Entry eldest) {        return size() > MAX_ENTRIES;    }    public static void main(String[] args) {        LRUCache<Integer,String> cache=new LRUCache<>();        cache.put(1, "a");        cache.put(2, "b");        cache.put(3, "c");        cache.get(1);        //LRU  键值1被访问过了,则最近最久未访问的就是2        cache.put(4, "d");        System.out.println(cache.keySet());    }}
[3, 1, 4]

三、WeakHashMap

1.存储结构

WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。

WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>

2.ConcurrentCache

Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。

ConcurrentCache 采取的是分代缓存:

public final class ConcurrentCache<K, V> {    private final int size;    private final Map<K, V> eden;    private final Map<K, V> longterm;    public ConcurrentCache(int size) {        this.size = size;        this.eden = new ConcurrentHashMap<>(size);        this.longterm = new WeakHashMap<>(size);    }    public V get(K k) {        V v = this.eden.get(k);        if (v == null) {            v = this.longterm.get(k);            if (v != null)                            this.eden.put(k, v);        }        return v;    }    public void put(K k, V v) {        if (this.eden.size() >= size) {            this.longterm.putAll(this.eden);            this.eden.clear();        }        this.eden.put(k, v);    }}

上述内容就是如何理解Java容器中Map的源码分析,你们学到知识或技能了吗?如果还想学到更多技能或者丰富自己的知识储备,欢迎关注编程网行业资讯频道。

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯