Java集合类之HashMap

综述

HashMap是一个存储键值对(Key-value)的集合,每一组键值对也叫做Entry,HashMap数组的初始化都是null。

1、成员变量
//初始化容量为16,这个必须是2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//大于这个阈值会将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//小于这个阈值会将红黑树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树时数组长度必须大于这个值,否则认为链表长度太长是因为数组长度不够导致哈希冲突太多
static final int MIN_TREEIFY_CAPACITY = 64;
2、HashMap设计相关原理

①、初始化容量为2的幂以及每次扩容后长度必须为2的幂的原因
HashMap中的hash函数如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

将得到的hash值的高16位与低16位进行异或运算作为低16位值高16位不变的新的hash值作为最终的hash值。
hash的目的在于构建一个分布均匀的hash函数,直接通过index = hashCode(key) % length 固然简单,但是此种情况冲突很多效率很低,因此HashMap中对获取index做了优化,在putVal()方法中有这么一句:

if ((p = tab[i = (n - 1) & hash]) == null)

也就是index = (length - 1) & hash,此时如果数组长度是2的幂,则length - 1的二进制位 ”全为1“,通过index = (length - 1) & hash得到的index只与得到的hash值对应的最后几位有关,比如下面的例子:
假设hash值为:101110001110101110 1001
数组长度为16,length - 1对应二进制为:1111
&运算之后,index为1001即为9,此时保证了只要的得到的hash值分布均匀,index就会分布的均匀,这种操作等价于取余运算。

②、如何保证hash函数取到的hash值分布均匀
HashMap中的hash函数获取到的hash值会数组长度length - 1进行&运算,结果只与hash值的后n位有关,此时hash值的前16位特征将会丢失,比较容易产生哈希冲突,采用将高16位与低16位进行异或运算后作为低16位的值既保证了高16位的特征也保留了低16位的特征,而且大大减少了冲突。

③、负载因子的作用以及为什么是0.75
负载因子的作用是为了解决HashMap的数据密度问题,当数组使用的长度大于了当前容量*负载因子时就会发生resize()操作,设置合适的负载因子可以提高空间利用率和减少查询时间成本。0.75是一个折中,如果设置为1,则空间利用率提高,但是查询成本加大;如果设置为0.5,查询成本虽然降低,但空间利用率变小,所以选择0.75完全是时间和空间上的一种折中。

HashMap中有这么一段注释:

Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution(泊松分布)
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

也就是在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

④、HashMap是如何保证容量是比设置的大的最小的2的幂
比如初始化容量设置为20,则实际初始化长度为32,完成这一转换的是HashMap中的tableSizeFor函数,如下:

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

对此算法进行简单理解:

  • 首先cap-1是为了防止cap已经是2的幂,如果cap已经为2的幂且不进行cap - 1操作,则会返回cap的2倍;
  • 如果cap为1,则n为0,最终结果返回1;
  • 如果cap - 1 不为0,则n的二进制中一定存在1,考虑最高位的1;
  • n |= n >>> 1;经过这步操作,n的最高位和右边相邻位均为1;
  • n |= n >>> 2;同上一步一致,会有四个连续的1;
  • n |= n >>> 4; 产生8个相邻的1;
  • n |= n >>> 8; 产生16个相邻的1;
  • n |= n >>> 16; 产生32个相邻的1
    最大也就是30个1,32个1是负数。

⑤、put()函数
put函数调用putVal()函数实现存键值对,putVal函数处理流程如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果尚未初始化,则先进行resize()函数初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
         //哈希到的位置是否为null,如果为null表示此位置尚未被使用可以直接插入
        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;
             //如果是红黑树,则进行红黑树的插入,调用putTreeVal函数完成红黑树插入
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //遍历链表,找到key值相等的,则更新value,否则插入链表尾部,插入后在检查是否需要转化为红黑树,如果需要调用treeifyBin函数树形化
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
                }
            }
            //更新value
            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;
    }

红黑树插入流程如下:

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                                       int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    TreeNode<K,V> root = (parent != null) ? root() : this;
    //从红黑树的根节点便历对比hash值
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;
        if ((ph = p.hash) > h)
            dir = -1;
        else if (ph < h)
            dir = 1;
         //如果哈希值和键值相等,则直接返回此节点,并在putVal中更新value
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            //如果当前节点和要添加的节点的key哈希值相等,但key不是同一个类,则按个对比左右孩子
            if (!searched) {
                TreeNode<K,V> q, ch;
                searched = true;
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    //如果在子树中可以找到要添加的节点,则直接返回
                    return q;
            }
            //哈希值相等但键值无法比较,调用此方法完成特殊处理给个结果
            dir = tieBreakOrder(k, pk);
        }
        //上述操作得到了当前节点和要插入节点的大小关系
        TreeNode<K,V> xp = p;
        //判断当前节点有没有左右孩子,没有左右孩子就直接插入,否则进入下一轮循环
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next;
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
            if (dir <= 0)
                xp.left = x;
            else
                xp.right = x;
            xp.next = x;
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x;
            //插入之后红黑树的自平衡操作
            moveRootToFront(tab, balanceInsertion(root, x));
            return null;
        }
    }
}

树形化函数:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果数组长度小于64,则认为哈希冲突太多的原因是数组长度太短,会及进行resize()操作
    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<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);
        //上述操作将单链表转换成了双向链表,并将节点转换为了红黑树的节点,下面的treeify()函数才是建立红黑树的关键
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

treeify函数

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        //设置根节点
        if (root == null) {
            x.parent = null;
            x.red = false;
            root = x;
        }
        else {
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //通过比较获取当前节点的插入位置
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                if ((ph = p.hash) > h)
                    dir = -1;
                else if (ph < h)
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);

                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    //插入之后平衡红黑树
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);
}

⑥、resize()扩容函数
当数组使用量大于等于数组长度*负载因子时就会发生resize将数组长度扩充为原来的两倍,resize之后元素会被rehash,但不需要再次计算hash值,如下图例子,当为1时索引位置就变为 元索引+原来数组长度,位0则不变
在这里插入图片描述2对应二进制位 0010,16 = 2^4,不需要改变索引位置;
10对应二进制位1010 ,为1,索引位置为2 + 8 = 10,同理26也是如此。
因为resize()操作开销很大,索引在创建HashMap的时候要设置合适的大小,比如要存入1000个数据,这是要设置容量为大于1000/.75f的2的幂,也就是2048。

⑦、为什么HashTable不允许null为键或者值
ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Java中的Map接口提供了一种将键映射到值的对象。其中一种实现是HashMap,它使用哈希表来实现。HashMap允许 null 键和 null 值,并且没有顺序保证。HashMap 的操作复杂度为 O(1) 平均复杂度,因此在许多情况下非常高效。 ### 回答2: Java集合类中的Map用来保存键-值对,HashMap是其中的一种实现方式。HashMap的内部实现是基于哈希表的,它可以将任意对象作为键,并且保证键的唯一性。在HashMap中,键和值都允许为null,但是同一个HashMap中,键不能重复,如果重复了,新的value会覆盖旧的value。 HashMap内部是通过hashCode()和equals()方法来判断键是否相等的。当我们向HashMap中添加一个键-值对时,系统会先计算出键的hashCode值,然后用该值来确定存放该键值对的位置。如果有多个键的hashCode值相同,称为哈希冲突,那么HashMap会在这些键值对所在的位置上,以链表的形式组成一个链表存储。 HashMap的优点在于插入、删除和查找都比较快,时间复杂度均为O(1),对于大量的数据,它的效率优于List或Set等集合。但是,在内存使用上,HashMap会比List或Set等集合耗费更多的内存空间,因为它需要额外的空间来存储哈希值和链表指针。 值得注意的是,在多线程环境下,HashMap是不安全的,会出现并发修改导致的数据不一致问题,这时可以使用ConcurrentHashMap或者加锁机制来保证线程安全。 总之,HashMapJava中非常实用的集合类,适用于大量键值对的存储和检索。我们应该了解HashMap的内部实现原理,并且在使用过程中需要注意其线程安全性等问题。 ### 回答3: Java中的Map是一种键值对的集合,其中每个元素都由一个键和一个值组成。在Map中,每个键必须是唯一的,而值可以重复。 在Map中,HashMap是最常用的实现类之一。它是基于哈希表实现的,可以通过键快速查找值。哈希表是一种支持常数时间快速查找的数据结构,因为它可以将键映射到与其对应的值的位置,因此查找时只需要计算键的哈希码即可找到对应的值。 HashMap的实现类中使用了两个数组来存储键值对:一个数组用于存储键,另一个数组用于存储值。当插入键值对时,首先会将键的哈希码计算出来,然后通过哈希码将键映射到键数组的位置,将值存储在值数组的相同位置。当需要查找一个键时,只需计算其哈希码并定位到键数组的位置,然后从值数组中取出对应的值即可。 与集合一样,HashMap也是线程不安全的,因此在多线程环境下需要使用ConcurrentHashMap或通过synchronized键字来保证线程安全性。此外,在使用HashMap时应该尽量避免使用null作为键,因为null的哈希码为0,可能与其他非null键的哈希码相同,导致哈希碰撞,影响HashMap的性能。 总之,HashMap是一种高效的键值对集合,通过哈希表实现快速的查找和插入操作。在正确使用和注意安全性的前提下,使用HashMap可以大大提升代码效率和性能。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值