单例模式实例化

18

下面展示的是单例对象的创建。

public class Map_en_US extends mapTree {

    private static Map_en_US m_instance;

    private Map_en_US() {}

    static{
        m_instance = new Map_en_US();
        m_instance.init();
    }

    public static Map_en_US getInstance(){
        return m_instance;
    }

    @Override
    protected void init() {
        //some code;
    }
}

我的问题是使用静态块进行实例化的原因是什么。我熟悉单例的以下实例化方式。

public static Map_en_US getInstance(){
    if(m_instance==null)
      m_instance = new Map_en_US();
}
10个回答

37

原因是线程安全

你所说的那种形式可能会多次初始化单例,并且即使已多次初始化,不同线程对getInstance()的后续调用也可能返回不同的实例!此外,一个线程可能会看到部分初始化的单例实例!(假设构造函数连接到数据库并进行身份验证;即使在构造函数中执行身份验证,一个线程也可能在其完成之前就能获得对单例的引用!)

处理线程时存在一些困难:

  1. 并发性:它们有可能同时执行;
  2. 可见性:由一个线程对内存所做的修改可能对其他线程不可见;
  3. 重排列:代码执行的顺序无法预测,这可能会导致非常奇怪的结果。

您应该学习这些困难,以确切地了解为什么这些奇怪的行为在JVM中是完全合法的,为什么它们实际上是好的,并且如何保护自己免受它们的影响。

静态块由JVM保证只会被执行一次(除非使用不同的ClassLoader加载和初始化类,但细节超出了这个问题的范围),并且只由一个线程执行,并且它的结果保证对每个其他线程可见。

这就是为什么你应该在静态块中初始化单例。

我喜欢的模式:线程安全和延迟

上面的模式将在第一次执行看到对Map_en_US类的引用时实例化单例(实际上,只有对类本身的引用将加载它,但可能尚未初始化它;有关详细信息,请检查引用)。也许你不想要那样。也许你只想在第一次调用Map_en_US.getInstance()时初始化单例(就像你所说的那个模式所做的那样)。

如果您想要这样做,可以使用以下模式:

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

上面的代码中,只有当类SingletonHolder被初始化时,单例才会被实例化。这只会发生一次(除非,正如我之前所说,您使用多个类加载器),代码将由一个线程执行,结果不会有可见性问题,并且初始化只会在第一次引用SingletonHolder时发生,它发生在getInstance()方法内部。这是我需要单例时最常用的模式。

另外的模式...

1. synchronized getInstace()

如本答案评论中讨论的那样,有另一种以线程安全方式实现单例的方法,几乎与您熟悉的(有缺陷的)方法相同:

public class Singleton {
  private static Singleton instance;
  public static synchronized getInstance() {
    if (instance == null)
      instance = new Singleton();
  }
}

以上代码是通过内存模型来保证线程安全的。JVM规范以更加晦涩的方式陈述了以下内容:假设L是任何对象的锁,T1和T2是两个线程。T1释放L的操作发生在T2获取L的操作之前

这意味着,在释放锁之前,T1进行的所有操作都将对其他线程可见,这些线程在获取相同锁后都能看到。

因此,假设T1是第一个进入getInstance()方法的线程。在它完成之前,没有其他线程能够进入相同的方法(因为它被同步了)。它将看到instance为null,将创建一个Singleton实例并存储在字段中。然后,它会释放锁并返回该实例。

接下来,等待锁的T2将能够获取并进入该方法。由于它获取了T1刚刚释放的相同锁,T2将看到字段instance包含由T1创建的Singleton的完全相同的实例,并简单地返回它。更重要的是,由T1完成的单例初始化发生在T1释放锁之前,T1释放锁发生在T2获取锁之前,因此T2不会看到部分初始化的单例。

以上代码是完全正确的。唯一的问题是对单例的访问将被序列化。如果它经常发生,它将降低应用程序的可扩展性。这就是为什么我更喜欢我上面展示的SingletonHolder模式:可以真正并发地访问单例,而无需同步!

2. 双重检查锁定(DCL)

通常,人们担心锁获取的成本。我读过现在它对大多数应用程序来说不那么重要。锁获取的真正问题是通过序列化对同步块的访问来损害可扩展性。

有人想出了一个聪明的方法来避免获取锁,并称之为双重检查锁定。问题是,大多数实现都是有缺陷的。也就是说,大多数实现不是线程安全的(即与原始问题中的getInstace()方法一样不安全)。

实现DCL的正确方法如下:

public class Singleton {
  private static volatile Singleton instance;
  public static Singleton getInstance() {
    if (instance == null) {
      synchronized {
        if (instance == null) {
          instance = new Singleton();
        }
      }
    }
    return instance;
  }
}
正确和不正确实现之间的区别在于volatile关键字。要理解为什么,让T1和T2成为两个线程。首先假设该字段不是volatile的。T1进入getInstace()方法,它是第一个进入该方法的线程,因此该字段为null。然后它进入同步块,然后进入第二个if。它也计算为true,因此T1创建单例的新实例并将其存储在字段中。锁定随后被释放,并返回单例。对于这个线程来说,保证单例完全初始化。现在,T2进入getInstace()方法。它可能(虽然不一定)看到instance!= null。然后它将跳过if块(因此它永远不会获得锁),并直接返回单例。由于重排序,T2可能无法看到单例构造函数中执行的所有初始化!重新访问db连接单例示例,T2可能会看到已连接但尚未进行身份验证的单例!建议阅读Java Concurrency in Practice和Java Language Specification获取更多信息。

1
@Thomas .. 谢谢,我明白了!... 嗯,使用_synchronized_会使getInstance方法线程安全吗?! - Gruber
@Neil 实际上,如果 getInstance() 声明为 synchronized,内存模型将保证单例的线程安全性:不会有并发初始化,并且如果 L 是任何对象的锁,T1 和 T2 是两个线程,则 T2 获取 L happens-before T1 释放 L,因此 T2 将看到持有单例的字段已填充,并且 Singleton 的构造函数已经对两个线程完成了执行。我会在我的答案中评论这一点。 - Bruno Reis
请注意,将getInstance()方法同步会引入一些同步开销。这可以通过“双重检查锁定”来大幅减少 - 请查看我的回答中的链接。 - Thomas
3
@Thomas:在使用双重检查锁定时要特别小心,因为大多数你在网上找到的实现都是有问题的。若要正确地执行此操作,请记得必须将字段标记为 volatile;如果不是 volatile,那么它必然是有问题的。 - Bruno Reis
根据我对JLS [17.5]和[8.3.2.1]的阅读,_Map_en_US_没有安全保证。另外一个问题是,在我看来,线程不安全/安全并不能回答“使用静态块的原因”的问题。@Bruno - gnat
显示剩余6条评论

1
这个方法用于消除静态块怎么样?
private static Map_en_US s_instance = new Map_en_US() {{init();}};

它做的事情相同,但更加整洁。

这个语法的解释:
外部一组大括号创建了一个匿名类。
内部一组大括号称为“实例块”——在构造期间触发。
通常那些不理解正在发生什么的人会错误地将此语法称为“双括号初始化器”语法。

另外,请注意:
m_是用于实例(即成员)字段的命名约定前缀。
s_是用于(即静态)字段的命名约定前缀。
因此,我将该字段的名称更改为s_...


然而,对于最终类来说,那是行不通的。基本上,双括号语法创建了一个匿名子类,在Map_en_US是final的情况下无法编译。 - Thomas
@Bohemian:严格来说,它并不做同样的事情。正如你所说,它创建了一个继承自Map_en_US的匿名类。因此,为了使这段代码工作,Map_en_US不能被标记为final - Bruno Reis
没什么大不了的。所以,如果你得到一个最终类(不常见),就不要使用它。 - Bohemian
为什么不在构造函数中直接调用 init 函数呢? - user85421

1

如果您在getInstance()方法中初始化,可能会出现竞争条件,即如果2个线程同时执行if(m_instance == null)检查,两者都可能看到实例为空,因此两者都可能调用m_instance = new Map_en_US();

由于静态初始化块仅由执行类加载器的一个线程执行一次,因此您在那里没有问题。

这里有一个很好的概述。


@Bruno 据我所知,没有每个线程的副本(除非你使用ThreadLocal),因此在变量初始化后,每次调用都应该返回相同的实例,不是吗?因此假设两个线程在初始化之后调用getInstance(),在这种情况下可能会返回不同的实例(通过在同一类实例上调用它,因此在这里没有类加载问题)? - Thomas
每个线程都有每个变量的本地“工作副本”(无论是ThreadLocal还是不是)。有时它们会与主内存同步(ThreadLocal好像没有同步到主内存)。您可以使用synchronizedvolatile强制同步。如果没有同步,JVM可以随意刷新变量的本地工作副本内容到主内存中(行为取决于实现)。因此,调用getInstance()的两个不同线程可能永远无法看到其他线程创建的实例! - Bruno Reis
@Bruno,你的解释很有道理,谢谢。现在我也更好地理解了你对双重检查锁定的担忧。如果可以的话,我会再给你一个+1的赞 :) - Thomas

0

静态块在这里允许进行init调用。其他编码方式可能是像这样(哪种更好是个人口味的问题)

public class Map_en_US extends mapTree {

    private static
            /* thread safe without final,
               see VM spec 2nd ed 2.17.15 */
            Map_en_US m_instance = createAndInit();

    private Map_en_US() {}

    public static Map_en_US getInstance(){
        return m_instance;
    }

    @Override
    protected void init() {
        //some code;
    }

    private static Map_en_US createAndInit() {
        final Map_en_US tmp = new Map_en_US();
        tmp.init();
        return tmp;
    }
}

更新 根据VM规范2.17.5进行了更正,详见评论


同意。静态块也会调用init()方法。我的init()方法包含的内容应该在单例对象创建时可用。 - pushya
@gnat:这里完全与“final”无关。它不会增加任何线程安全性。您混淆了在实例字段中使用“final”关键字的语义。您提出的代码在没有“final”的情况下完全是线程安全的。 - Bruno Reis
@Bruno,能否提供一下JLS参考文献,以确保在没有final的情况下线程安全? - gnat
1
@gnat:当线程验证类是否完全初始化并且初始化静态非最终字段(与静态块执行的时间相同)时,在Class对象上进行的锁获取/释放(发生在哪里)保证了非最终静态字段的初始值的可见性,由于给定锁的释放和获取之间存在* happens-before *关系。[Section 2.17.5](http://java.sun.com/docs/books/jvms/second_edition/html/Concepts.doc.html#24237) - Bruno Reis
顺便说一下,那是虚拟机规范,而不是Java语言规范。但我认为在这个主题上它至少和Java语言规范一样权威。 - Bruno Reis
@Bruno 谢谢!VM规范2.7.15就是我所询问的保证。我会在我的示例代码中进行更正。 - gnat

0
    // Best way to implement the singleton class in java
    package com.vsspl.test1;

    class STest {

        private static STest ob= null;
        private  STest(){
            System.out.println("private constructor");
        }

        public static STest  create(){

            if(ob==null)
                ob = new STest();
            return ob;
        }

        public  Object  clone(){
            STest   obb = create();
            return obb;
        }
    }

    public class SingletonTest {
        public static void main(String[] args)  {
            STest  ob1 = STest.create();
            STest  ob2 = STest.create();
            STest  ob3 = STest.create();

            System.out.println("obj1  " +  ob1.hashCode());
            System.out.println("obj2  " +  ob2.hashCode());
            System.out.println("obj3  " +  ob3.hashCode());


            STest  ob4 = (STest) ob3.clone();
            STest  ob5 = (STest) ob2.clone();
            System.out.println("obj4  " +  ob4.hashCode());
            System.out.println("obj5  " +  ob5.hashCode());

        }
    }

-------------------------------- OUT PUT -------------------------------------
private constructor
obj1  1169863946
obj2  1169863946
obj3  1169863946
obj4  1169863946
obj5  1169863946

0

这取决于init方法的资源密集程度。如果它需要大量工作,也许您希望在应用程序启动时完成该工作,而不是在第一次调用时完成。也许它会从互联网下载地图?我不知道...


0

静态块在JVM首次加载类时执行。正如Bruno所说,这有助于线程安全,因为不存在两个线程会争夺同一个getInstance()调用的可能性。


1
请记住,如果类被不同的ClassLoader加载和初始化,则静态块可以被执行多次 - Bruno Reis

0
  1. 使用静态实例化,无论创建多少个对象,每个类只会有一个实例的副本。
  2. 第二个优点是,这种方法是线程安全的,因为除了返回实例之外,您在方法中没有做任何事情。

如果同一个类被不同的类加载器加载和初始化多次,那么可能会存在多个实例。 - Bruno Reis

0

静态块实例化你的类并调用默认构造函数(如果有)仅一次,当应用程序启动并且JVM加载所有静态元素时。

使用getInstance()方法时,类的对象在调用该方法时构建和初始化,而不是在静态初始化时进行。如果您在同时运行getInstance()的不同线程中,则不是真正安全的。


2
静态块不是在应用程序启动时调用的。它只有在类被加载和初始化时才会被调用,而这可能永远不会发生! - Bruno Reis

-1

有趣,以前从未见过。似乎主要是一种风格偏好。我想一个区别是:静态初始化发生在VM启动时,而不是在第一次请求实例时,可能消除了并发实例化的问题?(这也可以通过synchronized getInstance()方法声明来处理)


错误。静态初始化不会在虚拟机启动时发生。它只会在类被引用时发生(这可能永远不会发生)。 - Bruno Reis
@bruno,那么静态初始化是什么时候发生的?如果它不是在JVM启动时发生的话。你所说的“只有在类被引用时才会发生”的情况是什么意思? - pushya
@FirstHearty:当执行线程首次遇到类的引用时,就会发生这种情况。假设您有一个 main(...) { System.out.println("bye"); },它不使用类 Singleton。因此,JVM 没有必要加载类 Singleton。因此它不会被初始化。 - Bruno Reis

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接