String-StringBuffer-StringBuilder

我们都知道String是一个特殊的类,被final修饰符修饰,是一个不可改变的类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变的,直至这个对象被销毁;StringBuffer代表一个字符序列可变的字符串,当一个StringBuffer被创建后,通过StringBuffer的append()等方法可以改变这个字符串对象的字符序列,一旦通过StringBuffer生成了最终想要的字符串,可以调用它的toString方法将其转换为一个String对象;StringBuilder和StringBuffer基本相似,但是StringBuffer是线程安全的,而StringBuilder没有实现线程安全功能,性能略高。

开篇先看一个代码示例,在做题中经常遇到,主要和java的内存模型有关:

package String;

public class TestObject0 {
	public static void main(String []args){
		String str1="abc";
		String str2=str1;
		String str3="abc";
		System.out.println(str1==str2);//true,常量去开辟空间存放“abc”,str1在栈中指向“abc”
		System.out.println(str1==str3);//true,str2在栈中也指向“abc”
		System.out.println(str2==str3);//true,因为“abc”已经存在,所以直接指向它,三个变量指向同一快内存地址,结果都为true
		
		str1="bcd";//常量去开辟新的空间存放“bcd”,str1指向“bcd”,而str2和str3指向“abc”
		System.out.println(str1==str2);//false
		System.out.println(str1==str3);//false
		System.out.println(str2==str3);//true
	}
}

上述,str1和str2和str3都是字符串常量,类似于基本数据类型,如果12行执行的是str1="abc",那么结果会都是true

更进一步例子

package String;

public class TestObject0 {
	public static void main(String []args){
		String ss1=new String("abc");//在堆里分配空间存放String对象,在常量区开辟空间存放常量“abc”,String对象指向常量,ss1指向该对象
		String ss2=ss1;//ss2指向上一步new出来的String对象
		String ss3=new String("abc");//在堆里分配新的空间存放String对象,新对象指向常量“abc”,ss3指向该对象
//		ss1和ss2指向的是同一个对象
		System.out.println(ss1==ss2);//true
		System.out.println(ss1==ss3);//false
		System.out.println(ss2==ss3);//false
		
		ss1="abc";
//		new出来的String不再是上面的字符串常量,而是字符串对象
//		由于String类是不可变的,所以String对象也是不可变的
//		每次给String赋值都相当于执行了一次new String,然后让变量指向这个新对象,而不是在原对象上修改
		System.out.println(ss1==ss2);//false
		System.out.println(ss1==ss3);//false
		System.out.println(ss2==ss3);//false
	}
}

正式从源码的角度来瞅瞅。。。

一,String

1.String类

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence 

String类被final修饰,也就是说String对象是不可变量

2.重要属性

java.lang.String对象中字符串主要以字符数组的形式存储,当调用对象方法获取字符串长度时,直接返回数组长度,判断字符串是否为空时,也直接检查数组长度是否为0

 /** The value is used for character storage. */
    private final char value[];//存储字符串的字符数组,一旦赋值,将不会更改

    /** Cache the hash code for the string */
    private int hash; //该String对象的哈希值

3.方法

构造方法比较多,列举几个:

不含参数的构造函数

public String() {
        this.value = "".value;
    }

参数为String类型

    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

参数为StringBuffer类型,StringBuffer为线程安全类,因此在此构造方法内部进行了synchronized关键字锁同步

    public String(StringBuffer buffer) {
        synchronized(buffer) {
            this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
        }
    }

相应的,StringBuilder是非线程安全的,,则未作处理

    public String(StringBuilder builder) {
        this.value = Arrays.copyOf(builder.getValue(), builder.length());
    }

参数为char数组,使用java.utils包中的Arrays类复制

    public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }

从位置offset开始复制count个字符

    public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

此外列举一下几个常用的方法:

检查字节数组是否越界

private static void checkBounds(byte[] bytes, int offset, int length) {
        if (length < 0)
            throw new StringIndexOutOfBoundsException(length);
        if (offset < 0)
            throw new StringIndexOutOfBoundsException(offset);
        if (offset > bytes.length - length)
            throw new StringIndexOutOfBoundsException(offset + length);
    }

返回字符串的长度

    public int length() {
        return value.length;
    }

判断是否为空

    public boolean isEmpty() {
        return value.length == 0;
    }

字符串存储使用字符数组,使用索引返回char值

    public char charAt(int index) {
        if ((index < 0) || (index >= value.length)) {
            throw new StringIndexOutOfBoundsException(index);
        }
        return value[index];
    }

equals用于比较两个对象存储内容是否相同,采用比较巧妙的方式进行排除比较:先“==”比较两个对象是否是同一对象,若是,直接返回true,否则进一步;判断待比较对象类型是否是java.lang.String,若不是直接返回false,是的话进一步;判断两字符串长度是否相等,若不是直接返回false,否则进一步;字符数组依次比较,一旦发现不相同字符直接返回false,若所在字符均相同则返回true,对字符数组中字符依次进行比较比较耗时,so放在最后执行,先利用其它条件进行判断,so巧妙!

public boolean equals(Object anObject) {
        if (this == anObject) {//如果引用的是同一个对象,返回true
            return true;
        }
        if (anObject instanceof String) {//如果不是String类型的数据,返回false
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {//如果char数组长度不相等,返回false
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {//从后往前单个字符判断,如果有不相等,返回false
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

基于字符串各个字符的Unicode值,按字典顺序此String对象表示的字符序列与参数字符串表示的字符序列进行比较,若此String对象位于参数字符串之前则返回一个负整数,若此String对象位于参数字符串之后则返回一个正整数,若这两个字符串相等,则结果为0

public int compareTo(String anotherString) {
        int len1 = value.length;//自身对象字符串长度
        int len2 = anotherString.value.length;//被比较对象字符串长度
        int lim = Math.min(len1, len2);//取两个字符串长度的最小值
        char v1[] = value;
        char v2[] = anotherString.value;
//从value的第一个字符开始到最小长度lim,如果字符不相等,返回自身(对象不相等处字符-被比较对象不相等字符)
        int k = 0;
        while (k < lim) {
            char c1 = v1[k];
            char c2 = v2[k];
            if (c1 != c2) {
                return c1 - c2;
            }
            k++;
        }
        return len1 - len2;//如果前面都相等,则返回(自身长度-被比较对象长度)
    }

字典排序:如果这两个字符串不同,那么它们要么在某个索引处的字符不同,要么长度不同,或者同时具备这两种情况。如果它们在一个或多个索引位置上的字符不同,假设k是这类索引的最小值,则在位置k上具有较小值的那个字符串,其字典顺序在其他字符串之前,在该情况下,返回的是这两个字符串在位置k出两个char值的差,即:

this.charAt(k)-anotherString.charAt(k)

如果没有字符不同的索引位置,则较短字符串的字典顺序在较长字符串之前,该情况下,返回两个字符串的长度差,即:

this.length()-anotherString.length()

起始比较和末尾比较都是比较常用的方法,eg,判断一个字符串是否是HTTP协议,或初步判断一个文件是否是MP3文件

   public boolean startsWith(String prefix, int toffset) {
        char ta[] = value;
        int to = toffset;
        char pa[] = prefix.value;
        int po = 0;
        int pc = prefix.value.length;
        // Note: toffset might be near -1>>>1.
        if ((toffset < 0) || (toffset > value.length - pc)) {
            return false;//如果起始地址小于0,或者(起始地址+所比较对象长度)大于自身对象长度,返回false
        }
        while (--pc >= 0) {//从所比较对象的末尾开始比较
            if (ta[to++] != pa[po++]) {
                return false;
            }
        }
        return true;
    }

   public boolean startsWith(String prefix) {
        return startsWith(prefix, 0);
    }

    public boolean endsWith(String suffix) {
        return startsWith(suffix, value.length - suffix.value.length);
    }

 String类重写了hashCode方法,String类的hash采用多项式计算而来,我们完全可以通过不相同的字符串得出同样的hash,so两个String对象的hashCode相同并不代表这两个String是一样的

public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {//如果hash没有被计算过且字符串不为空,则进行hashCode计算
            char val[] = value;
//s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

找出目标字符串第一次出现在指定字符串的位置,若不存在则返回-1,若存在则返回位置坐标,具体实现时调用static int indexOf方法,先对目标字符串中出现字符串的可能范围求解,然后在该范围中遍历出与字符串第一个字符相同的位置,并对后面字符进行比较分析

   public int indexOf(String str) {
        return indexOf(str, 0);
    }

    
    public int indexOf(String str, int fromIndex) {
        return indexOf(value, 0, value.length,
                str.value, 0, str.value.length, fromIndex);
    }
static int indexOf(char[] source, int sourceOffset, int sourceCount,
            char[] target, int targetOffset, int targetCount,
            int fromIndex) {
        if (fromIndex >= sourceCount) {
            return (targetCount == 0 ? sourceCount : -1);
        }
        if (fromIndex < 0) {
            fromIndex = 0;
        }
        if (targetCount == 0) {
            return fromIndex;
        }

        char first = target[targetOffset];
        int max = sourceOffset + (sourceCount - targetCount);

        for (int i = sourceOffset + fromIndex; i <= max; i++) {
            /* Look for first character. */
            if (source[i] != first) {
                while (++i <= max && source[i] != first);
            }

            /* Found first character, now look at the rest of v2 */
            if (i <= max) {
                int j = i + 1;
                int end = j + targetCount - 1;
                for (int k = targetOffset + 1; j < end && source[j]
                        == target[k]; j++, k++);

                if (j == end) {
                    /* Found whole string. */
                    return i - sourceOffset;
                }
            }
        }
        return -1;
    }

将指定字符串连接到此字符串的结尾,如果参数字符串长度为0,则返回此String对象,否则,创建一个新的String对象,用来表示由此String对象表示的字符序列和参数字符串表示的字符序列连接而成的字符序列

    public String concat(String str) {
        int otherLen = str.length();
        if (otherLen == 0) {//如果被添加的字符串对象为空,则返回对象本身
            return this;
        }
        int len = value.length;
        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);
        return new String(buf, true);
    }

将字符串中指定字符替换为新的字符。先判断待替换字符和新字符是否相同,若相同,则直接返回原字符串,若不同,则继续执行;找出第一次出现待替换字符位置i,创建新的等长字符数组,将该位置之前的字符依次放入新的字符数组中;从位置i依次遍历比较原字符数组中字符是否是待替换字符,若是,则将新字符放入新字符数组对应位置,若不是,则将原字符数组中字符放入对应位置,巧妙的做了一个小优化,即直接找出第一个出现待替换字符的位置,再从此开始遍历,提高效率

 public String replace(char oldChar, char newChar) {
        if (oldChar != newChar) {//新旧值先进行对比
            int len = value.length;
            int i = -1;
            char[] val = value; /* avoid getfield opcode */

            while (++i < len) {//找到旧值最开始出现的位置
                if (val[i] == oldChar) {
                    break;
                }
            }
            if (i < len) {//从那个位置开始直到末尾,用新值代替出现的旧值
                char buf[] = new char[len];
                for (int j = 0; j < i; j++) {
                    buf[j] = val[j];
                }
                while (i < len) {
                    char c = val[i];
                    buf[i] = (c == oldChar) ? newChar : c;
                    i++;
                }
                return new String(buf, true);
            }
        }
        return this;
    }

contains,当且仅当此字符串包含指定的char序列时返回true,否则返回false,由源码可以看出,该方法调用indexOf(返回指定字符串在此字符串中第一次出现出的索引,若未出现该字符则返回-1),若包含,即indexOf返回值大于-1,则返回true

    public boolean contains(CharSequence s) {
        return indexOf(s.toString()) > -1;
    }

根据匹配给定的正则表达式来拆分此字符串,返回的数组包含此字符串的子字符串,每个子字符串都由另一个匹配给定表达式的子字符串终止或由此字符串末尾终止,数组中的子字符串按他们在此字符串中出现的顺序排列,如果表达式不匹配输入的任何部分,那么所得数组只具有一个元素,即此字符串

 public String[] split(String regex) {
        return split(regex, 0);
    }
public String[] split(String regex, int limit) {
        /* fastpath if the regex is a
         (1)one-char String and this character is not one of the
            RegEx's meta characters ".$|()[{^?*+\\", or
         (2)two-char String and the first char is the backslash and
            the second is not the ascii digit or ascii letter.
         */
        char ch = 0;
        if (((regex.value.length == 1 &&
             ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
             (regex.length() == 2 &&
              regex.charAt(0) == '\\' &&
              (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
              ((ch-'a')|('z'-ch)) < 0 &&
              ((ch-'A')|('Z'-ch)) < 0)) &&
            (ch < Character.MIN_HIGH_SURROGATE ||
             ch > Character.MAX_LOW_SURROGATE))
        {
            int off = 0;
            int next = 0;
            boolean limited = limit > 0;
            ArrayList<String> list = new ArrayList<>();
            while ((next = indexOf(ch, off)) != -1) {
                if (!limited || list.size() < limit - 1) {
                    list.add(substring(off, next));
                    off = next + 1;
                } else {    // last one
                    //assert (list.size() == limit - 1);
                    list.add(substring(off, value.length));
                    off = value.length;
                    break;
                }
            }
            // If no match was found, return this
            if (off == 0)
                return new String[]{this};

            // Add remaining segment
            if (!limited || list.size() < limit)
                list.add(substring(off, value.length));

            // Construct result
            int resultSize = list.size();
            if (limit == 0) {
                while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
                    resultSize--;
                }
            }
            String[] result = new String[resultSize];
            return list.subList(0, resultSize).toArray(result);
        }
        return Pattern.compile(regex).split(this, limit);
    }

返回字符串的副本,忽略前导空白和后导空白

    public String trim() {
        int len = value.length;
        int st = 0;
        char[] val = value;    /* avoid getfield opcode */

        while ((st < len) && (val[st] <= ' ')) {//找到字符串前段没有空格的位置
            st++;
        }
        while ((st < len) && (val[len - 1] <= ' ')) {//找到字符串后段没有空格的位置
            len--;
        }
        return ((st > 0) || (len < value.length)) ? substring(st, len) : this;//如果前后都没有出现空格,返回字符串本身
    }

java.lang.StringBuilder和java.lang.StringBuffer同是继承于java.lang.AbstractStringBuilder,具体在功能实现大多在AbstractStringBuilder中,StringBuffer和StringBuilder相当于对其进行的一个接口封装,区别只是一个作了同步封装,一个未做同步封装

二,StringBuilder

1.StringBuilder是一个final类,不能被继承,其类继承的父类和实现接口如下

public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence

2.其内部代码中显式声明(不包括继承等隐式属性)的属性只有一个,即序列化ID

static final long serialVersionUID = 4383685877147921099L;

构造方法的内部实现也是通过super方法调用父类

/**
     * Constructs a string builder with no characters in it and an
     * initial capacity of 16 characters.
     */
    public StringBuilder() {
        super(16);
    }

    /**
     * Constructs a string builder with no characters in it and an
     * initial capacity specified by the {@code capacity} argument.
     *
     * @param      capacity  the initial capacity.
     * @throws     NegativeArraySizeException  if the {@code capacity}
     *               argument is less than {@code 0}.
     */
    public StringBuilder(int capacity) {
        super(capacity);
    }

    /**
     * Constructs a string builder initialized to the contents of the
     * specified string. The initial capacity of the string builder is
     * {@code 16} plus the length of the string argument.
     *
     * @param   str   the initial contents of the buffer.
     */
    public StringBuilder(String str) {
        super(str.length() + 16);
        append(str);
    }

    /**
     * Constructs a string builder that contains the same characters
     * as the specified {@code CharSequence}. The initial capacity of
     * the string builder is {@code 16} plus the length of the
     * {@code CharSequence} argument.
     *
     * @param      seq   the sequence to copy.
     */
    public StringBuilder(CharSequence seq) {
        this(seq.length() + 16);
        append(seq);
    }

append方法,在该方法内部仍然是一个super方法,调用父类方法实现,delete、replace、insert等也是如此(当然还有append的很多重载方法)

    @Override
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }

toString不是通过super调用父类,但是具体实现中的value、count属性是从父类中继承而来

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

三,StringBuffer

其类声明和构造方法与StringBuilder完全一样,各功能方法内部实现也一样,具体实现调用super方法通过父类实现,唯一不同的是,功能方法前面多了一个同步关键字synchronized,举几个例子:

类声明

public final class StringBuffer
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence

构造方法之一

    public StringBuffer(int capacity) {
        super(capacity);
    }

append

    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

四,AbstractStringBuilder

StringBuilder和StringBuffer均是继承于抽象类AbstractStringBuilder,且方法具体实现均是调用父类的方法

1.类

abstract class AbstractStringBuilder implements Appendable, CharSequence {

2.底层仍是通过字符数组实现字符串的存储,count参数记录实际存储的字符个数而不是字符数组value的长度

char[] value;

    /**
     * The count is the number of characters used.
     */
    int count;

    /**
     * This no-arg constructor is necessary for serialization of subclasses.
     */
    AbstractStringBuilder() {
    }

    /**
     * Creates an AbstractStringBuilder of the specified capacity.
     */
    AbstractStringBuilder(int capacity) {
        value = new char[capacity];
    }

和String相比,同时字符数组存储字符串,String中声明的字符数组是private final char value[],是不可修改的,而AbstractStringBuilder可以修改,这即StringBuffer和StringBuilder可以实现字符串修改功能

3.方法

length为实际内容的长度,capacity为开辟的 char[] value空间的大小

/**
     * Returns the length (character count).
     *
     * @return  the length of the sequence of characters currently
     *          represented by this object
     */
    @Override
    public int length() {
        return count;
    }

    /**
     * Returns the current capacity. The capacity is the amount of storage
     * available for newly inserted characters, beyond which an allocation
     * will occur.
     *
     * @return  the current capacity
     */
    public int capacity() {
        return value.length;
    }

append方法,重构比较多但原理类似:对传入形参进行正确性检查;对原字符数组长度进行检查,判断是否能容纳新加入的字符;对原字符数组进行相应添加操作

    public AbstractStringBuilder append(char[] str) {
        int len = str.length;
        ensureCapacityInternal(count + len);
        System.arraycopy(str, 0, value, count, len);
        count += len;
        return this;
    }

ensureCapacityInternal方法用来判断字符长度是否足够

    /**
     * For positive values of {@code minimumCapacity}, this method
     * behaves like {@code ensureCapacity}, however it is never
     * synchronized.
     * If {@code minimumCapacity} is non positive due to numeric
     * overflow, this method throws {@code OutOfMemoryError}.
     */
    private void ensureCapacityInternal(int minimumCapacity) {
        // overflow-conscious code
        if (minimumCapacity - value.length > 0) {
            value = Arrays.copyOf(value,
                    newCapacity(minimumCapacity));
        }
    }
当字符数组长度不够时,便创建一个新的数组,将原数组中的数据拷贝到新数组中
 /**
     * Returns a capacity at least as large as the given minimum capacity.
     * Returns the current capacity increased by the same amount + 2 if
     * that suffices.
     * Will not return a capacity greater than {@code MAX_ARRAY_SIZE}
     * unless the given minimum capacity is greater than that.
     *
     * @param  minCapacity the desired minimum capacity
     * @throws OutOfMemoryError if minCapacity is less than zero or
     *         greater than Integer.MAX_VALUE
     */
    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int newCapacity = (value.length << 1) + 2;
        if (newCapacity - minCapacity < 0) {
            newCapacity = minCapacity;
        }
        return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
            ? hugeCapacity(minCapacity)
            : newCapacity;
    }

    private int hugeCapacity(int minCapacity) {
        if (Integer.MAX_VALUE - minCapacity < 0) { // overflow
            throw new OutOfMemoryError();
        }
        return (minCapacity > MAX_ARRAY_SIZE)
            ? minCapacity : MAX_ARRAY_SIZE;
    }

由此可以看出,如果可以提前预估出最终的数组长度并在创建对象时提前设置数组的大小,对程序运行效率的提高是非常有帮助的

delete、replace和insert实现原理类似

delete,可实现删除指定数组起始终止位置之间的字符,将指定终止位置之后的字符依次向前移动len个字符,将起始位置的字符开始依次覆盖掉,相当于字符数组拷贝

    public AbstractStringBuilder delete(int start, int end) {
        if (start < 0)
            throw new StringIndexOutOfBoundsException(start);
        if (end > count)
            end = count;
        if (start > end)
            throw new StringIndexOutOfBoundsException();
        int len = end - start;
        if (len > 0) {
            System.arraycopy(value, start+len, value, start, count-end);
            count -= len;
        }
        return this;
    }

replace,字符数组拷贝

    public AbstractStringBuilder replace(int start, int end, String str) {
        if (start < 0)
            throw new StringIndexOutOfBoundsException(start);
        if (start > count)
            throw new StringIndexOutOfBoundsException("start > length()");
        if (start > end)
            throw new StringIndexOutOfBoundsException("start > end");

        if (end > count)
            end = count;
        int len = str.length();
        int newCount = count + len - (end - start);
        ensureCapacityInternal(newCount);

        System.arraycopy(value, end, value, start + len, count - end);
        str.getChars(value, start);
        count = newCount;
        return this;
    }

insert,在数组指定位置插入字符,底层也是字符数组拷贝

    public AbstractStringBuilder insert(int index, char[] str, int offset,
                                        int len)
    {
        if ((index < 0) || (index > length()))
            throw new StringIndexOutOfBoundsException(index);
        if ((offset < 0) || (len < 0) || (offset > str.length - len))
            throw new StringIndexOutOfBoundsException(
                "offset " + offset + ", len " + len + ", str.length "
                + str.length);
        ensureCapacityInternal(count + len);
        System.arraycopy(value, index, value, index + len, count - index);
        System.arraycopy(str, offset, value, index, len);
        count += len;
        return this;
    }

toString是抽象类中唯一一个抽象方法

    @Override
    public abstract String toString();

总结:StringBuilder和StringBuffer是对AbstractStringBuilder的一个继承封装,通过进程可以实现功能的一个扩展,StringBuilder仅仅只是功能的继承,StringBuffer在功能继承上做了synchronized加锁的操作,从而实现线程安全

AbstractStringBuilder才是功能方法的具体实现,和String一样,底层是用字符数组在存储字符串区别是String中字符数组是final类型,而AbstractStringBuilder中是可变的

StringBuffer和StringBuilder是final类,无法再被继承

参考:

https://blog.csdn.net/qq_31655965/article/details/53365564

https://www.cnblogs.com/hthuang/p/5456890.html

https://blog.csdn.net/zhcswlp0625/article/details/57508127

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值