单例模式

1. 什么是单例模式?

创建单例类的方法叫单例模式. 单例类, 就是只能产生一个对象的类.

2. 为什么使用单例模型

场景一: 一个写日志的类 (资源访问冲突)

  1. 首先, 假设如下方法 FileWriter 的 write 方法本身没有锁. 此假设下设计一个Log类. 在多线程下写日志会冲突, 导致日志覆盖问题.
    首先想到加锁, 尝试方法上加 synchronized, 发现不管用, 因为这个加在对象上的锁, 对不同对象, 没有锁控制. 于是想到在类上加锁. synchronized(Log.class)
public class Logger { 
    private FileWriter writer; 
    public Logger() { 
        File file = new File("/Users/wangzheng/log.txt"); 
        writer = new FileWriter(file, true); //true表示追加写入 
    } 
    public void log(String message) { 
        // synchronized(this) {        // 加锁加载对象上 (1)
        // synchronized (Log.class){   //  加锁加在类上
            writer.write(mesasge); 
        } 
    }
}
  1. 在类上加锁是一种很通用的方法, 除此之外, 解决资源竞争的方法还有

    • 将日志发送到一个 BlockingQueue, 用一个线程 EventLoop 负责将队列中的内容写到文件 (可参考 org.apache.spark.util.EventLoop)
  2. 如果用单例模式呢?
    上面的解决办法中, 虽然在类上加了锁, 但因为能创建多个 Log 对象, 导致空间浪费. 如果只能产生一个对象, 就可以节省内存. 当然即使只创建一个对象, 仍要保证线程安全问题, 单例模式和线程安全无关, 因为同一个对象可以被多个线程使用

3. 单例模式的实现方式

实现单例模式, 有几个问题需要考虑在内:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑 getInstance() 性能是否高(是否加锁)。

1. 饿汉式

  • 饿汉式的单例, 在类加载时, instance 静态实例就已经创建并初始化好了.
    • 实例初始化是和类加载绑定的
    • 用类的静态属性的方式保证只有一个实例
    // 一个单例的 ID 递增生成器
    public class IdGenerator {
        private static final IdGenerator instance = new IdGenerator();
        private AtomicLong id = new AtomicLong(0);
    
        private IdGenerator() {}
    
        public static IdGenerator getInstance() {
            return instance;
        }
    
        public long getId() {
            return id.incrementAndGet();
        }
    }
    
    
  • 争议点: 不能延迟加载, 对象随类初始化
    因为饿汉式的单利对象是在类加载时初始化的, 不能懒加载, 导致提前初始化. 所以其报表不已, 有人认为提前初始化是一种资源浪费, 应该真正使用时再去初始化; 而另一些人认为, 提前初始化满足 fail-fast 的设计原则(有问题及早暴露), 而且如果资源不够,就会在程序启动的时候触发报错

2. 懒汉式

  • 懒汉式相当于延迟加载版的饿汉式, 单例实例也是静态属性, 但实例是在 getInstance() 获取时创建, 也因此需要一把类级别的锁防止对象重复初始化.
    public class IdGenerator {
        private static IdGenerator instance;
        private AtomicLong id = new AtomicLong(0);
    
        private IdGenerator() {}
    
        public static synchronized IdGenerator getInstance() {   // 一把类级别的大锁
            if (instance == null) {
                instance = new IdGenerator();
            }
    
            return instance;
        }
    
        public long getId() {
            return id.incrementAndGet();
        }
    }
    
  • 缺点: 无法面对高并发场景
    懒汉式的缺点十分明显: 由于给 getInstance() 方法加了一把类级别的大锁(synchronzed), 导致函数的并发度为1, 相当于串行操作. 如果这个单例类偶尔被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,

3. 双重检测

饿汉式不支持延迟加载, 懒汉式不支持高并发. 因此出现第三种方式, 双重检测: 既能延迟加载, 又支持高并发.

  • 基于懒汉式的改造点

    • 如果实例已存在, 就不要先获得锁才能获取对象
      因此, 加锁操作的锁竞争放在判断 instance 为空后进行, 还是类级别的大锁 (因为确保静态方法的锁)
    public class IdGenerator { 
        private AtomicLong id = new AtomicLong(0); 
        private volatile static IdGenerator instance; 
        private IdGenerator() {} 
        public static IdGenerator getInstance() { 
            if (instance == null) { 
                synchronized(IdGenerator.class) { 
                    // 此处为类级别的锁 
                    if (instance == null) { 
                        instance = new IdGenerator(); 
                    } 
                } 
            } 
            return instance; 
        } 
        public long getId() { 
            return id.incrementAndGet(); 
        }
    }
    
  • 为什么是双重检测? 只检测一遍 instance == null 不行吗
    因为为了支持 getInstance() 的高并发, 锁没有加载方法上, 而是加在 if (instance == null) 这个条件的判断后. 即判断条件本身没有加锁, 所以在进入 synchronized 代码块后, 判断条件可能已经不成立, 需要再次判断. 第二次判断因为加了锁, 所以是安全的

  • 为什么 instance 实例加 volatile?
    在低版本的 jvm 中, 对象初始化instance = new IdGenerator() 这句其实是2个动做, 分为 new IdGenerator() 创建动作 和 instance= 赋值操作. CPU 的指令重排, 导致赋值语句和不依赖此变量的计算语句重排.(参考volatile), 即在释放锁指令可能先于赋值语句执行. 即同步块退出后, 可能其它线程看到的 instance 仍然是 null, 导致对象重复创建.
    高版本的 jvm 已不存在此问题, 解决办法很简单, 让对象的new和赋值成为原子操作即可.

4. 静态内部类

静态内部类的方式, 是饿汉式的改造, 将饿汉式单例类作为一个整体放在普通类内部, getInstance() 方法返回内部静态类的静态属性

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);

    private IdGenerator() {
    }

    public static IdGenerator getInstance() {
        return SingletonHolder.instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }

    private static class SingletonHolder {
        private static final IdGenerator instance = new IdGenerator();
    }
}

当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 的类加载来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

5. 枚举

  1. 上面4种方法的潜在问题?
    上面4中方法的问题在于, 我们是如何满足不让用户自己创建对象这一前提的? 是通过私有化构造函数, 避免用户访问构造函数. 可是即使不访问构造方法, 还有两种创建对象的方式:

    • 反序列化创建对象化:
      只要把单例对象序列化成字节流, 然后读取成新的对象, 就会创造出第二个对象. 因为反序列化是靠字节流和类模板实现, 不用通过构造函数
    • 反射:
      反射会通过 api 调用私有方法
      Constructor constructor = singleton.getClass().getDeclaredConstructor(new Class[0]);
      constructor.setAccessible(true);
      
  2. 用枚举实现单例, 解决上述所有问题

    1. jvm 如何实现枚举对象

      • 所有枚举编译后都是 Enum 的子类 .
      • Enum 类不支持序列化和反序列化. 对应方法直接抛异常
      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");
      }
      
      • enum 可以反射获取 value, 但不能反射调用构造函数
      • enum 的第一行, 是所有可能的, 不可变的枚举对象列表
      public enum Season {
          // enum 有一组不可变的常量集合 (常量不可变, 集合不可变)
          WINTER(5), SPRING(10), SUMMER(15), FALL(20);
      
          private int value;
      
          // compiler 限制 enum 的构造函数必须是 private
          private Season(int value) {
              this.value = value;
          }
      }
      

      枚举 Season 编译后生成的枚举类:

      final class Season extends Enum {
          public static Season[] values() {
              return (Season[]) $VALUES.clone();
          }
      
          public static Season valueOf(String s) {
              return (Season) Enum.valueOf(Season, s);
          }
      
          private Season(String s, int i, int j) {
              super(s, i);
              value = j;
          }
      
          public static final Season WINTER;
          public static final Season SUMMER;
          private int value;
          private static final Season $VALUES[];
      
          static {
              WINTER = new Season("WINTER", 0, 10);
              SUMMER = new Season("SUMMER", 1, 20);
              $VALUES = (new Season[]{
                      WINTER, SUMMER
              });
          }
      } 
      

      可见, 枚举第一行列出的所有可能的值(Enum类的name属性), 在编译后会变成静态属性, 初始化放到了静态代码块中, 与饿汉模式写法相同, 且其构造函数不能通过反射调用, 又不能序列化反序列化, 因此是实现单例的最佳模式.

    2. 基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:

    public enum IdGenerator {
        INSTANCE;
    
        private AtomicLong id = new AtomicLong(0);
    
        public long getId() {
            return id.incrementAndGet();
        }
    }
    
    

你可能感兴趣的:(单例模式)