这是我参与更文挑战的第3天,活动详情查看: 更文挑战
一、初识单例模式(Singleton)
1.1 概述
单例模式,是很多人学的第一个设计模式。引用百度百科的定义如下:
单例模式,属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例(根据需要,也有可能一个线程中属于单例,如:仅线程上下文内使用同一个实例)
在我们程序运行的时候,通常会生成很多实例,也就是我们经常说的new一个对象出来,基于一个类可以生成很多个对象,但是,这些对象是同一个吗?显然并不是。
public class SingletonTest {
public static void main(String[] args) {
Apple apple = new Apple();
Apple apple1 = new Apple();
System.out.println(apple==apple1); //false
}
}
复制代码
那么当我们想在程序中表示某个东西只会存在一个时,就会有“只能创建一个实例的需求”,如果想要达到下面两个目的,那就需要引入今天所讲的单例模式:
- 想要确保任何情况下都绝对只有一个实例。
- 想要在程序上表现出“只存在一个实例”
1.2 应用场景
上面说了单例模式的使用目的,那么在什么情况下会使用到单例模式呢?毕竟平常些代码中好些也没有用到。下面介绍下几个单例模式的应用场景:
- 1、Windows的任务管理器就是个典型的单例模式,因为你不可能同时打开两个。
- 2、老版本的网站中的计数器,一般也采用单例模式实现,便于同步,但现在好像都放入缓存了。
- 3、web应用的配置对象的读取,如果这个配置文件是共享的资源,一般也是单例模式。
- 4、数据库连接池的设计中,我们往往不会直接使用原生的JDBC操作来实现与数据库的连接,因为数据库的连接是一个很宝贵的资源且耗时,我们往往会在内存中引入一个资源池来统一管理数据库的连接,这种模式也被总结未:资源池模式和单例模式。主要是节省打开和关闭数据库连接所引起的效率损耗。
- 5、类似的,多线程的线程池的设计一般也是采用单例模式,便于管理线程。
- 6、操作系统的文件系统,也是大的单例模式实现的例子,一个操作系统只能有一个文件系统。
综上发现,单例模式的应用场景一般发生在以下条件:
(1)资源共享的情况下,避免由于资源操作时导致的性能或损耗等,如应用配置文件。
(2)控制资源的情况下,方便资源之间的互相通信,如线程池等。
二、单例模式的实现方式
单例模式分为两类:
- 饿汉式:类加载的时候就会创建实例对象
- 懒汉式:类加载不会创建实例对象,而是首次使用该对象时才会创建。
单例模式思路实现分三步走:
- 1、将构造函数私有,这样外界就不能直接通过new SingleTon来创建实例了
- 2、创建一个包含自己的实例,即私有的静态变量成员:instance
- 3、提供一个公共的静态方法给其他人获取实例
2.1 饿汉式–方式1 (静态变量方式)
饿汉模式通过静态修饰符修饰,一看就很饥饿,随着类的加载而加载,可以得到一个单一实例,线程是安全的。但是可能会浪费空间,加载了很多没有用到的对象,因此想出了懒汉式单例。
public class SingleTon {
private static SingleTon instance=new SingleTon();
private SingleTon(){}
public static SingleTon getInstance(){
return instance;
}
}
---- 测试代码-----
public class TestSingleTon {
public static void main(String[] args) {
SingleTon instance = SingleTon.getInstance();
SingleTon instance1 = SingleTon.getInstance();
System.out.println(instance==instance1); //true
}
}
复制代码
2.2 饿汉式–方式2 (静态代码块)
这种方式和上面没有基本没有什么区别,都会浪费内存。
public class SingleTon {
private static SingleTon instance;
private SingleTon(){}
static {
instance=new SingleTon();
}
public static SingleTon getInstance(){
return instance;
}
}
复制代码
2.3 懒汉式–方式1(线程不安全)
该模式存在缺点:多线程不能使用。
public class SingleTon {
private static SingleTon instance;
private SingleTon(){
}
public static SingleTon getInstance(){
if(instance==null){
instance=new SingleTon();
}
return instance;
}
}
复制代码
接下来拿多线程测试:
public class TestSingleTon {
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
new Thread() {
@Override
public void run() {
SingleTon instance = SingleTon.getInstance();
SingleTon singleTon2=SingleTon.getInstance();
System.out.println(instance==singleTon2);
}
}.start();
}
for (int i = 0; i < 50; i++) {
new Thread() {
@Override
public void run() {
SingleTon instance = SingleTon.getInstance();
SingleTon singleTon2=SingleTon.getInstance();
System.out.println(instance==singleTon2);
}
}.start();
}
}
}
复制代码
在打印的结果中,确实证明了该方式线程不安全。
那么为什么会造成线程不安全呢?
这是因为CPU在分配时间片的时候是随机的,也就是两个线程执行时间是随机切换的,同一时刻只有一个线程在执行,当线程A在执行if(instance==null)的时候,假设这个时间片交给线程B,那么线程B执行完new Instance()的时候,又切换回线程A,线程A继续执行下面的new Instance(),造成构造方法被执行了两次,所以,线程不安全!
问题解决,那么解决这个线程不安全的办法就是 : 加锁!
2.4 懒汉式–方式2(线程安全)
public class SingleTon {
private static SingleTon instance;
private SingleTon(){
}
public static synchronized SingleTon getInstance(){
if(instance==null){
instance=new SingleTon();
}
return instance;
}
}
复制代码
很显然,加锁之后,达到了我们的目的,既实现了懒加载特效,也解决了线程安全问题。但是加锁之后,会导致该方法的执行效率特别低,其实就是初始化的时候才会出现线程安全,一旦初始化完成就不存在了。因此,需要把锁的粒度降低。不在方法上加锁,在关键问题上上锁。
2.5 懒汉式–方式3(双重检查锁)
public class SingleTon {
private static SingleTon instance;
private SingleTon(){
}
public static synchronized SingleTon getInstance(){
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
if(instance==null){
synchronized (SingleTon.class){
//2、抢到锁后再次判断是否为null
if(instance==null){
instance=new SingleTon();
}
}
}
return instance;
}
}
复制代码
双重检查锁模式是一种非常好的单例模式,解决了单例、性能、线程安全等问题,看起来是完美无缺的,其实是存在问题的,在多线程下,可能会出现空指针问题,出现的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。
关于原子操作和指令重排简述如下:
1、原子操作: 就是不可分割的操作
在计算机中,不会因为线程调度被打断的操作,比如赋值:m=10,就是原子操作,而对于int m=10;这个语句就不是原子操作,拆分为声明变量和赋值。中间有个中间状态。
2、指令重排:计算机为了提高执行效率,会做一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。例如:
int a=1;
int b=2;
int a=b+1;
复制代码
指令重排可以在不影响结果的前提下打乱这几个执行顺序。那么对应到我们的单例模式:new Singleton(),它在new一个对象的时候也是分为了好几个步骤:
- 1、看class对象是否加载,如果没有就加载它。
- 2、分配内存空间,初始化实例。
- 3、调用构造方法
- 4、返回地址给引用。
而cpu为了优化程序,可能会进行指令重排,导致实例内存还没分配的时候,就被使用了,这就是前面说的空指针问题。
因此,想要解决空指针问题,只需要使用Volatile关键字,它可以保证可见性和有序性。在多线程下不会有性能问题。
public class SingleTon {
private static volatile SingleTon instance;
private SingleTon(){
}
public static synchronized SingleTon getInstance(){
//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
if(instance==null){
synchronized (SingleTon.class){
//2、抢到锁后再次判断是否为null
if(instance==null){
instance=new SingleTon();
}
}
}
return instance;
}
}
复制代码
2.6 懒汉式–方式4(静态内部类)
静态内部类单例模式由内部类创建,由于JVM在加载外部类的时候,是不会加载静态内部类的,只有内部类的属性/方法被调用的时候才会加载,并初始化其静态属性。静态属性由于被static修饰,只能实例化一次,保证了实例化顺序。
public class SingleTon {
private SingleTon(){
}
private static class InnerClass{
private static final SingleTon instance=new SingleTon();
}
//对外提供静态方法
public static SingleTon getInstance(){
return InnerClass.instance;
}
}
复制代码
静态内部类实现单例是一种优秀的单例模式,在没有加任何锁的情况下,保证了多线程的安全,并且没有任何的性能影响和空间的浪费。
2.7 枚举方式(饿汉式)
枚举类实现单例是极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分利用了枚举这个特性来实现单例模式,枚举类型是所有单例中唯一一种不会被破坏的实现模式。
/**
* 枚举方式
*/
public enum Singleton {
INSTANCE;
}
复制代码
三、攻击单例模式及化解攻击
3.1 攻击单例模式
由前面的学习,我们知道单例模式的能保证只有一个实例,因此,要攻击单例模式的话,我们就用上面的单例类来创建出多个对象出来,可以采用的方法有两种:
- 序列化和反序列化
- 反射
a、序列化和反序列化攻击
Singleton类:
public class Singleton implements Serializable {
//私有构造方法
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
//对外提供静态方法获取该对象
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
复制代码
Test类:
public class TestSingleTon {
public static void main(String[] args) throws Exception {
//写入文件
// writeObjectFile();
SingleTon singleTon = readObjectFile();
SingleTon singleTon1 = readObjectFile();
System.out.println(singleTon1==singleTon); //false
}
public static SingleTon readObjectFile() throws Exception{
ObjectInputStream os = new ObjectInputStream(new FileInputStream("D:\\cs\\a.txt"));
SingleTon singleTon= (SingleTon) os.readObject();
return singleTon;
}
/**
* 创建实例写入文件
* @throws Exception
*/
public static void writeObjectFile() throws Exception{
//获取Singleton对象
SingleTon instance = SingleTon.getInstance();
//创建对象输出流
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("D:\\cs\\a.txt"));
//将这个实例写入文件
objectOutputStream.writeObject(instance);
}
}
复制代码
经过测试:上面运行的结果为false,表明了序列化和饭序列化的操作已经破坏了单例模式。
3.2 反射攻击
Singleton类:
public class Singleton implements Serializable {
//私有构造方法
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
//对外提供静态方法获取该对象
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
复制代码
Test类:
public class SingleTonTest {
public static void main(String[] args) throws Exception {
//获取Singleton 类的字节码对象
Class<SingleTon> singleTonClass = SingleTon.class;
//获取Singleton 类的私有无参构造
Constructor<SingleTon> constructor = singleTonClass.getDeclaredConstructor();
//取消访问检查,就是能访问私有的构造方法
constructor.setAccessible(true);
//创建实例1和2
SingleTon singleTon = constructor.newInstance();
SingleTon singleTon1 = constructor.newInstance();
System.out.println(singleTon1==singleTon); //false
}
}
复制代码
上面的运行结果是false,表明序列化和反序列化已经破坏了单例设计模式。
3.2 化解攻击
a、化解序列化和反序列化攻击
在Singleton类中添加readResolve()方法,如果定义了这个方法,就返回这个方法的值,如果没有定义,则返回新new出来的对象。
Singleton类
public class SingleTon implements Serializable {
private SingleTon(){
}
private static class InnerClass{
private static final SingleTon instance=new SingleTon();
}
//对外提供静态方法
public static SingleTon getInstance(){
return InnerClass.instance;
}
/**
* 下面是为了解决序列化反序列化破解单例模式
*/
private Object readResolve() {
return InnerClass.instance;
}
}
复制代码
也就是序列化的时候我们用到了readObject方法,查看它的源码可以发现这个问题。
public final Object readObject() throws IOException, ClassNotFoundException{
...
// if nested read, passHandle contains handle of enclosing object
int outerHandle = passHandle;
try {
Object obj = readObject0(false);//重点查看readObject0方法
.....
}
private Object readObject0(boolean unshared) throws IOException {
...
try {
switch (tc) {
...
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));//重点查看readOrdinaryObject方法
...
}
} finally {
depth--;
bin.setBlockDataMode(oldMode);
}
}
private Object readOrdinaryObject(boolean unshared) throws IOException {
...
//isInstantiable 返回true,执行 desc.newInstance(),通过反射创建新的单例类,
obj = desc.isInstantiable() ? desc.newInstance() : null;
...
// 在Singleton类中添加 readResolve 方法后 desc.hasReadResolveMethod() 方法执行结果为true
if (obj != null && handles.lookupException(passHandle) == null && desc.hasReadResolveMethod()) {
// 通过反射调用 Singleton 类中的 readResolve 方法,将返回值赋值给rep变量
// 这样多次调用ObjectInputStream类中的readObject方法,继而就会调用我们定义的readResolve方法,所以返回的是同一个对象。
Object rep = desc.invokeReadResolve(obj);
...
}
return obj;
}
复制代码
b、化解发射攻击
public class SingleTon implements Serializable {
private SingleTon(){
if(getInstance()!=null){
throw new RuntimeException("不能重复构造对象");
}
}
private static class InnerClass{
private static final SingleTon instance=new SingleTon();
}
//对外提供静态方法
public static SingleTon getInstance(){
return InnerClass.instance;
}
}
复制代码
直接抛出异常来处理反射。第二个对象就无法构造
顺便一提,这个Runtime类是由单例模式实现的,典型的饿汉模式。
public class Runtime {
private static Runtime currentRuntime = new Runtime();
/**
* Returns the runtime object associated with the current Java application.
* Most of the methods of class <code>Runtime</code> are instance
* methods and must be invoked with respect to the current runtime object.
*
* @return the <code>Runtime</code> object associated with the current
* Java application.
*/
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
...
}
复制代码
四、枚举的强大之处
在《Effect java》这本书中,作者推荐使用枚举来实现单例模式,因为枚举不能被反射!!!!
这是在底层规定的,因此直接把这种攻击方式排除了。
那么序列化和反序列化可以破坏枚举单例吗?
答案是也不能。
因为枚举式单例是使用了Map<String,T>,Map的key就是我们枚举类中的INSTANCE。由于map中key的唯一性,所以也造就了唯一实例,这也被称为“注册式单例模式”。Spring中的ioc就是这个典型的代表。
五、总结
在本文中,我们学习了第一种设计模式,了解到单例模式的定义和常规实现方法,包括线程安全和不安全写法。单例模式的破坏和反破坏手段,这篇文章也花了我三四个小时的整理。以后在谈到单例模式的时候,这篇文章应该能解决大部分问题了。后续将继续我的浅谈设计模式之旅,如果对你有帮助,请帮忙点个赞,谢谢各位~