学完多线程,迫不及待的先踩几个坑

365 阅读7分钟

写在最前边

所有的线程安全问题都是对共享资源的操作问题,而解决线程安全问题最简单的方法就是不使用共享资源!!!

曾经有一段时间,一直以为前后端交互并不是多线程执行的。

有这个想法是因为陷入了一个误区:从入门到现在一直认为只要是多线程就需要考虑线程安全问题!而对于前后端的交互,我一直在写,一直没有考虑,一直没有问题!!!

因此,在后来学习多线程时还对此有很大的误解:既然前后端交互是并发执行,那么我controller,service调来调去,参数传来传去,没有线程安全问题的么?还是Spring已经强大、智能到帮我们解决了线程安全问题?

这里突然想起来有人说面试被问到SpringBean是否是线程安全的?

答案是确实没有并发问题!!!

因为使用的全部都是局部变量。每个前端请求过来以后,都是使用新的参数,两次请求没有任何需要交互的地方(以查询购物车为例,A,B,C,D请求即使同时调用查询方法,那么也是自己查询自己的数据,不会出现A的数据需要交给B,需要等待C,D的情况,哪怕是同时调用service、dao也是传递各自的参数,执行各自的逻辑,当然数据库层面会做同步操作,这里不需要我们考虑)。因此在全程都没有共享资源的情况下当然不会出现并发问题。

相信这些东西,一说可能都清楚,但是使用过程中,依然无法保证没有线程安全问题。学完后似乎仅仅只是在面试时候,能说一些似乎很对的话。真让自己实战写一写代码,都不敢保证没有线程安全问题?那么原因呢?缺少练习,缺少实战,缺少思考,缺少对共享资源的定位和理解,总会遗漏一些,常年单线程CRUD忽略的细节问题出现。

示例一 (关于共享变量)

@RestController
public class TestController{

    @Resource
    private TestService testService;

    private int count;

    @GetMapping("selectTest")
    public String selectTest(){
        count++;
        return "这是第"+count+"次访问,访问结果为:"+testService.selectTest();
    }
}
  1. 上述代码中存在几个共享变量?testService是共享变量么?

  2. 上述代码中是否存在线程安全问题?

  3. 存在哪些线程安全问题?原子性?可见性?有序性?

  4. 应该如何来去保证线程安全?

第一题答: 存在一个1个被操作的共享资源:count。testService,没有对其进行操作(新增、修改、删除),只是调用了方法,因此不存在安全问题。

第二题答: 存在线程安全问题,有对共享变量count进行++操作。

第三题答: 首先存在原子性问题:count++是非原子操作的,包含了三个指令。 其次存在可见性问题:多线程(多核CPU)存在高速缓存,并非每次都是直接操作内存。

第四题解答:

@RestController
public class TestController{

    @Resource
    private TestService testService;

    // 增加volatile关键字 通过LOCK指令禁用CPU高速缓存 解决可见性问题
    private volatile int count;

    @GetMapping("selectTest")
    public String selectTest(){
        // this 表示当前示例,而且spring中默认都是单例的,锁定当前实例即可
        synchronized(this){
              count++;
        } 
        return "这是第"+count+"次访问,访问结果为:"+testService.selectTest();
    }
}

示例二:(关于共享变量)

@RestController
public class TestController{

    @Resource
    private TestService testService;

    @GetMapping("selectTest")
    public String selectTest(User user , int days){ // 纯手写伪代码 不要在意参数、注解
        List<Result> result = new ArrayList();
        // 查询用户最近N天的记录,数据较多以一天为单位分批次查询,利用user对象中的days条件
        for(int i = 0;i<days;i++){
            Thread thread = new Thread(()->{
                  user.days = i;
                  result.add(testService.selectUserTest(user));
            });
            thread.start();
        }
        //  伪代码,此处无需考虑安全,假设是等待执行完毕再返回。
        Thread.sleep(1000)
        return result;
    }
}
  1. 上述代码中存在几个被操作的共享资源?result是共享变量么?
  2. 上述代码中是哪几个点存在线程安全问题?分别是什么问题原子性?可见性?有序性?
  3. 应该如何来去保证线程安全?

第一题解答: 存在2个被操作的共享资源:result、user。user中的days每个线程都会去设置,没有进行同步很容易出现线程安全问题,result.add在线程中执行的是添加操作,且并没有地方执行读取操作,因此不会造成线程安全问题。

回归本质,解决多线程问题最好的方式就是不使用(操作)共享资源。

Thread thread = new Thread(()->{
        // 由于此处操作共享变量user,且是并发处理
        // 因此testService.selectUserTest(user),user中的days不一定是本线程中设置的值,有可能是其他线程设置的值。
        // 此处可以转换为局部变量来使用 ,自己实现深克隆,或者使用其他工具提供的 深克隆方式,生成局部变量
        // 那么此时 无需任何锁,即可实现 并行执行。
      User newUser = user.deepClone();
      newUser.days = i;
      result.add(testService.selectUserTest(newUser));
});

示例三 (关于ThreadLocal)

线程内部共享变量存入ThreadLoacl就一定安全了么?

ThreadLocal作为线程内部共享变量,在某些情况下会出现获取到意料之外的结果。

1.1 场景

大量请求,在并发请求接口(线程),接口方法开始时,向ThreadLocal中存储一个int值,表示执行阶段,每执行一个阶段的任务,值+1,当执行到某一阶段后直接结束。 在该场景中,运行一段时间后会出现开始即最终的状态。

1.2 原因分析

由于服务器tomcat中默认线程池的关系,核心线程直接是复用的。线程1在执行完A任务后,会去执行B任务,而ThreadLocal是同一线程内部共享,所以B任务能够获取到A任务设置的值。因此在使用线程池时,一定要注意最后清理掉设置的ThreadLoacl值。

1.3 代码示例

   private static final ThreadLocal<Integer> local = new ThreadLocal();

    @RequestMapping("/local")
    public Map loaclThest(@RequestParam("userId") Integer userId){
        // 设置之前先打印 线程池中结果
        String before = Thread.currentThread().getName()+":" + local.get();
        local.set(userId);
        String after = Thread.currentThread().getName()+":" + local.get();

        Map<String,String> result = new HashMap<>();
        result.put("before",before);
        result.put("after",after);
        return result;
    }

线程安全类

使用了线程安全类,就一定是线程安全的嘛?

诸如ConcurrentHashmap等线程安全容器使用时千万不要陷入一个误区,我使用了线程安全类那就一定是线程安全的。对于此类容器来说,线程安全体现在容器内部的原子性、一致性和可见性,是内部CRUD操作的线程安全,对于外部而言,依然需要考虑如何保证线程安全。

2.1 场景

加入现在有一个苹果篮子,我们计划要装满100个苹果,用十个线程操作,每个线程开始执行前判断一下篮子中还需要装多少个苹果(一次最多装20个),如果缺口大于20则只放20个,如果小于则放差额数量。(篮子我们使用ConcurrentHashmap),最后执行结果会出乎意料,无法保证最后篮子中的一定是100个。

2.2 原因分析

首先,我们要分析在本示例中,有几个点需要保证线程安全。 一、ConcurrentHashmap一定是线程安全的,对于我们启动的20个线程而言,添加、读取肯定可以保证每次都是最准确的结果。 二、线程之间的操作也要保证线程安全。如果20个线程在同一时间启动并查看篮子中的数量,那么可能每个线程得到的结果都是我要往里边放20个苹果。最终可能就导致放了400个。(有兴趣的话,可以尝试一下20个线程每次只放一个进去,最终结果是否是100个) 虽然例子有点极端,但意思已经到位了,我们要保证整个业务的线程安全,肯定能紧靠使用线程安全类。在必要的情况下,需要对业务进行加锁(千万要注意锁的粒度)。