Map集合框架(一)——HashMap(JDK1.8)

(一)Map子父层级:

HashMap层级结构图
map接口常用实现类分为:HashMap、LinkedHashMap、HashTable、TreeMap、Properties;

(二)Map实现类

1、HashMap实现类

    (1)HashMap底层是Node数组+链表+红黑树,transient Node<K,V>[] table;
    (2)HashMap默认容量为16或者构造方法指定初始容量,static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16
    (3)HashMap扩容是翻倍,newThr = oldThr << 1; // double threshold(位运算:左移一位)
    (4)HashMap利用构造方法可以指定负载因子,默认为static final float DEFAULT_LOAD_FACTOR = 0.75f;
    (5)HashMap重要成员变量:扩容阈值(默认12,会改变)、树化阈值(链表长度>8,数组长度>64)、解树阈值(<6)、负载因子(默认0.75,可设置);
    (6)HashMap是线程不安全的,在多线程环境下,会报并发修改异常java.util.ConcurrentModificationException。

2、常见源码

(1)构造方法
 /** 指定初始容量值和负载因子 */
 public HashMap(int initialCapacity, float loadFactor) {
      if (initialCapacity < 0)//判定越界
          throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
      if (initialCapacity > MAXIMUM_CAPACITY)//判定越界
          initialCapacity = MAXIMUM_CAPACITY;
      if (loadFactor <= 0 || Float.isNaN(loadFactor))//判定值是否正常
          throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
      this.loadFactor = loadFactor;//设置负载因子
      //对指定容量值进行2的幂转换并设置扩容阈值为容器容量值,第一次put时会将阈值设置为正确的阈值
      this.threshold = tableSizeFor(initialCapacity);
  }
  //指定初始容量和使用默认负载因子
  public HashMap(int initialCapacity) {
      this(initialCapacity, DEFAULT_LOAD_FACTOR);
  }
  //无参构造函数,使用默认的负载因子,初始容量在put的时候进行初始化
  public HashMap() {
      this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
  }
  //将已存在的map加入到构造方法中进行初始化
  public HashMap(Map<? extends K, ? extends V> m) {
      this.loadFactor = DEFAULT_LOAD_FACTOR;
      putMapEntries(m, false);//将原map进行迁移到新map中
  }
  //旧map迁移到新map
  final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
      int s = m.size();//当前旧map的数据量
      if (s > 0) {
          if (table == null) { // pre-size
              float ft = ((float)s / loadFactor) + 1.0F;//根据数据量与负载因子的规律来算出总容量
              int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);//最大数值判定
              //容量数值转换为2的幂并设置阈值为新的容量值。第一次put会对其进行校正
              if (t > threshold)  threshold = tableSizeFor(t);
          }
          else if (s > threshold)  resize();//当前数值大于阈值进行扩容
          for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
              K key = e.getKey();//键
              V value = e.getValue();//值
              putVal(hash(key), key, value, false, evict);//put进新的map
          }
      }
  }
(2)put方法(核心)
//put外部暴露方法
 public V put(K key, V value) {
     return putVal(hash(key), key, value, false, true);//调用内部调用putVal方法
 }
 //将key进行hash转换,右移16位,再进行异或操作;key为空则直接返回0
 static final int hash(Object key) {
     int h;//判断key是否为空,为空则直接返回hash值为0,不为空则采用当前key右移16位,然后再进行异或
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }
 //底层putVal方法设值
 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                boolean evict) {
     Node<K,V>[] tab; Node<K,V> p; int n, i;
     if ((tab = table) == null || (n = tab.length) == 0)//是否初始化
         n = (tab = resize()).length;//利用扩容方法进行初始化
     if ((p = tab[i = (n - 1) & hash]) == null)//利用hash转换后的值与容量-1进行按位与运算,获得存储桶位置
         tab[i] = newNode(hash, key, value, null);//在桶位置没有值的情况下,直接存入node值
     else {//桶位置有值的情况下,考虑替换、链表插入、红黑树插入
         Node<K,V> e; K k;
         if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
             e = p;//代表替换,利用key的hash和equal方法判定
         else if (p instanceof TreeNode)//判定是红黑树节点插入
             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
         else {//代表该桶为链表,进行循环遍历
             for (int binCount = 0; ; ++binCount) {
                 if ((e = p.next) == null) {//尾插法进行插入
                     p.next = newNode(hash, key, value, null);
                     if (binCount >= TREEIFY_THRESHOLD - 1) //判定该链表是否已达到树化条件
                         treeifyBin(tab, hash);//调用树化方法
                     break;
                 }//判定替换key-value是否存在链表中
                 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                     break;//如果满足,则直接推出循环,并记录下e
                 p = e;//递推链表值,进行遍历
             }
         }
         if (e != null) { //是否满足替换条件,不为空则说明是需要替换操作
             V oldValue = e.value;记录原来的值,用于返回
             if (!onlyIfAbsent || oldValue == null)
                 e.value = value;//设置新的值
             afterNodeAccess(e);//用于其他子类重写使用的方法,HashMap无实际意义,代码块是一个空{}
             return oldValue;//返回久的值
         }
     }
     ++modCount;//操作次数自增,用于判定修改次数,多线程并发修改异常与该值有关
     if (++size > threshold)//判断是否需要扩容
         resize();//扩容
     afterNodeInsertion(evict);//同样也是其他子类重写方法,与排序类有关
     return null;//正常插入则返回为空
 }
(3)扩容(初始值)方法:
  /**
  * HashMap中多处使用到扩容方法:主要是初始化(仅赋值容量和阈值)操作和扩容(迁移数据)操作
  * put的判定是否初始化和插入值的时候进行初始化和扩容操作(实际底层调用putVal);
  * 树化方法treeifyBin中是否满足链表大于8和数组长度大于64中,不满足则进行扩容操作;
  * 基于Map创建新Map的构造方法中putMapEntries方法中会进行初始化操作(实际底层调用putVal);
  * 其它不常见的方法computeIfAbsent和compute中也会调用初始化效果(基本不常用)。
  */
 final Node<K,V>[] resize() {
     //此处为初始化和计算阈值的代码逻辑
     Node<K,V>[] oldTab = table;
     int oldCap = (oldTab == null) ? 0 : oldTab.length;//判定底层数组是否被初始化,三目运算进行赋值
     int oldThr = threshold;//当前扩容阈值
     int newCap, newThr = 0;
     if (oldCap > 0) {//判断数组是否已被初始化
         if (oldCap >= MAXIMUM_CAPACITY) {//判定当前数组长度是否超过最大存储值,进行数组长度限制
             threshold = Integer.MAX_VALUE;//限制最大长度值
             return oldTab;//返回之前的旧长度
         }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY //判定翻倍后的长度仍然在允许范围内且大于默认值16,则阈值翻倍
               && oldCap >= DEFAULT_INITIAL_CAPACITY)
             newThr = oldThr << 1; //将扩容阈值进行翻倍
     } else if (oldThr > 0) //oldThr是当前扩容阈值,此时程序如果走这判定代表数组需要被初始化且值为当前阈值(此时的阈值并不是容量*负载因子)
         newCap = oldThr;
     else {//前面两种判定无效时则说明,数组未初始化,阈值也没有其他途径被赋值,则使用默认值和默认因子进行赋值
         newCap = DEFAULT_INITIAL_CAPACITY;//默认容量16
         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//默认容量*默认负载因子0.75f=12
     }
     if (newThr == 0) {//newThr未在上面的判定中进行赋值,代表阈值需要进行正确计算
         float ft = (float)newCap * loadFactor;//阈值计算公式
         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                   (int)ft : Integer.MAX_VALUE);//三目运算计算阈值
     }
     threshold = newThr;//将阈值真正的赋值
     //下面就是有需要迁移数据的代码逻辑
     @SuppressWarnings({"rawtypes","unchecked"})
         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
     table = newTab;
     if (oldTab != null) {//保证遍历不是发生空指针
         for (int j = 0; j < oldCap; ++j) {
             Node<K,V> e;
             if ((e = oldTab[j]) != null) {判定桶位置是否有值
                 oldTab[j] = null;//便于GC回收
                 if (e.next == null)//代表桶上面只有一个node
                     newTab[e.hash & (newCap - 1)] = e;//重新确定新的桶位置并赋值
                 else if (e instanceof TreeNode)//桶上面有多个值且为树结构
                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//树化迁移
                 else { //链表迁移
                     Node<K,V> loHead = null, loTail = null;
                     Node<K,V> hiHead = null, hiTail = null;
                     Node<K,V> next;
                     do {//循环遍历且使用原容量的最高位hash值进行迁移
                         next = e.next;
                         if ((e.hash & oldCap) == 0) {//hash值与原容量值进行按位与确定低位(0)
                             if (loTail == null)//低位链表头节点,用于后续低位(当前桶)整体迁移
                                 loHead = e;//低位头结点
                             else
                                 loTail.next = e;//低位其他节点,尾插法
                             loTail = e;//低位链表递推,用于链表链接
                         }
                         else {//hash值与原容量值进行按位与确定高位
                             if (hiTail == null)//高位链表头节点,用于后续高位(当前桶)整体迁移
                                 hiHead = e;//高位头节点
                             else
                                 hiTail.next = e;//高位其他节点,尾插法
                             hiTail = e;//高位链表递推,用于链表连接
                         }
                     } while ((e = next) != null);
                     if (loTail != null) {//低位整体迁移尾节点是否有值,有值则代表前面低位被记录需要迁移
                         loTail.next = null;//低位尾节点的next值设置为null,表示后面无node节点
                         newTab[j] = loHead;//低位整体迁移
                     }
                     if (hiTail != null) {//高位整体迁移尾节点是否有值,有值则代表前面高位被记录需要迁移
                         hiTail.next = null;//高位尾节点的next值设置为null,表示后面无node节点
                         newTab[j + oldCap] = hiHead;//高位使用桶位置+旧容量=新容量桶位置(无需再次计算桶位置)迁移
                     }
                 }
             }
         }
     }
     return newTab;//返回扩容后的数组
 }
(4)treeifyBin树化节点:
 final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();//初始化(扩容)方法
    else if ((e = tab[index = (n - 1) & hash]) != null) {//找到当前需要树化的桶链表
        TreeNode<K,V> hd = null, tl = null;
        do {//循环遍历链表
            /**
            * 将链表node的hash,key,value,next,进行转化为树节点类型TreeNode;
            * 树节点也是继承(->)LinkedHashMap.Entry继承(->)HashMap.Node(hash,key,value,next)
            * 树节点只是比正常node多parent节点,left左节点,right节点,prev节点和red值(红黑树标记值新添加TreeNode默认为red)
            */
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;//设置当前桶的头树节点
            else {
                p.prev = tl;//设置当前节点的前节点为遍历的上一节点
                tl.next = p;//上节点的下一节点为当前节点
            }
            tl = p;//调整递推中间值
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)//将数组的当前桶位置设置为第一个树节点且不为空时进行真正的树化
            hd.treeify(tab);//利用第一个树节点调用真正的树化方法,将数组传入进行树化调整
    }
 }
(5)treeify真正树化方法:
/**
  * 树化调整,基于红黑树的五条规则要求如下:
  * 第一:所有节点是黑色和红色节点组成,根节点为黑色节点
  * 第二:不能出现两个连续的红节点
  * 第三:每条路径上的黑节点个数相同
  * 第四:叶子节点都是黑节点(叶子节点可能为空即默认空节点都是黑色节点)
  * 第五:每个红色节点下都有两个黑色节点
  */
final void treeify(Node<K,V>[] tab) {
  TreeNode<K,V> root = null;
  for (TreeNode<K,V> x = this, next; x != null; x = next) {//遍历桶树链表
      next = (TreeNode<K,V>)x.next;//递推节点
      x.left = x.right = null;
      if (root == null) {//根节点为空,设置根节点
          x.parent = null;//根节点的父节点设置空
          x.red = false;//设置黑节点
          root = x;//将第一个节点设置为根节点
      }else {
          K k = x.key;//新节点key
          int h = x.hash;新节点hash值
          Class<?> kc = null;
          for (TreeNode<K,V> p = root;;) {
              int dir, ph;
              K pk = p.key;//已存在的子树结构(非空叶子节点)的根节点key
              if ((ph = p.hash) > h)//子树节点的hash值与新节点对比,如果大,则标记-1,后面将其放在左节点
                  dir = -1;
              else if (ph < h)//子树节点的hash值与新节点对比,如果小,则标记1,后面将其放在右节点
                  dir = 1;
              else if ((kc == null &&//此种情况代表hash值相等,则根据是否实现comparable接口
                        (kc = comparableClassFor(k)) == null) ||//未实现comparable接口的比较
                       (dir = compareComparables(kc, k, pk)) == 0)//实现过comparable接口的比较
                  dir = tieBreakOrder(k, pk);//标记是否为1,0,-1
              TreeNode<K,V> xp = p;
              if ((p = (dir <= 0) ? p.left : p.right) == null) {//具体根据上述设置的标记放在左或右节点
                  x.parent = xp;
                  if (dir <= 0)//左子节点
                      xp.left = x;
                  else//右子节点
                      xp.right = x;
                  root = balanceInsertion(root, x);//平衡上述的树结构
                  break;
              }
          }
      }
  }
  moveRootToFront(tab, root);//将调整后的树根节点设置在桶位置
}
//平衡树结构:先旋转再变色
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,TreeNode<K,V> x) {
      x.red = true;//新节点默认为red,
      for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {//定义xp为父节点,xpp祖父节点,xppl祖父的左子节点,xppr祖父的右子节点
          if ((xp = x.parent) == null) {//父节点是否为空
              x.red = false;//设置当前节点为根节点,不需要调整
              return x;
          }else if (!xp.red || (xpp = xp.parent) == null)//父节点为黑色或者祖父节点为空,则代表父节点为根节点
              return root;//直接返回根节点,不需要调整
          if (xp == (xppl = xpp.left)) {//如果父节点是祖父左子节点
              if ((xppr = xpp.right) != null && xppr.red) {//判定祖父右子节点是否存在且为红色,如果满足,则进行变色,不需要旋转
                  xppr.red = false;//祖父右子节点变为黑色
                  xp.red = false;//父节点变为黑色
                  xpp.red = true;//祖父节点变为红色
                  x = xpp;//调整当前节点为祖父节点
                  /**此处原理:
                  * 父节点和祖父右子节点(叔叔节点)都是红色,因为新节点默认为红色,违背两个红色节点连续;
                  * 需要变色,祖父由黑变为红,祖父的下属左右子节点均需要变为黑色,同时将当前节点设置为祖父节点进行循环判定
                  */
              }else {//不满足上述if的条件即祖父右子节点不存在或为黑色
                  if (x == xp.right) {//判定当前节点是否为父节点右子节点
                      root = rotateLeft(root, x = xp);//进行左旋
                      xpp = (xp = x.parent) == null ? null : xp.parent;//判定祖父节点是否为空,将祖父节点重新赋值
                  }
                  if (xp != null) {父节点判定即可能是旧的祖父节点或者以前的父节点
                      xp.red = false;//修改父节点颜色即新的祖父节点或者不需要左旋的父节点颜色(双红连续节点)
                      if (xpp != null) {
                          xpp.red = true;//祖父节点设置为红色
                          root = rotateRight(root, xpp);//以祖父节点为支点,进行右旋
                      }
                  }
              }
          }else {//父节点是祖父右子节点
              if (xppl != null && xppl.red) {判断祖父左子节点存在且为红色,进行变色,同理上述if中的变色原理
                  xppl.red = false;//祖父左子节点变为黑色
                  xp.red = false;//父节点变为黑色
                  xpp.red = true;//祖父节点变为红色
                  x = xpp;//调整当前节点为祖父节点
              }else {
                  if (x == xp.left) {//判断当前节点是否为左节点
                      root = rotateRight(root, x = xp);//以父节点为支点进行右旋
                      xpp = (xp = x.parent) == null ? null : xp.parent;//重新赋值祖父节点
                  }
                  if (xp != null) {
                      xp.red = false;//父节点设置为黑色
                      if (xpp != null) {
                          xpp.red = true;//祖父节点设置为红色
                          root = rotateLeft(root, xpp);//以祖父节点为支点进行左旋
                      }
                  }
              }
          }
      }
  }
(6)rotateLeft左旋
//root树,p传入值父节点或者祖父节点,此处为传入进来的值
 static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,TreeNode<K,V> p) {
     TreeNode<K,V> r, pp, rl;
      if (p != null && (r = p.right) != null) {//存在右子节点即当前节点在右节点
          if ((rl = p.right = r.left) != null)//当前节点的左节点赋值给父节点的右节点
              rl.parent = p;//将旋转后的节点连接到旧的父节点下
          if ((pp = r.parent = p.parent) == null)//祖父节点为空,表明为根节点
              (root = r).red = false;//设置根节点为黑色
          else if (pp.left == p)
              pp.left = r;//左边局部微调上下级
          else
              pp.right = r;//右边局部微调上下级
          r.left = p;//将父节点连接到子节点下,当前节点上位为新的父节点
          p.parent = r;//双向设置上下级
      }
      return root;//返回根节点
  }
(7)rotateRight右旋
//root树,p传入值父节点或者祖父节点,此处为传入进来的值
 static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,TreeNode<K,V> p) {
    TreeNode<K,V> l, pp, lr;
     if (p != null && (l = p.left) != null) {//存在左子节点即当前节点在左边
         if ((lr = p.left = l.right) != null)//当前节点的右节点赋值给父节点的左节点
             lr.parent = p;//将旋转后的节点连接到旧的父节点下
         if ((pp = l.parent = p.parent) == null)//是否为根节点
             (root = l).red = false;//根节点设置为黑色
         else if (pp.right == p)
             pp.right = l;//右边局部微调上下级
         else
             pp.left = l;//左边局部微调上下级
         l.right = p;//将旧的父节点连接到子节点下,子节点上位为新的父节点
         p.parent = l;//双向设置上下级
     }
     return root;//返回根节点
 }
(8)get方法
//根据key进行查找数组、链表或红黑树进行遍历并返回value
 public V get(Object key) {
     Node<K,V> e;//调用遍历node方法,进行返回value
     return (e = getNode(hash(key), key)) == null ? null : e.value;
 }
 //调用遍历方法,根据key的hash运算(扰乱算法,右移16位和高低位异或操作),进行查找
 final Node<K,V> getNode(int hash, Object key) {
     Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
     //根据key的hash运算与容量-1进行按位与运算获得桶位置
     if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
         if (first.hash == hash &&((k = first.key) == key || (key != null && key.equals(k))))
             return first;//根据key的hash值和equal方法判定是否为查找值且使用第一个节点判定,如果是则返回node节点
         if ((e = first.next) != null) {//根据桶的位置,进行遍历查找,此时桶位置为链表
             if (first instanceof TreeNode)//判断是否为树化节点
                 return ((TreeNode<K,V>)first).getTreeNode(hash, key);//调用树化节点查找方法
             do {//链表查找判定
                 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                     return e;//判定对否为查找节点,根据hash值和equal方法判定,找到则返回并跳出循环
             } while ((e = e.next) != null);
         }
     }
     return null;//代表未找到指定值
 }
(9)remove方法
  public V remove(Object key) {
  	Node<K,V> e;//调用删除底层方法返回删除值
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
    null : e.value;
  }
  //根据指定key删除键值对
  final Node<K,V> removeNode(int hash,Object key,Object value,boolean matchValue,boolean movable) {
      Node<K,V>[] tab; Node<K,V> p; int n, index;
      if ((tab = table) != null && (n = tab.length) > 0 &&
          (p = tab[index = (n - 1) & hash]) != null) {//判断桶的索引
          Node<K,V> node = null, e; K k; V v;
          if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
              node = p;//标记key的键值对
          else if ((e = p.next) != null) {
              if (p instanceof TreeNode)//是否是树结构
                  node = ((TreeNode<K,V>)p).getTreeNode(hash, key);//根据key获取树节点
              else {//链表获取key键值对
                  do {//循环链表查找
                      if (e.hash == hash &&
                          ((k = e.key) == key ||
                           (key != null && key.equals(k)))) {
                          node = e;
                          break;
                      }
                      p = e;
                  } while ((e = e.next) != null);
              }
          }
          if (node != null && (!matchValue || (v = node.value) == value ||
                               (value != null && value.equals(v)))) {
              if (node instanceof TreeNode)//树结构的删除方法
                  ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
              else if (node == p)//是否是桶的位置
                  tab[index] = node.next;//直接将后续节点置为桶位置或者使用nexr为空的值(桶上只有一个节点即不是链表也不是树结构)
              else
                  p.next = node.next;//在链表中找到键值对,直接舍弃调node,利用节点连接,删除节点
              ++modCount;//修改次数
              --size;//数量减1
              afterNodeRemoval(node);//其他实现类操作方法
              return node;
          }
      }
      return null;
  }
3、总结
1HashMapNode数组+链表+红黑树结构,重要成员变量:负载因子(0.75f)、扩容阈值、树化阈值(6864)、初始容量(16);2HashMap扩容机制为翻倍,对于容量(默认16)不是2的幂先进行幂转换,然后进行位运算(左移1)扩容并迁移。
(3HashMap核心原理及方法:put方法包含四种插入逻辑即空桶插入、链表插入、红黑树插入、替换插入;
                        扩容resize方法即初始化table或翻倍扩容及迁移(高低位)数据;
                        红黑树相关方法即treeifyBin(链表node转树node)treeify(树化)balanceInsertion(平衡树方法)、左旋(rotateLeft)、右旋(rotateRight);4HashMap是线程不安全的,在多线程环境下,会报并发修改异常java.util.ConcurrentModificationException
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

进击的猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值