单例模式与同步的迷思

单例模式与同步的迷思

之前的代码里充满了各种单例,但其实我对单例的理解有误,一直以来我以为单例可以解决线程同步的问题,但是其实单例和线程同步是两个不同的事,一点关系都没有。趁此机会把单例和同步的概念都理一理。

首先我们讲下单例的常见应用场景,现在我认为单例的应用场景一定是条件约束的,不能滥用。

  1. 在Java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。正是由于这个特点,单例对象通常作为程序中的存放配置信息的载体,因为它能保证其他对象读到一致的信息,同时,对于配置信息,在配置没有更新的情况下,一般来说是不用多次读取的,所以用单例对象读取一次下一次就可以直接访问这个对象的属性。例如在某个服务器程序中,该服务器的配置信息可能存放在数据库或文件中,这些配置数据由某个单例对象统一读取,服务进程中的其他对象如果要获取这些配置信息,只需访问该单例对象即可。

  2. Windows的Task Manager(任务管理器)就是很典型的单例模式(这个很熟悉吧),想想看,是不是呢,你能打开两个windows task manager吗? 不信你自己试试看哦~

  3. windows的Recycle Bin(回收站)也是典型的单例应用。在整个系统运行过程中,回收站一直维护着仅有的一个实例。

  4. 网站的计数器,一般也是采用单例模式实现,否则难以同步。

  5. 应用程序的日志应用,一般都何用单例模式实现,这一般是由于共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加。

  6. Web应用的配置对象的读取,一般也应用单例模式,这个是由于配置文件是共享的资源。

  7. 数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源。数据库软件系统中使用数据库连接池,主要是节省打开或者关闭数据库连接所引起的效率损耗,这种效率上的损耗还是非常昂贵的,因为何用单例模式来维护,就可以大大降低这种损耗。

  8. 多线程的线程池的设计一般也是采用单例模式,这是由于线程池要方便对池中的线程进行控制。

  9. 操作系统的文件系统,也是大的单例模式实现的具体例子,一个操作系统只能有一个文件系统。

  10. HttpApplication 也是单位例的典型应用。熟悉ASP.Net(IIS)的整个请求生命周期的人应该知道HttpApplication也是单例模式,所有的HttpModule都共享一个HttpApplication实例.

总结以上,不难看出:

单例模式应用的场景一般发现在以下条件下:
  1. 资源共享的情况下,避免由于资源操作时导致的性能或损耗等,系统用一个对象来统一对资源进行操作,提升性能,某些对象很重,没有必要多次创建,比如数据库连接对象。如上述中的日志文件,应用配置。
  2. 控制资源的情况下,方便资源之间的互相通信。如线程池等。

单例模式应用场景 代码层面应该具有的特征:

  1. 算法固定
  2. 算法不存在状态,所有的变化均来自参数,且实现无需线程同步就可以保证线程安全
  3. 接收方要求符合某接口(泛指)
  4. 如果第一点不满足,那就不可能作为SingleTon存在。第二点是确保使用SingleTon的性能优势。而第三点是OO立场上的SingleTon必要性,否则为什么不用静态方法。

那么我对于单例的误解是什么呢:

  • 之前我以为,既然同一个对象,那么单例对象的非同步方法,会不会有线程争抢资源导致性能的损失呢。毕竟只有一个对象,naive的我以为既然是一个对象,那么肯定是会有线程来争抢这个对象,经过查阅大量资料,先给结论,对于单例对象的非同步方法,不存在争抢的情况,两个线程可以同时执行单例对象上的同一个方法。
  • 这个是为什么呢?首先放一张jvm的内存图
单例模式与同步的迷思_第1张图片
image.png
  • 和一个运行时数据区:


    单例模式与同步的迷思_第2张图片
    image.png
  • 我们关注到的是方法本身是被编译成指令,被放在方法区里,一个类的一个方法在jvm里面就是一块内存里保存的指令。方法里面的基础类型的变量和实例变量的引用是放在了栈区。
  • jvm在每一个线程启动的时候,会为当前线程创建一个栈,每一个栈的数据都是私有的,别的栈不能访问,所以线程之间栈的数据是独立的。
  • 每执行一个方法都是在栈中压入一个栈帧,栈帧中存储局部变量表、操作数栈、动态链接、方法接口 等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈出栈的过程
  • jvm执行一个方法的过程,举例来说明,如果有以下类:
class Sample{
    public void main(){
        int temp = 5;
    }
} 

new Sample().main();

  • 如果有一个Sample类的实例执行main方法,那么过程是这样的。
  1. 堆区中为一个新的Sample实例分配内存, 这个Sample实例持有着指向方法区的Sample类的类型信息的引用
  2. 获得main方法的字节码内存地址,执行其中的指令
  3. 在当前线程的栈中压入一个栈帧,temp是一个在main()方法中定义的变量,可见,它是一个局部变量,因此,它被会添加到了执行main()方法的主线程的JAVA方法调用栈中。而“=”将把这个test1变量指向堆区中的Sample实例,也就是说,它持有指向Sample实例的引用
  4. 该方法执行完了,对应的栈帧出栈。
  • 到了这里我们应该明白为什么我说不存在争抢的情况,两个线程可以同时执行单例对象上的同一个方法。因为方法本身只是方法区的一块内存,而两个线程在没有锁的情况下是可以同时读写一块内存的,那么两个线程当然可以同时执行方法对应的指令,而方法内部的局部变量,操作数等都是保存在线程内部的私有栈中,不共享,所以完全可以同时调用。

  • 那么什么时候单例对象需要同步的情况呢,很简单我们考虑一下需要同步的场景,简单来说,就是存在互斥场景,互斥场景我认为可以简单理解如下:

  1. 需要对某个共享资源(也许是该单例对象的成员变量,也许是另一个单例对象,或者是数据库里的同一份数据)进行写操作
  2. 如果一个方法对某共享资源的写操作会造成其它线程返回值的不确定性,则该方法应该同步该对象。
  • 到这里我们明白了,只有在某方法里涉及到互斥操作的时候才需要同步,这个时候可以再这个方法修饰符加synchronize关键字,因为非静态方法的synchronize关键字取得是这个对象的锁,而单例对象只对应一个锁,所以就完成了同步。

这里虽然说了什么时候单例需要同步,但其实同步和单例一点关系都没有,如果有互斥操作,那么即使不是单例,也应该同步。只是说单例对象比普通的对象,互斥操作多了对单例对象成员变量的写操作。

下方顺便写一下单例的几种常见写法,也是我最常用的:

// 非线程安全懒汉式,在android上很常见,因为端上多线程其实比较少,很多时候这种最简单的懒汉已经足够了,性能消耗最少,不用关注同步问题。
// Version 1.1
public class Single1 {
    private static Single1 instance;
    private Single1() {}
    public static Single1 getInstance() {
        if (instance == null) {
            instance = new Single1();
        }
        return instance;
    }
}
/ double check
public class Single3 {
    private volatile static Single3 instance;
    private Single3() {}
    public static Single3 getInstance() {
        if (instance == null) {
            synchronized (Single3.class) {
                if (instance == null) {
                    instance = new Single3();
                }
            }
        }
        return instance;
    }
}

// 静态内部类,推荐
// Effective Java 第一版推荐写法
public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}
// Effective Java 第二版推荐写法
// 枚举实现单例
public enum SingleInstance {
    INSTANCE;
    public void fun1() { 
        // do something
    }
}
 

参考资料

  1. Hi,我们再来聊一聊Java的单例吧
  2. 为什么单例对象的并发调用需要同步?
  3. 关于多个线程同时调用单例模式的对象,该对象中方法的局部变量是否会受多个线程的影响
  4. 单例模式请不要滥用
  5. 《深入理解Java虚拟机》读书笔记
  6. Java里的堆(heap)栈(stack)和方法区(method)

你可能感兴趣的:(单例模式与同步的迷思)