java集合-ConcurrentHashMap源码详解(基于JDK1.7版本)

    技术2022-07-10  147

    目录

     

    一、概述

    二、源码解读(JDK1.7)

    1,构造函数:

    1.1 无参构造函数

    1.2 有参的构造函数

    2,put方法

    2.1 ensureSegment(j)

    2.2 segment的put方法

    2.3 扩容方法

    3,get方法

    三、小结

    1,加载因子为什么默认是0.75?

    2,扩容对性能开销大怎么办?

    3,现在都2020年的,为什么还要看jdk1.7?


    一、概述

    ConcurrentHashMap是由Segment数组和HashEntry数组组成。Segment继承了ReentrantLock,所以它可以实现锁的功能,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment对一个HashEntry数组进行锁控制,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

    二、源码解读(JDK1.7)

    1,构造函数:

    1.1 无参构造函数

    默认的构造函数里,调用了三个参数的构造函数,传入了默认值:

    // 默认初始容量 static final int DEFAULT_INITIAL_CAPACITY = 16; // 默认的加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认并发级别 static final int DEFAULT_CONCURRENCY_LEVEL = 16; public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); }

    和HashMap相比,默认的初识容量和加载因子都是一样的。

    ConcurrentHashMap多了一个并发级别,这个可以理解为把数组分为多少段,每段数组单独的控制锁。

    1.2 有参的构造函数

    对传入的Map容量、加载因子和并发级别(分段数)进行处理对segment数组(分段数)进行初始化,并对该数据的下标为0的元素进行初始化对segment数组下标为0的位置初始化entry数组

    初始化之后的结构如下图:

    public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { // 判断传入的参数是否合法,如果不合法则会抛出异常 if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); //并发级别最大支持2的16次幂,如果超多这个数则使用最大值:MAX_SEGMENTS = 1 << 16; if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // 寻找一个最佳匹配的2的n次幂的数 int sshift = 0;// 记录循环(左移)了多少次 int ssize = 1;// Segment数组的大小 while (ssize < concurrencyLevel) { // concurrencyLevel为传入的并发级别,每次循环都左移一位,相当于2的的n次幂 // 当ssize这个数大于等于传入的并发级别时,就找到了最佳匹配的一个2的次方数 ++sshift; ssize <<= 1; } // int占32位,减去1左移了多少位,比如说传入的是默认参数值concurrencyLevel等于16,就是左移了4位 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; // 如果初始容量大于最大值则取最大值 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // 使用初始容量除以分段数,得到每段小数组的大小 int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; // 每个段Entry数组的最小容量,默认值是2 int cap = MIN_SEGMENT_TABLE_CAPACITY; // 计算出每段entry数组的大小 while (cap < c) cap <<= 1; // 创建一个segment对象模版,初始化该断Entry数组,并指定该Entry数组的大小和加载因子 Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); // 创建segment数组,元素都为null Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; // 使用unsafe类设置segment数组下标为0的值为模版对象 UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }

    2,put方法

    ConcurrentHashMap的key和value都不可以为null,这点可以从源码中看出来

    根据可计算出hash值根据hash值计算出要放到哪一段(哪个segment对象中)如果该segment对象为null,则初始华调用segment对象的put方法进行put public V put(K key, V value) { Segment<K,V> s; // 如果value为null则抛出异常 if (value == null) throw new NullPointerException(); // 和HashMap一样,要计算出hash值,这里没有判断key是否为null,但是如果key为null,则会报错 int hash = hash(key.hashCode()); // 计算出要放到哪个segment[]下标 int j = (hash >>> segmentShift) & segmentMask; // 使用unsafe取出j下标处的对象,如果为null则创建 if ((s = (Segment<K,V>)UNSAFE.getObject (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); // 调用segment的put方法来设置数据(entry对象) return s.put(key, hash, value, false); }

    下面看一下在j位置初始化segment对象的方法和segment对象的put方法

    2.1 ensureSegment(j)

    在segment数组的j位置初始化segment对象:

    根据下标k计算出该下标位置的内存地址偏移量使用segment[0]位置的模版来得到分段entry数组的大小、加载因子和阈值,并初始化该数组使用cas操作对segment[k]进行赋值,然后返回该对象 private Segment<K,V> ensureSegment(int k) { // 将segment数组赋值为局部变量ss final Segment<K,V>[] ss = this.segments; // k为角标位置,计算出该角标元素的内存地址偏移量 long u = (k << SSHIFT) + SBASE; // raw offset Segment<K,V> seg; // 使用unsage类从内存中获取该角标位置的segment对象,判断是否为空,如果不为空则返回该对象 if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // 使用segment[0]位置的模版对象(在初始化的时候创建了一个模版对象) Segment<K,V> proto = ss[0]; // use segment 0 as prototype int cap = proto.table.length;// 获取模版对象的entry数组长度 float lf = proto.loadFactor;// 加载因子 int threshold = (int)(cap * lf);// 计算出阈值 // 初始化entry数组对象 HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap]; // 到此再检查一遍内存中该对象有没有被初始化(因为这个是可以并发访问的,其他线程可能会进行初始化) if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck // 创建出segment对象 Segment<K,V> s = new Segment<K,V>(lf, threshold, tab); // 把segment对象放入到指定的下标位置 while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) {// 该位置为null才需要方segment对象进行 // 使用原子操作设置segment对象到下标为k的位置,为null是才需要设置 if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break;// 设置成功,跳出循环 } } } return seg;// 在segment[k]位置初始化了segment对象后,返回该对象 }

    2.2 segment的put方法

    put(K key, int hash, V value, boolean onlyIfAbsent)

    分两种情况

    2.2.1,尝试获取锁成功:

    获取entry数组index下标的元素如果不为空则遍厉该链表,判断key:key重复则替换,如果没有找到相同key,则插入一个新的entry对象到链表头插入entry对象后判断是否需要扩容 final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 尝试获取锁,如果获取锁失败,则先使用scanAndLockForPut方法来初始化entiry对象 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; // 获取到锁对象之后: try { // 将该段的entry数组赋值给局部变量 HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; // 使用unsafe对象获取entry数组index下标的entry对象 HashEntry<K,V> first = entryAt(tab, index); // 对该entry对象进行遍厉(单向链表结构) for (HashEntry<K,V> e = first;;) { if (e != null) { K k; // 如果找到key和hash相同的entry对象,则说明key重复,则对该数据进行覆盖,然后跳出循环 if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break;// 替换之后跳出循环 } // 如果没有找到相同的key则继续遍厉查找 e = e.next; } else {// 如果对该entry链表进行遍厉没有找到相同key的对象或者本身该链表就是null,则进入 if (node != null)// 不为null,则说明没有获取到锁,但是在scanAndLockForPut中初始化了该对象 node.setNext(first); else// 为null,则初始化该entry对象 node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; // 判断是否需要扩容 if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else// 如果不需要扩容,则把该entry对象放到entry数组的指定下标位置即可 setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break;// 插入之后跳出循环 } } } finally { unlock();// 释放锁 } return oldValue;// 如果是替换则返回旧的值 }

    2.2.2,尝试获取锁失败

    进入scanAndLockForPut(key, hash, value)方法

    从内存中取出将要插入entry数组下标位置的链表,赋值给e不断的尝试获取锁,同时对链表e进行遍厉,如果链表和将要插入的entry对象都为null则初始化entry对象,然后继续不断的获取锁,知道此时超过指定次数后,调用lock方法阻塞获取锁对entry对象初始化之后,每次尝试获取锁的同时都会对局部变量链表e的头元素与内存中的链表头元素进行比较,如果不同了的话,需要对局部变量链表e进行重新复制,然后对链表e进行重新遍厉最终方法结束的时候会保证获取到锁的 /** * 扫描指定的节点元素,同时尝试获取锁,在返回的时候确保获取锁成功 */ private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { // 在没有获取锁的情况下取出entry数组的entry链表头元素 HashEntry<K,V> first = entryForHash(this, hash); HashEntry<K,V> e = first;// 赋值给局部变量e HashEntry<K,V> node = null;// 定义将要插入的数据node int retries = -1; // negative while locating node while (!tryLock()) { HashEntry<K,V> f; // to recheck first below // 第一次尝试获取锁 if (retries < 0) { if (e == null) {// 如果链表的头节点为null,则初始化将要拆入的entry数据 if (node == null) // speculatively create node node = new HashEntry<K,V>(hash, key, value, null); retries = 0;// 设置为0后,第二次尝试获取锁就不会进入该代码块 } else if (key.equals(e.key))// 如果key相同,说明可能要覆盖,但是不在这里处理 retries = 0; else// 链表头节点不为null,并且还没有找到需要key相同的节点,则继续遍厉该链表 e = e.next; } // MAX_SCAN_RETRIES默认值与cup核心数有关,多核是MAX_SCAN_RETRIES值为64 else if (++retries > MAX_SCAN_RETRIES) { // 尝试获取锁的次数超过一定次数后,则阻塞获取锁 lock(); break; } // retries等于0,并且获取的链表头元素被与entry数组内存中对象不相同时 // 说明该entry数组index位置被其他线程更新过数据,则对局部变量e和first进行重新赋值,然后让循环进入遍厉链表 else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) { e = first = f; // re-traverse if entry changed retries = -1; } } return node; }

    该方法有点绕,它的目的就是在尝试获取锁的时候就对链表进行遍厉处理,如果在此过程中获取锁成功了,数组元素为null的情况下可以直接返回一个初始化好的entry对象,做了一些优化处理。

    2.3 扩容方法

    rehash(node);

    扩容之后插入新数据

    这里扩容只是对segment里的一个hashEntry数组进行扩容,而不是对整个map进行扩容。

    扩容方法是在获取锁之后进行调用的,所以不会存在并发扩容的问题。

    扩容和HashMap一样,都是翻倍的扩容。

    但是在数据迁移的时候做了一点优化:如果链表尾的多个元素在新数组中放在相同下标的话,可以对这多个元素进行一次迁移,可以减少新元素的创建。

    private void rehash(HashEntry<K,V> node) { HashEntry<K,V>[] oldTable = table;// 将entry数组赋值给局部变量 // 对数组容量进行翻倍 int oldCapacity = oldTable.length; int newCapacity = oldCapacity << 1; // 重新设置阈值 threshold = (int)(newCapacity * loadFactor); // 创建一个新的entry空数组 HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity]; int sizeMask = newCapacity - 1; // 遍厉老的数组,然后迁移到新数据(里边做了一点优化,但是有点绕,我自己也绕) // 大概步骤呢就是判断当前节点的下一节点是否和自己被迁移到新数组的同一个下标, // 并且下一个节点必须是尾节点,相当于一次迁移了多个节点的数据,减少迁移次数和减少新节点的创建 for (int i = 0; i < oldCapacity ; i++) { HashEntry<K,V> e = oldTable[i]; if (e != null) { HashEntry<K,V> next = e.next; int idx = e.hash & sizeMask; if (next == null) // Single node on list newTable[idx] = e; else { // Reuse consecutive sequence at same slot HashEntry<K,V> lastRun = e; int lastIdx = idx; for (HashEntry<K,V> last = next; last != null; last = last.next) { int k = last.hash & sizeMask; if (k != lastIdx) { lastIdx = k; lastRun = last; } } newTable[lastIdx] = lastRun; // Clone remaining nodes for (HashEntry<K,V> p = e; p != lastRun; p = p.next) { V v = p.value; int h = p.hash; int k = h & sizeMask; HashEntry<K,V> n = newTable[k]; newTable[k] = new HashEntry<K,V>(h, p.key, v, n); } } } } // 扩容之后插入新数据 int nodeIndex = node.hash & sizeMask; // add the new node node.setNext(newTable[nodeIndex]); newTable[nodeIndex] = node; // 将hashEntry数组更新为扩容之后的数组 table = newTable; }

     

    3,get方法

    通过key计算出segment对象的内存地址偏移量获取该segment对象通过key计算出segment对象中entry数组对象下标的内存地址偏移量获取该entry数组指定下标的元素(链表)遍厉链表找到key和hash值相同的对象,返回该对象的value值 public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; int h = hash(key.hashCode()); // 计算出key对应的segment对象内存地址偏移量 long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // 如果该segment对象和segment对象中的hashEntry数组都不为null,才会去获取key对应的value if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { // ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE:计算出hashEntry数组下标为h的内存地址偏移量 // (HashEntry<K,V>) UNSAFE.getObjectVolatile:获取内存中的hashEntry对象 // 对获取的对象进行遍厉(如果不为null的话) for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);e != null; e = e.next) { K k; // 找到该链表中key和hash值相同的对象,返回value值 if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } // 如果没有找到则返回null return null; }

    三、小结

    1,加载因子为什么默认是0.75?

    在源码中有一段注释,翻译成中文:从统计学上讲,在默认阈值下,当表需要扩容时,只有大约六分之一需要克隆。

    2,扩容对性能开销大怎么办?

    在初始化集合的时候,就根据估算容量的大小来创建指定大小的map集合,让该集合减少不必要的扩容,减小性能开销。

    3,现在都2020年的,为什么还要看jdk1.7?

    为了看大神的代码啊,为了夯实自己的基础啊,为了面试啊!!!

     

    Processed: 0.018, SQL: 9