ConCurrentHashMap的深入了解

简介

通过之前学习HashMap,知道在多线程环境下,同时进行HashMap的put操作时,可能造成死循环,所以需要一个更安全的Map在多线程环境下使用。
了解HashMap传送门:HashMap的深入了解

HashTable作为一个历史遗留类,所有线程去竞争同一把锁。当一个线程访问HashTable时,其他线程访问HashTable同步方法,就会进入等待或轮训状态,效率极其低下。

在 JDK1.5加入ConcurrentHashMap,了解ConcurrentHashMap,还有一些其他的知识要了解,如ReentrantLock,AbstractQueuedSynchronizer,本人目前对这些知识没有掌握,这里暂时不写,后续补上。

JDK1.7中ConcurrentHashMap使用了Segment(桶)锁分段技术。它将数据分段存储,并对每一段数据分别加锁,即一个线程访问一段数据,其他段数据仍然可以被其他线程访问,提高效率。

JDK1.8中,ConcurrentHashMap的实现参考了JDK1.8中HashMap的实现,舍弃了Segment,采用了数组+链表+红黑树,所以本文以JDK1.8源码为主。ConcurrentHashMap内部大量采用了CAS操作+volatile,CAS操作又是乐观锁的一种实现方式,所以这里先了解乐观锁和悲观锁,还有volatile。

乐观锁和悲观锁

乐观锁

在每次取数据的时候,乐观的认为别人不会在此期间对数据进行修改,所以不上锁。但是在更新的时候会判断一下别人是否对数据进行改动过,若数据改动过,则重新获取数据,重新判定改动。若判断未改动过,则更新数据。

悲观锁

在每次取数据的时候,悲观的认为别人会在此期间对数据进行修改,所以取数据的时候都会上锁。别人想拿到这个锁时会阻塞。

传统数据库用到的大多是悲观锁,如行锁、表锁、读锁,都是在操作前先上锁。java中为保证线程安全synchronized和ReetrantLock都是悲观锁的实现。

乐观锁的两种实现方式
  1. 版本号机制

    通常在数据表中多加一列版本号字段(version),用于表示数据的修改次数。数据被修改一次,version+1。当需要更新数据时,将版本号一起读取出来,在更新时,判断版本号和数据库中版本号是否一致。如果一致,则更新,不一致,则重新获取数据进行更新操作。

  2. CAS算法

    CAS算法的意思是compare and swap,比较并且交换。此算法实现了在不使用锁的情况下,实现线程之间的变量同步。CAS是CPU指令级的操作,只有一步原子操作,所以非常快。

    CAS涉及三个操作数:

    (1)内存值 V

    (2)旧的预期值 A

    (3)要修改的新值 B。

    当且仅当旧的预期值 A和内存值 V相同时,将内存值修改为B,否则什么都不做。

    举个例子:有两个线程t1、t2,他们都要去更新一个变量的值,所以他们会把主内存的值完全拷贝一份到自己的工作内存空间,当t1在线程竞争中更新了变量的值,然后将变量的值写回到主存中。t2再去获取变量,会将内存值和原有拷贝的值进行对比,若不相等,证明数据已经被修改,则放弃这次操作,重新进行更新操作。

volatile

学习volatile需要先了解内存屏障。

内存屏障 Memory Barrier

内存屏障,又称内存栅栏,是一个CPU指令

1,保证特定操作的执行顺序

2,影响某些数据(或者是某条指令的执行结果)的内存可见性

编译器和CPU可以重排指令,保证最终的执行结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU,不管什么指令,都不能和这条Memory Barrier指令重排序。

Memery Barrier 所做的另外一件事是强刷出各种CPU cache,如一个Write-barrier(写入屏障)将刷出所有在Barrier之前写入cashe的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

volatile

volatile就是基于Memory Barrier实现的。如果一个变量是volatile修饰的,JMM会在写入这个变量之前,插入一个write-Barrier指令,在变量之后插入一个Read-Barrier指令。

也就是说,

  1. 写入一个volatile变量,可以保证任何线程访问该变量都是最新值。

  2. 在写入变量前的写入操作,其更新数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

    刷出cache,直白的讲就是让原来CPU中的缓存消失,这样就需要重新从主存中取数据。

JDK 1.8 实现

JDK1.8的实现已经完全抛弃segment分段锁机制,利用CAS和Synchronized保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。盗一张图:
盗一张图

内部结构-字段

Table:默认为null,初始化在第一次插入时,默认数据大小为16,用来存储Node节点数据,扩容时总是2的幂次方(同HashMap中数组table)

transient volatile Node<K,V>[] table;

Node:保存key,value,hash,以及nextNode的数组结构。其中key和value用volatile修饰,保证并发的可见性。数据结构为链表。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    ... 省略部分代码
}

TreeBean:同上,hash<0,数据结构为红黑树

nextTable:默认为null,扩容时新生成的数组,大小为原来的2倍

sizeCtl:默认为0,用来控制table的初始化和扩容。

  • -1:代表table正在初始化(这里后续需要补充)
  • -N:表示有N-1个线程正在进行扩容操作
  • 其余情况:
    1.如果table未初始化,表示table需要初始化的大小
    2.如果table初始化完成,表示table的容量,默认是table长度的0.75倍(此时同HashMap中的threshold)

ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。只有table扩容时才起作用,作为一个占位符放在table中,表示该节点为null或者已经被移动

final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }
}
初始化
实例初始化

实例化ConcurrentHashMap时带参数时,会根据参数调整table的大小,假设参数为100,最终会调整成256,确保table的大小总是2的幂次方,算法如下:

ConcurrentHashMap<String, String> hashMap = new ConcurrentHashMap<>(100);
private static final int tableSizeFor(int c) {
    int n = c - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

ConcurrentHashMap在构造函数中只会初始化sizeCtl的值,而不是初始化table。

table初始化

table的初始化实在第一次put操作时完成。但是put操作是并发时,table的初始化是如何实现的。

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
//如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片
        if ((sc = sizeCtl) < 0) 
            Thread.yield(); // lost initialization race; just spin
        //将当前对象做偏移量与预期值比较,如果相等则赋值-1(可以理解为sizeCtl=-1)
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

sizeCtl默认为0,如果ConcurrentHashMap初始化时有参数,sizeCtl会是一个2的幂次方的值。当第一次执行put操作时,会执行U.compareAndSwapInt方法,使sizeCtl的值为-1,其他线程调用Thread.yield()方法等待table初始化完成。

内部结构-方法

假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作。

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)
            tab = initTable();		//初始化
        //f==null,证明这个位置第一次插入节点,使用CAS算法直接插入
        //i = (n - 1) & hash 定位索引位置
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //使用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)		//final MOVED=-1,证明正在扩容
            tab = helpTransfer(tab, f);			//帮助扩容
        else {
            
            else {
                V oldVal = null;
                synchronized (f) {
                    //同步代码块,将节点插入列表或红黑树,下面贴出
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);		//超过阈值,转为红黑树
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
    }
    addCount(1L, binCount);		//数量+1,判断是否需要扩容
    return null;
}

static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;}

从代码中可知,获取table中元素tabAt方法使用了Unsafe.getObjectVolatile来获取,而不是直接使用table[index],原因是虽然table被volatile修饰,但是只能保证table引用对象的可见性,并不能保证table内部数据的可见性,所以直接使用索引获取,无法保证拿到的是最新元素。

  • 问:既然不能保证内部数据的可见性,为什么还要在table上加volatile

    答:为了在扩容时,对其他线程保持可见性。

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);
}

Unsafe.getObjectVolatile可以保证,每次获取的数据都是最新元素。

同步内置锁代码块
synchronized (f) {		//在节点f上做同步
    if (tabAt(tab, i) == f) {	//同步后再次判断索引位置是否为f,防止其他线程修改
        if (fh >= 0) {			//如果f.hash>0,证明为头结点
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
                K ek;
                //如果找到key,则赋值新的value值
                if (e.hash == hash &&
                    ((ek = e.key) == key ||
                     (ek != null && key.equals(ek)))) {
                    oldVal = e.val;
                    if (!onlyIfAbsent)
                        e.val = value;
                    break;
                }
                Node<K,V> pred = e;	//插入节点到尾部
                if ((e = e.next) == null) {
                    pred.next = new Node<K,V>(hash, key,
                                              value, null);
                    break;
                }
            }
        }
        else if (f instanceof TreeBin) {	//如果是红黑树TreeBin
            Node<K,V> p;
            binCount = 2;
            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                  value)) != null) {
                oldVal = p.val;
                if (!onlyIfAbsent)
                    p.val = value;
            }
        }
    }
}
table扩容

ConcurrentHashMap的扩容步骤与HashMap的扩容机制流程一致:

  1. 先构建一个大小为table两倍的nextTable,
  2. 然后将原有数据复制到nextTable中。

但是ConcurrentHashMap是支持并发操作的,在扩容时也有可能出现并发的情况,这种情况下,第二步支持节点的并发复制。

private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        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))) {
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
        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();
            }
        }
    }

看了好几遍,看不懂。以后回过头再看。

get操作

读取数据并不涉及到并发,这里与HashMap相似。

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }
  1. 判断table或者该节点是否为空,否则直接返回null
  2. 判断是否在索引处,如果是直接返回value;判断是否为红黑树,是的或则进行查找并返回;循环判断链表,返回key值相同的value
  • 问:为什么ConcurrentHashMap的读操作不需要加锁?

    提示:volatile关键字

总结

ConcurrentHashMap是并发散列映射表的实现,他允许完全并发的读取,并且支持给定数量的并发更新。

  • ConcurrentHashMap和Collections.synchronizedMap(hashMap)、HashTable的比较

    1. 后两者使用全局锁来同步不同线程之间的并发访问,导致对容器的访问变成串行化了。

    2. JDK1.6采用ReentrantLock 分段锁的方式,使多个线程在segment上进行写操作不会发生阻塞。

    3. JDK1.8中使用的是内部锁,对节点进行同步,效率提升,但是实现方式上复杂度也较之前有了非常大提升(虽然不用我们写,但是读源码也挺累的)。

参考

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值