Java性能监控与调优

想要有更强的竞争力,这部分一定是得学的,并且小公司的话,你也不太可能只仅仅开发,这些也要全要搞。多了解一些总不是坏事,想要走的更远的话。
介绍下 JDK 或者第三方的一些工具来祝你优化你的程序!

JVM参数

首先来了解下 JVM 参数的类型,一般来说,可以分为下面的三大类:

  • 标准参数
    在 JVM 的各个版本中基本不变,比较稳定的。
    例如:-help-server-client-version-cp-classpath
  • X 参数
    非标准化参数,有可能会变,但是变化比较小。
    例如,解释执行:-Xint、第一次使用就编译成本地代码:-Xcomp 、混合模式(默认):-Xmixed
  • XX 参数
    使用的最多的一类参数,相对不稳定,主要用于 JVM 调优和 Debug。
    它还可以再分类,例如:
    布尔类型:-XX:[+-]<name> 表示启用或者禁用某个属性,启用 G1 垃圾收集器 -XX:+UseG1GC
    非布尔类型,也就是 K-V 的形式:-XX:<name>=<val> 就是用来调整属性的。

我们见的最多的应该是 -Xms 和 -Xmx 了,然而它俩其实是 XX 参数,是一种简写形式。
-Xms 等价于 -XX:InitialHeapSize ;-Xmx 等价于 -XX:MaxHeapSize ;-Xss 等价于 -XX:ThreadStackSize

查看JVM运行时参数

使用到的参数有:

  • -XX:+PrintFlagsInitial
    查看初始值
  • -XX:+PrintFlagsFinal
    查看最终值
  • -XX:+UnlockExperimentalVMOptions
    解锁实验参数,有些参数需要解锁后才可以设置。
  • -XX:+UnlockDiagnosticVMOptions
    解锁诊断参数
  • -XX:+PrintCommandLineFlags
    打印命令行参数

PrintFlagsFinal

打印的值有两类,= 表示默认值;:= 表示被用户或者 JVM 修改后的值。
可以在命令行中使用 java -XX:+PrintFlagsFinal -version 来体验一把。

jps

专门用来查看 Java 进程的,跟 Linux 中的 ps 指令类似,可以使用 jps -l 来查看详细信息,更多的参数介绍可以在官方的文档中找到。

jinfo

可以用来查看正在运行的 JVM 进程的参数,不过需要你知道参数的名字才行,例如:jinfo -flag MaxHeapSize [pid] 、查看垃圾回收器的:jinfo -flag [UseConcMarkSweepGC, UseG1GC] [pid]

jstat

可以查看 JVM 统计信息,例如类装载(-class)、垃圾收集(-gc)、JIT编译信息。
举个例子:jstat -class [pid] 1000 10 后面两个是可选的,意思是每隔 1000ms 输出一次,一共输出 10 次。
至于输出的是什么,文档里都有写,C 结尾表示的就是总量,U 结尾就是表示的已使用。

内存结构

简单起见可以参考这张图:

JVM内存结构

非堆区也叫 Metaspace,是 JDK8+ 才有的,它移除了永久代的概念,使用堆外直接内存;其中的 CCS 不一定存在,当启用了指针压缩(64 -> 32)才会有,CodeCache 是跟 JIT 编译相关的,还有一些其他的东西。
之前也看到过 64 位的 JVM 跟 32 位的 JVM 其实变化很大的,指针膨胀就是个大问题。

关于内存溢出

OOM 应该是常见的一种情况了,常见的分析思路就是看 Dump 文件,也就是内存镜像文件,发生 OOM 时自动导出这个文件可以这样配置:-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=./

除了自动导出,还可以使用 jmap 命令来手动导出。
例:jmap -dump:format=b,file=heap.hprof [pid]jmap -heap [pid]

使用MAT分析

这是 Eclipse 的一个工具,非常好用,官方地址:https://www.eclipse.org/mat/
载入 hprof 文件后,主页就会展示内存占用的分布情况,并且猜测那一块会有 OOM 的可能。
常用的就两个功能,查看对象的数量和查看对象占用的内存,一般来说只看强引用就行。
这个软件的详细用法 Google 一下。

死循环与死锁

这里要介绍下 jstack 这个命令,它可以看到线程信息,当发现我们的 CPU 飙高,就有可能发生了死循环或者死锁的情况。
使用 jstack 输出指定 pid 的情况,然后重定向到一个文件里,拿下来分析就好了。

PS:使用 top -p [pid] -H 命令可以查看某个进程里面的线程情况,使用 printf "%x" xxx 可以将十进制的 pid 转换为 16 进制。

jvisualvm可视化

jvisualvm 是 JDK 自带的一个工具,使用它可以可视化的监控 Java 程序的运行情况,当然,远程的也是可以进行监控的,不过需要设置了 JMX 才行。
然后,它可以安装第三方插件,推荐的两个插件是:VisualGC 和 BtraceWorkbench。

插件地址:http://visualvm.github.io/pluginscenters.html
需要先把对应版本的源添加进配置里才可以下载安装。
并且官网是有中文版的教程的,可以看一看。

Btrace调试

它的作用是在我们的应用程序不重启不修改,正在运行的情况下动态修改字节码,达到监控调试的目的。
使用步骤按照官方文档,设置个 BTRACE_HOME 的环境变量就可以了,运行方式有两种,一种直接命令行:btrace <PID> <trace_script> ,另一种可以使用 jvisualvm 插件来配合实现。
脚本代码非常简单,跟我们的 Java 代码也很类似(拦截器),所以不要怕。

至于下载地址,在 Github 上搜就可以了,项目主页:https://github.com/btraceio/btrace

脚本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@BTrace
public class PrintArgSimple {
// 在那个类,那个方法,什么时候进行拦截
@OnMethod(
clazz="com.imooc.monitor_tuning.chapter4.Ch4Controller",
method="arg1",
location=@Location(Kind.ENTRY)
)
public static void anyRead(@ProbeClassName String pcn, @ProbeMethodName String pmn, AnyType[] args) {
BTraceUtils.printArray(args);
BTraceUtils.println(pcn+","+pmn);
BTraceUtils.println();
}
}

编写脚本所依赖的几个 jar 包在你下载的安装包里都有,使用也非常简单,直接跟类名就行,他没有包的概念,并且跟程序是独立的。

使用 -cp 来指定额外的 classpath 依赖第三方的类库。

拦截方法

包括构造方法也是可以的,使用的是字节码风格 <init>
默认是在入口的时候进行拦截,其他的拦截时机:

  • ENTRY:入口(默认)
  • RETURN:返回
  • THROW:异常
  • Line:行

如果你的代码有异常,但是被 try 给吃掉了,那么如何确定代码是否抛异常了呢,可以使用官方提供的一个代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@BTrace 
public class PrintOnThrow {
// store current exception in a thread local
// variable (@TLS annotation). Note that we can't
// store it in a global variable!
@TLS
static Throwable currentException;

// introduce probe into every constructor of java.lang.Throwable
// class and store "this" in the thread local variable.
@OnMethod(
clazz="java.lang.Throwable",
method="<init>"
)
public static void onthrow(@Self Throwable self) {//new Throwable()
currentException = self;
}

@OnMethod(
clazz="java.lang.Throwable",
method="<init>"
)
public static void onthrow1(@Self Throwable self, String s) {//new Throwable(String msg)
currentException = self;
}

@OnMethod(
clazz="java.lang.Throwable",
method="<init>"
)
public static void onthrow1(@Self Throwable self, String s, Throwable cause) {//new Throwable(String msg, Throwable cause)
currentException = self;
}

@OnMethod(
clazz="java.lang.Throwable",
method="<init>"
)
public static void onthrow2(@Self Throwable self, Throwable cause) {//new Throwable(Throwable cause)
currentException = self;
}

// when any constructor of java.lang.Throwable returns
// print the currentException's stack trace.
@OnMethod(
clazz="java.lang.Throwable",
method="<init>",
location=@Location(Kind.RETURN)
)
public static void onthrowreturn() {
if (currentException != null) {
BTraceUtils.Threads.jstack(currentException);
BTraceUtils.println("=====================");
currentException = null;
}
}
}

同样,你可以判断方法的某行代码是否执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@BTrace
public class PrintLine {

@OnMethod(
clazz="com.imooc.monitor_tuning.chapter4.Ch4Controller",
// 支持使用正则表达式: /.*/
method="exception",
location=@Location(value=Kind.LINE, line=20)
)
public static void anyRead(@ProbeClassName String pcn, @ProbeMethodName String pmn, int line) {
BTraceUtils.println(pcn+","+pmn + "," +line);
BTraceUtils.println();
}
}

在处理方法参数类型的时候,你可以使用 AnyType 来接收,也可以使用确定的类型。

生产环境下可以使用,但是要注意,被修改的字节码是不会被还原的,除非重启 JVM。

tomcat调试

使用 jpda 可以进行远程调试,相关的开启方法自行 Google,在配置文件中设置好端口后,在本地的 IDE 里直接填上就可以了。

tomcat 自带的管理界面比较简陋,更好的方案是使用 psi-probe 来监控,可以在 Github 上找到。

优化方面,主要着重的配置是:

  • maxConnections
    猫能够接受和处理的最大连接数,在 8+ 版本,使用了 NIO 技术,多路复用提高了性能。
    使用 NIO 的情况下,默认是 10000
  • acceptCount
    当连接数超出了最大值,进入一个等待队列,这个属性控制队列的大小,默认 100
  • maxThreads
    配置工作线程的个数,默认是 200,同时并发处理的个数。
  • minSpareThreads
    最小空闲的工作线程数,不要太小。
  • enableLookups
    使用 request.getRemoteHost() 时进行 DNS 查询,建议禁用,8.5 默认禁用。
  • autoDeploy
    猫运行时,要不要周期性的检查是不是有新应用需要部署,需要开一个线程来周期性检测,生产环境要关闭,默认开启。
  • reloadable
    来监控 /WEB-INF/classes//WEB-INF/lib 的变化,同理建议禁用,8.5 默认禁用。
    开发环境,用来支持热加载还是不错的。
  • protocol
    在 server.xml 文件中配置,8+ 版本默认的使用 NIO,如果是高并发可以尝试使用 APR ,它使用的是 native 方法,性能会有一定提升。
  • 分布式情况下,如果使用了 SpringSession 类似的解决方案,建议禁用猫的 session,尤其是使用 JSP 的时候。

参考文档:docs/config/http.html、docs/config/host.html、docs/config/context.html

nginx优化

配置文件的解读就不说了,Google 很多,要注意的是,配置反向代理要关闭 selinux,setenforce 0。
使用 nginx -V 可以查看编译参数。

使用 nginx 提供的 ngx_http_stub_status 配置来监控连接信息,要使用它需要将这个模块加入编译参数才行。

1
2
3
4
5
6
location = /nginx_status {
stub_status on;
access_log off;
allow 127.0.0.1;
deny all;
}

还有一个好用的工具 ngxtop,使用 python 的 pip 包管理直接 install 就好。
在 Github 上可以找到它的官方文档(不过这个项目已经不活跃了)。

1
2
3
4
5
6
7
8
# 基本使用
ngxtop -c /etc/nginx/nginx.conf

# 查询状态是 200 的
ngxtop -c /etc/nginx/nginx.conf -i 'status == 200'

# 查询访问最多的 ip
ngxtop -c /etc/nginx/nginx.conf -g remote_addr

再来介绍另一款图形化的监控工具:Nginx-rrd,这个是基于 PHP 来做的,所以需要 PHP 的相关依赖,然后需要在 php-fpm 中跟 nginx 统一用户,具体的配置还蛮多的,可以去 Google 一下。
它的原理是使用定时任务来扫描,每次都会存储成一张张的图片,最后使用 web 端来进行展示。

PS:这些监控工具都是基于 ngx_http_stub_status 来做的,所以上面那个 location 配置不能少。


一些常见的基本的 Nginx 优化:

  • 增加工作线程数和并发连接数
    默认情况下,Nginx 只有一个工作线程,并发数为 1024。
  • 启用长连接
    默认对客户端使用的是长连接,也可以对反向代理的后端使用长连接。
  • 启用缓存、压缩
  • 操作系统参数优化

下面见一个示例的配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 配置工作线程,受限于 CPU 的数量,一般配置与 CPU 的数量相等。
worker_processes 1;

events {
# 每一个线程打开的最大连接数,包含 nginx 与客户端和 nginx 与 upstream 之间的连接
# 受限于操作系统,需要修改操作系统参数,默认 1024
worker_connections 10240;
# 可以一次建立多个连接
multi_accept on;
use epoll;
}

# 配置与后端的长连接示例
server {
listen 80;
server_name loli.com;

# 配置反向代理的集群
upstream server_pool{
server localhost:8080 weight=1 max_fails=2 fail_timeout=30s;
server localhost:8081 weight=1 max_fails=2 fail_timeout=30s;
# 300个长连接
keepalive 300;
}

# 配置反向代理
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://server_poll/;
}
}

然后可以使用 nginx -t 来测试下配置文件是否正确。
开启缓存和压缩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 开启gzip
gzip on;
# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;
# gzip 压缩级别,1-10,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
gzip_comp_level 2;
# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 禁用IE 6 gzip
gzip_disable "MSIE [1-6]\.";

# 开启缓存
location ~* ^.+\.(ico|gif|jpg|jpeg|png)$ {
access_log off;
expires 30d;
}

location ~* ^.+\.(css|js|txt|xml|swf|wav)$ {
access_log off;
expires 24h;
}

location ~* ^.+\.(html|htm)$ {
expires 1h;
}

location ~* ^.+\.(eot|ttf|otf|woff|svg)$ {
access_log off;
expires max;
}

# 格式
# expires 30s;
# expires 30m;
# expires 2h;
# expires 30d;

操作系统参数优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# /etc/sysctl.conf
# ipv4 相关优化
# 防止一个套接字在有过多试图连接到达时引起过载,默认为 0 表示关闭
sysctl -w net.ipv4.tcp_syncookies = 1
# os 的一个等待队列,默认 128,连接队列
sysctl -w net.core.somaxconn = 1024
# timewait 超时时间,在 tcp 的四次挥手时的状态,此状态下虽然已经断开,但是不能拿来用,没必要太长
sysctl -w net.ipv4.tcp_fin_timeout = 10
# os 直接使用 timewait 的连接,默认 0 表示关闭
sysctl -w net.ipv4.tcp_tw_reuse = 1
# timewait 回收禁用,默认 0,为了不必要的麻烦
sysctl -w net.ipv4.tcp_tw_recycle = 0

# 每个进程打开文件数量的限制
# /etc/security/limits.conf
* hard nofile 204800
* soft nofile 204800
* soft core unlimited
* soft stack 204800

# nginx 的 TCP 优化
http {
# 减少文件在应用和内核之间拷贝
sendfile on;
# 当数据包达到一定大小再发送
tcp_nopush on;
# 有数据随时发送
tcp_nodelay off;
... ...
}

更多详细配置参考:
https://wsgzao.github.io/post/sysctl/
https://imququ.com/post/my-nginx-conf-for-wpo.html
http://sfau.lt/b5DA5u

垃圾回收

如何选择垃圾收集器呢?

  • 优先调整堆大小,让服务器自己来选择
  • 如果内存小于 100M,使用串行收集器
  • 如果是单核,并且没有停顿时间的要求,串行或者 JVM 自己选
  • 如果允许停顿时间超过 1 秒,选择并行或者 JVM 自己选
  • 如果响应时间很重要,并且不能超过 1 秒,使用并发收集器

几种垃圾收集器在之前的笔记里都有介绍,这里只作补充。
对于并行的 GC,是有自适应的特性的,就是说你给定几个指标(吞吐量、停顿时间等)它会自动调整堆大小,但这不是最优的方案,因为动态调整也是消耗性能的。
对应 Web 应用,我们还是比较关注停顿时间的,所以一般都是用并发的 GC,例如 CMS,这类收集器是对 CPU 敏感的,虽然跟用户线程并发执行,但是用户线程的 cpu 资源就少了,并且会产生浮动垃圾和空间碎片,在 G1 出现之前使用还是非常广泛的,在 J8 中官方推荐使用 G1。

G1 在 JDK7 开始提供,到 J8 已经比较成熟了,适用于大内存、低停顿的场景,在 J9 里 G1 已经成为默认的收集器,并且将 CMS 设置为废弃。
在 G1 中,老年代新生代是逻辑上的称呼了,它将堆分为一个个的 Region,还有一些成为 H 区用来存放大对象(超过了 Region 的一半)。
G1 中的 YoungGC 和传统的并没有什么区别,但是它没有了 FullGC,多了个 MixedGC,它回收所有 Young 和部分 Old,它也有并发标记的过程,默认堆的占有率达到 45% 就会触发。在每次 YGC 之后和 MGC 之前,会检查垃圾占比是否达到了某一个阀值,只有达到了才会发生 MGC。
G1 的相关概念和参数还多得多,这里不再一一举例。

G1 最佳实践:

  • 年轻代大小避免使用 -Xmn、 -XX:NewRatio 等显式设置,会覆盖暂停时间目标值
  • 暂停时间目标时间不要太严苛,其吞吐量目标是 90% 的应用程序时间和 10% 的垃圾回收时间,太严苛会直接影响吞吐量。

当发生了下面的几种情况,可以考虑切换到 G1 了:

  • 50% 以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间特别长,超过了 1 秒

调优的过程就是在吞吐量响应时间之间找平衡的过程,并且……在 J12 又带来了新的收集器 ZGC….。

参考资料:
J8 JVM 规范
J8文档
J8 GC选择

日志分析

既然是分析日志,那么首先得拿到日志,使用这些参数来开启,然后就有日志文件了,直接读也是可以的,不过可视化更方便,可以使用这个在线分析,或者使用 GCViewer(在 Github)

1
2
3
4
5
6
7
8
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-Xloggc:$CATALINA HOME/logs/gc.log

## 下面的不用也可以
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution

GC调优

先来看并行的 ParallelGC,指导原则:

  • 除非确定,否则不要设置最大堆内存
  • 优先设置吞吐量目标
  • 如果吞吐量目标达不到,调大最大内存,不能让 OS 使用 Swap,如果仍然达不到,降低目标
  • 吞吐量能达到,GC 时间太长,设置停顿时间的目标

这些在 Oracle 官方文档里都有写的。
关于 G1 的调优,参考上面的 G1 最佳实践规则就可以了。

调优过程要一个参数一个参数的设置,避免不知道是改的那个参数起的作用,然后这是个循序渐进的过程,不太可能一步到位的。
这一块的内容,还是去慕课网看视频比较好,文字不太好描述。

其他

JVM 是基于栈的架构,相比经典的基于寄存器的架构来说,它的指令更短,但是指令数量会更多。
例如一个经典的问题,i++ 和 ++i 那个效率高,在 fori 循环里是很常用的,可以分析字节码,会发现这两种形式翻译的字节码是一样的,所以他们的效率其实是一样的。
然后再看下面的一段代码:

1
2
3
4
5
6
7
8
public String test(){
String s = "hello";
try{
return s;
}finally{
s = "ll"
}
}

那么到底返回的是那个呢,答案是 hello,通过字节码可以看出,因为字符串是不可变对象,会将 hello 和 ll 同时压入两个本地变量,在走到 return 的时候把第一个也就是 hello 返回,然后又将第二个 ll 赋值给了第一个本地变量。


我们知道在使用 + 进行字符串拼接时,默认会转换为 StringBuilder,那么这是绝对的么?

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void f1(){
final String s1 = "hello";
final String s2 = s1 + "Loli";
String s3 = s1 + s2;
System.out.println(s3);
}

public static void f2(){
final String s1 = "hello";
String s2 = s1 + "Loli";
String s3 = s1 + s2;
System.out.println(s3);
}

可以去分析字节码,这里就直接说结论,当 String 类型为 final 时,是在编译阶段就直接确定了,不会再进行拼接。
关于 String,还有一个很有趣的地方:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args){
String s = new String("1");
s.intern();
String s2 = "1";
System.out.print1n(s == s2);

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.print1n(s3 == s4);
}

在 JDK7- 和 JDK7+ 结果是不一样的,之前是两个 false,之后变成了 false 、 true,原因就是在 J7 之后字符串常量池移到了堆中,当使用 intern 方法并且常量池没有,堆中有的情况下,会将堆中的这个引用放到常量池中(常量池已经在堆中了),这样 s3 和 s4 就相等了。
G1 还有个字符串去重的功能(需要手动开启),也就是我们程序中字符串常量是占了很大比重的,而堆中和常量池中都有的话是很浪费的,当堆中字符串生命周期很长(有个阀值)就会触发去重操作。


常见的代码优化:

  • 尽量重用对象,不要循环创建对象,比如:for 循环字符串拼接
  • 容器类初始化的时候指定长度(List、Map)
  • 集合遍历尽量减少重复计算(例如条件是不确定的 size)
  • 尽量使用 Entry 来遍历 Map
  • 尽量使用基本类型而不是包装类型
  • 及时消除过期对象的引用,防止内存泄露
  • 尽量使用局部变量,减小变量的作用域
  • ThreadLocal 缓存线程不安全的对象 SimpleDateFormat(J8 可以直接用新的 API DateTimeFormat 它是线程安全的)
  • 尽量使用延时加载,例如单例模式(内部静态类)
  • 尽量减少使用反射,用的话也尽量加缓存
  • 慎用异常,不要用抛异常来表示正常业务逻辑,因为抛异常是比较重的操作,但是也别为了这个而不用,有些地方该用就得用。
  • String 操作尽量少用正则

又一个有趣的例子:

1
2
3
4
5
6
7
8
9
10
11
public void test(){
Integer a = 100;
Integer b = 100;
// true
System.out.println(a == b);

Integer c = 1000;
Integer d = 1000;
// false
System.out.println(a == b);
}

这是因为 Integer 会自动缓存一个字节的数字,如果在缓存中直接返回,如果不在才 new,其他有的包装类型也有部分有缓存,比如 Long,不过 Double 这种肯定是没有的啦。

喜欢就请我吃包辣条吧!

评论框加载失败,无法访问 Disqus

你可能需要魔法上网~~