高并发环境下下线程安全的单例模式

在所有的设计模式中,单例模式是我们在项目开发中最为常见的设计模式之一,而单例模式有很多种实现方式。但是高并发下如何保证单例模式的线程安全性呢?这个问题在下文会进行解释。

什么是单例模式?


在解释如何在高并发环境下如何保证单例模式的线程安全之前,先简单解释一下单例的概念。单例模式是为确保一个类只有一个实例,并为整个系统提供一个全局访问点的一种模式方法。
单例的特点:
1) 在任何情况下,单例类永远只有一个实例存在;
2) 单例需要有能力为整个系统提供这一唯一实例;

鉴于单例的特点,单例对象通常作为程序中的存放配置信息的载体,因为它能保证其他对象读到一致的信息。例如在某个服务器程序中,该服务器的配置信息可能存放在文件中,这些配置数据由某个单例对象统一读取,服务进程中的其他对象如果要获取这些配置信息,只需访问该单例对象即可。这种方式极大地简化了在复杂环境下,尤其是多线程环境下的配置管理,但是随着应用场景的不同,也可能带来一些同步问题。

单例模式的几种实现方式

立即加载 /“饿汉模式”

立即加载 / “饿汉模式”是在调用方法前,实例已经被创建。代码实现如下:

创建单例类:
package Test; 

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description 

 */

public class TestSingleton {

    //立即加载方式===饿汉模式

    private static TestSingleton singleton = new TestSingleton();

    

    public static TestSingleton getInstance(){

        return singleton;

    }

}

创建线程类并调用单例对象:

package Test; 

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午10:01:40

 * @Description 

 */

public class TestThread implements Runnable{


    @Override

    public void run() {

        System.out.println(TestSingleton.getInstance().hashCode());

    }


    public static void main(String[] args) {

        TestThread  runable = new TestThread();

        Thread t1 = new Thread(runable);

        Thread t2 = new Thread(runable);

        Thread t3 = new Thread(runable);

        t1.start();

        t2.start();

        t3.start();        

    }

}
运行结果如下:

869295101

869295101

869295101


控制台打印的hashCode是同一个值,说明对象是同一个,实现了饿汉式单例设计模式。

<注:>此版本的为立即加载,缺点是不能有其他的实例变量,因为getInstance()方法没有同步,所以有可能出现线程安全问题。但由于我们这次讨论的是单例模式,一个类只能有一个实例,所以“饿汉”式单例可以认为是线程安全的 


由于要进行线程同步,所以在访问量比较大,或者可能访问的线程比较多时,采用饿汉实现,可以实现更好的性能。这是以空间换时间

延迟加载 /“懒汉模式”

延迟加载是只有在调用getInstance()方法时实例才会被创建,常见的实现办法是在getInstance()方法中进行new 实例化。修改一下饿汉单例类,代码实现如下:

package Test; 

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description 

 */

public class TestSingleton {

    //延迟加载方式===懒汉模式

    private static TestSingleton singleton;

    

    public static TestSingleton getInstance(){

        try {

            if(singleton != null){

            }else{

                //创建实例之前可能会有一些准备性的耗时工作

                Thread.sleep(1000);

                singleton = new TestSingleton();

            }

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        return singleton;

    }

}


运行结果如下:

978613927

866891806

2134502363


控制台打印出了3种hashCode,说明创建出了3个对象,并没有实现单例。如果是单线程的情况,此方式可以使用,但如果是在多线程环境下,这就是"错误的单例模式"。那么如何解决该问题呢?

既然多个线程可以同时调用getInstance()方法,首先想到的是对getInstance()方法加锁 ,利用synchronsized关键字,继续修改代码如下:

package Test; 

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description 

 */

public class TestSingleton {

    //延迟加载方式===懒汉模式

    private static TestSingleton singleton;

    

    public synchronized static TestSingleton getInstance(){

        try {

            if(singleton != null){

            }else{

                //创建实例之前可能会有一些准备性的耗时工作

                Thread.sleep(1000);

                singleton = new TestSingleton();

            }

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        return singleton;

    }

}

运行结果:

978613927

978613927

978613927

控制台打印出的hashCode值是一致的,说明是一个对象,实现了单例。但是在高并发且要求响应速度快的业务场景下,如果响应速度慢用户体验度就会下降,这是不允许的。上述这种实现方式的运行效率非常低下。getInstance()是同步运行的,下一个线程想要取得对象,则必须等上一个线程释放锁之后,才可以继续执行。

同步方法是对方法的整体进行持锁,改成同步代码块会不会效率有提升呢?继续修改代码:

package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description

 *

 */

public class TestSingleton {

    // 延迟加载方式===懒汉模式

    private static TestSingleton singleton;


    public static TestSingleton getInstance() {

        // public synchronized static TestSingleton getInstance(){

        try {

            synchronized (TestSingleton.class) {

                if (singleton != null) {

                } else {

                    // 创建实例之前可能会有一些准备性的耗时工作

                    Thread.sleep(1000);

                    singleton = new TestSingleton();

                }

            }

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        return singleton;

    }

}

运行结果:

978613927

978613927

978613927


加入同步synchronized语句块得到相同实例对象,但是此种写法的运行效率也是非常低,和上面的同步方法一样是同步运行的。

其实我们发现,我们仅仅是想创建一个实例对象。而只有singleton为null时,才会去new 实例对象。我们是不是可以只针对new 实例对象这个模块进行单独的同步,而其他的代码不需要同步呢?答案是可以的。继续修改代码:


package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description

 *

 */

public class TestSingleton {

    // 延迟加载方式===懒汉模式

    private static TestSingleton singleton;


    public static TestSingleton getInstance() {

        // public synchronized static TestSingleton getInstance(){

        try {

            if (singleton != null) {

            } else {

                // 创建实例之前可能会有一些准备性的耗时工作

                Thread.sleep(1000);

                synchronized (TestSingleton.class) {

                    singleton = new TestSingleton();

                }

            }

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        return singleton;

    }

}

这样如果singleton不为null时,线程是可以并发去获取singleton对象的,效率会大幅提升。这样的代码简直完美,我们运行代码看一下结果:

1321522194

817899724

1547637284


老铁们,扎心不?为什么生成了3个实例对象(3种hashCode)?

原因:假设三个线程同时进入到getInstance()方法,同时判断singleton为null,那么就会都进入到else模块准备初始化对象,由于锁住了new实例对象语句,所以三个线程同步依次创建了一个单独的singleton对象,故得到三种hashCode。

其实上述问题在于,同步锁之前进行了一次对象判断是否为空之后,后续就再未进行对象为空判断,导致线程并发进入new实例对象模块,虽然是同步过程,但是却依次创建了一个对象。我们其实只要在同步模块内new实例之前再进行一次判断singleton是否为空即可避免该问题发生,俗称DCL双检查锁机制。

修改代码如下:

package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午9:53:57

 * @Description

 */

public class TestSingleton {

    // 延迟加载方式===懒汉模式

    private static TestSingleton singleton;


    public static TestSingleton getInstance() {

        // public synchronized static TestSingleton getInstance(){

        try {

            if (singleton != null) {

            } else {

                // 创建实例之前可能会有一些准备性的耗时工作

                Thread.sleep(1000);

                synchronized (TestSingleton.class) {

                    if(singleton == null){

                        singleton = new TestSingleton();

                    }

                }

            }

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        return singleton;

    }

}

运行结果如下:

2134502363

2134502363

2134502363


使用双检查锁功能,成功解决了“懒汉模式”遇到的多线程安全问题。DCL也是大多数多线程结合单例模式使用的解决方案。

但是大家需要注意的一点是上面的DCL双检查锁的写法其实也是有问题的,该写法有可能会产生空指针异常,解决该问题:使用volatile关键字修饰相关变量即可。这里面涉及到了java虚拟机指令重排序内存屏障相关知识,鉴于文章篇幅和侧重点在此就不再详细赘述相关知识,以后会单独写一篇volatile关键字相关的文章来介绍指令重排序和内存屏障相关知识。我们在实际运用中注意这一点即可。
<注:>在访问量小的时候,可以采用“懒汉模式“,以时间换空间。因为”懒汉模式“不需要在一开始初始化程序的时候就占用大量的内存。

static代码块和静态内置类

之所以说静态代码块是饿汉式单例的变种,是因为静态代码块中的代码在使用类的时候就已经执行了,和饿汉式单例的原理一样。代码实现如下:
package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月21日 下午11:24:51

 * @Description 

 */

public class TestSingleton {

    private static TestSingleton singleton = null;

    

    private TestSingleton(){}

    

    static {

        singleton = new TestSingleton();

    }


    public static TestSingleton getInstance() {

        return singleton;

    }

}

在调用TestSingleton类的时候,类加载的顺序是:静态变量->静态代码块->静态方法,所以在调用静态方法时,静态变量singleton已经实例化了,而且只会初始化一次,所以是线程安全的 。
(线程类复用上篇的代码)运行结果如下:

1876189785

1876189785

1876189785


静态内置类和静态代码块的实现线程安全的原理是一样的,都是利用类加载顺序实现线程安全。代码实现如下:

package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月21日 下午11:24:51

 * @Description 

 */

public class TestSingleton {

    

    private static class TestInnerSingleton{

        private static TestSingleton singleton = new TestSingleton();

    }

    private TestSingleton(){}

    

    public static TestSingleton getInstance() {

        return TestInnerSingleton.singleton;

    }

}


运行结果如下:

866891806

866891806

866891806


enum枚举数据类型

在介绍使用枚举类实现线程安全的单例模式之前,先简单介绍一下枚举类的特性:

1.enum和 class,interface的地位一样;
2.enum定义的枚举类默认继承了java.lang.Enum,而不是继承Object类。枚举类可以实现一个或多个接口;
3.枚举类的所有实例都必须放在第一行展示,不需使用new 关键字,不需显式调用构造器。自动添加public static final修饰,这一点保证了它可以以类型安全的形式来表示;
4.使用enum定义、非抽象的枚举类默认使用final修饰,不可以被继承;

5.枚举类的构造器只能是私有的(此点性质满足了实现单例模式的条件);
6.枚举中可以和java类一样定义方法;

那我们什么时候使用枚举类比较合适呢?

有的时候一个类的对象是有限且固定的,这种情况下我们使用枚举类就比较方便。

鉴于枚举类的第3,5点特性以及其使用场景,我们就可以理解为什么枚举类可以实现单例模式了:

首先,在枚举中我们明确了构造方法限制为私有,在我们访问枚举实例时会执行构造方法,同时每个枚举实例都是static final类型的,也就表明只能被实例化一次。在调用构造方法时,我们的单例被实例化。 也就是说,因为enum中的实例被保证只会被实例化一次。

代码实现如下:

package Test;

/**

 * @author zhaoxin06

 * @Date 2017年3月22日 下午10:04:44

 * @Description 

 */

public enum TestSingleton {

    SINGLETON;

    

    private TestSingletonHandler singleton = null;

    private TestSingleton(){

        singleton = new TestSingletonHandler();

    }


    

    public TestSingletonHandler getInstance(){

        return singleton;

    }

    

    class TestSingletonHandler{

        //其实我们可以直接在枚举类的构造函数中做一些初始化的东东,

        //这里只是为了方便显示表明初始化了一个类:声明了一个内部类
        //内部类也可以做一些动作:表现为网络连接,数据库连接,线程池

    }

}


多线程调用代码如下:

package Test; 

/**

 * @author zhaoxin06

 * @Date 2017年3月11日 下午10:01:40

 * @Description 

 *

 */

public class TestThread implements Runnable{


    @Override

    public void run() {

        System.out.println(TestSingleton.SINGLETON.getInstance().hashCode());

    }


    public static void main(String[] args) throws InterruptedException {

        TestThread  runable = new TestThread();

        Thread t1 = new Thread(runable);

        Thread t2 = new Thread(runable);

        Thread t3 = new Thread(runable);

        t1.start();

        t2.start();

        t3.start();

    }

}

代码运行结果如下:

397836821

397836821

397836821


控制台打印的hashCode值一致,表明是一个对象。

除了以上方式之外,还有通过序列化与反序列化方式实现单例;而且还可以通过类似注册表的方式来动态加载某些类的单例,在此就不再进行介绍了。有兴趣的朋友可以自行研究。

暧昧贴