JDK7-HashMap源码解析

为了方便阅读和写注释,笔者将HashMap源码单独拷出来了,推荐大家也这么做,阅读起来更加轻松,Debug也很方便,更重要的是,你可以修改源码来测试。

继承关系图

阅读源码建议采用自上而下的结构,建议先看Map接口、然后AbstractMap抽象类、最后HashMap。

整体结构分层

Map接口

定义Map具备的功能,使用内部接口Entry来对单个映射关系进行封装,Entry是Map的基本组成单元。

AbstractMap

实现了Map接口的抽象类,实现了绝大多数方法,put和entrySet没有实现,因为这两个方法涉及到底层的实现逻辑和数据结构,必须交给子类去实现。

HashMap

继承自AbstractMap,实现了Map。
实现了自己的Entry,除了保存映射关系外,因为是基于哈希的,所以还记录了哈希值,因为采用的链表结构,所以用next记录了下一个节点的地址。

HashMap解析

笔者几乎给每个属性和方法都写上了注释,想要源码的朋友可以私信我。

各属性的含义

/**
 * 默认容量:1<<4 = 10000 = 16
 * 必须是2的幂次方数
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
 * 最大容量:1<<30 = 1000000000000000000000000000000 = 1073741824
 * 必须是2的幂次方数
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的负载因子,可通过构造器指定
 * 扩容时机:当前元素数量 >= 数组容量 * 负载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 空数组,在HashMap没有put元素时共享
 */
static final Entry<?,?>[] EMPTY_TABLE = {};

/**
 * 存放Entry的数组,默认是空的
 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

/**
 * 当前包含的 key-value 映射关系的数量
 */
transient int size;

/**
 * 扩容的阈值,计算公式:容量 * 负载因子
 * 如果table是空的,表示第一次扩容,则根据初始大小来扩容
 */
int threshold;

/**
 * 扩容的负载因子
 * 如果不指定,则为0.75
 */
final float loadFactor;

/**
 * 记录HashMap在结构上修改的次数,和线程并发有关。
 */
transient int modCount;

/**
 * 容量的默认阈值,可以通过JVM参数来设置。
 * 超过该值,String类型的key会使用另外一种哈希算法,来减少哈希冲突。
 * 该值会影响哈希种子修改的时机。
 */
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

/**
 * 哈希种子,和计算Key的哈希算法有关
 * 当hashSeed!=0 且 Key的类型为String时,则采用Hashing.stringHash32()算法来计算哈希。
 * 目的:减少String类型作为Key所带来的哈希冲突。
 * 哈希种子何时会改变?去看 initHashSeedAsNeeded()
 */
transient int hashSeed = 0;

内部类Holder

该类没有任何方法,只有一个属性ALTERNATIVE_HASHING_THRESHOLD,它只做了一件事:读取JVM参数,来设置修改哈希种子的时机

/**
 * 该类只做了一件事:读取JVM参数,来设置修改哈希种子的时机。
 */
private static class Holder {

	/**
	 * 容量超过该值时,采用其他哈希算法,默认Integer.MAX_VALUE,可通过JVM参数设置
	 */
	static final int ALTERNATIVE_HASHING_THRESHOLD;

	static {
		String altThreshold = java.security.AccessController.doPrivileged(
				new sun.security.action.GetPropertyAction(
						"jdk.map.althashing.threshold"));

		int threshold;
		try {
			threshold = (null != altThreshold)
					? Integer.parseInt(altThreshold)
					: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;

			// disable alternative hashing if -1
			if (threshold == -1) {
				threshold = Integer.MAX_VALUE;
			}

			if (threshold < 0) {
				throw new IllegalArgumentException("value must be positive integer.");
			}
		} catch(IllegalArgumentException failed) {
			throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
		}

		ALTERNATIVE_HASHING_THRESHOLD = threshold;
	}
}

内部类Entry实现了Map.Entry接口,将映射关系封装在Entry中。因为基于Hash,所以还记录了hash值。因为基于链表结构,所以记录了指向下一个节点的next。

除此之外,还预留了两个钩子函数给子类扩展。

/**
 * 实现了Map.Entry接口,将映射关系封装在Entry中。
 * 因为基于Hash,所以还记录了hash值。
 * 因为基于链表结构,所以记录了指向下一个节点的next。
 */
static class Entry<K,V> implements Map.Entry<K,V> {
	final K key;
	V value;
	Entry<K,V> next;
	int hash;

	/**
	 * Creates new entry.
	 */
	Entry(int h, K k, V v, Entry<K,V> n) {
		value = v;
		next = n;
		key = k;
		hash = h;
	}

	public final K getKey() {
		return key;
	}

	public final V getValue() {
		return value;
	}

	public final V setValue(V newValue) {
		V oldValue = value;
		value = newValue;
		return oldValue;
	}

	public final boolean equals(Object o) {
		if (!(o instanceof Map.Entry))
			return false;
		Map.Entry e = (Map.Entry)o;
		Object k1 = getKey();
		Object k2 = e.getKey();
		if (k1 == k2 || (k1 != null && k1.equals(k2))) {
			Object v1 = getValue();
			Object v2 = e.getValue();
			if (v1 == v2 || (v1 != null && v1.equals(v2)))
				return true;
		}
		return false;
	}

	public final int hashCode() {
		return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
	}

	public final String toString() {
		return getKey() + "=" + getValue();
	}

	/**
	 * Entry添加时的钩子,也是为了给子类用的,例如:LinkedHashMap
	 */
	void recordAccess(HashMap<K,V> m) {
	}

	/**
	 * Entry删除时的钩子,也是为了给子类用的,例如:LinkedHashMap
	 */
	void recordRemoval(HashMap<K,V> m) {
	}
}

构造器

/**
 * 指定初始大小、负载因子来构建Map
 * 初始化时,虽然扩容阈值 = 初始大小,
 * 但是第一次put时,扩容阈值会被重新设置为:初始大小 * 负载因子
 */
public HashMap(int initialCapacity, float loadFactor) {
	if (initialCapacity < 0)
		throw new IllegalArgumentException("Illegal initial capacity: " +
				initialCapacity);
	if (initialCapacity > MAXIMUM_CAPACITY)
		initialCapacity = MAXIMUM_CAPACITY;
	if (loadFactor <= 0 || Float.isNaN(loadFactor))
		throw new IllegalArgumentException("Illegal load factor: " +
				loadFactor);

	this.loadFactor = loadFactor;
	threshold = initialCapacity;
	init();
}

/**
 * 指定初始大小来构建Map
 */
public HashMap(int initialCapacity) {
	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * 初始大小16 负载因子0.75 来构建Map
 */
public HashMap() {
	this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

/**
 * 根据Map来构建一个新的HashMap。
 * 初始大小根据旧Map的大小计算而来,负载因子0.75
 */
public HashMap(Map<? extends K, ? extends V> m) {
	this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
			DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
	inflateTable(threshold);
	putAllForCreate(m);
}

构造器的最后,会调用一个init(),这个方法在HashMap中是空的,它其实是提供给子类用的钩子函数。

/**
 * 提供给子类初始化用的钩子函数,例如:LinkedHashMap。
 */
void init() {
}

数据结构

HashMap采用数组+链表的结构来存放数据,画了一下结构图,大致如下图所示:

在这里插入图片描述

当我们调用put()时,HashMap做了什么?

整理了一下,put()方法内部的大致逻辑,如下图所示:

在这里插入图片描述

PUT

/**
 * put的逻辑
 */
public V put(K key, V value) {
	if (table == EMPTY_TABLE) {
		//第一次put时,table是空的,需要先扩容(懒加载)
		inflateTable(threshold);
	}
	if (key == null)
		//key为null的put逻辑
		return putForNullKey(value);

	//计算key的哈希
	int hash = hash(key);
	//计算放在table的哪个下标(放在哪个桶里)
	int i = indexFor(hash, table.length);

	//遍历链表,判断Key是否已存在
	for (Entry<K,V> e = table[i]; e != null; e = e.next) {
		Object k;
		if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
			//Key已存在,则覆盖
			//判断是否覆盖的逻辑可以记一下
			V oldValue = e.value;
			e.value = value;
			e.recordAccess(this);
			return oldValue;
		}
	}
	//Key不存在,新增操作
	modCount++;
	addEntry(hash, key, value, i);
	return null;
}

第一次put时,table是空的,需要先扩容(懒加载)

/**
 * 容器初始化扩容
 * 容器的大小必须为2的幂次方数
 * roundUpToPowerOf2()目的:找到 >= toSize的2的幂次方数
 */
private void inflateTable(int toSize) {
	// Find a power of 2 >= toSize
	int capacity = roundUpToPowerOf2(toSize);

	threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
	table = new Entry[capacity];
	initHashSeedAsNeeded(capacity);
}

容器的大小必须是2的幂次方,通过roundUpToPowerOf2()获得

/**
 * 找到 >= number 的2的幂次方数
 * 因为容器的大小必须为2的幂次方数
 * 又是位运算,这个算法很巧妙,可以多研究研究...
 */
private static int roundUpToPowerOf2(int number) {
	// assert number >= 0 : "number must be non-negative";
	return number >= MAXIMUM_CAPACITY
			? MAXIMUM_CAPACITY
			: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

初始化时判断是否要修改哈希种子,涉及到后面的哈希算法

/**
 * 哈希种子是否要修改 -> 是否采用其他哈希算法
 * 触发时机:容量 >= Integer.MAX_VALUE (可通过JVM参数设置)
 */
final boolean initHashSeedAsNeeded(int capacity) {
	boolean currentAltHashing = hashSeed != 0;
	boolean useAltHashing = sun.misc.VM.isBooted() &&
			(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
	boolean switching = currentAltHashing ^ useAltHashing;
	if (switching) {
		hashSeed = useAltHashing
				? Hashing.randomHashSeed(this)
				: 0;
	}
	return switching;
}

初始化完成后,如果Key为null,单独处理

/**
 * Key为null时的put逻辑
 */
private V putForNullKey(V value) {
	for (Entry<K,V> e = table[0]; e != null; e = e.next) {
		//table[0]有元素占用 直接覆盖
		if (e.key == null) {
			V oldValue = e.value;
			e.value = value;
			//给子类用的钩子函数
			e.recordAccess(this);
			return oldValue;
		}
	}
	//table[0]无元素,添加Entry。
	//hash为0,且放在table的第0个下标。
	modCount++;
	addEntry(0, null, value, 0);
	return null;
}

哈希算法

/**
 * 计算Key的哈希函数
 * 如果哈希种子不为0,且Key类型是String,则采用Hashing.stringHash32()哈希算法。
 * 哈希种子修改时机,去看 initHashSeedAsNeeded()。
 * 默认的哈希算法:拿到Key的hashCode,做了一次再散列。
 * 防止开发者重写的hashCode()分散性太差,而导致HashMap的哈希冲突。
 * Key为Null,总是会映射到数组的第0个下标。
 */
final int hash(Object k) {
	int h = hashSeed;
	if (0 != h && k instanceof String) {
		return Hashing.stringHash32((String) k);
	}
	h ^= k.hashCode();
	// This function ensures that hashCodes that differ only by
	// constant multiples at each bit position have a bounded
	// number of collisions (approximately 8 at default load factor).
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);
}

计算放在table的哪个下标(放在哪个桶里)

/**
 * 根据Key计算的哈希值来判断元素应该放到数组的哪个下标位置
 */
static int indexFor(int h, int length) {
	/**
	 * Java采用的是位运算,并没有采用 取模运算。
	 * 效果是一样的,但是位运算效率更高。
	 */
	return h & (length-1);
}

如果Key存在则覆盖,不存在,则准备插入,实际插入前,会判断是否需要扩容。

/**
 * 将Entry添加到table[bucketIndex]之前的处理逻辑
 * 添加之前,先判断是否需要扩容。
 * 扩容逻辑:元素 >= 扩容阈值,且 对应桶里的元素不为空。
 * 因为:如果桶里元素为空,说明table并没有填充的均匀,只是链表太长,链表过长进行扩容没有意义。
 * 理想:元素的分散性好,桶内元素均匀,链表短。
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
	if ((size >= threshold) && (null != table[bucketIndex])) {
		//扩容
		resize(2 * table.length);
		//扩容后需要重新计算哈希,因为哈希种子可能被修改
		hash = (null != key) ? hash(key) : 0;
		//重新计算需要被放到table的哪个下标位置
		bucketIndex = indexFor(hash, table.length);
	}
	//构建Entry,并添加到table[bucketIndex]中。
	createEntry(hash, key, value, bucketIndex);
}

扩容逻辑

/**
 * Map扩容,逻辑:当前容量 * 2
 * 创建新的数组,元素转移
 */
void resize(int newCapacity) {
	Entry[] oldTable = table;
	int oldCapacity = oldTable.length;
	//超过最大容量,不再扩容
	if (oldCapacity == MAXIMUM_CAPACITY) {
		threshold = Integer.MAX_VALUE;
		return;
	}
	Entry[] newTable = new Entry[newCapacity];
	//元素转移,从旧数组 -> 新数组、判断是否要采用其他哈希算法
	transfer(newTable, initHashSeedAsNeeded(newCapacity));
	table = newTable;
	//重新设置扩容阈值
	threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

扩容后,需要进行元素转移,每次扩容都要判断是否需要重新哈希。

/**
 * 转移所有的Entry到新数组中
 * rehash:是否需要重新哈希,详情去看initHashSeedAsNeeded(),绝大多数为false
 */
void transfer(Entry[] newTable, boolean rehash) {
	int newCapacity = newTable.length;
	for (Entry<K,V> e : table) {
		while(null != e) {
			Entry<K,V> next = e.next;
			if (rehash) {
				e.hash = null == e.key ? 0 : hash(e.key);
			}
			int i = indexFor(e.hash, newCapacity);
			e.next = newTable[i];
			newTable[i] = e;
			e = next;
		}
	}
}

元素转移后,table指向新数组,并重新设置扩容阈值。

扩容以后,对将要新插入的元素重新计算哈希,因为哈希种子可能被修改。还要重新计算桶下标,因为扩容后table.length已发生变化。

这些工作处理完成以后,才是真正的插入,新元素会插入到桶内链表的头节点,因为效率最高。

/**
 * 构建Entry,并添加到table[bucketIndex]中。
 * 如果桶内已经有元素,会直接添加到头节点中,因为效率最高,next指向旧的头节点。
 */
void createEntry(int hash, K key, V value, int bucketIndex) {
	Entry<K,V> e = table[bucketIndex];
	table[bucketIndex] = new Entry<>(hash, key, value, e);
	size++;
}

至此,PUT操作全部结束。

PUT完成后,HashMap会调用recordAccess钩子函数,为了子类扩展。

HashMap的问题

  • 为什么官方文档说HashMap不保证元素的顺序恒定不变?
    因为扩容后,元素转移时,链表的顺序会调换。
  • 为什么HashMap线程不安全?

在这里插入图片描述

GET

相较于PUT,GET的代码就简单很多了。

/**
 * 从容器中根据key获取映射的value
 * 返回Null有两种情况:
 * 1、Key不存在
 * 2、Key映射的value就是Null
 * 可以通过containsKey()判断。
 */
public V get(Object key) {
	if (key == null)
		return getForNullKey();
	Entry<K,V> entry = getEntry(key);
	return null == entry ? null : entry.getValue();
}

获取Key为Null的value单独处理,因为Key为null的元素强制放在table[0]。

/**
 * 获取Key为null对应的value
 */
private V getForNullKey() {
	if (size == 0) {
		//容器为空,直接返回null
		return null;
	}
	//Key为null的元素强制放在table[0]里
	for (Entry<K,V> e = table[0]; e != null; e = e.next) {
		if (e.key == null)
			return e.value;
	}
	return null;
}

Key不为Null就要去数组中寻找了,步骤一样的,先计算哈希,再计算桶下标,再遍历链表查找。

/**
 * 获取指定Key的映射关系
 */
final Entry<K,V> getEntry(Object key) {
	if (size == 0) {
		return null;
	}
	//先计算Key的哈希
	int hash = (key == null) ? 0 : hash(key);
	//判断在哪个桶里,然后遍历链表
	for (Entry<K,V> e = table[indexFor(hash, table.length)];
		 e != null;
		 e = e.next) {
		Object k;
		if (e.hash == hash &&
				((k = e.key) == key || (key != null && key.equals(k))))
			return e;
	}
	return null;
}

REMOVE

删除的代码也比较简单。

/**
 * 从容器中删除指定Key的映射关系,并返回
 */
public V remove(Object key) {
	Entry<K,V> e = removeEntryForKey(key);
	return (e == null ? null : e.value);
}

先计算哈希,计算桶下标,遍历链表找到元素,修改一下链表的指向就可以了。

/**
 * 根据Key删除Entry
 * 先计算哈希,判断在哪个桶里,
 * 然后遍历链表,找到了就删除(重新设置链表指向)
 */
final Entry<K,V> removeEntryForKey(Object key) {
	if (size == 0) {
		return null;
	}
	int hash = (key == null) ? 0 : hash(key);
	int i = indexFor(hash, table.length);
	Entry<K,V> prev = table[i];
	Entry<K,V> e = prev;

	while (e != null) {
		Entry<K,V> next = e.next;
		Object k;
		if (e.hash == hash &&
				((k = e.key) == key || (key != null && key.equals(k)))) {
			modCount++;
			size--;
			if (prev == e)
				//要删除的是链表头节点,直接头节点指向next即可。
				table[i] = next;
			else
				//否则,上一个节点的next,指向当前删除节点的next
				prev.next = next;
			e.recordRemoval(this);
			return e;
		}
		prev = e;
		e = next;
	}

	return e;
}

REMOVE完成后,HashMap会调用recordRemoval钩子函数,为了子类扩展。

其他一些方法

putAll、批量插入。

/**
 * 将指定容器中的所有元素添加到该容器中,Key存在会被替换。
 * 与putAllForCreate不同的是,该方法会进行扩容
 */
public void putAll(Map<? extends K, ? extends V> m) {
	int numKeysToBeAdded = m.size();
	if (numKeysToBeAdded == 0)
		return;

	if (table == EMPTY_TABLE) {
		inflateTable((int) Math.max(numKeysToBeAdded * loadFactor, threshold));
	}

	if (numKeysToBeAdded > threshold) {
		int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1);
		if (targetCapacity > MAXIMUM_CAPACITY)
			targetCapacity = MAXIMUM_CAPACITY;
		int newCapacity = table.length;
		while (newCapacity < targetCapacity)
			newCapacity <<= 1;
		if (newCapacity > table.length)
			resize(newCapacity);
	}

	for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
		put(e.getKey(), e.getValue());
}

putAllForCreate、不扩容的前提下批量插入,适用于已知长度的情况下提升性能。

/**
 * 不扩容的前提下,将旧Map里的元素,添加到当前容器。
 * 已知长度的情况下,一次性构建即可,没必要扩容,降低性能。
 */
private void putAllForCreate(Map<? extends K, ? extends V> m) {
	for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
		putForCreate(e.getKey(), e.getValue());
}

putForCreate、不扩容的插入,反序列化时有用。

/**
 * 逻辑和put类似,只是不会去扩容表。
 * 作用:反序列化时有用,一次性构建,而不是多次扩容。
 */
private void putForCreate(K key, V value) {
	int hash = null == key ? 0 : hash(key);
	int i = indexFor(hash, table.length);

	/**
	 * Look for preexisting entry for key.  This will never happen for
	 * clone or deserialize.  It will only happen for construction if the
	 * input Map is a sorted map whose ordering is inconsistent w/ equals.
	 */
	for (Entry<K,V> e = table[i]; e != null; e = e.next) {
		Object k;
		if (e.hash == hash &&
				((k = e.key) == key || (key != null && key.equals(k)))) {
			e.value = value;
			return;
		}
	}

	createEntry(hash, key, value, i);
}

clone

/**
 * 重写clone方法,返回一个元素相同的新容器
 * @return
 */
public Object clone() {
	HashMap<K,V> result = null;
	try {
		result = (HashMap<K,V>)super.clone();
	} catch (CloneNotSupportedException e) {
		// assert false;
	}
	if (result.table != EMPTY_TABLE) {
		result.inflateTable(Math.min(
				(int) Math.min(
						size * Math.min(1 / loadFactor, 4.0f),
						// we have limits...
						HashMap.MAXIMUM_CAPACITY),
				table.length));
	}
	result.entrySet = null;
	result.modCount = 0;
	result.size = 0;
	result.init();
	result.putAllForCreate(this);

	return result;
}

内部迭代器抽象类:HashIterator,主要为了方便迭代所有的Key、Value、Entry。

子类有:

  • ValueIterator
  • KeyIterator
  • EntryIterator

序列化和反序列化的处理

/**
 * 序列化时将内容写入到流中。
 */
private void writeObject(java.io.ObjectOutputStream s)
		throws IOException
{
	// Write out the threshold, loadfactor, and any hidden stuff
	s.defaultWriteObject();

	// Write out number of buckets
	if (table==EMPTY_TABLE) {
		s.writeInt(roundUpToPowerOf2(threshold));
	} else {
		s.writeInt(table.length);
	}

	// Write out size (number of Mappings)
	s.writeInt(size);

	// Write out keys and values (alternating)
	if (size > 0) {
		for(Map.Entry<K,V> e : entrySet0()) {
			s.writeObject(e.getKey());
			s.writeObject(e.getValue());
		}
	}
}

private static final long serialVersionUID = 362498820763181265L;

/**
 * 反序列化时从流中构建容器
 */
private void readObject(java.io.ObjectInputStream s)
		throws IOException, ClassNotFoundException
{
	// Read in the threshold (ignored), loadfactor, and any hidden stuff
	s.defaultReadObject();
	if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
		throw new InvalidObjectException("Illegal load factor: " +
				loadFactor);
	}

	// set other fields that need values
	table = (Entry<K,V>[]) EMPTY_TABLE;

	// Read in number of buckets
	s.readInt(); // ignored.

	// Read number of mappings
	int mappings = s.readInt();
	if (mappings < 0)
		throw new InvalidObjectException("Illegal mappings count: " +
				mappings);

	// capacity chosen by number of mappings and desired load (if >= 0.25)
	int capacity = (int) Math.min(
			mappings * Math.min(1 / loadFactor, 4.0f),
			// we have limits...
			HashMap.MAXIMUM_CAPACITY);

	// allocate the bucket array;
	if (mappings > 0) {
		inflateTable(capacity);
	} else {
		threshold = capacity;
	}

	init();  // Give subclass a chance to do its thing.

	// Read the keys and values, and put the mappings in the HashMap
	for (int i = 0; i < mappings; i++) {
		K key = (K) s.readObject();
		V value = (V) s.readObject();
		putForCreate(key, value);
	}
}

清空容器

/**
 * 清空容器,数组、链表仍然存在,只是将所有桶里的第0个元素置为空。
 */
public void clear() {
	modCount++;
	Arrays.fill(table, null);
	size = 0;
}

可能有部分代码没有贴出来,如果有遗漏,可以私信笔者。

发布了100 篇原创文章 · 获赞 23 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/qq_32099833/article/details/103647509
今日推荐