JVM问题典型案例定位学习

下面这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时间恢复,说明大概率就是这个原因。

posted @ 2020-03-15 22:10  菩提树下的杨过  阅读(...)  评论(...编辑  收藏