摘要:该项目是DAYU平台的数据开发(DLF),数据开发中一个重要的功能就是ETL(数据清洗)。ETL由源端到目的端,中间的业务逻辑通常由用户本身编写的SQL模板实现,velocity是其中涉及的一种模板语言。
Velocity模板语言的基本使用代码以下:java
在ETL业务中,Velocity模板的输出是用户的ETL SQL语句集,至关于.sql文件。这里官方提供的api须要传入一个java.io.Writer类的对象用于存储模板的生成的SQL语句集。而后,这些语句集会根据咱们的业务作SQL语句的拆分,逐个执行。linux
java.io.Writer类是一个抽象类,在JDK1.8中有多种实现,包括但不只限于如下几种:sql
因为云环境对用户文件读写建立等权限的安全性要求比较苛刻,所以,咱们使用了java.io.StringWriter,其底层是StringBuffer对象,StringBuffer底层是char数组。apache
简单模板Hellovelocity.vm:segmentfault
Welcome ${name} to velocity.com
today is ${date}数组
$one
Name: ${person.name}
Password: ${person.password}安全
package com.xlf;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List; public class HelloVelocity { public static void main(String[] args) { // 初始化模板引擎
VelocityEngine ve = new VelocityEngine(); ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath"); ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName()); ve.init(); // 获取模板文件 Template template = ve.getTemplate("Hellovelocity.vm"); VelocityContext ctx = new VelocityContext(); // 设置变量 ctx.put("name", "velocity"); ctx.put("date", (new Date())); List temp = new ArrayList(); temp.add("Hey"); temp.add("Volecity!"); ctx.put("list", temp); Person person = new Person(); ctx.put("person", person); // 输出 StringWriter sw = new StringWriter(); template.merge(ctx, sw); System.out.println(sw.toString()); }
}
(文件字数超出博客限制,稍后在附件中给出~~)
模板文件自己就379kb不算大,关键在于其中定义了一个包含90000多个元素的String数组,数组的每一个元素都是”1”,而后写了79层嵌套循环,循环的每一层都是遍历该String数组;最内层循环调用了一次:
show table;
这意味着这个模板将生成包含96372的79次方个SQL语句,其中每个SQL语句都是:
show table;
将如此巨大的字符量填充进StringWriter对象里面,至少须要10的380屡次方GB的内存空间,这几乎是不现实的。所以OOM溢出是必然的。
package com.xlf;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
import java.io.StringWriter; public class BigVelocity { public static void main(String[] args) { // 初始化模板引擎
VelocityEngine ve = new VelocityEngine(); ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath"); ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName()); ve.init(); // 获取模板文件 Template template = ve.getTemplate("BigVelocity.template.vm"); VelocityContext ctx = new VelocityContext(); StringWriter sw = new StringWriter(); template.merge(ctx, sw); }
}
控制台输出
Velocity模板生成的结果写入StringWriter对象中,如前面分析,其底层是一个char数组。直接产生OOM的代码在于java.util.Array.copyOf()函数:
package com.xlf;
import java.io.StringWriter; public class StringWriterOOMTest { public static void main(String[] args) {
System.out.println("The maximum value of Integer is: " + Integer.MAX_VALUE); StringWriter sw = new StringWriter(); int count = 0; for (int i = 0; i < 100000; i++) { for (int j = 0; j < 100000; j++) { sw.write("This will cause OOMn"); System.out.println("sw.getBuffer().length(): " + sw.getBuffer().length() + ", count: " + (++count)); } } }
}
环境:JDK8 + Windows10台式机 + 32GB内存 + 1TB SSD + i7-8700
若是你的硬件配置不充分,请勿轻易尝试!
StringWriterOOMTest运行时的整个进程内存大小在Windows任务管理器中达10300多MB时,程序中止。
char数组元素最大值不会超过Integer.MAX_VALUE,回事很是接近的一个值,我这里相差20多。网上搜索了一番,比较靠谱的说法是:确实比Integer.MAX_VALUE小一点,不会等于Integer.MAX_VALUE,是由于char[]对象还有一些别的空间占用,好比对象头,应该说是这些空间加起来不能超过Integer.MAX_VALUE。若是有读者感兴趣,能够自行探索下别的类型数组的元素个数。我这里也算是一点拙见,抛砖引玉。
经过上面一系列重现与分析,咱们知道了OOM的根本缘由是模板文件渲染而成的StringWriter对象过大。具体表如今:
前面分析过,出于安全的缘由,咱们只能用StringWriter对象去接收模板渲染结果的输出。不能用文件。因此只能在StringWriter自己去作文章进行改进了:
继承StringWriter类,重写其write方法为:
StringWriter sw = new StringWriter() { public void write(String str) { int length = this.getBuffer().length() + str.length(); // 限制大小为10MB
if (length > 10 * 1024 * 1024) { this.getBuffer().delete(0, this.getBuffer().length()); throw new RuntimeException("Velocity template size exceeds limit!"); } this.getBuffer().append(str); }
};
其余代码保持不变
package com.xlf;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
import java.io.StringWriter; public class BigVelocitySolution { public static void main(String[] args) { // 初始化模板引擎
VelocityEngine ve = new VelocityEngine(); ve.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath"); ve.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName()); ve.init(); // 获取模板文件 Template template = ve.getTemplate("BigVelocity.template.vm"); VelocityContext ctx = new VelocityContext(); StringWriter sw = new StringWriter() { public void write(String str) { int length = this.getBuffer().length() + str.length(); // 限制大小为10MB if (length > 10 * 1024 * 1024) { this.getBuffer().delete(0, this.getBuffer().length()); throw new RuntimeException("Velocity template size exceeds limit!"); } this.getBuffer().append(str); } }; template.merge(ctx, sw); }
}
若是velocity模板渲染后的sql语句集大小在容许的范围内,这些语句集会根据咱们的业务作SQL语句的拆分,逐句执行。
在后续逐句执行sql语句的过程当中,每一句sql都是调用的周边服务(DLI,OBS,MySql等)去执行的,结果每次都会返回给咱们的做业开发调度服务(DLF)后台。咱们的DLF平台支持及时中止做业的功能,也就是说假如这个做业在调度过程当中要执行10000条SQL,我要在中途中止不执行后面的SQL了——这样的功能是支持的。
在修改上面提到OOM那个bug并经过测试后,测试同窗发现咱们的做业没法中止下来,换句话说,咱们做业所在的java线程没法中止。
一番debug与代码深刻研读以后,发现咱们项目中确实是调用了对应的线程对象的interrupt方法thread.interrupt();去终止线程的。
那么为何调用了interrupt方法依旧没法终止线程?
package com.xlf; public class TestForInterruptedException { public static void main(String[] args) {
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10; i++) { sb.append("show tables;n"); } int i = 0; for (String str : sb.toString().split("n")) { if (i > 4) { Thread.currentThread().interrupt(); System.out.println(i + " after interrupt"); } System.out.println(str); System.out.println(i++); } }
}
TestForInterruptedException.main函数中作的事情足够简单,先产生一个大一点的字符串,拆分红10段小字符串,for循环中逐段打印小字符串;并企图从第5段(初始段为0)开始,去终止线程。结果发现线程并无终止!
这是怎么回事?为何调用了线程的interrupt方法并无终止线程?或者说是由于jvm须要一点时间去响应这个方法?其实并不是如此,感兴趣的同窗能够把循环次数加的更大一些,在循环开始几回就进行interrupt,你会发现结果仍是这样。
通过一番探索,线程终止的方法无外乎两种:
要终止线程,目前JDK中可行的作法有:
这两个作法都须要后续作相应处理好比去break循环,return方法或者抛出异常等等。
线程终止缘由通常来说有两种:
package com.xlf; public class ExplicitlyCatchExceptionAndDoNotThrow { public static void main(String[] args) throws Exception {
boolean flag = true; System.out.println("Main started!"); try { throw new InterruptedException(); } catch (InterruptedException exception) { System.out.println("InterruptedException is caught!"); } System.out.println("Main doesn't stop!"); try { throw new Throwable(); } catch (Throwable throwable) { System.out.println("Throwable is caught!"); } System.out.println("Main is still here!"); if (flag) { throw new Exception("Main is dead!"); } System.out.println("You'll never see this!"); }
}
这个测试验证了前面关于线程异常终止的结论:
线程执行中抛出Throwable对象且不被显式捕获,JVM会终止线程。
线程执行中须要手动终止,最好的作法就是设置标识位(能够是interrupt也能够是本身定义的),而后及时捕获标识位并抛出异常,在业务逻辑的最后去捕获异常并作一些收尾的清理动做:好比统计任务执行失败成功的比例,或者关闭某些流等等。这样,程序的执行就兼顾到了正常与异常的状况并获得了优雅的处理。
package com.xlf; public class TerminateThreadGracefully { public static void main(String[] args) {
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10; i++) { sb.append("show tables;n"); } int i = 0; try { for (String str : sb.toString().split("n")) { if (i > 4) { Thread.currentThread().interrupt(); if (Thread.currentThread().isInterrupted()) { throw new InterruptedException(); } System.out.println(i + " after interrupt"); } System.out.println(str); System.out.println(i++); } } catch (InterruptedException exception) { // TODO:此处可能作一些清理工做 System.out.println(Thread.currentThread().isInterrupted()); } System.out.println("Thread main stops normally!"); }
}
咱们项目中确实是调用了对应的线程对象的interrupt方法thread.interrupt();去终止线程的。
那么为何线程不能相应中断标识位并终止呢?
回到咱们项目的业务逻辑:
整个job分为模板读取、渲染以及SQL执行三个阶段,通常而言前两个阶段时间会比较快。在后续逐句执行sql语句的过程当中,每一句sql都是调用的周边服务(DLI,OBS,MySql等)去执行的,结果每次都会返回给咱们的做业开发调度服务(DLF)后台。咱们的DLF平台支持及时中止做业的功能,也就是说假如这个做业在调度过程当中要执行10000条SQL,我要在中途中止不执行后面的SQL了——这样的功能是支持的。
所以问题就出在了SQL执行的过程。通过屡次debug发现:在SQL执行过程当中须要每次都往OBS(华为自研,第三方包)中写log,该过程不可略去。调用该线程对象的interrupt方法thread.interrupt(),interrupt标识位最先被OBS底层用到的java.util.concurrent. CountDownLatch类的await()方法捕获到,重置标识位并抛出异常,而后在一层层往上抛的时候被转变成了别的异常类型,并且不能根据最终抛的异常类型去判断是不是因为咱们手动终止job引发的。
对于第三方包OBS根据本身的底层逻辑去处理CountDownLatch抛的异常,这本无可厚非。可是咱们的程序终止不了!为了达到终止线程的作法,我在其中加入了一个自定义的标志变量,当调用thread.interrupt()的时候去设置变量的状态,并在几个关键点好比OBS写log以后去判断个人自定义标识位的状态,若是状态改变了就抛出RuntimeException(能够不被捕获,最小化改动代码)。而且为了能重用线程池里的线程对象,在每次job开始的地方去从重置这一自定义标识位。最终达到了优雅手动终止job的目的。
这一部分的源码涉及项目细节就不贴出来了,可是相关的逻辑前面已经代码展现过。
在线程中运行过程当中定义的普通的局部变量,非ThreadLocal型,通常而言会随着线程结束而获得回收。我所遇到的现象是上面的那个线程没法中止的bug解决以后,线程停下来了,可是在linux上运行top命令相应进程内存占用仍是很高。
在以下试验中
设置jvm参数为:
-Xms100m -Xmx200m -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
其意义在于:
限制jvm初始内存为100M,最大堆内存为200M。并在jvm发生垃圾回收时及时打印详细的GC信息以及时间戳。而个人代码里要作的事情就是重现jvm内存不够而不得不发生垃圾回收。同时观察操做系统层面该java进程的内存占用。
package com.xlf;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; public class SystemMemoryOccupiedAndReleaseTest { public static void main(String[] args) { try {
System.out.println("start"); Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 3, 30, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new ThreadFactory() { public Thread newThread(Runnable r) { return new Thread(r); } }, new ThreadPoolExecutor.AbortPolicy()); try { System.out.println("(executor已初始化):"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } Thread t1 = new Thread(new Runnable() { { System.out.println("t1 已经初始化"); } @Override public void run() { byte[] b = new byte[100 * 1024 * 1024]; System.out.println("t1分配了100M空间给数组"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); throw new RuntimeException("t1 stop"); } System.out.println("t1 stop"); } }, "t1"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } Thread t2 = new Thread(new Runnable() { { System.out.println("t2 已经初始化"); } @Override public void run() { byte[] b = new byte[100 * 1024 * 1024]; System.out.println("t2分配了100M空间给数组"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); throw new RuntimeException("t2 stop"); } System.out.println("t2 stop"); } }, "t2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } Thread t3 = new Thread(new Runnable() { { System.out.println("t3 已经初始化"); } @Override public void run() { byte[] b = new byte[100 * 1024 * 1024]; System.out.println("t3分配了100M空间给数组"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); throw new RuntimeException("t3 stop"); } System.out.println("t3 stop"); } }, "t3"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } executor.execute(t1); System.out.println("t1 executed!"); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } executor.execute(t2); System.out.println("t2 executed!"); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } executor.execute(t3); System.out.println("t3 executed!"); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("jmap -histo:live pid by cmd:"); try { Thread.sleep(20000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("After jmap!"); // You may run jmap -heap pid using cmd here // executor.shutdown();
}
}
VelocityExperiment.zip 19.40KB
本文分享自华为云社区《一个神奇的bug:OOM?优雅终止线程?系统内存占用较高?》,原文做者:UnstoppableRock。