ConcurrentHashMap和LinkedHashMap的细致解析二合一

作为并发版本的HashMap,它的很多特性都是和HashMap一样的, 那么在这里我主要还是从源码的层面解析一下,然后看看和HashMap有哪些不同的地方,同时怎么保证线程安全的。

ConcurrentHashMap解析

我在写的时候发现里面新增了很多属性,而且这些属性很晦涩,所以为了便于理解,我不会先解释属性的作用,还是从方法入手,然后逐步的扩展解释各种对比HashMap新增的属性的作用。

ConcurrentHashMap的构造方法解析

java 复制代码
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    //构造方法很简单,就是计算容量,然后将容量赋值给sizeCtl,这个时候后面说做什么的
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               //这里传入参数和HashMap不同,HashMap是initialCapacity
               //主要就是考虑并发情况下table扩容的效率问题,比HashMap的初始容量要大
               //就是初始容量+初始容量向右移动一位+1 = 初始容量*1.5+1
               //空间更大,会降低一些扩容的几率,提高性能
               //tableSizeFor方法和Hash Map一样,都是传入参数最近的比它大的2的幂次倍
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;
}

重头戏:putVal方法

这里面将会涉及到并发情况下的扩容处理,维持数据一致性问题,怕说起来没完,具体的扩容方法不再这里展示,还是2倍,但是实现细节都是针对多线程的情况,加了sychronized,我这里只循着主线看。

java 复制代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    //这个计算Hash的方式和HashMap是不一样的,(h ^ (h >>> 16)) & HASH_BITS;
    //HASH_BITS这个值是0x7fffffff,转为二进制就是
    //2^31-1,二进制就是首位0,其他的都是1。所以这样的计算结果就会发现
    //如果原hashcode是负数,那么就会将最高位设置成0,也就是正数,然后
    //其他位保持不变,负数在和长度-1进行&运算的时候会和正数有差异
    //会导致向左移动,在并发的环境下更容易造成hash冲突。所以加了这样一个变化
    int hash = spread(key.hashCode());
    int binCount = 0;
    //循环table
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //如果table为空,就初始化,然后直接转移到下面看初始化的方法------------------1,做个标记一会回来
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //长度-1 &hash计算索引地址然后调用tabAt方法一行代码,信息量很大
        //Unsafe包的getObjectVolatile方法,防止指令重排弄乱结果
        //(Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
        //然后ASHIFT和ABASE这个常量是啥,
        //首先我们需要先普及一点小小的知识,我们通常使用数据进行随机访问的时候,无非就是如tab[1]这样
        //但是对于计算机来说,这样并不能确定数据在内存的具体位置,毕竟你只传入了一个index
        //而数组在计算机内存中是一段连续的存储空间,所以就需要起始地址+偏移量来确定
        //所以就需要一个寻址公式:就是起始地址+索引*数据类型大小,这个数据类型大小就是根据不同类型决定的
        //比如int类型是4个字节,byte类型是一个字节这样,所以记住这个公式,我们继续说这两个参数
        //arrayBaseOffset这个方法就是获取数组的起始地址的,在Java中这个方法如果获取数组的类型是基础数据类型
        //比如int,float等等还包括Object,String,那么返回的起始地址都是16
        //毕竟对象头大小在64为操作系统中是16个字节,方便对齐。
        //假设我们存储的是基础数据类型那么ABASE就=16,标记一下
        //Class<?> ak = Node[].class;
        //ABASE = U.arrayBaseOffset(ak);
        //这个就是获取数据类型大小的比如,byte是1,int是4,double是8,这有基础数据类型是这样的
        //如果我们是一个Object数组,那么返回值都是4,因为对象格式是4个字节对齐的。
        //而且如果是对象数组的话,那么存储的是对象的引用,别在这扯什么对象很大的事
        //int scale = U.arrayIndexScale(ak);
        //    if ((scale & (scale - 1)) != 0)
        //        throw new Error("data type scale not a power of two");
        //Integer.numberOfLeadingZeros这个是计算值的二进制有多少个0的,现在我们求出来scale是4了
        //4的二进制就是00000...0000100,1前面有20个0
        //所以ASHIFT = 31-29 =2 再标记一下  现在我们回到tabAt方法
        //   ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
        //计算方式是((long)i << ASHIFT) + ABASE  所以向左移动2位,然后+起始地址,向左移动2位就=*4
        //我们再找到上面说的寻址公式:地址 = 起始地址+索引*数据类型大小,闭环了。所以在tabAt中
        //使用getObjectVolatile就是使用上面这种方式获取的数据,同时能保证多线程环境下的一致性。
        //我会在多线程的篇章中详细的介绍Volatile
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //如果计算的索引地址没有数据,那么,再通过CAS将数据创建个节点然后放入到索引位置
            //放入的方式和tabAt类型,一样通过相同的内存计算方式放入,就不再说了
            //如果CAS失败了,不会break,继续这个循环,直到put成功!!
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        //这里这么判断需要对扩容有一些了解,当有线程对table进行扩容的时候
        //会将所有的节点转为ForwardingNode类型,然后将所有几点的hash指定为MOVED也就是-1.以此来代表
        //table正在扩容中,需要到新的table中查找数据。扩容的方法是transfer
        //其实也就是一定,大家可以自己看一看
        else if ((fh = f.hash) == MOVED)
            //所以就需要去帮助扩容,也就是将节点移动值新的table中,而进行扩容的时候sizeCtl就记录
            //帮助扩容的线程数,同时是负数,-N代表有N-1个线程正在进行扩容
            //这个方法没什么说的,就是里面一堆判断然后调用transfer方法
            tab = helpTransfer(tab, f);
        //到这里终于可以put数据了
        else {
            V oldVal = null;
            //毫无例外,加锁
            synchronized (f) {
                //二次判断,必须要保证持有锁之后数据也是一样的
                if (tabAt(tab, i) == f) {
                    //fh就是记录的hash,>0说明hash是对的,没有正在进行移动
                    if (fh >= 0) {
                         //链表计数器
                        binCount = 1;
                        //下面的方法就是挂节点
                        for (Node<K,V> 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<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    //这里需要说一点的是,TreeBin就是红黑树结构,当进行树化的时候会将
                    //所有节点的hash都变成-2,用来标识,
                    else if (f instanceof 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;
                        }
                    }
                }
            }
            //链表长度判断
            if (binCount != 0) {
                //链表长度>8 就树化,里面有对数组长度的判断
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

initTable方法解析--sizeCtl初露锋芒

java 复制代码
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        //判断sizeCtl是否小于0,我们看到上面的构造方法中将扩容之后的容量给到了sizeCtl,默认值是0,sizeCtl是个特殊的情况
        //当出现这样的情况下,就建议CPU让出资源,让其他线程优先执行
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        //我们看一下SIZECTL是哪里来的,还是个常量
        //private static final long SIZECTL;
        //    这个常量是通过Unsafe反射拿到类属性sizeCtl的值,也就是初始化的时候的sizeCtl的值
        //    U = sun.misc.Unsafe.getUnsafe();
        //    Class<?> k = ConcurrentHashMap.class;
        //    SIZECTL = U.objectFieldOffset
        //        (k.getDeclaredField("sizeCtl"));
        //所以到这里我们可以猜测一下,sizeCtl 是干啥的,首先它是volatile的,那么它一定是独立线程之外的东西,去记载某些东西
        //我们知道多线程中有一个ctl,来记录线程池状态的,那么这个sizeCtl是不是也是和记录状态相关,和size挂上,那么是不是和扩容状态相关?
        //我们往下找推测(虽然源码给注释了,但还是想自己推测一下作用,记忆深刻)
        //compareAndSwapInt这个方法的第二个参数是内存偏移量,而这个常量确实是sizeCtl字段的内存偏移量
        //所以这个方法就是去SIZECTL的内存偏移量找数据,然后和sc对比,
        //如果相等 ,然后就将SIZECTL偏移量位置的数据,也就是sizeCtl设置为-1,返回true,
        //否则不动,返回false,就是CAS操作,所以sizeCtl =-1的时候说明table正在初始化
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                //如果table为空的时候
                if ((tab = table) == null || tab.length == 0) {
                    //如果sc>0,sc给n,否则n设置为默认容量16
                    //而从上面的构造方法中我们发现sc经过计算是>0的,而有一种情况,就是调用ConcurrentHashMap的无参构造方法
                    //这个时候就是0了,然后就取默认容量了,然后创建个这个长度的数组,给table,table就初始化好了
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    //然后sc设置为数组长度的四分之三,也就是0.75。嗯?变成了扩容的阈值了
                    sc = n - (n >>> 2);
                }
            } finally {
                //这个时候将sizeCtl赋值为sc,这个方法是初始化table,如果刚开始都是0的时候sizeCtl就是扩容阈值了,同时SIZECTL = -1
                //如果不是的话,sizeCtl,就取sc的值
                sizeCtl = sc;
            }
            //回去接着看putVal
            break;
        }
    }
    return tab;
}

这个初始化的方法中sizeCtl我们可以看出,如果它>0的时候就表示已经初始化过了,等于阈值。如果=0的时候那么就是还没有初始化,等于-1的时候就是正在初始化。这一下出来三种状态的表示了,还有一状态在putVal注释中写了。

小总结: : 虽然我没有一行一行代码的进行分析,只分析了一些我们没有在HashMap中见到的代码,从这些代码中我们可以发现ConcurrentHashMap能保证并发下的数据一致性就是依赖于锁,也就是sychronized,而且细化到了节点,摒弃了1.7中分段锁的概念,很大程度的提升了性能,节省了锁的开销,同时用大量的CAS操作来保证数据的一致性,而扩容的算法,计算index等都是相同的算法。比如里面有个非常好的思路,如果你查找数据时候发现正在扩容,那么就帮助扩容,自己将自己需要的数据移动到新的数组中,然后返回。get方法我就不列出来了,因为很多代码都和putVal重复,毕竟更新也许需要先查找的,大家可以自己看一看。对于并发容器最核心的是对多线层的理解,后面我会出文章仔细分析Java的多线程和相关的池化技术。

LinkedHashMap解析

在这里我可以简单的说一下,不管是ConcurrentHashMap还是LinkedHashMap其实底层的思想都是HashMap,弄懂HashMap,那么这两个容器对我们来说都是进行扩展,只不过在展现形式上不同。大家可以看我的这篇文章# HashMap很细的分析--令人发指,比如ConcurrentHashMap完善了多线程环境下的数据一致性问题,而LinkedHashMap也有它的优势,比如它可以按照数据插入的顺序进行访问,支持按照元素的访问顺序进行排序(可以作为LRU容器)等。那么我也是和上面一样,从源码的角度分析一下LinkedHashMap

LinkedHashMap的结构

相对于HashMap,它的结构是一个双向链表,我们上面的特性说过了,它支持按照插入顺序进行遍历,但是呢HashMap本身是不支持的,所以就需要扩展存储数据的节点,而它的双向链表就体现出来了,看下面它的结构,它继承了Node,然后定义了beforeafter,这样就能使链表具备双向的能力了。

java 复制代码
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

LinkedHashMap get方法解析

相对于ConcurrentHashMapLinkedHashMap真的是非常简单的,连Put方法都是使用父类HashMap的,那么我们看一下get方法的源码

java 复制代码
public V get(Object key) {
    Node<K,V> e;
    //直接调用了HashMap的getNode方法,脸都不要了
    if ((e = getNode(hash(key), key)) == null)
        return null;
    //这里我们猜都能猜出来是干啥的,accessOrder就是访问顺序的意思,也就是如果你的LinedHash
    //是支持访问顺序排序的话,那么这个参数就是true,那我们也就知道,这个方法就是维护访问的
    //作用就是将访问的元素移动到链表末尾
    //因为是尾插法,按照插入顺序访问就直接从尾开始,这就会有疑问,每个桶都有链表
    //咋能知道拿个链表的尾部是真正最后插入的数据节点呢?看下面的方法
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

LinkedHashMap重写了newNode的方法,在linkNodeLast方法为Entry指定了beforeafter

java 复制代码
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    linkNodeLast(p);
    return p;
}
java 复制代码
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
    //因为插入的都在链表的尾部
    LinkedHashMap.Entry<K,V> last = tail;
    tail = p;
    //如果尾部位空,说明是个新节点,before和after都没有,毕竟就自己一个节点
    if (last == null)
        head = p;
    else {
        //否则尾部的after就是新节点,新节点的before就是尾部,双向了吧
        p.before = last;
        last.after = p;
    }
}

LinkedHashMap对于插入和删除节点时的维护解析

如果看过我写HashMap解析的时候应该看到我在put方法中有一个方法的注释写的是HashMap是空实现,LinkedHashMap会使用,这个方法就是afterNodeInsertion,插入节点的后处理方法,这个就是个钩子方法,也叫做模版方法。

afterNodeInsertion方法解析

java 复制代码
void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    //removeEldestEntry如果自己实现LRU的时候需要重写,就是逐出数据的条件
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        //移除头节点
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

afterNodeRemoval方法解析

这个也是模版方法,在HashMap移除节点的时候调用,看注释吧

java 复制代码
void afterNodeRemoval(Node<K,V> e) { // unlink
    LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
     //把p的before和after都设置null,断开联系
    p.before = p.after = null;
    //下面就是干一件事,删除节点之后得前后连起来。
    if (b == null)
        head = a;
    else
        b.after = a;
    if (a == null)
        tail = b;
    else
        a.before = b;
}
相关推荐
njnu@liyong8 分钟前
AOP-前置原理-怎么判断和拦截?
java·aop·拦截
末央&13 分钟前
【C++】内存管理
java·开发语言·c++
心之语歌26 分钟前
设计模式 享元模式(Flyweight Pattern)
java·设计模式·享元模式
MTingle27 分钟前
【Java EE】文件IO
java·java-ee
coffee_baby30 分钟前
享元模式详解:解锁高效资源管理的终极武器
java·spring boot·mybatis·享元模式
爱学习的真真子37 分钟前
菜鸟也能轻松上手的Java环境配置方法
java·开发语言
曳渔1 小时前
Java-数据结构-二叉树-习题(三)  ̄へ ̄
java·开发语言·数据结构·算法·链表
shark-chili1 小时前
数据结构与算法-Trie树添加与搜索
java·数据结构·算法·leetcode
白乐天_n1 小时前
FRIDA-JSAPI:Java使用
java·jsapi·frida
lucifer3111 小时前
线程池与最佳实践
java·后端