了解架构设计远远不够!一文拆解 Tomcat 高并发原理与性能调优
上帝视角拆解 Tomcat 架构设计,在了解整个组件设计思路之后。我们需要下凡深入了解每个组件的细节实现。从远到近,架构给人以宏观思维,细节展现饱满的美。关注「码哥字节」获取更多硬核,你,准备好了么?
在上文中,我们站在上帝视角给大家拆解了 Tomcat 架构设计,分析 Tomcat 如何实现启动、停止,通过设计连接池与容器两大组件完成了一个请求的接受与响应。连接器负责对外交流,处理 socket 连接,容器对内负责,加载 Servlet 以及处理具体 Request 请求与响应。
高并发拆解核心准备
这回,再次拆解,专注 Tomcat 高并发设计之道与性能调优,让大家对整个架构有更高层次的了解与感悟。其中设计的每个组件思路都是将 Java 面向对象、面向接口、如何封装变与不变,如何根据实际需求抽象不同组件分工合作,如何设计类实现单一职责,怎么做到将相似功能高内聚低耦合,设计模式运用到极致的学习借鉴。
这次主要涉及到的是 I/O 模型,以及线程池的基础内容。
希望大家重视如下几个知识点,在掌握以下知识点再来拆解 Tomcat,就会事半功倍,否则很容易迷失方向不得其法。
一起来看 Tomcat 如何实现并发连接处理以及任务处理,性能的优化是每一个组件都起到对应的作用,如何使用最少的内存,最快的速度执行是我们的目标。
设计模式
👉模板方法模式:抽象算法流程在抽象类中,封装流程中的变化与不变点。将变化点延迟到子类实现,达到代码复用,开闭原则。
👉观察者模式:针对事件不同组件有不同响应机制的需求场景,达到解耦灵活通知下游。
👉责任链模式:将对象连接成一条链,将沿着这条链传递请求。在 Tomcat 中的 Valve 就是该设计模式的运用。
I/O 模型
Tomcat 实现高并发接收连接,必然涉及到 I/O 模型的运用,了解同步阻塞、异步阻塞、I/O 多路复用,异步非阻塞相关概念以及 Java NIO 包的运用很有必要。本文也会带大家着重说明 I/O 是如何在 Tomcat 运用实现高并发连接。大家通过本文我相信对 I/O 模型也会有一个深刻认识。
Java 并发编程
实现高并发,除了整体每个组件的优雅设计、设计模式的合理、I/O 的运用,还需要线程模型,如何高效的并发编程技巧。在高并发过程中,不可避免的会出现多个线程对共享变量的访问,需要加锁实现,如何高效的降低锁冲突。因此作为程序员,要有意识的尽量避免锁的使用,比如可以使用原子类 CAS 或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。
对于并发相关的基础知识,如果读者感兴趣「码哥字节」后面也给大家安排上,目前也写了部分并发专辑,大家可移步到历史文章或者专辑翻阅,主要讲解了并发实现的原理、什么是内存可见性,JMM 内存模模型、读写锁等并发知识点。
Tomcat 总体架构
再次回顾下 Tomcat 整体架构设计,主要设计了 connector 连接器处理 TCP/IP 连接,container 容器作为 Servlet 容器,处理具体的业务请求。对外对内分别抽象两个组件实现拓展。
一个 Tomcat 实例默认会有一个 Service,而一个 Service 可以包含多个连接器。连接器主要有 ProtocalHandler 和 Adapter 两个组件共同完成连接器核心功能。
ProtocolHandler 主要由 Acceptor 以及 SocketProcessor 构成,实现了 TCP/IP 层 的 Socket 读取并转换成 TomcatRequest 和 TomcatResponse,最后根据 http 或者 ajp 协议获取合适的 Processor 解析为应用层协议,并通过 Adapter 将 TomcatRequest、TomcatResponse 转化成 标准的 ServletRequest、ServletResponse。通过 getAdapter().service(request, response);将请求传递到 Container 容器。
adapter.service()实现将请求转发到容器 org.apache.catalina.connector.CoyoteAdapter
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(
request, response);
这个调用会触发 getPipeline 构成的责任链模式将请求一步步走入容器内部,每个容器都有一条 Pipeline,通过 First 开始到 Basic 结束并进入容器内部持有的子类容器,最后到 Servlet,这里就是责任链模式的经典运用。具体的源码组件是 Pipeline 构成一条请求链,每一个链点由 Valve 组成。如下图所示,整个 Tomcat 的架构设计重要组件清晰可见,希望大家将这个全局架构图深深印在脑海里,掌握全局思路才能更好地分析细节之美。
启动流程:startup.sh 脚本到底发生了什么
Tomcat 本生就是一个 Java 程序,所以 startup.sh 脚本就是启动一个 JVM 来运行 Tomcat 的启动类 Bootstrap。
Bootstrap 主要就是实例化 Catalina 和初始化 Tomcat 自定义的类加载器。热加载与热部署就是靠他实现。
Catalina: 解析 server.xml 创建 Server 组件,并且调用 Server.start() 方法。
Server:管理 Service 组件,调用 Server 的 start() 方法。
Service:主要职责就是管理连接器和顶层容器 Engine,分别调用 Connector 和 Engine 的 start 方法。
Engine 容器主要就是组合模式将各个容器根据父子关系关联,并且 Container 容器继承了 Lifecycle 实现各个容器的初始化与启动。Lifecycle 定义了 init()、start()、stop() 控制整个容器组件的生命周期实现一键启停。
这里就是一个面向接口、单一职责的设计思想 ,Container 利用组合模式管理容器,LifecycleBase 抽象类继承 Lifecycle 将各大容器生命周期统一管理这里便是,而实现初始化与启动的过程又 LifecycleBase 运用了设计模式抽象出组件变化与不变的点,将不同组件的初始化延迟到具体子类实现。并且利用观察者模式发布启动事件解耦。
具体的 init 与 start 流程如下泳道图所示:这是我在阅读源码 debug 所做的笔记,读者朋友们不要怕笔记花费时间长,自己跟着 debug 慢慢记录,相信会有更深的感悟。
init 流程
start 流程
读者朋友根据我的两篇内容,抓住主线组件去 debug,然后跟着该泳道图阅读源码,我相信都会有所收获,并且事半功倍。在读源码的过程中,切勿进入某个细节,一定要先把各个组件抽象出来,了解每个组件的职责即可。最后在了解每个组件的职责与设计哲学之后再深入理解每个组件的实现细节,千万不要一开始就想着深入理解具体一篇叶子。
每个核心类我在架构设计图以及泳道图都标识出来了,「码哥字节」给大家分享下如何高效阅读源码,以及保持学习兴趣的心得体会。
如何正确阅读源码
切勿陷入细节,不看全局:我还没弄清楚森林长啥样,就盯着叶子看 ,看不到全貌和整体设计思路。所以阅读源码学习的时候不要一开始就进入细节,而是宏观看待整体架构设计思想,模块之间的关系。
1.阅读源码之前,需要有一定的技术储备
比如常用的设计模式,这个必须掌握,尤其是:模板方法、策略模式、单例、工厂、观察者、动态代理、适配器、责任链、装饰器。大家可以看 「码哥字节」关于设计模式的历史文章,打造好的基础。
2.必须会使用这个框架/类库,精通各种变通用法
魔鬼都在细节中,如果有些用法根本不知道,可能你能看明白代码是什么意思,但是不知道它为什么这些写。
3.先去找书,找资料,了解这个软件的整体设计。
从全局的视角去看待,上帝视角理出主要核心架构设计,先森林后树叶。都有哪些模块?模块之间是怎么关联的?怎么关联的?
可能一下子理解不了,但是要建立一个整体的概念,就像一个地图,防止你迷航。
在读源码的时候可以时不时看看自己在什么地方。就像「码哥字节」给大家梳理好了 Tomcat 相关架构设计,然后自己再尝试跟着 debug,这样的效率如虎添翼。
4. 搭建系统,把源代码跑起来!
Debug 是非常非常重要的手段, 你想通过只看而不运行就把系统搞清楚,那是根本不可能的!合理运用调用栈(观察调用过程上下文)。
5.笔记
一个非常重要的工作就是记笔记(又是写作!),画出系统的类图(不要依靠 IDE 给你生成的), 记录下主要的函数调用, 方便后续查看。
文档工作极为重要,因为代码太复杂,人的大脑容量也有限,记不住所有的细节。文档可以帮助你记住关键点, 到时候可以回想起来,迅速地接着往下看。
要不然,你今天看的,可能到明天就忘个差不多了。所以朋友们记得收藏后多翻来看看,尝试把源码下载下来反复调试。
错误方式
陷入细节,不看全局:我还没弄清楚森林长啥样,就盯着叶子看 ,看不到全貌和整体设计思路。所以阅读源码学习的时候不要一开始就进入细节,而是宏观看待整体架构设计思想,模块之间的关系。
还没学会用就研究如何设计:首先基本上框架都运用了设计模式,我们最起码也要了解常用的设计模式,即使是“背”,也得了然于胸。在学习一门技术,我推荐先看官方文档,看看有哪些模块、整体设计思想。然后下载示例跑一遍,最后才是看源码。
看源码深究细节:到了看具体某个模块源码的时候也要下意识的不要去深入细节,重要的是学习设计思路,而不是具体一个方法实现逻辑。除非自己要基于源码做二次开发,而且二次开发也是基于在了解整个架构的情况下才能深入细节。
组件设计-落实单一职责、面向接口思想
当我们接到一个功能需求的时候,最重要的就是抽象设计,将功能拆解主要核心组件,然后找到需求的变化与不变点,将相似功能内聚,功能之间若耦合,同时对外支持可拓展,对内关闭修改。努力做到一个需求下来的时候我们需要合理的抽象能力抽象出不同组件,而不是一锅端将所有功能糅合在一个类甚至一个方法之中,这样的代码牵一发而动全身,无法拓展,难以维护和阅读。
看看 Tomcat 如何实现将 Tomcat 启动,并且又是如何接受请求,将请求转发到我们的 Servlet 中。
Catalina
主要任务就是创建 Server,并不是简单创建,而是解析 server.xml 文件把文件配置的各个组件意义创建出来,接着调用 Server 的 init() 和 start() 方法,启动之旅从这里开始…,同时还要兼顾异常,比如关闭 Tomcat 还需要做到优雅关闭启动过程创建的资源需要释放,Tomcat 则是在 JVM 注册一个「关闭钩子」,源码我都加了注释,省略了部分无关代码。同时通过 await() 监听停止指令关闭 Tomcat。
/**
* Start a new server instance.
*/
public void start() {
// 若 server 为空,则解析 server.xml 创建
if (getServer() == null) {
load();
}
// 创建失败则报错并退出启动
if (getServer() == null) {
log.fatal("Cannot start server. Server instance is not configured.");
return;
}
// 开始启动 server
try {
getServer().start();
} catch (LifecycleException e) {
log.fatal(sm.getString("catalina.serverStartFail"), e);
try {
// 异常则执行 destroy 销毁资源
getServer().destroy();
} catch (LifecycleException e1) {
log.debug("destroy() failed for failed Server ", e1);
}
return;
}
// 创建并注册 JVM 关闭钩子
if (useShutdownHook) {
if (shutdownHook == null) {
shutdownHook = new CatalinaShutdownHook();
}
Runtime.getRuntime().addShutdownHook(shutdownHook);
}
// 通过 await 方法监听停止请求
if (await) {
await();
stop();
}
}
通过「关闭钩子」,就是当 JVM 关闭的时候做一些清理工作,比如说释放线程池,清理一些零时文件,刷新内存数据到磁盘中…...
「关闭钩子」本质就是一个线程,JVM 在停止之前会尝试执行这个线程。我们
来看下 CatalinaShutdownHook 这个钩子到底做了什么。
/**
* Shutdown hook which will perform a clean shutdown of Catalina if needed.
*/
protected class CatalinaShutdownHook extends Thread {
@Override
public void run() {
try {
if (getServer() != null) {
Catalina.this.stop();
}
} catch (Throwable ex) {
...
}
}
/**
* 关闭已经创建的 Server 实例
*/
public void stop() {
try {
// Remove the ShutdownHook first so that server.stop()
// doesn't get invoked twice
if (useShutdownHook) {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
}
} catch (Throwable t) {
......
}
// 关闭 Server
try {
Server s = getServer();
LifecycleState state = s.getState();
// 判断是否已经关闭,若是在关闭中,则不执行任何操作
if (LifecycleState.STOPPING_PREP.compareTo(state) <= 0
&& LifecycleState.DESTROYED.compareTo(state) >= 0) {
// Nothing to do. stop() was already called
} else {
s.stop();
s.destroy();
}
} catch (LifecycleException e) {
log.error("Catalina.stop", e);
}
}
实际上就是执行了 Server 的 stop 方法,Server 的 stop 方法会释放和清理所有的资源。
Server 组件
来体会下面向接口设计美,看 Tomcat 如何设计组件与接口,抽象 Server 组件,Server 组件需要生命周期管理,所以继承 Lifecycle 实现一键启停。
它的具体实现类是 StandardServer,如下图所示,我们知道 Lifecycle 主要的方法是组件的 初始化、启动、停止、销毁,和 监听器的管理维护,其实就是观察者模式的设计,当触发不同事件的时候发布事件给监听器执行不同业务处理,这里就是如何解耦的设计哲学体现。
而 Server 自生则是负责管理 Service 组件。
接着,我们再看 Server 组件的具体实现类是 StandardServer 有哪些功能,又跟哪些类关联?
在阅读源码的过程中,我们一定要多关注接口与抽象类,接口是组件全局设计的抽象;而抽象类基本上是模板方法模式的运用,主要目的就是抽象整个算法流程,将变化点交给子类,将不变点实现代码复用。
StandardServer 继承了 LifeCycleBase,它的生命周期被统一管理,并且它的子组件是 Service,因此它还需要管理 Service 的生命周期,也就是说在启动时调用 Service 组件的启动方法,在停止时调用它们的停止方法。Server 在内部维护了若干 Service 组件,它是以数组来保存的,那 Server 是如何添加一个 Service 到数组中的呢?
/**
* 添加 Service 到定义的数组中
*
* @param service The Service to be added
*/
@Override
public void addService(Service service) {
service.setServer(this);
synchronized (servicesLock) {
// 创建一个 services.length + 1 长度的 results 数组
Service results[] = new Service[services.length + 1];
// 将老的数据复制到 results 数组
System.arraycopy(services, 0, results, 0, services.length);
results[services.length] = service;
services = results;
// 启动 Service 组件
if (getState().isAvailable()) {
try {
service.start();
} catch (LifecycleException e) {
// Ignore
}
}
// 观察者模式运用,触发监听事件
support.firePropertyChange("service", null, service);
}
}
从上面的代码可以知道,并不是一开始就分配一个很长的数组,而是在新增过程中动态拓展长度,这里就是为了节省空间,对于我们平时开发是不是也要主要空间复杂度带来的内存损耗,追求的就是极致的美。
除此之外,还有一个重要功能,上面 Caralina 的启动方法的最后一行代码就是调用了 Server 的 await 方法。
这个方法主要就是监听停止端口,在 await 方法里会创建一个 Socket 监听 8005 端口,并在一个死循环里接收 Socket 上的连接请求,如果有新的连接到来就建立连接,然后从 Socket 中读取数据;如果读到的数据是停止命令“SHUTDOWN”,就退出循环,进入 stop 流程。
Service
同样是面向接口设计,Service 组件的具体实现类是 StandardService,Service 组件依然是继承 Lifecycle 管理生命周期,这里不再累赘展示图片关系图。我们先来看看 Service 接口主要定义的方法以及成员变量。通过接口我们才能知道核心功能,在阅读源码的时候一定要多关注每个接口之间的关系,不要急着进入实现类。
public interface Service extends Lifecycle {
// ----------主要成员变量
//Service 组件包含的顶层容器 Engine
public Engine getContainer();
// 设置 Service 的 Engine 容器
public void setContainer(Engine engine);
// 该 Service 所属的 Server 组件
public Server getServer();
// --------------------------------------------------------- Public Methods
// 添加 Service 关联的连接器
public void addConnector(Connector connector);
public Connector[] findConnectors();
// 自定义线程池
public void addExecutor(Executor ex);
// 主要作用就是根据 url 定位到 Service,Mapper 的主要作用就是用于定位一个请求所在的组件处理
Mapper getMapper();
}
接着再来细看 Service 的实现类:
public class StandardService extends LifecycleBase implements Service {
// 名字
private String name = null;
//Server 实例
private Server server = null;
// 连接器数组
protected Connector connectors[] = new Connector[0];
private final Object connectorsLock = new Object();
// 对应的 Engine 容器
private Engine engine = null;
// 映射器及其监听器,又是观察者模式的运用
protected final Mapper mapper = new Mapper();
protected final MapperListener mapperListener = new MapperListener(this);
}
StandardService 继承了 LifecycleBase 抽象类,抽象类定义了 三个 final 模板方法定义生命周期,每个方法将变化点定义抽象方法让不同组件实现自己的流程。这里也是我们学习的地方,利用模板方法抽象变与不变。
此外 StandardService 中还有一些我们熟悉的组件,比如 Server、Connector、Engine 和 Mapper。
那为什么还有一个 MapperListener?这是因为 Tomcat 支持热部署,当 Web 应用的部署发生变化时,Mapper 中的映射信息也要跟着变化,MapperListener 就是一个监听器,它监听容器的变化,并把信息更新到 Mapper 中,这是典型的观察者模式。下游服务根据多上游服务的动作做出不同处理,这就是的运用场景,实现一个事件多个监听器触发,事件发布者不用调用所有下游,而是通过观察者模式触发达到解耦。
Service 管理了 连接器以及 Engine 顶层容器,所以继续进入它的 startInternal 方法,其实就是 LifecycleBase 模板定义的 抽象方法。看看他是怎么启动每个组件顺序。
protected void startInternal() throws LifecycleException {
//1. 触发启动监听器
setState(LifecycleState.STARTING);
//2. 先启动 Engine,Engine 会启动它子容器,因为运用了组合模式,所以每一层容器在会先启动自己的子容器。
if (engine != null) {
synchronized (engine) {
engine.start();
}
}
//3. 再启动 Mapper 监听器
mapperListener.start();
//4. 最后启动连接器,连接器会启动它子组件,比如 Endpoint
synchronized (connectorsLock) {
for (Connector connector: connectors) {
if (connector.getState() != LifecycleState.FAILED) {
connector.start();
}
}
}
}
Service 先启动了 Engine 组件,再启动 Mapper 监听器,最后才是启动连接器。这很好理解,因为内层组件启动好了才能对外提供服务,才能启动外层的连接器组件。而 Mapper 也依赖容器组件,容器组件启动好了才能监听它们的变化,因此 Mapper 和 MapperListener 在容器组件之后启动。组件停止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。
Engine
作为 Container 的顶层组件,所以 Engine 本质就是一个容器,继承了 ContainerBase ,看到抽象类再次运用了模板方法设计模式。ContainerBase 使用一个 HashMap<String, Container> children = new HashMap<>(); 成员变量保存每个组件的子容器。同时使用 protected final Pipeline pipeline = new StandardPipeline(this); Pipeline 组成一个管道用于处理连接器传过来的请求,责任链模式构建管道。
public class StandardEngine extends ContainerBase implements Engine {
}
Engine 的子容器是 Host,所以 children 保存的就是 Host。
我们来看看 ContainerBase 做了什么...
initInternal 定义了容器初始化,同时创建了专门用于启动停止容器的线程池。
startInternal:容器启动默认实现,通过组合模式构建容器父子关系,首先获取自己的子容器,使用 startStopExecutor 启动子容器。
public abstract class ContainerBase extends LifecycleMBeanBase
implements Container {
// 提供了默认初始化逻辑
@Override
protected void initInternal() throws LifecycleException {
BlockingQueue<Runnable> startStopQueue = new LinkedBlockingQueue<>();
// 创建线程池用于启动或者停止容器
startStopExecutor = new ThreadPoolExecutor(
getStartStopThreadsInternal(),
getStartStopThreadsInternal(), 10, TimeUnit.SECONDS,
startStopQueue,
new StartStopThreadFactory(getName() + "-startStop-"));
startStopExecutor.allowCoreThreadTimeOut(true);
super.initInternal();
}
// 容器启动
@Override
protected synchronized void startInternal() throws LifecycleException {
// 获取子容器并提交到线程池启动
Container children[] = findChildren();
List<Future<Void>> results = new ArrayList<>();
for (Container child : children) {
results.add(startStopExecutor.submit(new StartChild(child)));
}
MultiThrowable multiThrowable = null;
// 获取启动结果
for (Future<Void> result : results) {
try {
result.get();
} catch (Throwable e) {
log.error(sm.getString("containerBase.threadedStartFailed"), e);
if (multiThrowable == null) {
multiThrowable = new MultiThrowable();
}
multiThrowable.add(e);
}
}
......
// 启动 pipeline 管道,用于处理连接器传递过来的请求
if (pipeline instanceof Lifecycle) {
((Lifecycle) pipeline).start();
}
// 发布启动事件
setState(LifecycleState.STARTING);
// Start our thread
threadStart();
}
}
继承了 LifecycleMBeanBase 也就是还实现了生命周期的管理,提供了子容器默认的启动方式,同时提供了对子容器的 CRUD 功能。
Engine 在启动 Host 容器就是 使用了 ContainerBase 的 startInternal 方法。Engine 自己还做了什么呢?
我们看下 构造方法,pipeline 设置了 setBasic,创建了 StandardEngineValve。
/**
* Create a new StandardEngine component with the default basic Valve.
*/
public StandardEngine() {
super();
pipeline.setBasic(new StandardEngineValve());
.....
}
容器主要的功能就是处理请求,把请求转发给某一个 Host 子容器来处理,具体是通过 Valve 来实现的。每个容器组件都有一个 Pipeline 用于组成一个责任链传递请求。而 Pipeline 中有一个基础阀(Basic Valve),而 Engine 容器的基础阀定义如下:
final class StandardEngineValve extends ValveBase {
@Override
public final void invoke(Request request, Response response)
throws IOException, ServletException {
// 选择一个合适的 Host 处理请求,通过 Mapper 组件获取到合适的 Host
Host host = request.getHost();
if (host == null) {
response.sendError
(HttpServletResponse.SC_BAD_REQUEST,
sm.getString("standardEngine.noHost",
request.getServerName()));
return;
}
if (request.isAsyncSupported()) {
request.setAsyncSupported(host.getPipeline().isAsyncSupported());
}
// 获取 Host 容器的 Pipeline first Valve ,将请求转发到 Host
host.getPipeline().getFirst().invoke(request, response);
}
这个基础阀实现非常简单,就是把请求转发到 Host 容器。处理请求的 Host 容器对象是从请求中拿到的,请求对象中怎么会有 Host 容器呢?这是因为请求到达 Engine 容器中之前,Mapper 组件已经对请求进行了路由处理,Mapper 组件通过请求的 URL 定位了相应的容器,并且把容器对象保存到了请求对象中。
组件设计总结
大家有没有发现,Tomcat 的设计几乎都是面向接口设计,也就是通过接口隔离功能设计其实就是单一职责的体现,每个接口抽象对象不同的组件,通过抽象类定义组件的共同执行流程。单一职责四个字的含义其实就是在这里体现出来了。在分析过程中,我们看到了观察者模式、模板方法模式、组合模式、责任链模式以及如何抽象组件面向接口设计的设计哲学。
连接器之 I/O 模型与线程池设计
连接器主要功能就是接受 TCP/IP 连接,限制连接数然后读取数据,最后将请求转发到 Container 容器。所以这里必然涉及到 I/O 编程,今天带大家一起分析 Tomcat 如何运用 I/O 模型实现高并发的,一起进入 I/O 的世界。
I/O 模型主要有 5 种:同步阻塞、同步非阻塞、I/O 多路复用、信号驱动、异步 I/O。是不是很熟悉但是又傻傻分不清他们有何区别?
所谓的I/O 就是计算机内存与外部设备之间拷贝数据的过程。
CPU 是先把外部设备的数据读到内存里,然后再进行处理。请考虑一下这个场景,当程序通过 CPU 向外部设备发出一个读指令时,数据从外部设备拷贝到内存往往需要一段时间,这个时候 CPU 没事干了,程序是主动把 CPU 让给别人?还是让 CPU 不停地查:数据到了吗,数据到了吗……
这就是 I/O 模型要解决的问题。今天我会先说说各种 I/O 模型的区别,然后重点分析 Tomcat 的 NioEndpoint 组件是如何实现非阻塞 I/O 模型的。
I/O 模型
网络读取主要有两个步骤:
用户线程等待内核将数据从网卡复制到内核空间。
内核将数据从内核空间复制到用户空间。
同理,将数据发送到网络也是一样的流程,将数据从用户线程复制到内核空间,内核空间将数据复制到网卡发送。
不同 I/O 模型的区别:实现这两个步骤的方式不一样。
对于同步,则指的应用程序调用一个方法是否立马返回,而不需要等待。
对于阻塞与非阻塞:主要就是数据从内核复制到用户空间的读写操作是否是阻塞等待的。
同步阻塞 I/O
用户线程发起read调用的时候,线程就阻塞了,只能让出 CPU,而内核则等待网卡数据到来,并把数据从网卡拷贝到内核空间,当内核把数据拷贝到用户空间,再把刚刚阻塞的读取用户线程唤醒,两个步骤的线程都是阻塞的。
同步非阻塞
用户线程一直不停的调用read方法,如果数据还没有复制到内核空间则返回失败,直到数据到达内核空间。用户线程在等待数据从内核空间复制到用户空间的时间里一直是阻塞的,等数据到达用户空间才被唤醒。循环调用read方法的时候不阻塞。
I/O 多路复用
用户线程的读取操作被划分为两步:
用户线程先发起 select 调用,主要就是询问内核数据准备好了没?当内核把数据准备好了就执行第二步。
用户线程再发起 read 调用,在等待内核把数据从内核空间复制到用户空间的时间里,发起 read 线程是阻塞的。
为何叫 I/O 多路复用,核心主要就是:一次 select 调用可以向内核查询多个**数据通道(Channel)**的状态,因此叫多路复用。
异步 I/O
用户线程执行 read 调用的时候会注册一个回调函数, read 调用立即返回,不会阻塞线程,在等待内核将数据准备好以后,再调用刚刚注册的回调函数处理数据,在整个过程中用户线程一直没有阻塞。
Tomcat NioEndpoint
Tomcat 的 NioEndpoit 组件实际上就是实现了 I/O 多路复用模型,正是因为这个并发能力才足够优秀。让我们一起窥探下 Tomcat NioEndpoint 的设计原理。
对于 Java 的多路复用器的使用,无非是两步:
-
创建一个 Seletor,在它身上注册各种感兴趣的事件,然后调用 select 方法,等待感兴趣的事情发生。 -
感兴趣的事情发生了,比如可以读了,这时便创建一个新的线程从 Channel 中读数据。
自定义线程池模型
Java 线程池
ThreadPoolExecutor
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
......
}
-
corePoolSize:保留在池中的线程数,即使它们空闲,除非设置了 allowCoreThreadTimeOut,不然不会关闭。 -
maximumPoolSize:队列满后池中允许的最大线程数。 -
keepAliveTime、TimeUnit:如果线程数大于核心数,多余的空闲线程的保持的最长时间会被销毁。unit 是 keepAliveTime 参数的时间单位。当设置 allowCoreThreadTimeOut(true) 时,线程池中 corePoolSize 范围内的线程空闲时间达到 keepAliveTime 也将回收。 -
workQueue:当线程数达到 corePoolSize 后,新增的任务就放到工作队列 workQueue 里,而线程池中的线程则努力地从 workQueue 里拉活来干,也就是调用 poll 方法来获取任务。 -
ThreadFactory:创建线程的工厂,比如设置是否是后台线程、线程名等。 -
RejectedExecutionHandler:拒绝策略,处理程序因为达到了线程界限和队列容量执行拒绝策略。也可以自定义拒绝策略,只要实现 RejectedExecutionHandler 即可。默认的拒绝策略:AbortPolicy 拒绝任务并抛出 RejectedExecutionException 异常;CallerRunsPolicy 提交该任务的线程执行;``
Tomcat 线程池
-
线程个数。 -
队列长度。
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
prestartAllCoreThreads();
}
@Override
protected 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);
}
-
Tomcat 有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。 -
Tomcat 对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。
-
前 corePoolSize 个任务时,来一个任务就创建一个新线程。 -
还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。 -
线程总线数达到 maximumPoolSize ,直接执行拒绝策略。
-
前 corePoolSize 个任务时,来一个任务就创建一个新线程。 -
还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。 -
线程总线数达到 maximumPoolSize ,继续尝试把任务放到队列中。如果队列也满了,插入任务失败,才执行拒绝策略。
public void execute(Runnable command, long timeout, TimeUnit unit) {
// 记录提交任务数 +1
submittedCount.incrementAndGet();
try {
// 调用 java 原生线程池来执行任务,当原生抛出拒绝策略
super.execute(command);
} catch (RejectedExecutionException rx) {
//总线程数达到 maximumPoolSize,Java 原生会执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 尝试把任务放入队列中
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 队列还是满的,插入失败则执行拒绝策略
throw new RejectedExecutionException("Queue capacity is full.");
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} else {
// 提交任务书 -1
submittedCount.decrementAndGet();
throw rx;
}
}
}
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
...
@Override
// 线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {
// 如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o);
// 执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
// 表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()<=(parent.getPoolSize()))
return super.offer(o);
//2. 如果已提交的任务数大于当前线程数,线程不够用了,返回 false 去创建新线程
if (parent.getPoolSize()<parent.getMaximumPoolSize())
return false;
// 默认情况下总是把任务添加到任务队列
return super.offer(o);
}
}
性能优化
线程池调优
Tomcat 内存溢出的原因分析及调优
java.lang.OutOfMemoryError: Java heap space
-
内存泄漏:本该回收的对象呗程序一直持有引用导致对象无法被回收,比如在线程池中使用 ThreadLocal、对象池、内存池。为了找到内存泄漏点,我们通过 jmap 工具生成 Heap Dump,再利用 MAT 分析找到内存泄漏点。jmap -dump:live,format=b,file=filename.bin pid -
内存不足:我们设置的堆大小对于应用程序来说不够,修改 JVM 参数调整堆大小,比如 -Xms256m -Xmx2048m。 -
finalize 方法的过度使用。如果我们想在 Java 类实例被 GC 之前执行一些逻辑,比如清理对象持有的资源,可以在 Java 类中定义 finalize 方法,这样 JVM GC 不会立即回收这些对象实例,而是将对象实例添加到一个叫“java.lang.ref.Finalizer.ReferenceQueue”的队列中,执行对象的 finalize 方法,之后才会回收这些对象。Finalizer 线程会和主线程竞争 CPU 资源,但由于优先级低,所以处理速度跟不上主线程创建对象的速度,因此 ReferenceQueue 队列中的对象就越来越多,最终会抛出 OutOfMemoryError。解决办法是尽量不要给 Java 类定义 finalize 方法。
java.lang.OutOfMemoryError: GC overhead limit exceeded
-verbose:gc //在控制台输出GC情况
-XX:+PrintGCDetails //在控制台输出详细的GC情况
-Xloggc: filepath //将GC日志输出到指定文件中
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
java.lang.OutOfMemoryError: MetaSpace
java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space
java.lang.OutOfMemoryError: Unable to create native threads
-
Java 程序向 JVM 请求创建一个新的 Java 线程。 -
JVM 本地代码(Native Code)代理该请求,通过调用操作系统 API 去创建一个操作系统级别的线程 Native Thread。 -
操作系统尝试创建一个新的 Native Thread,需要同时分配一些内存给该线程,每一个 Native Thread 都有一个线程栈,线程栈的大小由 JVM 参数-Xss决定。 -
由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。 -
JVM 抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错误。
更多精彩推荐
点分享 点点赞 点在看