J.U.C 学习(七)之 “concurrentHashMap与HashMap、hashTable”

前言

ConcurrentHashMap 是 J.U.C 包里面提供的一个线程安全并且高效的 HashMap,所以 ConcurrentHashMap 在并发编程的场景中使用的频率比较高,今天就从 ConcurrentHashMap 的使用上以及源码层面来分析 ConcurrentHashMap 到底是如何实现安全性的

api 使用
ConcurrentHashMap 是 Map 的派生类,所以 api 基本和 Hashmap 是类似,主要就是 put、 get 这些方法,接下来基于 ConcurrentHashMap 的 put 和 get 这两个方法作为切入点来分析 ConcurrentHashMap 的源码实现

hash算法

Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射。

还有例如 MD5 、 SHA 也是散列算法

发生hash冲突时的解决方案

  • 线性探索(开放寻址法)
  • 链式地址法(HashMap)
  • 再hash法(通过多个hash函数) -> 布隆过滤器 (bitMap)
  • 建立公共溢出区

JDK1.7 和 Jdk1.8 版本的变化
在 JDK1.7 中,ConrruentHashMap 由一个个 Segment 组成,简单来说, ConcurrentHashMap 是一个 Segment 数组,它通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来保证每个 segment 内的操作的线程安全性,从而实现全局线程安全。

hashMap和concurrentHashMap的区别

为什么要使用ConcurrentHashMap

在并发编程中,jdk1.7的情况下使用 HashMap 可能造成死循环,而jdk1.8 中有可能会造成数据丢失

ConcurrentHashMap:在hashMap的基础上,ConcurrentHashMap将数据分为多个segment(段),默认16个(concurrency level),然后每次操作对一个segment(段)加锁,避免多线程锁的几率,提高并发效率。

HashMap

  • 底层数组+链表实现,可以存储null键和null值,线程不安全

  • 初始size为1***6,扩容:newsize = oldsize2,size一定为2的n次幂

  • 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

  • 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)

  • 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀

  • 计算index方法:index = hash & (tab.length – 1)

  • HashMap的初始值还要考虑加载因子:

哈希冲突:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较。
加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。
空间换时间*:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始大小,以降低哈希冲突的概率。*

ConcurrentHashMap

  • 底层采用分段的数组+链表实现,线程安全

  • 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)

  • Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术

  • 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁

  • 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

ConcurrentHashMap是使用了锁分段技术来保证线程安全的。

锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。

ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。

hashMap的特点

  1. 在put的时候才会进行数组的初始化
  2. 默认初始化大小为16,扩容因子为0.75
  3. 采用数组+链表的形式(链表)
  4. 线程不安全
  5. 如果key为null,存储位置为table[0]或table[0]的冲突链上
  6. 如果该对应数据已存在(key值equals==true),执行覆盖操作。用新value替换旧value,并返回旧value
  7. 当size超过临界阈值,并且即将发生哈希冲突时进行扩容,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去
  8. 扩容后,循环遍历重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
  9. 数组长度设计必须为2的次幂的原因,一些位运算,减少hash冲突
  10. HashMap-get :如果key为null,则直接去table[0]处去检索即可。否则调用getEntry() 方法,通过key的hashcode值计算hash值,获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
  11. 重写equals() 方法的时候,也要重写hashcode方法
  12. 1.7及之前是数组+链表,1.8以后变成了数组+链表(或红黑树)
  13. hashMap允许null键和null值。
  14. 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。
  15. hashMap什么时候进行扩容呢?

    当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为216=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.751000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

总结:HashMap的实现原理:

  1. 利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况。(1)如果key相同,则覆盖原始值;(2)如果key不同(出现冲突),则将当前的key-value放入链表中
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
  4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
final Entry<K,V> getEntry(Object key) {
            
        if (size == 0) {
            return null;
        }
        //通过key的hashcode值计算hash值
        int hash = (key == null) ? 0 : hash(key);
        //indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && 
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

hashMap和hashTable的区别

  1. HashMap和Hashtable都实现了Map接口
  2. HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
  3. Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
  4. 这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的
  5. 我们能否让HashMap同步?HashMap可以通过下面的语句进行同步:Map m = Collections.synchronizeMap(hashMap);
  6. 除此之外没什么不同的

结论
Hashtable和HashMap有几个主要的不同:线程安全以及速度。仅在你需要完全的线程安全的时候使用Hashtable,而如果你使用Java 5或以上的话,请使用ConcurrentHashMap吧。

jdk1.7 数据结构图如下
J.U.C 学习(七)之 “concurrentHashMap与HashMap、hashTable”_第1张图片
当每个操作分布在不同的 segment 上的时候,默认情况下,理论上可以同时支持 16 个线程的并发写入。Segment 的结构和 HashMap 类似,是一个数组和链表结构。

jdk1.8 数据结构图如下
J.U.C 学习(七)之 “concurrentHashMap与HashMap、hashTable”_第2张图片
相比于 1.7 版本,它做了两个改进

  1. 取消了 segment 分段设计,直接使用 Node 数组来保存数据,并且采用 Node 数组元素作
    为锁
    来实现每一行数据进行加锁来进一步减少并发冲突的概率
  2. 将原本数组+单向链表的数据结构变更为了:数组+单向链表+红黑树的结构。
    为什么要引入红黑树呢?在正常情况下,key hash 之后如果能够很均匀的分散在数组中,那么 table 数组中的每个队列的长度主要为 0 或者 1.但是实际情况下,还是会存在一些队列长度过长的情况。如果还采用单向列表方式,那么查询某个节点的时间复杂度就变为 O(n); 因此对于队列长度超过 8 的列表,JDK1.8 采用了红黑树的结构,那么查询的时间复杂度就会降低到 O(logN),可以提升查找的性能;这个结构和 JDK1.8 版本中的 Hashmap 的实现结构基本一致,但是为了保证线程安全性, ConcurrentHashMap 的实现会稍微复杂一点。

接下来我们从源码层面来了解一下它的原理. 我们基于 put 和 get 方法来分析它的实现即可

ConcurrentHashMap源码分析

new的时候不会初始化数组,在put的时候才进行初始化

put 方法第一阶段

public V put(K key, V value) {
    return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());//计算hash值
    int binCount = 0;//用来记录链表的长度
    for (Node<K,V>[] tab = table;;) {//自旋操作,当出现线程竞争时不断自旋
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
        	// 如果数组为空,则进行数组的初始化
        	// 通过 hash 值对应的数组下标得到第一个节点; 以 volatile 读的方式来读取 
        	// table 数组中的元素,保证每次拿到的数据都是最新的
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
        //如果该下标返回的节点为空,则直接通过 cas 将新的值封装成 node 插入即可;
        //如果 cas 失败,说明存在竞争,则进入下一次循环
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
        	// 其他线程协助扩容
            tab = helpTransfer(tab, f);
        else {
			// 这里是扩容操作
		}
	}
	// 更新hashMap中的元素个数
	addCount(1L, binCount);
    return null;

假如在上面这段代码中存在两个线程,在不加锁的情况下:线程 A 成功执行 casTabAt 操作后,随后的线程 B 可以通过 tabAt 方法立刻看到 table[i]的改变。原因如下:
线程 A 的 casTabAt 操作,具有 volatile 读写相同的内存语义,根据 volatile 的 happens-before 规则:线程 A 的 casTabAt 操作,一定对线程 B 的 tabAt 操作可见

initTable 初始化数组

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
        	//被其他线程抢占了初始化的操作,则直接让出自己的 CPU 时间片
            Thread.yield(); // lost initialization race; just spin
        //通过 cas 操作,将 sizeCtl 替换为-1,标识当前线程抢占到了初始化资格
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                	// 默认初始容量为16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    //初始化数组,长度为16,或者初始化在构造 ConcurrentHashMap 的时候传入的长度
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;//将这个数组赋值给table
                    //计算下次扩容的大小,实际就是当前容量的 0.75 倍,这里使用了右移来计算
                    sc = n - (n >>> 2);
                }
            } finally {
            	//设置sizeCtl为sc, 如果默认是16的话,那么这个时候sc=16*0.75=12
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

数组初始化方法,这个方法比较简单,就是初始化一个合适大小的数组
sizeCtl 这个要单独说一下,如果没搞懂这个属性的意义,可能会被搞晕
这个标志是在 Node 数组初始化或者扩容的时候的一个控制位标识,负数代表正在进行初始化或者扩容操作

  • -1 代表正在初始化
  • -N 代表有 N-1 个线程正在进行扩容操作,这里不是简单的理解成 n 个线程,sizeCtl 就是-N
  • 0 标识 Node 数组还没有被初始化,正数代表初始化或者下一次扩容的大小

tabAt 方法

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

该方法获取对象中 offset 偏移地址对应的对象 field 的值。实际上这段代码的含义等价于 tab[i], 但是为什么不直接使用 tab[i]来计算呢?
getObjectVolatile,一旦看到 volatile 关键字,就表示可见性。因为对 volatile 写操作 happen- before 于 volatile 读操作,因此其他线程对 table 的修改均对 get 读取可见;
虽然 table 数组本身是增加了 volatile 属性,但是“volatile 的数组只针对数组的引用具有 volatile 的语义,而不是它的元素”。 所以如果有其他线程对这个数组的元素进行写操作,那 么当前线程来读的时候不一定能读到最新的值。
出于性能考虑,Doug Lea 直接通过 Unsafe 类来对 table 进行操作。

put 方法第二阶段(计数和扩容)

在 putVal 方法执行完成以后,会通过 addCount 来增加 ConcurrentHashMap 中的元素个数, 并且还会可能触发扩容操作。这里会有两个非常经典的设计

计数addCount
在 putVal 最后调用 addCount 的时候,传递了两个参数,分别是 1 和 binCount(链表长度), 看看 addCount 方法里面做了什么操作
x 表示这次需要在表中增加的元素个数,check 参数表示是否需要进行扩容检查,大于等于 0 都需要进行检查

如何保证 addCount 的数据安全性以及性能

private transient volatile long baseCount; //在没有竞争的情况下,去通过cas操作更新元素个数
private transient volatile CounterCell[] counterCells;//在存在线程竞争的情况下,存储元素个数
addCount(1L, binCount);
return null;
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 判断 counterCells 是否为空
	//1. 如果为空,就通过cas操作尝试修改baseCount变量,对这个变量进行原子累加操作
	//(做这个操作的意义是:如果在没有竞争的情况下,仍然采用 baseCount 来记录元素个数
	//2. 如果cas失败说明存在竞争,这个时候不能再采用 baseCount 来累加,而是通过
	// CounterCell 来记录
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;//是否冲突标识,默认为没有冲突
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
			/*这里有几个判断
			1. 计数表为空则直接调用 fullAddCount
			2. 从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount
			3. 通过 CAS 修改 CounterCell 随机位置的值,如果修改失败说明出现并发情况(这里又
			用到了一种巧妙的方法),调用fullAndCount。
			Random 在线程并发的时候会有性能问题以及可能会产生相同的随机数,
			ThreadLocalRandom.getProbe 可以解决这个问题,并且性能要比Random高 */

            fullAddCount(x, uncontended);//执行fullAddCount方法
            return;
        }
        if (check <= 1)//链表长度小于等于1,不需要考虑扩容
            return;
        s = sumCount();//统计ConcurrentHashMap元素个数
    }
    // 下面的逻辑是扩容操作
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

CounterCells 解释:

ConcurrentHashMap 是采用 CounterCell 数组来记录元素个数的,像一般的集合记录集合大小,直接定义一个 size 的成员变量即可,当出现改变的时候只要更新这个变量就行。为什么 ConcurrentHashMap 要用这种形式来处理呢?
问题还是处在并发上,ConcurrentHashMap 是并发集合,如果用一个成员变量来统计元素个 数的话,为了保证并发情况下共享变量的安全性,必然会需要通过加锁或者自旋来实现, 如果竞争比较激烈的情况下,size 的设置上会出现比较大的冲突反而影响了性能,所以在 ConcurrentHashMap 采用了分片的方法来记录大小。看下面代码

private transient volatile int cellsBusy;// 标识当前 cell 数组是否在初始化或扩容中的CAS 标志位

private transient volatile CounterCell[] counterCells;// counterCells 数组,总数值的分值分别存在每个 cell 中

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

//看到这段代码就能够明白了,CounterCell 数组的每个元素,都存储一个元素个数,而实际我们调用
//size 方法就是通过这个循环累加来得到的
//又是一个设计精华,大家可以借鉴; 有了这个前提,再会过去看 addCount 这个方法,就容易理解一些了
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

扩容

put 方法第三阶段(协助扩容)

  • (当元素个数大于阈值的时候)
  • 如果此时正在扩容, 在扩容阶段进来的线程会协助扩容

put 方法第四阶段(将链表转换为红黑树)

判断链表的长度是否已经达到临界值 8. 如果达到了临界值,这个时候会根据当前数组的长度来决定是扩容还是将链表转化为红黑树。也就是说如果当前数组的长度小于 64,就会先扩容。否则,会把当前链表转化为红黑树

下一篇
多线程学习(八)之 “线程池的设计与原理解析”

你可能感兴趣的:(java并发编程知识体系,java)