目录1. java.util.Hashtable类:
2. 使用 java.util.concurrent.ConcurrentHashMap 类:
3. Collections.synchronizedMap()
多线程环境下HashMap线程不安全的体现
1. 添加元素(put)时,存在数据覆盖的问题
2. resize的时候,会出现死循环(jdk1.7)
HashMap 是非线程安全的。在多线程条件下,容易导致死循环,具体表现为CPU使用率100%。因此多线程环境下保证 HashMap 的线程安全性,主要有如下几种方法:
- 使用 java.util.Hashtable 类,此类是线程安全的。
- 使用 java.util.concurrent.ConcurrentHashMap,此类是线程安全的。
- 使用 java.util.Collections.synchronizedMap() 方法包装 HashMap object,得到线程安全的Map,并在此Map上进行操作。
- 自己在程序的关键代码段加锁,保证多线程安全(不推荐)
1. java.util.Hashtable类:
查看该类的源码
public synchronized V get(Object key) {
…… //具体的实现省略,请参考 jdk实现
}
public synchronized V put(K key, V value) {
…… //具体的实现省略,请参考 jdk实现
}
public synchronized V remove(Object key) {
…… //具体的实现省略,请参考 jdk实现
}
上面是 Hashtable 类提供的几个主要方法,包括 get(),put(),remove() 等。注意到每个方法都使用了synchronized,对对象进行加锁,锁住的都是对象整体,不会出现两个线程同时对同一个对象的数据进行操作,因此保证了线程安全性,但是也大大的降低了执行效率。因此是不推荐的。
2. 使用 java.util.concurrent.ConcurrentHashMap 类:
该类是 HashMap 的线程安全版,与 Hashtable 相比, ConcurrentHashMap 不仅保证了访问的线程安全性,而且在效率上有较大的提高。
ConcurrentHashMap的数据结构如下:
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成,Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
每个Segment 原理上等同于一个 Hashtable, ConcurrentHashMap 等同于一个 Segment 的数组。下面是 ConcurrentHashMap 的 put 和 get 方法:
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
public V put(K key, V value) {
if (value == null)
throw new NullPointerException();
int hash = hash(key.hashCode());
return segmentFor(hash).put(key, hash, value, false);
}
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
向 ConcurrentHashMap 中插入数据(put) 或者 读取数据(get),首先都要将相应的 Key 映射到对应的 Segment,因此不用锁定整个对象, 只要对单个的 Segment 操作进行上锁操作就可以了。理论上如果有 n 个 Segment,那么最多可以同时支持 n 个线程的并发访问,从而大大提高了并发访问的效率。
HashMap的键值对允许有null,但是ConCurrentHashMap都不允许
3. Collections.synchronizedMap()
Collections.synchronizedMap()实现原理是Collections中定义了一个SynchronizedMap的内部类,并返回这个类的实例。
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
SynchronizedMap内部类实现了Map接口,在调用方法时使用synchronized来保证线程同步,当然了实际上操作的还是我们传入的HashMap实例,简单的说就是Collections.synchronizedMap()方法帮我们在操作HashMap时自动添加了synchronized来实现线程同步,类似的其它Collections.synchronizedXX方法也是类似原理)。
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
Mutex在构造时默认赋值为this,即所有方法都用的同一个锁,m就是我们传入的map。
参考:ConcurrentHashMap原理分析、Java 非线程安全的HashMap如何在多线程中使用、https://www.jianshu.com/p/24413fa7a50e
多线程环境下HashMap线程不安全的体现
1. 添加元素(put)时,存在数据覆盖的问题
- 线程a:希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行;
- 线程b:和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2. resize的时候,会出现死循环(jdk1.7)
HashMap初始容量大小为16,一般来说,当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大Hash表的尺寸,但是这样一来,整个Hash表里的元素都需要被重算一遍。这叫rehash,这个成本相当的大。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
这个方法的功能是将原来的记录重新计算在新桶的位置,然后迁移过去。
原本[3,A]->[7,B],从oldtable迁移到newtable后,会变为[7,B]->[3,A];
死循环场景:
假设这里有两个线程同时执行了put()
操作,并进入了transfer()
环节;
while(null != e) {
Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了
e.next = newTable[i];
newTable[i] = e;
e = next;
}
线程1:线程1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。
线程2:被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A](原来是[3,A]->[7,B],迁移后变为[7,B]->[3,A])。
线程1:重新被调度执行,此时的e = [3,A], next = [7,B];首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。