zoukankan      html  css  js  c++  java
  • HashMap源码解析、jdk7和8之后的区别、相关问题分析(多线程扩容带来的死循环)


    一、概览


    HashMap<String, Integer> map = new HashMap<>();
    

    这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[] 数组,叫 table,用来存储键值对。

    在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put 的时候才会创建数组叫 Node[] table ,用来存储键值对。


    二、源码的成员变量分析


    声明部分

    HashMap 实现了 Map 接口,又继承了 AbstractMap,但是 AbstractMap 也是实现了 Map 接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。

    1. 继承 AbstractMap ,这个父类作为抽象类,实现了 Map 的很多方法,为了减少直接实现类的工作;
    2. 实现 Cloneable 接口和 Serializable 接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝

    属性部分

    2.1 序列号serialVersionUID

    序列化默认版本号,不重要。

    2.2 默认初始化容量DEFAULT_INITIAL_CAPACITY

    集合默认初始化容量,注释里写了必须是 2 的幂次方数,默认是 16。

    问题 1 : 为什么非要是 2 的次方数呢?

    答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。

    这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:

    核心在这个方法里,会根据 (n - 1) & hash 这个公式计算出 ihash 是提前算出的 key 的哈希值,n 则是整个 map 的数组的长度。

    那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[] 的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。

    就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:


    2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)

    那么和 本来计算的具有唯一性的 hash 值相与,

    1. 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
    2. 保证了 hash 值的尽量散开。


    对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
    这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。

    因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。

    第二个方面的原因就是扩容的时候,重新要计算下标值 hash2 的幂次方带给了好处,下面的扩容部分有详细说明。

    注意到我们初始化 HashMap 的时候可以指定容量。

    问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?

    从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor 方法,此时传入的参数已经肯定是 <= 最大容量的数字了。

    tableSizeFor 这个方法会产生一个大于传入数字的、最小的 2 的幂次方数。

    2.3 最大容量MAXIMUM_CAPACITY

    最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方

    2.4 默认加载因子DEFAULT_LOAD_FACTOR

    默认加载因子为 0.75 ,也就是说,如果键值对超过了当前的容量 * 0.75 ,就会触发扩容。

    问题 为什么是 0.75 而不是别的数呢?

    答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。

    其实 0.75 是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75 的情况下,哈希碰撞的概率遵循参数约为 0.5 的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。

    2.5 转树的阈值TREEIFY_THRESHOLD

    默认为 8 ,也就是说一个桶内的链表节点数多于 8 的时候,结合数组当前长度会把链表转换为红黑树。

    问题 为什么是超过 8 就转为红黑树?

    答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。

    结合负载因子 0.75泊松分布结果,每个链表有 8 个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8 。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。

    所以到这里我个人的理解是:
    -> 最开始hashmap的思想就是数组加链表;
    -> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
    -> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.

    2.6 重新退化为链表的阈值UNTREEIFY_THRESHOLD

    默认为 6, 也就死说如果操作过程发现链表的长度小于 6 ,又会把树退回链表。

    2.7 转树的最小容量

    不仅仅是说有链表的节点多于 8 就转换,还要看 table 数组的长度是不是大于 64 ,只有大于 64 了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。

    还有属性的第二部分:

    第一个是容器 table 存放键值对的数组,就是保存链表或者树的数组,可以看到 Node 类型也是实现了 Entry 接口的,在 1.8 之前这个节点是不叫 Node 的,就叫的 Entry,因为就是一个键值对,现在换成了 Node,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode 类型,所以不是 Entry了。

    第二个是保存所有键值对的一个 set 集合,是一个存放缓存的;
    第三个 size 是整个hashmap 里的键值对的数目;
    第四个是 modCount 是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
    第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
    第六个 loadFactor 也是对应前面的加载因子。


    三、源码的核心方法分析


    3.1 构造方法

    可以看到,这几个重载的构造方法做的事就是设置一些参数。

    事实上,在 jdk1.8 之后,并不会直接初始化 hashmap,只是进行加载因子、容量参数的相关设定,真正开始将 table 数组空间开辟出来,是在 put 的时候才开始的。

    第一个:

    public HashMap()
    

    是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16

    第二个:

    public HashMap(int initialCapacity)
    

    为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity,在这个方法里面直接调用的是

    第三个构造方法:

    public HashMap(int initialCapacity, float loadFactor)
    

    用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数

    这里就是一个问题,把 capcity 调整成 2 的幂次方数,计算 threshold 的时候不应该要乘以 loadfactor 吗,怎么能直接赋给 threshold 呢?

    原因是这里没有用到 threshold ,还是在 put 的时候才进行 table 数组的初始化的,所以这里就没有操作。

    最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。

    3.2 put 和 putVal 方法

    put 方法是直接调用了计算 hash 值的方法计算哈希值,然后交给 putVal 方法去做的。

    hash 方法就是调用本地的 hashCode 方法再做一个位移操作计算出哈希值。

    为什么采用这种右移 16 位再异或的方式计算 hash 值呢?

    因为 hashCode 值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int 类型的数值是 4 个字节的,右移 16 位,再异或可以同时保留高 16 位低 16 位的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。

    另外,因为,异或运算能更好的保留各部分的特征,如果采用 & 运算计算出来的值会向 1 靠拢,采用 | 运算计算出来的值会向 0 靠拢, ^ 正好。

    最后的目的还是一样,为了减少哈希冲突。

    算出 hash 值后,调用的是 putVal 方法:

    传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。

    下面是源码及具体注释:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;//调用resize方法初始化tab,验证了我们说的,构造方法不会创建数组,而是插入的时候创建。
    
        //这个算法前面也已经讲过,就是计算索引,如果p的位置是 null,就在这里放入一个newNode;
        //如果p的位置不是 null,说明这个桶里已经有链表或者树了,就不能直接 new ,而是要遍历链表插入,并同时判断是不是需要转树
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                //已经不是链表是红黑树了,调用putTreeVal
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //是链表,用 for 循环遍历
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    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;//如果已经有值,覆盖,这里用到了onlyIfAbsent
                afterNodeAccess(e);
                return oldValue;
            }
        }
    
        //增加修改hashMap的次数
        ++modCount;
        //如果已经达到了阈值,就要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    这里面涉及到的步骤主要如下:

    1. 调用 resize 方法初始化 table 数组,jdk1.8 后确实是到 put 的时候才会初始化数组;

    2. hash 值计算出在数组里应该在的索引;

    3. 如果索引位置是 null,就直接放入一个新节点,也就是 Node 对象;

    4. 如果不是 null,则要在这个桶里插入:

      1. 如果遇见了一个节点的 hash 值、key值和传入的这个新的一样,赋值给 e 这个节点;
      2. instanceof 判断是否为 TreeNode 类型,也就是说如果这个桶里已经不是链表而是红黑树了,就调用 putTreeVal 方法;
      3. 如果不是,那就要遍历这个链表,同理,遍历的过程如果也找到了一个阶段的 hash 值、key 值和传入的一样,赋值给 e 这个节点,否则遍历到最后,把一个 Node 对象插到链表末尾,插完后链表长度已经大于阈值,就要转树。
    5. 结束插入的动作后,前面的 e 一旦被赋值过了,说明是有一样的 key 出现,那么就说明不用插入新节点,而是替代旧的 val

    这里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比较复杂的方法,下来进行介绍。

    3.3 treeifyBin 方法

    转换为树的方法

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
    
        //如果数组的长度还没有达到 64 ,就不转树,只是扩容。
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
            
        //如果 e 不为空,那么遍历整个链表,把每个节点都换成具有prev和next两个指针的树节点
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            //结束后要开始把一个普通的树(此时其实严格上说是一个双链表的形态)转化成红黑树
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
    

    treeify 里面调用了各种左旋啊、右旋啊,平衡啊,各种很复杂的红黑树操作方法,这里不再深入。

    3.4 resize 扩容方法

    问题:什么时候会扩容?

    从前面成员变量的解释和插入元素,已经能总结出两种扩容的情况:

    1. 当键值对的元素个数(也就是键值对的个数,size)超过了 数组长度*负载因子(0.75)的时候,扩容;
    2. 当其中某一个链表的元素个数达到 8 个,并且数组长度没有达到 64 ,则扩容而不转红黑树。

    扩容每次都会把数组的长度扩到 2 倍,并且之后还要把每个元素的下标重新计算,这样的开销是很大的。

    值得注意的是,重新计算下标值的方法 和第一次的计算方法一样,这样很简便且巧妙:

    • 首先,仍然使用 (n - 1) & hash 这个式子计算索引,但是显然有重新计算的时候,变化的是 n-1,有些就不会在原位置了;
    • n 的变化入手,因为是 2 倍扩容,而数组长度本身也设置是 2 的幂次,在二进制位上来说,新算出来的 n-1 只是相比旧的 n-1 左移了一位;
    比如 16-1 = 15,就是  1 0000 - 1 =  0 1111;
    新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
    
    • 那么这个值再和 hash 相与运算,节点要么在原来位置,要么在原位置+旧的容量的位置,也就是在最高位加上了一个原来的容量;
    • 这样计算的时候就不用频繁的再计算,而是用一个加法就直接定位到要挪动的地方。

    上面讲过的为什么长度设置 2 的幂次,这里也能作为一个优势的解释。

    源码如下:

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
    
        int newCap, newThr = 0;//新的容量和新的阈值
    
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; //这里把新的阈值和新的边界值都*2
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
    
    
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
    
        //创建新数组
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
    
    
        if (oldTab != null) {
            //for循环就开始把所有旧的节点都放到新数组里
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;//如果这个位置本来就只有一个元素,还用旧方法计算位置
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//如果是树节点,拆分
                    else { 
                        //是链表,保持顺序,用do-while循环进行新的位置安排
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {//用hash和oldCap的与结果,拆分链表
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }else {//用hash和oldCap的与结果,拆分链表
                                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;//放在新索引位置,就是加上 oldCap 
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    3.5 remove 和 removeNode 删除方法

    remove 直接调用的 removeNode 方法,类似于前面的 put 调用 putVal 。

    注意 remove 根据 key 的时候肯定默认那个对应的 value 也是要删除的,所以 matchValue 置为 false,意思就是不用看 value

    removeNode 的整体思路比较常规,就是我们能想到的:

    1. 如果本身 hashmap 不为空,且 hash 值对应的索引位置不为空,才去某一个桶里找并删除;

      1. 在遍历查找的过程里,分成对于链表节点和树节点的查找,就是根据 key 来比较的;
      2. 找到之后,根据 matchValue 判断要不要删除,删除的过程就是用之前找到的那个位置,然后指针操作就可。
    2. 否则,直接返回 null

    3.6 get 和 getNode 方法

    get 也只直接调用了 getNode 方法:

    这里面的代码就和 remove 方法的前半部分几乎一样,也就是找到指定的 key 的位置,并返回对应的 value

    3.7 HashMap的遍历

    HashMap 本身维护了一个 keySet 的 Set,拿到所有的 key 。(显然维护 value 是没办法的,因为 key 都是唯一的),但这种方法不推荐,因为拿到 key 后再去找 value又是对 map 的遍历。

    Set<String> keys = map.keySet();
    for (String key: keys){
        System.out.println(key + map.get(key));//根据key得到value
    }
    

    也可以拿到所有的 value 需要用 Collection 来接收:

    Collection<Integer> values = map.values();
    for (Integer v: values){
        System.out.println(v);
    }
    

    也可以获取到所有的键值对Entry 的 Set 集合,然后拿到对应的迭代器进行遍历:

    Set<Map.Entry<String,Integer>> entries = map.entrySet();
    Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();
    
    while (iterator.hasNext()){
        Map.Entry<String,Integer> entry = iterator.next();
        System.out.println(entry.getKey()+entry.getValue());//得到key和value
    }
    

    jdk 1.8 之后,还增加了一个 forEach 方法,可以接口里的这个方法本身也是通过第二种方法实现的,在HashMap 里重写了这个方法,变成了对 table 数组的遍历,使用的时候,用 lambda 表达式传入泛型就可以。

    map.forEach((key,value)->{
        System.out.println(key + value);
    });
    

    这种方法其实用到的也属于设计模式的代理模式


    四、总结 jdk 1.7 和 1.8 之后关于 HashMap 的区别


    4.1 数据结构的使用

    • 1.7 :单链表
    • 1.8 :单链表,如果链表长度>8且数组长度已经>64,转为红黑树

    关于数组本身,1.7 是一个 Entry 类型的数组,1.8是一个 Node 类型。

    4.2 什么时候扩容?

    1.7 扩容时机

    • 扩容只有一种情况。利用了两个信息:
    1. 数组长度 * 加载因子。加载因子默认情况是 0.75 ,等键值对个数 size 达到了数组长度 * 加载因子
    2. 产生哈希冲突,当前插入的时候数组的这个位置已经不为空了。

    扩容后,添加元素。

    1.8 的扩容时机

    先添加元素,再看是否需要扩容。

    • 扩容的第一种情况。

    数组长度 * 加载因子。加载因子默认情况是 0.75 ,等键值对个数 size 达到了数组长度 * 加载因子(这点判断是一样的)

    • 扩容的第二种情况。

    当其中某一个链表的元素个数达到 8 个,走到转树节点的方法里,但是又发现数组长度没有达到 64 ,则扩容而不转红黑树。

    4.3 扩容的实现

    1.7 扩容的实现

    • 数组长度 * 2 操作;
    • 然后用一个 transfer 方法进行数据迁移,transfer 里,对单向链表进行一个一个 hash 重新计算并且安排,采用头插法来安排单向链表,把节点都安排好。

    但是如果多线程的情况下,有别的线程先完成了扩容操作,这个时候链表的重新挪动已经导致节点位置的变化,切换回这个线程的时候,继续改变链表指针就可能会产生环,然后这个线程死循环。

    具体就是 7 的扩容方法在迁移的时候采用的是头插法,那么比如两个元素 ab一个链表,线程1和2都发现要扩容,就会去调用transfer方法:

    1. 1 先读取了 e 是 a,next 是 b,但是没来得及继续操作就挂起了;
    2. 2 开始读取,并采用头插法就是遍历ab,先把a移到新数组的位置,此时a.next = null;继续遍历到 b,b移到新位置,b.next = a;(形成了 b->a)
    3. 这时候切换到了线程 1 执行,本来已经再循环里面记录了 e 和 e.next 了,然而这时本来数组都变新的了,所以修改的时候计算位置啥的还是这个新数组里,不会变,因为计算的肯定是一样的, a.next = b,而前面就修改过了b.next = a,这样已经是环了,那么线程 1 继续while,一直next,死循环。

    1.8 扩容的实现

    因为是先插入,再扩容,所以插入的时候对于链表就是一个尾插法。

    然后如果达到了扩容的条件,也就先进行数组长度 * 2 操作,直接在 resize 方法里完成数据迁移,这里因为数据结构已经有链表+红黑树两种情况:

    1. 如果是链表,把单链表进行数据迁移,充分利用与运算,将单链表针对不同情况拆断,放到新数组的不同位置;
    2. 如果是红黑树,树节点里维护了相当于双向链表的指针,重新处理,如果处理之后发现树的节点(双向链表)小于等于 6 ,还会再操作把树又转换为单链表。

    但是如果在多线程的情况下,不会形成环链表,但是可能会丢失数据,因为会覆盖到一样的新位置。

    4.4 为什么HashMap线程不安全

    1. put、get 等等核心方法在多线程情况下,都会出现修改的覆盖,数据不一致等等问题。比如多个线程 put 先后的问题,会导致结果覆盖,如果一个 put 一个get,也可能会因为调度问题获取到错误的结果。
    2. 正如上面具体分析过的死循环问题,在多线程扩容的时候,1.7的 hashmap 因为采用头插法进行扩容之后的重新节点分配,可能会出现死循环;
    3. 因为 Hashmap 的迭代器是 fast-fail iterator,所以多线程一边写操作一边遍历,会出现 ConcurrentModificationException 并发读写异常。
  • 相关阅读:
    html中滚动条的样式
    在个人机上发布web项目
    Apache与SVN的集成
    待完成
    chmod
    【转】ubuntu修改IP地址和网关的方法
    ubuntu 添加svn服务
    生成指定大小的空文件
    数码单反相机完全攻略
    【转】ubuntu subversion安装
  • 原文地址:https://www.cnblogs.com/lifegoeson/p/13628737.html
Copyright © 2011-2022 走看看