在程序开发的过程中,往往会存在大量的重复的对象。重复的创建对象往往需要虚拟机开辟多块内存来存储这些对象,这就造成了内存的浪费。如果流量洪峰过来,而且对于系统的稳定性也是一个挑战。享元模式就可以解决我们所说的问题,“享元”何为享元,就是共享对象。重复的对象,无需重复创建,节约了内存空间。
享元的定义与特点
享元模式的定义提出了两种边界的要求,一个是内部状态,一个是外部状态。
- 内部状态指对象共享出来的信息,存储在享元信息内部,并且不回随环境的改变而改变;
- 外部状态指对象得以依赖的一个标记,随环境的改变而改变,不可共享。
后续的代码案例中,会有具体的体会。
享元模式的结构
享元模式的主要角色有如下。
- 抽象享元角色(Flyweight):是所有的具体享元类的基类,为具体享元规范需要实现的公共接口,非享元的外部状态以参数的形式通过方法传入。
- 具体享元(Concrete Flyweight)角色:实现抽象享元中的公共接口。
- 非享元(Unsharable Flyweight)角色:是不可以共享的外部状态,它以参数的形式注入具体享元的相关方法中。
- 享元工厂(Flyweight Factory)角色:享元对象的工厂,负责创建和管理享元角色。
享元模式结构图:
代码案例:
这里列举一个下期的案例,也是看网上举的比较典型的一个享元模式的案例。我们现在模拟下五子棋,然后棋子的种类只有两种,假如我们的棋盘是10x10的大小,如果设计下五子棋这个代码。 假如我们将棋子的颜色、坐标都包含在棋子的属性上。那么我们需要创建的棋子数目 2x10x10种可能。如果我们利用享元的设计思想,将相同的共享的角色棋子共享,是不是就只需要两个对象了,具体的坐标再抽离为不可共享的角色,使用时传入指定坐标。下面看下代码实现:
- 抽象享元角色(棋子)
//棋子抽象
public interface ChessPieces {
//下棋
void down(Point point);
}
- 具体享元角色1 黑棋
public class BlackChessPieces implements ChessPieces{
//棋子颜色
private String color;
public void setKey(String color) {
this.color = color;
}
@Override
public void down(Point point) {
System.out.println("当前黑棋子落盘位置 X:"+ point.getX() + ", Y:" + point.getY());
}
}
- 具体享元角色1 白棋
public class WhiteChessPieces implements ChessPieces {
// 棋子颜色
private String color;
public void setKey(String color) {
this.color = color;
}
@Override
public void down(Point point) {
System.out.println("当前黑棋子落盘位置 X:"+ point.getX() + ",Y:"+point.getY());
}
}
- 非享元角色享元角色中引用对象 坐标
public class Point {
//横坐标
private Integer x;
//纵坐标
private Integer y;
public Point(Integer x, Integer y) {
this.x = x;
this.y = y;
}
public Integer getX() {
return x;
}
public void setX(Integer x) {
this.x = x;
}
public Integer getY() {
return y;
}
public void setY(Integer y) {
this.y = y;
}
}
- 享元工厂类
public class ChessPiecesFactory {
//享元对象资源池
private Map<String, ChessPieces> pool = new HashMap<>();
public ChessPieces getChessPieces(String key){
ChessPieces poolChessPieces = pool.get(key);
if (poolChessPieces!=null) {
System.out.println("成功获取" + key + "棋子");
return poolChessPieces;
}else {
if (key.equals("黑色")) {
BlackChessPieces blackChessPieces = new BlackChessPieces();
blackChessPieces.setKey(key);
pool.put(key, blackChessPieces);
System.out.println("黑色棋子已被创建");
return blackChessPieces;
}else {
WhiteChessPieces whiteChessPieces = new WhiteChessPieces();
whiteChessPieces.setKey(key);
pool.put(key, whiteChessPieces);
System.out.println("白色棋子已被创建");
return whiteChessPieces;
}
}
}
public int poolSize(){
return pool.size();
}
}
- 使用方 模拟两人下棋
public class Client {
public static void main(String[] args) {
ChessPiecesFactory chessPiecesFactory = new ChessPiecesFactory();
String black = "黑色";
String white = "白色";
//假如模拟两人下期 小A、小B
//A:黑子先走
ChessPieces chessPieces = chessPiecesFactory.getChessPieces(black);
Point point1 = new Point(0, 0);
chessPieces.down(point1);
System.out.println("棋子数量:"+ chessPiecesFactory.poolSize());
//B:白色
ChessPieces chessPieces2 = chessPiecesFactory.getChessPieces(white);
Point point2 = new Point(0, 1);
chessPieces2.down(point2);
System.out.println("棋子数量:"+ chessPiecesFactory.poolSize());
//A:黑色
ChessPieces chessPieces3 = chessPiecesFactory.getChessPieces(black);
Point point3 = new Point(1, 1);
chessPieces3.down(point3);
System.out.println("棋子数量:"+ chessPiecesFactory.poolSize());
}
}
每次获取棋子后,查看当前共享资源池中的对象数量.
执行结果:
当前黑棋子落盘位置 X:0, Y:0
棋子数量:1
白色棋子已被创建
当前黑棋子落盘位置 X:0,Y:1
棋子数量:2
成功获取黑色棋子
当前黑棋子落盘位置 X:1, Y:1
棋子数量:2
可以看到,当第二次我们下黑棋时候,棋的数量没有改变。
案例结构图:
享元模式的优缺点
优点:
- 相同对象只要保存一份,这降低了系统中对象的数量,从而降低了系统中细粒度对象给内存带来的压力,减少了内存使用不足的风险。
缺点:
- 为了使对象可以共享,需要将一些不能共享的状态外部化,这将增加程序的复杂性。
- 内外的边界界定不好把控,如果后续更改引用处也需要修改,违背了开闭原则。
- 享元模式的工厂类一直保存着享元对象,即使没有其他对象使用,垃圾回收器也不会去回收,会造成内存浪费。
享元模式的使用场景
系统设计的过程中,就需要考虑到这一点,如果一个对象频繁的创建,而且又有公共的部分能共享。并且创建的多个对象,可能会造成内存的压力,这时候需要考虑通过享元模式来优化。
- 系统中存在大量相同相似对象;
- 细粒度的对象都具备较为接近的外部状态,内部共享状态一致;
享元模式在JDK源码中的使用
1.享元模式在Integer中的使用
先看段代码:
public class Test {
public static void main(String[] args) {
Integer i1 = 88;
Integer i2 = 88;
Integer i3 = 188;
Integer i4 = 188;
System.out.println(i1==i2);
System.out.println(i3==i4);
}
}
第一个输出的结果是true,第二个是false,为什么会出现这种情况,不妨点开Integer的源码,就能找到其原因;看源码之前先明确一个问题,基本数据类型JDK默认都有装箱开箱的操作,什么意思呢,就是如果是这我们使用到了基本类型定义变量,例如我们上面例子所写的Integer i1 = 88
,JDK底层执行的其实是Integer i1 = Integer.valueOf(88);
的操作,反过来如果变量i1包装类型通过基本数据类型int接收int i = i1
,底层其实操作的是 int i = i1.intValue()
。明确了这一点,我们看下Integer的源码:
public static Integer valueOf(String s, int radix) throws NumberFormatException {
return Integer.valueOf(parseInt(s,radix));
}
// valueOf 方法
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
//注意变量 IntegerCache
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() {}
}
发现默认静态块中,随着类的加载,已经将[-128, 127]
的数据放到Integer cache[]
数组中。这就造成了我们案例中如果是这个范围中的数值,直接在cache中拿,如果超出界限,我们才会去创建新的Integer对象。我们可以看到,默认有最小的数,最大的数值赋值,给的127,我们也可以通过参数设置,来更改最大值,根据自己的需要。如源码中所示
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
可以在启动参数上添加-Djava.lang.Integer.IntegerCache.high=255
。
2.享元模式在String中的使用
先看一段代码:
String str1 = "a";
String str2 = "a";
String str3 = new String("a");
String str4 = new String("b") + new String("c");
str4.intern();
String str5 = "bc";
System.out.println(str1 == str2); //true
System.out.println(str1 == str3); //false
System.out.println(str4==str5);//true
如果猜对了所有的结果,说明对String底层的存储了解已经到位。回到问题的本质,为什么str1 == str2
结果为true。而str1 == str3
结果为false。String str1 = "a"
这种方式定义的字符串会放到JVM内存的常量池空间中。所以定义str2时,直接去引用常量池中已存在的字符串。
String str3 = new String("a");
会在堆空间中开辟一块内存,Str3的地址指向是新开辟堆空间的地址,所有str1和Str3不是同一个对象。
而String str4 = new String("b") + new String("c");
和String str5 = "bc";
,为什么又相同了,这中间有个方法str4.intern();
。此操作执行两步操作,判断常量池中是否有Str4的字符串,如果没有则把字符串的值存储在常量池中(JDK7,JDK6的方式不同,JDK6直接将字符串存储到常量池中,而JDK7中是把对象地址的引用存储在常量池中,当前案例是基于JDK8),所以在创建Str5时,直接就通过已存在的地址,指向同一个对象,所以比较的对象地址相同。str4==str5
,结果为true。
总结:Java String 类的实现中,JVM开辟一块存储区专⻔存储字符串常量,字符串常量池,类似于Integer中的IntegerCache。不过,跟IntegerCache不同的是,它并非事先创建好需要共享的对象,而是 在程序的运行期间,根据需要来创建和缓存字符串常量。
拓展
1.享元模式与池化技术区别
网上看到很多文章,可以通过享元模式来实现线程池、数据库连接池等。享元模式中的共享是对同一个对象多处都可以共同使用,而我们所说的线程池、数据库连接池等技术,这里共享是线程不可共享的;比如数据库连接池,是初始化好一批数据库连接,某个线程去使用时,用完再归还。线程之间是不可共享的,而本文介绍的享元,共享部分就是对象相同。整个生命周期,都被所有的使用者共同持有。
那这里通过享元模式去实现线程池,数据库连接池。就需要考虑到这两者“共享区别”。
✨✨ 欢迎🔔订阅个人的微信公众号 享及时博文更新