集合HashMap

概述

HashMap基于Map接口实现,元素以键值对的方式存储,HashMap是线程不安全的。多线程环境中推荐是ConcurrentHashMap。
JDK 1.8 对 HashMap 进行了比较大的优化,底层实现由之前的 “数组+链表” 改为 “数组+链表+红黑树”

public class HashMap<K,V>extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

基本属性

// 默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
 //负载因子0.75
 /*加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,
	等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
	所以加载因子默认为0.75,也就是说大小为16的HashMap,
	到了第13个元素,就会扩容成32。*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;    

static final Entry<?,?>[] EMPTY_TABLE = {};         //初始化的默认数组
transient int size;     //HashMap中元素的数量
int threshold;          //判断是否需要调整HashMap的容量 
// threshold n.	门槛; 门口; 阈; 界; 起始点; 开端; 起点; 入门;

HashMap几个重要属性

属性名属性说明JDK7JDK8
loadFactor加载因子,初始值=0.75,与扩容有关
threshold临界值,与HashMap扩容相关
modCountmap中数据改变次数的统计
DEFAULT_INITIAL_CAPACITY默认的初始容量 ,=1<<4=16
MAXIMUM_CAPACITY最大容量,=1<<30
DEFAULT_LOAD_FACTOR默认加载因子,=0.75
TREEIFY_THRESHOLD使用TreeNode的临界值,默认=8×
UNTREEIFY_THRESHOLD与split方法有关×
MIN_TREEIFY_CAPACITY最小TreeNode的容量为64×

扩容机制 (重要)

HashMap扩容

扩容(resize):
向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
方法是使用一个新的数组代替已有的容量小的数组
1.8解决了resize时多线程死循环问题,但仍是非线程安全的。

扩容机制核心方法
Node<K,V>[] resize()

HashMap扩容可以分为三种情况:

第一种(第一次扩容):使用默认构造方法初始化HashMap。从下文代码可以知道HashMap在一开始初始化的时候会返回一个空的table,并且thershold(临界值)为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时threshold(临界值) = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12

第二种():HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold(临界值)量为原有的两倍

第三种:指定初始容量的构造方法初始化HashMap。那么从下面源码可以看到初始容量会等于threshold,接着threshold = 当前的容量(threshold) * DEFAULT_LOAD_FACTOR。

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首次初始化后table为Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;//默认构造器的情况下为0
        int newCap, newThr = 0;
        if (oldCap > 0) {//table扩容过(不是第一次扩容)
             //当前table容量大于最大值的时候返回当前table
             if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
        //使用带有初始容量的构造器时,table容量为初始化得到的threshold
        newCap = oldThr;
        else {  //默认构造器下进行扩容  
             // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        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) {
                HashMap.Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // help gc
                    oldTab[j] = null;
                    if (e.next == null)
                        // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
                        // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof HashMap.TreeNode)
                        // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把当前index对应的链表分成两个链表,减少扩容的迁移量
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.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) {
                            // help gc
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            // help gc
                            hiTail.next = null;
                            // 扩容长度为当前index位置+旧的容量
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

构造方法

HashMap()    //无参构造方法
HashMap(int initialCapacity)  //指定初始容量的构造方法 
HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子
HashMap(Map<? extends K,? extends V> m)  //指定集合,转化为HashMap

其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值
initialCapacity默认为16,loadFactory默认为0.75
我们看下其中一个

public HashMap(int initialCapacity, float loadFactor) {
     //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
        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;
        threshold = initialCapacity;
    }

从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组

添加方法:put() (重要)

HashMap的put方法执行过程可以通过下图来理解

在这里插入图片描述
①.判断键值对数组table[i]是否为空或为null,是的话就执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素的key是否和当前的key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

这边也可以引申到一个问题HashMap是先插入还是先扩容?

HashMap初始化后首次插入数据时,先发生resize扩容再插入数据之后每当插入的数据个数达到threshold(临界值)时就会发生resize,此时是先插入数据再resize

JDK1.8的put源码如下


public V put(K key, V value) {
// 对key的hashCode()做hash
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; Node<K,V> p; int n, i;
     // 步骤①:校验tab是否为空或者length等于0,如果是则调用resize方法进行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
    // 步骤②:通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
             // 步骤③:节点key存在,直接覆盖value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 步骤④:判断该链为红黑树
            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;
                    }
                     // key已经存在直接覆盖value
                    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;
            }
        }
        ++modCount;
     // 步骤⑥:超过最大容量 就扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

获取方法:get()

public V get(Object key) {
     if (key == null)
         //返回table[0] 的value值
         return getForNullKey();
     Entry<K,V> entry = getEntry(key);
 
     return null == entry ? null : entry.getValue();
}
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;
}

在get方法中,首先计算hash值,然后调用indexFor()方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回entry.value值。

删除方法

public V remove(Object key) {
     Entry<K,V> e = removeEntryForKey(key);
     return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
     if (size == 0) {
         return null;
     }
     int hash = (key == null) ? 0 : hash(key);
     int i = indexFor(hash, table.length);
     Entry<K,V> prev = table[i];
     Entry<K,V> e = prev;
 
     while (e != null) {
         Entry<K,V> next = e.next;
         Object k;
         if (e.hash == hash &&
             ((k = e.key) == key || (key != null && key.equals(k)))) {
             modCount++;
             size--;
             if (prev == e)
                 table[i] = next;
             else
                 prev.next = next;
             e.recordRemoval(this);
             return e;
         }
         prev = e;
         e = next;
    }
 
    return e;
}

删除操作,先计算指定key的hash值,然后计算出table中的存储位置,判断当前位置是否Entry实体存在,如果没有直接返回,若当前位置有Entry实体存在,则开始遍历列表。定义了三个Entry引用,分别为pre, e ,next。 在循环遍历的过程中,首先判断pre 和 e 是否相等,若相等表明,table的当前位置只有一个元素,直接将table[i] = next = null 。若形成了pre -> e -> next 的连接关系,判断e的key是否和指定的key 相等,若相等则让pre -> next ,e 失去引用。

containsKey

public boolean containsKey(Object key) {
        return getEntry(key) != null;
    }
final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key.hashCode());
        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;
    }

containsKey方法是先计算hash然后使用hash和table.length取摸得到index值,遍历table[index]元素查找是否包含key相同的值。

containsValue

public boolean containsValue(Object value) {
    if (value == null)
            return containsNullValue();
 
    Entry[] tab = table;
        for (int i = 0; i < tab.length ; i++)
            for (Entry e = tab[i] ; e != null ; e = e.next)
                if (value.equals(e.value))
                    return true;
    return false;
    }

containsValue方法就比较粗暴了,就是直接遍历所有元素直到找到value,由此可见HashMap的containsValue方法本质上和普通数组和list的contains方法没什么区别,你别指望它会像containsKey那么高效。

定位哈希桶数组索引位置 (重要)

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过 HashMap 的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率(通过get(key)方法的获取值,里面是对key进行hash运算来得到节点位置)。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。
下面是定位哈希桶数组的源码:

// 代码1
static final int hash(Object key) { // 计算key的hash值
    int h;
    // 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;

整个过程本质上就是三步:

拿到 key 的 hashCode 值
hashCode 的高位参与运算(高16位),重新计算 hash 值
将计算出来的 hash 值与 (table.length - 1) 进行 & 运算( 按位与运算符(&))

方法解读:

对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。

但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率

HashMap底层实现?(重要)

HashMap由数组和链表来实现对数据的存储
HashMap的实现是数组和链表(散列桶)的数据结构,每个数组元素里面有一个链表。这样的结构,能在查询和修改时,继承了数组的线性查找和链表的寻址修改。

HashMap使用key的hashCode来寻找存储位置,它存储的内容是键值对(key-value)映射,
讲put()方法的原理
先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。实际保存我们的key-value对的数据结构Node<K,V>

计算公式 h&(length-1)

不同的key可能具有相同的hashCode,这时候就出现哈希冲突了,

如果没有冲突,直接存入桶中

如果碰撞了,以链表的方式链接到后面

如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

如果桶满了(数组容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

HashMap和Hashtable的区别

主要的区别在于Hashtable是线程安全,而HashMap则非线程安全
针对null的不同
HashMap可以使用null作为key,而Hashtable则不允许null作为key
虽说HashMap支持null值作为key,不过建议还是尽量避免这样使用,因为一旦不小心使用了,若因此引发一些问题,排查起来很是费事。
Note:HashMap以null作为key时,总是存储在table数组的第一个节点上。
初始容量和扩容的不同
HashMap的初始容量为16,Hashtable初始容量为11,两者的填充因子默认都是0.75。

HashMap扩容时是当前容量翻倍即:capacity2,Hashtable扩容时是容量翻倍+1即:capacity2+1。

为什么HashMap线程不安全?

HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。

HashMap的线程不安全主要体现在下面两个方面:
1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

1、JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
数据丢失:
扩容方法不是同步的,在扩容过程中,会新生成一个新的数组,然后对原数组的所有键值对重新进行计算和写入新的数组,之后指向新生成的数组。
当并发执行扩容操作时,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。

详细参考:https://www.cnblogs.com/qiumingcheng/p/5259892.html
环形链表:
JDK1.7扩容造成死循环问题:
根源在transfer函数
在这里插入图片描述

转移元素的过程中,使用的是头插法会造成链表顺序的翻转,这里也是形成死循环的关键点。

jdk1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法,因此不会出现环形链表的情况。
详细参考:https://www.cnblogs.com/developer_chan/p/10450908.html

2、put的时候,多线程并发可能回导致数据覆盖

两个线程在同一个位置添加数据,后面添加的数据可能覆盖前面添加的。
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,如果A获取到存储位置的链表头结点后就因为时间片用完而停止了,而B成功将数据插入到了HashMap中,假设线程A计算出的插入地址和线程B要插入的地址是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它持有过期的链表头,这样就覆盖了线程B插入的数据,这样线程B插入的记录就凭空消失了,造成了数据覆盖。

Map里面的键和值可以为空吗?

Hashmap的key、value都可以为null。而HashTable则不可以。
此外,HashMap中的映射不是有序的,和放入的顺序并不相同。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值