Java容器(五):Map 之 HashMap源码分析

Java容器

集合框架

三、Map

Map主要用于存储健值对,根据键得到值,因此不允许键重复(重复则覆盖),但允许值重复。

3.1 HashMap

源码基于 JDK1.7

3.1.1 类结构

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {}
  • HashMap 实现了Map接口,继承AbstractMap。
  • 其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
  • HashMap 实现了 Cloneable接口,即实现克隆功能。
  • HashMap 实现了 Serializable接口,表示支持序列化。

3.1.2 属性

//table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
static final int DEFAULT_INITIAL_CAPACITY = 16;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//底层数据结构:table数组
transient Entry[] table;
//键值对的数量
transient int size;
//size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
int threshold;
//装载因子,table 能够使用的比例,threshold = capacity * loadFactor。
final float loadFactor;
//修改次数
transient int modCount;

3.1.3 构造方法

  • 构造一个带指定初始容量和装载因子的空 HashMap。

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    • 若传入的初始容量小于0,则抛出异常
    • 若传入的初始容量大于最大容量,则初始容量设置为最大容量
    • 若传入的装载因子小于0或者为NaN,则抛出异常
    • 装载因子和size临界值赋值
  • 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
  • 构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    
  • 构造一个带指定初始Map的 HashMap。

    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    
    • 设置默认装载因子
    • 将指定的Map放入table中

3.1.4 数据结构

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

transient Entry[] table;

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    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();
        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();
    }
}
  • Entry 存储着键值对。
  • 包含了四个字段,从 next 字段可以看出 Entry 是一个链表。
  • 即数组中的每个位置被当成一个桶,一个桶存放一个链表。
  • HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。

HashMap底层

3.1.5 拉链法

HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
  • 新建一个 HashMap,默认大小为 16;
  • 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 134,使用除留余数法得到所在的桶下标 134%16=6,插在 <K2,V2> 前面。

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

查找键值对,需要分成两步进行:

  • 计算键值对所在的桶;
  • 在链表上顺序查找,时间复杂度显然和链表的长度成正比。

拉链法

3.1.6 put(key,value)

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 键为 null 单独处理,调用putForNullKey()方法
    if (key == null)
        return putForNullKey(value);
    //计算键的hash值
    int hash = hash(key);
    // 确定桶下标,计算键的hash值在table数组中的位置
    int i = indexFor(hash, table.length);
    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判断该链上是否有hash值相同的 和 键相同
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            //若存在相同,则直接覆盖值,返回旧值
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    //修改次数+1
    modCount++;
    // 插入新键值对,添加至table表中i处
    addEntry(hash, key, value, i);
    return null;
}
  • 首先判断key是否为null,若为null,则直接调用putForNullKey方法。
  • 若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置。
  • 如果table数组在该位置处有元素,则通过比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素添加到链表。
private V putForNullKey(V value) {
    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++;
    addEntry(0, null, value, 0);
    return null;
}
  • HashMap 允许插入键为 null 的键值对。
  • 但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标(table[0])来存放。
  • HashMap 使用第 0 个桶存放键为 null 的键值对。
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;
}
  • 将该元素保存在链头(最先保存的元素放在链尾)

3.1.7 确定桶的下标

//计算键的hash值
int hash = hash(key);
// 确定桶下标,计算键的hash值在table数组中的位置
int i = indexFor(hash, table.length);
  1. 计算hash值
final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((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);
}
  1. 取模
  • 推导:

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

x   : 00010000
x-1 : 00001111

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

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

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

y   : 10110010
x   : 00010000
y%x : 00000010

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

  • 应用:

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

采用二进制位操作 &,相比于 % 而言,能够提高运算效率。

即:将模运算(%)转换为与运算(&)的前提是,容量(capacity)为 2 的 n 次方,因此 HashMap 的长度是 2 的幂次方。

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

3.1.8 扩容

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

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

添加元素的源码:

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);
}
  • 当需要扩容时,令 capacity 为原来的两倍。

resize()方法实现扩容:

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);
        }
    }
}
  • 扩容操作需要把 oldTable 的所有键值对重新插入 newTable 中,这一步是很费时的。

重新计算桶下标:

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

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

capacity     : 00010000
new capacity : 00100000

对于一个 Key,其哈希值在第 5 位上的值

  • 如果为 0,那么取模得到的结果和之前一样;
  • 如果为 1,那么得到的结果为原来的结果 +16。

3.1.9 计算数组容量

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

推导:

先考虑如何求一个数的掩码,对于 num = 10010000,掩码 mask = 11111111

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

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

num      10010000
mask+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;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

3.1.10 链表转红黑树

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

hashmap转红黑树

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    //如果节点数变小小于红黑树的节点数阈值时,调整空间
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
        //该方法直接返回一个红黑树结点。
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
            //从链表头开始依次插入红黑树
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

// For treeifyBin
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}
  • HashMap在JDK1.8的版本中引入了红黑树结构做优化,当链表元素个数大于等于8时,链表转换成树结构;
  • 若桶中链表元素个数小于等于6时,树结构还原成链表。
  • 因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。
  • 链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
  • 还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。
  • 假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

3.1.11 get(key)读取

public V get(Object key) {
    // 若为null,调用getForNullKey方法返回相对应的value
    if (key == null)
        return getForNullKey();
    // 根据该 key 的 hashCode 值计算它的 hash 码  
    int hash = hash(key.hashCode());
    // 取出 table 数组中指定索引处的值
    for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
        Object k;
        //若搜索的key与查找的key相同,则返回相对应的value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}
  • 通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

3.1.12 与 HashTable 的比较

  • HashMap线程不安全,HashTable是线程安全的。
    • HashMap内部实现没有任何线程同步相关的代码,所以相对而言性能要好一点。
    • 如果在多线程中使用HashMap需要自己管理线程同步。
    • HashTable大部分对外接口都使用synchronized包裹,所以是线程安全的,但是性能会相对差一些。
  • 二者的基类不一样。
    • HashMap派生于AbstractMap,HashTable派生于Dictionary。
    • 都实现Map, Cloneable, Serializable接口。
    • AbstractMap中提供的基础方法更多,并且实现了多个通用的方法。
    • 而在Dictionary中只有少量的接口,并且都是abstract类型。
  • key和value的取值范围不同。
    • HashMap的key和value都可以为null,但是HashTable的key和value都不能为null。
    • 对于HashMap如果get返回null,并不能表明HashMap不存在这个key,如果需要判断HashMap中是否包含某个key,就需要使用containsKey这个方法来判断。
  • 算法不一样。
    • HashMap的初始容量值 initialCapacity为16,而HashTable的初始容量值 initialCapacity为11。
    • HashMap中初始容量必须是2的幂,如果初始化传入的initialCapacity不是2的幂,将会自动调整为大于出入的initialCapacity最小的2的幂。HashTable 直接使用指定的容量,之后每次扩容,容量变为原来的 2n+1.
    • HashMap使用自己的计算hash的方法(会依赖key的hashCode方法),HashTable则使用key的hashCode方法得到。
  • HashMap 的迭代器是 fail-fast 迭代器。
  • HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
  • JDK 1.8之后的 HashMap,当链表长度大于阈值(默认为8),将链表转化为红黑树,减少搜索时间。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值