你的位置:首页 > ASP.net教程

[ASP.net教程]减小内存的占用问题——享元模式和单例模式的对比分析


俗话说,自己写的代码,6个月后也是别人的代码……复习!复习!复习!总结的知识点如下:

  • 享元模式概念和实现例子
  • 使用了享元模式的Java API String类
  • java.lang.Integer 的 valueOf(int)方法源码分析
  • 使用享元模式的条件
  • 享元模式和单例模式的区别

  前面的策略模式的话题提起了:如何解决策略类膨胀的问题,说到 “有时候可以通过把依赖于环境Context类的状态保存到客户端里面,而将策略类设计成可共享的,这样策略类实例可以被不同客户端使用。”换言之,可以使用享元模式来减少对象的数量,享元模式它的英文名字叫Flyweigh模式,又有人翻译为羽量级模式,它是构造型模式之一,它通过与其他类似对象共享数据来减小内存占用,也正应了它的名字:享-分享。

  那么到底是什么意思呢?有什么用呢?下面看个例子:我们有一个文档,里面写了很多英文,大家知道英文字母有26个,大小写一起一共是52个:

  那么我保存这个文件的时候,所有的单词都占据了一份内存,每个字母都是一个对象,如果文档里的字母有重复的,怎么办?难道每次都要创建新的字母对象去保存么?答案是否定的,其实每个字母只需要创建一次,然后把他们保存起来,当再次使用的时候直接在已经创建好的字母里取就ok了,这就是享元模式的一个思想的体现。说到这儿,其实想起了Java的String类,这个类就是应用了享元模式。稍后再说,先看享元模式的类图和具体实现例子。

  抽象享元角色(接口或者抽象类):所有具体享元类的父类,规定一些需要实现的公共接口。

  具体享元角色:抽象享元角色的具体实现类,并实现了抽象享元角色规定的方法。

  享元工厂角色:负责创建和管理享元角色。它必须保证享元对象可以被系统适当地共享。当一个客户端对象调用一个享元对象的时候,享元工厂角色会检查系统中是否已经有一个符合要求的享元对象。如果已经有了,享元工厂角色就应当提供这个已有的享元对象,如果系统中没有一个适当的享元对象的话,享元工厂角色就应当创建一个合适的享元对象。代码如下:

 1 public interface ICharacter { 2   /** 3    * 享元模式的抽象享元角色,所有具体享元类的父类,规定一些需要实现的公共接口。其实没有这个接口也可以的。 4    * 5    * 显式我自己的字母 6   */ 7   void displayCharacter(); 8 } 9 10 public class ChracterBuilder implements ICharacter {11   private char aChar;12 13   public ChracterBuilder(char c) {14     this.aChar = c;15   }16   /**17    * 具体的享元模式角色18   */19   @Override20   public void displayCharacter() {21     System.out.println(aChar);22   }23 }24 25 public class FlyWeightFactory {26   /**27    * 注意:享元模式采用一个共享来避免大量拥有相同内容对象的开销。这种开销最常见、最直观的就是内存的损耗。28    * 我们这里使用数组也行,或者 HashMap29   */30   private Map<Character, ICharacter> characterPool;31 32   public FlyWeightFactory() {33     this.characterPool = new HashMap<>();34   }35 36   public ICharacter getICharater(Character character) {37     // 先去pool里判断38     ICharacter iCharacter = this.characterPool.get(character);39 40     if (iCharacter == null) {41       // 如果池子里没有就new一个新的,并加到pool里42       iCharacter = new ChracterBuilder(character);43       this.characterPool.put(character, iCharacter);44     }45 46     // 否则直接从pool里取出47     return iCharacter;48   }49 }

View Code

下面使用客户端调用,先看看普通的方法

public class MainFlyWeight {  public static void main(String[] args) {    //===================================    // 不用享元模式,我们每次创建相同内容的字母的时候,都要new一个新的对象    ICharacter iCharacter = new ChracterBuilder('a');    ICharacter iCharacter1 = new ChracterBuilder('b');    ICharacter iCharacter2 = new ChracterBuilder('b');    ICharacter iCharacter3 = new ChracterBuilder('b');    ICharacter iCharacter4 = new ChracterBuilder('b');    iCharacter.displayCharacter();    iCharacter1.displayCharacter();    iCharacter2.displayCharacter();    iCharacter3.displayCharacter();    iCharacter4.displayCharacter();    // 再通过实验判断    if (iCharacter2 == iCharacter1) {      System.out.print("true");    } else {      // 打印了 false,说明是两个不同的对象      System.out.print("false");    }}

View Code

下面使用享元模式,必须指出的是,使用享元模式,那么客户端绝对不可以直接将具体享元类实例化,而必须通过一个工厂得到享元对象。

public class MainFlyWeight {  public static void main(String[] args) {    // 使用享元模式    // 必须指出的是,客户端不可以直接将具体享元类实例化,而必须通过一个工厂    FlyWeightFactory flyWeightFactory = new FlyWeightFactory();    ICharacter iCharacter = flyWeightFactory.getICharater('a');    ICharacter iCharacter1 = flyWeightFactory.getICharater('b');    ICharacter iCharacter2 = flyWeightFactory.getICharater('b');    iCharacter.displayCharacter();    iCharacter1.displayCharacter();    iCharacter2.displayCharacter();    if (iCharacter1 == iCharacter2) {      // 确实打印了      System.out.print("============");    }    // 同样打印的都一样,但是对象内存的占据却不一样了,减少了内存的占用  }}

View Code

  类图如下:

  一般而言,享元工厂对象在整个系统中只有一个,因此也可以使用单例模式,由工厂方法产生所需要的享元对象且设计模式不用拘泥于具体代码, 代码实现可能有n多种方式,n多语言……再看一例子,有老师类,继承Person类,老师类里保存一个数字编号,客户端可以通过它来找到对应的老师。

 1 public class Person { 2   private String name; 3  4   private int age; 5  6   private String sex; 7  8   /** 9    * person是享元抽象角色 10    * 11    * @param age int 12    * @param name String 13    * @param sex String 14   */ 15   public Person(int age, String name, String sex) { 16     this.age = age; 17     this.name = name; 18     this.sex = sex; 19   } 20  21   public Person() { 22  23   } 24  25   public int getAge() { 26     return age; 27   } 28  29   public void setAge(int age) { 30     this.age = age; 31   } 32  33   public String getName() { 34     return name; 35   } 36  37   public void setName(String name) { 38     this.name = name; 39   } 40  41   public String getSex() { 42     return sex; 43   } 44  45   public void setSex(String sex) { 46     this.sex = sex; 47   } 48 } 49  50 public class Teacher extends Person { 51   private int number; 52  53   /** 54    * teacher是具体的享元角色 55    * 56    * @param number int 57    * @param age int 58    * @param name String 59    * @param sex String 60   */ 61   public Teacher(int number, int age, String name, String sex) { 62     super(age, name, sex); 63     this.number = number; 64   } 65  66   public Teacher() { 67     super(); 68   } 69  70   public int getNumber() { 71     return number; 72   } 73  74   public void setNumber(int number) { 75     this.number = number; 76   } 77 } 78  79 public class TeacherFactory { 80   private Map<Integer, Teacher> integerTeacherMapPool; 81  82   private TeacherFactory() { 83     this.integerTeacherMapPool = new HashMap<>(); 84   } 85  86   public static TeacherFactory getInstance() { 87     return Holder.instance; 88   } 89  90   public Teacher getTeacher(int num) { 91     Teacher teacher = integerTeacherMapPool.get(num); 92  93     if (teacher == null) { 94       // TODO 模拟用,不要把teacher写死,每次使用set 95       teacher = new Teacher(); 96       teacher.setNumber(num); 97  98       integerTeacherMapPool.put(num, teacher); 99     }100 101     return teacher;102   }103 104   /**105    * 使用静态内部类,静态内部类相当于外部类的static域,它的对象与外部类对象间不存在依赖关系,因此可直接创建。106    * 因为静态内部类相当于其外部类的成员,所以在第一次被使用的时候才被会装载。且只装载一次。107    * 而对象内部类的实例,是绑定在外部对象实例中的。108    * 静态内部类中可以定义静态方法,在静态方法中只能够引用外部类中的静态成员方法或者成员变量。109    *110    * 在某些情况中,JVM 含地了同步,这些情况下就不用自己再来进行同步控制了。这些情况包括:111    *1.由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时112    *2.访问final字段时113    *3.在创建线程之前创建对象时114    *4.线程可以看见它将要处理的对象时115    * 116    * 故,我使用了静态初始化器来实现线程安全的单例类,它由 JVM 来保证线程安全性。117    * 且这种实现方式,会在类装载的时候(使用这个类的时候)就初始化对象,不管使用者需要不需要,且只实例化一次。118    * 119    * 故,我在外部类里再创建一个静态内部类,在静态内部类里去创建本类(外部类)的对象,这样只要不使用这个静态内部类,那就不创建对象实例,从而同时实现延迟加载和线程安全。120   */121   private static class Holder {122     private static final TeacherFactory instance = new TeacherFactory();123   }124 }

View Code

客户端调用

public class MainClass {  public static void main(String[] args) {    // 先创建工厂    TeacherFactory teacherFactory = TeacherFactory.getInstance();    // 通过工厂得到具体对象    Teacher teacher = teacherFactory.getTeacher(1000);    Teacher teacher1 = teacherFactory.getTeacher(1001);    Teacher teacher2 = teacherFactory.getTeacher(1000);    System.out.println(teacher.getNumber());    System.out.println(teacher1.getNumber());    System.out.println(teacher2.getNumber());    // 判断是否是相等对象    if (teacher == teacher2) {      // 确实打印了,ok      System.out.print("____________-");    }  }}

View Code

类图

  小结,到底系统需要满足什么样的条件才能使用享元模式。对于这个问题,总结出以下几点: 

  • 一个系统中存在着大量的细粒度对象,且这些细粒度对象耗费了大量的内存。 
  • 这些细粒度对象的状态中的大部分都可以外部化
  • 这些细粒度对象可以按照内蕴状态分成很多的组,当把外蕴对象从对象中剔除时,每一个组都可以仅用一个对象代替。 
  • 软件系统不依赖于这些对象的身份,换言之,这些对象可以是不可分辨的。

满足以上的这些条件的系统可以使用享元模型。最后,使用享元模式需要维护一个记录了系统已有的所有享元的哈希表,也称之为对象池,而这也需要耗费一定的资源。因此应当在有足够多的享元实例可供共享时才值得使用享元模式。

  好了,现在多了几个新的概念(外部化,内蕴,外蕴……),再次用教科书的理论,分析之前的享元模式的例子和概念:

  内蕴状态:存储在享元对象内部的对象,并且这些对象是不会随环境的改变而有所不同。因此,一个享元可以具有内蕴状态并可以共享

  外蕴状态:随环境的改变而改变不可以共享的对象。享元对象的外蕴状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候再传入到享元对象内部。外蕴状态不可以影响享元对象的内蕴状态,它们是相互独立的。

  享元对象能做到共享的关键是区分内蕴状态(Internal State)和外蕴状态(External State)。回忆之前的例子:最近的Teacher例子,具体享元角色类Teacher类其实就是有一个内蕴状态,在本例中一个int类型的number属性,它的值应当在享元对象被创建时赋予,也就是内蕴状态对象让享元对象自己去保存,且可以被客户端共享所有的内蕴状态在对象创建之后,就不会再改变了。下面看一个具有外蕴状态的享元模式:

public interface BaseDao {  /**   * 连接数据源,享元模式的抽象享元角色   *   * @param session String 和数据源连接的session,该参数就是外蕴状态   */  void connect(String session);}

View Code

  如果一个享元对象有外蕴状态的话,所有的外部状态都必须存储在客户端,在使用享元对象时,再由客户端传入享元对象。这里只有一个外蕴状态,connect()方法的参数就是由外部传入的外蕴状态

public class DaoA implements BaseDao {  /**   * 内蕴状态   */  private String strConn = null;  /**   * 内蕴状态在创建享元对象的时候作为参数传入构造器   *   * @param s String   */  public DaoA(String s) {    this.strConn = s;  }  /**   * 外蕴状态 session 作为参数传入抽象方法,可以改变方法的行为,但是对于内蕴状态不做改变,两者独立   * 外蕴状态(对象)存储在客户端,当客户端使用享元对象的时候才被传入享元对象,而不是开始就有。   *   * @param session Session 和数据源连接的session,该参数就是外蕴状态   */  @Override  public void connect(String session) {    System.out.print("内蕴状态 是" + this.strConn);    System.out.print("外蕴状态 是" + session);  }}

View Code

  享元工厂

public enum Factory {  /**   * 单例模式的最佳实现是使用枚举类型。只需要编写一个包含单个元素的枚举类型即可   * 简洁,且无偿提供序列化,并由JVM从根本上提供保障,绝对防止多次实例化,且能够抵御反射和序列化的攻击。   */  instance;  /**   * 可以有自己的操作   */  private Map<String, BaseDao> stringBaseDaoMapPool = new HashMap<>();  public BaseDao factory(String s) {    BaseDao baseDao = this.stringBaseDaoMapPool.get(s);    if (baseDao == null) {      baseDao = new DaoA(s);      this.stringBaseDaoMapPool.put(s, baseDao);    }    return baseDao;  }}

View Code

虽然客户端申请了三个享元对象,但是实际创建的享元对象只有两个,这就是共享的含义

public class Client {  public static void main(String[] args) {    BaseDao baseDao = Factory.instance.factory("A连接数据源");    BaseDao baseDao1 = Factory.instance.factory("B连接数据源");    BaseDao baseDao2 = Factory.instance.factory("A连接数据源");    baseDao.connect("session1");    baseDao1.connect("session2");    baseDao2.connect("session1");    if (baseDao == baseDao2) {      // 打印了      System.out.print("===========");    }  }}

View Code

 

  在JDK中有哪些使用享元模式的例子?举例说明。

  说两个,第一个是String类,第二个是java.lang.Integer 的 valueOf(int)方法。针对String,也是老生常谈了,它是final的,字符串常量通常是在编译的时候就确定好的,定义在类的方法区里,也就是说,不同的类,即使用了同样的字符串, 还是属于不同的对象。所以才需要通过引用字符串常量来减少相同的字符串的数量。

    String s1 = "hello";    String s2 = "he" + "llo";    if (s1 == s2) {      System.out.print("====");// 打印了,说明是1,2引用了一个对象hello    }

View Code

使用相同的字符序列而不是使用new关键字创建的两个字符串会创建指向Java字符串常量池中的同一个字符串的指针。字符串常量池是Java节约资源的一种方式。其实就是使用了享元模式的思想。字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串池,每当代码创建字符串常量时,JVM会首先检查字符串常量池。如果字符串已经存在池中,就返回池中的实例引用。如果字符串不在池中,就会实例化一个字符串并放到池中。Java能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突进行共享。

  java.lang.Integer 的 valueOf(int)方法源码分析(8.0版本)

    Integer a = 1;    Integer b = 1;    System.out.print(a == b);// true

View Code

再看一例子;

    Integer a = new Integer(1);    Integer b = new Integer(1);    System.out.print(a == b);// false

View Code

很容易理解,再看

    Integer a = 200;    Integer b = 200;    System.out.println(a == b);// false

View Code

怎么还是false呢?看看到底发生了什么,反编译上述程序;

public static main([Ljava/lang/String;)V  L0  LINENUMBER 19 L0  SIPUSH 200  INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;  ASTORE 1  L1  LINENUMBER 20 L1  SIPUSH 200  INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;  ASTORE 2

View Code

我发现每次都是使用了自动装箱

Integer c = Integer.valueOf(200);

再看该方法源码;

  public static Integer valueOf(int i) {    if (i >= IntegerCache.low && i <= IntegerCache.high)      return IntegerCache.cache[i + (-IntegerCache.low)];    return new Integer(i);  }

View Code

我发现,当使用Integer的自动装箱的时候,值在low和high之间时,会用缓存保存起来,供多次使用,以节约内存。如果不在这个范围内,则创建一个新的Integer对象。这不就是尼玛享元模式吗!看看范围:-128~+127

  private static class IntegerCache {    static final int low = -128;    static final int high;    static final Integer cache[];    static {      // high value may be configured by property      int h = 127;      String integerCacheHighPropValue =        sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");      if (integerCacheHighPropValue != null) {        try {          int i = parseInt(integerCacheHighPropValue);          i = Math.max(i, 127);          // Maximum array size is Integer.MAX_VALUE          h = Math.min(i, Integer.MAX_VALUE - (-low) -1);        } catch( NumberFormatException nfe) {          // If the property cannot be parsed into an int, ignore it.        }      }      high = h;      cache = new Integer[(high - low) + 1];      int j = low;      for(int k = 0; k < cache.length; k++)        cache[k] = new Integer(j++);      // range [-128, 127] must be interned (JLS7 5.1.7)      assert IntegerCache.high >= 127;    }    private IntegerCache() {}  }

View Code

   

  享元模式的缺陷是什么?

  享元模式的优点在于它大幅度地降低内存中对象的数量。但是,它做到这一点所付出的代价也是很高的:

  ●  享元模式使得系统更加复杂。为了使对象可以共享,需要将一些状态外部化,这使得程序的逻辑复杂化。

  ●  享元模式将享元对象的状态外部化,而读取外部状态使得运行时间稍微变长。

 

  享元模式比起工厂,单例,策略,装饰,观察者等模式,其实不算是常用的设计模式,它主要用在底层的设计上比较多,比如之前提到的String类,Integer的valueOf(int)方法等。好了,享元模式到这里总结的差不多了,记得之前有个老师的例子,对老师的工厂类使用了单例模式创建了工厂对象,后来又有一个BaseDao例子,工厂Factory类使用了枚举作为单例模式的实现,那么这里还要顺便总结一个老生常谈,但是又不见得真的谈对了的设计模式——单例模式,如下之前总结的C++版的; 软件开发常用设计模式—单例模式总结,发现Java实现的单例模式和C++的在线程安全上还是有些区别的。下面主要说下Java版的单例模式的线程安全性。之前的一些私有静态属性(饿汉式),双重检测加锁……不再赘述。

  简单看看,单例模式还得单开文章总结,涉及到了枚举实现和内存模型:单例类只能有一个实例,单例类必须自己创建自己的唯一实例,单例类必须给所有其他对象提供这一实例(提供全局访问点)。

 

  小结:享元模式和单例模式的异同

  享元是对象级别的, 也就是说在多个使用到这个对象的地方都只需要使用这一个对象即可满足要求, 而单例是类级别的, 就是说这个类必须只能实例化出来一个对象, 可以这么说, 单例是享元的一种特例, 设计模式不用拘泥于具体代码, 代码实现可能有n多种方式, 而单例可以看做是享元的实现方式中的一种, 但是他比享元更加严格的控制了对象的唯一性。