HashMap,ConcurrentHashMap常见问题

57 篇文章 4 订阅

原理

众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。
HashMap数组每一个元素的初始值都是Null。
在这里插入图片描述
对于HashMap,我们最常使用的是两个方法:Get 和 Put。

Put方法的原理

调用Put方法的时候发生了什么呢?
比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):
index = Hash(“apple”)
假定最后计算出的index是2,那么结果如下:
在这里插入图片描述
但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:
在这里插入图片描述
这时候该怎么办呢?我们可以利用链表来解决
HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:
在这里插入图片描述
需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。至于为什么不插入链表尾部,后面会有解释。

Get方法的原理

使用Get方法根据Key来查找Value的时候,发生了什么呢?
首先会把输入的Key做一次Hash映射,得到对应的index:
index = Hash(“apple”)
由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:
在这里插入图片描述
第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。
第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。
之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。

HashMap默认初始长度

初始长度是 16,每次扩展或者是手动初始化,长度必须是 2的幂
因为: index = HashCode(Key) & (length - 1), 如果 length是 2的 幂的话,则 length - 1就是 全是 1的二进制数,比如 16 - 1 = 1111,这样相当于是 坐落在长度为 length的hashMap上的位置只和 HashCode的后四位有关,这只要给出的HashCode算法本身分布均匀,算出的index就是分布均匀的。
因为HashMap的key是int类型,所以最大值是2^31 次方,但是查看源码,当到达 2^30次方,即 MAXIMUM_CAPACITY,之后,便不再进行扩容。

例子

下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

假设HashMap的长度是10,重复刚才的运算步骤
在这里插入图片描述
单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011 :
在这里插入图片描述
让我们再换一个HashCode 101110001110101110 1111 试试 :
在这里插入图片描述
是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则
反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

并发情况下死锁

我们看到默认HashMap的初始长度是16,比较小,每一次push的时候,都会检查当前容量是否超过 预定的 threshold,如果超过,扩大HashMap容量一倍,整个表里的所有元素都需要按照新的hash算法被算一遍,这个代价较大。提到死锁,对于HashMap来说,貌似只能和链表操作有关。

正常ReHash过程,可以看到,每个元素重新算hash值,将链表翻转(目的遍历每个bucket上的链表还是用的是头插法,时间复杂度最低),放到对应的bucket上的链表中
在这里插入图片描述
并发时候的reHash过程

while(null != e) {
                Entry<K,V> next = e.next; //线程1还没有执行这句 中断了
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }

在这里插入图片描述
过程:

(1)线程1,先被中断,线程2执行reHash过程
(2)线程2将原表 bucket 1 处的链表分发到 新表 bucket 1 和 bucket 3 上(hash值的后2位,第一位不同,则不是01就是11),分散到 bucket 3上的值有两个, key(3), key(7),遍历原表Bucket 1 上的 链表,采用头插法,结果就是 链表反转且还属于新表此bucket的元素放到 此bucket上。此时 key(7) -> key(3) -> null
(3)此时线程 2 被中断,线程 1调度。 此时线程 1 中 e 是 key(3)-> null

a. 执行 next = e.next , 得到 next = null
b. e.next = newTable[i], e.next = key(7)->key(3)->null,所以 e是 key(3)->key(7)->key(3)
c. newTable[i] = e; 此时newTable[i] 就是一个 循环链表。
d. e = next, e是null,跳出循环

这样等到get方法到对应的链表上取数据时,就会发生死循环。

如何解决

给整个集合加锁,使得同一时间其他线程为之阻塞。
在这里插入图片描述

ConcurrentHashMap

同时兼顾线程安全和运行效率
Segment是什么呢?Segment本身就相当于一个HashMap对象。
同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
在这里插入图片描述
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。
因此整个ConcurrentHashMap的结构如下:
在这里插入图片描述
可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
这样的二级结构,和数据库的水平拆分有些相似。
ConcurrentHashMap优势就是采用了锁分段技术,每一个Segment就好比一个自治区,读写操作高度自治,Segment之间互不影响

Case1:不同Segment的并发写入

在这里插入图片描述
不同Segment的写入是可以并发执行的。

Case2:同一Segment的一写一读

在这里插入图片描述
同一Segment的写和读是可以并发执行的。

Case3:同一Segment的并发写入

在这里插入图片描述
Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。

Get方法

1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.再次通过hash值,定位到Segment当中数组的具体位置。

Put方法

1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过hash值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁。

Segment各自加锁,调用Size方法如何解决一致性问题

Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来。
但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
1.遍历所有的Segment。
2.把Segment的元素数量累加起来。
3.把Segment的修改次数累加起来。
4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7.释放锁,统计结束。

java8对hashMap做了什么优化(数组+链表+红黑树)

简单说: java7中 hashMap每个桶中放置的是链表,这样当hash碰撞严重时,会导致个别位置链表长度过长,从而影响性能。
java8中,HashMap 每个桶中当链表长度超过8之后,会将链表转换成红黑树,从而提升增删改查的速度。
在这里插入图片描述

putVal方法

在这里插入图片描述

hashMap/hashSet 添加对象为啥重写hashCode和equals

因为,equals()方法只比较两个对象是否相同,相当于==,而不同的对象hashCode()肯定是不同,所以如果我们不是看对象,而只看对象的属性,则要重写这两个方法,如Integer和String他们的equals()方法都是重写过了,都只是比较对象里的内容。
使用HashMap,如果key是自定义的类,就必须重写hashcode()和equals()
一般先写hashCode再写equals。因为它返回的是对象的哈希值,那么不同的new出不同的对象,他们虽然名字一样但是哈希码可能会不一样。

例子

class Person {  
    public Person(String name, int age) {  
        this.name = name;  
        this.age = age;  
    }  
    private String name;  
    private int age;  
  
    public String getName() {  
        return name;  
    }  
    public void setName(String name) {  
        this.name = name;  
    }  
    public int getAge() {  
        return age;  
    }  
    public void setAge(int age) {  
        this.age = age;  
    }  
    public String toString() {  
        return "{" + name + ", " + age + "}";  
    }  
}  

 public class HashSetDemo {  
    public static void main(String[] args) {  
        Collection set = new HashSet();  
        set.add(new Person("张三", 21));  
        set.add(new Person("李四", 19));  
        set.add(new Person("王五", 22));  
        set.add(new Person("张三", 21));  
       sop(set);  
   }  
   private static void sop(Collection set) {  
       Iterator it = set.iterator();  
       while (it.hasNext()) {  
           Person p = it.next();  
           System.out.println(p.toString());  
       }  
   }  

}  

在存储的时候,我故意存了两个“21岁的张三”,我的本意是这是同一个人,也就是说set集合里面只需要出现一个“21岁的张三”,可事实是
在这里插入图片描述
其实,在往HashSet集合放置元素时,会根据其hashCode来判断两个元素是否一样,如果是一样,这后者覆盖前者。而hashCode默认是比较其内存地址值。于是,对于两个new 出来的“21岁的张三”,其地址值不一样,所以HashSet才将两个均加入其中。(实际上,hashcode是根据对象的内存地址经哈希算法得来的。)

 @Override
   public int hashCode() {
        return name.hashCode() + age * 10;
    }
  @Override
  public  boolean equals(Object obj)  {
        if  (!(obj  instanceof  Person))
            throw  new  ClassCastException("类型不匹配");
        Person  p   =   (Person)    obj;
        return  this.name.equals(p.getName()) && this.age == p.getAge();
    }

此时,再运行重写,结果如下:
在这里插入图片描述
总结: 一般对于存放到Set集合或者Map中键值对的元素,需要按需要重写hashCode与equals方法,以保证唯一性!

如果你重载了equals,比如说是基于对象的内容实现的,而保留hashCode的实现不变,那么很可能某两个对象明明是“相等”,而hashCode却不一样。
这样,当你用其中的一个作为键保存到hashMap、hasoTable或hashSet中,再以“相等的”找另一个作为键值去查找他们的时候,则根本找不到(因为找的过程中是通过key值对应的hash值去寻找的)。

对于每一个对象,通过其hashCode()方法可为其生成一个整形值(散列码),该整型值被处理后,将会作为数组下标,存放该对象所对应的Entry(存放该对象及其对应值)。
equals()方法则是在HashMap中插入值或查询时会使用到。当HashMap中插入值或查询值对应的散列码与数组中的散列码相等时,则会通过equals方法比较key值是否相等,
所以想以自建对象作为HashMap的key,必须重写该对象继承object的hashCode和equals方法。

小结

  1. 在一个应用程序执行期间,如果一个对象的equals方法做比较所用到的信息没有被修改的话,那么,对该对象调用hashCode方法多次,它必须始终如一地返回 同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不同,即这个应用程序这次执行返回的整数与下一次执行返回的整数可以不一致。
  2. 如果两个对象根据equals(Object)方法是相等的,那么调用这两个对象中任一个对象的hashCode方法必须产生同样的整数结果
  3. 如果两个对象根据equals(Object)方法是不相等的,那么调用这两个对象中任一个对象的hashCode方法,不要求必须产生不同的整数结果。然而,程序员应该意识到这样的事实,对于不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。

参考
https://mp.weixin.qq.com/s/usbtl6mDBuv_ayzK16FHfQ(什么是HashMap)
http://www.mamicode.com/info-detail-2120749.html(什么是hashMap,初始长度,高并发死锁,java8 hashMap做的性能提升)
http://www.cnblogs.com/xiaoxi/p/7233201.html(JDK1.8 HashMap源码分析)
https://yikun.github.io/2015/04/01/Java-HashMap%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%8F%8A%E5%AE%9E%E7%8E%B0/
http://www.sohu.com/a/205451532_684445(什么是ConcurrentHashMap)
http://www.cnblogs.com/xiaoxi/p/6170590.html(Java集合之LinkedHashMap)

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值