哈希表

178 阅读5分钟

3,散列表

Hash,一般翻译做散列、杂凑,或称哈希,是把任意长度的输入,通过散列算法变换成固定长度的输出,该输出就是散列值。
--扩展:MD5,SHA,CRC都是采用的散列算法。

散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。
可以说,如果没有数组,就没有散列表。

散列函数,我们可以把它定义成 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。


HashTable extends Dictionary implements Map<K,V>, Cloneable
    transient Entry<?,?>[] table; //保存节点的数组
    transient int count; //总条数
    int threshold;  //hashTable进行扩容的阈值
    float loadFactor; //用于计算阈值的加载因子,默认为0.75
    transient int modCount = 0; //进行破坏结构的修改次数,与遍历时的快速失败有关
    static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; //最大容量,2的31次方 - 9
    
    private static class Entry<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Entry<K,V> next;
        
        //构造函数
        protected Entry(int hash, K key, V value, Entry<K,V> next) {
            this.hash = hash;
            this.key =  key;
            this.value = value;
            this.next = next;
        }
    }
    
}

HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>,Cloneable{
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化容量16
    static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子是0.75f
    static final int TREEIFY_THRESHOLD = 8; //链表转成树的阈值
    static final int UNTREEIFY_THRESHOLD = 6; //树转换成链表的阈值
    static final int MIN_TREEIFY_CAPACITY = 64; //转换成树的最小容量阈值
    
    transient Node<K,V>[] table; //保存节点的数组
    transient Set<Map.Entry<K,V>> entrySet; //保存的所有节点
    transient int size; //保存的节点个数
    transient int modCount; //修改次数,用于迭代器的快速失败
    int threshold; //扩容的阈值
    final float loadFactor; //加载因子
    
    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;
        }
    }
    
}

哈希(或散列)冲突: 为什么会出现哈希冲突? 哈希算法产生的哈希值的长度是固定且有限的。比如前面举的 MD5 的例子,哈希值是固定的 128 位二进制串,能表示的数据是有限的,最多能表示 2^128 个数据,而我们要哈希的数据是无穷的。

解决方法:

  1. 开放寻址法
    -- 例:ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突
  • 优点:开放寻址法的话,散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。
  • 缺点:用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。
  • 总结:当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
  1. 链表法
    --例:LinkedHashMap采用链表法解决冲突
  • 优点:链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。对大装载因子的容忍度更高。
  • 缺点:链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。
  • 总结:基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
  1. 关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。
  2. 关于散列冲突解决方法的选择,我对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下,链表法更加普适。而且,我们还可以通过将链表法中的链表改造成其他动态查找数据结构,比如红黑树,来避免散列表时间复杂度退化成 O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。

HashMap 也是散列表。

  1. 初始大小HashMap 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。
  2. 装载因子和动态扩容最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
  3. 散列冲突解决方法HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

HashTable源码分析: mp.weixin.qq.com/s/wCaoQUKcB…

HashMap源码分析: mp.weixin.qq.com/s/uw5fbEQt0…

HashTable和HashMap的区别: mp.weixin.qq.com/s/ZPp_Tmz0U…