Java大话设计模式学习总结(二十一)---单例模式

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/a770794164/article/details/90716728

单例模式(Singleton),保证一个类仅有一个实例,并提供一个访问它的全局访问点。
来自大话设计模式
通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的方法。

参考文章:

  1. Java实现单例的5种方式
  2. 你真的会写单例模式吗——Java实现
  3. java单例模式几种实现方式

什么是单例模式

单例模式指的是在应用整个生命周期内只能存在一个实例。单例模式是一种被广泛使用的设计模式。他有很多好处,能够避免实例对象的重复创建,减少创建实例的系统开销,节省内存。

单例模式和静态类的区别

首先理解一下什么是静态类,静态类就是一个类里面都是静态方法和静态field,构造器被private修饰,因此不能被实例化。Math类就是一个静态类。
知道了什么是静态类后,来说一下他们两者之间的区别:

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

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

如何实现单例模式

饿汉式(线程安全,调用效率高,但是不能延时加载)

饿汉式就是立即加载,在类加载的时候已经产生。这种模式的缺点很明显,就是占用资源,当单例类很大的时候,其实我们是想使用的时候再产生实例。因此这种方式适合占用资源少,在初始化的时候就会被用到的类。但是饿汉式也有优点,就是线程安全,调用效率高。代码如下:

public class Singleton {   
    private static Singleton = new Singleton();
    private Singleton() {}
    public static getSignleton(){
        return singleton;
    }
}
懒汉式

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

A) 单线程写法,用Thread.sleep(1000)模拟多线程并发

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            try {
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            singleton = new Singleton();
        }
        return singleton;
    }
}

测试类:

public class Test {
    public static void main(String[] args) {
        TestThred[] ThreadArr = new TestThred[10];
        for (int i = 0; i < ThreadArr.length; i++) {
            ThreadArr[i] = new TestThred();
            ThreadArr[i].start();
        }
    }
}
class TestThred extends Thread{

    @Override
    public void run() {
        System.out.println(Singleton.getInstance().hashCode());
    }
    
}

结果:

1772827410
2057256033
2094435863
1474629390
739249834
1635036195
1152408485
429842367
197461654
1195510409

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

B) 使用synchronized关键字对getInstance方法进行同步 (线程安全,调用效率不高,但是能延时加载)

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }
    // 增加synchronized
    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            try {
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            singleton = new Singleton();
        }
        return singleton;
    }
}

结果:

652764634
652764634
652764634
652764634
652764634
652764634
652764634
652764634
652764634
652764634

虽然上面这种写法是可以正确运行的,但是效率太低,是同步运行的,下个线程想要取得对象,就必须要等上一个线程释放,才可以继续执行。因为每次调用getInstance()方法,都必须在synchronized这里进行排队,而真正遇到需要new的情况是非常少的。

C) 在实例化的时候加锁

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            try {
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 在这里加锁
            synchronized(Singleton.class){
                singleton = new Singleton();
            }
        }
        return singleton;
    }
}

结果:

1635036195
1073180601
2057256033
1745028020
963329488
377433899
2055569699
1474629390
1787454234
1523637982

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

D) 在同步代码块里面再一次做一下null判断,这种方式就是我们的DCL双重检查锁机制。 (线程安全,调用效率高,能延时加载,但是由于JVM底层模型原因,在jdk1.5版本前,会出问题)

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (singleton == null) {
            try {
                // 模拟在创建对象之前做一些准备工作
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized(Singleton.class){
                // 如果同时进来,当线程A抢到锁,创建对象后,线程B再进来,此时singleton对象就不为空了,就不会再创建新的对象
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

结果:

739249834
739249834
739249834
739249834
739249834
739249834
739249834
739249834
739249834
739249834

看似多此一举,但实际上却极大提升了并发度,进而提升了性能。为什么可以提高并发度呢?就像上文说的,在单例中new的情况非常少,绝大多数都是可以并行的读操作。因此在加锁前多进行一次null检查就可以减少绝大多数的加锁操作,执行效率提高的目的也就达到了

静态内部类(线程安全,调用效率高,可以延时加载)
public class Singleton {

    private static class Holder {
        private static Singleton singleton = new Singleton();
    }

    private Singleton() {
    }

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

结果:

378778546
378778546
378778546
378778546
378778546
378778546
378778546
378778546
378778546
378778546

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

但是,上面提到的所有实现方式都有两个共同的缺点:

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

下面看一下序列化与反序列化的实现:

// 实现Serializable接口,开启序列化功能
public class Singleton implements Serializable{

    private static final long serialVersionUID = 1L;

    private static class Holder {
        private static Singleton singleton = new Singleton();
    }

    private Singleton() {
    }

    public static Singleton getInstance() {
        return Holder.singleton;
    }
}

主程序:

public class TestSerialize {
    public static void main(String[] args) throws Exception {
        Singleton singleton = Singleton.getInstance();
        System.out.println(singleton.hashCode());
        
        System.out.println("====== 序列化 ======");
        FileOutputStream fo = new FileOutputStream("tem");
        ObjectOutputStream oo = new ObjectOutputStream(fo);
        oo.writeObject(singleton);
        oo.close();
        fo.close();
        System.out.println("====== 反序列化 ======");
        FileInputStream fi = new FileInputStream("tem");
        ObjectInputStream oi = new ObjectInputStream(fi);
        Singleton Singleton2 = (Singleton) oi.readObject();
        oi.close();
        fi.close();
        System.out.println(Singleton2.hashCode());
    }
}
结果
1227229563
====== 序列化 ======
====== 反序列化 ======
1416233903

发现序列化和反序列化得到的对象的HashCode不一样,说明生成了一个新的实例。

解决办法就是在反序列化中使用readResolve()方法,使反序列化时,对象输出流不使用之前持久化的实例。
修改Singleton类如下:

public class Singleton implements Serializable{

    private static final long serialVersionUID = 1L;

    private static class Holder {
        private static Singleton singleton = new Singleton();
    }

    private Singleton() {
    }

    public static Singleton getInstance() {
        return Holder.singleton;
    }
    protected Object readResolve(){
        System.out.println("调用readResolve方法");
        return Holder.singleton;
    }
}

主程序不变,执行结果为:

1227229563
====== 序列化 ======
====== 反序列化 ======
调用readResolve方法
1227229563

查看ObjectInputStream类,readUnshared()方法有如下注释:

     * Deserializing an object via readUnshared invalidates the stream handle
     * associated with the returned object.  Note that this in itself does not
     * always guarantee that the reference returned by readUnshared is unique;
     * the deserialized object may define a readResolve method which returns an
     * object visible to other parties, or readUnshared may return a Class
     * object or enum constant obtainable elsewhere in the stream or through
     * external means. If the deserialized object defines a readResolve method
     * and the invocation of that method returns an array, then readUnshared
     * returns a shallow clone of that array; this guarantees that the returned
     * array object is unique and cannot be obtained a second time from an
     * invocation of readObject or readUnshared on the ObjectInputStream,
     * even if the underlying data stream has been manipulated.

翻译如下:

通过readUnshared反序列化对象会使与返回对象关联的流句柄无效。 请注意,这本身并不总能保证readUnshared返回的引用是唯一的; 反序列化对象可以定义readResolve方法,该方法返回对其他方可见的对象,或者readUnshared可以返回可在流中的其他位置或通过外部方式获得的Class对象或枚举常量。 如果反序列化对象定义了readResolve方法,并且该方法的调用返回一个数组,那么readUnshared将返回该数组的浅层克隆; 这保证了返回的数组对象是唯一的,并且无法在ObjectInputStream上调用readObject或readUnshared时再次获取,即使已经操作了基础数据流。

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

枚举类(线程安全,调用效率高,不能延时加载,可以天然的防止反射和反序列化调用)

使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,Effective Java推荐尽可能地使用枚举来实现单例。

public enum SingletonEnum {
    // 枚举元素本身就是单例
    INSTANCE;
    // 添加自己需要的操作
    public void singletonOperation() {
    }
}

总结

如何选用:

  1. 单例对象占用资源少,不需要延时加载,枚举类 好于 饿汉式
  2. 单例对象占用资源多,需要延时加载,静态内部类 好于 懒汉式

最后,不管采取何种方案,请时刻牢记单例的三大要点:

  1. 线程安全
  2. 延迟加载
  3. 序列化与反序列化安全

猜你喜欢

转载自blog.csdn.net/a770794164/article/details/90716728