内容简介:我们上述所讲的Map都是非线程安全的,这意味着不应该在多个线程中对这些Map进行修改操作,轻则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环(插入会触发扩容,而扩容操作需要将原数组中的元素rehash到新数组,这时并发操作就有可能产生链表的循环引用从而成环),这样在查找时就会发生死循环,影响到整个应用程序。
我们上述所讲的Map都是非线程安全的,这意味着不应该在多个线程中对这些Map进行修改操作,轻则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环(插入会触发扩容,而扩容操作需要将原数组中的元素rehash到新数组,这时并发操作就有可能产生链表的循环引用从而成环),这样在查找时就会发生死循环,影响到整个应用程序。
Collections.synchronizedMap(Map<K,V> m)
可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map实现,而且包装类是基于 synchronized
关键字来保证线程安全的(时代的眼泪Hashtable也是基于 synchronized
关键字),底层使用的是互斥锁(同一时间内只能由持有锁的线程访问,其他竞争线程进入睡眠状态),性能与吞吐量差强人意。
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { return new SynchronizedMap<>(m); } private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable { private static final long serialVersionUID = 1978198479659022715L; private final Map<K,V> m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map<K,V> m) { this.m = Objects.requireNonNull(m); mutex = this; } SynchronizedMap(Map<K,V> m, Object mutex) { this.m = m; this.mutex = mutex; } public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } ............ }
然而ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而 导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
在 Java 7中,ConcurrentHashMap把内部细分成了若干个小的HashMap,称之为段(Segment),默认被分为16个段。对于一个写操作而言,会先根据hash code进行寻址,得出该Entry应被存放在哪一个Segment,然后只要对该Segment加锁即可。
理想情况下,一个默认的ConcurrentHashMap可以同时接受16个线程进行写操作(如果都是对不同Segment进行操作的话)。
分段锁对于 size()
这样的全局操作来说就没有任何作用了,想要得出Entry的数量就需要遍历所有Segment,获得所有的锁,然后再统计总数。事实上,ConcurrentHashMap会先试图使用无锁的方式统计总数,这个尝试会进行3次,如果在相邻的2次计算中获得的Segment的modCount次数一致,代表这两次计算过程中都没有发生过修改操作,那么就可以当做最终结果返回,否则,就要获得所有Segment的锁,重新计算size。
本文主要讨论的是Java 8的ConcurrentHashMap,它与Java 7的实现差别较大。完全放弃了段的设计,而是变回与HashMap相似的设计,使用buckets数组与分离链接法(同样会在超过阈值时树化,对于构造红黑树的逻辑与HashMap差别不大,只不过需要额外使用CAS来保证线程安全),锁的粒度也被细分到每个数组元素(个人认为这样做的原因是因为HashMap在Java 8中也实现了不少优化,即使碰撞严重,也能保证一定的性能,而且Segment不仅臃肿还有弱一致性的问题存在),所以它的并发级别与数组长度相关(Java 7则是与段数相关)。
/** * The array of bins. Lazily initialized upon first insertion. * Size is always a power of two. Accessed directly by iterators. */ transient volatile Node<K,V>[] table;
寻址
ConcurrentHashMap的散列函数与HashMap并没有什么区别,同样是把key的hash code的高16位与低16位进行异或运算(因为ConcurrentHashMap的buckets数组长度也永远是一个2的N次方),然后将扰乱后的hash code与数组的长度减一(实际可访问到的最大索引)进行与运算,得出的结果即是目标所在的位置。
// 2^31 - 1,int类型的最大值 // 该掩码表示节点hash的可用位,用来保证hash永远为一个正整数 static final int HASH_BITS = 0x7fffffff; static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }
下面是查找操作的源码,实现比较简单。
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { // 先尝试判断链表头是否为目标,如果是就直接返回 if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) // eh < 0代表这是一个特殊节点(TreeBin或ForwardingNode) // 所以直接调用find()进行遍历查找 return (p = e.find(h, key)) != null ? p.val : null; // 遍历链表 while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }
一个普通的节点(链表节点)的hash不可能小于0(已经在 spread()
函数中修正过了),所以小于0的只可能是一个特殊节点,它不能用while循环中遍历链表的方式来进行遍历。
TreeBin是红黑树的头部节点(红黑树的节点为TreeNode),它本身不含有key与value,而是指向一个TreeNode节点的链表与它们的根节点,同时使用CAS(ConcurrentHashMap并不是完全基于互斥锁实现的,而是与CAS这种乐观策略搭配使用,以提高性能)实现了一个读写锁,迫使Writer(持有这个锁)在树 重构 操作之前等待Reader完成。
ForwardingNode是一个在数据转移过程(由扩容引起)中使用的临时节点,它会被插入到头部。它与TreeBin(和TreeNode)都是Node类的子类。
为了判断出哪些是特殊节点,TreeBin和ForwardingNode的hash域都只是一个虚拟值:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; Node(int hash, K key, V val, Node<K,V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; } public final V setValue(V value) { throw new UnsupportedOperationException(); } ...... /** * Virtualized support for map.get(); overridden in subclasses. */ Node<K,V> find(int h, Object k) { Node<K,V> e = this; if (k != null) { do { K ek; if (e.hash == h && ((ek = e.key) == k || (ek != null && k.equals(ek)))) return e; } while ((e = e.next) != null); } return null; } } /* * Encodings for Node hash fields. See above for explanation. */ static final int MOVED = -1; // hash for forwarding nodes static final int TREEBIN = -2; // hash for roots of trees static final int RESERVED = -3; // hash for transient reservations static final class TreeBin<K,V> extends Node<K,V> { .... TreeBin(TreeNode<K,V> b) { super(TREEBIN, null, null, null); .... } .... } static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; ForwardingNode(Node<K,V>[] tab) { super(MOVED, null, null, null); this.nextTable = tab; } ..... }
可见性
我们在 get()
函数中并没有发现任何与锁相关的代码,那么它是怎么保证线程安全的呢?一个操作 ConcurrentHashMap.get("a")
,它的步骤基本分为以下几步:
- 根据散列函数计算出的索引访问table。
- 从table中取出头节点。
- 遍历头节点直到找到目标节点。
- 从目标节点中取出value并返回。
所以只要保证访问table与节点的操作总是能够返回最新的数据就可以了。ConcurrentHashMap并没有采用锁的方式,而是通过 volatile
关键字来保证它们的可见性。在上文贴出的代码中可以发现,table、Node.val和Node.next都是被 volatile
关键字所修饰的。
volatile
关键字保证了多线程环境下变量的可见性与有序性,底层实现基于内存屏障(Memory Barrier)。
为了优化性能,现代CPU工作时的指令执行顺序与应用程序的代码顺序其实是不一致的(有些编译器也会进行这种优化),也就是所谓的乱序执行技术。乱序执行可以提高CPU流水线的工作效率,只要保证数据符合程序逻辑上的正确性即可(遵循 happens-before
原则)。不过如今是多核时代,如果随便乱序而不提供防护措施那是会出问题的。每一个cpu上都会进行乱序优化,单cpu所保证的逻辑次序可能会被其他cpu所破坏。
内存屏障就是针对此情况的防护措施。可以认为它是一个同步点(但它本身也是一条cpu指令)。例如在 IA32
指令集架构中引入的 SFENCE
指令,在该指令之前的所有写操作必须全部完成,读操作仍可以乱序执行。 LFENCE
指令则保证之前的所有读操作必须全部完成,另外还有粒度更粗的 MFENCE
指令保证之前的所有读写操作都必须全部完成。
内存屏障就像是一个保护指令顺序的栅栏,保护后面的指令不被前面的指令跨越。将内存屏障插入到写操作与读操作之间,就可以保证之后的读操作可以访问到最新的数据,因为屏障前的写操作已经把数据写回到内存(根据缓存一致性协议,不会直接写回到内存,而是改变该cpu私有缓存中的状态,然后通知给其他cpu这个缓存行已经被修改过了,之后另一个cpu在读操作时就可以发现该缓存行已经是无效的了,这时它会从其他cpu中读取最新的缓存行,然后之前的cpu才会更改状态并写回到内存)。
例如,读一个被 volatile
修饰的变量V总是能够从JMM(Java Memory Model)主内存中获得最新的数据。因为内存屏障的原因,每次在使用变量V(通过JVM指令 use
,后面说的也都是JVM中的指令而不是cpu)之前都必须先执行 load
指令(把从主内存中得到的数据放入到工作内存),根据JVM的规定, load
指令必须发生在 read
指令(从主内存中读取数据)之后,所以每次访问变量V都会先从主内存中读取。相对的,写操作也因为内存屏障保证的指令顺序,每次都会直接写回到主内存。
不过 volatile
关键字并不能保证操作的原子性,对该变量进行并发的连续操作是非线程安全的,所幸ConcurrentHashMap只是用来确保访问到的变量是最新的,所以也不会发生什么问题。
出于性能考虑,Doug Lea( java.util.concurrent
包的作者)直接通过Unsafe类来对table进行操作。
Java号称是安全的编程语言,而保证安全的代价就是牺牲 程序员 自由操控内存的能力。像在C/C++中可以通过操作指针变量达到操作内存的目的(其实操作的是虚拟地址),但这种灵活性在新手手中也经常会带来一些愚蠢的错误,比如内存访问越界。
Unsafe从字面意思可以看出是不安全的,它包含了许多本地方法(在JVM平台上运行的其他语言编写的程序,主要为C/C++,由 JNI
实现),这些方法支持了对指针的操作,所以它才被称为是不安全的。虽然不安全,但毕竟是由C/C++实现的,像一些与操作系统交互的操作肯定是快过Java的,毕竟Java与操作系统之间还隔了一层抽象(JVM),不过代价就是失去了JVM所带来的多平台可移植性(本质上也只是一个c/cpp文件,如果换了平台那就要重新编译)。
对table进行操作的函数有以下三个,都使用到了Unsafe(在 java.util.concurrent
包随处可见):
@SuppressWarnings("unchecked") static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) { // 从tab数组中获取一个引用,遵循Volatile语义 // 参数2是一个在tab中的偏移量,用来寻找目标对象 return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE); } static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { // 通过CAS操作将tab数组中位于参数2偏移量位置的值替换为v // c是期望值,如果期望值与实际值不符,返回false // 否则,v会成功地被设置到目标位置,返回true return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); } static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) { // 设置tab数组中位于参数2偏移量位置的值,遵循Volatile语义 U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v); }
如果对Unsafe感兴趣,可以参考这篇文章: Java Magic. Part 4: sun.misc.Unsafe
初始化
ConcurrentHashMap与HashMap一样是Lazy的,buckets数组会在第一次访问 put()
函数时进行初始化,它的默认构造函数甚至是个空函数。
/** * Creates a new, empty map with the default initial table size (16). */ public ConcurrentHashMap() { }
但是有一点需要注意,ConcurrentHashMap是工作在多线程并发环境下的,如果有多个线程同时调用了 put()
函数该怎么办?这会导致重复初始化,所以必须要有对应的防护措施。
ConcurrentHashMap声明了一个用于控制table的初始化与扩容的实例变量sizeCtl,默认值为0。当它是一个负数的时候,代表table正处于初始化或者扩容的状态。 -1
表示table正在进行初始化, -N
则表示当前有N-1个线程正在进行扩容。
在其他情况下,如果table还未初始化( table == null
),sizeCtl表示table进行初始化的数组大小(所以从构造函数传入的initialCapacity在经过计算后会被赋给它)。如果table已经初始化过了,则表示下次触发扩容操作的阈值,算法 stzeCtl = n - (n >>> 2)
,也就是n的75%,与默认负载因子(0.75)的HashMap一致。
private transient volatile int sizeCtl;
初始化table的操作位于函数 initTable()
,源码如下:
/** * Initializes table, using the size recorded in sizeCtl. */ private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // sizeCtl小于0,这意味着已经有其他线程进行初始化了 // 所以当前线程让出CPU时间片 if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin // 否则,通过CAS操作尝试修改sizeCtl else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { // 默认构造函数,sizeCtl = 0,使用默认容量(16)进行初始化 // 否则,会根据sizeCtl进行初始化 int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; // 计算阈值,n的75% sc = n - (n >>> 2); } } finally { // 阈值赋给sizeCtl sizeCtl = sc; } break; } } return tab; }
sizeCtl是一个 volatile
变量,只要有一个线程CAS操作成功,sizeCtl就会被暂时地修改为-1,这样其他线程就能够根据sizeCtl得知table是否已经处于初始化状态中,最后sizeCtl会被设置成阈值,用于触发扩容操作。
扩容
ConcurrentHashMap触发扩容的时机与HashMap类似,要么是在将链表转换成红黑树时判断table数组的长度是否小于阈值(64),如果小于就进行扩容而不是树化,要么就是在添加元素的时候,判断当前Entry数量是否超过阈值,如果超过就进行扩容。
private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { // 小于MIN_TREEIFY_CAPACITY,进行扩容 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) tryPresize(n << 1); else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { synchronized (b) { // 将链表转换成红黑树... } } } } ... final V putVal(K key, V value, boolean onlyIfAbsent) { ... addCount(1L, binCount); // 计数 return null; } private final void addCount(long x, int check) { // 计数... if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; // s(元素个数)大于等于sizeCtl,触发扩容 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { // 扩容标志位 int rs = resizeStamp(n); // sizeCtl为负数,代表正有其他线程进行扩容 if (sc < 0) { // 扩容已经结束,中断循环 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; // 进行扩容,并设置sizeCtl,表示扩容线程 + 1 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } // 触发扩容(第一个进行扩容的线程) // 并设置sizeCtl告知其他线程 else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); // 统计个数,用于循环检测是否还需要扩容 s = sumCount(); } } }
可以看到有关sizeCtl的操作牵涉到了大量的位运算,我们先来理解这些位运算的意义。首先是 resizeStamp()
,该函数返回一个用于数据校验的标志位,意思是对长度为n的table进行扩容。它将n的前导零(最高有效位之前的零的数量)和 1 << 15
做或运算,这时低16位的最高位为1,其他都为n的前导零。
static final int resizeStamp(int n) { // RESIZE_STAMP_BITS = 16 return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); }
初始化sizeCtl(扩容操作被第一个线程首次进行)的算法为 (rs << RESIZE_STAMP_SHIFT) + 2
,首先 RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS = 16
,那么 rs << 16
等于将这个标志位移动到了高16位,这时最高位为1,所以sizeCtl此时是个负数,然后加二(至于为什么是2,还记得有关sizeCtl的说明吗?1代表初始化状态,所以实际的线程个数是要减去1的)代表当前有一个线程正在进行扩容,
这样sizeCtl就被分割成了两部分,高16位是一个对n的数据校验的标志位,低16位表示参与扩容操作的线程个数 + 1。
可能会有读者有所疑惑,更新进行扩容的线程数量的操作为什么是 sc + 1
而不是 sc - 1
,这是因为对sizeCtl的操作都是基于位运算的,所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而 sc + 1
会在低16位上加1。
tryPresize()
函数跟 addCount()
的后半段逻辑类似,不断地根据sizeCtl判断当前的状态,然后选择对应的策略。
private final void tryPresize(int size) { // 对size进行修正 int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1); int sc; // sizeCtl是默认值或正整数 // 代表table还未初始化 // 或还没有其他线程正在进行扩容 while ((sc = sizeCtl) >= 0) { Node<K,V>[] tab = table; int n; if (tab == null || (n = tab.length) == 0) { n = (sc > c) ? sc : c; // 设置sizeCtl,告诉其他线程,table现在正处于初始化状态 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 sizeCtl = sc; } } } // 没有超过阈值或者大于容量的上限,中断循环 else if (c <= sc || n >= MAXIMUM_CAPACITY) break; // 进行扩容,与addCount()后半段的逻辑一致 else if (tab == table) { int rs = resizeStamp(n); 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; 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); } } }
扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下是行不通的,需要保证线程安全性,在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?有人可能会说,这不难啊,用一个互斥锁把数据转移操作的过程锁住不就好了?这确实是一种可行的解决方法,但同样也会带来极差的吞吐量。
互斥锁会导致所有访问临界区的线程陷入阻塞状态,这会消耗额外的系统资源,内核需要保存这些线程的上下文并放到阻塞队列,持有锁的线程耗时越长,其他竞争线程就会一直被阻塞,因此吞吐量低下,导致响应时间缓慢。而且锁总是会伴随着死锁问题,一旦发生死锁,整个应用程序都会因此受到影响,所以加锁永远是最后的备选方案。
Doug Lea没有选择直接加锁,而是基于CAS实现无锁的并发同步策略,令人佩服的是他不仅没有把其他线程拒之门外,甚至还邀请它们一起来协助工作。
那么如何才能让多个线程协同工作呢?Doug Lea把整个table数组当做多个线程之间共享的任务队列,然后只需维护一个指针,当有一个线程开始进行数据转移,就会先移动指针,表示指针划过的这片bucket区域由该线程负责。
这个指针被声明为一个 volatile
整型变量,它的初始位置位于table的尾部,即它等于 table.length
,很明显这个任务队列是逆向遍历的。
/** * The next table index (plus one) to split while resizing. */ private transient volatile int transferIndex; /** * 一个线程需要负责的最小bucket数 */ private static final int MIN_TRANSFER_STRIDE = 16; /** * The next table to use; non-null only while resizing. */ private transient volatile Node<K,V>[] nextTable;
一个已经迁移完毕的bucket会被替换成ForwardingNode节点,用来标记此bucket已经被其他线程迁移完毕了。我们之前提到过ForwardingNode,它是一个特殊节点,可以通过hash域的虚拟值来识别它,它同样重写了 find()
函数,用来在新数组中查找目标。
数据迁移的操作位于 transfer()
函数,多个线程之间依靠sizeCtl与transferIndex指针来协同工作,每个线程都有自己负责的区域,一个完成迁移的bucket会被设置为ForwardingNode,其他线程遇见这个特殊节点就跳过该bucket,处理下一个bucket。
transfer()
函数可以大致分为三部分,第一部分对后续需要使用的变量进行初始化:
/** * Moves and/or copies the nodes in each bin to new table. See * above for explanation. */ private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; // 根据当前机器的CPU数量来决定每个线程负责的bucket数 // 避免因为扩容线程过多,反而影响到性能 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // subdivide range // 初始化nextTab,容量为旧数组的一倍 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; // 初始化指针 } int nextn = nextTab.length; ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); boolean advance = true; boolean finishing = false; // to ensure sweep before committing nextTab
第二部分为当前线程分配任务和控制当前线程的任务进度,这部分是 transfer()
的核心逻辑,描述了如何与其他线程协同工作:
// i指向当前bucket,bound表示当前线程所负责的bucket区域的边界 for (int i = 0, bound = 0;;) { Node<K,V> f; int fh; // 这个循环使用CAS不断尝试为当前线程分配任务 // 直到分配成功或任务队列已经被全部分配完毕 // 如果当前线程已经被分配过bucket区域 // 那么会通过--i指向下一个待处理bucket然后退出该循环 while (advance) { int nextIndex, nextBound; // --i表示将i指向下一个待处理的bucket // 如果--i >= bound,代表当前线程已经分配过bucket区域 // 并且还留有未处理的bucket if (--i >= bound || finishing) advance = false; // transferIndex指针 <= 0 表示所有bucket已经被分配完毕 else if ((nextIndex = transferIndex) <= 0) { i = -1; advance = false; } // 移动transferIndex指针 // 为当前线程设置所负责的bucket区域的范围 // i指向该范围的第一个bucket,注意i是逆向遍历的 // 这个范围为(bound, i),i是该区域最后一个bucket,遍历顺序是逆向的 else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { bound = nextBound; i = nextIndex - 1; advance = false; } } // 当前线程已经处理完了所负责的所有bucket if (i < 0 || i >= n || i + n >= nextn) { int sc; // 如果任务队列已经全部完成 if (finishing) { nextTable = null; table = nextTab; // 设置新的阈值 sizeCtl = (n << 1) - (n >>> 1); return; } // 工作中的扩容线程数量减1 if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // (resizeStamp << RESIZE_STAMP_SHIFT) + 2代表当前有一个扩容线程 // 相对的,(sc - 2) != resizeStamp << RESIZE_STAMP_SHIFT // 表示当前还有其他线程正在进行扩容,所以直接返回 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) return; // 否则,当前线程就是最后一个进行扩容的线程 // 设置finishing标识 finishing = advance = true; i = n; // recheck before commit } } // 如果待处理bucket是空的 // 那么插入ForwardingNode,以通知其他线程 else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd); // 如果待处理bucket的头节点是ForwardingNode // 说明此bucket已经被处理过了,跳过该bucket else if ((fh = f.hash) == MOVED) advance = true; // already processed
最后一部分是具体的迁移过程(对当前指向的bucket),这部分的逻辑与HashMap类似,拿旧数组的容量当做一个掩码,然后与节点的hash进行与操作,可以得出该节点的新增有效位,如果新增有效位为0就放入一个链表A,如果为1就放入另一个链表B,链表A在新数组中的位置不变(跟在旧数组的索引一致),链表B在新数组中的位置为原索引加上旧数组容量。
这个方法减少了rehash的计算量,而且还能达到均匀分布的目的,如果不能理解请去看本文中HashMap扩容操作的解释。
else { // 对于节点的操作还是要加上锁的 // 不过这个锁的粒度很小,只锁住了bucket的头节点 synchronized (f) { if (tabAt(tab, i) == f) { Node<K,V> ln, hn; // hash code不为负,代表这是条链表 if (fh >= 0) { // fh & n 获得hash code的新增有效位,用于将链表分离成两类 // 要么是0要么是1,关于这个位运算的更多细节 // 请看本文中有关HashMap扩容操作的解释 int runBit = fh & n; Node<K,V> lastRun = f; // 这个循环用于记录最后一段连续的同一类节点 // 这个类别是通过fh & n来区分的 // 这段连续的同类节点直接被复用,不会产生额外的复制 for (Node<K,V> p = f.next; p != null; p = p.next) { int b = p.hash & n; if (b != runBit) { runBit = b; lastRun = p; } } // 0被放入ln链表,1被放入hn链表 // lastRun是连续同类节点的起始节点 if (runBit == 0) { ln = lastRun; hn = null; } else { hn = lastRun; ln = null; } // 将最后一段的连续同类节点之前的节点按类别复制到ln或hn // 链表的插入方向是往头部插入的,Node构造函数的第四个参数是next // 所以就算遇到类别与lastRun一致的节点也只会被插入到头部 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); } // ln链表被放入到原索引位置,hn放入到原索引 + 旧数组容量 // 这一点与HashMap一致,如果看不懂请去参考本文对HashMap扩容的讲解 setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); // 标记该bucket已被处理 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; } } // 元素数量没有超过UNTREEIFY_THRESHOLD,退化成链表 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; }
计数
在Java 7中ConcurrentHashMap对每个Segment单独计数,想要得到总数就需要获得所有Segment的锁,然后进行统计。由于Java 8抛弃了Segment,显然是不能再这样做了,而且这种方法虽然简单准确但也舍弃了性能。
Java 8声明了一个 volatile
变量baseCount用于记录元素的个数,对这个变量的修改操作是基于CAS的,每当插入元素或删除元素时都会调用 addCount()
函数进行计数。
private transient volatile long baseCount; private final void addCount(long x, int check) { CounterCell[] as; long b, s; // 尝试使用CAS更新baseCount失败 // 转用CounterCells进行更新 if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; boolean uncontended = true; // 在CounterCells未初始化 // 或尝试通过CAS更新当前线程的CounterCell失败时 // 调用fullAddCount(),该函数负责初始化CounterCells和更新计数 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) { // 判断是否需要扩容,在上文中已经讲过了 } }
counterCells是一个元素为CounterCell的数组,该数组的大小与当前机器的CPU数量有关,并且它不会被主动初始化,只有在调用 fullAddCount()
函数时才会进行初始化。
CounterCell是一个简单的内部静态类,每个CounterCell都是一个用于记录数量的单元:
/** * Table of counter cells. When non-null, size is a power of 2. */ private transient volatile CounterCell[] counterCells; /** * A padded cell for distributing counts. Adapted from LongAdder * and Striped64. See their internal docs for explanation. */ @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } }
注解 @sun.misc.Contended
用于解决伪共享问题。所谓伪共享,即是在同一缓存行(CPU缓存的基本单位)中存储了多个变量,当其中一个变量被修改时,就会影响到同一缓存行内的其他变量,导致它们也要跟着被标记为失效,其他变量的缓存命中率将会受到影响。解决伪共享问题的方法一般是对该变量填充一些无意义的占位数据,从而使它独享一个缓存行。
ConcurrentHashMap的计数设计与LongAdder类似。在一个低并发的情况下,就只是简单地使用CAS操作来对baseCount进行更新,但只要这个CAS操作失败一次,就代表有多个线程正在竞争,那么就转而使用CounterCell数组进行计数,数组内的每个ConuterCell都是一个独立的计数单元。
每个线程都会通过 ThreadLocalRandom.getProbe() & m
寻址找到属于它的CounterCell,然后进行计数。ThreadLocalRandom是一个线程私有的伪随机数生成器,每个线程的probe都是不同的(这点基于ThreadLocalRandom的内部实现,它在内部维护了一个probeGenerator,这是一个类型为AtomicInteger的静态常量,每当初始化一个ThreadLocalRandom时probeGenerator都会先自增一个常量然后返回的整数即为当前线程的probe,probe变量被维护在Thread对象中),可以认为每个线程的probe就是它在CounterCell数组中的hash code。
这种方法将竞争数据按照线程的粒度进行分离,相比所有竞争线程对一个共享变量使用CAS不断尝试在性能上要效率多了,这也是为什么在高并发环境下LongAdder要优于AtomicInteger的原因。
fullAddCount()
函数根据当前线程的probe寻找对应的CounterCell进行计数,如果CounterCell数组未被初始化,则初始化CounterCell数组和CounterCell。该函数的实现与Striped64类(LongAdder的父类)的 longAccumulate()
函数是一样的,把CounterCell数组当成一个散列表,每个线程的probe就是hash code,散列函数也仅仅是简单的 (n - 1) & probe
。
CounterCell数组的大小永远是一个2的n次方,初始容量为2,每次扩容的新容量都是之前容量乘以二,处于性能考虑,它的最大容量上限是机器的CPU数量。
所以说CounterCell数组的碰撞冲突是很严重的,因为它的bucket基数太小了。而发生碰撞就代表着一个CounterCell会被多个线程竞争,为了解决这个问题,Doug Lea使用无限循环加上CAS来模拟出一个自旋锁来保证线程安全,自旋锁的实现基于一个被 volatile
修饰的整数变量,该变量只会有两种状态:0和1,当它被设置为0时表示没有加锁,当它被设置为1时表示已被其他线程加锁。这个自旋锁用于保护初始化CounterCell、初始化CounterCell数组以及对CounterCell数组进行扩容时的安全。
CounterCell更新计数是依赖于CAS的,每次循环都会尝试通过CAS进行更新,如果成功就退出无限循环,否则就调用 ThreadLocalRandom.advanceProbe()
函数为当前线程更新probe,然后重新开始循环,以期望下一次寻址到的CounterCell没有被其他线程竞争。
如果连着两次CAS更新都没有成功,那么会对CounterCell数组进行一次扩容,这个扩容操作只会在当前循环中触发一次,而且只能在容量小于上限时触发。
fullAddCount()
函数的主要流程如下:
- 首先检查当前线程有没有初始化过ThreadLocalRandom,如果没有则进行初始化。ThreadLocalRandom负责更新线程的probe,而probe又是在数组中进行寻址的关键。
- 检查CounterCell数组是否已经初始化,如果已初始化,那么就根据probe找到对应的CounterCell。
- 如果这个CounterCell等于null,需要先初始化CounterCell,通过把计数增量传入构造函数,所以初始化只要成功就说明更新计数已经完成了。初始化的过程需要获取自旋锁。
- 如果不为null,就按上文所说的逻辑对CounterCell实施更新计数。
- CounterCell数组未被初始化,尝试获取自旋锁,进行初始化。数组初始化的过程会附带初始化一个CounterCell来记录计数增量,所以只要初始化成功就表示更新计数完成。
- 如果自旋锁被其他线程占用,无法进行数组的初始化,只好通过CAS更新baseCount。
private final void fullAddCount(long x, boolean wasUncontended) { int h; // 当前线程的probe等于0,证明该线程的ThreadLocalRandom还未被初始化 // 以及当前线程是第一次进入该函数 if ((h = ThreadLocalRandom.getProbe()) == 0) { // 初始化ThreadLocalRandom,当前线程会被设置一个probe ThreadLocalRandom.localInit(); // force initialization // probe用于在CounterCell数组中寻址 h = ThreadLocalRandom.getProbe(); // 未竞争标志 wasUncontended = true; } // 冲突标志 boolean collide = false; // True if last slot nonempty for (;;) { CounterCell[] as; CounterCell a; int n; long v; // CounterCell数组已初始化 if ((as = counterCells) != null && (n = as.length) > 0) { // 如果寻址到的Cell为空,那么创建一个新的Cell if ((a = as[(n - 1) & h]) == null) { // cellsBusy是一个只有0和1两个状态的volatile整数 // 它被当做一个自旋锁,0代表无锁,1代表加锁 if (cellsBusy == 0) { // Try to attach new Cell // 将传入的x作为初始值创建一个新的CounterCell CounterCell r = new CounterCell(x); // Optimistic create // 通过CAS尝试对自旋锁加锁 if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { // 加锁成功,声明Cell是否创建成功的标志 boolean created = false; try { // Recheck under lock CounterCell[] rs; int m, j; // 再次检查CounterCell数组是否不为空 // 并且寻址到的Cell为空 if ((rs = counterCells) != null && (m = rs.length) > 0 && rs[j = (m - 1) & h] == null) { // 将之前创建的新Cell放入数组 rs[j] = r; created = true; } } finally { // 释放锁 cellsBusy = 0; } // 如果已经创建成功,中断循环 // 因为新Cell的初始值就是传入的增量,所以计数已经完毕了 if (created) break; // 如果未成功 // 代表as[(n - 1) & h]这个位置的Cell已经被其他线程设置 // 那么就从循环头重新开始 continue; // Slot is now non-empty } } collide = false; } // as[(n - 1) & h]非空 // 在addCount()函数中通过CAS更新当前线程的Cell进行计数失败 // 会传入wasUncontended = false,代表已经有其他线程进行竞争 else if (!wasUncontended) // CAS already known to fail // 设置未竞争标志,之后会重新计算probe,然后重新执行循环 wasUncontended = true; // Continue after rehash // 尝试进行计数,如果成功,那么就退出循环 else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) break; // 尝试更新失败,检查counterCell数组是否已经扩容 // 或者容量达到最大值(CPU的数量) else if (counterCells != as || n >= NCPU) // 设置冲突标志,防止跳入下面的扩容分支 // 之后会重新计算probe collide = false; // At max size or stale // 设置冲突标志,重新执行循环 // 如果下次循环执行到该分支,并且冲突标志仍然为true // 那么会跳过该分支,到下一个分支进行扩容 else if (!collide) collide = true; // 尝试加锁,然后对counterCells数组进行扩容 else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { try { // 检查是否已被扩容 if (counterCells == as) {// Expand table unless stale // 新数组容量为之前的1倍 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 } // 为当前线程重新计算probe h = ThreadLocalRandom.advanceProbe(h); } // CounterCell数组未初始化,尝试获取自旋锁,然后进行初始化 else if (cellsBusy == 0 && counterCells == as && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { boolean init = false; try { // Initialize table if (counterCells == as) { // 初始化CounterCell数组,初始容量为2 CounterCell[] rs = new CounterCell[2]; // 初始化CounterCell rs[h & 1] = new CounterCell(x); counterCells = rs; init = true; } } finally { cellsBusy = 0; } // 初始化CounterCell数组成功,退出循环 if (init) break; } // 如果自旋锁被占用,则只好尝试更新baseCount else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x)) break; // Fall back on using base } }
对于统计总数,只要能够理解CounterCell的思想,就很简单了。仔细想一想,每次计数的更新都会被分摊在baseCount和CounterCell数组中的某一CounterCell,想要获得总数,把它们统计相加就是了。
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }
其实 size()
函数返回的总数可能并不是百分百精确的,试想如果前一个遍历过的CounterCell又进行了更新会怎么样?尽管只是一个估算值,但在大多数场景下都还能接受,而且性能上是要比Java 7好上太多了。
添加元素
添加元素的主要逻辑与HashMap没什么区别,有所区别的复杂操作如扩容和计数我们上文都已经深入解析过了,所以整体来说 putVal()
函数还是比较简单的,可能唯一需要注意的就是在对节点进行操作的时候需要通过互斥锁保证线程安全,这个互斥锁的粒度很小,只对需要操作的这个bucket加锁。
public V put(K key, V value) { return putVal(key, value, false); } /** Implementation for put and putIfAbsent */ 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; // 初始化table if (tab == null || (n = tab.length) == 0) tab = initTable(); // bucket为null,通过CAS创建头节点,如果成功就结束循环 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } // bucket为ForwardingNode // 当前线程前去协助进行扩容 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { // 节点是链表 if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; // 找到目标,设置value 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; // 未找到节点,插入新节点到链表尾部 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; } } } } // 根据bucket中的节点数决定是否树化 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // oldVal不等于null,说明没有新节点 // 所以直接返回,不进行计数 if (oldVal != null) return oldVal; break; } } } // 计数 addCount(1L, binCount); return null; }
至于删除元素的操作位于函数 replaceNode(Object key, V value, Object cv)
,当 table[key].val
等于期望值cv时(或cv等于null),更新节点的值为value,如果value等于null,那么删除该节点。
remove()
函数通过调用 replaceNode(key, null, null)
来达成删除目标节点的目的, replaceNode()
的具体实现与 putVal()
没什么差别,只不过对链表的操作有所不同而已,所以就不多叙述了。
并行计算
Java 8除了对ConcurrentHashMap重新设计以外,还引入了基于Lambda表达式的Stream API。它是对集合对象功能上的增强(所以不止ConcurrentHashMap,其他集合也都实现了该API),以一种优雅的方式来批量操作、聚合或遍历集合中的数据。
最重要的是,它还提供了并行模式,充分利用了多核CPU的优势实现并行计算。让我们看看如下的示例代码:
public static void main(String[] args) { ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); String keys = "ABCDEFG"; for (int i = 1; i <= keys.length(); i++) { map.put(String.valueOf(keys.charAt(i - 1)), i); } map.forEach(2, (k, v) -> System.out.println("key-" + k + ":value-" + v + ". by thread->" + Thread.currentThread().getName())); }
这段代码通过两个线程(包括主线程)并行地遍历map中的元素,然后输出到控制台,输出如下:
key-A:value-1. by thread->main key-D:value-4. by thread->ForkJoinPool.commonPool-worker-2 key-B:value-2. by thread->main key-E:value-5. by thread->ForkJoinPool.commonPool-worker-2 key-C:value-3. by thread->main key-F:value-6. by thread->ForkJoinPool.commonPool-worker-2 key-G:value-7. by thread->ForkJoinPool.commonPool-worker-2
很明显,有两个线程在进行工作,那么这是怎么实现的呢?我们先来看看 forEach()
函数:
public void forEach(long parallelismThreshold, BiConsumer<? super K,? super V> action) { if (action == null) throw new NullPointerException(); new ForEachMappingTask<K,V> (null, batchFor(parallelismThreshold), 0, 0, table, action).invoke(); }
parallelismThreshold
是需要并行执行该操作的线程数量, action
则是回调函数(我们想要执行的操作)。 action
的类型为BiConsumer,是一个用于支持Lambda表达式的FunctionalInterface,它接受两个输入参数并返回0个结果。
@FunctionalInterface public interface BiConsumer<T, U> { /** * Performs this operation on the given arguments. * * @param t the first input argument * @param u the second input argument */ void accept(T t, U u);
看来实现并行计算的关键在于ForEachMappingTask对象,通过它的继承关系结构图可以发现,ForEachMappingTask其实就是ForkJoinTask。
集合的并行计算是基于Fork/Join框架实现的,工作线程交由ForkJoinPool线程池维护。它推崇分而治之的思想,将一个大的任务分解成多个小的任务,通过 fork()
函数(有点像 Linux 的 fork()
系统调用来创建子进程)来开启一个工作线程执行其中一个小任务,通过 join()
函数等待工作线程执行完毕(需要等所有工作线程执行完毕才能合并最终结果),只要所有的小任务都已经处理完成,就代表这个大的任务也完成了。
像上文中的示例代码就是将遍历这个大任务分解成了N个小任务,然后交由两个工作线程进行处理。
static final class ForEachMappingTask<K,V> extends BulkTask<K,V,Void> { final BiConsumer<? super K, ? super V> action; ForEachMappingTask (BulkTask<K,V,?> p, int b, int i, int f, Node<K,V>[] t, BiConsumer<? super K,? super V> action) { super(p, b, i, f, t); this.action = action; } public final void compute() { final BiConsumer<? super K, ? super V> action; if ((action = this.action) != null) { for (int i = baseIndex, f, h; batch > 0 && (h = ((f = baseLimit) + i) >>> 1) > i;) { // 记录待完成任务的数量 addToPendingCount(1); // 开启一个工作线程执行任务 // 其余参数是任务的区间以及table和回调函数 new ForEachMappingTask<K,V> (this, batch >>>= 1, baseLimit = h, f, tab, action).fork(); } for (Node<K,V> p; (p = advance()) != null; ) // 调用回调函数 action.accept(p.key, p.val); // 与addToPendingCount()相反 // 它会减少待完成任务的计数器 // 如果该计数器为0,代表所有任务已经完成了 propagateCompletion(); } } }
其他并行计算函数的实现也都差不多,只不过具体的Task实现不同,例如 search()
:
public <U> U search(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> searchFunction) { if (searchFunction == null) throw new NullPointerException(); return new SearchMappingsTask<K,V,U> (null, batchFor(parallelismThreshold), 0, 0, table, searchFunction, new AtomicReference<U>()).invoke(); }
为了节省篇幅(说实话现在似乎很少有人能耐心看完一篇长文 (:з」∠) ),有关Stream API是如何使用Fork/Join框架进行工作以及实现细节就不多讲了,以后有机会再说吧。
参考文献
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- Map大家族的那点事儿(4) :HashMap – 解决冲突
- Map大家族的那点事儿(4) :HashMap – 为什么是hash?
- Flutter:Slivers大家族,让滑动视图的组合变得很简单!
- Linux 管道那些事儿
- 代码重构那些事儿
- Web渲染那些事儿
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
淘宝天猫店是如何运营的
贾真 / 电子工业出版社 / 2017-5 / 49.8
《淘宝天猫店是如何运营的——网店从0到千万实操手册》是由天猫行业Top10卖家、电商圈知名讲师贾真写就的一本运营干货书籍。《淘宝天猫店是如何运营的——网店从0到千万实操手册》的最大卖点就是作者把自己运营店铺的经验系统地总结出来,把碎片化的“干货”形成一个系统的知识体系。句句易懂,读后受益! 现在网上能看到的电商经验,大多是碎片化知识,零散不成体系,其实很难系统地给卖家提供帮助。《淘宝天猫店是......一起来看看 《淘宝天猫店是如何运营的》 这本书的介绍吧!