• [Java]HashMap实现与哈希冲突,与HashTable的区别


    对于 Map ,最直观就是理解就是键值对,映射,key-value 形式。一个映射不能包含重复的键,一个键只能有一个值。平常我们使用的时候,最常用的无非就是 HashMap。

    HashMap 实现了 Map 接口,允许使用 null 值 和 null 键,并且不保证映射顺序。

    HashMap 有两个参数影响性能:

    初始容量:表示哈希表在其容量自动增加之前可以达到多满的一种尺度
    加载因子:当哈希表中的条目超过了容量和加载因子的乘积的时候,就会进行重哈希操作。
    如下成员变量源码:

    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    transient Node<K,V>[] table;
    

    可以看到,默认加载因子为 0.75, 默认容量为 1 << 4,也就是 16。加载因子过高,容易产生哈希冲突,加载因子过小,容易浪费空间,0.75是一种折中。

    另外,整个 HashMap 的实现原理可以简单的理解成:当我们 put 的时候,首先根据 key 算出一个数值 x,然后在 table[x] 中存放我们的值。这样有一个好处是,以后的 get 等操作的时间复杂度直接就是O(1),因为 HashMap 内部就是基于数组的一个实现。

    put 方法的实现 与 哈希冲突

    下面再结合代码重点分析下 HashMap 的 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;
        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);
                        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;
                }
            }
            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;
    }
    

    首先我们看到 hash(key) 这个就是表示要根据 key 值算出一个数值,以此来决定在 table 数组的哪一个位置存放我们的数值。(Ps:这个 hash(key) 方法 也是大有讲究的,会严重影响性能,实现得不好会让 HashMap 的 O(1) 时间复杂度降到 O(n),在JDK8以下的版本中带来灾难性影响。它需要保证得出的数在哈希表中的均匀分布,目的就是要减少哈希冲突)

    重要说明一下:

    **JDK8 中哈希冲突过多,链表会转红黑树,时间复杂度是O(logn),不会是O(n) **
    **JDK8 中哈希冲突过多,链表会转红黑树,时间复杂度是O(logn),不会是O(n) **
    **JDK8 中哈希冲突过多,链表会转红黑树,时间复杂度是O(logn),不会是O(n) **
    然后,我们再看到:

    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        ......
    

    这就表示,如果没有 哈希冲突,那么就可以放入数据 tab[i] = newNode(hash, key, value, null); 如果有哈希冲突,那么就执行 else 需要解决哈希冲突。

    那么放入数据 其实就是 建立一个 Node 节点,该 Node节点有属性 key,value,分别保存我们的 key 值 和 value 值,然后再把这个 Node 节点放入到 table 数组中,并没有什么神秘的地方。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        
    }
    

    上述可以看到 Node 节点中 有一个 Node<K,V> next; ,其实仔细思考下就应该知道这个是用来解决哈希冲突的。下面再看看是如何解决哈希冲突的:

    哈希冲突:通俗的讲就是首先我们进行一次 put 操作,算出了我们要在 table 数组的 x 位置放入这个值。那么下次再进行一个 put 操作的时候,又算出了我们要在 table 数组的 x 位置放入这个值,那之前已经放入过值了,那现在怎么处理呢?

    其实就是通过链表法进行解决。

    首先,如果有哈希冲突,那么:

    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;
    

    需要判断 两者的 key 是否一样的,因为 HashMap 不能加入重复的键。如果一样,那么就覆盖,如果不一样,那么就先判断是不是 TreeNode 类型的:

     else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    

    这里表示 是不是现在已经转红黑树了(在大量哈希冲突的情况下,链表会转红黑树),一般我们小数据的情况下,是不会转的,所以这里暂时不考虑这种情况(Ps:本人也没太深入研究红黑树,所以就不说这个了)。

    如果是正常情况下,会执行下面的语句来解决哈希冲突:

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

    这里其实就是用链表法来解决。并且:

    冲突的节点放在链表的最下面。
    冲突的节点放在链表的最下面。
    冲突的节点放在链表的最下面。

    因为 首先有:p = tab[i = (n - 1) & hash] ,再 for 循环,然后有 if ((e = p.next) == null) { ,并且如果 当前节点的下一个节点有值的话,那么就 p = e;,这就说明了放在最下面。

    强烈建议自己拿笔拿纸画画。

    总结

    一个映射不能包含重复的键,一个键只能有一个值。允许使用 null 值 和 null 键,并且不保证映射顺序。
    HashMap 解决冲突的办法先是使用链表法,然后如果哈希冲突过多,那么会把链表转换成红黑树,以此来保证效率。
    如果出现了哈希冲突,那么新加入的节点放在链表的最后面。
    参考

    强烈建议看一下:

    Java HashMap工作原理及实现
    Java 8:HashMap的性能提升

    HashTable

    HashTable 是 HashMap 的线程安全版本。 内部的实现几乎和 HashMap 一模一样。例如:

    同样的有一个数组:

    private transient Entry<?,?>[] table;
    

    对于 put 方法:

    public synchronized V put(K key, V value) {
        ......
    
        // 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;
    }
    

    这里可以看到, for 循环表示如果出现了哈希冲突,那么就放在最后一位。因为不断的进行 entry = entry.next,直到 entry != null。需要注意的是,JDK8 中的 HashMap 如果有很多哈希冲突的话,那么是可能会把链表变成红黑树以此来提高效率。但是这里 HashTable 并没有这样做。

    另外,从这里也可以看出,HashTable 实现多线程同步的主要方式是通过加 synchronized 关键字。

    另外,对于 get 方法:

    @SuppressWarnings("unchecked")
    public synchronized V get(Object key) {
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
            if ((e.hash == hash) && e.key.equals(key)) {
                return (V)e.value;
            }
        }
        return null;
    }
    

    这里最明显的就是 synchronized,其实还有很多其他的方法用的也是 synchronized。get 方法的处理也是先根据 key 定位到 table 的某一个位置,最后再 for 循环拿到该值(因为可能出现了哈希冲突,所以要 for 循环)。

    总结

    • Hashtable的方法是同步的,HashMap则是非同步的,所以在多线程场合要手动同步HashMap,这个区别就像Vector和ArrayList一样。
    • Hashtable不允许null值(key和value都不可以),HashMap允许null值(key和value都可以)。
    • Hashtable比HashMap多一个elements方法用于遍历。
    • Hashtable使用Enumeration,HashMap使用Iterator。
    • 哈希值的使用不同,Hashtable直接使用对象的hashCode,而HashMap重新计算hash值,而且用与代替求模。
    • Hashtable中hash数组默认大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数。
  • 相关阅读:
    Android插件化技术简介
    Fragment回退栈&commit()和commitAllowingStateLoss()
    Android热更新技术——Tinker、nuwa、AndFix、Dexposed
    Object的wait/notify/notifyAll&&Thread的sleep/yield/join/holdsLock
    synchronized与Lock的区别与使用
    多分类Logistics回归公式的梯度上升推导&极大似然证明sigmoid函数的由来
    从损失函数的角度详解常见机器学习算法
    LR的损失函数&为何使用-log损失函数而非平方损失函数
    css Cascading Style Sheet 层叠样式表
    前端—— 前端介绍开始(二)
  • 原文地址:https://www.cnblogs.com/zhousysu/p/5483932.html
Copyright © 2020-2023  润新知