Java 大白话讲解设计模式之 -- 单例模式

声明:原创作品,转载请注明出处https://www.jianshu.com/p/b99e870f4ce0

有的时候,我们需要某个类只能被实例化一次,那么我们就可以使用这种模式。单例模式是相对来讲比较简单的一种设计模式,虽说简单,却是处处暗藏杀机。首先我们来看下一个类如何才能只被实例化一次。我们知道一般实例化对象时,都会调用类的构造方法。如果构造方法为public,那么肯定每个人都能实例化这个类,也就无法保证该类对象的唯一性。所以这个类的构造方法必须为private,不能向外界提供。但是这样我们就无法调用它的构造方法,也就无法实例化对象了,那么由谁来实例化呢?想必你也想到了,由类自身调用,因为这时候也只有它自身能调用构造方法了。我们看下代码:

public class Singleton {
    
    private Singleton(){}
    
    public Singleton getInsatnce(){
        return new Singleton();
    }
}

我们在类中定义一个getInstance方法来提供一个该类的实例化对象,不过有个问题,我们该如何调用这个方法呢,因为只有在该类实例化后才能调用这个方法,然而这个方法就是用来实例化对象的。是不是很纠结,有什么好办法吗?很简单我们只要将这个方法表示成静态方法就可以:

public class Singleton {

    private Singleton(){}

    public static Singleton getInsatnce(){
        return new Singleton();
    }
}

这样我们就可以直接调用Singleton.getInstance()来创建对象了。

恶汉式单例模式

可是这也不是唯一的啊,每次调用这个方法都会新建一个实例对象。别担心,我们只要稍微改变下就好了:


public class Singleton {
    
    private static Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInsatnce(){
        return singleton;
    }
}

我们在类中事先就创建好一个实例对象,每次调用getInstance方法时返回这个对象就可以了。这样我们的单例模式就诞生了。我们可以看到,只要这个类一加载完,就会创建实例对象,显得很着急,像一个恶汉一样,所以我们称之为恶汉式单例模式。

懒汉式单例模式

上面的恶汉式单例模式会带来一个问题,假如我们代码中都没用到这个类的实例对象,如果这个类简单还好,要是复杂的话就会造成大量资源浪费。这时你可以用懒加载的方式来创建对象,即当你要使用这个实例对象时再创建。代码如下:

public class Singleton {

    private static Singleton singleton ;

    private Singleton(){}

    public static Singleton getInsatnce(){
        if (singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

由于只有当用到时才创建对象,比较懒,我们称之为懒汉式单例模式。

线程安全的懒汉式单例模式(双重检验锁模式)

以上懒汉式单例模式虽然可以延迟创建实例,不过会带来新的问题,我们先看个简单的例子:

我们在上述类的构造方法中加入一句打印语句,如果该类被实例化就会打印出日志。如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce(){
        if (singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

然后新建一个测试类来实例化该类:


public class TestClient {

    public static void main(String[] args){
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton.getInsatnce();
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton.getInsatnce();
            }
        });
        thread1.start();
        thread2.start();
    }
}

测试类也很简单,创建了两个线程,每个线程都调用Singleton的getInstance方法来获取对象。好了我们运行该测试类看下:

输出结果:
-------------------------------------------
实例化对象
-------------------------------------------

正如我们所愿,虽然我们调用两次getInstance,但该类只被实例化一次。但不要高兴的太早,你多运行几次后会发现,有的时候会打印两遍,也就说该类被实例化了两遍:

输出结果:
-------------------------------------------
实例化对象
实例化对象
-------------------------------------------

这是为什么呢?我们明明已经做了判断,如果对象为空就创建,不为空就直接返回,怎么还会创建两遍呢?其实这正是多线程在作怪。

我们知道,程序的运行其实就是CPU在一条条的执行指令,如果是单线程,那么CPU就会依次执行该线程中的指令,但如果是多线程,比如有两个线程,线程A和线程B。为了让两个线程同时执行,那么CPU会执行A线程一段时间然后暂停,去执行B线程的指令,一段时间后再切换到A线程执行。这样反复切换直到程序结束,由于CPU切换的速度很快,所以让人感觉两个线程是同时执行的。那么到底CPU什么时候切换,以及每次切换执行多久呢,其实这都是随机的,一般来讲每个线程被执行到的几率都差不多,不过你可以提高某个线程的优先级来让CPU多执行你会儿,但什么时候切换你是无法控制的,只能提高你被执行到的几率。

好了,回到上面的例子,我们来看下这个问题到底是怎么产生的。为了便于说明我在Singleton类中标记了两行代码,这两行代码也正是问题的关键:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce(){
        if (singleton == null){      //-----------------------------------1
            singleton = new Singleton();    //--------------------------- 2
        }
        return singleton;
    }
}

假设现在有两个线程A和B,首先A线程调用getInstance方法,当执行到语句1时会判断对象是否为空,由于该类还没被实例化,所以条件成立,遍进入到花括号中准备执行语句2,正如前面所说线程的切换是随机,当正准备执行语句2时,线程A突然停在这里了,CPU切换到线程B去执行。当线程B执行这个方法时,也会判断语句1的条件是否成立,由于A线程停在了语句1和2之间,实例还未创建,所以条件成立,也会进入到花括号中,注意此时线程B并未停止,而是顺利的执行语句2,创建了一个实例,并返回。然后线程B又切换回了线程A,别忘了,这时,线程A还停在语句1和2之间,切换回它的时候就又继续执行下面的代码,也就是执行语句2,创建了一个实例,并返回。这样,两个对象就被创建出来了,我们的单例模式也就失效了。

好了,找到原因了,那有什么方法解决吗?很简单只要在getInstance方法前加上关键字synchronized就可以了。代码如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("实例化对象\n");
    }

    public static synchronized Singleton getInsatnce(){
        if (singleton == null){      //-----------------------------------1
            singleton = new Singleton();    //--------------------------- 2
        }
        return singleton;
    }
}

synchronized修饰这个方法,相当于给这个方法加了把锁,只要有线程进入到这个方法里面,那么这个锁就会被锁上,这时其他的线程想要执行这个方法时,一看,呦,厕所门关着待会再来上。只有当里面的线程执行完这个方法后,这个锁才会打开,其他线程才能进入。这样就很好的避免前面重复创建对象的问题。synchronized虽然解决了这个问题,但是synchronized会降低程序执行效率,试想你开车到某地有两条路,突然其中一条在维修,被封锁了,那就势必会造成另一条路的拥堵。看来我们还得再优化下上面的代码。

由上面的分析我们知道,这个实例的重复创建问题主要是在实例还未被创建的时候,且是在执行语句1,2时产生的,只要实例创建成功了,就没有必要加锁了。换句话说,我们没有必要给整个getInstance方法加锁,其实只用在实例还未创建时给语句1和语句2加个锁就可以了,当实例创建成功后会直接返回实例。优化后的代码如下:


public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

这里synchronized 的用法和上面的有所不同,上面我们用synchronized 来修饰方法,表示给整个方法上了把锁,我们称之为同步方法。这里我们只给语句1和语句2加了把锁,我们称这种结构为同步代码块,同步代码块synchronized 后面的括号中需要一个对象,可以任意,这里我们用了Singleton的类对象Singleton.class。可以看到我们在方法中进行了两次对象是否为空的判断,一次在同步代码块外面,一次在里面。因此称之为双重检验锁模式(Double Checked Locking Pattern)。为什么要判断两次呢,当还未实例化的时候,就进行同步创建对象,为什么同步代码块里面还要做次判断呢?我们来分析下如果里面不做判断会怎么样:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//-----------2
                singleton = new Singleton();    //----------3
            }
        }
        return singleton;
    }
}

如上,我们在同步代码块中去掉了判断语句,这时有两个线程A、B调用getInstance方法。假设A先调用,当A调用方法时,会执行语句1进行条件判断,由于对象尚未创建,所以条件成立,正准备执行语句2来获取同步锁。我们上面也分析过了,线程的切换是随机的,还未执行语句2时,线程A突然停这了,切换到线程B执行。当线程B调用getInstance方法时也会执行语句1进行条件的判断,由于这时实例还未创建,所以条件成立,注意这时线程B还是没有停,又继续执行了语句2和3,即获取了同步锁并创建了Singleton对象。这时线程B切换回A,由于A此时还停在语句1和2之间,切回A时,就又继续执行语句2和3,即获取同步锁并创建了Singleton对象,这样两个对象就被创建出来了,synchronized 也失去了意义。所以我们需要在同步代码块中再做次判断:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//------------2
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

这样当线程A从语句1和2之间醒来,然后获取到同步锁后在创建对象前做一个判断,如果对象为空就创建,如果不为空就直接跳出同步代码块并返回之前线程B创建的对象。这样当下次再调用getInstance方法时,由于之前创建过对象,就不会再进入到同步代码块中,而是直接返回实例。我们代码的执行效率也就上去了。好了现在我们的双重检验锁模式既解决了在多线程中重复创建对象问题,又提高了代码执行效率,同时还是懒加载模式,是不是已经非常完美了?别高兴的太早,其实这还是有问题的。纳尼!!搞了这么久怎么还有问题,有的朋友可能已经坐不住准备退票了。你先别急,让我们看看到底哪里还有问题。其实问题就出在Singleton的创建语句上:

singleton = new Singleton();  

为什么这句会有问题呢,在分析原因之前,我们先来了解下Java虚拟机在执行该对象创建语句时具体做了哪些事情,我们简单概括为3步:

  • 1 在栈内存中创建singleton 变量,在堆内存中开辟一个空间用来存放Singleton实例对象,该空间会得到一个随机地址,假设为0x0001。
  • 2 对Singleton实例对象初始化。
  • 3 将singleton变量指向该对象,也就是将该对象的地址0x0001赋值给singleton变量,此时singleton就不为null了。

我们之前说过,程序的运行其实就是CPU在一条条执行指令,有的时候CPU为了提高程序运行效率会打乱部分指令的执行顺序,也就是所谓的指令重排序,当然这种指令重排序并不改变最后的运行结果。我们上面的3步就包含了大量的CPU指令,当CPU执行时,是无法保证一定是按照123的顺序执行,也可能由于指令重排序的优化,会以132的顺序执行。假设现在有两个线程A、B,CPU先切换到线程A,当执行上述创建对象语句时,假设是以132的顺序执行,当线程A执行完3时(执行完第3步后singleton就不为null了),突然停住了,CPU切换到了线程B去调用getInstance方法,由于singleton此时不为null,就直接返回了singleton,但此时步骤2是还没执行的,返回的对象还是未初始化的,这样程序也就出问题了。那有什么方法解决吗?很简单只要用volatile修饰singleton变量就可以了:

 private volatile static Singleton singleton;

那为什么volatile 修饰变量就可以了呢,我们上面提到指令的重排序,其实CPU在执行指令时并不是无节操随意打乱顺序,而是有一定的原则可寻的,这个原则也叫先行发生原则(happens-before),只要不符合这个原则,那么执行顺序也是得不到保障的,具体有以下8条原则:

先行发生原则(happens-before):

  • 1 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 2 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • 3 volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 4 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 5 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 6 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 7 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 8 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

这些原则你可能不太理解,不过没关系,这里我们重点关注第3条:即,对volatile变量的写操作先行与对这个变量的读操作。我们知道上面的问题主要是线程B对Singleton 对象读取时,该对象还未写入初始化导致的,那么如果我们用volatile来修饰的话,就不会出现这种情况,我们的虚拟机在读取该对象时,会保证其一定是写入好的,也就是不会出现132这种情况。这样也就不会出现问题啦。我们来看下完整的代码:

public class Singleton {

    private volatile static Singleton singleton;

    private Singleton() {
        System.out.print("实例化对象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//------------2
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

好了,这就是完美的双重检验锁的单例模式啦,放心,现在绝对没坑了。不过每次写单例模式都要写这么多,也是挺麻烦的,有简单点的吗?当然有了,下面介绍两种比较简洁的单例模式,即用静态内部类和枚举来实现,我们来简单了解下。

静态内部类实现单例模式

public class Singleton {  
    private static class SingletonHolder {  
        private static final Singleton singleton = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
        return SingletonHolder.singleton; 
    }  
}

可以看到,我们在Singleton 类内部定义一个SingletonHolder 静态内部类,里面有一个对象实例,当然由于Java虚拟机自身的特性,只有调用该静态内部类时才会创建该对象,从而实现了单例的延迟加载,同样由于虚拟机的特性,该模式是线程安全的,并且后续读取该单例对象时不会进行同步加锁的操作,提高了性能。

枚举实现单例模式

接下来,我们看下如何用枚举来实现单例。可能有的朋友对枚举还不是很熟悉,其实枚举和我们的普通类没有太大区别,比如我们下面举个简单的例子:

public class Person {
    
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

上面,我们定义了一个简单的Person类,类中定义了一个属性name,非常简单,接下来,如果你想要操作这个类,比如创建一个person对象,并写入对象的name然后再获取name,也是非常简单:

    Person person1 = new Person();
    person1.setName("张三");
    System.out.print(person1.getName());

这里,想必你只要接触过Java语言都能看懂上面的代码,我们创建了一个Person对象,并给对象设置了一个名字,然后打印出名字。

接下来我们看下用枚举如何完成上面的操作,我们把上面的Person类稍加修改:

public enum Person {
    person1;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

你可能发现了不同的地方,我们把Person类名前的class改为了enum,表示这个Person类是个枚举类,然后你会发现,这个类中比之前的类多了一行代码

    person1;

这个person1是什么,你发现前面我们实例化Person的时候也出现过person1,是的你没猜错,这里的person1就是我们这个枚举类的一个对象实例,也就是说,如果你要获取一个Person对象,不用再像上面那样调用new Person()来创建对象,直接获取这个枚举类中的person1就可以了,这个person1就是一个Person对象实例,你可能不信,没关系我们来试验下:

 Person.person1.setName("张三");
 System.out.print(Person.person1.getName());

运行后你会发现,成功打印出来名字,是不是很方便。可能你会说,那我想再创建一个Person对象比如叫做person2,怎么办呢,很简单,只要在person1后面再加上person2就可以了,如下:

   person1,person2;

注意:实例person1和实例person2之间要用逗号隔开,用分号结束,且实例要定义在类中的第一行。

好了,了解的枚举的简单实用,问题来了如何将上述的枚举Person类改为单例呢,很简单,我们只要在类中中定义一个实例就可以了,比如去掉person2,只保留person1,如下:

public enum Person {
    person1;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

这样你就只能获取到一个Person实例对象了。可能有的人有疑惑了,不对啊,难道我就不能再new一个吗?这个是不能的,因为枚举类的构造方法是私有掉的,你是无法调用到的并且你也无法通过反射来创建该实例,这也是枚举的独特之处。可能有人会问了,如果这个Person的name需要在对象创建时就初始化好,那该怎么办呢?很简单,就和普通类一样只要在里面定义一个构造方法,传入name参数就可以了。如下:

public enum Person {
    person1("张三");
    private String name;

    Person(String name){
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

可以看到就和普通类一样,我们在枚举类中定义了一个入参为name的构造方法,注意这构造方法前面虽然没有加权限修饰的,但并不表示它的权限是默认的,前面提到枚举类中的构造方法是私有的,即使你强行给它加个public,编辑器也会报错。好了,定义好了构造方法,就可以调用。调用也会简单,直接在实例person1后面加个括号传入一个名字就可以了,这样Person中的name字段就有值了,你可以直接调用Person.person1.getName()来获取这个名字,是不是很方便。

另外枚举类实例的创建也是线程安全的,所以使用枚举来实现单例也是一种比较推荐的方法,但是如果你是做Android开发的,你需要注意了,因为使用枚举所使用的内存是静态变量的两倍之上,如果你的应用中使用了大量的枚举,这将会影响到你应用的性能,不过一般用的不多的话还是没什么关系的。

好了,单例模式到这里也介绍的差不多,你可以根据自己的喜好以及具体的业务选择其中一种。

设计模式持续更新中...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342