一、案例故障描述
1.1 具体现象
这是不久前的一个客户案例,客户的一个门户网站系统是基于java开发的,运行多年,一直正常,而最近经常罢工,频繁出现java进程占用CPU资源很高的情况,在CPU资源占用很高的时候,web系统响应缓慢,下图是某时刻服务器的一个状态截图:
下面是htop获取的状态信息:
从图中可以看出,java进程占用CPU资源达到300%以上,而每个CPU核资源占用也比较高,都在30%左右,让客户的运维人员检查后,也没发现什么异常,于是就把问题抛给了程序方面,而研发查看了代码,也没发现什么异常情况,最后又推给运维了,说是系统或者网络问题,研发说正常情况下java进程占用CPU不会超过100%,而这个系统达到了300%多,肯定是系统有问题,然而运维也无计可施了,最后,急中生智,重启了系统,java进程占用的CPU资源一下子就下来了。
就这样,重启成了运维解决问题的唯一办法。
然而,这个重启的办法,用了几天就不行了,今天,又例行重启了系统,但是重启后,web系统仅能正常维持30分钟左右,接着,彻底无法访问了,现象还是java占用CPU大量资源。
1.2 问题分析
从这个案例现象来看,问题应该出在程序方面,原因如下:
- java进程占用资源过高,可能是在做GC,也可能是内部程序出现死锁,这个需要进一步排查。
- 网站故障的时候,访问量并不高,也没有其他并发请求,攻击也排除了(内网应用),所以肯定不是系统资源不足导致的。
- java占用CPU资源超过100%完全可能的,因为java是支持多线程的,每个内核都在工作,而java进程持续占用大量CPU资源就不正常了,具体原因要进一步排查。
- 检查发现,web系统使用的是tomcat服务器,并且tomcat配置未作任何优化,因此这个需要配置一些优化参数。
针对上面四个原因,那么下面就具体分析下,如何对这个Web系统进程故障分析和调优。
二、java中进程与线程的概念
- 要了解java占用CPU资源超过100%的情况,就需要知道进程和线程的概念和关系。
- 进程是程序的一次动态执行,它对应从代码加载,执行至完毕的一个完整的过程,是一个动态的实体,它有自己的生命周期。它因创建而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被撤销。
- 线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行。
进程和线程的关系:
1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
2. 进程作为资源分配的最小单位,资源是分配给进程的,同一进程的所有线程共享该进程的所有资源。
3. 真正在处理机上运行的是线程。
进程与线程的区别:
- 调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位。
- 并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可并发执行。
- 拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。
- 系统开销:在创建或撤销进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤销线程时的开销。
Tomcat底层是通过JVM运行的,JVM在操作系统中是作为一个进程存在的,而java中的所有线程在JVM进程中,但是CPU调度的是进程中的线程。因此,java是支持多线程的,体现在操作系统中,就是java进程可以使用CPU的多核资源。那么java进程占用CPU资源300%以上是完全可能的。
三、排查java进程占用CPU过高的思路
下面重点来了,如何有效的去排查java进程占用CPU过高呢,下面给出具体的操作思路和方法。
3.1 提取占用CPU过高的进程
提取占用CPU高进程的方法很多,常用的方法有如下两个:
方法一:使用top或htop命令查找到占用CPU高得进程的pid
- top -d 1
方法二:使用ps查找到tomcat运行的进程
- ps -ef | grep tomcat
3.2 定位有问题的线程的pid
在Linux中,程序中创建的线程(也称为轻量级进程,LWP)会具有和程序的PID相同的"线程组ID"。同时,各个线程会获得其自身的线程ID(TID)。对于Linux内核调度器而言,线程不过是恰好共享特定资源的标准的进程而已。
那么如何查看进行对应的线程信息呢,方法很多,常用的命令有ps,top和htop命令
3.2.1 ps命令查看进程的线程信息
在ps命令中,"-T"选项可以开启线程查看。下面的命令列出了由进程号为的进程创建的所有线程。
- ps -T -p <pid>
例如:
- [root@Jenkins ~]# jps
- 25394 jenkins.war
- 29503 Jps
- [root@Jenkins ~]# ps -T -p 25394
- PID SPID TTY TIME CMD
- 25394 25394 ? 00:00:00 java
- 25394 26930 ? 00:00:00 java
- 25394 26931 ? 00:00:03 java
- 25394 26932 ? 00:00:00 java
- 25394 26933 ? 00:00:00 java
- 25394 26934 ? 00:00:00 java
- 25394 26935 ? 00:00:18 java
- 25394 26936 ? 00:00:04 java
- 25394 26937 ? 00:00:00 java
- 25394 26938 ? 00:00:11 java
- 25394 26939 ? 00:00:00 java
- 25394 26940 ? 00:00:00 java
- 25394 26941 ? 00:00:00 java
- 25394 26943 ? 00:00:00 java
在输出中,"SPID"栏表示线程ID,而"CMD"栏则显示了线程名称。
使用ps命令的一个缺点是,无法动态查看每个线程消耗资源的情况,仅仅能看线程id信息,所以我们更多使用的是top和htop
3.2.2 top命令获取线程信息
top命令可以实时显示各个线程情况。要在top输出中开启线程查看,可调用top命令的"-H"选项,该选项会列出所有Linux线程。看下图:
要让top输出某个特定进程并检查该进程内的线程情况,可执行如下命令
top -H -p <pid>
- [root@Jenkins ~]# top -H -p 25394 -d 5
- top - 16:56:43 up 1 day, 7:36, 3 users, load average: 0.00, 0.01, 0.05
- Threads: 61 total, 0 running, 61 sleeping, 0 stopped, 0 zombie
- %Cpu(s): 0.2 us, 0.2 sy, 0.0 ni, 99.6 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
- KiB Mem : 997948 total, 113388 free, 469936 used, 414624 buff/cache
- KiB Swap: 2097148 total, 2073740 free, 23408 used. 334440 avail Mem
- PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
- 25394 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.05 java
- 26930 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.81 java
- 26931 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:03.43 java
- 26932 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.00 java
- 26933 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.01 java
- 26934 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.00 java
- 26935 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:18.80 java
- 26936 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:04.47 java
- 26937 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.00 java
- 26938 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:12.45 java
- 26939 jenkins 20 0 2473868 414404 26680 S 0.0 41.5 0:00.00 java
可以看到,每个线程状态是实时刷新的,这样我们就可以观察,哪个线程消耗CPU资源最多,然后把它的SPID(TID)记录下来。
3.2.3 htop命令获取线程信息
通过htop命令查看单个进程的线程信息,更加简单和友好,此命令可以在树状视图中监控单个独立线程。
要在htop中启用线程查看,可先执行htop,然后按F2键来进入htop的设置菜单。选择"设置"栏下面的"显示选项",然后开启"树状视图"和"显示自定义线程名"选项。最后按F10键退出设置。如下图所示:
很简单的把,这样就可以清楚地看到单个进程的线程视图了,并且状态信息也是实时刷新的。通过这个方法也可以查找出CPU利用率最厉害的线程号。然后记录下来。
3.3 将线程的pid转换为16进制数
printf '%x\n' pid
注意,此处的SPID(TID)为上一步找到的占CPU高的线程号
3.4 使用jstack工具将进程信息打印输出
- jstack是java虚拟机自带的一种堆栈跟踪工具。可以用于生成java虚拟机当前时刻的线程快照。
- 线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。
-
- 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。
总结一句话:jstack命令主要用来查看Java线程的调用堆栈的,可以用来分析线程问题(如死锁)。
想要通过jstack命令来分析线程的情况的话,首先要知道线程都有哪些状态
- 线程堆栈信息可能会看到的线程的几种状态
-
- NEW:未启动的。不会出现在Dump中
- RUNNABLE:在虚拟机内执行的。运行状态,可能里面还能看到locked字样,表明它获得了某把锁
- BLOCKED:受阻塞并等待监视器锁。被某个锁(synchronizers)给block住了。
- WAITING:无限期等待另一个线程执行特定操作。等待某个condition或monitor发生,一般停留在park(),wait(),sleep(),join()等语句里。
- TIMED_WATING:有时限的等待另一个线程的特定操作。和WAITING的区别是wait()等语句加上了时间限制wait(timeout)
- TERMINATED:已退出的
那么怎么去使用jstack呢,很简单,用jstack打印线程信息,将信息重定向到文件中,可执行如下操作:
jstack pid | grep spid(tid)
例如:
- [root@localhost ~]# jstack 2020 | grep -A 15 "7e5"
- "main" #1 prio=5 os_prio=0 tid=0x00007f7788009000 nid=0x7e5 runnable [0x00007f778f291000]
- java.lang.Thread.State: RUNNABLE
- at java.net.PlainSocketImpl.socketAccept(Native Method)
- at java.net.AbstractPlainSocketImpl.accept(AbstractPlainSocketImpl.java:409)
- at java.net.ServerSocket.implAccept(ServerSocket.java:545)
- at java.net.ServerSocket.accept(ServerSocket.java:513)
- at org.apache.catalina.core.StandardServer.await(StandardServer.java:446)
- at org.apache.catalina.startup.Catalina.await(Catalina.java:713)
- at org.apache.catalina.startup.Catalina.start(Catalina.java:659)
- at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
- at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
- at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
- at java.lang.reflect.Method.invoke(Method.java:497)
- at org.apache.catalina.startup.Bootstrap.start(Bootstrap.java:351)
- at org.apache.catalina.startup.Bootstrap.main(Bootstrap.java:485)
这里面重点关注下输出的线程状态,如果有异常,会输出相关异常信息或者跟程序相关的信息,将这些信息给开发人员,就可以马上定位CPU过高的问题,所以这个方法非常有效。
3.5 根据输出信息进行具体分析
学会了怎么使用jstack命令之后,我们就可以看看,如何使用jstack分析死锁,这也是我们一定要掌握的内容。什么是死锁?所谓死锁:是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外界因素作用,它们都将无法执行下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
最后,我们使用jstack来看一下线程堆栈信息:
- Found one Java-level deadlock:
- =============================
- "Thread-1":
- waiting to lock monitor 0x00007f0134003ae8 (object 0x00000007d6ab2c98, a java.lang.Object),
- which is held by "Thread-0"
- "Thread-0":
- waiting to lock monitor 0x00007f0134006168 (object 0x00000007d6ab2ca8, a java.lang.Object),
- which is held by "Thread-1"
- Java stack information for the threads listed above:
- ===================================================
- "Thread-1":
- at javaCommand.DeadLockclass.run(JStackDemo.java:40)
- - waiting to lock <0x00000007d6ab2c98>; (a java.lang.Object)
- - locked <0x00000007d6ab2ca8>; (a java.lang.Object)
- at java.lang.Thread.run(Thread.java:745)
- "Thread-0":
- at javaCommand.DeadLockclass.run(JStackDemo.java:27)
- - waiting to lock <0x00000007d6ab2ca8> (a java.lang.Object)
- - locked <0x00000007d6ab2c98> (a java.lang.Object)
- at java.lang.Thread.run(Thread.java:745)
- Found 1 deadlock.
这个结果显示的很详细了,它告诉我们“Found one Java-level deadlock”,然后指出造成死锁的两个线程的内容。接着又通过“Java stack information for the threads listed above”来显示更详细的死锁的信息,具体内容解读如下:
- Thread-1在想要执行文件名为JStackDemo的第40行代码的时候;
- 因为需要等待资源<0x00000007d6ab2c98>;
- 所以锁住了资源<0x00000007d6ab2ca8>。
- Thread-0在想要执行文件名为JStackDemo的第27行代码的时候;
- 因为它在等待资源<0x00000007d6ab2ca8>;
- 所以锁住了资源<0x00000007d6ab2c98>。
- 由于这两个线程都持有资源,并且都需要对方的资源,所以造成了死锁。 原因我们找到了,就可以具体问题具体分析,解决这个死锁了。
四、tomcat配置调优
在实际工作中接触过很多线上基于java的tomcat应用案例,多多少少都会出现一些性能问题,在追查原因的时候发现,tomcat的配置都是默认的,没有经过任何修改和调优,这肯定会出现性能问题了,在tomcat默认配置中,很多参数都设置的很低,尤其是内存和线程的配置,这些默认配置在web没有大量业务请求时,不会出现问题,而一旦业务量增长,很容易成为性能瓶颈。
- tomcat的调优,主要是从三个方面进行:
-
- 内存
- 并发
- 缓存
4.1 内存调优
这个主要是配置tomcat对JVM参数的设置,我们可以在tomcat的启动脚本catalina.sh中设置java_OPTS参数。
- JAVA_OPTS参数常用的有如下几个:
-
- -server:表示启用jdk的server运行模式
- Xms:设置JVM初始堆内存的大小
- Xmx:设置JVM最大堆内存的大小
- XX:PermSize:设置堆内存持久代初始值大小
- XX:MaxPermSize:设置持久代最大值
- Xmn1g:设置堆内存年轻代大小
JVM的大小设置,跟服务器的物理内存有直接关系,不能太小,也不能太大,如果服务器内存为32G,可以采用以下配置:
- JAVA_OPTS='-server -Xms8192m -Xmx8192m -XX:MaxNewSize=256m -XX:PermSize=256m -XX:MaxPermSize=512m'
对于堆内存大小的设置有如下经验:
- 将最小堆大小(Xms)和最大堆大小(Xmx)设置为彼此相等;
- 堆内存不能设置过大,虽然堆内存越大,JVM可用的内存就越多。
- 但请注意,太多的堆内存可能会使垃圾收集长时间处于暂停状态。
- 将Xmx设置为不超过物理内存的50%,最大不超过32GB
4.2 Tomcat并发优化与缓存优化
这部分主要是对Tomcat配置文件server.xml内的参数进行的优化和配置。默认的server.xml文件一些性能参数配置很低,无法达到tomcat最高性能,因此需要有针对性的修改一下,常用的tomcat优化参数如下:
- <Connector port="8080"
- protocol="HTTP/1.1"
- maxHttpHeaderSize="8192" #最大的http请求头部大小8K
- maxThreads="1000" #处理用户请求的最大线程数
- minSpareThreads="100" #最少保留100个线程处于空闲状态
- maxSpareThreads="1000" #最大保留1000个线程处于空闲状态
- minProcessors="100" #Tomcat4+版本中,此参数失效
- maxProcessors="1000" #Tomcat4+版本中,此参数失效
- enableLookups="false" #禁止DNS反向解析
- compression="on" #打开文件传输的压缩功能
- compressionMinSize="2048" #当数据大于2KB才会进行压缩
- compressableMimeType="text/html,text/xml,text/javascript,text/css,text/plain" #待压缩的文件类型
- connectionTimeout="20000" #连接超时时间2万毫秒(20秒)
- URIEncoding="utf-8" #URL统一编码格式支持中文
- acceptCount="1000" #监听端口的等待队列最大数,(等待1000+maxThreads1000,最大并发2000),超过此数字服务器会直接拒绝此次请求,返回connection refused
- redirectPort="8443" #用于基于安全通道的场景,把用户请求转发到SSL的redirectPort端口
- disableUploadTimeout="true"/> #上传时是否启动超时机制
五、Tomcat Connector三种运行模式(BIO,NIO,APR)比较与优化
5.1 什么是BIO,NIO,APR
- BIO(blocking I/O):即阻塞式I/O操作的java API;
-
- Tomcat7以下版本默认是BIO模式,由于每个请求都要创建一个线程来处理,线程开销较大,不能处理高并发的场景,在三种模式中性能最低
- 同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,因此,当并发量高时,线程数会较多,造成资源浪费。
- NIO(non-blocking I/O):基于缓冲区,并能提供非阻塞I/O操作的java API;
-
- Tomcat8及以上默认是NIO模式,比BIO拥有更好的并发运行性能。
- 同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
- AIO(Apache Portable Runtime I/O):Apache可移植运行时I/O,是Apache HTTP服务器的支持库
-
- Tomcat将以JNI的形式调用Apache HTTP服务器的核心动态链接库来处理文件读取或网络传输操作,从而大大地提高Tomcat对静态文件的处理性能。
- Tomcat APR也是tomcat上运行高并发应用的首选模式
- 异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,可以看出,AIO是从操作系统级别来解决异步I/O问题,因此可以大幅度提高性能。
从这个运行模式中,我们基本可以看出,三种模式的优劣了,下面总结三种模式的特点和使用环境
- BIO:已经淘汰,JDK1.4以前的唯一选择
- NIO:适用于连接数目多且连接比较短的架构,比如消息通信服务器,并发局限于应用中,编程比较复杂,JDK1.4之后支持
- AIO:用于连接数目多且长连接的架构中,比如直播服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持
5.2 tomcat中如何使用BIO,NIO,APR模式
这里以tomcat8.5.29为例进行介绍,在tomcat8版本中,默认使用的就是模式,也就是无需做任何配置,Tomcat启动的时候,可以通过catalina.out文件看到Connector使用的是哪一种运行模式,默认情况下会输出如下日志信息:
- 15-Nov-2018 23:13:56.737 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["http-nio-8080"]
- 15-Nov-2018 23:13:56.762 INFO [main] org.apache.coyote.AbstractProtocol.init Initializing ProtocolHandler ["ajp-nio-8009"]
- 15-Nov-2018 23:13:56.764 INFO [main] org.apache.catalina.startup.Catalina.load Initialization processed in 715 ms
这个日志表面目前tomcat使用的是NIO模式
要让tomcat运行在APR默认的话,首先需要安装apr,apr-utils,tomcat-native等依赖包,简单安装过程如下:
- #安装apr与apr-util
- #从Download - The Apache Portable Runtime Project 下载apr和apr-utils
- [root@lampserver app]# tar xf apr-1.6.3.tar.gz
- [root@lampserver app]# cd apr-1.6.3
- [root@lampserver apr-1.6.3]# ./configure --prefix=/usr/local/apr
- [root@lampserver apr-1.6.3]# make && make install
- [root@lampserver /]#yum -y install expat expat-devel
- [root@lampserver app]# tar xf apr-util-1.6.1.tar.gz
- [root@lampserver app]# cd apr-util-1.6.1
- [root@lampserver apr-util-1.6.1]# ./configure --prefix=/usr/local/apr-util --with-apr=/usr/local/apr
- [root@lampserver apr-util-1.6.1]# make && make install
- #安装tomcat-native
- #从Apache Tomcat® - Tomcat Native Downloads 下载tomcat-native
- [root@localhost ~]# tar xf tomcat-native-1.2.18-src.tar.gz
- [root@localhost ~]# cd tomcat-native-1.2.18-src/native
- [root@localhost ~]# ./configure --with-apr=/usr/local/apr --with-java-home=/usr/local/java/
- [root@localhost ~]# make && make install
- #设置环境变量,将如下内容添加到/etc/profile文件中
- JAVA_HOME=/usr/local/java
- JAVA_BIN=$JAVA_HOME/bin
- PATH=$PATH:$JAVA_BIN
- CLASSPATH=$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
- export JAVA_HOME JAVA_BIN PATH CLASSPATH
- export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/apr/lib
- #执行source命令
- [root@localhost ~]#source /etc/profile
- #修改tomcat配置文件server.xml,找到tocmat默认的http的8080端口配置的Connector
- <Connector port="8080" protocol="HTTP/1.1"
- connectionTimeout="20000"
- redirectPort="8443" />
- #修改为:
- <Connector port="8080" protocol="org.apache.coyote.http11.Http11AprProtocol"
- connectionTimeout="20000"
- redirectPort="8443" />;
- #接着,修改tocmat默认的ajp的8009端口配置的Connector,找到如下内容:
- <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />;
- #修改为:
- <Connector port="8009" protocol="org.apache.coyote.ajp.AjpAprProtocol" redirectPort="8443" />;
最后,重启tomcat,使配置生效。Tomcat重启过程中,可以通过catalina.out文件看到Connector使用的是哪一种运行模式,如果能看到类似下面的输出,表示配置成功。
- 06-Nov-2018 14:03:31.048 信息 [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-apr-8000"]
- 06-Nov-2018 14:03:31.103 信息 [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["ajp-apr-8009"]
5.3 tomcat三种模式测试
通过测试,随着线程的不断增多,bio模式性能越来越差,就算是在本地,错误率和响应时间都在明显增加,而吞吐量样本数和每秒传输速率都在下降;
而Nio和Aio模式基本上没有变化太多,都保持在一个稳定的状态;
因此,生产环境tomcat到底要用什么模式,具体的服务器配置还需要进行具体的压力测试。