前语

首要java语言的特性是不需像C和C++那样自己手动开释内存,由于java本身有废物收回机制(废物收回称为GC),顾名思义便是开释废物占用的空间,避免内存泄露。JVM运行时占用内存最大的空间便是堆内存,另外栈区和办法区也会占用空间可是占用有限本章就不探求了。那么堆中的空间又分为年青代和老时代,所以咱们粗略的把废物收回分为两种:年青代的废物收回称为Young GC,老时代的废物收回称为Full GC,实际上此处的Full GC也包含了新生代,老时代,元空间等的收回。

由于Full GC的收回过程会使体系的一切线程STW(Stop The World),那么咱们一定希望让体系尽量不要进行Full GC,或许必需求进行FullGC的时分履行的时刻越短越好。下面咱们首要探求Full GC的角度动身剖析我在开发运营后台的时分遇到的频频Full GC过程。

工作布景

项目介绍:

咱们团队做的是一个后台办理体系,由于针对不同用户担任的功能不同那么需求的权限也就不相同,所以引进了干流的shiro结构做权限操控,该结构可以操控菜单栏,按钮,操作框等。在引进这个结构时同时引进了辅佐组件shiro-redis,该组件是一个缓存层方便办理用户登录信息,内存走漏的问题也是就现在这个辅佐组件上。

工作复原:

在周五的正午11:30分收到了监控的报警信息提示体系在频频Full GC,此刻咱们立刻做两件工作

第一: 登录公司的UMP监控渠道(开源监控可以参阅:【Prometheus+grafana监控】)检查该机器的体系目标,发现确实在频频FullGC从11点继续到了11点半

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

第二: 保存一台机器作为依据搜集,其他机器进行重启保证事务能正常拜访,重启后full gc正常

第三: 仓库信息操作指令 ./jmap -F -dump:live,format=b,file=/jmapfile.hprof 18362 (-F操作是强制导出仓库信息,18362是应用pid,通过 top -c 指令获取)

第四: 由于个人无权限导出仓库信息,马上电话联络运维通过上面指令导出该机器上的仓库文件,便是抓取现场依据,由于过了这个时刻堆内存可能就正常了

依据JVM常识剖析,常见Full GC时的五种状况如下:

1. 老时代内存缺乏(大目标过多或内存走漏)
2. Metaspace 空间缺乏
3. 代码自动触发 System.gc()
4. YGC 时的失望战略
5. dump live 的内存信息时,比方 jmap -dump:live

剖析原因

1、检查公司SGM监控渠道(开源监控可以参阅:【Prometheus+grafana监控】),元空间最大内存256M,FullGC发生前后为117M,排除Metaspace缺乏形成的原因

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

2、在体系中搜索第三方jar包,没有自动履行System.gc()操作的代码

3、检查JVM发动参数中有下面两个参数,所以排除了YGC时分的失望战略原因

-XX:CMSInitiatingOccupancyFraction=70      # 堆内存到达 70%进行 FullGC
-XX:+UseCMSInitiatingOccupancyOnly         # 制止 YGC 时的失望战略(YGC 前后判别是否需求 FullGC),只需到达阈值才进行 FullGc

4、通过和运维、研制组交流没有人自动履行dump操作,检查体系的前史履行指令也没有dump操作,自动dump的原因排除

开始剖析成果:

通过上面依靠监控渠道、JVM发动参数、代码排除、指令剖析,最终嫌疑最大的便是老时代内存空间缺乏形成频频Full GC,可是作为技能者,排除法明显不能作为原因定位的依据,咱们还需求继续确认咱们的猜想,下面会结合JVM发动参数,Tomcat发动参数,仓库文件三大要害要素做具体剖析。

下图是进行FullGC时分的老时代内存状况,把下面的72%、1794Mb、2496Mb、448Mb先记住,下面会跟这些值做对比

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

目标信息:

JVM中心参数:

-Xms2048M 								# 体系发动初始化堆空间
-Xmx4096M 								# 体系最大堆空间
-Xmn1600M 								# 年青代空间(包含 From 区和 To),From 和 To 默认占年青代 20%
-XX:MaxPermSize=256M 					# 最大非堆内存,按需分配
-XX:MetaspaceSize=256M 					# 元空间巨细,JDK1.8 取消了永久代(PermGen)新增元空间,元空间并不在虚拟机中,而是运用本地内存。因此,默认状况下,元空间的巨细仅受本地内存限制,存储类和类加载器的元数据信息
-XX:CMSInitiatingOccupancyFraction=70 	# 堆内存到达 70%进行 FullGC
-XX:+UseCMSInitiatingOccupancyOnly 		# 制止 YGC 时的失望战略(YGC 前后判别是否需求 FullGC),只需到达阈值才进行 FullGc
-XX:+UseConcMarkSweepGC 				# 运用 CMS 作为废物搜集器

Tomcat中心参数:

maxThreads=750		# Tomcat 线程池最多能起的线程数
minSpareThreads=50	# Tomcat 初始化的线程池巨细或许说 Tomcat 线程池最少会有这么多线程
acceptCount=1000	# Tomcat 维护最大的行列数

通过上边的目标信息咱们能对体系的功能瓶颈有大致了解,首要依据JVM参数剖析成果如下:

堆最大空间4096M

年青代占用空间1600M(包含Eden区1280M,Survivor From160M,Survivor To160M)

老时代最大占用空间2496M(跟上面的2496Mb对应)

体系初始化堆内存2048M

那么老时代初始内存(448M) (跟上面的448Mb对应)= 初始化堆内存(2048M) – 年青代内存(1600M)

依据JVM发动参数确认堆内存到达70时进行废物收回, 体系进行废物收回时堆内存占比72%(跟上面的72%对应)一向大于70%,那么运用内存是0.72 * 2496Mb ≈ 1794Mb(跟上面的1794Mb对应)

仓库剖析:

在查询仓库前履行GC原因指令:jstat -gccause [pid] 1000,履行成果如下图,可以看到 LGCC 这一列代表了最终履行 gc 的原因。CMS Initial Mark 和 CMS Final Remark 这两个阶段是 CMS 废物收回的初始符号和最终符号阶段是耗时最长也是形成 STW(Stop The World)的两个阶段

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

导出仓库指令:jmap -dump:live,format=b,file=jmapfile.hprof [pid]。 导出的文件需求运用MAT软件剖析,全称 MemoryAnalyzer,首要剖析堆内存。参阅下载链接:eclipse.org/mat/downloa…

从仓库文件剖析成果中发现有50个org.apache.tomcat.util.threads.TaskThread占用空间很大。共占用空间96.16%

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

每个TaskThread实例占用空间36M左右

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

检查内存概况保存最大最多的目标是ThreadLocal中存储的SessionInMemory目标

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

最终原因:

通过剖析上面的JVM参数、Tomcat参数、仓库文件,内存走漏的原因是每个线程中有一个ThreadLocal存储许多 SessionInMemory,由于Tomcat的发动中心线程数是50个,每个线程的内存占用 36M 左右,共占用 1.8G,老时代内存到达 70%也便是 2496 * 0.7 = 1747.2M 就会进行废物收回,1.8G 刚比方 1747.2M 略微大一些。可是线程中的目标又没办法被收回,所以就会看到体系再频频 FullGC。

定位问题

通过上面内存剖析现已定位到内存走漏的原因是每个线程中有许多 SessionInMemory,下面过程就仔细剖析代码找到其中创立如此多目标还不毁掉的原因。

通过开始剖析发现 SessionInMemory 是引证 shiro-redis 的工具包里边的目标,首要封装Session 信息和创立时刻。首要作用是在当前线程的jvm中做一层缓存当体系频频获取 Session 时不用去 redis 获取了。SessionInMemary目标是shiro判别用户登录成功时分存储的数据,首要包含用户信息,认证信息,权限信息等,由于用户登录成功后不会重复认证,shiro会对不同用户做权限判别

剖析代码发现处理本地缓存Session的流程有明显问题,我画了一个简易的流程图,在介绍流程图前我先描绘一下Session和用户登录操作如何联络起来

咱们都知道运营后台需求用户登录,登录成功后会生成一个cookie保存到浏览器中,cookie存储一个要害字段sessionId用来标识用户的状态和信息,当用户拜访页面调用接口的时分shiro会从恳求Request中获取cookie中的sessionId,依据这个唯一标识生成Session来存储用户的登录态和登录信息等,这些信息会保存到redis中。shiro-redis组件担任从redis中获取的Session信息通过ThreadLoca做到线程阻隔。

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

上图流程归纳便是:用户拜访页面先从本地缓存获取Session,假如存在且没有超越一秒就回来成果,假如没有Session或许过期了就把现在的Session删去并新建一个回来成果。全体看思路清晰,先获取Session,假如没有就新建回来,假如过期了就删去再新建回来。

流程图躲藏的问题(中心问题)

1、多个线程会仿制多份相同Session使内存成倍增加(Session相同线程不同)

举个比方:用户登录后台生成一个Session,假设恳求都到一台机器上,第一次恳求到线程 1,第二个恳求到线程 2,由于Session相同可是线程之间是阻隔的,所以线程 1 和线程 2 都会创立一份相同 Session 存储到 ThreaLocal 中,Tomcat 最小闲暇线程数越多仿制的 Session 份数也越多。由于Tomcat的中心线程数不会关闭,所以里边的资源也不会开释。此处有个疑问ThreadLocad的key是弱引证可是为什么没收回呢?下面统统解答

2、旧Session无法清除(线程相同Session不同)

举个比方1:假设一切恳求都到一台机器的同一个线程,用户第一次登录后台生成Session1,第一次恳求到线程 1,1 秒内一切恳求都履行完了,此刻 Session 没有移除(由于Session移除战略是懒删去,需求等下次同一个Session拜访时判别过期条件再删去),用户从头登录,生成了Session2,由于Session2在线程1中还没有就会从头创立,导致第一次登录时分用到的 Session1 就一向保存到该线程中了

举个比方2:参阅比方1的思路,假如用户用Session1没有在1秒内把一切恳求履行完,就会履行懒删去操作,可是删去后又新建了一个,那么用户从头登录后方才新建的那个Session还是没有被删去,所以总结出来只需用户从头登录必定有一个旧的Session会保存到线程中

代码剖析

1、在RedisSessionDAO.java文件中界说了一个ThreadLocal变量作为线程阻隔

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

2、用户拜访接口、js 文件、css 文件等资源的时分会进入 shiro 的阻拦机制。在阻拦过程中会频频调用 doReasSession()办法获取用户的 Session 信息,首要是获取信息校验用户的权限操控等。

下面的办法首要整合了获取Session操作和设置Session操作,假如从ThreadLocal中没有获取到或许本地缓存超越1秒了就回来null,判别为null之后就会从redis中获取并新建一个Session存储到ThreadLoca中

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

3、从ThreadLocal中取出sessionMap,依据sessionId在sessionMap中寻找Session,假如没找到直接回来null,假如找到了再判别时刻是否超越了1秒,假如没超越回来Session,假如超越了移除回来null

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

4、从ThreadLocal中获取sessionMap,假如为null就新建一个保存起来,由于用户第一次拜访的时分线程中的sessionMap还没有呢所以要新建。然后向sessionMap中存储Session目标

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

所以代码的完成流程总结:获取 Session 的操作是调用 getSessionFromThreadLocal()办法,假如没有获取到 Session 就回来 null,调用 setSessionToThreadLocal()办法会从头设置一个 Session。假如 Session 在当前线程的保存时刻超越 1 秒就 remove。

通过上面剖析JVM、Tomat、仓库、代码现已把问题定位了,由于shiro-redis中存储的SessionInMemory目标处理不妥导致线程间存储越来越多,最终使内存走漏进而导致了频频FullGC。由于咱们引证的shiro-redis版别是3.2.2版别,所以存在这个漏洞,作者已于2019年3月晋级jar包到3.2.3版别把该问题解决。备注:3.2.2及以下版别存在该问题

解决问题

解决问题的计划目前有四种。 针对咱们体系运用的是计划 1+计划 4

序号 计划描绘 优点 缺点
计划1 每次设置session时遍历删去曾通过期或许为null的session 自动删去,删去频次依赖用户的拜访频次 假如在1秒内有许多用户拜访,总session许多无效session很少,遍历一切session做了许多无用功导致拜访变慢
计划2 取消threadLocal战略,一切恳求直接查询缓存(redis) 削减本地内存运用 拜访缓存耗时比本地长,通过测试发现一个接口会调用16次左右的获取session操作,一个页面几十个接口,直接查询缓存功能存在问题
计划3 运用本地缓存(guavaCache或许EhCache等),并对缓存做移除战略 多个线程共用一份内存,节省内存空间,提高体系功能 对结构有深入了解,接入需求开发成本
计划4 把tomcat的中心线程数减小,比方把原来的50改成 5 削减体系资源,削减相同Session的仿制份数,大于5的线程毁掉资源也一同收回 处理并发能力略低

疑问解答

Q: 在 RedisSessionDAO 里边只界说了一个 ThreadLocal 的变量 sessionsInThread,怎么就会是 50 个线程把相同的 Session 仿制 50 份呢?

A: 首要咱们先了解 ThreadLocal 的结构,ThreadLocal 有一个静态类 ThreadLocalMap,ThreadLocalMap 里边还有一个 Entry,咱们的 key 和 value 便是保存在 Entry 的,key 是一个弱引证的 ThreadLocal 类型,,这个 key 在一切的线程中都是相同的,实际上便是咱们界说的静态 sessionsInThread。那又是怎么做到线程阻隔的呢?

这就讲到Thread中的一个成员变量threadLocals,这个目标便是ThreadLocal.ThreadLocalMap类型,也便是每次创立一个线程都会new一个ThreadLocalMap,所以每个线程中的 ThreadLocalMap 都是不同的,可是里边 Entry 存储的 key 都是相同的,也便是咱们前面界说的 sessionsInThread 静态变量。

当一个线程需求获取 Entry 中存储的 value 时分,调用 sessionsInThread.get()办法,这个办法做了三件工作,一是获取当前线程的实例,二是从线程实例中获取 ThreadLocalMap,三是从 ThreadLocalMap 中依据 ThreadLocal 这个 key 获取指定的 value

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

获取 Thread 中的 ThreadLocalMap

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队

从 ThreadLocalMap 中获取指定的 value,又有个疑问,获取 Entry 为什么还要从一个 table数组中拿呢?这个很好了解一个线程不一定只需一个 ThreadLocal 变量吧,多个 ThreadLocal变量便是有多个 key,所以就放到 table 数组里边了

频繁FullGC的原因竟然是“开源代码”? | 京东云技术团队


Q: 都说 ThreadLocal 的 key 是一个弱引证,假如内存缺乏了会被废物收回,咱们的 key 从仓库看并没有收回呀? A: 这是个好问题,首要咱们的 RedisSessionDAO 是 Spring 注入的单例形式,ThreadLocal被界说成一个静态变量,静态变量在内存中是不会收回的。 弥补:一般咱们在运用 ThreadLocal 的时分都会界说成静态变量,假如界说成非静态变量创立一个目标就会 new 一个 ThreadLocal,那么 ThreadLocal 就没有存在的意义了。

Q: 现已结束的线程,为什么还会存活,里边的目标也不会消失?

A: 由于设置的最小闲暇线程数是50,事务量不大并发数没有超越50,tomcat会保存最小的线程数量不会新建也不用收回,ThreadLocalMap是线程中的成员变量所以不会收回

Q: 拜访一次接口就会生成一个 sessionId 吗?

A: 拜访接口先判别用户信息是否有用,无效才会从头登录获取新的 sessionId

Q: shiro-redis在本地保存Session为什么设置1秒过期时刻?

A: 由于运营后台不同于事务接口会继续调用,后台接口大部分的场景是用户拜访一个页面并停留在页面上做一些操作,拜访一个页面的时分浏览器会加载多个资源,包含静态资源html,css,js等,和接口的动态数据,整个资源加载过程尽量保持在一秒内完成,假如超越一秒的话体系体验功能较差,所以本地缓存一秒足够了。

收获总结

报警前:

1.了解第三方jar包的工作原理,尤其是个人开发工具包,由于没有通过商场检验运用前要分外小心

2.可以运用jvisualvm进行本地压测调查jvm状况

3.关注监控报警,掌握监控渠道操作,可以从监控中查询体系各项目标信息

4.依据事务合理装备JVM参数和Tomcat参数

报警后:

1.可以第一时刻抓取体系的JVM信息,比方仓库,GC信息,线程栈等

2.通过运用MAT内存辅佐软件帮助自己剖析问题原因

作者:京东科技 郭银利

来源:京东云开发者社区