【MAP】 HashMap ConcurrentHashMap all-in-one

0 文章结构

  •    HashMap 1.7 vs 1.8 
  •    ConcurrentHashMap 1.7 vs 1.8

1 HashMap 1.7 1.8(数组+链表OR红黑树)

HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快 的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记 录的值为 null。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导 致数据的不一致。 如果需要满足线程安全, 可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力, 或者使用 ConcurrentHashMap。

【MAP】 HashMap ConcurrentHashMap all-in-one_第1张图片

大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色 的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。

  •  capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
  •  loadFactor:负载因子,默认为 0.75。
  •  threshold:扩容的阈值,等于 capacity * loadFactor

Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑 树 组成。

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的 具体下标,但是之后的话, 需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决 于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后, 会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)

 

【MAP】 HashMap ConcurrentHashMap all-in-one_第2张图片

 

2  ConcurrentHashMap 1.7 1.8

1.7 Segment 段

ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一 些。整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的 意思,所以很多地方都会将其描述为分段锁。

 1.7 线程安全(Segment 继承 ReentrantLock 加锁)

简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每 个 Segment 是线程安全的,也就实现了全局的线程安全。

 

【MAP】 HashMap ConcurrentHashMap all-in-one_第3张图片

  • concurrencyLevel:并行级别、并发数、Segment 数。默认是 16, 也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支 持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。
  • 这个值可以在初始化的时 候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
  • 具体到每个 Segment 内部,其实 每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些.

Java1.8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树。

【MAP】 HashMap ConcurrentHashMap all-in-one_第4张图片

3  ConcurrentHashMap  1.8 细节面试知识点1(总体变化?)

  • ConcurrentHashMap:检索操作(包括get)通常不会阻塞,因此可能与更新操作(包括put和remove)重叠,ConcurrentHashMap跟Hashtable类似但不同于HashMap,它不可以存放空值,key和value都不可以为null。
  • ConcurrentHashMap从JDK1.5开始随java.util.concurrent包一起引入JDK中,在JDK8以前,ConcurrentHashMap都是基于Segment分段锁来实现的,在JDK8以后,就换成synchronized和CAS这套实现机制了。JDK1.8中的ConcurrentHashMap中仍然存在Segment这个类,而这个类的声明则是为了兼容之前的版本序列化而存在的。
  • JDK1.8中的ConcurrentHashMap不再使用Segment分段锁,而是以table数组的头结点作为synchronized的锁。和JDK1.8中的HashMap类似,对于hashCode相同的时候,在Node节点的数量少于8个时,这时的Node存储结构是链表形式,时间复杂度为O(N),当Node节点的个数超过8个时,则会转换为红黑树,此时访问的时间复杂度为O(long(N))

4  ConcurrentHashMap  1.8 细节面试知识点2(可见性如何实现?)

  1. 使用volatile保证当Node中的值变化时对于其他线程是可见的
  2. 【Node中的val和next都被volatile关键字修饰。我们改动val的值或者next的值对于其他线程是可见的,因为volatile关键字,会在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。】
  3. 【ConcurrentHashMap提供类似tabAt来读取Table数组中的元素,这里是以volatile读的方式读取table数组中的元素,主要通过Unsafe这个类来实现的,保证其他线程改变了这个数组中的值的情况下,在当前线程get的时候能拿到。】
  4. static final Node tabAt(Node[] tab, int i) { return (Node)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
  5. 【而与之对应的,是setTabAt,这里是以volatile写的方式往数组写入元素,这样能保证修改后能对其他线程可见。】

  6.  static final void setTabAt(Node[] tab, int i, Node v) {U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);}

5  ConcurrentHashMap  1.8 细节面试知识点3(安全如何实现?)

  1. 使用table数组的头结点作为synchronized的锁来保证写操作的安全
  2. 【当头结点不为null时,则使用该头结点加锁,这样就能多线程去put hashCode相同的时候不会出现数据丢失的问题。synchronized是互斥锁,有且只有一个线程能够拿到这个锁,从而保证了put操作是线程安全的。】
  3. 当头结点为null时,使用CAS操作来保证数据能正确的写入。

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node[] tab = table;;) {
            Node f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null, // cas insert head
                             new Node(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node p;
                            binCount = 2;
                            if ((p = ((TreeBin)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

    static final  boolean casTabAt(Node[] tab, int i,
                                        Node c, Node v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
  • 【所谓的CAS,即compareAndSwap,执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。】
  • asTabAt同样是通过调用Unsafe类来实现的,调用Unsafe的compareAndSwapObject来实现,其实如果仔细去追踪这条线路,会发现其实最终调用的是cmpxchg这个CPU指令来实现的,这是一个CPU的原子指令,能保证数据的一致性问题。
  • 【MAP】 HashMap ConcurrentHashMap all-in-one_第5张图片

6  ConcurrentHashMap  1.8 细节面试知识点4(为啥?快了? 哪里快?)

  1. 旧版本的一个segment锁,保护了多个hash桶,而jdk8版本的一个锁只保护一个hash桶,由于锁的粒度变小了,写操作的并发性得到了极大的提升。
  2. 【更多的扩容线程】扩容时,需要锁的保护。因此:旧版本最多可以同时扩容的线程数是segment锁的个数。而jdk8的版本,理论上最多可以同时扩容的线程数是:hash桶的个数(table数组的长度)。但是为了防止扩容线程过多ConcurrentHashMap规定了扩容线程每次最少迁移16个hash桶因此jdk8的版本实际上最多可以同时扩容的线程数是:hash桶的个数/16
  3. 【扩容期间,依然保证较高的并发度】旧版本的segment锁,锁定范围太大,导致扩容期间,写并发度,严重下降。而新版本的采用更加细粒度的hash桶级别锁,扩容期间,依然可以保证写操作的并发度。
  4. 【ConcurrentHashMap的重要结构与方法】ConcurrentHashMap内部,和hashmap一样,维护了一个table数组,数组元素是Node链表或者红黑树.
  5. 【关于table数组,有3个重要方法】
  • 以volatile读的方式读取table数组中的元素   Node tabAt(Node[] tab, int i) U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE)

  • 以volatile写的方式,将元素插入table数组   setTabAt(Node[] tab, int i, Node v)  U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);

  • 以CAS的方式,将元素插入table数组  casTabAt(Node[] tab, int i,Node c, Node v) U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);

【MAP】 HashMap ConcurrentHashMap all-in-one_第6张图片

7  ConcurrentHashMap  1.8 细节面试知识点5(源码解读)

  1. 我们看下ConcurrentHashMap的put方法是如何通过CAS确保线程安全的:假设此时有2个put线程,都发现此时桶为空,线程一执行casTabAt(tab,i,null,node1),此时tab[i]等于预期值null,因此会插入node1。随后线程二执行casTabAt(tba,i,null,node2),此时tab[i]不等于预期值null,插入失败。然后线程二会回到for循环开始处,重新获取tab[i]作为预期值,重复上述逻辑。
  2. get方法,get方法同样利用了volatile特性,实现了无锁读。

    查找value的过程如下:

    1. 根据key定位hash桶,通过tabAt的volatile读,获取hash桶的头结点。

    2. 通过头结点Node的volatile属性next,遍历Node链表

    3. 找到目标node后,读取Node的volatile属性val

    可见上述3个操作都是volatile读,因此可以做到在不加锁的情况下,保证value的内存可见性

  3. put方法,由于锁的粒度是hash桶,多个put线程只有在请求同一个hash桶时,才会被阻塞。请求不同hash桶的put线程,可以并发执行。put线程,请求的hash桶为空时,采用for循环+CAS的方式无锁插入。

  4. remove方法,如图所示:删除的node节点的next依然指着下一个元素。此时若有一个遍历线程正在遍历这个已经删除的节点,这个遍历线程依然可以通过next属性访问下一个元素。从遍历线程的角度看,他并没有感知到此节点已经删除了,这说明了ConcurrentHashMap提供了弱一致性的迭代器。

【MAP】 HashMap ConcurrentHashMap all-in-one_第7张图片

7 Map 1.7 1.8 总结

  1.  线程安全和非安全的比较
  2.  安全方面:volatile cas synchronize 替换了 segment lock 方案,并发度有默认16 扩展为数组节点数;
  3.  速度: 红黑树O(logN) 

 

你可能感兴趣的:(java,并发,concurrent)