JAVASpring

杂谈:SpringMVC中Controller为什么能够处理并发访问?

问题


SpringMVC中用来处理http请求的Controller是基于Servlet实现的,Spring中绝大多数的类都是单例的,Servlet也是这样。

Controller、Service、DAO都是默认单例模式

既然Controller是单例模式,那么它是怎么能够在同时处理很多个请求的呢?

想要搞明白这点,首先面临的一个问题是:计算机是如何处理一个请求的呢?

计算机大部分的任务都是由CPU来完成的,Controller虽然叫做控制器,但是实际上执行处理任务的角色是CPU。控制器只是提供了CPU处理请求的方法,所以实际上是CPU根据Controller中的代码来处理。

那么是谁来控制CPU来进行任务呢?当然是进程了,在我们面对的计算机中,进程是运行的基本单元。

所以计算机是如何处理一个请求的呢?请求是由计算机中某个进程根据特定的指令来处理的。

根据这一点,我们可以知道,当服务器收到一个请求后,会有一个进程来处理它,把这个请求经过拦截器等等不同的处理程序,终于来到了控制器了,控制器对它进行了一些处理,然后又把它交给下一步的程序处理(实际上的实施主体是进程),经过一些处理,这时就可以叫处理过后的数据为响应了,进程把这些数据发送到某个接受的地方,一次Http请求就完成了。

在这个过程中,真正操作的是一个进程,代码是存放在内存中的一段一段数据,进程从中读取数据,也许会对其中的某些数据进行修改(这里就涉及到了多线程的安全问题)。

而这一次处理请求并返回响应的过程,在实际中操作的是一个线程,它在主进程中创建,用于处理一个请求。

Spring mvc的controller的同一个接口同时接受多个请求时,程序执行是并发的。线程是由web容器创建的,而不是spring。一个请求过来由容器其一个线程将请求转发给DisoatcherServelet处理,它根据HadlerMapping再将请求转发给controller,这才到了我们熟悉的mvc部分。所以,HTTP并发请求是由容器负责分配线程处理的。

spring管理的对象默认都是单例,所以多线程访问同一资源(可能是controller或者service或者dao中的成员变量),必然存在线程安全问题。所以需要用到的变量尽量放到方法中。因为方法中的变量分配内存时,是分配到当前线程的栈中的当前方法的栈帧中。属于线程私有变量,所以不存在线程安全问题。


当多个请求同时访问服务器的时候


现在,有多个请求同时访问服务器,每个请求都有一个线程来处理,线程由服务器程序来创建(例如SpringBoot默认使用的Tomcat),线程根据内存中的代码(代码相当于说明书)执行下去,每个线程都可以访问到Controller中的代码,如果Controller只有一个的话,那每个线程都访问这个Controller,根据它的代码来执行。代码就像是一份说明书,无论多少的请求,都按照同一份说明书来处理。

知道了每个请求都是由一个线程来处理,我们也就可以明白一个服务器同时能够处理的请求数与它的线程数有很大的关系。线程的创建是比较消耗资源的,所以容器一般维持一个线程池。像Tomcat的线程池 maxThreads 是200, minSpareThreads 是25。实际中单个Tomcat服务器的最大并发数只有几百,部分原因就是只能同时处理这么多线程上的任务。当然,并发的限制肯定不止在这里,还有很多需要考虑的地方。

因此,应对请求分配线程处理的是servlet容器(也就是tomcat等服务器程序)。

Controller、Service、DAO等类都默认为单例模式


我们知道, Controller、Service、DAO都是默认为单例模式的,

又因为,如果一个类无论什么时候都不会改变,那么它就是线程安全的,无论多少线程同时访问,都会得到相同的结果,不会有任何影响,不用考虑多线程带来的影响。

所以在Controller、Service、DAO尽量使用局部变量,不要使用类的成员变量,如果使用的话,记得一定要加锁。

控制器中如果没有维持可变的成员变量,也类似于不可变类,它在多线程情况下也不需要多考虑,和在单线程下区别不大,当然这一般不会发生。我们经常在其中定义许多Service,在容器启动的时候这些Service被注入进来,用户传入的请求大部分在这里和服务器进行交互,比如查看当前是否登录,请求查看用户信息等等,根据Controller中的代码,调用不同的Service对这些信息进行处理。这里就要考虑到线程安全的问题了。

Controller、Service、DAO等类中的方法当中的并发问题


尽管 Controller、Service、DAO都是默认为单例模式的,

但是每个方法在调用栈里都会有自己独立的栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。

栈帧是在调用方法时创建,方法返回时“消亡”。

局部变量存放在哪里?
局部变量的作用域在方法内部,当方法执行完,局部变量也就没用了。可以这么说,方法返回时,局部变量也就“消亡”了。此时,我们会联想到调用栈的栈帧。没错,局部变量就是存放在调用栈里的。此时,我们可以将方法的调用栈用下图表示。

线程封闭
方法里的局部变量,因为不会和其他线程共享,所以不会存在并发问题。这种解决问题的技术也叫做线程封闭。仅在单线程内访问数据。由于不存在共享,所以即使不设置同步,也不会出现并发问题。

所以在Controller、Service、DAO尽量使用局部变量,不要使用类的成员变量,如果使用的话,记得一定要根据业务逻辑来判断是否要加锁。

Controller不是线程安全的(单例,存在成员变量时出现线程安全问题)


正因为Controller默认是单例,所以不是线程安全的。如果用SpringMVC 的 Controller时,尽量不在 Controller中使用实例变量,否则会出现线程不安全性的情况,导致数据逻辑混乱。

举一个简单的例子,在一个Controller中定义一个非静态成员变量 num 。通过Controller成员方法来对 num 增加。

@Controller
public class TestController {
    private int num = 0;
    
    @RequestMapping("/addNum")
    public void addNum() {
        System.out.println(++num);
    }
}

在本地运行后:

首先访问 http:// localhost:8080 / addNum,得到的答案是1;
再次访问 http:// localhost:8080 / addNum,得到的答案是 2。
两次访问得到的结果不同,num已经被修改,并不是我们希望的结果,接口的幂等性被破坏。

从这个例子可以看出,所有的请求访问同一个Controller实例,Controller的私有成员变量就是线程共用的。某个请求对应的线程如果修改了这个变量,那么在别的请求中也可以读到这个变量修改后的的值。

关于DAO并发访问数据的问题


假设一个例子,现在要做一个用户注册服务,用户注册需要绑定手机号码,因此,注册的业务逻辑中必须要有一个判断,也就是判断该手机号码有没有被注册,这需要DAO层去数据库查询是否有拥有该手机号码的记录。

现在有两个注册请求同时发出,带着一样的电话号码。这两个请求同时到达Tomcat服务器,在两个线程内同时调用Controller,在DAO层查询电话号码的结果都是“该手机号码没有被注册”,于是都用该电话号码进行了注册,但是由于数据库库中,用户表中,电话号码这个属性被设置了unique key,所以这两个注册请求一定会有一个请求发生异常,因此,做这种功能的时候一定要加上异常处理。

**单例模式(Singleton)**是程序设计中一种非常重要的设计模式,设计模式也是Java面试重点考察的一个方面。面试经常会问到的一个问题是:SpringMVC中的Controller是单例还是多例,很多同学可能会想当然认为Controller是多例,其实不然。

根据Tomcat官网中的介绍,对于一个浏览器请求,tomcat会指定一个处理线程,或是在线程池中选取空闲的,或者新建一个线程。

在Tomcat容器中,每个servlet是单例的。在SpringMVC中,Controller 默认也是单例。采用单例模式的最大好处,就是可以在高并发场景下极大地节省内存资源,提高服务抗压能力。

单例模式容易出现的问题是:在Controller中定义的实例变量,在多个请求并发时会出现竞争访问,Controller中的实例变量不是线程安全的。

关于controller的一点疑问


一般情况下,spring中的controller是单例模式的,也就是说所有的访问都会调用同一个controller的方法,自然而然的就会想到并发的问题,当某一个请求调用controller方法尚未退出时,是否会造成后续请求的阻塞。
写个小demo测试一下

	@RequestMapping("/dotest01/{id}")
	@ResponseBody
	public String dotest01(@PathVariable("id") int id) {
		long start =System.currentTimeMillis();
		String str;
		if(id==1) {
			try {
				Thread.sleep(4000);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			log.info("1号"+Thread.currentThread().getName());
			log.info(this.toString());
			str="1号结果!";
		}else {
			log.info("2号"+Thread.currentThread().getName());
			log.info(this.toString());
			str="2号结果";
		}
		long time =System.currentTimeMillis()-start;
		return str+time;
	}


进行测试先发送访问dotest01/1,后访问dotest01/2,很明显第一个页面尚在加载,第二个就已经返回

再看看后头的对象

很明显,线程是单独的但是controller是同一个。
可以确认的是,不用考虑controller的阻塞问题,再写一个多线程测试案例

public class TestThread {
	public static void main(String[] args) {
		MyTool tool = new MyTool();
		new Thread(new MyThread(0, tool)).start();
		new Thread(new MyThread(1, tool)).start();
	}
}

class MyThread implements Runnable {
	
	private int id;
	
	private MyTool tool;
	
	public MyThread(int id,MyTool tool) {
		this.id=id;
		this.tool=tool;
	}
	
	public void run() {
		long start = System.currentTimeMillis();
		System.out.println("Thred:"+Thread.currentThread().getName()+"=="+tool.toString());
		tool.dosome(id);
		long time = System.currentTimeMillis()-start;
		System.out.println("id"+id+"==time="+time);
	}
}

 class MyTool {
	public void dosome(int id) {
		if(id ==0) {
		System.out.println("00开始工作!");
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println("00工作完成!");
		}else {
			System.out.println("01开始工作!");
			System.out.println("01工作完成!");
		}		
	}
}

测试结果为

符合期望,工作线程不同,工作对象同一个。在修改一下

public synchronized void dosome(int id){
		.....
}

为方法加上锁,这时候就不同了

此时线程才会阻塞,说到底对阻塞的概念有点混乱,多线程是可以同时访问通一个不加锁方法,所谓额并发问题是多线程操作造成的数据混乱,阻塞是加锁造成的,很显然controller并未给方法加锁,所以并不会有阻塞的问题。
但是也得注意,在controller中创建全局变量这时候就要考虑并发问题了。

Springboot中的定时任务是否会发生阻塞?


Springboot中一个定时任务没执行完,是否会影响下一个定时任务执行?

在springboot中使用定时任务的步骤

1.在启动类上加上注解:@EnableScheduling,表示允许定时任务执行

2.定时任务需要在类上加上@Component或者其衍生类(Controller、Service等),用于纳入Spring容器管理。

3.在需要定时任务方法上增加注解@Scheduled,注解的参数是定时任务执行时机

首先需要知道:定时任务默认是单线程的。所以默认情况下,上一个定时任务没有执行完,下一个定时任务是不会开始的。

结论:

  • 1.定时任务默认是单线程的。如果任务执行时间超过定时任务间隔时间,不管是同一个定时任务还是不同的定时任务,下一个任务都会被阻塞。
  • 2.实现SchedulingConfigurer接口后,定时任务会变成多线程执行。不同的定时任务之间互不影响,同一个定时任务(方法)依然会有被阻塞的机制。
  • 3.如果定时任务交给线程池处理,则下一个任务不会被阻塞。