Java7之前的HashMap与ConcurrentHashMap原理

470 阅读7分钟

一、HashMap结构图(数组+链表)

HashMap结构 1. entry是一个对象,里面存放key、value、hash值、next(下一个entry对象) 2. HashMap中的数组叫做table。size是指整个上述结构中,entry对象的数量

二、HashMap的put方法

HashMap可以说是JDK中一个的经典的轮子,其中有许多非常实用的方法,但是逻辑最复杂的,还是put(K key, V value)方法。

  • 其中包括了找出一个比当前数大的2的次方数、计算key的hash值、数组扩容以及旧数组转移到新数组的一系列操作及问题。
  • 下图是put方法的执行流程: put方法流程

三、put方法流程详解

3.1 初始化数组:

  • 当第一次放入entry元素,即数组为空时,会初始化数组。
  • 初始化时,将构造方法传入的initCapacity进行计算,算出比initCapacity大的2的次方整数capacity(如7->8、15->16),并创建长度为capacity的数组。
  • 计算比initCapacity大的2的次方整数capacity时,是用的Integer.highestOneBit()方法,这里就不细聊了。

3.2 key == null

  • 因为HashMap允许key为null的情况存在,这种情况,我们会默认它的hash值为0,即放在数组的第一个下标中,即table[0]。

3.3 key != null

  • 计算key的hash值:
 /**
  * 根据key计算hashcode,计算完之后进行散列处理
  * 避免与数组长度进行或运算时,总是得到相同的值
  */
 final int hash(Object k) {
     int h = hashSeed;
//        if (0 != h && k instanceof String) {
//            return sun.misc.Hashing.stringHash32((String) k);
//        }
     h ^= k.hashCode();
     h ^= (h >>> 20) ^ (h >>> 12);
     return h ^ (h >>> 7) ^ (h >>> 4);
 }

可以看到,求出key的hashcode之后,还对哈希值进行了很多次的右移运算,这样做的好处,我们之后再说。

  • 计算key的hash值之后,会让该hash值与数组长度进行&运算,像这样: hash & table.length,得到的值为i,i就是这个key所在的数组下标,而hashcode的作用就是为了可以使数组的下标计算随机化。这样我们就能看出来上述的右移运算的作用了,可以使hash算法的散列化效果充分发挥,如果仅仅只是计算key的hashcode作为最终hash值,那么很有可能会发生数组占用效率低,而链表过长的情况。看到这里不得不感叹,JDK的工程师真的nb。

3.4 替换或创建entry

  • (1)找到相应的数组下标之后,首先判断该元素是否为null,如果为null,那么说明该位置还没有entry元素,直接放入就好。
  • (2) 如果不为null,说明有一个以上的entry元素组成的链表,则遍历该链表,对比key的值。
    • 注意:这里要先对比key的hash值是否相等,如果不相等再用==和equals比较值,这样会提高比较的效率,因为hash值不同的两个对象,一定不相同。
    • 如果key相同,则覆盖之前的value值。
    • 如果整个链表没有相同的key,那么就新建一个entry元素,放入链表头,这就是大家一直说的头插法

3.5 扩容

其实这一步,是创建新的entry数组之前的逻辑。但是因为比较复杂,所以放在这里说明:

  • 每次创建新的entry之前,都会先判断一下:(entry的数量是否达到了阈值)&&(当前新建entry的数组元素是否 != 空),满足条件之后,才会进行扩容;
  • 扩容的过程就是把创建一个比原本长度大两倍的数组,因为数组长度改变,所以要把现有的entry链表分别根据hash值和新长度再计算新数组的下标,这个过程需要遍历整个HashMap,效率很低,所以,当我们在使用HashMap时,要尽量预估好需要存放的entry元素个数,尽量避免扩容

四、HashMap在多线程下,扩容时的弊端

  • 先看扩容时的代码段:
    /**
     * 扩容函数
     *
     * @param newCapacity 扩容之后的数组长度
     */
    private void resize(int newCapacity) {
        Entry<K, V>[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 当现在的数组长度已经到了Integer的最大容量
        if (oldCapacity == Integer.MAX_VALUE) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

翻译一下就是如果当前数组长度已经达到Integer对象所能表示的最大值时,将阈值也设置成Integer最大值(一般不会触发);否则新建一个数组,长度为现任数组的2倍,并将旧数组的元素转移到新数组。

  • 转移数组:transfer方法
    • 代码段:
    /**
     * 将现有的表移动到新表中
     *
     * @param newTable 新表
     */
    void transfer(Entry[] newTable) {
        int newCapacity = newTable.length;
        for (Entry<K, V> e : table) {
            while (e != null) {
                Entry<K, V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

总结一下就是循环整个数组和链表,先计算出key的对应新数组的下标,再将e的next元素指向新数组的下标,最后把e移动到数组下标的元素上。 如果这时候,是多线程环境下进行扩容,因为e.next = newTable[i],所以很容易发生循环链表,详情可以去bilibili搜一下hashMap的底层原理,很多视频讲的都很细。 为了解决这种并发执行的扩容问题,JDK提供了ConcurrentHashMap。

五、并发扩容的解决方案

5.1 从根本上解决:

使用HashMap时,估算好存放进Map的元素数量,这样就不会扩容,就不会引发扩容问题。

5.2 将整个HashMap加上锁:

即JDK的HashTable。HashTable就是将整个HashMap的put和get方法,加上synchronized关键字,然后直接锁住整个方法,这样虽然安全,但是效率极低,笔者并没有在实际的生产环境中见到有人用HashTable。

5.3 尽量不要使用ConcurrentHashMap

只有在不确定HashMap元素数量且还涉及到并发场景的情况下才推荐使用ConcurrentHashMap。

六、Fast-fail

在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException将被抛出,也即Fast-fail策略。

当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的次数)。

HashIterator() {
  expectedModCount = modCount;
  if (size > 0) { // advance to first entry
  Entry[] t = table;
  while (index < t.length && (next = t[index++]) == null)
    ;
  }
}

七、ConcurrentHashMap结构

7.1 结构图:

ConcurrentHashMap

7.2 Segement

可以看到,所谓的并发安全的ConcurrentHashMap,可以看作是若干个小的HashMap组成一个ConcurrentHashMap,每一个小的HashMap都被称为Segement。这样扩容时就不用锁住整个表,只需要锁住当前操作的Segement即可,可以提高执行效率。

7.3 每个Segement里有几个Entry

而每个Segement里有几个Entry,其实取决于初始化ConcurrentHashMap时,传入的一个concurrencyLevel参数——并发级别,根据这个参数进行Segement数组的length计算。而根据整个ConcurrentHashMap的数组长度/Segement数组的length得到每个Segement中的entry数组长度。

7.4 查找元素的过程

Java 7中的ConcurrentHashMap的底层数据结构仍然是数组和链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组。每个Segment包含一个与HashMap数据结构差不多的链表数组。整体数据结构如下图所示。

concurrenthashmap_java7.png

在读写某个Key时,先取该Key的哈希值。并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,接着如同操作HashMap一样操作这个Segment。

7.5 同步方式

Segment继承自ReentrantLock,所以我们可以很方便的对每一个Segment上锁。

对于读操作,获取Key所在的Segment时,需要保证可见性。具体实现上可以使用volatile关键字,也可使用锁。但使用锁开销太大,而使用volatile时每次写操作都会让所有CPU内缓存无效,也有一定开销。ConcurrentHashMap使用如下方法保证可见性,取得最新的Segment。

Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)

获取Segment中的HashEntry时也使用了类似方法

HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
  (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)

对于写操作,并不要求同时获取所有Segment的锁,因为那样相当于锁住了整个Map。它会先获取该Key-Value对所在的Segment的锁,获取成功后就可以像操作一个普通的HashMap一样操作该Segment,并保证该Segment的安全性。 同时由于其它Segment的锁并未被获取,因此理论上可支持concurrencyLevel(等于Segment的个数)个线程安全的并发读写。

7.6 size操作

put、remove和get操作只需要关心一个Segment,而size操作需要遍历所有的Segment才能算出整个Map的大小。一个简单的方案是,先锁住所有Sgment,计算完后再解锁。但这样做,在做size操作时,不仅无法对Map进行写操作,同时也无法进行读操作,不利于对Map的并行操作。

为更好支持并发操作,ConcurrentHashMap会在不上锁的前提逐个Segment计算3次size,如果某相邻两次计算获取的所有Segment的更新次数(每个Segment都与HashMap一样通过modCount跟踪自己的修改次数,Segment每修改一次其modCount加一)相等,说明这两次计算过程中无更新操作,则这两次计算出的总size相等,可直接作为最终结果返回。如果这三次计算过程中Map有更新,则对所有Segment加锁重新计算Size。该计算方法代码如下

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  try {
    for (;;) {
      if (retries++ == RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
          ensureSegment(j).lock(); // force creation
      }
      sum = 0L;
      size = 0;
      overflow = false;
      for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
          sum += seg.modCount;
          int c = seg.count;
          if (c < 0 || (size += c) < 0)
            overflow = true;
        }
      }
      if (sum == last)
        break;
      last = sum;
    }
  } finally {
    if (retries > RETRIES_BEFORE_LOCK) {
      for (int j = 0; j < segments.length; ++j)
        segmentAt(segments, j).unlock();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

八、对比

ConcurrentHashMap与HashMap相比,有以下不同点:

  • ConcurrentHashMap线程安全,而HashMap非线程安全
  • HashMap允许Key和Value为null,而ConcurrentHashMap不允许。
    • 因为get()方法获取值为null会有多义性;可能是没有这个entry,也可能是entry的值为空
    • 在并发的Hash表中,两次调用之间的map映射可能已更改。如果允许设置值为null,那么获取值为null的情况,你并不知道映射是否已经更改。
  • HashMap不允许通过Iterator遍历的同时通过HashMap修改,而ConcurrentHashMap允许该行为,并且该更新对后续的遍历可见

ConcurrentHashMap参考:www.jasongj.com/java/concur…