JVM实战篇
实战篇
目标

1、内存调优
1.1 内存溢出和内存泄漏
1.1.1概念
内存泄漏(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况就称之为内存泄漏。
内存泄漏绝大多数情况都是由堆内存泄漏引起的,所以后续没有特别说明则讨论的都是堆内存泄漏。
如果学生对象1不再使用

可以选择将ArrayList到学生对象1的引用删除:

或者将对象A堆ArrayList的引用删除,这样所有的学生对象包括ArrayList都可以回收:

但是如果不移除这两个引用中的任何一个,学生对象1就属于内存泄漏了。
少量的内存泄漏可以容忍,但是如果发生持续的内存泄漏,就像滚雪球雪球越滚越大,不管有多大的内存迟早会被消耗完,最终导致的结果就是内存溢出。但是产生内存溢出并不是只有内存泄漏这一种原因。
正常情况的内存结构图如下:

内存溢出出现时如下:

内存泄漏的对象和依然在GC ROOT引用链上需要使用的对象加起来占满了内存空间,无法为新的对象分配内存。
1.1.2内存泄漏的常见场景:
1、内存泄漏导致溢出的常见场景是大型的Java后端应用中,**在处理用户的请求之后,没有及时将用户的数据删除。**随着用户请求数量越来越多,内存泄漏的对象占满了堆内存最终导致内存溢出。
这种产生的内存溢出会直接导致用户请求无法处理,影响用户的正常使用。重启可以恢复应用使用,但是在运行一段时间之后依然会出现内存溢出。
1 | packagecom.itheima.jvmoptimize.controller; |
- 设置虚拟机参数,将最大堆内存设置为1g:
- 在Postman中测试,登录id为1的用户
- 调用logout接口,id为1那么数据会正常删除
- 连续调用login传递不同的id,但是不调用logout,几次后:

2、第二种常见场景是分布式任务调度系统如Elastic-job、Quartz等进行任务调度时,被调度的Java应用在调度任务结束中出现了内存泄漏,最终导致多次调度之后内存溢出。
这种产生的内存溢出会导致应用执行下次的调度任务执行。同样重启可以恢复应用使用,但是在调度执行一段时间之后依然会出现内存溢出。
开启定时任务:

1 | packagecom.itheima.jvmoptimize.task; |
启动程序之后很快就出现了内存溢出:

这两个例子的本质上,都是我们创建了对象,为了后续使用进行了保存,但是后续不使用了没有自己清理,导致滚雪球
1.2 解决内存溢出的方法
思路:

首先要熟悉一些常用的监控工具:
1.2.1 常用监控工具
Top命令
top命令是linux下用来查看系统信息的一个命令,它提供给我们去实时地去查看系统的资源,比如执行时的进程、线程和系统参数等信息。进程使用的内存为RES(常驻内存)- SHR(共享内存)

优点:
- 操作简单
- 无额外的软件安装
缺点:
只能查看最基础的进程信息,无法查看到每个部分的内存占用(堆、方法区、堆外)
VisualVM
VisualVM是多功能合一的Java故障排除工具并且他是一款可视化工具,整合了命令行 JDK 工具和轻量级分析功能,功能非常强大。JDK9后要手动下载
下载地址:https://visualvm.github.io/

优点:
- 功能丰富,实时监控CPU、内存、线程等详细信息
- 支持Idea插件,开发过程中也可以使用
缺点:
对大量集群化部署的Java进程需要手动进行管理
如果需要进行远程监控,可以通过jmx方式进行连接。在启动java程序时添加如下参数:
1 | -Djava.rmi.server.hostname=服务器ip地址 |


Arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。
优点:
- 功能强大,不止于监控基础的信息,还能监控单个方法的执行耗时等细节内容。
- 支持应用的集群管理
缺点:
部分高级功能使用门槛较高
使用阿里arthas tunnel管理所有的需要监控的程序
背景:
使用了微服务架构,生产环境上的应用数量非常多,使用arthas还得登录到每一台服务器上再去操作非常不方便。可以使用tunnel来管理所有需要监控的程序。

步骤:
在Spring Boot程序中添加arthas的依赖(支持Spring Boot2),在配置文件中添加tunnel服务端的地址,便于tunnel去监控所有的程序。
- 将tunnel服务端程序部署在某台服务器上并启动。
- 启动java程序
- 打开tunnel的服务端页面,查看所有的进程列表,并选择进程进行arthas的操作。
pom.xml添加依赖:
1 | <dependency> |
application.yml中添加配置:
1 | arthas: |
在资料中找到arthas-tunnel-server.3.7.1-fatjar.jar上传到服务器,并使用
nohup java -jar -Darthas.enable-detail-pages=true arthas-tunnel-server.3.7.1-fatjar.jar & 命令启动该程序。-Darthas.enable-detail-pages=true参数作用是可以有一个页面展示内容。通过服务器ip地址:8080/apps.html打开页面,目前没有注册上来任何应用。
启动spring boot应用,如果在一台服务器上,注意区分端口。
1 | -Dserver.port=tomcat端口号 |

最终就能看到两个应用:

单击应用就可以进入操作arthas了。
Prometheus+Grafana
Prometheus+Grafana是企业中运维常用的监控方案,其中Prometheus用来采集系统或者应用的相关数据,同时具备告警功能。Grafana可以将Prometheus采集到的数据以可视化的方式进行展示。
Java程序员要学会如何读懂Grafana展示的Java虚拟机相关的参数。

优点:
- 支持系统级别和应用级别的监控,比如linux操作系统、Redis、MySQL、Java进程。
- 支持告警并允许自定义告警指标,通过邮件、短信等方式尽早通知相关人员进行处理
缺点:
环境搭建较为复杂,一般由运维人员完成
1.2.2 堆内存状况的对比
- 正常情况
- 处理业务时会出现上下起伏,业务对象频繁创建内存会升高,触发MinorGC之后内存会降下来。
- 手动执行FULL GC之后,内存大小会骤降,而且每次降完之后的大小是接近的。
- 长时间观察内存曲线应该是在一个范围内。

- 出现内存泄漏
- 处于持续增长的情况,即使Minor GC也不能把大部分对象回收
- 手动FULL GC之后的内存量每一次都在增长
- 长时间观察内存曲线持续增长

1.2.3 产生内存溢出原因一 :代码中的内存泄漏
总结了6种产生内存泄漏的原因,均来自于java代码的不当处理:
- equals()和hashCode(),不正确的equals()和hashCode()实现导致内存泄漏
- ThreadLocal的使用,由于线程池中的线程不被回收导致的ThreadLocal内存泄漏
- 内部类引用外部类,非静态的内部类和匿名内部类的错误使用导致内存泄漏
- String的intern方法,由于JDK6中的字符串常量池位于永久代,intern被大量调用并保存产生的内存泄漏
- 通过静态字段保存对象,大量的数据在静态变量中被引用,但是不再使用,成为了内存泄漏
- 资源没有正常关闭,由于资源没有调用close方法正常关闭,导致的内存溢出
案例1:equals()和hashCode()导致的内存泄漏
问题:
在定义新类时没有重写正确的equals()和hashCode()方法。在使用HashMap的场景下,如果使用这个类对象作为key,HashMap在判断key是否已经存在时会使用这些方法,如果重写方式不正确,会导致相同的数据被保存多份。
正常情况:
1、以JDK8为例,首先调用hash方法计算key的哈希值,hash方法中会使用到key的hashcode方法。根据hash方法的结果决定存放的数组中位置。
2、如果没有元素,直接放入。如果有元素,先判断key是否相等,会用到equals方法,如果key相等,直接替换value;key不相等,走链表或者红黑树查找逻辑,其中也会使用equals比对是否相同。

异常情况:
1、hashCode方法实现不正确,会导致相同id的学生对象计算出来的hash值不同,可能会被分到不同的槽中。

2、equals方法实现不正确,会导致key在比对时,即便学生对象的id是相同的,也被认为是不同的key。

3、长时间运行之后HashMap中会保存大量相同id的学生数据。

1 | package com.itheima.jvmoptimize.leakdemo.demo2; |
运行之后通过visualvm观察:

出现内存泄漏的现象。
解决方案:
1、在定义新实体时,始终重写equals()和hashCode()方法。
2、重写时一定要确定使用了唯一标识去区分不同的对象,比如用户的id等。
3、hashmap使用时尽量使用编号id等数据作为key,不要将整个实体类对象作为key存放。
代码:
1 | package com.itheima.jvmoptimize.leakdemo.demo2; |
案例2:内部类引用外部类
问题:
1、非静态的内部类默认会持有外部类,尽管代码上不再使用外部类,所以如果有地方引用了这个非静态内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类。

会有一个this$0的属性指向外部类
2、匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者。


非静态内部类如果长期存活,会间接让外部类无法被回收,造成内存泄漏。


如何避免(解决方案):
不随意在非静态方法中用匿名内部类;
内部类如果不需要访问外部类成员,一律用 static;
案例3:ThreadLocal的使用(ThreadLocal 与线程池结合使用时可能导致内存泄漏)出现频率高

问题:
如果仅仅使用手动创建的线程,就算没有调用ThreadLocal的remove方法清理数据,也不会产生内存泄漏。因为当线程被回收时,ThreadLocal也同样被回收。但是如果使用线程池就不一定了。

1 | package com.itheima.jvmoptimize.leakdemo.demo5; |

解决方案:
线程方法执行完,一定要调用ThreadLocal中的remove方法清理对象。
1 | package com.itheima.jvmoptimize.leakdemo.demo5; |

同时满足这三个条件,才可能导致 ThreadLocal 引起内存泄漏:
| 条件 | 描述 |
|---|---|
| ① ThreadLocal 生命周期短于线程 | ThreadLocal 被 GC 回收后,其 key 变成 null,但 value 还在 |
| ② 线程未被销毁 | 如线程池中的线程 |
③ 没有手动调用 remove() |
残留的 value 无法释放,形成“孤儿对象” |
总结:在使用线程池时,如果你使用了 ThreadLocal 却没有手动调用 .remove() 清除数据,就可能造成线程复用时的内存泄漏,尤其是 value 是大对象时更容易 OOM。
内存泄漏与线程池的最佳实践
| 推荐做法 | 原因说明 |
|---|---|
手动调用 ThreadLocal.remove() |
防止 value 留在线程中,造成堆积 |
尽量使用 static final ThreadLocal |
避免生命周期问题 |
| 在线程执行完后清理上下文 | 防止串线程数据、内存泄漏 |
尽量使用 JDK 提供的 InheritableThreadLocal |
只在明确需要线程继承变量时使用 |
案例4:String的intern方法
问题:
JDK6中字符串常量池位于堆内存中的Perm Gen永久代中,如果不同字符串的intern方法被大量调用,字符串常量池会不停的变大超过永久代内存上限之后就会产生内存溢出问题。
1 | package com.itheima.jvmoptimize.leakdemo.demo6; |
解决方案:
1、注意代码中的逻辑,尽量不要将随机生成的字符串加入字符串常量池
2、增大永久代空间的大小,根据实际的测试/估算结果进行设置-XX:MaxPermSize=256M
总结:在 JDK6 中,intern() 会将字符串加入永久代中的常量池,如果对大量动态字符串调用 intern() 且保留引用,会引起永久代空间溢出,从而导致内存泄漏和 OOM,需谨慎使用。
案例5:通过静态字段保存对象(出现频率高)-静态变量导致的内存泄漏

问题:
如果大量的数据在静态变量中被长期引用,数据就不会被释放,如果这些数据不再使用,就成为了内存泄漏。

解决方案:
1、尽量减少将对象长时间的保存在静态变量中,如果不再使用,必须将对象删除(比如在集合中)或者将静态变量设置为null。

2、使用单例模式时,尽量使用懒加载,而不是立即加载。
1 | package com.itheima.jvmoptimize.leakdemo.demo7; |
- Spring 默认会 启动时加载所有单例 Bean;
- 如果这个类不是懒加载的,就会在程序启动时分配 1GB 内存;
- 若 Bean 中长期保存大对象但并未使用,就是典型的内存浪费。
@Lazy保证这个类在用到时才实例化;避免了启动时加载大量无用 Bean,占用大量堆内存;控制资源创建时机,降低长期内存占用。
3、Spring的Bean中不要长期存放大对象,如果是缓存用于提升性能,尽量设置过期时间定期失效。
1 | package com.itheima.jvmoptimize.leakdemo.demo7; |

案例6:资源没有正常关闭(资源泄漏问题)
问题:
连接和流这些资源会占用内存,如果使用完之后没有关闭,这部分内存不一定会出现内存泄漏,但是会导致close方法不被执行。

1 | package com.itheima.jvmoptimize.leakdemo.demo1; |
这段代码不会产生内存泄漏

| 问题点 | 是否 GC 自动处理? | 需要程序员关闭? | 后果 |
|---|---|---|---|
Connection / Statement |
❌ 否 | ✅ 是 | 数据库连接泄漏,挂死系统 |
InputStream / FileInputStream |
❌ 否 | ✅ 是 | 文件占用,无法删除或写入 |
Socket / HttpURLConnection |
❌ 否 | ✅ 是 | 端口占用,线程卡死 |
解决方案:
1、为了防止出现这类的资源对象泄漏问题,必须在finally块中关闭不再使用的资源。
2、从 Java 7 开始,使用try-with-resources语法可以用于自动关闭资源。

1.2.4产生内存溢出原因二 : 并发请求问题(出现频率高)
并发请求问题指的是**由于用户的并发请求量有可能很大,同时处理数据的时间很长,导致大量的数据存在于内存中,最终超过了内存的上限,导致内存溢出。**这类问题的处理思路和内存泄漏类似,首先要定位到对象产生的根源。
如何模拟并发请求
使用Apache Jmeter软件可以进行并发请求测试。
使用Jmeter进行并发测试,发现内存溢出问题
1.打开资料中的Jmeter,找到bin目录,双击jmeter.bat启动程序。
2.在线程组中增加Http请求,添加随机参数。

添加线程组参数:

添加Http请求

添加http参数:

接口代码:
1 | 接口代码: |
- 在线程组中添加监听器 – 聚合报告,用来展示最终结果。

- 启动程序,运行线程组并观察程序是否出现内存溢出。
添加虚拟机参数:

点击运行
很快就出现了内存溢出
再来看一个案例测试内存溢出:
1、设置线程池参数:

2、设置http接口参数

3、代码:
1 | /** |
在静态变量中保存了大量不用的对象,导致内存泄露最后内存溢出(案例5)
4、我们想生成随机的名字和id,选择函数助手对话框

5、选择Random随机数生成器

6、让随机数生成器生效,值中直接ctrl + v就行,已经被复制到粘贴板了。

7、字符串也是同理的设置方法:

8、添加name字段:

9、点击测试,一段时间之后同样出现了内存溢出:

1.2.5 诊断

内存快照(检查报告)
当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照(Heap Profile )文件。
使用MAT打开hprof文件,并选择内存泄漏检测功能,MAT会自行根据内存快照中保存的数据分析内存泄漏的根源。
生成内存快照的Java虚拟机参数:
-XX:+HeapDumpOnOutOfMemoryError:发生OutOfMemoryError错误时,自动生成hprof内存快照文件。
-XX:HeapDumpPath=<path>:指定hprof文件的输出路径。
使用MAT打开hprof文件,并选择内存泄漏检测功能,MAT会自行根据内存快照中保存的数据分析内存泄漏的根源。
在程序中添加jvm参数:
1 | -Xmx256m -Xms256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\jvm\dump\test1.hprof |
运行程序之后:

使用MAT打开hprof文件(操作步骤见前文GC Root小节),首页就展示了MAT检测出来的内存泄漏问题原因。

点击Details查看详情,这个线程持有了大量的字节数组:

继续往下来,还可以看到溢出时线程栈,通过栈信息也可以怀疑下是否是因为这句代码创建了大量的对象:

MAT内存泄漏检测的原理
MAT提供了称为支配树(Dominator Tree)的对象图。支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。
如下图,A引用B、C,B、C引用D, C引用E,D、E引用F,转成支配树之后。由于E只有C引用,所以E挂在C上。接下来B、C、D、F都由其他至少1个对象引用,所以追溯上去,只有A满足支配它们的条件。

- 支配树中对象本身占用的空间称之为浅堆(Shallow Heap)。
- 支配树中对象的子树就是所有被该对象支配的内容,这些内容组成了对象的深堆(Retained Heap),也称之为保留集( Retained Set ) 。深堆的大小表示该对象如果可以被回收,能释放多大的内存空间。
如下图:C自身包含一个浅堆,而C底下挂了E,所以C+E占用的空间大小代表C的深堆。A的深蹲是A,B,C,D,E,F所有这些内存空间加起来的大小

需求:
使用如下代码生成内存快照,并分析TestClass对象的深堆和浅堆。
如何在不内存溢出情况下生成堆内存快照?-XX:+HeapDumpBeforeFullGC可以在FullGC之前就生成内存快照。
1 | package com.itheima.jvmoptimize.matdemo; |
上面代码的引用链如下:

转换成支配树,TestClass简称为tc。tc1 tc2 tc3都是直接挂在main线程对象上,itheima2 itheima3都只能通过tc2和tc3访问,所以直接挂上。itheima1不同,他可以由tc1 tc2访问,所以他要挂载他们的上级也就是main线程对象上:

使用mat来分析,添加虚拟机参数:

在FullGC之后产生了内存快照文件

直接查看MAT的支配树功能:

输入main进行搜索

可以看到结构与之前分析的是一致的:

同时可以看到字符串的浅堆大小和深堆大小:

为什么字符串对象的浅堆大小是24字节,深堆大小是56字节呢?首先字符串对象引用了字符数组,字符数组的字节大小底下有展示是32字节,那我们只需要搞清楚浅堆大小也就是他自身为什么是24字节就可以了。使用jol框架打印下对象大小(原理篇会详细展开讲解,这里先有个基本的认知)。
添加依赖:
1 | <dependency> |
使用代码打印:
1 | public class StringSize { |

对象头占用了12字节,value字符数组的引用占用了4字节,int类型的hash字段占用4字节,还有4字节是对象填充,所以加起来是24字节。至于对象填充、对象头是做什么用的,在《原理篇》中会详细讲解。
MAT内存泄漏检测的原理总结:
MAT就是根据支配树,从叶子节点向根节点遍历,如果发现深堆的大小超过整个堆内存的一定比例阈值,就会将其标记成内存泄漏的“嫌疑对象”。

服务器上的内存快照导出和分析
刚才我们都是在本地导出内存快照的,并且是程序已经出现了内存溢出,接下来我们要做到防范于未然,一旦看到内存大量增长就去分析内存快照,那此时内存还没溢出,怎么样去获得内存快照文件呢?
背景:
小李的团队通过监控系统发现有一个服务内存在持续增长,希望尽快通过内存快照分析增长的原因,由于并未产生内存溢出所以不能通过HeapDumpOnOutOfMemoryError参数生成内存快照。
思路:
导出运行中系统的内存快照,比较简单的方式有两种,注意只需要导出标记为存活的对象:
通过JDK自带的jmap命令导出,格式为:
jmap -dump:live,format=b,file=文件路径和文件名 进程ID
通过arthas的heapdump命令导出,格式为:
heapdump --live 文件路径和文件名
先使用jps或者ps -ef查看进程ID:

通过jmap命令导出内存快照文件,live代表只保存存活对象,format=b用二进制方式保存:

也可以在arthas中输出heapdump命令:

接下来下载到本地分析即可。
大文件的处理
在程序员开发用的机器内存范围之内的快照文件,直接使用MAT打开分析即可。但是经常会遇到服务器上的程序占用的内存达到10G以上,开发机无法正常打开此类内存快照,此时需要下载服务器操作系统对应的MAT。下载地址:https://eclipse.dev/mat/downloads.php
通过MAT中的脚本生成分析报告:
./ParseHeapDump.sh 快照文件路径 org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
注意:默认MAT分析时只使用了1G的堆内存,如果快照文件超过1G,需要修改MAT目录下的MemoryAnalyzer.ini配置文件调整最大堆内存。


最终会生成报告文件:

将这些文件下载到本地,解压之后打开index.html文件:

同样可以看到类似的报告
1.2.6修复

案例1 - 分页查询文章接口的内存溢出:
背景:
小李负责的新闻资讯类项目采用了微服务架构,其中有一个文章微服务,这个微服务在业务高峰期出现了内存溢出的现象。

解决思路:
- 1、服务出现OOM内存溢出时,生成内存快照。
- 2、使用MAT分析内存快照,找到内存溢出的对象。
- 3、尝试在开发环境中重现问题,分析代码中问题产生的原因。
- 4、修改代码。
- 5、测试并验证结果。
代码是com.itheima.jvmoptimize.practice.oom.controller.DemoQueryController

首先将项目打包,放到服务器上,同时使用如下启动命令启动。设置了最大堆内存为512m,同时堆内存溢出时会生成hprof文件:

Jmeter设置size数据量一次性获取10000条,线程150,每个线程执行10次方法调用:


执行之后可以发现服务器上已经生成了hprof文件,将其下载到本地,通过MAT分析发现是Mysql返回的ResultSet存在大量的数据**:**

通过支配树,可以发现里边包含的数据,如果数据有一些特殊的标识,其实就可以判断出来是哪个接口产生的数据:

如果想知道每个线程在执行哪个方法,先找到spring的HandlerMethod对象:

接着去找引用关系:

通过描述信息就可以看到接口:

通过直方图的查找功能,也可以找到项目里哪些对象比较多:

问题根源:
文章微服务中的分页接口没有限制最大单次访问条数,并且单个文章对象占用的内存量较大,在业务高峰期并发量较大时这部分从数据库获取到内存之后会占用大量的内存空间。
解决思路:
1、与产品设计人员沟通,限制最大的单次访问条数。
以下代码,限制了每次访问的最大条数为100条

2、分页接口如果只是为了展示文章列表,不需要获取文章内容,可以大大减少对象的大小。
把文章内容去掉,减少对象大小:

3、在高峰期对微服务进行限流保护。
案列2- Mybatis导致的内存溢出:
背景:
小李负责的文章微服务进行了升级,新增加了一个判断id是否存在的接口,第二天业务高峰期再次出现了内存溢出,小李觉得应该和新增加的接口有关系。

解决思路:
- 1、服务出现OOM内存溢出时,生成内存快照。
- 2、使用MAT分析内存快照,找到内存溢出的对象。
- 3、尝试在开发环境中重现问题,分析代码中问题产生的原因。
- 4、修改代码。
- 5、测试并验证结果。
通过分析hprof发现调用的方法,但是这个仅供参考:

分析支配树,找到大对象来源,是一些字符串,里边还包含SQL


通过SQL内容搜索下可以找到对应的方法:

发现里边用了foreach,如果循环内容很大,会产生特别大的一个SQL语句。
直接打开jmeter,打开测试脚本进行测试:

本地测试之后,出现了内存溢出:

问题根源:
Mybatis在使用foreach进行sql拼接时,会在内存中创建对象,如果foreach处理的数组或者集合元素个数过多,会占用大量的内存空间。
解决思路:
1、限制参数中最大的id个数。
2、将id缓存到redis或者内存缓存中,通过缓存进行校验。
案例3 - 导出大文件内存溢出
小李团队使用的是k8s将管理系统部署到了容器中,所以这一次我们使用阿里云的k8s环境还原场景,并解决问题。阿里云的k8s整体规划如下:

K8S环境搭建(了解)
1、创建镜像仓库

2、项目中添加Dockerfile文件
1 | FROM openjdk:8-jre |
3、完全按照阿里云的教程执行命令:

4、推送成功之后,镜像仓库中已经出现了镜像:

5、通过镜像构建k8s中的pod:

6、选择刚才的镜像:

7、在OSS中创建一个Bucket:

8、创建存储声明,选择刚才的Bucket:

9、选择这个存储声明,并添加hprof文件生成的路径映射,要和Dockerfile中虚拟机参数里的路径相同:

10、创建一个service,填写配置,方便外网进行访问。


11、打开jmeter文件并测试:

12、OSS中出现了这个hprof文件:

13、从直方图就可以看到是导出文件导致的内存溢出:

问题根源:
Excel文件导出如果使用POI的XSSFWorkbook,在大数据量(几十万)的情况下会占用大量的内存。
代码:com.itheima.jvmoptimize.practice.oom.controller.Demo2ExcelController
解决思路:
1、使用poi的SXSSFWorkbook。
2、hutool提供的BigExcelWriter减少内存开销。
1 | //http://www.hutool.cn/docs/#/poi/Excel%E5%A4%A7%E6%95%B0%E6%8D%AE%E7%94%9F%E6%88%90-BigExcelWriter |
3、使用easy excel,对内存进行了大量的优化。
1 | //https://easyexcel.opensource.alibaba.com/docs/current/quickstart/write#%E9%87%8D%E5%A4%8D%E5%A4%9A%E6%AC%A1%E5%86%99%E5%85%A5%E5%86%99%E5%88%B0%E5%8D%95%E4%B8%AA%E6%88%96%E8%80%85%E5%A4%9A%E4%B8%AAsheet |
案例4 – ThreadLocal使用时占用大量内存
背景:
小李负责了一个微服务,但是他发现系统在没有任何用户使用时,也占用了大量的内存。导致可以使用的内存大大减少。

1、打开jmeter测试脚本

2、内存有增长,但是没溢出。所以通过jmap命令导出hprof文件

3、MAT分析之后发现每个线程中都包含了大量的对象
4、在支配树中可以发现是ThreadLocalMap导致的内存增长:

5、ThreadLocalMap就是ThreadLocal对象保存数据的地方,所以只要分析ThreadLocal代码即可。在拦截器中,ThreadLocal清理的代码被错误的放在postHandle中,如果接口发生了异常,这段代码不会调用到,这样就产生了内存泄漏,将其移动到afterCompletion就可以了。

问题根源和解决思路:
很多微服务会选择在拦截器preHandle方法中去解析请求头中的数据,并放入一些数据到ThreadLocal中方便后续使用。在拦截器的afterCompletion方法中,必须要将ThreadLocal中的数据清理掉。
案例5 – 文章内容审核接口的内存问题
背景:
文章微服务中提供了文章审核接口,会调用阿里云的内容安全接口进行文章中文字和图片的审核,在自测过程中出现内存占用较大的问题。

设计1:使用SpringBoot中的@Async注解进行异步的审核。
com.itheima.jvmoptimize.practice.oom.controller.Demo1ArticleController类中的article1方法

1、打开jmeter脚本,已经准好了一段测试用的文本。

2、运行测试,发现线程数一直在增加:

3、发现是因为异步线程池的最大线程数设置了Integer的最大值,所以只要没到上限就一直创建线程:

4、接下来修改为100,再次测试:

5、这次线程数相对来说比较正常:

存在问题:
1、线程池参数设置不当,会导致大量线程的创建或者队列中保存大量的数据。
2、任务没有持久化,一旦走线程池的拒绝策略或者服务宕机、服务器掉电等情况很有可能会丢失任务。
设计2:使用生产者和消费者模式进行处理,队列数据可以实现持久化到数据库。
代码实现:article2方法

1、测试之后发现,出现内存泄漏问题(其实并不是泄漏,而是内存中存放了太多的对象,但是从图上看着像内存泄漏了):

2、每次接口调用之后,都会将数据放入队列中。

3、而这个队列没有设置上限:

4、调整一下上限设置为2000:

5、这次就没有出现内存泄漏问题了:

存在问题:
1、队列参数设置不正确,会保存大量的数据。
2、实现复杂,需要自行实现持久化的机制,否则数据会丢失。
设计3:使用mq消息队列进行处理,由mq来保存文章的数据。
发送消息的服务和拉取消息的服务可以是同一个,也可以不是同一个。
代码方法:article3

测试结果:
内存没有出现膨胀的情况

问题根源和解决思路:
在项目中如果要使用异步进行业务处理,或者实现生产者 – 消费者的模型,如果在Java代码中实现,会占用大量的内存去保存中间数据。
尽量使用Mq消息队列,可以很好地将中间数据单独进行保存,不会占用Java的内存。同时也可以将生产者和消费者拆分成不同的微服务。
在线定位问题

诊断问题有两种方法,之前我们介绍的是第一种:
生成内存快照并分析。
- 优点:
- 通过完整的内存快照准确地判断出问题产生的原因
- 缺点:
- 内存较大时,生成内存快照较慢,这个过程中会影响用户的使用
- 通过MAT分析内存快照,至少要准备1.5 – 2倍大小的内存空间
在线定位问题
- 优点:
- 无需生成内存快照,整个过程对用户的影响较小
- 缺点:
- 无法查看到详细的内存信息
- 需要通过arthas或者btrace工具调测发现问题产生的原因,需要具备一定的经验
为了监控响应时间RT、每秒事务数TPS等指标,需要在Jmeter上安装gc插件。
安装Jmeter插件
为了监控响应时间RT、每秒事务数TPS等指标,需要在Jmeter上安装gc插件。
按插件包中的目录,复制到jmeter安装目录的lib目录下。

重启之后就可以在监听器中看到三个选项,分别是活跃线程数、响应时间RT、每秒事务数TPS。

Arthas stack命令在线定位步骤
1、使用jmap -histo:live 进程ID > 文件名 命令将内存中存活对象以直方图的形式保存到文件中,这个过程会影响用户的时间,但是时间比较短暂。

2、分析内存占用最多的对象,一般这些对象就是造成内存泄漏的原因。 打开1.txt文件,从图中可以看到,有一个UserEntity对象占用非常多的内存。

3、使用arthas的stack命令,追踪对象创建的方法被调用的调用路径,找到对象创建的根源。也可以使用btrace工具编写脚本追踪方法执行的过程。

接下来启动jmeter脚本,会发现有大量的方法调用这样不利于观察。

加上 -n 1 参数,限制只查看一笔调用:

这样就定位到了是login接口中创建的对象:

btrace在线定位问题步骤
相比较arthas的stack命令,btrace允许我们自己编写代码获取感兴趣的内容,灵活性更高。
BTrace 是一个在Java 平台上执行的追踪工具,可以有效地用于线上运行系统的方法追踪,具有侵入性小、对性能的影响微乎其微等特点。 项目中可以使用btrace工具,打印出方法被调用的栈信息。
1、下载btrace工具, 官方地址:https://github.com/btraceio/btrace/releases/latest
2、编写btrace脚本,通常是一个java文件 依赖:
1 | <dependencies> |
代码:
代码非常简单,就是打印出栈信息。clazz指定类,method指定监控的方法。
1 | import org.openjdk.btrace.core.annotations.*; |
3、将btrace工具和脚本上传到服务器,在服务器上运行 btrace 进程ID 脚本文件名 。
配置btrace环境变量,与JDK配置方式基本相同:

在服务器上运行 btrace 进程ID 脚本文件名:

4、观察执行结果。 启动jmeter之后,同样获取到了栈信息:

2、GC调优
GC调优指的是对垃圾回收(Garbage Collection)进行调优。GC调优的主要目标是避免由垃圾回收引起程序性能下降。
GC调优的核心分成三部分:
1、通用Jvm参数的设置。
2、特定垃圾回收器的Jvm参数的设置。
3、解决由频繁的FULLGC引起的程序性能问题。
GC调优没有没有唯一的标准答案,如何调优与硬件、程序本身、使用情况均有关系,重点学习调优的工具和方法。
2.1 GC调优的核心指标
所以判断GC是否需要调优,需要从三方面来考虑(垃圾回收吞吐量,延迟,内存使用量),与GC算法的评判标准类似
2.2.1.吞吐量
吞吐量(Throughput) 吞吐量分为业务吞吐量和垃圾回收吞吐量
保证高吞吐量的常规手段有两条:
- 优化业务执行性能,减少单次业务的执行时间
- 优化垃圾回收吞吐量
1.业务吞吐量指的在一段时间内,程序需要完成的业务数量。比如企业中对于吞吐量的要求可能会是这样的:
- 支持用户每天生成10000笔订单
- 在晚上8点到10点,支持用户查询50000条商品信息
2.垃圾回收吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高,允许更多的CPU时间去处理用户的业务,相应的业务吞吐量也就越高。

2.1.2 延迟(Latency)
- 延迟指的是从用户发起一个请求到收到响应这其中经历的时间。比如企业中对于延迟的要求可能会是这样的:
- 所有的请求必须在5秒内返回给用户结果
- 延迟 = GC延迟 + 业务执行时间,所以如果GC时间过长,会影响到用户的使用。

2.1.3 内存使用量
内存使用量指的是Java应用占用系统内存的最大值,一般通过Jvm参数调整,在满足上述两个指标的前提下,这个值越小越好。
2.2 GC调优的步骤

2.2.1 发现问题 - 常用工具
jstat工具
Jstat工具是JDK自带的一款监控工具,可以提供各种垃圾回收、类加载、编译信息
等不同的数据。使用方法为:jstat -gc 进程ID 每次统计的间隔(毫秒) 统计次数

C代表Capacity容量,U代表Used使用量
S – 幸存者区,E – 伊甸园区,O – 老年代,M – 元空间
YGC、YGT:年轻代GC次数和GC耗时(单位:秒)
FGC、FGCT:Full GC次数和Full GC耗时
GCT:GC总耗时
- 优点:
- 操作简单
- 无额外的软件安装
- 缺点:
- 无法精确到GC产生的时间,只能用于判断GC是否存在问题
Visualvm插件
VisualVm中提供了一款Visual GC插件,实时监控Java进程的堆内存结构、堆内存变化趋势以及垃圾回收时间的变化趋势。同时还可以监控对象晋升的直方图。

- 优点:
- 适合开发使用,能直观的看到堆内存和GC的变化趋势
- 缺点:
- 对程序运行性能有一定影响
- 生产环境程序员一般没有权限进行操作

Prometheus + Grafana
Prometheus+Grafana是企业中运维常用的监控方案,其中Prometheus用来采系统或者应用的相关数据,同时具备告警功能。Grafana可以将Prometheus采集到的数据以可视化的方式进行展示。
Java程序员要学会如何读懂Grafana展示的Java虚拟机相关的参数。

- 优点:
- 支持系统级别和应用级别的监控,比如linux操作系统、Redis、MySQL、Java进程。
- 支持告警并允许自定义告警指标,通过邮件、短信等方式尽早通知相关人员进行处理
- 缺点:
- 环境搭建较为复杂,一般由运维人员完成
GC日志
通过GC日志,可以更好的看到垃圾回收细节上的数据,同时也可以根据每款垃圾回收器的不同特点更好地发现存在的问题。
使用方法(JDK 8及以下):-XX:+PrintGCDetails -Xloggc:文件名
使用方法(JDK 9+):-Xlog:gc*:file=文件名
1、添加虚拟机参数:

2、打开日志文件就可以看到GC日志

3、分析GC日志
分析GC日志 - GCViewer
GCViewer是一个将GC日志转换成可视化图表的小工具,github地址: https://github.com/chewiebug/GCViewer 使用方法:java -jar gcviewer_1.3.4.jar 日志文件.log

右下角是基础信息,左边是内存趋势图

分析GC日志 - GCEasy
GCeasy是业界首款使用AI机器学习技术在线进行GC分析和诊断的工具。定位内存泄漏、GC延迟高的问题,提供JVM参数优化建议,支持在线的可视化工具图表展示。 官方网站:https://gceasy.io/

使用方法:
1、选择文件,找到GC日志并上传

2、点击Analyze分析就可以看到报告,每个账号每个月能免费上传5个GC日志。
建议部分:

内存情况:

GC关键性指标:

GC的趋势图:

引发GC的原因:

2.2.2 常见的GC模式
1、正常情况
特点:呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,存留的对象较少。

2、缓存对象过多
特点:呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,处于比较高的位置。
问题产生原因: 程序中保存了大量的缓存对象,导致GC之后无法释放,可以使用MAT或者HeapHero等工具进行分析内存占用的原因。

3、内存泄漏
特点:呈现锯齿状,每次垃圾回收之后下降到的内存位置越来越高,最后由于垃圾回收无法释放空间导致对象无法分配产生OutOfMemory的错误。
问题产生原因: 程序中保存了大量的内存泄漏对象,导致GC之后无法释放,可以使用MAT或者HeapHero等工具进行分析是哪些对象产生了内存泄漏。

4、持续的FullGC
特点:在某个时间点产生多次Full GC,CPU使用率同时飙高,用户请求基本无法处理。一段时间之后恢复正常。
问题产生原因: 在该时间范围请求量激增,程序开始生成更多对象,同时垃圾收集无法跟上对象创建速率,导致持续地在进行FULL GC。GC分析报告

比如如下报告就产生了持续的FULL GC:

整体的延迟就变得很长:

原因就是老年代满了:

由于分配不了对象,导致频繁的FULLGC:

5、元空间不足导致的FULLGC
特点:堆内存的大小并不是特别大,但是持续发生FULLGC。
问题产生原因: 元空间大小不足,导致持续FULLGC回收元空间的数据。GC分析报告
元空间并不是满了才触发FULLGC,而是JVM自动会计算一个阈值,如下图中元空间并没有满,但是频繁产生了FULLGC。

停顿时间也比较长:

非常频繁的FULLGC:

2.2.3 解决GC问题的手段

- 优化基础JVM参数,基础JVM参数的设置不当,会导致频繁FULLGC的产生
- 减少对象产生,大多数场景下的FULLGC是由于对象产生速度过快导致的,减少对象产生可以有效的缓解FULLGC的发生
- 更换垃圾回收器,选择适合当前业务场景的垃圾回收器,减少延迟、提高吞吐量
- 优化垃圾回收器参数,优化垃圾回收器的参数,能在一定程度上提升GC效率
1.优化基础JVM参数
参数1 : -Xmx 和 –Xms
-Xmx参数设置的是最大堆内存,但是由于程序是运行在服务器或者容器上,计算可用内存时,要将元空间、操作系统、其它软件占用的内存排除掉。
案例: 服务器内存4G,操作系统+元空间最大值+其它软件占用1.5G,-Xmx可以设置为2g。
最合理的设置方式应该是根据最大并发量估算服务器的配置,然后再根据服务器配置计算最大堆内存的值。

-Xms用来设置初始堆大小,建议将-Xms设置的和-Xmx一样大,有以下几点好处:
- 运行时性能更好,堆的扩容是需要向操作系统申请内存的,这样会导致程序性能短期下降。
- 可用性问题,如果在扩容时其他程序正在使用大量内存,很容易因为操作系统内存不足分配失败。
- 启动速度更快,Oracle官方文档的原话:如果初始堆太小,Java 应用程序启动会变得很慢,因为 JVM 被迫频繁执行垃圾收集,直到堆增长到更合理的大小。为了获得最佳启动性能,请将初始堆大小设置为与最大堆大小相同。
参数2 : -XX:MaxMetaspaceSize 和 –XX:MetaspaceSize
-XX:MaxMetaspaceSize=值 参数指的是最大元空间大小,默认值比较大,如果出现元空间内存泄漏会让操作系统可用内存不可控,建议根据测试情况设置最大值,一般设置为256m。
-XX:MetaspaceSize=值 参数指的是到达这个值之后会触发FULLGC,后续什么时候再触发JVM会自行计算。如果设置为和MaxMetaspaceSize一样大,就不会FULLGC,但是对象也无法回收。

计算出来第一次因元空间触发FULLGC的阈值:

参数3 : -Xss虚拟机栈大小
如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
比如Linux x86 64位 : 1MB,如果不需要用到这么大的栈内存,完全可以将此值调小节省内存空间,合理值为256k – 1m之间。
使用:-Xss256k
参数4 : 不建议手动设置的参数
由于JVM底层设计极为复杂,一个参数的调整也许让某个接口得益,但同样有可能影响其他更多接口。
-Xmn 年轻代的大小,默认值为整个堆的1/3,可以根据峰值流量计算最大的年轻代大小,尽量让对象只存放在年轻代,不进入老年代。但是实际的场景中,接口的响应时间、创建对象的大小、程序内部还会有一些定时任务等不确定因素都会导致这个值的大小并不能仅凭计算得出,如果设置该值要进行大量的测试。G1垃圾回收器尽量不要设置该值,G1会动态调整年轻代的大小。

‐XX:SurvivorRatio 伊甸园区和幸存者区的大小比例,默认值为8。
‐XX:MaxTenuringThreshold 最大晋升阈值,年龄大于此值之后,会进入老年代。另外JVM有动态年龄判断机制:将年龄从小到大的对象占据的空间加起来,如果大于survivor区域的50%,然后把等于或大于该年龄的对象,放入到老年代。
比如下图中,年龄1+年龄2+年龄3 = 55m已经超过了S区的50%,所以会将年龄3及以上的对象全部放入老年代。

其他参数 :
-XX:+DisableExplicitGC
禁止在代码中使用System.gc(), System.gc()可能会引起FULLGC,在代码中尽量不要使用。使用DisableExplicitGC参数可以禁止使用System.gc()方法调用。
-XX:+HeapDumpOnOutOfMemoryError:发生OutOfMemoryError错误时,自动生成hprof内存快照文件。
-XX:HeapDumpPath=
打印GC日志
JDK8及之前 : -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
JDK9及之后 : -Xlog:gc*:file=文件路径
JVM参数模板
1 | -Xms1g |
注意:
JDK9及之后gc日志输出修改为 -Xlog:gc*:file=文件名
堆内存大小和栈内存大小根据实际情况灵活调整。
2.减少对象产生
需要用到内存调优里的一些方案,将创建对象速度延缓,需要根据具体方案和代码进行针对性的调整
3.更换垃圾回收器
背景:
小李这次他希望能采用更合理的垃圾回收器优化性能。
思路:
编写Jmeter脚本对程序进行压测,同时添加RT响应时间、每秒钟的事务数
等指标进行监控。
选择不同的垃圾回收器进行测试,并发量分别设置50、100、200,观察
数据的变化情况。
- JDK8 下 ParNew + CMS 组合 : -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
默认组合 : PS + PO
JDK8使用g1 : -XX:+UseG1GC JDK11 默认 g1
测试用代码:
1 | com.itheima.jvmoptimize.fullgcdemo.Demo2Controller |
1、使用jmeter测试脚本

2、添加基础JVM测试参数:
1 | -Xms8g -Xmx8g -Xss256k -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:/test.hprof -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps |
JDK8默认情况下测试的是PS+PO组合
测试结果:
| 垃圾回收器 | 参数 | 50并发(最大响应时间) | 100并发(最大响应时间) | 200并发(最大响应时间) |
|---|---|---|---|---|
| PS+PO | 默认 | 260ms | 474ms | 930ms |
| CMS | -XX:+UseParNewGC -XX:+UseConcMarkSweepGC | 157ms | 未测试 | 833ms |
| G1 | JDK11默认 | 未测试 | 未测试 | 248ms |
可见使用了JDK11之后使用G1垃圾回收器,性能优化结果还是非常明显的。
4.优化垃圾回收器的参数
这部分优化效果未必出色,仅当前边的一些手动无效时才考虑。
一个优化的案例:
CMS的并发模式失败(concurrent mode failure)现象。由于CMS的垃圾清理线程和用户线程是并行进行的,如果在并发清理的过程中老年代的空间不足以容纳放入老年代的对象,会产生并发模式失败。

老年代已经满了此时有一些对象要晋升到老年代:

解决方案:
1.减少对象的产生以及对象的晋升。
2.增加堆内存大小
3.优化垃圾回收器的参数,比如-XX:CMSInitiatingOccupancyFraction=值,当老年代大小到达该阈值时,会自动进行CMS垃圾回收,通过控制这个参数提前进行老年代的垃圾回收,减少其大小。
JDK8中默认这个参数值为 -1,根据其他几个参数计算出阈值:
((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)
本机计算之后的结果是92:

该参数设置完是不会生效的,必须开启-XX:+UseCMSInitiatingOccupancyOnly参数。
调整前和调整之后的效果对比:

很明显FULLGC产生的次数下降了。(虽然下降了,但依旧存在很多Full GC,并没有解决根本问题,应该考虑方案一或二)
案例实战(真尼玛难)

用ps -ef查看一下虚拟机参数

1 | -Xms1g -Xmx1g -Xss256k -XX:MaxMetaspaceSize=256m -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+DisableExplicitGC -Xloggc:D:/test.log |

1、打开测试脚本:

2、发现有几笔响应时间特别长的请求,怀疑是GC引起的:

3、把GC日志上传到GCEasy之后发现内存占用情况很严重:

出现了几次FULLGC,并且FULL GC之后,内存占用也有160m左右:

问题1:
发生了连续的FULL GC,堆内存1g如果没有请求的情况下,内存大小在200-300mb之间。
分析:
没有请求的情况下,内存大小并没有处于很低的情况,满足缓存对象过多的情况,怀疑内存种缓存了很多数据。需要将堆内存快照保存下来进行分析。
1、在本地测试,通过visualvm将hprof文件保存下来:

2、通过Heap Hero分析文件,操作方式与GCEasy相同,上传的是hprof文件:

但是我们发现,生成的文件非常小,与接近200m大小不符

3、怀疑有些对象已经可以回收,所以没有下载下来。使用jmap调整下参数,将live参数去掉,这样即便是垃圾对象也能保存下来:

4、在MAT中分析,选择不可达对象直方图:

5、大量的对象都是字节数组对象:

6.那么这些对象是如何产生的呢?继续往下来,捕捉到有大量的线程对象,如果没有发现这个点,只能去查代码看看哪里创建了大量的字节数组了:

问题2:
由于这些对象已经不在引用链上,无法通过支配树等手段分析创建的位置。
分析:
在不可达对象列表中,除了发现大量的byte[]还发现了大量的线程,可以考虑跟踪线程的栈信息来判断对象在哪里创建。
1、在VisualVM中使用采样功能,对内存采样:

2、观察到这个线程一直在发生变化,说明有线程频繁创建销毁:

3、选择线程功能,保存线程栈:

4、抓到了一个线程,线程后边的ID很大,说明已经创建过很多线程了:

5、通过栈信息找到源代码:

这里有个定时任务,每隔200ms就创建线程。
问题产生原因:
在定时任务中通过线程创建了大量的对象,导致堆内存一直处于比较高的位置。
解决方案:
暂时先将这段代码注释掉,测试效果,由于这个服务本身的内存压力比较大,将这段定时任务移动到别的服务中。
问题3:
修复之后内存基本上处于100m左右,但是当请求发生时,依然有频繁FULL GC的发生。
分析:
请求产生的内存大小比当前最大堆内存大,尝试选择配置更高的服务器,将-Xmx和-Xms参数调大一些。

当前的堆内存大小无法支撑请求量,所以要不就将请求量降下来,比如限制tomcat线程数、限流,或者提升服务器配置,增大堆内存。
调整为4G之后的效果,FULLGC数量很少:


省流:扩大内存,启用G1

3、性能调优

3.1 性能调优解决的问题
应用程序在运行过程中经常会出现性能问题,比较常见的性能问题现象是:
1、通过top命令查看CPU占用率高,接近100甚至多核CPU下超过100都是有可能的。

2、请求单个服务处理时间特别长,多服务使用skywalking等监控系统来判断是哪一个环节性能低下。

3、程序启动之后运行正常,但是在运行一段时间之后无法处理任何的请求(内存和GC正常)。
3.2 性能调优的方法
线程转储的查看方式
线程转储(Thread Dump)提供了对所有运行中的线程当前状态的快照。线程转储可以通过jstack、visualvm等工具获取。其中包含了线程名、优先级、线程ID、线程状态、线程栈信息等等内容,可以用来解决CPU占用率高、死锁等问题。

1、通过jps查看进程ID:

2、通过jstack 进程ID查看线程栈信息:

3、通过jstack 进程ID > 文件名导出线程栈文件


3.3案例实战
3.3.1案例一解决CPU占用率高的问题(定位进程CPU占用率高的问题)
问题:
监控人员通过prometheus的告警发现CPU占用率一直处于很高的情况,通过top命令看到是由于Java程序引起的,希望能快速定位到是哪一部分代码导致了性能问题。
解决思路:
1、通过top –c 命令找到CPU占用率高的进程,获取它的进程ID。

2、使用top -p 进程ID单独监控某个进程,按H可以查看到所有的线程以及线程对应的CPU使用率,找到CPU使用率特别高的线程。

3、使用 jstack 进程ID 命令可以查看到所有线程正在执行的栈信息。使用 jstack 进程ID > 文件名 保存到文件中方便查看。


4、找到nid线程ID相同的栈信息,需要将之前记录下的十进制线程号转换成16进制。通过 printf ‘%x\n’ 线程ID 命令直接获得16进制下的线程ID。


5、找到栈信息对应的源代码,并分析问题产生原因。
在定位CPU占用率高的问题时,比较需要关注的是状态为RUNNABLE的线程。但实际上,有一些线程执行本地方法时并不会消耗CPU,而只是在等待。但 JVM 仍然会将它们标识成“RUNNABLE”状态。

3.3.2案例二定位接口响应时间很长的问题
问题:
在程序运行过程中,发现有几个接口的响应时间特别长,需要快速定位到是哪一个方法的代码执行过程中出现了性能问题。
解决思路:
已经确定是某个接口性能出现了问题,但是由于方法嵌套比较深,需要借助于arthas定位到具体的方法。
比如调用链是A方法 -> B方法 -> C方法 -> D方法,整体耗时较长。我们需要定位出来是C方法慢导致的问题。

trace命令监控
使用arthas的trace命令,可以展示出整个方法的调用路径以及每一个方法的执行耗时。
命令: trace 类名 方法名
- 添加
--skipJDKMethod false参数可以输出JDK核心包中的方法及耗时。 - 添加 ‘#cost > 毫秒值’ 参数,只会显示耗时超过该毫秒值的调用。
- 添加
–n 数值参数,最多显示该数值条数的数据。 - 所有监控都结束之后,输入
stop结束监控,重置arthas增强的对象。

测试方法:
1 | com.itheima.jvmoptimize.performance.PerformanceController.a() |
1、使用trace命令,监控方法的执行:

2、发起一次请求调用:

3、显示出了方法调用的耗时占比:

4、添加 --skipJDKMethod false 参数可以输出JDK核心包中的方法及耗时:

5、添加 ‘#cost > 1000’ 参数,只显示耗时超过1秒的调用。

6、添加 –n 1 参数,最多显示1条数据,避免数据太多看起来不清晰。

7、所有监控都结束之后,输入stop结束监控,重置arthas增强的对象。避免对性能产生影响。

watch命令监控
在使用trace定位到性能较低的方法之后,使用watch命令监控该方法,可以获得更为详细的方法信息。
命令:
1 | watch 类名 方法名 ‘{params, returnObj}’ ‘#cost>毫秒值' -x 2 |
‘{params, returnObj}‘ 代表打印参数和返回值。
-x 代表打印的结果中如果有嵌套(比如对象里有属性),最多只展开2层。允许设置的最大值为4。
测试方法:
1 | com.itheima.jvmoptimize.performance.PerformanceController.a() |
1、执行命令,发起一笔接口调用:


2、cost = 1565ms代表方法执行时间是1.56秒,result = 后边是参数的内容,首先是一个集合(既可以获取返回值,也可以获取参数),第一个数组就是参数,里边只有一个元素是一个整数值为1。

总结:
1、通过arthas的trace命令,首先找到性能较差的具体方法,如果访问量比较大,建议设置最小的耗时,精确的找到耗时比较高的调用。
2、通过watch命令,查看此调用的参数和返回值,重点是参数,这样就可以在开发环境或者测试环境模拟类似的现象,通过debug找到具体的问题根源。
3、使用stop命令将所有增强的对象恢复。
3.3.3案例三定位偏底层的性能问题(火焰图定位接口响应时间长的问题)
问题:
有一个接口中使用了for循环向ArrayList中添加数据,但是最终发现执行时间比较长,需要定位是由于什么原因导致的性能低下。
解决思路:
Arthas提供了性能火焰图的功能,可以非常直观地显示所有方法中哪些方法执行时间比较长。
测试方法:
1 | com.itheima.jvmoptimize.performance.PerformanceController.test6() |
使用arthas的profile命令,生成性能监控的火焰图。(profiler不支持windows环境)
命令1: profiler start 开始监控方法执行性能
命令2: profiler stop --format html 以HTML的方式生成火焰图
火焰图中一般找绿色部分Java中栈顶上比较平的部分,很可能就是性能的瓶颈。

1、使用命令开始监控:

2、发送请求测试:

3、执行命令结束,并生成火焰图的HTML

4、观察火焰图的结果:

火焰图中重点关注左边部分,是我们自己编写的代码的执行性能,右边是Java虚拟机底层方法的性能。火焰图中会展示出Java虚拟机自身方法执行的时间。
火焰图中越宽的部分代表执行时间越长,比如:

很明显ArrayList类中的add方法调用花费了大量的时间,这其中可以发现一个copyOf方法,数组的拷贝占用时间较多。
观察源码可以知道,频繁的扩容需要多次将老数组中的元素复制到新数组,浪费了大量的时间。

在ArrayList的构造方法中,设置一下最大容量,一开始就让它具备这样的大小,避免频繁扩容带来的影响:

最终这部分开销就没有了,宽度变大是因为我放大了这张图:

总结:
偏底层的性能问题,特别是由于JDK中某些方法被大量调用导致的性能低下,可以使用火焰图非常直观的找到原因。
这个案例中是由于创建ArrayList时没有手动指定容量,导致使用默认的容量而在添加对象过程中发生了多次的扩容,扩容需要将原来数组中的元素复制到新的数组中,消耗了大量的时间。通过火焰图可以看到大量的调用,修复完之后节省了20% ~ 50%的时间。
3.3.4案例4线程被耗尽问题(死锁问题的检测)
问题:
程序在启动运行一段时间之后,就无法接受任何请求了。将程序重启之后继续运行,依然会出现相同的情况。
解决思路:
线程耗尽问题,一般是由于执行时间过长,分析方法分成两步:
1、检测是否有死锁产生,无法自动解除的死锁会将线程永远阻塞。
2、如果没有死锁,再使用案例1的打印线程栈的方法检测线程正在执行哪个方法,一般这些大量出现的方法就是慢方法。
死锁:两个或以上的线程因为争夺资源而造成互相等待的现象。
解决方案:
线程死锁可以通过三种方法定位问题:
测试方法:
1 | com.itheima.jvmoptimize.performance.PerformanceController.test6() |
先调用deadlock1(test6)方法

再调用deadlock2(test7)方法,就可以产生死锁

1、 jstack -l 进程ID > 文件名 将线程栈保存到本地。

在文件中搜索deadlock即可找到死锁位置:

2、 开发环境中使用visual vm或者Jconsole工具,都可以检测出死锁。使用线程快照生成工具就可以看到死锁的根源。生产环境的服务一般不会允许使用这两种工具连接。

3、使用fastthread自动检测线程问题。 https://fastthread.io/ Fastthread和Gceasy类似,是一款在线的AI自动线程问题检测工具,可以提供线程分析报告。通过报告查看是否存在死锁问题。
在visualvm中保存线程栈:

选择文件并点击分析:
死锁分析报告:

3.4JMH基准测试框架
面试中容易问到性能测试问题:

Java程序在运行过程中,**JIT即时编译器会实时对代码进行性能优化,所以仅凭少量的测试是无法真实反应运行系统最终给用户提供的性能。**如下图,随着执行次数的增加,程序性能会逐渐优化。

所以简单地打印时间是不准确的,JIT有可能还没有对程序进行性能优化,我们拿到的测试数据和最终用户使用的数据是不一致的。
OpenJDK中提供了一款叫JMH(Java Microbenchmark Harness)的工具,可以准确地对Java代码进行基准测试,量化方法的执行性能。 官网地址:https://github.com/openjdk/jmhc JMH会首先执行预热过程,确保JIT对代码进行优化之后再进行真正的迭代测试,最后输出测试的结果。

3.4.1JMH环境搭建
创建基准测试项目,在CMD窗口中,使用以下命令创建JMH环境项目:
1 | mvn archetype:generate \ |
修改POM文件中的JDK版本号和JMH版本号,JMH最新版本号参考Github。

编写测试方法,几个需要注意的点:
- 死代码问题
- 黑洞的用法
初始代码:
1 | package org.sample; |
如果不将返回,JIT会直接将这段代码去掉,因为它认为你不会使用i那么我们对i进行的任何处理都是没有意义的,这种代码无法执行的现象称之为死代码


我们可以将i返回,或者添加黑洞来消费这些变量,让JIT无法消除这些代码:


通过maven的verify命令,检测代码问题并打包成jar包。通过 java -jar target/benchmarks.jar 命令执行基准测试。
添加这行参数,可以生成JSON文件,测试结果通过https://jmh.morethan.io/生成可视化的结果。

3.4.2案例:日期格式化方法性能测试
问题:
在JDK8中,可以使用Date进行日期的格式化,也可以使用LocalDateTime进行格式化,使用JMH对比这两种格式化的性能。
解决思路:
1、搭建JMH测试环境。
2、编写JMH测试代码。
3、进行测试。
4、比对测试结果。
1 | package org.sample; |
优化:


@Warmup是预热,iterations是预热次数,time是时间
@Benchmark是测试方法。
@Fork是启动多少个进程,value为1代表只启动一个进程,可以追加Java虚拟机的参数。
@BenchmarkMode是指定当前显示的结果,Mode.AverageTime平均时间,Mode.Throughput吞吐量, Mode.All是显示所有内容。
@OutputTimeUnit是指定显示时间单位,TimeUnit.NANOSECONDS。
@State是变量共享范围(在测试过程中共享还是单个线程共享)。Scope.Benchmark

显然localDateTime性能更好
3.5 性能调优综合案例

问题:
小李的项目中有一个获取用户信息的接口性能比较差,他希望能对这个接口在代码中进行彻底的优化,提升性能。
解决思路:
1、使用trace分析性能瓶颈。
2、优化代码,反复使用trace测试性能提升的情况。
3、使用JMH在SpringBoot环境中进行测试。
4、比对测试结果。
1 | package com.itheima.jvmoptimize.performance.practice.controller; |
在SpringBoot项目中整合JMH:
1、pom文件中添加依赖:
1 | <dependency> |
2、测试类中编写:
1 | package com.itheima.jvmoptimize; |


总结:
1、本案例中性能问题产生的原因是两层for循环导致的循环次数过多,处理时间在循环次数变大的情况下变得非常长,考虑将一层循环拆出去,创建HashMap用来查询提升性能。
2、使用LocalDateTime替代SimpleDateFormat进行日期的格式化。
3、使用stream流改造代码,这一步可能会导致性能下降,主要是为了第四次优化准备。
4、使用并行流利用多核CPU的优势并行执行提升性能。






