Java-第十五部分-源码解读-HashMap

209 阅读9分钟

源码阅读全文

HashMap

基础概念

  • 一个元素 -> 计算哈希值 -> 映射成下标 -> 存入哈希表
  • 哈希值,通过散列算法,将不固定长度的输入,转换成一个统一长度的输出,且是唯一的,元素的身份证
  • 哈希表,存储哈希值的数组,将哈希值通过某种方法,映射成数组长度内的下标
  • 哈希函数,一个运算规则,将哈希值通过哈希函数映射成index
  • 哈希冲突/碰撞,两个元素通过哈希函数运算后,产生的下标一样
  1. 重哈希,重新计算哈希值并得到下标,解决哈希冲突
  2. 开放地址,往下找或者往上找,在相同下标的旁边找空位置,靠数组空位解决,ThreadLocalMap
  3. 益出区,开辟额外的空间,放产生哈希冲突的元素
  4. 链地址法,通过链的方式连接哈希冲突的元素,jkd8之后,在链长度多之后,转换成红黑树
  • 如果元素均匀得存储在角标位,不产生冲突,就是效率最高的
  1. 尽可能少产生哈希冲突,哈希函数计算出来的角标,需要尽可能均匀
  2. 通过数据结构解决,提高效率

哈希函数

jdk1.7

  • 结合容量为2的幂次方-1后,保证每位都是1,有可能出现
static int indexFor(int h, int length) {
    return h & (length-1);
}
  • length长度规则,必须是2的幂次方,初始默认容量为16,让下标充分利用
  1. 保证参与下标都是1,容量为16 10000,对应下标15 1111,任何数 & 上(len - 1)就在0000 ~ 1111之间,也就是长度为16的角标
  2. 如果不是2的幂次方。奇数5,参与下标为4 100,任何数&上都是偶数,也就是只能为4,奇数位角标永远没有值,数组空间浪费;偶数6,参与下标为5 101,中间也有一个0,永远不能取到3 011的下标
  3. 扩容后是原长度的两倍,在扩容时,需要复制一份,也可以保证迁移后的数据均匀分布,例如原来的容量为8,扩容为16,哈希低四位为1111,原来的下标为5 101,新下标为1101 13,也就是原来的下标+扩容长度,要么就在原角标位(低四位 0111)
  4. jdk1.7重新哈希,1.8没有 image.png image.png
  • 通过roundUpToPowerOf2保证初始化长度为2的幂次方,在第一次put的时候调用
private static int roundUpToPowerOf2(int number) {
    return number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            //取-1后的最高位,后面全部补零 1010减一 -> 1001 9 -> 10000 16
            //1111 -> 1110 -> 10000
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

jdk1.8

  • 同样是通过模的方式取下标 image.png
  • 通过tableSizeFor保证初始化长度为2的幂次方,保证输入的所有数最后每位都能为1,加1后,就是2的幂次方了
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;
}

添加节点

jdk1.7

  • 头插法

hash, key, value, e,分别为 hash值/key/value/next节点

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++;
}

jdk1.8

  • 当前角标为null,直接加入
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  • 单向链表的插入
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) { //当前下标元素的next为null,直接尾插法
        p.next = newNode(hash, key, value, null);
        //该角标,头节点被处理过了
        //默认为8,只要当前角标下的链表长度>=8了,将该角标的链表转换成红黑树
        //概率学的问题,长度为8;进行树化的概率很低,尽量避免树化
        //并且8以下的长度,获取和查找的性能都是可以接受的
        //红黑树一个节点的空间占用是普通的两倍,只有长度大于8时,才值得去树化,以空间换时间
        //当极端情况下,会导致哈希碰撞概率升高,会进行树化,以提高查找效率
        //但是理想情况的hash,达到8的概率很小
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            //转换成红黑树
            treeifyBin(tab, hash);
        break;
    }
    //找到相同hash,相同的值,替换旧值
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}
  • 单向链表替换旧值
if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}
  • 树化,需要满足两个条件,表长>=64 && 角标元素个数>=8
  1. 先形成双向链表
  2. 再进行红黑树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //对表长度有要求,默认64,表长度不达标也不会尝试树化,通过扩容的方式解决
    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包装
            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); //树化,利用hash值构建二叉搜索树(红黑树),并进行平衡
    }
}
  • treeify image.png

扩容

  • 遇到哈希冲突,通过扩容解决,节点个数大于阈值时扩容
  • 1.7之前通过单向链表,1.8之后通过单向链表 7个节点/红黑树 8个节点

jdk1.7

  • 添加元素使用头插法
  • 什么时候扩容,put方法中,如果找到了hash相等且key相等,那么就替换值,并且直接返回旧值,否则如果容量超过阈值,需要扩容,最后添加元素
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    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;
        }
    }
    //记录修改的次数,和线程安全有关
    //Fail-Fast 机制,当多个线程对同一集合的内容进行操作时,发现`exceptModCount`不一样,会抛出异常
    modCount++; 
    addEntry(hash, key, value, i);
    return null;
}
  • 阈值默认是size * 0.75 image.png
  • 怎么扩容
void addEntry(int hash, K key, V value, int bucketIndex) {
    //需要扩容且对应角标不为null
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //扩容
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        //重新计算hash和角标,再次添加
        bucketIndex = indexFor(hash, table.length);
    }
    //如果不需要扩容,或对应角标为null,直接增加
    //或者扩容完再添加
    createEntry(hash, key, value, bucketIndex);
}
  • transfer实际进行迁移工作,重新计算元素下标,扩容数据迁移时,是否重哈希要看参数rehash

单向链表的迁移,头插法,将新元素作为head image.png

  • 扩容后问题(多线程下),头插法会导致形成环形链表
  1. 原数据10->2->6,线程1执行到next=e.next,线程1中断,线程2执行完成2->10,线程1继续执行,也就形成了10->2的环形情况

jdk1.8

  • 添加元素使用尾插法
  1. 如果角标位是单向链表,将单向链表进行数据迁移
  2. 如果角标位是红黑树,通过其中的双向链表结构进行数据迁移
  • 阈值初始值由tableSizeFor进行初始化,初始化最近的2的整数次幂 image.png
  • 放入第一个元素,才有真正的大小 image.png
  • 后续为newCap * 0.75的大小 image.png
  • 什么时候扩容,先添加元素,判断节点个数是否达到阈值,超过直接扩容
if (++size > threshold)
    resize();
  • 怎么扩容
  1. 容量扩充两倍,更新阈值
  2. 迁移元素,并根据是否能放在高位,将原下标的节点分开存放
  • 扩容时,没有重哈希,看元素最高位与原长度的关系,resize进行迁移工作
  1. 因为迁移时,涉及到链表/树结构,需要检查每个节点的情况
  2. 尾插法,将新元素作为Tail
  • 针对于单向链表的迁移,(e.hash & oldCap) == 0lo保存,放在原位置;不为0,用hi保存,放在高位(原下标+原长度) image.png
  • 如果是红黑树结构,通过TreeNode中维护的双向链表,进行迁移 image.png
  • 通过类似单向链表的迁移方式,进行迁移,过程中需要记录节点个数,判断是否需要再次进行树化
  1. e.next = null;(e.prev = loTail) == null的操作,同时断掉了原先双向链表的结构,形成了新的双向链表 image.png
  • 是否需要生成红黑树结构的判断逻辑,迁移之后数量小于6,转换回为单向链表
if (loHead != null) {
    //UNTREEIFY_THRESHOLD 默认为6
    if (lc <= UNTREEIFY_THRESHOLD)
        //将节点转换回Node
        tab[index] = loHead.untreeify(map);
    else {
        tab[index] = loHead;
        //如果为null,那么就不要重新树化,所有节点都在原位置
        //如果不为null,那么树中的某些节点迁移到了高位,需要重新树化
        if (hiHead != null) // (else is already treeified)
            loHead.treeify(tab);
    }
}
if (hiHead != null) {
    if (hc <= UNTREEIFY_THRESHOLD)
        tab[index + bit] = hiHead.untreeify(map);
    else {
        tab[index + bit] = hiHead;
        if (loHead != null)
            hiHead.treeify(tab);
    }
}
  • 扩容后问题(多线程下)

存在数据丢失、值的覆盖,多线程操作一个角标

获取节点

jdk1.7

  • get
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}
  • getEntry
final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    //遍历链表查找
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

jdk1.8

  • get
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
  • getNode
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node 
            //第一个就找到了
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode) //树结构
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {//链表查找
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

删除节点

jdk1.7

  • remove
public V remove(Object key) {
    Entry<K,V> e = removeEntryForKey(key);
    return (e == null ? null : e.value);
}
  • 删除逻辑 recordRemoval为空实现,模版方法设计模式,LinkedHashMap中有实现 image.png

jdk1.8

  • remove
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
  • 删除逻辑 image.png
  • 实际删除 image.png

节点

jdk1.7

  • 构造,需要传入next,配合头插法
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}

jdk1.8

  • Node
Node(int hash, K key, V value, Node<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;
}
  • TreeNode,多了left/right/prev/red,同时维护红黑树和双向链表
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }

区别

  • 数据结构不同
  1. jdk1.7,单向链表
  2. jdk1.8,7个节点单向链表,8个节点或以上红黑树
  • 插入方法不同
  1. jdk1.7,头插法,多线程下形成环形链表
  2. jdk1.8,尾插法,多线程下容易导致节点覆盖,数据丢失
  • 扩容
  1. jdk1.7,先扩容,再添加
  2. jdk1.8,先添加(过程中判断是否需要树化),再扩容(过程中检查是否需要还原回单向链表)
  • 数据迁移的不同
  1. jdk1.7,待添加元素需要重哈希,其他元素可能需要重哈希,重新计算元素下标
  2. jdk1.8,直接看hash & 原高度,是否需要+原高度