单例模式

0x00、 Singleton Pattern

需求 : 保证一个类只有一个实例,并且提供这个实例的全局访问点。

0x01、 饿汉方式

  1. 优点 : 线程安全,执行效率高,这一点存疑

  2. 缺点 : 在类加载的时候实例对象就被创建,不论这个实例在后续的程序中是否会被使用。

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

    private Singleton(){}

    public static Singleton getInstance(){
        return instace;
    }
}

0x02、 懒汉方式

  1. 优点 : 在真正时候到该类实例的时候才被实例化避免了不必要的资源占用。

  2. 缺点 : 未考虑线程安全。如果两个线程同时调用到 getInstance() 方法造成的结果是不满足单例模式的设计需求。

public class Singleton{
    private static Singleton instance = null;

    private Singleton(){}

    public static getInstance(){
        if(instance == null){
            instance = new Singleton();
        } 
        return instance;
    }
}

0x03、 懒汉 · 尝试解决线程安全问题

3.1、 方式一 · 失败的尝试

  1. 优点 : 使用时实例化

  2. 缺点 : 未能彻底解决线程安全问题。假设两个线程同时访问 getInstance() 方法并且同时执行到 标记① ,这时两个线程会依次初始化一个 Singleton 实例,从而导致不满足单例模式设计需求的情况出现。

public class Singleton{
    private static Singleton instance = null;

    private Singleton(){}

    public static getInstance(){
        if(instance == null){
            //标记①
            synchronized(Singleton.class){
                instance = new Singleton();
            }
        }
        return instance;
    }
}

3.2、 方式二

  1. 优点 : 使用时实例化,并且避免了线程安全问题。

  2. 缺点 : 锁加在方法上导致多线程的逐个访问方法的结果访问降低了多线程执行效率(如果并发访问效率更高)。

public class Singleton{
    private static Singleton instance = null;

    private Singleton(){}

    public static synchronized getInstance(){
        if(instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

0x04、 双重校验锁

  1. 优点 : 在兼顾了执行效率的同时有效避免了线程安全问题。

/*通过 volatile 和 synchronized 两个关键字解决了线程安全问题,具体是如何解决的在第 5 小结详述*/
public class Singleton{
    // volatile 关键字解决可见性和原子性问题
    public static volatile Singleton instonce = null;

    private Singleton(){}

    public static Singleton getInstance(){
        if(instance == null){
            //synchronized 解决同一时间操作线程的唯一性问题
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

0x05 提问

5.1. 加锁(synchronized)会导致执行效率降低?

5.2、 synchronized

什么时候应该使用锁 : 如果你正在写一个变量,它可能接下来被另一个线程读取,或者正在读取一个上一次已经被线程写过的变量,那么你必须使用同步,并且读写线程都必须使用相同的监视锁同步。

synchronized(Object){
    //some code
}

public void synchronized test(){
    //some code
}

如果某一个任务处于一个被 synchronized 关键字修饰的代码(方法或者代码块)中,如果线程①正在执行这个任务(我们称这部分代码被锁住了),此时要执行这个任务的其他线程将会处于阻塞状态,直到这个锁被释放。

关于 synchronized method(){}

  • 所有的对象都自动含有单一锁(称为对象锁),当在某个确定的对象上调用任意一个 synchronized 方法的时候该对象的其他 synchronized 方法也都会被锁住知道这个方法执行完成并返回。

  • 一个对象中的 synchronized 方法能够调用这个对象的其它的 synchronized 方法,这时候 JVM 负责追踪并对象被枷锁的次数进行增减,直到对象被枷锁次数为 0 的时候表示对象锁被释放。

关于 synchronized(Object){}

  •   public void test(Object obj){
          synchronized(obj){
              //业务代码
          }
      }
  • 如果两个线程拿着同样的对象 obj 访问 test() 方法,这两个线程必将有一个会阻塞直到另一个线程执行完成业务代码再继续执行业务代码

  • 如果两个线程拿着不同的对象 obj 访问 test() 方法,则不会发生阻塞。

5.3、 关于原子性与可视性 volatile

关于原子性

  • 有关 Java 线程的讨论中,一个不正确的知识是 “原子操作不需要进行同步控制”。

  • 一个危险的认知是 “原子操作是不能被线程调度中断的机制,一旦操作开始,它一定可以在可能发生的‘上下文切换’前执行完毕。”

  • 在排除可视性的前提下依赖原子性是没有问题的,但事实上可视性的问题不容忽略。所以依赖于原子性是很棘手且危险的。

  • 事实上原子性可以用于除 longdouble 之外的所有基本数据类型之上的‘简单操作’。 对除longdouble之外的基本数据类型的读写操作可以被保证是不可分的(原子)操作来操作内存。

    JVM 可以将 64位 数据类型(longdouble)的操作分解为两次对 32位 数据的操作,这就产生了在一个读取或写入操作的间隙发生上下文切换从而导致不同线程看到统一变量不同结果的现象。

  • 但是你定义的 longdoublevolatile 修饰的话你就能得到(简单的赋值与返回操作的)原子性(在 Java SE5 之前 volatile 一直不能正确的工作)。

  • 使用原子操作来替代同步对于非专家程序员来说是危险的。

关于可视性

  • 对于多处理器系统(多核即将多个CPU集成在单个芯片上的技术),可视性问题比原子性问题严重的多(相对于单处理器操作系统)。因为一个任务做出的修改即便在在不中断的意义上将是原子性的,也可能出现可视性的问题(因为操作的结果肯能只是暂时性的存放在 CPU 的缓存中没有存储到内存),因此不同任务对应用的视图是不同的。

  • 如果将一个域用 volatile 标识,只要对该域产生了写操作,所有的读操作都能看到这个修改。即便使用了本地缓存(CPU缓存)情况也是如此,volatile 的操作结果会立即写入主存中,读取操作就是读取的主存中的数据。

5.4、 小结

同步机制保证了两个因素: 1. 操作线程的唯一性。—— synchronized 负责 2. 对数据操作结果在应用中的原子性和可视性。—— volatile 负责

参考

  1. 抄录 : Java 编程思想

  2. 设计模式 - 可复用面向对象软件的基础

Last updated