前言声明:本人菜鸟一枚,如有地方描述不当望谅解,毕竟本人身处某十八线小城市三十六线小公司。
国庆节前头给项目换了套参数加解密方式,换完之后服务时不时就会出现如下报错,服务停止:
HikariPool-1 - Thread starvation or clock leap detected (housekeeper delta=1m11s757ms601µs134ns)
翻译后是HikariPool-1-检测到线程不足或时钟跳跃。
搜索了很多文章以为是HikariPool数据库连接池配置不合理导致的,但一番排查后并非该问题,直至后来看见一个清晰的错误,没错那就是OOM(OutOfMemoryError: Java heap space),堆内存溢出了,可惜头并未借由此机会让我去检查,而是选择了重启服务。
头质问我是不是在程序中写了死循环或者大量创建对象的情况。
怎么可能,写代码如此不规范
的我是不可能
在程序中如此乱搞的,而且在换加解密之前程序是如此健壮,测试做完压测后也并未出现OOM。而在换完加解密方式后,测试发很小的请求就出现了上边线程不足的情况。
后续头让修改启动参数,增大堆内存,也并未有所好转。
正值头要外出培训三天,因此给了我一个千载难逢的好机会。第一天他就让我走查一遍所有代码,不合适的地方全部都改掉。
我心里冷哼一声,就这?我心里还是很大程序怀疑加解密有问题的。
翻来覆去决定查看日志,也并未发现是哪块代码导致的。这就有些为难我月某人了,出此下策我决定:Arthas,启动!
通过Arthas提供的监控面板,我频繁的疯狂触发接口,从监控上不难看到我们的老年代不出意外的越来越大辣!(这里只复原并提供模拟,就不暴露项目内容了)。
通过面板可以发现频繁的触发了FULL GC,但是老年代还是居高不下,最终导致程序卡死,堆内存溢出~。
想要解决问题,就要想办法复现,这样才能对症下药。我在复现时通过dashboard面板发现有多条线程去GC,最终因不能回收老年代内存导致不断调用线程去GC,因此才会报Thread starvation or clock leap detected
介个错误。
由于在启动命令中并未指定垃圾收集器,且GC时并行调用线程,所以不出意外现在咱们的垃圾回收器应该是Parallel
了。很好,也算是有了个解决思路,因此在查阅多篇资料后我决定换垃圾收集器来try一try。直接更换为了G1收集器
。
换完G1后,还是老问题,老年代的内存得不到释放,在查阅资料痛定思痛决定通过visualVM分析堆转储文件分析到底是哪里创建了回收不了的对象(肯定还是在加解密这块)。
通过jmap命令生成hprof文件,并拷贝到本地,使用JDK自带的VisualVM工具打开(以下仅供模拟,实际作者在生产中拿到的hprof文件中LinkedHashMap拥有千万个实例):
通过分析实例的垃圾回收根节点也是找到了罪魁祸首:
最终发现是工具类中使用了BouncyCastleProvider
这个类,在每次加解密时,都创建该对象:
/**
* 加密
*
* @param content 加密的字符串
* @param encryptKey key值
*/
public static String encrypt(String content, String encryptKey) throws Exception {
//设置Cipher对象
Cipher cipher = Cipher.getInstance(ALGORITHMS, new BouncyCastleProvider());
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encryptKey.getBytes(), KEY_ALGORITHM));
//调用doFinal
byte[] b = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
// 转base64
return Base64.encodeBase64String(b);
}
/**
* 解密
*
* @param encryptStr 解密的字符串
* @param decryptKey 解密的key值
*/
public static String decrypt(String encryptStr, String decryptKey) throws Exception {
//base64格式的key字符串转byte
byte[] decodeBase64 = Base64.decodeBase64(encryptStr);
//设置Cipher对象
Cipher cipher = Cipher.getInstance(ALGORITHMS, new BouncyCastleProvider());
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(decryptKey.getBytes(), KEY_ALGORITHM));
//调用doFinal解密
byte[] decryptBytes = cipher.doFinal(decodeBase64);
return new String(decryptBytes);
}
阅读了该类的源码后(部分):
public final class BouncyCastleProvider extends Provider
implements ConfigurableProvider
{
private static final Map keyInfoConverters = new HashMap();
public void addKeyInfoConverter(ASN1ObjectIdentifier oid, AsymmetricKeyInfoConverter keyInfoConverter)
{
synchronized (keyInfoConverters)
{
keyInfoConverters.put(oid, keyInfoConverter);
}
}
private void loadPQCKeys()
{
addKeyInfoConverter(PQCObjectIdentifiers.sphincs256, new Sphincs256KeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.newHope, new NHKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.xmss, new XMSSKeyFactorySpi());
addKeyInfoConverter(IsaraObjectIdentifiers.id_alg_xmss, new XMSSKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.xmss_mt, new XMSSMTKeyFactorySpi());
addKeyInfoConverter(IsaraObjectIdentifiers.id_alg_xmssmt, new XMSSMTKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.mcEliece, new McElieceKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.mcElieceCca2, new McElieceCCA2KeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.rainbow, new RainbowKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.qTESLA_p_I, new QTESLAKeyFactorySpi());
addKeyInfoConverter(PQCObjectIdentifiers.qTESLA_p_III, new QTESLAKeyFactorySpi());
addKeyInfoConverter(PKCSObjectIdentifiers.id_alg_hss_lms_hashsig, new LMSKeyFactorySpi());
}
private static AsymmetricKeyInfoConverter getAsymmetricKeyInfoConverter(ASN1ObjectIdentifier algorithm)
{
synchronized (keyInfoConverters)
{
return (AsymmetricKeyInfoConverter)keyInfoConverters.get(algorithm);
}
}
}
所以只需在外部创建对象时保持单例或者说是保持只有一个实例即可,我在这里直接在外部创建了一个静态变量,用于解决这个问题。解决完再未出现老年代不释放内存的问题,如此,便大功告成了。