1.问题引入
开发过程使用了HashMap全局变量作为缓存
HashMap<String, String> mCacheMap
写(put)mCacheMap是线程R
读(get)mCacheMap是线程W
Hashmap是非线程安全的集合类,在此场景中RW分属于两个不同线程,会存在读写数据不一致性问题。比如W线程正在更新HashMap过程中,R线程同时读取HashMap,由于没有加锁同步,此时R线程读取到的数据具有不确定性。
此时R线程最终读取到的数据会有哪些可能?会不会出现线程R读取到的既不是R线程本地缓存的值,也不是W线程最新写入的数据,又不是NULL值,而是W线程在写内存过程中的一个中间状态值呢?我们先看下一些书里的观点:
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。《Java并发编程艺术 3.3.4》
书中的观点总结一下就是 : JVM在底层实现上保证了数据访问的安全性,不会出现读取到内存写过程的中间无中生有的值。因此R线程读取到的数据的范围是有限制的,有哪些可能呢,为什么会有这些可能?这需要我们先弄清楚Java内存模型的基本概念。
2.Java内存模型分析
Java的并发,线程之间通信采用共享内存的方式,由Java内存模型(简称JMM)控制。JMM决定一个线程对共享变量的写入何时对其他线程可见。
JMM定义:线程之间共享的变量存储在主内存,每个线程都有一个私有的本地内存,本地内存存储了共享变量的副本。本地内存是JMM的一个抽象概念,与主内存对应的物理内存并非在同一个空间,它包含了CPU的L1 L2 L3高速缓存、写缓冲区、寄存器、或者其他硬件和编译器的优化。
图:Java内存模型
初步了解了JAVA内存模型,对应到上面的问题,R W线程共享了主内存中hashmap,在自己的工作内存中都存储了这个hashmap的变量副本,因此当W线程写同时R线程读可能会出现如下几种场景:
<1> W线程正在更新自己工作内存中的变量副本,还没有开始向主内存同步数据,此时R线程开始读取,读取到的数据是自己工作内存中的变量副本。JMM规定了线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。
<2> W线程已经完成自己工作内存中变量副本的更新,并且将数据同步到了主内存,当主内存数据发生更新后,会出现缓存一致性问题,此时会通知R线程同步主内存中的最新数据到本地工作内存。此时R线程开始读取,读取到的数据是W线程最新写入的数据。关于缓存一致性的实现细节,可以参考CPU缓存一致性问题的探讨。
<3> W线程已经完成了自己工作内存中变量副本的更新,正在将数据同步到主内存,此时R线程开始读取,读取到的数据是自己本地工作内存中的数据。
可见,在上述探讨的问题场景中,W线程写过程,R线程读取的数据要么是W线程写之前的旧数据,要么是W线程写之后的最新数据。
3.问题延伸
问题1分析结果,R W线程一读一写同时发生,最多会引起数据不一致性的问题。那么使用hashmap,如果可以确定写线程唯一,读线程有多个,还会有其他线程安全问题么?
这里引出java集合常见的一个错误:fail-fast。它是java集合的一种错误检测机制,在集合遍历过程(iterator或者foreach),如果发生对集合add或者remove操作而迭代器不知道,就会触发fast-fail并抛出异常。
这里有两点注意:
<1> 没有说必须是多线程修改集合才会引起fast fail错误。只要是遍历过程集合发生add或者remove操作就可能发生。
<2> 只有在修改集合的时候iterator不知道才会发生fast fail错误,因此可以理解并非遍历过程就无法修改集合,通过Iterator的remove方法就可以实现。
遍历过程中线程内部修改集合:
HashMap hashMap = new HashMap();
hashMap.put("1","1");
hashMap.put("12","1");
hashMap.put("13","1");
Iterator iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()){
Map.Entry entry = (Map.Entry)iterator.next();
hashMap.remove(entry.getKey());
}
运行结果:抛出异常ConcurrentModificationException
遍历过程,通过iterator内部接口修改集合
HashMap hashMap = new HashMap();
hashMap.put("1","1");
hashMap.put("12","1");
hashMap.put("13","1");
Iterator iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()){
Map.Entry entry = (Map.Entry)iterator.next();
iterator.remove();
}
运行结果:正常,不会抛异常
看完现象和结论,回来看一下JDK中集合迭代器遍历过程中fastfail的源码实现,以hashmap中entry迭代为例:
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
modCount是hashmap中的成员变量。在调用put(),remove(),clear(),ensureCapacity()这些会修改数据结构的方法中都会使modCount++。在获取迭代器的时候会把modCount赋值给迭代器的expectedModCount变量。此时modCount与expectedModCount肯定相等,在迭代元素的过程中如果hashmap调用自身方法使集合发生变化,那么modCount肯定会变,此时modCount与expectedModCount肯定会不相等。在迭代过程中,只要发现modCount!=expectedModCount,则说明结构发生了变化也就没有必要继续迭代元素了。此时会抛出ConcurrentModificationException,终止迭代操作。
fastfail问题告诉我们,非线程安全集合在使用过程是需要谨慎的,我们开发过程该如何应对呢?
同样以hashmap为例:
场景1:写线程唯一、读线程不确定,没有迭代操作。使用hashmap不会存在程序不安全,最多就是发生数据不一致性的问题。
场景2:写线程唯一、读线程不确定,有迭代操作,此时不能使用hashmap,会存在fastfail问题
场景3: 读写线程是同一个,且唯一,有迭代操作,此时注意不能通过集合方法remove或者add更改,只能通过iterator内方法来更新。不然会存在fastfail问题。
怎么来解决fast fail问题
方法1: 在iterator迭代过程和写hashmap的操作都加锁
方法2:使用ConcurrentHashMap代替HashMap
方法1通过加锁实现线程同步安全,这样在迭代过程避免modCount发生改变,因此不会发生fastfail错误。
方法2,ConcurrentHashMap是一种线程安全的HashMap。查看源码,ConcurrentHashMap没有设置modCount标志,允许在迭代过程数据发生add或者remove操作。
static final class EntryIterator<K,V> extends BaseIterator<K,V>
implements Iterator<Map.Entry<K,V>> {
EntryIterator(Node<K,V>[] tab, int index, int size, int limit,
ConcurrentHashMap<K,V> map) {
super(tab, index, size, limit, map);
}
public final Map.Entry<K,V> next() {
Node<K,V> p;
if ((p = next) == null)
throw new NoSuchElementException();
K k = p.key;
V v = p.val;
lastReturned = p;
//遍历链表或者树
advance();
return new MapEntry<K,V>(k, v, map);
}
}
有没有必要使用ConcurrentHashMap替换HashMap?
从上面的案例分析可以知道,如果涉及到多线程操作,或者用到Iterator迭代器,是非常容易发生程序错误。为了减少这类基础问题的发生,建议使用ConcurrentHashMap替换HashMap。
<1> ConcurrentHashMap1.8之前使用segment分段锁,jdk1.8以后通过CAS算法实现无锁化,目标都是为了实现轻量级线程同步。相比HashTable性能高很多。
<2> ConcurrentHashMap没有fastfail问题,可以减少程序错误的发生。
线程安全集合
从hashmap fastfail案例可以推衍到整个集合线程安全的问题,java.util.concurrent包含许多线程安全、测试良好、高性能的并发构建块,我们在开发过程如果遇到多线程安全的问题,可以考虑优先使用这些集合框架。
非线程安全 | 线程安全 |
hashmap | ConcurrentHashMap |
ArrayList | CopyOnWriteArrayList |
LinkedList | ConcurrentLinkedQueue |
TreeSet/HashSet | CopyOnWriteArraySet |
java.util.concurrent实现的线程优势
java除了通过java.util.concurrent开发了高性能的线程安全集合,还有其他方式:
1.vecotr是线程安全的arraylist,hashtable是线程安全的hashmap。
2.通过Collections提供的工具方法,比如
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
可以将一个非线程安全的集合转变成线程安全的集合。
这两种方式的共同点都是使用syncronized对集合的读写操作进行加锁,缺点是性能比较低。java.util.concurrent通过CAS算法实现了轻量级的线程同步,性能会高效很多。
4总结
开发过程,遇到多线程操作,优先使用java.util.concurrent提供的线程同步集合。既可以解决线程安全问题,比如写写数据一致性问题,也可以避免发生fast fail错误。本文从引入具体问题,分析求证的方式探讨了hashmap线程安全,如果想把这个问题彻底弄明白,需要对Java内存模型、硬件存储模型、CPU编译器优化等知识点比较清晰,具体包括:
知识点延伸:
Synchronized内存语义
CAS算法实现原理
ConcurrentHashMap实现原理
JMM Java内存模型
Java线程本地实现原理
Java线程工作内存含义
硬件存储模型,从磁盘、内存、L3 L2 L1 cache
volatile的原理
参考
《Java并发编程的艺术》
《深入理解Java虚拟机:JVM高级特性与最佳实践》