关于java:同事写了个惊天-bug还不容易被发现

41次阅读

共计 5791 个字符,预计需要花费 15 分钟才能阅读完成。

作者:树洞君 \
链接:https://juejin.cn/post/7064376361334358046

事变形容

从 6 点 32 分开始大量用户拜访 app 时会呈现首页拜访异样,到 7 点 20 分首页服务大规模不可用,7 点 36 分问题解决。

整体通过

6:58 发现报警,同时发现群里反馈首页呈现网络忙碌,思考到 前几日 早晨门店列表服务上线公布过,所以思考回滚代码紧急解决问题。

7:07 开始先后分割 XXX 查看解决问题。

7:36 代码回滚完,服务恢复正常。

事变根本原因 - 事变代码模仿

public static void test() throws InterruptedException, ExecutionException {Executor executor = Executors.newFixedThreadPool(3);
    CompletionService<String> service = new ExecutorCompletionService<>(executor);
        service.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {return "HelloWorld--" + Thread.currentThread().getName();}
        });
}

本源就在于 ExecutorCompletionService 后果没 调用 take,poll 办法。

正确的写法如下所示:

public static void test() throws InterruptedException, ExecutionException {Executor executor = Executors.newFixedThreadPool(3);
    CompletionService<String> service = new ExecutorCompletionService<>(executor);
    service.submit(new Callable<String>() {
        @Override
        public String call() throws Exception {return "HelloWorld--" + Thread.currentThread().getName();}
    });
    service.take().get();
}

一行代码引发的血案,而且不容易被发现,因为 oom 是一个内存迟缓增长的过程,略微粗枝大叶就会疏忽,如果是这个代码块的调用量少的话,很可能几天甚至几个月后暴雷。

操作人回滚 or 重启服务器的确是最快的形式,然而如果不是预先疾速剖析出 oom 的代码,而且不巧回滚的版本也是带 oom 代码的,就比拟悲催了,如方才所说,流量小了,回滚或者重启都能够开释内存;然而流量大的状况下,除非回滚到失常的版本,否则 GG。

举荐一个开源收费的 Spring Boot 实战我的项目:https://github.com/javastacks/spring-boot-best-practice

探询问题的本源

为了更好的了解 ExecutorCompletionService 的“套路”咱们用 ExecutorService 来作为比照,能够让咱们更好的分明,什么场景下用 ExecutorCompletionService。

先看 ExecutorService 代码(倡议 down 下来跑一跑)

public static void test1() throws Exception{ExecutorService executorService = Executors.newCachedThreadPool();
    ArrayList<Future<String>> futureArrayList = new ArrayList<>();
    System.out.println("公司让你告诉大家聚餐 你开车去接人");
    Future<String> future10 = executorService.submit(() -> {System.out.println("总裁:我在家上大号 我最近拉肚子比较慢 要蹲 1 个小时能力进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(10);
        System.out.println("总裁:1 小时了 我上完大号了。你来接吧");
        return "总裁上完大号了";

    });
    futureArrayList.add(future10);
    Future<String> future3 = executorService.submit(() -> {System.out.println("研发:我在家上大号 我比拟快 要蹲 3 分钟就能够进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(3);
        System.out.println("研发:3 分钟 我上完大号了。你来接吧");
        return "研发上完大号了";
    });
    futureArrayList.add(future3);
    Future<String> future6 = executorService.submit(() -> {System.out.println("中层治理:我在家上大号  要蹲 10 分钟就能够进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(6);
        System.out.println("中层治理:10 分钟 我上完大号了。你来接吧");
        return "中层治理上完大号了";
    });
    futureArrayList.add(future6);
    TimeUnit.SECONDS.sleep(1);
    System.out.println("都告诉完了, 等着接吧。");
    try {for (Future<String> future : futureArrayList) {String returnStr = future.get();
            System.out.println(returnStr + ",你去接他");
        }
        Thread.currentThread().join();
    } catch (Exception e) {e.printStackTrace();
    }
}

三个工作,每个工作执行工夫别离是 10s、3s、6s。通过 JDK 线程池的 submit 提交这三个 Callable 类型的工作。

  • step1 主线程把三个工作提交到线程池外面去,把对应返回的 Future 放到 List 外面存起来,而后执行“都告诉完了, 等着接吧。”这行输入语句。
  • step2 在循环外面执行 future.get() 操作,阻塞期待。最初后果如下:

先告诉到总裁,也是先接总裁 足足等了 1 个小时,接到总裁后再去接研发和中层治理,只管他们早就完事儿了,也得等总裁上完厕所~~

耗时最久的 -10s 异步工作最先进入 list 执行,所以在循环过程中获取这个 10s 的工作后果的时候,get 操作会始终阻塞,直到 10s 异步工作执行结束。即便 3s、5s 的工作早就执行完了,也得阻塞期待 10s 工作执行完。

看到这里 尤其是做网关业务的同学可能会产生共鸣,一般来说网关 RPC 会调用上游 N 多个接口,如下图

如果都依照 ExecutorService 这种形式,并且凑巧前几个工作调用的接口耗时比拟久,同时阻塞期待,那就比拟悲催了。所以 ExecutorCompletionService 应景而出。它作为工作线程的正当管控者,“工作规划师”的名称货真价实。

雷同场景 ExecutorCompletionService 代码

public static void test2() throws Exception {ExecutorService executorService = Executors.newCachedThreadPool();
    ExecutorCompletionService<String> completionService = new ExecutorCompletionService<>(executorService);
    System.out.println("公司让你告诉大家聚餐 你开车去接人");
    completionService.submit(() -> {System.out.println("总裁:我在家上大号 我最近拉肚子比较慢 要蹲 1 个小时能力进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(10);
        System.out.println("总裁:1 小时了 我上完大号了。你来接吧");
        return "总裁上完大号了";
    });
    completionService.submit(() -> {System.out.println("研发:我在家上大号 我比拟快 要蹲 3 分钟就能够进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(3);
        System.out.println("研发:3 分钟 我上完大号了。你来接吧");
        return "研发上完大号了";
    });
    completionService.submit(() -> {System.out.println("中层治理:我在家上大号  要蹲 10 分钟就能够进去 你等会来接我吧");
        TimeUnit.SECONDS.sleep(6);
        System.out.println("中层治理:10 分钟 我上完大号了。你来接吧");
        return "中层治理上完大号了";
    });
    TimeUnit.SECONDS.sleep(1);
    System.out.println("都告诉完了, 等着接吧。");
    // 提交了 3 个异步工作)for (int i = 0; i < 3; i++) {String returnStr = completionService.take().get();
        System.out.println(returnStr + ",你去接他");
    }
    Thread.currentThread().join();
}

跑完后果如下:

这次就绝对高效了一些,尽管先告诉的总裁,然而依据大家上大号的速度,谁先拉完先去接谁,不必期待上大号最久的总裁了(现实生活里 倡议采纳第一种 不等总裁的结果 emmm 哈哈哈)。

放在一起比照下输入后果:

两段代码的差别十分小 获取后果的时候 ExecutorCompletionService 应用了

completionService.take().get();

为什么要用 take() 而后再 get()呢????咱们看看源码

CompletionService 接口 以及接口的实现类

1、ExecutorCompletionService 是 CompletionService 接口的实现类

2、接着跟一下 ExecutorCompletionService 的构造方法,能够看到入参须要传一个线程池对象,默认应用的队列是 LinkedBlockingQueue,不过还有另外一个构造方法能够指定队列类型,如下两张图,两个构造方法。

默认 LinkedBlockingQueue 的构造方法

可选队列类型的构造方法

3、submit 工作提交的两种形式,都是有返回值的,咱们例子中用到的就是第一种 Callable 类型的办法。

4、比照 ExecutorService 和 ExecutorCompletionService submit 办法 能够看出区别(1)ExecutorService

(2)ExecutorCompletionService

5、差别就在 QueueingFuture,这个到底作用是啥,咱们持续跟进去看

  • QueueingFuture 继承自 FutureTask,而且红线局部标注的地位,重写了 done()办法。
  • 把 task 放到 completionQueue 队列外面,当工作执行实现后,task 就会被放到队列外面去了。
  • 此时此刻 completionQueue 队列外面的 task 都是曾经 done()实现了的 task,而这个 task 就是咱们拿到的一个个的 future 后果。
  • 如果调用 completionQueue 的 task 办法,会阻塞期待工作。等到的肯定是实现了的 future,咱们调用 .get()办法 就能立马取得后果。

看到这里 置信大家伙都应该多少明确点了

  • 咱们在应用 ExecutorService submit 提交工作后须要关注每个工作返回的 future,然而 CompletionService 对这些 future 进行了追踪,并且重写了 done 办法,让你等的 completionQueue 队列外面 肯定是实现了的 task。
  • 作为网关 RPC 层,咱们不必因为某一个接口的响应慢连累所有的申请,能够在解决最快响应的业务场景里应用 CompletionService。

but 留神、留神、留神 也是本次事变的外围

当只有调用了 ExecutorCompletionService 上面的 3 个办法的任意一个时,阻塞队列中的 task 执行后果才会从队列中移除掉, 开释堆内存,因为该业务不须要应用工作的返回值,则没进行调用 take,poll 办法。从而导致没有开释堆内存,堆内存会随着调用量的减少始终增长。

所以,业务场景中不须要应用工作返回值的 别没事儿应用 CompletionService,如果应用了,记得肯定要从阻塞队列中 移除掉 task 执行后果,防止 OOM!

总结

晓得事变的起因,咱们来总结下方法论,毕竟孔子他老人家说过:自省吾身,常思己过,善修其身!

上线前:

  • 严格的代码 review 习惯,肯定要交给 back 人去看,毕竟本人写的代码本人是看不出问题的,置信每个程序猿都有这个自信(这个后续事变里可能会重复提到!很重要)
  • 上线记录 - 备注好上一个可回滚的包版本(给本人留一个后路)
  • 上线前确认回滚后,业务是否可降级,如果不可降级,肯定要严格拉长这次上线的监控周期 上线后:
  • 继续关注内存增长状况(这部分极容易被疏忽,大家对内存的器重度不如 cpu 使用率)
  • 继续关注 cpu 使用率增长状况
  • gc 状况、线程数是否增长、是否有频繁的 fullgc 等
  • 关注服务性能报警,tp99、999、max 是否呈现显著的增高

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0