美文网首页
单例模式

单例模式

作者: lj72808up | 来源:发表于2021-04-06 19:05 被阅读0次

    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();
          }
      }
      
      

    相关文章

      网友评论

          本文标题:单例模式

          本文链接:https://www.haomeiwen.com/subject/lfrekltx.html