刨根问底-JVM类加载死锁

引言

单例模式大家都了解,其中懒汉式如下:


public class Singleton {
    private static Singleton singleton;

    private Singleton(){}
    
    public static Singleton getInstance(){
        if(singleton == null){                 
            singleton = new Singleton();
        }
        
        return singleton;
   }
}

优化非常简单,就是在getInstance方法上面做了同步,但是synchronized就会导致这个方法比较低效,导致程序性能下降,那么怎么解决呢?聪明的人们想到了双重检查 DCL。但DCL并不完美,在对象初始化的时候还存在另外一个问题。

//DCL
public class Singleton {
   private static Singleton singleton;

   private Singleton(){}

   public static Singleton getInstance(){
       if(singleton == null){                              // 1
           synchronized (Singleton.class){                 // 2
               if(singleton == null){                      // 3
                   singleton = new Singleton();            // 4
               }
           }
       }
       return singleton;
   }
}

原本对象实例化执行顺序如下:
1.分配内存空间
2.初始化对象
3.将内存空间的地址赋值给对应的引用
但是由于重排序的缘故,步骤2、3可能会发生重排序,其过程如下:
1.分配内存空间
2.将内存空间的地址赋值给对应的引用
3.初始化对象

如果2、3发生了重排序就会导致第二个判断会出错,singleton != null,但是它其实仅仅只是一个地址而已,此时对象还没有被初始化,所以return的singleton对象是一个没有被初始化的对象

通过上面的阐述,我们可以判断DCL的错误根源在于步骤4:
singleton = new Singleton();
知道问题根源所在,那么怎么解决呢?有两个解决办法:

  1. 不允许初始化阶段步骤2 、3发生重排序。
  2. 允许初始化阶段步骤2 、3发生重排序,但是不允许其他线程“看到”这个重排序。

基于volatile解决方案:
当singleton声明为volatile后,步骤2、步骤3就不会被重排序了,也就可以解决上面那问题了

public class Singleton {
   //通过volatile关键字来确保安全
   private volatile static Singleton singleton;

   private Singleton(){}

   public static Singleton getInstance(){
       if(singleton == null){
           synchronized (Singleton.class){
               if(singleton == null){
                   singleton = new Singleton();
               }
           }
       }
       return singleton;
   }
}

基于类初始化的解决方案:
利用classloder的机制来保证初始化instance时只有一个线程。JVM在类初始化阶段会获取一个锁,这个锁可以同步多个线程对同一个类的初始化
这种解决方案的实质是:运行步骤2和步骤3重排序,但是不允许其他线程看见。
(Java语言规定,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之相对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化阶段期间会获取这个初始化锁,并且每一个线程至少获取一次锁来确保这个类已经被初始化过了。)

public class Singleton {
   private static class SingletonHolder{
       public static Singleton singleton = new Singleton();
   }
   
   public static Singleton getInstance(){
       return SingletonHolder.singleton;
   }
}

上面的类初始化方案提到了JVM类加载的时候,是有一把LC锁,保证多线程下类只会被加载一次。其中singleton是静态资源在类加载的初始化阶段由类构造器< clinit >()完成静态资源的加载任务。如果对这里还有疑问的,下面进行简单的阐述说明。

类加载中的初始化

类初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(字节码)。

在准备阶段,变量已经赋过一次系统要求的初始值(零值);而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化类变量和其他资源,或者更直接地说:初始化阶段是执行类构造器< clinit >()方法的过程。< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

类构造器< clinit >()与实例构造器< init >()不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器< clinit >()执行之前,父类的类构造< clinit >()执行完毕。由于父类的构造器< clinit >()先执行,也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。特别地,类构造器< clinit >()对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器< clinit >()。

虚拟机会保证一个类的类构造器< clinit >()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器< clinit >(),其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法完毕。特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行< clinit >()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行< clinit >()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的< clinit >()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的

具体类加载过程可参考:JVM类加载与初始化

类加载中的死锁

首先看一段代码,你觉得有什么问题吗?

public class TestClassLoadingNew {
    public static class A{
        static {
            System.out.println("class A init");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            B.test();
        }

        public static void test() {
            System.out.println("aaa");
        }
    }

    public static class B{
        static {
            System.out.println("class B init");
            A.test();
        }


        public static void test() {
            System.out.println("bbb");
        }
    }
    public static void main(String[] args) {
        new Thread(() -> A.test()).start();
        new Thread(() -> B.test()).start();
    }
}

Java 平台的早期版本中,多线程自定义类加载器在没有双亲委派模型时可能会产生死锁。套用一下JDK官方举例的死锁场景,很直接:

Class Hierarchy:
  class A extends B
  class C extends D

ClassLoader Delegation Hierarchy:

Custom Classloader CL1:
  directly loads class A 
  delegates to custom ClassLoader CL2 for class B

Custom Classloader CL2:
  directly loads class C
  delegates to custom ClassLoader CL1 for class D

Thread 1:
  Use CL1 to load class A (locks CL1)
    defineClass A triggers
      loadClass B (try to lock CL2)

Thread 2:
  Use CL2 to load class C (locks CL2)
    defineClass C triggers
      loadClass D (try to lock CL1)

这里,问题的根本原因,其实是:
类加载器在初始化一个类的时候,会对加载当前类的classLoad加锁,加锁后,再执行类的静态初始化块。
所以,上面会发生:

  1. 线程1:类A对classLoad1加锁,加锁后,执行类的静态初始化块(在堆栈里体现为函数),发现用到了class B,于是去加载B(尝试获取classLoad2锁资源);
  2. 线程2:类B对classLoad2加锁,加锁后,执行类的静态初始化块(在堆栈里体现为函数),发现用到了class A,于是去加载A(尝试获取classLoad1锁资源);
  3. 死锁发生。

我们在上面的类加载初始化中说到一般类加载初始化中的死锁都是非常隐蔽的,不信你通过jstack 加上 -l 参数,打印出各个线程持有的锁的信息看看是什么效果?

"Thread-1" #15 prio=5 os_prio=0 tid=0x000000002178a000 nid=0x2df8 in Object.wait() [0x0000000021f4e000]
   java.lang.Thread.State: RUNNABLE
        at com.dmtest.netty_learn.TestClassLoading$B.<clinit>(TestClassLoading.java:32)
        at com.dmtest.netty_learn.TestClassLoading.lambda$main$1(TestClassLoading.java:42)
        at com.dmtest.netty_learn.TestClassLoading$$Lambda$2/736709391.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
        - None

"Thread-0" #14 prio=5 os_prio=0 tid=0x0000000021787800 nid=0x2618 in Object.wait() [0x00000000213be000]
   java.lang.Thread.State: RUNNABLE
        at com.dmtest.netty_learn.TestClassLoading$A.<clinit>(TestClassLoading.java:21)
        at com.dmtest.netty_learn.TestClassLoading.lambda$main$0(TestClassLoading.java:41)
        at com.dmtest.netty_learn.TestClassLoading$$Lambda$1/611437735.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
        - None

这里你看下Thread状态是RUNNABLE,但是又是卡在Object.wait()处的,这里确实只能说是JVM里的一个bug吧,状态不一致,从线程dump的线程栈来看完全看不出是调用Object.wait,但是从线程输出来看确实有Object.wait,为了找出哪里调用了它,我们可以通过jstack -m 来看,看到输出之后,你会觉得不可思议,确实有wait的逻辑,那这个逻辑从名字上来不难猜到是正在做类的初始化。

类加载的初始化阶段是执行类构造器< clinit >()方法的过程。< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。但是JVM必须要保证这个方法只能被执行一次,如果有其他线程并发调用触发了这个类的多次初始化,那只能让一个线程真正执行clinit方法,其他线程都必须等待,当clinit方法执行完之后,然后再唤醒其他等待这里的线程继续操作,当然不会再让它们有机会再执行clinit方法,因为每个类都有一个状态,这个状态可以保证这一点

public static class ClassState {
	//类加载初始化中的几种状态
     public static final InstanceKlass.ClassState ALLOCATED = new InstanceKlass.ClassState("allocated");
     public static final InstanceKlass.ClassState LOADED = new InstanceKlass.ClassState("loaded");
     public static final InstanceKlass.ClassState LINKED = new InstanceKlass.ClassState("linked");
     public static final InstanceKlass.ClassState BEING_INITIALIZED = new InstanceKlass.ClassState("beingInitialized");
     public static final InstanceKlass.ClassState FULLY_INITIALIZED = new InstanceKlass.ClassState("fullyInitialized");
     public static final InstanceKlass.ClassState INITIALIZATION_ERROR = new InstanceKlass.ClassState("initializationError");
     private String value;

     private ClassState(String value) {
         this.value = value;
     }

     public String toString() {
         return this.value;
     }
}

当有个线程正在执行这个类的clinit方法的时候,就会设置这个类的状态为being_initialized,当正常执行完之后就马上设置为fully_initialized,然后才唤醒其他也在等着对其做初始化的线程继续往下走,在继续走下去之前,会先判断这个类的状态,如果已经是fully_initialized了说明有线程已经执行完了clinit方法,因此不会再执行clinit方法了

在这里插入图片描述
上面Demo里的那两个线程,从dump来看确实是死锁了,那这个场景当时是怎么发生的呢?线程1首先执行B.test(),于是会对B类做初始化,设置B的类状态为being_initialized,接着去执行B的clinit方法,但是在clinit方法里要去调用A.test方法,理论上此时会对A做初始化并调用其test方法,但是就在设置完B的类状态之后,执行其clinit里的A.test方法之前,线程2却执行了A.test方法,此时线程2会优先负责对A的初始化工作,即设置A类的状态为being_initialized,然后再去执行A的clinit方法,此时线程1发现A的类状态是being_initialized了,那线程1就认为有线程对A类正在做初始化,于是就等待了,而线程2同样发现B的类状态也是being_initialized,于是也开始等待,这样就形成了互等的情况,造成了类死锁的现象。

JDK解决方案&建议

如果自定义类加载器遵循双亲委派机制,则它们不会陷入死锁。在这个机制中,每个类加载器都有一个父级(委托)。当一个类被请求时,类加载器首先检查该类之前是否被加载过。如果未找到该类,则类加载器要求其父类定位该类。如果父级找不到该类,则类加载器会尝试定位该类本身。

Java SE 7 发行版包含具有并行能力的类加载器的概念。现在,通过具有并行能力的类加载器加载类会在由类加载器和类名组成的对上进行同步

在之前的场景中,使用 Java SE 7 版本,线程不再死锁,所有类都加载成功:
可以看到这次同步的锁对象不再是classLoad,而是classLoad+类名。

Thread 1:
  Use CL1 to load class A (locks CL1+A)
    defineClass A triggers
      loadClass B (locks CL2+B)

Thread 2:
  Use CL2 to load class C (locks CL2+C)
    defineClass C triggers
      loadClass D (locks CL1+D)

要创建新的自定义类加载器,Java SE 7 版本中的过程与以前版本中的过程类似。创建一个子类 ClassLoader,然后覆盖 findClass()方法或loadClass()方法。 覆盖loadClass()会破坏双亲委派机制,但它是使用不同委托模型的唯一方法(可能产生死锁)。

如果有一个存在死锁风险的自定义类加载器,那么在 Java SE 7 版本中,可以通过遵循以下规则来避免死锁:

  1. 确保自定义类加载器对于并发类加载是多线程安全的。
    a. 决定一个内部锁定方案。例如, java.lang.ClassLoader使用基于请求的类名的锁定方案。
    b.单独删除类加载器对象锁上的所有同步。
    c.确保临界区对于加载不同类的多个线程是安全的。
  2. 在自定义类加载器的静态初始化程序中,调用 java.lang.ClassLoader的静态方法 registerAsParallelCapable()。此注册表明自定义类加载器的所有实例都是多线程安全的。
  3. 检查此自定义类加载器扩展的所有类加载器类是否也在 registerAsParallelCapable()其类初始值设定项中调用该方法。确保它们对于并发类加载是多线程安全的。

如果自定义类加载器仅覆盖 findClass(String),则不需要进一步更改。这是创建自定义类加载器的推荐机制。

如果自定义类加载器覆盖了protected loadClass(String, boolean)方法或public loadClass(String)方法,那么还必须确保defineClass()每个类加载器和类名对只调用一次受保护的方法。

参考:
https://www.cnblogs.com/grey-wolf/p/11378747.html
JDK官方解决方案

总结:
觉得有用的客官可以点赞、关注下!感谢支持🙏谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值