走进序列化之基础篇(勇气篇)

转至:http://wlh0706-163-com.iteye.com/blog/1867354

走进序列化之基础篇(勇气篇)

介绍:

基础篇:超多案例+超详细解析序列化后的二进制文件(以字节为单位分析)。

原理篇:JDK源码+Java-Object Serialization Specification官方文档解读。

终结篇:序列化机制之我见+翻译的序列化英文的官方文档PDF

(基础篇称之为勇气篇,内容超多能看完确实需要勇气)

目的:

通过了解序列化机制,进一步增加对JDK源码的了解,感受JDK源码的优美。

声明:

由于排版问题,很多地方以图片形式展示,在附件中有所有案例的源代码。

目录:

1:浅谈序列化

2:走进Java序列化

第一:内置序列化机制之完全性序列化

第二:内置序列化机制之选择性序列化

第三:内置序列化机制+自定义序列化(重点)

第四:完全自定义序列化

第一部分:浅谈序列化

    序列化并非只是Java语言特有的一种机制,有很多面向对象语言都支持。序列化是指将对象的状态信息转换成可存储、可传输的特定格式的数据并保存到一种临时(如主存)或者永久性(如硬盘)存储区域中的过程,当再次需要使用这个对象的时候,需要经过反序列化的过程,利用保存的对象的某时刻的状态数据和类信息(如class文件)一起恢复该对象。但是序列化并不是序列化对象,而是指序列化对象的某个状态。

    无论我们使用new关键字或者Java提供的反射机制生成的对象,其初始属性值都是固定的。每次从Class文件中创建的对象从某种意义上都是一模一样(当然内存地址不同),而当创建了该对象并使用后,其实它的很多属性都已经改变,此时的对象已经不再是当初的对象,这个可以简单的理解为对象的状态。如果我们可以对某一个时刻的对象某些状态属性进行保存,类似保存成class文件一样的二进制文件,那么我们拥有的就不再只是那个每次都一模一样那个的对象了,这样该是多么美妙的一件事情。

对于它的用处,可以简单的想象一下来体会它的魅力。如果某个系统在某个时刻崩溃了,这个还是完全有可能的,假如崩溃前某一时刻系统中所有正在使用的对象们的信息已经经过序列化进行保存,那么当机器重启,我们拥有的是什么?拥有的就不再是第一次创建时候的对象们,我们以前所做的所有的工作不会因为机器故障而付之东流。

第二部分:走进序列化

1)内置序列化机制之完全性序列化(只实现Serializable接口)

对于Java中的序列化,大家估计都知道,只要实现Serializable接口就可以,尽管该接口并没有定义任何方法和属性。其实它只是一个标志,表明“我”的立场:我支持序列化。

先体验一把。

我们的代码结构图:如果用英文实在不好表示,所以出现了汉语包名


    
而且,每个测试都分别对应下面的案例,如Test5对应案例5

 其实这样的命名对写测试的人很不合适,因为后边案例命名都依赖于编号在它前面的测试,设想当我想在完全序列化包下添加一个新的案例的时候,其后面的案例的名字全部要更改,这个类似数组的插入,代价很大!但是为了结构清楚和讨论方便还是这样做了。

案例1:序列化“小学生类”(省略了setget方法)


   
案例1测试(Test1)

 
运行结果:

也许这个过于简单,稍微改动一下:

 案例2测试(Test2):

 新的运行结果:

 
和你想象的是否有出入呢?命名设Id为“9999”,但是结果依旧是1009.

这个和序列化原理有关,后面会详细进行分析。

    案例3:序列化很多对象

这里提示一下:如果你是序列化了很多对象,按照上面的写入没有问题,当读出的时候,按照一般流的读出会在while循环判断根据read的返回值是不是-1或者null判断,但是objectInputStream返回的既不是-1也不是null,判断会很麻烦。一个比较好的解决办法是使用容器例如ArrayList,将一个容器写入然后再将整个容器读出。

案例3测试(Test1):

运行一下:

 
SerializeHelper是我进行封装的一个简单的工具。

序列化:

 
反序列化:


  
第二:内置序列化机制之选择性序列化(关键字transient和使用serialPersistentFields

总有些时候不需要或不能全部序列化。试想:

1:某敏感字段如密码不想要被序列化

2:某字段本身不能序列化

解决办法:使用关键字transient(瞬时的)或者设定serialPersistentFields

案例4:高中生总会有些秘密

 
案例4测试(Test4):

 
案例4测试结果

 
Password字段的状态信息没有被写入到磁盘中,当读出的时候已经默认将值为nullpassword转换成字符串“null”。

案例5:大学生有些字段(如导师)不能被序列化。

 

 
案例5测试(Test5):

 
案例5测试结果:

 
怎么办?不序列化它,使用transient关键字可以,此处我们采用另外一种:

案例5测试结果:

 
第三:内置序列化机制+自定义序列化(实现readObjectwriteObject方法)

试想:

1:某字段如密码必须被序列化,但是需要用户指定序列化方式

解决办法:在被序列化类中自定义readObjectwriteObject方法,该方法要求很严格,修饰符private、返回值void、参数列表ObjectInputStream或者objectOutputStream

案例6:女硕士的年龄总是需要模糊处理一下为好

 

案例6测试:

案例6测试结果:25左移两位后是100

 
当然想要解密,则只需要在readObject中进行处理(如将上述代码取消注销)

当机密的信息经过网络传输时,可以先在本地加密(在writeObject中实施),但是在readObject中并不解密,而是传输到远方(Remote)的机器中后进行解密。中间既是被人截获readObject得到的也是假的数据。

真的可以这么随便吗?当然不是。

从现在我们开始分析序列化生成的二进制文件

首先我们使用能够打开二进制数据的软件(本人使用WinHex)打开序列化“小学生”时得到的primary2.out文件(16进制)

 
这个文件对应的是“完全序列化”时

然后,我们选中所有数据,拷贝出来。(自己用的不是很熟,中间拷贝的时候遇到些问题,现在把过程贴出来)

选择编辑后

 

 

 

 

 

我写了两个简单方法把这个很长的字符串转换成了上述格式(加上0x,表示16进制)其中反序列化时用到了该方法:

 
案例7测试结果:


    
就是这样,我们通过改变二进制文件就实现了对id属性值的改变。

    借此机会,我们好好研究一下序列化生成的二进制文件,当然这个需要借助Java-Object Serialization Specification(Java对象系列化官方文档),可以在官网上下载,我已经下载好会在附件中提供。分析的时候关键标识如下图:请记住该图片我们称之为【标准图】



 

看不懂不要紧,下面一步一步详细进行分析

本案例说实在有点过于简单,只是写入一个对象,既然要分析,我们来点复杂的,更能说明问题。

 

 
案例8测试:

 
案例8生成的二进制文件:

 

 


  下面我们开始第一次解读序列化后的二进制文件:注意参看【标准图】

上述一共四个操作

1writeBoolean

2writeObject

3writeInt

4writeObject

其中:

基本数据类型的开始标志是TC_ BLOCKDATA

对象的开始标志是TC_OBJECT

String开始标志是TC_STRING

ACED0005表示基本描述信息

[AC ED]这两个字节表示“魔数”MAGIC,和class文件相似

[00 05]这两个字节表示版本号

---------------------以下对应writeBooleantrue--------------------------------------------------

77 01 01表示写进去一个长度为1字节,值为1的基本数据类型的数据。

[77]表示TC_BLOCKDATA,意思是以下是基本数据类型的数据。

[01]表示数据长度

[01]表示数据值为1(对应Boolean中的true

---------------------以下对应writeObjectson----------------------------------------------------

有点长,用图片标出

  

 
[73]表示TC_OBJECT ,意思是下面是一个对象

---------------------------------下面是对Son类描述信息--------------------------------------

[72]表示TC_CLASSDESC,意思是对象对应的类的描述

[ 00 11]表示类名长度为17(十六进制11代表十进制17

[63 6F 6D 2E 77 73 63 2E 62 65 61 6E 73 2E 53 6F 6E]:“com.wsc.beans.Son”(长17

[14 83 9E E6 B7 C2 B9 E9]表示SonserialVersionUID,都是8个字节长

 
Son中生成的一样。以后serialVersionUID就一笔带过。

[03]:表示该类支持序列化,同时又实现了WriteObject方法。这个在文档里面根本查不到,网上找遍了也没找到,自己从凌晨310分到408分,耗了我一个多小时,终于在源码中找到。截屏以纪念:汗

该类是:ObjectStreamClass 670行到681行之间。

因为很多案例此处是02,而且在下面分析的FatherGrandFather也是0202表示支持序列化,文档可以查到,一步一步debug后才找到。

 
凌晨414分,继续吧!!

[00 02]表示有两个属性(nameage

[4C]表示ASCII码值“L”查看【标准图】,对应Object

[00 03]表示用字符串来表示的长度是3(即属性age的长度)

[61 67 65]表示ASCII码:[a][g][e]                                                 

[74]表示TC_STRING意思是字符串。

[00 12]表示字符串长度是18(十六进制12表示十进制18

[4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B]表示“Ljava/lang/String;”共计18个字符。

到此处属性age描述完毕

下面描述属性name

[4C]表示ASCII码值“L”查看【标准图】,对应Object

[00 04]表示用字符串来表示的长度是4(即属性name的长度)

[6E 61 6D 65]表示[n][a][m][e]

[71]表示TC_REFRENCE 意思是引用

[00 7E 00 01]表示一个baseWireHadle 是一个引用地址。由于“Ljava/lang/String;”在上面已经出现过,此处只是用一个指针指向了已经存在的。这里是01,其实00表示的是第一次调用WriteObject时的Son

【到此处说点题外话,昨天有幸去听清华大学郑纬民教授(他路径长沙)的Big Data讲座,感受了以下大家风范。他讲到清华做的一个成功的系统“Meepo”做冗余处理的时候曾经用到该策略,很多数据重复只要采取好的策略删冗还是很有效果。听的时候有点豁然开朗的感觉】

[78]表示TC_ENDBLOCKDATA 表示结束的意思,当然这只是Son类描述信息结束

----------------------------------以下是Father类的描述信息-----------------------------

[72]表示TC_CLASSDESC,意思是对象对应的类(Father类)的描述

[00 14]表示长度20

[63 6F 6D 2E 77 73 63 2E 62 65 61 6E 73 2E 46 61 74 68 65 72]对应的是“com.wsc.beans.Father”共20个字符

[13 B9 B8 7C C2 0C 74 A9]表示8个字节长度的serialVersionUIDFather类)

[02]表示支持序列化(但是没有定义readObjectWriteObject方法)

[00 01]表示有哦1个属性(即name属性)

[4C]表示ASCII中的“L”即为Object

[00 04]表示长度为4

[6E 61 6D 65]表示[n][a][m][e]

[71]表示TC_REFRENCE引用

[00 7E 00 01]表示一个baseWireHandle,是一个地址,指向了首先在Son类中描述过的“Ljava/lang/String;

[78]我们迎来了TC_ENDBLOCKDATA,意味着Father类描述信息结束。

-----------------------------------以下是GrandFather类描述信息--------------------

[72] 表示TC_CLASSDESC,意思是对象对应的类(GrandFather类)的描述

[00 19]表示长度是25

[63 6F 6D 2E 77 …68 65 72]表示“com.wsc.beans.GrandFather”共计25个字符

[D4 41 2D CA BF FC 03 F5]相信很多人知道是8个字节长度的serialVersionUID

[02]表示支持序列化

[00 03]表示有3个属性

[49]表示I即为Int

[00 02]表示长度是2

[69 64]表示[id]

[4C]表示“LL对应的是Object

[00 03]表示长度是3

[61 67 65]表示[a][g][e]

[71]表示TC_REFRENCE引用

[00 7E 00 01]出现了好几次了,表示引用地址,引用了“Ljava/lang/String;

[4C]表示“LL对应的是Object

[00 04]长度是4

[6E 61 6D 65]对应4个字符[n][a][m][e]

[71]表示TC_REFERENCE引用

[00 7E 00 01]引用地址,引用了“Ljava/lang/String;

[78]我们迎来了TC_ENDBLOCKDATA表示GrandFather类描述信息结束

[70]表示TC_NULL意味着没有父类了。(可以看出序列化并不包含Object虽然它是根类)

---------------------以上全部是描述信息即元数据信息,下面是实例信息-----------

实例信息是倒着来赋值的,先GrandFatherFather然后是Son

[00 0D 70 70]表示GrandFather类属性值

[00 0D]由于根据描述信息,idInt4个字节,00 0D表示13

[70]表示TC_NULL意思是空(这里不是没有父类),表示age属性为空

[70]表示name属性为空

[70]表示Father类属性值,只有一个属性name 而且为空

[74 00 02 32 32 74 00 08 54 68 69 72 74 65 65 6E 74]表示Son类实例信息

[74]表示TC_STRING 表示字符串

[00 02]长度为2

[32 32]对应[2][2](十六进制32表示十进制50,对应ASCII中数字2)即age=22

[74]表示TC_STRING 表示字符串

[00 08]长度是8

[54 68 69 72 74 65 65 6E ]对应的是“Thirteen

由于Son 中定义了readObject WriteObject 方法

 

还有最后一个

[74 00 0C 4A 61 76 61 54 68 69 72 74 65 65 6E]表示字符串JavaThirteen

[74] 表示TC_STRING 表示字符串

[00 0C]长度是12

[4A617661546869727465656E]表示“JavaThirteen

[78] 我们终于迎来了TC_ENDDATABLOCK 结束

[到此WriteObjectson)完毕]

[下面是writeInt(10)]

[77 04 00 00 00 0A]表示Int类型数据10

[77] TC_BLOCKDATA 基本类型数据

[04] 长度是4

[00 00 00 0A]表示10

[下面是writeObjectIamThirteenComeFromCSU]

[74 00 16 49 61 6D 54 68 69 72 74 65 65 6E 43 6F 6D 65 46 72 6F 6D 43 53 55]

表示字符串IamThirteenComeFromCSU

[74]表示TC_STRING是指字符串

[00 16]长度是22

[49 61 6D 54 68 69 72 74 65 65 6E 43 6F 6D 65 46 72 6F 6D 43 53 55]22个字符表示为IamThirteenComeFromCSU

[到此处我们的二进制文件解释完毕]

我们对整个文件形式做个总结:如图

 
其实序列化文件保持这class文件的风格:紧凑。而且你会发现,关于类的信息是分为两部分年即元数据信息(描述信息)和实例数据信息,这样做是典型的面向对象思想,下面举例子会用更多信息去展示它同时也去解释一下第一节完全序列化的时候出现的问题

在第一节中有这样一个问题:

由于“小学生”案例稍微改动一下:

 
新的运行结果:


 
为什么同一对象改变了Id后再次写入实际并没有改变呢?我们可以从二进制文件中找到答案。

 和原来的相比,二进制文件只增加了红笔中的5个字节

 
[71]表示TC_REFRENCE 引用

[71 00 7E 00 02]表示改变id9999后写入的对象指向了编号为2的对象,下面分析一个稍微复杂点的例子,再来介绍它的编号问题。

案例9

 
案例9运行结果是:


  写入的第二个son2是通过new出来的,是跟第一个son不同的对象;

 而当改变了第一个sonid后再次写入son后,只是添加了一个引用,这个引用指向了我们写入的第一个sonid=13),所以它的id虽然改成了9999,但是并没有起到作用,原因是因为对于一个对象只要改变了一个属性,在序列化的范围内实际上就相当于改变成另外一种状态(序列化是指序列化对象的状态),只是简单的写进去根本不行,所以当改变了属性后需要重新创建一个对象才能实现自己想要的结果

对应的二进制文件有那些改变呢?



 
这个和我们分析的最详细的案例的差别在于多了上述红色标出的部分

多出来的第一部分:代表son2

[73 71 00 7E 00 00 00 00 03 E8 70 70 70 74 00 04 31 30 30 30 74 00 08 46 6F 75 72 74 65 65 6E 74 00 0C 4A 61 76 61 46 6F 75 72 74 65 65 6E 78]

多出来的第二部分:代表改变sonid9999后写入的son

[71 00 7E 00 04]

可以发现,第一部分前5个字节[73 71 7E 00 00][73 71 7E 00 04]

显然[71]表示TC_REFERENE引用。

[73 71 7E 00 00]son2的引用,引向编号为0的对象,其实这是一个ObjectStreamClass对象,是Son类型的描述信息。

[73 71 7E 00 04]是改变了id后的son的引用,引向编号为04的对象

那么序列化后编号为04的对象到底是那一个呢?如何查看呢?

最好的工具当然是Debug模式(由于本人开始不是很会用这个好东西,分析得时候很吃力,不过都过去了..

我们先对编号的结果一睹为快:

 
结果是

 
可以清楚的看到编号是04的对象是Son。至于这个是如何编号的呢?也就是序列化的原理,稍微有点复杂,涉及到很多源代码分析,会在下一篇博客“原理篇”通过官方文档+源代码详细分析。当然可能序列化不会经常用到,如果还有心情和勇气看下去,可以看下一篇博客,可能需要点时间,写一片精致博客很耗精力的。这里稍微介绍一点到底是如何判断sonnew 关键字创建的(如son2)还是本来就有的son呢?

第一步:在WriteObject方法中会先进行判断是否已经存在该对象

 

 

 这里用到hash函数

 



 它的不同之处在于不是根据hashCode来计算的,因为对hashCode方法熟悉的人知道,当我们自定义可变对象的时候,可能会重写该方法,相当于自定义比较方法(HashMap方法就是根据hashCode进行比较的)

这样判断显然不行,也别想用“equals”或者“==”,且不说正确与否,这两者需要知道两个引用即“A==B”,这里是查找(当然遍历也行)。源代码使用的是系统方法:更让人高兴的是该方法是public修饰的

 
 通过二进制文件分析了案例789后,我们曾经试着通过修改二进制代码来改变对象,(1009改成了9999)。由于序列化后的二进制文件格式是公开的(如同class文件),如果你定义的类严重不希望也不能被别人改变,可能需要做一些措施了。

下面介绍以下这种序列化方式需要注意的事情:参考《Effective Java

第一件事情:对于自定义对象中字段之间关系有约束条件的,需要做一些必要性检查。借用《Effective Java》书中一个案例做一下说明:

案例10

例如自定义对象Period中需要序列化的两个字段是时间Date类型,startend。要求start必须是小于end

 
Period被定义为final,而且其字段全部是final。在构造函数中同时新建一个对象,为的就是一旦初始化该对象,就再也无法更改其属性值。当然,我们如果要求start要小于end,我们可以在构造函数中检查一下。但是要记住,序列化机制相当于为用户另开了一扇创建对象的门。Class文件相当于模版,而序列化得到的文件则是描述该对象一些信息的参数,用户有对该二进制文件修改的能力。你要做的就是检查以下用户修改后数据是否符合一些硬性要求如start小于end。其他你无能为力,如果用户把某个值从4改成5你真的没有办法检查出来。

 
如果只是这样,用户完全可以修改period.out文件,创建不合法的Period对象

所以需要在Period类中增加readObject方法并实现在构造函数中同样的操作:增加约束条件

 
这样就安全了吗?还有更加可怕的。

案例11

虽然创建的Period在构造函数和get方法中都进行了clone,但是由于序列化机制相当于对用户开启另外一扇门,我们可以通过引用来获得startend。只需要添加10个字节就搞定

我们先来看看案例10中的start和end对象的引用是什么,通过debug得到

 现在创建一个新类:

 



  案例11测试:

案例11测试结果

 
就是这样,我们通过修改二进制文件,浑水摸鱼用MutalePeriod中的两个对象startend指向了内存中真实的对象,从而绕过了readObject中的安全性检查,任意妄为。

这个时候怎么办?需要对其进行保护性拷贝。

 
你拿到内存中对象又如何?我让我的startend指向了另外一个new出来的对象,这个对象是在readObject中实现,序列化文件时(writeObject)并不会保留该对象地址,你根本拿不到

 



  
可以看出确实没保存。

案例11测试结果:

 
案例12:单例模式问题

由于反序列化的时候,已经通过反射机制在内存中创建了一个对象,其拿到的是根据序列化二进制文件中参数生成的一个副本,这个会破坏单例模式。

我们用一个简单的版本

 

案例11测试结果:

尽管是使用内存流照样不同

 
那么如何改变?

案例12

Singleton中添加4个方法,前两个很熟,后两个是新方法

 
案例12测试:


 案例
12测试结果是:

 默认这4个方法都会调用。在readResolvewriteReplace返回INSTANCE即可

当然,安全是一个博大精深的问题,此处只是稍微介绍几个简单案例。 

 

 4)完全自定义序列化(实现接口Externalizable

当然,对于用户一般也可以采用完全自主式方法实现自定义序列化,一种就是在第三种方式中WriteObjectreadObject舍弃ois.defaultReadObject()oos.defaultWriteObject()自己定义实现过程。同样还可以实现接口Externalizable

这里只做简单介绍,分析了原理之后再介绍

案例13 :上面说到女硕士,现在是男硕士

 
可以看出有两个 public 方法需要自己实现,我只是简单的实现了下密码。

案例13测试:

 
案例13测试结果:虽然密码是用transient修饰

 



   
关于这一节涉及东西不少,以后再具体说,不过如果对序列化了解不深或者用的很少,不建议用完全自定义。

   其实自己也是菜鸟一个,很多东西有可能分析得不好,如果有错或理解不同的地方请指出!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值