hashmap 线程安全问题分析

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高级特性与最佳实践》

猜你喜欢

转载自blog.csdn.net/rambomatrix/article/details/80788187