和风微醺,人间四月天。 记于 2019.4.1 早10:31分
一直都想给自己开一个博客写一点技术类的东西,但也一直疲于工作无暇他顾。恰近日突然的工作变动反而让自己得以略空闲,身心得以舒展并思考未来的路该怎么走。这个开博事宜又悄然上心头, 所谓想及千百遍不如实际走一遭。 交代好前言即将准备写下这篇技术博文,回顾10年多职业生涯在诸多技术主题里也思绪了片刻,突然也有点懵逼哈。写啥?怎么写?如何写的以词达意。。。 在片刻犹豫之后意上心头:<论如何编写出与JVM自适应优化所契合的代码>。 嗯,如大家所想这个标题范围有点广,似乎有点嗨,应该是一篇逼格略高但也许不知所云的文章。。。权且先这样定个基调吧。
切入正题, 这是一篇源于实际生产事故一篇实操性质的技术博文。一切源于工作,如有雷同实属巧合。问题背景: 朋友所在公司开发一款新功能涉及大文本前端展示, 文本主体内容后台编辑留有在占位符, 服务端填充占位输出客户端展示,流程大致如此。在自测/内测过程中都挺OK,毕竟就是很简单的功能开发。上线后也相安无事了一段时间,但后面发生的事情就较为诡异了。由于市场部门推广,此功能受众面迅速扩大,访问量相比未推广前曾几何级递增且服务器稳定性出现波动,经常性OOM且需要重启才能解决。朋友公司所在技术团队,根据OOM的堆转存储文件分析发现:OOM发生时,堆内存活着大量的String对象 , char[]数组,占据好几GB的堆内存,大大超过其他存活对象所占据的内存。
问题看起来貌似很明朗了,实际好像也是这样。朋友所在技术团队经过模块排查逐步定位,基本确定了最近上线的一系列功能模块里, 大文本前端展示为首要原因,这个模块贡献了大量的存活且无法有效释放的char[]数组。问题原因似乎找到了,但为何会是这样的呢? 于是乎针对这个功能的二次代码review如影随形的开展了。代码逻辑如下:网页客户端发起请求--->Controller层[接受客户端请求]--->Service层[根据业务逻辑填充占位符]--->DAO层[读取后台编辑后存储在数据库中的大文本]数据载体String对象就这样层层向上传递最终输出到客户端。因为代码着实简单review完也没看出具体问题。示例代码:
```
public String getBigText(final String tId) throws SQLException {
ResultSet result = null; //这里null为模拟获取结果集.
String bigText = result.getString("content");
return bigText;
}
```
先说下解决方案吧,问题很简单却也挺恼人。我们可以采用intern方法字符串池化, 对于大量重复的字符串使用得当可以节约内存。当然大文本渲染的方式的也需要发生变化,填充数据需要异步读取显示。示例代码:
```
public String getBigText(final String tId) throws SQLException {
ResultSet result = null; //这里null为模拟获取结果集.
String bigText = result.getString("content").intern(); //字符串池化
return bigText;
}
```
回到文章的开头貌似与<论如何编写出与JVM自适应优化所契合的代码>这个大标题一点关系都没有啊。在解决沟通的过程中提及了两个概念: 1.针对大量重复字符串,不采用其他技术方案的情况下使用intern可以最小化代码改动并达到低内存占用的目标 2. intern后GC Root引用链是如何被切断的,它是对象内存可被快速释放的关键要素 [备注:GC Root引用链,即JVM对象引用判断-根检索算法链式模型]。
Linux之父有一段名言:talk is cheap, show me the code! 示例代码如下(这段代码摘自网络,单纯用来演示这项技术):
```
package util;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
JVM参数:
-Xmx4096m
-Xms4096m
-XX:+PrintGCDetails
**/
public final class StringIntern {
private static final int MAX = 1000 * 10000;
private static final String[] arr = new String[MAX];
public static void main(String[] args) throws Exception {
TimeUnit.SECONDS.sleep(20); //这里暂停线程,挂载JVM分析器
Integer[] DB_DATA = new Integer[10];
Random random = new Random(10 * 10000);
for (int i = 0; i < DB_DATA.length; i++) {
DB_DATA[i] = random.nextInt();
}
for (int i = 0; i < MAX; i++) {
arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
// arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern(); //intern,切断GC 引用链
}
System.out.println("执行完毕,可以开启JVM堆分析了...");
TimeUnit.SECONDS.sleep(600); //这里暂停线程,挂载JVM分析器
}
}
```
我们先来一段由性能剖析软件发起的针对上面代码执行中, 强制GC前后的内存占比.
GC前:
GC后:
如图所示,Full GC后大量的String对象与char[]数组依然存活,由此我们根据JVM内存清理算法可以推导出这些对象与数组存在强引用。不知是否有注意int[] 数组在GC后由138MB释放到了21423kb,原因即方法执行结束调用已出栈没有被强引用所持有,所以在可释放的范围内。我们再接着看提到多次的GC Root引用链到底是什么鬼,见下图:
我们仔细看标蓝的部分,剖析软件基于此字符串对象根据根检索算法正向与反向推导出完整的GC Root引用链。在此情况下回到文章开头部分,大文本客户端渲染在用户访问量大的情况下,对大数据字符串的引用直到完成输出到客户端前都会存在强引用,但凡出现OOM也只有重启一条路可以走了。。。
-------------------------------华丽分割线-----------------------------------------
问题已经分析的接近尾声了,我们再聊下阻断GC Root链后会发生什么。把代码intern 注释去掉,把上一行代码加上注释, 如下:
```
// arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
```
GC前的图一样,就不重复贴了,直接上GC后的图:
小伙伴们惊讶么? 一次GC直接释放完毕了。。。这个时候对方法的调用尚未结束,因为线程sleep了。再引申一个技术点吧,N多的技术书籍都在传授栈内存会随着线程的消亡而释放,其实这句话原则上是对的,但如果非要死扣细节其实我愿意加上:如果方法执行过程中,数据已经出栈且不存在强引用,GC即可释放分配在堆中的无引用对象而无需等到方法全部执行完线程退出后。
题外话: 虽然intern方法是个好东西但是使用要慎重,起码你要真的懂才行。否则引起永久代内存溢出或者青年代GC耗时增大等问题也是非常头疼的且更隐晦! JVM常量池内部是一个HashSet数据结构,有容量的限制有参数可以控制,高版本的JDK貌似具备自动resize的功能,个人没做更多版本差异性研究,大家有兴趣可以自行研究。
题外案例:
1. 国外著名的微博twitter也曾经遇到过字符串高内存占用的问题,部分技术博客亦有提及,也是采用intern解决的。
2. 阿里fastJson框架也曾在intern方法上跌过坑,有兴趣可搜索学习。
JVM案例:
```
/*
* Private remove method that skips bounds checking and does not
* return the value removed.
*/
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
}
```
以上代码源自JDK1.8 ArrayList remove方法, elementData[--size] = null; 此处调用即为引用链阻断。 使得对象引用不可达最终被GC回收,避免了内存泄露。最后,由于intern调用将引用指向了常量池,所以new String创建的对象被阻断了。。。无引用持有,保证了GC回收。
写在最后,这是我的第一篇技术博客如有写的不对的地方,麻烦留言指正喔,在此非常感谢。
2019.4.1 晚 19:16分 geweixinerr, End。