兄弟,你的单例模式可能不是单例!!!

面试官:请你写个单例模式

你:(太简单了吧,我给他来个“饿汉式”,再来个“懒汉式”)java

(2分钟后,你的代码新鲜出炉了)面试

饿汉式单例模式代码

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

懒汉式单例模式代码

public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (instance == null) { // 1
            instance = new LazySingleton(); // 2
        }
        return instance;
    }
}

(很棒~可是他们真的时单例吗)编程

代码分析

第一段代码

instance 是一个类变量,类变量再类初始化时建立,类初始化时至关于会加个锁,保证原子性。所以他确实能保证单例,除非时屡次加载这个类。安全

第二段代码

单线程环境下没有问题,确实是单例。多线程

多线程下则须要考虑下了.并发

假设线程A走到了2,同时线程B走到了1. 线程A走完了,实例化了LazySingleton,因为B在A尚未给instance赋值时走到了1,因此判断为instance==null, 因此他也会建立一个LazySingleton实例。高并发

所以此段代码存在线程安全问题,也就是不能保证LazySingleton是单例的。性能

解决方案

方案一:直接给获取实例的方法加锁

咱们能够经过将getInstance变为同步方法来保证同一时刻只能有一个线程进入到方法。优化

以下:线程

public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }

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

这种方式简单粗暴,可是当高并发去获取单例时,只有一个线程能竞争到锁,其余的线程都将阻塞,效率低下

方案二:双重锁定

public class DoubleCheckLocking {
    private static DoubleCheckLocking instance;

    public DoubleCheckLocking() {
    }

    public DoubleCheckLocking getInstance() {
        if (instance == null) {  // 1
            synchronized (DoubleCheckLocking.class) {
                if (instance == null) {
                    instance = new DoubleCheckLocking(); // 问题根源
                }
            }
        }
        return instance;
    }
}

这段代码很巧妙,前一种方法直接将getInstance方法变为同步方法会带来效率低下问题,那么咱们就只在建立对象的时候加锁,这样既能保证效率,也能保证单例。

然而,这种方式也有问题,方腾飞老师在《Java并发编程艺术》中无情地嘲讽了这种作法,原文以下:

所以,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)

问题的根源就在于new DoubleCheckLocking()这句话,建立一个对象大致分为三步, 伪码表示以下:

memory=allocate()    1分配对象内存
ctorInstance(memory) 2初始化对象
instance=memory      3引用指向建立的对象

其中2和3是可能重排序的,由于他们不存在数据依赖性。也就是3可能在2以前执行。

假设A线程获取单例按一、三、2走到了3,那么此时instance不为null, 此时B线程走到1处,直接将instance返回了,因此调用方拿到了一个未被初始化的对象。

因此,这个方法严格来说是不可取的。

方案二:改良双重锁定

方案很简单,直接在instance变量前加volatile关键字,以下

private static volatile DoubleCheckLocking instance;

加上volatile能够阻止上述二、3两条指令的重排序。

方案三:基于类初始化

public class MySingleInstance {
    private MySingleInstance() {
    }

    private static class InstanceHolder {
        public static MySingleInstance instance = new MySingleInstance();
    }

    public static MySingleInstance getInstance() {
        return InstanceHolder.instance;
    }
}

JVM在执行类的初始化期间会去获取一个锁, 这个锁能够同步多个线程对同一个类的初始化。

一些知识点

这里简单总结下以上解决方案中涉及的一些知识点, 只是知识点的简单罗列,后面会继续写一些文章来介绍。

线程

线程是轻量级进程,是系统调度的基本单元,线程之间能够共享内存变量,每一个线程都有本身独立的计数器、堆栈和局部变量。

syncronized

方案一中咱们经过syncronized将获取实例的方法同步化了。

三种形式

  1. 普通同步方法,锁为当前实例对象
  2. 静态同步方法,锁为当前类的Class对象
  3. 同步代码块,锁为()里边的那个对象

基本原理

在对象头存储锁信息,基于进入和退出Monitor对象来实现同步方法和代码块同步。

volatile

方案三中,咱们经过volatile解决了重排序和内存可见性问题。

volatile的特色:

  • 轻量级的synchronized,不会引发线程上下文切换
  • 保证共享变量可见性,即一个线程修改共享变量时,其余线程能读到修改后的值
  • 加了volatile后,写操做会当即从本地内存刷新到主内存,读操做会直接标记本地内存无效,从主内存中读取

这里的本地内存只是一个抽象概念,并不是真实存在

重排序

方案二中,咱们分析是重排序致使这个方案存在问题。

重排序是编译器或处理器为了优化程序性能对指令序列进行从新排列的过程。

分类:

  1. 编译器的指令重排序
  2. 处理器的指令重排序

处理器的指令重排序规则较为宽松,java编译器为了防止处理器对某些指令重排序会使用内存屏障。

例如上面的volatile, 编译器生成字节码时会经过加入内存屏障来阻止cpu对volatile变量读写操做的重排序。

内部类

在方案三中,咱们使用到了内部类。内部类就是类里边的类。

外部类没法访问内部类成员,只能经过内部类的实例访问。

内部类能够直接访问外部类的信息,静态内部类不能访问实例成员。

按照其所处的不一样位置能够分为:

  • 成员内部类
  • 静态内部类
  • 方法内部类
  • 匿名内部类

总结

本文介绍常见写单例的方式存在的问题及解决方案,并将解决方案中涉及的重要知识点作了简单罗列。

相关文章
相关标签/搜索