实例剖析单例模式的局限性

原创 2014年01月10日 23:57:47

单例模式是23中设计模式之一,我认为单例模式是一种极其简约的模式。因为在设计或开发中,肯定会有这么一种情况,一个类只能有一个对象被创建,如果有多个对象的话,可能会导致状态的混乱和不一致。这种情况下,单例模式是最恰当的解决办法。在23种设计模式中,单例模式应该是最简单的一种了。但是要把单例模式用的恰到好处还是有一定的困难的,尤其是在实际应用中,还需考虑很多因素。


本文主要讲解单例模式的几个局限性,这些局限性都是我根据平时工作中(我是做android应用开发的)遇到的问题总结出来的。在叙述中,主要以android相关的实例讲解,对android不熟的可能理解起来有些困难。除此之外,还会谈一下我对单例模式的一些看法。



局限一:单例对象中所依赖的其他对象可能会过期


在面向对象的世界中,对象之间的关系一般只有两种,即继承和组合。单例对象也是一个普通的对象,所以它和其他对象之间的关系也无非这两种。也就是说在单例对象中也会组合其他对象,并且在java中,单例对象是由静态变量所引用的,这就决定了单例对象的声明周期很长,和程序的声明周期相同。如果没有改变单例对象的结构,但是改变了单例对象中组合的那个对象,可能会使单例对象变得无效。下面看一个实例。
在做一款企业应用时,登录界面作为一个独立的界面存在,并且在登录界面上有设置服务器地址的功能,每当点击“服务期地址设置”时,会弹出一个对话框,要求输入地址。如下图所示:
                            

为了管理地址设置这一个独立的功能,创建了ServerAddessManager类,并且将它定义为了一个单例对象。这个ServerAddessManager的主要逻辑是这样的:弹出对话框,让用户输入地址, 并且将地址进行持久性存储(保存到SharedPreference中),并且提供了一个读取地址的接口,考虑到该功能总是在主线程调用,所以没有考虑线程安全的问题:

public class ServerAddessManager {
	
	//用于存储服务器地址设置的sp的名称
	private static final String SP_NAME = "server_address_sp";	
	
	//SP中服务器地址的键
	private static final String ADDRESS_KEY = "address";
	
	//默认的地址
	private static final String DEFAULT_SERVER = "http://";
	
	private Context context ;
	
	//用于存储服务器地址的SP
	private SharedPreferences sp;
	
	/**
	 * 构造方法 
	 * @param context
	 */
	private ServerAddessManager(Context context){
		this.context = context;
		sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
	}
	
//	private static ServerAddessManager manager = null;
	
	/**
	 * 单例模式, 返回单例对象
	 * @param context
	 * @return
	 */
	public static ServerAddessManager getInstance(Context context){
		
		if(manager == null){
			manager = new ServerAddessManager(context);
		}
		
		return manager;
	}
	
	
	/**
	 * 获得设置的服务器地址
	 * @return	服务器地址
	 */
	public String getServerAddress(){
		
		//获取上次设置的服务器的地址, 若从未设置过, 则返回默认的地址
		return sp.getString(ADDRESS_KEY, DEFAULT_SERVER);
		
	}
	
	/**
	 * 弹出对话框,设置服务器的地址
	 */
	public void setServerAddress(){
		
		 final EditText inputServer =  
				 (EditText)LayoutInflater.from(context).inflate(R.layout.server_address_edittext, null);
	     inputServer.setText(getServerAddress());	//将上次设置的地址显示到对话框中的输入框中
	 
	     
	        AlertDialog.Builder builder = new AlertDialog.Builder(context);
	        builder.setTitle("请设置服务器地址")
	        		.setView(inputServer).setNegativeButton("取消", null)
	        		.setPositiveButton("确定", new DialogInterface.OnClickListener() {

	                    public void onClick(DialogInterface dialog, int which) {
	                        String inputName = inputServer.getText().toString().trim();
	                        setServerAddress(inputName);
	                    }
	                });
	        builder.show();	//弹出对话框让用户输入新的服务器地址
	}
	
	/**
	 * 设置服务器地址
	 * @param address	要设置的地址
	 */
	public void setServerAddress(String address){
		
		Editor editor = sp.edit();
		
		//保存用户输入的地址,若用户输入的地址为空, 则保存默认地址
		editor.putString(ADDRESS_KEY, 
				(address == null||"".equals(address)) ? DEFAULT_SERVER : address);
		
		editor.commit();
	}
}

因为要调用android framwork中的SharedPreferences,并且要创建对话框,所以该对象必须组合一个Context对象(从后文可以知道,这是引起问题的主要原因)。
在调用该功能的登界面中,主要逻辑是这样的:点击设置服务器地址,可以弹出对话框;在点击登录后,后台进行登录验证,验证通过后会finish当前登录界面(Activity),如果多次调到登录功能,会启动新的界面(这个也是引起问题的原因之一)。

	@Override
	public void onClick(View v) {
        switch(v.getId()) {
        case R.id.loginbutton:
        	logIn();
			break;
        case R.id.set_server_address:
        	
        	//处理服务器地址设置的逻辑
        	ServerAddessManager.getInstance(this).setServerAddress();
        	break;
        }		
    }

	public void logIn(){
		//...
		
		//登陆验证
		
		if(success){
			startMainActivity();
			finish();
		}

但是当多次设置地址时,却抛出了以下错误:

这里抛出了WindowManager.BadTokenException,unable to add window的意思是不能启动对话框,badtoken是指的对话框要依附的Activity(对话框不能独立存在,必须依附一个activity界面),也就是在调用ServerAddessManager.getInstance(this).setServerAddress();时传入的this,在ServerAddessManager对象内部是一个Context。
在错误提示中,还询问:你的对话框所依赖的Activity是否处于运行状态?
其实这个错误是由ServerAddessManager中Context对象的状态错误造成的,以下解析原因:

  1. 第一次进入登录界面(LoginActivity),点击设置地址,弹出对话框,这时会创建ServerAddessManager单例对象,并且这个对象组合了当前的Activity对象,也就是context变量持有了当前Activity对象的引用;
  2. 点击登录,登录成功后,当前Activity被finish掉, 但ServerAddessManager对象中仍然持有这个已经被finish掉的Activity的引用,因为ServerAddessManager是单例的(静态的),会长期持有一个对象;
  3. 下次再来到登录界面,是一个LoginActivity的新实例;
  4. 再次点击设置服务器地址,还是调用的ServerAddessManager单例对象,单例对象中还是持有的老的已经被finish掉的上个LoginActivity实例,再次以这个老的context启动对话框,就出现了上述错误。

总结一下,就是单例对象中所持有的其他对象的状态被改变,会导致单例对象无效。但是有人会说,将持有的其他对象设置成private的,并且不在程序中访问这个对象就可以了,但是,可能不只有你的单例对象引用这个对象,其他的对象还可能引用它,我们的程序是运行在android framework框架中的,并且这个Activity实例是由框架创建并管理的,框架肯定会持有它的引用,并且会随着程序的运行,不断改变这个对象的状态,比如,调用了finish方法后,该Activity就不会再显示到屏幕上。

所以将ServerAddessManager类做了修改,不再使用单例模式:
	//private static ServerAddessManager manager = null;
	

	public static ServerAddessManager getInstance(Context context){
		
//		if(manager == null){
//			manager = new ServerAddessManager(context);
//		}
//		
//		return manager;
		
		//单例模式在这里不适用, 因为单例对象中的context字段过期
		return new ServerAddessManager(context);
	}

每次调用getInstance方法, 都会创建一个新的ServerAddessManager对象,并且向ServerAddessManager对象注入的context总是当前最新的LoginActivity,这样的话, context就不会过期。


局限二:单例对象引起内存泄露


上文提到过,单例对象是由静态引用来引用的,这就导致他的声明周期很长,并且他所引用的其他对象的周期也会很长。内存图如下所示:

从静态区的manager变量,引用到堆区的ServerAddessManager单例对象,在通过ServerAddessManager对象的context字段引用到一个LoginActivity对象1,不管当前已经创建了多少个LoginActivity的实例,也不管当前显示的是哪个LoginActivity的实例,最初的那个LoginActivity对象1永远被引用,尽管他已经不再被框架使用,但是由于有一个也能用一直在暗地里引用他,所以这个对象永远不会被垃圾回收,这就引起了内存泄露。

总结:在使用单例模式时,如果单例对象引用的其他对象是系统中的组件或资源,那么很可能引起内存泄露,并且可能会使用到的资源不会被释放,这是由单例对象的静态性决定的。

对单例模式的其他认识


1 延迟初始化真的有必要吗?


学习过单例模式的同学都知道,单例模式有两种形式,一个叫做饿汉式,另一种叫做懒汉式。饿汉式指的是静态单例对象在声明的时候直接进行初始化(即随着类的加载而创建),而懒汉式指的是在调用getInstance方法时,如果还没有创建,再创建单例对象,这也叫做延迟初始化。
一般都是写延迟初始化的时候比较多,但是,延迟初始化真的有必要吗?这需要根据具体情况而定。因为延迟初始化比较复杂,并且会引入并发问题,所以,尽量不要使用延迟初始化。
那么什么时候最好使用延迟初始化呢?一是对象的创建比较复杂且耗时,也就是说是一个重量级对象,如果在声明时创建,可能会影响类加载的速度,那么做好使用延迟初始化;二是你不能确定创建的对象到底要不要用到,如果在声明时创建,而这个对象根本不会使用,那么就白白创建了一个对象,所以这种情况适合延迟初始化,只有真正用到的时候才创建对象。
那么相反,如果这个单例对象一定会被用到,并且创建这个对象又不是那么麻烦,那么适合在声明的同时初始化。

2 真的只有一个对象吗?


其实是否这个类是否真的只有一个对象,有时也不是那么显而易见。我在做项目的过程中就遇到过一个有趣的例子。因为应用中的一个功能需要多次使用到屏幕的尺寸,所以我把屏幕尺寸定义成一个对象,并设计成单例的,但是却疏忽了这样一个问题:横屏和竖屏的时候屏幕尺寸是不一样的,长宽正好颠倒,所以在竖屏模式下创建了这个单例对象,那么切换到横屏时,这个对象就不再适用了。

3 单例模式真的有必要吗?


单例模式作为一种设计模式,其实很多时候是没必要的,只有确定真的只有一个对象,并且不会引起其他技术问题(比如上面提到的局限性),才可以使用。但是很多时候,一个类只创建一个对象的情况不是很多,比如上面讨论的ServerAddessManager,现在再仔细想一下,他就是个工具类,根本没必要创建对象,直接提供静态方法就行,更没必要创建单例模式,因为可以创建多个对象。如果创建多个对象,不会引起其他问题,虽然创建对象需要开销,但是这些对象可以被垃圾回收,单例对象有静态变量引用,不会被释放,总会占用一块内存,,并且被这个对象引用的其他所有对象也不会被释放。所以,在实际情况中,是否需要使用单例是需要仔细权衡的。


相关文章推荐

面试必备:常用的设计模式总结

说说我自己吧,应届生一枚,大专学历,软件专业,在学校也学得不怎么样,刚开始出来找工作很不容易,万幸的是,还是有公司要我了。进公司也快一个月了,明天就是国庆长假了,趁着自己有时间,写点博客,记录下自己的...

【JavaWeb-8】JSP原理、3大命令、6大动作、9大对象、4大域对象、EL表达式、JSTL的几个标签

1、什么是JSP(Java Server Pages)?它和servlet一样都是SUN推出的用于开发动态web资源的一种技术。JSP本质上也是一个servlet。我们暂时可以理解为JSP就是HTML...

单例模式讲解说明与实例

  • 2014年10月15日 11:27
  • 83KB
  • 下载

单例模式(C# 实例 源码-经典3分)

  • 2009年01月15日 21:12
  • 737KB
  • 下载

ios oc中的静态方法和实例方法、单例模式

静态方法与实例方法 方法是类的行为,写在接口和实现两个文件中。在接口部分声明方法,在实现部分实现方法。 1、类方法与实例方法 Objective-C中的类可以声明两种类型的方法:实例方法和类方法...

java单例模式实例

  • 2015年04月03日 23:30
  • 8KB
  • 下载

C++ 单例模式和禁止在栈中创建实例

在C++中,禁止在栈中

java单例模式完全剖析

  • 2011年03月07日 21:20
  • 189KB
  • 下载

Java单例模式实例---读取配置文件

因为配置文件里的信息都是一样的,不论哪个用户要登录系统访问连接数据库,都是要读取配置文件的,这样每次如果都要实例化读取配置文件的类,这样就会非常浪费系统资源。因此使用单例模式:只要实例化一次之后,有了...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:实例剖析单例模式的局限性
举报原因:
原因补充:

(最多只允许输入30个字)