JAVASpring

SpringMVC中的@Controller和@Service高并发线程安全问题

背景


Spring Bean的scope默认是singleton(单例)模式,容器本身并没有提供Bean的线程安全策略,因此Spring容器中的默认Bean本身线程不安全。

之前的文章 Spring的线程安全问题 也说明了Bean在多线程如果有共享变量线程不安全的示例以及解决单例模式Bean线程不安全的方案。

  • scope 的值改为 prototype
  • bean改为无状态的
  • 加锁
  • ThreadLocal

Spring并发访问的线程安全性问题  


由于Spring MVC默认是Singleton的,所以会产生一个潜在的安全隐患。根本核心是instance变量保持状态的问题。这意味着每个request过来,系统都会用原有的instance去处理,这样导致了两个结果:

  • 一是我们不用每次创建Controller,
  • 二是减少了对象创建和垃圾收集的时间;

由于只有一个Controller的instance,当多个线程同时调用它的时候,它里面的instance变量就不是线程安全的了,会发生窜数据的问题。
当然大多数情况下,我们根本不需要考虑线程安全的问题,比如dao,service等,除非在bean中声明了实例变量。因此,我们在使用spring mvc 的contrller时,应避免在controller中定义实例变量。

public class Controller extends AbstractCommandController {
   protectedCompany company;
   
   ......
   
   protected ModelAndView handle(HttpServletRequest request, 
                                 HttpServletResponse response,
                                 Object command, BindException errors)throwsException{
      company =................;
   }
}

在这里有声明一个变量company,这里就存在并发线程安全的问题。如果控制器是使用单例形式,且controller中有一个私有的变量company,所有请求到同一个controller时,使用的company变量是共用的,即若是某个请求中修改了这个变量company,则,在别的请求中能够读到这个修改的内容。

有几种解决方法

  • 在控制器中不使用实例变量
  • 将控制器的作用域从单例改为原型,即在spring配置文件Controller中声明scope=”prototype”,每次都创建新的controller
  • 在Controller中使用ThreadLocal变量

这几种做法有好有坏:

第一种:需要开发人员拥有较高的编程水平与思想意识,在编码过程中力求避免出现这种BUG,

好处是实例对象只有一个,所有的请求都调用该实例对象,速度和性能上要优于第二种, 不好的地方,就是需要程序员自己去控制实例变量的状态保持问题。

第二种:则是容器自动的对每个请求产生一个实例,由JVM进行垃圾回收,因此做到了线程安全。
由于每次请求都创建一个实例,所以会消耗较多的内存空间。

所以在使用spring开发web 时要注意,默认Controller、Dao、Service都是单例的

那今天聊下 spring单例,为什么controller、service和dao确能保证线程安全?


首先,看一下认证控制类PcAuthController :

@RestController
public class PcAuthController extends BaseController {
    private static final Logger LOG = LoggerFactory.getLogger(PcAuthController.class);
    
    @Autowired
    PcCardAuth4Service cardAuth4Service;
	
    //controller层对外暴露接口,尽量不要做业务处理,通过统一上下文BusinessContext承载数据,调用service模块处理
    @RequestMapping(value = "/milkyway-pc/pcng-auth-cardAuth4", method = RequestMethod.POST)
    public PcCardAuth4Resp cardAuth4(@RequestBody PcCardAuth4Req req) {
    
        // 业务调用逻辑编排
        cardAuth4Service.transaction(bizContext);
    }
}

控制类PcAuthController注入服务类PcCardAuth4Service,每次接收到 “/milkyway-pc/pcng-auth-cardAuth4” 的请求,均会调用方法cardAuth4。

每个线程调用同一个对象的同一个方法,都拥有自己的线程栈,这个线程栈包含了这个线程调用的方法当前执行点相关的信息,即使两个线程执行同样的代码,一个线程创建的本地变量对其它线程不可见,仅自己可见。

每个请求都是单独的线程,即使同时访问同一个Controller对象,因为并没有修改Controller对象,相当于针对Controller对象而言,只是读操作,没有写操作,不需要做同步处理。

方法cardAuth4内部调用成员变量cardAuth4Service的方法transaction(),多线程场景下,成员变量修改会线程不安全,但只是调用成员变量cardAuth4Service的方法transaction(),并没有修改PcCardAuth4Service对象,故也不存在线程安全问题。

认证服务类PcCardAuth4Service :

@Service
public class PcCardAuth4Service extends BaseFeignService {

    private static final Logger LOG = LoggerFactory.getLogger(PcCardAuth4Service.class);

    /**
     * socket交易通讯IP
     */
    @Value("${service.socketUppJson.ip}")
    private String socketIp;

    /**
     * socket交易通讯端口
     */
    @Value("${service.socketUppJson.port}")
    private String socketPort;

    @Autowired
    private PcTranDaoService pcTranDaoService;


    @Override
    public boolean transaction(IBizContext bizContext) {
        LOG.info("PcCardAuth4Service enter !!!");

        // TODO somethings
    }
}


Service层、Dao层用默认singleton就行,虽然Service类也有dao这样的属性,但dao这些类都是没有状态信息的,也就是 相当于不变(immutable)类,所以不影响。

SpringMVC默认创建bean是单例的,高并发情况下,如何保证性能的?


咋一想,因为SpringMVC默认创建bean是单例的,高并发时,会不会因为都要访问同一个bean而影响性能呢。

实质上这种理解是错误的,Java里有个API叫做ThreadLocal,spring单例模式下用它来切换不同线程之间的参数。用ThreadLocal是为了保证线程安全,实际上ThreadLoacal的key就是当前线程的Thread实例。单例模式下,spring把每个线程可能存在线程安全问题的参数值放进了ThreadLocal。这样虽然是一个实例在操作,但是不同线程下的数据互相之间都是隔离的,因为运行时创建和销毁的bean大大减少了,所以大多数场景下这种方式对内存资源的消耗较少,而且并发越高优势越明显。

总的来说就是,单例模式因为大大节省了实例的创建和销毁的时间,有利于提高性能,而ThreadLocal用来保证线程安全性。

单例模式是spring推荐的配置,它在高并发下能极大的节省资源,提高服务抗压能力。

@Controller/@Service使用注意事项


默认情况下,注入的Bean对象,scope值是单例-singleton的,线程不安全。
默认情况下,@Controller/@Service,scope值是单例-singleton的,也是线程不安全。
尽量不要在@Controller/@Service等容器中定义静态变量,不论是单例(singleton)还是多实例(prototype)他都是线程不安全的。
一定要定义变量的话,用ThreadLocal来封装,这个是线程安全的。

多线程操作同一个对象并且调用相同的方法,为什么不会阻塞?


这个问题的缘起是 spring mvc 默认是单例的,所以我产生了这样一个疑问?在高并发下,是单例模式效率高还是多例模式效率高?之所以有这个疑问是因为我认为单例模式下,当一个请求在处理中的时候,接下来的请求会处于等待中(小白,大家勿笑)。之后有大神告诉我不会阻塞,因为 web 容器是多线程的。当时我觉得自己理解了。但之后我就咂摸这句话,是因为多线程所以不会阻塞。为什么多线程就不会阻塞呢?为什么多个线程操作同一个对象的同一个方法就不会阻塞呢?为什么多线程执行同一个对象的同一个方法中的相同代码不会阻塞呢?求教各位大神。

回答:

新的请求会新开线程进行相应。

一个函数被执行时会分配新的内存区域,一个线程执行就分配一次,所以他们是互不可见的。

而如果有static变量或者单例变量,这种情况多线程写会有冲突。

每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。

函数就像菜谱,不同线程就像不同厨师,能不能并行做菜和菜谱的性质本身无关,而是比如只有一个水龙头,菜谱要求每个厨师去接水,这个接水才会产生并发冲突

ChatGpt的回答

小结


  • spring单例模式,controller、service和dao一般都是无状态的,就算是有状态的,只是调用里面的方法,而且多线程调用一个实例的方法,会在内存中复制变量,这是自己的线程的工作内存,是线程安全的。
  • controller、service一定要定义变量的话,用ThreadLocal来封装,保证线程安全。
  • 单例模式大大节省了实例的创建和销毁的时间,有利于提高性能,而ThreadLocal用来保证线程安全性。