一、前文回顾

上篇文章,我们提到多线程读写共享变量时,由于多个线程同时操作主内存中的共享变量时,会把其拷贝到每个线程的工作内存中进行操作,所以会出现缓存不一致的问题。

本篇文章,我们将盘点一下常见并发问题的解决方法有哪些?这里用一张图说明:
15735400_1622345382.png
图一

从上图我们看到,解决并发问题的方法分为两大类:无锁和有锁
无锁可分为:局部变量,不可变对象,ThreadLocal、CAS原子类
有锁可分为:synchronized 关键字和ReentrantLock可重入锁

下面,我们分析下为什么这些方法能解决并发问题?

二、局部变量

因为局部变量仅仅存在于每个线程的工作内存中,假设有2个线程执行如下代码:
37313200_1622345382.png

只有当每个线程执行到 : int i=0 这一行代码的时候,会在各自新城所在工作内存中创建这个变量,如下面图2所示:
kpew1m2k0442.jpg
图2

看到这里你就会明白,原来每个线程都只在自己的公祖哦内存操作各自变量 “i”,不同线程之间的”i”根本没有任何奇交集,所以就不存在并发问题了。

Jmeter 并发测试通过,10个线程,循环1次。全部打印 1。

三、不可变对象

谓不可变对象是指一经创建,就对外的状态就不会改变的对象。如果一个对象的状态是亘古不变的,那么自然就不存在什么并发问题。因为对象是不可变的,所以无论多少个线程,对它做什么操作,它都是不变的。

四、ThreadLocal

ThreadLocal本质上也是在每个线程有自己的一个副本,每个线程的副本是互不影响的,没有任何关系的。
kpew3uv10flx.jpg
图3
如图3所示,一个命名为 “i”的ThreadLocal类,它会在每个线程都有一个Integer的 对象,虽然每个线程都会从主内存中把Integer对象拷贝到工作内存中,但是线程1和线程2拷贝过来的对象并不是同一个对象,其实每个对象只会被其中一个线程操作,这种场景不存在所谓的“共享变量”,也就不存在并发问题了。

Jmeter 验证通过

  1. package com.example.demo.config;
  2. import org.springframework.stereotype.Component;
  3. /**
  4. * @version 1.0
  5. * @Description
  6. * @Date 2021/8/2 16:08
  7. * @Author wangyun
  8. */
  9. @Component
  10. public class ThreadLoaclTest extends ThreadLocal {
  11. @Override
  12. protected Object initialValue() {
  13. return super.initialValue();
  14. }
  15. public ThreadLoaclTest() {
  16. super();
  17. }
  18. @Override
  19. public Object get() {
  20. return super.get();
  21. }
  22. @Override
  23. public void set(Object value) {
  24. super.set(value);
  25. }
  26. @Override
  27. public void remove() {
  28. super.remove();
  29. }
  30. }

image.png

五、CAS原子类

CAS的意思是:Compre and Swap ,为 “比较并置换”。CAS机制当中使用了3个基本操作数:内存地址V, 旧的预期值A,要修改的新值B, 只有当内存地址V所对应的值和旧的预期值A相等的时候,才会将内存地址V对应的值更新为新的值B。

在Java中的实现则通常是指以英文Atommic为前缀的一系列类,他们都采用了CAS的思想。

Atomic系列的使用是一种无锁化的CAS操作,是预计乐观锁的,它的并发性能比较高,可以多个线程同时执行,并且保证不会出现安全问题。

让我们来看看AtomicInter 的简单使用:

Jmeter 验证测试,10个线程,循环一次。
image.png

实际运行情况,如图4
58919600_1622345383.png
图4

有两个线程同时去调用access()方法,其中图4中间淡绿色区域的操作是原子操作,要不全部执行,要不全部不执行。
假设此时主存的accessCount的值为0,线程1和线程2同时通过CAS对accessCount的值进行累加,线程1和线程2都需要将accessCount从0更新到1,结果线程1很幸运的成功了,将accessCount的值更新为1,而线程2就失败了,线程2失败后,会再次通过CAS操作进行累加,这时线程2 重新读取最新的accessCount的值为1,接着将accessCount的值从1更新为2,最后就得到了准确的计算结果。

六、Synchronized/ReentrantLock加锁

Synchronized和ReentrantLock都是采用悲观锁的策略,因为他们的试下非常类似,只不过一种是通过语言层面来实现(Synchronized),另一种是通过编程方式实现(ReentrantLock),所以咱们把两种方式放在一起分析了。
先来看看一个 i++的列子,通过这两种方式都能保证线程安全。
56998100_1622677158.png
代码中lockMethod1使用了ReentrantLock的方式对累加操作进行加锁,在lock()方法调用之后,和unlock方法调用之前的代码能够保证执行的时候是原子性的。如果多个线程同时调用lockMethod1的话,也不会存在线程安全问题。

lockMethod2中,直接在方法上加了synchronized关键字,意味着这个方法执行的的时候也是原子性的,也同样不会存在线程安全问题。

在i++ 这个操作上,主要分为3个步骤:
1、读取i的值,
2、将i的值加1
3、将i的值写会主存中

这上面的3个操作都通过加锁可以保证是原子性的,要么3个操作都执行,要么3个操作都不执行,所以可以解决线程安全问题。

加锁原理如图5:
75657400_1622345383.png
首先两个线程都去争抢同一个锁,假设线程1获取到了锁,而线程2获取不到锁,就会进入等待队列,等到线程1,执行完代码逻辑之后,会去通知线程2:嘿,哥们儿,我用完了,你可以去尝试获取锁了。这时线程2会重新尝试去获取锁,假如线程2获取锁成功,线程2才开始执行代码。

Jmeter 验证通过
image.png

七、总结
最后做一下小结,本篇文章介绍了一些常见的解决并发问题的方法,这些方法分为无锁和有锁两大类。其中无锁的方法有采用局部变量,ThreadLocal、不可变对象、CAS原子类等形式的方法,而有锁也可以分为通过synchronized的方式和ReentrantLock的方式。文中分别对这些方法进行了介绍,并且通过画图和代码分析其中的实现原理,希望大家能好好学习,吸收这些知识。