问题背景:
在本期的开发中,为了向客户的API接口每天定时发送订货的数据(订货的数据量较大,大概有800多家门店,平均每家店铺每天大约有100条左右的订货数据),数据总量大约是10万左右。这这些数据我们需要每天都在某一个时间点一次性发送给对方。
开发设计概要:
在设计时我们我们采用了一个开源的分布式任务调度框架XXL-JOB,通过简单明了WEB页面来操作我们的数据发送任务。
程序概要:
1.查询获取发生订货的所有门店ID;
2.循环门店ID来获取门店的订货信息,并发送
问题出现发生:
在开发过程中,我们都是在测试数据库中进行的。门店数较少,订货数据也只有几十条。顺利开发出来后,把程序放在模拟真实环境的服务器上,第一天程序正常运行,在晚上下班前还登录XXL-JOB的控制页面查看了一遍一切正常,到此我以为这个项目也就算是完成了。瞬间感觉轻松了许多,回家的脚步都轻快了不少。
第二天早上来上班后,我想看看昨晚程序的运行是否稳定,准备打开调度控制页面。然而,当我在浏览器中输入URL回车后,页面加载了2秒,没有出来。顿时我心中一紧,果然最终页面提示我“无法访问此页面”。
通过查看服务器的的Log,在早上7点半时程序报了内存溢出错误。同时这时也把我的服务程序给停掉了。
分析问题:
从报内存溢出的错误,我们能很明确的得到导致程序死掉的原因肯定是,我的程序在运行过程中占用了大量的JVM创建的堆内存,导致。
于是我百度了一下,导致内存溢出的原因可能有如下几种:
1.内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3.代码中存在死循环或循环产生过多重复的对象实体;
4.使用的第三方软件中的BUG;
5.启动参数内存值设定的过小;
这时首先想到的可能就是增加JVM的堆内存空间,但这并非根本原因。根本问题还是代码出来问题。
//逐个门店按要货时间进行发送订货数据
List<Map<String, Object>> allOrderedStore=allOrderedStore(sysDate); for (int i = 0; i < allOrderedStore.size(); i++) { String store_id=allOrderedStore.get(i).get("store_id").toString(); String receive_date=allOrderedStore.get(i).get("receive_date").toString(); //所有门店订货信息 List<Map<String, Object>> storeOrderDetails = jdbcSourceDAO.getOrderItemList(sysDate,store_id,receive_date); storeOrder(store_id,receive_date,storeOrderDetails); }
回顾我的代码,我是按照门店来取数据进行逐个发送的,如此我就循环创建了很多List对象,用来存放门店的订货数据,或许是我太错频繁的创建,同时又没有及时清空,而导致GC回收机制没有及时回收,导致内存溢出呢?
垃圾回收机制:
在生活中,所谓垃圾,是指我们不需要的、不在使用的物品,这些物品挤占了我们的所需要的空间,
而让我们有丢弃它,来腾出更多空间的想法。
这段对垃圾的理解中有两个非常重要的基准,一个是物品,一个是空间。
1.堆内存:
引申到java中物品对应就是“对象”,而空间就是“java堆内存”(JVM启动时创建的,主要用来维护运行时的数据);
此处再丰富一点,栈内存:用来存放一些基本类型的变量和对象的引用变量。
2.垃圾:
在java内存中的垃圾是指,不再存活的对象。而判断对象是否存活主要有两种方式:
a.引用计数:
为每一个创建的对象分配一个引用计数器,当该对象被引用时则计数器加1,去掉引用时计数器减1,如此反复。当计数器值为0时,则判定该对象“死亡”。但这种方案存在一个严重的问题——无法监测“循环应用”,当两个对象互相应用时,即使它没有被外界任何对象引用,它俩的引用计数也永远不能为零,如此它两就会永远存活。
b.可达性分析:
把所有的引用对象想象成一棵树,从树的根节点GC Roots 出发,持续遍历找出所有连接(引用)的树枝对象,这些对象则是“可达”的对象,是存活的,而那些不能被遍历找到的则是不“可达”的,是死亡的。
参考下图,object5,object6 和 object7 便是不可达对象,视为“死亡状态”,应该被垃圾回收器回收。
GC Roots:
首先,我们从字面上去看,GC Roots 这个根用的是“roots”是一个复数,我们不难猜测根对象是有多个的,而非唯一的,这也就说引用对象“树”有多棵。主要的根对象有四种:
● 虚拟机栈(帧栈中的本地变量表)中引用的对象。
● 方法区中静态属性引用的对象。
● 方法区中常量引用的对象。
● 本地方法栈中 JNI 引用的对象。
3.垃圾回收:
通过上面的简单介绍,已经知道了那些对象是垃圾了,现在我再来找找工具来打扫垃圾。
如下图:
图1
绿色块表示可用的空间,灰色表示存活对象占用的空间,黑色表示垃圾对象占用的空间
垃圾回收的3种算法:
a.标记清理:
第一步,所谓“标记”就是利用可达性遍历堆内存,把“存活”对象和“垃圾”对象进行标记,得到的结果如图1;
第二步,既然“垃圾”已经标记好了,那我们再遍历一遍,把所有“垃圾”对象所占的空间直接 清空 即可。
结果如下:
图2
这是标记清理,简单方便,但容易产生内存碎片。
b.标记整理:
为了弥补标记清理算法容易产生内存碎片,我们在对垃圾对象进行清理前做一次整理。把所有的存活对象移动到一起,这样清理垃圾后就不会产生内存碎片了。
结果如下:
标记清理与标记整理没有对存活对象做太多操作,所以这两种方法适合对存活对象多,垃圾对象少的场景。
c.复制清空
这种方式简单粗暴,直接把内存分为两部分,一段时间内只允许在其中一块内存上进行分配,当这块内存被分配完后,则执行垃圾回收,把所有 存活 对象全部复制到另一块内存上,当前内存则直接全部清空。
结果如下:
开始时只使用上面部分的内存,直到内存使用完毕,才进行垃圾回收,把所有存活对象搬到下半部分,并把上半部分进行清空。
这种做法不容易产生碎片,也简单粗暴;但是,它意味着你在一段时间内只能使用一部分的内存,超过这部分内存的话就意味着堆内存里频繁的 复制清空。
因为这种算法需要把存活的对象从工作区复制到存储区,这种方案适合 存活对象少,垃圾多 的情况。
知道了有三种算法可以进行垃圾回收,那么java是选择的那种算法进行垃圾回收呢?
因为上面的三种算法都有自己的适用场景,所以java也应该分场景来选择回收算法。
但标记清理容易产生内存碎片,因为这个缺陷所以一般情况下应该不会选择,那么就只剩下了标记整理和复制清理了。
标记整理适合存活对象多的场景,复制清理适合存活对象少的场景。那么java程序在何时存活对象多,在何时存活对象少,又有那些对象是永远存活着的呢?
回归我们的程序,一般程序中都会有大量的局部变量,和相对较少的全局变量。因此在程序开始运行时一定会产生很多新的存活对象,但是其中大部分的存活时间都不会太长,没多久就会变为不可达的对象,快速死去,而另一些对象则会存活沉淀下来,存活相对长的时间,而另一些对象,比如静态文件,这些对象的特点是不需要垃圾回收的。
根据这些特性,前辈们将java的堆内存空间做了一个简易的划分。
新生代:刚刚创建的对象,被统一的放在一个区域。(存活对象少,垃圾多) 适合复制清空算法
老年代:存活一段时间的对象,被统一放置的区域。(存活对象多,垃圾少) 适合标记整理算法
元空间:永久存在的对象统一放置的区域。
标记整理较好理解,但复制清空算法,由于涉及到内存空间的划分,所以相对理解的费劲点。如下是对复制清空算法的深入探究。
在对内存空间进行划分时最容易让人想到的就是对半分,我们把新生代的内存空间按1:1来进行如下图的划分
每次只使用一半的内存,当这一半满了后,就进行垃圾回收,把存活的对象直接复制到另一半内存,并清空当前一半的内存。
这种分法的缺陷是相当于只有一半的可用内存,对于新生代而言,新对象持续不断地被创建,很可能很快就占满了Eden内存而内存被占满后就必须进行垃圾回收了,显然持续不断地进行垃圾回收工作,反而影响到了正常程序的运行,得不偿失。
针对原始形态图示意中的Eden内存太少,而survivor内存空间又有富余,那么我们就来增加Eden的比例吧,让Eden空间占9成。如下图:
最开始在 9 的内存区使用,当 9 快要满时,执行复制回收,把 9 内仍然存活的对象复制到 1 区,并清空 9 区。
这样看起来是比上面的方法好了,但是它存在比较严重的问题。
当我们把 9 区存活对象复制到 1 区时,由于内存空间比例相差比较大,所以很有可能 1 区放不下从9区过来的存活对象,此时就不得不把对象移到 老年区 。而这就意味着,可能会有一部分 并不老 的 9 区对象由于 1 区放不下了而被放到了 老年区 ,可想而知,这破坏了 老年区 的规则。或者说,一定程度上的 老年区 并不一定全是 老年对象。
那应该如何才能把真正比较 老 的对象挪到 老年区 呢?
既然如此,那么我们在分一个区域(SurvivorB)出来存放可能会被放入老年区的存活对象,如果一个对象在该区域存活了一定的次数(通常是15次),则我们认为这是一个正在的老年对象,此时我们便把它放入老年区。
分区图如下:
此时再回到前面的问题,由于我是在短时间内创建了大量的占用大内存空间的对象,且此方法的栈帧长时间也不能被回收(局部变量表中没有复用的操作);故此时我只需要在对象创建使用后手动清空,再调用GC.代码如下:
storeOrderDetails.clear();
System.gc();
通过此方法,暂时解决了我的内存泄漏问题。