一文彻底弄懂ConcurrentHashMap,轻松应对面试官!
简介
ConcurrentHashMap是HashMap的线程安全版本,内部也是使用(数组 + 链表 + 红黑树)的结构来存储元素。相比于同样线程安全的HashTable来说,效率等各方面都有极大地提高。
在阅读这篇文章之前,如果对HashMap不是很了解,可以阅读这篇文章:深入理解HashMap源码
先简单介绍一下各种锁,以便下文讲到相关概念时能有个印象。
锁
synchronized
java中的关键字,内部实现为监视器锁,主要是通过对象监视器在对象头中的字段来表明的。
synchronized从旧版本到现在已经做了很多优化了,在运行时会有三种存在方式:偏向锁,轻量级锁,重量级锁。
偏向锁:是指一段同步代码一直被一个线程访问,那么这个线程会自动获取锁,降低获取锁的代价。
轻量级锁:是指当锁是偏向锁时,被另一个线程所访问,偏向锁会升级为轻量级锁,这个线程会通过自旋的方式尝试获取锁,不会阻塞,提高性能。
重量级锁:是指当锁是轻量级锁时,当自旋的线程自旋了一定的次数后,还没有获取到锁,就会进入阻塞状态,该锁升级为重量级锁,重量级锁会使其他线程阻塞,性能降低。
CAS
CAS,Compare And Swap
,它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。
volatile(非锁)
java中的关键字,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
自旋锁
自旋锁,是指尝试获取锁的线程不会阻塞,而是循环的方式不断尝试,这样的好处是减少线程的上下文切换带来的开锁,提高性能,缺点是循环会消耗CPU。
分段锁
分段锁,是一种锁的设计思路,它细化了锁的粒度,主要运用在ConcurrentHashMap中,实现高效的并发操作,当操作不需要更新整个数组时,就只锁数组中的一项就可以了。
ReentrantLock
可重入锁,是指一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。其实,synchronized 也是可重入锁。
正文
在 Java 中,HashMap 是非线程安全的,如果想在多线程下安全的操作 map,有哪些解决方法呢?
-
第一种方法,使用
Hashtable
线程安全类; -
第二种方法,使用
Collections.synchronizedMap
方法,对方法进行加同步锁; -
第三种方法,使用并发包中的
ConcurrentHashMap
类;
Hashtable 是一个线程安全的类,Hashtable 几乎所有的添加、删除、查询方法都加了synchronized
同步锁!
相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用!
Collections.synchronizedMap
里面使用对象锁来保证多线程场景下,操作安全,本质也是对 HashMap 进行全表锁!
使用Collections.synchronizedMap
方法,在竞争激烈的多线程环境下性能依然也非常差,所以不推荐使用!
ConcurrentHashMap 类所采用的是分段锁的思想,将 HashMap 进行切割,把 HashMap 中的哈希数组切分成小数组,每个小数组有 n 个 HashEntry 组成,其中小数组继承自ReentrantLock(可重入锁)
,这个小数组名叫Segment
, 如下图:
当然,JDK1.7 和 JDK1.8 对 ConcurrentHashMap 的实现有很大的不同!
JDK1.8 对 HashMap 做了改造,当冲突链表长度大于8时,会将链表转变成红黑树结构,上图是 ConcurrentHashMap 的整体结构,参考 JDK1.7!
我们再来看看 JDK1.8 中 ConcurrentHashMap 的整体结构,内容如下:
JDK1.8 中 ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS
+ synchronized
来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 效率又提升了 N 倍!
说了这么多,我们再一起来看看 ConcurrentHashMap 的源码实现。
JDK1.7 中的 ConcurrentHashMap
Segment是什么呢?
Segment本身就相当于一个HashMap对象。
同HashMap一样,Segment包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。 因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
这样的二级结构,和数据库的水平拆分有些相似。
下面我们看看ConcurrentHashMap并发读写的几种情况?
Case1:不同Segment的并发写入(可以并发执行)
Case2:同一Segment的一写一读(可以并发执行)
Case3:同一Segment的并发写入
Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。
由此可见,ConcurrentHashMap 中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
接下来,我们就来看下ConcurrentHashMap读写的过程。
get方法
-
为输入的Key做Hash运算,得到hash值。
-
通过hash值,定位到对应的Segment对象
-
再次通过hash值,定位到Segment当中数组的具体位置。
put方法
-
为输入的Key做Hash运算,得到hash值。
-
通过hash值,定位到对应的Segment对象
-
获取可重入锁
-
再次通过hash值,定位到Segment当中数组的具体位置。
-
插入或覆盖HashEntry对象。
-
释放锁。
JDK1.8 中的 ConcurrentHashMap
虽然 JDK1.7 中的 ConcurrentHashMap 解决了 HashMap 并发的安全性,但是当冲突的链表过长时,在查询遍历的时候依然很慢!
在 JDK1.8 中,HashMap 引入了红黑二叉树设计,当冲突的链表长度大于8时,会将链表转化成红黑二叉树结构,红黑二叉树又被称为平衡二叉树,在查询效率方面,又大大的提高了不少。
JDK1.8 中的ConcurrentHashMap 相比 JDK1.7 中的 ConcurrentHashMap, 它抛弃了原有的 Segment 分段锁实现,采用了 CAS + synchronized
来保证并发的安全性。
JDK1.8 中的 ConcurrentHashMap 对节点Node
类中的共享变量,和 JDK1.7 一样,使用volatile
关键字,保证多线程操作时,变量的可见性!
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; } ...... } 复制代码
下面来具体分析一下JDK1.8 中的 ConcurrentHashMap源码。
put方法
public V put(K key, V value) { return putVal(key, value, false); } final V putVal(K key, V value, boolean onlyIfAbsent) { // key和value都不能为null if (key == null || value == null) throw new NullPointerException(); // 计算hash值 int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) // 如果tab未初始化或者个数为0,则初始化node数组 tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) // 如果使用CAS插入元素时,发现已经有元素了,则进入下一次循环,重新操作 // 如果使用CAS插入元素成功,则break跳出循环,流程结束 break; // no lock when adding to empty bin } else if ((fh = f.hash) == MOVED) // 如果要插入的元素所在的tab的第一个元素的hash是MOVED,则当前线程帮忙一起迁移元素 tab = helpTransfer(tab, f); else { // 如果这个tab不为空且不在迁移元素,则锁住这个tab(分段锁) // 并查找要插入的元素是否在这个tab中 // 存在,则替换值(onlyIfAbsent=false) // 不存在,则插入到链表结尾或插入树中 V oldVal = null; synchronized (f) { // 再次检测第一个元素是否有变化,如果有变化则进入下一次循环,从头来过 if (tabAt(tab, i) == f) { // 如果第一个元素的hash值大于等于0(说明不是在迁移,也不是树) // 那就是tab中的元素使用的是链表方式存储 if (fh >= 0) { // tab中元素个数赋值为1 binCount = 1; // 遍历整个tab,每次结束binCount加1 for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { // 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false) // 并退出循环 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; // tab中元素个数赋值为2 binCount = 2; // 调用红黑树的插入方法插入元素 // 如果成功插入则返回null // 否则返回寻找到的节点 if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { // 如果找到了这个元素,则赋值了新值(onlyIfAbsent=false) // 并退出循环 oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } // 如果binCount不为0,说明成功插入了元素或者寻找到了元素 if (binCount != 0) { // 如果链表元素个数达到了8,则尝试树化 // 因为上面把元素插入到树中时,binCount只赋值了2,并没有计算整个树中元素的个数 // 所以不会重复树化 if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // 如果要插入的元素已经存在,则返回旧值 if (oldVal != null) return oldVal; // 退出外层大循环,流程结束 break; } } } // 成功插入元素,元素个数加1(是否要扩容在这个里面) addCount(1L, binCount); // 成功插入元素返回null return null; } 复制代码
当进行 put 操作时,流程大概可以分如下几个步骤:
-
首先会判断 key、value是否为空,如果为空就抛异常;
-
接着会判断容器数组是否为空,如果为空就初始化数组;
-
进一步判断,要插入的元素
f
,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入; -
在接着判断
f.hash == -1
是否成立,如果成立,说明当前f
是ForwardingNode
节点,表示有其它线程正在扩容,则一起进行扩容操作; -
其他的情况,就是把新的
Node
节点按链表或红黑树的方式插入到合适的位置; -
节点插入完成之后,接着判断链表长度是否超过
8
,如果超过8
个,就将链表转化为红黑树结构; -
最后,插入完成之后,进行扩容判断。
initTable 初始化数组
private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) // 如果sizeCtl<0说明正在初始化或者扩容,让出CPU Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 如果把sizeCtl原子更新为-1成功,则当前线程进入初始化 // 如果原子更新失败则说明有其它线程先一步进入初始化了,则进入下一次循环 // 如果下一次循环时还没初始化完毕,则sizeCtl<0进入上面if的逻辑让出CPU // 如果下一次循环更新完毕了,则table.length!=0,退出循环 try { // 再次检查table是否为空,防止ABA问题 if ((tab = table) == null || tab.length == 0) { // 如果sc为0则使用默认值16 int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 新建数组 @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 把tab数组赋值给table table = tab = nt; // 设置sc为数组长度的0.75倍 // n - (n >>> 2) = n - n/4 = 0.75n // 可见这里装载因子和扩容门槛都是写死了的 // 这也正是没有threshold和loadFactor属性的原因 sc = n - (n >>> 2); } } finally { // 把sc赋值给sizeCtl,这时存储的是扩容门槛 sizeCtl = sc; } break; } } return tab; } 复制代码
-
使用CAS锁控制只有一个线程初始化tab数组;
-
sizeCtl在初始化后存储的是扩容门槛;
-
扩容门槛写死的是tab数组大小的0.75倍,tab数组大小即map的容量,也就是最多存储多少个元素。
helpTransfer 协助扩容
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { Node<K,V>[] nextTab; int sc; // 如果tab数组不为空,并且当前tab第一个元素为ForwardingNode类型,并且nextTab不为空 // 说明当前tab已经迁移完毕了,才去帮忙迁移其它tab的元素 // 扩容时会把旧tab的第一个元素置为ForwardingNode,并让其nextTab指向新tab数组 if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) { int rs = resizeStamp(tab.length); // sizeCtl<0,说明正在扩容 while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) { if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0) break; // 扩容线程数加1 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { // 当前线程帮忙迁移元素 transfer(tab, nextTab); break; } } return nextTab; } return table; } 复制代码
操作步骤如下:
-
第1步,对 table、node 节点、node 节点的 nextTable,进行数据校验;
-
第2步,根据数组的length得到一个标识符号;
-
第3步,进一步校验 nextTab、tab、sizeCtl 值,如果 nextTab 没有被并发修改并且 tab 也没有被并发修改,同时
sizeCtl < 0
,说明还在扩容; -
第4步,对 sizeCtl 参数值进行分析判断,如果不满足任何一个判断,将
sizeCtl + 1
, 增加了一个线程帮助其扩容。
addCount 扩容判断
private final void addCount(long x, int check) { CounterCell[] as; long b, s; // 先尝试把数量加到baseCount上,如果失败再加到分段的CounterCell上 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(); } //检查是否需要扩容,默认check=1,需要检查 if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; // 如果元素个数达到了扩容门槛,则进行扩容 // 注意,正常情况下sizeCtl存储的是扩容门槛,即容量的0.75倍 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { // rs是扩容时的一个标识 int rs = resizeStamp(n); if (sc < 0) { // sc<0说明正在扩容中 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) // 扩容已经完成了,退出循环 break; // 扩容未完成,则当前线程加入迁移元素中 // 并把扩容线程数加1 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(); } } } 复制代码
操作步骤如下:
-
第1步,利用CAS将方法更新baseCount的值
-
第2步,检查是否需要扩容,默认check = 1,需要检查;
-
第3步,如果满足扩容条件,判断当前是否正在扩容,如果是正在扩容就一起扩容;
-
第4步,如果不在扩容,将sizeCtl更新为负数,并进行扩容处理。
以上就是整个put方法的流程,可以从中发现,里面大量的使用了CAS
方法,CAS 表示比较与替换,里面有3个参数,分别是目标内存地址、旧值、新值,每次判断的时候,会将旧值与目标内存地址中的值进行比较,如果相等,就将新值更新到内存地址里,如果不相等,就继续循环,直到操作成功为止!
get方法
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; // 计算hash int h = spread(key.hashCode()); // 判断数组是否为空,通过key定位到数组下标是否为空; 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) // hash小于0,说明是树或者正在扩容 // 使用find寻找元素,find的寻找方式依据Node的不同子类有不同的实现方式 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; } 复制代码
步骤如下:
-
第1步,判断数组是否为空,通过key定位到数组下标是否为空;
-
第2步,判断node节点第一个元素是不是要找到,如果是直接返回;
-
第3步,如果是红黑树结构,就从红黑树里面查询;
-
第4步,如果是链表结构,循环遍历判断。
reomve 方法
public V remove(Object key) { // 调用替换节点方法 return replaceNode(key, null, null); } final V replaceNode(Object key, V value, Object cv) { // 计算hash int hash = spread(key.hashCode()); // 循环遍历数组 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; //校验参数 if (tab == null || (n = tab.length) == 0 || (f = tabAt(tab, i = (n - 1) & hash)) == null) break; else if ((fh = f.hash) == MOVED) // 如果正在扩容中,协助扩容 tab = helpTransfer(tab, f); else { V oldVal = null; // 标记是否处理过 boolean validated = false; //用 synchronized 同步锁,保证并发时元素移除安全 synchronized (f) { // 再次验证当前tab元素是否被修改过 if (tabAt(tab, i) == f) { if (fh >= 0) { // fh>=0表示是链表节点 validated = true; // 遍历链表寻找目标节点 for (Node<K,V> e = f, pred = null;;) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { V ev = e.val; if (cv == null || cv == ev || (ev != null && cv.equals(ev))) { oldVal = ev; if (value != null) e.val = value; else if (pred != null) pred.next = e.next; else setTabAt(tab, i, e.next); } break; } pred = e; // 遍历到链表尾部还没找到元素,跳出循环 if ((e = e.next) == null) break; } } else if (f instanceof TreeBin) { // 如果是树节点 validated = true; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; // 遍历树找到了目标节点 if ((r = t.root) != null && (p = r.findTreeNode(hash, key, null)) != null) { V pv = p.val; if (cv == null || cv == pv || (pv != null && cv.equals(pv))) { oldVal = pv; if (value != null) p.val = value; else if (t.removeTreeNode(p)) setTabAt(tab, i, untreeify(t.first)); } } } } } // 如果处理过,不管有没有找到元素都返回 if (validated) { // 如果找到了元素,返回其旧值 if (oldVal != null) { // 如果要替换的值为空,元素个数减1 if (value == null) addCount(-1L, -1); return oldVal; } break; } } } // 没找到元素返回空 return null; } 复制代码
步骤如下:
-
第1步,循环遍历数组,接着校验参数;
-
第2步,判断是否有别的线程正在扩容,如果是一起扩容;
-
第3步,用 synchronized 同步锁,保证并发时元素移除安全;
-
第4步,因为
check= -1
,所以不会进行扩容操作,利用CAS操作修改baseCount值。
提问
ConcurrentHashMap不能解决什么问题呢?
请看下面的例子:
private static final Map<Integer, Integer> map = new ConcurrentHashMap<>(); public void unsafeUpdate(Integer key, Integer value) { Integer oldValue = map.get(key); if (oldValue == null) { map.put(key, value); } } 复制代码
这里如果有多个线程同时调用unsafeUpdate()这个方法,ConcurrentHashMap还能保证线程安全吗?
答案是不能。因为get()之后if之前可能有其它线程已经put()了这个元素,这时候再put()就把那个线程put()的元素覆盖了。
那怎么修改呢?
答案也很简单,使用putIfAbsent()
方法,它会保证元素不存在时才插入元素,如下:
public void safeUpdate(Integer key, Integer value) { map.putIfAbsent(key, value); } 复制代码
那么,如果上面oldValue
不是跟 null 比较,而是跟一个特定的值比如1进行比较怎么办?
也就是下面这样:
public void unsafeUpdate(Integer key, Integer value) { Integer oldValue = map.get(key); if (oldValue == 1) { map.put(key, value); } } 复制代码
这样的话就没办法使用putIfAbsent()
方法了。
其实,ConcurrentHashMap还提供了另一个方法叫replace(K key, V oldValue, V newValue)
可以解决这个问题。
replace(K key, V oldValue, V newValue)
这个方法可不能乱用,如果传入的newValue是null,则会删除元素。
public void safeUpdate(Integer key, Integer value) { map.replace(key, 1, value); } 复制代码
那么,如果if之后不是简单的put()操作,而是还有其它业务操作,之后才是put(),比如下面这样,这该怎么办呢?
public void unsafeUpdate(Integer key, Integer value) { Integer oldValue = map.get(key); if (oldValue == 1) { System.out.println(System.currentTimeMillis()); /** * 其它业务操作 */ System.out.println(System.currentTimeMillis()); map.put(key, value); } } 复制代码
这时候就没办法使用ConcurrentHashMap提供的方法了,只能业务自己来保证线程安全了,比如下面这样:
public void safeUpdate(Integer key, Integer value) { synchronized (map) { Integer oldValue = map.get(key); if (oldValue == null) { System.out.println(System.currentTimeMillis()); /** * 其它业务操作 */ System.out.println(System.currentTimeMillis()); map.put(key, value); } } } 复制代码
这样虽然不太友好,但是最起码能保证业务逻辑是正确的。
当然,这里使用ConcurrentHashMap的意义也就不大了,可以换成普通的HashMap了。
我们不能听说ConcurrentHashMap是线程安全的,就认为它无论什么情况下都是线程安全的。
总结
虽然 HashMap 在多线程环境下操作不安全,但是在 java.util.concurrent
包下,java 为我们提供了 ConcurrentHashMap 类,保证在多线程下 HashMap 操作安全!
在 JDK1.7 中,ConcurrentHashMap 采用了分段锁策略,将一个 HashMap 切割成 Segment 数组,其中 Segment 可以看成一个 HashMap, 不同点是 Segment 继承自 ReentrantLock,在操作的时候给 Segment 赋予了一个对象锁,从而保证多线程环境下并发操作安全。
但是 JDK1.7 中,HashMap 容易因为冲突链表过长,造成查询效率低,所以在 JDK1.8 中,HashMap 引入了红黑树特性,当冲突链表长度大于8时,会将链表转化成红黑二叉树结构。
在 JDK1.8 中,与此对应的 ConcurrentHashMap 也是采用了与 HashMap 类似的存储结构,但是 JDK1.8 中 ConcurrentHashMap 并没有采用分段锁的策略,而是在元素的节点上采用 CAS + synchronized
操作来保证并发的安全性,源码的实现比 JDK1.7 要复杂的多。