HashMap底层数据结构学习记录

  • 默认大小、负载因子以及扩容倍数是多少
  • 底层数据结构
  • 如何处理 hash 冲突的
  • 如何计算一个 key 的 hash 值
  • 数组长度为什么是 2 的幂次方
  • 扩容、查找过过程

  • HashMap

HashMap底层的数据结构

JDK7:数组+链表

JDK8: 数组+链表+红黑树(使用了单向链表,也使用了双向链表,双向链表主要是为了链表操作方便,应该在插入,扩容,链表转红黑树,红黑树转链表的过程中都要操作链表)

JDK8中的HashMap什么时候将链表转化为红黑树?

只有当链表中的元素个数大于8,并且数组的长度大于等于64时才会将链表转为红黑树

1、hashCode值

hash算法是一种可以从任何数据中提取出其“指纹”的数据摘要算法,它将任意大小的数据映射到一个固定大小的序列上,这个序列被称为hash code、数据摘要或者指纹。比较出名的hash算法有MD5、SHA。hash是具有唯一性且不可逆的,唯一性是指相同的“对象”产生的hash code永远是一样的。

2、Hash表的物理结构

HashMap和Hashtable是散列表,其中维护了一个长度为2的幂次方的Entry类型的数组table,数组的每一个元素被称为一个桶(bucket),你添加的映射关系(key,value)最终都被封装为一个Map.Entry类型的对象,放到了某个table[index]桶中。使用数组的目的是查询和添加的效率高,可以根据索引直接定位到某个table[index]。

(1)数组元素类型:Map.Entry

JDK1.7:

映射关系被封装为HashMap.Entry类型,而这个类型实现了Map.Entry接口。

观察HashMap.Entry类型是个结点类型,即table[index]下的映射关系可能串起来一个链表。因此我们把table[index]称为“桶bucket"。

public class HashMap<K,V>{
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    static class Entry<K,V> implements Map.Entry<K,V> {
            final K key;
            V value;
            Entry<K,V> next;
            int hash;
            //...省略
    }
    //...
}

JDK1.8:

映射关系被封装为HashMap.Node类型或HashMap.TreeNode类型,它俩都直接或间接的实现了Map.Entry接口。

存储到table数组的可能是Node结点对象,也可能是TreeNode结点对象,它们也是Map.Entry接口的实现类。即table[index]下的映射关系可能串起来一个链表或一棵红黑树(自平衡的二叉树)。

public class HashMap<K,V>{
    transient Node<K,V>[] table;
    static class Node<K,V> implements Map.Entry<K,V> {
            final int hash;
            final K key;
            V value;
            Node<K,V> next;
            //...省略
    }
    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;
        boolean red;//是红结点还是黑结点
        //...省略
    }
    //....
}

public class LinkedHashMap<K,V>{
	static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }
    //...
}

//默认初始容量为16  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
//默认负载因子为0.75  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  
//Hash数组(在resize()中初始化)  
transient Node<K,V>[] table;  
//元素个数  
transient int size;  
//容量阈值(元素个数超过该值会自动扩容)  
int threshold;

可以看出初始容量为16、负载因子为0.75、元素的个数

在table数组中的存放的是Node对象

static class Node<K,V> implements Map.Entry<K,V> {  
    final int hash;  
    final K key;  
    V value;  
    Node<K,V> next;  

    Node(int hash, K key, V value, Node<K,V> next) {  
        this.hash = hash;  
        this.key = key;  
        this.value = value;  
        this.next = next;  
    }  

    public final K getKey()        { return key; }  
    public final V getValue()      { return value; }  
    public final String toString() { return key + "=" + value; }  
    public final int hashCode() {  
        return Objects.hashCode(key) ^ Objects.hashCode(value);//^表示相同返回0,不同返回1  
        //Objects.hashCode(o)————>return o != null ? o.hashCode() : 0;  
    }  

    public final V setValue(V newValue) {  
        V oldValue = value;  
        value = newValue;  
        return oldValue;  
    }  

    public final boolean equals(Object o) {  
        if (o == this)  
            return true;  
        if (o instanceof Map.Entry) {  
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;  
            //Objects.equals(1,b)————> return (a == b) || (a != null && a.equals(b));  
            if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))  
                return true;  
        }  
        return false;  
    }  
}

table的数组长度永远为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;
    }

HashMap是通过index=hash&(table.length-1)来进行计算元素在table数组下存放的位置下标,等价于hash%length,设置数组的长度为2的幂次方的,原因就是便于提高运算的效率以及增加hash的随机性,减少hash冲突的。

扩容

HashMap 每次扩容都是建立一个新的 table 数组,长度和容量阈值都变为原来的两倍,然后把原数组元素重新映射到新数组上,具体步骤如下:

  • 首先会判断 table 数组长度,如果大于 0 说明已被初始化过,那么按当前 table 数组长度的 2 倍进行扩容,阈值也变为原来的 2 倍
  • 若 table 数组未被初始化过,且 threshold(阈值)大于 0 说明调用了 HashMap(initialCapacity, loadFactor) 构造方法,那么就把数组大小设为 threshold
  • 若 table 数组未被初始化,且 threshold 为 0 说明调用 HashMap() 构造方法,那么就把数组大小设为 16,threshold 设为 16*0.75
  • 接着需要判断如果不是第一次初始化,那么扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去,如果节点是红黑树类型的话则需要进行红黑树的拆分。


链表树化

将链表转变为红黑树,需要满足两个条件:

  • 链表长度大于等于 8
  • table 数组长度大于等于 64
  • table数组容量比较小的时候,键值对节点hash的碰撞率会比较的高,进而导致链表的长度较长。这个时候先进行扩容,不是立马的进行树化。

查找

HashMap 的查找是非常快的,要查找一个元素首先得知道 key 的 hash 值,在 HashMap 中并不是直接通过 key 的 hashcode 方法获取哈希值,而是通过内部自定义的 hash 方法计算哈希值

static final int hash(Object key) {  
    int h;  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}
/**
*(h = key.hashCode()) ^ (h >>> 16) 是为了让高位数据与低位数据进行异或
,变相的让高位数据参与到计算中,int 有 32 位,右移 16 位就能让低 16 位
和高 16 位进行异或,也是为了增加 hash 值的随机性。
*/


public V get(Object key) {  
    Node<K,V> e;  
    return (e = getNode(hash(key), key)) == null ? null : e.value;//hash(key)不等于key.hashCode  
}  

final Node<K,V> getNode(int hash, Object key) {  
    Node<K,V>[] tab; //指向hash数组  
    Node<K,V> first, e; //first指向hash数组链接的第一个节点,e指向下一个节点  
    int n;//hash数组长度  
    K k;  
    /*(n - 1) & hash ————>根据hash值计算出在数组中的索引index(相当于对数组长度取模,这里用位运算进行了优化)*/  
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {  
        //基本类型用==比较,其它用euqals比较  
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))  
            return first;  
        if ((e = first.next) != null) {  
            //如果first是TreeNode类型,则调用红黑树查找方法  
            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;  
}



插入

我们先来看看插入元素的步骤:

  1. 当 table 数组为空时,通过扩容的方式初始化 table
  2. 通过计算键的 hash 值求出下标后,若该位置上没有元素(没有发生 hash 冲突),则新建 Node 节点插入
  3. 若发生了 hash 冲突,遍历链表查找要插入的 key 是否已经存在,存在的话根据条件判断是否用新值替换旧值
  4. 如果不存在,则将元素插入链表尾部,并根据链表长度决定是否将链表转为红黑树
  5. 判断键值对数量是否大于阈值,大于的话则进行扩容操作
public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);  
}  

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {  
    Node<K,V>[] tab;//指向hash数组  
    Node<K,V> p;//初始化为table中第一个节点  
    int n, i;//n为数组长度,i为索引  

    //tab被延迟到插入新数据时再进行初始化  
    if ((tab = table) == null || (n = tab.length) == 0)  
        n = (tab = resize()).length;  
    //如果数组中不包含Node引用,则新建Node节点存入数组中即可  
    if ((p = tab[i = (n - 1) & hash]) == null)  
        tab[i] = newNode(hash, key, value, null);//new Node<>(hash, key, value, next)  
    else {  
        Node<K,V> e; //如果要插入的key-value已存在,用e指向该节点  
        K k;  
        //如果第一个节点就是要插入的key-value,则让e指向第一个节点(p在这里指向第一个节点)  
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))  
            e = p;  
        //如果p是TreeNode类型,则调用红黑树的插入操作(注意:TreeNode是Node的子类)  
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
        else {  
            //对链表进行遍历,并用binCount统计链表长度  
            for (int binCount = 0; ; ++binCount) {  
                //如果链表中不包含要插入的key-value,则将其插入到链表尾部  
                if ((e = p.next) == null) {  
                    p.next = newNode(hash, key, value, null);  
                    //如果链表长度大于或等于树化阈值,则进行树化操作  
                    if (binCount >= TREEIFY_THRESHOLD - 1)  
                        treeifyBin(tab, hash);  
                    break;  
                }  
                //如果要插入的key-value已存在则终止遍历,否则向后遍历  
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))  
                    break;  
                p = e;  
            }  
        }  
        //如果e不为null说明要插入的key-value已存在  
        if (e != null) {  
            V oldValue = e.value;  
            //根据传入的onlyIfAbsent判断是否要更新旧值  
            if (!onlyIfAbsent || oldValue == null)  
                e.value = value;  
            afterNodeAccess(e);  
            return oldValue;  
        }  
    }  
    ++modCount;  
    //键值对数量超过阈值时,则进行扩容  
    if (++size > threshold)  
        resize();  
    afterNodeInsertion(evict);//也是空函数?回调?不知道干嘛的  
    return null;  
}

删除

HashMap 的删除操作需三个步骤即可完成。

  1. 定位桶位置
  2. 遍历链表找到相等的节点
  3. 第三步删除节点
public V remove(Object key) {  
    Node<K,V> e;  
    return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;  
}  

final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {  
    Node<K,V>[] tab;  
    Node<K,V> p;  
    int n, index;  
    //1、定位元素桶位置  
    if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {  
        Node<K,V> node = null, e;  
        K k;  
        V v;  
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点  
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))  
            node = p;  
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点  
            if (p instanceof TreeNode)  
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);  
            else {  
                // 2、遍历链表,找到待删除节点  
                do {  
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {  
                        node = e;  
                        break;  
                    }  
                    p = e;  
                } while ((e = e.next) != null);  
            }  
        }  
        // 3、删除节点,并修复链表或红黑树  
        if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {  
            if (node instanceof TreeNode)  
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);  
            else if (node == p)  
                tab[index] = node.next;  
            else  
                p.next = node.next;  
            ++modCount;  
            --size;  
            afterNodeRemoval(node);  
            return node;  
        }  
    }  
    return null;  
}

对于Map中遍历的操作不能够使用for-each来进行遍历的,若遍历HashMap的时候remove()方法,会出现ConcurrentModificationException异常

原因在于

transient int modCount;

常说的fail-fast(快速失败机制)。modCount的变量是用来进行记载被修改的次数,修改的值是插入或者删除元素的时候,都会对modCount进行操作,若和原来的结果进行比较,若不相等,则就说明集合被修改过的,然后就会抛出异常。

HashMap中的扩容流程是怎么样的呀?

  1. HashMap的扩容指的就是数组的扩容, 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新数组上来,这样才是数组的扩容
  2. 在HashMap中也是一样,先新建一个2被数组大小的数组
  3. 然后遍历老数组上的没一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去
  4. 在这个过程中就需要遍历链表,当然jdk7,和jdk8在这个实现时是有不一样的,jdk7就是简单的遍历链表上的没一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个下标,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率
  5. 而在jdk8中,因为涉及到红黑树,这个其实比较复杂,jdk8中其实还会用到一个双向链表来维护红黑树中的元素,所以jdk8中在转移某个位置上的元素时,会去判断如果这个位置是一个红黑树,那么会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的位置,否则把单向链表放到对应的位置。
  6. 元素转移完了之后,在把新数组对象赋值给HashMap的table属性,老数组会被回收到。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值