ConcurrentHashMap 源码分析 (一)

转自:http://blog.csdn.net/ykdsg/article/details/6257449

 很早就想研究ConcurrentHashMap ,不过一直拖拉,我也是个很容易被新奇好玩的技术吸引的人,这个呢有好也有坏。废话不多说上干货。

ConcurrentHashMap 最重要的就是引入了Segment 的概念,他在自己内部定义了这个Class来管理数据,这个Segment 类似于HashMap的定义,因为ConcurrentHashMap 会将对应的读写操作交给Segment 。Segment 在ConcurrentHashMap 内部维护一个Segment 数组(默认16个),先将key值的hash值定位到Segment 数组中,取得对应的Segment 之后再利用Segment 的相应方法(对应HashMap里的方法)来读写数据,写操作会加锁。这样做的好处就是,如果有多个线程对ConcurrentHashMap 操作的时候,理想情况下如果key hash到的Segment 是不同的,那么写操作是可以并发执行的。当然像size(),isEmpty(),containsValue(Object value)这些操作涉及到跨Segment 的操作,需要一定的机制来保证,极端情况下需要锁定所有Segment 来做统计。

Segment 主要继承ReentrantLock,在Java5中,ReentrantLock的性能要远远高于内部锁。在Java6中,由于管理内部锁的算法采用了类似于 ReentrantLock使用的算法,因此内部锁和ReentrantLock之间的性能差别不大。
ReentrantLock的构造函数提供了两种公平性选择:创建非公平锁(默认)或者公平锁。在公平锁中,如果锁已被其它线程占有,那么请求线程会加入到等待队列中,并按顺序获得锁;在非公平锁中,当请求锁的时候,如果锁的状态是可用,那么请求线程可以直接获得锁,而不管等待队列中是否有线程已经在等待该锁。公平锁的代价是更多的挂起和重新开始线程的性能开销。在多数情况下,非公平锁的性能高于公平锁。Java内部锁也没有提供确定的公平性保证, Java语言规范也没有要求JVM公平地实现内部锁,因此ReentrantLock并没有减少锁的公平性。在中等或者更高负荷下,ReentrantLock有更好的性能,并且拥有可轮询和可定时的请求锁等高级功能。具体请参考:http://www.blogjava.net/killme2008/archive/2007/09/14/145195.html

有时间的话也研究下ReentrantLock的源码。

Segment源码分析:

  1. static final class Segment<K, V> extends ReentrantLock implements
  2. Serializable
  3. /**
  4. * Segment中元素个数
  5. */
  6. transient volatile int count;
  7. /**
  8. * 更新而导致表数目改变的次数。这是用来确保大量读方法看到一致的快照。
  9. * 如果在各个segments循环计算size或者检索containsValue时modCounts改变,那么将得到不一致的结果。
  10. * 通常这个必须重来。
  11. */
  12. transient int modCount;
  13. /**
  14. * table将会重新hash如果size超过threshold。这个值=(容量 * {@link #loadFactor})
  15. */
  16. transient int threshold;
  17. /**
  18. * 存放元素的table数组
  19. */
  20. transient volatile HashEntry<K, V>[] table;
  21. /**
  22. * 装载因子. 尽管这个值对所有的Segment是相同,但是它需要被复制,以避免外部对象对他的引用。
  23. *
  24. * @serial
  25. */
  26. final float loadFactor;
  27. Segment(int initialCapacity, float lf) {
  28. loadFactor = lf;
  29. setTable(HashEntry.<K, V> newArray(initialCapacity));
  30. }

可以看到真正存放数据的是HashEntry<K, V>[] table ,这里用到了自定义的class HashEntry ,因为ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间删除元素,读操作不加锁将得到不一致的数据。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,其结构如下所示:

  1. static final class HashEntry<K, V> {
  2. final K key;
  3. final int hash;
  4. volatile V value;
  5. final HashEntry<K, V> next;
  6. HashEntry(K key, int hash, HashEntry<K, V> next, V value) {
  7. this.key = key;
  8. this.hash = hash;
  9. this.next = next;
  10. this.value = value;
  11. }
  12. @SuppressWarnings("unchecked")
  13. static final <K, V> HashEntry<K, V>[] newArray(int i) {
  14. return new HashEntry[i];
  15. }
  16. }

可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部删除节点,因为这需要修改next引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。这在讲解删除操作时还会详述。为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。

  1. /**
  2. * 根据hash值返回适当取得的第一个entry Returns properly casted first entry of bin for
  3. * given hash.
  4. */
  5. HashEntry<K, V> getFirst(int hash) {
  6. HashEntry<K, V>[] tab = table;
  7. return tab[hash & (tab.length - 1)];
  8. }
  9. /**
  10. * 在带锁的情况下读取一个entry的值,如果该值曾经为null就会被调用。 只有在编译器碰巧切换这个HashEntry的初始化到table
  11. * assignment时,这个时候HashEntry没有初始化好,但是已经可被其他线程可见。 这个在内存模型下是合法 的。
  12. *
  13. * 感觉主要是与JIT相关,这句话的意思是说当你tab[index] = new HashEntry()的时候,可能发生partial
  14. * construct,
  15. * 也就是说其他的Thread可以看到这个reference,但是对象却没有构造完全。这是由JLS来决定的,因为JLS没有说
  16. * ,是否应该完成之后再赋值。 而现代的编译器(JIT)可以使用out of
  17. * ordering来调度指令,可能就把reference的赋值放在构造函数前边。
  18. * 这篇文章给除了很好的分析:http://www.ibm.com
  19. * /developerworks/java/library/j-dcl.html。 所以在这里为了避免partial
  20. * construct,当value是null的时候,也就是说可能发生partial construct的时候,需要lock()来保证,
  21. * 如果发生了partial construct,那么必然在构造HashEntry,这个HashEntry的构造是在lock()之下的,
  22. * 所以这里的lock就可以等待对象构造的完全。 Reads value field of an entry under lock.
  23. * Called if value field ever appears to be null. This is possible only
  24. * if a compiler happens to reorder a HashEntry initialization with its
  25. * table assignment, which is legal under memory model but is not known
  26. * to ever occur.
  27. */
  28. V readValueUnderLock(HashEntry<K, V> e) {
  29. lock();
  30. try {
  31. return e.value;
  32. } finally {
  33. unlock();
  34. }
  35. }
  36. /**
  37. * 为什么其他的final的field在partial construct的情况下,不会发生null value之类的错误? 文献中这么说:
  38. * A new guarantee of initialization safety should be provided. If an
  39. * object is properly constructed (which means that references to it do
  40. * not escape during construction), then all threads which see a
  41. * reference to that object will also see the values for its final
  42. * fields that were set in the constructor, without the need for
  43. * synchronization. 一个对像的值域在构造函数没有逃逸,那么将不会出现部分初始化的现象
  44. */
  45. V get(Object key, int hash) {
  46. if (count != 0) { // read-volatile
  47. HashEntry<K, V> e = getFirst(hash);
  48. while (e != null) {
  49. if (e.hash == hash && key.equals(e.key)) {
  50. V v = e.value;
  51. if (v != null)
  52. return v;
  53. return readValueUnderLock(e); // 这里需要再确认是否有发生部分构造而导致的null
  54. }
  55. e = e.next;
  56. }
  57. }
  58. return null;
  59. }

这里读取采用了加锁的方式,注释里也提到了原因。关于逃逸分析可以参考http://blog.csdn.net/ykdsg/archive/2011/03/17/6255618.aspx

其他的操作像:containsKey, containsValue,clear基本上也是采用get这样的循环方式。这里就不具体分析了。接下来看下put方法,因为涉及到扩容操作。

  1. V put(K key, int hash, V value, boolean onlyIfAbsent) {
  2. lock();
  3. try {
  4. int c = count;
  5. if (c++ > threshold) // 需要扩容
  6. rehash();
  7. HashEntry<K, V>[] tab = table;
  8. int index = hash & (tab.length - 1);
  9. HashEntry<K, V> first = tab[index];
  10. HashEntry<K, V> e = first;
  11. while (e != null && (e.hash != hash || !e.key.equals(key)))
  12. e = e.next;
  13. V oldValue;
  14. if (e != null) {// 如果e不等于null,说明键值重复
  15. oldValue = e.value;
  16. if (!onlyIfAbsent) {
  17. e.value = value;
  18. }
  19. } else {// 如果e为null说明需要增加一个
  20. oldValue = null;
  21. ++modCount;
  22. tab[index] = new HashEntry<K, V>(key, hash, first, value);
  23. count = c; // write-volatile
  24. }
  25. return oldValue;
  26. } finally {
  27. unlock();
  28. }
  29. }

其中关键的地方是rehash()方法和++modCount;这个操作,后面会讲到为什么要维护modCount(在改变元素个数的情况下++)。

  1. void rehash() {
  2. HashEntry<K, V>[] oldTable = table;
  3. int oldCapacity = oldTable.length;
  4. if (oldCapacity >= MAXIMUM_CAPACITY)
  5. return;
  6. // 扩容一倍
  7. HashEntry<K, V>[] newTable = HashEntry.newArray(oldCapacity << 1);
  8. threshold = (int) (newTable.length * loadFactor);
  9. int sizeMask = newTable.length - 1;
  10. for (int i = 0; i < oldCapacity; i++) {
  11. // 必须保证现有的map可以继续读,所以我们不能清空每个槽
  12. HashEntry<K, V> e = oldTable[i];
  13. if (e != null) {
  14. HashEntry<K, V> next = e.next;
  15. int idx = e.hash & sizeMask;
  16. // 如果只是单个节点
  17. if (next == null)
  18. newTable[idx] = e;
  19. else {
  20. HashEntry<K, V> lastRun = e;
  21. int lastIdx = idx;
  22. // 先把链表末端的节点移到新的槽位,然后把其余的节点克隆到新的槽位 ,rehash之后idx相同的元素
  23. // 这些元素相当于一个串一起移到新的槽位 ,例如:原来oldTable[i] 下有10个HashEntry,
  24. // 按照下面的算法,假设从第5个开始,这些HashEntry在newTable中计算的hash值都一样,那么只要移动第5位即可。
  25. for (HashEntry<K, V> last = next; last != null; last = last.next) {
  26. // 在新table中的hash值
  27. int k = last.hash & sizeMask;
  28. if (k != lastIdx) {
  29. lastIdx = k;
  30. lastRun = last;
  31. }
  32. }
  33. // 这里这样处理不知道是否有算法的保证,因为怎么确定newTable[lastIdx]在循环中一直没有值?
  34. newTable[lastIdx] = lastRun;
  35. // 复制剩下的所有HashEntry
  36. for (HashEntry<K, V> p = e; p != lastRun; p = p.next) {
  37. int k = p.hash & sizeMask;
  38. HashEntry<K, V> n = newTable[k];
  39. newTable[k] = new HashEntry<K, V>(p.key, p.hash, n,
  40. p.value);
  41. }
  42. }
  43. }
  44. }
  45. table = newTable;
  46. }

可以看到rehash()方法就是把table数组扩容一倍,再把原来的引用计算出在新数组的位置e.hash & sizeMask ,然后放过去,原来旧的table数组就交给垃圾回收。

  1. /**
  2. * Remove; match on key only if value null, else match both.
  3. */
  4. V remove(Object key, int hash, Object value) {
  5. lock();
  6. try {
  7. int c = count - 1;
  8. HashEntry<K, V>[] tab = table;
  9. int index = hash & (tab.length - 1);
  10. HashEntry<K, V> first = tab[index];
  11. HashEntry<K, V> e = first;
  12. while (e != null && (e.hash != hash || !key.equals(e.key)))
  13. e = e.next;
  14. V oldValue = null;
  15. if (e != null) {
  16. V v = e.value;
  17. if (value == null || value.equals(v)) {
  18. oldValue = v;
  19. // 被删除节点的后面列表可以保留,但是前面的需要复制
  20. ++modCount;
  21. HashEntry<K, V> newFirst = e.next;
  22. // 如果原来是1,2,3,4,5,6,7;要移除第4位,那么新的序列变成3,2,1,5,6,7
  23. for (HashEntry<K, V> p = first; p != e; p = p.next)
  24. newFirst = new HashEntry<K, V>(p.key, p.hash,
  25. newFirst, p.value);
  26. tab[index] = newFirst;
  27. count = c; // write-volatile
  28. }
  29. }
  30. return oldValue;
  31. } finally {
  32. unlock();
  33. }
  34. }

remove方法其实就是需要重新建立next链,所以需要复制。

基本上Segment特殊一点的方法就上面几个了。

接下来看ConcurrentHashMap是怎么使用的。因为ConcurrentHashMap做了二次hash,一些方法像entrySet()方法就要重写了。

[c-sharp] view plain copy print ?
  1. @Override
  2. public Set<java.util.Map.Entry<K, V>> entrySet() {
  3. Set<Map.Entry<K, V>> es = entrySet;
  4. return (es != null) ? es : (entrySet = new EntrySet());
  5. }
  6. final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
  7. public Iterator<Map.Entry<K,V>> iterator() {
  8. return new EntryIterator();
  9. }
  10. public boolean contains(Object o) {
  11. if (!(o instanceof Map.Entry))
  12. return false;
  13. Map.Entry<?,?> e = (Map.Entry<?,?>)o;
  14. V v = ConcurrentHashMap.this.get(e.getKey());
  15. return v != null && v.equals(e.getValue());
  16. }
  17. public boolean remove(Object o) {
  18. if (!(o instanceof Map.Entry))
  19. return false;
  20. Map.Entry<?,?> e = (Map.Entry<?,?>)o;
  21. return ConcurrentHashMap.this.remove(e.getKey(), e.getValue());
  22. }
  23. public int size() {
  24. return ConcurrentHashMap.this.size();
  25. }
  26. public void clear() {
  27. ConcurrentHashMap.this.clear();
  28. }
  29. }
  30. final class EntryIterator extends HashIterator implements
  31. Iterator<Entry<K, V>> {
  32. @Override
  33. public Map.Entry<K, V> next() {
  34. HashEntry<K, V> e = super.nextEntry();
  35. return new WriteThroughEntry(e.key, e.value);
  36. }
  37. }
  38. final class WriteThroughEntry extends AbstractMap.SimpleEntry<K, V> {
  39. WriteThroughEntry(K k, V v) {
  40. super(k, v);
  41. }
  42. /**
  43. * Set our entry's value and write through to the map. The value to
  44. * return is somewhat arbitrary here. Since a WriteThroughEntry does not
  45. * necessarily track asynchronous changes, the most recent "previous"
  46. * value could be different from what we return (or could even have been
  47. * removed in which case the put will re-establish). We do not and
  48. * cannot guarantee more.
  49. */
  50. @Override
  51. public V setValue(V value) {
  52. if (value == null)
  53. throw new NullPointerException();
  54. V v = super.setValue(value);
  55. MyConcurrentHashMap.this.put(getKey(), value);
  56. return v;
  57. }
  58. }
  59. /* ---------------- Iterator Support -------------- */
  60. abstract class HashIterator {
  61. int nextSegmentIndex;
  62. int nextTableIndex;
  63. HashEntry<K, V>[] currentTable;
  64. HashEntry<K, V> nextEntry;
  65. HashEntry<K, V> lastReturned;
  66. HashIterator() {
  67. nextSegmentIndex = segments.length - 1;
  68. nextTableIndex = -1;
  69. advance();
  70. }
  71. public boolean hasMoreElements() {
  72. return hasNext();
  73. }
  74. //循环segments 返回nextEntry
  75. final void advance() {
  76. if (nextEntry != null && (nextEntry = nextEntry.next) != null)
  77. return;
  78. while (nextTableIndex >= 0) {
  79. if ((nextEntry = currentTable[nextTableIndex--]) != null)
  80. return;
  81. }
  82. while (nextSegmentIndex >= 0) {
  83. Segment<K, V> seg = segments[nextSegmentIndex--];
  84. if (seg.count != 0) {
  85. currentTable = seg.table;
  86. for (int j = currentTable.length - 1; j >= 0; --j) {
  87. if ((nextEntry = currentTable[j]) != null) {
  88. nextTableIndex = j - 1;
  89. return;
  90. }
  91. }
  92. }
  93. }
  94. }
  95. public boolean hasNext() {
  96. return nextEntry != null;
  97. }
  98. HashEntry<K, V> nextEntry() {
  99. if (nextEntry == null)
  100. throw new NoSuchElementException();
  101. lastReturned = nextEntry;
  102. advance();
  103. return lastReturned;
  104. }
  105. public void remove() {
  106. if (lastReturned == null)
  107. throw new IllegalStateException();
  108. ConcurrentHashMap.this.remove(lastReturned.key);
  109. lastReturned = null;
  110. }
  111. }

其中HashIterator 的方法advance会循环Segment和其中的table数据,并分别记录下标,下次会在原来的下标继续循环。

构造函数

  1. public ConcurrentHashMap(int initialCapacity, float loadFactor,
  2. int concurrencyLevel) {
  3. if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
  4. throw new IllegalArgumentException();
  5. if (concurrencyLevel > MAX_SEGMENTS)
  6. concurrencyLevel = MAX_SEGMENTS;
  7. // Find power-of-two sizes best matching arguments
  8. int sshift = 0;
  9. //Segment数组长度
  10. int ssize = 1;
  11. while (ssize < concurrencyLevel) {
  12. ++sshift;
  13. ssize <<= 1;
  14. }
  15. segmentShift = 32 - sshift;
  16. segmentMask = ssize - 1;
  17. this.segments = Segment.newArray(ssize);
  18. if (initialCapacity > MAXIMUM_CAPACITY)
  19. initialCapacity = MAXIMUM_CAPACITY;
  20. int c = initialCapacity / ssize;
  21. if (c * ssize < initialCapacity)
  22. ++c;
  23. //每个Segment的初始容量
  24. int cap = 1;
  25. while (cap < c)
  26. cap <<= 1;
  27. for (int i = 0; i < this.segments.length; ++i)
  28. this.segments[i] = new Segment<K, V>(cap, loadFactor);
  29. }

就是初始化一些基本的值,初始化好Segment数组,接下来的几个构造函数都是调用这个,只是提供了一些初始值,默认的Segment数组长度是16,装载因子是0.75f,初始容量是16。因为构造好Map之后,Segment数组是不会扩容的,如果要放的数据比较多的话,传入比较大的concurrencyLevel 可以支持比较好的并发性。

现在看下get方法的实现

  1. @Override
  2. public V get(Object key) {
  3. int hash = hash(key.hashCode());
  4. return segmentFor(hash).get(key, hash);
  5. }
  6. /**
  7. * Returns the segment that should be used for key with given hash
  8. *
  9. * @param hash
  10. * the hash code for the key
  11. * @return the segment
  12. */
  13. final Segment<K, V> segmentFor(int hash) {
  14. return segments[(hash >>> segmentShift) & segmentMask];
  15. }

很简单,就是先根据hash找到对应的segment ,然后再调用segment 的get方法。其他的像put ,containsKey,replace 等这些值涉及到单个segment 的操作都是类似的。

下面看下涉及到跨段操作的几个方法

  1. public boolean isEmpty() {
  2. final Segment<K, V>[] segments = this.segments;
  3. /*
  4. * We keep track of per-segment modCounts to avoid ABA problems in which
  5. * an element in one segment was added and in another removed during
  6. * traversal, in which case the table was never actually empty at any
  7. * point. Note the similar use of modCounts in the size() and
  8. * containsValue() methods, which are the only other methods also
  9. * susceptible to ABA problems.
  10. */
  11. /**
  12. *保存每个segment
  13. * 的modCounts值,避免ABA问题在一个segment中一个元素被增加和一个元素在遍历的时候被删除。在这种情况下
  14. * ,table数组没有真正的 empty 在任何一个点。注意相似的在
  15. * size()方法和containsValue()方法,基本上只有这些方法比较容易受ABA问题的影响。
  16. */
  17. int[] mc = new int[segments.length];
  18. int mcsum = 0;
  19. for (int i = 0; i < segments.length; ++i) {
  20. if (segments[i].count != 0)
  21. return false;
  22. else
  23. mcsum += mc[i] = segments[i].modCount;
  24. }
  25. // If mcsum happens to be zero, then we know we got a snapshot
  26. // before any modifications at all were made. This is
  27. // probably common enough to bother tracking.
  28. /*
  29. * 如果mcsm==0,那么我们刚好得到一个在任何修改操作之前的快照。如果不是那可能就是常见的打扰跟踪。
  30. * 但貌似也不是百分百的,如果在if的时候mcsum是0的
  31. * ,但是这个时候线程被切开了,其他的线程增加或者删除了元素,这个时候再切回来,貌似就不准了
  32. * 不过就像上面说的这个就是个快照,在多线程的情况下不会那么精准,基本上的业务都应该可以忍受这样的进度,如果非要准确值,估计只有加锁了。
  33. */
  34. if (mcsum != 0) {
  35. // 如果mcsum不为0, 那么说明在统计的过程中有变化,需要重新统计
  36. for (int i = 0; i < segments.length; ++i) {
  37. //modCount有变化就说明不为empty
  38. if (segments[i].count != 0 || mc[i] != segments[i].modCount)
  39. return false;
  40. }
  41. }
  42. return true;
  43. }
  44. @Override
  45. public int size() {
  46. final Segment<K, V>[] segments = this.segments;
  47. long sum = 0;
  48. long check = 0;
  49. int[] mc = new int[segments.length];
  50. // Try a few times to get accurate count. On failure due to
  51. // continuous async changes in table, resort to locking.
  52. for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
  53. check = 0;
  54. sum = 0;
  55. int mcsum = 0;
  56. for (int i = 0; i < segments.length; ++i) {
  57. sum += segments[i].count;
  58. mcsum += mc[i] = segments[i].modCount;
  59. }
  60. if (mcsum != 0) {
  61. for (int i = 0; i < segments.length; ++i) {
  62. check += segments[i].count;
  63. if (mc[i] != segments[i].modCount) {
  64. // 如果这个时候segments有过数据的改变,那么强制从新统计
  65. check = -1; // force retry
  66. break;
  67. }
  68. }
  69. }
  70. if (check == sum)
  71. break;
  72. }
  73. // 加锁统计
  74. if (check != sum) { // Resort to locking all segments
  75. sum = 0;
  76. for (int i = 0; i < segments.length; ++i)
  77. segments[i].lock();
  78. for (int i = 0; i < segments.length; ++i)
  79. sum += segments[i].count;
  80. for (int i = 0; i < segments.length; ++i)
  81. segments[i].unlock();
  82. }
  83. if (sum > Integer.MAX_VALUE)
  84. return Integer.MAX_VALUE;
  85. else
  86. return (int) sum;
  87. }

可以看到跨Segment的操作中,先是是不锁表的,但是在多线程的情况下,就会造成数据的不一致,这里就用到了Segment中的modCount来做比较,如果modCount有变化就说明被其他线程污染了,就需要重新做统计,这个时候也是不带锁的。但是这样的循环不可能无限进行下去,所以做了限制,在不带锁的情况下允许进行2次尝试,如果还是受到其他线程的污染,那就要加锁统计了。注意要顺序的加锁再顺序的解锁,不然可能会出现死锁。containsValue的是实现与size相似。

本文基于java6 的源码分析,对一些英文的翻译不一定很到位,一些理解上也存在偏颇,欢迎大家指正。


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值