目录
GOF23中单例模式的实现
本文主要探讨了GOF23中单例模式的实现方法以及在实现过程中可能遇到的安全问题及解决方案。同时,通过一个具体的案例分析,阐述了单例模式在JAVA中的应用和重要性。
首先,本文介绍了单例模式的定义和作用,即确保一个类只有一个实例,并提供一个全局访问点。单例模式在JAVA中被广泛应用于日志记录器、配置管理器、数据库连接等场景。
其次,本文详细介绍了GOF23中单例模式的实现方法,包括饿汉式和懒汉式两种方式。饿汉式单例模式在类加载时即初始化实例,保证了线程安全,但可能导致内存空间的浪费。懒汉式单例模式则是在类加载时不进行初始化,而是在第一次调用时才进行初始化,实现了延迟加载和避免多次加载。
然而,在实现单例模式的过程中,可能会遇到一些安全问题,如可以被反序列化和反射破坏以及在多线程环境下的多个实例问题、线程阻塞和死锁问题、无法实例化问题等。针对这些问题,本文提出了相应的解决方案,包括避免反序列化及反射和使用双重检查锁机制、对构造函数进行私有化处理、对getInstance方法进行同步处理等。
最后,本文通过相应的案例分析,阐述单例模式在JAVA中的应用和重要性。可以避免重复创建实例,减少资源浪费和提高性能。
综上所述,单例模式作为一种常用的设计模式,在JAVA中被广泛应用于日志记录器、配置管理器、数据库连接等场景。
单例模式是一种创建型设计模式,分为饿汉式和懒汉式。它确保一个类只有一个实例,并提供一个全局访问点。单例模式通常用于创建那些需要频繁使用,且不需要进行垃圾回收的对象,例如:日志记录器、配置管理器、数据库连接等。
单例模式的实现方式有多种,包括饿汉式、懒汉式、双重检查锁、枚举等。其中,饿汉式单例模式是在类加载时就立即初始化,因此也被称为“急”模式。懒汉式单例模式则是在类加载时不进行初始化,而是在第一次调用时才进行初始化。双重检查锁模式则是在保证线程安全的前提下,实现延迟加载和避免多次加载。
单例模式的优点包括:可以节省内存空间,避免了对系统资源的浪费;可以避免对资源的多重占用;可以设置全局访问点,方便优化和共享资源的访问。但同时也存在一些缺点,例如:可能会造成线程阻塞和死锁等问题;可能会存在无法实例化的问题;可能会存在无法扩展的问题。
总之,单例模式是一种常用的设计模式,适用于需要频繁使用且不需要进行垃圾回收的对象。
单例模式只在内存中创建一个对象,在系统中不同对象的多次调用都会指向同一个地址,调用过程如图1.0 1所示。
单例模式的主要组成有:单例类,调用类;基本结构如类图:图1.0 2所示。
饿汉式单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点。这种模式的实现方式是在类加载时就立即初始化,因此也被称为“急”模式。
饿汉式单例模式的优点在于其线程安全,因为实例在被访问之前就已经创建了。但可能存在的问题是可能会造成内存空间的浪费,特别是在单例对象较多的情况下。
public class HugerSingleton {
// 静态实例代码段,饿汉实现类加载初始化时调用构造方法
private static final HugerSingleton ourInstance = new HugerSingleton();
public static HugerSingleton getInstance() {
return ourInstance;
}
// 私有默认无参构造参数防止外部调用创建对象
private HugerSingleton() {}
}
存在安全问题:可以被反射和反序列化破坏模式
反射是在Java中,运行状态中对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。这种动态获取信息以及动态调用对象方法的功能称为Java语言的反射机制。简单来说,反射机制指的是程序在运行时能够获取自身的信息。在Java中,只要给定类的名字,就可以通过反射机制来获得类的所有信息。
反序列化是指将保存到存储介质的数据通过某种方式重新恢复为java对象的过程(以java为例),那么序列化则相反。
反射破坏实例图 1
package com.crud1024.gof.main.singelcase;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class test {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
System.out.println("已经创建一个:"+HugerSingleton.class);
Class c = HugerSingleton.class;
Constructor<?> constructor = c.getDeclaredConstructor();
constructor.setAccessible(true);
System.out.println("通过反射机制破坏单例再次创建实例:"+constructor.newInstance());
System.out.println("通过反射机制破坏单例再次创建实例:"+constructor.newInstance());
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
//反序列化
ByteArrayOutputStream outputStream=new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream=new ObjectOutputStream(outputStream);
//将类转化
objectOutputStream.writeObject((Object) HugerSingleton.getInstance());
System.out.println(HugerSingleton.getInstance());
ObjectInputStream objectInputStream=new ObjectInputStream(new ByteArrayInputStream(outputStream.toByteArray()));
//读出类,变为一个新的类
HugerSingleton newclass= (HugerSingleton) objectInputStream.readObject();
System.out.println("已创建的单例类"+HugerSingleton.getInstance());
System.out.println("通过反序列化破坏后创建的单例类"+newclass);
System.out.println("前后对比是否成功破坏创建新的实例类"+(HugerSingleton.getInstance()==newclass));
}
package com.crud1024.gof.main.singelcase;
import java.io.Serializable;
/**
* 饿汉模式:线程安全,耗费资源。
*/
public class HugerSingletonSuper implements Serializable{
//该对象的引用不可修改
private static final HugerSingletonSuper ourInstance = new HugerSingletonSuper();
public static HugerSingletonSuper getInstance() {
return ourInstance;
}
private HugerSingletonSuper() {
// 防止反射,因为使用反射是必定会用到构造器,而使用反射破坏模式也正是将私有构造公共化后直接利用构造器创建,
// 现在只要使用了无参构造就判断是否已存在,如果有就说明在使用反射了,则抛出异常
if (ourInstance != null){
throw new RuntimeException("单例模式不能创建");
}
}
//防止反序列化破坏单例
public Object readResolve(){
return ourInstance;
}
}
因为使用反射是必定会用到构造器,而使用反射破坏模式也正是将私有构造公共化后直接利用构造器创建新的实例,故在构造器中进行判断是否已经存在便可以直接解决问题。
修改案例后执行结果为,如图 3
因为使用了无参构造内部判断了是否已存在实例,如果有就说明在使用反射了,则抛出我们定义的异常类型。
因为该过程涉及到Java自带jar中内置的方法,需要进一步分析原因,过程如下:
首先进入ObjectInputStream.java类内部,找到readObject方法代码块中的readObject0方法,如图 4图 5。
然后在readObject0方法中可以看到Switch的代码块(用于判断反序列化对象类型),找到TC_OBJECT,如图 6。
然后其中有readOrdinaryObject方法,再次进入查看源代码,如图 7。
显然在标出的单行代码中,使用了java18之后推出的三目运算符,对对象进行判断是否可被执行序列化,这里就又用到了反射实例化新的对象,所以说反序列化的过程是基于反射实现的,如图 8。
接下开我们看他的方法注释:
"Returns true if represented class is serializable or externalizable and defines a conformant readResolve method. Otherwise, returns false."
翻译:
如果所表示的类是可序列化或可外部化的,并且定义了一个符合规范的readResolve方法,则返回true。否则,返回false。
到此就找到了底层调用方法的具体含义,故在单例类中使用声明readResolve方法可防止序列化的问题迎刃而解。
修改案例后执行结果,如图 9。
可以看到对象的hash地址均为@3764951d那么也就符合了单例模式的定义。
public enum SingletonOfEnum {
TestClass;
public static TestClass GetInstance(){
return new TestClass();
}
}
package com.crud1024.gof.main.singelcase;
/**
* 静态志方式饿汉式单例
*/
public class HungryStaticSingleton {
private static final HungryStaticSingleton hungrySingleton ;
static {
hungrySingleton = new HungryStaticSingleton();
}
private HungryStaticSingleton() {}
//公开方法获取实例
public static HungryStaticSingleton getInstance() {
return hungrySingleton;
}
}
在Java中,静态代码块会在类首次被加载到JVM时执行,且只会执行一次。类被加载的情况通常包括以下几种:
当你第一次创建类的新对象时。
当你第一次调用类的静态方法时。
当你第一次访问类的任何静态变量时。
以上任何一种情况都会导致类被加载,从而触发静态代码块的执行。另外,需要注意的是,静态代码块只在类首次加载时执行一次,即使你再次创建类的对象或访问类的静态成员,也不会再次执行静态代码块。
懒汉式单例模式是一种创建型设计模式,它确保一个类只有一个实例,但与饿汉式单例模式不同的是,懒汉式单例模式不是一开始就创建实例,而是在需要时才创建。
懒汉式单例模式的实现方式有多种,其中一种是在类中定义一个静态变量来保存实例对象,并在需要时进行初始化。这种实现方式的优点是实现简单,且不会造成内存空间的浪费。但缺点是在多线程环境下可能会出现多个实例的情况,因此需要使用synchronized关键字进行同步处理。
另外一种懒汉式单例模式的实现方式是使用双重检查锁机制,它可以在保证线程安全的前提下,实现延迟加载和避免多次加载。具体实现方式是在第一次访问时进行实例化,并在第二次访问时直接返回实例对象。这种实现方式的优点是实现了延迟加载和避免多次加载,同时也可以避免线程阻塞和死锁等问题。但缺点是实现相对复杂,且可能会存在无法实例化的问题。
总之,懒汉式单例模式是一种常用的设计模式,适用于需要延迟加载和避免多次加载的情况。
package com.crud1024.gof.main.singelcase;
import sun.security.jca.GetInstance;
/**
* 普通懒汉式
*/
public class OrdinaryLazySingleTon {
public OrdinaryLazySingleTon() {
}
private static OrdinaryLazySingleTon ordinaryLazySingleTon;
public static OrdinaryLazySingleTon GetInstance(){
if (ordinaryLazySingleTon==null)ordinaryLazySingleTon = new OrdinaryLazySingleTon();
return ordinaryLazySingleTon;
}
}
显然线程不安全,当多线程同时访问在某时刻都进入了GetInstance方法就会创建不同的实例。
package com.crud1024.gof.main.singelcase;
/**
* 模拟多线程
*/
public class ThreadTest extends Thread{
public ThreadTest() {
}
@Override
public void run() {
System.out.println(OrdinaryLazySingleTon.GetInstance());
}
public static void main(String[] args) {
ThreadTest threadTest1 = new ThreadTest();
ThreadTest threadTest2 = new ThreadTest();
ThreadTest threadTest3 = new ThreadTest();
threadTest1.setName("实例1");
threadTest2.setName("实例2");
threadTest2.setName("实例3");
threadTest1.start();
threadTest2.start();
threadTest3.start();
}
}
运行结果如图 10。
显然后两个hash地址一样;是正常的,但是第一个的hash地址发生了变化,即出现了多线程安全问题。
那么为了解决问题,我们需要对方法进行同步使之线程安全。
package com.crud1024.gof.main.singelcase;
/**
* 普通懒汉式修改后线程安全并防止指令重排并保证共享属性的值始终都是最新的
*/
public class OrdinaryLazySingleTonSuper {
public OrdinaryLazySingleTonSuper() {
}
private volatile static OrdinaryLazySingleTonSuper ordinaryLazySingleTon;
public synchronized static OrdinaryLazySingleTonSuper GetInstance(){
if (ordinaryLazySingleTon==null)ordinaryLazySingleTon = new OrdinaryLazySingleTonSuper();
return ordinaryLazySingleTon;
}
}
在Java中,使用volatile关键字修饰一个变量可以保证该变量的可见性和禁止进行指令重排序。
首先,volatile关键字可以保证变量的可见性。在多线程环境下,如果一个线程修改了一个共享变量的值,其他线程需要立即看到该值的变化。使用volatile关键字可以保证每次读取共享变量时,都会从主内存中获取最新的值,而不是从本地缓存中读取。这样可以保证多个线程之间可以正确地共享和同步变量。
其次,volatile关键字禁止进行指令重排序。在Java中,编译器和处理器可能会对指令进行优化和重排序,以改善性能。但是,这种重排序可能导致多线程环境下的数据不一致问题。使用volatile关键字可以禁止编译器和处理器对变量进行重排序,从而保证多线程环境下的数据一致性。
需要注意的是,虽然volatile关键字可以保证可见性和禁止重排序,但它并不能保证原子性。也就是说,如果一个变量需要执行复杂的操作,比如自增或自减等,使用volatile关键字无法保证该操作的原子性。在这种情况下,需要使用其他同步机制,比如synchronized关键字或java.util.concurrent.atomic包中的原子变量。
使用synchronized关键字修饰一个方法可以保证该方法在多线程环境下的安全性。具体来说,当一个线程进入一个synchronized方法时,该线程会获得一个锁,直到方法执行完毕才释放该锁。在这个过程中,其他线程无法进入该方法,必须等待该线程执行完毕并释放锁后才能进入。
使用synchronized关键字可以保证方法的原子性,也就是说,在多线程环境下,每个线程都会看到方法执行完毕后的结果,而不会出现执行一半被其他线程打断的情况。
需要注意的是,使用synchronized关键字会带来一定的性能开销,因为线程需要等待其他线程释放锁才能进入方法。如果一个方法被多个线程同时访问,而只有一个线程能够进入该方法,其他线程需要等待很长时间,就会造成线程阻塞的情况。因此,在使用synchronized关键字时需要权衡性能和安全性。
修改后再次运行多线程模拟结果如图 11。
这次hash地址相同也就达到了线程安全,完美解决问题。
为了避免这种情况的发生,可以使用双重检查锁机制来实现懒汉式单例模式。双重检查锁机制的原理是在进入初始化过程之前先检查单例对象是否已经初始化,如果已经初始化则直接返回对象;如果没有初始化,则进入同步块,执行单例对象的构造函数和初始化语句,并返回对象。这样可以确保只有一个线程能够进入初始化过程,避免了线程阻塞的情况。
再次进行案例修改使之更加安全(双重检查锁机制案例——线程安全)。
package com.crud1024.gof.main.singelcase;
/**
* 双重检查锁DCL(Double Check Lock双端检锁)
*/
public class LazyDoubleCheckSingleton {
private LazyDoubleCheckSingleton() {
}
//使用volatile保证数据的时效性,防止指令重排。
private volatile static LazyDoubleCheckSingleton instance;
public static LazyDoubleCheckSingleton getInstance() {
// 用同步代码块实现双重检查锁 线程安全
if (instance == null) {//第一次检查
synchronized (LazyDoubleCheckSingleton.class) {//同步信号
if (instance == null) {//第二次检查
instance = new LazyDoubleCheckSingleton();
}
}
}
return instance;
}
}
多线程模拟并发结果如图 12。
上述使用到了双重检查锁DCL(Double Check Lock双端检锁)。
package com.crud1024.gof.main.singelcase;
/**
* 静态内部类单例模式
* 优选
*/
public class StaticInterClassSingleton {
private static class LazySingletonHodler {
private static final StaticInterClassSingleton ourInstance = new StaticInterClassSingleton();
}
public static StaticInterClassSingleton getInstance() {
return LazySingletonHodler.ourInstance;
}
private StaticInterClassSingleton() {}
}
同样为防止反射破坏,可以将StaticInterClassSingleton改写为:
private StaticInterClassSingleton() {
//与之前的饿汉式当中的反射解决案例雷同
if(LazySingletonHodler.ourInstance!=null)throw new RuntimeException("禁用反例创建多实例");
}
静态内部类的懒汉式单例模式可以避免线程安全问题,思路是只要被static修饰的方法或者变量亦或者类(成员内部类)是通过类名调用的。静态成员不能对实例进行操作,只能访问被static所修饰的成员;而实例对象则恰恰是可以使用当前实例的成员和共享唯一的静态成员,所以不存在安全问题。
这个案例在Java中,静态变量属于类,而不是属于类的实例。因此,当一个线程访问静态变量时,它与其他线程共享数据。
单例对象是一个静态变量,那么它的初始化只会在第一次访问时发生,而且只会在一个线程中初始化。因此,在多线程环境下,只有一个线程能够初始化这个单例对象,其他线程需要等待初始化完成后再使用该对象。
由于只有一个线程能够初始化这个单例对象,因此不会出现多个线程同时初始化同一个单例对象的情况,也就避免了线程安全问题。
这个单例类使用了一种叫做"静态内部类单例模式"的方法来实现单例。在这个实现方式中,单例类的构造函数是私有的,外部无法直接创建这个类的实例。同时,这个单例类有一个静态内部类LazySingletonHodler,它有一个静态成员变量ourInstance,这个变量在类加载的时候就会被初始化。
在多线程环境下,如果有多个线程同时调用getInstance方法,它们会共享同一个ourInstance变量。但是,由于ourInstance变量的初始化是在类加载的时候就已经完成了,因此在第一次调用getInstance方法时,ourInstance变量已经初始化完成了。
在这个过程中,如果有多个线程同时访问getInstance方法,它们会按照顺序一个一个地执行,不会出现线程安全问题。因此,这个代码不会导致线程阻塞。
- JAVA设计模式
- 站内搜索学习