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

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

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

(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线程获取单例按1、3、2走到了3,那么此时instance不为null, 此时B线程走到1处,直接将instance返回了,所以调用方拿到了一个未被初始化的对象。

所以,这个方法严格来讲是不可取的。

方案二:改良双重锁定

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

private static volatile DoubleCheckLocking instance;

加上volatile可以阻止上述2、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变量读写操作的重排序。

内部类

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

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

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

按照其所处的不同位置可以分为:

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

总结

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

posted @ 2020-04-29 11:11  大~熊  阅读(2435)  评论(22编辑  收藏  举报