单例模式

单例模式

单例模式

​ 单例对象(Singleton)是一种常见的设计模式。在java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。就是在当前进程中,通过单例模式创建的类有且只有一个实例

特点

  • 在java应用中单例模式能保证在一个JVM中,该对象只有一个实例存在。
  • 构造器必须是私有的,外部类无法通过调用构造器方法创建该实例。
  • 没有公开的set方法,外部类无法调用set方法创建该实例。
  • 模拟一个公开的get方法获取唯一的这个实例。

单例模式的好处

  • 某些类创建比较繁琐,对于一些大型的对象,这是一笔很大的系统开销。
  • 省去了new操作符,降低了系统内存的使用频率,减轻GC压力。
  • 系统中某些类,如Spring里的controller,控制着处理流程,如果该类可以创建多个的话,系统就完全乱了。

单例模式的写法如下

​ 懒汉式:延迟加载方式

​ 饿汉式:立即加载

单例模式如果使用不当,就容易引起线程安全问题。

  • 饿汉式不存在线程安全问题,但是它一般不被使用,因为它会浪费内存的空间;
  • 懒汉式会合理使用空间,只有第一次被加载的时候,才会真正去创建对象,但是这种方式存在线程安全问题。

懒汉式单例模式的实现方式有三种:

  • 双重检查锁方式(DCL)
  • 静态内部类方式
  • 枚举方式

使用场景

  • 要求生成唯一序列号的环境;
  • 在整个项目中需要一个共享访问点或共享数据,例如一个 Web 页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
  • 创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源;
  • 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为 static 的方式)。

饿汉式

/**
 * 饿汉式
 *     在类创建时,直接把该类对象new出来,就是说一旦类创建,类的静态成员就会在类加载过程中被初始
 * 化,初始化的时候它就会加锁,所以这个流程是线程安全的。
 * 实现方法:
 *     1.构造私有
 *     2.使用私有静态成员变量初始化本身对象
 *     3.对外提供静态公共方法获取本身对象
*/
public class HungryHanstyle {
     //成员变量初始化本身对象
     private static HungryHanstyle hungryHanstyle = new HungryHanstyle();

     //构造方法私有化
     private HungryHanstyle(){}

     //对外提供公共方法获取对象
     public static HungryHanstyle getInstance(){
         return hungryHanstyle;
     }
}

懒汉式

/**
 * 懒汉式
 *     在类创建时,先不初始化本身对象,只有在调用getInstance()方法的时候才new对象。
 *
 * 实现方法:
 *     1.构造私有
 *     2.定义私有静态成员变量,先不初始化
 *     3.定义公开静态方法,获取本身对象
 *
 * 线程安全问题,判断依据:
 *     1.是否存在多线程    是
 *     2.是否有共享数据    是
 *     3.是否存在非原子性操作
 *
 */
public class Sluggard {
       //1.构造私有
       private Sluggard(){}

       //2.定义私有静态成员变量,先不初始化
       private static Sluggard sluggard = null;

       //3.定义公开静态方法,获取本身对象
       public static Sluggard getInstance(){
           //如果没有对象,则创建
           if(sluggard == null){
               sluggard = new Sluggard();
           }
           //如果有对象就返回已经有的对象
           return sluggard;
       }

}

​ 懒汉式虽然合理的运用了空间,但却是线程不安全的,比如AB两个线程,如果没加锁,AB两线程都会访问到 getInstance() 方法里面, 假如A线程和B线程都访问到“sluggard = new Sluggard();”这行代码的时候,并且它们都创建成功了sluggard对象,也就是说在堆中有A线程创建的sluggard对象,也有B线程创建的sluggard对象。画出图(假设对象的地址码是0x001):

在这里插入图片描述

​ sluggard 变量是没法引用多个对象的,只能引用其中一个,所以另一个就是垃圾对象,需要被回收,sluggard 只能引用最后一个对象。

在这里插入图片描述

​ 就是说,如果有多个线程都成功创建了sluggard对象,这些对象中只有一个被引用,而其他的对象就失去了引用,于是就成了垃圾对象,这些垃圾对象就会被GC回收。

​ 这种方式创建对象,造成了线程安全问题,以及浪费了堆内存。

以下有两种方式优化该代码,解决了线程安全问题:

方式一加锁

/**
 * 懒汉式(方法上加锁)
 *
 * synchronized 关键字锁住的是这个对象,这样的用法,在性能上会有所下降
 * 因为每次调用 getInstance(),都要对对象上锁
 * 事实上,只有在第一次创建对象的时候需要加锁,之后就不需要了
 *
 */
public class Sluggard1 {
    //1.构造私有
    private Sluggard1(){}

    //2.定义私有静态成员变量,先不初始化
    private static Sluggard1 sluggard = null;

    //3.定义公开静态方法,获取本身对象,给方法加锁(synchronized 关键字)
    public static synchronized Sluggard1 getInstance(){
        //如果没有对象,则创建
        if(sluggard == null){
            sluggard = new Sluggard1();
        }
        //如果有对象就返回已经有的对象
        return sluggard;
    }

}

​ 虽然加锁是线程安全的,但是这个加锁的方式力度大了,因为多线程环境下每个线程都要执行 getInstance(),都要等上一个线程把锁释放,导致阻塞,所以性能下降。

方式二双重检查锁

/**
 * 懒汉式(通过代码块的方式加锁)
 *
 */
public class Sluggard2 {
    //1.构造私有
    private Sluggard2(){}

    //2.定义私有静态成员变量,先不初始化
    private static Sluggard2 sluggard = null;

    //3.定义公开静态方法,获取本身对象
    public static Sluggard2 getInstance(){
        //如果没有对象,则创建
        if(sluggard == null){						//**位置1**
            //采用这种方式,对于对象的选择会有问题
            //JVM优化机制:先分配内存空间,再初始化
            synchronized (Sluggard2.class){
                if(sluggard == null){
                    sluggard = new Sluggard2();	//**位置2**
                    //1、new--->申请内存空间(此时已经由内存空间地址)
                    //2、完成属性的初始化(赋值)
                    //3、将对象内存地址交给变量 sluggard 来保存
                    //new--->开辟JVM中堆空间--->产生堆内存地址保存到栈内存的 sluggard 引用中--->创建对象
                }
            }
        }
        return sluggard;
    }

}

​ 这种方式,在多线程环境下执行 getInstance() 时先判断单例对象是否已经初始化,如果已经初始化,就直接返回单例对象,如果未初始化,就在同步代码块中先进行初始化,然后返回,效率很高。

​ 但是这种方式是一个错误的优化,问题出在位置2中的“sluggard = new Sluggard2();”这行代码。它可以分解为如下3行代码:

memory = Sluggard2();	//1.分配对象的内存空间
ctorInstance(memory);	//2.初始化对象
sluggard = memory;		//3.设置sInstance指向刚分配的内存地址

​ 上述伪代码中的2和3之间可能会发生重排序,重排序(也就是指令重排序)后的执行顺序如下:

memory = Sluggard2();	//1.分配对象的内存空间
sluggard = memory;		//2.设置sInstance指向刚分配的内存地址
ctorInstance(memory);	//3.初始化对象

​ 因为这种重排序并不影响Java规范中的规范:intra-thread sematics 允许那些在单线程内不会改变单线程程序执行结果的重排序。其实指令重排序在多CPU情况下,为了提高CPU的利用率。

​ 当发生指令重排后,多线程并发时可能会出现以下情况:

在这里插入图片描述

​ 也就是说,如果A线程先执行并且执行到了位置2(这里说的位置2是双重检查锁代码标注的注释位置)的“sluggard = new Sluggard2();”这行代码,new分配了内存空间,并设置 sluggard 指向分配的内存,但这时A线程的CPU时间片段发生停顿了,这时线程B开始执行了,执行到了位置1的代码,于是线程B判断sluggard是否为空,发现不为空,于是就访问 sluggard 引用的对象,可这个对象是没有初始化的,所以B线程访问到的 sluggard 是一个未初始化的对象,程序就报错了。

​ 如果上面图例不理解,可以用JVM内存图来表示,如下:

在这里插入图片描述

​ 就是说,如果A、B线程都判断了第一个if,A、B线程跑到了 synchronized 块这里,这时A线程先抢到锁,所以A进去了。A进去判断了if,发现对象为空,于是就执行了“sluggard = new Sluggard2();”这行代码,由于JVM内部的优化机制,JVM会先分配一个空白内存给 Sluggard2 实例,并将这个实例赋值给 sluggard 成员(注意此时JVM还没有开始初始化这个实例),此时,A线程突然有急事,没等实例初始化就跑了,A离开了 synchronized 块,释放了锁。接下来B线程就拿到了锁,进入了 synchronized 块,判断了if,发现 sluggard 不为null(这时对象还没有初始化),所以B也离开了,B打算使用这个 Sluggard2 实例,却发现它没有被初始化,于是就产生了错误。用时间线来表示,如下图:

在这里插入图片描述

以下三种是解决双重检查锁问题的优化代码:

使用volatile

/**
 * 懒汉式(双重检查锁和属性加volatile的方式)
 *
 */
public class Sluggard3 {
    //1.构造私有
    private Sluggard3(){}

    //2.定义私有静态成员变量,先不初始化
    //加上volatile(强刷工作内存,保证了有序性,禁止指令重排)
    private volatile static Sluggard3 sluggard = null;

    //3.定义公开静态方法,获取本身对象
    public static Sluggard3 getInstance(){
        //如果没有对象,则创建
        if(sluggard == null){
            synchronized (Sluggard3.class){
                if(sluggard == null){
                    sluggard = new Sluggard3();
                }
            }
        }
        return sluggard;
    }

}

​ 加上volatile后,在多线程环境中禁止指令重排序,并且保证其可见性。

静态内部类

import java.io.Serializable;

/**
 * 懒汉式(静态内部类的方式)
*
*/
public class StaticSingleton implements Serializable {
 private static final long serialVersionUID = 1L;

 //构造私有
 private StaticSingleton(){}

 /**
     * 此处使用一个内部类来维护单例,JVM在类加载的时候,是互斥的,所有可以由此保证线程安全问题
  */
 private static class SingletonFactory{
     private static StaticSingleton singleton = new StaticSingleton();
 }

 /**
     * 获取实例
     * @return
  */
 public static StaticSingleton getInstance(){
     return SingletonFactory.singleton;
 }

}

​ 静态内部类不会随着外部类的初始化而初始化,他是要单独去加载和初始化的,当第一次执行 getInstance() 方法时,SingletonFactory 类会被初始化。

静态对象 singleton 的初始化在 SingletonFactory 类初始化阶段进行,类初始化阶段即虚拟机执行类构造器< clinit >()方法的过程。

虚拟机会保证一个类的< clinit >()方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的< clinit >()方法,其它线程都会阻塞等待。

​ < clinit >() 方法是类构造器方法,对静态变量,静态代码块进行初始化,在类加载过程的初始化阶段虚拟机会执行 < clinit >() 方法。

以下待完善

===========================================

枚举类

/**
 * 枚举单例
 */
public enum EnumSingleton {
    INSTANCE;
    public void talk(){
        System.out.println("This is an EnumSingleton "+this.hashCode());
    }
}

​ Java枚举都隐式继承自Enum抽象类,而Enum抽象类根本没有无参构造方法,只有如下一个构造方法:

protected Enum(String name, int ordinal){

​ this.name = name;

​ this.ordinal = ordinal;

}

​ 所以枚举单例对反射防御。

如果是用:

Constructor constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);

其结果还是无法破坏。

来到 Constructor.newInstance() 方法中,有如下语句:

if((clazz.getModifiers() & Modifier.ENUM) != 0){

​ throw new IllegalArgumentException(“Cannot reflectively create enum objects”);

}

可见,JDK 反射机制内部完全禁止了用反射创建枚举实例的可能性。

​ 枚举对序列化的防御。

如果将 serializationAttack() 方法中的攻击目标换成 EnumSingleton,那么我们会发现s1和s2实际上是同一个实例,最终会打印出true。这是因为 ObjectInputStream 类中,对枚举类型有一个专门的 readEnum() 方法来处理,其简要流程如下:

  • 通过类描述获取枚举单例的类型 EnumSingleton;
  • 取得枚举单例中的枚举值的名字(这里是 INSTANCE);
  • 调用 Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。

​ 这种处理方法与 readResolve() 方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是 JDK 内部实现的。

​ 综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,而且JDK能够保证其安全性,不需要我们做额外的工作。

1和s2实际上是同一个实例,最终会打印出true。这是因为 ObjectInputStream 类中,对枚举类型有一个专门的 readEnum() 方法来处理,其简要流程如下:

  • 通过类描述获取枚举单例的类型 EnumSingleton;
  • 取得枚举单例中的枚举值的名字(这里是 INSTANCE);
  • 调用 Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。

​ 这种处理方法与 readResolve() 方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是 JDK 内部实现的。

​ 综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,而且JDK能够保证其安全性,不需要我们做额外的工作。

============

以上是本人的个人总结,还有瑕疵,待完善。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值