jit deoptimization的日志也有这个
1 | <uncommon_trap thread='6154' reason='range_check' action='make_not_entrant' debug_id='0' compile_id='67267' compiler='c2' level='4' stamp='390.396'> |
线上火焰图,parseHost有较高的占比——1.29(572 samples)
压测时没有发现类似的问题
使用arthas查看线上host的值,发现主要有两种
第一种是域名形式的,由nginx调用过来。第二种是ip+port的形式,主要是健康检查保活。
第一种形式,在tomcat8.x版本下,会抛出ArrayIndexOutOfBoundException,异常的初始化比较耗费cpu。
第二种形式,则会正常解析结束。
出问题的代码如下,就是一个静态的数组,范围是0 ~ 127,存储其是否是字母、数字。
异常的case,是c超过了下标的范围,从而导致 ArrayIndexOutOfBoundsException 异常。
1 | public static boolean isAlpha(int c) { |
异常路径,有异常栈的填充,理论上应该比较慢。可以用monitor看到avg耗时,然后用watch找出耗时长的请求:
1 | [arthas@28]$ watch org.apache.tomcat.util.http.parser.HttpParser isAlpha 'params[0]' '#cost>0.05' -n 5 |
可以看到,有异常的值-1,-1会导致ArrayIndexOutOfBoundsException 异常。
接着跟进下-1的来源:
1 | [arthas@28]$ stack org.apache.tomcat.util.http.parser.HttpParser isNumeric 'params[0] < 0' -n 5 |
出问题的地方是从readHostDomainName过来的,看下对应实现:
readHostDomainName返回值是ip:port的分隔符,”:”的index位置。
解析逻辑,就是从host对应的reader中,不断地读取字符,传递给状态机。然后状态机根据传入的字符,进行不同状态的流转。
如果传入的是域名,则会一直读取到流结束,流结束之后,read返回的是-1,从而走到异常的逻辑。
如果传入的是ip:port,则会在流结束之前,正常的走到解析流程,不会走到异常的逻辑。
1 | static int readHostDomainName(Reader reader) throws IOException { |
看下状态机的状态:
1 | // 状态机实现 |
tomcat已经在8.5.41版本修复,对应的release note:
https://tomcat.apache.org/tomcat-8.5-doc/changelog.html
对应的代码diff,可以看到优先处理流结束的情况,就会避免isAlpha抛异常:
http/1.1之后,要求header中必须存在Host字段。
nginx在转发时,会将Host字段设置为对应的域名。同时探活时是单节点探活,对应的Host是ip:port
低版本的tomcat(< 8.5.41),在解析域名这种host的时候,存在bug。bug会导致isAlpha和isNumeric方法内部抛出ArrayIndexOutofRange异常。异常的影响主要有两点:
性能损失跟客户端的请求量相关,请求量越大,越明显
从火焰图中,可以清晰地看到,spring应用的启动是在HostConfig#deployDirectory中进行的。那么这个HostConfig到底是何方神圣,启动过程中,怎么没有见到他的身影呢?
1 | public class HostConfig implements LifecycleListener { |
HostConfig是LifecycleListener的实现,通过前面的分析,我们知道所有的Listener都在LifecyBase中注册。开启debug模式,在addListener的时候,添加断点,就不难找到调用链路了。
Digester解析StandardHost过程中创建的HostConfig,默认的我们的server.xml中是没有声明HostConfig的,顺藤摸瓜,可以在代码中找到调用点:
1 | // org.apache.catalina.startup.Catalina#createStartDigester |
先看start方法:
1 | // org.apache.catalina.startup.HostConfig#start |
再看看看他在监听的方法里做了什么:
1 | // org.apache.catalina.startup.HostConfig#lifecycleEvent |
LifeCycleEvent中,除了PERIODIC_EVENT不是状态转移触发的,其他的基本都是状态转移触发的,可以查看前面的相关文章。
首先看Lifecycle.PERIODIC_EVENT
,这个事件是ContainerBase中发出的, 是在单独的线程中处理的。
1 | // org.apache.catalina.core.ContainerBase#backgroundProcess |
ContainerBase在startInternal的最后,如果backgroundProcessorDelay > 0(默认值-1),则会启动一个线程来周期性地调用自身和child容器的backgroundProcess。只有StandardEngine修改了默认值,改为了10,所以会持有这个backgroundProcessor:
1 | // org.apache.catalina.core.StandardEngine#StandardEngine |
用jstack可以验证下,发现只有一条这个线程:
1 | "ContainerBackgroundProcessor[StandardEngine[Catalina]]" #57 daemon prio=5 os_prio=31 tid=0x0000000118f72000 nid=0x7203 waiting on condition [0x000000017a0ba000] |
线程启动的代码位置:
1 | // org.apache.catalina.core.ContainerBase#startInternal |
StandardEngine会递归的调用子容器的backgroundProcess方法,该方法中会发出PERIODIC_EVENT。
StandardHost发出PERIODIC_EVENT,HostConfig作为其listener接收到PERIODIC_EVENT,会执行check的逻辑,
1 | // org.apache.catalina.startup.HostConfig#check() |
这三种形式的deploy最终都会以任务的形式提交到host的startStopExecutor中(不阻塞其他的Listener),
最终也会调用HostConfig的方法进行部署,以DeployDirectory为例,最终调用org.apache.catalina.startup.HostConfig#deployDirectory。
这个过程跟火焰图中的调用栈就对得上了。
1 | // org.apache.catalina.startup.HostConfig#deployDirectory |
核心的代码就是创建Contex,添加为host的子容器。Context可以通过META-INF/context.xml里定制,如果没有的话,会走默认的。这样应用就添加到了tomcat里。子容器在添加之后,host会调用其start方法,触发它的初始化流程。
创建server.xml中声明的appBase和configBase目录:
1 | // org.apache.catalina.startup.HostConfig#beforeStart |
1 | // org.apache.catalina.startup.HostConfig#start |
这里只是注册HostConfig到Mbean的Registry中,如果开启了deployOnStartup,这里也会尝试部署一次应用。
1 | // org.apache.catalina.startup.HostConfig#stop |
同理,stop中,只是将自身从Registry中移除。
和HostConfig类似,Context会有一个对应的LifecycleListener,叫做ContextConfig。他也是在创建的时候默认指定的:
1 | // org.apache.catalina.startup.ContextRuleSet |
看下他在监听部分做了什么:
1 | //org.apache.catalina.startup.ContextConfig#lifecycleEvent |
StandardContext在启动的时候会发出这个事件,Listener在收到这个event之后,会做一些初始化的准备工作。listener逻辑执行完成之后,会继续执行Context启动的后续逻辑
1 | // org.apache.catalina.core.StandardContext#startInternal |
loadOnStartup如果是true,则启动的时候就拉起Servlet,否则的话是第一个请求过来时触发加载,lazy式的:
1 | // org.apache.catalina.core.StandardContext#loadOnStartup |
在这个事件的处理函数configureStart中,会扫描web.xml以及相关的文件,配置context。最主要的方法是webConfig()。
Scan the web.xml files that apply to the web application and merge them
using the rules defined in the spec. For the global web.xml files,
where there is duplicate configuration, the most specific level wins. ie
an application’s web.xml takes precedence over the host level or global
web.xml file.
值得一提的是,这里的listener处理是同步的,处理完才会返回到主流程中。webConfig中包含了Servlet注解、filter等的扫描,也包含了SCI的处理。
1 | // org.apache.catalina.startup.ContextConfig#configureStart |
logEffectiveWebXml
Set to true
if you want the effective web.xml used for a web application to be logged (at INFO level) when the application starts. The effective web.xml is the result of combining the application’s web.xml with any defaults configured by Tomcat and any web-fragment.xml files and annotations discovered. If not specified, the default value offalse
is used.
调用start之前的钩子,主要是计算docBase
1 | // org.apache.catalina.startup.ContextConfig#beforeStart |
Restore docBase for management tools
1 | // Restore docBase for management tools |
和configure start event对应,容器销毁时执行:
如果存在conf/context.xml
,则处理下
1 | // org.apache.catalina.startup.ContextConfig#init |
删除对应的work dir
1 | // org.apache.catalina.startup.ContextConfig#destroy |
backgroundProcess
方法,Host容器会发出PERIODIC_EVENT经典的网络server,一般有如下的流程:
今天来看下tomcat对应的步骤是如何实现的。
几个关键日志:
1 | 19-Oct-2022 11:32:58.320 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"] |
对应关系:
tomcat的组件都实现了LifeCycle接口,都会有init
和start
方法。bind
和listen
默认就是在init
方法中初始化的;accept
是Acceptor初始化之后开始的,是在start方法中进行的。
org.apache.catalina.startup.Catalina.load Initialization processed in 9593 ms
1 | // org.apache.catalina.startup.Catalina#load() |
StandardServer的init会触发子组件的init,直到AbstractProtocol的init:
1 | "main@1" prio=5 tid=0x1 nid=NA runnable |
Initializing ProtocolHandler [“http-nio-8080”]
1 | // org.apache.coyote.AbstractProtocol#init |
endpoint实际进行了bind和listen
1 | // org.apache.tomcat.util.net.AbstractEndpoint#init |
至此已经bind
和listen
,但是应用层还没有accept
连接,如果此时有请求过来,都是待在SYN Queue
和Accept Queue
中。
bindOnInit配置:
Controls when the socket used by the connector is bound.
By default it is bound when the connector is initiated and unbound when the connector is destroyed.
If set to false, the socket will be bound when the connector is started and unbound when it is stopped.
org.apache.catalina.startup.Catalina.start Server startup in 43847 ms
对应代码:
1 | // org.apache.catalina.startup.Catalina#start |
同样的StandardServer的start也会触发子组件的start
1 | "main@1" prio=5 tid=0x1 nid=NA runnable |
Starting ProtocolHandler [“http-nio-8080”]
1 | // org.apache.coyote.AbstractProtocol#start |
AbstractProtocol最终调用endpoint的start方法:
1 | // org.apache.tomcat.util.net.AbstractEndpoint#start |
至此acceptor线程启动,tomcat具备了accept的能力。看下Acceptor线程是干啥的:
1 | // org.apache.tomcat.util.net.NioEndpoint.Acceptor |
其他组件初始化是在bind/accept之间,还是之后?
1 | 19-Oct-2022 14:51:28.147 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"] |
Initializing ProtocolHandler [“http-nio-8080”] -> Starting ProtocolHandler [“http-nio-8080”] -> contextInitialized… begin sleep (Context Listener) -> initing Filter… (Filter)
-> init TestServlet2 (@WebServlet) -> init TestServlet3 (web.xml配置的servlet)
1 | 19-Oct-2022 14:29:36.778 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-21002"] |
Initializing ProtocolHandler [“http-nio-21002”] -> spring初始化 -> Starting ProtocolHandler [“http-nio-21002”]
从上面的日志可以看到,bind&listen和accept的初始化都是在main线程中,其他操作是在localhost-startStop-1线程中(RMI TCP这个估计跟idea有关系,暂且搁置)。
main thread就是tomcat的主线程,tomcat在启动Context/Engine/Host/Wrapper等组件时,会丢到startStopExectutor中进行,最终阻塞等待所有结果返回,如下代码所示:
1 | // org.apache.catalina.core.ContainerBase#initInternal |
正常启动时,spring就是由startStopExecutor的线程拉起的,梳理tomcat组件之间的启动顺序可以发现是这样的:
service有多个组件,包含engine和connector。start时,engine的调用顺序在connector前面。
engine后续会负责servlet容器的初始化,从而触发spring的初始化。虽然是在线程池中异步初始化的,但是会一直等待子组件初始化完成,再返回。
connector会触发endpoint的初始化,最终触发Acceptor的初始化。
所以默认的servlet初始化应该是在accept之前,从本地的测试日志也可以看出来:
19-Oct-2022 14:51:38.296 INFO [localhost-startStop-1] org.apache.catalina.startup.HostConfig.deployDirectory Deployment of web application directory [/Users/qishengli/Downloads/apache-tomcat-8.5.66/webapps/manager] has finished in [42] ms
本地测试的结果不对,应该是idea使用了RMI调用,在启动结束之后,添加了Context。
The number of milliseconds this Connector will wait, after accepting a connection, for the request URI line to be presented. Use a value of -1 to indicate no (i.e. infinite) timeout. The default value is 60000 (i.e. 60 seconds) but note that the standard server.xml that ships with Tomcat sets this to 20000 (i.e. 20 seconds). Unless disableUploadTimeout is set to false
, this timeout will also be used when reading the request body (if any).
从连接被accept之后,到request line出现的超时时间,单位是毫秒。
使用telnet连接上tomcat的端口,然后不发送请求,等待超时。得到如下结果,耗时大概20s左右。
1 | ➜ qsli.github.com (hexo|✚23…) time telnet localhost 8087 |
使用arthas查看mbean,找到超时的配置:
1 | [arthas@77045]$ mbean Catalina:type=Connector,port=8087 |
connectionTimeout 20000
配置的是20s
实际是socket timeout
1 | // org.apache.coyote.AbstractProtocol#getConnectionTimeout |
使用的地方,设置在了NioSocketWrapper的ReadTimeout和WriteTimeout上:
1 | //org.apache.tomcat.util.net.NioEndpoint.Poller#register |
Poller线程中会check这个key是否过期,并不是每次都check,而是有一定的策略:
However, do process timeouts if any of the following are true:
- the selector simply timed out (suggests there isn’t much load)
- the nextExpiration time has passed
- the server socket is being closed
1 | // org.apache.tomcat.util.net.NioEndpoint.Poller#timeout |
使用arthas观察下超时时服务端的处理:
1 | `---ts=2022-10-18 23:38:39;thread_name=http-nio-8087-ClientPoller-0;id=1c;is_daemon=true;priority=5;TCCL=java.net.URLClassLoader@470e2030 |
超时之后,new了一个SocketTimeoutException,交给processSocket进行处理:
1 | [arthas@22174]$ trace org.apache.tomcat.util.net.AbstractEndpoint processSocket -v -n 5 --skipJDKMethod false '1==1' |
接着跟进,看看是哪里退出的:
1 | [arthas@22174]$ trace org.apache.tomcat.util.net.NioEndpoint$SocketProcessor doRun -v -n 5 --skipJDKMethod false '1==1' |
由于传入的是SocketEvent.ERROR,在ConnectionHandler中就直接返回了:
1 | // org.apache.coyote.AbstractProtocol.ConnectionHandler#process |
从监控上看,tomcat的线程busy的非常少,线程池使用率很低,但是线程池里的线程的个数却很多。
难道tomcat的线程池没有回落机制吗?
1 | [arthas@22]$ mbean | grep -i thread |
几个关键点:
干活的线程只有2个,但是线程池里有916个线程?why?
多次观察,仍然是这个情况。
先搞清楚mbean的数据来源。
1 | // org.apache.tomcat.util.net.AbstractEndpoint#init |
currentThreadBusy——当前有任务的线程个数
1 | // org.apache.tomcat.util.net.AbstractEndpoint#getCurrentThreadsBusy |
currentThreadCount——线程池中,当前线程个数
1 | // org.apache.tomcat.util.net.AbstractEndpoint#getCurrentThreadCount |
maxThreads——最大线程数
1 | // org.apache.tomcat.util.net.AbstractEndpoint#getMaxThreads |
minSpareThreads——核心线程数
1 | // org.apache.tomcat.util.net.AbstractEndpoint#getMinSpareThreads |
默认线程池初始化逻辑:
1 | // org.apache.tomcat.util.net.AbstractEndpoint#createExecutor |
看到线程池的初始化,就会发现miniSpareThreads其实就是corePoolSize! 而且有一个写死的keepAliveTime 60s。而且任务队列是个无界的队列。
先看JDK中的注释:
@param keepAliveTime when the number of threads is greater than
the core, this is the maximum time that excess idle threads
will wait for new tasks before terminating.
简单来说,就是超过核心数的线程,如果等待keepAliveTime,还没有接到任务,就会被终止掉。
看一眼实现:
1 | // java.util.concurrent.ThreadPoolExecutor#runWorker |
从源码上看,这个keepAliveTime并没有什么问题。
有没有一种可能,task queue的poll是雨露均撒的?
When you have eliminated the impossible, whatever remains, however improbable, must be the truth.
tomcat使用的TaskQueue作为队列,继承自LinkedBlockingQueue。但是核心的poll逻辑,还是用的LinkedBlockingQueue:
1 | // org.apache.tomcat.util.threads.TaskQueue#poll |
核心就在takeLock和notEmpty上,takeLock是ReentrantLock默认非公平,notEmpty是takeLock的条件队列。
1 | // java.util.concurrent.LinkedBlockingQueue |
ReentrantLock默认非公平的,底层基于AQS实现。公平和非公平的区别只是在首次抢锁的行为上,首次如果没有抢到,都是排队,然后按顺序解锁。
1 | // java.util.concurrent.locks.ReentrantLock.Sync#nonfairTryAcquire |
qps比较低的场景下,锁的竞争并不激烈,大部分线程即使抢到了锁,也拿不到任务,只能在条件队列中。
1 | // java.util.concurrent.locks.AbstractQueuedLongSynchronizer.ConditionObject#signal |
条件队列里是按排队的顺序(longest-waiting thread)去通知的,将条件队列里的wait node转移到锁的等待队列中,重新竞争锁。
此时竞争的对象很少,基本就是busy的线程+被notify唤醒的线程,因此大概率还是能抢到任务的。
问题的根源在于如果task很少,大家会在notEmpty的Condition队列中排队;task来的时候,又是按顺序解锁,如果qps和keepAliveTime合适,在keepAliveTime时间内,每个worker线程都能有机会至少活得一个task,从而不会被回收掉。
maxThreads设置为10,打印每次处理的线程的名称,测试代码:
1 |
|
串行curl 7次:
1 | for i in `seq 1 10`; do curl "http://localhost:8087/web_war_exploded/hello" && echo -e '\n'; done; |
输出:
1 | ➜ conf for i in `seq 1 10`; do curl "http://localhost:8087/web_war_exploded/hello" && echo -e '\n'; done; |
确实是类似round robin的形式来的
tomcat默认的线程池,keepAliveTime是60s,修改maxThreads为10,minSpareThreads为3。
启动之后,mbean输出:
1 | [arthas@98537]$ mbean Catalina:type=ThreadPool,name=* |
跟设置一致,先来波高峰请求,创建出来10个worker(maxThreads)
1 | for i in `seq 1 10`; do curl -s "http://localhost:8087/web_war_exploded/hello" & done; |
此时mbean输出:
1 | [arthas@98537]$ mbean Catalina:type=ThreadPool,name=* |
currentThreadCount有10个了,等1min,然后再看:
1 | [arthas@98537]$ mbean Catalina:type=ThreadPool,name=* |
currentThreadCount已经回落到了3个(minSpareThreads)
线程不回落,只用保证每个线程1min内有一个task就行了。maxThreads是10,也就是10 qpm就行了。
先冲高
1 | for i in `seq 1 10`; do curl -s "http://localhost:8087/web_war_exploded/hello" & done; |
再维持10 qpm
1 | for i in `seq 1 100000`; do curl -s "http://localhost:8087/web_war_exploded/hello" && echo "-n" && sleep 5; done; |
代码里sleep了1s,加上curl的sleep 5s,一个请求6s,一分钟10个请求。此时再看mbean输出:
1 | [arthas@98537]$ mbean Catalina:type=ThreadPool,name=* |
一直是10,跟线上的现象一样,复现了线程不回落的情形。
修改sleep的时间,降低qpm,看看是否有部分回落:
1 | for i in `seq 1 100000`; do curl -s "http://localhost:8087/web_war_exploded/hello" && echo "-n" && sleep 7; done; |
逐渐回落至8个线程:
1 | [arthas@98537]$ mbean Catalina:type=ThreadPool,name=* | grep -i currentThreadCount |
QPS的临界值是maxThreads / keepAliveTime,考虑上请求的处理时间,实际值可能稍微大一点。大于临界值则不会发生线程的回落,小于临界值会逐渐回落。
Tomcat使用默认的线程池,keepAliveTime是无法调整的,但是可以使用自定义的线程池,可以设置maxIdleTime(即keepAliveTime)。
1 | <!--The connectors can use a shared executor, you can define one or more named thread pools--> |
调整为10s之后,维持10qpm,很快就回落了:
1 | [arthas@54257]$ mbean Catalina:type=ThreadPool,name=* | grep -i currentThreadCount |
首先需要看下队列长度,使用tomcat默认的线程池,采用的是无界队列:
1 | // org.apache.tomcat.util.net.AbstractEndpoint#createExecutor |
好在可以自定义线程池:
1 | <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" |
此处可以设置maxQueueSize,这里设置为3
启动之后,使用arthas查看mbean:
1 | [arthas@84145]$ mbean Catalina:type=Executor,name=tomcatThreadPool |
maxQueueSize确实是3,maxThreads是7
servlet代码,代码里直接sleep,占住tomcat的线程:
1 |
|
客户端直接curl,20个并发请求 > maxThreads + maxQueueSize = 7 + 3 = 10
1 | for i in `seq 1 20`; do curl -v http://localhost:8080/web_war_exploded/hello &; done |
看一眼tomcat的状态:
1 | [arthas@84145]$ mbean Catalina:type=Executor,name=tomcatThreadPool |
queueSize 3已经达到了maxQueueSize。
此时我们再次curl,tomcat应该就会抛出队列满的异常:
1 | ➜ ~ curl http://localhost:8080/web_war_exploded/hello --trace-ascii - |
curl的连接直接别reset了,再看tomcat的日志:
1 | java.util.concurrent.RejectedExecutionException: The executor's work queue is full |
提交任务到线程池失败之后,tomcat会cancel掉这个key:
1 | [arthas@84145]$ stack org.apache.tomcat.util.net.NioEndpoint$Poller cancelledKey -n 5 |
tomcat,线程池满了之后,观察到的现象:
代码位置:
Executes the given command at some time in the future. The command may execute in a new thread, in a pooled thread, or in the calling thread, at the discretion of the
Executor
implementation.
If no threads are available, it will be added to the work queue.
If the work queue is full, the system will wait for the specified time and it throw a RejectedExecutionException if the queue is still full after that.
1 | // org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit) |
用arthas验证下,是否走到force:
1 | [arthas@84145]$ stack org.apache.tomcat.util.threads.TaskQueue force -n 5 |
确实走到了force的逻辑,但是默认的timeout是0,0代表不等待。只是相当于多了一次尝试。
而且这个超时是无法配置的,对于http请求来说,功能相当于是废掉的。
AbstractEndpoint -> StandardThreadExecutor -> org.apache.tomcat.util.threads.ThreadPoolExecutor
1 | // org.apache.tomcat.util.net.AbstractEndpoint#processSocket |
从提交线程池的地方,逆流而上:
1 | // org.apache.tomcat.util.net.AbstractEndpoint#processSocket |
AbstractEndpoint#processSocket 返回false -> NioEndpoint.Poller#cancelledKey,取消主要包含了4步:
那么为啥是tcp reset呢?
socket接收缓冲区(Recv-Q)中的数据,未完全被应用程序读取时,关闭该socket会产生TCP Reset
Http协议的解析都是在worker线程中进行的,由于提交任务失败,这部分内容是没有读取的。因此在连接关闭时,TCP发现Receive Buffer中还有数据没有读取,因此给对端发送了Rest。
众所周知,http协议是文本协议,因此传输过程中的ByteChunk和CharChunk最终都会转为String。tomcat为了减少内存占用,减少对GC的影响,提出了StringCache的解决方案。
先看下StringCache的实现:
1 | // org.apache.tomcat.util.buf.StringCache |
StringCache包含两类,一类是ByteChunk转过来的,一类是CharChunk转过来的。底层的缓存逻辑是一致的,只是类型不同,我们只需关注一种即可。缓存使用数组实现,以ByteChunk为例,数组的类型是ByteEntry:
1 | //org.apache.tomcat.util.buf.StringCache.ByteEntry |
这个类一目了然,这里不再赘述。当调用StringCache的toString方法时,会优先从cache中取。
1 | // org.apache.tomcat.util.buf.StringCache#toString(org.apache.tomcat.util.buf.ByteChunk) |
cache的查找使用的是二分法:
1 | //org.apache.tomcat.util.buf.StringCache#findClosest(org.apache.tomcat.util.buf.ByteChunk, org.apache.tomcat.util.buf.StringCache.ByteEntry[], int) |
缓存的核心是缓存的维护。StringCache更像一个半成品,采用固定长度的缓存。
在启动初期,有一个训练的阈值,调用次数没有达到阈值之前,只会做stat;超过阈值之后,才会根据前面统计到的stat来构建cache。
1 | // org.apache.tomcat.util.buf.StringCache#toString(org.apache.tomcat.util.buf.ByteChunk) |
tomcat.util.buf.StringCache.cacheSize
tomcat.util.buf.StringCache.byte.enabled
tomcat.util.buf.StringCache.char.enabled
tomcat.util.buf.StringCache.trainThreshold
tomcat.util.buf.StringCache.maxStringSize
从源码角度看,这个缓存的开销主要有两部分:
tomcat默认的缓存大小是200,但是这个ByteChunk非常底层,uri中的参数、postbody中的内容、header中的内容等都会使用到,很容易被污染。而且缓存的效果取决于启动初期的流量,如果是预热请求,收集到的采样数据可能不准确。
生产环境,通过观测,有些场景下,cpu开销约为1%,主要花费在二分查找上:
使用arthas查看tomcat暴露出来的mbean信息
1 | [arthas@96]$ mbean | grep -i StringCache |
注意,计数存在溢出的情况。
有些人说反射很慢,但是也没有人真正地测试过。spring的代码里有好多使用反射的地方,所以性能应该也没有那么差。
本文就来挖一挖反射的实现原理以及可能导致的问题。
简单地用反射的方式获取一个field的属性:
1 | .class) (JUnit4ClassRunner |
运行起来(-XX:+TraceClassLoading ),输出如下:
1 | [Loaded sun.reflect.NativeMethodAccessorImpl from /Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home/jre/lib/rt.jar] |
从调用栈可以看到,Method的invoke的调用路径:
DelegatingMethodAccessorImpl -> NativeMethodAccessorImpl
翻下invoke的实现:
1 | // java.lang.reflect.Method#invoke |
最终调用是委托给了MethodAccessor,这是java中的一个接口:
1 | // sun.reflect.MethodAccessor |
实现类有三个,DelegatingMethodAccessorImpl是代理模式,主要是为了切换底层的实现。因此主要的实现就两种,一个是MethodAccessorImpl,一个是NativeMethodAccessorImpl。
Delegates its invocation to another MethodAccessorImpl and can change its delegate at run time.
1 |
|
1 | 1: cost 19792 |
注意看,第1次调用和第16次调用,时间都比较长。inflation的默认阈值是15,超过15之后就会转为动态字节码生成的方式,中间要生成字节码,所以耗时较高,之后耗时就降下来了。
Before Java 1.4
Method.invoke
worked through a JNI call to VM runtime.Since Java 1.4
Method.invoke
uses dynamic bytecode generation if a method is called more than 15 times (configurable viasun.reflect.inflationThreshold
system property).
Java 1.4之前都是使用Native的方式调用,1.4之后,会根据调用的阈值做优化,超过一定的阈值-Dsun.reflect.inflationThreshold
,会转换成dynamic bytecode generation的方式。dynamic bytecode generation的性能会更好。
1 | // sun.reflect.NativeMethodAccessorImpl#invoke |
去jdk的代码里看看这个nativev方法的实现:
1 | // NativeAccessors.c |
invoke方法就比较复杂了,这里就不跟进了,可以看到native的实现就是使用JNI调用,然后利用jvm内部的数据结构完成方法的调用。
The approach with dynamic bytecode generation is much faster since it
稍微改造下代码:
1 |
|
程序跑起来之后,反复调用了20次,超过了默认的阈值,会自动生成字节码。
第一次输出的调用栈:
1 | java.lang.RuntimeException |
最后一次输出的调用栈:
1 | java.lang.RuntimeException |
调用栈发生了变化,从NativeMethodAccessorImpl变为了GeneratedMethodAccessor1
我们用arthas找下生成的字节码:
1 | [arthas@45767]$ sc -d *GeneratedMethodAccessor1 |
反编译下看看生成的类:
1 | [arthas@45767]$ jad sun.reflect.GeneratedMethodAccessor1 |
可以看到,动态生成的字节码,跟直接方法调用差别并不是很大。值得注意的是,这个类的classloader是sun.reflect.DelegatingClassLoader
.
DelegatingClassLoader有何特殊之处?看代码也没有特殊的实现,应该只是为了做classloader隔离。
1 | // sun.reflect.DelegatingClassLoader |
之所以搞一个新的类加载器,是为了性能考虑,在某些情况下可以卸载这些生成的类,因为类的卸载是只有在类加载器可以被回收的情况下才会被回收的,如果用了原来的类加载器,那可能导致这些新创建的类一直无法被卸载,从其设计来看本身就不希望他们一直存在内存里的,在需要的时候有就行了,在内存紧俏的时候可以释放掉内存
——你假笨 假笨说-从一起GC血案谈到反射原理
first, it avoids any possible security risk of having these bytecodes in the same loader.
Second, it allows the generated bytecodes to be unloaded earlier
than would otherwise be possible, decreasing run-time footprint.
1 | // jdk.internal.reflect.ClassDefiner |
前面说到达到阈值,切换为动态字节码生成时没有加锁。而每次生成动态字节码,都会生成自己的类加载器。如果并发很高,会导致classloader和class过多,占用相应的内存。
现代微服务架构正在逐渐普及。面对真正高并发的生产系统,解耦成大量微服务后,以前容易实现的重点任务变得不容易实现了:用户体验优化、后台真实错误原因分析、系统内各组件的调用情况等。分布式跟踪系统(Zipkin、Dapper、HTrace、X-Trace等)可以解决这个问题,但是这些系统使用不兼容的API,难以整合到一起。
OpenTracing提供平台无关、厂商无关的API,让开发人员可以方便地添加、更换追踪系统。
相当于是在做标准化,类似日志中的SLF4j,目前还在发展中。
1、Trace(追踪):
在广义上,一个trace代表了一个事务或者流程在(分布式)系统中的执行过程。在OpenTracing标准中,trace是多个span组成的一个有向无环图(DAG),每一个span代表trace中被命名并计时的连续性的执行片段。
2、Span(跨度):一个span代表系统中具有开始时间和执行时长的逻辑运行单元。span之间通过嵌套或者顺序排列建立逻辑因果关系。
request-id
1 | { |
第三方有问题反馈时,可以拿着这个id作为凭证,就省去了很多沟通的问题
1 | [qisheng.li@YD-app-api-01 logs]$ curl -sI 'http://api2.yaduo.com/atourlife/duomicang/queryDuoMiCangTabOtherData?appVer=3.6.0&channelId=10005&platType=1&token=7254035f0e3e4d05bc7af3afb54f313e&deviceId=73519b32-c539-3c18-af4c-ce4523938bb9&activitySource=ydaandroid&activeId=&inactiveId=' |
elk关联日志
幂等
Brave is a distributed tracing instrumentation library.
Brave’s dependency-free tracer library works against JRE6+.
可以简单理解为标准的实现(类比logback和log4j)
1 | Client Tracer Server Tracer |
http请求
1 | 2021-03-12 01:29:19.624 INFO [order-center,f211feedd7b9904e,9c4b9442005296fb,true] --- [o-9301-exec-131] http.request.response.log : |
header中的x-b3
开头的会自动传递下去
采样:
1 | Server Tracer |
上报
上报方式
1 |
|
- 采样- Reporter- eureka支持
Sleuth configures everything you need to get started. This includes where trace data (spans) are reported to, how many traces to keep (sampling), if remote fields (baggage) are sent, and which libraries are traced.
Spring Cloud Sleuth integrates with the OpenZipkin Brave tracer via the bridge that is available in the
spring-cloud-sleuth-brave
module.
baggage
Request级别的日志debug开关
相关代码位置:
1 | // org.springframework.cloud.sleuth.instrument.web.TraceWebServletAutoConfiguration |
埋点增强
db
线程池
Brave:
1 | // brave.propagation.CurrentTraceContext |
sleuth:
1 | // org.springframework.cloud.sleuth.instrument.async.LazyTraceExecutor |
bolt
feign
rocketmq
Bootstrap
类,发现它只是个传话筒,内部通过发射将调用都转给了Catalina
,用官方的话来说就是roundabout approach
(迂回战术),目的是为了不将tomcat的内部lib暴露给class path。这篇文章,我们就分析下Catalina
以及tomcat内部的关键组件的启动。
先看下tomcat的整体组件,按web.xml中的声明,主要包含Catalina、Server、Service、Connector、Engine、Host、Context、Wrapper等,以及图中没有画到的Valve、Listener等组件。
组件启动顺序:
Startup/Shutdown shell program for Catalina.
Catalina提供了命令行参数的解析,持有Server对象,主要提供的功能:
start
stop
Configtest
从前面的分析我们知道,Bootstrap是通过反射直接调用的Catalina的start方法,start方法的实现如下:
1 | // org.apache.catalina.startup.Catalina#start |
load
方法里就是解析web.xml的具体过程,这里就不赘述了,同时load方法里会调用server的init方法进行初始化,绑定Server所属的Catalina。
初始化之后,就直接调用了Server的start方法,触发其包含的组件的启动。然后这里还注册了Jvm的shutdownHook,关闭的时候也会调用Catalina的stop方法。
最后,调用server的await方法,等待Server的声明周期结束。
Server是tomcat中比较重要的组件,默认实现是StandardServer
。主要提供的功能:
Server实现了Lifecycle接口,我们着重关注下initInternal
方法和startInternal
方法。
1 | // org.apache.catalina.core.StandardServer#initInternal |
在前面的文章中,我们知道Server默认实现了LifecycleMbeanBase
,会自动将自身暴露给Jmx,这里Server手动也额外地注册了个MBean的对象。然后初始化了Naming相关的东西,extension validator。最后也是最关键的,对Server中包含的所有的Service调用其init方法,触发其初始化。
1 | // org.apache.catalina.core.StandardServer#startInternal |
这里除了基类默认触发的时间,这里也有自己定义的CONFIGURE_START_EVENT
事件,然后触发naming相关的启动。最后,调用对应Service的start方法。
Catalina会调用Server的await,来等待Server结束服务。await的实现如下:
1 | // org.apache.catalina.core.StandardServer#await |
配置了shutdown端口,会监听这个端口,如果发送过来的是SHUTDOWN
的命令,就会调用
1 | <Server port="8005" shutdown="SHUTDOWN"> |
测试下:
1 | ➜ bin telnet localhost 8005 |
被shutdown的同时,会在Catalina.out中打印如下的日志:
1 | 27-Nov-2021 21:00:14.933 INFO [main] org.apache.catalina.core.StandardServer.await A valid shutdown command was received via the shutdown port. Stopping the Server instance. |
如果下次,tomcat莫名奇妙shutdown了,可以考虑下是不是被人打接口导致的。
A “Service” is a collection of one or more “Connectors” that share
a single “Container” Note: A “Service” is not itself a “Container”,
so you may not define subcomponents such as “Valves” at this level.
service的作用就是连接多个Connectors
和一个Container
。主要提供的功能:
init操作也是中规中矩,没有特殊操作,挨个调用被管理的Engine/Connector/Executor/MapperListener的init
方法。
同initInternal一样,调用子组件的start方法。
Connector内部是数组存储的,每次修改操作会加锁:
1 | /** |
重要属性变更时,会发出一个PropertyChangeEvent:
1 | /** |
If used, an Engine is always the top level Container in a Catalina hierarchy.
It is useful in the following types of scenarios:
- You wish to use Interceptors that see every single request processed
by the entire engine.- You wish to run Catalina in with a standalone HTTP connector, but still
want support for multiple virtual hosts.
Engine容器的子容器,必须是Host容器,而且他自身必须是top level的容器,也就是不能有parent 容器。Engine下可以配置Valve,可以拦截所有的请求。同时可以配置多个virtual host。
默认的实现是StandardEngine
,StandardEngine
继承了ContainerBase
,ContainerBase
实现了子容器的管理、以及ContainerListener
的管理。
Engine自身没有特殊的实现,逻辑都在ContainerBase中:
1 | // org.apache.catalina.core.ContainerBase#initInternal |
仅仅是初始化了一个startStopExecutor
逻辑也在ContainerBase中:
1 | // org.apache.catalina.core.ContainerBase#startInternal |
Engine的子容器是Host容器,它与url中的host对应,server.xml中的配置如下:
1 | <Host name="localhost" appBase="webapps" |
配置中指定了该host的部署目录,比如webapps,是否自动解压war包,自动部署等属性。默认实现是StandardHost,init和start没有特殊的逻辑,只是设置了error report valve。valve的机制,会在后面请求处理过程中详细解析。
A Context is a Container that represents a servlet context, and therefore an individual web application, in the Catalina servlet engine.
Context代表一个tomcat的应用,也就是appBase下的一个目录。可以包含一个或者多个Servlet。
Standard implementation of the Wrapper interface that represents an individual servlet definition. No child Containers are allowed, and the parent Container must be a Context.
wrapper就是servlet的包装,默认实现是StandardWrapper,init和start没有特殊的逻辑。
Connector组件负责网络连接的处理、协议的解析等。网络协议的处理是tomcat中很重要的一块儿,后面也会单独分析不同协议的实现。
1 | // org.apache.catalina.connector.Connector#initInternal |
主要是protocolHandler的初始化
1 | // org.apache.catalina.connector.Connector#startInternal |
同样的委托给protocolHandler。
Executor也是标准的tomcat组件,它的默认实现类是StandardThreadExecutor
。可以在server.xml的Service节点下配置,默认是没有配置的。tomcat给了一个示例:
1 | 55 <!--The connectors can use a shared executor, you can define one or more named thread pools--> |
如果这里设置了,是可以在Connector中共享的,这一部分是在解析server.xml时实现的:
1 | // org.apache.catalina.startup.ConnectorCreateRule#begin |
executor
A reference to the name in an Executor element. If this attribute is set, and the named executor exists, the connector will use the executor, and all the other thread attributes will be ignored. Note that if a shared executor is not specified for a connector then the connector will use a private, internal executor to provide the thread pool
无特殊逻辑
1 | //org.apache.catalina.core.StandardThreadExecutor#startInternal |
没有特殊的逻辑,只是这个tomcat的自己实现的Executor,和jdk的默认executor在行为上有所差异,后面会专门分析。
MapperListener实现了ContainerListener
接口和LifecycleListener
接口,可以监听容器发出的ContainerEvent
。MapperListener主要是为了Mapper服务的,通过监听到的事件,注册对应的信息到Mapper中。
这个组件没有覆写initInternal,startInternal的时候,将自己注册为Engine以及Engine的各个子容器的listener:
1 | //org.apache.catalina.mapper.MapperListener#addListeners |
同时会将Host组件的相关信息注册至Mapper:
1 | // org.apache.catalina.mapper.MapperListener#registerHost |
以此类推,从 Engine -> Host -> Context -> Wrapper都会将映射信息注册到Mapper中,为后面的查找提供支撑。
除了启动时,自动注册信息到Mapper中,动态添加组件时,MapperListener也能监听到对应的变动:
1 | // org.apache.catalina.mapper.MapperListener#lifecycleEvent |
Mapper, which implements the servlet API mapping rules (which are derived
from the HTTP rules).
Mapper,顾名思义,是专门做映射的。请求进来的时候负责根据请求中的host、uri等参数找到对应的容器。
映射的代码在org.apache.catalina.mapper.Mapper#internalMap
,后续我们会在请求处理篇章中,具体分析映射的过程。
这个类没有实现接口。
本文走马观花似的,过了一遍tomcat启动过程中涉及到的各个基础组件,分析了各个组件的initInternal和startInternal方法,详细地梳理了tomcat初始化的流程详细。
Lifecycle 接口主要定义三个功能:
Lifecycle接口是tomcat中很基础的接口,tomcat的组件都直接或者间接地实现了这个接口,继承这个接口的类如图所示。
从图中可以看出,tomcat的Server接口、Service接口、以及Container接口都继承了Lifecycle。这些常用的组件一般不会直接实现这个接口,一般会通过继承LifeCycleBase
(LifecycleBase —> Lifecycle)或者LifecycleMbeanBase
(LifecycleMbeanBase —> LifecycleBase —> Lifecycle)
Base implementation of the {@link Lifecycle} interface that implements the
state transition rules for {@link Lifecycle#start()} and
{@link Lifecycle#stop()}
这个类实现了接口定义中的LifecycleListener管理、以及组件状态的管理。他的子类无需关心状态转移、以及Listener的通知,只用实现对应的抽象方法:
1 | protected abstract void startInternal() throws LifecycleException; |
以这个接口实现的init为例:
1 | // org.apache.catalina.util.LifecycleBase#init |
代码中已经做了状态转移的判断,只有从NEW状态才能调用init,抽象方法initInternal
,实现了状态从INITIALIZING
到状态INITIALIZED
的转义,发生异常时会自动的将状态转移到FAILED
。
setStateInternal
中也完成了Listener的触发:
1 | // org.apache.catalina.util.LifecycleBase#setStateInternal |
这样状态转移的时候,listener也能感知到了,注意这都是在一个线程中通知的,不要在Listener中做特别重的操作。
状态对应的event:
1 | // org.apache.catalina.LifecycleState |
LifecycleMbeanBase
继承了LifeCycleBase
,同时也实现了JmxEnabled
接口:
1 | public interface JmxEnabled extends MBeanRegistration { |
JmxEnabled
接口继承了javax.management.MBeanRegistration
,用以通过Mbean来暴露对应的组件。可以用arthas 查看tomcat暴露的mbean信息:
1 | [arthas@62513]$ mbean |
可以看到这里暴露了一个Service,正是StandardService
,他继承了LifecycleMbeanBase
,于是自动的暴露出去了。下面来分析下他是如何实现的:
1 |
|
在初始化的时候,如果当前组件没有注册到Registry
,会自动的进行注册。注意,子类在覆盖这个方法的时候,不要忘了调用父类的initInternal
。在组件声明周期结束的时候,也会自动的将其从Registry
移除。
具体的注册逻辑:
1 | /** |
默认注册的名称,格式是domain:组件名称
,这里默认的domain就是Catalina
。组件的名称是通过getObjectNameKeyProperties
,这是个抽象方法,留给子类的钩子。我们看下StandardService
是如何实现的:
1 | // org.apache.catalina.core.StandardService#getObjectNameKeyProperties |
这个跟arthas的输出结果正好印证上了。
tomcat通过Lifecycle接口来管理各个组件,定义了init/start/stop/destroy等方法。同时提供了抽象类的实现,对子类屏蔽了状态转移和Listener机制的实现。也通过LifecycleMbeanBase提供了通一的暴露到jmx的方式。
至于这些组件的init/start/stop/destroy等方法是何时被调用的,我们会在接下来的文章中接着分析启动的过程。
]]>一般是用$CATALINA_HOME/bin/startup.sh
脚本启动:
1 | ➜ bin cat startup.sh |
这个脚本最终调用的是catalina.sh
,传入的参数是start
和我们的命令行参数
这个脚本除了start,还有其他的命令,相当于其他脚本的一个入口:
1 | ➜ bin catalina.sh |
比如version
:
1 | ➜ bin catalina.sh version |
看一下start对应的源码部分:
1 | elif [ "$1" = "start" ] ; then |
基本上就是把之前detect到的各种环境变量当做参数,传递给java命令,这个脚本里默认会执行bin/setenv.sh
,所以一般会在这个文件中设置tomcat的环境变量,比如本机的设置:
1 | ➜ bin cat setenv.sh |
对应的脚本位置:
1 | ➜ bin grep -n setenv catalina.sh |
最终我们能拿到的命令形式:
1 | "/Users/qishengli/software/jdk8/jre/bin/java" "-Djava.util.logging.config.file=/Users/qishengli/software/apache-tomcat-8.5.32/conf/logging.properties" -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Dorg.apache.catalina.security.SecurityListener.UMASK=0027 -agentpath:/Users/qishengli/Downloads/async-profiler-2.5-macos/build/libasyncProfiler.so=start,event=cpu,interval=1ms,file=profile.html -Djava.rmi.server.logCalls=true -Dsun.rmi.server.logLevel=debug -Dignore.endorsed.dirs="" -classpath "/Users/qishengli/software/apache-tomcat-8.5.32/bin/bootstrap.jar:/Users/qishengli/software/apache-tomcat-8.5.32/bin/tomcat-juli.jar" -Dcatalina.base="/Users/qishengli/software/apache-tomcat-8.5.32" -Dcatalina.home="/Users/qishengli/software/apache-tomcat-8.5.32" -Djava.io.tmpdir="/Users/qishengli/software/apache-tomcat-8.5.32/temp" org.apache.catalina.startup.Bootstrap start & |
最后终于到了对应的java代码org.apache.catalina.startup.Bootstrap start
。
1 | /Users/qishengli/software/apache-tomcat-8.5.32/bin/catalina.sh run |
直接调用的catalina.sh的run命令
1 | 376 elif [ "$1" = "run" ]; then |
跟脚本里启动相比,这里有两点不同:
没有创建PID文件
使用的是eval exec,而不是eval
通过-Dcatalina.base=xxx,指定了catalina.base的位置为idea自定义的目录(tomcat 默认读取catalina.base下的web.xml)
catalina.sh run
starts tomcat in the foreground, displaying the logs on the console that you started it. Hitting Ctrl-C will terminate tomcat.
startup.sh
will start tomcat in the background. You’ll have totail -f logs/catalina.out
to see the logs.Both will do the same things, apart from the foreground/background distinction.
后续的流程就到了java代码里
The purpose of this roundabout approach is to keep the Catalina internal classes (and any
other classes they depend on, such as an XML parser) out of the system
class path and therefore not visible to application level classes.
bootstrap只是一张皮,先初始化了org.apache.catalina.startup.Catalina
,然后调用其start
方法。这么做的原因,注释中也给出了解释——防止tomcat的内部类被应用层感知(不在class path中,class path中只引入两个jar包,一个叫/bin/bootstrap.jar,一个叫/tomcat-juli.jar,其他的内部的jar包都在lib目录中,这部分是不在class path中的)。
1 | // org.apache.catalina.startup.Bootstrap#start |
初始化的时候,会初始化三个类加载器commonLoader
、catalinaLoader
、sharedLoader
。这三个类加载器本质上都是URLClassLoader,只是负责的加载的路径不同,可以在catalina.properties中配置:
1 | 38 # List of comma-separated paths defining the contents of the "common" |
这部分涉及到tomcat的类加载机制,会单独写一篇解析的文章,可以暂且跳过。
接力棒转交到Catalina之后,就涉及到配置文件的解析、tomcat的各个组件的启动了,会在第二篇中接着讲。
从火焰图中看,Servlet是在RMI的线程中加载的:
debug,获取对应的socket信息
可以看出这个RMI调用是idea发起的,server是tomcat
1 | ➜ conf lsof -i:54276 |
查看idea此时的栈信息,可以找到对应的线程栈:
1 | "javaee connector" #5620 prio=4 os_prio=31 cpu=17.64ms elapsed=926.91s tid=0x000000036be2e400 nid=0x4e78b runnable [0x000000039bbb9000] |
idea的社区版里没有找到这个类,用arthas 反编译org.jetbrains.idea.tomcat.admin.TomcatAdminServerBase
,得到源码:
1 | [arthas@55040]$ jad org.jetbrains.idea.tomcat.admin.TomcatAdminServerBase$2 |
正是这里调用了tomcat的createStandardContext
idea通过RMI调用tomcat的DynamicBean,可以显示的指定app的class目录,而无需放到tomcat的指定目录下:
同时,ide里对应配置的修改,也会反应到idea自己创建的web.xml上:
1 | ➜ conf cat /Users/qishengli/Library/Caches/JetBrains/IntelliJIdea2021.2/tomcat/15632928-a384-44e8-ba78-fe9ca3f37059/conf/server.xml |
使用jmc也能看到tomcat暴露出来的mbena是包含一些operation的,可以通过RMI调用:
Servlets are initialized either lazily at request processing time or eagerly during
deployment. In the latter case, they are initialized in the order indicated by
their load-on-startup elements.
在web容器启动的时候,可以采用lazily
加载的方式和eagerly
的方式。
load-on-startup
中的值决定了进行哪种方式。
If the value is a negative integer, or the element is not present, the
container is free to load the servlet whenever it chooses. If the value is a positive
integer or 0, the container must load and initialize the servlet as the application is
deployed.
如果
如果里面的值是正数或者0,容器必须保证在容器启动的时候加载和初始化这个servlet
The container must guarantee that servlets marked with lower integers
are loaded before servlets marked with higher integers.
这个值越小,优先级越高,容器优先加载。
The container may choose
the order of loading of servlets with the same load-on-startup value.
如果里面的值是一样的,那么加载的顺序由容器来决定(不同实现可能不同)
1 | ➜ qsli.github.com (hexo|✚1…) npm config get prefix |
修改owner
1 | sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share} |
修改owner之后就可以正常执行hexo了。
maven的git-commit-id
插件,可以在release jar包时,生成一个git.properties
文件,文件中可以附带上git的一些信息。git.properties
示例:
1 | #Generated by Git-Commit-Id-Plugin |
使用过程中,会发现一些大的项目,执行这个插件的时间总是很长:
1 | 2021-07-16 21:23:25.000 [INFO] [INFO] --- git-commit-id-plugin:2.2.6:revision (get-the-git-infos) @ xxx --- |
比如这个模块,就执行了38s,如果有多个模块的话,这个时间的花费就非常的客观了,一次release都能好几分钟。
获取git的哪些属性可以通过xml来配置:
1 | <plugin> |
有些属性的获取是比较耗时的,需要遍历所有的commit记录(比如Tags等)。但是我们的配置中并没有这个属性,时间还是很长,执行火焰图发现:
从火焰图中,可以明细的看出,在递归地遍历git的history,而且这个操作是getTags触发的。阅读源码发现,低版本的插件,是先计算,后过滤。也就是不管你配置了没有配置这个属性,都会参与一遍计算:
1 | // pl.project13.maven.git.GitCommitIdMojo#execute |
loadGitData时,将所有的属性都计算了一遍,然后扔到properties中,后续再propertiesFilterer进行过滤。切换到最新版的代码,已经修改为:
1 | // pl.project13.core.GitDataProvider#loadGitData |
新的代码中已经改为provider的模式了,这种是懒加载的,实际去get的时候,才会触发计算。因此直接升级之后就好了,升级之后,耗时直接变为毫秒级。
除了计算逻辑上的bug,还有一个jgit与native git的性能差异,
issue中也有人反馈tag过多导致执行慢的,但是通过使用本地的git替换之后从38s到3.6s
Long execution times with jgit · Issue #408 · git-commit-id/git-commit-id-maven-plugin
作者的回复中也比较了JGit和NativeGit的区别,JGit可以不用关心git的版本导致的输出形式的变化(这些问题由JGit来负责);如果使用Native Git的话,是自己解析的git的输出,如果git版本变了,这个解析可能出错。所以默认是使用JGit。
using the native git binary should usually give your build some performance boost, it may randomly break if you upgrade your git version and it decides to print information in a different format suddenly. As rule of thumb, keep using the default
jgit
implementation until you notice performance problems within your build
TCP协议栈的keepalive
,连接空闲一定时间后,会进行保活探测
1 | [qisheng.li@YD-order-center-01 ~]$ sudo sysctl -a | grep keep |
tcp_keepalive_time
the interval between the last data packet sent (simple ACKs are not considered data) and the first keepalive probe; after the connection is marked to need keepalive, this counter is not used any further
连接空闲tcp_keepalive_time
这么久之后,系统协议栈会认为连接需要保活
tcp_keepalive_intvl
the interval between subsequential keepalive probes, regardless of what the connection has exchanged in the meantime
两次探测的间隔
tcp_keepalive_probes
the number of unacknowledged probes to send before considering the connection dead and notifying the application layer
探测次数
从HTTP/1.1之后默认就使用keepalive了,http请求之后,连接不会关闭。这里只是实现了连接的复用,但是并没有保活相关的逻辑。
主要是通过header中的Connection: Keep-Alive
来实现连接的复用的,http/1.1之后默认就是keepalive,除非显式地声明为close。
parameters
A comma-separated list of parameters, each consisting of an identifier and a value separated by the equal sign (
'='
). The following identifiers are possible:
timeout
: indicating the minimum amount of time an idle connection has to be kept opened (in seconds). Note that timeouts longer than the TCP timeout may be ignored if no keep-alive TCP message is set at the transport level.max
: indicating the maximum number of requests that can be sent on this connection before closing it. Unless0
, this value is ignored for non-pipelined connections as another request will be sent in the next response. An HTTP pipeline can use it to limit the pipelining.
返回示例:
1 | HTTP/1.1 200 OK |
那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。比如,一个 Web 页面中内嵌的图片就都来自同一个 Web 站点,如果初始化了一个持久连接,你就可以复用该连接,以请求其他资源,而不需要重新再建立新的 TCP 连接。
1 | http { |
默认情况下,nginx已经自动开启了对client连接的keep alive支持。一般场景可以直接使用,但是对于一些比较特殊的场景,还是有必要调整个别参数。
需要修改nginx的配置文件(在nginx安装目录下的conf/nginx.conf):
1
2
3
4
5 > http {
> keepalive_timeout 120s 120s; // 默认75s
> keepalive_requests 10000; // 默认是100
> }
>
keepalive_timeout
第一个参数设置keep-alive客户端连接在服务器端保持开启的超时值。值为0会禁用keep-alive客户端连接。可选的第二个参数在响应的header域中设置一个值“Keep-Alive: timeout=time”。这两个参数可以不一样。
keepalive_requests
keepalive_requests指令用于设置一个keep-alive连接上可以服务的请求的最大数量。当最大请求数量达到时,连接被关闭。默认是100。
keepalive
The
*connections*
parameter sets the maximum number of idle keepalive connections to upstream servers that are preserved in the cache of each worker process. When this number is exceeded, the least recently used connections are closed.
类似maxIdle
配置名称 | 备注 |
---|---|
keepAliveTimeout | The number of milliseconds this Connector will wait for another HTTP request before closing the connection. The default value is to use the value that has been set for the connectionTimeout attribute. Use a value of -1 to indicate no (i.e. infinite) timeout. |
maxKeepAliveRequests | The maximum number of HTTP requests which can be pipelined until the connection is closed by the server. Setting this attribute to 1 will disable HTTP/1.0 keep-alive, as well as HTTP/1.1 keep-alive and pipelining. Setting this to -1 will allow an unlimited amount of pipelined or keep-alive HTTP requests. If not specified, this attribute is set to 100. |
apache的httpclient也没有保活的机制,连接的复用依赖于HTTP协议中的keep-alive
。HttpClient中有定时的任务,去清理过期和空闲的连接。
1 | /** |
归还连接时,根据response header中的来判断是否可以复用:
1 | // org.apache.http.impl.execchain.MinimalClientExec#execute |
1 | // org.apache.http.conn.ConnectionKeepAliveStrategy |
默认实现:
1 | // org.apache.http.impl.client.DefaultConnectionKeepAliveStrategy |
1 | // org.apache.http.ConnectionReuseStrategy |
默认实现:
1 | // org.apache.http.impl.DefaultConnectionReuseStrategy |
1 | // org.apache.dubbo.remoting.exchange.support.header.HeartbeatTimerTask#doTask |
在Druid-1.0.27之前的版本,DruidDataSource建议使用TestWhileIdle来保证连接的有效性,但仍有很多场景需要对连接进行保活处理。在1.0.28版本之后,新加入keepAlive配置,缺省关闭。
使用keepAlive功能,建议使用最新版本,比如1.1.21或者更高版本
⏳
keepaliveTime
This property controls how frequently HikariCP will attempt to keep a connection alive, in order to prevent it from being timed out by the database or network infrastructure. This value must be less than themaxLifetime
value. A “keepalive” will only occur on an idle connection. When the time arrives for a “keepalive” against a given connection, that connection will be removed from the pool, “pinged”, and then returned to the pool. The ‘ping’ is one of either: invocation of the JDBC4isValid()
method, or execution of theconnectionTestQuery
. Typically, the duration out-of-the-pool should be measured in single digit milliseconds or even sub-millisecond, and therefore should have little or no noticible performance impact. The minimum allowed value is 30000ms (30 seconds), but a value in the range of minutes is most desirable. Default: 0 (disabled)
⏳
idleTimeout
This property controls the maximum amount of time that a connection is allowed to sit idle in the pool. This setting only applies whenminimumIdle
is defined to be less thanmaximumPoolSize
. Idle connections will not be retired once the pool reachesminimumIdle
connections. Whether a connection is retired as idle or not is subject to a maximum variation of +30 seconds, and average variation of +15 seconds. A connection will never be retired as idle before this timeout. A value of 0 means that idle connections are never removed from the pool. The minimum allowed value is 10000ms (10 seconds). Default: 600000 (10 minutes)
1 |
|
结果:1
2
3
4
5
6
7
82021-03-23T03:07:15.228849Z 93 Connectroot@localhost on test using TCP/IP
2021-03-23T03:07:15.235215Z 93 Query/* mysql-connector-java-8.0.20 (Revision: afc0a13cd3c5a0bf57eaa809ee0ee6df1fd5ac9b) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@collation_server AS collation_server, @@collation_connection AS collation_connection, @@init_connect AS init_connect, @@interactive_timeout AS interactive_timeout, @@license AS license, @@lower_case_table_names AS lower_case_table_names, @@max_allowed_packet AS max_allowed_packet, @@net_write_timeout AS net_write_timeout, @@performance_schema AS performance_schema, @@sql_mode AS sql_mode, @@system_time_zone AS system_time_zone, @@time_zone AS time_zone, @@transaction_isolation AS transaction_isolation, @@wait_timeout AS wait_timeout
2021-03-23T03:07:15.257727Z 93 QuerySET character_set_results = NULL
2021-03-23T03:07:15.261596Z 93 QuerySET autocommit=0
2021-03-23T03:07:15.296076Z 93 Queryupdate words set word=CONCAT(word, '++') where id=2
2021-03-23T03:07:15.305666Z 93 Queryrollback
2021-03-23T03:07:15.330138Z 93 Queryrollback
2021-03-23T03:07:15.347768Z 93 Quit
可以看出使用原始的JDBC提供的接口,需要获取conn,设置各种属性,获取statement,同时还需要处理各种资源的关闭,事务的commit或者rollback。这些步骤就是boilerplate code
——样板化的代码,非常适合使用模板方法,将这些细节隐藏起来。
spring提供了JdbcTemplate来简化jdbc相关的开发,对于事务相关的开发,提供了声明式事务和编程式事务。
1 | <bean id="mindTransactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> |
使用
1 | "mindTransactionManager", readOnly = true) (transactionManager = |
1 | "orderShardingTransactionTemplate") (name = |
使用:
1 | transactionTemplate.execute(status -> {}) |
声明式事务,最终通过AOP代理到了TransactionInterceptor
(org.springframework.transaction.interceptor.TransactionInterceptor),处理逻辑可以顺着配置类查看,这里不再赘述。
下面从编程式事务入手,解析下源码
1 | // org.springframework.transaction.support.TransactionTemplate#execute |
模板里的代码没什么看的,整体的逻辑都委托给了PlatformTransactionManager
This is the central interface in Spring’s transaction infrastructure.
Applications can use this directly, but it is not primarily meant as API:
Typically, applications will work with either TransactionTemplate or
declarative transaction demarcation through AOP.
PlatformTransactionManager
定义了三个接口,getTransaction
/commit
/rollback
1 | public interface PlatformTransactionManager { |
AbstractPlatformTransactionManager
主要实现了两个功能:
TransactionSynchronization
事务的状态1 | /** |
1 | // org.springframework.transaction.support.AbstractPlatformTransactionManager#commit |
1 | // org.springframework.transaction.support.AbstractPlatformTransactionManager#rollback |
这个类是AbstractPlatformTransactionManager
的实现,使用javax.sql.DataSource
获取连接的都可以是用这个类来管理事务。
几个关键template method的实现:
开始事务:
1 | //org.springframework.jdbc.datasource.DataSourceTransactionManager#doGetTransaction |
提交事务:
1 | // org.springframework.jdbc.datasource.DataSourceTransactionManager#doCommit |
回滚事务:
1 |
|
清理工作:
1 | // org.springframework.jdbc.datasource.DataSourceTransactionManager#doCleanupAfterCompletion |
1 | /** |
可以监听事务的不同阶段的信息
是主要的ThreadLocal管理类,用来做事务的同步。Mybatis的SqlSession,Jdbc的Connection都会通过这个类来和ThreadLocal交互。
主要属性:
1 | public abstract class TransactionSynchronizationManager { |
资源的管理的主要接口:
1 | // org.springframework.transaction.support.TransactionSynchronizationManager#bindResource |
这个提供了引用计数的功能:
1 | /** |
Spring在处理这些带状态的类SqlSession
、Connection
都做了ThreadLocal
的绑定。
在事务的场景下,需要复用同一个连接,spring存到ThreadLocal里的就是ResourceHolderSupport
的子类,每次请求计数就加一
1 | // org.springframework.jdbc.datasource.DataSourceUtils#doGetConnection |
当计数减到0的时候,可以认为这个连接已经没有人用,可以回收。有点类似java中的垃圾回收算法。
]]>依赖包地址:
1 | <dependency> |
使用配置:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 <!-- 扫描mapper-->
<bean id="mindSqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="mindDataSource"/>
<property name="configLocation" value="classpath:mybatis-config.xml"/>
<property name="mapperLocations" value="classpath*:mapper/minder/*Mapper.xml"/>
<property name="plugins">
<array>
<ref bean="sqlInterceptor"/>
</array>
</property>
</bean>
<!-- 生成Dao代理类-->
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="sqlSessionFactoryBeanName" value="mindSqlSessionFactory" />
<property name="basePackage" value="com.air.persistence.minder.dao"/>
</bean>
接口类也要交给spring管理:
1 |
|
经过上面的配置,MinderDao
就交给spring容器管理了,使用的时候直接注入就行:
1 |
|
FactoryBean that creates an MyBatis SqlSessionFactory.
This is the usual way to set up a shared MyBatis {@code SqlSessionFactory} in a Spring > >application context;
the SqlSessionFactory can then be passed to MyBatis-based DAOs via dependency injection.
1 | /** |
BeanDefinitionRegistryPostProcessor that searches recursively starting from a base package for
interfaces and registers them as MapperFactoryBean . Note that only interfaces with at
least one method will be registered; concrete classes will be ignored.
这个类的作用是,扫描配置的接口,生成代理的Dao对象,这里扫描完之后,注册的是MapperFactoryBean
。对象生成过程如下:
1 | // org.mybatis.spring.mapper.MapperFactoryBean#getObject |
简单看下扫描过程:
1 | public class MapperScannerConfigurer implements BeanDefinitionRegistryPostProcessor, InitializingBean, ApplicationContextAware, BeanNameAware { |
从上面的代码分析可以看出来,拿到的代理类,底层的SqlSession实际上是SqlSessionTemplate
,SqlSessionTemplate
内部采用了JDK的代理,将实际的请求代理给了SqlSessionInterceptor
:
1 | // org.mybatis.spring.SqlSessionTemplate |
SqlSessionUtils
是mybatis提供的工具类,处理了SqlSession的ThreadLocal
绑定和事务结束后的释放:
1 | // org.mybatis.spring.SqlSessionUtils#getSqlSession(org.apache.ibatis.session.SqlSessionFactory, org.apache.ibatis.session.ExecutorType, org.springframework.dao.support.PersistenceExceptionTranslator) |
SqlSession
不是线程安全的,多线程环境下必然有竞争问题。众所周知,spring是通过给每个Thread做绑定来解决竞争问题的。SqlSessionTemplate
中的sqlSession实际上是个代理对象,他是没有状态的,每次执行的时候,再通过工具类类创建或者复用ThreadLocal
中的session,从而避免了多线程的问题。
事务状态变化的时候,这个回调会得到通知,在通知里做了一些清理的工作:
1 | /** |
SpringManagedTransaction handles the lifecycle of a JDBC connection.
It retrieves a connection from Spring’s transaction manager and returns it back to it
when it is no longer needed.
If Spring’s transaction handling is active it will no-op all commit/rollback/close calls
assuming that the Spring transaction manager will do the job.
If it is not it will behave like JdbcTransaction.
这个Transaction也是mybatis为了适配spring的体系定制的,获取连接和释放连接都委托给了spring提供的工具类DataSourceUtil
1 | public class SpringManagedTransaction implements Transaction { |
除了连接的获取和释放,这个类的commit和rollback也做了特殊处理:
1 | /** |
DataSourceUtils
是spring提供的工具类,主要是加了一层ThreadLocal缓存的管理:
1 | // org.springframework.jdbc.datasource.DataSourceUtils#getConnection |
最终实现了TransactionSynchronization
接口,AbstractPlatformTransactionManager
接口会在当前事务状态发生变化(比如挂起,完成等)通知TransactionSynchronization
。对于事务连接的关闭就是在ConnectionSynchronization
接口中
1 | // org.springframework.jdbc.datasource.DataSourceUtils.ConnectionSynchronization |
1 | final List<Object> selectAll = sqlSession.selectList("selectAll", null, new RowBounds(10, 20)); |
但是这种方式缺乏类型安全,参数传递的过程容易出错。
mybatis还支持生成代理类的方式来使用:
1 | <mapper namespace="com.air.mybatis.sqlsession.WordsDao"> |
注意,namespace必须是WordsDao
1 | package com.air.mybatis.sqlsession; |
测试代码:
1 | // com.air.mybatis.sqlsession.SqlSessionTest#testProxy |
先从sqlSession.getMapper(WordsDao.class);
入手,看看大概:
1 | // org.apache.ibatis.session.defaults.DefaultSqlSession#getMapper |
客户端最终拿到的是一个MapperProxy
的代理对象(com.sun.proxy.$Proxy6
),下面看看调用过程的逻辑:
1 | // org.apache.ibatis.binding.MapperProxy#invoke |
1 | // org.apache.ibatis.builder.xml.XMLMapperBuilder#bindMapperForNamespace |
SqlSession是mybatis面向用户的一个类,使用如下:
1 |
|
SqlSession
创建过程:
执行过程:
这一层提供的接口主要是针对MappedStatement
的:
1 | /** |
在创建Session的时候,可以指定使用哪种executor
1 | // org.apache.ibatis.session.Configuration#newExecutor(org.apache.ibatis.transaction.Transaction, org.apache.ibatis.session.ExecutorType) |
一级缓存默认打开
MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。
1 | configuration.setLocalCacheScope(LocalCacheScope.valueOf(props.getProperty("localCacheScope", "SESSION"))); |
没有配置默认就是session级别的,配置示例:
1 | <setting name="localCacheScope" value="SESSION"/> |
Executor是跟session绑定的,所以这个缓存是session级别的,也就是连接级别的。连接关闭之后,这个缓存也就消失了。
1 | // org.apache.ibatis.executor.BaseExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler) |
CachingExecutor
加了一层Statement
级别的缓存,其他的逻辑都是委托给其他的Executor来实现的。
1 | // org.apache.ibatis.executor.CachingExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql) |
实际处理类的逻辑:
1 | // org.apache.ibatis.executor.SimpleExecutor#doQuery |
Cache的实现使用了装饰者模式:
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache
以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。
SynchronizedCache
:同步Cache,实现比较简单,直接使用synchronized修饰方法。LoggingCache
:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。SerializedCache
:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。LruCache
:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。PerpetualCache
: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。
二级缓存跨session存在,有很大的风险会读到错误的数据。而且大部分的互联网应用都是分布式的,一般不共享状态,可以水平扩展;但是本地缓存打破了无状态下,很有可能会读到错误的数据,应该慎重使用。
又叫PSCache
,这里对应的是ReuseExecutor
,这个缓存也是Session级别的。除了在Mybatis这一层做缓存,还可以在MySQL驱动和MysqlServer做缓存,参见jdbc预编译缓存加速sql执行 | KL’s blog
1 | // org.apache.ibatis.executor.ReuseExecutor#prepareStatement |
StatementHandler
主要是跟javax
里的Statement
打交道的,相当于对Statement
的操作进行了一层封装,也是mybatis
和jdbc
的一个隔离层。
接口:
1 | /** |
可以看出,接口中的参数,都是Statement
而不是mybatis
自己的MappedStatement
继承关系:
其中RoutingStatementHandler
就是用来路由的,根据查询的类型路由到SimpleStatementHandler
、CallableStatementHandler
、PreparedStatementHandler
1 | public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { |
TypeHandler
主要负责类型转换,类似spring的ConversionService
, 主要用于两个地方,一个是设置PrepareStatement
,占位符对应的参数;一个是将ResultSet返回的结果集转换成对象。
1 | /** |
比如数据库里面存的是VARCHAR
,传给mybatis的是一个Bean
对象,就可以在这一层做一个转换:
1 |
|
默认实现org.apache.ibatis.scripting.defaults.DefaultParameterHandler
1 | // org.apache.ibatis.scripting.defaults.DefaultParameterHandler#setParameters |
用于转换JDBC
返回的ResultSet
对象为Statement
中定义的返回值类型。
1 | /** |
默认实现:
1 | // org.apache.ibatis.executor.resultset.DefaultResultSetHandler#handleResultSet |