相信很多同学小时候都玩过《超级马里奥》这款游戏,不知道你是否还记得你曾经营救过的公主?你们在一起了吗?哈哈!小时候我家可没这个条件,经常跑到同学家里玩(或者看别人玩),可羡慕死我了。小的时候只知道玩,长大后才知道原来这么多关卡的马里奥竟然只占用40KB,我现在随手拍张照片也有个5MB左右呀!后来经过查阅资料才知道其中的道道:
基本原理就如上所示,当然游戏内部还有一些其他优化措施,有兴趣的同学自行查阅。超级马里奥游戏正是利用了8*8的瓦片作为基本单元减少了看似复杂页面的内存占用量,因为实际屏幕中很多元素实际上是相同的,或者说均出自预先设置好的原型。不仅只有马里奥游戏会存在这种,几乎很多大型游戏中肯定也都会通过原型来复用一些元素(对象),比如游戏中的许许多多的同类型小怪,这些小怪的行为、部分数据实际上都一样,只存在某时间点上收到游戏玩家的或预设的影响。那么,在具体程序中,这些小怪对象你认为有必要在内存中互相隔离吗?每个小怪对象都是由创建而来的吗?
小怪的数量众多,并且很多属性数据实际上是公用的,因此就可借鉴于马里奥,这些公用的属性和行为完全可以抽取为原型。如果小怪对象的初始化属性过程十分复杂,当需创建的小怪数量上来时自然就会导致客户端卡顿,影响游戏玩家体验。因此,小怪对象的创建能否通过其原型对象直接复制而来呢?答案是可以,这也是我们今天要讲的原型模式。
一、原型模式的理解
原型模式是一种对象的创建型模式。其一般定义如下:
使用原型实例指定创建对象的种类,并且通过复制这些原型来创建新的对象。
通过定义我们首先需要明确"原型"是指什么。既然能够通过复制原型来获取新的对象,那原型肯定是指"对象"而不是"类型" 。复制原型创建出来的新对象的类型肯定也是和原型的类型一致。然后第二个问题:为什么要通过复制来获取新对象而不是使用New关键字呢?这就要从对象本质上来说了,对象实际上等价于类型+数据,或者等价于行为方法+数据属性,使用new之类方式创建的对象是没有数据,而通过复制对象创建的对象是保留了之前的原型对象的数据!因此,从这里就能够看出一般情况下使用复制原型对象创建对象(即,原型模式)的动机所在--减少重复对象数据的创建及内存占用 。
原型和拷贝的关系。在很多参考资料中,经常把这两个概念混淆,提到原型模式,就直接等同于对象拷贝。这没有太大的问题,但问题也已经不小了。原型就是用于拷贝的,从定义中就明确了,但问题是拷贝的一定是原型吗?肯定不一定。原型这个概念是指原型对象的数据和行为可以直接被其他对象所复用,具有很强的业务含义。那我前面有说没太大问题呢?因为在实际业务开发中,能使用原型模式业务场景真的很少,我只能想到像游戏之类具有大量重复、数据几乎完全一致的元素存在的场景。在我们常常解除的业务开发中,几乎不会碰到这种需要同时存在许多同样数据的不同对象存在的,一般一个对象就直接代表了一个业务含义。我们更常见确实是对象拷贝,但我们一定要清楚"原型模式存在对象拷贝,但对象拷贝不一定是原型模式 "。
概念总结:
- 原型模式主要解决大量重复对象数据的创建效率及内存占用问题。
- 原型就是对象而不是类型
- 原型模式肯定存在对象拷贝,但对象拷贝不一定是原型模式
二、应用实践
上一章节限于个人水平有限,理解角度与主流理解不完全契合,也会存在让大家误解的地方。因此,这个章节就通过具体的案例来尝试说明下我对原型模的理解。
本章给出的案例是关于英雄联盟(俗称,LOL)游戏。在这款游戏中角色以英雄(由玩家操控)为主,游戏系统会每隔一段时间刷新兵线(由几个小兵以及大兵组成,如下图所示)。玩家会通过杀死对方的兵线来获取一定的游戏经济,然后购买适合的游戏装备,进而提升自身的攻击或防御属性。其中每一个小兵都有自身属性,比如攻击力,防御力,移动速度,攻击速度等等,并且这些属性后随着时间(游戏进程)还会发生改变。
案例分析:如上图所示,一条兵线实际上由三种类型:走在前面的是近战兵(防御力稍强),走在后面的是远程兵(攻击力稍强),中间的是大兵或炮车兵,一般属性都比小兵好,游戏玩家杀死其之后获取的经济也多。我们先来考虑近战兵对象的生成,在游戏的某时刻近战兵可能会存在很多,很明显这些近战兵的很多属性可能都几乎不变,比如渲染的图片url,移动速度,攻击速度等。这种就很适合我们前面所说的原型模式。我们可以设置一个近战兵原型对象,其属性初始化完,游戏内部每次生成近战兵的时候会可以直接通过复制原型对象来得到一个新的近战兵,这个原型对象就是近战兵的初始状态。
下面我们使用原型模式来模拟近战兵的生成过程:
基本属性类:
java
/**
* 属性实体类
*/
@Data
public class BaseAttributes {
private int physicalAttack; // 物理攻击力
private int magicalAttack; // 魔法攻击力
private int physicalDefense; // 物理防御
private int magicalDefense; // 魔法防御
private int attackSpeed; // 攻击速度
private int movementSpeed; // 移动速度
private String imageUrl; // 渲染URL
private int maxHealth; // 最大血量
private int initialPosition; // 初始位置
public BaseAttributes(int physicalAttack, int magicalAttack, int physicalDefense,
int magicalDefense, int attackSpeed, int movementSpeed,
String imageUrl, int maxHealth, int initialPosition) {
this.physicalAttack = physicalAttack;
this.magicalAttack = magicalAttack;
this.physicalDefense = physicalDefense;
this.magicalDefense = magicalDefense;
this.attackSpeed = attackSpeed;
this.movementSpeed = movementSpeed;
this.imageUrl = imageUrl;
this.maxHealth = maxHealth;
this.initialPosition = initialPosition;
}
}
近战兵类:
java
@Getter
@Setter
public class MeleeSoldier {
private int realtimeHealth; // 实时血量
private long realtimeLocation; // 实时位置(一般应该是经纬度,这里简化为一维)
private BaseAttributes baseAttributes; // 基本属性
public void attack() {
System.out.println("勇往直前...");
}
}
近战兵原型类:
java
public class MeleeSoldierPrototype extends MeleeSoldier implements Cloneable {
public static final MeleeSoldierPrototype ins = new MeleeSoldierPrototype();
private MeleeSoldierPrototype() {
// 初始化近战兵基本属性
initBaseAttributes(this);
// 初始化近战兵原型属性
initProtoAttributes(this);
}
private void initBaseAttributes(MeleeSoldierPrototype ins) {
// 这里省略一大段复杂的基本属性初始化逻辑
// 很多属性可能与时间相关,比如攻击力、防御力
// 比如加载图片资源,可能还会涉及资源的存在性、安全性检查
// this.setBaseAttributes(new BaseAttributes(...));
}
private void initProtoAttributes(MeleeSoldierPrototype ins) {
// 设置原型对象血量为最大血量
this.setRealtimeHealth(this.getBaseAttributes().getMaxHealth());
// 设置原型对象位置为初始化位置
this.setRealtimeLocation(this.getBaseAttributes().getInitialPosition());
}
@Override
public MeleeSoldier clone() {
try {
// 不复制内部不可变状态,baseAttributes属性会被复用
return (MeleeSoldier) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
客户端创建近战兵对象示例:
java
public class Client {
public static void main(String[] args) {
// 需要获取2个近战兵对象
MeleeSoldier meleeSoldier1 = MeleeSoldierPrototype.ins.clone();
meleeSoldier1.attack();
MeleeSoldier meleeSoldier2 = MeleeSoldierPrototype.ins.clone();
meleeSoldier1.attack();
System.out.println(meleeSoldier1.getBaseAttributes() == meleeSoldier2.getBaseAttributes()); // true
}
}
从上述代码设计中,近战兵原型类继承了近战兵类,并实现了Cloneable接口。接口提供了复制能力,前者保证了复制后的对象类型也是近战兵。上述代码还有几个细节点,第一个细节就是原型对象是单例的,难道还不是单例的么?第二个细节是直接提供近战兵原型类而不是由近战兵类来实现Cloneable接口。直接复制近战兵是不符合业务逻辑,难道正在作战中的小兵还能分身不成?第三个细节就是由原型对象创建出的多个近战兵对象的基本属性实际是同一个属性对象。
通过使用原型模式来创建近战兵对象,直接跳过了复杂的属性初始化逻辑,并且多个创建对象可以复用一些基本的、不变的数据。两个方面提高了对象的创建效率以及内存占用率,这就是原型模式给我们带来的好处。
【上文只考虑了近战兵种类的原型,读者也可以试着实现其他兵种类的原型,如果原型多的话,可以考虑封装为原型管理器,这里就不再赘述了】
原型模式的优点:
- 提高了对象的创建效率以及内存有效占用率
- 适合创建大量重复对象的场景。
原型模式的缺点:
- 应用场景较少
三、拷贝
原型模式基本上已经讲完了,因为原型模式中肯定会涉及到拷贝操作,这里也梳理下JAVA语言中的拷贝相关知识。
3.1 JDK拷贝
先从JDK开始说起,JDK1.0提供了对象的拷贝方法-Object.clone():
java
protected native Object clone() throws CloneNotSupportedException;
克隆(拷贝)实际上是个native方法,其本质上是利用了JVM底层完成内存空间的复制处理。这里还有个异常,如果调用clone()方法的对象类型没有实现Cloneable接口时,就会抛出CloneNotSupportedException异常。Cloneable接口就是个标记接口,用于JVM识别是否能够进行克隆操作的标识。
JAVA中对象之前引用链可能十分的长, JVM在执行对象内存空间的复制操作时,是不会区分是否为引用类型并复制另外一份空间的,这样操作的效率可能十分低,并且可能也不是用户希望看到的。因此JVM执行克隆操作时只会复制当前对象所在的内存区域数据,这种拷贝就是浅拷贝 。浅拷贝出的新对象中的引用对象和原对象的引用对象实际上指向的是同一份内存区域。与之相对应的深拷贝 ,就是拷贝后的新对象和旧对象之间没有复用内存空间,两者的数据操作、变更不会互相影响,完全独立。
浅拷贝和深拷贝都有其独自的应用场景,比如本文原型模式中就能够利用浅拷贝的机制复用对象以达到节省内存的目的。但是,在我们平时日常开发中,对象拷贝一般都期望以使用深拷贝。那么我们怎么实现深拷贝呢?
要想实现深拷贝,我们需要对拷贝对象的引用属性再调用拷贝方法。通过多级引用属性浅拷贝来完成深拷贝的效果。但需要注意的是,如果对象引用属性所属类没有实现Cloneable接口的话,该属性也无法被复制。
java
@Data
public class CopyEntity implements Cloneable{
private int intAttr;
private String stringAttr;
private ArrayList<Long> listAttr;
private BaseAttributes baseAttributes; // 无法克隆
@Override
public CopyEntity clone() {
try {
CopyEntity copyEntity = (CopyEntity) super.clone();
if(listAttr != null) {
copyEntity.setListAttr((ArrayList<Long>) copyEntity.listAttr.clone());
}
return copyEntity;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
如上示例中,intAttr、stringAttr可以通过Object#clone方法复制,listAttr就需要调用ArrayList#clone()方法来克隆,而BaseAttributes 由于没有实现Cloneable接口,这个属性对象内存空间是无法被复制的,因此CopyEntity对象并不能完全深度拷贝。
这里需要注意stringAttr属性是String类型,其实也是属于引用类型,并且String也没有实现Cloneable接口,但是String类型属性是可以复制的。因为在JVM中String可以算是个特殊类型,其引用的是常量池,即使拷贝对象修改了stringAttr值(实际修改的是引用),也不会影响到原对象string值。
使用Clone方法来复制对象时性能最高的,甚至比创建对象更快,因为其内部就是复制内存。
3.2 JDK序列化
JDK中可以通过序列化和反序列化的方式实现深拷贝,虽然这种方式很简单,但是克隆效率会稍慢。
在Java中,被序列化或被反序列化的对象类型必须实现Serializable接口,同时为了保证版本兼容,建议手动设置serialVersionUID值(默认系统自动添加)。Serializable接口也是一个标记型接口,没有实现Serializable接口序列化时会报出NotSerializableException异常。
为了实现深拷贝,我们可以将对象序列化为字节数组,然后将其反序列化为新对象。在这个序列化、反序列化中,源对象中所有引用过的对象都会进行拷贝,以此实现了深拷贝。【注意】但需要说明一点就是,序列化对象的所有引用成员对象都需要实现Serializable接口。
下面给出序列化、反序列化工具类代码:
java
public class SerializableUtil {
public static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream objOut = new ObjectOutputStream(out);
objOut.writeObject(obj);
objOut.flush();
objOut.close();
return out.toByteArray();
}
public static Object deserialize(byte[] bytes) throws IOException, ClassNotFoundException {
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
ObjectInputStream objIn = new ObjectInputStream(in);
Object obj = objIn.readObject();
objIn.close();
return obj;
}
}
3.3 第三方工具-Kyro序列化
Kryo是一个快速高效的Java 二进制对象图序列化框架。使用Kryo的序列化功能也可以实现对象的深拷贝。由于Kryo是第三方工具,因此需要引入maven依赖<com.esotericsoftware, kryo>,使用方式如下:
java
public static Object copyByKryo(Object obj) {
return kryoIns.copy(obj);
}
需注意kyro序列化过程不是线程安全的,使用过程需注意。【相关参考资料Kyro】
3.4 其他第三方工具
实际项目中常用的属性拷贝工具有很多,包括Apache的BeanUtils、Spring的BeanUtils、Cglib的BeanCopier、mapstruct等。这部分工具使用、性能对比等留给读者思考。Java对象拷贝原理剖析及最佳实践