Java集合HashMap引发的一系列问题

HashMap

通读了源码之后,收获不小,看多少篇文章不如自己写一篇文章,秉着坚持输出的原则,本文篇幅可能很长O(∩_∩)O,用于复习会更香!我觉得一个新技术会出现,是为了解决一些问题,而学习一们新技术,首先要了解他是干嘛的?怎么用?,然后他的实现原理是什么?最后才是如何实现(源代码),所以有时候直接上源代码并不友好!不用原理在那里将源码的就是耍流氓haha

数据结构中的HashMap是什么样的?它的结构和底层原理?

1、这就要拿jdk1.7和1.8分说了

1.1、在jdk1.7是这样的

HashMap是非常常用的数据结构,由数组和链表组合构成的数据结构,数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。
在这里插入图片描述

我们知道HashMap是键值对的方式存储,在put插入的时候会根据key的hash去计算一个index值, 然后根据hash值插入到数组的具体位置,这也是HashSet无序的原因,具体如下
在这里插入图片描述
put(“b”,28),put(“a”,21),这两个元素时,假如hash(b)=2, 那么在index=2的位置就会添加b这个元素,put元素a的时候同样如此

为何又要引入链表呢?

哈希本身就存在概率性,有可能出现两个key计算出来的hash值是相同的
这就导致了哈希冲突,同一个位置需要插入两个元素
解决办法就是在下方生成一个链表来储存相应的元素

在这里插入图片描述put(“c”,30)
假设c和b和hash值都是2的话,就出现了上述的情况,那么在2的位置会生成一个链表来存储新的元素

其实这里还有一个问题,hashmap是怎么确定b和c不是同一个元素的呢,万一存的是两个b呢?我们可以看出来,可机器只会执行命令!

按理说hash值相同不应该直接就覆盖掉了吗?

其实在hash冲突的时候,要满足在链表上添加新元素,还有一个eques方法来比较key的内容是否一致,key一般都是用String类型的字符串进行存储,而它恰好String重写了这两个方法(hashCode和eques)(字符串“gdejicbegh”与字符串"hgebcijedg"具有相同的hashCode()返回值-801038016,而eques就能比较出来他们是不一样的内容),这种情况下就满足了在链表上添加相应的元素

上述提到元素的存储在Java7叫Entry在Java8中叫Node,那么新的元素是如何添加到链表上的呢?

Java8之后都是尾插法,Java7以及之前都是采用的头插法新来的元素总在最前面,如上述c的插入成功吧b推向链表,链表的形成如下(上述所说的Entry节点记录着每一个元素)

static class Node<K,V> implements Map.Entry<K,V> {
    
    
        final int hash;//根据key计算的hash值
        final K key;//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;
        }

为什么使用头插法?为什么后来又改成了尾插法?

都知道链表查询速度是很慢的,难道是因为新put的元素被get的几率很高所以放前面这样某种意义上来说还提高了查询效率?我是这么想的!

但是说到为什么改成了尾插法,肯定是头插法存在着问题需要解决,这个问题的出现,就牵扯到他的扩容机制了

resize()?1.7如何扩容?

在1.7会创建一个新的Entry数组长度是原来的两倍,先给出源码解释(引用了简书:https://www.jianshu.com/p/08e12481c611的内容)

void transfer (HashMapEntry[]newTable ){
    
    
            //新容量数组桶大小为旧的table的2倍
            int newCapacity = newTable.length;
            // 遍历旧的数组桶table
            for (HashMapEntry<K, V> e : table) {
    
    
                // 如果这个数组位置上有元素且存在哈希冲突的链表结构则继续遍历链表
                while (null != e) {
    
    
                    //取当前数组索引位上单向链表的下一个元素
                    HashMapEntry<K, V> next = e.next;
                    //重新依据hash值计算元素在扩容后数组中的索引位置
                    //(Hash的公式---> index = HashCode(Key) & (Length - 1))因为长度变了
                    //所以需要重新计算
                    int i = indexFor(e.hash, newCapacity);
                    //将数组i的元素赋值给当前链表元素的下一个节点
                    e.next = newTable[i];
                    //将链表元素放入数组位置
                    newTable[i] = e;
                    //将当前数组索引位上单向链表的下一个元素赋值给e进行新的一圈链表遍历
                    e = next;
                }
            }
        }

整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部,也就是发生 Hash 冲突后最后放入的冲突元素)然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标然后进行交换(即原来 hash 冲突的单向链表尾部变成了扩容后单向链表的头部)

在这里插入图片描述

回归正题,这样引出了什么问题呢?

上图可以发现用头插法完成扩容之后,会出现这样一种情况,二上图原链表中,9–>5–>1 而新链表5–>9,但是这时候。9的下一个元素节点指向的还是5,造成了 5–>9–>5–>9–>5–>9…的死循环,这时候如果去get元素,会发生Infinite Loop死循环。。

这就有了1.8的尾插法,由于采用尾插法,新形成的链表和旧的在顺序上至少保证了一致,从而避免了这种情况

接着说jdk1.8的一些升级吧,都知道jdk1.8采用了数组+链表+红黑树的方式实现,具体做了那些升级呢?

1.8除了采用尾插法解决上述的问题之外,加入红黑树,主要就是解决哈希冲突导致链表过长从而引发的效率问题,链表长度越长,遍历就会越慢,当链表长度达到8时,链表就会转换成红黑树也叫平衡二叉树,相比于链表,查询是很快的

问题又来了,Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?

根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。毕竟整个下半部分的结构都是为了解决哈希冲突

接着讨论,HashMap的默认初始化长度为什么是16?不是别的?

只要是2次幂,其实用 8 和 32 都差不多,原因如下:(出处:https://blog.csdn.net/zhucegemingzizheng/article/details/81289479)

虽然我们有链表红黑树等一系列解决方案,但是根本问题还是在于减少哈希冲突才能提高效率,
用2次幂次方作为初始值,目的是为了Hash算法均匀分布的原则尽量减少哈希冲突

我们知道index的计算公式:index = HashCode(Key) & (Length- 1)

1.假定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的线程安全问题了,首先它是线程安全的吗?

它不是线程安全的,首先如果多个线程同时使用put方法添加元素,而且假设正好存在两个put的key发生了碰撞(hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。第二就是如果多个线程同时检测到元素个数超过数组大小*loadFactor,这样就会发生多个线程同时对Node数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失。

那么解决线程安全问题用到哪些方式呢?

三种方式:

//Hashtable
Map<String, String> hashtable = new Hashtable<>();
  
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
  
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

1、HashTable源码中是使用synchronized来保证线程安全的,比如下面的get方法和put方法:

public synchronized V get(Object key) {
    
    
       // 省略实现
    }
public synchronized V put(K key, V value) {
    
    
    // 省略实现
    }

,当一个线程使用put方法时,另一个线程不但不可以使用put方法,连get方法都不可以,效率很低,现在基本不会选择它了!

1.1你还能说出一些Hashtable 跟HashMap不一样点么?

Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。
这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。

实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。
初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。

所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。

fail-fast(快速失败):通过检测modCount是否一致来判断,因为每次对集合的操作都会改变modCount的值(具体可以看ArrayList那篇文章,原理一样)

2、SynchronizedMap
在 SynchronizedMap 类中使用了 synchronized 同步关键字来保证对 Map 的操作是线程安全的。

// synchronizedMap方法
 2 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    
    
 3        return new SynchronizedMap<>(m);
 4    }
 5 // SynchronizedMap类
 6 private static class SynchronizedMap<K,V>
 7        implements Map<K,V>, Serializable {
    
    
 8        private static final long serialVersionUID = 1978198479659022715L;
 9 
10        private final Map<K,V> m;     // Backing Map
11        final Object      mutex;        // Object on which to synchronize
12 
13        SynchronizedMap(Map<K,V> m) {
    
    
14            this.m = Objects.requireNonNull(m);
15            mutex = this;
16        }
17 
18        SynchronizedMap(Map<K,V> m, Object mutex) {
    
    
19            this.m = m;
20            this.mutex = mutex;
21        }
22 
23        public int size() {
    
    
24            synchronized (mutex) {
    
    return m.size();}
25        }
26        public boolean isEmpty() {
    
    
27            synchronized (mutex) {
    
    return m.isEmpty();}
28        }
29        public boolean containsKey(Object key) {
    
    
30            synchronized (mutex) {
    
    return m.containsKey(key);}
31        }
32        public boolean containsValue(Object value) {
    
    
33            synchronized (mutex) {
    
    return m.containsValue(value);}
34        }
35        public V get(Object key) {
    
    
36            synchronized (mutex) {
    
    return m.get(key);}
37        }
38 
39        public V put(K key, V value) {
    
    
40            synchronized (mutex) {
    
    return m.put(key, value);}
41        }
42        public V remove(Object key) {
    
    
43            synchronized (mutex) {
    
    return m.remove(key);}
44        }
45        // 省略其他方法

3、ConcurrentHashMap
具体的实现可以参考这篇博客很详细:地址

也可看下面简单介绍:

相对于前两者,这种方式支持更多的并发,ConcurrentHashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。在8中 CHM 摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用CAS算法。

3.1首先是1.7中是这样的

是由Segment数组、HashEntry组成,和HashMap一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    
        
private static final long serialVersionUID = 2249069246763182397L;    
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶    
transient volatile HashEntry<K,V>[] table;   
 transient int count;        
 // 记得快速失败(fail—fast)么?    
 transient int modCount;        
 // 大小    
 transient int threshold;        
 // 负载因子    
 final float loadFactor;}

HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。

volatile的特性是啥?

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
  • 禁止进行指令重排序。(实现有序性)
  • volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。

concurrentHashMap的核心就是采用了分段锁技术,其中Segment继承于ReentrantLock。
不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。
每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

PUT逻辑:

public V put(K key, V value) {
    
        
    Segment<K,V> s;    
    if (value == null)        
        throw new NullPointerException(); //这就是为啥他不可以put null值的原因
    int hash = hash(key);    
    int j = (hash >>> segmentShift) & segmentMask;    
    if ((s = (Segment<K,V>)UNSAFE.getObject                   
        (segments, (j << SSHIFT) + SBASE)) == null)         
        s = ensureSegment(j);    
    return s.put(key, hash, value, false);
}

他先定位到Segment,然后在进行put操作
我们看看他的put源代码,你就知道他是怎么做到线程安全的了。

 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    
              
        // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry            
        HashEntry<K,V> node = tryLock() ? null :                
                scanAndLockForPut(key, hash, value);            
        V oldValue;            
        try {
    
                    
            HashEntry<K,V>[] tab = table;                
            int index = (tab.length - 1) & hash;                
            HashEntry<K,V> first = entryAt(tab, index);                
            for (HashEntry<K,V> e = first;;) {
    
                        
                if (e != null) {
    
                            
                    K k; 
                    // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。                        
                    if ((k = e.key) == key ||                            
                        (e.hash == hash && key.equals(k))) {
    
                               
                        oldValue = e.value;                           
                        if (!onlyIfAbsent) {
    
                                   
                            e.value = value;                                
                            ++modCount;                            
                        }                            
                        break;                        
                    }                        
                    e = e.next;                    
                }                    
                else {
    
                     
                    // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。                        
                    if (node != null)                            
                        node.setNext(first);                        
                    else                            
                        node = new HashEntry<K,V>(hash, key, value, first);                        
                    int c = count + 1;                        
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)                            
                        rehash(node);                        
                    else                            
                        setEntryAt(tab, index, node);                       
                        ++modCount;                        
                        count = c;                        
                        oldValue = null;                        
                        break;                    
                }                
            }          
        } finally {
    
                   
            //释放锁                
            unlock();            
        }           
        return oldValue;        
    }

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。

尝试自旋获取锁。
如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

GET逻辑:
get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁

3.2而1.8中是这样的

其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

它的值的存取操作么?以及是怎么保证线程安全的?

ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

在这里插入图片描述

这里在学习一下敖丙大神的CAS是什么?

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

这是一种乐观策略,认为并发操作并不总会发生。

CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?

synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。

ConcurrentHashMap的get操作又是怎么样子的呢?

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值
  • 如果是红黑树那就按照树的方式获取值。
  • 就不满足那就按照链表的方式遍历获取值。

在这里插入图片描述

本文章学习了其他博客后所写,对于下文所得概念我并不清楚所以搬了敖丙大神的文章:地址

猜你喜欢

转载自blog.csdn.net/weixin_44078653/article/details/105067235