聊聊并发安全集合ConcurrentHashMap

为什么要用ConcurrentHashMap

  • HashMap是线程不安全的
  • HashTable是直接在方法上加上粗粒度的锁,虽然可以实现线程安全,但是会对性能造成影响。
  • 为了平衡安全和性能,可以使用并发安全集合ConcurrentHashMap

ConcurrentHashMap的使用

java 8 添加了lambda的支持,并且解决了复合操作的非原子性问题。

  • computeIfAbsent:如果key不存在,调用后面的mappingFunction计算,把计算的返回值作为value。
  • computeIfPresent:如果key存在,则修改,如果不存在,则返回null。
  • compute:上面两种的结合。
  • merge:合并数据。

存储结构和实现

JDK 1.7
使用segment,没有使用红黑树,在某些极端情况下性能比较差。

JDK 1.8
引入红黑树,时间复杂度从O(n)变为O(lgN)

在这里插入图片描述

源码实现

putVal

向ConcurrentHashMap中put值

	public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        //自旋(cas)
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //如果tab为空,说明还没有初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();		//初始化完成后,进入到下一次循环
            //(n - 1) & hash  -> 0-15 -> 计算数组下标位置
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            	//如果当前的node的位置为空,直接存储到该位置,通过cas保证其原子性
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //当前有数据正在进行迁移,帮忙迁移
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            //当前位置已经存在值,存在hash冲突
            else {
                V oldVal = null;
                //f代表当前节点,只对当前node节点进行加锁,如下图
                synchronized (f) {
                    if (tabAt(tab, i) == f) {	//重新判断f的值
                        if (fh >= 0) {		//针对链表处理
                            binCount = 1;		//统计了链表的长度
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                //判断是否存在相同的key,存在则覆盖
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                //不存在则把当前的key/value添加到链表中
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {		//针对红黑树处理
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                	//如果链表长度大于等于8,则会调用treeifyBin方法
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

我们首先看下不同的map锁的范围。
在这里插入图片描述

initTable

初始化map代码

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //只要tab没有初始化,就不断循环直到初始化完成
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        //通过cas自旋(通过CAS来占用一个锁的标记)    
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {	//sizeCtl等于-1,代表正在初始化
        	//说明抢到了锁
            try {
            	//初始化
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);		//扩容的阈值
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

我们来看一个很重要的变量sizeCtl
在这里插入图片描述

treeifyBin

会根据阈值来判断,是转化为红黑树还是扩容

    private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n, sc;
        if (tab != null) {
        	//如果数组长度小于64,扩容
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                tryPresize(n << 1);
			//否则进行红黑树转换
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
                synchronized (b) {
                    if (tabAt(tab, index) == b) {
                        TreeNode<K,V> hd = null, tl = null;
                        for (Node<K,V> e = b; e != null; e = e.next) {
                            TreeNode<K,V> p =
                                new TreeNode<K,V>(e.hash, e.key, e.val,
                                                  null, null);
                            if ((p.prev = tl) == null)
                                hd = p;
                            else
                                tl.next = p;
                            tl = p;
                        }
                        setTabAt(tab, index, new TreeBin<K,V>(hd));
                    }
                }
            }
        }
    }

tryPresize

该方法实现扩容

  • 多线程并发扩容(允许多个线程来协助扩容)
  • 扩容的本质
    • 创建一个新的数组(长度翻倍)
    • 然后把老的数据迁移到新的数组里面
    private final void tryPresize(int size) {
    	//用来判断扩容的目标大小,将传入的值转为最近的2的幂次方的大小
        int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
            tableSizeFor(size + (size >>> 1) + 1);
        int sc;
        while ((sc = sizeCtl) >= 0) {	//sizeCtl >= 0说明未初始化,进行初始化操作
            Node<K,V>[] tab = table; int n;
            if (tab == null || (n = tab.length) == 0) {
                n = (sc > c) ? sc : c;		//初始容量和扩容目标容量,谁更大选谁
                if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                    try {
                        if (table == tab) {
                            @SuppressWarnings("unchecked")
                            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                            table = nt;
                            sc = n - (n >>> 2);
                        }
                    } finally {
                        sizeCtl = sc;
                    }
                }
            }
            else if (c <= sc || n >= MAXIMUM_CAPACITY)		//已经是最大容量,不能扩容了,直接返回
                break;
            else if (tab == table) {
                int rs = resizeStamp(n);		//扩容戳,保证当前扩容范围的唯一性
                //第一次扩容不会走这段逻辑
                //第二次由于sc被修改为1000 0000 0001 1011 0000 0000 0000 0010,最高位为1,代表sc为负数,则会走这段逻辑
                if (sc < 0) {
                    Node<K,V>[] nt;
                    //代表扩容结束
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    //代表扩容未结束,每来一个线程sc+1,即低16位代表当前扩容的线程数量,扩容结束后sc-1
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                //第一次扩容走这段逻辑
                //第一次扩容时,会将这个rs左移16位变成1000 0000 0001 1011 0000 0000 0000 0000 
				//高16位表示当前的扩容标记,保持唯一性,低16位表示当前扩容的线程数量
				//加2变成1000 0000 0001 1011 0000 0000 0000 0010->表示当前有一个线程来扩容
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
            }
        }
    }

resizeStamp 返回一个无符号整数最高位非0位前面的0的个数为x,并把得到的数转为二进制,且把第16位置为1
假设初始数组大小为16,转化为二进制是0000 0000 0000 0000 0000 0000 0001 0000
前面有27个0,再把27转换为二进制为0000 0000 0000 0000 0000 0000 0001 1011
再把上述数组第16位置为1,得到0000 0000 0000 0000 1000 0000 0001 1011

static final int resizeStamp(int n) {
  return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

多线程并发扩容
在这里插入图片描述

transfer

实现数据转移
如何实现多个线程对同一个线程的数据迁移?

  • 数据的迁移
    • 需要计算当前线程的数据迁移空间(任务拆分)
    • 创建一个新的数组(容量为扩容后的大小)
    • 实现数据的转移
      • 如果是红黑树
        • 数据迁移后,不满足红黑树的条件,则红黑树转链表
      • 如果是链表
      • 高低位的迁移
    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
        int n = tab.length, stride;
        //计算每个线程处理的数据的区间大小,最小是16
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
            stride = MIN_TRANSFER_STRIDE; // subdivide range
        //表示扩容之后的数组,在原来的基础上扩大两倍
        if (nextTab == null) {            // initiating
            try {
                @SuppressWarnings("unchecked")
                Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
                nextTab = nt;
            } catch (Throwable ex) {      // try to cope with OOME
                sizeCtl = Integer.MAX_VALUE;
                return;
            }
            nextTable = nextTab;
            transferIndex = n;
        }
        //transferIndex = old table[]的长度
        int nextn = nextTab.length;
        //用来表示已经迁移完的状态,也就是说,如果某个old数组的节点完成了迁移,则需要更改成fwd
        ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
        boolean advance = true;
        boolean finishing = false; // to ensure sweep before committing nextTab
        for (int i = 0, bound = 0;;) {
            Node<K,V> f; int fh;
            while (advance) {
                int nextIndex, nextBound;
                if (--i >= bound || finishing)
                    advance = false;
                else if ((nextIndex = transferIndex) <= 0) {
                    i = -1;
                    advance = false;
                }
                else if (U.compareAndSwapInt
                         (this, TRANSFERINDEX, nextIndex,
                          nextBound = (nextIndex > stride ?
                                       nextIndex - stride : 0))) {
                    bound = nextBound;
                    i = nextIndex - 1;
                    advance = false;
                }
                //通过循环去计算区间,假设数组长度是32,第一次是[16(nextBound),31(i)],第二次是[0,15]
            }
            //是否扩容结束
            if (i < 0 || i >= n || i + n >= nextn) {
                int sc;
                if (finishing) {
                    nextTable = null;
                    table = nextTab;
                    sizeCtl = (n << 1) - (n >>> 1);
                    return;
                }
                //当线程执行完对sc-1,当全部完成后还原sc
                if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                        return;
                    finishing = advance = true;
                    i = n; // recheck before commit
                }
            }
            //得到数组下标为31的位置的值,即从尾部往头部处理
            else if ((f = tabAt(tab, i)) == null)	//说明当前数组位置为空
                advance = casTabAt(tab, i, null, fwd);	//直接改成fwd->表示迁移完成
            else if ((fh = f.hash) == MOVED)	//判断是否已经被处理过了,如果是,则进入下一次区间遍历
                advance = true; // already processed
            else {
        		//针对当前要迁移的节点进行加锁
                synchronized (f) {	//加锁,保证迁移过程中,其他线程调用put()方法时,必须要等待
                	//针对不同类型的节点,做不同的处理    * 数组 * 红黑树
                    if (tabAt(tab, i) == f) {
                        Node<K,V> ln, hn;
                        if (fh >= 0) {
                            int runBit = fh & n;	//根据算出的hash&(n-1)算出新的hash值,进行高低位链区分
                            Node<K,V> lastRun = f;
                            for (Node<K,V> p = f.next; p != null; p = p.next) {
                                int b = p.hash & n;
                                if (b != runBit) {
                                    runBit = b;
                                    lastRun = p;
                                }
                            }
                            if (runBit == 0) {
                                ln = lastRun;
                                hn = null;
                            }
                            else {
                                hn = lastRun;
                                ln = null;
                            }
                            //遍历链路进行扩容转移
                            for (Node<K,V> p = f; p != lastRun; p = p.next) {
                                int ph = p.hash; K pk = p.key; V pv = p.val;
                                if ((ph & n) == 0)
                                    ln = new Node<K,V>(ph, pk, pv, ln);
                                else
                                    hn = new Node<K,V>(ph, pk, pv, hn);
                            }
                            setTabAt(nextTab, i, ln);	//放在原来位置的链路
                            setTabAt(nextTab, i + n, hn);	//放在新位置的链路
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                        //红黑树的转换和迁移
                        else if (f instanceof TreeBin) {
                            TreeBin<K,V> t = (TreeBin<K,V>)f;
                            TreeNode<K,V> lo = null, loTail = null;
                            TreeNode<K,V> hi = null, hiTail = null;
                            int lc = 0, hc = 0;
                            for (Node<K,V> e = t.first; e != null; e = e.next) {
                                int h = e.hash;
                                TreeNode<K,V> p = new TreeNode<K,V>
                                    (h, e.key, e.val, null, null);
                                if ((h & n) == 0) {
                                    if ((p.prev = loTail) == null)
                                        lo = p;
                                    else
                                        loTail.next = p;
                                    loTail = p;
                                    ++lc;
                                }
                                else {
                                    if ((p.prev = hiTail) == null)
                                        hi = p;
                                    else
                                        hiTail.next = p;
                                    hiTail = p;
                                    ++hc;
                                }
                            }
                            ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                                (hc != 0) ? new TreeBin<K,V>(lo) : t;
                            hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                                (lc != 0) ? new TreeBin<K,V>(hi) : t;
                            setTabAt(nextTab, i, ln);
                            setTabAt(nextTab, i + n, hn);
                            setTabAt(tab, i, fwd);
                            advance = true;
                        }
                    }
                }
            }
        }
    }

扩容过程中的高低位链拆分。
在这里插入图片描述
位于高位的元素是需要改变数组位置的,位于低链的元素是不需要改变位置的。
在这里插入图片描述

分段锁的设计

在对map的容量进行计算的时候是如何进行计算的呢?
这里存在两个变量baseCount和counterCells

private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

  • 如果在竞争不激烈的情况下,直接用cas(baseCount+1)的方式计数
  • 如果竞争激烈的情况下,采用数组的方式来进行计数

在这里插入图片描述
在这里插入图片描述

计算map容量

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        //统计元素个数
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                  U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
        //是否要做扩容
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //helpTransfer	->	第一次扩容的场景
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }

竞争激烈的时候进行扩容并统计

    private final void fullAddCount(long x, boolean wasUncontended) {
        int h;
        if ((h = ThreadLocalRandom.getProbe()) == 0) {
            ThreadLocalRandom.localInit();      // force initialization
            h = ThreadLocalRandom.getProbe();
            wasUncontended = true;
        }
        boolean collide = false;                // True if last slot nonempty
        for (;;) {
            CounterCell[] as; CounterCell a; int n; long v;
            if ((as = counterCells) != null && (n = as.length) > 0) {
                if ((a = as[(n - 1) & h]) == null) {
                    if (cellsBusy == 0) {            // Try to attach new Cell
                        CounterCell r = new CounterCell(x); // Optimistic create
                        if (cellsBusy == 0 &&
                            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                            boolean created = false;
                            try {               // Recheck under lock
                            	//针对已经初始化的数组的某个位置,去添加一个CountCell
                                CounterCell[] rs; int m, j;
                                if ((rs = counterCells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    created = true;
                                }
                            } finally {
                                cellsBusy = 0;
                            }
                            if (created)
                                break;
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
                }
                else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;      // Continue after rehash
                else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                    break;
                else if (counterCells != as || n >= NCPU)
                    collide = false;            // At max size or stale
                else if (!collide)
                    collide = true;
                //扩容部分
                else if (cellsBusy == 0 &&
                         U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                    try {
                        if (counterCells == as) {// Expand table unless stale
                            CounterCell[] rs = new CounterCell[n << 1];
                            for (int i = 0; i < n; ++i)
                                rs[i] = as[i];
                            counterCells = rs;
                        }
                    } finally {
                        cellsBusy = 0;
                    }
                    collide = false;
                    continue;                   // Retry with expanded table
                }
                h = ThreadLocalRandom.advanceProbe(h);
            }
            //如果CounterCell为空,保证在初始化过程中的线程安全性。
            else if (cellsBusy == 0 && counterCells == as &&
                     U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {	//一旦cas成功,说明当前线程抢到了锁
                boolean init = false;
                try {                           // Initialize table
                    if (counterCells == as) {
                    	//初始化长度为2的数组
                        CounterCell[] rs = new CounterCell[2];
                        rs[h & 1] = new CounterCell(x);
                        counterCells = rs;	//赋值
                        init = true;
                    }
                } finally {
                    cellsBusy = 0;	//成功释放锁
                }
                if (init)
                    break;
            }
            //最终的情况,直接去修改baseCount
            else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
                break;                          // Fall back on using base
        }
    }

红黑树

红黑树是一棵平衡二叉树。可以保证较少的时间复杂度。

红黑树的平衡规则

  • 红黑树的每个节点颜色只能是红色或者黑色。
  • 根节点是黑色。
  • 如果当前的节点是红色,那么它的子节点必须是黑色。
  • 所有叶子节点(NIL节点,NIL节点表示叶子节点为空的节点)都是黑色。
  • 从任一节点到其每个叶子节点的所有简单路径都包含相同数目的黑色节点。

红黑树赋值

    private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n, sc;
        if (tab != null) {
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                tryPresize(n << 1);
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
                synchronized (b) {
                    if (tabAt(tab, index) == b) {
                        TreeNode<K,V> hd = null, tl = null;
                        for (Node<K,V> e = b; e != null; e = e.next) {
                            TreeNode<K,V> p =
                                new TreeNode<K,V>(e.hash, e.key, e.val,
                                                  null, null);
                            if ((p.prev = tl) == null)
                                hd = p;
                            else
                                tl.next = p;
                            tl = p;
                        }
                        setTabAt(tab, index, new TreeBin<K,V>(hd));
                    }
                }
            }
        }
    }

初始化TreeBin

        TreeBin(TreeNode<K,V> b) {
            super(TREEBIN, null, null, null);
            this.first = b;
            TreeNode<K,V> r = null;
            for (TreeNode<K,V> x = b, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                if (r == null) {
                    x.parent = null;
                    x.red = false;
                    r = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    for (TreeNode<K,V> p = r;;) {
                        int dir, ph;
                        K pk = p.key;
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);
                            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;
                            r = balanceInsertion(r, x);
                            break;
                        }
                    }
                }
            }
            this.root = r;
            assert checkInvariants(root);
        }

总结

  • ConcurrentHashMap使用java8语法。
  • 安全性的保障,保证了多步操作的原子性。
  • 原理分析
    • put元素添加,构建数据
    • 解决hash冲突的方式,链式寻址
    • 数组扩容
      • 数据迁移
      • 多线程并发协助数据迁移
      • 高低位迁移,将数据分为两条链,高低位链,然后一次性set到制定的数组下标位置
    • 元素统计
      • 数组的方式,分片统计的设计思想
      • 汇总数组+baseCount的值来完成数据累加
    • 当链表长度大于等于8,并且数组长度大于等于64的时候,链表转化为红黑树
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值