ConcurrentHashMap原理

为什么使用CocurrentHashMap

  1. ConcurrentHashMap解决了在多线程情况下数据存储被覆盖的问题,同时也提高了存储效率。
  2. 相比于HashMap是线程不安全的,在多线程情况下HashMap的put操作变成死循环。
  3. HashTable中使用synchronized来解决多线程情况下的问题,但是随之也导致了效率非常低。

CocurrentHashMap的结构

对于ConcurrentHashMap的结构,jdk1.8多了很多优化,这里我们对1.7和1.8进行一个对比

1.7:

  1. Segments数组 + HashEntry数组 + 链表
  2. 采用分段锁机制
  3. 底层是数组+链表
  4. Segments的意思就是将一个大的table分成多个Segments,每一个Segments就相当于是一个HashMap

1.8:

  1. 取消了Segment
  2. 使用了CAS+synchronized+Node来保证并发安全

CocurrentHashMap初始化

/**
 * 多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。
 * 不同状态,sizeCtl所代表的含义也有所不同。
 * 未初始化:
 *  sizeCtl=0:表示没有指定初始容量。
 *  sizeCtl>0:表示初始容量。
 * 初始化中:
 *  sizeCtl=-1,标记作用,告知其他线程,正在初始化
 * 正常状态:
 *  sizeCtl=0.75n ,扩容阈值
 * 扩容中:
 *  sizeCtl < 0 : 表示有其他线程正在执行扩容
 *  sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 :表示此时只有一个线程在执行扩容
 */
private transient volatile int sizeCtl;

/**
 * 扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)。
 * 1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。
 * 2 扩容线程,在迁移数据之前,首先要将transferIndex左移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。
 * 每个扩容线程都会通过for循环+CAS的  方式设置transferIndex,因此可以确保多线程扩容的并发安全。
 */
private transient volatile int transferIndex;



// ConcurrentHashMap的创建有五种方式
// 第一种方式

/**
 * 创建一个空对象,默认初始容量是16
 * Creates a new, empty map with the default initial table size (16).
 */
public ConcurrentHashMap() {
}

// 第二种
/**
 * 创建一个指定初始容量的对象
 * Creates a new, empty map with an initial table size
 * accommodating the specified number of elements without the need
 * to dynamically resize.
 */
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
     // 计算得出的容量,如果指定的容量大于等于最大容量的一半则直接设置容量为最大值
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;
}

/**
 * 返回一个最接近指定容量的2的次幂的数
 */
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;
}

// 第三种方式
public ConcurrentHashMap(Map m) {
    // 设置默认容量
    this.sizeCtl = DEFAULT_CAPACITY;
    // 接受一个map,创建一个新的map
    putAll(m);
}
/**
 * 将传入map中所有的元素循环copy到新的ConcurrentHashMap中
 * @param m mappings to be stored in this map
 */
public void putAll(Map m) {
    // 尝试在插入前扩容
    tryPresize(m.size());
    for (Map.Entry e : m.entrySet())
        putVal(e.getKey(), e.getValue(), false);
}


// 第四种方式

/**
 * 根据指定的初始容量和加载因子创建一个新的map
 */
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
    this(initialCapacity, loadFactor, 1);
}

// 第五种方式
/**
 * 根据指定的初始容量和加载因子和并发级别创建一个新的map
 * 这里想一个问题, concurrencyLevel 是干什么用的,发挥了什么作用,我们后面在分析
 * @param concurrencyLevel the estimated number of concurrently
 * updating threads. The implementation may use this value as
 * a sizing hint.
 */
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (initialCapacity < concurrencyLevel)   // Use at least as many bins
        initialCapacity = concurrencyLevel;   // as estimated threads
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;
}

trySize-预扩容

/**
 * Tries to presize table to accommodate the given number of elements.
 *
 * @param size number of elements (doesn't need to be perfectly accurate)
 */
private final void tryPresize(int size) {
    // 如果指定的容量大于等于最大容量的一半则直接设置容量为最大值
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    // 这里sizeCtl默认初始容量16
    while ((sc = sizeCtl) >= 0) {
        Node[] tab = table; int n;
        // 当数组为空时
        if (tab == null || (n = tab.length) == 0) {
            // 设置容量,这里sc默认是16,如果传入的对象大小小于16都会设置为16
            n = (sc > c) ? sc : c;
            // SIZECTL = -1 表示加锁
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        // 这里再次判断之后创建一个指定长度的数组并赋个table
                        @SuppressWarnings("unchecked")
                        Node[] nt = (Node[])new Node[n];
                        table = nt;
                        // 计算下次扩容阈值 n - n / 4 = n * 0.75
                        sc = n - (n >>> 2);
                    }
                } finally {
                    // 将下次扩容阈值赋值给 sizeCtl 
                    sizeCtl = sc;
                }
            }
        }
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            // 这里说明数组已经初始化过了
            // 如果扩容大小小于等于扩容阈值或者数组长度已经是最大值了,直接结束
            break;
        else if (tab == table) {
            // 这里说明数组还未发生变化
            int rs = resizeStamp(n);
            if (sc < 0) {
                // 表示正在扩容,
                Node[] nt;
                 //条件1: true -> 当前线程获取到的扩容唯一标识戳非本次扩容批次,执行方法体
                 //      false -> 当前线程获取到的扩容唯一标识戳是本次扩容批次,进入下一个判断
                 //条件2: jdk1.8有bug 这里应该是 sc == (rs << RESIZE_STAMP_SHIFT) + 1
                 //      true -> 表示所有线程都执行完毕了 线程数量是 = 1+n,执行方法体
                 //      false -> 表示还在扩容中,进入下一个判断
                 //条件3: jdk1.8有bug 这里应该是 sc == (rs << RESIZE_STAMP_SHIFT) + MAX_RESIZERS
                 //      true -> 表示当前参与扩容的线程数量达到最大限度了,不能再参与了,执行方法体
                 //      false -> 表示当前参与扩容的线程数量还未达到最大限度,当前线程可以参与,进入下一个判断
                 //条件4: true -> 表示扩容已完毕,执行方法体
                 //      false -> 表示扩容还在进行,进入下一个判断
                 //条件5: true -> 表示全局范围内的任务已经分配完了,执行方法体
                 //      false -> 表示还有任务可分配,结束判断
                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);
        }
    }
}

put流程

public V put(K key, V value) {
    return putVal(key, value, false);
}

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 这一行代码可以看出在ConcurrentHashMap中 key和value都不能为空,这也是跟HashMap的一个区别
    if (key == null || value == null) throw new NullPointerException();
    // 对key值进行二次 hash
    int hash = spread(key.hashCode());
    // binCount 用于链表节点计数,用于后续判断是否转成红黑树
    int binCount = 0;
    // 循环遍历数组,这里使用的是乐观锁的方式
    for (Node[] tab = table;;) {
        Node f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            // table初始化,初始化完成后进入下一轮循环,后面就不会走到这一步了
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 若果要插入的位置为空,则使用cas的方式插入新元素,防止并发修改,然后跳出循环
            if (casTabAt(tab, i, null,
                         new Node(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            // 如果要插入的位置不为空,并且节点hash是 MOVE,说明数组正在扩容,调用 helpTransfer 协助扩容并把插入元素插入到新的table中
            tab = helpTransfer(tab, f);
        else {
            // 走到这里说明发生了hash冲突,这里只对要插入的位置中的节点加锁
            V oldVal = null;
            synchronized (f) {
                // 再次判断要插入的位置元素与之前取出来的元素是否一样,防止被修改
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        // 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
                        binCount = 2;
                        if ((p = ((TreeBin)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            // 走到这一步说明插入新元素成功,
            if (binCount != 0) {
                // 如果 binCount 大于等于红黑树转换阈值8,并且数组长度达到64,进行转换
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 第一个参数表示元素变化值,1表示新增一个元素,-1表示移除一个元素,
    addCount(1L, binCount);
    return null;
}

下面我们来对put方法做一个总结

  1. 在进行插入操作时,首先会进入乐观锁,如果还没初始化就先进行初始化操作
  2. 然后判断要插入的节点是否为空,如果为空则直接通过CAS插入
  3. 如果不为空,先判断数组是否处于扩容状态,如果是正在扩容,帮助扩容
  4. 如果不是扩容状态,找到发生hash冲突的那个节点,循环遍历该节点,然后判断相等则更新,不等则插入

spread-二次hash

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

initTable-数组初始化

private final Node[] initTable() {
    Node[] tab; int sc;
    // 循环检查是否已经初始化
    while ((tab = table) == null || tab.length == 0) {
        // sizeCtl < 0,就是-1表示正在初始化,直接线程阻塞
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            // 进入该判断说明 sizeCtl >= 0,存在两种情况,一个是未初始化,二是正在扩容sizeCtl为阈值
            // 这里进入初始化代码,现将 SIZECTL 设置为初始化状态
            try {
                // 再次判断数组是否已经初始化
                if ((tab = table) == null || tab.length == 0) {
                    // 设置初始化容量,默认16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node[] nt = (Node[])new Node[n];
                    table = tab = nt;
                    // sc表示扩容阈值
                    sc = n - (n >>> 2);
                }
            } finally {
                // 初始化完成后将阈值赋给 sizeCtl
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

下面我们来分析以下初始化的步骤:

  1. 判断哈希表是否初始化
  2. 如果未初始化,判断sizeCtl的值如果sizeCtl
  3. 如果sizeCtl >= 0,则有两种可能,一是哈希表未初始化;二是有其他线程在已经初始化完成,此时sizeCtl记录的是扩容阈值
  4. 使用CAS将 SIZECTL 值修改为-1。如果修改成功,开始初始化操作
  5. 如果修改失败,进入下一轮循环判断是否初始化
  6. 修改-1成功后再次判断是否初始化,这里为什么要采用双重校验呢?因为在并发情况下,如果其他线程已经完成了初始化,sizeCtl也是 >=0,也会进入这个判断
  7. 如果还未初始化,则开始初始化
  8. 如果顺利初始化完成,sizeCtl记录扩容阈值。

ConcurrentHashMap原理_第1张图片

 helpTransfer-协助扩容

/**
 * Helps transfer if a resize is in progress.
 */
 https://blog.csdn.net/weixin_41392674/article/details/126250297
 https://blog.csdn.net/u010285974/article/details/106301101/
final Node[] helpTransfer(Node[] tab, Node f) {
    Node[] nextTab; int sc;
    // 前面我们已经知道Node是链表节点,TreeNode是红黑树节点,那么这里的 ForwardingNode 代表说明呢?
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode)f).nextTable) != null) {
        int rs = resizeStamp(tab.length);
        while (nextTab == nextTable && table == tab &&
               (sc = sizeCtl) < 0) {
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}


/**
 * A node inserted at head of bins during transfer operations.
 */
static final class ForwardingNode extends Node {
    final Node[] nextTable;
    ForwardingNode(Node[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }

    Node find(int h, Object k) {
        // loop to avoid arbitrarily deep recursion on forwarding nodes
        outer: for (Node[] tab = nextTable;;) {
            Node e; int n;
            if (k == null || tab == null || (n = tab.length) == 0 ||
                (e = tabAt(tab, (n - 1) & h)) == null)
                return null;
            for (;;) {
                int eh; K ek;
                if ((eh = e.hash) == h &&
                    ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
                if (eh < 0) {
                    if (e instanceof ForwardingNode) {
                        tab = ((ForwardingNode)e).nextTable;
                        continue outer;
                    }
                    else
                        return e.find(h, k);
                }
                if ((e = e.next) == null)
                    return null;
            }
        }
    }
}

treeifyBin-红黑树转换

// 从put流程中的这段代码我们可以看出当节点链表长度大于等于 8 是会调用红黑树扩容方法 treeifyBin
if (binCount >= TREEIFY_THRESHOLD)
    treeifyBin(tab, i);

/**
 * Replaces all linked nodes in bin at given index unless table is
 * too small, in which case resizes instead.
 */
private final void treeifyBin(Node[] tab, int index) {
    Node b; int n, sc;
    if (tab != null) {
        // 这里第一步会再判断数组的长度是否大于等于 64 ,如果小于64还是会进行扩容,扩容流程前面我们已经分析过了
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            // 走到这里说明数组长度 >= 64 并且链表长度 >= 8
            synchronized (b) {
                // 加锁
                if (tabAt(tab, index) == b) {
                    TreeNode hd = null, tl = null;
                    // 遍历链表,创建红黑树
                    for (Node e = b; e != null; e = e.next) {
                        TreeNode p =
                            new TreeNode(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // 将红黑树结构元素设置到数组中
                    setTabAt(tab, index, new TreeBin(hd));
                }
            }
        }
    }
}

addCount-计数扩容

/**
 * Adds to count, and if table is too small and not already
 * resizing, initiates transfer. If already resizing, helps
 * perform transfer if work is available.  Rechecks occupancy
 * after a transfer to see if another resize is already needed
 * because resizings are lagging additions.
 *
 * @param x the count to add
 * @param check if <0, don't check resize, if <= 1 only check if uncontended
 */
 // 该方法主要有两个作用,1:计数;2:判断是否扩容
 // x : 1表示新增元素,-1表示移除元素
 // check:正常是数组某个hash槽中链表或红黑树元素个数
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 第一次执行时 as = counterCells == null
    /* U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) ,baseCount表示元素个数
     * 1. 在没有竞争的情况下写入成功,第一个if判断返回false
     * 2. 在多线程情况下,如果写入失败,进入第一个if判断
     */ 
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // 1. 如果as == null,说明多线程竞争失败,counterCells 初始化
        // 2. 如果as != null,并且当前线程对应as数组存储对象不为空,直接使用as存储值 +x
        // 3. 如果as != null,当前线程对应as数组中没有数据,调用 fullAddCount 插入
        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;
        // 返回当前map中数组个数
        s = sumCount();
    }
    if (check >= 0) {
        // 检查扩容
        Node[] 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流程

public V get(Object key) {
    Node[] tab; Node 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) {
        // 如果数组非空,元素并且存在该key,开始查询
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
            // 如果正好在数组内,找到一样的key,hash则直接返回
                return e.val;
        }
        else if (eh < 0)
            // hash<0 这里有两种情况
            // static final int MOVED     = -1; // hash for forwarding nodes
            // static final int TREEBIN   = -2; // hash for roots of trees
            // 当 eh = -1 时,说明数组正在扩容,e 是ForwardingNode
            // 当 eh = -2 时,说明数组中节点是红黑树结构, e 是TreeBin
            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;
}

分析完get流程,我们发现get方法中没有加锁的操作,这是为什么呢?

ConcurrentHashMap 读操作为什么不用加锁

1.7:

ConcurrentHashMap 内部采用了一种分段锁的机制,即将整个哈希表分成多个 Segment,每个 Segment 都是一个独立的哈希表,每个 Segment 内部的操作都是线程安全的。当多个线程同时访问 ConcurrentHashMap 时,每个线程会被分配到不同的 Segment,从而避免了锁的竞争。这样就可以在不影响读操作的情况下,同时支持多个线程的并发写操作。

1.8:

ConcurrentHashMap的get操作并没有像put操作一样有CAS和synchronized锁。get操作不需要加锁,因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,所以在多线程的环境下,即便value的值被修改了,在线程之间也是可见的。

JDK1.8中为什么使用synchronized替换可重入锁ReentrantLock

// 在JDK1.7中,就是通过锁定Segment对象实现线程安全的
static class Segment extends ReentrantLock implements Serializable {
    private static final long serialVersionUID = 2249069246763182397L;
    final float loadFactor;
    Segment(float lf) { this.loadFactor = lf; }
}
// 从这段代码可以看出 Segment 继承了ReentrantLock 类,说明 Segment 是一个可重入锁

下面我们来分析一下为什么要用 synchronized 代替 ReentrantLock

  1. Segemnt 其实锁住的就是一个 HashEntry 数组,而 synchronized 锁的是发生hash冲突的头节点或者红黑树的节点提供了性能
  2. 从jdk1.6开始,对synchronized进行了很多优化,对其增加了状态转换,锁会从无锁->偏向锁->轻量级锁->重量级锁一步步升级,只要线程可以通过自旋拿到锁就不会升级为重量级锁,从而线程不会被挂起减少了线程的切换消耗,ReentrantLock并没有自旋的功能

ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?

ConcurrentHashMap的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。

HashMap迭代器是强一致性

扩容期间在未迁移到的hash桶插入数据会发生什么

只要插入的位置扩容线程还未迁移到,就可以直接插入;

如果当前要插入的位置正在迁移,会帮助其他扩容线程一起扩容直到扩容结束,然后再加synchronized锁执行插入操作。

正在迁移的hash桶遇到get操作会发生什么

因为ln和hn是在原本链表的基础上复制出来的,复制引用。

原链表并不会受到影响,可以正常的访问。

如果数据迁移结束,会在对应的哈希槽上放一个fwd,那么之后的get请求,就会将请求转发到扩容后的数组中。

正在迁移的hash桶遇到put/remove操作会发生什么

协助扩容

ConcurrentHashMap 是如何发现当前槽点正在扩容的

ConcurrentHashMap 新增了一个节点类型,叫做转移节点 ForwardingNode,当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 Map 正在进行扩容。

// remove 方法  
final V replaceNode(Object key, V value, Object cv) {
        int hash = spread(key.hashCode());
        for (Node[] tab = table;;) {
            Node f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0 ||
                (f = tabAt(tab, i = (n - 1) & hash)) == null)
                break;
            else if ((fh = f.hash) == MOVED)
                // MOVED = -1
                tab = helpTransfer(tab, f);
            else {
            ......
            }
 }

// put方法
   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,
                             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 {
            ......
            }
    }

为什么超过冲突超过8才将链表转为红黑树而不直接用红黑树

因为红黑树需要进行左旋,右旋操作, 而单链表不需要

以下都是单链表与红黑树结构对比。

如果元素小于8个,查询成本高,新增成本低

如果元素大于8个,查询成本低,新增成本高

因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。 还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

时间和空间的权衡

时间:因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

空间:因为红黑树需要进行左旋,右旋操作, 而单链表不需要,TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。

描述一下 CAS 算法在 ConcurrentHashMap 中的应用

CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都是原子性操作,没有线程安全问题。

 putVal() 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:

  1. 计算出数组索引下标,拿出下标对应的原值;
  2. CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出循环,否则不赋值,转到 3;
  3. 进行下一次 for 循环,重复执行 1,2,直到成功为止。

可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。

ConcurrentHashMap 和 Hashtable 的区别

1.底层数据结构:

JDK1.7的ConcurrentHashMap底层采用:Segments数组+HashEntry数组+链表

JDK1.8的ConcurrentHashMap底层采用:Node数据+链表+红黑树

Hashtable底层数据结构采用:数组+链表

2.实现线程安全的方式:

在JDK1.7中ConcurrentHashMap采用分段锁实现线程安全。

在JDK1.8中ConcurrentHashMap采用synchronized和CAS来实现线程安全。

Hashtable采用synchronized来实现线程安全。在方法上加synchronized同步锁。

ConcurrentHashMap 和 HashMap 的区别

HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不 一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。

Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。

ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。

它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。

你可能感兴趣的:(java,java,后端,面试)