ConcurrentHashMap源码整理

转自https://blog.csdn.net/justloveyou_/article/details/72783008,这是我的阅读笔记,自己做了部分修改。

1.ConcurrentHash的介绍:

通过段(Segment)将ConcurrentHashMap划分为不同的部分,ConcurrentHashMap就可以使用不同的锁来控制对哈希表的不同部分的修改,从而允许多个修改操作并发进行,这正是ConcurrentHashMap锁分段技术和核心内涵,可以把整个ConcurrentHashMap看成一个父哈希表,每个Segment看成一个子哈希表。 

线程对映射表的读操作时,一般情况下不需要加锁就可以完成,对容器的结构性修改操作(比如put操作、remove操作)才需要加锁。ConcurrentHashMap不同于HashMap,它既不允许key值为null,也不允许value值为null。

2.ConcurrentHashMap的结构:

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segeme数组的默认值是16,Segment继承了ReentrantLock,在ConcurrentHashMap中扮演了锁的角色,HashEntry用来存储键值对数据。一个Segment里包含了一个HashEntry数组,每一个HashEntry是一个链表结构的元素,每个Segement守护着一个HashEntry数组里的元素,当对HashEntry数组中数据进行修改时,必须首先获取与它对应的Segment锁。

3.类ConcurrentHashMap的成员变量:

segmentMask——用于定位段,大小等于segement

segmentShift——用于定位段,大小等于32(hash值的位数)减去对Segment的大小除以2为底的对数值,是不可变的

segment[]——ConcurrentHashMap的底层是一个Segment数组

使用示例:定位到某个segment,(hash>>>segmentShift)&segmentMask

类HashEntry:

static final class HashEntry<K,V> {
       final K key;                       // 声明 key 为 final 的
       final int hash;                   // 声明 hash 值为 final 的
       volatile V value;                // 声明 value 被volatile所修饰
       final HashEntry<K,V> next;      // 声明 next 为 final 的

        HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
            this.key = key;
            this.hash = hash;
            this.next = next;
            this.value = value;
        }

        @SuppressWarnings("unchecked")
        static final <K,V> HashEntry<K,V>[] newArray(int i) {
        return new HashEntry[i];
        }
    }

4.类Segment:

类Segmet包含下面这几个属性:

count——Segment中元素的数量,可见的

modCount——对count的大小造成影响的操作的次数

threshold——阈值,HashEntry中元素的数量超过这个值就会对HashEntry进行扩容

table[]——HashEnty对象的数组

loadFactor——负载因子

static final class Segment<K,V> extends ReentrantLock implements Serializable {

        /**
         * The number of elements in this segment's region.
         */
        transient volatile int count;    // Segment中元素的数量,可见的

        /**
         * Number of updates that alter the size of the table. This is
         * used during bulk-read methods to make sure they see a
         * consistent snapshot: If modCounts change during a traversal
         * of segments computing size or checking containsValue, then
         * we might have an inconsistent view of state so (usually)
         * must retry.
         */
        transient int modCount;  //对count的大小造成影响的操作的次数(比如put或者remove操作)

        /**
         * The table is rehashed when its size exceeds this threshold.
         * (The value of this field is always <tt>(int)(capacity *
         * loadFactor)</tt>.)
         */
        transient int threshold;      // 阈值,段中元素的数量超过这个值就会对Segment进行扩容

        /**
         * The per-segment table.
         */
        transient volatile HashEntry<K,V>[] table;  // 链表数组

        /**
         * The load factor for the hash table.  Even though this value
         * is same for all segments, it is replicated to avoid needing
         * links to outer object.
         * @serial
         */
        final float loadFactor;  // 段的负载因子,其值等同于ConcurrentHashMap的负载因子

        ...
    }

5.ConcurrentHashMap的初始化:

ConcurrentHashMap一共提供了五个构造函数:

1.ConcurrentHashMap(int initialCapacity,float loadFactor,int concurrenryLevel)

 /**
     * Creates a new, empty map with the specified initial
     * capacity, load factor and concurrency level.
     *
     * @param initialCapacity the initial capacity. The implementation
     * performs internal sizing to accommodate this many elements.
     * @param loadFactor  the load factor threshold, used to control resizing.
     * Resizing may be performed when the average number of elements per
     * bin exceeds this threshold.
     * @param concurrencyLevel the estimated number of concurrently
     * updating threads. The implementation performs internal sizing
     * to try to accommodate this many threads.
     * @throws IllegalArgumentException if the initial capacity is
     * negative or the load factor or concurrencyLevel are
     * nonpositive.
     */
    //initialCapacity   初始容量,每个Segement的HashEntry的数组长度
    //loadFactor        负载因子
    //concurrencyLevel  并发级数,大于等于该值的2的N次方就是segments数组的长度
    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();

        if (concurrencyLevel > MAX_SEGMENTS)              
            concurrencyLevel = MAX_SEGMENTS;

        // Find power-of-two sizes best matching arguments
        int sshift = 0;            // 大小为 lg(ssize) 
        int ssize = 1;            // 段的数目,segments数组的大小(2的幂次方)
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        segmentShift = 32 - sshift;      // 用于定位段
        segmentMask = ssize - 1;      // 用于定位段
        this.segments = Segment.newArray(ssize);   // 创建segments数组

        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;    // 总的桶数/总的段数
        if (c * ssize < initialCapacity)
            ++c;
        int cap = 1;     // 每个段所拥有的桶的数目(2的幂次方)
        while (cap < c)
            cap <<= 1;

        for (int i = 0; i < this.segments.length; ++i)      // 初始化segments数组
            this.segments[i] = new Segment<K,V>(cap, loadFactor);
    }

看起来有点麻烦,这里涉及很多的位运算,我们来逐一分析:

        int sshift = 0;            // 大小为 lg(ssize) 
        int ssize = 1;            // 段的数目,segments数组的大小(2的幂次方)
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        segmentShift = 32 - sshift;      // 用于定位段
        segmentMask = ssize - 1;      // 用于定位段
        this.segments = Segment.newArray(ssize);   // 创建segments数组

这段代码的目的是为了保证segments数组的长度是2的N次方,所以计算出一个大于等于concurrencyLevel的最小2的N次次方值作为segments数组的长度。举例:如果concurrenryLevel = 15,此时ssize的值等于16,如果concurrencyLevel = 17,此时ssize的值等于32。

可以将过程打印出来更容易理解:

class Test{
    public static void main(String[] args) {
    	int sshift = 0;
    	int ssize = 1;
    	int concurrencyLevel = 15;
    	while(ssize<concurrencyLevel){
    		++sshift;
    		ssize<<=1;
    		System.out.println("sshift: "+sshift);
    		System.out.println("ssize: "+ssize);
    	}
    	System.out.println("------------");
    	System.out.println("sshift: "+sshift);
    	System.out.println("ssize: "+ssize);
    	int segmentShift = 32 - sshift;		//int类型一共32位
    	int segmentMask = ssize - 1;		//其实就是segment数组长度减去1,用来后面与运算的
    	
    	System.out.println(segmentShift);
    	System.out.println(segmentMask);
    }
}

输出:

sshift: 1
ssize: 2
sshift: 2
ssize: 4
sshift: 3
ssize: 8
sshift: 4
ssize: 16
------------
sshift: 4
ssize: 16
28
15

过程就是ssize找到一个大于等于concurrencyLevel的2的N次方的值,而sshift可以看成是ssize由几位二进制表示。

再看第二部分的代码:

 int c = initialCapacity / ssize;    // 总的桶数/总的段数
        if (c * ssize < initialCapacity)
            ++c;
        int cap = 1;     // 每个段所拥有的桶的数目(2的幂次方)
        while (cap < c)
            cap <<= 1;

每个Segment有多个哈希桶数组,这段代码就是初始化哈希桶数组的大小。举例:如果Segment数组的大小是16,初始化容量initialCapacity为32,此时可以算出cap = 2,如果initialCapaticy为31,此时cap也为2,cap跟Segment数组长度一样,都必须是2的n次方。

6.定位到segment:

int hash = hash(key.hashCode());   //取得哈希值
private static int hash(int h){    //再哈希函数
    h += (h<<15)^0xffffcd7d;
    h ^= (h>>>10);
    h += (h<<3);
    h ^= (h>>>6);
    h += (h<<2) + (h<<14);
    return h^(h>>>16);
}

final Segment<K,V> segmentFor(int hash) {//定位到某个segment
        return segments[(hash >>> segmentShift) & segmentMask];
//这里用与替换取余,因为与计算快//疑问:这里是不是不与也是可以的?????
}

为啥要再哈希:自己的理解,默认情况下segmentShift为28,SegmentMask为15,如果不使用再哈希算法,那么实际上只使用了key.hashCode()值的高4位,此时很容易产生散列中途,可以简单将再散列理解为将key.hashCode()值各位再互相运算,最终使得高4位尽可能的不同,减少散列冲突的可能性

在定位是第几个Segment时,使用的是公式:(hash >>> segmentShift) & segmentMask,segmentShift是Segment数组的长度减去1,假设segmentShift为28,Segment数组的长度是16,此时segmentShift = 15,转为二进制为0x00000000 00000000 00000000 00001111,此时任意一个值和15相与,得到的值都在0 -15之间,而hash>>>segmentShift取出hash的高四位,举个例子hash = 0x01000111 10000111 11110000 11110000,hash>>>28 = 0x0000000 00000000 00000000 0100,所以公式的(hash>>segmentShift)&segmentMask的目的就是取出高(32-segmentShift)位。 

补充常见的移位操作:


<< : 左移运算符,num << 1,向左移动1位,右边补0

>> : 右移运算符,num >> 1,向右移动1位,如果是正数,左边补0,如果是负数,右边补1

>>> : 无符号右移,忽略符号位,左边都以0补齐
举例:-8  
-8的源码:10000000 00000000 0000000 00000100
-8的反码:11111111 11111111 1111111 11111011
-8的补码:11111111 11111111 1111111 11111100
11111111 11111111 1111111 11111100>>1  = 11111111 11111111 1111111 11111110
11111111 11111111 1111111 11111100>>>1 = 01111111 11111111 1111111 11111110

7.在某个segment中定位到HashEntry:

int hash = hash(key.hashCode());    //取得hash值

private static int hash(int h){    //再哈希函数
    h += (h<<15)^0xffffcd7d;
    h ^= (h>>>10);
    h += (h<<3);
    h ^= (h>>>6);
    h += (h<<2) + (h<<14);
    return h^(h>>>16);
}

int index = hash&(tab.length-1)    //定位到segment数组中的某个HashEntry

说明:定位Segment和定位HashEntry的散列算法虽然一样,都是数组长度减去1再与一个值v相与,但是相与的这个值不同,定位Segment使用的是key的哈希值的再散列后的得到的值的高位,而定位HashEntry直接使用了key的哈希值再散列后的值。取不同的值目的是避免两次散列后的值一样,虽然在Segment中散列开了,但是却没有在HashEntry中散列开

8.put方法:

 public V put(K key, V value) {
        if (value == null)            //value值不能为空
            throw new NullPointerException();
        int hash = hash(key.hashCode());    //取得hash值
        return segmentFor(hash).put(key, hash, value, false);//定位segment
    }

向ConcurrentHashMap中put一个键值对时,首先会获得key的哈希值并对其再次哈希,然后根据最终的hash值定位到这条记录所应该插入的端,定位段的源码如下:

final Segment<K,V> segmentFor(int hash) {
        return segments[(hash >>> segmentShift) & segmentMask];  //根据hash值定位到段
    }

put方法首先定位具体的Segment,fangsegmentFor()方法根据传入的hash值向右无符号右移segmentShift位,然后和segmentMask进行与操作就可以定位到特定的段。根据key的hash值的高n位就可以确定元素到底在哪一个段中。紧接着调用这个段的put()方法来将目标键值对插到段中。段的put()方法的源码如下:

 V put(K key, int hash, V value, boolean onlyIfAbsent) {
            lock();    // 上锁
            try {
                int c = count;
                if (c++ > threshold) // 检查本次插入会不会导致Segment中元素超过阈值
                    rehash();       //如果超过,就先对Segment进行扩容和重哈希操作
                HashEntry<K,V>[] tab = table;    // table是Volatile的
                int index = hash & (tab.length - 1);    // 定位到段中特定的桶
                HashEntry<K,V> first = tab[index];   // first指向桶中链表的表头
                HashEntry<K,V> e = first;

                // 检查该桶中是否存在相同key的结点
                while (e != null && (e.hash != hash || !key.equals(e.key)))  
                    e = e.next;   

                V oldValue;
                if (e != null) {        // 该桶中存在相同key的结点
                    oldValue = e.value;    //就更新value值
                    if (!onlyIfAbsent)
                        e.value = value;        // 更新value值
                }else {         // 该桶中不存在相同key的结点
                    oldValue = null;
                    ++modCount;     // 结构性修改,modCount加1
                    tab[index] = new HashEntry<K,V>(key, hash, first, value);  // 创建HashEntry并将其链到表头,这里每次都放到表头,因为next是final修饰的
                    count = c;      //write-volatile,count值的更新一定要放在最后一步(volatile变量)
                }
                return oldValue;    // 返回旧值(该桶中不存在相同key的结点,则返回null)
            } finally {
                unlock();      // 在finally子句中解锁
            }
        }

put方法需要对共享变量操作,操作共享变量时必须加锁。插入操作需要分两步,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置。

ConcurrentHashMap对Segment的put操作是加锁完成的,因为插入操作在某一个Segement的某一个桶中完成的,不需要锁定整个ConcurrentHashMap。因此,其他写线程对另外15个Segment的加锁并不会因为当前线程对这个Segment的加锁而阻塞。在理想情况下,ConcurrentHashMap可以支持16个线程执行并发写操作(如果并级别设置为16),及任意数量线程的读操作。

HashEntry中的next属性设置为final的,所以添加新节点时,新节点只能做头节点,然后将新节点的next执行原点的头节点,这部分代码如下:

 oldValue = null;
 ++modCount;     // 结构性修改,modCount加1
 tab[index] = new HashEntry<K,V>(key, hash, first, value);  // 创建HashEntry并将其链到表头,这里每次都放到表头,因为next是final修饰的
 count = c;      //write-volatile,count值的更新一定要放在最后一步(volatile变量)

ConcurrentHashMap的重哈希操作:rehash()

  void rehash() {
            HashEntry<K,V>[] oldTable = table;    // 扩容前的table
            int oldCapacity = oldTable.length;
            if (oldCapacity >= MAXIMUM_CAPACITY)   // 已经扩到最大容量,直接返回
                return;

            // 新创建一个table,其容量是原来的2倍
            HashEntry<K,V>[] newTable = HashEntry.newArray(oldCapacity<<1);   
            threshold = (int)(newTable.length * loadFactor);   // 新的阈值
            int sizeMask = newTable.length - 1;     // 用于定位桶
            for (int i = 0; i < oldCapacity ; i++) {
                // We need to guarantee that any existing reads of old Map can
                //  proceed. So we cannot yet null out each bin.
                HashEntry<K,V> e = oldTable[i];  // 依次指向旧table中的每个桶的链表表头

                if (e != null) {    // 旧table的该桶中链表不为空
                    HashEntry<K,V> next = e.next;
                    int idx = e.hash & sizeMask;   // 重哈希已定位到新桶
                    if (next == null)    //  旧table的该桶中只有一个节点
                        newTable[idx] = e;
                    else {    
                        // Reuse trailing consecutive sequence at same slot
                        HashEntry<K,V> lastRun = e;
                        int lastIdx = idx;
                        for (HashEntry<K,V> last = next;
                             last != null;
                             last = last.next) {
                            int k = last.hash & sizeMask;
                            // 寻找k值相同的子链,该子链尾节点与父链的尾节点必须是同一个
                            if (k != lastIdx) {
                                lastIdx = k;
                                lastRun = last;
                            }
                        }

                        // JDK直接将子链lastRun放到newTable[lastIdx]桶中
                        newTable[lastIdx] = lastRun;

                        // 对该子链之前的结点,JDK会挨个遍历并把它们复制到新桶中
                        for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                            int k = p.hash & sizeMask;
                            HashEntry<K,V> n = newTable[k];
                            newTable[k] = new HashEntry<K,V>(p.key, p.hash,
                                                             n, p.value);
                        }
                    }
                }
            }
            table = newTable;   // 扩容完成
        }

在扩容时,首先会创建一个容量为原来容量两倍的数组,然后将元数组的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,只会对某个segment进行扩容。由于扩容是按照2的幂次方进行的,所以扩展前在同一桶中的元素,现在要么在原来的序列号的桶里,或者就是原来的序列号再加上一个2的幂次方,就这两种选择

9.ConcurrentHashMap的读取操作:get(Object key)

public V get(Object key) {
        int hash = hash(key.hashCode());        //在哈希
        return segmentFor(hash).get(key, hash); //segmentFor(hash)定位到某个segment
    }

查询一个指定的键值对时,首先会定位其存在的段,然后查询委托给这个段处理。

V get(Object key, int hash) {
            if (count != 0) {            // read-volatile,首先读 count 变量,count用于统计当前Segment的大小
                HashEntry<K,V> e = getFirst(hash);   // 获取桶中链表头结点
                while (e != null) {        
                    if (e.hash == hash && key.equals(e.key)) {  // 查找链中是否存在指定Key的键值对
                        V v = e.value;      
                        if (v != null)  // 如果读到value域不为 null,直接返回
                            return v;   
                        // 如果读到value域为null,说明发生了重排序,加锁后重新读取
                        return readValueUnderLock(e); // recheck    
                    }
                    e = e.next;    //取链表的下一个HashEntry
                }
            }
            return null;  // 如果不存在,直接返回null
        }

从代码中可以看出,整个get的过程中不需要加锁,除非读到空值才会加锁重读。ConcurrentHashMap的get操作能做到不加锁,因为它的get方法里将要使用的共享变量定义成volatile类型(count用于统计segment大小,value用于存储HashEntry的value值),定义为volatile的变量,能够在线程之间保持可见性,能够被立即被读线程读取。

注意:HashEntry的key、value、next都是被final修饰的,这意味着,不能把节点添加到链表的中间和尾部,也不能在链表的中间和尾部删除节点。这个特性可以保证:在方法某个节点时 ,这个节点的后面的节点是不会改变。这个特征可以大大降低处理链表时的复杂性

出现返回value值为null的场景:初始化HashEntry时发生的指令重排导致的,也就是在HashEntry初始化完成之前便返回了它的引用。这时,JDK给出解决方案就是加锁重读。

V readValueUnderLock(HashEntry<K,V> e) {
            lock();                //加锁,等待写完成后
            try {
                return e.value;    //返回value值
            } finally {
                unlock();
            }
        }

ConcurrentHashMap读操作不需要加锁的秘密:

1)HashEntry中的key、hash和next指针都是final的,意味着我们不能把节点添加到链表的中间和尾部,也不能在链表的中间和尾部删除节点。这个特征可以保证,在访问某个节点时,这个节点之后的链接不会改变,这个特征可以大大降低处理链表时的复杂性。对put操作分析:

 {         // 该桶中不存在相同key的结点
                    oldValue = null;
                    ++modCount;     // 结构性修改,modCount加1
                    tab[index] = new HashEntry<K,V>(key, hash, first, value);  // 创建HashEntry并将其链到表头
                    count = c;      //write-volatile,count值的更新一定要放在最后一步(volatile变量)
                }

put操作的代码如上所示,我们知道put操作如果需要插入一个新节点到链表时需要在链表头部插入这个新节点,此时链表的原有节点的链接并没有被修改,也就是说,插入新节点中操作不会影响读线程正常遍历这个链表具体分析tab[index] = new HashEntry<K,V>(key,hash,first,value),一种是读的时候新节点已经完全插入到链表中,此时变量的就是新的链表 ,一种是读的时候刚执行完new HashEntry<K,V>(key,hash,first,value),此时新节点的next指向了头节点,但是还没有来得及将新头结点放入HeshEntry数组中,此时读的时候遍历的还是旧的链表。还有一种情况创建新的HashEntry节点时发生了指令重排,此时新结点的空间已经分配了,但是还没有分配数据,此时读操作会发生读取的value值为空的情况,这种情况下会加锁重读

对remove操作分析:

 V remove(Object key, int hash, Object value) {
            lock();     // 加锁
            try {
                int c = count - 1;      
                HashEntry<K,V>[] tab = table;
                int index = hash & (tab.length - 1);        // 定位桶
                HashEntry<K,V> first = tab[index];        //找到头节点
                HashEntry<K,V> e = first;                //将头节点赋值给e
                while (e != null && (e.hash != hash || !key.equals(e.key)))  // 查找待删除的键值对
                    e = e.next;            //找到待删除的节点e

                V oldValue = null;
                if (e != null) {    // 找到
                    V v = e.value;            //保存待删除节点的value值
                    if (value == null || value.equals(v)) {
                        oldValue = v;
                        // All entries following removed node can stay
                        // in list, but all preceding ones need to be
                        // cloned.
                        ++modCount;
                        // 所有处于待删除节点之后的节点原样保留在链表中
                        HashEntry<K,V> newFirst = e.next;
                        // 所有处于待删除节点之前的节点被克隆到新链表中
                        for (HashEntry<K,V> p = first; p != e; p = p.next)
                            newFirst = new HashEntry<K,V>(p.key, p.hash,newFirst, p.value); 

                        tab[index] = newFirst;   // 将删除指定节点并重组后的链重新放到桶中
                        count = c;      // write-volatile,更新Volatile变量count
                    }
                }
                return oldValue;
            } finally {
                unlock();          // finally子句解锁
            }
        }

疑问:删除数组,如果不是定义为final,那么直接使用A.next = c会出现场什么问题。难道A.next = c不是原子操作。

图 4. 执行删除之前的原链表:

图 5. 执行删除之后的新链表

2)与此同时,由于HashEntry类的value字段被声明为volatile的,因此Java的内存模型就可以保证:某个写线程对value字段的写入马上就可以被后续的某个读线程看到。

3)ConcurrentHashMap不允许用null作为键和值,所以当读线程读到某个HashEntry的value为null时,便知道产出了冲突,发生了指令重排现象,此时便会加锁重新读入这个value值。

10.统计整个ConcurrentHashMap里元素的大小:

因为类Segment中定义了count属性,所以每个Segment对象都有一个count值代表每个Segment中HashEntry对象的个数,所以可以将每个Segment中的count值相加就等于ConcurrentHashMap的大小。多线程情况下,刚加完前5个Segment的count值,第2个Segment的值又被改变了(在第二个Segment中又添加了新HashEntry对象),所以最先想到的是在统计size的时候把所有Segment的put、remove和clean方法都锁住,但这种做法显然非常低效。因为在累加count操作过程中,之前累加过的count发生变化的几率很小,所以ConcurrentHashMap中采用的先在不锁住Segment的情况下尝试2次统计所有Segment大小,如果统计的过程中,容器modCount发生了变化,再采用对所有Segment加锁的方法来统计所有Segment的count的大小。

11.CurrentHashMap和HashMap的区别

线程安全性+键和值是否可以为null+使用迭代器遍历的时候是否允许修改

具体来说:

哈希值的获取:HashMap是再哈希算法是高位和低位异或

                         ConcurrentHashMap是使用复杂的再哈希算法

12.JDK8中的currentHashMap

JDK8摒弃了分段锁的方案,直接使用一个大的数组。Java8在链表长度超过阈值(默认为8)的时候,将链表转为红黑树。

对于put操作,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素不为空(即链表的表头或树的根结点不为空),则对该元素使用synchronized关键字申请锁。

参考:Java并发编程的艺术

https://blog.csdn.net/justloveyou_/article/details/72783008

http://www.jasongj.com/java/concurrenthashmap/

https://www.cnblogs.com/ITtangtang/p/3948786.html

https://blog.csdn.net/u010412719/article/details/52145145

http://www.jasongj.com/java/concurrenthashmap/

猜你喜欢

转载自blog.csdn.net/chenkaibsw/article/details/80380760
今日推荐