03.07 写个例子说明HashMap线程不安全?

吖Pat


在平时开发中,我们经常采用HashMap来作为本地缓存的一种实现方式,将一些如系统变量等数据量比较少的参数保存在HashMap中,并将其作为单例类的一个属性。在系统运行中,使用到这些缓存数据,都可以直接从该单例中获取该属性集合。但是,最近发现,HashMap并不是线程安全的,如果你的单例类没有做代码同步或对象锁的控制,就可能出现异常。

首先看下在多线程的访问下,非现场安全的HashMap的表现如何,在网上看了一些资料,自己也做了一下测试:

1public class MainClass {

2

3 public static final HashMap<string> firstHashMap=new HashMap<string>();/<string>/<string>

4

5 public static void main(String[] args) throws InterruptedException {

6

7 //线程一

8 Thread t1=new Thread(){

9 public void run() {

10 for(int i=0;i<25;i++){

11 firstHashMap.put(String.valueOf(i), String.valueOf(i));

12 }

13 }

14 };

15

16 //线程二

17 Thread t2=new Thread(){

18 public void run() {

19 for(int j=25;j<50;j++){

20 firstHashMap.put(String.valueOf(j), String.valueOf(j));

21 }

22 }

23 };

24

25 t1.start();

26 t2.start();

27

28 //主线程休眠1秒钟,以便t1和t2两个线程将firstHashMap填装完毕。

29 Thread.currentThread().sleep(1000);

30

31 for(int l=0;l<50;l++){

32 //如果key和value不同,说明在两个线程put的过程中出现异常。

33 if(!String.valueOf(l).equals(firstHashMap.get(String.valueOf(l)))){

34 System.err.println(String.valueOf(l)+":"+firstHashMap.get(String.valueOf(l)));

35 }

36 }

37

38 }

39

40}

上面的代码在多次执行后,发现表现很不稳定,有时没有异常文案打出,有时则有个异常出现:

为什么会出现这种情况,主要看下HashMap的实现:

1public V put(K key, V value) {

2 if (key == null)

3 return putForNullKey(value);

4 int hash = hash(key.hashCode());

5 int i = indexFor(hash, table.length);

6 for (Entry e = table[i]; e != null; e = e.next) {

7 Object k;

8 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

9 V oldValue = e.value;

10 e.value = value;

11 e.recordAccess(this);

12 return oldValue;

13 }

14 }

15

16 modCount++;

17 addEntry(hash, key, value, i);

18 return null;

19 }

我觉得问题主要出现在方法addEntry,继续看:

1void addEntry(int hash, K key, V value, int bucketIndex) {

2 Entry e = table[bucketIndex];

3 table[bucketIndex] = new Entry(hash, key, value, e);

4 if (size++ >= threshold)

5 resize(2 * table.length);

6 }

从代码中,可以看到,如果发现哈希表的大小超过阀值threshold,就会调用resize方法,扩大容量为原来的两倍,而扩大容量的做法是新建一个Entry[]:

1void resize(int newCapacity) {

2 Entry[] oldTable = table;

3 int oldCapacity = oldTable.length;

4 if (oldCapacity == MAXIMUM_CAPACITY) {

5 threshold = Integer.MAX_VALUE;

6 return;

7 }

8

9 Entry[] newTable = new Entry[newCapacity];

10 transfer(newTable);

11 table = newTable;

12 threshold = (int)(newCapacity * loadFactor);

13 }

一般我们声明HashMap时,使用的都是默认的构造方法:HashMap,看了代码你会发现,它还有其它的构造方法:HashMap(int initialCapacity, float loadFactor),其中参数initialCapacity为初始容量,loadFactor为加载因子,而之前我们看到的threshold = (int)(capacity * loadFactor); 如果在默认情况下,一个HashMap的容量为16,加载因子为0.75,那么阀值就是12,所以在往HashMap中put的值到达12时,它将自动扩容两倍,如果两个线程同时遇到HashMap的大小达到12的倍数时,就很有可能会出现在将oldTable转移到newTable的过程中遇到问题,从而导致最终的HashMap的值存储异常。

当多个线程同时检测到总数量超过门限值的时候就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。而且当某些线程已经完成赋值而其他线程刚开始的时候,就会用已经被赋值的table作为原始数组,这样也会有问题。

JDK1.0引入了第一个关联的集合类HashTable,它是线程安全的。HashTable的所有方法都是同步的。

JDK2.0引入了HashMap,它提供了一个不同步的基类和一个同步的包装器synchronizedMap。synchronizedMap被称为有条件的线程安全类。

JDK5.0util.concurrent包中引入对Map线程安全的实现ConcurrentHashMap,比起synchronizedMap,它提供了更高的灵活性。同时进行的读和写操作都可以并发地执行。

所以在开始的测试中,如果我们采用ConcurrentHashMap,它的表现就很稳定,所以以后如果使用Map实现本地缓存,为了提高并发时的稳定性,还是建议使用ConcurrentHashMap。


小红的甜心


HashMap 扩容的源代码如下:

resize 扩容方法中最重要的代码如下:

resize 扩容步骤如下:

  1. 根据 newCapacity 生成一个数组。
  2. 遍历旧的数组,然后对其中的每一个值进行hash,重新进行插入。
  3. 修改扩容的阀值:threshold 。

下面我们分别展示 在单线程和多线程的环境的扩容

我们定义的Map为 Map<integer>

单线程环境下的扩容

我们先定义有个简单的hash, hash = key%length

默认hash表的长度为2,插入的元素为 5 9 11

5%2 = 1;

9%2 = 1;

11%2=1;

三个元素都碰撞在下标为1 的位置上。

下面我们扩容到4:

5%4 = 1;

9%4 = 1;

11%4=3;

扩容步骤如下:

并发环境下的扩容

首先线程1 和 线程2 同时扩容

线程1 和 线程2 的 e 为 5 。e.next = 9

但是此时,线程1 由于调度问题暂停执行。

线程2继续执行,执行结束后如下:

这时,线程1被唤醒了。这时线程1的e为5,e.next = 9;

此时:

处理完5后,我们就要处理9。

此时

因为在线程2中9的下一个节点为5,所以还要继续处理5,会把5放到线程1的table[1] 处,

这时就会循环生成一个循环列表。11这个元素时无法加入到线程1里面了。


分享到:


相關文章: