【问题标题】:Which implementation for lazy singleton whose initialialization might fail?初始化可能失败的惰性单例的哪个实现?
【发布时间】:2011-03-02 20:53:48
【问题描述】:

假设你有一个静态的无参数方法,它是幂等的并且总是返回相同的值,并且可能会抛出一个检查异常,如下所示:

class Foo {
 public static Pi bar() throws Baz { getPi(); } // gets Pi, may throw 
}

如果构造返回的对象的东西很昂贵并且永远不会改变,那么现在这是一个懒惰的单例的好选择。一种选择是 Holder 模式:

class Foo {
  static class PiHolder {
   static final Pi PI_SINGLETON = getPi();
  }
  public static Pi bar() { return PiHolder.PI_SINGLETON; }
}

不幸的是,这不起作用,因为我们不能从(隐式)静态初始化程序块中抛出已检查的异常,因此我们可以尝试这样的事情(假设我们想要保留调用者获得检查的行为当他们调用bar()时出现异常:

class Foo {
  static class PiHolder {
   static final Pi PI_SINGLETON;
   static { 
    try { 
     PI_SINGLETON =  = getPi(); }
    } catch (Baz b) {
     throw new ExceptionInInitializerError(b);
    }
  }

  public static Pi bar() throws Bar {
   try {
    return PiHolder.PI_SINGLETON;
   } catch (ExceptionInInitializerError e) {
    if (e.getCause() instanceof Bar)
     throw (Bar)e.getCause();
    throw e;
   }
}

在这一点上,也许双重检查锁定更干净?

class Foo {
 static volatile Pi PI_INSTANCE;
 public static Pi bar() throws Bar {
  Pi p = PI_INSTANCE;
  if (p == null) {
   synchronized (this) {
    if ((p = PI_INSTANCE) == null)
     return PI_INSTANCE = getPi();
   }
  }
  return p;
 }
}

DCL 仍然是反模式吗?我在这里是否缺少其他解决方案(也可以使用诸如 racy single check 之类的小变体,但不要从根本上改变解决方案)?是否有充分的理由选择其中一个?

我没有尝试上面的示例,所以它们完全有可能无法编译。

编辑:我没有重新实现或重新构建这个单例的消费者(即Foo.bar() 的调用者)的奢侈,我也没有机会引入一个DI框架来解决这个问题。我最感兴趣的是在给定约束内解决问题的答案(提供带有已检查异常的单例传播给调用者)。

更新:毕竟我决定使用 DCL,因为它提供了最干净的方式来保存现有合同,并且没有人提供应该避免正确执行 DCL 的具体原因。我没有在接受的答案中使用该方法,因为它似乎只是实现同一目标的一种过于复杂的方法。

【问题讨论】:

    标签: java design-patterns concurrency singleton double-checked-locking


    【解决方案1】:

    “Holder”技巧本质上是 JVM 执行的双重检查锁定。根据规范,类初始化处于(双重检查)锁定状态。 JVM 可以安全(且快速)地执行 DCL,不幸的是,Java 程序员无法获得这种能力。我们能做的最接近的是通过中间的最终参考。请参阅有关 DCL 的维基百科。

    您保留异常的要求并不难:

    class Foo {
      static class PiHolder {
        static final Pi PI_SINGLETON;
        static Bar exception;
        static { 
          try { 
            PI_SINGLETON =  = getPi(); }
          } catch (Bar b) {
            exception = b;
          }
        }
      }
    public Pi bar() throws Bar {
      if(PiHolder.exception!=null)
        throw PiHolder.exception;  
      else
        return PiHolder.PI_SINGLETON;
    }
    

    【讨论】:

    • “JVM 可以安全地执行 DCL,不幸的是,Java 程序员无法使用这种能力”。真的吗?那么为什么维基百科上关于双重检查锁定的文章写道:“从 J2SE 5.0 开始,此问题已得到修复。volatile 关键字现在确保多个线程正确处理单例实例”。
    • 至少从 JDK 1.5 开始,我们可以使用 volatile 进行 DCL。
    • 我不确定你的意思 - 机器代码“可以在没有 volatile 的情况下执行 DCL”是什么意思? volatile 是 Java 语言级别的概念,JVM 会将其映射到适当的硬件指令、内存屏障和优化限制中。在本机代码中,java volatile 不存在,但存在相同的问题。因此 JVM 在类初始化时不能做任何魔术来避免正确排序的成本。在 x86 和 x86-64 上的现代实现中,易失性读取不需要任何特殊操作 - 它们是常规读取,因此非常便宜。
    • 我接受这个答案,因为它是唯一试图回答所提出的具体问题的答案,而不是“不要使用 XYZ”或“使用 DI 框架”。
    • @BeeOnRope 最低限度,一旦线程读取非空值,就不需要再次读取它。 VM 可以优化非易失性读取。很难优化 volatile 读取;这需要全局分析。
    【解决方案2】:

    我强烈建议总体上抛弃单例和可变静态。 “正确使用构造函数。”构造对象并将其传递给需要它的对象。

    【讨论】:

    • @irreputable:什么不懒?调用构造函数?
    • @irreputable 不偷懒可能没关系。如果确实需要(不太可能),您可以将其隐藏在代理后面。
    • 还假设调用代码不能更改。我想维持这样的合同,即无论在创建 Pi 时发生的任何请求 Pi 的消费代码都会处理异常。不幸的是,我没有重新构建这段代码的许多调用的奢侈。另外,来自例如静态方法的调用者呢?我想那些是邪恶的,我也需要删除它们只是为了实现这个简单的单例......
    • @BeeOnRope 使用单例或其他形式的可变静态的代码已损坏。没有必要比这更进一步。
    • 然后考虑可变静态的实现细节。也许 bar() 方法不是静态的,而是选择使用私有静态作为隐藏的实现细节 - 该类可以替换为具有相同接口的另一个类,该类使用适合测试的任何 DI 魔法或其他任何东西。在这种情况下,静态在实际意义上甚至都不是真正的“可变”——它只能假设一个非空值。我只想将错误传播给被调用者。就像 String 中的哈希码在概念上是不可变的(就像字符串一样),但出于性能原因是可变的。
    【解决方案3】:

    根据我的经验,当您尝试获取的对象需要的不仅仅是简单的构造函数调用时,最好使用依赖注入。

    public class Foo {
      private Pi pi;
      public Foo(Pi pi) {
        this.pi = pi;
      }
      public Pi bar() { return pi; }
    }
    

    ...或者如果懒惰很重要:

    public class Foo {
      private IocWrapper iocWrapper;
      public Foo(IocWrapper iocWrapper) {
        this.iocWrapper = iocWrapper;
      }
      public Pi bar() { return iocWrapper.get(Pi.class); }
    }
    

    (具体情况在一定程度上取决于您的 DI 框架)

    您可以告诉 DI 框架将对象绑定为单例。从长远来看,这为您提供了更大的灵活性,并使您的课程更易于单元测试。

    另外,我的理解是,Java 中的双重检查锁定不是线程安全的,因为 JIT 编译器可能会重新排序指令。 编辑:正如 Mereron 指出的那样,double -checked 锁定可以在 Java 中工作,但您必须使用 volatile 关键字。

    最后一点:如果您使用良好的模式,通常很少或没有理由希望您的类被延迟实例化。最好让您的构造函数非常轻量级,并将大部分逻辑作为方法的一部分执行。我并不是说在这种特殊情况下你一定做错了什么,但你可能想更广泛地看看你是如何使用这个单例的,看看是否有更好的方法来构建事物。

    【讨论】:

    • 这不是偷懒。 Pi 在使用前被初始化。
    • 在 Java 5.0 发布之前,双重检查锁定是不安全的——六年多以前!如今,声明字段volatile 就足够了。 (c.f.en.wikipedia.org/wiki/Double-checked_locking#Usage_in_Java)
    • op 似乎非常注重性能。 spring.getInstance(name) 慢了一百万倍。
    • @irreputable:在不知道他用它做什么的情况下,我无话可说。大多数情况下,期望延迟实例化是不值得的,而在大多数情况下,非常注重性能也并不值得。我给出的模式是我根据个人经验提出的建议。我也很想听听你的建议。
    • 在这种情况下,出于性能原因,我对 lazy 部分并不真正感兴趣 - 而是将错误传播给第一个调用者(而不是那些无辜的其他调用者可能出于其他原因访问 Foo)。所以事实上,不是懒惰但保持这种行为的解决方案也很有趣。使用单例的根本原因是 getPi() 是一个昂贵的调用。
    【解决方案4】:

    由于您没有告诉我们您需要什么,因此很难提出更好的方法来实现它。我可以告诉你,惰性单例很少是最好的方法。

    不过,我可以看到您的代码存在一些问题:

    try {
        return PiHolder.PI_SINGLETON;
    } catch (ExceptionInInitializerError e) {
    

    您希望字段访问如何引发异常?


    编辑:正如 Irreputable 指出的那样,如果访问导致类初始化,并且由于静态初始化程序抛出异常而导致初始化失败,那么您实际上会在这里得到 ExceptionInInitializerError。但是,VM 不会在第一次失败后再次尝试初始化该类,而是使用不同的异常进行通信,如下面的代码所示:

    static class H {
        final static String s; 
        static {
            Object o = null;
            s = o.toString();
        }
    }
    
    public static void main(String[] args) throws Exception {
        try {
            System.out.println(H.s);
        } catch (ExceptionInInitializerError e) {
        }
        System.out.println(H.s);
    }
    

    结果:

    Exception in thread "main" java.lang.NoClassDefFoundError: Could not initialize class tools.Test$H 
            at tools.Test.main(Test.java:21)
    

    而不是 ExceptionInInitializerError。


    您的双重检查锁定遇到了类似的问题;如果构造失败,则该字段保持为空,并且每次访问 PI 时都会重新尝试构造 PI。如果失败是永久性的且代价高昂,您可能希望以不同的方式做事。

    【讨论】:

    • 在字段访问之前,有一个类访问,如果类初始化失败,可能会失败。
    • 你确定吗?你试过了吗?静态字段访问肯定会引发异常。
    • 更多关于为什么需要它 - Pi 对象是一个庄严的单例对象,它实现了类似于解析器的东西。这个解析器的创建成本很高,所以我希望只创建其中一个。消费者不能改变,所以 bar() 的签名和功能应该保持不变。即使消费者可以改变,我认为这是一种简单而合适的模式(与 DI 相比)。
    猜你喜欢
    • 1970-01-01
    • 2019-12-17
    • 2017-07-26
    • 2014-05-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多