hi,大家好我是东哥,相信大家在校招以及社招面试过程中,肯定经历过如下情形
面试官:“项目开发过程中遇到过什么性能问题,内存问题吗?如何解决的呢?”
我:没有遇到过什么性能问题呢,就是写写后端接口,遇到的问题通常百度一下搜一搜
面试官:这小子还挺实诚。。。
其实对于大多数同学来讲,上述回答也是迫不得已,“被迫实诚”罢了,尤其是对于校招同学,简历上的项目没有真正的投入使用,没有过性能优化的经历,更是无从谈起。个别细心的同学会在在网上随便搜搜文章,当做自己的性能优化经历,但是总归没有实际操刀,有点胆怯,细问也容易露馅。
老天爷呀,真的就没有什么好办法了吗?不要慌,东哥在工作后再重新看《深入理解Java虚拟机》的部分章节时,有了新的感悟,之前总是当做八股文去看,没有与实际的工作相结合,后续东哥决定做一个系列,把工作多年遇到的问题抽象一下并结合理论整理成文章,抛砖引玉,发出来供大家参考
那么,性能问题是什么?或者说如何判断服务出现了性能问题呢?
一般是服务遇到了瓶颈、接口响应时间不符合预期、甚至说在某次代码变更之后服务出现GC时间过长,接口平均响应时间出现抖动等情况,总之通过服务监控以及告警(对于校招同学后面可以单独出一篇文章,介绍有哪些工具可以监控服务指标),发现了服务各项指标偏离预期,这时候就需要去分析问题,优化服务性能。
对于Java服务**,性能优化和内存优化是密不可分的**,服务遇到性能问题,大概率和内存有关。比如服务瓶颈后,通过调整最大堆内存、升级机器配置等,便发现性能问题解决了,美滋滋,原来优化这么简单!当然升级机器配置只是短期方案,甚至如果机器的内存已经设置的足够大了,再升级机器配置就要考虑成本问题了
东哥在工作中发现,性能问题通常并非空穴来潮,一般遇到性能问题,除QPS快速增长外,一般是服务最近有代码变更且代码中存在不合理的地方,东哥今天就来分享一个实际案例
在介绍案例之前,东哥先带大家回顾一下Java的内存模型
一、Java内存模型
Java虚拟机内存划分:
- 方法区:用于存储被虚拟机加载类的元数据信息,常量(字面量和符号引用),静态变量,即时编译器编译后的代码缓存等;
- 类的元数据信息:主要包括类的名称,访问修饰符,父类,字段信息、方法信息等
- (方法区)常量:主要指的是字面量和符号引用,有的小伙伴可能下意识的想到如下的代码:
public class MemoryStruct { private final int intErrorNum = 0; }
注意:上面代码中的intErrorNum 不是方法区中的“常量”,尽管intErrorNum 是final常量,但是它是对象中的一部分,对象是存放在堆区中的。
下面代码中定义的数据,才会被放到方法区中当做常量存储
public class MemoryStruct {
public void test() {
//数值字面量
int intNum = 10;
float floatNum = 10.0f;
long longNumber = 100L;
//布尔字面量
boolean flag = true;
//字符串字面量
String str = "Hello world";
}
}
注:int intNum = 10; 其中“10”是一个数值字面量,它在编译期间便可以确定,所以当做常量存放在方法区中;而intNum是一个局部变量,存放在虚拟机栈中
- 静态变量:不属于任何实例对象,而是属于MemoryStruct类本身,存储在方法区中
- 即时编译器编译后的代码缓存:当JVM运行Java程序时,会首先将Java 字节码解释执行。但是对于一些热点代码(即被频繁执行的代码),JVM会使用即时编译器(Just-In-Time compiler)将这部分字节码编译成本地机器代码并缓存放在方法区中,这样当再次执行这部分热点代码时,JVM便可以直接使用缓存的机器代码,提高执行效率
2. Java堆:虚拟机所管理的最大的一块内存,基本上所有的“对象实例”均在Java堆分配内存
3. 虚拟机栈:执行Java方法时,虚拟机创建一个“栈帧”,栈帧用于存储该Java方法的局部变量,方法的出口信息等
4. 本地方法栈:与虚拟机栈类似,只不过是为“非Java”方法服务,比如在Java服务可以通过JNI调用C++代码,这里用到的便是本地方法栈
5. 程序计数器:当前线程所执行的字节码的行号指示器,通过改变“程序计数器” 的值来选取下一条需要执行的字节码指令,程序计数器占用空间很少
上面我们简单介绍了Java的内存模型,那究竟和内存优化有什么关系呢?
二、为什么会出现内存问题,不是有垃圾回收器吗?
大家都知道Java有自己的垃圾回收器,来对“死去的对象”进行回收,那为什么还会有内存问题呢?对于这个问题我们首先要搞清楚垃圾回收器是回收的是哪部分内存的对象,如何进行回收?
垃圾回收器回收的是“已经死亡的对象”,那么如何判断对象“已死”,从而进行回收呢?主流的方法是“可达性分析算法”,其基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集合,从该集合的起始节点出发,根据引用关系向下搜索,搜索走过的路径称为“引用链”,如果堆内存中某个对象与GC Roots间没有任何引用链相连,则该对象“已死”,可以被垃圾回收器处理。
那么GC Roots中对象包括哪些呢?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性应用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- Java虚拟机内部的引用,比如基本数据类型对象(Integer,Long等),常驻的异常对象(NullPointException)以及系统类加载器
举个通俗易懂的例子,GC Roots中的元素都是“黑帮大佬”,凡是和黑帮大佬有关系的“古惑仔”,垃圾回收器都不能招惹它,即无法回收这些被“黑帮大佬”罩着的古惑仔们。
问题就在这里,假如堆内存中大部分对象均和GC Roots中的起始节点有关系,那么垃圾回收器无法进行回收,这时就有可能出现内存或者性能问题了。
出现这种现象的原因,有可能就是代码存在不合理的地方,导致某段时间内,GC Roots集合中的“黑帮大佬”过多了且“黑帮大佬”在堆内存中有大量的“古惑仔”小弟,垃圾回收器表示也无能为力,自己势单力薄,无法对“古惑仔”小弟进行强制回收。
三、实际案例
在了解上述内容后,下面这段抽象后的伪代码,有哪些不足之处呢?
public void processData() {
//(1)-从存储中获取北京所有学校的相关数据
List<SchoolDataStore> schoolDataStoreList = getDataFromRedis();
//(2)-业务逻辑1: 对上述查询得到的数据进行格式化,删除冗余字段,减少数据量
List<FormatData> formatDataList = processStoreData(schoolDataStoreList);
//(3)-业务逻辑2: 对上述格式化后的数据进行处理, 得到相关学生的信息
List<StudentInfo> studentInfoList = convert(formatDataList);
//(4)-存储学生信息
saveData(studentInfoList);
}
假如代码(1)从存储中获取到北京所有学校的数据,数量量较大,大约500M左右(存放在Java堆内存中),那么schoolDataStoreList 作为局部变量(在虚拟机栈中)其生命周期会在执行完processData()方法后才结束,schoolDataStoreList自然是属于GC Roots集合,也就是说在整个processData()方法执行期间,schoolDataStoreList 手下的500M大小的“古惑仔”小弟就持续猖狂,因为垃圾回收器没有能力回收他们。
这时假如代码(3)、(4)业务执行逻辑也相对比较复杂且占用内存较高,那么服务在QPS增长后,堆内存有可能会出现比较紧张的情况,服务便会GC来尽量获取足够内存,但是在processData()函数执行期间schoolDataStoreList 依旧在GC Roots集合中,与其相关联的堆内存依旧无法释放,然后服务再次GC企图获取足够内存,陷入死循环,导致服务出现频繁GC或者单次GC时间变长,导致系统出现性能问题
本来代码(3)、(4)并没有直接使用schoolDataStoreList,也就是说在执行代码(3)、(4)时,与schoolDataStoreList所关联的堆对象本应该是可以被回收的,仅仅是因为和GC Roots中的某些元素扯上了关系,从而导致无法被回收
因此可以对上述代码简单改造一下,变成下面这样:
public List<FormatData> processData1() {
//(1)-从存储中获取某地区所有学校的相关数据
List<SchoolDataStore> schoolDataStoreList = getDataFromRedis();
//(2)-业务逻辑1: 对查询得到的数据进行格式化,删除冗余字段,减少数据量
return processStoreData(schoolDataStoreList);
}
public void processData2() {
List<FormatData> formatDataList = processData1();
//(3)-业务逻辑2: 对上面处理过的数据, 进行处理, 得到相关学生的信息
List<StudentInfo> studentInfoList = convert(formatDataList);
//(4)-存储学生信息
saveData(studentInfoList);
}
这样在执行完processData1()方法后,schoolDataStoreList作为局部变量便弹出虚拟机栈,也不再属于GC Roots集合,因此堆内存中与schoolDataStoreList 有引用关系的数据,便可以被垃圾回收器所回收
注:当然在实际的相关查询服务中,一次查询的数据量一般不会超过500M,这里东哥只是举个例子,让大家明白实际写代码过程中,可以通过拆分函数的方式,减少GC Roots集合中元素数量,避免大量垃圾对象没有即时回收从而造成内存不足而进一步导致性能问题。