晋级Review归来话代码有毒

这几天做应届生的转正定级答辩,结束后小黑哥在朋友圈里面发了下面一幅图:

并在评论里面说了一句话:网络不超时,堆外不清理,异步不回调,异常不监控。

上面的中毒情况总结得非常不错,这让我想起了《唐伯虎点秋香》里面的一段情节:

华夫人对唐伯虎说:一日丧命散是用七种不同的毒虫再加上那鹤顶红提炼七七四十九天而成的,无色无味,杀人于无影无踪。吃了我们一日丧命散的人,一天之内会武功全失,经脉逆流,胡思乱想而至走火入魔,最后啊,会血管爆裂而死。

借着这个情节和小黑哥的中毒,来总结一下代码里面的七种毒虫吧。

1.资源不限制

这是很多码农容易中的毒,典型的症状是:

1.1 缓存不设置最大数量限制

比如我们使用map实现了一个cache,但是没有设置cache的大小,久而久之你可以想象一下这个cache最终会膨胀到什么程度。当然有同学可能会说我使用的是Guava cache,可以设置数量限制。well done,你再回去看看你的full gc是不是比较频繁,没准是因为这个值太大导致的哟~

1.2 线程池不设置队列大小

比如我们使用newFixedThreadPool,但是我们没有设置队列的大小,而悲催的是,在java中,线程池队列的默认大小是Integer.MAX_VALUE,我想你自己也能清楚这种情况下会发生什么问题。
再比如Executors.newCachedThreadPool,这个接口很多人会用到,但很多用的人都没有仔细想过会不会在某种情况下这里创建出巨多的线程。

2.网络不超时

这里说的是网络调用的场景不设置超时。

在服务化设计大行其道的今天,我们很多的服务调用都是通过网络调用进行的。设想一下,如果我们调用的某个服务性能低下或者网络异常,长时间没有返回结果,那么调用端线程就会一直阻塞在这里。如果没有使用线程池,那么应用会阻塞在这里。如果使用了线程池,那么线程就会长时间阻塞,最终导致线程池爆掉。

在review代码的时候,这种情况尤其常见,尤其是使用httpclient的时候,各位看官要多加注意哟。

3.自我不保护

这种情况尤其常见于对外提供接口的情况。对于调用方传过来的参数,我们没有做严格的有效性检查或者限制,最终导致不符合要求的数据将程序搞挂。比如我们提供了一个用户信息批量查询的接口,参数是一个数组。结果是某个调用方传了有1000个元素的数组过来,最终可能直接导致程序内存溢出……这种情况我们能去怪罪调用方吗?严格来说,我们不能,在这里可以借鉴一句经典的话:不要相信前端传来的任何数据。我想对于接口也是一样:不要相信调用端传来的任何数据。

还有一种情况就是系统处理能力的保护,最典型的就是QPS。如果我们的系统能够承受的QPS是100,那么如果QPS超过这个数值,我们该怎么办?我想很多人心里已经有了答案了。

4.连接不关闭

这里其实是有两点要强调,连接和流。

4.1 连接不关闭

说到连接,这里所说的是网络连接。我们都知道,网络资源是一种受限资源,无限制的网络连接会给自身及对方系统带来很大的压力,因此我们会使用连接池或其他连接复用技术来降低系统压力,以此提高效率。但是如果我们忘记了关闭连接的话,就会对对端系统带来隐患。比如数据库连接,如果忘记关闭,会造成数据库服务器连接资源耗尽最终导致拒绝服务。当然有同学说,现在很多服务器都有超时检查机制,长时间不活动的连接会自动清除。那么你想过没有,如果连接被服务器断开,这种情况下客户端是感知不到的。只有你下次使用的时候才发现连接已经不可用了。

4.2 流不关闭

流的问题很好理解,我们读写文件、网络传输,很多很多场景需要使用流,而在操作系统层面,对文件、网络的操作都是有限制的。在使用流的时候,我们首先需要进行open,最后close。如果我们忘记了close,那么系统就不会释放对此资源的占用,最终超出系统限制而导致后续的操作失败,最典型的错误就是too many open files。

5.异步不回调

在分布式服务架构中,很多的服务为了提高并发处理能力,使用了异步机制。异步的好处是消费端可以不必一直等待服务端的结果,而将资源交给其他的逻辑处理。当服务端有结果返回时,通过回调机制调用消费端的逻辑进行后续的处理。但是在实际的使用过程中,发现很多人还是使用同步的方式来调用异步方法,比如:

fooService.findFoo(fooId); //fooService.findFoo方法支持异步
Future<Foo> fooFuture = RpcContext.getContext().getFuture(); 

Foo foo = fooFuture.get(); // 如果foo已返回,直接拿到返回值,否则线程wait住,等待foo返回后,线程会被notify唤醒

我们知道Future对象具有如下的特性:

  1. 异步执行,可用 get 方法获取执行结果;
  2. 如果计算还没完成,get 方法是会阻塞的,如果完成了,是可以多次获取并立即得到结果的;
  3. 如果计算还没完成,是可以取消计算的;
  4. 可以查询计算的执行状态

所以上述的代码问题主要出现在第3行,如果fooService.findFoo方法还没有返回值,那么fooFuture.get()方法会被阻塞直到结果返回,这样就失去了异步调用的优势了。

那么针对上面的代码,正确的写法应该是通过回调机制做数据返回时的处理:

fooService.findFoo(fooId);  

ResponseFuture future = ((FutureAdapter)RpcContext.getContext().getFuture()).getFuture();
future.setCallback(new ResponseCallback(){
    public void done(Object response){
       //调用正常的时候执行
    }

    public void caught(Throwable exception){
      //调用异常的时候执行
    }
});

当然,上面的方式只是举了个栗子,具体情况需要根据不同的RPC框架去做处理。这里推荐Google guava concurrent包里面的接口和工具类,比如ListenableFuture、Futures等,具体实例网上有很多的说明,请大家自行安利,这里就不做赘述了。

6.异常不监控

当系统抛出异常被捕获后,很多同学只是通过日志记录了一下异常信息,但是并没有通过监控系统记录异常。这样带来的隐患就是我们没法在第一时间感知系统异常,只能是影响到其他监控了才会有所体现,但这样就只能靠运气了。如果被影响的监控比较敏感,或许很快就会有告警;但是如果被影响的监控不敏感,或者压根没开告警,那么你就呵呵了……

7.堆外不清理

在某些业务场景下,我们可能需要使用DirectByteBuffer分配字节缓冲区,或者使用MappedByteBuffer做内存映射,那么我们不可避免地需要使用堆外内存。DirectByteBuffer对象在创建过程中会先通过Unsafe接口直接通过os::malloc来分配内存,然后将内存的起始地址和大小存到DirectByteBuffer对象里,这样就可以直接操作这些内存。我们dump内存时可能会发现DirectByteBuffer对象很小,但是其实它后面可能关联了一个非常大的堆外内存,因此我们通常称之为“冰山对象”。这些内存只有在DirectByteBuffer回收掉之后才有机会被回收,因此如果这些对象大部分都移到了old,但是一直没有触发CMS GC或者Full GC,那么悲剧将会发生,因为你的物理内存被他们耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存。

另外,当前我们的使用的服务框架、第三方组件,很多都是基于netty、mina这种NIO框架实现网络调用,而这些NIO框架基本都在使用堆外内存。而我们知道,堆外内存是无法通过JVM的垃圾回收器回收的,只能通过System.gc()来回收。但是很多同学在设置JVM参数的时候,往往选择拷贝已有的配置,而忽略了一个很重要的参数DisableExplicitGC,这个参数的意思是禁用显式GC,也就是使System.gc()失效。如果我们在jvm参数中配置了-XX:+DisableExplicitGC,那么带来的后果就是进入到老年代的堆外内存无法被回收掉,最终导致OOM。

至此,一日丧命散的其中毒虫就介绍完了。如果想要活得久,请远离这七条毒虫,哈哈。