设计模式——单例模式(懒汉式与饿汉式)详解

一、什么是单例?
    单例模式(Singleon),是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。


二、单例的种类有哪些?之间有什么区别?

  • 懒汉式:指全局的单例实例在第一次被使用时构建。
  • 饿汉式:全局的单例实例在类装载(ClassLoader)时构建。(饿汉式单例性能优于懒汉式单例)

1、懒汉式与饿汉式区别:

        1.1懒汉式默认不会实例化,外部什么时候调用什么时候new。饿汉式在类加载的时候就实例化,并且创建单例对象。

        1.2、懒汉式是延时加载,在需要的时候才创建对象,而饿汉式是在虚拟机启动的时候就会创建。

        1.3、懒汉式在多线程中是线程不安全的,而饿汉式是不存在多线程安全问题的。

2、懒汉模式:

2.1、创建一个最简单的懒汉式单例【方法1】

//最简单的一种懒汉式单例模式
public class SingleTest {
    //定义一个私有类变量来存放单例,私有的目的是指外部无法直接获取这个变量,而要使用提供的公共方法来获取。
    private static SingleTest instance; 
    public SingleTest () {}  
    //定义一个公共的公开的方法来返回该类的实例。
    public static SingleTest getInstance() {
        //第一次访问去创建实例
        if (instance == null) {
            instance = new SingleTest ();
        }
        //否则直接返回实例
        return instance;
    }
}

注意:
上面是最简单的一种懒汉式单例,但是这样写会有一些问题需要改进:

    1、构造器为public,这样外部可以调用,要改为私有的private,防止外部调用。
    2、这种方式在多线程下是不安全的。 额。。 好吧!那么我们接着改进完善,让它越来越完美。

2.2、创建一个线程安全的懒汉式单例【方法2】

 // 把构造函数设置为私有,并使用synchronized修饰词来修饰方法,保证线程同步从而达到线程安全。
public class SingleTest {
    private static SingleTest instance;
    //定义私有构造器,表示只在类内部使用,只能在内部创建。
    private SingleTest () {}
    //使用synchronized修饰达到线程同步从而保证线程安全。
    public static synchronized SingleTest getInstance() {
        if (instance == null) {
            instance = new SingleTest ();
        }
        return instance;
    }
}

注意:
以上方法修改之后解决了第一次创建遗留的两个问题:
    1、修改构造函数为private,避免外部调用。
    2、使用synchronized修饰方法(也就是获取对象的锁),此时就避免多线程同时访问同一个对象,保证线程同步,从而达到线程安全。

额。。看上去已经好了,但是还有问题
举个例子吧:
    现在有A、B、C三个线程来访问此对象创建单例。由于线程执行顺序是由CPU心情决定的,所以不能保证谁先先访问到。
假设:
    1、A线程先创建单例,getInstance()使用synchronized同步锁,这个时候A线程就获得了getInstance()的锁。(synchronized同步锁不了解的同学可以先学习下多线程中的线程同步)。

    2、此时B、C线程也被CPU调度来创建单例。但是这个时候A线程已经获取了getInstance()的锁,那么B、C将无法调用getInstance()方法。B、C线程就会一直等待,直到A线程执行完毕才可以访问。

    3、这样的话就造成了线程阻塞,影响性能。

2.3、让我们改变同步锁的位置试一试。。【方法3】

// 改变了synchronizaed同步锁的位置,双重检查判断
public class SingleTest {
    private static SingleTest instance;
    private SingleTest () {}
    public static SingleTest getInstance() {
        //先进行实例判断,只有当不存在的时候才去创建实例。
        //这样就解决了99%的已经获取实例但是还要去获取同步锁的问题。
        if (instance == null) {
            //用synchronized 同步代码块
            //注意:此处不能用this,因为this不能和static同用。
            synchronized (SingleTest.class) {
                if (instance == null) {
                    instance = new SingleTest();
                }
            }
        }
        //如果已经获取实例,那么直接返回就可以,不必要去获取同步锁,也就不会影响其他线程,造成线程阻塞问题。
        return instance;
    }
}

    感觉是不是已经不错了,也解决了上面写法的效率问题,为什么说解决了效率问题呢?不是解决是提升效率哈哈。。。那么我们来分析一下吧:

    1、首先我们单例的定义和含义:“单例对象的类必须保证只有一个实例存在”。那么我们用单例其实就是为了限制一个类不能多个实例,也就是说只能被创建一次,明白这点之后我们来看【方法2】的代码。。。

    2、【方法2】A、B、C三个线程来创建实例,只有第一个访问的线程才会走:if(instance)下面的代码 instance = new SingleTest();来创建实例。否则都会直接返回这个实例 return instance;。100次调用,1次new,99次直接return。创建实例的概率为1%,获取实例的概率为99%。
    3、但是我们如果要像【方法2】中用synchronized同步锁来修饰getInstance()方法,那么不管是需要创建实例还是获取实例都会只能被一个线程调用,那么性能肯定会浪费很多,其实我们只关心1%创建实例。99%都是浪费性能。

    4、先在判断if(instance)==null,如果是,那代表第一次来获取实例,接着我们把同步锁加在创建实例的代码块上,这样就减少了获取线程的性能消耗,只有在需要创建的时候才会加同步锁,才可能会造成线程阻塞,只有1%的情况会影响性能,而不是【方法二】100%会影响。所以性能会得到提升。否则直接返回实例 return instance。

    OK,好像这样写既提升了性能,还保证了线程安全,已经很完善了,真的是这样吗?真的是安全了吗?跟着我继续看,看一看还能不能继续改进,哈哈!

2.4、让我们继续改进吧!【方法4】

//使用volatile修饰变量,具有原子性、可见性和防止指令重排的特性。
public class SingleTest {
    private static volatile SingleTest instance;
    private SingleTest() {}
    public static SingleTest getInstance() {
        if (instance == null) {
            synchronized (SingleTest.class) {
                if (instance == null) {
                    instance = new SingleTest();
                }
            }
        }
        return instance;
    }
}

【方法4】中我们只在变量上加了一个volatile修饰词,那么为什么要这么做呢?这样做有什么好处?我们接着分析:
  1、我们了解下原子操作、指令重排这两个知识点。

  2、什么是原子操作呢?
     简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。
小例子:

a=1;//赋值,把值1赋给a,这是原子操作。

假如m原先的值为1,那么对于这个操作,要么执行成功m变成了6,要么是没执行m还是1,而不会出现诸如m=3这种中间态——即使是在并发的线程中。

int a=1;//先声明一个变量,再把值赋给这个变量,这不是原子操作。

因为这个操作对于计算机来说是两步:
    1、声明变量 int a;
    2、进行赋值 a=1;
这样的话单线程情况下是没有问题的,那么在多线程下就会出现问题,因为多线程的执行顺序是不确定的。接下来大家需要了解一个新的知识点:指令重排。

  3、什么是指令重排呢?
简单来说,就是计算机为了提高执行效率,会做的一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。

  4、上述案例

第一步:int a;
第二步:a=1;

计算机在运行的时候不一定会按照正常的顺序1——>2步执行,它可能会是2——>1,由于计算机的指令重排的特性,无论他们的执行顺序是什么都不会对运行结果造成影响。

  5、接下来我们再看我们【方法4】创建单例方法。
主要在于singleton = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情
    1、内存区域分配内存给singleton
    2、 调用 Singleton 的构造函数来初始化成员变量,形成实例
    3、 将singleton对象指向分配的内存空间(执行完这步 singleton才是非 null 了)
  分析完之后,是不是稍微明白了点,为什么说方法【4】及时加了同步锁也不是线程安全的。
    但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第1步和第3步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程B抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程B会直接返回 instance,然后使用,然后顺理成章地报错。
    为了保证原子性和禁止指令重排,所以我们valutile修饰词。从而解决小概率线程不安全事件。

以上的话基本就是懒汉式单例模式的几种写法以及写法的深入了解。大家多多思考。


3、饿汉模式

3.1、我们来创建一个饿汉式的单例模式【方法1】

//饿汉式单例实现

public class SingleTest {

    private static final SingleTest INSTANCE = new SingleTest();

    private SingleTest() {}

    public static SingleTest getInstance() {

        return INSTANCE;

    }

}

  饿汉式的写法这样就可以了,那么我们来说下饿汉式的缺点吧。
    1、所以它的缺点也就只是饿汉式单例本身的缺点所在了——由于INSTANCE的初始化是在类加载时进行的,而类的加载是由ClassLoader来做的,所以开发者本来对于它初始化的时机就很难去准确把握。
    2、过早的就会被实例化,可能会造成资源浪费。
    3、如果初始化本身依赖于一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。

3.2让我们继续改进一下【方法2】

public class Singleton {
    //创建一个内部静态类
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

  通过内部静态类来实现对初始化的控制。
    1、对于内部类SingletonHolder,它是一个饿汉式的单例实现,在SingletonHolder初始化的时候会由ClassLoader来保证同步,使INSTANCE是一个真·单例。
    2、由于SingletonHolder是一个内部类,只在外部类的Singleton的getInstance()中被使用,所以它被加载的时机也就是在getInstance()方法第一次被调用的时候。

总结:它利用了ClassLoader来保证了同步,同时又能让开发者控制类加载的时机。从内部看是一个饿汉式的单例,但是从外部看来,又的确是懒汉式的实现。

4、使用枚举创建单例【最好的方式】

4.1、枚举创建单例

//枚举单例实现
public enum SingleEnum {
    INSTANCE;

    public void getInstance() {
        System.out.print("do something");
    }
}
   
public static void main(String[] args) {
    SingleEnum.INSTANCE.getInstance();
}

  这样写的优点:
    1、写法很简洁。
    2、JVM保证线程安全。
    3、防止反序列化和反射的破坏。


三、单例有哪些优点和缺点呢?


优点:
    1、在内存里只有一个实例,减少了内存的开销,避免频繁的创建和销毁实例。
    2、避免对资源的多重占用(比如写文件操作),提升了性能。
    3、提供了对唯一实例的受控访问。

缺点:
    1、不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
    2、由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
    3、从设计原则方面说,单例类的职责过重,在一定程度上违背了“单一职责原则”。
    4、滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。


四、单例模式的使用场景:
1、WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。
2、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。

你可能感兴趣的:(设计模式,JAVA)