HashMap

先说一下hashmap简单的概述:

HashMap基于Map接口实现,元素以键值对的方式存储,键值对可以为null,因为key不允许重复,因此只能有一个键为null。HashMap不能保证放入元素的顺序,它是无序的,和放入的顺序并不能相同。HashMap是线程不安全的。

JDK1.7版本

HashMap底层存储结构图

大家都知道hashmap底层基于数组+链表实现。

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体,依次来解决Hash冲突的问题,因为HashMap是按照Key的hash值来计算Entry在HashMap中存储的位置的,如果hash值相同,而key内容不相等,那么就用链表来解决这种hash冲突。

下面看一下HashMap的一些参数:

  • static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  // aka 16   初始化桶大小(底层数组默认长度)
  • static final int MAXIMUM_CAPACITY = 1 << 30;  // 最大容量
  • static final float DEFAULT_LOAD_FACTOR = 0.75f;  // 默认负载因子
  • transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  // table 真正存放数据的数组
  • transient int size // map存放数量的大小
  • int threshold;  // 桶大小,可在初始化时显式指定,用于判断是否需要扩容(threshold= 容量*负载因子)
  • final float loadFactor;  // 负载因子,初始化可显示指定

给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容。

根据代码可以看到其实真正存放数据的是

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;

}

Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:

  • key 就是写入时的键。
  • value 就是值。
  • 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
  • hash 存放的是当前 key 的 hashcode。

public V put(K key, V value) {
        if (table == EMPTY_TABLE) { //是否初始化
            inflateTable(threshold);
        }
        if (key == null) //放置在0号位置
            return putForNullKey(value);
        int hash = hash(key); //计算hash值
        int i = indexFor(hash, table.length);  //计算在Entry[]中的存储位置
        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;
            }
        }

        modCount++;
        addEntry(hash, key, value, i); //添加到Map中
        return null;
}

在该方法中,添加键值对时,首先进行table是否初始化的判断,如果没有进行初始化(分配空间,Entry[]数组的长度)。然后进行key是否为null的判断,如果key==null ,放置在Entry[]的0号位置。计算在Entry[]数组的存储位置,判断该位置上是否已有元素,如果已经有元素存在,则遍历该Entry[]数组位置上的单链表。判断key是否存在,如果key已经存在,则用新的value值,替换点旧的value值,并将旧的value值返回。如果key不存在于HashMap中,程序继续向下执行。将key-vlaue, 生成Entry实体,添加到HashMap中的Entry[]数组中。

/*
 * hash hash值
 * key 键值
 * value value值
 * bucketIndex Entry[]数组中的存储索引
 * / 
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);
        }
      // 往数组中添加新的key-value键值对
      createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
       // 取出当前位置的元素,如果是新添加的key,则e为null,已经有的元素为不为空。
        Entry<K,V> e = table[bucketIndex];
        // 添加新的key-value值或构建链表
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
}

在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进性扩容操作,扩充的容量为table长度的2倍。重新计算hash值,和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反。然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。(这种操作在高并发的环境下容易导致死锁,所以JDK1.8之后,新插入的元素都放在了链表的尾部。)

 

get方法:

  • 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
  • 判断该位置是否为链表。
  • 不是链表就根据 key、key 的 hashcode 是否相等来返回值。
  • 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
  • 啥都没取到就直接返回 null 。

 

JDK1.8的改变

当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)

数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式

TREEIFY_THRESHOLD:用于判断是否需要将链表转换为红黑树的阈值。
HashEntry 修改为 Node

Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。

下面看核心方法put的改动:

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; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
(判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化))
 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
(根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。)

    else {    如果当前桶有值( Hash 冲突)
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;

(比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 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);
(如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。)

                    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;
(如果在遍历过程中找到 key 相同时直接退出遍历)
                    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方法代码就不放了,简单介绍一下流程:

  • 首先将 key hash 之后取得所定位的桶。
  • 如果桶为空则直接返回 null 。
  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
  • 红黑树就按照树的查找方式返回值。
  • 不然就按照链表的方式遍历匹配返回值。

 

hash碰撞和扩容导致的线程不安全问题

以1.7为例,

transfer方法做的事情:

1.遍历旧的table

2.将旧的table中每个元素重新计算hash值, 然后赋予新的table中

单线程扩容:

假设:hash算法就是简单的key与length(数组长度)求余。

          hash表长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key%table.length)的话都应该碰撞到table[1]上

扩容:hash表长度会扩容为4

          重新hash,key=3 会落到table[3]上(3%4=3), 当前e.next为key(7), 继续while循环

          重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞, 这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句中代码)

          当前e.next为key(5), 继续while循环

          重新hash,key=5 会落到table[1]上(5%4=3), 当前e.next为null, 跳出while循环, resize结束

如题如图所示:

Image(3)

多线程扩容:

这里我们先把核心代码搬出来, 方便查看

while(null != e) {

    Entry<K,V> next = e.next; //第一行

    int i = indexFor(e.hash, newCapacity); //第二行

    e.next = newTable[i]; //第三行

    newTable[i] = e; //第四行

    e = next; //第五行

}

去掉了一些冗余的代码, 层次结构更加清晰了。

第一行:记录odl hash表中e.next

第二行:rehash计算出数组的位置(hash表中桶的位置)

第三行:e要插入链表的头部, 所以要先将e.next指向new hash表中的第一个元素

第四行:将e放入到new hash表的头部

第五行: 转移e到下一个节点, 继续循环下去

 

核心代码如上所说, 下面就是多线程同时put的情况了, 然后同时进入transfer方法中:

假设这里有两个线程同时执行了put()操作,并进入了transfer()环节

  1. while(null != e) {

  2. Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了

  3. e.next = newTable[i];

  4. newTable[i] = e;

  5. e = next;

  6. }

那么现在的状态为:

从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。

然后线程1被唤醒了:

  1. 执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null
  2. 执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
  3. 执行e = next,将 e 指向 next,所以新的 e 是 key(7)

然后该执行 key(3)的 next 节点 key(7)了:

  1. 现在的 e 节点是 key(7),首先执行Entry<K,V> next = e.next,那么 next 就是 key(3)了
  2. 执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
  4. 执行e = next,将 e 指向 next,所以新的 e 是 key(3)

这时候的状态图为:

然后又该执行 key(7)的 next 节点 key(3)了:

  1. 现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null
  2. 执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
  4. 执行e = next,将 e 指向 next,所以新的 e 是 key(7)

这时候的状态如图所示:

由图可见,已经出现了环形链。所以在多线程情况下,会导致hashmap出现链表闭环,一旦进入了闭环get数据,程序就会进入死循环,所以导致HashMap是非线程安全的。

JDK8解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题.

所以多线程环境下, JDK 推出了专项专用的 ConcurrentHashMap ,该类位于 java.util.concurrent 包下,专门用于解决并发问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值