一种更快的针对Java对象深拷贝的方法(翻译)

一种更快的针对Java对象深拷贝的方法(翻译)

翻译自Faster Deep Copies of Java Objects

The java.lang.Object root superclass defines a clone() method that will, assuming the subclass implements the java.lang.Cloneable interface, return a copy of the object. While Java classes are free to override this method to do more complex kinds of cloning, the default behavior of clone() is to return a shallow copy of the object. This means that the values of all of the origical object’s fields are copied to the fields of the new object.

java.lang.object 定义了一个clone()方法,假定子类实现java.lang.cloneable接口,则返回该对象的一个​​副本。
而java类可以自由地重写这个方法来实现更复杂的克隆方式,clone()的默认行为是返回对象的浅表副本。
这意味着所有原始对象的字段的值被复制到新对象的字段中

A property of shallow copies is that fields that refer to other objects will point to the same objects in both the original and the clone. For fields that contain primitive or immutable values (int, String, float, etc…), there is little chance of this causing problems. For mutable objects, however, cloning can lead to unexpected results. Figure 1 shows an example.

浅拷贝的特性是引用其他对象的字段将指向原始和克隆中的相同对象。对于包含原始或不可变值的字段(int,string,float等等),这个问题几乎不存在。对于可变对象,克隆可能会导致意想不到的结果。如下面的代码1所示。

import java.util.Vector;

public class Example1 {

    public static void main(String[] args) {
        // Make a Vector
        Vector original = new Vector();

        // Make a StringBuffer and add it to the Vector
        StringBuffer text = new StringBuffer("The quick brown fox");
        original.addElement(text);

        // Clone the vector and print out the contents
        Vector clone = (Vector) original.clone();
        System.out.println("A. After cloning");
        printVectorContents(original, "original");
        printVectorContents(clone, "clone");
        System.out.println(
            "--------------------------------------------------------");
        System.out.println();

        // Add another object (an Integer) to the clone and 
        // print out the contents
        clone.addElement(new Integer(5));
        System.out.println("B. After adding an Integer to the clone");
        printVectorContents(original, "original");
        printVectorContents(clone, "clone");
        System.out.println(
            "--------------------------------------------------------");
        System.out.println();

        // Change the StringBuffer contents
        text.append(" jumps over the lazy dog.");
        System.out.println("C. After modifying one of original's elements");
        printVectorContents(original, "original");
        printVectorContents(clone, "clone");
        System.out.println(
            "--------------------------------------------------------");
        System.out.println();
    }

    public static void printVectorContents(Vector v, String name) {
        System.out.println("  Contents of \"" + name + "\":");

        // For each element in the vector, print out the index, the
        // class of the element, and the element itself
        for (int i = 0; i < v.size(); i++) {
            Object element = v.elementAt(i);
            System.out.println("   " + i + " (" + 
                element.getClass().getName() + "): " + 
                element);
        }
        System.out.println();
    }

}

Figure 1. Modifying Vector contents after
代码1.在本例中修改克隆后的矢量内容

In this example we create a Vector and add a StringBuffer to it. Note that StringBuffer (unlike, for example, String is mutable — it’s contents can be changed after creation. Figure 2 shows the output of the example in Figure 1.

我们创建一个矢量并添加一个字符串缓冲区。请注意,字符串缓冲区(例如,不像字符串是可变的 - 它的内容可以在创建后更改。图2显示了图1中的示例的输出。


A. After cloning

  Contents of "original":
   0 (java.lang.StringBuffer): The quick brown fox

  Contents of "clone":
   0 (java.lang.StringBuffer): The quick brown fox

 --------------------------------------------------------

B. After adding an Integer to the clone
  Contents of "original":
   0 (java.lang.StringBuffer): The quick brown fox

  Contents of "clone":
   0 (java.lang.StringBuffer): The quick brown fox
   1 (java.lang.Integer): 5

 --------------------------------------------------------

C. After modifying one of original's elements
  Contents of "original":
   0 (java.lang.StringBuffer): The quick brown fox jumps over the lazy dog.

  Contents of "clone":
   0 (java.lang.StringBuffer): The quick brown fox jumps over the lazy dog.
   1 (java.lang.Integer): 5

 --------------------------------------------------------

Figure 2. Output from the example code in Figure 1

In the first block of output (“A”), we see that the clone operation was successful: The original vector and the clone have the same size (1), content types, and values. The second block of output (“B”) shows that the original vector and its clone are distinct objects. If we add another element to the clone, it only appears in the clone, and not in the original. The third block of output (“C”) is, however, a little trickier. Modifying the StringBuffer that was added to the original vector has changed the value of the first element of both the original vector and its clone. The explanation for this lies in the fact that clone made a shallow copy of the vector, so both vectors now point to the exact same StringBuffer instance.

在第一个输出块(“a”)中,我们看到克隆操作是成功的:原始向量和克隆具有相同的大小(1),内容类型和值。第二块输出(“b”)表明原始矢量及其克隆是不同的对象。如果我们向克隆添加另一个元素,它只会出现在克隆中,而不是原来的。然而,输出的第三块(“c”)却有点棘手。修改添加到原始矢量的字符串缓冲区已经改变了原始矢量及其克隆的第一个元素的值。对此的解释在于克隆做了一个向量的浅拷贝,所以现在这两个向量都指向了完全相同的stringbuffer实例。

This is, of course, sometimes exactly the behavior that you need. In other cases, however, it can lead to frustrating and inexplicable errors, as the state of an object seems to change “behind your back”.

当然,这有时恰恰是你需要的结果。但是在其他情况下,它可能会导致令人沮丧和莫名其妙的错误,因为一个对象的状态似乎在“背后”改变。

The solution to this problem is to make a deep copy of the object. A deep copy makes a distinct copy of each of the object’s fields, recursing through the entire graph of other objects referenced by the object being copied. The Java API provides no deep-copy equivalent to Object.clone(). One solution is to simply implement your own custom method (e.g., deepCopy()) that returns a deep copy of an instance of one of your classes. This may be the best solution if you need a complex mixture of deep and shallow copies for different fields, but has a few significant drawbacks:

解决这个问题的办法是做一个对象的深层拷贝。一个深层副本将对每个对象的字段进行独特的拷贝,递归遍历被拷贝对象引用的其他对象。java api没有提供与object.clone()相当的深层拷贝。一种解决方案是简单地实现自己的自定义方法(例如,deepcopy()),该方法返回您的某个类的实例的深层副本。这可能是最好的解决方案,如果你需要一个复杂的深层和浅层副本混合的不同场景,但也有一些明显的缺点:

  1. You must be able to modify the class (i.e., have the source code) or implement a subclass. If you have a third-party class for which you do not have the source and which is marked final, you are out of luck.

    您必须能够修改该类(即具有源代码)或实现一个子类。如果你有一个第三方类,你没有源代码,而且是final类型的,那么你运气不好。

  2. You must be able to access all of the fields of the class’s superclasses. If significant parts of the object’s state are contained in private fields of a superclass, you will not be able to access them.
    您必须能够访问该类的所有父类的字段。如果对象状态的重要部分包含在超类的私有字段中,则将无法访问它们。
  3. You must have a way to make copies of instances of all of the other kinds of objects that the object references. This is particularly problematic if the exact classes of referenced objects cannot be known until runtime.
    您必须有办法复制对象引用的所有其他类型对象的实例的副本。如果直到运行时才能知道所引用对象的确切类别,则这是非常有问题的。
  4. Custom deep copy methods are tedious to implement, easy to get wrong, and difficult to maintain. The method must be revisited any time a change is made to the class or to any of its superclasses.
    自定义的深层复制方法实施繁琐,容易出错,难以维护。该方法必须在任何时候对类或其任何父类进行更改时重新修改。

A common solution to the deep copy problem is to use Java Object Serialization (JOS). The idea is simple: Write the object to an array using JOS’s ObjectOutputStream and then use ObjectInputStream to reconsistute a copy of the object. The result will be a completely distinct object, with completely distinct referenced objects. JOS takes care of all of the details: superclass fields, following object graphs, and handling repeated references to the same object within the graph. Figure 3 shows a first draft of a utility class that uses JOS for making deep copies. 深拷贝问题的一个常见解决方案是使用java对象序列化(jos)。这个想法很简单:使用jos的ObjectOutputStream将对象写入数组,然后使用ObjectOutputStream来重新构造对象的副本。结果将是完全不同的对象,具有完全不同的引用对象。jos负责所有的细节:父类字段,引用的对象,以及处理引用中同一对象的重复引用。代码3显示了使用jos实现深度拷贝的实用程序类的demo。
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectInputStream;

/**
 * Utility for making deep copies (vs. clone()'s shallow copies) of 
 * objects. Objects are first serialized and then deserialized. Error
 * checking is fairly minimal in this implementation. If an object is
 * encountered that cannot be serialized (or that references an object
 * that cannot be serialized) an error is printed to System.err and
 * null is returned. Depending on your specific application, it might
 * make more sense to have copy(...) re-throw the exception.
 *
 * A later version of this class includes some minor optimizations.
 */
public class UnoptimizedDeepCopy {

    /**
     * Returns a copy of the object, or null if the object cannot
     * be serialized.
     */
    public static Object copy(Object orig) {
        Object obj = null;
        try {
            // Write the object out to a byte array
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);
            out.writeObject(orig);
            out.flush();
            out.close();

            // Make an input stream from the byte array and read
            // a copy of the object back in.
            ObjectInputStream in = new ObjectInputStream(
                new ByteArrayInputStream(bos.toByteArray()));
            obj = in.readObject();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        catch(ClassNotFoundException cnfe) {
            cnfe.printStackTrace();
        }
        return obj;
    }

}

Figure 3. Using Java Object Serialization to make a deep copy

图3.使用java对象序列化来进行深层复制

Unfortunately, this approach has some problems, too:
  1. It will only work when the object being copied, as well as all of the other objects references directly or indirectly by the object, are serializable. (In other words, they must implement java.io.Serializable.) Fortunately it is often sufficient to simply declare that a given class implements java.io.Serializable and let Java’s default serialization mechanisms do their thing.不幸的是,这种方法也有一些问题:只有当被复制的对象以及对象直接或间接引用的所有其他对象引用都是可序列化的时才会起作用。(换句话说,它们必须实现java.io.Serializable。)幸运的是,简单地声明一个给定的类实现java.io.Serializable并让Java的默认序列化机制完成它们通常就足够了。

  2. Java Object Serialization is slow, and using it to make a deep copy requires both serializing and deserializing. There are ways to speed it up (e.g., by pre-computing serial version ids and defining custom readObject() and writeObject() methods), but this will usually be the primary bottleneck.Java对象的序列化速度很慢,使用它进行深度复制需要序列化和反序列化。有很多方法可以加快速度(例如,通过预先计算串行版本ID并定义自定义的readobject()和writeobject()方法),但这通常是主要的瓶颈.。

  3. The byte array stream implementations included in the java.io package are designed to be general enough to perform reasonable well for data of different sizes and to be safe to use in a multi-threaded environment. These characteristics, however, slow down ByteArrayOutputStream and (to a lesser extent) ByteArrayInputStream.包含在java.io包中的字节数组流实现被设计为足够通用,以便对不同大小的数据执行合理的处理,并且可以安全地在多线程环境中使用。然而,这些特性会减慢ByteArrayOutputStream和(在较小程度上)ByteArrayInputStream。

The first two of these problems cannot be addressed in a general way. We can, however, use alternative implementations of ByteArrayOutputStream and ByteArrayInputStream that makes three simple optimizations:

前两个问题不能一概而论。但是,我们可以使用ByteArrayOutputStream和ByteArrayInputStream的替代实现来进行三个简单的优化:


  1. ByteArrayOutputStream, by default, begins with a 32 byte array for the output. As content is written to the stream, the required size of the content is computed and (if necessary), the array is expanded to the greater of the required size or twice the current size. JOS produces output that is somewhat bloated (for example, fully qualifies path names are included in uncompressed string form), so the 32 byte default starting size means that lots of small arrays are created, copied into, and thrown away as data is written. This has an easy fix: construct the array with a larger inital size.

ByteArrayOutputStream默认情况下以输出的32字节数组开始。当内容被写入到流中时,计算内容的所需大小,并且(如果需要的话),数组被扩展到所需大小或两倍当前大小。jos产生的输出有点臃肿(例如,完全限定路径名被包含在未压缩的字符串形式中),所以32字节的默认开始大小意味着在写入数据时创建,复制和丢弃大量的小数组。这有一个简单的解决方法:构建一个更大容量的数组。

  • All of the methods of ByteArrayOutputStream that modify the contents of the byte array are synchronized. In general this is a good idea, but in this case we can be certain that only a single thread will ever be accessing the stream. Removing the synchronization will speed things up a little. ByteArrayInputStream‘s methods are also synchronized.
    所有修改字节数组内容的ByteArrayOutputStream方法都是同步的。一般来说这是一个好主意,但在这种情况下,我们可以确定只有一个线程将访问流。去除同步会加快一点。ByteArrayInputStream的方法也是同步的。
  • The toByteArray() method creates and returns a copy of the stream’s byte array. Again, this is usually a good idea: If you retrieve the byte array and then continue writing to the stream, the retrieved byte array should not change. For this case, however, creating another byte array and copying into it merely wastes cycles and makes extra work for the garbage collector.
    toByteArray()方法创建并返回流的字节数组的副本。再次,这通常是一个好主意:如果您检索字节数组,然后继续写入流,检索到的字节数组不应该改变。但是,在这种情况下,创建另一个字节数组并复制到其中只会浪费周期并为垃圾回收器增加额外的工作量。

  • An optimized implementation of ByteArrayOutputStream is shown in Figure 4.

ByteArrayOutputStream的优化实现如图4所示。


import java.io.OutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ByteArrayInputStream;

/**
 * ByteArrayOutputStream implementation that doesn't synchronize methods
 * and doesn't copy the data on toByteArray().
 */
public class FastByteArrayOutputStream extends OutputStream {
    /**
     * Buffer and size
     */
    protected byte[] buf = null;
    protected int size = 0;

    /**
     * Constructs a stream with buffer capacity size 5K 
     */
    public FastByteArrayOutputStream() {
        this(5 * 1024);
    }

    /**
     * Constructs a stream with the given initial size
     */
    public FastByteArrayOutputStream(int initSize) {
        this.size = 0;
        this.buf = new byte[initSize];
    }

    /**
     * Ensures that we have a large enough buffer for the given size.
     */
    private void verifyBufferSize(int sz) {
        if (sz > buf.length) {
            byte[] old = buf;
            buf = new byte[Math.max(sz, 2 * buf.length )];
            System.arraycopy(old, 0, buf, 0, old.length);
            old = null;
        }
    }

    public int getSize() {
        return size;
    }

    /**
     * Returns the byte array containing the written data. Note that this
     * array will almost always be larger than the amount of data actually
     * written.
     */
    public byte[] getByteArray() {
        return buf;
    }

    public final void write(byte b[]) {
        verifyBufferSize(size + b.length);
        System.arraycopy(b, 0, buf, size, b.length);
        size += b.length;
    }

    public final void write(byte b[], int off, int len) {
        verifyBufferSize(size + len);
        System.arraycopy(b, off, buf, size, len);
        size += len;
    }

    public final void write(int b) {
        verifyBufferSize(size + 1);
        buf[size++] = (byte) b;
    }

    public void reset() {
        size = 0;
    }

    /**
     * Returns a ByteArrayInputStream for reading back the written data
     */
    public InputStream getInputStream() {
        return new FastByteArrayInputStream(buf, size);
    }

}

Figure 4. Optimized version of ByteArrayOutputStream

表4.优化版本的ByteArrayOutputStream

The getInputStream() method returns an instance of an optimized version of ByteArrayInputStream that has unsychronized methods. The implementation of FastByteArrayInputStream is shown in Figure 5.

getInputStream()方法返回具有非同步方法的ByteArrayInputStream优化版本的一个实例。FastByteArrayInputStream的实现如图5所示。

import java.io.InputStream;
import java.io.IOException;

/**
 * ByteArrayInputStream implementation that does not synchronize methods.
 */
public class FastByteArrayInputStream extends InputStream {
    /**
     * Our byte buffer
     */
    protected byte[] buf = null;

    /**
     * Number of bytes that we can read from the buffer
     */
    protected int count = 0;

    /**
     * Number of bytes that have been read from the buffer
     */
    protected int pos = 0;

    public FastByteArrayInputStream(byte[] buf, int count) {
        this.buf = buf;
        this.count = count;
    }

    public final int available() {
        return count - pos;
    }

    public final int read() {
        return (pos < count) ? (buf[pos++] & 0xff) : -1;
    }

    public final int read(byte[] b, int off, int len) {
        if (pos >= count)
            return -1;

        if ((pos + len) > count)
            len = (count - pos);

        System.arraycopy(buf, pos, b, off, len);
        pos += len;
        return len;
    }

    public final long skip(long n) {
        if ((pos + n) > count)
            n = count - pos;
        if (n < 0)
            return 0;
        pos += n;
        return n;
    }

}

Figure 5. Optimized version of ByteArrayInputStream.

优化版本ByteArrayInputStream

Figure 6 shows a version of a deep copy utility that uses these classes:

表6 展示了一个利用这些类的深拷贝工具类


import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectInputStream;

/**
 * Utility for making deep copies (vs. clone()'s shallow copies) of 
 * objects. Objects are first serialized and then deserialized. Error
 * checking is fairly minimal in this implementation. If an object is
 * encountered that cannot be serialized (or that references an object
 * that cannot be serialized) an error is printed to System.err and
 * null is returned. Depending on your specific application, it might
 * make more sense to have copy(...) re-throw the exception.
 */
public class DeepCopy {

    /**
     * Returns a copy of the object, or null if the object cannot
     * be serialized.
     */
    public static Object copy(Object orig) {
        Object obj = null;
        try {
            // Write the object out to a byte array
            FastByteArrayOutputStream fbos = 
                    new FastByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(fbos);
            out.writeObject(orig);
            out.flush();
            out.close();

            // Retrieve an input stream from the byte array and read
            // a copy of the object back in. 
            ObjectInputStream in = 
                new ObjectInputStream(fbos.getInputStream());
            obj = in.readObject();
        }
        catch(IOException e) {
            e.printStackTrace();
        }
        catch(ClassNotFoundException cnfe) {
            cnfe.printStackTrace();
        }
        return obj;
    }

}

Figure 6. Deep-copy implementation using optimized byte array streams

表6.使用优化的字节数组流实现深拷贝

The extent of the speed boost will depend on a number of factors in your specific application (more on this later), but the simple class shown in Figure 7 tests the optimized and unoptimized versions of the deep copy utility by repeatedly copying a large object.

速度提升的程度将取决于特定应用程序中的许多因素(稍后会详细介绍),但是图7中显示的简单类通过反复复制大型对象来测试深度复制实用程序的优化和未优化版本。

import java.util.Hashtable;
import java.util.Vector;
import java.util.Date;

public class SpeedTest {

    public static void main(String[] args) {
        // Make a reasonable large test object. Note that this doesn't
        // do anything useful -- it is simply intended to be large, have
        // several levels of references, and be somewhat random. We start
        // with a hashtable and add vectors to it, where each element in
        // the vector is a Date object (initialized to the current time),
        // a semi-random string, and a (circular) reference back to the
        // object itself. In this case the resulting object produces
        // a serialized representation that is approximate 700K.
        Hashtable obj = new Hashtable();
        for (int i = 0; i < 100; i++) {
            Vector v = new Vector();
            for (int j = 0; j < 100; j++) {
                v.addElement(new Object[] { 
                    new Date(), 
                    "A random number: " + Math.random(),
                    obj
                 });
            }
            obj.put(new Integer(i), v);
        } 

        int iterations = 10;

        // Make copies of the object using the unoptimized version
        // of the deep copy utility.
        long unoptimizedTime = 0L;
        for (int i = 0; i < iterations; i++) {
            long start = System.currentTimeMillis();
            Object copy = UnoptimizedDeepCopy.copy(obj);
            unoptimizedTime += (System.currentTimeMillis() - start);

            // Avoid having GC run while we are timing...
            copy = null;
            System.gc();
        }


        // Repeat with the optimized version
        long optimizedTime = 0L;
        for (int i = 0; i < iterations; i++) {
            long start = System.currentTimeMillis();
            Object copy = DeepCopy.copy(obj);
            optimizedTime += (System.currentTimeMillis() - start);

            // Avoid having GC run while we are timing...
            copy = null;
            System.gc();
        }

        System.out.println("Unoptimized time: " + unoptimizedTime);
        System.out.println("  Optimized time: " + optimizedTime);
    }

}

Figure 7. Testing the two deep copy implementations.
图7:测试两种深拷贝的实现

A few notes about this test:

关于这次测试的一些注意事项:


  • The object that we are copying is large. While somewhat random, it will generally have a serialized size of around 700 Kbytes.

我们正在复制的对象很大。虽然有点随意,但通常会有大约700k字节的序列化大小。

  • The most significant speed boost comes from avoid extra copying of data in FastByteArrayOutputStream. This has several implications:

最重要的速度提升来自避免在FastByteArrayOutputStream中的数据的额外复制。这有几个含义:

  1. Using the unsynchronized FastByteArrayInputStream speeds things up a little, but the standard java.io.ByteArrayInputStream is nearly as fast.

使用非同步FastByteArrayInputStream加快了一点,但标准java.io.ByteArrayInputStream几乎一样快。

  • Performance is mildly sensitive to the initial buffer size in FastByteArrayOutputStream, but is much more sensitive to the rate at which the buffer grows. If the objects you are copying tend to be of similar size, copying will be much faster if you initialize the buffer size and tweak the rate of growth.

    性能对FastByteArrayOutputStream中的初始缓冲区大小是轻度敏感的,但是对缓冲区增长的速率敏感得多。如果要复制的对象大小相似,则如果初始化设定合适的缓冲区大小并调整增长率,复制速度将会快得多。

  • Measuring speed using elapsed time between two calls to System.currentTimeMillis() is problematic, but for single-threaded applications and testing relatively slow operations it is sufficient. A number of commercial tools (such as JProfiler) will give more accurate per-method timing data.

    使用两次调用System.currentTimeMillis()之间的经过时间来测量速度是有问题的,但是对于单线程应用程序和测试相对较慢的操作来说就足够了。一些商业工具(如JProfiler)将会提供更准确的每个方法的时间数据。

  • Testing code in a loop is also problematic, since the first few iterations will be slower until HotSpot decides to compile the code. Testing larger numbers of iterations aleviates this problems.

    在循环中测试代码也是有问题的,因为在HotSpot虚拟机决定编译代码之前,最初的几次迭代将会变慢。测试更多的迭代可以减轻这个问题。

  • Garbage collection further complicates matters, particularly in cases where lots of memory is allocated. In this example, we manually invoke the garbage collector after each copy to try to keep it from running while a copy is in progress.
    垃圾收集进一步使事情复杂化,特别是在分配大量内存的情况下。在这个例子中,我们在每个副本之后手动调用垃圾收集器,以便在复制过程中防止GC。

  • These caveats aside, the performance difference is sigificant. For example, the code as shown in Figure 7 (on a 500Mhz G3 Macintosh iBook running OSX 10.3 and Java 1.4.1) reveals that the unoptimized version requires about 1.8 seconds per copy, while the optimized version only requires about 1.3 seconds. Whether or not this difference is signficant will, of course, depend on the frequency with which your application does deep copies and the size of the objects being copied.

    抛开这些警告,性能差异是显着的。例如,如图7所示的代码(在运行osx 10.3和java 1.4.1的500mhz g3 macintosh ibook上)显示未优化版本需要每个拷贝约1.8秒,而优化版本仅需要约1.3秒。当然,这种差别是否显着取决于应用程序执行深度复制的频率以及被复制对象的大小。

    For very large objects, an extension to this approach can reduce the peak memory footprint by serializing and deserializing in parallel threads. See “Low-Memory Deep Copy Technique for Java Objects” for more information.

    对于非常大的对象,此方法的扩展可以通过在并行线程中进行序列化和反序列化来减少峰值内存占用量。有关更多信息,请参阅“用于java对象的低内存深层复制技术”。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值