享元模式

寵の児 提交于 2019-12-05 04:28:33
28.1 内存溢出, 司空见惯下午, 我正在开会中, 老大推门进来。“三儿, 出来一下。 ”我刚出会议室门口, 老大就发话了。“郎当(姓朗, 顺口就叫郎当) 的那个报考系统又crash了一台机器, 两天已经宕了4次了, 你这边还有紧急的事情没有? ……没有, 那赶快过去顶一下, 就运行三天的程序, 两天宕了4次, 还怎么玩? ! ”我马上收拾东西, 冲到马路上拦了出租车, 同时打电话给郎当。“三哥, 厂商人员已经定位出了, OutOfMemory内存溢出, 没查到有内存泄漏的情况, 现在还在跟踪……是突然暴涨的, 都是在繁忙期出现问题的……”内存溢出对Java应用来说实在是太平常了, 有以下两种可能。● 内存泄漏无意识的代码缺陷, 导致内存泄漏, JVM不能获得连续的内存空间。● 对象太多代码写得很烂, 产生的对象太多, 内存被耗尽。 现在的情况是没有内存泄漏, 那只有一种原因——代码太差把内存耗尽。到现场后, 郎当给我介绍了一下系统情况。 该系统是一个报考系统, 其中有一个模块负责社会人员报名, 该模块对全国的考试人员只开放3天, 并且限制报考人员数量。 第一天9点开始报考, 系统慢得像蜗牛, 基本上都不能访问, 后来设置了HTTP Server的并发数量, 稍有缓解, 40分钟后宕了一台机器, 10分钟后, 又挂了一台, 下午3点又挂了一台, 看样子晚上要让郎当去寺庙烧烧香了。该系统一共有8台应用服务器, 基本上CPU繁忙程度都在60%以上, HTTP的最大并发是2000, 平均分配到每台应用服务器上没有太大的压力, 于是怀疑是代码问题, 然后详细了解了一下业务和数据流逻辑, 基本的业务操作过程清楚了, 先登录(没有账号的, 则要先注册) , 登录后, 需要填写以下信息:● 考试科目, 选择框。● 考试地点, 选择框, 根据科目不同, 列表不同。● 准考证邮寄地址, 输入框。还有其他一堆信息, 我们以这三者作为代表来讲解。 信息填写完毕后, 点击确认, 报名就结束了。 简单程序的业务逻辑也确实是这样, 为什么出现Crash情况呢? 那肯定是和压力有关系!我们先把这个过程的静态类图画出来, 如图28-1所示。图28-1 报考系统类图很简单的工厂方法模式, 表现层通过工厂方法模式创建对象, 然后传递给业务层和持久层, 最终保存到数据库中, 为什么要使用工厂方法模式而不用直接new一个对象呢? 因为是在框架下编程, 必须有一个对象工厂(ObjectFactory,Spring也有对象工厂) 。 我们先来看报考信息, 如代码清单28-1所示。代码清单28-1 报考信息public class SignInfo {//报名人员的IDprivate String id;//考试地点private String location;//考试科目private String subject;//邮寄地址private String postAddress;public String getId() {return id;}public void setId(String id) {this.id = id;}public String getLocation() {return location;}public void setLocation(String location) {this.location = location;}public String getSubject() {return subject;}public void setSubject(String subject) {this.subject = subject;}public String getPostAddress() {return postAddress;}public void setPostAddress(String postAddress) {this.postAddress = postAddress;}}它是一个很简单的POJO对象(Plain Ordinary Java Object, 简单Java对象) 。 我们再来看工厂类, 如代码清单28-2所示。代码清单28-2 报考信息工厂public class SignInfoFactory {//报名信息的对象工厂public static SignInfo getSignInfo(){return new SignInfo();}}工厂类就这么简单? 非也, 这是我们的教学代码, 真实的ObjectFactory要复杂得多, 主要是注入了部分Handler的管理。 表现层是如何创建对象的, 如代码清单28-3所示。代码清单28-3 场景类public class Client {public static void main(String[] args) {//从工厂中获得一个对象SignInfo signInfo = SignInfoFactory.getSignInfo();//进行其他业务处理}}就这么简单, 但是简单为什么会出现问题呢? 而且这样写也没有问题呀, 很标准的工厂方法模式, 应该不会有大问题, 然后又看了看系统厂商提供的分析报告, 报告中指出: 内存突然由800MB飙升到1.4GB, 新的对象申请不到内存空间, 于是出现OutOfMemory, 同时报告中还列出宕机时刻内存中的对象, 其中SignInfo类的对象就有400MB, 疯子, 绝对是疯子! 报告都没有看嘛!问题找到了, 我拉郎当过来谈话, “厂商不是分析出原因了嘛, 人家已经指出SignInfo类的对象占用了400MB多的内存, 这是怎么回事? ”“三哥, 这是很正常的, 这么大的访问量, 产生出这么多的SignInfo对象也是应该的, 内存中有这么多对象并不表示这些对象正在被使用呀, 估计很大一部分还没有被回收而已, 垃圾回收器什么时候回收内存中的对象这是不确定的。 你看, 并发200多个, 这可是并发数量……”我想了想, 也确实是这么回事。 既然已经定位是内存中对象太多, 那就应该想到使用一种共享的技术减少对象数量, 那怎么共享呢?大家知道, 对象池(Object Pool) 的实现有很多开源工具, 比如Apache的commons-pool就是一个非常不错的池工具, 我们暂时还用不到这种重量级的工具, 我们自己来设计一个共享对象池, 需要实现如下两个功能。● 容器定义我们要定义一个池容器, 在这个容器中容纳哪些对象。● 提供客户端访问的接口我们要提供一个接口供客户端访问, 池中有可用对象时, 可以直接从池中获得, 否则建立一个新的对象, 并放置到池中。设计思路有了, 那我们池中对象的标准是什么呢? 你想想看, 如果你把所有的对象都放到池中, 那还有什么意义? 内存早就给你撑爆了! 这么多对象, 必然有一些相同的属性值,如几十万SignInfo对象中, 考试科目就4个, 考试地点也就是30多个, 其他的属性则是每个对象都不相同的, 我们把对象的相同属性提取出来, 不同的属性在系统内进行赋值处理, 是不是就可以建立一个池了? 话无须多说, 我们以类图来表示, 如图28-2所示。图28-2 增加对象池的类图做一个很小的改动, 增加了一个子类, 实现带缓冲池的对象建立, 同时在工厂类上增加了一个容器对象HashMap, 保存池中的所有对象。 我们先来看产品子类, 如代码清单28-4所示。代码清单28-4 带对象池的报考信息public class SignInfo4Pool extends SignInfo {//定义一个对象池提取的KEY值private String key;//构造函数获得相同标志public SignInfo4Pool(String _key){this.key = _key;}public String getKey() {return key;}public void setKey(String key) {this.key = key;}}很简单, 就是增加了一个key值, 为什么要增加key值? 为什么要使用子类, 而不在SignInfo类上做修改? 好, 我来给你解释为什么要这样做, 我们刚刚已经分析了所有的SignInfo对象都有一些共同的属性: 考试科目和考试地点, 我们把这些共性提取出来作为所有对象的外部状态, 在这个对象池中一个具体的外部状态只有一个对象。 按照这个设计, 我们定义key值的标准为: 考试科目+考试地点的复合字符串作为唯一的池对象标准, 也就是说在对象池中, 一个key值唯一对应一个对象。注意 在对象池中, 对象一旦产生, 必然有一个唯一的、 可访问的状态标志该对象, 而且池中的对象声明周期是由池容器决定, 而不是由使用者决定的。你可能马上就要提出了, 为什么不建立一个新的类, 包含subject和location两个属性作为外部状态呢? 嗯, 这是一个办法, 但不是最好的办法, 有两个原因:● 修改的工作量太大, 增加的这个类由谁来创建呢? 同时, SignInfo类是否也要修改呢? 你不可能让两段相同的POJO程序同时出现在同一模块中吧!● 性能问题, 我们会在扩展模块中讲解。说了这么多, 我们还是继续来看程序, 工厂类如代码清单28-5所示。代码清单28-5 带对象池的工厂类public class SignInfoFactory {//池容器private static HashMap<String,SignInfo> pool = new HashMap<String,SignInfo>(//报名信息的对象工厂@Deprecatedpublic static SignInfo(){return new SignInfo();}//从池中获得对象public static SignInfo getSignInfo(String key){//设置返回对象SignInfo result = null;//池中没有该对象, 则建立, 并放入池中if(!pool.containsKey(key)){System.out.println(key + "----建立对象, 并放置到池中");result = new SignInfo4Pool(key);pool.put(key, result);}else{result = pool.get(key);System.out.println(key +"---直接从池中取得");}return result;}}方法都很简单, 不多解释。 读者需要注意一点的是@Deprecated注解, 不要有删除投产中代码的念头, 如果方法或类确实不再使用了, 增加该注解, 表示该方法或类已经过时, 尽量不要再使用了, 我们应该保持历史原貌, 同时也有助于版本向下兼容, 特别是在产品级研发中。我们再来看看客户端是如何调用的, 如代码清单28-6所示。代码清单28-6 场景类public class Client {public static void main(String[] args) {//初始化对象池for(int i=0;i<4;i++){String subject = "科目" + i;//初始化地址for(int j=0;j<30;j++){String key = subject + "考试地点"+j;SignInfoFactory.getSignInfo(key);}}SignInfo signInfo = SignInfoFactory.getSignInfo("科目1考试地点1");}}运行结果如下所示:科目3考试地点25----建立对象, 并放置到池中科目3考试地点26----建立对象, 并放置到池中科目3考试地点27----建立对象, 并放置到池中科目3考试地点28----建立对象, 并放置到池中科目3考试地点29----建立对象, 并放置到池中科目1考试地点1---直接从池中取得前面还有很多的对象创建提示语句, 不再复制。 通过这样的改造后, 我们想想内存中有多少个SignInfo对象? 是的, 最多120个对象, 相比之前几万个SignInfo对象优化了非常多。 细心的读者可能注意到了SignInfo4Pool类基本上没有跑出我们的视线范围, 仅仅在工厂方法中使用到了, 尽量缩小变更引起的风险, 想想看我们的改动是不是很小, 只要在展示层中拼一个字符串, 然后传递到工厂方法中就可以了。通过这样的改造后, 第三天系统运行得非常稳定, CPU占用率也下降了, 而且以后再也没有出现类似问题, 这就是享元模式的功劳。28.2 享元模式的定义享元模式(Flyweight Pattern) 是池技术的重要实现方式, 其定义如下: Use sharing tosupport large numbers of fine-grained objects efficiently.(使用共享对象可有效地支持大量的细粒度的对象。 )享元模式的定义为我们提出了两个要求: 细粒度的对象和共享对象。 我们知道分配太多的对象到应用程序中将有损程序的性能, 同时还容易造成内存溢出, 那怎么避免呢? 就是享元模式提到的共享技术。 我们先来了解一下对象的内部状态和外部状态。要求细粒度对象, 那么不可避免地使得对象数量多且性质相近, 那我们就将这些对象的信息分为两个部分: 内部状态(intrinsic) 与外部状态(extrinsic) 。● 内部状态内部状态是对象可共享出来的信息, 存储在享元对象内部并且不会随环境改变而改变,如我们例子中的id、 postAddress等, 它们可以作为一个对象的动态附加信息, 不必直接储存在具体某个对象中, 属于可以共享的部分。● 外部状态外部状态是对象得以依赖的一个标记, 是随环境改变而改变的、 不可以共享的状态, 如我们例子中的考试科目+考试地点复合字符串, 它是一批对象的统一标识, 是唯一的一个索引值。有了对象的两个状态, 我们就可以来看享元模式的通用类图, 如图28-3所示。图28-3 享元模式的通用类图类图也很简单, 我们先来看我们享元模式角色名称。● Flyweight——抽象享元角色它简单地说就是一个产品的抽象类, 同时定义出对象的外部状态和内部状态的接口或实现。● ConcreteFlyweight——具体享元角色具体的一个产品类, 实现抽象角色定义的业务。 该角色中需要注意的是内部状态处理应该与环境无关, 不应该出现一个操作改变了内部状态, 同时修改了外部状态, 这是绝对不允许的。● unsharedConcreteFlyweight——不可共享的享元角色不存在外部状态或者安全要求(如线程安全) 不能够使用共享技术的对象, 该对象一般不会出现在享元工厂中。● FlyweightFactory——享元工厂职责非常简单, 就是构造一个池容器, 同时提供从池中获得对象的方法。享元模式的目的在于运用共享技术, 使得一些细粒度的对象可以共享, 我们的设计确实也应该这样, 多使用细粒度的对象, 便于重用或重构。 我来看享元模式的通用代码, 先看抽象享元角色, 如代码清单28-7所示。代码清单28-7 抽象享元角色public abstract class Flyweight {//内部状态private String intrinsic;//外部状态protected final String Extrinsic;//要求享元角色必须接受外部状态public Flyweight(String _Extrinsic){this.Extrinsic = _Extrinsic;}//定义业务操作public abstract void operate();//内部状态的getter/setterpublic String getIntrinsic() {return intrinsic;}public void setIntrinsic(String intrinsic) {this.intrinsic = intrinsic;}}抽象享元角色一般为抽象类, 在实际项目中, 一般是一个实现类, 它是描述一类事物的方法。 在抽象角色中, 一般需要把外部状态和内部状态(当然了, 可以没有内部状态, 只有行为也是可以的) 定义出来, 避免子类的随意扩展。 我们再来看具体的享元角色, 如代码清单28-8所示。代码清单28-8 具体享元角色public class ConcreteFlyweight1 extends Flyweight{//接受外部状态public ConcreteFlyweight1(String _Extrinsic){super(_Extrinsic);}//根据外部状态进行逻辑处理public void operate(){//业务逻辑}}public class ConcreteFlyweight2 extends Flyweight{//接受外部状态public ConcreteFlyweight2(String _Extrinsic){super(_Extrinsic);}//根据外部状态进行逻辑处理public void operate(){//业务逻辑}}这很简单, 实现自己的业务逻辑, 然后接收外部状态, 以便内部业务逻辑对外部状态的依赖。 注意, 我们在抽象享元中对外部状态加上了final关键字, 防止意外产生, 什么意外?获得了一个外部状态, 然后无意修改了一下, 池就混乱了!注意 在程序开发中, 确认只需要一次赋值的属性则设置为final类型, 避免无意修改导致逻辑混乱, 特别是Session级的常量或变量。我们继续看享元工厂, 如代码清单28-9所示。代码清单28-9 享元工厂public class FlyweightFactory {//定义一个池容器private static HashMap<String,Flyweight> pool= new HashMap<String,Flyweight//享元工厂public static Flyweight getFlyweight(String Extrinsic){//需要返回的对象Flyweight flyweight = null;//在池中没有该对象if(pool.containsKey(Extrinsic)){flyweight = pool.get(Extrinsic);}else{//根据外部状态创建享元对象flyweight = new ConcreteFlyweight1(Extrinsic);//放置到池中pool.put(Extrinsic, flyweight);}return flyweight;}}28.3 享元模式的应用28.3.1 享元模式的优点和缺点享元模式是一个非常简单的模式, 它可以大大减少应用程序创建的对象, 降低程序内存的占用, 增强程序的性能, 但它同时也提高了系统复杂性, 需要分离出外部状态和内部状态, 而且外部状态具有固化特性, 不应该随内部状态改变而改变, 否则导致系统的逻辑混乱。28.3.2 享元模式的使用场景在如下场景中则可以选择使用享元模式。● 系统中存在大量的相似对象。● 细粒度的对象都具备较接近的外部状态, 而且内部状态与环境无关, 也就是说对象没有特定身份。● 需要缓冲池的场景。28.4 享元模式的扩展28.4.1 线程安全的问题线程安全是一个老生常谈的话题, 只要使用Java开发都会遇到这个问题, 我们之所以要在今天的享元模式中提到该问题, 是因为该模式有太大的几率发生线程不安全, 为什么呢?我们还以报考系统为例来说明这个问题。 大家有没有想过, 为什么要以考试科目+考试地点作为外部状态呢? 为什么不能以考试科目或者考试地点作为外部状态呢? 这样池中的对象会更少! 可以! 完全可以! 我们把程序以考试科目为外部状态, 把享元工厂稍作修改, 如代码清单28-10所示。代码清单28-10 报考信息工厂public class SignInfoFactory {//池容器private static HashMap<String,SignInfo> pool = new HashMap<String,SignInfo>(//从池中获得对象public static SignInfo getSignInfo(String key){//设置返回对象SignInfo result = null;//池中没有该对象, 则建立, 并放入池中if(!pool.containsKey(key)){result = new SignInfo();pool.put(key, result);}else{result = pool.get(key);}return result;}}下面做很小的改动, 只修改了黑色字体部分。 为了展示多线程的情况, 我们写一个多线程的类, 如代码清单28-11所示。代码清单28-11 多线程场景public class MultiThread extends Thread {private SignInfo signInfo;public MultiThread(SignInfo _signInfo){this.signInfo = _signInfo;}public void run(){if(!signInfo.getId().equals(signInfo.getLocation())){System.out.println("编号: "+signInfo.getId());System.out.println("考试地址: "+signInfo.getLocation());System.out.println("线程不安全了! ");}}}在run方法中判断特殊值, 检查是否是线程安全, 我们来看看场景类, 如代码清单28-12所示。代码清单28-12 场景类public class Client {public static void main(String[] args) {//在对象池中初始化4个对象SignInfoFactory.getSignInfo("科目1");SignInfoFactory.getSignInfo("科目2");SignInfoFactory.getSignInfo("科目3");SignInfoFactory.getSignInfo("科目4");//取得对象SignInfo signInfo = SignInfoFactory.getSignInfo("科目2");while(true){signInfo.setId("ZhangSan");signInfo.setLocation("ZhangSan");(new MultiThread(signInfo)).start();signInfo.setId("LiSi");signInfo.setLocation("LiSi");(new MultiThread(signInfo)).start();}}}模拟实际的多线程情况, 在对象池中我们保留4个对象, 然后启动N多个线程来模拟,我们马上就看到如下的提示:编号: LiSi考试地址: ZhangSan线程不安全了!看看, 线程不安全了吧, 这是正常的, 设置的享元对象数量太少, 导致每个线程都到对象池中获得对象, 然后都去修改其属性, 于是就出现一些不和谐数据。 只要使用Java开发,线程问题是不可避免的, 那我们怎么去避免这个问题呢? 享元模式是让我们使用共享技术,而Java的多线程又有如此问题, 该如何设计呢? 没什么可以参考的标准, 只有依靠经验, 在需要的地方考虑一下线程安全, 在大部分的场景下都不用考虑。 我们在使用享元模式时, 对象池中的享元对象尽量多, 多到足够满足业务为止。28.4.2 性能平衡尽量使用Java基本类型作为外部状态。 在报考系统中, 我们不考虑系统的修改风险, 完全可以重新建立一个类作为外部状态, 因为这才完全符合面向对象编程的理念。 好, 我们实现处理, 先看类图, 如图28-4所示。图28-4 类作为外部状态我们首先来看ExtrinsicState外部状态类, 如代码清单28-13所示。代码清单28-13 外部状态类public class ExtrinsicState {//考试科目private String subject;//考试地点private String location;public String getSubject() {return subject;}public void setSubject(String subject) {this.subject = subject;}public String getLocation() {return location;}public void setLocation(String location) {this.location = location;}@Overridepublic boolean equals(Object obj){if(obj instanceof ExtrinsicState){ExtrinsicState state = (ExtrinsicState)obj;return state.getLocation().equals(location) && state.getSubject}return false;}@Overridepublic int hashCode(){return subject.hashCode() + location.hashCode();}}注意, 一定要覆写equals和hashCode方法, 否则它作为HashMap中的key值是根本没有意义的, 只有hashCode值相等, 并且equals返回结果为true, 两个对象才相等, 也只有在这种情况下才有可能从对象池中查找获得对象。注意 如果把一个对象作为Map类的键值, 一定要确保重写了equals和hashCode方法,否则会出现通过键值搜索失败的情况, 例如map.get(object)、 map.contains(object)等会返回失败的结果。SignInfo的修改较小, 仅在SignInfo中引入该ExtrinsicState外部状态对象, 在此不再赘述。我们再来看享元工厂, 它是以ExtrinsicState类作为外部状态, 如代码清单28-14所示。代码清单28-14 享元工厂public class SignInfoFactory {//池容器private static HashMap<ExtrinsicState,SignInfo> pool = new HashMap <Extrinsi//从池中获得对象public static SignInfo getSignInfo(ExtrinsicState key){//设置返回对象SignInfo result = null;//池中没有该对象, 则建立, 并放入池中if(!pool.containsKey(key)){result = new SignInfo();pool.put(key, result);}else{result = pool.get(key);}return result;}}重点是看看我们的场景类, 我们来测试一下性能差异, 如代码清单28-15所示。代码清单28-15 场景类public class Client {public static void main(String[] args) {//初始化对象池ExtrinsicState state1 = new ExtrinsicState();state1.setSubject("科目1");state1.setLocation("上海");SignInfoFactory.getSignInfo(state1);ExtrinsicState state2 = new ExtrinsicState();state2.setSubject("科目1");state2.setLocation("上海");//计算执行100万次需要的时间long currentTime = System.currentTimeMillis();for(int i=0;i<1000000;i++){SignInfoFactory.getSignInfo(state2);}long tailTime = System.currentTimeMillis();System.out.println("执行时间: "+(tailTime - currentTime) + " ms");}}运行结果如下所示:执行时间: 172 ms同样, 我们看看以String类型作为外部状态的运行情况, 如代码清单28-16所示。代码清单28-16 场景类public class Client {public static void main(String[] args) {String key1 = "科目1上海";String key2 = "科目1上海";//初始化对象池SignInfoFactory.getSignInfo(key1);//计算执行10万次需要的时间long currentTime = System.currentTimeMillis();for(int i=0;i<10000000;i++){SignInfoFactory.getSignInfo(key2);}long tailTime = System.currentTimeMillis();System.out.println("执行时间: "+(tailTime - currentTime) + " ms");}}运行结果如下所示:执行时间: 78 ms看到没? 一半的效率, 这还是非常简单的享元对象, 看看我们重写的equals方法和hashCode方法, 这段代码是必须实现的, 如果比较复杂, 这个时间差异会更大。各位, 想想看, 使用自己编写的类作为外部状态, 必须覆写equals方法和hashCode方法, 而且执行效率还比较低, 这种吃力不讨好的事情最好别做, 外部状态最好以Java的基本类型作为标志, 如String、 int等, 可以大幅地提升效率。28.5 最佳实践Flyweight是拳击比赛中的特用名词, 意思是“特轻量级”, 指的是51公斤级比赛, 用到设计模式中是指我们的类要轻量级, 粒度要小, 这才是它要表达的意思。 粒度小了, 带来的问题就是对象太多, 那就用共享技术来解决。享元模式在Java API中也是随处可见, 如这样的程序就是一个很好的例子, 如代码清单28-17所示。代码清单28-17 API中的享元模式public class Test {public static void main(String[] args) {String str1 = "和谐";String str2 = "社会";String str3 = "和谐社会";String str4;str4 = str1 + str2;System.out.println(str3 == str4);str4 = (str1 + str2).intern();System.out.println(str3 == str4);}}看看Java的帮助文件中String类的intern方法。 如果是String的对象池中有该类型的值, 则直接返回对象池中的对象, 那当然相等了。需要说明一下的是, 虽然可以使用享元模式可以实现对象池, 但是这两者还是有比较大的差异, 对象池着重在对象的复用上, 池中的每个对象是可替换的, 从同一个池中获得A对象和B对象对客户端来说是完全相同的, 它主要解决复用, 而享元模式在主要解决的对象的共享问题, 如何建立多个可共享的细粒度对象则是其关注的重点。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!