一、问题背景
根据业务需求做了一个接口,主要功能是根据查询规则在数据库中查出数据,然后生成文件,校验文件,压缩文件,之后再把这三个文件上传到指定服务器。但由于数据量大,平时1kw+峰值则4kw+,虽然处理性能要求不高,但也不能跑太慢。按常规方法实现后跑1500w数据大概需要半个小时,还算符合业务需求。这两天数据量大,跑到2200w的时候出现了OOM。
二、问题处理
OOM的出现主要有两点,1.是内存真的不够了,执行的时候需要扩大堆内存。2.程序中出现了死循环有实例持有大量new对象得不到释放。针对这两点进行排查。
首先看下出问题的代码块
//生成文件
public boolean loadData(EtlRule etlRule) throws IOException, SQLException {
if (etlRule.getCreate_sql().trim().equals(""))
logger.error("\"error: 配置中包括查询语句为空[create_sq;]\"");
long FILE_UNIT_SIZE = 1024 * 1024 * 1024;
String selSql;
Connection destCon = null;// 源数据库连接对象
Statement stmt = null;
BufferedWriter output = null;
long rowCount = 0L;
int colCounts = 0;
long flen = 0L;
ResultSet res=null;
int closeNum = 5000000; //每五百万条记录关闭一次输入流,目的是清空堆内存。
try {
//初始化数据库连接
EtlDBConnect etlDBConnect = new EtlDBConnect();
destCon = etlDBConnect.initConfig2(destCon);
if (etlRule.getCreate_sql().indexOf("${SRC_TABLE}") > 0) {
selSql = etlRule.getCreate_sql().replace("${SRC_TABLE}", etlRule.table_name);
}else {
selSql = etlRule.getCreate_sql();
}
logger.info("数据查询:sql=[" + selSql + "]");
//生成的文件个数
int fileCount = 1;
//向dataPath中添加路文件径数据
String dataPathArr = etlRule.getLocal_path() + "/" + etlRule.getFile_name().replace("${NUM}",
"001");
etlRule.getDataPath().add(dataPathArr);
File file = new File(etlRule.getDataPath().get(fileCount-1));
if (!file.exists()) {
file.createNewFile();
//System.out.println("ORA-000 数据文件创建失败: " + etlRule.getDataPath().get(0));
//return false;
}
logger.info("[*输出数据文件*]:[" + etlRule.getDataPath().get(0) + "]");
assert destCon != null;
//stmt = destCon.createStatement(ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_READ_ONLY);
stmt = destCon.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
logger.info("[查询主表] sql==========" + selSql);
res = stmt.executeQuery(selSql);
//preFetchRowNum 为配置文件中的值
res.setFetchSize(5000); //设置预处理行数
int fetchSize = res.getFetchSize();
System.out.println("------默认fetchSize预处理行数:"+fetchSize);
if (res == null || !res.next()) {
logger.error("数据文件sql无数据!");
return false;
}
res.previous();
output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true),
StandardCharsets.UTF_8),1024);
//StringBuilder line = new StringBuilder();
String str = "";
// 获取字段元信息
ResultSetMetaData rsmd1 = res.getMetaData();
colCounts = rsmd1.getColumnCount();
//Date date = new Date();
while (res.next()) {
// 打印进度
rowCount++;
if (rowCount % printUnit == 0) {
//20w条数据的时候打印一条提示信息
//logger.info(rowCount + " ----rows proceed");
System.out.println(rowCount +" ----rows proceed");
}
//获取所有列对应的数据
for (int i = 1; i <= colCounts; i++) {
//line.append(res.getString(i)).append("\n");
str = res.getString(i)+"\n";
}
//读取一条查询结果,写入一次
output.write(str);
output.flush();
str = "";
//如果文件长度大于2G,建立新文件,最多不会超过9个-------c中40亿的定值超int范围了
if (file.length() >= (FILE_UNIT_SIZE * 2)) {
//如果存在${NUM},则生成新的文件,不存在就继续向原文件中写入
if (etlRule.getFile_name().contains("${NUM}")){
output.close();
fileCount++;
flen = file.length();
//添加记录数
etlRule.getDataCount().add(rowCount);
etlRule.getDataSize().add(flen);
String newFile = etlRule.getLocal_path() + "/" + etlRule.getFile_name().replace(
"${NUM}", "00" + fileCount);
etlRule.getDataPath().add(newFile);
file = new File(etlRule.getDataPath().get(etlRule.getDataPath().size() - 1));
if (!file.exists()) {
file.createNewFile();
}
output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, false), StandardCharsets.UTF_8));
}
}
//logger.info("\t"+ String.valueOf(rowCount) +"\t\t\trows processed");
}//while
//数据写入字段
flen = file.length();
etlRule.getDataCount().add(rowCount);
etlRule.getDataSize().add(flen);
} catch (Exception | Error throwables) {
System.out.println("捕获到错误:"+throwables.getMessage());
System.out.println("规则运行标识复位。");
ruleConfigReset(etlRule);
//throwables.printStackTrace();
}finally {
if (output != null) {
try {
//刷新缓存区
output.flush();
output.close();
} catch (IOException e) {
logger.error("关闭输出流异常:{}", e);
}
}
//关闭数据库连接
assert res != null;
res.close();
stmt.close();
destCon.commit();
destCon.close();
}
return true;
}
通过代码块的分析,把问题锁定在了while()循环中,因为只有这里可能出现死循环,或者持有new对象过多的情况。通过进一步查找锁定在了
output = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true),
StandardCharsets.UTF_8),1024);
//读取一条查询结果,写入一次
output.write(str);
output.flush();
那么继续追踪BufferedWriter的write()方法
public void write(String str) throws IOException {
write(str, 0, str.length());
}
writre(String str)中又调用了重载的另一个write(String str, int off, int len)方法
public void write(String str, int off, int len) throws IOException {
synchronized (lock) {
char cbuf[];
if (len <= writeBufferSize) {
if (writeBuffer == null) {
writeBuffer = new char[writeBufferSize];
}
cbuf = writeBuffer;
} else {
// Don't permanently allocate very large buffers.
cbuf = new char[len];
}
str.getChars(off, (off + len), cbuf, 0);
write(cbuf, 0, len);
}
}
通过源码的查看,我们先找到几个变量的定义
/**
* The object used to synchronize operations on this stream. For
* efficiency, a character-stream object may use an object other than
* itself to protect critical sections. A subclass should therefore use
* the object in this field rather than <tt>this</tt> or a synchronized
* method.
* 用于同步此流上的操作的对象。为了提高效率,字符流对象可以使用其他对象来保护临界区。
* 因此,子类应该在这个字段中使用对象,而不是<tt>这个</tt>或同步方法。
*/
protected Object lock;
/**
* Temporary buffer used to hold writes of strings and single characters
* 用于保存写字符串和单个字符的临时缓冲区
*/
private char[] writeBuffer;
/**
* Size of writeBuffer, must be >= 1
*/
private final int writeBufferSize = 1024;
下面进行write(String str, int off, int len) 方法的解读
1.synchronized (lock) 我就不班门弄斧了,因为我平时都没这么用过,只是知道一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。
2.当len长度小于writeBufferSize时writeBuffer = new char[writeBufferSize];
反之cbuf = new char[len];
3.追踪str.getChars(off, (off + len), cbuf, 0);
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
if (srcBegin < 0) {
throw new StringIndexOutOfBoundsException(srcBegin);
}
if (srcEnd > value.length) {
throw new StringIndexOutOfBoundsException(srcEnd);
}
if (srcBegin > srcEnd) {
throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
}
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}
此方法的作用就是把一个数组中某一段字节数据放到另一个数组中。至于从第一个数组中取出几个数据,放到第二个数组中的什么位置都是可以通过这个方法的参数控制的。
4.追踪write(cbuf, 0, len);发现它是个抽象方法
abstract public void write(char cbuf[], int off, int len) throws IOException;
由此可知,write(cbuf, 0, len)是给子类或接口实现特定功能用的,没有用到的话可以忽略。
三、分析
经过上面一波点点点,我们发现了,无论任何情况下只要调用BufferedWriter的write()方法就会new一个char[],这样的话如果数据量很大,就会造成OOM,原因是实例持有的对象不能得到及时的释放。
四、解决办法
1.增大堆内存。
2.优化代码。
第一种方法只能解一时之痛,因为数据量是不确定的,盲目增大堆内存不仅浪费资源,而且可能影响到其他程序运行。
linux下可以用此命令来查看jvm运行时的默认最大堆内存空间
java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
注意下 java HotSpot 是Server 还是Client 因为两种模式下堆内存的默认分配有些许不同。一般来说开发环境下都是Server 模式
//win下可以采用Runtime.getRuntime()下的方法来测试
public class jvm {
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
long usableMemory = maxMemory - totalMemory + freeMemory;
System.out.println("可获得的最大内存是:"+maxMemory/(1024*1024));
System.out.println("已经分配到的内存大小是:"+(totalMemory/(1024*1024)));
System.out.println("所分配内存的剩余大小是:"+(freeMemory/(1024*1024)));
System.out.println("最大可用内存是:"+usableMemory/(1024*1024));
}
}
//如果懒得新建工程,可以直接新建个java文件,javac编译后直接运行class文件就行了。
第二种方法,优化代码,这个难度就根据具体情况来了。
根据我自己的情况,每当数据读取500w条的时候就重启一次BufferedWriter,目的是释放先前占用的堆内存。其他方法暂时没想到,修改后的代码已经提交给测试了,不知道大数据量下能不能跑通,如果我后续没更新,就应该是没问题了。
五、自说自话
这里我只提供了一个排查的思路,OOM问题还得根据自己代码的具体情况来分析。
这里提供另一篇OOM排查案例,供大家参考
记录一次java.lang.OutOfMemoryError: Java heap space的问题处理【转载】