JDK7中ConcurrentHashMap源码分析

·  阅读 467

一、Unsafe的使用

1.1、获取Unsafe

本篇文章涉及到的代码的JDK版本是1.7
Unsafe, 顾名思义是不安全的, 其里面提供的方法都是较为底层的操作, 比如能够直接操作堆外内存, 比如
synchronized底层用到的monitorEnter和monitorExit同步功能等等, Unsafe对象默认的构造方法是private
所以我们需要通过反射的方式来获取这个对象

Constructor<Unsafe> declaredConstructor = Unsafe.class.getDeclaredConstructor();
declaredConstructor.setAccessible( true );
Unsafe unsafe = declaredConstructor.newInstance();
复制代码

1.2、Unsafe操作普通对象

class Person {
	private String username;

  /* getter / setter */
}

Person person = new Person();
person.setUsername( "zhangsan" );
System.out.println( person.getUsername() );
unsafe.putObjectVolatile( person,
      unsafe.objectFieldOffset( Person.class.getDeclaredField( "username" ) ), "lisi" );
System.out.println( person.getUsername() );

假设我们有一个Person类, 同时在main方法中创建了一个person对象, 开始的时候设置为zhangsan, 然后调用
了unsafe的putObjectVolatile, 会发现, 第一次打印的是zhangsan, 但是第二次打印的就是lisi了

unsafe.objectFieldOffset方法用于获取一个对象的某个属性在内存中的偏移量, 通过上面的代码, 我们获取
了username这个属性在内存的偏移量, 或者我们可以这么理解, 我们一个对象存储在堆中的时候, 是占据了一块
连续的空间, 而对象中的属性就放置在这块空间的某个位置, 即偏移量, 当我们知道一个对象在内存中的位置的
时候, 再根据属性的偏移量, 就能得出这个属性在内存中的位置, 从而可以直接操控内存了

unsafe.putObjectVolatile, 就是用于更新一个属性的值, 比如更新person对象的username属性, 更新这个
属性, 就需要获取属性的偏移量, 即利用objectFieldOffset方法

unsafe还有一个putOrderedObject方法, 其参数和功能跟putObjectVolatile是一样的, 不同的地方在于, 前
者是快速写入, 不保证内存的可见性, 而后者有volatile语义, 能够保证内存的可见性, 所以两个方法的区别为
是否保证了内存的可见性
复制代码

1.3、Unsafe操作数组

String[] arr = new String[]{ "zhangsan1", "zhangsan2", "zhangsan3" };
long firstElementOffset = unsafe.arrayBaseOffset( arr.getClass() );
long perElementSize = unsafe.arrayIndexScale( arr.getClass() );
unsafe.putOrderedObject( arr, firstElementOffset + perElementSize , "lisi" );
System.out.println(Arrays.toString( arr ) );

数组在堆中的存储也是一段连续的空间, unsafe.arrayBaseOffset方法用于获取数组中第一个元素在内存的偏
移量, unsafe.arrayIndexScale用于获取数组中每个元素占据内存的大小, 所以当我们要获取第二个元素在内
存中的偏移量的时候, 只需将这两个方法的返回值进行相加就可以得到了, 上面的代码就是一个简单的例子, 通
过输出结果我们可以发现, 第二个元素已经变成了lisi
复制代码

二、ConcurrentHashMap源码分析

2.1、理论分析

我们知道HashMap是线程不安全的, 为了能够使得HashMap的操作是线程安全的, 我们通常会将这些操作放置在
一个同步块中, 然而这就会使得HashMap的操作是并行的, 效率就受到了极大的影响, 而ConcurrentHashMap则
对这种情况进行了改善, 我们对HashMap进行同步操作的时候, 是使得整个HashMap的每一步操作都放置在同步
块中, 而ConcurrentHashMap则是进行了拆分, 使得一个put操作仅仅锁住一部分的数据, 这样就极大的提高了
并发的程度, 如下图所示, 是ConcurrentHashMap的内部数据结构, 在最外层有一个数组X, 数组X中每个索引下
又有一个数组Y, 数组Y中每个索引下维护了一个链表, 其实联合上一篇对HashMap源码的分析, 我们其实可以发
现数组Y其实就是一个HashMap数据结构, 确实如此

当我们进行添加操作的时候, 会先计算出该key-value在数组X中的索引, 然后再计算出在数组Y中的索引, 最后
执行添加操作, 而为了并发安全与提高并发程度, 我们的锁仅仅会锁住数组Y, 换句话说, 数组X中的每个单独的
数组Y都会拥有一把独立的锁, 一次put操作, 仅仅会锁住对应的数组Y, 而不会把整个数组X给锁住
复制代码

02_JDK7的ConcurrentHashMap.png

2.2、数据结构分析

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}

在上一小节中, 我们知道数组Y其实是和HashMap的结构一样的, 而HashEntry就是数组Y中每个元素的存储结构
可以看到, 跟HashMap中的Entry是一样的

static final class Segment<K,V> extends ReentrantLock{
  volatile HashEntry<K,V>[] table;
  int modCount;
  int count;
  int threshold;
  float loadFactor;
}

数组X中每个元素是Segment对象, 根据上一小节中的分析可以知道, 每个Segment对象应该有自己独立的一把锁
而Segment对象则是通过继承于ReentrantLock来保证自己独立的一把锁的, 更多的是, 每个Segment对象中会
维护一个HashEntry构成的table, 即我们说的数组Y, 同时Segment中还会有modCount、count(等价于
HashMap中的size)、threshold、loadFactor, 这些在HashMap中都是存在的, 所以说Segment对象其实就像
是一个小型的HashMap, 而ConcurrentHashMap就像是由一个个的HashMap构成, 对每个HashMap的操作通过
ReentrantLock保证了并发安全

在ConcurrentHashMap中, Segment数组本身是不会进行扩容的, 而Segment中的HashEntry数组却是会进行扩
容, 所以Segment数组的长度其实可以认为是并发级别, 长度越长, 则每个Segment中的元素可能就会越少, 并
发程度就更高了, 长度越短, 则每个Segment中的元素可能就会越多, 并发程度就更低了
复制代码

2.3、构造方法分析

public ConcurrentHashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
    ............................
}

initialCapacity指的是数组X中所有数组Y大小的总和, 即所有HashEntry数组加起来的大小, loadFactor即
负载因子, 数组Y的长度与负载因子的乘积, 则是该数组Y进行扩容时需要达到的元素个数阈值大小,
concurrencyLevel则是并发级别, 即用于表示Segment[]这个数组的长度, 然而因为有参构造方法是public
的, 所以允许开发者自己提供参数值, 根据上一篇文章对HashMap的源码分析, 我们了解到了, 计算一个元素应
该放到哪个索引位置, 是采用元素计算得到的哈希值和数组的长度进行与操作得到的, 而其中一个限制就是数组
的长度必须是2的幂次方, 所以这个带参数的构造方法中, 需要根据用户传入的值计算出一个大于该值的并且是2
的幂次方的值, 最后才创建数组

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    // Find power-of-two sizes best matching arguments
    int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;
    ..............................
}
第一段代码, 其实就是为了计算出Segment数组的大小, 即sszie(segment size), sszie初始为0001, 利用一
个while循环, 找到第一个大于concurrencyLevel的2的幂次方的值, 从而确定了segment数组的长度, 而
segmentMask则是用于计算一个元素应该放在segment数组的哪个索引下时用于与操作的值, 即根据一个元素计算
出一个hash值后, 再根据hash & segmentMask来获取索引, 所以segmentMask必须是ssize - 1, 而其原因在
分析HashMap源码的时候已经进行详细分析了(保证高位均为0, 低位均为1), segmentShift是用于之后利用
Unsafe对象操作Segment数组中的元素的, 之后我们再进行分析

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
  if (initialCapacity > MAXIMUM_CAPACITY)
      initialCapacity = MAXIMUM_CAPACITY;
  int c = initialCapacity / ssize;
  if (c * ssize < initialCapacity)
      ++c;

  int cap = MIN_SEGMENT_TABLE_CAPACITY;
  while (cap < c)
      cap <<= 1;

  .....................................
}
第二段代码, 首先通过initialCapacity / ssize获取每个Segment对象中HashEntry的平均大小, 如果
initialCapacity为17, 而ssize为16, 那么经过触发后得到的值就是1了, 所以此时会有余数, 这个时候会使
得c进行加1操作, 确保总的容量是大于等于initialCapacity的

然而, 我们认为, 每个Segment对象中维护的应该是一个小型的HashMap, 所以HashEntry数组的容量应该也是
2的幂次方, 所以cap先设置为为最小的2的幂次方, 当其小于c的时候, 就进行左移操作, 从而获取到第一个大于
等于c的值, 并且这个值是2的幂次方, 这个值就是HashEntry数组的大小

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
    // create segments and segments[0]
    Segment<K,V> s0 =
        new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                          (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}
第三段代码, 开始初始化ConcurrentHashMap中的数组了, 创建一个Segment对象, 设置为s0, 原因是通过将这
个对象存储在Segment数组索引为0的位置下, 之后的添加操作中, 如果Segment[]数组其他位置需要创建该对象
时, loadFactor、HashEntry数组的长度等都可以通过s0来获取
在此之后, 创建Segment数组ss, 最后赋值给segments这个成员属性, 并且利用UNSAFE.putOrderedObject,
指定偏离量为SBASE(Segment数组中索引为0的偏移量, 在static块中初始化), 从而将s0放到了Segment数组
索引为0的位置
复制代码

2.4、put方法

1、整体分析

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
            (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

利用hash方法计算出key对应的哈希值, 这个hash方法跟HashMap中的有点类似, 有兴趣的可以去研究下细节,
(hash >>> segmentShift) & segmentMask, 这一段代码, 是用来计算key应该放在哪个Segment中的, 即计
算Segment[]数组的索引, 所以需要跟segmentMask(上面的分析中可以得到这个值为Segment数组的长度 - 1)
进行与操作, segmentShift被称为段偏移量, 在构造方法中被赋予值, 计算Segment数组长度的时候, 需要获
得一个大于等于concurrentLevel的值, 并且这个值是2的幂次方, 所以会涉及到左移操作, 而segmentShift则
等于32减去左移的位数, 比如左移了4位, 那么在计算key对应的索引的时候, 就会使得哈希值右移
(32 - 4 = 28)位, 从而只保留了高四位, 利用高四位与segmentMask进行与操作从而获取到一个合适的索引

取到了索引j以后, 通过Unsafe的getObject方法获取到Segment数组中对应索引j所在的Segment对象, 如果
这个对象为空, 则利用ensureSegment来创建这个对象, 最后调用Segment对象的put方法将key-value给放入
到HashEntry[]数组合适的位置上

根据之前我们对Unsafe对象的操作分析, 获取一个数组指定索引位置, 需要用到arrayBaseOffset方法获取数
组中第一个元素的偏移量以及利用arrayIndexScale方法获取到数组中每个位置占据的对象大小, 在static块
中有如下代码:
    Class sc = Segment[].class;
    SBASE = UNSAFE.arrayBaseOffset(sc);
    ss = UNSAFE.arrayIndexScale(sc);
    SSHIFT = 31 - Integer.numberOfLeadingZeros(ss);

(j << SSHIFT) + SBASE), 这段代码等价于j * UNSAFE.arrayIndexScale(Segment[].class) + c, 即获
取Segment数组中索引位置为j的Segment对象, 大家有兴趣的话可以去写个demo试试, 所以put方法中的第二个
if判断的功能就清晰了: 获取Segment[]数组索引位置为j的Segment对象
复制代码

2、ensureSegment方法创建Segment对象

private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    ................
}
先来看这两行代码, 有了上面的分析后, 我们可以很清楚的了解到, 这就是获取Segment[]数组中索引位置为k
中对应的Segment对象的偏移量

private Segment<K,V> ensureSegment(int k) {
    ..........................
    Segment<K,V> seg;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        Segment<K,V> proto = ss[0]; // use segment 0 as prototype
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // recheck
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                    == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

如果索引位置为k中Segment对象为空, 那么就取得索引位置为0中的Segment对象, 在构造方法的分析中, 我们
知道在Segment数组初始化完成后会往索引位置为0的地方放入一个Segment对象, 这样之后其他索引位置就能利
用这个对象中保留的属性来创建Segment对象

随后初始化HashEntry数组的, 以及threshold负载因子, 因为ConcurrentHashMap要保证多线程安全, 即可能
在多线程中调用, 所以在完成上述工作后再一次判断索引位置为k的地方是否有其他线程已经放入了Segment对象

如果没有, 则创建Segment对象, 然后利用一个while循环, 并且利用CAS来将Segment对象放入到对应的索引位
置, 只有当CAS更新成功, 或者从索引位置为k中取到了Segment对象时, 才会退出while循环, 即最后这个
while循环加CAS就是用来保证多线程情况下并发安全的
复制代码

3、Segment对象的put方法

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        .....................
    } finally {
        unlock();
    }
    return oldValue;
}

先来看看这段缩减后的代码, 最上层是一个拿锁的操作, 然后利用try...finally语句来完成释放锁的操作,
根据文章开头对ConcurrentHashMap的理论分析中, 我们知道, ConcurrentHashMap采用的是分段锁, 即每次
put操作只会锁住一段数据, 即只会锁住一个Segment对象中的HashEntry[]数组, 而这个锁的来源是Segment
对象本身, 因为Segment对象继承了ReentrantLock, 所以拥有ReentrantLock所有的方法

在这第一段代码中, 先通过tryLock尝试获取锁, 如果没有获取成功(有其他线程在持有锁), 则调用
scanAndLockForPut方法不停的获取锁, 由于第一次的tryLock没有获取到锁, 此时为了能够做一些其他的事
情, 从而提高性能, 所以在scanAndLockForPut方法中会计算key对应在HashEntry数组中的索引, 进而扫描该
索引下的链表中是否存在对应的值, 如果不存在, 则会创建对应的HashEntry对象, 进而返回, 所以这个node其
实就是当key-value所在的HashEntry链表中没有对应key时创建的, 这样就能够在try...finally语句块中直
接进行添加操作, 而可以省去创建HashEntry的时间了, 可见, ConcurrentHashMap的作者对性能的要求是很极
致的

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    ..........................................
    HashEntry<K,V>[] tab = table;
    int index = (tab.length - 1) & hash;
    HashEntry<K,V> first = entryAt(tab, index);
    for (HashEntry<K,V> e = first;;) {
        if (e != null) {
            K k;
            if ((k = e.key) == key ||
                (e.hash == hash && key.equals(k))) {
                oldValue = e.value;
                if (!onlyIfAbsent) {
                    e.value = value;
                    ++modCount;
                }
                break;
            }
            e = e.next;
        }
        else {
            if (node != null)
                node.setNext(first);
            else
                node = new HashEntry<K,V>(hash, key, value, first);
            int c = count + 1;
            if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                rehash(node);
            else
                setEntryAt(tab, index, node);
            ++modCount;
            count = c;
            oldValue = null;
            break;
        }
    }
    ..........................................
}

这一段代码就是在try...finally语句块中的了, 所要完成的就是往HashEntry数组中添加元素, 利用entryAt
方法获取Segment[]数组中指定索引下的第一个元素, 然后利用一个for循环来遍历这个元素形成的链表

如果遍历到的元素不为空, 则判断是否与key相等, 如果相等, 则当onlyIfAbsent为false的时候才替换这个对
应的值, onlyIfAbsent在我们调用ConcurrentHashMap的putIfAbsent方法的时候才会为true, 否则为false
如果有找到与key相等的元素, 那么就直接break了, 否则就继续下一次循环, 直到e为null为止, 这个时候说明
在这个链表中没有与key相等的元素, 从而要执行添加元素的操作了, 即for循环中的else逻辑

如果node不为空, 说明是tryLock拿锁失败了, 然后在scanAndLockForPut中经过扫描所有的元素中不存在这个
key, 进而创建的HashEntry对象, 这个时候, 就直接将其next指向该Segment中HashEntry对应索引下的第一个
元素, 如果c大于了阈值, 并且HashEntry的长度没有达到阈值, 那么就会调用rehash方法进行扩容操作, 否则
则利用setEntryAt方法将node这个节点设置到HashEntry[]数组对应索引的位置, 这样就完成了添加操作

rehash方法相对有点复杂, 主要是ConcurrentHashMap的作者想要提高性能才使得这么复杂的, 简单的实现就
是计算出元素在新数组中的索引, 然后将元素一个个的搬过去, 在rehash方法中, 对于HashEntry[]数组指定
索引(比如X)下的HashEntry链表, 在将HashEntry数组扩容了以后, 需要重新计算这个链表中的元素应该放在
哪个索引位置, 在rehash方法中进行了一次优化, 将从尾部开始, 如果连续的一串元素计算出来的索引位置是相
同的, 那么就会直接将这一串元素最上面那个元素放到新数组对应的位置, 比如索引i有如果下元素
A -> B -> C -> D -> E, 如果C、D、E这三个元素计算出在新的数组中的索引位置是相同的(比如新的索引位
置为Y, 该索引下没有值), 那么就会直接执行下面这样的操作(伪代码):
    newTable[Y] = C
这样就直接将C及其后面的元素一次迁移过去了, 具体的源码就不分析了, 大家有兴趣可以去研究一下, 总体的
思路我已经用文字进行了简单的描述
复制代码

2.5、get方法

get方法就不进行仔细分析了, 有了put方法的基础, get方法看起来会非常的简单, 这里需要提及的一点是,
整个get方法是没有加锁, 所以在使用ConcurrentHashMap的时候要非常注意这一点
复制代码
分类:
后端
标签: