面试必问:HashMap,HashTable,ConcurrentHashMap以及Hash冲突的解决

HashMap,HashTable,ConcurrentHashMap以及Hash冲突的解决

HashMap

要点:

  • HashMap继承自AbstractMap类
  • 基于哈希表实现的
  • 实现了Map接口
  • 实现了Serializable,因此可以被序列化
  • 实现了Cloneable接口,因此可以被克隆
  • 非线程安全的,多线程环境下可以采用concurrent并发包下的concurrentHashMap。
  • key和value可以为null,key为null的元素会被存储在table[0]中
  • 内部Entry数组的容量默认为16,负载因子默认为0.75

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元。Entry是HashMap的一个内部类,实现了Map.Entry接口。
Entry有以下几个重要的属性:

  • key:就是存储的key
  • value:存储的value
  • hash:对key的hashcode值进行hash运算后得到的值
  • next:指向下一个Entry节点

简单来说,HashMap就是由数组和链表组成的,引用一张图片来说就是这样:
面试必问:HashMap,HashTable,ConcurrentHashMap以及Hash冲突的解决_第1张图片

数组是Map的核心主体,而链表的存在只是为了解决Hash冲突。
当我们进行插入操作的时候,如果目标数组中不含链表,即该数组元素为null或者数组中Entry的next指向了null,则进行查找或者插入的操作的事件复杂度就为O(1)。
如果目标数组中包含链表,则在进行插入操作的时候,需要遍历链表。如果该元素存在,就会重新覆盖。如果不存在,则会在尾部新增。进行查询操作的时候,同样需要遍历链表,然后通过key的equals方法来判断是不是我们要找的元素。所以当数组中链表越少,则HaashMap的效率越高。

HashMap的几个重要属性:

transient Node<K,V>[] table //实际上存储key-value的数组,被封装成了Node
transient int size // table的大小
final float loadFactor //负载因子,代表了hashmap被填充的程度。默认为0.75,超过0.75之后会进行扩容操作。可以通过构造函数指定,减缓了哈希冲突的发生。

int threshold//存储阈值,未被初始化的时候,默认为16.被初始化后,值为初始容量*loadFactor
transient int modCount;//hashmap被改变的次数。如果在hashmap迭代的过程中其他线程对hashmap进行操作了,则会抛出异常。

HahMap有4个构造函数,分别是无参构造函数,含有1个参数的构造函数,含有两个参数的构造函数,用map去初始化的构造函数。
其中,前两个内部都调用了了第三个构造函数。我们直接分析第三个:

//构造一个空的初始容量为initialCapacity,负载因子为loadFactor的HashMap
    public HashMap(int initialCapacity, float loadFactor) {
    //如果初始容量小于0,抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
                                               
     //如果初始容量大于最大容量,将最大容量复制给初始的容量
        if (initialCapacity >MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
            
     //如果负载因子小于0,或者负载因子不是一个数字,抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
         //赋值                                      
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

    //最大容量
    //static final int MAXIMUM_CAPACITY = 1 <<30;

看到这里我们有一个疑惑,我们将传入的参数赋值给了类变量,但在构造函数中并没有对我们的存储结构进行改动啊,Node数组没有变化啊。其实真正初始化table 这个变量的操作在put()方法中。
.我们来看put()方法:


//实现put和相关方法。
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果table为空或者长度为0,则resize()
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //确定插入table的位置,算法是(n - 1)& hash,在n为2的幂时,相当于取摸操作。
        ////找到key值对应的槽并且是第一个,直接加入
        
        if ((p = tab[i = (n - 1) &hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //在table的i位置发生碰撞,有两种情况:
        //1.key值是一样的,替换value值,
        //2.key值不一样的有两种处理方式:2.1.存储在i位置的链表;2.2.存储在红黑树中
        else {
            Node<K,V> e; K k;
            //第一个node的hash值即为要加入元素的hash
            
            if (p.hash == hash&&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //2.2
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //2.1
            
            else{
                //不是TreeNode,即为链表,遍历链表
                for (int binCount = 0; ; ++binCount) {
                ///链表的尾端也没有找到key值相同的节点,则生成一个新的Node,
                //并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树。
                    if ((e = p.next) == null) {
                         // 创建链表节点并插入尾部
                        p.next = newNode(hash, key, value, null);
                        ////超过了链表的设置长度8就转换成红黑树
                        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;
                }
            }
            //如果e不为空就替换旧的oldValue值
            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 &gt; threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

为主干数组table在内存中分配存储空间的时候,会选取2的整数次幂,原因是:

在实际存储地址的运算方式为:
在这里插入图片描述

  • 当length为2的整数次幂的时候,可以保证length-1的低位全部为0,这样在做位运算的时候,j减少了碰撞的几率,可以保证存储下标分布比较均匀。

HashMap中插入数据的流程图如下(图片参考自:HashMap):
面试必问:HashMap,HashTable,ConcurrentHashMap以及Hash冲突的解决_第2张图片

HashTable

要点:

  • HashTable是基于哈希表实现的
  • 线程安全的,HashTable中方法是synchronize的
  • HashMap继承自Dictionary类,实现了Map接口
  • 实现了Serializable接口,支持序列化,实现了Cloneable接口,能被克隆。
  • HashTable 默认容量为11

由于HashTable中很多操作时与HashMap类似的,所以我们对比HashMap来看HashTable

1. 线程安全性: 我们看同样功能的方法,

// 判断Hashtable是否包含key      
 public synchronized boolean containsKey(Object key)
// HashMap是否包含key      
    public boolean containsKey(Object key)

我们可以看出,hashtable比hashmap多了synchronized 锁来保证线程安全

2.插入元素能否为空:
在上方我们提到,hashmap中是可以插入 这样的键值对的,而且这样的键值对会被放在Entry数组的0号位。而hashtable不可以,在源码中我们可以看到:
面试必问:HashMap,HashTable,ConcurrentHashMap以及Hash冲突的解决_第3张图片
3.遍历方式:
hashmap和hashtable都是用了Iterator,但除此以外hashtable还使用了Enumeration

4.索引的计算方式不同:
hashtable直接使用了key的hashcode()获取的值
hashmap先获取l了key的hashcode()值,叫做hash,然后再通过一个hash()方法得到了一个h,然后将h与length-1做了位运算。得到了索引的值。

5.内部数组的扩容方式不同
hashtable的默认容量为11,扩容方式为size = size2+1,且不要求数组容量为2的n次幂
hashmap的默认容量为16,扩容方式为size = size
2,求数组容量为2的n次幂,

ConcurrentHashMap(1.7)

ConcurrentHashMap使用了一种与hashmap类似的结构,但却也不太相同.
核心属性:

  • Segment[] segments
  • Set keySet
  • Set> entrySet

其中Segment是ConcurrentHashMap的一个内部类,它的属性中有一个核心的真正填充数据的HashEntry[] table 变量,这个变量被volatile 关键字修饰。而HashEntry则和hashmap中的Entry属性很相近。

与hashtable不同的是,hashtable实现线程安全是一个全局的锁,线程A去put数据的时候,线程B去get数据会被阻塞。而ConcurrentHashMap采用了分段的可重入锁(ReentrantLock),根据key获取到segment的位置之后,然后通过key的hashcode来定位到HashEntry,然后开始尝试获取锁(自旋),然后后面的过程与hashmap类似,在完成放入元素的操作之后会释放锁。不同的Segment之间的锁不会互相影响。相比hashtable提高了效率。

ConcurrentHashMap(1.8)

在JDK1.8之后, ConcurrentHashMap的结构就与hashmap一致了,都是数组+链表+红黑树的方式。
而且放弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。
将原来的HashEntry改为了Node。

ConcurrentHashMap的put方法经历了以下的判断:

  1. 判断Node数组是否为空,为空则开始初始化
  2. 通过key计算得出要插入的位置,判断是否为空,为空则创建新的节点
  3. 判断当前是否处于扩容状态((fh = f.hash) == MOVED),则参与扩容操作,并返回扩容之后的Node数组
  4. 如果以上都不是,则开始获取要添加的Node对象的锁。
  5. 如果是链表的情况的话,判断是否key值相等,相等的话就覆盖,不等的话就插入新的节点
  6. 如果是红黑树的情况的话,直接向红黑树内添加元素
  7. 判断容量是否超过阈值,超过的话就开始转换为红黑树

ConcurrentHashMap的get()方法也可以支持并发。也1.7中一致,操作顺序如下:

  1. 计算key的hash值,先在Node数组中查找,找到了就直接返回
  2. 如果存储结构是红黑树,在红黑树中查找,找到了就返回
  3. 在链表中寻找,找到了就返回。

Hash冲突的几种解决方法

一.开放地址法

使用开放地址法进行建立散列表时,建表前须将表中所有单元中存储的数据置空

1.线性探测法
如果当前hash值发生冲突,就在此hash值的基础上加一个单位,直到不发生hash冲突。
基本思想:假设散列表 T[0,m-1],从初始地址D开始探查,则最长的探查序列为:
D,D+1,D+2,…,m-1,0,1,2,…,D-1
探查结束的三种情况:

  1. 当前探查的单元为空,则代表查找失败,如果是插入的话则将key值写入

  2. 当前探查的单元有key值,则代表插入失败,但是查找成功。

  3. 若探查到T[D-1]时,未发现空单元,也未找到key值,则表示查找失败并且插入失败(此时表满)

    缺点:

    1. 需要另外的程序来处理溢出的情况,一般存储在一个顺序表中。
    2. 删除数据时,不能直接置为空,而是需要添加一个被删除的标记。否则会对以后的查找产生印象(情况1)
    3. 容易产生堆聚现象

2. 再平方探测:如果当前hash值发生冲突,就在此hash值的基础上加一个单位的平方,如果还是冲突,就在原来的hash值减去一个单位的平方。如果还是冲突就操作两个单位的平方,三个单位的平方等。

3. 伪随机探测:如果当前hash值发生冲突。就用随机函数生成一个随机值,加在原来的hash值上,直到不发生hash冲突。

二.拉链法

通过计算出来的hash值,如果发生冲突,则用链表或者红黑树进行存储
对于相同的hash值,使用链表进行连接,使用数组进行存储链表。这里找到了一个能动画演示的效果:
拉链法动画演示

  • 在进行插入操作的时候,会先查找所插入的元素是否已经存在,查找次数小于等于装载因子的大小(装填因子a = m/n. m表示要装填的关键字个数 n为:装载链表的数组的大小,一般情况下m>n),所以时间复杂度为O(1)。
  • 在进行查询操作的时候,和上边的类似,时间复杂度为O(1)。
  • 在进行删除操作的时候,由于链表的结构,所以时间复杂度也是O(1)
  • 在取出元素的时候,如果hash值相同,会在该链表中通过equals()方法进行比对

与开放地址法相比的优点:

  • 处理较为简单,且无堆积现象,查找时间较短
  • 链表的长度是动态的,适合数据规模不清楚的情况
  • 开放地址法为例减少hash冲突,会使得装载因子变小,及增大散列表的规模。这种方式在数据规模较大的时候会浪费很多空间。而使用拉链法则可以使装载因子>1,节省了空间开销。
  • 删除节点的效率较高

缺点:
指针需要额外的空间,所以当数据规模较小的时候,可以选择开放地址法。将节省的指针控件可以用来增加散列表的规模,是的hash冲突减少

三.再哈希法

对于冲突的哈希值再次进行通过哈希函数进行运算,直到没有哈希冲突

四.建立公共溢出区

建立公共溢出区存储所有哈希冲突的数据

总结:

  1. hashmap(1.7)是数组+链表
  2. hashmap(1.8)是数组+链表+红黑树
  3. hashmap扩容为2的n次幂。便于存储的时候,元素均匀分布
  4. hashmap线程不安全,并发扩容可能会制造出环形链表。如果想要并发操作可以使用:Collections.synchronizedMap(HashMap hashmap)。
  5. hashmap与hashtable的主要区别在于线程安全性
  6. concurrenthashma(1.7)是Segment[]+HashEntry[]+HashEntry
  7. concurrenthashma(1.8)是Node数组+链表+红黑树
  8. concurrenthashma扩容操作:1.创建一个size*2大小的table,然后将旧的table的数据复制过去
  9. concurrenthashma支持并发插入数据,但扩容和转化为红黑树的过程也是加锁的
  10. concurrenthashmap的缺点就是读取数据可能不及时,它只能读取已经写入的,不能读取到正在写入的(hashtable可以)

先就写这些,以后再慢慢查漏补缺

你可能感兴趣的:(深入学习JAVA)