发布于 

How to calculate picture

How to calculate picture

某天当我在沉浸式摸鱼的时候,突然发现线上发出了告警:OutOfMemoryError

image-20220819145059802

顿时这鱼也不敢摸了,兴趣顿时起来了。PS:不是自己的业务,但是本着要有项目own的精神。我就开始排查为什么会有OOM的告警。

这时候又要大家开始回答八股文了,线上哪里地方会发生OOM?

回答:略略略~~,老板需要你自己去看下这八股文了。

我们通过上面的异常告警,可以直接知道是堆空间的OOM,你问我怎么知道的?

老板这时候看英文呢?java.lang.OutOfMemoryError: Java heap space

接下来我跟老板先介绍下本次的业务,获取颜色主色业务,并且在这里插一个眼,本次是因为只上传了一张1.5M的图片,导致线上堆空间不足

这里先给出图片,图片链接:https://img.noahpan.cn/img-2022/202208191537393.png

我写个最小代码原型。

@SneakyThrows
public void minQuery(File file) {

//获取文件流
InputStream is =FileUtil.getInputStream(file);

//读取图片到内存
BufferedImage bi = ImageIO.read(is);

//获取图片的宽高
int height = bi.getHeight();
int width = bi.getWidth();

//获取颜色主色调(引入第三方jar包)
MMCQ.CMap colors = ColorThief.getColorMap(bi, 5, 10, false);
}

接着又开始新的的八股文了,当线上OOM的时候,你会怎么做?

希望老板可以通过我的步骤,可以总结直接的方法论。

Online monitoring

发生异常的时候,我们应该第一时间看监控。这时候可以从监控大盘看出个大概。

image-20220819144513345

这时候我们要牢记,我们是来解决:java.lang.OutOfMemoryError: Java heap space

从监控我们可以看到堆的空间,在某些时间短的时间,堆的空间基本都消耗完了。

此时我们应该优先看服务是否crash,然后导致重启。

这时候我们JVM通常都配置了-XX:ErrorFile=/data/java/logs/hs_err_%p.log

当进程被crash了,会收集被crash的一些关键信息方便我们分析问题。

说到我分析问题到这一步,我们容器没有被crash重启。

这里插个眼,关于k8s健康检查不通过,重启pod的配置。我们后面特定说下,因为这也是很重要的!!

这时候就可能有老板要问了,为什么heap OOM了,为什么没有重启容器呢?

这里读者如果你有这样的想法就错了。

OOM的发生表示了此刻JVM堆内存告罄,不能分配出更多的资源,或者gc回收效率不可观。

一个线程的OOM,在一定程度的并发下,若此时其他线程也需要申请堆内存,那么其他线程也会因为申请不到内存而OOM,连锁反应才导致整个JVM的退出。

我们发现问题后第一时间进行了扩容,从3G扩容到了8G。但是还是会heap OOM。

这时候就要开始看代码了,也到了经典八股文了,heap OOM的原因

  1. 代码中可能存在大对象分配
  2. 可能存在内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象

Code Review

通过上面的分析,我们已经扩容到8G还是会有OOM。通过上面的监控和分析,我们可以知道我们写的代码有问题。

这时候进入我们大家喜闻乐见的源码分析环节。

在分析源码之前,我要在这里跟大家特地介绍下上传的资源。

图片链接:https://img.noahpan.cn/img-2022/202208191537393.png

image-20220819154058768

  • 我们从第一点可以知道,这张图片只有1.7MB的大小
  • 这张图片的重点是第二点,图片的尺寸:16667✖️16667

image-20220825103429243

上面已经贴了最小原型代码,我们现在要找出大对象的代码到底是哪个对象。

这时候我们就要科普2种查看占用内存大小的工具了。

/**
* RamUsageEstimator,获取对象占用内存的大小
*
* @param is
* @param bi
*/
private static void RamUsageCountMemory(InputStream is, BufferedImage bi) {

String isLucene = RamUsageEstimator.humanSizeOf(is);
String biLucene = RamUsageEstimator.humanSizeOf(bi);

log.info("lucene is isLucene:{},biLucene:{}", isLucene, biLucene);
}

/**
* Instrumentation,获取对象占用内存的大小
*
* @param is
* @param bi
*/
private static void InstCountMemory(InputStream is, BufferedImage bi) {

ByteBuddyAgent.install();
Instrumentation inst = ByteBuddyAgent.getInstrumentation();

long instIs = inst.getObjectSize(is);
long instBi = inst.getObjectSize(bi);
log.info("inst is instIs:{},instBi:{}", instIs, instBi);
}

其中Instrumentation的方式可能不够精准,因为无法统计嵌套子对象所暂用的内存。推荐使用第一种方式。

这时候我们本地跑,is是文件的流,bi是图片的像素流。PS:这里我先注释颜色主色调的代码。

image-20220825172315772

这个我们的运行结果,我们很惊讶的发现,图片的像素流bi居然占了1GB的堆空间,我们从JVM看和日志打印一直。

这里我们先引出这个问题,为什么我获取图片的宽/高,需要消耗1G的堆空间。

并且我看很多网上的所谓”教程”,也是通过上面的代码获取图片的宽高。

image-20220825173130878

这里就不点击到里面去了,但是里面都是坑。这里提示一个思路,我们都知道每个文件都是自己的魔术值,表示这是什么文件,比如.java,.txt等。这些被称为元信息的数值。再比如文件的大小,图片的长宽高这些,在文件的元信息已经存在的。

我们只需要读取头部信息就可以知道了,就不需要再读区整个图片像素流了。怎么做呢?show code环节。

@SneakyThrows
public static void rightGetHeightAndWidth(File file) {

//获取文件流
InputStream is = FileUtil.getInputStream(file);

ImageInputStream stream = ImageIO.createImageInputStream(is);
Iterator<ImageReader> readers = ImageIO.getImageReaders(stream);

BufferedImage bi = null;

if (readers.hasNext()) {

ImageReader reader = readers.next();
reader.setInput(stream);

int height = reader.getHeight(0);
int width = reader.getWidth(0);

log.info("height:{},width:{}", height, width);
}
}

其实为什么这样就可以获取到宽高呢。其实看源码我们就可以知道了。

你调用ImageIO#read,图片的宽高也是这样set进去的。

image-20220829162704619

image-20220829163107176

最后我们比较下这两种方式获取图片宽高的性能差距。

image-20220829164248681

性能的差距不是一点点。

最后我们说下,我们已经知道了GC的原因了。因为读取的文件只有1MB的大小,但是图片的像素大小是1.6w✖️1.6w的大小。

当我们需要计算图片主色调的时候,需要根据每个像素计算主色调。

所以我们的解决方案:

  1. 使用Apollo配置值,限制图片的宽高的乘积最大值。
  2. 使用优化后的方案来获取图片宽高。
  3. 图片的宽高乘积没有超过Apollo的限制值,则计算主色调。

好了,到此我们fix了我们的OOM问题。

下文私带一些干货。K8s Liveness

K8s Liveness

Liveness:   http-get http://:http-metrics/actuator/health delay=300s timeout=1s period=5s #success=1 #failure=5
Readiness: http-get http://:http-metrics/actuator/health delay=10s timeout=1s period=5s #success=1 #failure=3

上面的两个配置是k8s健康检查的配置,Liveness存活探针,Readiness就绪探针。

下面重点解析下他是怎么配置出来的,以及他们的含义。

kubectl get deploy deploy-name -n namespace-xx -o json

image-20220824151152742

Liveness的配置生成,是通过delpoy中的livenessProbe属性生成的。

我们可以重点解读下配置的含义,如下:

  • httpGet:对应HTTPGetAction对象,属性包括:host、httpHeaders、path、port、scheme
  • initialDelaySeconds:容器启动后开始探测之前需要等多少秒,如应用启动一般30s的话,就设置为 30s
  • periodSeconds:执行探测的频率(多少秒执行一次)。默认为10秒。最小值为1。
  • successThreshold:探针失败后,最少连续成功多少次才视为成功。默认值为1。最小值为1。
  • failureThreshold:最少连续多少次失败才视为失败。默认值为3。最小值为1。
  • timeoutSeconds:探测的超时时间,默认 1s,最小 1s

我们整体解读下:在程序启动延迟300s后,开始执行存活探针任务,每隔5s执行一次,发起http请求,超市时间为1s,如果连续失败超过5次,则重启容器。也就意味着25s内都健康检查失败就重启容器,有一次成功了则重置。