【并发容器】ConcurrentHashMap 在 Java7 和 8 有何不同?

TodoCoder大约 11 分钟JavaConcurrentHashMap并发容器Java并发容器

  大家好,我是Coder哥,在技术日新月异的今天,真正应该花费时间学习的是那些不变的编程思想,上一章 【并发容器】HashMap 为什么是线程不安全的?open in new window 聊了HashMap 我们知道HashMap 是线程不安全的容器,那线程安全的是哪个呢,我们本章聊一下线程安全的HashMap: ConcurrentHashMap

在 Java 8 中,对于 ConcurrentHashMap 这个常用的工具类进行了很大的升级,对比之前 Java 7 版本在诸多方面都进行了调整和变化。不过,在 Java 7 中的 Segment 的设计思想依然具有参考和学习的价值,所以在很多情况下面试官都会问你:ConcurrentHashMap 在 Java 7 和 Java 8 中的结构分别是什么?它们有什么相同点和不同点?所以本课时就对 ConcurrentHashMap 在这两个版本的特点和性质进行对比和介绍。

Java 7 版本的 ConcurrentHashMap

我们首先来看一下 Java 7 版本中的 ConcurrentHashMap 的结构示意图:

Java 7 ConcurrentHashMap 的结构示意图
Java 7 ConcurrentHashMap 的结构示意图

从图中我们可以看出,在 Java 7 中,ConcurrentHashMap 使用了 Segment 进行分段锁设计,这是一个相对高效的并发容器。每个 Segment 都继承自 ReentrantLock,每个段拥有独立的锁,因此操作不同的段时不会相互干扰,显著提高了并发效率。相比于 Hashtable,ConcurrentHashMap 通过分段锁减少了锁的粒度,避免了全局锁的竞争。

每个 Segment 内部结构类似于 HashMap,采用数组和链表的组合(拉链法)。在 Java 7 中,ConcurrentHashMap 默认初始化了 16 个 Segment,这意味着最多支持 16 个线程并发访问不同的 Segment。需要注意的是,这个数量一旦初始化后就不能动态扩展。

Java 8 版本的 ConcurrentHashMap

Java 8 中,ConcurrentHashMap 经过了彻底的重写,设计和实现发生了较大变化,源码量从 Java 7 的 1000 行大幅增加到 6000 多行。为了帮助大家理解 Java 8 的设计思路,我们首先从整体架构入手,然后再深入探讨细节。

Java 8 ConcurrentHashMap 结构图
Java 8 ConcurrentHashMap 结构图

在 Java 8 中,ConcurrentHashMap 的内部结构引入了三种不同的节点类型:

  • 空节点: 空着的位置表示当前位置还没有元素。
  • 链表节点: 和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,当哈希冲突发生时,多个元素通过链表连接。
  • 红黑树节点: 当链表长度超过某个阈值(默认 8)时,链表会转化为红黑树,以提升查找效率。这是 Java 7 的 ConcurrentHashMap 中所没有的结构,在此之前我们可能也很少接触这样的数据结构。

这一改进解决了 Java 7 中链表查找效率低的问题。在 Java 8 中,红黑树的引入确保了即使在大量哈希冲突的情况下,查找操作的时间复杂度也能保持在 O(log n),相比 Java 7 中可能退化到 O(n) 的链表查找,性能有了显著提升。所以,Java 8 的一个重要变化就是引入了红黑树的设计,由于红黑树并不是一种常见的数据结构,所以我们在此简要介绍一下红黑树的特点。

红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。

由于自平衡的特点,即左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是 O(log(n)) 级别;反观链表,它的时间复杂度就不一样了,如果发生了最坏的情况,可能需要遍历整个链表才能找到目标元素,时间复杂度为 O(n),远远大于红黑树的 O(log(n)),尤其是在节点越来越多的情况下,O(log(n)) 体现出的优势会更加明显。

红黑树的一些其他特点:

  • 每个节点要么是红色,要么是黑色.
  • 根节点永远是黑色的。
  • 红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。
  • 从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点。

正是由于这些规则和要求的限制,红黑树保证了较高的查找效率,所以现在就可以理解为什么 Java 8 的 ConcurrentHashMap 要引入红黑树了。好处就是避免在极端的情况下冲突链表变得很长,在查询的时候,效率会非常慢。而红黑树具有自平衡的特点,所以,即便是极端情况下,也可以保证查询效率在 O(log(n))。

分析 Java 8 版本的 ConcurrentHashMap 的重要源码

前面我们讲解了 Java 7 和 Java 8 中 ConcurrentHashMap 的主体结构,下面我们深入源码分析。由于 Java 7 版本已经过时了,所以我们把重点放在 Java 8 版本的源码分析上。

Node 节点

我们先来看看最基础的内部存储结构 Node,这就是一个一个的节点,如这段代码所示:

static class Node<K,V> implements Map.Entry<K,V> {    
	final int hash;    
	final K key;    
	volatile V val;    
	volatile Node<K,V> next;    
	// ...
}

可以看出,每个 Node 里面是 key-value 的形式,并且把 value 用 volatile 修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next 指针,方便产生链表结构。

下面我们看两个最重要、最核心的方法。

put 方法源码分析

put 方法的核心是 putVal 方法,为了方便阅读,我把重要步骤的解读用注释的形式补充在下面的源码中。我们逐步分析这个最重要的方法,这个方法相对有些长,我们一步一步把它看清楚。

final V putVal(K key, V value, boolean onlyIfAbsent) {    
	if (key == null || value == null) {        
		throw new NullPointerException();    
	}    
	//计算 hash 值    
	int hash = spread(key.hashCode());    
	int binCount = 0;    
	for (Node<K, V>[] tab = table; ; ) {        
    Node<K, V> f;        
    int n, i, fh;        
    //如果数组是空的,就进行初始化        
    if (tab == null || (n = tab.length) == 0) {            
    	tab = initTable();        
    }        
    // 找该 hash 值对应的数组下标        
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {            
    	//如果该位置是空的,就用 CAS 的方式放入新值            
    	if (casTabAt(tab, i, null,new Node<K, V>(hash, key, value, null))) {                				break;            
    	}        
    }        
    //hash值等于 MOVED 代表在扩容        
    else if ((fh = f.hash) == MOVED) {            
    	tab = helpTransfer(tab, f);        
    }        
    //槽点上是有值的情况        
    else {            
    	V oldVal = null;            
    	//用 synchronized 锁住当前槽点,保证并发安全            
    	synchronized (f) {                
    		if (tabAt(tab, i) == f) {                    
    			//如果是链表的形式                    
    			if (fh >= 0) {                        
    				binCount = 1;                        
    				//遍历链表                        
    				for (Node<K, V> e = f; ; ++binCount) {                            
    					K ek;                            
    					//如果发现该 key 已存在,就判断是否需要进行覆盖,然后返回                            							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;                            
    					//到了链表的尾部也没有发现该 key,说明之前不存在,就把新值添加到链表的最后                            							if ((e = e.next) == null) {                                
    						pred.next = new Node<K, V>(hash, key, value, null);                                								 break;                            
    					}                        
    				}                    
    			}                    
    			//如果是红黑树的形式                    
    			else if (f instanceof TreeBin) {                        
    				Node<K, V> p;                        
    				binCount = 2;                        
    				//调用 putTreeVal 方法往红黑树里增加数据                        
    				if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key, value)) != null) {                            						 	 oldVal = p.val;                            
    					if (!onlyIfAbsent) {                                
    					p.val = value;                            
    				}                        
    			}                    
    		}                
    	}            
    }            
    if (binCount != 0) {                
    	//检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值是 8                		   if (binCount >= TREEIFY_THRESHOLD) {                    
    	treeifyBin(tab, i);                
    }                
    //putVal 的返回是添加前的旧值,所以返回 oldVal                
    if (oldVal != null) {                    
    	return oldVal;                
    }                
    break;            
   }        
  }    
 }    
 addCount(1L, binCount);    
 return null;
}

通过以上的源码分析,我们对于 putVal 方法有了详细的认识,可以看出,方法中会逐步根据当前槽点是未初始化、空、扩容、链表、红黑树等不同情况做出不同的处理。

get 方法源码分析

get 方法比较简单,我们同样用源码注释的方式来分析一下:

public V get(Object key) {    
	Node<K,V>[] tab; 
	Node<K,V> e, p; int n, eh; K ek;    
  //计算 hash 值    
  int h = spread(key.hashCode());    
  //如果整个数组是空的,或者当前槽点的数据是空的,说明 key 对应的 value 不存在,直接返回 null    
  if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {        
    //判断头结点是否就是我们需要的节点,如果是则直接返回        
    if ((eh = e.hash) == h) {            
      if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val;        
    } 
    //如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找        
    else if (eh < 0) 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 的过程:

  1. 计算 Hash 值,并由此值找到对应的槽点;
  2. 如果数组是空的或者该位置为 null,那么直接返回 null 就可以了;
  3. 如果该位置处的节点刚好就是我们需要的,直接返回该节点的值;
  4. 如果该位置节点是红黑树或者正在扩容,就用 find 方法继续查找;
  5. 否则那就是链表,就进行遍历链表查找。

对比Java7 和Java8 的异同和优缺点

数据结构

正如本课时最开始的两个结构示意图所示,Java 7 采用 Segment 分段锁来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树,在这一点上它们的差别非常大。

Java 7 ConcurrentHashMap 的结构示意图
Java 7 ConcurrentHashMap 的结构示意图
Java 8 ConcurrentHashMap 的结构示意图
Java 8 ConcurrentHashMap 的结构示意图

并发度

Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。

但是到了 Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高。

保证并发安全的原理

Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。

Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized 保证线程安全。

遇到 Hash 碰撞

Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。

Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。

查询时间复杂度

Java 7 遍历链表的时间复杂度是 O(n),n 为链表长度。

Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n)),n 为树的节点个数。

最后

从 Java 7 到 Java 8,ConcurrentHashMap 进行了结构和实现的重大改进。Java 8 引入的红黑树、大幅提高了并发度并优化了性能,使得在高并发环境下的 ConcurrentHashMap 更加高效。通过这些优化,ConcurrentHashMap 成为 Java 中处理高并发场景的重要工具之一。掌握这些细节不仅有助于我们更好地理解并发容器的设计,还能帮助我们在面试中应对有关并发容器的经典问题。

希望这篇文章能够为你理解 Java 中并发容器的演变过程提供有价值的参考。

书籍推荐:
《计算机内功修炼系列》https://www.todocoder.com/pdf/jichu/001001.htmlopen in new window
《Java编程思想》https://www.todocoder.com/pdf/java/002002.htmlopen in new window
《Java并发编程实战》https://www.todocoder.com/pdf/java/002004.htmlopen in new window