Java 语言特性【二】——Java 集合类 HashMap 解析

引言

Java 类库中包含了 Map 的几种实现,包括:HashMap,TreeMap,LinkedHashMap,WeakHashMap,ConcurrentHashMap,IdentityHashMap。

下面对 HashMap 进行分析,几个问题:构造函数?如何存取?

HashMap

HashMap 底层是 哈希表(Hash table) 或称为 散列表,散列表的实现常常叫做 散列(hashing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术。

散列是数据结构的一种。(关于数据结构有个可视化网站,对数据结构的理解很有帮助 Data Structure Visualizations

对 HashMap 来说,Key 不允许重复,Value 允许重复,k、v都可以是null。

示例解析

在开发过程中最常用的是 put 和 get 方法存取值。先来个 hello world:

package com.xgcd.map;

import java.util.HashMap;

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<Object, Object> map = new HashMap<>();
        map.put("abc", "123");
        System.out.println(map.get("abc"));// 123
    }
}

下面对这三行代码分析一下,new、put、get。

一、new HashMap<>()

在创建 map 对象时,new HashMap 用的是 HashMap 的无参构造方法,看源码,默认初始容量(initial capacity) 16,负载因子(load factory) 0.75 。

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

初始容量默认 16,用于设置 map 对象中元素个数。

负载因子默认 0.75,是指当 map 中元素超过容量的75%时会自动扩容(resize),扩容放到后面说。

二、put() 方法

进入 put() 方法,key 和 value 相关联:

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

继续,进入 putVal() 方法:

    /**
     * Implements Map.put and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // tab为数组,p是每个桶
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
        // 如果数组(table)为空,则调用resize()扩容创建一个数组
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 计算元素索要存储的数组下标,算法是(n-1)&hash,如果此下标没有元素则直接插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 如果在数组table的下标i位置已经有元素了,也就是发生了所谓的hash碰撞,有两种情况:
        // 1、key值是一样的,直接替换value值(也就是覆盖)
        // 2、key值不一样,又有两种处理方式,判断链表是否是红黑树:
            // 2.1 是红黑树,存储在红黑树中
            // 2.2 是正常的链表,则存储在i位置的链表中(直接插到最后面)
        else {
            HashMap.Node<K,V> e; K k;
            // 1、key值一样
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 2.1 是红黑树
            else if (p instanceof HashMap.TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 2.2 不是红黑树即是链表,遍历链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    // 遍历直到链表尾端都没有找到key值相同的节点,则生成一个新的Node
                    if ((e = p.next) == null) {
                        // 创建链表节点并插入尾部
                        p.next = newNode(hash, key, value, null);
                        // 超过了链表的设置长度8则转为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 如果节点key存在,则覆盖原来位置上的key,同时将原来位置的元素沿着链表向后移动一位(也就是传说中的“头插法”)
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

其中,table 是一个成员变量,用散装英语翻译一下:

该 表(table) 在首次使用时初始化,并根据需要调整大小。 分配后,长度始终是2的幂。 (在某些操作中,我们还允许长度为零,以允许使用当前不需要的引导机制。)

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

查看 table 数据类型 Node<K,V>[] 发现 Node 是实现了 Map.Entry<K,V> 而 Entry 是单向链表,table 就是以 Node<K,V> 为元素的数组,这也就是称 HashMap 底层是 数组+链表 的原因。JDK1.8 后,HashMap 又引入了红黑树的数据结构。

 

 

 

需要注意的是,在计算元素所在数组下标 index 时,算法是(n - 1) & hash;代码如下:

int index = hash(key) & (capacity - 1)

hash 值的计算是调用的 hash(Object key) 方法。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

可见,hash 值并不是 key 本身的 hashcode,而是一种算法。

这里思考两个问题:

1、为什么必须是右移16位
首先hashcode本身是个32位整型值(int是32位)。获取对象的hashcode之后,先进行移位运算,再和自己做异或运算,非常巧妙,将高16位移到低16位,这样计算得到的整型值将“具有”高位和低位的性质。

因为需要考虑这样的情况:有些数据计算出的hash值差异主要在高位,而hashmap里的hash寻址(也就是计算放置到数组的索引位置)是忽略容量(初始16)以上的高位的,这种处理可以有效避免类似情况的哈希碰撞。

举个例子:我们假设有一种情况,对象A的hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。

如果容量是16,16-1=15,二进制1111,对 与运算这两个数, 你会发现高位都未参与运算,结果都是0。这样的散列结果太让人失望了。很明显不是一个好的hash算法。

但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。

2、为什么要容量减1
最后,用hash表当前的容量减1,再和刚计算出来的整型值做位与运算,为什么要容量减1呢?

因为A%B = A & (B-1),该式子在B是2的指数时成立,转换为取模运算,结果只取决于hash值。这也是为什么容量建议2的幂次方,这样保证&中的二进制位全是1,最大限度利用hash值,更好的散列,让hash值均匀的分布在桶中!

另外思考两个问题,在 putVal 方法中还提到了当超过链表的设置长度时,会转为红黑树,那为什么要引入红黑树?为什么树化的临界值又是8呢?

为什么会引入红黑树?

引入红黑树目的是做查询优化。在平常用 HashMap 的时候,HashMap 里面存储的 key 是具有良好的 hash 算法的 key(比如String、Integer等包装类),冲突几率自然微乎其微,此时链表几乎不会转化为红黑树,但是当 key 为我们自定义的对象时,我们可能采用了

不好的 hash 算法,使 HashMap 中 key 的冲突率极高,但是这时HashMap为了保证高速的查找效率,就引入了红黑树来优化查询了。

为什么树化的临界值为8?

通过源码我们得知 HashMap 源码作者通过泊松分布算出,当桶中结点个数为8时,出现的几率是亿分之6的,因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,因为转化为树还需要时间和空间,所以此时没有转化成树的必要。

既然个数为8时发生的几率这么低,为什么还要当链表个数大于8时来树化,来优化这几乎不会发生的场景呢?

首先我们要知道亿分之6这个几乎不可能的概率是建立在什么情况下的?答案是建立在良好的 hash 算法情况下。例如 String,Integer 等包装类的 hash 算法、一旦发生桶中元素大于8,说明是不正常情况,可能采用了冲突较大的hash算法,此时桶中个数出现超过8

的概率是非常大的,可能有n个key冲突在同一个桶中,此时再看链表的平均查询复杂度和红黑树的时间复杂度,就知道为什么要引入红黑树了。举个例子,若hash算法写的不好,一个桶中冲突1024个 key,使用链表平均需要查询512次,但是红黑树仅仅10次,红黑

树的引入保证了在大量 hash 冲突的情况下,HashMap 还具有良好的查询性能。

 三、get() 方法

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

进入 getNode() 方法:

    /**
     * Implements Map.get and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final HashMap.Node<K,V> getNode(int hash, Object key) {
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
            // 直接命中返回该元素
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                // 遍历红黑树查找元素
                if (first instanceof HashMap.TreeNode)
                    return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
                // 遍历链表查找元素
                do {
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

这里看一下 equals() 方法:

    public boolean equals(Object obj) {
        return (this == obj);
    }

思考两个问题:

为什么equals()方法要重写?

判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等,而继承Object中的equals方法只能判断两个引用变量是否是同一个对象。这样我们往往需要重写equals()方法。

我们向一个没有重复对象的集合中添加元素时,集合中存放的往往是对象,我们需要先判断集合中是否存在已知对象,这样就必须重写equals方法。

怎样重写equals()方法?

重写equals方法的注意点:

1、自反性:对于任何非空引用x,x.equals(x)应该返回true。

2、对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。

3、传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。

4、一致性:如果x和y引用的对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。

5、非空性:对于任意非空引用x,x.equals(null)应该返回false。

扩容机制 resize()

插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍。

当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组。

然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。

扩容:一是扩大table的长度,而是修改node的位置。容量n扩大一倍,新table中,node的下标要么还是原来的t,要么是t+n。

HashMap有两个成员变量:

DEFAULT_INITIAL_CAPACITY: HashMap默认的初始化数组的大小,默认为16

DEFAULT_LOAD_FACTOR: 加载因子,默认为0.75,,当HashMap的大小达到数组的0.75的时候就会扩容。

查看 resize() 方法代码:

final Node<K,V>[] resize() {
    //创建一个oldTab数组用于保存之前的数组
    Node<K,V>[] oldTab = table;     
    //获取原来数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;  
    //原来数组扩容的临界值
    int oldThr = threshold;     
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果原来的数组长度大于最大值(2^30)
        if (oldCap >= MAXIMUM_CAPACITY) {   
            //扩容临界值提高到正无穷
            threshold = Integer.MAX_VALUE;  
            //返回原来的数组,也就是系统已经管不了了
            return oldTab;      
        }
        //新数组(newCap)长度乘2 < 最大值(2^30) && (原来的数组长度) >= 初始长度(2^4)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)    
            //这个else if中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,
            //同时交待了扩容是以2^1为单位扩容的。
            newThr = oldThr << 1; 
    }// newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1
    else if (oldThr > 0) // initial capacity was placed in threshold
        //新数组的初始容量设置 为老数组扩容的临界值
        newCap = oldThr;    
    // 否则 oldThr == 0,零初始阈值表示使用默认值
    else {               
        //新数组初始容量设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;  
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果newThr ==0,说明为上面 else if(oldThr > 0)的情况(其他两种情况都对newThr的值做了改变),
    //此时newCap = oldThr;
    if (newThr == 0) {  
        //ft为临时变量,用于判断阈值的合法性,
        float ft = (float)newCap * loadFactor; 
        //计算新的阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE); 
    }
    //改变threshold值为新的阈值
    threshold = newThr; 
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //改变table全局变量为扩容后的newTable
    table = newTab; 
    if (oldTab != null) {
        //遍历老数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //新建一个Node<K,V>类对象,用它来遍历整个数组。
            if ((e = oldTab[j]) != null) {  
                oldTab[j] = null;//老的table不用了,赋值为null,垃圾回收
                //如果e的下一个节点是null说明没有链表或树的结构,重新计算下标,赋值到新的table
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果e已经是一个红黑树的元素
                else if (e instanceof TreeNode) 
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 链表重排,注意,原table的某些key会被计算到同一个下标,但是新的table中不一定
                // 因此,链表可能会拆散,变成0-2个链表
                // 所以,定义两个node对,一个是loHead,loTail;一个是hiHead,hiTail
                else { 
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                        do {
                            next = e.next;
                            // e.hash & oldCap==0的Node会被分配到同一个位置,确切的说,和原table下标一样
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //其余节点会被分配到另一个的同一位置,确切说是原table下标+oldCap
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 在对应的位置上赋值
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
}

这里思考扩容涉及到一个问题

1、如何知道要将原数组的某个元素放到新数组的哪个索引位置上?

也就是说如何确定元素e在新数组的位置。之前put的时候,用的是hash(key) & (capacity - 1)确定,为什么不继续用该方法,却转而判断(e.hash & oldCap) == 0,判断原来的元素在新数组上是否移位,假设capacity是16,只需要看倒数第五位,如果为0,下标不变,

如果是1,下标加上容量oldCap。


HashMap为什么是线程不安全的?

 HashMap 在并发时出现的问题可能是两方面:

1、put的时候导致的多线程数据不一致

比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了

桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线

程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

2、resize 而引起死循环

这种情况发生在HashMap自动扩容时,当2个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7中,会出现resize前后元素顺序倒置的情况)。接下来再想

通过get()获取某一个元素,就会出现死循环。

HashMap和HashTable的区别?

1、HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。

HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null (HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。

2、HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比

HashTable的扩展性更好。

3、另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。

4、由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。

5、HashMap不能保证随着时间的推移Map中的元素次序是不变的。

拉链法导致的链表过深,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。引入红

黑树就是为了查找数据快,解决链表查询深度的问题。我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑

树,引入反而会慢。

 

扩展问题简答

table数组什么时候获得初始化?

第一次插入元素的时候

初始化hashMap后,第一次放入元素,table的长度是多少?

16

new HashMap(19),创建的map中table数组长度多大?

初始化时实际上为null,第一次插入元素时32.

你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?

HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。

当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。

这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。

这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。

两个hashcode相同的时候会发生什么?

hashcode相同,bucket的位置会相同,也就是说会发生碰撞,哈希表中的结构其实有链表(LinkedList),这种冲突通过将元素储存到LinkedList中,解决碰撞。储存顺序是放在表头。

如果两个键的hashcode相同,如何获取值对象?

如果两个键的hashcode相同,即找到bucket位置之后,我们通过key.equals()找到链表LinkedList中正确的节点,最终找到要找的值对象。

一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键

是非常好的选择。

如果HashMap的大小超过了负载因子(load factor)定义的容量?怎么办?

HashMap里面默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作

rehashing,因为它调用hash方法找到新的bucket位置。

HashMap在并发执行put操作,会引起死循环,为什么?

hashmap本身就不是线程安全的。多线程会导致hashmap的node链表形成环形链表,一旦形成环形链表,node 的next节点永远不为空,就会产生死循环获取node。从而导致CPU利用率接近100%。

hashing的概念

散列法(Hashing)或哈希法是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在

各种解密算法中。

为什么String, Interger这样的wrapper类适合作为键?

因为他们一般不是不可变的,源码上面final,使用不可变类,而且重写了equals和hashcode方法,避免了键值对改写。提高HashMap性能。

String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要

防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时

候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

 

感谢

https://blog.csdn.net/wjl31802/article/details/89603285

https://blog.csdn.net/qq_43519310/article/details/102887039

HashMap方法hash()、tableSizeFor()

https://www.jianshu.com/p/ee0de4c99f87

https://blog.csdn.net/wufaliang003/article/details/79997585

 

posted @ 2020-02-19 23:53  习惯沉淀  阅读(391)  评论(0编辑  收藏  举报