Java单例模式

2024-04-23 17:58:19 浏览数 (2)

1 什么是单例模式?

Java中单例(Singleton)模式是一种广泛使用的设计模式。单例模式的主要作用是保证在Java程序中,某个类只有一个实例存在,一些管理器和控制器常被设计成单例模式。

单例模式确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。这些应用都或多或少具有资源管理器的功能。每台计算机可以有若干个打印机,但只能有一个Printer Spooler,以避免两个打印作业同时输出到打印机中。每台计算机可以有若干通信端口,系统应当集中管理这些通信端口,以避免一个通信端口同时被两个请求同时调用。总之,选择单例模式就是为了避免不一致状态,避免政出多头。

单例模式有很多好处,它能够避免实例对象的重复创建,不仅可以减少每次创建对象的时间开销,还可以节约内存空间;能够避免由于操作多个实例导致的逻辑错误。如果一个对象有可能贯穿整个应用程序,而且起到了全局统一管理控制的作用,那么单例模式也许是一个值得考虑的选择。

单例模式有以下特点:

  • 单例类只能有一个实例。
  • 单例类必须自己创建自己的唯一实例。
  • 单例类必须给所有其他对象提供这一实例。

2 单例模式与静态类的区别

首先理解一下什么是静态类,静态类就是一个类里面都是静态方法和静态field,构造器被private修饰,因此不能被实例化。Math类就是一个静态类。

知道了什么是静态类后,来说一下他们两者之间的区别

  • 首先单例模式会提供给你一个全局唯一的对象,静态类只是提供给你很多静态方法,这些方法不用创建对象,通过类就可以直接调用。
  • 如果是一个非常重的对象,单例模式可以懒加载,静态类就无法做到。

那么什么时候应该用静态类,什么时候应该用单例模式呢?首先如果你只是想使用一些工具方法,那么最好用静态类,静态类比单例类更快,因为静态的绑定是在编译期进行的。如果你要维护状态信息,或者访问资源时,应该选用单例模式。还可以这样说,当你需要面向对象的能力(比如继承、多态)时,选用单例类,当你仅仅是提供一些方法时选用静态类。

3 如何实现单例模式?

单例模式有很多种写法,大部分写法都或多或少有一些不足。下面将分别对这几种写法进行介绍:

3.1 饿汉模式

所谓饿汉模式就是立即加载,是最简单的一种实现方式。饿汉模式在类加载的时候就对实例进行创建,实例在整个程序周期都存在。一般情况下在调用getInstance()方法之前就已经产生了实例,也就是在类加载的时候已经产生了。

  • 声明静态私有类变量,且立即实例化,保证实例化一次
  • 私有构造,防止外部实例化(通过反射是可以实例化的,不考虑此种情况)
  • 提供public的getInstance()方法供外部获取单例实例
代码语言:javascript复制
public class Singleton {
	//创建对象实例
    private static Singleton instance = new Singleton();
    //将构造器设置为private禁止通过new进行实例化
    private Singleton() {

    }
    public static Singleton getInstance() {
        return instance;
    }
}

从代码中我们看到,类的构造函数定义为private的,保证其他类不能实例化此类,然后提供了一个静态实例并返回给调用者。它的好处是只在类加载的时候创建一次实例,不会存在多个线程创建多个实例的情况,避免了多线程同步的问题。它的缺点也很明显,即使这个单例没有用到也会被创建,而且在类加载之后就被创建,内存就被浪费了。

这种实现方式适合单例占用内存比较小,在初始化时就会被用到的情况。但是,如果单例占用的内存比较大,或单例只是在某个特定场景下才会用到,使用饿汉模式就不合适了,这时候就需要用到懒汉模式进行延迟加载。

优点

  • 线程安全,获取实例速度快。只在类加载的时候创建一次实例,不会存在多个线程创建多个实例的情况,避免了多线程同步的问题。

缺点

  • 占用资源,类加载即初始化实例,内存浪费。

3.2 懒汉模式

懒汉模式就是延迟加载,也叫懒加载。在程序需要用到的时候再创建实例,这样保证了内存不会被浪费。针对懒汉模式,这里给出了5种实现方式,有些实现方式是线程不安全的,也就是说在多线程并发的环境下可能出现资源同步问题。

首先第一种方式,在单线程下没问题,在多线程下就出现问题了。

代码语言:javascript复制
//单例模式的懒汉实现1--线程不安全
public class SingletonLazy1 {
    private static SingletonLazy1 singletonLazy;
    private SingletonLazy1() {

    }
    public static SingletonLazy1 getInstance() {
        if (null == singletonLazy) {
            try {
                //模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            singletonLazy = new SingletonLazy1();
        }
        return singletonLazy;
    }
}
代码语言:javascript复制
//模拟10个异步线程
public class SingletonLazyTest {
    public static void main(String[] args) {
        Thread2[] ThreadArr = new Thread2[10];
        for (int i = 0; i < ThreadArr.length; i  ) {
            ThreadArr[i] = new Thread2();
            ThreadArr[i].start();
        }
    }
}

//测试线程
public class Thread2 extends Thread {
    @Override
    public void run() {
        System.out.println(SingletonLazy1.getInstance().hashCode());
    }
}
代码语言:javascript复制
//运行结果
124191239
124191239
872096466
1603289047
1698032342
1913667618
371739364
124191239
1723650563
367137303

可以看到他们的hashCode不都是一样的,说明在多线程环境下,产生了多个对象,不符合单例模式的要求。

那么如何使线程安全呢?第二种方法,我们使用synchronized关键字对getInstance()方法进行同步。

代码语言:javascript复制
//单例模式的懒汉实现2--线程安全
//通过设置同步方法,效率太低,整个方法被加锁
public class SingletonLazy2 {
    private static SingletonLazy2 singletonLazy;
    private SingletonLazy2() {

    }
    public static synchronized SingletonLazy2 getInstance() {
        try {
            if (null == singletonLazy) {
                //模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
                singletonLazy = new SingletonLazy2();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return singletonLazy;
    }
}
代码语言:javascript复制
//运行结果
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989

可以看到,这种方式达到了线程安全。但是缺点就是效率太低,是同步运行的,下个线程想要取得对象,就必须要等上一个线程释放,才可以继续执行。

那我们可以不对方法加锁,而是将里面的代码加锁,也可以实现线程安全。但这种方式和同步方法一样,也是同步运行的,效率也很低。

代码语言:javascript复制
//单例模式的懒汉实现3--线程安全
//通过设置同步代码块,效率也太低,整个代码块被加锁
public class SingletonLazy3 {
    private static SingletonLazy3 singletonLazy;
    private SingletonLazy3() {

    }
    public static SingletonLazy3 getInstance() {
        try {
            synchronized (SingletonLazy3.class) {
                if (null == singletonLazy) {
                    //模拟在创建对象之前做一些准备工作
                    Thread.sleep(1000);
                    singletonLazy = new SingletonLazy3();
                }
            }
        } catch (InterruptedException e) {
            // TODO: handle exception
        }
        return singletonLazy;
    }
}

继续优化代码,只给创建对象的代码进行加锁,但是这样能保证线程安全吗?

代码语言:javascript复制
// 单例模式的懒汉实现4--线程不安全
// 通过设置同步代码块,只同步创建实例的代码
// 但是还是有线程安全问题
public class SingletonLazy4 {
    private static SingletonLazy4 singletonLazy;
    private SingletonLazy4() {

    }
    public static SingletonLazy4 getInstance() {
        try {
            if (null == singletonLazy) {        //代码1
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
                synchronized (SingletonLazy4.class) {
                    singletonLazy = new SingletonLazy4(); //代码2
                }
            }
        } catch (InterruptedException e) {
            // TODO: handle exception
        }
        return singletonLazy;
    }
}
代码语言:javascript复制
//运行结果
1210004989
1425839054
1723650563
389001266
1356914048
389001266
1560241484
278778395
124191239
367137303

从结果看来,这种方式不能保证线程安全,为什么呢?我们假设有两个线程A和B同时走到了“代码1”,因为此时对象还是空的,所以都能进到方法里面,线程A首先抢到锁,创建了对象。释放锁后线程B拿到了锁也会走到“代码2”,也创建了一个对象,因此多线程环境下就不能保证单例了。

3.3 双重校验锁

让我们来继续优化一下,既然上述方式存在问题,那我们在同步代码块里面再一次做一下null判断不就行了,这种方式就是我们的DCL双重检查锁机制。

代码语言:javascript复制
//单例模式的懒汉实现5--线程安全
//通过设置同步代码块,使用DCL双检查锁机制
//使用双检查锁机制成功的解决了单例模式的懒汉实现的线程不安全问题和效率问题
//DCL也是大多数多线程结合单例模式使用的解决方案
//第一个if判断的作用:是为了提高程序的效率,当SingletonLazy5对象被创建以后,再获取SingletonLazy5对象时就不用去验证同步代码块的锁及后面的代码,直接返回SingletonLazy5对象
//第二个if判断的作用:是为了解决多线程下的安全性问题,也就是保证对象的唯一。
public class SingletonLazy5 {
    private static volatile SingletonLazy5 singletonLazy;
    private SingletonLazy5() {

    }
    public static SingletonLazy5 getInstance() {
        try {
            if (null == singletonLazy) {
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
                synchronized (SingletonLazy5.class) {
                    if(null == singletonLazy) {
                        singletonLazy = new SingletonLazy5();
                    }
                }
            }
        } catch (InterruptedException e) {
        }
        return singletonLazy;
    }
}
代码语言:javascript复制
//运行结果
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239

我们可以看到DCL双重检查锁机制很好的解决了懒加载单例模式的效率问题和线程安全问题。这也是我们最常用到的方式。

volatile关键字

这里注意到在定义singletonLazy的时候用到了volatile关键字,这是为了防止指令重排序的,为什么要这么做呢?我们来看一个场景:

代码走到了singletonLazy = new SingletonLazy5();看起来是一句话,但这并不是一个原子操作(要么全部执行完,要么全部不执行,不能执行一半),这句话被编译成8条汇编指令,大致做了3件事情:

(1)给SingletonLazy5的实例分配内存。 (2)初始化SingletonLazy5的构造器。 (3)将singletonLazy对象指向分配的内存空间(注意到这步instance就非null了)。

由于Java编译器允许处理器乱序执行(out-of-order),以及JDK1.5之前JMM(Java Memory Medel)中Cache、寄存器到主内存回写顺序的规定,上面的第二点和第三点的顺序是无法保证的,也就是说,执行顺序可能是1-2-3也可能是1-3-2,如果是后者,并且在3执行完毕、2未执行之前,被切换到线程二上,这时候singletonLazy因为已经在线程一内执行过了第三点,singletonLazy已经是非空了,所以线程二直接拿走singletonLazy,然后使用,然后顺理成章地报错,而且这种难以跟踪难以重现的错误估计调试上一星期都未必能找得出来。

DCL的写法来实现单例是很多技术书、教科书(包括基于JDK1.4以前版本的书籍)上推荐的写法,实际上是不完全正确的。的确在一些语言(譬如C语言)上DCL是可行的,取决于是否能保证2、3步的顺序。在JDK1.5之后,官方已经注意到这种问题,因此调整了JMM、具体化了volatile关键字,因此如果JDK是1.5或之后的版本,只需要将singletonLazy的定义加上volatile关键字,就可以保证每次都去singletonLazy都从主内存读取,并且可以禁止重排序,就可以使用DCL的写法来完成单例模式。当然volatile或多或少也会影响到性能,最重要的是我们还要考虑JDK1.42以及之前的版本,所以单例模式写法的改进还在继续。

3.4 静态内部类

基于上面的考虑,我们可以使用静态内部类实现单例模式,代码如下:

代码语言:javascript复制
//使用静态内部类实现单例模式--线程安全
class Singleton {
    private static class SingletonHolder {
        private static Singleton instance = new Singleton();
    }
    private Singleton() {

    }
    public static Singleton getInstance() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        return SingletonHolder.instance;
    }
}

这种方式同样利用了类加载机制来保证只创建一个instance实例。可以看到使用这种方式我们没有显式的进行任何同步操作,那他是如何保证线程安全呢?和饿汉模式一样,是靠JVM保证类的静态成员只能被加载一次的特点,这样就从JVM层面保证了只会有一个实例对象。那么问题来了,这种方式和饿汉模式又有什么区别呢?不也是立即加载么?实则不然,加载一个类时,其内部类不会同时被加载。一个类被加载,当且仅当其某个静态成员(静态域、构造器、静态方法等)被调用时发生。它是在内部类里面去创建对象实例,这样的话,只要应用中不使用内部类,JVM就不会去加载这个单例类,也就不会创建单例对象,从而实现懒汉式的延迟加载。也就是说这种方式可以同时保证延迟加载和线程安全。

可以说这种方式是实现单例模式的最优解

3.5 静态代码块

这种方式和第一种类似,也是一种饿汉模式。

代码语言:javascript复制
//使用静态代码块实现单例模式
class SingletonStaticBlock {
    private static SingletonStaticBlock singletonStaticBlock;
    static {
        singletonStaticBlock = new SingletonStaticBlock();
    }
    public static SingletonStaticBlock getInstance() {
        return singletonStaticBlock;
    }
}

3.6 枚举

代码语言:javascript复制
public enum Singleton{
    instance;
    public void whateverMethod(){}    
}

上面提到的几种实现单例的方式都有共同的缺点:

  • 需要额外的工作来实现序列化,否则每次反序列化一个序列化的对象时都会创建一个新的实例。
  • 可以使用反射强行调用私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

而枚举类很好的解决了这两个问题,使用枚举除了线程安全和防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,《Effective Java》作者推荐使用的方法。不过,在实际工作中,很少看见有人这么写。

3.7 序列化与反序列化

为什么要提序列化和反序列化呢?因为单例模式虽然能保证线程安全,但在序列化和反序列化的情况下会出现生成多个对象的情况。

代码语言:javascript复制
public class SingletonStaticInnerSerializeTest {
    public static void main(String[] args) {
        try {
            SingletonStaticInnerSerialize serialize = SingletonStaticInnerSerialize.getInstance();
            System.out.println(serialize.hashCode());
            //序列化
            FileOutputStream fo = new FileOutputStream("tem");
            ObjectOutputStream oo = new ObjectOutputStream(fo);
            oo.writeObject(serialize);
            oo.close();
            fo.close();
            //反序列化
            FileInputStream fi = new FileInputStream("tem");
            ObjectInputStream oi = new ObjectInputStream(fi);
            SingletonStaticInnerSerialize serialize2 = (SingletonStaticInnerSerialize) oi.readObject();
            oi.close();
            fi.close();
            System.out.println(serialize2.hashCode());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

//使用匿名内部类实现单例模式,在遇见序列化和反序列化的场景,得到的不是同一个实例
//解决这个问题是在序列化的时候使用readResolve方法,即去掉注释的部分
public class SingletonStaticInnerSerialize implements Serializable {
    private static final long serialVersionUID = 1L;
    private static class InnerClass {
        private static SingletonStaticInnerSerialize singletonStaticInnerSerialize = new SingletonStaticInnerSerialize();
    }
    public static SingletonStaticInnerSerialize getInstance() {
        return InnerClass.singletonStaticInnerSerialize;
    }
//    protected Object readResolve() {
//        System.out.println("调用了readResolve方法");
//        return InnerClass.singletonStaticInnerSerialize;
//    }
}
代码语言:javascript复制
//运行结果
865113938
1078694789

结果表明的确是两个不同的对象实例,违背了单例模式,那么如何解决这个问题呢?解决办法就是在反序列化中使用readResolve()方法,将上面的注释代码去掉,再次运行。

代码语言:javascript复制
//运行结果
865113938
调用了readResolve方法
865113938

其实当JVM从内存中反序列化地"组装"一个新对象时,就会自动调用这个 readResolve方法来返回我们指定好的对象了, 单例规则也就得到了保证。readResolve()的出现允许程序员自行控制通过反序列化得到的对象。

0 人点赞