ConcurrentHashMap,hashtable以及两者比较

转载地址:https://blog.csdn.net/cheidou123/article/details/58070525

1.HashTable原理

hashtable继承于 Dictionary 类,实现了 Map, Cloneable, java.io.Serializable接口。存储的是内容是键值对,通过拉链法实现哈希表。

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下

2.ConcurrentHashMap原理

⑴概述

JDK1.5开始,为了保证hashmap的线程安全,Doug Lea给我们带来了concurrent包。
使用的是分段锁的概念,把一个大的MAP拆分成几个类似hashtable结构,根据key.hashCode()来决定把key放到哪个HashTable中。就是把Map分成了N个Segment,Segment是一种可重入锁ReentrantLock,put和get的时候,都是现根据key.hashCode()算出放到哪个Segment中,只有在同一个分段内才存在竞态关系。试想,原来只能一个线程进入,现在却能同时16个(默认是分16段)写线程进入(写线程才需要锁定,而读线程并不锁定,只有在求size等操作时才需要锁定整个表),并发性的提升是显而易见的。
ConcurrentHashMap中的HashEntry相对于HashMap中的Entry有一定的差异性:HashEntry中的value以及next都被volatile修饰,这样在多线程读写过程中能够保持它们的可见性


ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点)

⑵定位操作

[java]  view plain  copy
  1. final Segment<K,V> segmentFor(int hash) {    
  2.     return segments[(hash >>> segmentShift) & segmentMask];    
  3. }  
可以看到ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再哈希。再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率。

⑶数据结构
ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。每个Segment相当于一个子Hash表,它的数据成员如下:

[java]  view plain  copy
  1. static final class Segment<K,V> extends ReentrantLock implements Serializable {      
  2.          /**  
  3.           * The number of elements in this segment's region.  
  4.           */  
  5.          transient volatileint count;    
  6.          /**  
  7.           * Number of updates that alter the size of the table. This is  
  8.           * used during bulk-read methods to make sure they see a  
  9.           * consistent snapshot: If modCounts change during a traversal  
  10.           * of segments computing size or checking containsValue, then  
  11.           * we might have an inconsistent view of state so (usually)  
  12.           * must retry.  
  13.           */  
  14.          transient int modCount;    
  15.          /**  
  16.           * The table is rehashed when its size exceeds this threshold.  
  17.           * (The value of this field is always <tt>(int)(capacity *  
  18.           * loadFactor)</tt>.)  
  19.           */  
  20.          transient int threshold;    
  21.          /**  
  22.           * The per-segment table.  
  23.           */  
  24.          transient volatile HashEntry<K,V>[] table;    
  25.          /**  
  26.           * The load factor for the hash table.  Even though this value  
  27.           * is same for all segments, it is replicated to avoid needing  
  28.           * links to outer object.  
  29.           * @serial  
  30.           */  
  31.          final float loadFactor;    
  32.  }   
 count用来统计该段数据的个数,它是volatile,它用来协调修改和读取操作,以保证读取操作能够读取到几乎最新的修改。协调方式是这样的,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操作开始都要读取count的值。这利用了 Java 5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述。threashold用来表示需要进行rehash的界限值。table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得能够读取到最新的 table值而不需要同步。loadFactor表示负载因子。

⑷删除操作remove(key)

[java]  view plain  copy
  1. public V remove(Object key) {    
  2.    hash = hash(key.hashCode());     
  3.    return segmentFor(hash).remove(key, hash, null);     
  4. }  
整个操作是先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。
下面是Segment的remove方法实现:
[java]  view plain  copy
  1. V remove(Object key, int hash, Object value) {    
  2.      lock();    
  3.      try {    
  4.          int c = count - 1;    
  5.          HashEntry<K,V>[] tab = table;    
  6.          int index = hash & (tab.length - 1);    
  7.          HashEntry<K,V> first = tab[index];    
  8.          HashEntry<K,V> e = first;    
  9.          while (e != null && (e.hash != hash || !key.equals(e.key)))    
  10.              e = e.next;    
  11.          V oldValue = null;    
  12.          if (e != null) {    
  13.              V v = e.value;    
  14.              if (value == null || value.equals(v)) {    
  15.                  oldValue = v;    
  16.   
  17.                  // All entries following removed node can stay    
  18.                  // in list, but all preceding ones need to be    
  19.                  // cloned.    
  20.                  ++modCount;    
  21.                  HashEntry<K,V> newFirst = e.next;    
  22.                  *for (HashEntry<K,V> p = first; p != e; p = p.next)    
  23.                      *newFirst = new HashEntry<K,V>(p.key, p.hash,    
  24.                                                    newFirst, p.value);    
  25.                  tab[index] = newFirst;    
  26.                  count = c; // write-volatile    
  27.              }    
  28.          }    
  29.          return oldValue;    
  30.      } finally {    
  31.          unlock();    
  32.      }    
  33.  }  
整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用。
中间那个for循环是做什么用的呢?(*号标记)从代码来看,就是将定位之后的所有entry克隆并拼回前面去,但有必要吗? 每次删除一个元素就要将那之前的元素克隆一遍?这点其实是由entry的不变性来决定的,仔细观察entry定义,发现除了value,其他所有属性都是用final来修饰的,这意味着在第一次设置了next域之后便不能再改变它,取而代之的是将它之前的节点全都克隆一次 。至于entry为什么要设置为不变性,这跟不变性的访问不需要同步从而节省时间有关。
整个remove实现并不复杂,但是需要注意如下几点。第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是 volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。

⑸get操作

ConcurrentHashMap的get操作是直接委托给Segment的get方法,直接看Segment的get方法:

[java]  view plain  copy
  1. V get(Object key, int hash) {    
  2.      if (count != 0) { // read-volatile 当前桶的数据个数是否为0   
  3.          HashEntry<K,V> e = getFirst(hash);  得到头节点  
  4.          while (e != null) {    
  5.              if (e.hash == hash && key.equals(e.key)) {    
  6.                  V v = e.value;    
  7.                  if (v != null)    
  8.                      return v;    
  9.                  return readValueUnderLock(e); // recheck    
  10.              }    
  11.              e = e.next;    
  12.          }    
  13.      }    
  14.      returnnull;    
  15.  }   
get操作不需要锁。 原因是它的get方法里将要使用的共享变量都定义成volatile
 第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count 变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。

 接下来就是根据hash和key对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在 table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。

 最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为 put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为 HashEntry中的value不是final的,非同步读取有可能读取到空值。仔细看下put操作的语句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。这里当v为空时,可能是一个线程正在改变节点,而之前的get操作都未进行锁定,根据bernstein条件,读后写或写后读都会引起数据的不一致,所以这里要对这个e重新上锁再读一遍,以保证得到的是正确值。

[java]  view plain  copy
  1. V readValueUnderLock(HashEntry<K,V> e) {    
  2.     lock();    
  3.     try {    
  4.         return e.value;    
  5.     } finally {    
  6.         unlock();    
  7.     }    
  8. }  
 如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景
⑹put方法

同样地put操作也是委托给段的put方法。下面是段的put方法:

[java]  view plain  copy
  1. V put(K key, int hash, V value, boolean onlyIfAbsent) {    
  2.      lock();    
  3.      try {    
  4.          int c = count;    
  5.          if (c++ > threshold) // ensure capacity    
  6.              rehash();    
  7.          HashEntry<K,V>[] tab = table;    
  8.          int index = hash & (tab.length - 1);    
  9.          HashEntry<K,V> first = tab[index];    
  10.          HashEntry<K,V> e = first;    
  11.          while (e != null && (e.hash != hash || !key.equals(e.key)))    
  12.              e = e.next;    
  13.          V oldValue;    
  14.          if (e != null) {    
  15.              oldValue = e.value;    
  16.              if (!onlyIfAbsent)    
  17.                  e.value = value;    
  18.          }    
  19.          else {    
  20.              oldValue = null;    
  21.              ++modCount;    
  22.              tab[index] = new HashEntry<K,V>(key, hash, first, value);    
  23.              count = c; // write-volatile    
  24.          }    
  25.          return oldValue;    
  26.      } finally {    
  27.          unlock();    
  28.      }    
  29.  }  

 该方法也是在持有段锁(锁定整个segment)的情况下执行的,这当然是为了并发的安全,修改数据是不能并发进行的,必须得有个判断是否超限的语句以确保容量不足时能够rehash。接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2^n,这里就不介绍了。而比较难懂的是这句int index = hash & (tab.length - 1),原来segment里面才是真正的hashtable,即每个segment是一个传统意义上的hashtable,如上图,从两者的结构就可以看出区别,这里就是找出需要的entry在table的哪一个位置,之后得到的entry就是这个链的第一个节点,如果e!=null,说明找到了,这是就要替换节点的值(onlyIfAbsent == false),否则,我们需要new一个entry,它的后继是first,而让tab[index]指向它,什么意思呢?实际上就是将这个新entry插入到链头,剩下的就非常容易理解了

  由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。Put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里。
是否需要扩容。在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
如何扩容。扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

另一个操作是containsKey,这个实现就要简单得多了,因为它不需要读取值:

[java]  view plain  copy
  1. boolean containsKey(Object key, int hash) {    
  2.     if (count != 0) { // read-volatile    
  3.         HashEntry<K,V> e = getFirst(hash);    
  4.         while (e != null) {    
  5.             if (e.hash == hash && key.equals(e.key))    
  6.                 returntrue;    
  7.             e = e.next;    
  8.         }    
  9.     }    
  10.     returnfalse;    
  11. }   
⑺size()操作   如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。
  因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
  那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

3.与HashTable比较

⑴  HashTable的线程安全使用的是一个单独的全部Map范围的锁,ConcurrentHashMap抛弃了HashTable的单锁机制,使用了锁分离技术,使得多个修改操作能够并发进行,只有进行SIZE()操作时ConcurrentHashMap会锁住整张表。

⑵  HashTable的put和get方法都是同步方法,  而ConcurrentHashMap的get方法多数情况都不用锁,put方法需要锁。

但是ConcurrentHashMap不能替代HashTable,因为两者的迭代器的一致性不同的,hash table的迭代器是强一致性的,而concurrenthashmap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值