ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

HashMap源码阅读(jekyll迁移)

2020-08-12 21:34:28  阅读:245  来源: 互联网

标签:Node hash HashMap jekyll 源码 key null 节点


layout: post
title: HashMap源码阅读
date: 2020-02-02
author: xiepl1997
tags: 源码阅读

下面是JDK11中HashMap的源码分析,对代码的分析将主要以注释的方式来体现。

1 概述

1.1 HashMap的主要概念

  1. HashMap是基于Map接口实现的哈希表,实现了Map接口中的所有操作,而且HashMap允许键为空值,也允许值为空值,与之对应的是Hashtable,Hashtable不能将键和值设置为空。HashMap不能保证元素的顺序,特别是,它不能保证随着时间的推移保持顺序不变。

  2. HashMap为基本操作(get和put)提供了恒定的时间性能,假设散列函数在木桶(buckets)中适当地分散了元素。集合(Collection)的迭代需要的时间与HashMap实例的“容量”和它的大小成比例。因此,如果迭代的性能很重要,要求很高,那么不将初始容量设置得太高(或负载因素过低)是非常重要的。

  3. HashMap是线程不安全的集合,即当多线程访问时,同一时刻如果无法保证只有一个线程修改HashMap,则会毁坏HashMap,抛出ConcurrentModificationException。

1.2 HashMap的基本实现

HashMap底层使用哈希表(数组 + 单链表),当链表过长会将链表转成红黑树以实现O(logn)时间复杂度内查找。
HashMap的定义为class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable

1.3 HashMap内部类

1.Node
2.KeySet
3.Values
4.EntrySet
5.HashIterator
6.KeyIterator
7.ValueIterator
8.EntryIterator
9.HashMapSpliterator
10.KeySpliterator
11.ValueSpliterator
12.EntrySpliterator
13.TreeNode 代表红黑树节点,HashMap中对红黑树的操作的方法都在此类中

1.4 扩容原理

HashMap采用的扩容策略是,每次加倍的方式。这样,原来位置的Entry在新扩展的数组中要么依然在原来的位置,要么在原来的位置+原来的容量的位置。

1.5 hash计算

HashMap通过hash()函数(也叫“扰动函数”)来计算hash值,方法为key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;,计算出来的hash值存放在Node.hash中。

hash的值计算相当于将高16位与底16位进行异或,结果是高16位不变,底16位变成异或的新结果。为什么这样做呢,原因是HashMap扩容之前的数组大小才为16,散列值是不能直接拿来用的。在进行长度取模运算时采用的只是取二进制中的最右端的几位,并没有用到高位二进制的信息,做带来的结果就是hash结果分布不太均匀。而将高16位和底16位异或后就可以让低位附带高位的信息,加大低位的随机性。

在对散列值做完高低位的异或操作后,在对异或结果进行对长度的取模得到最终的结果。具体参考JDK源码中HashMap的hash方法原理是什么?-胖君的回答-知乎

1.6 插入null原理

在hash计算中,null的hash值为0,然后按照正常的putVal()插入。

1.7 new HashMap()

从源码中(下文构造函数)我们可以看到,new HashMap()开销非常少,仅仅确认装载因子。真正的创建table的操作尽可能的往后延迟,这使得HashMap有不少操作都需要检查table是否初始化。这种设计有一种好处,就是能够不必担心HashMap的开销,可以一次性大量的创建HashMap。

2 HashMap的成员变量

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
	//用于序列化
	private static final long serialVersionUID = 362498820763181265L;
	//HashMap的默认容量是16
	static final int DEFAULT_INITIAL_CAPACITY = 16;
	//最大容量为1073741824(2的30次方,即1<<30)
	static final int MAXIMUM_CAPACITY = 1073741824;
	//默认装载因子为0.75f
	static final float DEFAULT_LOAD_FACTOR = 0.75F;
	/*
	将链表转化为红黑树的阈值为8,即当链表长度 >= 8时,链表转化为红黑树,也就是树形化。
	为什么要树形化呢?想一下我们为什么要用HashMap,是因为通过Hash算法在理想情况下时间复杂度O(1)就能找到元素,特别快,但仅限于理想情况下,如果遇到了hash碰撞,且碰撞比较频繁的话,那么当我们get一个元素的时候,定位到了这个数组,还需要在这个数组中遍历一次链表最终才能找到要get的元素,是不是已经失去了hashmap的初心了?(因为需要遍历链表,所以时间复杂度就高上去了)。
	所以使用红黑树这种数据结构来解决链表过长的问题,可以理解为红黑树遍历比链表遍历快,时间复杂度低。
	*/
	static final int TREEIFY_THRESHOLD = 8;
	//将红黑树转化成链表的阈值为6(<6时),这个是在resize()的过程中调用TreeNode.split()实现
	static final int UNTREEIFY_THRESHOLD = 6;
	/*
	最小树形化阈值。要树化并不仅仅是要超过TREEIFY_THRESHOLD,同时容量要超过MIN_TREEIFY_CAPACITY,如果只是超过TREEIFY_THRESHOLD,则会进行扩容(调用resize())。为什么这个时候是扩容而不是树形化呢?
	原因就在于,造成链表过长也可能是数组(桶)太短了也就是容量太小了。举个例子,如果数组长度为1,那么所有的元素都挤在了数组的第0个位置上,这个时候就算树形化只是治标不治本,因为引起链表过长的根本原因是数组过短。
	所以在执行树形化之前(链表长度>=8),会检查数组长度,如果长度小于64,则对数组进行扩容,而不是树形化。
	*/
	static final int MIN_TREEIFY_CAPACITY = 64;
	/*
	哈希表的数组主体定义,初始化时,在构造函数中并不会初始化,所以在各种操作中总是要先检查table是否为null。
	*/
	transient HashMap.Node<K, V>[] table;
	/*
	作为一个entrySet缓存,使用entrySet首先检查其是否为null,不为null则使用这个缓存,否则生成一个entrySet并缓存至此。
	*/
	transient Set<Entry<K, V>> entrySet;
	//HashMap中的Entry的数量
	transient int size;
	/*
	记录修改内部结构化修改次数,用于实现fail-fast,ConcurrentModificationException就是通过检测这个抛出。
	*/
	transient int modCount;
	//其值=capacity*loadFactor,当size超过threshold的时候便进行一次扩容
	int threshold;
	//装载因子
	final float loadFactor;

	……
}

3 HashMap的方法

3.1 构造函数

  1. 该构造函数进行对容量和装载因子的合法性验证,然而没有对容量进行存储,只是用来确定扩容阈值threshold。
public HashMap(int initialCapacity, float loadFactor) {
	if (initialCapacity < 0) {
		throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
	} else {
		if (initialCapacity > 1073741824) {
			initialCapacity = 1073741824;
		}

		if (loadFactor > 0.0F && !Float.isNaN(loadFactor)) {
			this.loadFactor = loadFactor;
			this.threshold = tableSizeFor(initialCapacity);
		} else {
			throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
		}
	}
}
  1. 只传入初始容量
public HashMap(int initialCapacity) {
	this(initialCapacity, 0.75F);
}
  1. 无参构造函数仅仅确认装载因子
public HashMap() {
	this.loadFactor = 0.75F;
}
  1. 通过Map构造HashMap时,使用默认装载因子,并调用putMapEntries将Map装入HashMap
	public HashMap(Map<? extends K, ? extends V> m) {
		this.loadFactor = 0.75F;
		this.putMapEntries(m, false);
	}

	final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
		//获取该map实际长度
	    int s = m.size();
	    if (s > 0) {
	    	//判断table是否初始化,如果没有初始化
	        if (this.table == null) {
	        	/**求出需要的容量,因为实际使用的长度=容量*0.75得来的,+1是因为小数相除,基本都不会是整数,容量大小
				不能为小数的,后面转化为int,多余的小数就要被丢掉,所以+1,例如,map实际长度为29.3,则所需要的容量为30.
	        	*/
	            float ft = (float)s / this.loadFactor + 1.0F;
	            //判断该容量大小是否超出上限
	            int t = ft < 1.07374182E9F ? (int)ft : 1073741824;
	            //对临界值进行初始化,tableSizeFor(t)这个方法会返回大于t值的,且离其最近的2次幂,例如t为29,则返回的值是32
	            if (t > this.threshold) {
	                this.threshold = tableSizeFor(t);
	            }
	        } else if (s > this.threshold) {  //如果table已经初始化,则进行扩容操作,resize就是扩容
	            this.resize();
	        }

	        Iterator var8 = m.entrySet().iterator();

	        //遍历,把map中的数据转移到hashmap中
	        while(var8.hasNext()) {
	            Entry<? extends K, ? extends V> e = (Entry)var8.next();
	            K key = e.getKey();
	            V value = e.getValue();
	            this.putVal(hash(key), key, value, false, evict);
	        }
	    }

	}

该构造函数,传入一个Map,然后把该Map转为hashMap,resize方法在下面添加元素的时候会详细讲解,在上面entrySet方法会返回一个Set<Map.Entry<K,V>>,泛型为Map的内部类Entry,它是一个存放key-value的实例,也就是Map中的每一个key-value就是一个Entry实例,为什么使用这个方式进行遍历,因为效率高,putVal方法把取出来的每个key-value存入到hashMap中。

3.2 hash(Object key)

hash函数负责产生hashcode,计算方法为若为空则返回0,否则返回对key的高16位和底16位的异或的结果。

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

3.3 comparableClassFor(Object x)

这个方法是判断传入的Object对象x是否实现了Comparable接口,如果传入的是String对象,自然实现了Comparable接口,直接返回就行。但是对于其他的类,比方说我们自己写了一个类对象,然后存在HashMap中,但是就HashMap来说它并不知道我们有没有实现Comparable接口,甚至都不知道我们Comparable接口中有没有用泛型,泛型具体用的是哪个类。

static Class<?> comparableClassFor(Object x) {
	if (x instanceof Comparable) {
		Class c;
		if ((c = x.getClass()) == String.class) {
			return c;
		}

		ype[] ts;
		if ((ts = c.getGenericInterfaces()) != null) {
			Type[] var5 = ts;
			int var6 = ts.length;

			for(int var7 = 0; var7 < var6; ++var7) {
				Type t = var5[var7];
				Type[] as;
				ParameterizedType p;
				if (t instanceof ParameterizedType && (p = (ParameterizedType)t).getRawType() == Comparable.class && (as = p.getActualTypeArguments()) != null && as.length == 1 && as[0] == c) {
					return c;
				}
			}
		}
	}
	return null;
}

3.4 compareComparables(Class<?> kc, Object k, Object x)

如果x为空,返回0;如果x的类型为kc,则返回compareTo(x)。

static int compareComparables(Class<?> kc, Object k, Object x) {
	return x != null && x.getClass() == kc ? ((Comparable)k).compareTo(x) : 0;
}

3.5 tableSizeFor(int cap)

该函数用于计算大于等于cap的的最小的2的整数幂,用于做table的长度。numberOfLeadingZeros()方法的作用是返回无符号整形i的最高非零位前面的0的个数,包括符号位在内。

static final int tableSizeFor(int cap) {
	int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
	return n < 0 ? 1 : (n >= 1073741824 ? 1073741824 : n + 1);
}

3.6 put(K key, V value)

	public V put(K key, V value) {
		//四个参数,第一个hash值,第四个参数表示如果该key存在值,如果为null的话,则插入新的value,最后一个参数,在hashMap中没有用,可以不用管。
        return this.putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    	//tab哈希数组,p为该哈希桶的首节点,n为hashMap的长度,i为计算出的数组下标
        HashMap.Node[] tab;
        int n;
        //获取长度并扩容,使用的是懒加载,table一开始是没有加载的,等puthou才开始加载
        if ((tab = this.table) == null || (n = tab.length) == 0) {
            n = (tab = this.resize()).length;
        }

        Object p;
        int i;
        //如果计算出的该哈希桶的位置没有值,则把新插入的key-value放到此处,此处就算没有插入成功,也就是发生哈希冲突时也会把哈希桶的首节点赋予p
        if ((p = tab[i = n - 1 & hash]) == null) {
            tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);
        } else {  //发生哈希冲突的几种情况
        	//e临时节点的作用,k存放当前节点的key值
            Object e;
            Object k;
            //第一种,插入的key-value的hash值,key都与当前节点相等,e=p,则表示为首节点
            if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
                e = p;
            }
            //第二种,hash值不等于首节点,判断该p是否属于红黑树的节点
            else if (p instanceof HashMap.TreeNode) {
            	/*
            	为红黑树的节点,则在红黑树中进行添加,如果该节点已经存在,则返回该节点(不为null),
            	该值很重要,用来判断put操作是否成功,如果添加成功返回null
            	*/
                e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
            }
            //第三种,hash值不等于首节点,不为红黑树节点,则为链表的节点
            else {
            	//遍历该链表
                int binCount = 0;
                while(true) {
                	//如果找到尾部,则表明添加的key-value没有重复,在尾部进行添加。
                    if ((e = ((HashMap.Node)p).next) == null) {
                        ((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
                        //判断是否要转化为红黑树结构
                        if (binCount >= 7) {
                            this.treeifyBin(tab, hash);
                        }
                        break;
                    }

                    //如果链表有重复的key,e为当前重复的节点,结束循环。
                    if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
                        break;
                    }

                    p = e;
                    ++binCount;
                }
            }
            //e不为null,则说明有重复的key,则用待插入值进行覆盖,返回旧值。
            if (e != null) {
                V oldValue = ((HashMap.Node)e).value;
                if (!onlyIfAbsent || oldValue == null) {
                    ((HashMap.Node)e).value = value;
                }

                this.afterNodeAccess((HashMap.Node)e);
                return oldValue;
            }
        }

        /*
        到了这步,说明待插入的key-value是没有key的重复,因为插入成功的e节点的值为null。
        修改次数+1
        */
        ++this.modCount;
        //实际长度+1,并判断是否大于临界值,大于则扩容
        if (++this.size > this.threshold) {
            this.resize();
        }

        this.afterNodeInsertion(evict);
        //添加成功
        return null;
    }

3.7 resize()

扩容方法resize()

    final HashMap.Node<K, V>[] resize() {
    	//把没有插入之前的哈希数组叫做oldTab
        HashMap.Node<K, V>[] oldTab = this.table;
        //oldTab的长度
        int oldCap = oldTab == null ? 0 : oldTab.length;
        //oldTab的临界值
        int oldThr = this.threshold;
        //初始化new的长度和临界值
        int newThr = 0;
        int newCap;
        //oldCap>0也就说明不是首次加载,因为hashMap用的是懒加载
        if (oldCap > 0) {
        	//如果大于最大值
            if (oldCap >= 1073741824) {
            	//将临界值设置为整数的最大值
                this.threshold = 2147483647;
                return oldTab;
            }
            //位置*。其他情况,扩容两倍,并且扩容后的长度要小于最大值,old的长度也要大于16
            if ((newCap = oldCap << 1) < 1073741824 && oldCap >= 16) {
            	//临界值也要扩容为old的2倍
                newThr = oldThr << 1;
            }
        }
        /*
        如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,
        如果是首次初始化,它的临界值则为0.
        */
        else if (oldThr > 0) {
            newCap = oldThr;
        }
        //首次初始化,给默认值
        else {
            newCap = 16;
            newThr = 12;  //临界值等于容量*0.75
        }
        //位置*的补充,也就是初始化时容量小于默认值16的,此时newThr没有赋值
        if (newThr == 0) {
        	//new的临界值
            float ft = (float)newCap * this.loadFactor;
            //判断new容量是否大于最大值,临界值是否大于最大值
            newThr = newCap < 1073741824 && ft < 1.07374182E9F ? (int)ft : 2147483647;
        }

        //把上面各种情况分析出的临界值,在此处进行真正的改变,也就是容量和临界值都改变了
        this.threshold = newThr;
        //初始化
        HashMap.Node<K, V>[] newTab = new HashMap.Node[newCap];
        //赋予当前的table
        this.table = newTab;
        //此处自然是把old中的元素,遍历到new中
        if (oldTab != null) {
            for(int j = 0; j < oldCap; ++j) {
            	//临时变量
                HashMap.Node e;
                //当前哈希桶的位置值不为null,也就是数组下标处有值,因为有值表示可能会发生冲突
                if ((e = oldTab[j]) != null) {
                	//把已经赋值之后的变量置位null,当然是为了好回收,释放内存
                    oldTab[j] = null;
                    //如果下标处的节点没有下一个元素
                    if (e.next == null) {
                    	//把该变量的值存入newTab中,e.hash & new Cap-1并不等于j
                        newTab[e.hash & newCap - 1] = e;
                    }
                    //如果该节点为红黑树结构,也就是存在hash冲突,该hash桶中有多个元素
                    else if (e instanceof HashMap.TreeNode) {
                    	//把此树转移到newTab中
                        ((HashMap.TreeNode)e).split(this, newTab, j, oldCap);
                    }
                    /*
                    此处表示为链表结构,同样把链表转移到newTab中,就是把链表遍历后,把值转过去,再置位null
                    */
                    else {
                        HashMap.Node<K, V> loHead = null;
                        HashMap.Node<K, V> loTail = null;
                        HashMap.Node<K, V> hiHead = null;
                        HashMap.Node hiTail = null;

                        HashMap.Node next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null) {
                                    loHead = e;
                                } else {
                                    loTail.next = e;
                                }

                                loTail = e;
                            } else {
                                if (hiTail == null) {
                                    hiHead = e;
                                } else {
                                    hiTail.next = e;
                                }

                                hiTail = e;
                            }

                            e = next;
                        } while(next != null);

                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }

                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        //返回扩容后的hashmap
        return newTab;
    }

3.8 remove(Object key)

删除元素

    public V remove(Object key) {
    	//临时变量
        HashMap.Node e;
        /*
        调用removeNode,第三个value表示,把key的节点直接都删除了,不需要用到值,
        如果设为值,则还需要去进行查找操作。
        */
        return (e = this.removeNode(hash(key), key, (Object)null, false, true)) == null ? null : e.value;
    }

    /*
    第一参数为哈希值,第二个为key,第三个为value,第四个为true的话,则表示删除它
    key对应的value,不删除key,第四个如果为false,则表示删除后,不移动节点。
    */
    final HashMap.Node<K, V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
    	//tab哈希数组,p数组下标节点,n长度,index当前数组下标
        HashMap.Node[] tab;
        HashMap.Node p;
        int n;
        int index;
        //哈希数组不为null,且长度大于0,然后获得要删除key的节点的数组下标位置
        if ((tab = this.table) != null && (n = tab.length) > 0 && (p = tab[index = n - 1 & hash]) != null) {
        	//node存储要删除的节点,e临时变量,k当前节点的key,v当前节点的value
            HashMap.Node<K, V> node = null;
            Object k;
            //如果数组下标的节点正好是要删除的节点,把值赋给临时变量
            if (p.hash == hash && ((k = p.key) == key || key != null && key.equals(k))) {
                node = p;
            }
            //也就是要删除的节点,在链表或者红黑树上,先判断是否为红黑树的节点
            else {
                HashMap.Node e;
                if ((e = p.next) != null) {
                    if (p instanceof HashMap.TreeNode) {
                    	//遍历红黑树,找到该节点并返回
                        node = ((HashMap.TreeNode)p).getTreeNode(hash, key);
                    }
                    //如果是链表节点,遍历找到该节点
                    else {
                        label88: {
                            while(e.hash != hash || (k = e.key) != key && (key == null || !key.equals(k))) {
                            	//p为要删除节点的上一个节点
                                p = e;
                                if ((e = e.next) == null) {
                                    break label88;
                                }
                            }

                            //node为要删除的节点
                            node = e;
                        }
                    }
                }
            }

            Object v;
            /*
            找到要删除的节点后,判断!matchValue,我们正常的remove删除,!matchValue都为true
            */
            if (node != null && (!matchValue || (v = ((HashMap.Node)node).value) == value || value != null && value.equals(v))) {
                //如果删除的节点是红黑树节点,则从红黑树中删除
                if (node instanceof HashMap.TreeNode) {
                    ((HashMap.TreeNode)node).removeTreeNode(this, tab, movable);
                }
                //如果是链表节点,且删除的节点为数组下标节点,也就是头节点,直接让下一个作为头。
                else if (node == p) {
                    tab[index] = ((HashMap.Node)node).next;
                }
                //为链表结构,删除的节点在链表中,要把删除的下一个节点设为上一个节点的下一个节点。
                else {
                    p.next = ((HashMap.Node)node).next;
                }

                //修改计数器
                ++this.modCount;
                //长度减1
                --this.size;
                this.afterNodeRemoval((HashMap.Node)node);
                //返回删除的节点
                return (HashMap.Node)node;
            }
        }

        return null;
    }

删除还有clear方法,把所有的数组下标元素都置位null。

3.9 get()

下面在看较为简单的获取元素。

    public V get(Object key) {
        HashMap.Node e;
        //也是调用getNode方法来完成
        return (e = this.getNode(hash(key), key)) == null ? null : e.value;
    }

    final HashMap.Node<K, V> getNode(int hash, Object key) {
    	//tab哈希数组,first头节点,n长度,k为key
        HashMap.Node[] tab;
        HashMap.Node first;
        int n;
        //如果哈希数组不为null,且长度大于0,获取key值所在的链表头赋值给first
        if ((tab = this.table) != null && (n = tab.length) > 0 && (first = tab[n - 1 & hash]) != null) {
            Object k;
            //如果是头节点,则直接返回头节点。
            if (first.hash == hash && ((k = first.key) == key || key != null && key.equals(k))) {
                return first;
            }

            HashMap.Node e;
            //结果不是头节点
            if ((e = first.next) != null) {
            	//判断是否是红黑树结构
                if (first instanceof HashMap.TreeNode) {
                	//去红黑树中找,然后返回
                    return ((HashMap.TreeNode)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;
    }

hashMap源码暂时分析到这里,能力有限,如果内容出现错误,欢迎指出。

标签:Node,hash,HashMap,jekyll,源码,key,null,节点
来源: https://www.cnblogs.com/xiepl1997/p/13493112.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有