举例说明:火车站卖票,火车票总数是一定的,卖火车票的窗口有多个(2个),每个窗口就相当于一个线程,这么多的线程共用所有的火车票数量资源。如果在一个时间点上,两个线程同时使用这个资源,这样就会给乘客造成问题。问题1卖出去同一个座位的票;问题2卖出了超出火车票的总数。
在说明上述问题之前,我们首先普及一下,容器(tomcat)并行处理与Servlet的关系;假设12306看作一个web工程,部署在tomcat下。
@Component public class TicketActionImpl implements TicketAction{ @Autowired TicketService ticketService; @Override public Response sell(String id) throws InterruptedException { System.out.printf("ticketNo="+id+"; print hashcode "+this.hashCode()); int total = ticketService.getTotal(id); ticketService.updateTotal(id,total--); total = ticketService.getTotal(id); return Response.ok().build(); } }
@Service public class TicketServiceImpl implements TicketService{ public static int total=10; public int getTotal(String ticketNo){ return total; } @Override public void updateTotal(String ticketNo, int count) { total=count; } }发送两次请求第一次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户1购买G2008火车票)第二次请求:http://localhost:8080/app/api/ticket?id=G2009(窗户2购买G2009火车票)控制台打印结果:ticketNo=G2008; print hashcode 2049737772 ticketNo=G2009; print hashcode 2049737772
总结:通过打印TicketActionImpl实例的hashcode,我们发现两次请求hashcode码相同,说明tomcat将请求转发给同一个Selvlet(action)实例来处理,并不是每次请求都会新建一个Selvlet(action)实例。tomcat容器如何实现并行处理, 各种Web容器,如Tomcat,Resion,Jetty等都有自己的线程池,所以在客户端进行请求调用的时候,程序员不用针对Client的每一次请求,都新建一个线程。而容器会自动分配线程池中的线程将用户请求分发给Selvet处理,提高访问速度。
在了解Tomcat与Selvlet关系之后,我们来看看,多线程访问同一资源带来的问题。
把action中的sell方法添加Thread.sleep(10000);
public Response sell(String id) throws InterruptedException { int total = ticketService.getTotal(id); System.out.println("购买前,车票剩余总数:"+total); Thread.sleep(10000); ticketService.updateTotal(id,--total); total = ticketService.getTotal(id); System.out.println("购买后,车票剩余总数:"+total); return Response.ok().build(); }发送两次请求第一次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户1购买G2008火车票)第二次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户2购买G2008火车票)控制台打印结果:购买前,车票剩余总数:10 购买前,车票剩余总数:10 购买后,车票剩余总数:9 购买后,车票剩余总数:9总结:通过控制台打印结果发现,两个窗口同时卖同一列次火车票后,火车票总是变成了9张,很明显的错误应该是8张才对,换句话说两个窗口卖出去同一座位的车票。(上述例子中Thread.sleep(10000)为了模拟一个耗时较长的操作,例如读写文件、请求其他接口等等)
如何解决在多线程访问同一资源带来的问题,我们可以采取的措施下面详细说明并对比,我们把这种编程称之为同步、加锁。
方法一:在sell方法前声明synchronize关键字。
@Override public synchronized Response sell(String id) throws InterruptedException { System.out.println("进入sell方法");Thread.sleep(5000);int total = ticketService.getTotal(id); System.out.println("购买前,车票剩余总数:"+total); Thread.sleep(10000); ticketService.updateTotal(id,--total); total = ticketService.getTotal(id); System.out.println("购买后,车票剩余总数:"+total);System.out.println("跳出sell方法");return Response.ok().build();}
发送两次请求第一次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户1购买G2008火车票)第二次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户2购买G2008火车票)控制台打印结果:进入sell方法
购买前,车票剩余总数:10
购买后,车票剩余总数:9
跳出sell方法
进入sell方法
购买前,车票剩余总数:9
购买后,车票剩余总数:8
跳出sell方法总结:通过控制台打印结果发现,两个窗口同时卖同一列次火车票后,第一个窗口卖出后,车票剩余9张;第二个窗口卖出后,车票剩余8张,得到正确结果。需要注意的一点:第二个窗口是等第一个窗口卖完票后才会去卖票,也就是说第二个线程会等待第一个线程执行完sell方法后才会执行sell方法,内部原理当一个线程调用一个同步方法的时候,他就自动地获得了该方法所属对象的内部锁,并在方法返回的时候释放该锁。方法二:在sell方法中代码块声明synchronize关键字。@Override
public Response sell(String id) throws InterruptedException { System.out.println("进入sell方法"); Thread.sleep(5000); synchronized(this){ int total = ticketService.getTotal(id); System.out.println("购买前,车票剩余总数:"+total); Thread.sleep(10000); ticketService.updateTotal(id,--total); total = ticketService.getTotal(id); System.out.println("购买后,车票剩余总数:"+total); } System.out.println("跳出sell方法"); return Response.ok().build(); }发送两次请求第一次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户1购买G2008火车票)第二次请求:http://localhost:8080/app/api/ticket?id=G2008(窗户2购买G2008火车票)控制台打印结果:进入sell方法 进入sell方法 购买前,车票剩余总数:10 购买后,车票剩余总数:9 跳出sell方法 购买前,车票剩余总数:9 购买后,车票剩余总数:8 跳出sell方法总结:通过控制台打印结果发现,窗口2没有等待窗口1执行完sell方法后才会去执行sell方法,synchronized(this){}同步代码块持有的也是对象锁。对比方法1方法2得出,同步方法直接在方法上加synchronized实现加锁,同步代码块则在方法内部加锁,很明显,同步方法锁的范围比较大,而同步代码块范围要小点,一般同步的范围越大,性能就越差,一般需要加锁进行同步的时候,肯定是范围越小越好,这样性能更好。