细说Java中的几种单例模式

在Java中,单例模式分为很多种,本人所了解的单例模式有以下几种,如有不全还请大家留言指点:

  • 饿汉式
  • 懒汉式/Double check(双重检索)
  • 静态内部类
  • 枚举单例

一、饿汉式

image

饿汉式是在jvm加载这个单例类的时候,就会初始化这个类中的实例,在使用单例中的实例时直接拿来使用就好,因为加载这个类的时候就已经完成初始化,并且由于是已经加载好的单例实例因此是线程安全的,并发获取的情况下不会有问题,是一种可投入使用的可靠单例。

优点:使用起来效率高、线程安全

缺点:由于jvm在加载单例类的时候需要初始化单例实例,因此在加载单例的时候针对jvm内存不够友好。

二、懒汉式

image

最简单的懒汉式,核心思想就是弥补饿汉式的缺点,在jvm加载单例类的时候不去初始化实例,而是在第一次获取实例的时候再去初始化实例。但是这样理论完美的单例在使用的时候有一个致命的缺点,在多线程使用的情况下,有时会出现不同线程从单例实例中获取不同的实体。针对多线程环境中并不可靠。

优点:针对jvm内存比较友好,实现了实例的懒加载。

缺点:多线程环境下不安全,会出现不同线程从单例实例中获取不同的实体的情况。

具体为什么会出现不同线程从单例实例中获取不同的实体的情况呢?如下图,我们通过分析去解释,为何他是线程不安全的。

image

假设,当前有两个线程同时首次获取此单例中的实例时:

  1. 线程一执行getInstence方法,并判断instance实例是否已经被初始化。
  2. 线程一判断instance为null,执行到 2 处,此时线程一还没有开始执行,然后执行权被线程二获取,线程一进入等待。
  3. 线程二执行到 1 处判断instance为null,因为线程一即将开始初始化instance,但是还没有初始化。
  4. 线程二执行到 2 处开始初始化instence方法,并完成初始化,返回一个instance实例。
  5. 这时线程一被唤醒,继续从 2 处执行,开始初始化instence方法,并且也返回一个instance实例。

这样,线程一和线程二从单例中获取了两个不同的实例。针对懒汉式的这种线程不安全的现象,攻城狮们也是开始头脑风暴来改善它,比较容易想到的是将getInstence方法加锁,来实现懒汉式的线程安全:

image

这样虽然看似解决问题了,但是未免太过于激进了,synchronized锁住获取实例的整个方法,因此在并发获取单例实例的时候会有性能问题,并且线程安全问题的出现只是在第一次获取实例的情况才会出现,初始化之后不会再出现性能问题,synchronized锁的运用未免因小失大。

于是为了线程安全,还为了能在并发情况下高效的性能,便有了Double check(双重检索)的懒汉式单例

image

Double check的理论为:当第一次创建单例实例的时候,只有一个线程可以去创建实例,因此不会出现多个线程获取不同实例的情况。

假设时间序列:

  1. 线程一进入getInstence方法
  2. 线程一判断instence为null,并在 1 处进入synchronize块,此时线程二开始执行,线程一等待
  3. 线程二进入getInstence方法,判断instence为null,并准备进入synchronize块,此时发现synchronize块的锁被占用,因此进入等待
  4. 线程一开始再次判断instence为null,然后开始初始化instence实例,然后释放synchronize的锁,获取到了实例执行完成
  5. 此时线程二开始得到synchronize锁,进入synchronize块再次判断instence是否为null,发现instence此时已经有值,释放锁,直接获取instence实例返回

Double check的理论看起来非常的完美,然而一切到头来发现仅仅是想得美而已,在实际运行中他还是有问题的。

年轻稚嫩的猿也许会一脸懵逼,老谋深算的猿也许会微微一笑,但是可能他们都会想 弄啥子嘞?

其实,这个理论的失败,并不是jvm实现的bug,而是归咎于Java平台的内存模型,Java的内存模型是围绕着如何在并发过程中处理原子性、可见性、有序性这3个特征建立的,而针对有序性,引用深入JVM虚拟机中的一句话解释是:如果在本线程中观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行指令”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。而针对原子性,看似简单一行代码,经过虚拟机编译成字节码信息后,可能就不是一行代码了。而针对可见性,一个线程改变的变量值,并不会立刻对其他线程可见。

而上面Double check代码失败的源头就是 instence = new DoubleCheck(); 这句话,而这句看似简的一句话,其实在虚拟机中分成了三个步骤:

  1. 为即将实例化的对象分配内存空间
  2. 初始化单例实体对象执行构造函数
  3. 将内存空间地址赋值给instence实例引用

也就是说其实我们所谓的new对象 并不是一个原子操作,并且,针对上面的2 3 步骤虚拟机会进行指令重排序,如果上面的Double check代码的对象实例化的经过重排序顺序变成1 3 2 的话,就会出现问题:

  1. 线程一进入getInstence方法
  2. 线程一判断instence为null,并在 1 处进入synchronize块
  3. 线程一再次判断instence为null,最后执行到 3 处,然而分配完内存,获取到实例地址,此时instence不再为null,但是还未初始化对象执行构造方法,此时县城而获取执行权,线程一被挂起
  4. 线程二获取getInstence方法,并判断instence不再null,然后获取到了一个instence对象的地址,但是此时instence对象并未完成初始化,线程二后续执行就会出现问题
  5. 线程一此时苏醒,完成后面的instence对象初始化的动作,并返回实例

然而在jdk1.5以后,这种情况有了解决方法,原因在于jdk1.5开始针对volatile进行了增强,volatile变量开始可以屏蔽指令重排,也就是说

当我们将instence引用进行volatile进行修饰的话instence = new DoubleCheck();这句话中的指令将不会被指令重排序,Double check也就不再只是想想了。附上完整代码:

image

三、静态内部类

image

静态内部类的优点是:外部类加载时并不会立即加载内部类,内部类不被加载就不去初始化实例,因此实现了懒加载。当StaticSingle第一次被加载时,并不需要去加载内部类Holder,只有当getInstance()方法第一次被调用时,才会导致虚拟机加载Holer类菜会去初始化StaticSingle实例。这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

那么静态内部类是如何实现线程安全的呢?我们需要了解下面一些只是

针对于类的初始化,JVM虚拟机严格规定了有且仅有5种情况必须对类进行“初始化“:

  1. 遇到new、getstatic、setstatic或者invikestatic这4个字节码指令时,对应的java代码场景为:new一个关键字或者一个实例化对象时、读取或设置一个静态字段时(final修饰、已在编译期把结果放入常量池的除外)、调用一个类的静态方法时。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没进行初始化,需要先调用其初始化方法进行初始化。
  3. 当初始化一个类时,如果其父类还未进行初始化,会先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
  5. 当使用JDK 1.7等动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这5种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是"有且仅有",那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的行列。

我们再回头看下getInstance()方法,调用的是Holer.INSTANCE,取的是Holer里的INSTANCE对象,跟上面那个DCL方法不同的是,getInstance()方法并没有多次去new对象,故不管多少个线程去调用getInstance()方法,取的都是同一个INSTANCE对象,而不用去重新创建。当getInstance()方法被调用时,Holer才在StaticSingle的运行时常量池里,把符号引用替换为直接引用,这时静态对象INSTANCE也真正被创建,然后再被getInstance()方法返回出去,这点同饿汉模式。那么INSTANCE在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:

虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行()方法后,其他线程唤醒之后不会再次进入()方法。同一个加载器下,一个类型只会初始化一次。),在实际应用中,这种阻塞往往是很隐蔽的。

故而,可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

那么,是不是可以说静态内部类单例就是最完美的单例模式了呢?其实不然,静态内部类也有着一个致命的缺点,就是传参的问题,由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如Context这种参数,所以,我们创建单例时,可以在静态内部类与DCL模式里自己斟酌。

四、枚举单例

从上述3种单例模式的写法中,似乎也解决了效率或者懒加载以及线程安全的问题,但是它们都有两个共同的缺点:

  • 序列化可能会破坏单例模式,比较每次反序列化一个序列化的对象实例时都会创建一个新的实例,解决方案如下:
image
  • 使用反射强行调用私有构造器,解决方式可以修改构造器,让它在创建第二个实例的时候抛异常,解决方案如下:
image

如上所述,问题确实也得到了解决,但问题是我们为此付出了不少努力,即添加了不少代码,还应该注意到如果单例类维持了其他对象的状态时还需要使他们成为transient的对象,这种就更复杂了,那有没有更简单更高效的呢?当然是有的,那就是枚举单例了,先来看看如何实现:

image

代码相当简洁,我们也可以像常规类一样编写enum类,为其添加变量和方法,访问方式也更简单,使用EnumSingle.INSTANCE进行访问,这样也就避免调用getInstance方法,更重要的是使用枚举单例的写法,我们完全不用考虑序列化和反射的问题。枚举序列化是由jvm保证的,每一个枚举类型和定义的枚举变量在JVM中都是唯一的。

在枚举类型的序列化和反序列化上,Java做了特殊的规定:在序列化时Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的并禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法,从而保证了枚举实例的唯一性,这里我们不妨再次看看Enum类的valueOf方法:

public static <T extends Enum<T>> T valueOf(Class<T> enumType,
                                              String name) {
      T result = enumType.enumConstantDirectory().get(name);
      if (result != null)
          return result;
      if (name == null)
          throw new NullPointerException("Name is null");
      throw new IllegalArgumentException(
          "No enum constant " + enumType.getCanonicalName() + "." + name);
  }

实际上通过调用enumType(Class对象的引用)的enumConstantDirectory方法获取到的是一个Map集合,在该集合中存放了以枚举name为key和以枚举实例变量为value的Key&Value数据,因此通过name的值就可以获取到枚举实例,看看enumConstantDirectory方法源码:

Map<String, T> enumConstantDirectory() {
        if (enumConstantDirectory == null) {
            //getEnumConstantsShared最终通过反射调用枚举类的values方法
            T[] universe = getEnumConstantsShared();
            if (universe == null)
                throw new IllegalArgumentException(
                    getName() + " is not an enum type");
            Map<String, T> m = new HashMap<>(2 * universe.length);
            //map存放了当前enum类的所有枚举实例变量,以name为key值
            for (T constant : universe)
                m.put(((Enum<?>)constant).name(), constant);
            enumConstantDirectory = m;
        }
        return enumConstantDirectory;
    }
    private volatile transient Map<String, T> enumConstantDirectory = null;

到这里我们也就可以看出枚举序列化确实不会重新创建新实例,jvm保证了每个枚举实例变量的唯一性。再来看看反射到底能不能创建枚举,下面试图通过反射获取构造器并创建枚举

public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
   //获取枚举类的构造函数(前面的源码已分析过)
   Constructor<EnumSingle> constructor=EnumSingle.class.getDeclaredConstructor(String.class,int.class);
   constructor.setAccessible(true);
   //创建枚举
   EnumSingle singleton=constructor.newInstance("otherInstance",9);
  }

执行报错

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
    at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
    at zejian.SingletonEnum.main(SingletonEnum.java:38)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)

显然告诉我们不能使用反射创建枚举类,这是为什么呢?不妨看看newInstance方法源码:

 public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, null, modifiers);
            }
        }
        //这里判断Modifier.ENUM是不是枚举修饰符,如果是就抛异常
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
    }

源码很了然,确实无法使用反射创建枚举实例,也就是说明了创建枚举实例只有编译器能够做到而已。显然枚举单例模式确实是很不错的选择,因此我们推荐使用它。但是这总不是万能的,对于android平台这个可能未必是最好的选择,在android开发中,内存优化是个大块头,而使用枚举时占用的内存常常是静态变量的两倍还多,因此android官方在内存优化方面给出的建议是尽量避免在android中使用enum。但是不管如何,关于单例,我们总是应该记住:线程安全,延迟加载,序列化与反序列化安全,反射安全是很重重要的。

至此,单例模式的介绍完毕,不足之处大家补充指点。

参考:

https://blog.csdn.net/chenchaofuck1/article/details/51702129

https://blog.csdn.net/mnb65482/article/details/80458571

https://blog.csdn.net/javazejian/article/details/71333103#%E6%9E%9A%E4%B8%BE%E4%B8%8E%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F

《深入理解Java虚拟机 JVM高级特性与最佳实践》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,440评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,814评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,427评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,710评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,625评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,014评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,511评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,162评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,311评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,262评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,278评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,989评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,583评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,664评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,904评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,274评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,856评论 2 339

推荐阅读更多精彩内容