两周没更新了,感觉不写点什么,有点不难受的感觉。
前言
回顾一下学Java的历程,过后是看JavaSE(根本语法、线程、泛型),而后是JavaEE,JavaEE也根本就是围绕着Servlet的应用、JSP、JDBC来学习,过后看的是B站up主颜群的教学视频:
- JavaWeb视频教程(JSP/Servlet/上传/下载/分页/MVC/三层架构/Ajax)https://www.bilibili.com/video/BV18s411u7EH?p=6&vd_source=aae...
当初一看这个播放量破百万了,当初我看的时候应该播放量很少,当初这么多倒是有点昨舌。学完了这个之后,开始学习框架:Spring、SpringMVC、MyBatis、SpringBoot。尽管Spring MVC实质上也是基于Servlet做封装,但前面根本就转型成Spring 工程师了,最近碰到一些问题,又看了一篇文章,感觉一些问题之前本人还是没思考到,颇有种离了Spring家族,不会写后端一样。原本明天的行文最后是什么是异步Servlet,异步Servlet该如何应用。然而想想没有切入实质,所以将其换成了对话体。
注释
咱们接着有请之前的实习生小陈,每当咱们须要用到对话体、故事体这样的行文。实习生小陈就会出场。明天的小陈呢感觉行情有些不好,然而还是感觉想进来看看,毕竟金三银四,于是下午就向领导销假去面试了。进到面试的中央,一番自我介绍,面试官首先问了这样一个问题:
一个申请是怎么被Tomcat所解决的呢?
小陈答复到:
我目前用的都是Spring Boot工程,我看都是启动都是在main函数外面启动整个我的项目的,而main函数又被main线程执行,所以我想应该是申请过去之后,被main线程所解决,给出响应的。
面试官:
╮(╯▽╰)╭,main函数确实是被main线程执行,但都是被main线程解决的? 这不合理吧,假如某个申请占用了main线程三秒,那这三秒内,零碎都无奈再回应申请了。你要不再想想?
小陈挠了挠头,接着答到:
的确是,浏览器和Tomcat通信用的是HTTP协定,我也学过网络编程,所以我感觉应该是一个线程一个申请吧。像上面这样:
public class ServerSocketDemo { private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(4); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); while (true){ // 一个socket对象代表一个连贯 // 期待TCP连贯申请的建设,在TCP连贯申请建设实现之前,会陷入阻塞 Socket socket = serverSocket.accept(); System.out.println("以后连贯建设:"+ socket.getInetAddress().getHostName()+socket); EXECUTOR_SERVICE.submit(()->{ try { // 从输出流中读取客户端发送的内容 InputStream inputStream = socket.getInputStream(); // 从输入流里向客户端写入数据 OutputStream outPutStream = socket.getOutputStream(); } catch (IOException e) { e.printStackTrace(); } }); } }}
serverSocket的accept在连贯建设起来会陷入阻塞。
面试官点了拍板, 接着问到:
你这个main线程负责检测连贯是否建设,而后建设之后将后续的业务解决放入线程池,这个是NIO吧。
小陈笑了笑说道:
尽管我对NIO理解不多,但这应该也不是NIO,因为前面的线程在期待数据可读可写的过程中会陷入阻塞。在操作系统中,线程是一个相当低廉的资源,咱们个别应用线程池,能够让线程的创立和回收老本绝对较低,在流动连接数不是特地高的状况下(单机小于1000),这种,模型是比拟不错的,能够让每一个连贯专一于本人的I/O并且编程模型简略。但要命的就是在连贯上来之后,这种模型呈现了问题。咱们来剖析一下咱们下面的BIO模型存在的问题,主线程在承受连贯之后返回一个Socket对象,将Socket对象提交给线程池解决。由这个线程池的线程来执行读写操作,那事实上这个线程承当的工作有判断数据可读、判断数据可写,对可读数据进行业务操作之后,将须要写入的数据进行写入。 那陷入阻塞的就是在期待数据可写、期待数据可读的过程,在NIO模型下对本来一个线程的工作进行了拆分,将判断可读可写工作进行了拆散或者对原先的模型进行了革新,原先的业务解决就只做业务解决,将判断可读还是可写、以及写入这个工作专门进行拆散。
咱们将判断可读、可写、有新连贯建设的线程权且就称之为I/O主线程吧,这个主线程在一直轮询这三个事件是否产生,如果产生了就将其就给对应的处理器。这也就是最简略的Reactor模式: 注册所有感兴趣的事件处理器,单线程轮询抉择就绪事件,执行事件处理器。
当初咱们就能够大抵总结进去NIO是怎么解决掉线程的瓶颈并解决海量连贯的: 由原来的阻塞读写变成了单线程轮询事件,找到能够进行读写的网络描述符进行读写。除了事件的轮询是阻塞的(没有满足的事件就必须要要阻塞),残余的I/O操作都是纯CPU操作,没有必要开启多线程。
面试官点了拍板,说道:
还能够嘛,小伙子,刚刚问怎么卡(qia)壳了?
小陈不好意思的挠挠头, 笑道:
其实之前看过这部分内容,只不过可能常识不必就想不起来,您提醒了一下,我才想起来。
面试官笑了一下,接着问:
那当初的服务器,个别都是多核解决,如果可能利用多外围进行I/O, 无疑对效率会有更大的进步。 是否对下面的模型进行继续优化呢?
小陈想了想答道:
认真分一下咱们须要的线程,其实次要包含以下几种:
- 事件散发器,单线程抉择就绪的事件。
- I/O处理器,包含connect、read、writer等,这种纯CPU操作,个别开启CPU外围个线程就能够了
- 业务线程,在解决完I/O后,业务个别还会有本人的业务逻辑,有的还会有其余的阻塞I/O,如DB操作,RPC等。只有有阻塞,就须要独自的线程。
面试官点了拍板,接着问道:
不错,不错。那Java的NIO晓得嘛。
小陈点了拍板说道:
晓得,Java引入了Selector、Channel 、Buffer,来实现咱们新建设的模型,Selector字面意思是选择器,负责感应事件,也就是咱们下面提到的事件散发器。Channel是一种对I/O操作的形象,能够用于读取和写入数据。Buffer则是一种用于存储数据的缓冲区,提供对立存取的操作。
面试官又问道:
有理解过Java的Selector在Linux零碎下的限度嘛?
小陈答道:
Java的Selector对于Linux零碎来说,有一个致命的限度: 同一个channel的select不能被并发的调用。因而,如果有多个I/O线程,必须保障: 一个socket只能属于一个IO线程,而一个IO线程能够治理多个socket。
面试官点了拍板:
不错,不错。Tomcat有罕用的默认配置参数有: acceptorThreadCount 、 maxConnections、maxThreads 。解释一下这几个参数的意义,并且给出一个申请在达到Tomcat之后是怎么被解决的,要求联合Servlet来进行阐明。
小陈深思了一下道:
acceptorThreadCount 用来管制接管连贯的线程数,如果服务器是多外围,能够调大一点。然而Tomcat的官网文档倡议不要超过2个。管制接管连贯这部分的代码在Acceptor这个类里,你能够看到这个类是Runnable的实现类。在Tomcat的8.0版本,你还能查到这个参数的阐明,然而在8.5这个版本就查不到,我没找到对应的阐明,然而在Tomcat 9.0源码的AbstractProtocol类中的setAcceptorThreadCount办法能够看到,这个参数被废除,下面还有阐明,说这个参数将在Tomcat的10.0被移除。maxConnections用于管制Tomcat可能接受的TCP连接数,当达到最大连接数时,操作系统会将申请的连贯放入到队列外面,这个队列的数目由acceptCount这个参数管制,默认值为100,如果超过了操作系统能接受的连贯数目,这个参数也会不起作用,TCP连贯会被操作系统回绝。maxConnections在NIO和NIO2下, 默认值是10000,在APR/native模式下,默认值是8192.
maxThreads管制最大线程数,一个HTTP申请默认会被一个线程解决,也就是一个Servlet一个线程,能够这么了解maxThreads的数目决定了Tomcat可能同时解决的HTTP申请数。默认为200。
面试官仿佛很称心,点了拍板,接着道:
小伙子,看的还挺多,NIO下面你曾经讲了, NIO2和APR是什么,你有理解过嘛?
小陈考虑了一下答复到:
我先来介绍APR吧,APR是 Apache Portable Runtime的缩写,是一个为Tomcat提供扩大能力的库,之所以带上native的起因是APR不应用Java编写的连接器,而是抉择间接调用操作系统,防止了JVM级别的开销,实践上性能会更好。NIO2加强了NIO,咱们先在只探讨网络方面的加强,NIO下面咱们是启用了轮询来判断对应的事件是否能够进行,NIO2则引入了异步IO,咱们不必再轮询,只用接管操作系统给咱们的告诉。
面试官:
当初咱们将下面的问题连贯在一起,向Tomcat应用服务器收回HTTP申请,在NIO模式下,这个申请是如何被Tomcat所解决的。
小陈道:
申请会首先达到操作系统,建设TCP连贯,这个过程由操作系统实现,咱们临时疏忽,当初这个连贯申请实现达到了Acceptor(连接器),连接器在NIO模式下会借助NIO中的channel,将其设置为非阻塞模式,而后将NioChannel注册到轮询线程上,轮询工作由Poller这个类来实现,而后由Poller将就绪的事件生成SocketProcessor, 交给Excutor去执行,Excutor这是一个线程池,线程池的大小就是在Connector 节点配置的 maxThreads 的值,这个线程池解决的工作为:
- 从socket中读取http request
- 解析生成HttpServletRequest对象
- 分派到相应的servlet并实现逻辑
- 将response通过socket发回client。
面试官:
这个线程池,你有理解过嘛?
小陈道:
这个线程池不是JDK的线程池,继承了JDK的ThreadPoolExecutor, 本身做了一些扩写,我看网上的一些博客是说的是这个ThreadPoolExecutor跟JDK的ThreadPoolExecutor行为不太统一,JDK外面的ThreadPoolExecutor在接管到工作的时候是,看以后线程池沉闷的线程数目是否小于外围线程数,如果小于就创立一个线程来执行以后提交的工作,如果以后沉闷的线程数目等于外围线程数,那么就将这个工作放到阻塞队列中,如果阻塞队列满了,判断以后沉闷的线程数目是否达到最大线程数目,如果没达到,就创立新线程去执行提交的工作。当工作处理完毕,线程池中沉闷的线程数超过外围线程池数,超出的在存活keepAliveTime和unit的工夫,就会被回收。 简略的说,就是JDK的线程池是先外围线程,再队列,最初是最大线程数。我看到的一些博客说Tomcat是先外围线程,再最大线程数,最初是队列。然而我看了Tomcat的源码,在StandardThreadExecutor执行工作的时候还是调用父类的办法,这让我很不解,先外围线程,再最大线程数,最初是队列,这个论断是怎么得进去的。
面试官点了拍板:
还不错,蛮有实证精力的,看了博客还会本人去验证。我还是蛮观赏你的,你过去一下,咱们看着源码看看能不能得出这个论断:
@Overrideprotected void startInternal() throws LifecycleException { taskqueue = new TaskQueue(maxQueueSize); TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority()); executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf); executor.setThreadRenewalDelay(threadRenewalDelay); if (prestartminSpareThreads) { executor.prestartAllCoreThreads(); } taskqueue.setParent(executor); setState(LifecycleState.STARTING); }
你说的那个线程池在StandardThreadExecutor这个类的startInternal外面被初始化,咱们看看有没有什么生面孔,恐怕惟一的生面孔就是这个TaskQueue,咱们简略的看下这个队列。从源码外面咱们能够看进去,这个类继承了LinkedBlockingQueue,咱们重点看入队和出队的办法
@Overridepublic boolean offer(Runnable o) { //we can't do any checks if (parent==null) { return super.offer(o); } //we are maxed out on threads, simply queue the object if (parent.getPoolSize() == parent.getMaximumPoolSize()) { return super.offer(o); } //we have idle threads, just add it to the queue if (parent.getSubmittedCount()<=(parent.getPoolSize())) { return super.offer(o); } //if we have less threads than maximum force creation of a new thread if (parent.getPoolSize()<parent.getMaximumPoolSize()) { return false; } //if we reached here, we need to add it to the queue return super.offer(o);}
@Override public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException { Runnable runnable = super.poll(timeout, unit); if (runnable == null && parent != null) { // the poll timed out, it gives an opportunity to stop the current // thread if needed to avoid memory leaks. parent.stopCurrentThreadIfNeeded(); } return runnable; } @Override public Runnable take() throws InterruptedException { if (parent != null && parent.currentThreadShouldBeStopped()) { return poll(parent.getKeepAliveTime(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS); // yes, this may return null (in case of timeout) which normally // does not occur with take() // but the ThreadPoolExecutor implementation allows this } return super.take(); }
通过上文咱们能够晓得,如果在线程池的线程数量和最大线程数相等,才会入队。以后未实现的工作小于以后线程池的线程数目也会入队。如果以后线程池的线程数目小于最大线程数,入队失败返回false。Tomcat的ThreadPoolExecutor继承了JDK的线程池,但在执行工作的时候仍然调用的是父类的办法,看上面的代码:
public void execute(Runnable command, long timeout, TimeUnit unit) { submittedCount.incrementAndGet(); try { super.execute(command); } catch (RejectedExecutionException rx) { if (super.getQueue() instanceof TaskQueue) { final TaskQueue queue = (TaskQueue)super.getQueue(); try { if (!queue.force(command, timeout, unit)) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull")); } } catch (InterruptedException x) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(x); } } else { submittedCount.decrementAndGet(); throw rx; } } }
所以咱们还是要进JDK的线程池看这个execute办法是怎么执行的:
public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (! isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command); }
这个代码也比拟直观,不如你提交了一个null值,抛空指针异样。而后判断以后线程池的线程数是否小于外围线程数,小于则增加线程。如果不小于外围线程数,判断以后线程池是否还在运行,如果还在运行,就尝试将工作增加进队列,走到这个判断阐明以后线程池的线程曾经达到外围线程数,然而还小于最大线程数,而后TaskQueue返回false,就接着向线程池增加线程。那么当初整个Tomcat解决申请的流程,咱们心里就大抵无数了,当初我想问一个问题,当初已知的是,我能够认为执行咱们controller办法的是线程池的线程,然而如果办法外面执行工夫比拟长,那么线程池的线程就会始终被占用,咱们的零碎当初随着业务的增长刚好面临着这样的问题,一些文件上传碰上流量高峰期,就会始终占用这个线程,导致整个零碎处于一种不可用的状态。请问该如何解决?
小陈道:
通过异步能够解决嘛,就是将这类工作进行隔离,碰上这类工作先进行返回,等到执行结束再给响应?我的意思是说应用线程池。
面试官道:
但用户怎么晓得我上传的图片是否胜利呢,你返回的后果是什么呢,是未知,而后让用户过几分钟再看看上传后果? 这看起来有些不敌对哦。 你能剖析一下外围问题在哪里嘛?
小陈陷入了深思,想了一会说道:
是的,您说的对,这的确有些不敌对,我想外围问题还是开释执行controller层办法线程,同时放弃TCP连贯。
面试官点了拍板:
还能够,其实这个能够通过异步Servlet来解决,Servlet 3.0 引入了异步Servlet,解决了咱们下面的问题,咱们能够将这种工作专门交付给一个线程池解决的共事,也放弃着本来的HTTP连贯。具体的应用如下:
@WebServlet(urlPatterns = "/asyncServlet",asyncSupported = true)public class AsynchronousServlet extends HttpServlet { private static final ExecutorService BIG_FILE_POOL = Executors.newFixedThreadPool(10); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { AsyncContext asyncContext = req.startAsync(req,resp); BIG_FILE_POOL.submit(()->{ try { TimeUnit.SECONDS.sleep(10); ServletOutputStream outputStream = resp.getOutputStream(); outputStream.write("task complete".getBytes(StandardCharsets.UTF_8)); outputStream.flush(); } catch (Exception e) { e.printStackTrace(); } asyncContext.complete(); }); }}
在Spring MVC下 该如何应用呢, Spring MVC对异步Servlet进行了封装,只须要返回DeferredResult,就能简便的应用异步Servlet:
@RequestMapping("/quotes")@ResponseBodypublic DeferredResult<String> quotes() { DeferredResult<String> deferredResult = new DeferredResult<String>(); // Add deferredResult to a Queue or a Map... return deferredResult;}// In some other thread...deferredResult.setResult(data);// Remove deferredResult from the Queue or Map
@RestControllerpublic class AsyncTestController { private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4,4,4L,TimeUnit.SECONDS,new LinkedBlockingQueue<>()); @GetMapping("/asnc") public DeferredResult<String> pictureUrl(){ DeferredResult<String> deferredResult = new DeferredResult<>(); threadPoolExecutor.execute(()->{ try { // 模仿耗时操作 TimeUnit.SECONDS.sleep(6); } catch (InterruptedException e) { e.printStackTrace(); } deferredResult.setResult("hello world"); }); return deferredResult; }}
哈哈哈哈,感觉不是面试,感觉我在给你上课一样。我对你的感觉还能够,等二面吧。
小陈:
啊,好的。
写在最初
写本文的时候用到了 chatGPT来查资料,然而chatGPT给的材料存在很多谬误,chatGPT呈现了认知偏差,比方将Jetty解决申请流程当成了Tomcat解决申请的流程,更细一点感觉还是没方法答复进去。还是要本人去看的。
参考资料
- Java NIO浅析 https://zhuanlan.zhihu.com/p/23488863
- 深度解读 Tomcat 中的 NIO 模型 https://klose911.github.io/html/nio/tomcat.html
- Tomcat - maxThreads vs. maxConnections https://stackoverflow.com/questions/24678661/tomcat-maxthreads-vs-maxconnections
- 从一次线上问题说起,详解 TCP 半连贯队列、全连贯队列 https://developer.aliyun.com/article/804896
- 就是要你懂TCP--半连贯队列和全连贯队列 https://plantegg.github.io/2017/06/07/%E5%B0%B1%E6%98%AF%E8%A...
- Tomcat 配置文档 https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
- Java NIO 系列文章之 浅析Reactor模式 https://pjmike.github.io/2018/09/20/Java-NIO-%E7%B3%BB%E5%88%...
- Java NIO - non-blocking channels vs AsynchronousChannels https://stackoverflow.com/questions/22177722/java-nio-non-blocking-channels-vs-asynchronouschannels
- asynchronous and non-blocking calls? also between blocking and synchronous https://stackoverflow.com/questions/2625493/asynchronous-and-non-blocking-calls-also-between-blocking-and-synchronous
- Java AIO 源码解析 https://cdf.wiki/posts/2976168065/
- 每天都在用,但你晓得 Tomcat 的线程池有多致力吗? https://www.cnblogs.com/thisiswhy/p/12782548.html
- 异步Servlet在转转图片服务的实际 https://juejin.cn/post/7124116514382774286