背景介绍
整个系统是一个第三方支付平台,面向的是商户端;出现宕机是其中的通知服务,通知服务主要的功能是将支付的结果通知商户,通知地址其实就是每个商户在提交支付订单的时候提供的;而通知地址大部分都是域名地址,所以会出现大量通过域名获取ip的操作。
问题描述
通知系统在运行一段时间后(可能是一周,一个月,甚至几个月),突然出现宕机,无法提供通知,日志中出现大量的如下错误:
java.lang.NullPointerException
at java.net.InetAddress$Cache.put(InetAddress.java:779) ~[?:1.7.0_79]
at java.net.InetAddress.cacheAddresses(InetAddress.java:858) ~[?:1.7.0_79]
at java.net.InetAddress.getAddressesFromNameService(InetAddress.java:1334) ~[?:1.7.0_79]
at java.net.InetAddress.getAllByName0(InetAddress.java:1248) ~[?:1.7.0_79]
at java.net.InetAddress.getAllByName(InetAddress.java:1164) ~[?:1.7.0_79]
at java.net.InetAddress.getAllByName(InetAddress.java:1098) ~[?:1.7.0_79]
at java.net.InetAddress.getByName(InetAddress.java:1048) ~[?:1.7.0_79]
通知服务本身部署了多节点,同时也提供了重试机制,为了尽快恢复生产,往往是直接重启宕机的节点,重启之后一切正常(果然重启大法好);再看问题本身出在了JDK提供的域名解析服务中,将域名映射的地址缓存的时候出现了空指针,所以有必要去了解一下JDK域名解析服务;
域名解析
JDK提供了InetAddress类来帮助我们通过域名地址获取IP地址,当然为了提高性能不可能每次都去远程的域名服务器获取地址,所以内部提供了缓存机制;使用起来也是非常简单;
如何使用
InetAddress inetAddress = InetAddress.getByName("www.baidu.com");
String address = inetAddress.getHostAddress();
String name = inetAddress.getHostName();
输出:
address = 180.101.49.12,name = www.baidu.com
缓存分析
每次获取域名映射的时候首先会去本地缓存查找,如果查找不到就去远程服务获取域名映射的ip地址;
- 缓存类型
域名映射缓存分为两种类型分别是:Positive和Negative,可以简单理解就是成功的和失败的,并且都有各自的有效期;为什么会有成功和失败两种类型其实也好理解,成功的缓存好理解,失败了其实也一样,一次失败就让它在一段有效期时间内就不要去请求远程域名服务了,因为大概率也是失败的;
// 解析成功的缓存
private static Cache addressCache = new Cache(Cache.Type.Positive);
// 解析失败的缓存
private static Cache negativeCache = new Cache(Cache.Type.Negative);
- 缓存策略 缓存的时间是通过内置的InetAddressCachePolicy类设置,其内置的枚举类包括:FOREVER、NEVER,分别表示从不过期和从不缓存;其实它们都分别对应一个整数:
public static final int FOREVER = -1;
public static final int NEVER = 0;
当然也可以自定义一个整数,官方给的解释如下:
# any negative value: cache forever
# any positive value: the number of seconds to cache negative lookup results
# zero: do not cache
0表示不缓存,负整数表示从不过期,正整数表示缓存的时间单位秒;
- 设置过期时间 有了缓存策略需要给两种缓存类型分别设置过期时间,当然JDK也设置了默认的过期时间,大部分时间我们是不需要改动的; 具体配置文件路径:jdk/jre/lib/security/java.security,其中有分别针对两种类型的缓存时间设置:
#networkaddress.cache.ttl=-1
networkaddress.cache.negative.ttl=10
失败解析的缓存时间为10秒,而成功解析的缓存时间被注释了没有配置,官方给的注释如下:
# default value is forever (FOREVER). For security reasons, this
# caching is made forever when a security manager is set. When a security
# manager is not set, the default behavior in this implementation
# is to cache for 30 seconds.
如果设置了安全管理器这个是永久的,如果没有设置默认值为30秒;
获取流程
有了上面的了解,我们现在大致看一下获取映射地址的流程:
private static InetAddress[] getAllByName0 (String host, InetAddress reqAddr, boolean check)
throws UnknownHostException {
...
InetAddress[] addresses = getCachedAddresses(host);
if (addresses == null) {
addresses = getAddressesFromNameService(host, reqAddr);
}
...
}
很好理解就是先去缓存获取地址,获取不到就去远程服务获取,在getAddressesFromNameService内会添加缓存操作cacheAddresses;
private static InetAddress[] getCachedAddresses(String hostname) {
hostname = hostname.toLowerCase();
synchronized (addressCache) {
cacheInitIfNeeded();
CacheEntry entry = addressCache.get(hostname);
if (entry == null) {
entry = negativeCache.get(hostname);
}
if (entry != null) {
return entry.addresses;
}
}
return null;
}
获取缓存两个要点分别是: 第一点获取缓存的时候需要加锁,这是因为在获取的时候其实会做有效期检查,如果过期会做删除处理,需要保证线程的安全; 第二点是首先去成功获取解析结果的缓存中获取,如果没有再去失败的缓存中获取; 保存缓存的方法cacheAddresses也类似,这个我们在下面重点介绍一下,报错的地方也是在这;
问题分析
根据上面的错误堆栈可以快速定位到报错的地方也就是cacheAddresses方法,也就是每次从远程域名服务获取地址后进行缓存的地方:
private static void cacheAddresses(String hostname,
InetAddress[] addresses,
boolean success) {
hostname = hostname.toLowerCase();
synchronized (addressCache) {
cacheInitIfNeeded();
if (success) {
addressCache.put(hostname, addresses);
} else {
negativeCache.put(hostname, addresses);
}
}
}
同getCachedAddresses方法一样需要有加锁操作,因为在put的时候同样会检查缓存是否过期;如果获取成功就放入addressCache中,失败放入negativeCache中,下面重点看一下put方法:
//存放缓存使用的是LinkedHashMap
private LinkedHashMap<String, CacheEntry> cache;
public Cache put(String host, InetAddress[] addresses) {
int policy = getPolicy();
if (policy == InetAddressCachePolicy.NEVER) {
return this;
}
if (policy != InetAddressCachePolicy.FOREVER) {
LinkedList<String> expired = new LinkedList<>();
long now = System.currentTimeMillis();
for (String key : cache.keySet()) {
CacheEntry entry = cache.get(key);
if (entry.expiration >= 0 && entry.expiration < now) {
expired.add(key);
} else {
break;
}
}
for (String key : expired) {
cache.remove(key);
}
}
long expiration;
if (policy == InetAddressCachePolicy.FOREVER) {
expiration = -1;
} else {
expiration = System.currentTimeMillis() + (policy * 1000);
}
CacheEntry entry = new CacheEntry(addresses, expiration);
cache.put(host, entry);
return this;
}
put操作的大致流程:先获取缓存策略,这个在上面介绍过,如果是NEVER那就需要缓存直接返回,如果是FOREVER表示缓存是永久的,不需要检查缓存是否过期;最后缓存的时间计算过期的时间点,然后保存到LinkedHashMap中; 根据堆栈日志最终定位的行是在遍历LinkedHashMap,检查是否过期的地方出现了空指针,也就是说LinkedHashMap中出现了空对象,导致每次跑到这里的时候直接报错,而通知服务很关键的一步就是域名解析,最终导致整个节点无法提供服务;
为什么会出现空对象
问题分析完关键点就是为什么会出现空对象,很容易联想到线程安全问题,发现LinkedHashMap是非线程安全的,最早的时候怀疑过是不是JDK的bug,因为LinkedHashMap本身是非线程安全的,而且会同时出现get,remove,put等操作,这些操作都没有加锁操作,从而引发线程安全问题;但其实经过上面的分析,其实在所有调用get和put的外层方法中都添加了锁,所以并不会出现线程安全问题;想想也是JDK怎么可能会有这种低级错误(此处应该有狗头);
反射猜测
思来想去有无果,突然灵光一闪,会不会有哪个二哈直接通过反射的方式去改变JDK里面的属性值;直接全局搜索关键字addressCache,发现如下代码:
static {
Class clazz = java.net.InetAddress.class;
final Field cacheField = clazz.getDeclaredField("addressCache");
cacheField.setAccessible(true);
final Object o = cacheField.get(clazz);
Class clazz2 = o.getClass();
final Field cacheMapField = clazz2.getDeclaredField("cache");
cacheMapField.setAccessible(true);
final Map cacheMap = (Map)cacheMapField.get(o);
}
你没有看错,直接通过反射拿到InetAddress中的addressCache,然后再拿到cache,其实这个cache就是上面介绍的LinkedHashMap,下面的代码就不贴出来了,对LinkedHashMap做了一些删除操作,重点是没有任何加锁操作,导致了最终的线程安全问题;
问题解决
给出解决问题之前我们应该先想想为什么要通过反射去改变JDK内部的对象属性,是不是有这个必要;真要这样做的话那需要给这里的cache加上同样的锁:synchronized (addressCache);获取可以参考dns缓存项目:java-dns-cache-manipulator
总结
遇到生产问题我们首要是先恢复生产,其次再对问题进行全方位分析,找到问题的根因,有时候不妨发散思维,做出一些大胆的猜测,说不准有意外的收获。
感谢关注
可以关注微信公众号「 回滚吧代码」,第一时间阅读,文章持续更新;专注Java源码、架构、算法和面试;本文 GitHub 已经收录,欢迎Star。