下面这4个案例来自大神“你假笨”(任职阿里期间,花名:寒泉子)在qcon上的分享,记录一下:
一、类加载死锁
现象:jstack将线程dump出来后,找不到deadlock字样的死锁信息,但是有大量的线程在调用Class.forName加载类
@CallerSensitive public static Class<?> forName(String className) throws ClassNotFoundException { Class<?> caller = Reflection.getCallerClass(); return forName0(className, true, ClassLoader.getClassLoader(caller), caller); } private static native Class<?> forName0(String name, boolean initialize, ClassLoader loader, Class<?> caller) throws ClassNotFoundException;
可以看到forName0是一个native方法,分析该方法的C++源码实现,可以发现使用了锁(细节略)
tips: jstack -m pid (可以看到native的详细输出信息,但不推荐生产上用,极端情况会让应用不稳定)
类加载在底层要加锁的原因也不难理解 ,如上图,如果三个线程并发加载类C,如果没有锁,最后可能会把类的元数据信息,在perm区(JDK8以前的版本,JDK8后取消了Perm区)中存多份,很容易造成内存泄露,所以需要加锁,加锁后变成下面这样:
这个并发加载的情况,从JDK7开始就做了优化,支持并发类加载,但是要使用该功能,必须注册成并行类加载器,否则仍然存在死锁可能。
参考文章:
https://docs.oracle.com/javase/7/docs/technotes/guides/lang/cl-mt.html
https://www.cnblogs.com/cz123/p/6918708.html
https://www.jianshu.com/p/8e8a5a773648
解决方法:
既然多线程并发加载可能出问题,那么就放在单线程里加载,可参考下面的示例,假设有2个类:Parent及Child
package com.cnblogs.yjmyzz.test; public class Parent { static { System.out.println("Parent init."); } public static final Parent EMPTY = new Child(); public static void test() { System.out.println("test called in class Parent."); } }
及
package com.cnblogs.yjmyzz.test; public class Child extends Parent { static { System.out.println("Child init."); } }
如果用2个线程并发加载:
public static void main(String[] args) { new Thread(() -> new Child(), "T-1").start(); new Thread(() -> Parent.test(), "T-2").start(); }
T-1线程中,new Child()时,要先初始化父类Parent,需要加载类Parent,而T-2线程中调用Parent时,其static成员EMPTY又会尝试加载子类Child. 上述这段代码,如果试着运行几次,就有很大概率会遇到死锁:
会一直卡在这里。可以显式在主线程最开始用forName加载这2个类,这样类加载就变成在main线程中串行加载,问题得到解决:
public static void main(String[] args) throws ClassNotFoundException { Class.forName("com.cnblogs.yjmyzz.test.Parent"); Class.forName("com.cnblogs.yjmyzz.test.Child"); new Thread(() -> new Child(), "T-1").start(); new Thread(() -> Parent.test(), "T-2").start(); }
二、FinalReference堆积
现象:用jmap命令分析查看占用内存最多的对象时, 发现java.lang.ref.Finalizer实例排在最前面。
原因:
Object类有一个finalize方法,类似析构器,开发人员可以重载这个方法,用于清理资源。大多数情况下,java并不推荐重载该方法,因为jvm的GC已经把垃圾回收做得很好了。
但如果有某种原因,开发人员确实需要重载该方法:
@Override protected void finalize() throws Throwable { //开发人员自定义的清理逻辑 }
即:这里有些自定义的清理逻辑。这种重载了finalize方法,且实现代码非空的类,在类加载时会被特殊标识,当实例创建时,被包装成FinalReference,放入一个队列里,当GC发生时,如果该实例被标识为垃圾对象,GC清理完后,会用一个额外的线程(重点:这是1个独立的单线程),从队列里一个个取出来,调用重载的finalize方法,如果这种对象在JVM中有大量实例,而且finalize里的清理逻辑,耗时又比较久的话,单线程忙不过来,只能等到下1个GC周期,才会继续清理,因此造成堆积。
建议:不用使用重载finalize的方式来清理资源。
三、堆外内存不释放
先回顾下堆外内存的分布,对于DirectByteBuffer之类的对象,JVM堆上只存放了其"引用",如下图,引用指向的实际内存块在JVM堆外(即:实际分配的堆外内存不受GC管控)
GC能管理的只是堆上的"引用"数据,但是这块数据通常又非常小,就算经过GC不停折腾,从年青代晋升到老年代,只要老年代的空间还够,就会一直存活,因此其指向的堆外内存也不会释放。除非发生Full GC,把"引用"数据给干掉了,其指向的堆外内存,才会被释放。
建议:使用-XX:MaxDirectMemorySize参数,限制堆外内存大小。
四、YGC时间不断拉长
现象:随着系统持续运行,单次YGC的时间越来越长。
可能原因:大量调用了String.intern方法,导致字符串的常量池越来越大,而每次YGC都要先mark标记,字符串常量池越大,需要扫描mark的对象也越多,时间就变长了。
排查方法:jmap -histo:live pid 强制触发一次Full GC,这会强制清理字符串常量池StringTable中无效的对象,如果YGC时间恢复,说明大概率就是这个原因。