java 单例模式的几种实现方式详解

2018-12-14  本文已影响0人  冯玉然

  单例模式,在工作中也是经常用到/见到的一种设计模式,这种模式还是比较好理解的,简单来说就是该类只会被new出来一个对象,该模式也是并发的基础,spring默认的bean都是单例的,这意味着每个bean都只有一个,也就是不会出现同名的bean,而多线程并发的实质就是多条线程从内存中拿到对象的拷贝然后运行代码,因为是单例模式,所以线程从内存中拷贝出来的都是同一个对象,这时候涉及到的并发问题应该是比较经典的也是我们最常碰到的并发问题了。

  如果是非单例模式的话,多线程并发时拿到的对象的拷贝可能不是同一个,这种模式下的并发问题处理起来可能会比较麻烦,笔者还没碰到这种情况,就不在此献丑了(而且笔者理解的并发就是同一个对象在多条线程中的并发问题,而不同的对象也不存在所谓的并发问题了,因为每个线程拿到的对象都是不一样的,相互之间没影响)。

  在单例模式下我们可以使用各种方法来保证线程安全,譬如说sync关键字、redis锁等等方法,以后有机会的话会写文章来谈谈并发的问题,而单例模式平时写crud代码时的确用不到,但是spring其实已经帮我们做好了单例模式,我们并不需要刻意去使用这种模式,但是在实现某些稍微复杂、特殊的需求时,我们可能会用到这种模式。

  举个栗子:在项目中,有一个处理用户请求的接口,现在要求是:如果检测到用户的账户余额不足的话,需要调用发送邮件服务向用户的邮箱发送一封告警邮件。这时候我们需要做的是:如果在接口中检测到用户余额不足,则会把该用户的邮箱放到一个队列中,而在项目启动的时候启动一个线程,该线程用来从队列中取出来用户的邮箱,然后调用现有的邮件服务来发送邮件。在这个过程中,我们需要保证的是:在接口中向队列中放进去的数据与在线程中取出来的数据是一致的,那么该如何保证这种一致性呢?其实我们只要保证是同一个队列(Queue)就行了,队列肯定是放在一个对象中的,被当做对象的一个成员变量,
因此,我们只需要保证从头到尾只有一个对象就可以了,这样就可以保证数据的一致性了。下面我们来介绍几种不同的单例模式来实现该需求(这里我们只关注含有队列的那个类,其他的类的代码就不展示了)。

简单单例模式(饿汉式单例)

直接贴代码

/**
 * 简单单例模式
 * 
 * @author fengyr
 *
 */
public class EmailQueueEntity {

    /**
     * 只会有这一个对象,这个对象在类初始化的时候为成员变量赋值时生成; 可能会有疑问类的初始化是否会有多线程并发的情况,从而导致出现多个对象,经过百度发现类在初始化的时候会有初始化锁,
     * 简单理解就是只会有一个线程在初始化类,因此不必担心这个问题
     */
    private static EmailQueueEntity singleton = new EmailQueueEntity();


    // 保存用户邮箱的队列,这里使用了线程安全的阻塞队列,是可以支持多线程的,是否采用多线程存/取是取决于数据量的大小及速度的需求;
    // 这个没必要是static的,因为是单例模式,只会有一个对象了
    private LinkedBlockingQueue<String> data = new LinkedBlockingQueue<String>();

    /**
     * 私有构造不允许外部new出来该对象
     */
    private EmailQueueEntity() {

    }

    /**
     * 方法必须要是静态的,这样在调用的时候直接通过类名来调用就可以了
     * 
     * @return
     */
    public static EmailQueueEntity getInstance() {
        return singleton;
    }

    public LinkedBlockingQueue<String> getQueue() {
        return data;
    }

}

我们还有另一种类似的写法:



    private static EmailQueueEntity singleton = null; // 1

    static {
        singleton = new EmailQueueEntity(); // 2
    }

    private LinkedBlockingQueue<String> data = new LinkedBlockingQueue<String>();

    /**
     * 私有构造不允许外部new出来该对象
     */
    private EmailQueueEntity() {

    }

    /**
     * 方法必须要是静态的,这样在调用的时候直接通过类名来调用就可以了
     * 
     * @return
     */
    public static EmailQueueEntity getInstance() {
        return singleton;
    }

    public LinkedBlockingQueue<String> getQueue() {
        return data;
    }

  这种方式其实跟上面的做法是差不多的,只不过是把创建对象的过程放到了静态块中,需要注意得是1与2的顺序不能变,否则会造成空指针

  以上两种写法是最简单的单例模式,很简洁,当然也有其缺陷:当EmailQueueEntity的构造方法非常复杂时(可能是业务功能复杂导致的),而可能很长时间都没有使用到这个Queue时(譬如说在这个例子中用户的余额在大部分情况下都很充足),会造成资源的浪费,因为无论是否有使用到Queue,都会把EmailQueueEntity给new出来,为了避免这种浪费,我们可以采用另外一种单例模式。

双重校验单例模式(饱汉式单例)

先贴代码再来分析:

/**
 * 双重检验单例模式
 * 
 * @author fengyr
 *
 */
public class EmailQueueEntity {


    /**
     * 单例对象
     */
    private volatile static EmailQueueEntity instance = null;

    /**
     * 缓存队列
     */
    private LinkedBlockingQueue<String> EmailCache = new LinkedBlockingQueue<String>();

    /**
     * 私有构造方法保护对象
     */
    private EmailQueueEntity() {

    }

    /**
     * 双重校验单例模式
     * 
     * @return
     */
    public static EmailQueueEntity getInstance() {
        if (instance == null) { // 1
            synchronized (EmailQueueEntity.class) { // 2
                if (instance == null) { // 3
                    instance = new EmailQueueEntity(); // 4
                }
            }
        }
        return instance; // 5
    }

    public LinkedBlockingQueue<String> getEmailQueue() {
        return EmailCache;
    }


}

  这种方式有一个显而易见的优点,就是在用不到对象时是不会生成的,只会在第一次使用对象时才会去new出来,所以这种方式在构造方法很复杂的情况下是可以节省一些内存开销的,缺点可能就是写起来比饿汉式单例要麻烦一些,代码中需要注意的是getInstance()方法,我们把该方法分解为四个步骤,第1、4步是很容易想到的,之所以加第二步同步类锁是因为防止因为多条线程并发调用该方法时会new多个对象,因此加一个同步类锁防止这种情况的出现,那第三步是为什么要加上去呢?我们思考这么一个并发场景:A、B两个线程同时调用该方法,A线程走到了1,但是还没走到2,就是说A没有拿到同步锁,此时A让出cpu资源给B,B走完了1、2、4步,然后B让出cpu资源给A,那么A又会接着走完2、4步,这时候就会出现两个对象了,因此我们为了防止这种情况的出现,在同步锁中再次进行一次判断,也就是加上了3;但是还需要注意一点,就算上面的1、2、3、4都写上了,也有可能出现一个问题:返回的对象有可能是一个未初始化的对象,这是因为步骤4的指令重排序所造成的。

  我们来分析一下4,这是一个new对象并赋值的操作,他可以拆分成以下三个步骤:

a、new EmailQueueEntity(),分配内存给新建对象
b、初始化新建对象
c、instance=新建对象,instance指向新建对象

  这三个步骤从直观上会觉得是一步一步完成的,但是因为cpu的指令重排序,有可能会导致执行顺序是 a-c-b,假如A线程就是a-c-b这么执行的,当执行完了c时,还没执行b,此时线程B进来了该方法,那么在1中的判断会是false,而会直接执行5,但是这时候return回去的对象是没有初始化的,这时候就会出问题,要想解决这种指令重排序的问题,我们使用了volatile关键字,它的作用是禁止指令重排序,强制让执行顺序变为 a-b-c 它的具体原理就不在这里解释了,可以自行百度。

利用枚举类实现单例模式

先上代码:

/**
 * 枚举单例
 * 
 * @author fengyr
 *
 */
public class EmailQueueEntity {

    /**
     * 单例对象
     */
    private volatile static EmailQueueEntity instance = null;

    /**
     * 缓存队列
     */
    private LinkedBlockingQueue<String> EmailCache = new LinkedBlockingQueue<String>();

    /**
     * 私有构造方法保护对象
     */
    private EmailQueueEntity() {

    }

    /**
     * 私有内部枚举类实现单例
     * 
     * @return
     */
    public static EmailQueueEntity getInstance() {
        return Singleton.INSTANCE.getInstance();
    }

    public LinkedBlockingQueue<String> getEmailQueue() {
        return EmailCache;
    }


    private enum Singleton {
        INSTANCE;

        private EmailQueueEntity singleton;

        // JNM会保证这个方法只调用一次
        Singleton() {
            singleton = new EmailQueueEntity();
        }

        public EmailQueueEntity getInstance() {
            return singleton;
        }
    }

}

  这种方式是比较推荐写的方式,写法比较简单,不会像双重检验模式一样容易遗漏一些东西,由JVM保证单例,这种方式主要依赖枚举类完成,枚举类的构造方法是由编译器来调用并实现单例模式的,所以通常枚举类的构造方法都是私有的,使用单例是因为枚举类得值是固定的,不需要改变,只需要实例化一次,然后在整个程序之中就可以调用它的方法和成员变量了。注意这里与平时代码中碰到的最多的枚举类不太一样,毕竟这个枚举类只有一个枚举值,并且不需要使用括号赋值的方式(不用括号赋值所以就不用有参构造函数了),直接一个无参构造方法就行了,其他的jvm会帮我们保证,写起来还是很简洁的,因此推荐大家使用这种方式。

上一篇下一篇

猜你喜欢

热点阅读