百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

频繁FullGC的原因竟然是“开源代码”?

cac55 2025-03-04 10:57 12 浏览 0 评论

前言

首先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点半

??第二:保留一台机器作为证据收集,其他机器进行重启保障业务能正常访问,重启后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不足造成的原因

??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先记住,下面会跟这些值做对比

指标信息:

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)的两个阶段

??导出堆栈指令:jmap -dump:live,format=b,file=jmapfile.hprof [pid]。导出的文件需要使用MAT软件分析,全称 MemoryAnalyzer,主要分析堆内存。参考下载链接:
http://eclipse.org/mat/downloads.php?

从堆栈文件分析结果中发现有50个
org.apache.tomcat.util.threads.TaskThread占用空间很大。共占用空间96.16%

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

??查看内存详情保存最大最多的对象是ThreadLocal中存储的SessionInMemory对象

??最终原因:

通过分析上面的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做到线程隔离。

??上图流程概括就是:用户访问页面先从本地缓存获取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变量作为线程隔离

??2、用户访问接口、js 文件、css 文件等资源的时候会进入 shiro 的拦截机制。在拦截过程中会频繁调用 doReasSession()方法获取用户的 Session 信息,主要是获取信息校验用户的权限控制等。

下面的方法主要整合了获取Session操作和设置Session操作,如果从ThreadLocal中没有获取到或者本地缓存超过1秒了就返回null,判断为null之后就会从redis中获取并新建一个Session存储到ThreadLoca中

??3、从ThreadLocal中取出sessionMap,根据sessionId在sessionMap中寻找Session,如果没找到直接返回null,如果找到了再判断时间是否超过了1秒,如果没超过返回Session,如果超过了移除返回null

??4、从ThreadLocal中获取sessionMap,如果为null就新建一个保存起来,因为用户第一次访问的时候线程中的sessionMap还没有呢所以要新建。然后向sessionMap中存储Session对象

??所以代码的完成流程总结:获取 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

??获取 Thread 中的 ThreadLocalMap

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

??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内存辅助软件帮助自己分析问题原因

作者:京东科技 郭银利

来源:京东云开发者社区

相关推荐

用闲置电脑当软路由安装OpenWRT(小白教程)

话说软路由系统OpenWRT用起来真是香,里面的好多功能都是普通路由无法实现的,由于众所周知的原因,在这里就不细说,等安装完自己体验吧。今天就介绍用一台闲置的电脑(自带两个网口)充当软路由,安装Ope...

一招把废旧路由器改成交换机(用旧路由器做交换机)

家里面的路由器用个几年,就会WIFI变卡,新路由器买回来,旧路由器就没什么用了?我在这里教大家把老路由器变成交换机。近两年新出的路由器,基本都是2个LAN口,接网络设备还需要买交换机,淘汰下来的路由器...

如何将PC电脑变成web服务器:将内网主机映射到外网实现远程访问

我是艾西,今天跟大家分享内容还是比较多人问的一个问题:如何将PC电脑变成web服务器。内网主机作为web服务器,内容包括本地内网映射、多层内网映射解决方案、绕过电信80端口封锁、DDNS功能的实现(非...

电脑怎么改Wi-Fi密码(电脑怎么改wifi密码视频教程)

一.电脑打开“任意浏览器ie/google浏览器等”——>地址栏里输入管理ip地址然后按“回车键”打开该地址,如下图所示。二.输入正确的管理员密码——>点击“登录”即可(下图是PC版本的路...

旧路由器不要扔,可当电脑无线网卡使用,你还不知道吧!

家里有旧路由器,卖二手又不值钱,扔了又可惜。想不到路由器还有以下这些功能:扩大Wifi覆盖范围;充当电脑无线网卡;把这个技巧学起来,提升网络冲浪的幸福感!导航栏路由器恢复出厂设置(通用教程)有线桥接无...

硬件大师AIDA64 5.60.3716更新下载:“认准”Win10

著名硬件测试工具AIDA64更新至5.60.3716Beta版,本次更新修复了Win10Build版本号检测错误问题,识别更准确。另外还添加了对ITEIT8738F传感器、ASRock主板、NVI...

互联网病毒木马与盗版软件流量产业链(一)

A.相关地下产业链整体深度分析可能很多用户都有这样的经历,就是不管打开什么网站,甚至根本就没有打开浏览器,都会跳出来一堆的弹窗广告。那么,这个用户要么是中的病毒木马,或者是使用了盗版软件。不管是...

穿越火线tenparty.dat文件损坏怎么办?

很多玩家在玩火线的时候经常会因弹出错误代码,而被退出游戏。下面就教大家一些常见错误代码的解决方案。方法/步骤1SX提示码提示说明:您的电脑出现1,xxx,0(xxx代表任意数字)提示码,存在游...

办公小技巧015:如何关闭Windows Defender安全中心

WindowsDefenderWindowsDefender是Widows中自带杀毒软件,可以检测及清除潜藏在操作系统里的间谍软件及广告软件。为电脑提供最高强度的安全防护,也被誉为Windows的...

Win7/8.1/10团灭:微软发现严重漏洞

据外媒报道称,微软已经停止为Windows7发布新的安全更新了,理由是IE存在严重漏洞。存在严重漏洞的IE按照微软的说法,这个远程代码执行漏洞存在于IE浏览器处理脚本引擎对象的内存中。该漏洞可能以一...

WinCC flexible 2008 SP4 的安装步骤及系统要求

1、软件安装过程安装注意事项(必须严格遵守):软件仅支持以下操作系统(必须是微软原版的操作系统,Ghost版系统不支持,如番茄花园、雨林木风、电脑城装机版等):WinCCflexible2008...

Windows三方杀毒防护软件可能问题以及使用建议

在处理ECSWindows相关案例中,我们遇到很多奇怪的操作系统问题,例如软件安装失败,无法激活操作系统,无法访问本地磁盘,网络访问受到影响,系统蓝屏,系统Hang等,排查发现这与客户安装的各类杀...

杀毒软件被指泄露个人隐私(杀毒软件查出来一定是毒吗)

最近的多篇报道显示,你使用的杀毒软件在监视着你,而不仅仅是你计算机上的文件。2014年的一项研究使用虚拟机监视了杀毒软件产品向企业发送了什么信息。他们发现,所有测试的杀毒软件都给电脑分配了一个唯一的识...

开源杀毒软件ClamAV在推出约20年后终于到达1.0版本

ClamAV是一个开源的反病毒引擎,用于检测木马、病毒、恶意软件和其他恶意威胁。与商业Windows反恶意软件程序相比,它的检测水平相当低,但开发工作已经持续了几十年。该工具可用于所有平台,尽管它主要...

【Excel函数使用】时分秒时间怎么转换成秒?(二)

本节主要分享的函数是IFERROR和NUMBERVALUE上回我们用MID和FIND函数已经将数值提取出来,但是一些错误的返回值显示“#VALUE!”,此时我们需要检验错误返回值,并将错误值返回指定值...

取消回复欢迎 发表评论: