EffectiveJava--序列化

编程技术  /  houtizong 发布于 3年前   54
本章内容:
1. 谨慎地实现Serializable接口
2. 考虑使用自定义的序列化形式
3. 保护性地编写readObject方法
4. 对于实例控制,枚举类型优先于readResolve
5. 考虑用序列化代理代替序列化实例

1. 谨慎地实现Serializable接口
    对象序列化API,它提供了一个框架,用来将对象编码成字节流,并从字节流编码中重新构建对象。将一个对象编码成一个字节流,称作将该对象序列化,相反的处理过程称作反序列化。一旦对象被序列化后,它的编码就可以从一台正在运行的虚拟机传递到另一台虚拟机上,或者被存储到磁盘上,供以后反序列化时用。序列化技术为远程通信提供了标准和线路级对象表示法,也为JavaBeans组件提供了标准和持久化数据格式。
    要想使一个类的实例可被序列化,非常简单,只要在它的声明中加入"implements Serializable"字样即可。正因为太容易了,所以普遍存在这样一种误解,认为程序员只需要做极少量的工作就可以支持序列化了。实际的情形要复杂得多。虽然使一个类可被序列化的直接开销低到甚至可以忽略不计,但是为了序列化而付出的长期开销往往是实实在在的。

    为实现Serializable而付出的最大代价是,一旦一个类被发布,就大大降低了"改变这个类的实现"的灵活性。如果一个类实现了Serializable,它的字节流编码(或者说序列化形式,serialized form)就变成了它的导出的API的一部分。一旦这个类被广泛使用,往往必须永远支持这种序列化形式,就好像你必须要支持导出的API的所有其他部分一样。如果你不努力设计一个自定义的序列化形式(custom serialized form),而仅仅接受了默认的序列化形式,这种序列化形式将被永远地束缚在该类最初的内部表示法上。换句话说,如果你接受了默认的序列化形式,这个类中私有的和包级私有的实例域将都变成导出的API的一部分,这不符合"最低限度地访问域"的实践准则(见第13条),从而它就失去了作为信息隐藏工具的有效性。
    如果你接受了默认的序列化形式,并且以后又要改变这个类的内部表示法,结果可能导致序列化形式的不兼容。客户端程序企图用这个类的旧版本来序列化一个类,然后用新版本进行反序列化,结果将导致程序失败。在改变内部表示法的同时仍然维持原来的序列化形式(使用ObjectOutputStream.putFields和ObjectInputStream.readFields),这也是可能的,但是做起来比较困难,并且会在源代码中留下一些可以明显的隐患。因此,你应该仔细地设计一种高质量的序列化形式,并且在很长时间内都愿意使用这种形式(见第75,78条)。这样做将会增加开发的初始成本,但这是值得的。设计良好的序列化形式也许会给类的演变带来限制;但是设计不好的序列化形式则可能会使类根本无法演变。
    序列化会使类的演变受到限制,这种限制的一个例子与流的唯一标识符(stream unique identifier)有关,通常它也被称为序列版本UID(serial version UID)。每个可序列化的类都有一个唯一标识号与它相关联。如果你没有在一个名为serialVersionUID的私有静态final的long域中显式地指定该标识号,系统就会自动地将一个复杂的过程作用在这个类上,从而在运行时产生该标识号。这个自动产生的值会受到类名称、它所实现的接口的名称、以及所有公有的和受保护的成员的名称所影响。如果你通过任何方式改变了这些信息,比如,增加了一个不是很重要的工具方法,自动产生的序列版本UID也会发生变化。因此,如果你没有声明一个显式的序列版本UID,兼容性将会遭到破坏,在运行时导致InvalidClassException异常。

    实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。通常情况下,对象是利用构造器来创建的;序列化机制是一种语言之外的对象创建机制(extralinguistic mechanism)。无论你是接受了默认的行为,还是覆盖了默认的行为,反序列化机制(deserialization)都是一个"隐藏的构造器",具备与其他构造器相同的特点。因为反序列化机制中没有显式的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有"由构造器建立起来的约束关系",并且不允许攻击者访问正在构造过程中的对象的内部信息。依靠默认的反序列化机制,可以很容易地使对象的约束关系遭到破坏,以及遭受到非法访问(见第3条)。

    实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。当一个可序列化的类被修订的时候,很重要的一点是,要检查是否可以"在新版本中序列化一个实例,然后在旧版本中反序列化",反之亦然。因此,测试所需要的工作量与"可序列化的类的数量和发行版本号"的乘积成正比,这个乘积可能会非常大。这些测试不可能自动构建,因为除了二进制兼容性(binary compatibility)以外,你还必须测试语义兼容性(semantic compatibility)。换句话说,你必须既要确保"序列化-反序列化"过程成功,也要确保结果产生的对象真正是原始对象的复制品。可序列化类的变化越大,它就越需要测试。如果在最初编写一个类的时候,就精心设计了自定义的序列化形式,测试的要求就可以有所降低,但是也不能完全没有测试。

    实现Serializable接口并不是一个很轻松就可以做出的决定。它提供了一些实在的益处:如果一个类将要加入到某个框架中,并且该框架依赖于序列化来实现对象传输或者持久化,对于这个类来说,实现Serializable接口就非常有必要。更进一步来看,如果这个类要成为另一个类的一个组件,并且后者必须实现Serializable接口,若前者也实现了Serializable接口,它就会更易于被后者使用。然而,有许多实际的开销都与实现Serializable接口有关。每当你实现一个类的时候,都需要权衡一下所付出的代价和带来的好处。根据经验,比如Date和BigInteger这样的值类应该实现Serializable,大多数的集合类也应该如此。代表活动实体的类,比如线程池(thread pool),一般不应该实现Serializable。
    为了继承而设计的类应该很少实现Serializable,接口也应该很少会扩展它。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然而在有些情况下违反这条规则却是合适的。例如,如果一个类或者接口存在的目的主要是为了参与到某个框架中,该框架要求所有的参与者都必须实现Serializable,那么,对于这个类或者接口来说,实现或者扩展Serializable就是非常有意义的。
    为了继承而设计的类中真正实现了Serializable的有Throwable、Component和HttpServlet。因为Throwable实现了Serializable,所以RMI的异常可以从服务器端传到客户端。Component实现了Serializable,因此GUI可以被发送、保存和恢复。HttpServlet实现了Serializable,因此会话状态可以被缓存。
    如果一个专门为了继承而设计的类不是可序列化的,就不可能编写出可序列化的子类。特别是,如果超类没有提供可访问的无参构造器,子类也不可能做到可序列化。因此,对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器。

    内部类不应该实现Serializable。它们使用编译器产生的合成域来保存指向外围实例的引用,以及保存来自外围作用域的局部变量的值。因此,内部类的默认序列化形式是定义不清楚的。然而,静态成员类却是可以实现Serializable接口。
    简而言之,千万不要认为实现Serializable接口会很容易。除非一个类在用了一段时间之后就会被抛弃,否则,实现Serializable接口就是个很严肃的承诺,必须认真对待。如果一个类是为了继承而设计的,则吏加需要加倍小心。对于这样的类而言,在“允许子类实现Serializable接口”或“禁止子类实现Serializable接口”两者之间的一个折衷设计方案是,提供一个可访问的无参构造器,这种设计方案允许(但不要求)子类实现Serializable接口。

2. 考虑使用自定义的序列化形式
    设计一个类的序列化形式和设计该类的API 同样重要,因此在没有认真考虑好默认的序列化形式是否合适之前,不要贸然使用默认的序列化行为。在作出决定之前,你需要从灵活性、性能和正确性多个角度对这种编码形式进行考察。一般来讲,只有当你自行设计的自定义序列化形式与默认的形式基本相同时,才能接受默认的序列化形式。比如,当一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。见如下代码示例:
        public class Name implements Serializable {
            private final String lastName;
            private final String firstName;
            private final String middleName;
            ... ...
        }
    从逻辑角度而言,该类的三个域字段精确的反应出它的逻辑内容。然而有的时候,即便默认的序列化形式是合适的,通常还必须提供一个readObject 方法以保证约束关系和安全性,如上例代码中,firstName 和lastName 不能为null 等。
    下面我们再看一个极端的例子:
        public final class StringList implements Serializable {
            private int size = 0;
            private Entry head = null;
            private static class Entry implements Serializable {
                String data;
                Entry next;
                Entry previous;
            }
        }
    对于上面的示例代码,如果采用默认形式的序列化,将会导致双向链表中的每一个节点的数据以及前后关系都会被序列化。因此这种物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下几个缺点:
(1)它使这个类的导出API 永远的束缚在该类的内部表示法上,即使今后找到更好的的实现方式,也无法摆脱原有的实现方式。
(2)它会消耗过多的空间。事实上对于上面的示例代码,我们只需要序列化数据部分,可以完全忽略
链表节点之间的关系。
(3)它会消耗过多的时间。
(4)它会引起栈溢出。
    根据以上四点,我们修订了StringList 类的序列化实现方式,见如下代码:
        public final class StringList implements Serializable {
            private transient int size = 0;
            private transient Entry head = null;
            private static class Entry {
                String data;
                Entry next;
                Entry previous;
            }
            private void writeObject(ObjectOutputStream s) throws IOException{
                s.defaultWriteObject();
                s.writeInt(size);
                for (Entry e = head; e != null; e = e.next)
                    s.writeObject(e.data);
            }
            private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
                s.defaultReadObject();
                int numElemnet = s.readInt();
                for (int i = 0; i < numElements; i++)
                    add((String)s.readObject());
            }
            public final void add(String s) { ... }
                 ... ...
        }
    在修订代码中,所有的域字段都是transient(表明这个实例域将从默认序列化形式中省略掉),但writeObject 和readObject 方法的首要任务仍然是先调用defaultWriteObject 和defaultReadObject 方法,即便这对于缺省序列化形式并不是必须的。因为在今后的修改中,很有可能会为该类添加非transient 域字段,一旦忘记同步修改writeObject 或readObject 方法,将会导致序列化和反序列化的数据处理方式不一致。
    对于默认序列化还需要进一步说明的是,当一个或多个域字段被标记为transient 时,如果要进行反序列化,这些域字段都将被初始化为其类型默认值,如对象引用域被置为null,数值基本域的默认值为0,boolean域的默认值为false。如果这些值不能被任何transient 域所接受,你就必须提供一个readObject方法。它首先调用defaultReadObject,然后再把这些transient 域恢复为可接受的值。
    最后需要说明的是,无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步,见如下代码:
        private synchronized void writeObject(ObjectOutputStream s) throws IOException {
            s.defaultWriteObject();
        } 
   
    无论你选择了哪种序列化形式,都要为自己编写的每个可序列化的类声明一个显式的序列版本UID。这样可以避免序列版本UID成为潜在的不兼容根源,同时也会带来小小的性能好处,因为不需要去算序列版本UID。

3. 保护性地编写readObject方法
    在前面介绍了一个不可变的日期范围类,它包含可变的私有Date 域。该类通过在其构造器和访问方法中保护性的拷贝Date 对象,极力的维护其约束条件和不可变性。见如下代码:
        public final class Period {
            private final Date start;
            private final Date end;
            public Period(Date start, Date end) {
                this.start = new Date(start.getTime());
                this.end = new Date(end.getTime());
                if (this.start.compareTo(this.end) > 0)
                    throw new IllegalArgumentException();
            }
            public Date start() {
                return new Date(start.getTime());
            }
            public Date end() {
                return new Date(end.getTime());
            }
            public String toString() {
                return start + " - " + end;
            }
            ... ...
        }
    这个对象的物理表示法和其逻辑表示法完全匹配,所以我们可以使用默认的序列化形式。因此在声明该类的地方增加" implements Serializable "。然而,如果你真是这样做了,那么这个类将不再保证他的关键约束了。
    问题在于,如果反序列化的数据源来自于该类实例的正常序列化,那么将不会引发任何问题。如果恰恰相反,反序列化的数据源来自于一组伪造的数据流,事实上,反序列化的机制就是从一组有规则的数据流中实例化指定对象,那么我们将不得不面对Period 实例对象的内部约束被破坏的危险,见如下代码:
        public class BogusPeriod {
            private static final byte [] serializedForm = new byte[] {
                0x00, 0x43,(byte)0xf8,0x34, (bype)0xde , ......
            };
            public static void main(String[] args) {
                Period p = (Period)deserialize(serializedForm);
                System.out.println(p);
            }
            private static Object deserialize(byte[] sf) {
                try {
                    InputStream is = new ByteArrayInputStream(sf);
                    ObjectInputStream ois = new ObjectInputStream(is);
                    return ois.readObject();
                } catch (Exception e) {
                    throw new IllegalArgumentException(e);
                }
            }
        }
    如果执行上面的代码就会发现Period 的约束被打破了,end 的日期早于start。为了修正这个问题,可以为Period 提供一个readObject 方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果检查失败,则抛出InvalidObjectException 异常,使反序列化过程不能成功地完成。
        private void readObject(ObjectInputStream s) throws IOException,ClassNotFoundException {
            s.defaultReadObject();
            if (start.compareTo(end) > 0)
                throw new InvalidObjectException(start + " after " + end);
        }
    除了上面的攻击方式之外,还存在着另外一种更为隐匿的攻击方式,它也是通过伪造序列化数据流的方式来骗取反序列化方法的信任。它在伪造数据时,将私有域字段的引用在外部保存起来,这样当对象实例反序列化成功后,由于外部仍然可以操作其内部数据,因此危险仍然存在。如何避免该风险呢?见如下修订后的readObject 方法:
        private void readObject(ObjectInputStream s) throws IOException,ClassNotFoundException {
            s.defaultReadObject();
            //执行保护性copy
            start = new Date(start.getTime());
            end = new Date(end.getTime());
            if (start.compareTo(end) > 0)
                throw new InvalidObjectException(start + " after " + end);
        }
    注意,保护性copy 一定要在有效性检查之前进行。
    这里给出一个基本的规则,可以用来帮助确定默认的readObject 方法是否可以被接受。规则是增加一个公有的构造器,其参数对应于该对象中每个非transient 域,并且无论参数的值是什么,都是不进行检查就可以保存到相应的域中的。对于这样的做法如果仍然可以接受,那么默认的readObject 就是合理,否则就需要提供一个显式的readObject 方法。
    对于非final 的可序列化类,在readObject 方法和构造器之间还有其他类似的地方,readObject方法不可以调用可被覆盖的方法,无论是直接调用还是间接调都不可以。如果违反了该规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。程序很可能会失败。

4. 对于实例控制,枚举类型优先于readResolve
    如果在Singleton模式的类的声明中加上“implements Serializable”它就不再是一个Singleton。无论该类使用了默认的序列化形式,还是自定义的序列化形式,都没有关系,也跟它是否提供了显式的readObject方法无关。任何一个readObject方法,不管是显式的还是默认的,它都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例。
    readResolve特性允许你用readObject创建的实例代替另一个实例,对于一个正在被反序列化的对象,如果它的类定义了一个readResolve方法,并且具备正确的声明,那么在反序列化之后,新建对象上的readResolve方法就会被调用,然后该方法返回的对象引用将被返回,取代新建的对象。如下:
        public class Elvis implements Serializable{
            public static final Elvis INSTANCE = new Elvis();
            private Elvis(){...}
            public void leaveTheBuilding(){...}
            private Object readResolve(){
                return INSTANCE;
            }
        }
    该方法忽略了被反序列化的对象,只返回该类初始化时创建的那个特殊的Elvis实例。因此,Elvis实例的序列化形式并不需要包含任何实际的数据,所有的实例域都应该被声明为transient。事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域都必须声明为transient的。否则,那种破釜沉舟式的攻击者,就有可能在readResolve方法被运行之前,保护指向反序列化对象的引用。这种攻击有点复杂,但是背后的思想却很简单,如果Singleton包含了一个非transient的对象引用域,这个域的内容就可以在Singleton的readResolve方法运行之前被反序列化,当对象引用域的内容被反序列化时,它就允许一个精心制作的流“盗用”指向最初被反序列化的Singleton的引用。如下:
        // Broken singleton - has nontransient object reference field!
        public class Elvis implements Serializable {
            public static final Elvis INSTANCE = new Elvis();
            private Elvis() { }
            private String[] favoriteSongs =  { "Hound Dog", "Heartbreak Hotel" };
            public void printFavorites() {
                System.out.println(Arrays.toString(favoriteSongs));
            }

            private Object readResolve() throws ObjectStreamException {
                return INSTANCE;
            }
        }
    下面是盗用者类:
        public class ElvisStealer implements Serializable {
            static  Elvis impersonator;
            private Elvis payload;
            private Object readResolve() {
                impersonator = payload;
                return new String[]{"A Fool Such as I"};
            }
            private static final long serialVersionUID = 0;
        }
    最后,这是一个不完整的程序,它反序列化一个手工制作的流,为那个有缺陷的Singleton产生两个截然不同的实例。
        public class ElvisImpersonator{
            private static final byte [] serializedForm = new byte[] {
                0x00, 0x43,(byte)0xf8,0x34, (bype)0xde , ......
            };
            public static void main(String[] args) {
                Elvis elvis = (Elvis)deserialize(serialiaedForm)
                Elvis impersonator = ElvisStealer.impersonator;
                elvis.printFavorites();
                impersonator.printFavorites();
            }
        }
    运行这个程序会产生下列输出,最终证明可以创建两个截然不同的Elvis实例。
   
    通过将favorite域声明为transient,可以修正这个问题,但是最好把Elvis做成是一个单元素的枚举类型进行修正:
        public enum Elvis {
            INSTANCE;
            private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
            public void printFavorites() {
                System.out.println(Arrays.toString(favoriteSongs));
            }
        }
    readResolve的可访问性很重要。如果把readResolve方法放在一个final类上,它就应该是私有的。如果readResolve方法是受保护的或者公有的,并且子类没有覆盖它,对序列化过的子类实例进行反序列化,就会产生一个超类实例,这样有可能导致ClassCastException异常。

    总而言之,你应该尽可能地使用枚举类型来实施控制的约束条件。如果做不到,同时又需要一个既可序列化又是实例受控的类,就必须提供一个readResolver方法,并确保该类的所有实例域都为基本类型,或者是transient的。

5. 考虑用序列化代理代替序列化实例
    正如前面所说,决定实现Serializable,会增加出错和出现安全问题的可能性,因为它导致实例要利用语言之外的机制来创建,而不是用普通的构造器。然而,有一种方法可以极大地减少这些风险。这种方法就是序列化代理模式。
   序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的实例的逻辑状态。这个嵌套类被称作序列化代理(serialization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这个构造器只从它的参数中复制数据:它不需要进行任何一致性检查或者保护性拷贝。按设计,序列代理的默认序列化形式是外围类最好的序列化形式。外围类及其序列代理都必须声明实现Serializable接口。 编写一个序列化代理如下:
        // Serialization proxy for Period class
        private static class SerializationProxy implements Serializable {
            private final Date start;
            private final Date end;
            SerializationProxy(Period p) {
                this.start = p.start;
                this.end = p.end;
            }
            private static final long serialVersionUID = 234098243823485285L;
        }
    接下来,将下面的writeReplace方法添加到外围类中。通过序列化代理,这个方法可以被逐字地复制到任何类中:
        // writeReplace method for the serialization proxy pattern
        private Object writeReplace() {
            return new SerializationProxy(this);
        }
    这个方法的存在导致序列化系统产生一个SerializationProxy实例,代替外围类的实例。换句话说,writeReplace方法在序列化之前,将外围类的实例转变成了它的序列化代理。有了这个writeReplace方法之后,序列化系统永远不会产生外围类的序列化实例,但是攻击者有可能伪造,企图违反该类的约束条件。为了确保这种攻击无法得逞,只要在外围类中添加这个readObject方法即可:
        // readObject method for the serialization proxy pattern
        private void readObject(ObjectInputStream stream) throws InvalidObjectException {
            throw new InvalidObjectException("Proxy required");
        }
    最后,在SerializationProxy类中提供一个readResolve方法,它返回一个逻辑上相当的外围类的实例。这个方法的出现,导致序列化系统在反序列化时将序列化代理转变回外围类的实例。这个readResolve方法仅仅利用它的公有API创建外围类的一个实例,这正是该模式的魅力之所在。它极大地消除了序列化机制中语言本身之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或者构造器建立了这些约束条件,并且它的实例方法在维持着这些约束条件,你就可以确信序列化也会维持这些约束条件。
    以下是上述Period.SerializationProxy的readResolve方法:
        // readResolve method for Period.SerializationProxy
        private Object readResolve() {
            return new Period(start, end); // Uses public constructor
        }
    正如保护性拷贝方法一样,序列化代理方法可以阻止伪字节流的攻击以及内部域的盗用攻击。与前两种方法不同,这种方法允许Period的域为final的,为了确保Period类真正是不可变的,这一点很有必要。与前两种方法不同的还有,这种方法不需要太费心思。你不必知道哪些域可能受到狡猾的序列化攻击的威胁,你也不必显式地执行有效性检查,作为反序列化的一部分。
    还有一种方法,利用这种方法时,序列化代理模式的功能比保护性拷贝的更加强大。序列化代理模式允许反序列化实例有着与原始序列化实例不同的类。你可能认为这在实际应用中没有什么作用,其实不然。
    考虑EnumSet的情况。这个类没有公有的构造器,只有静态工厂。从客户的角度来看,它们返回EnumSet实例,但是实际上,它们是返回两种子类之一,具体取决于底层枚举类型的大小。如果底层的枚举类型有64个或者少于64个的元素,静态工厂就返回一个RegularEnumSet;否则,它们就返回一个JumboEnumSet。现在考虑这种情况:如果序列化一个枚举集合,它的枚举类型有60个元素,然后给这个枚举类型再增加5个元素,之后反序列化这个枚举集合。当它被序列化的时候,是一个RegularEnumSet实例,但是一旦它被反序列化,它最好是一个JumboEnumSet实例。实际发生的情况正是如此,因为EnumSet使用序列化代理模式。如果你有兴趣,可以看看EnumSet的这个序列化代理,它实际上就这么简单:
        // EnumSet's serialization proxy
        private static class SerializationProxy <E extends Enum<E>> implements Serializable {
            // The element type of this enum set.
            private final Class<E> elementType;
            // The elements contained in this enum set.
            private final Enum[] elements;
            SerializationProxy(EnumSet<E> set) {
                elementType = set.elementType;
                elements = set.toArray(EMPTY_ENUM_ARRAY); // (Item 43)
            }
            private Object readResolve() {
                EnumSet<E> result = EnumSet.noneOf(elementType);
                for (Enum e : elements)
                    result.add((E)e);
                return result;
            }
            private static final long serialVersionUID = 362491234563181265L;
        }
    序列化代理模式有两个局限性。它不能与可以被客户端扩展的类兼容。它也不能与对象图中包含循环的某些类兼容:如果你企图从一个对象的序列化代理的readResolve方法内部调用这个对象中的方法,就会得到一个ClassCastException异常,因为你还没有这个对象,只有它的序列化代理。
    最后,序列化代理模式所增强的功能和安全性并不是没有代价的。在我的机器上,通过序列化代理来序列化和反序列化Period实例的开销,比用保护性拷贝进行的开销增加了14%。
    总而言之,每当你发现自己必须在一个不能被客户端扩展的类上编写readObject或者writeObject方法的时候,就应该考虑使用序列化代理模式。要想稳健地将带有重要约束条件的对象序列化时,这种模式可能是最容易的方法。
上一篇:EffectiveJava--并发
下一篇:Hadoop

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!

留言需要登陆哦

技术博客集 - 网站简介:
前后端技术:
后端基于Hyperf2.1框架开发,前端使用Bootstrap可视化布局系统生成

网站主要作用:
1.编程技术分享及讨论交流,内置聊天系统;
2.测试交流框架问题,比如:Hyperf、Laravel、TP、beego;
3.本站数据是基于大数据采集等爬虫技术为基础助力分享知识,如有侵权请发邮件到站长邮箱,站长会尽快处理;
4.站长邮箱:[email protected];

      订阅博客周刊 去订阅

文章归档

文章标签

友情链接

Auther ·HouTiZong
侯体宗的博客
© 2020 zongscan.com
版权所有ICP证 : 粤ICP备20027696号
PHP交流群 也可以扫右边的二维码
侯体宗的博客