本文将会介绍到Java序列化与反序列化的原理,以及对Google Protobuf框架的简单介绍
序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程,而相反的过程就称为反序列化。
在java中允许我们创建可复用的对象,但是这些对象仅仅存在jvm的堆内存中,有可能被垃圾回收器回收掉而消失,也可能随着jvm的停止而消失,但是有的时候我们希望这些对象被持久化下来,能够在需要的时候重新读取出来。比如我们需要在网络中传输对象,首先就需要把对象序列化二进制,然后在网络中传输,接收端收到这些二进制数据后进行反序列化还原成对象,完成对象的网络传输,java的序列化和反序列化功能就可以帮助我们现实此功能。
那么java要怎么样才能实现序列化和反序列化呢?
Serializable接口
在java中要实现序列化和和反序列化只需要实现Serializable接口,任何视图将没有实现此接口的对象进行序列化和反序列化操作都会抛出NotSerializableException,下面是实现
public <T> byte[] serializer(T obj) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = null; try { oos = new ObjectOutputStream(baos); oos.writeObject(obj); } catch (IOException e) { logger.error("java序列化发生异常:{}",e); throw new RuntimeException(e); }finally{ try { if(oos != null)oos.close(); } catch (IOException e) { logger.error("java序列化发生异常:{}",e); } } return baos.toByteArray(); } public <T> T deserializer(byte[] data, Class<T> clazz) { ByteArrayInputStream bais = new ByteArrayInputStream(data); ObjectInputStream ois = null; try { ois = new ObjectInputStream(bais); return (T)ois.readObject(); } catch (Exception e) { logger.error("java反序列化发生异常:{}",e); throw new RuntimeException(e); }finally{ try { ois.close(); } catch (IOException e) { logger.error("java反序列化发生异常:{}",e); throw new RuntimeException(e); } } }
transient关键字
正常情况下,在序列化过程中,对象里面的属性都会被序列化,但是有的时候,我们想过滤掉某个属性不要被序列化,该怎么办呢,很简单java给我们提供了一个关键字来实现:transient,只要被transient关键字修饰了,就会被过滤掉
readObject和writeObject方法
在序列化过程中,如果被序列化的类中定义了writeObject 和 readObject 方法,虚拟机会试图调用对象类里的 writeObject 和 readObject 方法,进行用户自定义的序列化和反序列化。
如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。
用户自定义的 writeObject 和 readObject 方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。
细心的你肯定也发现了,我们在序列化的类里面定于了这两个方法,但是并没有显式的调用这两个方法,那到底是谁调用的,又是何时被调用的呢?
深入ByteArrayOutputStream类源码会发现其调用栈:
ObjectOutputStream.writeObject(Object obj)----------->writeObject0(Object obj, boolean unshared)----------->writeOrdinaryObject(Object obj,ObjectStreamClass desc,boolean unshared)----------->writeSerialData(Object obj, ObjectStreamClass desc)
在writeSerialData方法里面会先获取序列化类里面是否有writeObject(ObjectOutputStream out),有就会反射的调用,没有就执行默认的序列化方法defaultWriteFields(obj, slotDesc)。
ByteArrayInputStream也是同样的原理。
如果您读过在ArrayList的源码,你可能会发现在ArrayList中的字段elementData被关键字transient修饰了,而elementData字段是ArrayList存储元素的,难道ArrayList存储的元素序列化会被忽略吗?但是你会发现并没有被忽略,而是能正常的序列化和反序列化,这是为什么呢?答案就是,ArrayList写有上面提到的readObject和writeObject两个方法,ArrayList实际上是动态数组,每次在放满以后自动增长设定的长度值,如果数组自动增长长度设为50,而实际只放了1个元素,那就会序列化49个null元素。为了保证在序列化的时候不会将这么多null同时进行序列化,ArrayList把元素数组设置为transient,自定义序列化过程,这样可以优化存储。
Externalizable接口
除了Serializable 之外,java中还提供了另一个序列化接口Externalizable,继承了Serializable,该接口中定义了两个抽象方法:writeExternal()与readExternal()。当使用Externalizable接口来进行序列化与反序列化的时候需要开发人员重写writeExternal()与readExternal()方法。
序列化ID
虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致(就是 private static final long serialVersionUID)
序列化 ID 在 Eclipse 下提供了两种生成策略,一个是固定的 1L,一个是随机生成一个不重复的 long 类型数据(实际上是使用 JDK 工具生成),在这里有一个建议,如果没有特殊需求,就是用默认的 1L 就可以,这样可以确保代码一致时反序列化成功。那么随机生成的序列化 ID 有什么作用呢,有些时候,通过改变序列化 ID 可以用来限制某些用户的使用。
Protobuf框架的好处与用法介绍
我们知道java自带的序列化效率是非常低的,因为它序列化生成的字节数非常多(包含了很多类的信息),不太适合用于存储和在网络上传输,下面来介绍下google给我们提供一个序列化效率相当高的protobuff框架,比起java原生的序列化出来的字节数小十几倍。那么它是如何做到的呢?
以int类型为例,int在java的占用4个字节,如果我们不做特殊处理,int类型的值转化成二进制也需要占用4个字节的空间,但是protobuff却不是这样做的,请看下面代码:
while (true) { if ((value & ~0x7F) == 0) { UnsafeUtil.putByte(buffer, position++, (byte) value); break; } else { UnsafeUtil.putByte(buffer, position++, (byte) ((value & 0x7F) | 0x80)); value >>>= 7; } }
value & ~0x7F 是什么意思呢?0x7F取反跟value相与,那么value的低7位全部被置0了,如果此时相与的值等于0,说明value的值不会大于0x7F=127,就可以用一个字节来表示,大大节省了字节数,看个列子:
value=0x00000067,转换成二进制:
0000 0000 0000 0000 0000 0000 0110 0111
& 1111 1111 1111 1111 1111 1111 1000 0000
= 0000 0000 0000 0000 0000 0000 0000 0000
此时value & ~0x7F=0,当把value强制转换成byte类型时,int会被截断,只剩下低位字节,于是当value值小于128时,序列化后的字节就变成:0110 0111,一个字节就可以表示了。
问题来了,如果value的值大于0x7F呢,接着看(value & 0x7F) | 0x80这句代码,假设value=2240,
0000 0000 0000 0000 0000 1000 1100 0000 0x000008C0
& 0000 0000 0000 0000 0000 0000 0111 1111 0x0000007F
= 0000 0000 0000 0000 0000 0000 1100 0000 0x000000C0
| 0000 0000 0000 0000 0000 0000 1000 0000 0x00000080
= 0000 0000 0000 0000 0000 0000 1100 0000 0x000000C0
这个过程意思就是获取value的最低位字节,把这个字节的最高位置为1,表示后面还有可读字节。
对0x000000C0强转byte类型就变成:1100 0000,然后向右移7位:
0000 0000 0000 0000 0000 0000 0001 0001
重复上面的步骤,得到0001 0001,循环结束,最后得到:
1100 0000 0001 0001
2个字节就可以表示2240了,但是此时你会发现我们每次向右移动的是7位,移4次才能表示28位,但是int要占用32位,如果value的值比较大,假如等于2147483647,那么这是就需要5个字节来表示,综上所述protobuff表示一个int类型的值就不会固定4个字节,而是用1-5个字节动态来表示;那么你可能又会有疑问了,5个字节来表示一个int,字节数不是变多了么?其实从概率角度来看,我们业务上不能可能每一个int值都是一个非常大的值,所以还是可以为我们节省非常大的字节空间。同理long,double,float也是同样的原理。下面就以proptobuff3来介绍下protobuff的使用
一、整备
从protobuff官网下载protoc.exe可执行文件
二、编写proto文件,具体的语法参见官网文档
syntax = "proto3"; option java_package = "com.tpyyes.serialize.protobuf3"; option java_outer_classname = "PersonModule"; message Person { int32 age = 1; int64 time = 2; string name = 3; map<string,string> properties = 4; }
三、编译成java类
e:/study/protobuf/bin/protoc.exe -I=D:/workspace/serialize/src/main/java/com/tpyyes/serialize/protobuf3 --java_out= D:/workspace/serialize/src/main/java person.proto
-I:表示proto文件所在目录
--java_out:表示输出java的类
执行以上命令就会在指定的目录生成一个java类PersonModule.java,接下来就可以使用了
@Test public void testProtobuffSerialize() throws InvalidProtocolBufferException { Builder builder = PersonModule.Person.newBuilder(); builder.setAge(21); builder.setTime(100L); builder.setName("yanghui"); builder.putProperties("key1", "value1"); com.tpyyes.serialize.protobuf3.PersonModule.Person person = builder.build(); byte[] personBytes = person.toByteArray(); System.out.println(Arrays.toString(personBytes)); System.out.println(personBytes.length); com.tpyyes.serialize.protobuf3.PersonModule.Person p = com.tpyyes.serialize.protobuf3.PersonModule.Person.parseFrom(personBytes); System.out.println(p.toString()); }