关注「Java视界」公众号,获取更多技术干货

享元模式Flyweight —— 将拿来主义进行到底!

在享元模式中通常会出现工厂模式,需要创建一个享元工厂来负责维护一个享元池(Flyweight Pool)用于存储具有相同内部状态的享元对象。主要用于减少创建对象的数量,以减少内存占用和提高性能。

一、什么是享元模式?

在开发中会出现重复使用同一个对象的情况,每次使用都 new 一个对象出来。这样的话对于内存来说就需要多次反复的申请内存了,触发GC的频率高了会影响JVM的效率。那么能不能创建 new 一个对象,然后使用的时候就共同使用一个就好了。这也就是享元模式的含义所在了——共享一个对象。

因此享元模式主要在于共享通用对象,减少内存的使用,提升系统的访问效率。

而这部分共享对象通常比较耗费内存或者需要查询大量接口或者使用数据库资源,因此统一抽离作为共享对象使用。

比如数据库连接池的使用、多线程线程池的使用,在一些游戏场景下,很多都是客户端需要进行渲染地图效果,比如;树木、花草、鱼虫,通过设置不同元素描述,使用享元公用对象,减少内存的占用,让客户端的游戏更加流畅。

FlyWeight : 是抽象享元角色,是产品抽象类,同时定义出对象外部状态和内部状态

  • 内部状态 是存储在享元对象内部并且不会随环境改变而改变的状态,因此内部状态可以共享。
  • 外部状态 是随环境改变而改变的、不可以共享的状态。享元对象的外部状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候再传入到享元对象内部。一个外部状态与另一个外部状态之间是相互独立的。

ConcreteFlyWeight : 是具体的享元角色,是具体的产品类,实现抽象角色定义相关业务

UnshareaConcreteFlyWeight : 是不可共享角色

FlyWeightFactory : 享元工厂类,用于构建一个池容器,同时提供获取方法

二、代码实现

假设我们做饭的时候,每次做饭之后就把锅扔了,要做饭的时候又去准备新的锅的话,先不说浪费钱了,恐怕还得饿肚子。

实际生活中我们就是利用享元模式解决的,每次的锅不扔,而是接着炒新的菜,只有在没有匹配我们要做的饭的锅时才会去准备新的锅:

public abstract class Pot {
    public void cooking(){};
}
@AllArgsConstructor
public class ConcretePot extends Pot {
    private String type;

    @Override
    public void cooking() {
        Console.log("使用" + type + "做饭。");
    }
}
public class PotFactory {

    private HashMap<String, ConcretePot> pool = new HashMap<>();

    public Pot getPot(String type) {
        if (!pool.containsKey(type)) {
            pool.put(type, new ConcretePot(type));
        }
        return pool.get(type);
    }
}
public class Client {
    public static void main(String[] args) {
        PotFactory potFactory = new PotFactory();

        Pot pot01 = potFactory.getPot("平底锅");
        pot01.cooking();

        Pot pot02 = potFactory.getPot("高压锅");
        pot02.cooking();
    }
}
使用平底锅做饭。
使用高压锅做饭。

这里的 pool 就是内存中的一块存储,若在pool中已存在则直接复用对象而不新增对象,也就是“拿来主义”。

三、适用场景

优点:

  • 可以极大减少内存中对象的数量,使得相同或相似对象在内存中只保存一份,从而可以节约系统资源,提高系统性能。
  • 享元模式的外部状态相对独立,而且不会影响其内部状态,从而使得享元对象可以在不同的环境中被共享。

缺点:

  • 享元模式使得系统变得复杂,需要分离出内部状态和外部状态,这使得程序的逻辑复杂化。
  • 为了使对象可以共享,享元模式需要将享元对象的部分状态外部化,而读取外部状态将使得运行时间变长。

适用场景

  • 一个系统有大量相同或者相似的对象,造成内存的大量耗费。
  • 对象的大部分状态都可以外部化,可以将这些外部状态传入对象中。
  • 在使用享元模式时需要维护一个存储享元对象的享元池,而这需要耗费一定的系统资源,因此,应当在需要多次重复使用享元对象时才值得使用享元模式。

四、享元模式在JDK中的应用举例

4.1 Integer类

Integer有一个初始化值默认范围(-128到127),如果在这个范围内Integer.valueOf(n)这个方法返回缓存中的值,否则创建一个新的Integer对象:

看下源码:

用一个Integer数组先缓存了,后面如果是是在区间内的数直接从缓存数组中取,否则才构造新的Integer。

但你想想为啥不是整个范围的数都这样缓存起来?看看上面说的享元模式的缺点就清楚了。

4.2 String类

Java中将String类定义为final(不可改变的),JVM中字符串一般保存在字符串常量池中,java会确保一个字符串在常量池中只有一个拷贝,这个字符串常量池在JDK6.0以前是位于常量池中,位于永久代,而在JDK7.0中,JVM将其从永久代拿出来放置于堆中。

String字符串常量池的设计主要目的是为了实现数据共享,可以分为静态常量池和运行时常量池。

  • 静态常量池: 程序*.class文件在加载时自动将此程序中保存的字符串、普通常量、类等全部进行分配的常量池。
  • 运行时常量池: 程序*.class文件加载后,里面可能有的变量,这时候的常量池。
public class Main {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = "hello";
        String s3 = "he" + "llo";
        String s4 = "hel" + new String("lo");
        String s5 = new String("hello");
        String s6 = s5.intern();
        String s7 = "h";
        String s8 = "ello";
        String s9 = s7 + s8;
        System.out.println(s1==s2);//true
        System.out.println(s1==s3);//true
        System.out.println(s1==s4);//false
        System.out.println(s1==s9);//false
        System.out.println(s4==s5);//false
        System.out.println(s1==s6);//true
    }
}

String类final修饰的,以字面量的形式创建String变量时,jvm会在编译期间就把该字面量hello放到字符串常量池中,由Java程序启动的时候就已经加载到内存中了。

这个字符串常量池的特点就是有且只有一份相同的字面量,如果有其它相同的字面量,jvm则返回这个字面量的引用,如果没有相同的字面量,则在字符串常量池创建这个字面量并返回它的引用。

  • 由于s2指向的字面量hello在常量池中已经存在了(s1先于s2),于是jvm就返回这个字面量绑定的引用,所以s1==s2
  • s3中字面量的拼接其实就是hello,jvm在编译期间就已经对它进行优化,所以s1和s3也是相等的。
  • s4中的new String("lo")生成了两个对象,lo,new String("lo"),lo存在字符串常量池,new String("lo") 存在堆中,String s4 = "hel" + new String("lo")实质上是两个对象的相加,编译器不会进行优化,相加的结果存在堆中,而s1存在字符串常量池中,当然不相等。s1=s9的原理一样。

  • s4==s5两个相加的结果都在堆中,不用说,肯定不相等。

  • s1==s6中,s5.intern()方法能使一个位于堆中的字符串在运行期间动态地加入到字符串常量池中(字符串常量池的内容是程序启动的时候就已经加载好了),如果字符串常量池中有该对象对应的字面量,则返回该字面量在字符串常量池中的引用,否则,创建复制一份该字面量到字符串常量池并返回它的引用。因此s1==s6输出true。

posted @ 2022-06-25 14:02  沙滩de流沙  阅读(28)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货