拜托!别再问我hashmap是否线程安全

Posted 黑马程序员官方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了拜托!别再问我hashmap是否线程安全相关的知识,希望对你有一定的参考价值。

拜托!别再问我hashmap是否线程安全

一、糟糕的面试

面试官:小王,你说说HashMap的是线程安全的吗?

小王:HashMap不安全,在多线程下,会出现线程安全问题。他兄弟HashTable

线程是安全的,但是出于性能考虑,我们往往会选择ConcurrentHashMap。

面试官:HashMap线程不安全的原因是什么?

小王:这个…暂时忘记了

面试官:为什么HashTable线程安全,为什么性能低?

小王:这个…

面试官:ConcurrentHashMap是怎么实现线程安全的?性能为什么较高?

小王:…

面试官:回答的很不错,回去等通知吧。

二、hashMap

2.1 暴露问题

大家都知道,HashMap在多线程下会存在线程安全问题,如下:

public class Demo2 
    public static void main(String[] args) 
        //shift+ctrl+alt+u
        HashMap<String, String> map = new HashMap<>();

        Thread t1 =   new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 0; i <= 10; i++) 
                    map.put(i+"",i+"");
                
            
        );
        Thread t2 =   new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 11; i <= 20; i++) 
                    map.put(i+"",i+"");
                
            
        );
        t1.start();
        t2.start();
        //确保两个子线程执行完毕之后,主线程再来打印hashmap
        try 
            Thread.sleep(1000);
         catch (InterruptedException e) 
            e.printStackTrace();
        
        //遍历hashMap
        for (int i = 1; i <= 20; i++) 
            System.out.println(map.get(i + ""));
        
    

控制台:

null
2
null
null
null
6
7
8
9
10
null
null
13
null
null
null
17
18
19
20

以上例子证明了,HashMap确实存在线程安全问题。

2.2 源码追踪

翻阅源码(1.8)如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) 
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
	//此处线程不安全
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else 
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else 
            for (int binCount = 0; ; ++binCount) 
                if ((e = p.next) == null) 
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            
        
        if (e != null)  // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        
    
    ++modCount;
    //此处线程不安全。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;

(1)代码一

if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

是否Hash冲突,没冲突就直接赋值给数组当前索引。

线程A判断通过,进入方法,切换B线程,判断通过,进入方法,切换A线程,赋值成功,切换B线程赋值成功,B线程的值覆盖了A线程的值,发生了数据覆盖,用户感受到是数据丢失。

(2) 代码二

if (++size > threshold)
        resize();

当元素个数size大于扩容阈值,则扩容,这里会有两个问题。

  • 成员的size变量没有保证原子性,因此多线程下size自增是存在原子性问题。即添加了两个元素,但是size只增加了1。
  • 两个线程如果都通过上面阈值的判断,就会发生扩容两次的情况,这也是一种安全问题。

三、HashTable

3.1 线程安全演示

我们可以使用HashTable来解决上面的安全问题。

看下面的代码:

public class Demo2 
    public static void main(String[] args) 
        Hashtable<String, String> map = new Hashtable<>();
        Thread t1 =   new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 0; i <= 10; i++) 
                    map.put(i+"",i+"");//i,i
                
            
        );
        //20,20  21,21 ... 39,39
        Thread t2 =   new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 11; i <= 20; i++) 
                    map.put(i+"",i+"");//i,i
                
            
        );
        t1.start();
        t2.start();
        //确保两个子线程执行完毕之后,主线程再来打印hashmap
        try 
            Thread.sleep(1000);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        for (int i = 0; i <= 20; i++) 
            System.out.println(map.get(i + ""));
        
    

控制台:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

以上代码,说明了Hashtable的确是线程安全的。

3.2 翻看源码

Hashtable源码:

public synchronized V put(K key, V value) 
    // Make sure the value is not null
    if (value == null) 
        throw new NullPointerException();
    

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) 
        if ((entry.hash == hash) && entry.key.equals(key)) 
            V old = entry.value;
            entry.value = value;
            return old;
        
    

    addEntry(hash, key, value, index);
    return null;


    public synchronized V get(Object key) 
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) 
            if ((e.hash == hash) && e.key.equals(key)) 
                return (V)e.value;
            
        
        return null;
    

    public synchronized int size() 
        return count;
    

通过阅读源码可以发现,Hashtable每个操作数据的方法,都是使用了重量级锁synchronized。线程A在操作数据的时候,线程B只能阻塞。保证了整个Hash表只能线程串行化执行,从而解决了多线程产生的安全问题。

因为Hashtable是对整个哈希表进行加锁,加锁粒度过大,发生线程阻塞的概率非常大,虽然synchronized有自己的锁优化机制,但是也很快就会升级成重量级锁。而当synchronized成为了重量级锁,就会请求底层系统锁,跳出jvm级别,频繁涉及用户态和内核态的切换,性能开销比较大。

所以在今天已经不推荐使用HashTable了。

四、ConcurrentHashMap

4.1 线程安全演示

以上两个章节我们发现,在Map集合中HashMap是最常用的集合对象。但是多线程操作HashMap会有线程安全问题,解决方式就是使用HashTable,但是HashTable会全表加锁性能牺牲很大。

JDK1.5以后所提供了ConcurrentHashMap,使用它既能解决线程安全问题,性能又比HashTable高很多,所以这是目前主流的折中方案。

代码如下:

public class Demo3 
    public static void main(String[] args) 
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
        Thread t1 = new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 0; i <= 10; i++) 
                    map.put(i + "", i + "");//i,i
                
            
        );
        //20,20  21,21 ... 39,39
        Thread t2 = new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 11; i <= 20; i++) 
                    map.put(i + "", i + "");//i,i
                
            
        );
        t1.start();
        t2.start();
        //确保两个子线程执行完毕之后,主线程再来打印hashmap
        try 
            Thread.sleep(1000);
         catch (InterruptedException e) 
            e.printStackTrace();
        

        for (int i = 0; i <= 20; i++) 
            System.out.println(map.get(i + ""));
        
    

控制台:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

线程安全得到保证。

总结 :

​ 1 ,HashMap是线程不安全的。多线程环境下会有数据安全问题

​ 2 ,Hashtable是线程安全的,但是会将整张表锁起来,效率低下

​ 3,ConcurrentHashMap也是线程安全的,效率较高。 在JDK7和JDK8中,底层原理不一样。

4.2 jdk7原理解析

1.ConcurrentHashMap集合底层是一个默认长度为16,加载因子为0.75的大数组 Segment数组。大数组通常是创建之后长度就固定的,而扩容是指小数组扩容。

2.默认情况下还会创建一个长度为2的小数组,把地址赋值给0索引处,其他索引此时的元素仍为null。

(Segment 继承 ReentrantLock 锁,用于存放数组 HashEntry[]。)

如下图

3.调用put方法时,此时会根据key的哈希值来计算出在大数组中存储的索引位置。

如果这个索引此时为null,则按照0素引的模板小数组来创建小数组。创建完毕后会二次哈希计算出key在小数组中存储的位置,然后把键值对对象存储小数组的该索引位。

如下图,先根据key的哈希算出来在大数组的4索引,创建小数组挂在4索引。接着继续使用key的hash算出存在小数组的0素引。

4.调用put方法时,此时会根据key的哈希值来计算出在大数组中存储的索引位置。

如果该位置不为null,就会根据记录的地址值找到小数组。二次哈希计算出小数组的索引位置。

如果需要扩容就把小数组扩容2倍。

如果不需要扩容,则会判断小数组该索引是否有元素

​ 如果没有元素,就直接存

​ 如果有元素,就调用equals方法比较key是否相同

​ 比较发现没有重复,就会在小数组上挂链表。

如下图

线程一来访问索引4,此时就会对索引4的Segment进行加锁。其他线程访问索引4就会阻塞,访问其他索引就可以访问,这种技术叫分段锁,将数据拆成一段一段的进行加锁。

在当前例子中,我们没有指定大数组的长度,因此长度默认是 16。在理想情况下,最多可以支持16个线程同时操作不同的segment对象,达到了并发的目的。但是如果多个线程同时操作同一个segment,就会阻塞,串行化执行。

关键字:分段锁、二次哈希、Segment数组不能扩容、HashEntry数组可以扩容

总结

ConcurrentHashMap1.7使用Segment+HashEntry数组实现的。本质上是一个 Segment 数组,Segment 继承 ReentrantLock ,同时具备了加锁和释放锁的功能。每个Segment都线程安全,全局也就安全了。把Hashtable的锁全表,变成了锁一段段的数据,粒度细提高性能。

补充:ConcurrentHashMap1.8则完全不同,放弃了Segment。数据结构使用synchronized+CAS+红黑树。锁的粒度也从段锁缩小为结点锁,粒度更细,同时数组支持扩容,并发能力更高。使用synchronized其实也是因为1.6jdk对synchronized的优化有关。

4.3 jdk8 原理解析

在1.8中,ConcurrentHashMap可以说发生翻天覆地的变化,底层数据结构不再采用segment数组,也不再采用分段锁。而是采用 数组+链表+红黑树来实现,锁也从分段锁提升成了节点锁,粒度更细。使用CAS+synchronized来保证线程安全。

底层结构:数组+链表+红黑树

CAS + synchronized同步代码块 保证线程安全

初始化数组源码如下:

//假设多线程来扩容,concurrentHashMap为了线程安全,只能让一个线程成功初始化数组,其他线程均失败。
private final Node<K,V>[] initTable() 
        Node<K,V>[] tab; int sc;
    	//所有线程进入循环,去抢着初始化数组
        while ((tab = table) == null || tab.length == 0) 
            if ((sc = sizeCtl) < 0)
                Thread.yield(); //让线程让出cpu,以至于把cpu更多的可能让给初始化操作的线程
            //CAS操作,保证一个线程进入下面的逻辑,其他线程最终只能执行 Thread.yield();
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) 
                try 
                    if ((tab = table) == null || tab.length == 0) 
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        //这个就是初始化数组,默认长度n为16
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    
                 finally 
                    sizeCtl = sc;
                
                break;
            
        
        re

以上是关于拜托!别再问我hashmap是否线程安全的主要内容,如果未能解决你的问题,请参考以下文章

拜托,别再问我桶排序了!!!

拜托,别再问我基数排序了!!!

拜托,别再问我 QPSTPSPVUVGMVIPRPS 好吗?

拜托,面试别再问我基数排序了!!!

拜托,面试别再问我堆(排序)了!

Spring源码剖析-拜托面试官别再问我AOP原理了