闲话一二

清明小长假,由于没有回老家探亲,趁着难得的三天假期,可以好好地丰富下自己的知识储备。今天是第一天,上午花了半天时间看了下单例模式,正好解决了最近手头自动化测试工作中碰到的困扰,也顺便了解了下 volatile 关键字的使用。

也许有人会说,网上关于设计模式的文章很多,为什么还要写设计模式。但是,那毕竟是人家的,没有经过自己的理解、实践、总结、沉淀,是很难化为己用的。至于我写博客的目的,更不是为了博得他人的关注和认可,主要是为了将自己学习过的知识能加深理解,吸收前人的优秀经验和巧妙设计思想,在自己平日的工作中看有没有可以借鉴的地方。当然,如果能有经验丰富的人看了我的博客,不管是在学习工作方式上还是知识内容上给我些许诚恳的提点和意见,本人将感激不尽。个人博客园地址:http://www.cnblogs.com/znicy/

另外,随着知识的积累,很多知识在一段时间不接触后会遗忘,写博客的一大好处就是随时可以找到之前曾经接触的这一片区域,并且还可以抓到当时写博时的思路,很快地回忆起知识的内容。

使用场景

开始介绍单例模式之前,必须要先描述下使用场景,以及自己在代码编写时遇到的痛点。

在很多时候,有些对象我们希望在整个程序只有一个实例,如线程池、数据库连接池、缓存、日志对象、注册表等。而最近,在我的实际工作中,在编写接口自动化代码时就遇到了下列两种场景:

  1. 自动化所有用到的接口,在发送 https 请求时,都需要包含一个参数 sessionId,该参数可以通过登录 webserver 的接口获取,我希望这个 sessiondId 是唯一的,且只需要获取一次。
  2. 由于系统的 webserver 是支持高可用的,即如果一个 active webserver 挂了,另一个 standby webserver 就会立即投入工作,此时 web host 就需要切换。为了支持高可用,我在发送请求时加入了兼容代码:如果捕获了连接异常(ConnectException)就会去尝试 switchWebHost。在多线程并发执行测试用例的时候,我希望这个 switchWebHost 操作只需要执行一次。而如果将整个代码块加入 synchronized 同步,会导致不能同时发送 https 请求,导致并发量降低。

借用单例模式或借鉴其思想就可以解决上述问题。

定义

单例模式确保一个类只有一个实例,并提供一个全局访问点。

经典单例模式

public class Singleton{
    private static Singleton uniqueInstance;
    private Singleton(){}
    public static Singleton getInstance(){
        if (null==uniqueInstance){
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

Singleton 类拥有一个静态变量 uniqueInstance 来记录 Singleton 的唯一实例,注意它的构造函数是private的,这就注定了只有 Sinleton 类内才可以使用该构造器。在其他类中我们无法通过 new Singleton() 的方式类获取一个 Singleton 的实例,只能通过 Singleton.getInstance() 的方式获取。并且由于 uniqueInstance 是一个静态变量,属于 Singleton 这个类,所以保证了其唯一性。

经典模式有个好处,就是它的对象的实例化只有等到 getInstance 方法被调用时才会被 jvm 加载,如果 getInstance 始终没有被调用,jvm 就不会生成该实例。如果该对象的实例化需要消耗较多的资源,这种 “延迟实例化” 的方式可以减小 jvm 的开销。

但是,上述的实现方式很容易会想到存在一个严重的缺陷,就是 “非线程安全”。当多个线程同时调用 Singleton.getInstance() 来获取实例时,uniqueInstance 对象就可能被多次实例化。最简单的方式就是通过 synchronized 关键字来实现线程同步:

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

“急切实例化” 方式

在经典单例模式中加入了 synchronized 关键字后,我们可以发现整个 getInstance 方法是线程同步的操作,当一个线程在调用该方法时,其他所有线程都会被阻塞。如果 getInstance 方法的执行时间开销很小,那么我们是可以使用这种方式的。但是,如果 getInstanc 方法的执行时间开销很大,就会极大地降低并发效率。在这种情况下,可以考虑将实例化的操作提前到 Singleton 类加载的时候,即 “急切实例化” 方式:

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

利用这种方式,我们可以依赖 jvm 在加载这个类时马上创建此唯一的单例,jvm 会保证在任何线程访问 uniqueInstance 静态变量之前,一定先创建此实例。

“双重检查加锁” 方式

综合上述两种方式,为了平衡实例创建开销和并发量受限的代价,“双重检查加锁” 通过部分同步的方式同时解决了两者的问题。

public class Singleton{
    private volatile static Singleton uniqueInstance;
    private Singleton(){}
    public Singleton getInstance(){
        if (null == uniqueInstance){
            synchronized (Singleton.class){
                if( null == uniqueInstance){
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

可以看到,这种方式也是将实例化延迟到了 getInstance 方法被调用时,区别于经典单例模式,该方式引入了 “双重检查”,在多线程并行执行到同步代码块时,会再次判断 uniqueInsance 是否为 null,有效防止了多次实例化的发生。并且这种方式并没有对整个 getInstance 方法加锁,只是在第一次生成 Singleton 的唯一实例时进行了一次同步,并没有降低程序的并发性。

volatile 关键字

而对于 volatile 关键字的使用,查阅了《Thinking in Java》,作者的解释是 “violate 定义的域在发生修改后,它会直接写到主存中,对其他任务可见”。

用 volatile 修饰的变量,线程在每次开始使用变量的时候,都会读取变量修改后的最新的值。但是这并不代表,使用 volatile 就可以实现线程同步,它只是在线程 “开始使用” 该变量时读取到该变量的最新值。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存(主存)的变量的值,然后把堆内存变量的具体值 load 到线程本地内存(本地缓存)中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。

下面这幅流程图描述了一个共享变量在线程中被使用时其线程工作内存与主内存的交互方式。

线程工作内存

图片转自博客:God is Coder

静态内部类方式

最后再介绍一下静态内部类的方式也可以实现同时满足性能和并发要求的单例模式。

public class Singleton{
    private static class Holder{
       private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton(){}
    public static final Singleton getInstance(){
        return Holder.INSTANCE;
    }
}

可以看到,该方式其实是第二种 “急切实例化” 方式的变种,该实例只有在 jvm 加载类 Holder 时会被实例化,并且可以保证在各个线程获取 Holder.INSTANCE 变量之前完成。在保证线程安全的同时,又可以延迟实例化,并且没有降低并发性。

问题解决

在介绍了几种单例模式后,现在来解决我们在 “使用场景” 中碰到的两个问题。

1.session 获取

使用 “静态内部类” 方法创建 SessionFactory 类:

public class SessionFactory {
    private static String sessionId;
    private static BaseConfig baseConfig = BaseConfigFactory.getInstance();

    private static class SessionidHolder{
        private final static SessionFactory INSTANCE = new SessionFactory();
    }

    public static final SessionFactory getInstance(){
        return SessionidHolder.INSTANCE;
    }
    private SessionFactory(){
        LoginApi api  = new LoginApi();
        String username = baseConfig.getLoginUsername();
        String password = baseConfig.getLoginPassword();
        sessionId= api.login(username, password).getValue("session.id");
    }

    public String getSessionId() {
        return sessionId;
    }
}

使用 Testng 编写测试代码:

public class SessionTest {
    @Test(threadPoolSize=10, invocationCount=10)
    public void sessionTest(){
        SessionFactory sessionFactory = SessionFactory.getInstance();
        System.out.println("Thread id="+ Thread.currentThread().getId()+ 
        ", session.id=" + sessionFactory.getSessionId());
    }
}

测试结果:

Thread id=13, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=18, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=11, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=16, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=12, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=17, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=10, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=15, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=14, session.id=36afe1a1-19df-4400-8fbf-4687293d7294
Thread id=19, session.id=36afe1a1-19df-4400-8fbf-4687293d7294

可以看到,10 个线程并发执行时,session.id 是唯一的,说明 sessionFactory 是唯一的,只被实例化了一次。

或许你会问,能不能在 SessionFactory 中将 getSessionId 方法设为静态方法,直接调用 SessionFactory.getSessionId() 来获取 sessionId?当然可以,但是前提是你还是必须要先通过调用 SessionFactory.getInstance() 方法来将 SessionFactory 类实例化,否则你会发现获取到的 sessionId 就是 null,可以看出,jvm 在加载一个类时,如果该类没有被实例化就不会去主动调用它的构造方法。

2.遇到 webserver 切换时,希望 switchWebHost 操作只需要执行一次

借用 “双重检查,部分同步” 的思想,可以设计伪代码逻辑如下(篇幅考虑使用伪代码代替):

try {
    sendHttpsRequest();
}catch(ConnectException e){
    numRquestFail++;
    synchronized (BaseApi.class) {
        if (isWebHostChanged()){
            return;
        }
        switchWebHost(actualUrl, numRequestFail, e);
    }
}

即,将切换 webhost 部分的代码进行同步,并且在切换时先通过调用 isWebHostChanged() 方法判断是否已经被其他线程切换。防止 host 多次发生切换。同时,这种方式不会影响到 sendHttpsRequest 方法的并发。

总结

其实,写到这里,从早上开始拿起手头的《Head First 设计模式》看单例模式,到翻书查资料理解相关的知识(volatile、jvm 内存管理)到重构自动化的代码,到反复测试各种条件下的程序执行情况,到写完整篇总结,已经花了一整天的时间,虽说花的时间有点多,但是知识的扫盲本身就不是一蹴而就的,尤其基础的东西理解地深刻我相信对以后的学习肯定是有帮助的。


↙↙↙阅读原文可查看相关链接,并与作者交流