JDK源码阅读 (二) : String

本文详细探讨了Java中的String对象,包括构造方式、不可变性、字符串常量池的演变以及字符集和编码。重点讲解了ASCII、Unicode、UTF-8、UTF-16和UTF-32等编码方式,并分析了Java如何处理字符存储。同时,文章还介绍了String的equals、compareTo、匹配、替换、分割等方法及其工作原理。
摘要由CSDN通过智能技术生成

1. String

1.1 构造String对象

有两种方式可以构造一个String对象:

String s1 = "dog";
String s2 = new String("dog");

①第一种构造方式是直接从字符串常量池中取得一个字符串对象"dog",然后s1指向常量池中对应的位置。

需要注意的是,当试图从字符串常量池中取得"dog"对象时,发现没有,则会在池中创建该对象。当下一次又要从池中获取"dog"对象时,可以直接取得。这是一种缓存思想

②第二种构造方式是在堆中申请一块区域,使用构造器创建一个String对象。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
  
  	......
    //使用构造器创建对象
  	public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }
}

在第二种创建方式中,我们调用了构造方法,首先会进行参数传递,也就是将"dog"赋值给original。此时又进行了第一种创建方式,但是现在可以直接从字符串常量池中取得“dog”对象。然后,在构造方法中,进行对象的浅拷贝。也就是说它们的成员char型数组是同一个。

1.2 String的不可变性

String类中持有一个char型数组,该数组类型被限制为 private final,即数组内容不可改变

/** The value is used for character storage. */
private final char value[];

由于String内部所持有的字符序列不可变,那么其提供的所有的拼接、剪裁字符串的操作,实际上都返回了一个新的String对象,而不是原对象。

String的不可变性是字符串常量池设计的基础:由于字符串是不可变的,所以无需担心常量池中的数据会发生冲突,每个字符串对象都是独一无二的。

字符串常量池所处的位置

  • JDK6及以前,字符串常量池存放在永久代(方法区),永久代的空间有限,很少会被GC,如果使用不当,就有可能会出现OOM;
  • JDK7,字符串常量池转移到 java堆中,所有这些字符串就跟普通对象一样存储在堆中,可以让用户在进行调优时仅需要调整堆的大小即可,同时也避免了永久代被占满的情况;
  • JDK8永久代改为元空间,字符串常量池依然存放在堆中。

此外,String类本身也是不可以被继承的,它被声明为final class。

1.3 字符集和字符编码

1.3.1 简介
  • 编码:把自然语言中出现的字符比如英文、汉字等转换为字节(即二进制数),以存储在计算机中,称之为“编码encode”。
  • 解码:把存储在计算机中的二进制数(即字节)转换为人类可以理解的字符,称之为“解码decode”。
  • 编码字符集:指这个字符集里的每一个字符,都对应到唯一的一个数字,这些数字叫做代码点(code point),可以看做是这个字符在编码字符集里的序号,字符在给定的编码方式下的二进制比特序列称为代码单元(code unit)。
  • 字符编码:是编码字符集的字符和实际的存储值之间的转换关系。常见的编码方式有:UTF-8(Unicode字符集的编码方式)、UTF-16(Unicode字符集的编码方式)、UTF-32(Unicode字符集的编码方式)、ASCII(ASCII字符集的编码方式)等。
1.3.2 常用字符集

ASCII字符集

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)主要用于对ASCII字符集进行单字节编码。ASCII字符集仅覆盖了控制字符(回车键、退格、换行键等)和可显示字符(英文大小写字符、阿拉伯数字和西文符号)。

它使用7位表示一个字符,这样只能支持128个字符。而后扩展到使用8位表示一个字符,共可表示256个字符。

Unicode字符集

由于ASCII字符集支持的字符太少,因此出现了Unicode字符集。Unicode把所有语言都统一到一套编码里,在表示一个Unicode的字符时,通常会用“U+”然后紧接着一组十六进制的数字来表示这一个字符。

在基本多文种平面(Basic Multilingual Plane, BMP, 简称为“零号平面”)里的所有字符,要用4位十六进制数,即U+0000到U+FFFF。比如”汉“对应的数字是U+6c49(十进制27721),”字“对应的数字是U+5b57(十进制23383)。这里的数字也就是上面所说的”代码点“。

零号平面有一个专用区:0xE000-0xF8FF,有6400个码位。零号平面的0xD800-0xDFFF,共2048个码位,是一个被称作代理区(Surrogate)的特殊区域。

在零号平面以外的字符则需要使用5位或6位十六进制数表示,即U+10000到U+10FFFF的数字。

1.3.3 常用字符编码方式

在Unicode中,有多种方式可以将数字表示成程序中的数据,包括:UTF-8、UTF-16、UTF-32。UTF,全称Unicode Transformation Format,意思是Unicode转换格式,即怎样将Unicode定义的数字转换成程序数据。

1)UTF-8

UTF-8以字节为单位对Unicode进行编码。编码方式如下:

Unicode编码(十六进制)UTF-8字节流(二进制)
000000-00007F0xxxxxxx
000080-0007FF110xxxxx 10xxxxxx
000800-00FFFF1110xxxx 10xxxxxx 10xxxxxx
010000-10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

UTF-8是一种可变长字符编码,也是一种前缀码。

UTF-8使用1到4个字节为每个字符编码:

  • 前128个ASCII字符只需1个字节编码,最高位为0,这128个字符的编码方式兼容ASCII码;
  • 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母需要2个字节编码;
  • 其他基本多文种屏幕中的字符(包括汉字)使用3个字节编码。比如,"汉"这个字符的Unicode编码是0x6c49 (十进制27721),写成二进制是:0110 1100 0100 1001,将其填充到三字节编码模板中,得到的二进制字节流是11100110 10110001 10001001,即 -26 -79 -119;
  • 其他少数字符使用4个字节编码。

java代码的验证:

通过对字符串调用getBytes()方法,实际上是对该字符串进行编码,然后将编码结果存放在byte数组中。该方法中默认的字符编码方式是 UTF-8,当然也可以传入其他的编码方式。

String str = "汉";
byte[] bytes = str.getBytes();
//bytes数组 = {-26,-79,-119}

再将字节数组解码成字符串,这里默认的解码方式也是UTF-8:

String str = "汉";
byte[] bytes = str.getBytes();
String s = new String(bytes);//“汉"

注意:以什么方式编码的,就要以什么方式解码。

2) UTF-16

UTF-16编码以16位无符号整数为单位。

如果Unicode编码不超过0xFFFF,则其UTF-16编码就是Unicode编码对应的16位无符号整数;

如果Unicode编码 (假设为U) 超过0xFFFF,我们先计算U’=U-0x10000,然后将U’写成二进制形式:yyyy yyyy yyxx xxxx xxxx,U的UTF-16编码(二进制)就是:110110yyyyyyyyyy 110111xxxxxxxxxx。

3) UTF-32

UTF-32编码以32位无符号整数为单位。Unicode的UTF-32编码就是其对应的32位无符号整数。

1.3.4 java中的字符集

java使用Unicode字符集。

java中的char类型是16位无符号基本数据类型,用于存储Unicode字符。char类型使用UTF-16编码,也即是说Unicode编码不超过0xFFFF只需要使用一个char;如果超出了0xFFFF,则使用2个char。汉字可以存储在一个char中。

String str = "汉";
byte[] bytes = str.getBytes();

jdk8 中,String中使用char数组存储字符序列,因此,字符串"汉"仅需使用一个char来存。

当字符串str调用getBytes方法时,将对char数组以UTF-8的方式进行编码,将"汉"对应的Unicode编码重新使用三字节编码,得到 -26 -79 -119。

jdk9 中,String中改用byte数组来存储字符序列。因为很多拉丁系语言的字符,使用16位char会造成了一定的空间浪费。使用byte来存储,可以更加紧凑,带来更小的内存占用和更快的操作速度。并且,底层的改变对java字符串的行为没有任何大的影响,这个特性对于绝大部分应用来说是透明的,绝大部分情况不需要修改已有代码。

// since java 9.0
private final byte[] value;

现在,我们通过代码来看看是如何将Unicode字符存储在byte数组中的。

String s1 = "a";
String s2 = "汉";

debug模式下,s1的byte数组值为{97},s2的byte的数组值为{73, 108}。

"a"的Unicode编码为0x61,对应字节的十进制为97,直接将这个单字节存放在1个byte中。

而"汉"的Unicode编码为0x6c49,对应字节的十进制数为108 73,直接将这个16位双字节的Unicode编码存入byte数组中,但是存储时低位字节73放在了低端地址,高位字节108放在了高端地址,这是一种小端模式(Little endian)。

  • 大端模式Big-Endian:就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
  • 小端模式Little-Endian:就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

而在调用getBytes方法将Unicode数字通过UTF-16编码为字节数组时,前两位的-2,-1,即十六进制下的FEFF(补码),FEFF表示存储采用大端模式,而FFFE表示使用小端模式。因此,这里采用的是大端模式,先存储了高位字节108,后存储了低位字节73。

String s1 = "a";
String s2 = "汉";
byte[] bytes1 = s1.getBytes("UTF-16");//{-2,-1,0,97}
byte[] bytes2 = s2.getBytes("UTF-16");//{-2,-1,108,73}

然而,原char数组实现方式下,字符串的最大长度就是数组本身的长度。当使用byte数组时,数组长度相同的情况下,存储能力则退化一倍。

1.4 equals方法

equals方法用于判断两个字符串的字符序列是否相同。

public boolean equals(Object anObject) {
  	//首先判断本字符串与要比较的对象是否地址相同,如果地址相同,则为同一个对象
    if (this == anObject) {
        return true;
    }
  	//其次,判断传入的对象是否是String类型的,必须都是String类型的对象才可以比较
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
      	//长度不同,肯定不相等
        if (n == anotherString.value.length) {
          	//长度相同的情况下,判断每一位是否一样
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

忽略大小写比较两个字符串是否相等。

public boolean equalsIgnoreCase(String anotherString) {
    return (this == anotherString) ? true
            : (anotherString != null)
            && (anotherString.value.length == value.length)
            && regionMatches(true, 0, anotherString, 0, value.length);
}

1.5 compareTo方法

根据字符的编码顺序,从左往右比较两个字符串谁大谁小。如果某个字符串是另一个字符串的前缀子串,则返回调用者和被比较者的长度之差。

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;

    int k = 0;
    while (k < lim) {
        char c1 = v1[k];
        char c2 = v2[k];
        if (c1 != c2) {
            return c1 - c2;
        }
        k++;
    }
    return len1 - len2;
}

1.6 比较前缀和后缀

比较字符串是否以某前缀开始,或者以某后缀结束,都可以得益于下面方法。

判断从某个偏移位置开始,是否以传入的前缀开始。

比较前缀可以调用startsWith(prefix, 0);比较后缀可以调用startsWith(suffix, value.length - suffix.value.length)。

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;
    //如果偏移位置小于0,或者偏移位置开始+前缀子串的长度已经超出了原字符串的长度,则直接返回false
    if ((toffset < 0) || (toffset > value.length - pc)) {
        return false;
    }
  	//一切就绪后,就可以开始一个字符一个字符地比较了,直到出现两个字符不一致,就返回false
    while (--pc >= 0) {
        if (ta[to++] != pa[po++]) {
            return false;
        }
    }
    return true;
}

1.7 匹配子串

1)寻找子串在原串中第一次出现的位置。String类中使用了朴素的字符串匹配方式,一个一个位置依次匹配。

	/**
     * @param   source       原字符串
     * @param   sourceOffset 原字符串的偏移位置
     * @param   sourceCount  原字符串的长度
     * @param   target       目标字符串
     * @param   targetOffset 目标字符串的偏移位置
     * @param   targetCount  目标字符串的长度
     * @param   fromIndex    从哪个位置开始搜索.
     */
static int indexOf(char[] source, int sourceOffset, int sourceCount,
        char[] target, int targetOffset, int targetCount,
        int fromIndex) {
  	//如果起始搜索位置超出了原字符串的长度
    if (fromIndex >= sourceCount) {
      	//如果目标字符串长度为0,则返回原串的长度,否则返回-1
        return (targetCount == 0 ? sourceCount : -1);
    }
  	//纠正起始搜索位置
    if (fromIndex < 0) {
        fromIndex = 0;
    }
  	//如果目标子串长度为0,则返回起始搜索位置
    if (targetCount == 0) {
        return fromIndex;
    }

    char first = target[targetOffset];
  	//目标子串最多只可能出现在原串起始位置加上两串长度之差的位置max,也就是说最多只需要遍历到原串的max位置
    int max = sourceOffset + (sourceCount - targetCount);

    for (int i = sourceOffset + fromIndex; i <= max; i++) {
        //先找到第一个相同字符的位置
        if (source[i] != first) {
            while (++i <= max && source[i] != first);
        }

      	//找到了第一个相同的字符,现在开始判断剩余的字符
        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) {
               	//找到了目标子串在原串中第一次出现的位置。
                return i - sourceOffset;
            }
        }
    }
    return -1;
}

2)寻找子串在原串中最后一次出现的位置。依然使用了朴素的字符串匹配方式,从最后一个相同的元素开始一一匹配。

static int lastIndexOf(char[] source, int sourceOffset, int sourceCount,
        char[] target, int targetOffset, int targetCount,
        int fromIndex) {
		//可能的最后起始位置
    int rightIndex = sourceCount - targetCount;
    if (fromIndex < 0) {
        return -1;
    }
    if (fromIndex > rightIndex) {
        fromIndex = rightIndex;
    }
    /* Empty string always matches. */
    if (targetCount == 0) {
        return fromIndex;
    }
		//目标子串的最后一个位置
    int strLastIndex = targetOffset + targetCount - 1;
  	//目标子串的最后一个元素
    char strLastChar = target[strLastIndex];
  	//原串中的可能的最前位置
    int min = sourceOffset + targetCount - 1;
  	//起始搜索位置
    int i = min + fromIndex;
//寻找最后一个匹配的元素
startSearchForLastChar:
    while (true) {
      	//找到最后一个匹配的元素的位置
        while (i >= min && source[i] != strLastChar) {
            i--;
        }
        if (i < min) {
            return -1;
        }
      	//匹配剩余位置的元素,j为原串对应子串长度下末尾的前一个位置
        int j = i - 1;
      	//start为原串对应子串长度下的第一个位置的前一个位置
        int start = j - (targetCount - 1);
      	//目标子串的倒数第二个位置
        int k = strLastIndex - 1;

        while (j > start) {
          	//发现剩下的元素无法匹配时,重新寻找最后一个匹配的元素的位置
            if (source[j--] != target[k--]) {
                i--;
                continue startSearchForLastChar;
            }
        }
      	//剩下元素都匹配时,整个子串匹配成功,返回原串中的对应位置
        return start - sourceOffset + 1;
    }
}

1.8 替换和分割字符串

1)字符替换

replace方法首先找到第一个需要替换的字符的位置,然后把前面所有的字符复制到新的数组中,然后从第一个需要替换的位置开始,依次判断是否需要替换成新的字符。

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;
}

2)使用正则表达式分割字符串

public String[] split(String regex, int limit) {
 
  	//首先判断是否可以采用快速方式,下面两种情况符合一种即可:
  	//(1)如果传入的分割字符串只有一个字符,并且不是不属于正则表达式的元字符集".$|()[{^?*+\\"
  	//(2)如果传入的分割字符串是两个字符,并且第一个字符是反斜杠,第二个字符不是ascii数字或ascii字母
    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;
     //返回数组的长度是否受限制,当limit大于0时,表示数组的长度为limit,分割将在limit限制下结束;当limit等于0时,表示数组长度无限制,将会尽最大努力分割
        boolean limited = limit > 0;
      	//用ArrayList盛装分割出来的子串
        ArrayList<String> list = new ArrayList<>();
      	//通过调用indexOf方法,找到的ch的位置赋值给next,off是字符串开始搜索时的偏移量
        while ((next = indexOf(ch, off)) != -1) {
          	//如果分割不受限制
            if (!limited || list.size() < limit - 1) {
              	//将[off,next)这段子串放入list容器
                list.add(substring(off, next));
              	//然后off更新为next的下一位置,开始下一轮查找
                off = next + 1;
            } else {    
                //由于限制数组长度,分割将要结束,把剩余的子串作为最后的子串,放入list
                list.add(substring(off, value.length));
              	//更新off
                off = value.length;
              	//跳出循环
                break;
            }
        }
        
      	//如果没有匹配到,则返回这个字符串的复制品
        if (off == 0)
            return new String[]{this};

        // 不受限制的情况下,找不到下一个匹配位置了,把剩余的子串作为最后一个子串放入list
        if (!limited || list.size() < limit)
            list.add(substring(off, value.length));

        // 构建结果数组,首先取得list的尺寸
        int resultSize = list.size();
      	//如果数组长度不受限制
        if (limit == 0) {
          	//如果list中存在空串"",则不会为空串创建数组空间
            while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
                resultSize--;
            }
        }
      	//构造结果数组
        String[] result = new String[resultSize];
      	//list调用subList方法取得子容器,然后将子容器中的元素转换为数组形式返回
        return list.subList(0, resultSize).toArray(result);
    }
  	//如果不可以用快速方式,则需要使用pattern进行分割,
  	//首先将regex解析成一个pattern,然后再对字符串继续分割
    return Pattern.compile(regex).split(this, limit);
}

3)String类中还提供将字符串两边的空格去除的方法trim()

public String trim() {
    int len = value.length;
    int st = 0;
    char[] val = value;    /* avoid getfield opcode */
		//去掉左侧空格,st指向新串的第一个字符
    while ((st < len) && (val[st] <= ' ')) {
        st++;
    }
  	//去除右侧空格,len指向新串的最后一个字符的下一位置
    while ((st < len) && (val[len - 1] <= ' ')) {
        len--;
    }
    return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}

1.9 其他类型转换为String

String类中提供了很多将其他类型转换为String的静态方法,大部分都可以直接调用已有的接口。

比如一个对象转换为String:

public static String valueOf(Object obj) {
  	//直接调用对象的toString方法
    return (obj == null) ? "null" : obj.toString();
}

char型数组转换为String:

public static String valueOf(char data[]) {
  	//调构造器
    return new String(data);
}

int转换为String:

public static String valueOf(int i) {
  	//调用Integer类提供的方法
    return Integer.toString(i);
}

1.10 字符串拼接

情形1:将一个堆中的String对象"a"和一个从字符串常量池中取得的字符串"b"进行拼接,或者将两个堆中的String对象"a"和"b"进行拼接。

String s1 = new String("a")+ "b";
String s2 = new String("a")+ new String("b");

jdk8中,以上代码在执行时,实际上是先创建了一个StringBuilder对象,依次将加号前后的字符串append进自身,然后调用toString方法形成得到一个新的String对象"ab"然后返回。

但值得注意的是,StringBuilder的toString方法。

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

该方法调用String类的构造器String(char value[], int offset, int count),其构造的String对象中持有的字符序列实际上是StringBuilder中value的一个副本,并且也没有把String字符串放入常量池中

因此,现在常量池中没有字符串"ab"。

注意:jdk9 中没有使用StringBuilder,而是利用了invokeDynamic指令(实际是利用了MethodHandle,统一了入口),将字符串拼接的优化与javac生成的字节码解耦。

情形2

String s3 = "aa" + "bb" + "cc";

上述代码在javac编译期,就会被优化为直接从字符串常量池中取出字符串"aabbcc"。

1.11 intern方法

该方法被调用的时候,如果字符串常量池中已经存在该字符串,则直接从取常量池中的字符串对象返回;

如果池中不存在该字符串,那么就把这个字符串对象放入池中,更准确地说,是在常量池中引用堆中的这个字符串,并返回。

public native String intern();

通过下面代码验证:

1 String s1 = new String("a")+ new String("b");
2 s1.intern();
3 String s2 = "ab";
4 System.out.println(s1 == s2);//true
  • 第1条语句执行完成后,由1.10中可知,常量池中没有字符串“ab”;
  • 第2条语句调用intern方法,在常量池中放入这个字符串,但只是引用堆中s1这个对象而已;
  • 第3条语句将s2指向常量池中的字符串,而池中实际上引用了对象s1;
  • 所以,第4条语句可以得到 s1 == s2。

intern有何作用?

如果程序中需要创建大量的字符串,并且很多字符串是重复的,那么使用intern()将可以节省空间,因为重复的数据都会从常量池中取得。

比如:

public class InternTest{
	static final int MAX_COUNT = 1000 * 1000;
	static final String[] arr = new String[MAX_COUNT];
	
	public static void main(String[] args){
		Integer[] data = new Integer[]{1,2,3,4,5};

		for(int i = 0; i < MAX_COUNT; ++i){
      
      //下面做法将会创建MAX_COUNT个String对象,并将地址存入arr数组中
			//arr[i] = new String(String.valueOf(data[i % data.length]));
      
      //使用intern后,节约大量堆空间
			arr[i] = new String(String.valueOf(data[i % data.length])).intern();
      /*前5次循环,创建String对象,常量池引用这些堆中创建的String对象,然后返回常量池中的字符串
			* 5轮循环过后,依然创建这些字符串,但不用再放入常量池,因为已存在,现在只返回常量池的字符串,而堆中新创			 * 建的字符串,将会因为没有被引用,会被垃圾收集器清理。现在只需要维护5个String对象。
			*/
		}
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值