HashMap是线程安全的吗?有什么替代方案?


(转自 https://zhuanlan.zhihu.com/p/374510765)

前言

在之前的文章《面试官:谈谈你对HashMap的理解》中对HashMap的原理进行了简单介绍,本篇文章主要从线程安全的角度来对HashMap进行分析,以及如何实现线程安全的HashMap。

本篇文章主要涉及以下几个内容:

HashMap 是线程安全的吗?
HashTable 如何保证线程安全?
Collections.synchronizedMap 如何保证线程安全?
ConcurrentHashMap 如何保证线程安全?
HashMap 是线程安全的吗?
相信大家都知道HashMap不是线程安全的,那么为什么HashMap不是线程安全的呢?我觉得可以从原子性和内存可见性两个角度来思考。

原子性

什么是原子性呢?原子性意味着不可分割,也就是说多个不可分割的操作同时只能被一个线程执行。注意原子性说的是在多个操作之间整体保证原子性(同时只能被一个线程执行),单个操作的原子性并不能保证操作整体的原子性。

下面结合一个具体的例子来看一下:

//定义一个table数组,长度为4
int[] table = new int[4];

/**
  * 判断table数组下标为index的位置是否已经设置了值,
  * 没有设置则设置为指定的val
  * @param index 数组下标
  * @param val 要设置的值
  * @return 设置成功返回true,否则返回flase
  */
public boolean setIfNotExist(int index, int val) {
  if (table[index) == 0) {  //table的index下标为0,说明是初始状态
    table[index] = val; //设置为val
    return true;
  }
  return false;
}

以上示例中定义了一个整数型的数组table,长度大小为4;另外定义了一个方法 setIfNotExist(index, val) ,执行逻辑是如果数组指定的下标还没有设置值,则将该下标的位置设置给定的值val,设置成功返回true,否则返回false。

假设现在有线程A和线程B两个线程同时执行,线程A执行 setIfNotExist(1, 1)操作,线程B执行 setIfNotExist(1, 2)操作,并且同时执行到 if (table[index) == 0) 这一行代码,因为此时table[1]的值为0,因此线程A和线程B同时执行这个 if 语句都返回了true,接下来线程A和线程B都会执行 table[index] = val ,所以线程A会将table[1]的值设置为1,线程B会将table[1]的值设置为2,并且线程A和线程B执行的结果都返回了true,那么线程A和线程B执行完后,table[1]的值是1还是2呢?如果线程A先于线程B写入,那么table[1]的值为1,如果线程A后于线程B写入,那么table[1]的值为2,最终我们不能确定写入的结果到底是多少,这种操作结果的不确定性,也就是多线程操作导致的线程安全问题。

那么我们该如何解决这个问题呢?

如果我们可以保证同时只有一个线程可以进入setIfNotExist方法中执行,也就是保证setIfNotExists方法执行的原子性,那么是不是就可以保证方法执行结果的确定性了呢?

假设线程A先进入方法执行,将table[1]的值设置为1,并且返回了true;线程A执行完毕后线程B再进入方法中执行,执行到if (table[index) == 0) 这一行代码,判断table[1]的值不为0,此时线程B不做任何操作返回false;从两个线程的返回结果中,我们可以知道最终table[1]的值为1。

通过上面的例子,我们知道只要保证方法执行的原子性,是可以消除了多线程执行时导致的结果不确定的问题,从而实现了解决线程安全的问题。

了解了方法执行的原子性导致的线程安全问题后,让我们回到HashMap中,因为HashMap的底层是基于数组+链表的方式实现,而HashMap中的数组和链表都属于HashMap的成员变量,如果多个线程同时操作同一个HashMap时,那么底层的数组和链表就属于多个线程间共享的变量,而如果多个线程同时对共享变量进行修改的话,就会由于线程间的操作结果相互影响导致最终结果的不确定性。对应到HashMap中,就是多线程同时在添加数据时,可能会导致添加的数据相互覆盖,从而导致线程安全问题。

内存可见性

内存可见性说的是当一个线程对共享变量进行了修改,其他的线程不能立刻看到共享变量的最新值。

为什么会产生内存可见性问题呢?

现在我们的cpu都是采用多核架构,每个线程在运行时都会占用一个cpu的核心,多个线程同时执行,就是说多个线程同时占用多个cpu核心运行,假如我们的cpu是4核的,那么理论上只能有4个线程同时运行。

在cpu中存在多级缓存,比如寄存器缓存,一级缓存,二级缓存等,这些缓存是cpu级别,每个cpu核心都会有各自的缓存,相互之间互不影响。而在java中,将cpu级别的多级缓存抽象为线程本地内存,每个线程自己的缓存,是线程私有的,也就是说对于一个线程来说,它是看不到其他线程的本地内存中的数据。

java中还有主内存的概念,可以类比为计算机硬件领域就是我们的内存条,当程序启动时会将数据读入到内存条中,也就是主内存。主内存是所有线程共享的,当线程要访问一个共享变量时,首先需要将该共享变量读入到线程的本地内存中,然后再对本地内存中的变量执行具体的操作,线程操作完后会将本地内存中的变量的值刷新回主内存中。

线程本地内存和主内存的关系图线程本地内存和主内存的关系图

例如修改一个共享变量时,需要执行以下步骤:

将共享变量从主内存中读取到线程本地内存;
线程修改本地内存中共享变量的值;
线程将本地内存中修改后的共享变量的值刷新回主内存;
此时就会存在一个问题,每个线程在它自己的本地内存对共享遍历做了更改,如果线程还没有将更改后的值刷新回主内存,那么其它线程是看不到共享变量的最新值的,此时多线程间的操作就可能发生一些不确定的错误,这就是共享变量在多线程之间存在的内存可见性问题导致的线程安全问题。

明白了共享变量的在线程间存在的内存可见性问题后,让我们回到HashMap中,因为HashMap的底层是基于数组+链表的方式实现,而HashMap中的数组和链表都属于HashMap的成员变量,如果线程A对数组table的某个位置进行了修改,此时其它线程并不能马上感知到线程A的修改操作,如果其它线程也对同一个位置进行操作,就会导致多线程间的操作相互覆盖,进而产生线程安全的问题。

小结

上面我们简单介绍了对共享变量操作的原子性和内存可见性可能导致的线程安全问题。对应到HashMap中,也就是HashMap对其中使用到的共享变量没有提供操作的原子性以及可见性保证,最终导致多个线程间的操作存在线程安全问题。

Hashtable 如何保证线程安全?

通过之前的分析,我们知道要保证HashMap是线程安全的,只要保证对共享变量操作的原子性和内存可见性即可,下面我们看看Hashtable是如何做到的?

synchronized保证原子性

如果我们查看Hashtable的源码,就会发现在Hashtable的方法上,都使用了synchronized关键字进行修饰,如下所示:

public class Hashtable<K,V> {
...省略无关代码
  public synchronized V get(Object key) {
    //执行get获取逻辑
  }
...省略无关代码
  public synchronized V put(K key, V value) {
        if (value == null) {
            throw new NullPointerException();
        }
        //执行put逻辑
    }
...省略无关代码
}

使用了synchronized关键字修饰方法,可以保证方法执行的原子性,也就是同时只能有一个线程进入方法执行操作,这样一来也就解决了对共享变量操作的原子性问题。

对应的Hashtable中,在put方法上加了synchronized关键字,因此同时只能有一个线程调用put方法执行添加数据的操作,保证了多线程操作的原子性问题,提供了线程安全的保障。

synchronized保证内存可见性

synchronized关键字是可以保证内存可见性的,那么它是如何做到的呢?

在前面的分享中,我们知道共享变量的内存可见性问题是由于线程私有本地内存和主内存中的值不一致导致的,因为线程在本地内存中所做的修改如果没有及时刷新会主内存中,导致其它线程感知不到线程间所做的修改,从而导致内存可见性问题。

而使用synchronized关键字修饰的同步代码块在执行完毕后,会将当前线程本地内存中的共享变量都刷新回主内存中;其它线程在进入synchronized关键字修饰的同步代码块时,会将当前线程本地内存中的共享变量的值设置为无效,然后当前线程使用到的共享遍历都要从主内存中读取。通过这种方式就可以保证每个线程看到的共享变量的值是最新的值,也就保证了共享变量在多线程间的内存可见性问题。

对应到Hashtable中,当一个线程调用put方法添加完数据后,其他的线程进入put方法后,能够读取Hashtable中的最新值;而且get方法也使用了synchronized关键字修饰,因此线程调用get方法查询数据时,也是能够读取到Hashtable中的最新值。

小结

Hashtable是通过synchronized关键字保证了Hashtable底层共享变量操作的原子性与内存可见性,从而实现了多线程将的操作是线程安全的。

Collections.synchronizedMap 如何保证线程安全?
既然HashMap不是线程安全的,那如果我们定义一个类对HashMap进行包裹,而我们只要保证对HashMap进行包裹的类的操作是线程安全的,那就可以保证对HashMap的操作也是线程安全的。

下面我们看一个例子:

//定义一个SynchronizedMap类对HashMap进行包裹
static class SynchronizedMap<K,V> implements Map<K,V> {
  //被包裹HashMap,不是线程安全的
  private final Map<K,V> m;
  //锁的对象,配合synchronized保证线程安全
  final Object mutex;
  //通过构造函数将非线程安全饿HashMap对象传入
  SynchronizedMap(Map<K,V> m) {
      //原始的hashMap对象
      this.m = m;
      //锁对象为this,也就是当前对象
      mutex = this;
  }
  ...省略其他代码
  //put操作的具体逻辑委托给底层的HashMap实现
  public V put(K key, V value) {
      //通过synchronized保证线程安全
      synchronized (mutex) {
        return m.put(key, value);
      }
  }
  
  //get操作的具体逻辑委托给底层的HashMap实现
  public V get(Object key) {
      //通过synchronized保证线程安全
      synchronized (mutex) {
        return m.get(key);
      }
  }
  ...省略其他代码
}

上面定义了一个 SynchronizedMap 类,类中的put方法和get没有复杂的实现逻辑,仅仅是通过synchronized关键字保证了线程安全,而具体的put和get的实现逻辑是委托给了底层的HashMap实现,这么一来,即便底层的HahMap不是线程安全的,但我们也可以通过SynronizedMap来间接的保证对底层HashMap操作的线程安全。

SynchronizedMap 的如何使用:

//实例化一个HashMap对象,该对象是非线程安全的
Map map = new HashMap();
//实例化一个SynchronizedMap对象,该对象是线程安全的
//将非线程安全的map对象传入到线程安全的SynchronizedMap对象中
SynchronizedMap synchronizedMap = new SynchronizedMap(map);
//底层通过synchronized保证了线程安全
synchronizedMap.put("author", "小徐的技术笔记");

以上代码设计其实是装饰器模式的一种实现。通过装饰器模式来提供一个装饰器类(SynchronizedMap)对原始类(HashMap)进行包裹,并且装饰器类(SynchronizedMap)与原始类实现相同的接口(Map),装饰器类(SynchronizedMap)对Map接口的实现委托给了原始类(HashMap)来实现,而装饰器类(SynchronizedMap)则在原始类(HashMap)的基础上,对原始类(HashMap)的功能实现了增强,对应到SynchronizedMap实现中,提供的增强功能就是在HashMap的基础上增强了线程安全的保障。

现在再来看一下Collections.synchronizedMap的实现,如下所示:

//静态方法,为传入的Map对象封装了线程安全的装饰器类实现
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    //创建线程安全的SynchronizedMap对象
    return new SynchronizedMap<>(m);
}

Collections.synchronizedMap方法的实现很简单,该方法是一个静态方法,封装了装饰器类SynchronizedMap的创建逻辑,使方法的调用者无需了解底层的具体实现,从而简化了编程。

小结

Collections.synchronizedMap方法使用了装饰器模式为线程不安全的HashMap提供了一个线程安全的装饰器类SynchronizedMap,通过SynchronizedMap来间接的保证对HashMap的操作是线程安全,而SynchronizedMap底层也是通过synchronized关键字来保证操作的线程安全,具体synchronized如何保证线程安全在前面已经介绍,此处就不重复了。
*

ConcurrentHashMap 如何保证线程安全?

Hashtable和Collections.synchronizedMap返回的装饰器类SynchronizedMap都是通过synchronized关键字来保证多线程操作的线程安全,但使用synchronized会有一个问题,就是锁的粒度太大,同时只能有一个线程进行操作,导致并发度低下,影响了操作的性能。

比如:Hashtable的get和put方法,都使用了关键字synchronized修饰,这就意味着当一个线程调用put方法添加元素时,其它线程不能再同时执行put添加元素,也不能调用get方法获取数据。

为了解决synchronized并发度低的问题,ConcurrentHashMap使用了cas+synchronized解决共享遍历操作原子性问题,使用volatile保障共享变量的内存可见性问题。通过前面的分享,我们知道解决了共享变量操作的原子性和可见性问题,就可以保证操作的线程安全问题。
*

cas+synchronized保证原子性

ConcurrentHashMap的put方法使用了cas+syncronized保证了添加键值对数据的原子性,如下所示:

Void putVal(K key, V value) {
    ...省略无关代码
    for (Node<K,V>[] tab = table;;) {
      ...省略无关代码
    if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      //如果当前位置还没有添加任何元素,则使用cas添加元素
        if (casTabAt(tab, i, null,
                     new Node<K,V>(hash, key, value, null)))
            break;
    }
    ...省略无关代码
    else {
        synchronized (f) {
            if (tabAt(tab, i) == f) {
                //追加节点到链表或者红黑树
            }
        }
    }
}

}
在putVal方法中,通过无限for循环中判断table数组key映射的位置是否已经添加过元素,如果没有添加过元素则使用cas将元素设置到数组下标的位置,cas操作可以保证当多个线程同时执行时只有一个线程执行成功,其它的线程会重新进入for循环进行重试,重试的线程判断table数组key映射的下标节点不为空,因此会执行到synchronized同步代码块,又因为synchronized可以保证同时只有一个线程可以进入执行,因此整个方法执行的原子性由cas+synchronized保证。

在上面的代码中,我们注意到synchronized的加锁对象是 f ,这个 f 就是key映射到table数组某个下标的节点,也就是发生hash冲突的而拉出的链表,因此synchronized锁的对象是table数组的某个位置的节点,这是细粒度锁的实现。也就是table数组不同的位置用不同的synchronized锁来保护,因此table数组不同位置的节点之间是产生并发阻塞的,通过这种方式最大程度的提升了ConcurrentHashMap操作的并发度,进而提高了操作性能。
*

volatile保证共享变量的内存可见性

使用volatile关键字修饰共享变量,可以保证共享变量的可见性。

在前面的分享中,我们知道共享变量的内存可见性问题是由于线程私有本地内存和主内存中的值不一致导致的,因为线程在本地内存中所做的修改如果没有及时刷新会主内存中,导致其它线程感知不到线程间所做的修改,从而导致内存可见性问题。

当一个线程对被volatile关键字修饰的共享变量进行写入操作时,会将共享变量的最新值写入到主内存中,同时该写入操作会将其它线程的本地内存中缓存的共享变量的值设置为无效,所以其它线程读同一个共享变量的值时,就会强制从主内存中读取,也就可以读取到共享变量最新写入的值,因此也就保证了共享变量的可见性问题。

下面看一下ConcurrentHashMap中如何使用volatile的:

//table数组使用volatile关键字修饰
volatile Node<K,V>[] table;

class Node<K,V> implements Map.Entry<K,V> {
   final int hash; //使用fianl关键字修饰
   final K key; //使用fianl关键字修饰
   volatile V val;  //使用volatile关键字修饰
   volatile Node<K,V> next; //使用volatile关键字修饰
}

ConcurrentHashMap中的table数组使用volatile关键字修饰,Node节点中的val和next也使用volatile关键字修饰,因此这些共享变量在多线程间是保证内存可见性的。细心的朋友们可能发现,Node节点中的key和hash值并没有使用volatile关键字修饰,那会不会有什么问题呢?

实际上从ConcurrentHashMap的保存的键值对的特点来看,设置的键值是不会改变的,键值不改变的话,键对应的hash值也是不会改变的,因此在ConcurrentHashMap中的Node节点中将键和hash值使用final关键字修饰来不可变性;另外被final关键字修饰的变量也是具备了内存可见性,因此不会有线程安全问题,关于final关键字如何保证变量的内存可见性问题,此处就不展开讲解了。

小结

本篇文章中,首先分析了HashMap不是线程安全的原因,然后介绍了java中线程安全的Hashtable以及Collections.synchronizedMap的实现,最后介绍了ConcurrentHashMap是如何保证线程安全的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值