zoukankan      html  css  js  c++  java
  • hashmap hashtable

    作者:付佳豪
    链接:https://zhuanlan.zhihu.com/p/37607299
    来源:知乎
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    在面试的时候,java集合最容易被问到的知识就是HashMap与Hashtable的比较,通常我们也很容易回答出一下几点:

    1、HashMap是线程不安全的,在多线程环境下会容易产生死循环,但是单线程环境下运行效率高;Hashtable线程安全的,很多方法都有synchronized修饰,但同时因为加锁导致单线程环境下效率较低。

    2、HashMap允许有一个key为null,允许多个value为null;而Hashtable不允许key或者value为null。

    如果你觉得会到处这两点就对HashMap、HashTable有很深的了解的话,那就大错特错!本文将基于JDK1.8的HashMap和Hashtable的源码进行详细的比较。

    构造函数的比较

    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);
    }
    //该方法返回大于等于cap的最小2次幂的整数
    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;
    }
    

    Hashtable:

    public Hashtable(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal Load: "+loadFactor);
    if (initialCapacity==0)
            initialCapacity = 1;
        this.loadFactor = loadFactor;
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    }
    public Hashtable() {
        this(11, 0.75f);
    }
    

    可以看出HashMap的底层数组的长度必须为2^n,这样做的好处是为以后的hash算法做准备,而Hashtable底层数组的长度可以为任意值,这就造成了当底层数组长度为合数的时候,Hashtable的hash算法散射不均匀,容易产生hash冲突。所以,可以清楚的看到Hashtable的默认构造函数底层数组长度为11(质数),至于为什么Hashtable的底层数组用质数较好,请参考博文:

    Hash算法的比较

    hash算法的区别

    HashMap:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    //手写的,源码在不存在这一句,但是原理是类似的,详情可以去看putVal方法
    int i = (table.length-1) & hash(key)
    

    HashMap的hash算法通过非常规的设计,将底层table长度设计为2^n(合数),这是HashMap的一处优化。它使用了&运算来代替%运算以减少性能上面的损耗。为何&运算可以代替%运算呢?

    如果两个整数做&运算,实质是两个整数转换为2进制数后每一个bit位的分别做&运算,所以其最终的运算结果的值不会超过最小的那个数,这个时候只需要搞清楚三点就能明白其实现原理:

    1、合数2^n转换为2进制的数之后,最高位为1其余的位数都为0,比如16-->10000,32-->100000。那么,2^n-1转换为2进制后,所有的bit位都为1,比如31-->11111,127-->1111111。所以,hashcode与(2^n-1)做&运算每一个bit位都可以保持原来的值。

    2、当hash()方法得到的值<=(table.length-1),其运算结果就在[0,table.length-1]范围内均匀散射。当hash()方法得到的值小于table.length-1的时候,运算结果就是该方法的原值。当hash()方法得到的值等于table.length-1的的时候,运算结果为0。

    3、当hash()得到的值>(table.length-1),此时table.length-1为较小的数,所以&运算的结果还是在[0,table.length-1]之间。具体实现是这样的,table.length-1转化为2进制的数之后位数小于hash()方法得到的2进制数,所以它的高位只能用0去补齐,又由于&运算的特性,只要有一个为0那么都为0,所以此时相当于转化为情况1。

    而hash()方法的具体作用是使得table的length较小的时候高低bit都能参与运算,具体分析请参考:

    Hashtable:

    int hash = key.hashCode();
    //0x7FFFFFFF转换为10进制之后是Intger.MAX_VALUE,也就是2^31 - 1
    int index = (hash & 0x7FFFFFFF) % tab.length;
    

    很容易看出Hashtable的hash算法首先使得hash的值小于等于整型数的最大值,再通过%运算实现均匀散射。

    由于计算机是底层的运算是基于2进制的,所以HashMap的hash算法使用&运算代替%运算,在运算速度上明显HashMap的hash算法更优

    扩容的机制的区别

    因为无论是HasHMap或者HashTable的扩容都是基于底层的hash算法的,所以将扩容机制放在hash算法部分讲。

    HashMap扩容:

    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) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 将阈值扩大为2倍
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // 当threshold的为0的使用默认的容量,也就是16
            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"})
            //新建一个数组长度为原来2倍的数组
            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 { 
                        //HashMap在JDK1.8的时候改善了扩容机制,原数组索引i上的链表不需要再反转。
                        // 扩容之后的索引位置只能是i或者i+oldCap(原数组的长度)
                        // 所以我们只需要看hashcode新增的bit为0或者1。
                       // 假如是0扩容之后就在新数组索引i位置,新增为1,就在索引i+oldCap位置
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            // 新增bit为0,扩容之后在新数组的索引不变
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {  //新增bit为1,扩容之后在新数组索引变为i+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;
                            //数组索引位置变化为j + oldCap
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    从源码中可以看出,HashMap数组的扩容的整体思想就是创建一个长度为原先2倍的数组。然后对原数组进行遍历和复制。只不过jdk1.8对扩容进行优化,使得扩容不再需要进行链表的反转,只需要知道hashcode新增的bit位为0还是1。如果是0就在原索引位置,新增索引是1就在oldIndex+oldCap位置。

    可能有些人对新增bit位感到困惑。在这里解释一下,这里的新增指的是有效bit位。在上面说到过,两个整数做&运算,转换为2进制的后,看bit位较短的那个数。也就是说bit位较长的数与bit位较短的数做&运算,多出来的bit需要用0来补齐,由于是&运算(只有一个为0那么其结果就为0),所以,新增的0位不是有效的bit位。对应于hash算法来说,通常hashcode的值比较大(转换为2进制数后bit为较多),扩容之后将数组的长度扩大为2倍,那么n(数组的长度),转换为2进制数后相较于未扩容之前的n多增加了一个1的有效bit位。简化版的例子如下:

    初始容量为16,那么15转换为二进制数位1111,现在进行一次扩容之后容量变为32,那么31转换为2进制是为11111。现有两个key,一个hashcode为107转换为二进制数后为1101011,另一个的hashcode是379转换为二进制数后为101111011。在容量为16的时候,这两个key,具体计算索引过程为:
    0001111 & 1101011 = 1011 000001111 & 101111011 = 1011 转换为10进制数后都为11。
    现在来看一下扩容之后两个key的索引:
    0011111 & 1101011 = 1011 000011111 & 101111011 = 11011 一个对应的索引仍然是11,而另一个却变为27(27 = 11+16)

    Hashtable扩容:

    protected void rehash() {
        int oldCapacity = table.length;
        Entry<?,?>[] oldMap = table;
    // overflow-conscious code
        int newCapacity = (oldCapacity << 1) + 1;
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            if (oldCapacity == MAX_ARRAY_SIZE)
                // Keep running with MAX_ARRAY_SIZE buckets
                return;
            newCapacity = MAX_ARRAY_SIZE;
        }
        Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
    modCount++;
        threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
        table = newMap;
    for (int i = oldCapacity ; i-- > 0 ;) {
            for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
                Entry<K,V> e = old;
                old = old.next;
    int index = (e.hash & 0x7FFFFFFF) % newCapacity;
                //使用头插法将链表反序
                e.next = (Entry<K,V>)newMap[index];
                newMap[index] = e;
            }
        }
    }
    

    Hashtable的扩容将先创建一个长度为原长度2倍的数组,再使用头插法将链表进行反序。

    结构的区别

    HashMap在jdk1.8在原先的数组+链表的结构进行了优化,将实现结构变为数组+链表+红黑树,有关红黑树的文章详细请参考博文:。这里只需要之后,红黑树是近似平衡树。做这样的优化,是为了防止在一个哈希桶位置链表过长,影响get等方法的时间。详细分析用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;
        //HashMap在构造方法中只是设置了一些参数,只有到put方法才会创建底层数组
        //这使用的是懒加载策略
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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的时候就需要转换为红黑树节点
                        //当然对底层数组的长度也有要求,最低长度为64,否则会先进行扩容
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    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;
        //put一个元素后检查size是否大于阈值,大于则需要进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    从源码中可以看出基于HashMap为了防止链表过长影响get等方法的性能,在一条链表节点元素大于8的时候,会将链表封装成红黑树。

    再来看一下Hashtable

    public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }
    // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }
    addEntry(hash, key, value, index);
        return null;
    }
    private void addEntry(int hash, K key, V value, int index) {
        modCount++;
    Entry<?,?> tab[] = table;
        if (count >= threshold) {
            // Rehash the table if the threshold is exceeded
            rehash();
    tab = table;
            hash = key.hashCode();
            index = (hash & 0x7FFFFFFF) % tab.length;
        }
    // Creates the new entry.
        @SuppressWarnings("unchecked")
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
    }
    

    可以看出Hashtable到了jdk1.8了内部结构并没有实质优化,继续使用数组+链表的方式实现。

    总结

    可以看出到jdk1.8 HashMap和Hashtable的区别越来越大,HashMap相较与之前的jdk做了很多的优化,最重要的是在内部实现结构上引进了红黑数还有扩容上的优化。Hashtable作为jdk1.2遗留下来的类,到jdk1.8没有大改,所以对数据的一致性要求较低的话可以使用ConcurrentHashMap来替代Hashtable

  • 相关阅读:
    [NOI Online 2021 提高组] 愤怒的小N
    [NOI Online 2021 提高组] 积木小赛
    「2020-2021 集训队作业」Yet Another Permutation Problem
    无标号有根树/无根树 计数
    无向图的 三元环
    有标号二分图计数
    有标号荒漠计数
    「雅礼集训 2018 Day8」B
    CF708E Student's Camp
    清华集训 2016 选做
  • 原文地址:https://www.cnblogs.com/kakaisgood/p/9295480.html
Copyright © 2011-2022 走看看