HashMap从认识到源码分析

image

Map

Map在开发过程当中使用频率很高的数据结构,Map是Key-value键值对映射的抽象接口,该映射不包括重复的键,既一个键对应一个值。HashMapHashTableConcurrentHashMap都是Java Collection Framework的重要成员。Map接口提供三种collection视图,容许以键集(keySet())、值集(values())或键-值映射关系集(entrySet())的形式查看某个映射的内容。 node

HasH表

咱们知道数组的储存方式是在内存上分配固定的连续的空间,寻址速度快(查询速度快),时间复杂度为O(1),可是在插入、删除元素时候须要移动数组的元素,因此插入、删除时候速度慢,时间复杂度为O(n)。链表的存储方式在内存上是不连续的,每一个元素都保存着下个元素的内存地址,经过这个地址找到下个元素,因此链表在查询的时候速度慢,时间复杂度为O(n),在插入和删除的时候速度快,时间复杂度为O(1)
若是咱们想要一个数据结构既查询速度快,插入和删除速度也要快,那咱们应该怎么作呢?这时哈希(Hash)表就应时而生了,经过哈希函数计算出在哈希表中指定的储存位置(注意这里的储存位置是在表中的位置,并非内存的地址),称为哈希地址,而后将值储存在这个哈希地址上,而后经过就能够直接操做到,查询、插入、删除等操做时间复杂度都是O(1)
既然是键经过哈希函数计算出储存位置,那么哈希函数的好坏直接影响到哈希表的操做效率,如会出现浪费储存空间、出现大量冲突(即不一样的键计算出来的储存位置同样)。数组

哈希函数能够将任意长度的输入映射成固定长度的输出,也就是哈希地址 哈希冲突是不可避免的,经常使用的哈希冲突解决办法有如下2种方法。安全

  1. 链地址法(拉链法)
    采用数组和链表结合的方法,对哈希表中每一个哈希地址创建一个线性表,将哈希地址相同的数据储存在线性表中,并将链表的头指针保存在数组中,哈希地址、键、值等信息通常保存在链表节点中。通常经过哈希地址计算出数组的下标,将哈希值相同的保存在下标相同的数组中的。拉链法适合常常进行插入、删除操做的状况。
  2. 开放定址法
    开放定址法也称线性探测法,基本思想是:将哈希表T[0...m-1]当作是个循环向量,若初始探测地址为d,则最长的探测路径为:d,d+i,d+2i,...,m-1。即探测时候从地址d开始,首先探测T[d],若是T[d]发生哈希冲突则继续探测下一个T[d+1]...直到探测到T[m-1]为止,i为自定义的常数。开放定址法很容易产生堆聚现象,所谓堆聚现象就是哈希表中的数据连成一片,在加入新元素的时候就容易产生哈希冲突。
  3. 拉链法和开放定址比较
    拉链法:处理冲突简单,无堆聚现象,同时链表插入、删除操做简单,因此拉链法适合常常进行插入、删除操做的状况。
    开放定址法:为了减小冲突,要求**负载因子(装填因子)**较小,当节点规模较大时候会浪费不少空间。且开放定址法在删除节点的时候,不能简单的将节点所在的空间置为空,不然将截断在它以后的节点的查找路径,这是由于各类开放定址法中,空地址单元都是查找失败的条件。所以在进行删除节点操做的时候,须要使用逻辑删除,即在被删除的节点上作删除标记。

负载因子 = 填入哈希表中的元素个数 / 哈希表的数组长度bash

HashMap

数据结构

HashMap采用上述的拉链法解决哈希冲突.HashMap是非线程安全的,容许键、值为null,不保证有序(好比插入的顺序),也不保证顺序不随时间变化(哈希表加倍扩容后,数据会有迁移)。
咱们建立个HashMap运行看看数据结构

HashMap<String, Integer> map = new HashMap();
map.put("语文", 1);
map.put("数学", 2);
map.put("英语", 3);
map.put("历史", 4);
map.put("政治", 5);
map.put("地理", 6);
map.put("生物", 7);
map.put("化学", 8);
复制代码

经过图能够看到HashMap并非按照插入顺序存储的(无序的)。
接下来咱们看看HashMap的 数据结构
HashMap有几个重要的成员变量, tablesizethresholdloadFactormodCount

  • table:是一个Entry[]数组类型,而Entry其实是一个单向链表,哈希表的键值对都是储存在Entry数组中,每一个Entry对应一个哈希地址,这里的Entry即常说的桶
  • size:是HashMap的大小,为保存的键值对的数量
  • DEFAULT_INITIAL_CAPACITY:HashMap默认容量(数组的大小) 默认为16
  • MAXIMUM_CAPACITY:HashMap的最大容量(2的30),若是传入的容量大于这个值,则被最大容量替换
  • threshold:是HashMap的阈值,用于判断是否须要调整HashMap的容量。threshold=容量*负载因子,当HashMap中储存的键值对数量到达threshold时,HashMap就会将容量加倍的扩容
  • loadFactor:即负载因子
  • modCount:用来实现快速失败(fail-fast)机制

快速失败机制:对于线程不安全(注意是线程不安全的集合才有这个机制)的集合对象的迭代器,若是在使用迭代器的过程当中有其余的线程修改了集合对象的结构或者元素数量,那么迭代马上结束,迭代器将抛出ConcurrentModificationExceptionapp

构造函数

HashMap有4个构造函数,以下:函数

//无参构造函数,负载因子为默认的0.75,HashMap的容量(数组大小)默认容量为16
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//指定HashMap容量大小的构造函数 负载因子为默认的0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//指定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);
}

//包含子Map的构造函数,负载因子为默认的0.75
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
复制代码

为何负载因子默认是0.75?按照官方给出的解释是,当负载因子为0.75时候,Entry单链表的长度几乎不可能超过8(到达8的几率是0.00000006),做用就是让Entry单链表的长度尽可能小,让HashMap的查询效率尽量高。ui

因为当HashMap的大小(即size)大于初始容量(capacity)时候,HashMap就会扩大一倍,因为不少时候并不须要扩大这么多,因此当咱们知道咱们的数据的大小的时候,就能够在HashMap初始化的时候指定容量(数组大小)。
须要注意的是,咱们指定的容量必须是2的幂次方,即便咱们传入的容量不是2的幂次方,源码中也会将容量转成2的幂次方,好比咱们传入的是5,最终的容量是8。this

为何容量必定要是2的幂次方?由于HashMap是数组+单链表的结构,咱们但愿元素的存放的更均匀,最理想的状态是每一个Entry中只存放一个元素,这样在查询的时候效率最高。那怎么才能均匀的存放呢?咱们首先想到的是取模运算 哈希地址%容量大小,SUN的大师们的想法和咱们的也同样,只不过他们使用位运算来实现这个运算(位运算效率高),为了使位运算和取模运算结果同样,即hash & (capacity - 1) == hash % capacity,容量(Capacity)的大小就必须为2的幂次方。spa

put方法

在JDK1.8以前hashMap的插入是在链表的头部插入的,本文分析的是JDK1.8源码,是在链表的尾部插入的。

  1. 根据键(key)的hashCode()计算出当前键值对的哈希地址,用于定位键值对在HashMap数组中存储的下标
  2. 判断table是否初始化,没有初始化则调用resize()table初始化容量,以及threshold的值
  3. 根据**table数组长度和哈希地址作&运算(i = (n - 1) & hash)**计算出该key对应的table数组索引,若是对应的数组索引位置没有值,则调用newNode(hash, key, value, null)方法,为该键值对建立节点。

这里思考个问题,当table数组长度变化后,是否是取到的值就不正确了?后面给出分析。这里简单分析下为何不是直接按照哈希地址作数组下标,而是用table数组长度和哈希地址作&运算(i = (n - 1) & hash)(由于数组的大小是2的幂次方,因此这个运算等效于mod 数组大小的运算)计算数组下标,由于哈希地址可能超过数组大小,还有就是为了让键值对更均匀的分布的在各个桶(链表)中,也由于容量会变因此各个桶(链表)中的节点的哈希地址并非相同的,相同的哈希地址也可能分到不一样的下标。

  1. 若是根据哈希地址计算出该key对应的table数组索引有节点,且节点的键key和传入的键key相等,哈希地址和传入的哈希地址也相等,则将对应的节点引用赋值给e
  2. 若是根据哈希地址计算出该key对应的table数组索引有节点,且节点的哈希地址和传入的哈希地址同样,可是节点的键key和传入的键key不相等,则遍历链表,若是遍历过程当中找到节点的键key和传入的键key相等,哈希地址和传入的哈希地址也相等,则将对应的value值更新。不然调用newNode(hash, key, value, null)方法,为该键值对建立节点添加到链表尾部,若是追加节点后的链表长度 >= 8,则转为红黑树
  3. 若是e不为空,且onlyIfAbsenttrue则不会覆盖相同key和相同哈希地址的value
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

//若是参数onlyIfAbsent是true,那么不会覆盖相同key的值value。若是evict是false。那么表示是在初始化时调用的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //tab存放 当前的哈希桶, p用做临时链表节点  
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //若是当前哈希表是空的,表明是初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        //那么直接去扩容哈希表,而且将扩容后的哈希桶长度赋值给n
        n = (tab = resize()).length;
    //若是当前index的节点是空的,表示没有发生哈希碰撞。 直接构建一个新节点Node,挂载在index处便可。
    //这里再啰嗦一下,数组下标index 是利用 哈希地址 & 哈希桶的长度-1,替代模运算
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {//不然 发生了哈希冲突。
        //e
        Node<K,V> e; K k;
        //若是哈希值相等,key也相等,则是覆盖value操做
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;//将当前节点引用赋值给e
        else if (p instanceof TreeNode)//红黑树暂且不谈
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//不是覆盖操做,则插入一个普通链表节点
            //遍历链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {//遍历到尾部,追加新节点到尾部
                    p.next = newNode(hash, key, value, null);
                    //若是追加节点后,链表数量》=8,则转化为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //若是找到了要覆盖的节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //若是e不是null,说明有须要覆盖的节点,
        if (e != null) { // existing mapping for key
            //则覆盖节点值,并返回原oldValue
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //这是一个空实现的函数,用做LinkedHashMap重写使用。
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //若是执行到了这里,说明插入了一个新的节点,因此会修改modCount,以及返回null。

    //修改modCount
    ++modCount;
    //更新size,并判断是否须要扩容。
    if (++size > threshold)
        resize();
    //这是一个空实现的函数,用做LinkedHashMap重写使用。
    afterNodeInsertion(evict);
    return null;
}
复制代码

hashCode()是Object类的一个方法,hashCode()方法返回对象的hash code,这个方法是为了更好的支持hash表,好比Set、HashTable、HashMap等。hashCode()的做用:若是用equals去比较的话,若是存在1000个元素,你new一个新的元素出来,须要去调用1000次equals去逐个和它们比较是不是同一个对象,这样会大大下降效率。ashcode其实是返回对象的存储地址,若是这个位置上没有元素,就把元素直接存储在上面,若是这个位置上已经存在元素,这个时候才去调用equal方法与新元素进行比较,相同的话就不存了,散列到其余地址上。

get方法

  1. table不为空,且table的长度大于0,且根据键keyhashCode()计算出哈希地址,再根据桶的数量-1和哈希地址作&运算计算出数组的下标,该下标下不为空(即存有链表头指针)则继续往下进行,不然返回null
  2. 若是和第一个节点的哈希地址、键key都相同,则返回第一个节点。
  3. 若是第一个节点的下个节点不为空,则继续,若是第一个节点为树的节点,则执行getTreeNode(hash, key),在树中寻找节点,而且返回。不然遍历链表,找到键key、哈希地址同样的则返回此节点。
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

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 && // 若是索引到的第一个Node,key 和 hash值都和传递进来的参数相等,则返回该Node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) { //若是索引到的第一个Node 不符合要求,循环变量它的下一个节点。
            if (first instanceof TreeNode) // 在树中get
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {// 在链表中get
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
复制代码

remove方法

  1. table不为空,且table的长度大于0,且根据键keyhashCode()计算出哈希地址,再根据哈希地址计算出数组的下标,该下标下不为空(即存有链表头指针)则继续往下进行,不然执行6`。
  2. 若是哈希地址、键key同样,则将对应的节点引用赋值给node,而后执行4。不然执行3。
  3. 若是为树,则执行getTreeNode(hash, key)在树中寻找节点而且返回,不然遍历链表,找到键key、哈希地址同样的节点而后将对应的节点引用赋值给node,而后执行4,不然执行6。
  4. 若是节点node不为空(即查询到键key对应的节点),且当matchValuefalse的时候或者value也相等的时候,则执行5,不然执行6。
  5. 若是节点为树,则调用removeTreeNode(this, tab, movable)移除相应的节点。不然在链表中移除相应的节点,
  6. 返回null
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) {
    // p 是待删除节点的前置节点
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //若是哈希表不为空,则根据hash值算出的index下 有节点的话。
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //node是待删除节点
        Node<K,V> node = null, e; K k; V v;
        //若是链表头的就是须要删除的节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;//将待删除节点引用赋给node
        else if ((e = p.next) != null) {//不然循环遍历 找到待删除节点,赋值给node
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //若是有待删除节点node,  且 matchValue为false,或者值也相等
        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)//若是node ==  p,说明是链表头是待删除节点
                tab[index] = node.next;
            else//不然待删除节点在表中间
                p.next = node.next;
            ++modCount;//修改modCount
            --size;//修改size
            afterNodeRemoval(node);//LinkedHashMap回调函数
            return node;
        }
    }
    return null;
}
复制代码

containsKey方法

若是存在指定的键key,返回true,不然返回false。
containsKey方法调用的get调用的方法同样的方法,参考get方法的解析。

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}
复制代码

哈希表的初始化和加倍扩容resize方法

分析resize方法,咱们就能够知道为何哈希表的容量变化后,仍然能取到正确的值

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //若是哈希表是空的 则将旧容量置为0,不然置为旧哈希表的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //旧的哈希表的阈值
        int oldThr = threshold;
        //新的哈希表的容量和阈值 都置为0
        int newCap, newThr = 0;
        //若是旧的容量大于0 即不是第一次初始化 是扩容操做
        if (oldCap > 0) {
            //旧的容量是否大于2的30次幂方(容量的最大值)
            if (oldCap >= MAXIMUM_CAPACITY) {
                //阈值设置为Integer的最大值
                threshold = Integer.MAX_VALUE;
                //返回旧的哈希表(旧的哈希表已经到最大的容量了,不能继续扩容 因此返回)
                return oldTab;
            }
            //新的哈希表容量的=旧的容量<<1,即新的容量=旧的2倍,若是新的容量小于2的30次幂方(容量的最大值) 且 旧的容量大于等于默认的容量(16)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //新的哈希表的阈值=旧的哈希表的阈值<<1,既即新的阈值=旧的2倍 扩容table
                newThr = oldThr << 1; // double threshold
        }
        //第一次初始化,若是旧的阈值>0
        即HashMap是以传入容量大小或者传入容量大小、负载因子的构造函数进行初始化的,阈值thr
        eshlod已经在构造函数初始化过了,因此阈值在这里大于0
        else if (oldThr > 0) // initial capacity was placed in threshold
            //新的容量=旧的阈值
            newCap = oldThr;
        //若是是以无参构造函数进行初始化的,则
        新的容量大小=默认的容量大小,新的阈值=默认的负载因子*默认的容量大小
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //新的阈值=0,即执行的是上面的else if (oldThr >
        0)(使用带参数的构造函数初始化),是使用带参数的构造函数进行的初始化,而且计算出新的
        阈值
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        //建立新的哈希表,容量为新的容量
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //重点看这部分,若是旧的哈希表不为空
        if (oldTab != null) {
            //遍历旧的哈希表,
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //若是旧的哈希表的节点不为空
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //若是节点没有下个节点了(即只有一个节点),则直接放到新的哈希表中
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //将旧的哈希表的节点所有从新定位,好比旧的哈希表容量是16,有一个
                        值a放在数组下标为0上,如今新的哈希表容量是32,从新定位后值a就被重
                        新定位到下标为32上,即新的哈希表的下标为32储存值a,简单来讲就是新
                        的下标=旧的哈希表的下标+新的哈希表的容量,正是由于这个节点的迁移,
                       因此咱们在hashMapputget操做的时候,在哈希表容量变化后仍让取到正确
                       的值,可是也由于这个迁移操做,会消耗不少资源,因此尽可能在建立HashMa
                       p的时候就估计哈希表的容量,尽可能不要让他加倍扩容。这里的迁移也都是
                       运用的位运算,因此在初始化的时候,桶的数量必须是2幂次方,才能保证
                       位运算和取模运算结果同样。
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
复制代码

咱们能够运行个例子,调试看看。

HashMap<String, Integer> map = new HashMap();
for (int i = 1; i <= 24; i ++) {
    map.put(String.valueOf(i), i);
}
for (int i = 25; i <= 80; i ++) {
    map.put(String.valueOf(i), i);
}
复制代码

咱们以无参构造函数(即哈希表容量默认是16,负载因子默认是0.75)new一个HashMap,而后调试看看

运行第一个 for循环,看到 11保存的下标为0, 12保存的下标是1
在继续运行第二个 for,发现下标为0的变成了44,下标为1的变成了45
那咱们的11和12保存在哪了?能够发现11和12到了下标为3二、33上,即当执行第二个 for的时候哈希表发生了扩容,而后节点都迁移了,新的下标=旧的下标+新的哈希表的容量

参考资料

Java HashMap工做原理及实现
Map 综述(一):彻头彻尾理解 HashMap

原文地址:https://ddnd.cn/2019/03/07/jdk1.8-hashmap/

相关文章
相关标签/搜索