• Java-HashMap


    Map的结构:

    HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。 

    在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数。Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

    但是即使是最好的hash函数和负载因子,还是会出现hash冲突导致链表过长,所以在1.8后引入了红黑树,长度默认超过8时链表转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法,有兴趣可以自己去看一下这个数据结构。

    存储结构:数组+链表+红黑树(jdk8)

    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;
    // tableSizeFor会得到离initialCapacity最近的2的整数次幂的值
    this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
        static final int tableSizeFor(int cap) {
    // 返回里cap最近的2的整数次幂的数值
    // 比如cap为8,二进制为1000,n-1后为0111,最后n为0111,返回n+1为1000
    // 这些位或(|=)用来干什么的呢?比方你有一个数为10000000 00000000 00000000 00000000
    // 那么执行完这些操作后的结果为 11111111 11111111 11111111 11111111
    // 然后你想一想就明白了,32位的int执行完这几步后高位第一个1后面全变1了
    // 那为什么cap要减一,加入还是上面的8,如果不减一那返回的结果就是16
    // 减一的话就是8,如果是7,那返回的还是8,自己感受下为什么减一
    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; }

    Hash桶数组:

    transient Node<K,V>[] table;

    Node<K, V>:

    static class Node<K,V> implements Map.Entry<K,V> {
            final int hash; //用来定位数组索引位置
            final K key;
            V value;
            Node<K,V> next; //下一个Node,所以是单向链表
    
            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);
            }
    
            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;
                    if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                        return true;
                }
                return false;
            }
        }

    hash函数:(h = key.hashCode()) ^ (h >>> 16),hashCode方法返回int,这个式子就是让key的hashCode的高16位也参与hash的计算。

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

    如果对象的hashcode值时一样的,那么hash的结果也是一样的,而且存在不同的对象拥有相同的hashcode值,所以会冲突,hashmap解决冲突的方法是链地址法。

    哈希表: https://www.cnblogs.com/gavanwanggw/p/7307596.html

    调用HashMap的put方法是会调用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;
            if ((tab = table) == null || (n = tab.length) == 0)
    // hashmap的扩容,实例化HashMap后table还是null n
    = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null)
    // (n - 1) & hash是取模运算
    // 这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,
    // 而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。
    // 当length总是2的n次方时,h& (length-1)运算等价于对length取模,
    // 也就是h%length,但是&比%具有更高的效率。可以看下下面的图
    tab[i]
    = newNode(hash, key, value, null); else {
    // p那个位置有数据 Node
    <K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
    // 如果他们hash是一样的并且key内存地址一样或者key逻辑相等,那么这两个Node当作是同一个,算是找到了节点e e
    = p; else if (p instanceof TreeNode)
    // 如果是树节点,那么按照红黑树插入 e
    = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
    // hash值一样但是是不同的节点,那么就是冲突了
    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; }
    // 一直找,直到找到一样的节点,链表长度也没到8
    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key
    // 也就是映射到了key那个Node V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value;
    // 这是一个回调函数,在HashMap里是空的,在LinkedHashMap有具体实现,
    // 而LinkedHashMap是HashMap的子类,所以这里体现了模板方法这个设计模式
    // 具体的实现延迟到了子类,LinkedHashMap里这个函数是将node放到最后 afterNodeAccess(e);
    return oldValue; } }
    // 这是一个记录HashMap结构改变的次数的变量
    ++modCount;
    // 如果size大于了threshold那么又要resize了
    // 一个Node[]长度为16,负载因子为0.75,那么threshold = cap(16) * loadFactory为12,
    // size为HashMap中存在的键值对数量
    if (++size > threshold) resize();
    // 和上面的afterNodeAccess(e)是一个道理 afterNodeInsertion(evict);
    return null; }

    resize() :

    final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            if (oldCap > 0) {
                if (oldCap >= MAXIMUM_CAPACITY) {
    // MAXIMUM_CAPACITY = 1 << 30; Integer.MAX_VALUE = (1 << 31) - 1;
    // 因为扩容变成两倍的话就是1 << 31,但是int是32为,还有一个符号位,所以最大是(1 << 31) -1 threshold
    = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
    // 扩容后的HashMap容量是之前的两倍 newThr
    = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold
    // 在第一次带参数初始化时候会有这种情况, like this:new HashMap<String, String>(1) 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) { Node<K,V> e; if ((e = oldTab[j]) != null) {
    // 旧的table设为空 oldTab[j]
    = null; if (e.next == null)
    // 如果数组的某个位置只有这一个节点,那就直接重hash newTab[e.hash
    & (newCap - 1)] = e; else if (e instanceof TreeNode)
    // 如果节点是树 ((TreeNode
    <K,V>)e).split(this, newTab, j, oldCap); else { // preserve order 保持顺序
    // 1.7的时候链表会倒转,1.8不会倒转
    // 这里比较特殊:它没有重新计算位置 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) {
    // 详细看下面
    // 所以说如果e.hash & oldCap是0,则表示hash的高位不是1,那位置不变,比如再去看下面的例子
    // oldCap = 0B10000 = 16; key1(hash1) = 00101;key2(hash2) = 10101
    // 那是不是(e.hash & oldCap) == 0的时候高位为0,那么当cap翻倍后为32时
    // (e.hash & (newCap - 1))是不是不变
    // 下面就是求出位置不变的,组成新链表,下面直接把链表头节点给数组某个位置
    // 所以这里的数组是不会倒置的,1.7会
    // 我给出了一段代码,可以调试,加深理解,在这
    if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else {
    // 位置加上一个oldCap
    // 组成新链表
    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; }

     在这里

     这是JDK1.8的一个优化,可以不重新计算hash:经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。比如你有一个容量为2,又两个Node的key为1,3,那扩容之后容量为4,那么1还在原来的位置,3的位置加了2。

    图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

    还是来图吧。

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    回到代码

    调试代码:断点搭载第四行,capacity为2,threshold为2,扩容后1,5不变,3的位置加2,试试看

            HashMap<String, String> map2 = new HashMap<String, String>(2, 1f);
            map2.put("1", "1");
            map2.put("3", "3");
            map2.put("5", "5");

    cap从16变为32的示例图:

    小结

    (1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

    (2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改(负载因子越小,能放的键值对也少,冲突的几率和链表的长度也小,性能比较好,空间换时间吧),除非情况非常特殊。

    (3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

    (4) JDK1.8引入红黑树大程度优化了HashMap的性能。

    (5) 还没升级JDK1.8的,现在开始升级吧。HashMap的性能提升仅仅是JDK1.8的冰山一角。

     

    最后试着回答一下这几个问题:

    1. HashMap的实现原理,jdk8做了哪些优化?

    1)数组 + 链表 + 红黑树
    2)相较1.7:加入红黑树,对于链表过长时的性能优化;链表可以不rehash,因为位置要么在原地或者加oldCap,而且元素额顺序不变
    参考

    2. HashMap怎么扩容的,为什么容量是2的整数次幂?

    1)
    2)在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化(源代码中有很多地方都是因为table长度是合数才可以进行优化,比如取模时用&而不是%),同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
    参考

    3.为什么HashMap是线程不安全的?

    public static void main(String[] args) throws InterruptedException {
            final HashMap<String, String> map = new HashMap<String, String>();
            int threads = 1000;
    // 这是闭锁,这个代码里就是让多个线程一起开始(并行的话还是看cpu的)
    final CountDownLatch startGate = new CountDownLatch(1); final CountDownLatch endGate = new CountDownLatch(threads); for (int i = 0; i < threads; i++) { Thread thread = new Thread() { @Override public void run() { try {
    // 都等着startGate变0 startGate.await();
    try { map.put(Thread.currentThread().getName(), " "); } finally { endGate.countDown(); } } catch (InterruptedException e) { e.printStackTrace(); } } }; thread.start(); } startGate.countDown(); // -1
    // 等所有线程的任务都结束 endGate.await();
    // 找理由应该输出1000,但是可能输出999,998之类的 System.out.println(map.size()); }

    就这段代码来说,可能多个线程发现同一个位置是null,都直接赋值上去,那么后面的线程可能会覆盖前面的。

    甚至resize可能造成节点之间的环路,然后你get(key)的时候就GG了。

    参考:

    https://zhuanlan.zhihu.com/p/21673805

  • 相关阅读:
    c#MD5珍藏
    html跳转倒计时
    微信支付.NET版开发总结(JS API),好多坑,适当精简
    zip文件jQuery工作地点选择城市代码
    百度地图Api进阶教程-点击生成和拖动标注4.html
    MySQL注入
    SQL语法:inner join on, left join on, right join on详细使用方法
    Mysql INNER,LEFT ,RIGHT join的使用
    让Sql语句区分大小写
    关于try和finaly 里面return的问题
  • 原文地址:https://www.cnblogs.com/KuroNJQ/p/11268807.html
Copyright © 2020-2023  润新知