一个NullPointerException,竟然有这么多花样!

来自:肥朝(微信号:feichao_java),作者:肥朝

案发现场

我们先看一下给出的异常栈

java.lang.NullPointerException
 at org.springframework.data.redis.cache.RedisCache.get(RedisCache.java:180)
 at org.springframework.data.redis.cache.RedisCache.get(RedisCache.java:133)
 at org.springframework.cache.transaction.TransactionAwareCacheDecorator.get(TransactionAwareCacheDecorator.java:69)
 at org.springframework.cache.interceptor.AbstractCacheInvoker.doGet(AbstractCacheInvoker.java:71)
 at org.springframework.cache.interceptor.CacheAspectSupport.findInCaches(CacheAspectSupport.java:537)
 at org.springframework.cache.interceptor.CacheAspectSupport.findCachedItem(CacheAspectSupport.java:503)
 at org.springframework.cache.interceptor.CacheAspectSupport.execute(CacheAspectSupport.java:389)
 at org.springframework.cache.interceptor.CacheAspectSupport.execute(CacheAspectSupport.java:327)
 at org.springframework.cache.interceptor.CacheInterceptor.invoke(CacheInterceptor.java:61)

根据异常栈我们很轻松就能定位到源码位置

很明显exists为null,就会出现本篇的NullPointerException。当然如果这样三分钟没到本篇就草率结束,明显有失肥朝的男儿本色!另外若当真如此草率,那么关注肥朝公众号的意义何在?肥朝公众号的老粉丝们都知道,肥朝的海量源码实战类文章,都有三个特点

  • 从源码原理角度分析,为什么会出现这个问题?

  • 如何解决掉这个问题?

  • 我们如何深度思考,不断从这次经历中压榨出最大价值。(非常重点!)

比如就拿这个问题来说,按照我们正常的思维惯性,我们看到176行有个return,又知道existsnull,那么我们点进connection.exists(cacheKey.getKeyBytes())这个方法一探究竟。

结果发现果然如我们所料,这里竟然有两个return null的情况,这个时候就有粉丝把持不住,要九浅一深直入源码分析,看这两个条件什么时候满足。但是肥朝想说的是,且慢动手!


你注意看这段代码

Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {

        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.exists(cacheKey.getKeyBytes());
        }
    });


public interface RedisOperations<KV{
    <T> execute(RedisCallback<T> action);
}

我们现在已知execute的返回值T(exists)为null,Taction的返回值的关系,要看execute代码里面的具体实现的!这点很重要,也很容易被疏忽!由于文中提到了随机出现,那么这里就涉及到一个排查的技巧。一般和随机出现有关的,根据经验,主要从两个方向入手

1.根据多个异常的情况的入参数据来分析,看看多个异常情况的入参都有什么特点。

2.模拟并发量,因为很多问题本地重现不了是因为并发量不够。

该粉丝根据方式二,将自己的项目代码中的Redis代码抽取了一个最简模型,将问题重现。但是由于该模型是ssm项目,为了方便大家都能重现并参与其中,我用springboot抽取了相关模型,如下图:

真相大白

将上面的代码运行,果不其然出现了我们想要找的异常栈。

我们把断点打在了上面说的那两个可能返回null的地方,可能你却惊奇的发现,断点根本没有进去。那就只剩下一个可能了。那就是redisOperations.execute()方法。我们发现,demo中继承RedisTemplate自定义了RedisOperations,名为RedisCustomTemplate。然后我们在catch处打上断点。

从这里我们就知道了。连接池参数blockWhenExhausted = true(默认),如果连接池没有可用Jedis连接,会等待maxWaitMillis(毫秒),依然没有获取到可用Jedis连接,会抛出这个异常。

根据沟通了解到,该同学生产代码中,catch住后是没有任何日志输出,直接返回了null,自然就导致了NullPointerException。

如何解决

肥朝认为解决问题要从两个方面入手。

1.连接池为什么不够?连接数不够时,盲目调大连接数是最常见的错误做法,根本解决办法还是要挖掘背后不够的原因,一般连接数不够,根据《Redis开发与运维》作者付磊在书中总结出无非是以下几点。

  • 1.1 连接泄漏(较为常见)

  • 1.2 业务并发量大,maxTotal确实设置小了。

  • 1.3 Jedis连接还的太慢(Redis发生了阻塞,例如慢查询等原因)。

  • 1.4 其他问题(例如丢包、DNS、客户端TCP参数配置)。

具体是属于上述哪个情况,自己对症下药。

2.自定义RedisCache

我们知道RedisCallback确实会存在两个返回null的情况,根据

if (!exists) {
    return null;
}

这样的判断方式,存在很大的空指针异常隐患,我们可以继承RedisCache,然后重写该方法,将这个判断的bug改掉,也可以去最大同性交友网站github上,查看最新版本的bug修复情况,当然很多公司更换jar版本都要走流程,所以具体处理方式,酌情处理。

问题复盘

在真相大白和给出解决方案后,那么我们就将整件事进行复盘。

1.提问的艺术

我们再回顾一下该粉丝的提问:“你们有没有遇到xxx问题”,其实显而易见,这样的提问方式,相信和他一样因为这个不规范,并且公司还有一定流量触发出问题的概率,小之又小。如果把“你们有没有遇到过xxx问题”换成“我遇到了一个xxx问题”,这样可能回复率还会多一丢丢。当然,问人毕竟是最低效的解决方式,最重要的还是,要掌握分析问题的技巧,以及源码实战的经验。当然很多同学反馈,公司根本没有源码实战经验的机会,因此,关注肥朝公众号,积累源码实战经验就显得格外重要了。

2.编码规范

如果同学没有吞掉异常,日志输出了真实异常

org.springframework.data.redis.RedisConnectionFailureException: Cannot get Jedis connection; 

nested exception is redis.clients.jedis.exceptions.JedisException: Could not get a resource from the pool

那么这个问题简直是随便搜索一下都秒解决。但是关键就在于吞掉了真实异常,并且返回null,非常碰巧的是

public Boolean exists(byte[] key) {
    try {
        if (isPipelined()) {
            pipeline(new JedisResult(pipeline.exists(key)));
            return null;
        }
        if (isQueueing()) {
            transaction(new JedisResult(transaction.exists(key)));
            return null;
        }
        return jedis.exists(key);
    } catch (Exception ex) {
        throw convertJedisAccessException(ex);
    }
}

该方法还有两个返回null的情况,给排查时造成了极大的迷惑性,容易让我们把精力放在了这里。

3.命名不规范。

在沟通中,我发现demo中RedisCustomTemplate这个成为问题最关键突破口的地方,在他们公司,竟然被起了一个欺骗性的命名。

我之前就在【编码不规范,同事真的会两行泪?】吐槽过这种欺骗性的命名,从这个案例中,我们再一次验证了,欺骗性的命名危害有多大,

4.异常输出。

从该粉丝的demo中,我们看到了这样的代码。

别以为这只是个demo无所谓,细节往往能看出编码的意识! 大家可以检查一下自己的项目,之前肥朝在【面试官问我,Redis分布式锁如何续期?懵了。】中就吐槽过这种输出方式。亦或者存在

ex.printStackTrace();

的方式。这样的方式存在两个非常大的隐患。

4.1 该方式用到了synchronized,当然这个还是小问题,毕竟synchronized在jdk 1.6做了很多优化,性能提升了很大。(这个很多优化到底是啥优化,后续肥朝再讲解)

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

4.2 这样的输出方式,无法将异常输出到日志文件。因为这个是jdk的方法,人家怎么可能输出到你要的文件路径。然后我们生产分析问题,都是要看日志,你通过这种方式输出,自然就会导致关键信息丢失!

4.3 ex.getMessage()的方式,是无法输出异常栈信息的。我们来看一下阿里规范手册提到的输出方式:

敲黑板划重点

大家自行检查自己公司项目的代码,是否存在上述的问题。有则下面留言告诉肥朝。

推荐↓↓↓
Java编程
上一篇:Spring Cloud微服务如何设计异常处理机制? 下一篇:从JDK中,我们能学到哪些设计模式?