java序列化总结

目录

对象序列化是什么

为什么需要序列化与反序列化

序列化及反序列化相关知识

Java 序列化中如果有些字段不想进行序列化,怎么办?

Java序列化接口 java.io.Serializable

使用序列化和serialVersionUID进行类重构

Java外部化接口 java.io.Externalizable

Serializable和Externalizable的区别与联系

序列化的4大方法

序列化结合继承

序列化代理模式

序列化底层原理

序列化:writeObject()

反序列化:readObject()

static和transient字段不能被序列化

如何实现自定义序列化和反序列化?


注意:本文参考   Java 序列化 JDK序列化总结 - ixenos - 博客园

深入学习 Java 序列化_dingxie1963的博客-CSDN博客

对象序列化是什么

简单来说:

序列化: 将数据结构或对象转换成二进制字节流的过程

反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

1、对象序列化就是把一个对象的状态转化成一个字节流

我们可以把这样的字节流存储为一个文件,作为对这个对象的复制(深拷贝);在一些分布式应用中,我们还可以把对象的字节流发送到网络上的其他计算机

对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。

反序列化是把流结构的对象恢复为其原有形式

2、Java平台允许我们在内存中创建可复用的Java对象,但一般情况下,只有当JVM处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比JVM的生命周期更长。但在现实应用中,就可能要求在JVM停止运行之后能够保存(持久化)指定的对象,并在将来重新读取被保存的对象。Java对象序列化就能够帮助我们实现该功能。

3、使用Java对象序列化,在保存对象时,会把其状态保存为一组字节,在未来,再将这些字节组装成对象必须注意地是,对象序列化保存的是对象的”状态”,即它的成员变量。由此可知,对象序列化不会关注类中的静态变量

因为静态变量是类在 加载-链接-初始化 的阶段就分配空间和初始化了的,更不用说在编译期初始化的静态常量了。

4、除了在持久化对象时会用到对象序列化之外,当使用RMI(远程方法调用),或在网络中传递对象时,都会用到对象序列化。Java序列化API为处理对象序列化提供了一个标准机制,该API简单易用。

5、综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。

为什么需要序列化与反序列化

我们知道,当两个进程进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等, 而这些数据都会以二进制序列的形式在网络上传送。

那么当两个Java进程进行通信时,能否实现进程间的对象传送呢?答案是可以的!如何做到呢?这就需要Java序列化与反序列化了!

换句话说,一方面,发送方需要把这个Java对象转换为字节序列,然后在网络上传送;另一方面,接收方需要从字节序列中恢复出Java对象。

当我们明晰了为什么需要Java序列化和反序列化后,我们很自然地会想Java序列化的好处。其好处一是实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里),二是,利用序列化实现远程通信,即在网络上传送对象的字节序列。

总的来说可以归结为以下几点:

(1)永久性保存对象,保存对象的字节序列到本地文件或者数据库中;
(2)通过序列化以字节流的形式使对象在网络中进行传递和接收;
(3)通过序列化在进程间传递对象;

序列化及反序列化相关知识

1、在Java中,只要一个类实现了java.io.Serializable接口,那么它就可以被序列化。

2、通过ObjectOutputStreamObjectInputStream对对象进行序列化及反序列化

3、虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致(就是 private static final long serialVersionUID

4、序列化并不保存静态变量

5、要想将父类对象也序列化,就需要让父类也实现Serializable 接口。

6、Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。

7、服务器端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串等,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。

Java 序列化中如果有些字段不想进行序列化,怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

transient 只能修饰变量,不能修饰类和方法。

transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0

static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

Java序列化接口 java.io.Serializable

如果你希望一个类对象是可序列化的,你所要做的是实现java.io.Serializable接口。序列化一种标记接口,不需要实现任何字段和方法,这就像是一种选择性加入的处理,通过它可以使类对象成为可序列化的对象。

序列化处理是通过ObjectInputStream和ObjectOutputStream实现的,因此我们所要做的是基于它们进行一层封装,要么将其保存为文件,要么将其通过网络发送。我们来看一个简单的序列化示例。

package com.journaldev.serialization;

import java.io.Serializable;

public class Employee implements Serializable {

//  private static final long serialVersionUID = -6470090944414208496L;

    private String name;
    private int id;
    transient private int salary;
//  private String password;

    @Override
    public String toString(){
        return "Employee{name="+name+",id="+id+",salary="+salary+"}";
    }

    //getter and setter methods
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getSalary() {
        return salary;
    }

    public void setSalary(int salary) {
        this.salary = salary;
    }

//  public String getPassword() {
//      return password;
//  }
//
//  public void setPassword(String password) {
//      this.password = password;
//  }

}

注意一下,这是一个简单的java bean,拥有一些属性以及getter-setter方法,如果你想要某个对象属性不被序列化成流,你可以使用transient关键字,正如示例中我在salary变量上的做法那样。

现在我们假设需要把我们的对象写入文件,之后从相同的文件中将其反序列化,因此我们需要一些工具方法,通过使用ObjectInputStream和ObjectOutputStream来达到序列化的目的。

package com.journaldev.serialization;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

/**
 * A simple class with generic serialize and deserialize method implementations
 *
 * @author pankaj
 *
 */
public class SerializationUtil {

    // deserialize to Object from given file
    public static Object deserialize(String fileName) throws IOException,
            ClassNotFoundException {
        FileInputStream fis = new FileInputStream(fileName);
        ObjectInputStream ois = new ObjectInputStream(fis);
        Object obj = ois.readObject();
        ois.close();
        return obj;
    }

    // serialize the given object and save it to file
    public static void serialize(Object obj, String fileName)
            throws IOException {
        FileOutputStream fos = new FileOutputStream(fileName);
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        oos.writeObject(obj);

        fos.close();
    }

}

注意一下,方法的参数是Object,它是任何Java类的基类,这样写法以一种很自然的方式保证了通用性。

现在我们来写一个测试程序,看一下Java序列化的实战。

package com.journaldev.serialization;

import java.io.IOException;

public class SerializationTest {

    public static void main(String[] args) {
        String fileName="employee.ser";
        Employee emp = new Employee();
        emp.setId(100);
        emp.setName("Pankaj");
        emp.setSalary(5000);

        //serialize to file
        try {
            SerializationUtil.serialize(emp, fileName);
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }

        Employee empNew = null;
        try {
            empNew = (Employee) SerializationUtil.deserialize(fileName);
        } catch (ClassNotFoundException | IOException e) {
            e.printStackTrace();
        }

        System.out.println("emp Object::"+emp);
        System.out.println("empNew Object::"+empNew);
    }

}

运行以上测试程序,可以得到以下输出。

emp Object::Employee{name=Pankaj,id=100,salary=5000}
empNew Object::Employee{name=Pankaj,id=100,salary=0}

由于salary是一个transient变量,它的值不会被存入文件中,因此也不会在新的对象中被恢复。类似的,静态变量的值也不会被序列化,因为他们是属于类而非对象的。

使用序列化和serialVersionUID进行类重构

Java序列化允许java类中的一些变化,如果他们可以被忽略的话。一些不会影响到反序列化处理的变化有:

在类中添加一些新的变量。

将变量从transient转变为非tansient,对于序列化来说,就像是新加入了一个变量而已。

将变量从静态的转变为非静态的,对于序列化来说,就也像是新加入了一个变量而已。

不过这些变化要正常工作,java类需要具有为该类定义的serialVersionUID,我们来写一个测试类,只对之前测试类已经生成的序列化文件进行反序列化。

package com.journaldev.serialization;

import java.io.IOException;

public class DeserializationTest {

    public static void main(String[] args) {

        String fileName="employee.ser";
        Employee empNew = null;

        try {
            empNew = (Employee) SerializationUtil.deserialize(fileName);
        } catch (ClassNotFoundException | IOException e) {
            e.printStackTrace();
        }

        System.out.println("empNew Object::"+empNew);

    }

}

现在,在Employee类中去掉”password”变量的注释和它的getter-setter方法,运行。你会得到以下异常。

java.io.InvalidClassException: com.journaldev.serialization.Employee; local class incompatible: stream classdesc serialVersionUID = -6470090944414208496, local class serialVersionUID = -6234198221249432383
    at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:604)
    at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1601)
    at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1514)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1750)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1347)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:369)
    at com.journaldev.serialization.SerializationUtil.deserialize(SerializationUtil.java:22)
    at com.journaldev.serialization.DeserializationTest.main(DeserializationTest.java:13)
empNew Object::null

原因很显然,上一个类和新类的serialVersionUID是不同的,事实上如果一个类没有定义serialVersionUID,它会自动计算出来并分配给该类。Java使用类变量、方法、类名称、包,等等来产生这个特殊的长数。如果你在任何一个IDE上工作,你都会得到警告“可序列化类Employee没有定义一个静态的final的serialVersionUID,类型为long”。

我们可以使用java工具”serialver”来产生一个类的serialVersionUID,对于Employee类,可以执行以下命令。

SerializationExample/bin$serialver -classpath . com.journaldev.serialization.Employee

记住,从程序本身生成序列版本并不是必须的,我们可以根据需要指定值,这个值的作用仅仅是告知反序列化处理机制,新的类是相同的类的新版本,应该进行可能的反序列化处理。

举个例子,在Employee类中仅仅将serialVersionUID字段的注释去掉,运行SerializationTest程序。现在再将Employee类中的password字段的注释去掉,运行DeserializationTest程序,你会看到对象流被成功地反序列化了,因为Employee类中的变动与序列化处理是相容的。

Java外部化接口 java.io.Externalizable

如果你在序列化处理中留个心,你会发现它是自动处理的。有时候我们想要去隐藏对象数据,来保持它的完整性,可以通过实现java.io.Externalizable接口,并提供writeExternal()和readExternal()方法的实现,它们被用于序列化处理。

package com.journaldev.externalization;

import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

public class Person implements Externalizable{

    private int id;
    private String name;
    private String gender;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeInt(id);
        out.writeObject(name+"xyz");
        out.writeObject("abc"+gender);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException,
            ClassNotFoundException {
        id=in.readInt();
        //read in the same order as written
        name=(String) in.readObject();
        if(!name.endsWith("xyz")) throw new IOException("corrupted data");
        name=name.substring(0, name.length()-3);
        gender=(String) in.readObject();
        if(!gender.startsWith("abc")) throw new IOException("corrupted data");
        gender=gender.substring(3);
    }

    @Override
    public String toString(){
        return "Person{id="+id+",name="+name+",gender="+gender+"}";
    }
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getGender() {
        return gender;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

}

注意,在将其转换为流之前,我已经更改了字段的值,之后读取时会得到这些更改,通过这种方式,可以在某种程度上保证数据的完整性,我们可以在读取流数据之后抛出异常,表明完整性检查失败

来看一个测试程序。

package com.journaldev.externalization;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class ExternalizationTest {

    public static void main(String[] args) {

        String fileName = "person.ser";
        Person person = new Person();
        person.setId(1);
        person.setName("Pankaj");
        person.setGender("Male");

        try {
            FileOutputStream fos = new FileOutputStream(fileName);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(person);
            oos.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        FileInputStream fis;
        try {
            fis = new FileInputStream(fileName);
            ObjectInputStream ois = new ObjectInputStream(fis);
            Person p = (Person)ois.readObject();
            ois.close();
            System.out.println("Person Object Read="+p);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }

    }

}

运行以上测试程序,可以得到以下输出。

Person Object Read=Person{id=1,name=Pankaj,gender=Male}

那么哪个方式更适合被用来做序列化处理呢?实际上使用序列化接口更好,当你看到这篇教程的末尾时,你会知道原因的。

Serializable和Externalizable的区别与联系

1、Externalizable继承自Serializable;Externalizable 实例也可以通过 Serializable 接口中记录的 writeReplace 和 readResolve 方法来指派一个替代对象

2、实现Externalizable接口的类必须有默认构造方法。在读入可外部化的(Externalizable)类时,对象流将先用无参构造器创建一个对象,然后调用readExternal方法按writeExternal定义的机制反序列化;

3、若要完全控制某一对象及其超类型的流格式和内容,则它要实现 Externalizable 接口的 writeExternal 和 readExternal 方法。这些方法必须显式与超类型进行协调以保存其状态这些方法将代替定制的 writeObject 和 readObject 方法实现;write/readExternal要对包括超类数据在内的整个对象的存储和恢复负全责,而Serializable在流中仅仅记录该对象所属的类的属性状态;

public void readExternal(ObjectInput in) throws IOException,
      ClassNotFoundException {
     name = (String) in.readObject();
     password = (String) in.readObject();
}

public void writeExternal(ObjectOutput out) throws IOException {
    out.writeObject( name);
    out.writeObject( password);
}

4、相比Serializable,Externalizable序列化的速度更快,序列化之后的数据更小,但读和取都需要开发人员自行实现, Serializable开发相对简单,速度慢,序列化后的数据更大些。 

5、这两种序列化方式都有一个特点,如果多个对象a的内部属性b同时指向同一个对象,即同时引用了另外一个对象b。序列化->反序列化之后,这几个对象属性还是会同时指向同一个对象b,不会反序列化出多个b对象。 

但是,如果多个对象a是多次被序列化的,在反序列后对象b会被反序列化多次,因为反序列化时都调用构造器(Externalizable调用无参构造器)在堆中生成新对象,内存地址都是不同的,即多个a对象的属性b是不一样的。 

6、Serialization 对象将使用 Serializable 和 Externalizable 接口。对象持久性机制也可以使用它们。要存储的每个对象都需要检测是否支持 Externalizable 接口

a) 如果对象支持 Externalizable,则调用 writeExternal 方法。如果对象不支持 Externalizable 但实现了 Serializable,则使用 ObjectOutputStream 保存该对象。

b) 在重构 Externalizable 对象时,先使用无参数的公共构造方法创建一个实例,然后调用 readExternal 方法。通过从 ObjectInputStream 中读取 Serializable 对象可以恢复这些对象。

序列化的4大方法

java的序列化是自动的,我们所要做的仅仅是实现序列化接口,其实现已经存在于ObjectInputStream和ObjectOutputStream类中了。不过如果我们想要更改存储数据的方式,比如说在对象中含有一些敏感信息,在存储/获取它们之前我们要进行加密/解密,这该怎么办呢?这就是为什么在类中我们拥有四种方法,能够改变序列化行为。

如果以下方法在被序列化类中存在,它们就会被用于序列化处理

readObject(ObjectInputStream ois):如果这个方法存在,ObjectInputStream readObject()方法会调用该方法从流中读取对象

writeObject(ObjectOutputStream oos):如果这个方法存在,ObjectOutputStream writeObject()方法会调用该方法从流中写入对象一种普遍的用法是隐藏对象的值来保证完整性。

Object writeReplace():如果这个方法存在,那么在序列化处理之后,该方法会被调用并将返回的对象序列化到流中

Object readResolve():如果这个方法存在,那么在序列化处理之后,该方法会被调用并返回一个最终的对象给调用程序(keyijinxing)。一种使用方法是在序列化类中实现单例模式,你可以从序列化和单例中读到更多知识。此方法返回的对象,会被作为readOjbect的返回值(即使readObject方法定义并没有返回任何对象)

通常情况下,当实现以上方法时,应该将其设定为私有类型,这样子类就无法覆盖它们了,因为它们本来就是为了序列化而建立的,设定为私有类型能避免一些安全性问题。

readObejct和writeObject的具体示例:Java Object 对象序列化和反序列化

writeReplace和readResolve的具体示例:Java Object 序列化与单例模式 以及本文的 序列化代理模式

序列化结合继承

有时候我们需要对一个没有实现序列化接口的类进行扩展如果依赖于自动化的序列化行为,而一些状态是父类拥有的,那么它们将不会被转换为流,因此以后也无法获取。

在此,readObject()writeObject()就可以派上大用处了,通过提供它们的实现,我们可以将父类的状态存入流中,以便今后获取。我们来看一下实战。

package com.journaldev.serialization.inheritance;

public class SuperClass {

    private int id;
    private String value;

    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public String getValue() {
        return value;
    }
    public void setValue(String value) {
        this.value = value;
    }

}

父类是一个简单的java bean,没有实现序列化接口。

package com.journaldev.serialization.inheritance;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectInputValidation;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SubClass extends SuperClass implements Serializable, ObjectInputValidation{

    private static final long serialVersionUID = -1322322139926390329L;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString(){
        return "SubClass{id="+getId()+",value="+getValue()+",name="+getName()+"}";
    }

    //adding helper method for serialization to save/initialize super class state
    private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
        ois.defaultReadObject();

        //注意读和写的顺序要一致,反序列化出值,再赋给父类属性
        setId(ois.readInt());
        setValue((String) ois.readObject());

    }

    private void writeObject(ObjectOutputStream oos) throws IOException{
        oos.defaultWriteObject();
        //调用父类的getId获得值单独写到序列化流中
        oos.writeInt(getId());
        oos.writeObject(getValue());
    }

    @Override
    public void validateObject() throws InvalidObjectException {
        //validate the object here
        if(name == null || "".equals(name)) throw new InvalidObjectException("name can't be null or empty");
        if(getId() <=0) throw new InvalidObjectException("ID can't be negative or zero");
    }

}

注意,将额外数据写入流和读取流的顺序应该是一致的,我们可以在读与写之中添加一些逻辑,使其更安全。

同时还需要注意,这个类实现了ObjectInputValidation接口,通过实现validateObject()方法,可以添加一些业务验证来确保数据完整性没有遭到破坏。

以下通过编写一个测试类,看一下我们是否能够从序列化的数据中获取父类的状态。

package com.journaldev.serialization.inheritance;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class InheritanceSerializationTest {

    public static void main(String[] args) {
        String fileName = "subclass.ser";

        SubClass subClass = new SubClass();
        subClass.setId(10);
        subClass.setValue("Data");
        subClass.setName("Pankaj");

        try {
            SerializationUtil.serialize(subClass, fileName);
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }

        try {
            SubClass subNew = (SubClass) SerializationUtil.deserialize(fileName);
            System.out.println("SubClass read = "+subNew);
        } catch (ClassNotFoundException | IOException e) {
            e.printStackTrace();
        }
    }

}

运行以上测试程序,可以得到以下输出。

SubClass read = SubClass{id=10,value=Data,name=Pankaj}

因此通过这种方式,可以序列化父类的状态,即便它没有实现序列化接口当父类是一个我们无法改变的第三方的类,这个策略就有用武之地了

序列化代理模式

Java序列化也带来了一些严重的误区,比如:

类的结构无法大量改变,除非中断序列化处理,因此即便我们之后已经不需要某些变量了,我们也需要保留它们,仅仅是为了向后兼容。

序列化会导致巨大的安全性危机,一个攻击者可以更改流的顺序,继而对系统造成伤害。举个例子,用户角色被序列化了,攻击者可以更改流的值为admin,再执行恶意代码。

序列化代理模式是一种使序列化能达到极高安全性的方式,在这个模式下,一个内部的私有静态类被用作序列化的代理类,该类的设计目的是用于保留主类的状态。这个模式的实现需要合理实现readResolve()和writeReplace()方法。

让我们先来写一个类,实现了序列化代码模式,之后再对其进行分析,以便更好的理解原理。

package com.journaldev.serialization.proxy;

import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Data implements Serializable{

    private static final long serialVersionUID = 2087368867376448459L;

    private String data;

    public Data(String d){
        this.data=d;
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }

    @Override
    public String toString(){
        return "Data{data="+data+"}";
    }

    //serialization proxy class
    private static class DataProxy implements Serializable{

        private static final long serialVersionUID = 8333905273185436744L;

        private String dataProxy;
        private static final String PREFIX = "ABC";
        private static final String SUFFIX = "DEFG";

        public DataProxy(Data d){
            //obscuring data for security
            this.dataProxy = PREFIX + d.data + SUFFIX;
        }

        private Object readResolve() throws InvalidObjectException {
            if(dataProxy.startsWith(PREFIX) && dataProxy.endsWith(SUFFIX)){
            return new Data(dataProxy.substring(3, dataProxy.length() -4));
            }else throw new InvalidObjectException("data corrupted");
        }

    }

    //replacing serialized object to DataProxy object
    private Object writeReplace(){
        return new DataProxy(this);
    }

    private void readObject(ObjectInputStream ois) throws InvalidObjectException{
        throw new InvalidObjectException("Proxy is not used, something fishy");
    }
}

Data和DataProxy类都应该实现序列化接口。

DataProxy应该能够保留Data对象的状态。

DataProxy是一个内部的私有静态类,因此其他类无法访问它。

DataProxy应该有一个单独的构造方法,接收Data作为参数。

Data类应该提供writeReplace()方法,返回DataProxy实例,这样当Data对象被序列化时,返回的流是属于DataProxy类的,不过DataProxy类在外部是不可见的,所有它不能被直接使用。

DataProxy应该实现readResolve()方法,返回Data对象,这样当Data类被反序列化时,在内部其实是DataProxy类被反序列化了,之后它的readResolve()方法被调用,我们得到了Data对象。

最后,在Data类中实现readObject()方法,抛出InvalidObjectException异常,防止黑客通过伪造Data对象的流并对其进行解析,继而执行攻击。

我们来写一个小测试,检查一下这样的实现是否能工作。

package com.journaldev.serialization.proxy;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class SerializationProxyTest {

    public static void main(String[] args) {
        String fileName = "data.ser";

        Data data = new Data("Pankaj");

        try {
            SerializationUtil.serialize(data, fileName);
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            Data newData = (Data) SerializationUtil.deserialize(fileName);
            System.out.println(newData);
        } catch (ClassNotFoundException | IOException e) {
            e.printStackTrace();
        }
    }

}

运行以上测试程序,可以得到以下输出。

Data{data=Pankaj

如果你打开data.ser文件,可以看到DataProxy对象已经被作为流存入了文件中。

这就是Java序列化的所有内容,看上去很简单但我们应当谨慎地使用它,通常来说,最好不要依赖于默认实现。你可以从上面的链接中下载项目,玩一玩,这能让你学到更多。

序列化底层原理

序列化:writeObject()

调用wroteObject()进行序列化之前会先调用ObjectOutputStream的构造函数生成一个ObjectOutputStream对象,构造函数如下:

public ObjectOutputStream(OutputStream out) throws IOException {
    verifySubclass();  // bout表示底层的字节数据容器
  bout = new BlockDataOutputStream(out);
  handles = new HandleTable(10, (float) 3.00);
  subs = new ReplaceTable(10, (float) 3.00);
  enableOverride = false;
  writeStreamHeader(); // 写入文件头
  bout.setBlockDataMode(true); // flush数据
  if (extendedDebugInfo) {
        debugInfoStack = new DebugTraceInfoStack();
  } else {
        debugInfoStack = null;
  }
}

构造函数中首先会把bout绑定到底层的字节数据容器,接着会调用writeStreamHeader()方法,该方法实现如下:

protected void writeStreamHeader() throws IOException {
    bout.writeShort(STREAM_MAGIC);
    bout.writeShort(STREAM_VERSION);
}

在writeStreamHeader()方法中首先会往底层字节容器中写入表示序列化的Magic Number以及版本号,定义为

/**
 * Magic number that is written to the stream header. 
 */final static short STREAM_MAGIC = (short)0xaced;/**
 * Version number that is written to the stream header. 
 */final static short STREAM_VERSION = 5;

接下来会调用writeObject()方法进行序列化,实现如下:

public final void writeObject(Object obj) throws IOException {
     if (enableOverride) {
         writeObjectOverride(obj);
         return ;
     }
     try {
         // 调用writeObject0()方法序列化
         writeObject0(obj, false );
     } catch (IOException ex) {
         if (depth == 0 ) {
             writeFatalException(ex);
         }
         throw ex;
     }
}

正常情况下会调用writeObject0()进行序列化操作,该方法实现如下:

private void writeObject0(Object obj, boolean unshared)
     throws IOException
{
     // 一些省略代码
     try {
         // 一些省略代码
         Object orig = obj;
         // 获取要序列化的对象的Class对象
         Class cl = obj.getClass();
         ObjectStreamClass desc;
         for (;;) {
             Class repCl;
             // 创建描述cl的ObjectStreamClass对象
             desc = ObjectStreamClass.lookup(cl, true );
             // 其他省略代码
         }
         // 一些省略代码
         // 根据实际的类型进行不同的写入操作
         // remaining cases
         if (obj instanceof String) {
             writeString((String) obj, unshared);
         } else if (cl.isArray()) {
             writeArray(obj, desc, unshared);
         } else if (obj instanceof Enum) {
             writeEnum((Enum) obj, desc, unshared);
         } else if (obj instanceof Serializable) {
             // 被序列化对象实现了Serializable接口
             writeOrdinaryObject(obj, desc, unshared);
         } else {
             if (extendedDebugInfo) {
                 throw new NotSerializableException(
                     cl.getName() + "\n" + debugInfoStack.toString());
             } else {
                 throw new NotSerializableException(cl.getName());
             }
         }
     } finally {
         depth--;
         bout.setBlockDataMode(oldMode);
     }
}

从代码里面可以看到,程序会

1 生成一个描述被序列化对象的类的类元信息的ObjectStreamClass对象。

2 根据传入的需要序列化的对象的实际类型进行不同的序列化操作。从代码里面可以很明显的看到,对于String类型、数组类型和Enum可以直接进行序列化。如果被序列化对象实现了Serializable对象,则会调用writeOrdinaryObject()方法进行序列化。

这里可以解释一个问题:Serializbale接口是个空的接口,并没有定义任何方法,为什么需要序列化的接口只要实现Serializbale接口就能够进行序列化。

答案是:Serializable接口这是一个标识,告诉程序所有实现了”我”的对象都需要进行序列化。

因此,序列化过程接下来会执行到writeOrdinaryObject()这个方法中,该方法实现如下:

private void writeOrdinaryObject(Object obj,
                                  ObjectStreamClass desc,
                                  boolean unshared) throws IOException
{
     if (extendedDebugInfo) {
         debugInfoStack.push(
             (depth == 1 ? "root " : "" ) + "object (class \"" +
             obj.getClass().getName() + "\", " + obj.toString() + ")" );
     }
     try {
         desc.checkSerialize();
 
         bout.writeByte(TC_OBJECT); // 写入Object标志位
         writeClassDesc(desc, false ); // 写入类元数据
         handles.assign(unshared ? null : obj);
         if (desc.isExternalizable() && !desc.isProxy()) {
             writeExternalData((Externalizable) obj);
         } else {
             writeSerialData(obj, desc); // 写入被序列化的对象的实例数据
         }
     } finally {
         if (extendedDebugInfo) {
             debugInfoStack.pop();
         }
     }
}

在这个方法中首先会往底层字节容器中写入TC_OBJECT,表示这是一个新的Object

/**
  * new Object.
  */
final static byte TC_OBJECT =       ( byte ) 0x73 ;

接下来会调用writeClassDesc()方法写入被序列化对象的类的类元数据,writeClassDesc()方法实现如下:

private void writeClassDesc(ObjectStreamClass desc, boolean unshared)
     throws IOException
{
     int handle;
     if (desc == null ) {
         // 如果desc为null
         writeNull();
     } else if (!unshared && (handle = handles.lookup(desc)) != - 1 ) {
         writeHandle(handle);
     } else if (desc.isProxy()) {
         writeProxyDesc(desc, unshared);
     } else {
         writeNonProxyDesc(desc, unshared);
     }
}

在这个方法中会先判断传入的desc是否为null,如果为null则调用writeNull()方法

private void writeNull() throws IOException {
     // TC_NULL =         (byte)0x70;
     // 表示对一个Object引用的描述的结束
     bout.writeByte(TC_NULL);
}

如果不为null,则一般情况下接下来会调用writeNonProxyDesc()方法,该方法实现如下:

private void writeNonProxyDesc(ObjectStreamClass desc, boolean unshared)
     throws IOException
{
     // TC_CLASSDESC =    (byte)0x72;
     // 表示一个新的Class描述符
     bout.writeByte(TC_CLASSDESC);
     handles.assign(unshared ? null : desc);
 
     if (protocol == PROTOCOL_VERSION_1) {
         // do not invoke class descriptor write hook with old protocol
         desc.writeNonProxy( this );
     } else {
         writeClassDescriptor(desc);
     }
 
     Class cl = desc.forClass();
     bout.setBlockDataMode( true );
     if (cl != null && isCustomSubclass()) {
         ReflectUtil.checkPackageAccess(cl);
     }
     annotateClass(cl);
     bout.setBlockDataMode( false );
     bout.writeByte(TC_ENDBLOCKDATA);
 
     writeClassDesc(desc.getSuperDesc(), false );
}

在这个方法中首先会写入一个字节的TC_CLASSDESC,这个字节表示接下来的数据是一个新的Class描述符,接着会调用writeNonProxy()方法写入实际的类元信息,writeNonProxy()实现如下:

void writeNonProxy(ObjectOutputStream out) throws IOException {
     out.writeUTF(name); // 写入类的名字
     out.writeLong(getSerialVersionUID()); // 写入类的序列号
 
     byte flags = 0 ;
     // 获取类的标识
     if (externalizable) {
         flags |= ObjectStreamConstants.SC_EXTERNALIZABLE;
         int protocol = out.getProtocolVersion();
         if (protocol != ObjectStreamConstants.PROTOCOL_VERSION_1) {
             flags |= ObjectStreamConstants.SC_BLOCK_DATA;
         }
     } else if (serializable) {
         flags |= ObjectStreamConstants.SC_SERIALIZABLE;
     }
     if (hasWriteObjectData) {
         flags |= ObjectStreamConstants.SC_WRITE_METHOD;
     }
     if (isEnum) {
         flags |= ObjectStreamConstants.SC_ENUM;
     }
     out.writeByte(flags); // 写入类的flag
 
     out.writeShort(fields.length); // 写入对象的字段的个数
     for ( int i = 0 ; i < fields.length; i++) {
         ObjectStreamField f = fields[i];
         out.writeByte(f.getTypeCode());
         out.writeUTF(f.getName());
         if (!f.isPrimitive()) {
             // 如果不是原始类型,即是对象或者Interface
             // 则会写入表示对象或者类的类型字符串
             out.writeTypeString(f.getTypeString());
         }
     }
}

writeNonProxy()方法中会按照以下几个过程来写入数据:

1. 调用writeUTF()方法写入对象所属类的名字,对于本例中name = com.beautyboss.slogen.TestObject.对于writeUTF()这个方法,在写入实际的数据之前会先写入name的字节数,代码如下:

void writeUTF(String s, long utflen) throws IOException {
         if (utflen > 0xFFFFL) {
             throw new UTFDataFormatException();
         }
         // 写入两个字节的s的长度
         writeShort(( int ) utflen);
         if (utflen == ( long ) s.length()) {
             writeBytes(s);
         } else {
             writeUTFBody(s);
         }
     }

2. 接下来会调用writeLong()方法写入类的序列号UID,UID是通过getSerialVersionUID()方法来获取。

3. 接着会判断被序列化的对象所属类的flag,并写入底层字节容器中(占用两个字节)。类的flag分为以下几类:

final static byte SC_EXTERNALIZABLE = 0×04;表示该类为Externalizable类,即实现了Externalizable接口。

final static byte SC_SERIALIZABLE = 0×02;表示该类实现了Serializable接口。

final static byte SC_WRITE_METHOD = 0×01;表示该类实现了Serializable接口且自定义了writeObject()方法。

final static byte SC_ENUM = 0×10;表示该类是个Enum类型。

对于本例中flag = 0×02表示只是Serializable类型。

4. 第四步会依次写入被序列化对象的字段的元数据。

<1> 首先会写入被序列化对象的字段的个数,占用两个字节。本例中为2,因为TestObject类中只有两个字段,一个是int类型的testValue,一个是InnerObject类型的innerValue。

<2> 依次写入每个字段的元数据。每个单独的字段由ObjectStreamField类来表示。

1) 写入字段的类型码,占一个字节。 类型码的映射关系如下:

 2) 调用writeUTF()方法写入每个字段的名字。注意,writeUTF()方法会先写入名字占用的字节数。

3) 如果被写入的字段不是基本类型,则会接着调用writeTypeString()方法写入代表对象或者类的类型字符串,该方法需要一个参数,表示对应的类或者接口的字符串,最终调用的还是writeString()方法,实现如下

private void writeString(String str, boolean unshared) throws IOException {
     handles.assign(unshared ? null : str);
     long utflen = bout.getUTFLength(str);
     if (utflen <= 0xFFFF ) {
         // final static byte TC_STRING = (byte)0x74;
         // 表示接下来的字节表示一个字符串
         bout.writeByte(TC_STRING);
         bout.writeUTF(str, utflen);
     } else {
         bout.writeByte(TC_LONGSTRING);
         bout.writeLongUTF(str, utflen);
     }
}

在这个方法中会先写入一个标志位TC_STRING表示接下来的数据是一个字符串,接着会调用writeUTF()写入字符串。

执行完上面的过程之后,程序流程重新回到writeNonProxyDesc()方法中

private void writeNonProxyDesc(ObjectStreamClass desc, boolean unshared)
     throws IOException
{
     // 其他省略代码
 
     // TC_ENDBLOCKDATA = (byte)0x78;
     // 表示对一个object的描述块的结束
     bout.writeByte(TC_ENDBLOCKDATA);
 
     writeClassDesc(desc.getSuperDesc(), false ); // 尾递归调用,写入父类的类元数据
}

接下来会写入一个字节的标志位TC_ENDBLOCKDATA表示对一个object的描述块的结束。

然后会调用writeClassDesc()方法,传入父类的ObjectStreamClass对象,写入父类的类元数据。

需要注意的是writeClassDesc()这个方法是个递归调用,调用结束返回的条件是没有了父类,即传入的ObjectStreamClass对象为null,这个时候会写入一个字节的标识位TC_NULL.

在递归调用完成写入类的类元数据之后,程序执行流程回到wriyeOrdinaryObject()方法中,

private void writeOrdinaryObject(Object obj,
                                  ObjectStreamClass desc,
                                  boolean unshared) throws IOException
{
     // 其他省略代码
     try {
         desc.checkSerialize();
         // 其他省略代码
         if (desc.isExternalizable() && !desc.isProxy()) {
             writeExternalData((Externalizable) obj);
         } else {
             writeSerialData(obj, desc); // 写入被序列化的对象的实例数据
         }
     } finally {
         if (extendedDebugInfo) {
             debugInfoStack.pop();
         }
     }
}

从上面的分析中我们可以知道,当写入类的元数据的时候,是先写子类的类元数据,然后递归调用的写入父类的类元数据。

接下来会调用writeSerialData()方法写入被序列化的对象的字段的数据,方法实现如下:

private void writeSerialData(Object obj, ObjectStreamClass desc)
     throws IOException
{
     // 获取表示被序列化对象的数据的布局的ClassDataSlot数组,父类在前
     ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
     for ( int i = 0 ; i < slots.length; i++) {
         ObjectStreamClass slotDesc = slots[i].desc;
         if (slotDesc.hasWriteObjectMethod()) {
            // 如果被序列化对象自己实现了writeObject()方法,则执行if块里的代码
 
            // 一些省略代码
         } else {
             // 调用默认的方法写入实例数据
             defaultWriteFields(obj, slotDesc);
         }
     }
}

在这个方法中首先会调用getClassDataSlot()方法获取被序列化对象的数据的布局,关于这个方法官方文档中说明如下:

/**
  * Returns array of ClassDataSlot instances representing the data layout
  * (including superclass data) for serialized objects described by this
  * class descriptor.  ClassDataSlots are ordered by inheritance with those
  * containing "higher" superclasses appearing first.  The final
  * ClassDataSlot contains a reference to this descriptor.
  */
  ClassDataSlot[] getClassDataLayout() throws InvalidClassException;

需要注意的是这个方法会把从父类继承的数据一并返回,并且表示从父类继承的数据的ClassDataSlot对象在数组的最前面。

对于没有自定义writeObject()方法的对象来说,接下来会调用defaultWriteFields()方法写入数据,该方法实现如下:

private void defaultWriteFields(Object obj, ObjectStreamClass desc)
     throws IOException
{
     // 其他一些省略代码
 
     int primDataSize = desc.getPrimDataSize();
     if (primVals == null || primVals.length < primDataSize) {
         primVals = new byte [primDataSize];
     }
     // 获取对应类中的基本数据类型的数据并保存在primVals字节数组中
     desc.getPrimFieldValues(obj, primVals);
     // 把基本数据类型的数据写入底层字节容器中
     bout.write(primVals, 0 , primDataSize, false );
 
     // 获取对应类的所有的字段对象
     ObjectStreamField[] fields = desc.getFields( false );
     Object[] objVals = new Object[desc.getNumObjFields()];
     int numPrimFields = fields.length - objVals.length;
     // 把对应类的Object类型(非原始类型)的对象保存到objVals数组中
     desc.getObjFieldValues(obj, objVals);
     for ( int i = 0 ; i < objVals.length; i++) {
         // 一些省略的代码
 
         try {
             // 对所有Object类型的字段递归调用writeObject0()方法写入对应的数据
             writeObject0(objVals[i],
                          fields[numPrimFields + i].isUnshared());
         } finally {
             if (extendedDebugInfo) {
                 debugInfoStack.pop();
             }
         }
     }
}

可以看到,在这个方法中会做下面几件事情:

<1> 获取对应类的基本类型的字段的数据,并写入到底层的字节容器中。
<2> 获取对应类的Object类型(非基本类型)的字段成员,递归调用writeObject0()方法写入相应的数据。

从上面对写入数据的分析可以知道,写入数据是是按照先父类后子类的顺序来写的。

至此,Java序列化过程分析完毕,总结一下,在本例中序列化过程如下:

现在可以来分析下第二步中写入的temp.out文件的内容了。

aced        Stream Magic
0005        序列化版本号
73          标志位:TC_OBJECT,表示接下来是个新的Object
72          标志位:TC_CLASSDESC,表示接下来是对Class的描述
0020        类名的长度为 32
636f 6d2e 6265 6175 7479 626f 7373 2e73 com.beautyboss.s
6c6f 6765 6e2e 5465 7374 4f62 6a65 6374 logen.TestObject
d3c6 7e1c 4f13 2afe 序列号
02          flag,可序列化
00 02       TestObject的字段的个数,为 2
49          TypeCode,I,表示 int 类型
0009        字段名长度,占 9 个字节
7465 7374 5661 6c75 65      字段名:testValue
4c          TypeCode:L,表示是个Class或者Interface
000b        字段名长度,占 11 个字节
696e 6e65 724f 626a 6563 74 字段名:innerObject
74          标志位:TC_STRING,表示后面的数据是个字符串
0023        类名长度,占 35 个字节
4c63 6f6d 2f62 6561 7574 7962 6f73 732f  Lcom/beautyboss/
736c 6f67 656e 2f49 6e6e 6572 4f62 6a65  slogen/InnerObje
6374 3b                                  ct;
78          标志位:TC_ENDBLOCKDATA,对象的数据块描述的结束

接下来开始写入数据,从父类Parent开始

0000 0064 parentValue的值: 100

0000 012c testValue的值: 300

接下来是写入InnerObject的类元信息

73 标志位,TC_OBJECT:表示接下来是个新的Object
72 标志位,TC_CLASSDESC:表示接下来是对Class的描述
0021 类名的长度,为 33
636f 6d2e 6265 6175 7479 626f 7373 com.beautyboss
2e73 6c6f 6765 6e2e 496e 6e65 724f .slogen.InnerO
626a 6563 74 bject
4f2c 148a 4024 fb12 序列号
02 flag,表示可序列化
0001 字段个数, 1 个
49 TypeCode,I,表示 int 类型
00 0a 字段名长度, 10 个字节
69 6e6e 6572 5661 6c75 65 innerValue
78 标志位:TC_ENDBLOCKDATA,对象的数据块描述的结束
70 标志位:TC_NULL,Null object reference.
0000 00c8 innervalue的值: 200

反序列化:readObject()

反序列化过程就是按照前面介绍的序列化算法来解析二进制数据。

有一个需要注意的问题就是,如果子类实现了Serializable接口,但是父类没有实现Serializable接口,这个时候进行反序列化会发生什么情况?

答:如果父类有默认构造函数的话,即使没有实现Serializable接口也不会有问题,反序列化的时候会调用默认构造函数进行初始化,否则的话反序列化的时候会抛出.InvalidClassException:异常,异常原因为no valid constructor。

static和transient字段不能被序列化

序列化的时候所有的数据都是来自于ObejctStreamClass对象,在生成ObjectStreamClass的构造函数中会调用fields = getSerialFields(cl);这句代码来获取需要被序列化的字段,getSerialFields()方法实际上是调用getDefaultSerialFields()方法的,getDefaultSerialFields()实现如下:

private static ObjectStreamField[] getDefaultSerialFields(Class<?> cl) {
     Field[] clFields = cl.getDeclaredFields();
     ArrayList<ObjectStreamField> list = new ArrayList<>();
     int mask = Modifier.STATIC | Modifier.TRANSIENT;
 
     for ( int i = 0 ; i < clFields.length; i++) {
         if ((clFields[i].getModifiers() & mask) == 0 ) {
             // 如果字段既不是static也不是transient的才会被加入到需要被序列化字段列表中去
             list.add( new ObjectStreamField(clFields[i], false , true ));
         }
     }
     int size = list.size();
     return (size == 0 ) ? NO_FIELDS :
         list.toArray( new ObjectStreamField[size]);
}

从上面的代码中可以很明显的看到,在计算需要被序列化的字段的时候会把被static和transient修饰的字段给过滤掉。

在进行反序列化的时候会给默认值。

如何实现自定义序列化和反序列化?

只需要被序列化的对象所属的类定义了void writeObject(ObjectOutputStream oos)和void readObject(ObjectInputStream ois)方法即可,Java序列化和反序列化的时候会调用这两个方法,那么这个功能是怎么实现的呢?

1. 在ObjectClassStream类的构造函数中有下面几行代码:

cons = getSerializableConstructor(cl);
writeObjectMethod = getPrivateMethod(cl, "writeObject" ,
     new Class<?>[] { ObjectOutputStream. class },
     Void.TYPE);
readObjectMethod = getPrivateMethod(cl, "readObject" ,
     new Class<?>[] { ObjectInputStream. class },
     Void.TYPE);
readObjectNoDataMethod = getPrivateMethod(
     cl, "readObjectNoData" , null , Void.TYPE);
hasWriteObjectData = (writeObjectMethod != null );

getPrivateMethod()方法实现如下:

private static Method getPrivateMethod(Class<?> cl, String name,
                                    Class<?>[] argTypes,
                                    Class<?> returnType)
{
     try {
         Method meth = cl.getDeclaredMethod(name, argTypes);
         meth.setAccessible( true );
         int mods = meth.getModifiers();
         return ((meth.getReturnType() == returnType) &&
                 ((mods & Modifier.STATIC) == 0 ) &&
                 ((mods & Modifier.PRIVATE) != 0 )) ? meth : null ;
     } catch (NoSuchMethodException ex) {
         return null ;
     }
}

可以看到在ObejctStreamClass的构造函数中会查找被序列化类中有没有定义为void writeObject(ObjectOutputStream oos) 的函数,如果找到的话,则会把找到的方法赋值给writeObjectMethod这个变量,如果没有找到的话则为null。

2. 在调用writeSerialData()方法写入序列化数据的时候有

private void writeSerialData(Object obj, ObjectStreamClass desc)
     throws IOException
{
     ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
     for ( int i = 0 ; i < slots.length; i++) {
         ObjectStreamClass slotDesc = slots[i].desc;
         if (slotDesc.hasWriteObjectMethod()) {
             // 其他一些省略代码
             try {
                 curContext = new SerialCallbackContext(obj, slotDesc);
                 bout.setBlockDataMode( true );
                 // 在这里调用用户自定义的方法
                 slotDesc.invokeWriteObject(obj, this );
                 bout.setBlockDataMode( false );
                 bout.writeByte(TC_ENDBLOCKDATA);
             } finally {
                 curContext.setUsed();
                 curContext = oldContext;
                 if (extendedDebugInfo) {
                     debugInfoStack.pop();
                 }
             }
 
             curPut = oldPut;
         } else {
             defaultWriteFields(obj, slotDesc);
         }
     }
}

首先会调用hasWriteObjectMethod()方法判断有没有自定义的writeObject(),代码如下

boolean hasWriteObjectMethod() {
     return (writeObjectMethod != null );
}

hasWriteObjectMethod()这个方法仅仅是判断writeObjectMethod是不是等于null,而上面说了,如果用户自定义了void writeObject(ObjectOutputStream oos)这么个方法,则writeObjectMethod不为null,在if()代码块中会调用slotDesc.invokeWriteObject(obj, this);方法,该方法中会调用用户自定义的writeObject()方法

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值