单例设计模式

一、主要解决的问题场景

避免⼀个全局使⽤的类频繁的创建和消费,提升整体的代码性能,减少内存开支

二、主要实现方式

2.1 饿汉式

public class HungryMan {

    /**
     * 饿汉式,类加载的时候就实例化对象
     */
    private static final HungryMan HUNGRY_MAN = new HungryMan();

    /**
     * 构造方法私有化,外部无法访问并通过空参构造创建新的对象
     */
    private HungryMan() {
    }

    /**
     * 对外只提供一个获取对象的方法,每次调用只返回同一个对象
     */
    public static HungryMan getInstance() {
        return HUNGRY_MAN;
    }
}

//测试
@Test
public void testHungry() throws InterruptedException {
    for (int i = 0; i < 2; i++) {
        new Thread(() -> {
            HungryMan instance = HungryMan.getInstance();
            System.out.println(Thread.currentThread().getName() + "------" + System.identityHashCode(instance)); //我们使用System.identityHashCode获取对象的hash值,检查是否为同一个对象
        }).start();
    }
    Thread.currentThread().join();
}

//我们可以多线程调用,返回的都是同一个对象
Thread-1------374756906
Thread-2------374756906
Thread-0------374756906
Thread-3------374756906
Thread-4------374756906

2.2 懒汉式

(1)非线程安全
public class LazyMan {
    private static LazyMan lazyMan;
    private LazyMan() {
    }
    public static LazyMan getInstance() {
        if (null == lazyMan) {
            
            //检查是否有多个线程重新创建对象,导致并发问题
            System.out.println("线程" + Thread.currentThread().getName() + ", 重新创建了对象");
            lazyMan = new LazyMan();
        }
        return lazyMan;
    }
}

//我们多线程调用,会发现Thread-1、Thread-3、线程Thread-4、Thread-2都重新创建对象,且其hashcode不一致,返回的不是同一个对象
线程Thread-1, 重新创建了对象
线程Thread-3, 重新创建了对象
Thread-3------1362804950
线程Thread-4, 重新创建了对象
Thread-4------1017499014
线程Thread-2, 重新创建了对象
Thread-2------1824846967
Thread-1------374756906
Thread-0------1824846967

对比饿汉式,主要有以下三点区别

a. 饿汉式声明变量同时初始化对象,懒汉式调用方法时初始化对象

b. 在第一次调用对象前,懒汉式比饿汉式节省空间,饿汉式在类加载的时候就实例化,生命周期长

c. 由于饿汉式是类加载实例化对象,所以不存在线程安全问题

(2)DCL双重锁检验懒汉式
public static LazyMan getInstance() {
    if (null == lazyMan) {
        synchronized (LazyMan.class) {
            if (null == lazyMan) {
                System.out.println("线程" + Thread.currentThread().getName() + ", 重新创建了对象");
                lazyMan = new LazyMan();
            }
        }
    }
    return lazyMan;
}

我们也可以在getInstance()方法上加synchronized,但是这样会大大降低执行效率,本来多个线程执行这个方法,大多数都是可以直接在第一个if就直接return掉,但在方法上加锁后,就需要集体等待锁释放。

第二层的判断主要是防止,当AB两个线程都在第一层判为空,A拿到锁执行实例化对象,B在A释放锁后也执行,就会出现并发问题。

对于这种DCL模式,还有一个问题就是:指令重排序

创建对象的过程一般是如下顺序:
(1)堆中开辟空间
(2)调用构造方法初始化
(3)把地址赋值给栈中变量
但是JVM会考虑到效率问题,出现无序写入现象:赋值语句在对象实例化之前调用,从而使顺序变为(1)、(3)、(2),可能会出现A线程执行到(3),但还未初始化属性,此时,B线程开始执行,经过第一层的if判断,lazyMan != null,直接返回了属性未初始化的lazyMan 的情况。

//增加volatile,解决指令重排序
private static volatile LazyMan lazyMan;

2.3 静态内部类

public class StaticInnerSingle {

    //外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存
    private static class Holder {
        private static final StaticInnerSingle STATIC_INNER_SINGLE = new StaticInnerSingle();
    }
	
    //调用getInstance方法的时候,会加载内部类
    public static StaticInnerSingle getInstance() {
        return Holder.STATIC_INNER_SINGLE;
    }
}

静态内部类保证单例的原因:类初始化阶段,JVM保证同一个类的static{}方法只被执行一次,JVM靠类的全限定类名以及加载它的类加载器来唯一确定一个类,并保证是同一个类。

2.4 CAS算法单例

public class CASSingle {
    
    //AtomicReference类提供了一个可以原子读写的对象引用变量
    private static final AtomicReference<CASSingle> INSTANCE = new AtomicReference<>();
    private static CASSingle casSingle;
    private CASSingle() {
    }
    public static CASSingle getInstance() {
        for (;;) {
            CASSingle casSingle = INSTANCE.get();
            if (null != casSingle) {
                return casSingle;
            }
            
            //比较&交换操作,1、获取预期值null;2、实例化新对象;3、获取内存值比较,一致,则引用
            if(INSTANCE.compareAndSet(null, new CASSingle())) {
                return INSTANCE.get();
            }
        }
    }
}

CAS单例是原子操作,意味着尝试更改相同AtomicReference的多个线程,不会使AtomicReference最终达到不一致的状态。

(1)不需要使⽤传统的加锁⽅式保证线程安全,⽽是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以⽀持较⼤的并发性

(2)缺点就是忙等,一直没有获取到就会死循环;另外就是会创建大量的CASSingle对象

2.5 枚举单例

public enum EnumSingle {
    INSTANCE;
    EnumSingle() {
    }
    public static EnumSingle getInstance() {
        return INSTANCE;
    }
}

枚举单例是线程安全的,但是效率相对低。

三、反射破解

3.1 反射破解方式

以懒汉式为例,进行单例反射破解

@Test
public void testReflectSingle() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    
    //获取私有构造方法,获取访问权限,创建对象
    Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor();
    declaredConstructor.setAccessible(true);
    LazyMan lazyMan = LazyMan.getInstance();
    LazyMan lazyManReflect = declaredConstructor.newInstance(null);
    System.out.println("lazyMan = " + System.identityHashCode(lazyMan));
    System.out.println("lazyManReflect = " + System.identityHashCode(lazyManReflect));
}

//打印结果
lazyMan = 366004251
lazyManReflect = 1791868405

除了枚举,其他的单例模式实现方式都是可以被破解的,主要原因在于空参的构造方法可以反射获取到,因此我们可以使用如下的解决办法——对空参构造方法进行判断处理。

/**
 * 空参构造方法,防止反射破解处理
 */
private LazyMan() {
    synchronized (LazyMan.class) {
        if (null != lazyMan) {
            throw new RuntimeException("禁止反射破解!!");
        }
    }
}

//输出结果
Caused by: java.lang.RuntimeException: 禁止反射破解!!
	at com.jd.domain.single.HungryMan.<init>(HungryMan.java:21)
	... 27 more

但是,如果我们从一开始没有使用getInstance()实例化对象,直接反射获取对象,那么依然无法阻止反射破解。

//直接反射创建对象
LazyMan lazyManReflect1 = declaredConstructor.newInstance(null);
LazyMan lazyManReflect2 = declaredConstructor.newInstance(null);

//打印
lazyManReflect1 = 366004251
lazyManReflect2 = 1791868405

主要是因为直接反射创建对象的时候,没有操作成员变量lazyman的实例化,每次判断都是空,都能创建成功。

我们可以设置一个私有成员变量,第一次通过空参构造实例化对象的时候,修改掉这个变量值,如若再次通过反射实例化,可以利用这个变量进行判定。

private static boolean baaccfedaceddfa = false;
private LazyMan() {
    synchronized (LazyMan.class) {
        if (baaccfedaceddfa) {
            throw new RuntimeException("禁止反射破解!!");
        }
        baaccfedaceddfa = true;
    }
}

//打印结果
Caused by: java.lang.RuntimeException: 禁止反射破解!!
	at com.jd.domain.single.LazyMan.<init>(LazyMan.java:19)
	... 27 more

即使如此,如果我们可以获得这个变量的名称,以入可以获得访问控制,修改为原始状态,同样反射破解成功

//获取到这个成员变量名,获得访问权限,将值修改为false即可再次破解
Field baaccfedaceddfa = LazyMan.class.getDeclaredField("baaccfedaceddfa");
baaccfedaceddfa.setAccessible(true);
baaccfedaceddfa.setBoolean(LazyMan.class, false);
LazyMan lazyManReflect2 = declaredConstructor.newInstance(null);

//打印结果
lazyManReflect1 = 1791868405
lazyManReflect2 = 1260134048

3.2 枚举禁止反射破解

我们再枚举单例里定义了一个空参构造方法

public enum EnumSingle {
    INSTANCE;
    
    //空参构造
    EnumSingle() {}
    public static EnumSingle getInstance() {
        return INSTANCE;
    }
}

然后我们使用反射获取这个空参构造,进行实例化对象

Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
EnumSingle enumSingleReflect = declaredConstructor.newInstance(null);

//打印结果
java.lang.NoSuchMethodException: com.jd.domain.single.EnumSingle.<init>()

出现异常,主要原因是这个枚举类并没有无参构造,这就有点黑人问好了???!!!

我们使用XJad对这个class文件进行反编译,看一下java是否在编译过程中进行了什么神操作。

//final修饰的类,不能被继承
public final class EnumSingle extends Enum {

	public static final EnumSingle INSTANCE;
	
    ......
	
    //替换为有参构造
	private EnumSingle(String s, int i) {
		super(s, i);
	}

	public static EnumSingle getInstance() {
		return INSTANCE;
	}
	
    //静态代码块,类加载时就实例化对象
	static {
        
        //有参构造内容
		INSTANCE = new EnumSingle("INSTANCE", 0);
		$VALUES = (new EnumSingle[] {
			INSTANCE
		});
	}
}

可以发现,枚举类,其实就是在编译的时候继承了一个Enum基类,也确实取消了无参构造,而实际使用的是参构造。

既然找到了有参构造的内容,即INSTANCE 和 0,那么我们可以通过有参构造方式反射破解。

Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
declaredConstructor.setAccessible(true);
EnumSingle enumSingleReflect = declaredConstructor.newInstance("INSTANCE", 0);

//打印结果:禁止反射创建枚举对象
java.lang.IllegalArgumentException: Cannot reflectively create enum objects

在JDK的反射包里,newInstance方法中,就有对枚举的断言

public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
           IllegalArgumentException, InvocationTargetException {
    ......
      
    /*如果是枚举类型,禁止反射破解*/           
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
    ......
}

四、克隆“破解单例”

以饿汉式为例子,需要遵从序列化接口Serializable,然后我们使用Hutool的深度克隆进行序列化操作。

HungryMan instance = HungryMan.getInstance();
HungryMan cloneInstance = ObjectUtil.cloneByStream(instance);

//打印结果
1484594489
1758386724

很明显,不能满足单例要求。

但其实,这已经与我们使用单例的目的背道而驰了,我们使用单例,是为了保证全局唯一,而我们使用克隆,就是不想全局唯一,互不干扰。

我们点开Enum枚举类的JDK源码,会发现,枚举是天然支持禁止序列化和反序列化的

/**
 * prevent default deserialization
 */
private void readObject(ObjectInputStream in) throws IOException,
    ClassNotFoundException {
    throw new InvalidObjectException("can't deserialize enum");
}
private void readObjectNoData() throws ObjectStreamException {
    throw new InvalidObjectException("can't deserialize enum");
}

总结枚举单例模式更简洁,⽆偿地提供了串⾏化机制,绝对防⽌对此实例化,即使是在⾯对复杂的串⾏化或者反射攻击的时候。虽然这中⽅法还没有⼴泛采⽤,但是单元素的枚举类型已经成为实现Singleton的最佳⽅法,但是在继承情景下不适用

五、饿汉式的优势不必双重锁检验的懒汉式差

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。

不过,我个人并不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。

采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。

如果资源不够,就会在程序启动的时候触发报错(比如Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。—— 摘自·王争《设计模式之美》

六、单例模式其他应用

6.1 ThreadLocal实现线程唯一单例

Threadlocal的使用很简单,这里不赘述,其底层维护的就是一个Map,将当前线程作为key,将Object作为value,我们可以将一个类的实现放入这个value,每次执行的时候直接从里面获取当前线程对应的Object(线程唯一),这样就保证了线程唯一单例。

6.2 进程间单例

进程间单例
在这里插入图片描述

集群相当于多个进程构成,所以也是集群间唯一,方式就是将单例对象序列化到公用文件(内存)中,需要使用redis的分布式锁。

@Slf4j
@Component //必须加这个扫描注解,才能保证这个类的静态方法跟着spring注入一起进行
public class ColonySingle implements Serializable {

    /**
     * 固定序列号
     */
    private static final long serialVersionUID = 6055841422234810284L;
    private static ColonySingle instance;

    @Resource
    private RedisUtil redisUtil; //注入redis
    private static ColonySingle colonySingle;

    public ColonySingle() {
    }

    /**
     * 该注解标识启动的时候加载,这样可以将service注入的类放到静态方法内执行
     */
    @PostConstruct
    public void init() {
        colonySingle = this;
        colonySingle.redisUtil = this.redisUtil;
    }

    /**
     * 获取单例对象:
     * 1、方法上的锁主要目的是解决线程并发问题
     * 2、第二个锁的目的是控制进程,保证进程间仅有一个进程在使用这个对象
     *    只要不释放,其他进程就不能访问外部文件获取对象
     */
    public static synchronized ColonySingle getInstance() {
        if (null == instance) {

            //获取分布式锁
            boolean lock = colonySingle.redisUtil.setNx("single", "1", 1000);
            if (lock) {
                //从外部文件读取对象
                BufferedInputStream inputStream = null;
                try {
                    inputStream = FileUtil.getInputStream("E:\\EmailMessage\\markdown\\colonySingle.class");
                    instance = IoUtil.readObj(inputStream);
                } catch (Exception e) {
                    log.error("从外部文件读取对象错误!", e);
                } finally {
                    IoUtil.close(inputStream);
                }
            }
        }
        return instance;
    }

    /**
     * 释放对象
     */
    public static void removeInstance() {

        //将对象再次存入外部文件
        BufferedOutputStream outputStream = null;
        try {
            outputStream = FileUtil.getOutputStream("E:\\EmailMessage\\markdown\\colonySingle.class");
            IoUtil.writeObj(outputStream, false, instance);
        } catch (IORuntimeException e) {
            log.error("将对象写入外部文件错误!", e);
        } finally {
            IoUtil.close(outputStream);
        }

        //销毁对象和释放锁
        instance = null;
        colonySingle.redisUtil.delKey("single");
    }
}

这个类里的将service注入的类可以放到静态方法执行,除了@PostConstruct,还可以使用如下方法:

private static RedisUtil redisUtil; //注入redis

/**
 * 注入redis
 */
@Autowired
public ColonySingle(RedisUtil redisUtil) {
    ColonySingle.redisUtil = redisUtil;
}

6.3 多例模式——Logger日志框架

多例模式就是一个类可以创建多个对象,比如简单工厂模式;另一种就是根据不同类型创建不同对象,比如日志框架。

public class LoggerFrame {

    /**
     * 不同类型的不同实例化
     */
    private static final ConcurrentMap<String, LoggerFrame> instances = Maps.newConcurrentMap();

    public LoggerFrame() {
    }

    /**
     * 获取实例
     */
    public static LoggerFrame getInstance(Class<?> clazz) {
        String name = clazz.getName();
        instances.putIfAbsent(name, new LoggerFrame());
        return instances.get(name);
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值