背景
我有一个朋友(无中生友好吧),在一次服务压测并没有通过后查看打印的StopWatch日志,发现正常流程之外居然有一段二十多行的代码平均耗时达到了15ms?!这明显有问题好吧,具体代码不方便贴,咱就用伪代码表示一下
factorList.parallelStream().forEach(factorId -> {
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
if (!entry.getValue().containsKey(factorId)) {
continue;
}
// 第一次
maxVal = Math.max(maxVal, Double.parseDouble(String.valueOf(entry.getValue().get(factorId))));
}
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
if (!entry.getValue().containsKey(factorId)) {
continue;
}
// 第二次
double v = (Double.parseDouble(String.valueOf(entry.getValue().get(factorId)))) / maxVal;
entry.getValue().put(factorId, v);
}
for (Long bFactorId : normalizeBFactorIds) {
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
// 第三次
double d = Math.floor((Double.parseDouble(String.valueOf(entry.getValue().get(bFactorId)))) * 10);
entry.getValue().put(bFactorId, d);
}
}
}
问题排查
一开始我们将问题定位在循环次数上,整一个代码结构是在O(n^2)的复杂度上的,降维这个复杂度是不可能了,所以我们尽量减少了循环的次数,优化第一版本后可以我们理解为上面标识了第三步的那层循环优化调了,通过日志来看也确实降了5ms左右下来。
但是问题又来了,整段逻辑是属于纯内存计算,没有其他IO影响,这个耗时依旧很有问题,因此我们观察了代码后发现,被优化的代码与上面第一次第二次所在循环的地方都有一个Object to Double的转化,这一步的转换是经历了两步:
-
String.valueOf
-
Double.parseDouble
我们分别查看这这两步,发现valueOf就是new了一个String,而parseDouble则调用了这样一段代码:
static FloatingDecimal.ASCIIToBinaryConverter readJavaFormatString(String var0) throws NumberFormatException {
boolean var1 = false;
boolean var2 = false;
try {
var0 = var0.trim();
int var5 = var0.length();
if (var5 == 0) {
throw new NumberFormatException("empty String");
}
int var6 = 0;
switch(var0.charAt(var6)) {
case '-':
var1 = true;
case '+':
++var6;
var2 = true;
}
char var4 = var0.charAt(var6);
if (var4 == 'N') {
if (var5 - var6 == NAN_LENGTH && var0.indexOf("NaN", var6) == var6) {
return A2BC_NOT_A_NUMBER;
}
} else if (var4 == 'I') {
if (var5 - var6 == INFINITY_LENGTH && var0.indexOf("Infinity", var6) == var6) {
return var1 ? A2BC_NEGATIVE_INFINITY : A2BC_POSITIVE_INFINITY;
}
} else {
if (var4 == '0' && var5 > var6 + 1) {
char var7 = var0.charAt(var6 + 1);
if (var7 == 'x' || var7 == 'X') {
return parseHexString(var0);
}
}
char[] var21 = new char[var5];
int var8 = 0;
boolean var9 = false;
int var10 = 0;
int var11 = 0;
int var12;
for(var12 = 0; var6 < var5; ++var6) {
var4 = var0.charAt(var6);
if (var4 == '0') {
++var11;
} else {
if (var4 != '.') {
break;
}
if (var9) {
throw new NumberFormatException("multiple points");
}
var10 = var6;
if (var2) {
var10 = var6 - 1;
}
var9 = true;
}
}
for(; var6 < var5; ++var6) {
var4 = var0.charAt(var6);
if (var4 >= '1' && var4 <= '9') {
var21[var8++] = var4;
var12 = 0;
} else if (var4 == '0') {
var21[var8++] = var4;
++var12;
} else {
if (var4 != '.') {
break;
}
if (var9) {
throw new NumberFormatException("multiple points");
}
var10 = var6;
if (var2) {
var10 = var6 - 1;
}
var9 = true;
}
}
// 以下省略部分代
}
} catch (StringIndexOutOfBoundsException var20) {
}
throw new NumberFormatException("For input string: \"" + var0 + "\"");
}
整个过程也是在不断校验传入的字符串的合法性,需要一个字符一个字符进行匹配,这里复杂度上又是多个O(N)的操作,逻辑其实很复杂,因此我们也分别测试了这两部分的耗时情况,发现确实循环一万次的情况下会有较大的损耗
解决方式
解决方法很简单,仅仅是强制类型转换从Object转换到Double就可以,整个代码也变成了这样
factorList.parallelStream().forEach(factorId -> {
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
if (!entry.getValue().containsKey(factorId)) {
continue;
}
// 第一次
maxVal = Math.max(maxVal, (double) entry.getValue().get(factorId));
}
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
if (!entry.getValue().containsKey(factorId)) {
continue;
}
// 第二次
double v = (double) entry.getValue().get(factorId) / maxVal;
entry.getValue().put(factorId, v);
}
for (Long bFactorId : normalizeBFactorIds) {
for (Map.Entry<Long, Map<Long, Object>> entry : result.entrySet()) {
// 第三次
double d = Math.floor((double) entry.getValue().get(bFactorId) * 10);
entry.getValue().put(bFactorId, d);
}
}
}
我们拉取了线上真实参数进行这两部分代码的离线测试,结果如下,发现从15ms降到了2ms左右
深耕
两部分代码都使用到了类型转换,只不过第二种方式简单粗暴地进行了强制类型转换,这一个小括号(double)到底做了什么呢,我们写了一个小demo,并借助了idea上查看字节码地jclasslib工具发现了点端倪
public static void main(String[] args) {
Object a = 0.33;
double d = (double) a;
}
idc2_w根据编码规范就是将double值从运行时常量池中拿出,运行时常量池是针对Class文件常量池的一个动态转换,在Class文件加载过程中执行
invokestatic调用了Double.valueof方法,创建一个Double对象
astore_1根据编码规范就是将创建的Double对象引用放到临时变量a中
而后checkcast会在强制转换之前判断源类型与目标类型是否是一个
中间我们跳过部分指令,可以看到有invokevirtual,调用了doubleValue拿到了double值,这个命令也对应了(double) a这行代码隐式存在的逻辑。所以整体来看,这两行代码是创建了Double对象给到了临时变量a,然后强制转换时通过doubleValue拿到值。
总结
总结来说,我这个朋友的做法很蠢,我看见了都想吐槽他,平常代码中有很多基础知识引起的问题,可以一两次执行看不出来,但是像这个问题在大量的循环场景下就得以体现,大家引以为戒,周末快乐~
【RPC系列合集】