ConcurrentHashMap一定线程安全吗

·  阅读 741

一、学习目标

1、并发问题的三个来源:

  • 可见性问题:多线程情况下,线程之间的变量往往是不共享的,因为CPU在计算时优先从离自己最近、速度最快的CPU缓存中获取数据去计算,其次再从内存中获取数据。
  • 原子性问题:即使两个线程跑在了同一个CPU核心上,避免了可见性问题干扰,另外一个原子性问题依然会让你的并发代码不可控。
  • 有序性问题:多线程并发代码执行产生不可预知的结果。原理可以参考上节的原子性问题。

2、ConcurrentHashMap 的线程安全指的是什么?

  • ConcurrentHashMap 的线程安全指的是:ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。 也就是put()、get()操作是线程安全的。这两个操作对于多线程同时操作,线程之间是可见的,因为ConcurrentHashMap.Node.val和因为ConcurrentHashMap.baseCount被volatile修饰。

3、如何正确使用 ConcurrentHashMap

  • ConcurrentHashMap#putIfAbsent(),实现get()、put()原子性操作,因为 ConcurrentHashMap#putIfAbsent() 方法内部加了synchronized锁

二、用户注册模拟并发问题

在这个例子中模拟了用户注册行为,定义了相同用户名不能重复注册的规则,我们使用ConcurrentHashMap保存用户信息,通过模拟同时注册的动作体现并发问题。

测试类:

public class ConcurrentHashMapTest {

	@Test
	public void test() throws InterruptedException {
		UserService userService = new UserService();

		int threadCount = 8;

		ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
		forkJoinPool.execute(() -> IntStream.range(0, threadCount)
				.mapToObj(i -> new User("张三", i))
				.parallel().forEach(userService::register));

		// 等待1s,否则看不到日志输出程序就结束了
		TimeUnit.SECONDS.sleep(1);
	}
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class User {
	/**
	 * 用户名,也是Map的key
	 */
	private String username;

	private int age;
}

class UserService {

	private Map<String, User> userMap = new ConcurrentHashMap<>();

	/**
	 * 用户注册
	 *
	 * @param user
	 * @return
	 */
	boolean register(User user) {
		if (userMap.containsKey(user.getUsername())) {
			System.out.println("用户已存在");

			return false;
		} else {
			userMap.put(user.getUsername(), user);
			System.out.println("用户" + user.getUsername() + "_" + user.getAge() + "注册成功");

			return true;
		}
	}
}
复制代码

运行结果:

用户已存在
用户已存在
用户张三_4注册成功
用户已存在
用户张三_7注册成功
用户张三_5注册成功
用户已存在
用户已存在
复制代码

可以看到,在注册中存在判断用户是否已注册的逻辑,但在实际测试中有3个都是名为张三的用户同时注册成功,这显然不符合同名用户不能注册规则。

三、并发问题的三个来源

并发问题的三个来源:原子性、可见性、有序性

ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。

为什么用上了线程安全的ConcurrentHashMap还是出现了并发问题呢?

1、可见性问题

用户注册代码中使用containsKey()方法判断用户是否存在,直观上我们认为操作的是同一个Map,如果另一个线程写入了张三这个key,当前线程访问userMap时一定会看到,而实际情况要更加复杂一些。

在学习计算机原理的时候讲过CPU缓存、内存、硬盘三者的速度天差地别,因此CPU在计算时优先从离自己最近、速度最快的CPU缓存中获取数据去计算,其次再从内存中获取数据。

另外,CPU经历了多年的发展之后,单核的性能提升越来越困难,为了提高单机性能,如今的计算机都是采用多个CPU核心的方式。

下图所展现的就是CPU与其缓存以及内存之间的关系。每个CPU核心都有独享的Cache的缓存。

image

而我们的线程可能会跑在不同的CPU核心上,此时Thread1将用户注册信息写入到内存中,但Thread2还是从自己的CPU缓存中获取的数据,因此对于Thread2来说看到的注册信息里没有张三,这就是可见性问题。

image

2、原子性问题

即使两个线程跑在了同一个CPU核心上,避免了可见性问题干扰,另外一个原子性问题依然会让你的并发代码不可控。

下图展示了在时间轴上注册用户的流程,boolean register(User user)这个方法在CPU计算的时间尺度上并不是做一个操作,而是包含了:

  • 访问userMap判断当前用户是否注册
  • 注册用户

这两步操作,在Thread1访问userMap后返回当前用户未注册但还未将用户信息put进userMap前,Thread2也去访问了userMap那么它也会获取到当前用户未注册的结果,因此也会执行后面的注册操作。

CPU在执行任务时

image

而实际上我们希望判断用户是否注册,注册用户这两步操作同时进行,如下图所示,Thread1在执行register(User user)方法时会将两个操作放在一起执行完,这与数据库事务的原子性理解差不多。

image

3、有序性问题

编译器为了提高性能有时候会改变代码执行的顺序,对于单线程代码指令重排序对于执行没有什么影响,但是会对多线程并发代码执行产生不可预知的结果。原理可以参考上节的原子性问题。

四、ConcurrentHashMap应该怎么用

ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。 也就是put()、get()操作是线程安全的。这两个操作对于多线程同时操作,线程之间是可见的,因为ConcurrentHashMap.Node.val和因为ConcurrentHashMap.baseCount被volatile修饰。

了解并发问题的根源之后,才能真正用好并发工具类,发挥它的真正威力。我们改造一下代码:

boolean registerAtomic(User user) {
    User hasMapped = userMap.putIfAbsent(user.getUsername(), user);
    if (hasMapped != null) {
        System.out.println("用户已存在");
        return false;
    } else {
        System.out.println("用户" + user.getUsername() + "_" + user.getAge() + "注册成功");
        return true;
    }
}
复制代码

这里我们使用了ConcurrentHashMap#putIfAbsent(),其含义是如果key已经存在则返回存储的对象,否则返回null。

那么ConcurrentHashMap#putIfAbsent()如何实现get()、put()原子性操作的呢?

其实就是加了锁,体现在ConcurrentHashMap#putVal()方法。在这个场景中如果不使用putIfAbsent就要对register(User user)方法加锁,对于性能的影响更大。

ConcurrentHashMap#putIfAbsent():

public V putIfAbsent(K key, V value) {
    return putVal(key, value, true);
}
复制代码

ConcurrentHashMap#putVal():

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}
复制代码

五、总结

1、并发问题的三个来源:

  • 可见性问题:多线程情况下,线程之间的变量往往是不共享的,因为CPU在计算时优先从离自己最近、速度最快的CPU缓存中获取数据去计算,其次再从内存中获取数据。
  • 原子性问题:即使两个线程跑在了同一个CPU核心上,避免了可见性问题干扰,另外一个原子性问题依然会让你的并发代码不可控。
  • 有序性问题:多线程并发代码执行产生不可预知的结果。原理可以参考上节的原子性问题。

2、ConcurrentHashMap 的线程安全指的是什么?

  • ConcurrentHashMap 的线程安全指的是:ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的。 也就是put()、get()操作是线程安全的。这两个操作对于多线程同时操作,线程之间是可见的,因为ConcurrentHashMap.Node.val和因为ConcurrentHashMap.baseCount被volatile修饰。

3、如何正确使用 ConcurrentHashMap

  • ConcurrentHashMap#putIfAbsent(),实现get()、put()原子性操作,因为 ConcurrentHashMap#putIfAbsent() 方法内部加了synchronized锁

六、参考

xie.infoq.cn/article/f92…

分类:
后端
标签: