回顾HashMap
既然说到HashMap了,那么我们就先来简单总结一下HashMap的重点。
1.基本结构
HashMap存储的是存在映射关系的键值对,存储在被称为哈希表(数组+链表/红黑树)的数据结构中。通过计算key的hashCode值来确定键值对在数组中的位置,假如产生碰撞,则使用链表或红黑树。
需要注意的是,key最好使用不可变类型的对象,否则当对象本身产生变化,重新计算key的hashcode时会与之前的不一样,导致查找错误。
由这一点可知,在存储键值对时,我们希望的情况是尽量避免碰撞。那么如何尽量避免碰撞?核心在于元素的分布策略和动态扩容。
2.分布策略
分布策略方面的优化主要为三个方向:
HashMap底层数组的长度始终保持为2的次幂
将哈希值的高位参与运算
通过与操作来等价取模操作
3.动态扩容
动态扩容方面,由于底层数组的长度始终为2的次幂,也就是说每次扩容,长度值都会扩大一倍,数组长度length的二进制表示在高位会多出1bit。
而扩容时,该length值将 会参与位于操作来确定元素所在数组中的新位置。所以,原数组中的元素所在位置要么保持不动,要么就是移动2次幂个位置。
以上三点都是关于HashMap本身设计特点,不在本文的主要讨论范围内。如果还不太熟悉,建议先了解HashMap的原理。
但是,HashMap美中不足的是:它不是线程安全的。主要体现在两个方面:
扩容时出现著名的环形链表异常,此问题在JDK1 .8版本被解决。
并发下脏读脏写
所以,程序员们就想要一种与HashMap功能同样强大,但又能保证读写线程安全的集合容器,这就是本文的主角——ConcurrentHashMap
HashTable
有的读者可能会有这样的疑惑:既然HashMap有线程安全问题,那我每次进行get/put操作时,都用锁进行控制不就好了?太对了,HashTable就是这么做的,可以看到它的源码里简单粗暴,给put/get操作都加上了sychronized。
public synchronized V get(object key) {Entry<?,?> tab[] = table;int hash = key.hashCode() ;int index = (hash & 0x7FFFFFFF) % tab.length;for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {if ((e.hash == hash) & e.key.equals(key)) {return (V)e.value;}}return null;
}
public synchronized V put(K key, V value) {// Make sure the value is not nullif (value == null) {throw new NullPointerException();}// Makes sure the key is not already in the hashtable.Entry<?,?> tab[] = table;int hash = key.hashCode();int index = (hash & 0x7FFFFFFF) % tab.length;@SuppressWarnings ("unchecked")Entry<K,V> entry = (Entry<K, V>)tab[index];for(; entry != null ; entry = entry.next) {if ((entry.hash == hash) & entry.key.equals(key)) {V old = entry.value;entry.value = value;return old;}}addEntry (hash, key, value, index);return null;
}
但这样会导致效率低下,在多线程环境下,当锁住map,进行读写操作时,其他想要操作的线程都会被阻塞。所以现在基本上都不再推荐使用HashTable。
有的读者可能又有疑惑了:ConcurrentHashMap难道就没有这个问题吗?它的内部应该也是用的锁吧?
ConcurrentHashMap
这就是本文要讲的重点,看看ConcurrentHashMap是如何在保证线程安全的情况下,并且做到高效的。
在JDK1.7和1.8中,ConcurrentHashMap的实现方式发生了很大的变化。有人可能觉得既然都已经更新了,那1.7就没有看的必要了。我认为读源码的目的是为了学习其中的思想,并尝试在今后的开发中进行运用。在1.7版本中,ConcurrentHashMap的分段锁思想比较经典,值得学习,本文主要从1.7版本说起。
HashTable之所以性能差是因为在每个方法上都加上了sychronized,这就相当于只用一把锁,锁住了整个数组资源。而ConcurrentHashMap用到了分段锁,每把锁只锁数组中的一段数据,这样就能大大减少锁的竞争。概念上很简单,那么具体是如何实现的呢?首先来看这么一张数据结构示意图:
可以看到,ConcurrentHashMap内部维护了一个segment数组,该数组的每个元素是HashEntry数组,看到HashEntry数组的这个结构是不是很熟悉,和HashMap中的哈希表如出一辙。
如果你读懂了这张图,基本上也就明白了ConcurrentHashMap是如何存储数据的,不过仅仅了解这些还不够,ConcurrentHashMap究竟通过哪些设计来保证其线程安全,我们需要进一步深挖。细节都藏在源码里。
这里再次声明一下,ConcurrentHashMap内部有很多和HashMap样的设计和技巧,一旦遇到,本文不再详细介绍。
源码
在JDK1.7版本中,ConcurrentHashMap的源码并不长,首先来看ConcurrentHashMap类的继承情况。
public class ConcurrentHashMap<K,V> extends AbstractMap<K, V>implements ConcurrentMap<K,V>,Serializable
继承情况
ConcurrentHashMap继承了AbstractMap抽象类,实现了ConcurrentMap接口。
AbstractMap类内部是一些Map通用方法的声明以及一些公共方法实现,本文不做深究。
ConcurrentMap接口中声明了四个方法,是对Map本身的增删改查,只不过要求实现类保证这些操作的线程安全,我们之后会看ConcurrentHashMap具体是如何实现的。
public interface ConcurrentMap<K, V> extends Map<K,V> {V putIfAbsent(K key, V value);boolean remove (object key, object value);boolean replace(K key, V oldValue, V newValue) ;V replace(K key, V value);
}
接下来就来看ConcurrentHashMap的内部实现。
静态变量
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0. 75f;
static final int DEFAULT_CONCURRENCY LEVEL = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
static final int RETRIES_BEFORE_LOCK = 2;
以上各个静态变量都被final修饰了,而且都是基本数据类型,所以是作为常量来使用的。结合这张结构图,一期理解各个变量的含义。
DEFAULT_INITIAL_CAPACITY:HashEntry数组长度的初始值
DEFAULT_LOAD_FACTOR:加载因子,决定扩容时机,和HashMap里一样
DE FAULT_CONCURRENCY_LEVEL:并发等级(后续详细介绍)
MAXIMUM_CAPACITY:HashEntry数组长度的最大值,这里为2的30次方
MIN_SEGMENT_TABLE _CAPACITY:Segment数组最小长度,这里为2
MAX_SEGMENTS:Segment数组最大长度,这里为2的16次方
RETRIES_BEFORE_LOCK:重试次数(后续介绍在哪里用到)
属性
在没有看源码前,根据上述结构图,大致可以猜到最核心的属性应该有支持范型的Segment数组(其元素为HashEntry数组)。核心内部类应该就是Segment和HashEntry。在源码中也确实如此。
final int segmentMask;
final int segmentShift;final Segment<K,V>[] segments; // Segment数组// HashEntry数组保存的KV相关信息
transient Set<K> keySet;
transient Set<Map. Entry<K,V>> entrySet;
transient Collection<V> values;
除此之外,segmentMask和segmentShift这两个属性我们暂时还不明白它们的作用,后续用
到时会详细讲到。
内部类
HashEntry
static final class HashEntry<K, V> {final int hash; final K key;volatile V value;volatile HashEntry<K, V> next;HashEntry(int hash, K key, V value, HashEntry<K, V> next) {this.hash = hash; this.key = key;this.value = value;this.next = next;}/*** Sets next field with volatile write semantics. (See above about use of * putOrderedobject. )*/final void setNext (HashEntry<K,V> n) {UNSAFE. putOrderedobject(this, nextOffset, n);}// Unsafe mechanicsstatic final sun.misc.Unsafe UNSAFE;static final long nextOffset;static {try {UNSAFE = sun.misc.Unsafe.getUnsafe();Class k = HashEntry.class;nextOffset = UNSAFE.objectField0ffset(k. getDeclaredField("next");} catch (Exception e) {throw new Error(e);}}
}
HashEntry的结构不复杂,它拥有hash、key、value三个属性值,并且可以通过next引用来构建链表,整体上和HashMap中的内部类Node比较类似。但是不难发现26-35行有一段被static修饰的代码,这段代码是什么含义呢?
简单介绍一下Unsafe这个类,正如它的名字一样,Unsafe对象用于执行一些不安全的、较为底层的操作,比如直接访问系统资源。因为使用它的风险较高并且场景较少,所以我们在日常的业务代码中几乎看不到对Unsafe的使用,但是对于一些追求高效,并且有能力保证安全的下层组件来说,使用Unsafe是家常便饭。
第30行中objectField0ffset方法返回的是“指定成员属性在内存地址相对于此对象的内存地址的偏移量”,这句听上去比较拗口。在这里,使用该方法获取next属性的相对内存偏移量,然后方便在第10行中调用putOrderedObject来对next进行赋值,而putOrdered0bject下层是一个CAS调用。可以这么理解:这里直接使用Unsafe对象获取next的内存偏移量,是为了更方便地使用CAS对next进行赋值。如果这段话你不是很明白,忽略也不会影响后续理解。
整体上,HashEntry结构清晰,易于理解。下面我们来看看相对复杂一点的Segment。
Segment
继承关系
static final class Segment<K,V> extends ReentrantLock implements Serializable
Segment继承自ReentrantLock,这里分段锁的味道就体现出来了。每个Segment对象就是一把锁,一个Segment对象内部存在一个HashEntry数组,也就是说,HashEntry数组中的数据同步依赖同一把锁。不同HashEntry数组的读写互不干扰,这就形成了所谓的分段锁。
我们可以大胆猜想:假设Segment数组的长度为n,那么相较于Hashtable,理论上ConcurrentHashMap的性能就要提升n倍以上。有的读者可能会存在疑惑: ConcurrentHashMap用n把相互独立的锁替换Hashtable全局1把锁,那照理说性能提升最多也就是n倍,为什么要说n倍以上呢?
因为相较于Hashtable中使用的synchronized,ConcurrentHashMap对锁本身也做了优化。具体是怎么优化的,我们下文会讲到。
属性
static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 :1 ;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
MAX_ SCAN_ RETRIES:指定的重试次数。在多线程进行put操作时,只有一个线程能够成功获得锁进行写操作,那么此时其他线程也不必死等,可以通过多次tryLock进行重试,并做一些其他的工作,这就体现出了效率的提升,后面会讲到。
table:之前提到的HashEntry数组
count:HashEntry数组中元素个数
modCount:HashEntry数组修改次数
threshold:触发扩容的阈值
loadFactor:负载因子
上述属性也比较易于理解,基本和HashMap中同名属性的意义一样。
方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {}
private void rehash(HashEntry<K,V> node) {}
private HashEntry<K, V> scanAndLockForPut(K key, int hash, V value) {}
private void scanAndLock(0bject key, int hash) {}
final V remove(0bject key, int hash, object value) {}
final boolean replace(K key, int hash, V oldValue, V newValue) {}
final V replace(K key, int hash, V value) {}
final void clear() {}
方法基本上就是增删改查还有扩容,由于篇幅原因这里没有展示方法体内容,在讲解到具体方法时,再来看方法内部的具体逻辑。
但有两个方法名比较陌生:scanAndLockForPut和scanAndLock。 之前我们说到这样一个常见场景:当A线程正在修改HashEntry数组(属性名为table)的某个桶,此时B线程也想要修改这个桶,但是A线程持有了独占锁,所以B线程只能等待或重试,若只是干等或不断重试,可能会是一种浪费,所以有一种优化思路就是让B线程在重试的过程中抽空去预先完成一些后续将会用到的准备工作。
scanAndLockForPut和scanAndLock方法的逻辑就实现了这种优化,下文我们会按照自顶向下的流程详解这两个方法。
方法
至此为止,开胃菜吃完了,我们已经介绍了如下内容:
ConcurrentHashMap的属性(其中segmentMask和segmentShift还未介绍)
核心内部类HashEntry和Segment (其中Segment的方法体还未详解)
接下来就是正餐,看一看ConcurrentHashMap究竟如何实现线程安全的put操作,至于get、
replace、remove操作,相较于put更加简单,篇幅原因本文不再赘述。相信你如果能够理解put的设计后,其他都能通过举一反三的方式理解。
构造方法
首先看一看ConcurrentHashMap的构造方法,这将帮助你对它有一个更加直观的认识。
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {if (!(loadFactor > 0) |I initialCapacity < 0 II concurrencyLevel <= 0) throw new IllegalArgumentException();if (concurrencyLevel > MAX_ SEGMENTS)concurrencyLevel = MAX_ SEGMENTS;// Find power-of-two sizes best matching argumentsint sshift = 0;int ssize = 1;while (ssize < concurrencyLevel) {++sshift; ssize <<= 1;}this.segmentShift = 32 - sshift;this.segmentMask = ssize - 1;if (initialCapacity > MAXIMUM_ CAPACITY)initialCapacity = MAXIMUM_ CAPACITY;int C = initialCapacity / ssize;if (C * ssize < initialCapacity)++c;int cap = MIN_ SEGMENT TABLE_ CAPACITY;while (cap < c)cap <<= 1;// create segments and segments [0]Segment<K, V> s0 =new Segment<K, V> (loadFactor, (int) (cap ★loadFactor),(HashEntry<K,V>[]) new HashEntry[cap]);Segment<K, V>[] ss = (Segment<K, V>[]) new Segment[ssize];UNSAFE.putOrderedobject(ss, SBASE,s0); // ordered write of segments[0]this.segments = ss;
}
三个参数initialCapacity、loadFactor、 concurrencyLevel三个值的含义在开头介绍过,这三个值一般采用默认值16、0.75、16。
1.3-4行。对参数进行简单的校验,如果不满足条件则抛出异常
2.8-13行。出现了两个局部变量sshift、ssize。 ssize就是segment数组的长度,初始值为1,当ssize小于concurrencyLevel时,sshift自增1, ssize左移1位,相当于扩大两倍。也就是说,当concurrencyLevel为16时,ssize最终也为16,如果concurrencyLevel为17,那么ssize最终为32。为什么要这么设计呢?这是为了控制segment数组的长度始终为2的次幂,为什么要控制其为2的次幂?这是为了在计算元素索引时进行优化,和HashMap中的设计方式一样。
3.14-15行。 我们假设concurrencyLevel为16,此时,sshift为4, ssize为16。 那么segmentShift为28,segmentMask为15。ssize是segment数组长度并且总是2的次幂,segmentMask为ssize减1,二进制下为1111,这被称为掩码。在这里,segmentMask的二进制序列上每一位总是1。掩码用于与key的哈希值进行位与操作来代替取模,计算出索引值,以此定位key所在的桶。这个操作是不是很熟悉,在HashMap中也是用到了相同的设计。
4.16-23行。segment数组的长度确定了,接下俩需要确定segment数组的每个元素,即HashEntry数组的长度。变量cap即为计算后的HashEntry数组长度,相同地,cap也一定是2的次幂。
5.25-30行,确定了Segment与HashEntry的相关参数,接下来进行初始化。并且向Segment数组中加入了第一个元素。
万事俱备,只欠东风,准备阶段的内容都已经理解了的话。接下来我们就来看ConcurrentHashMap最经典的put操作。
Public put
public V put(K key, V value) {Segment<K,V> s;if (value == null)throw new NullPointerException();int hash = hash(key);int j = (hash >>> segmentShift) & segmentMask;if ((s = (Segment<K, V>) UNSAFE.get0bject // nonvolatile; recheck(segments,(j << SSHIFT) + SBASE)) == null) // in ensureSegments = ensureSegment(j) ;return s.put(key, hash, value, false) ;
}public V putIfAbsent(K key, V value) {Segment<K,V> s;if (value == null)throw new NullPointerException();int hash = hash(key);int j = (hash >>> segmentShift) & segmentMask;if ((s = (Segment<K,V>) UNSAFE.get0bject(segments,(j << SSHIFT) + SBASE)) == null)s = ensureSegment(j);return s.put(key,hash,value,true);
}
put操作主要有两个方法:put和putIfAbsent,不难发现除了最后一行,其他都一样。有的读者可能要说了:写的什么垃圾代码,懂不懂封装啊。(手动狗头)我理解这是为了屏蔽调用方的理解难度,这是闲话不表,继续看代码。主要就来讲put方法。
1.第5行。首先对key进行哈希,hash方法内部就是一系列的数学运算,细节这里就不介绍了。
2.第6行。接下来计算变量j的操作,首先移位,然后和掩码进行位与计算,其间的含义和HashMap中如出一辙。这里就不在赘述。
3.7-9行,通过索引j和ensureSegment方法来取出目标Segment对象。在介绍构造函数的篇幅中我们提过,构造函数中只为Segment数组第0个元素赋值。而ensureSegment内部会对 第j个元素是否存在进行判断,若不存在,则使用CAS进行初始化保证取出的s对象不为null。 ensureSegment内部的逻辑这里不深究,但是这种懒加载的思想值得学习。
Private put
该方法相对负复杂与核心。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);V oldValue;try {HashEntry<K,V>[] tab = table;int index = (tab. length - 1) & hash;HashEntry<K,V> first = entryAt(tab, index) ; for (HashEntry<K, V> e = first; ; ) {if (e != null) {K k;if ((k = e.key) == key ||(e.hash == hash & key . equals(k))) {oldValue = e.value;if (!onlyIfAbsent) {e.value = value;++modCount;}break;}e = e.next;} else {if (node != null)node.setNext(first);elsenode = new HashEntry<K,V>(hash, key, value, first);int c = count + 1;if (c > threshold && tab.length < MAXIMUM_CAPACITY)rehash(node);elsesetEntryAt(tab, index, node);++modCount;count = c;oldvalue = null;break;}}} finally {unlock();}return oldValue;
}
先来看这几个形参:key、hash、value、onlylfAbsent。
前三个参数很易于理解,因为要插入新的HashEntry,那必定要构造该对象,这三个参数都是HashEntry的构造方法所需要的。最后一个onlylfAbsent指定插入条件,如果当前key已经存在,那么只有当onlylfAbsent为false时才会覆盖。
2-3行
首先就是一个三目运算符,终于见到了心心念念的tryLock()。因为插入是一个并发操作,这里通过ReentrantLock的性质来进行控制。如果当前线程获得了锁,那么将node置为null,等待后续的初始化;否则执行scanAndLockForPut方法。
这里有的读者可能会有疑问:为什么不直接调用ReentrantLock的lock方法,若第一时间没获得锁那就等待直到获取。我认为这样做也不是不行,但显而易见在这里等待的时间浪费了,作者Doug Lee提供了一种性能更高解决方法,也是精华所在。就是当某线程若没有第一时间获得锁,将会执行scanAndLockForPut方法,进行一些预处理工作,这样就减少了时间上的浪费,该方法我们下文会细讲。
4-22行
我们来看如果线程tryLock获得了锁的情况。首先计算index (length -1就是获得掩码)。我们知道,HashEntry数组table中每个元素都可能是HashEntry链表。entryAt通过index拿到HashEntry链表的头节点。接下来通过头节点去遍历链表,如果发现key已存在,则根据onlylfAbsent值判断是否应该覆盖value,然后退出;如果key不存在或头结点本身就是null,那么进入else块,执行插入新节点的逻辑。
23-35行
eles块中的逻辑比较丰富,需要仔细来看。首先判断node是否为null,这个判断有点略显奇怪,node只在最开始的三目运算中操作过,若当前线程抢到锁,那么node为null,而这里既然判断node是否为null,是不是也就是说若线程没抢到锁,执行scanAndLockForPut的结果,就是初始化node。这个可能性很大,我们暂不去证实,等到具体看scanAndLockForPut方法时再确定。
若node还是null,那么进行初始化,并插入链表,看到参数中有first,大致能猜到使用了头插法,看看HashEntry的构造方法中的逻辑,确认了一下果然。
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {this.hash = hash;this.key = key;this.value = value;this.next = next;
}
接下来判断HashEntry数组是否需要扩容,如果不需要扩容,那么将node放入HashEntry数组的相应位置,这里需要注意的是,我们刚才说将node插入链表使用的是头插法,所以这里node已经成为了头结点。最终释放锁。
该方法因为一开始就已经用锁进行了控制,所以内部不会出现并发问题,理解上应该不难。
scanAndLockForPut
接下来就是上文一直提到的scanAndLockForPut方法,在进入put方法时,如果当前线程没有拿到锁,那么将会面临什么命运呢?接下去看。
private HashEntry<K, V> scanAndLockForPut(K key, int hash, V value) {HashEntry<K,V> first = entryForHash(this,hash);HashEntry<K,V> e = first;HashEntry<K,V> node = null;int retries = -1; // negative while locating nodewhile (!tryLock()) {HashEntry<K,V> f; // to recheck first belowif (retries < 0) {if (e == null) {if (node == null) // speculatively create nodenode = new HashEntry<K, V> (hash, key, value, null);retries = 0;} else if (key.equals(e.key))retries = 0;elsee = e.next;} else if (++retries > MAX_SCAN_RETRIES) {lock();break;} else if ((retries & 1) == 0 &&(f = entryForHash(this,hash)) != first) {e = first = f; // re-traverse if entry changedretries = -1;}}return node;
}
上文我们已经做出了一个大胆的猜想:scanAndLockForPut中,线程预创建了node,并插入了链表。
反应比较快的读者在这里可能会产生如下疑惑:如果scanAndLockForPut方法中根据key、value、hash预创建了node,但是后续返回的put方法中有逻辑会对当前key是否存在进行判断,如果已经存在,那么根本用不到这个预先创建的node,这样不是就浪费了,或者说根本没有预先创建的必要吗?
如果能产生这个疑问,那么说明已经完全理解了。但实际上的预创建的逻辑要更加高明一些,在创建过程中会对key是否存在进行判断。
这是一种预创建的思想,当一部分线程无事可干的时候,不要让它们干等,而是让它们去做一些可以预先完成的任务。以后在工作中遇到类似的场景时,完全可以借鉴这种思想。
在阅读该方法的逻辑时,我们需要知道其中逻辑都是处于并发状态下,因为获取锁的线程可能正在修改链表(增、删、改)。
6行
可以看到方法在第6行进入了一个while循环,当tryLock()为false,也就是说若当前线程未获得锁时,将会不断执行。
8-16行
retries值的初始值为-1,也就是说循环第一轮一定会进入这块逻辑。若e为null,也就是说链表的头结点为null,那么进入初始化node的逻辑;如果e不为null,那么遍历链表,一旦发现链表中存在相同key的node,就将retries置为0,表示不再进入这段可能初始化node的逻辑。
17-19行
若retries已经被置为>=0,说明因为存在相同key不需要创建node,或者node已经创建好了。这里就对retries开始自增,相当于自选,如果超过自选次数后还未获得锁,那么调用lock(),老老实实排队。
20-23行
如果当前retries&1== 0,这是个什么操作?也就是说retries每自增两次,将会出现一次retries&1 == 0。且此时出现了hash值一样的key的话,那么将会再次遍历链表检查是否需要创建node(因为也有可能目标key所在node已经被其他线程删除了)。以此往复,直到获取锁或retries超过阈值。
这个方法因为出现在了并发环境下,所以需要考虑的情况比较多,有兴趣的读者不妨画出流程图来仔细品一品。
扩容
最后我们再提一下扩容,ConcurrentHashMap中的扩容仅针对HashEntry数组,Segment数组在初始化后无法再扩容。
源码中我们也看到,在调用put操作时,会对是否需要rehash进行检查。扩容本身是很重要的知识点,但是由于HashEntry数组的扩容和HashMap中基本一样,所以就不赘述了。不同的是,HashEntry数组的扩容操作已经被外层put方法中获取的锁保护起来了,所以能保证线程安全。
本文讲解了JDK1.7版本的ConcurrentHashMap内部实现,相较于HashMap,它实现了线程安全的读写与扩容。相较于HashTable,它采用分段锁,通过并发等级这个参数来控制并发程度,提高了N倍的读写效率。ConcurrentHashMap的核心在于Segment和HashEntry的实现。此外,ConcurrentHashMap在进行put操作时,采用了一种“预先创建”的思想来进行优化,这是常常被人忽视,但是却很有趣的设计。