Java设计模式(2):单例模式

本文深入探讨了单例模式的多种实现方式,包括饿汉式、懒汉式等,并重点介绍了线程安全问题及其解决方案,如双重检查锁定和静态内部类等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

单例模式概述

定义

保证类在内存中只有一个对象。

如何保证类在内存中只有一个对象呢?

A:把构造方法私有化,不让外界创建该对象
B:在单例类中创建好一个单例对象
C:通过一个公共的方法提供访问

单例模式可以分为懒汉式和饿汉式:

懒汉式单例模式:在类加载时不初始化。

饿汉式单例模式:在类加载时就完成了初始化,,如果从始至终从未使用过这个实例,则会造成内存的浪费。

1. 饿汉式

  • 线程安全,类加载的时候就初始化。
  • 如果从始至终从未使用过这个实例,则会造成内存的浪费。

使用场景:

如果应用程序总是创建并使用单例实例或在创建和运行时开销不大。

package com.example.test;

/**
 * 饿汉式:类加载的时候就初始化
 * <p>
 * 线程安全
 * 如果从始至终从未使用过这个实例,则会造成内存的浪费。
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton1 {

    //私有构造方法,使得外界不能实例化该对象
    private Singleton1() {
    }

    private static Singleton1 instance = new Singleton1();

    //公有静态方法,对外提供获取单例接口
    public static Singleton1 getInstance() {
        return instance;
    }
}

2 .懒汉式(线程不安全)

如果开销比较大,希望用到时才创建就要考虑延迟实例化, Singleton的初始化需要某些外部资源(比如网络或存储设备)。

package com.example.test;

/**
 * 懒汉式:会产生线程安全问题
 * 多线程的程序中,多个线程同时访问该单例,会有可能创建多个实例。这个时候就需要用“锁”将它锁起来。
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton2 {

    private Singleton2() {
    }

    private static Singleton2 instance;

    public static Singleton2 getInstance() {
        if (instance == null) {
            instance = new Singleton2();
        }
        return instance;
    }
}

这是存在线程安全问题的,那具体是存在怎样的线程安全问题?怎样导致这种问题的?

好,我们来说一下什么情况下这种写法会有问题。

在运行过程中可能存在这么一种情况:多个线程去调用getInstance方法来获取Singleton的实例,那么就有可能发生这样一种情况,当第一个线程在执行if(instancenull)时,此时instance是为null的进入语句。在还没有执行instance=new Singleton()时(此时instance是为null的)第二个线程也进入了if(instancenull)这个语句,因为之前进入这个语句的线程中还没有执行instance=new Singleton(),所以它会执行instance = new Singleton()来实例化Singleton对象,因为第二个线程也进入了if语句所以它会实例化Singleton对象。这样就导致了实例化了两个Singleton对象。所以单例模式的懒汉式是存在线程安全的,既然它存在问题,那么可能有解决办法,于是就有下面加锁这种写法。

演示案例:

public class Singleton {

    private static volatile Singleton mInstance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (mInstance == null) {
            //创建实例之前可能会有一些准备性的耗时工作
            try {
                Thread.sleep(2000);
                mInstance = new Singleton();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
        return mInstance;
    }
}
 public static void main(String[] args) {

        MyThread myThread1=new MyThread();
        MyThread myThread2=new MyThread();

        myThread1.start();
        myThread2.start();

    }

    static class MyThread extends Thread {
        @Override
        public void run() {
            super.run();

            System.out.println(Singleton.getInstance().toString());
        }
    }

打印结果:

com.example.demo.Singleton.Singleton@1428c6c1
com.example.demo.Singleton.Singleton@4ab4a933

上面案例可发现两个Singleton对象不是同一个对象。

3. 线程安全的懒汉式

  • 加锁,但效率低,不推荐使用。
  • 但是又存在一个问题,每个对象进来都判断锁,实际上一定程度上影响了效率
package com.example.test;

/**
 * 线程安全的懒汉模式,但是效率低
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton3 {

    private Singleton3() {
    }

    private static Singleton3 instance;

    public static Singleton3 getInstance() {

        // 等同于 synchronized public static Singleton3 getInstance()
        synchronized (Singleton3.class) {
            if (instance == null) {
                instance = new Singleton3();
            }
        }
        return instance;
    }
}


4. 懒汉式(线程安全):双重检查,提高效率

原理:假如线程1先判断有没有对象,没有就进同步代码块创建对象,这时第二个线程也来了,他判断也没有实例对象,也进同步代码块,但是这时候它判断线程1正在持有锁,所以2进不去,当线程1创建对象并释放锁之后,线程2得到锁,进入同步代码块,但是这时发现s不等于null,函数就直接返回对象给线程二了,之后的线程都是可以直接拿到对象了。

这样只要创建了对象其它线程进来不必一直判断锁,提高了效率.

package com.example.test;


/**
 * 使用双重检查进一步做了优化,可以避免整个方法被锁,只对需要锁的代码部分加锁,可以提高执行效率。
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton4 {

    private Singleton4() {
    }

    private static Singleton4 instance;

    // 双重检查
    public static Singleton4 getInstance() {
        if (instance == null) {
            synchronized (Singleton4.class) {
                if (instance == null) {
                    instance = new Singleton4();
                }
            }
        }
        return instance;
    }
}

5. 双重校验锁(推荐用)

增加volatile关键字,禁止指令重排。

我们先来看 instance = new Singleton5()这句代码做了那些事情:

  1. 给instance实例分配对象;
  2. 调用Singleton5构造方法,初始化成员字段;
  3. 将创建的对象赋值给instance

这三个步骤在jdk里面可以是乱序的,可能会进行指令重排,比如第三步在第二步之前执行,会导致双重检查失效,所以加上volatile关键字,禁止指令重排。

package com.example.test;

/**
 * 双重校验锁(推荐用)
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton5 {

 //增加volatile关键字,确保实例化instance时,编译成汇编指令的执行顺序
    private volatile static Singleton5 instance;

    private Singleton5() {
    }

    public static Singleton5 getSingletonDemo7() {
        if (instance == null) {
            synchronized (Singleton5.class) {
                if (instance == null) {
                    instance = new Singleton5();
                }
            }
        }
        return instance;
    }
}

6. 静态内部类

package com.example.test;

/**
 * 静态内部类:
 * 避免了线程不安全,延迟加载,效率高。
 * 使用内部类的好处是,静态内部类不会在单例加载时就加载,而是在调用getInstance()方法时才进行加载,
 * 达到了类似懒汉模式的效果,而这种方法又是线程安全的。
 * Created by xiaoyehai on 2018/3/27 0027.
 */

public class Singleton6 {

    private Singleton6() {
    }

    private static class SingletonInstance {
        private static final Singleton6 INSTANCE = new Singleton6();
    }

    public static Singleton6 getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

7. 枚举

利用枚举的特性,让JVM来帮我们保证线程安全和单一实例的问题。除此之外,写法还特别简单。

package com.example.test;

/**
 * 枚举单例模式
 * 枚举实例的创建是线程安全的,任何情况下都是单例(包括反序列化)
 * Created by xiaoyehai on 2018/3/27 0027.
 */
public enum Singleton {

    INSTANCE;
    
    public void doSomething() {
        System.out.println("doSomething");
    }

}

使用:

public class Main {
    public static void main(String[] args) {
        Singleton.INSTANCE.doSomething();
    }

}

枚举单例的实现方法:

class Resource{
}

public enum SomeThing {

    INSTANCE;
    
    private Resource instance;
    
    SomeThing() {
        instance = new Resource();
    }
    
    public Resource getInstance() {
        return instance;
    }
}

上面的类Resource是我们要应用单例模式的资源,具体可以表现为网络连接,数据库连接,线程池等等。

获取资源的方式很简单,只要 SomeThing.INSTANCE.getInstance() 即可获得所要实例。下面我们来看看单例是如何被保证的:

首先,在枚举中我们明确了构造方法限制为私有,在我们访问枚举实例时会执行构造方法,同时每个枚举实例都是static final类型的,也就表明只能被实例化一次。在调用构造方法时,我们的单例被实例化。 也就是说,因为enum中的实例被保证只会被实例化一次,所以我们的INSTANCE也被保证实例化一次。
可以看到,枚举实现单例还是比较简单的,除此之外我们再来看一下Enum这个类的声明:

public abstract class Enum<E extends Enum<E>>
        implements Comparable<E>, Serializable

可以看到,枚举也提供了序列化机制。某些情况,比如我们要通过网络传输一个数据库连接的句柄,会提供很多帮助。

最后借用 《Effective Java》一书中的话,

单元素的枚举类型已经成为实现Singleton的最佳方法。

8. 使用容器类实现单例

/**
 * 单例特征:
 * 1.把构造方法私有化,不让外界创建该对象
 * 2.在单例类中创建好一个单例对象
 * 3.通过一个公共的静态方法或者枚举返回单例类的对象
 * 4.注意多线程的场景
 * 5.注意单例类对象在反序列化是不会重新创建对象
 */
public class Singleton {

    private static Map<String, Object> map = new HashMap<>();

    public static void putService(String key, Object instance) {
        if (!map.containsKey(key)) {
            map.put(key, instance);
        }
    }

    public static Object getServicee(String key) {
        return map.get(key);
    }

}

volatile

volatile通常被比喻成”轻量级的synchronized“,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。

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

如以上代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton。

volatile作为java中的关键词之一,用以声明变量的值可能随时会别的线程修改,使用volatile修饰的变量会强制将修改的值立即写入主存,主存中值的更新会使缓存中的值失效(非volatile变量不具备这样的特性,非volatile变量的值会被缓存,线程A更新了这个值,线程B读取这个变量的值时可能读到的并不是是线程A更新后的值)。volatile会禁止指令重排。

volatile具有可见性、有序性,不具备原子性。

注意,volatile不具备原子性,这是volatile与java中的synchronized、java.util.concurrent.locks.Lock最大的功能差异,这一点在面试中也是非常容易问到的点。

在这里插入图片描述

volatile的原理

java为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。

但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

volatile与可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

在Java中,每个线程都有一个独立的内存空间,称为工作内存; 它保存了用于执行操作的不同变量的值。在执行操作之后,线程将变量的更新值复制到主存储器,这样其他线程可以从那里读取最新值。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

volatile与有序性

有序性即程序执行的顺序按照代码的先后顺序执行。

java内存模型除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。

而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。

普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。

volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。

volatile与原子性

原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

volatile是不能保证原子性的。

总结与思考

我们介绍过了volatile关键字和synchronized关键字。现在我们知道,synchronized可以保证原子性、有序性和可见性。而volatile却只能保证有序性和可见性。

那么,我们再来看一下双重校验锁实现的单例,已经使用了synchronized,为什么还需要volatile?

volatile不会让线程阻塞,响应速度比synchronized高,这是它的优点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值