JAVA单例模式小结

单例模式:最简单的设计模式之一。其作用是保证每个类只有一个实例。使用这一设计模式的目的主要是“统一”,即防止在多实例情况下不同对象内部属性不同,造成访问不同实例时其行为和结果不统一而产生的问题。除此之外,使用单例模式也可以起到节约内存资源的作用。交由Spring框架管理的bean默认模式下都是单例模式。
举一个维基百科上的例子:

许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。

维基百科也说明了单例模式的实现思路和注意事项:

实现单例模式的思路是:一个类能返回对象一个引用(永远是同一个)一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

单例模式在多线程的应用场合下必须小心使用。如果当唯一实例尚未创建时,有两个线程同时调用创建方法,那么它们同时没有检测到唯一实例的存在,从而同时各自创建了一个实例,这样就有两个实例被构造出来,从而违反了单例模式中实例唯一的原则。 解决这个问题的办法是为指示类是否已经实例化的变量提供一个互斥锁(虽然这样会降低效率)。

从上述内容可以总结出单例模式的写法思路:
1、获得实例的静态方法
2、私有的构造方法
3、应对多线程环境的互斥设计

Eager模式(饿汉式)

public class EagerSingleton {
    private static EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {
    }

    public static EagerSingleton getInstance() {
        return instance;
    }
}

这个类的实例在类被加载时才会创建,而ClassLoader的特性保证了这个类只会被加载一次,保证了多线程情况下的安全性。因此这是最简单的单例模式的写法,在大部分情况下也是合适的。

Lazy模式(懒汉式)

public class LazySingleton {
    private static volatile LazySingleton instance;

    private LazySingleton() {
    }

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

这种写法可以保证instance只在第一次调用getInstance方法时才会被实例化。不过相比于饿汉式,懒汉式写法要繁琐很多,当然其实要点也不难记,只需要记住:①getInstance方法需要使用double check+synchronized(即双重检查锁)的方式保证多线程环境下的互斥并且兼顾并发性能 ②instance一定要用volatile关键字修饰
第①点比较容易理解,第②点则是因为volatile关键字可以保证变量的可见性以及防止指令重排序,下面展开说一下。

instance = new LazySingleton();

虽然只是一行代码,但是在字节码层面实际分为3个步骤
1、分配空间——在堆内存中开辟一块区域用于放置LazySingleton实例
2、初始化——初始化LazySingleton实例
3、引用赋值——将LazySingleton实例的引用赋值给成员变量instance
通过idea上的jclasslib插件可以看到getInstance方法的字节码内容如下:

 0 aload_0
 1 getfield #2 <com/chaltang/singleton/LazySingleton.instance : Lcom/chaltang/singleton/LazySingleton;>
 4 ifnonnull 18 (+14)
 7 aload_0
 8 new #3 <com/chaltang/singleton/LazySingleton>
11 dup
12 invokespecial #4 <com/chaltang/singleton/LazySingleton.<init> : ()V>
15 putfield #2 <com/chaltang/singleton/LazySingleton.instance : Lcom/chaltang/singleton/LazySingleton;>
18 aload_0
19 getfield #2 <com/chaltang/singleton/LazySingleton.instance : Lcom/chaltang/singleton/LazySingleton;>
22 areturn

编号8、12、15的三个指令分别代表了1、2、3这三步。在CPU的实际执行中,以及JIT即时编译器的即时编译下,2、3两步由于没有先后关系的要求,就可能出现1、3、2的执行顺序。在单线程环境下,这样其实无所谓,因为这种执行顺序的后果无非是JVM先将一个未初始化的LazySingleton实例的引用赋值给了instance对象,然后再将LazySingleton实例初始化而已。但是在多线程环境下,就有可能出现这种情况:线程T1刚好按照1、3的顺序执行完了第3步,将一个未初始化的空白对象赋值给instance变量之后,CPU时间片到期,线程T2执行getInstance方法,发现instance!=null,于是将instance返回给调用者,调用者执行LazySingleton类的其它成员方法,但由于LazySingleton实例并未初始化,进而发生某些不可预知的错误。
使用volatile关键字修饰instance变量的目的便是使用其防止指令重排序的功能,只要保证1、2、3的执行顺序,就可以防止上述问题的发生。

静态内部类模式

这个模式同样利用了ClassLoader类加载器的特性保证线程安全,这种写法相对Eager模式的好处就是由于SingletonHolder仅仅作为instance的容器存在,理论上正常情况下不会因为getInstance()方法被调用之外的其它原因而被初始化,也就保证了instance只会在getInstance()方法被调用时才会被创建

public class InnerClassSingleton {
    private static class SingletonHolder {
        private static InnerClassSingleton instance = new InnerClassSingleton();
    }

    private InnerClassSingleton() {
    }

    public InnerClassSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

枚举类模式

我们知道枚举类中每个代表常量的实例都是单例的,那么我们定义一个枚举类,并且其中只定义一个常量,那么就获得了一个天然实现了单例模式的类。

public enum EnumSingleton {
    INSTANCE;
}

但是我个人非常不推荐用这种方法实现单例模式,因为这种方式违背了枚举类被创造的初衷。就跟JAVA语言的格式和命名规范一样,虽然语法中没有强制规定,但是大家都默认了类名用大驼峰,变量名用小驼峰模式。枚举类就应该被用来当做一种实现【枚举】功能的常量类来使用,而不应该被当做一种实现单例模式的旁门左道。

进阶-防止反射攻击

我们知道,JAVA里反射可以射一切,我们虽然使用私有化构造方法的方式防止单例模式类被多次实例化,但通过反射是可以绕过这一限制,实例化多个单例对象的。以前面提到的Eager模式为例:

public class ReflectionAttack {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Constructor<EagerSingleton> constructor = EagerSingleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        EagerSingleton reflectInstance = constructor.newInstance();
        EagerSingleton instance = EagerSingleton.getInstance();
        System.out.println(instance == reflectInstance);
    }
}

代码的执行结果是false:
在这里插入图片描述
如果通过反射方式实例化的是枚举类,则会报错:

public class ReflectionAttackEnum {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        EnumSingleton instance = constructor.newInstance("INSTANCE", 0);
        System.out.println(instance);
    }
}

在这里插入图片描述

进到报错处看一下源码,可以发现原来JDK的开发者早已考虑到这一情况,如果发现反射实例化的是枚举类,便会抛出异常:
在这里插入图片描述
枚举类有JDK的保护,我们自己写的类如果想要防止反射攻击,就只能靠自己了:

public class EagerSingleton {
    private static EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {
        if (instance != null) {
            throw new RuntimeException("Cannot reflectively create singleton objects");
        }
    }

    public static EagerSingleton getInstance() {
        return instance;
    }
}

通过在构造方法中加入instance是否为null的判断,便可以阻止攻击者通过反射调用私有构造方法实例化单例类的尝试。因为反射需要先加载类,而类加载之后就会先实例化instance,后续构造方法再被调用时,只可能是通过反射调用的。
当然,在我们一般的业务开发中是不需要这么写的。因为只要服务器的运行环境安全,我们并不需要这样处心积虑地防止反射攻击。而如果攻击者已经可以在服务器上执行恶意反射代码了,仅仅这一处的防反射设计也只是杯水车薪罢了。

进阶-支持序列化

有时,我们的对象需要进行网络传输,此时需要类支持序列化。对于一般类,支持Serializable接口并指定一个序列化ID即可。但是单例模式的类仅做到这一步还不够:

public class EagerSingleton implements Serializable {
    private static final long serialVersionUID = -6824975464427476492L;
    
    private static EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {
        if (instance != null) {
            throw new RuntimeException("Cannot reflectively create singleton objects");
        }
    }

    public static EagerSingleton getInstance() {
        return instance;
    }
}

public class SerializeTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        File file = new File("serialized");
        FileOutputStream fos = new FileOutputStream(file);
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        // 先将instance序列化为文件
        EagerSingleton instance = EagerSingleton.getInstance();
        oos.writeObject(instance);
        oos.close();
        // 再从文件反序列化为对象
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        EagerSingleton obj = (EagerSingleton) ois.readObject();
        ois.close();
        // 比较两个对象是不是同一个对象
        System.out.println(instance == obj);
    }
}

在这里插入图片描述
可以看到上述代码的执行结果是false,单例模式被破坏。怎么解决这一问题呢?JDK设计者已经考虑到了。我们看一下Serializable的接口文档:
在这里插入图片描述

Classes that need to designate a replacement when an instance of it is read from the stream should implement this special method with the exact signature.
ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;
简单翻译:那些需要替换从流中读取出来的实例的类,需要实现下面的方法
任意访问修饰符 Object readResolve() throws ObjectStreamException;

将EagerSingleton的代码改成下面的样子,再次运行SerializeTest,结果就是true了。

public class EagerSingleton implements Serializable {
    private static final long serialVersionUID = -6824975464427476492L;

    private static EagerSingleton instance = new EagerSingleton();

    private Object readResolve() throws ObjectStreamException {
        return getInstance();
    }

    private EagerSingleton() {
        if (instance != null) {
            throw new RuntimeException("Cannot reflectively create singleton objects");
        }
    }

    public static EagerSingleton getInstance() {
        return instance;
    }
}

在这里插入图片描述
ObjectInputStream#readObject()方法是怎么调用到我们写的readResolve()方法的,并不在本文的讨论范围内,读者可以自行debug看一下调用逻辑,或者百度一下网上的文章。
关键是需要记住,单例模式并且需要序列化、反序列化的类,需要实现readResolve()即可。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值