详解Java HashMap

这篇具有很好参考价值的文章主要介绍了详解Java HashMap。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

HashMap介绍

HashMap是Map接口的实现类,基于哈希表来存储键值对。
详解Java HashMap
HashMap可以存储null的key和value,可以允许多个value为null,但是只能允许一个key为null。
JDK1.8之前的HashMap底层数据结构采用数组+链表实现,JDK1.8之后采用数组+链表/红黑树实现。数组是HashMap的主体,采用拉链法(链表)解决哈希冲突,当链表长度大于等于阈值(默认为8)时,链表会转换为红黑树(链表转红黑树之前会判断,数组长度小于64时会优先扩容数组);当红黑树节点小于等于6时,红黑树会退化为链表。
HashMap的默认大小是16,之后每次扩容,容量变成原来的2倍。
另外,HashMap不是线程安全的,在多线程环境下,推荐使用ConcurrentHashMap或HashTable(渐被淘汰)。

常用方法

Map<Integer, String> map = new HashMap<>();
//存入键值对
map.put(1, "Java");
map.put(2, "Python");
map.put(3, "C++");
map.put(4, "Rust");
map.put(5, "Go");
//通过key获取value
System.out.println(map.get(1));
//查找hashmap中是否有对应的key,没有则存入默认的value
System.out.println(map.getOrDefault(10, "code"));
//根据key删除元素
map.remove(10);
//查询是否有该key
System.out.println(map.containsKey(10));

HashMap核心源码分析

下面代码是对Java17版本的HashMap进行分析。

类属性

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
	// 序列号
    private static final long serialVersionUID = 362498820763181265L;
	// 默认容量大小16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
	// 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
	// 默认负载因子0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
	// 链表转红黑树的阈值
    static final int TREEIFY_THRESHOLD = 8;
	// 红黑树转链表的阈值
    static final int UNTREEIFY_THRESHOLD = 6;
	// 转成红黑树时数组的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;
	// 数组
    transient Node<K,V>[] table;
	// 存放具体元素的集合
    transient Set<Map.Entry<K,V>> entrySet;
	// 存放元素的个数,不等于数组长度
    transient int size;
	// 记录HashMap增删元素导致结构改动的次数
    transient int modCount;
	// 扩容的阈值 = 当前容量 * 负载因子
    int threshold;
	//负载因子
    final float loadFactor;

负载因子loadFactor:负载因子 = 数组存放的元素 / 数组大小,负载因子越接近1,则数组存放的元素越密集,发生哈希碰撞的概率就越大,负载因子越接近0,数组中存放的元素就越分散,发生哈希碰撞的概率也就越小。HashMap默认0.75,可以保证时间和空间上的平衡。

threshold扩容阈值:threshold = capability * loadFactor,当size>threshold时就会触发数组的扩容。

构造方法

HashMap有4个构造方法。

//默认构造方法
public HashMap() {
	this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//指定初始化容量和负载因子
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;
	this.threshold = tableSizeFor(initialCapacity);
}

//指定初始化容量
public HashMap(int initialCapacity) {
	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//传入一个map
public HashMap(Map<? extends K, ? extends V> m) {
	this.loadFactor = DEFAULT_LOAD_FACTOR;
	putMapEntries(m, false);
}

put

HashMap的put方法实际调用了putVal方法来插入键值对:

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

对于putVal方法,参数的意思分别是:
hash:调用hash方法计算哈希值;
key:传入key;
value:传入value;
onlyIfAbsent:如果为true,当键相同时不修改已存在的值;
evict:如果为false则数组处于创建模式。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
			   boolean evict) {
	//tab表示数组,p表示当前插入的节点
	Node<K,V>[] tab; Node<K,V> p; int n, i;
	//如果数组为空则调用resize()创建一个数组
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;
	//判断插入位置是否哈希冲突,如果不冲突则直接创建新节点插入
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
	else {
		//处理冲突
		Node<K,V> e; K k;
		//判断table[i]中的元素是否与插入的key一样,如果一样就先用变量e保存这个节点,待后续用新值替换旧值。
		if (p.hash == hash &&
			((k = p.key) == key || (key != null && key.equals(k))))
			e = p;
		//判断插入的是红黑树还是链表,是红黑树则调用putTreeVal插入到红黑树中
		else if (p instanceof TreeNode)
			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
		else {
			//如果是链表,则遍历链表到尾部插入新节点
			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;
				}
				//判断链表中的节点key与插入的key是否相等,相等则退出遍历
				if (e.hash == hash &&
					((k = e.key) == key || (key != null && key.equals(k))))
					break;
				p = e;
			}
		}
		//处理待插入key已存在的节点,用新值替换旧值,并返回旧值。
		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;
}

resize

resize()方法用来初始化hashmap和扩容,当前存放元素数量达到threshold时就会触发扩容,按照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;
		}
		//计算新哈希表容量,旧容量左移一位,相当于*2
		//如果旧容量>=默认初始容量则新阈值*2
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				 oldCap >= DEFAULT_INITIAL_CAPACITY)
			newThr = oldThr << 1; // double threshold
	}
	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 (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 { // preserve order
					//如果是多个链表的节点,则将原链表拆分从两个链表
					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) {
							if (loTail == null)
								loHead = e;
							else
								//尾插法插入节点
								loTail.next = e;
							loTail = e;
						}
						else {
							if (hiTail == null)
								hiHead = e;
							else
								hiTail.next = e;
							hiTail = e;
						}
					} while ((e = next) != null);
					//链表1存于原索引位置
					if (loTail != null) {
						loTail.next = null;
						newTab[j] = loHead;
					}
					//链表2存于[原索引+旧容量]位置
					if (hiTail != null) {
						hiTail.next = null;
						newTab[j + oldCap] = hiHead;
					}
				}
			}
		}
	}
	return newTab;
}

get

get方法在hashmap中查找键所对应的值,实际调用的是getNode方法。

public V get(Object key) {
	Node<K,V> e;
	return (e = getNode(key)) == null ? null : e.value;
}
final Node<K,V> getNode(Object key) {
	Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
	//fisrt获取数组这个桶位置下的第一个节点
	if ((tab = table) != null && (n = tab.length) > 0 &&
		(first = tab[(n - 1) & (hash = hash(key))]) != null) {
		//如果key相等则直接返回该节点
		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 TreeNode)
				//返回树节点
				return ((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;
}

HashMap遍历方式

HashMap的遍历,大体上可分为4类,而每种类型下又有不同的实现方式,总共的遍历方式可分为7种:

  1. 迭代器遍历:
    • 使用迭代器对EntrySet遍历;
    • 使用迭代器对KeySet遍历;
  2. foreach遍历:
    • 使用foreach对EntrySet遍历;
    • 使用foreach对KeySet遍历;
  3. lambda表达式遍历;
  4. streams API遍历:
    • Streams API单线程方式遍历;
    • Streams API多线程方式遍历。

下面列举出几种遍历方式,首先创建并初始化一个HashMap:

public class HashMapTest {
    public static void main(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        map.put(1, "Java");
        map.put(2, "Python");
        map.put(3, "C++");
        map.put(4, "Rust");
        map.put(5, "Go");

        System.out.println("1. 迭代器遍历EntrySet");
        traverseByIterator1(map);
        System.out.println("2. 迭代器遍历KeySet");
        traverseByIterator2(map);
        System.out.println("3. foreach遍历EntrySet");
        traverseByForeach1(map);
        System.out.println("4. foreach遍历KeySet");
        traverseByForeach2(map);
        System.out.println("5. lambda表达式遍历");
        traverseByLambda(map);
        System.out.println("6. streams API单线程");
        traverseByStreams1(map);
        System.out.println("7. streams API多线程");
        traverseByStreams2(map);
    }
}

迭代器

EntrySet

public static void traverseByIterator1(Map map) {
	Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
	while (iterator.hasNext()) {
		Map.Entry<Integer, String> entry = iterator.next();
		System.out.println(entry.getKey() + " : " + entry.getValue());
	}
}

输出:

1. 迭代器遍历EntrySet
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

KeySet

public static void traverseByIterator2(Map map) {
	Iterator<Integer> iterator = map.keySet().iterator();
	while (iterator.hasNext()) {
		Integer key = iterator.next();
		System.out.println(key + " : " + map.get(key));
	}
}

输出:

2. 迭代器遍历KeySet
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

foreach

对于foreach遍历,内部也是通过创建迭代器来遍历

EntrySet

public static void traverseByForeach1(Map<Integer, String> map) {
	for (Map.Entry<Integer, String> entry : map.entrySet()) {
		System.out.println(entry.getKey() + " : " + entry.getValue());
	}
}

输出:

3. foreach遍历EntrySet
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

KeySet

public static void traverseByForeach2(Map<Integer, String> map) {
	for (Integer key : map.keySet()) {
		System.out.println(key + " : " + map.get(key));
	}
}

输出:

4. foreach遍历KeySet
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

lambda表达式

public static void traverseByLambda(Map map) {
	map.forEach((key, value) -> {
		System.out.println(key + " : " + value);
	});
}

输出:

5. lambda表达式遍历
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

streams API

单线程

public static void traverseByStreams1(Map<Integer, String> map) {
	map.entrySet().stream().forEach((entry) -> {
		System.out.println(entry.getKey() + " : " + entry.getValue());
	});
}

输出:

6. streams API单线程
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

多线程

public static void traverseByStreams2(Map<Integer, String> map) {
	map.entrySet().parallelStream().forEach((entry) -> {
		System.out.println(entry.getKey() + " : " + entry.getValue());
	});
}

输出:

7. streams API多线程
1 : Java
2 : Python
3 : C++
4 : Rust
5 : Go

遍历时删除元素

在上述几种遍历方式中,有些可以在遍历过程中安全删除元素,有些则会抛出ConcurrentModificationException异常,这是因为遍历过程中会比较modCount != expectedModCount,不相等就会抛出异常,具体分析请往下看。

迭代器

Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
	Map.Entry<Integer, String> entry = iterator.next();
	if (entry.getKey() == 1) {
		iterator.remove();
	}
}

通过迭代器遍历,并使用迭代器的remove()方法可以正常删除元素。
成功的原因是,调用iterator.remove()方法最后会对expectedModCount值进行更新,这样就保证了迭代器调用next()获取下一个元素时,检查modCount == expectedModCount
详解Java HashMap

foreach

for (Map.Entry<Integer, String> entry : map.entrySet()) {
	if (entry.getKey() == 2) {
		map.remove(entry.getKey());
	}
}

在foreach遍历的过程中调用Map的remove()方法会抛出ConcurrentModificationException异常。
通过查看源码得知,抛出异常的原因是删除元素之后,进行下一个元素的遍历时,比较变量modCountexpectedModCount不相等:
详解Java HashMap
HashMap中的变量modCount记录了HashMap的修改次数,HashIterator中的变量expectedModCount在遍历前会初始化与modCount相等,当删除一个元素时,++modCount,之后迭代器通过next()获取下一个元素时,检查modCount != expectedModCount,就会抛出异常。
此外,这种检查到错误就抛出异常并停止程序后续执行的机制被称为fail-fast机制。

lambda

map.forEach((key, value) -> {
	if (key == 1) {
		map.remove(key);
	}
});

使用lambda表达式遍历时删除也会抛出ConcurrentModificationException。

可以通过removeIf()对key进行判断后删除。

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

sterams:

map.entrySet().stream().forEach((entry) -> {
	if (entry.getKey() == 1) {
		map.remove(entry.getKey());
	}
});

使用stream遍历删除同样抛出ConcurrentModificationException。
可以使用filter()过滤掉不需要的数据再遍历,但是这种方式不会真正删除hashmap中的元素。

map.entrySet().stream().filter(e -> 1 != e.getKey()).forEach((entry) -> {
	if (entry.getKey() == 1) {
		System.out.println(entry.getKey());
	}
});

HashMap线程安全问题

HashMap线程不安全体现在:

  • JDK1.7中,多线程扩容时,调用了transfer方法,会导致链表成环,造成死循环、数据丢失。
  • JDK1.8中,多线程put操作,调用putVal方法,会导致数据覆盖。

原因

JDK1.7:

扩容时,调用transfer方法将原哈希表的元素转移到新哈希表,采用头插法插入节点到链表中,链表的顺序会反转,在多线程操作下就会形成环形链表。

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;
		}
	}
}

扩容造成死循环

扩容造成数据丢失

JDK1.8:

多线程情况下数据覆盖
在JDK1.8中,链表插入节点改成尾插法,因此不会出现链表成环的情况,但是在多线程环境下也会出现数据覆盖问题。
在putVal方法中,会判断如果没有哈希碰撞则直接插入元素。

if ((p = tab[i = (n - 1) & hash]) == null)
	tab[i] = newNode(hash, key, value, null);

如果线程A和线程B同时put操作,恰好两个线程插入的key的hash一样,并且数组该位置为空,线程A和线程B都会进入这行代码。当线程A执行完if判断还未插入数据时被挂起,线程B正常执行并插入数据,之后线程A获得时间片,继续执行插入数据的操作,就会直接覆盖掉之前线程B的数据。

此外,putVal方法末尾在判断是否需要扩容时的代码:

if (++size > threshold)

因为++size不是原子性操作,如果存在两个线程A和B同时put,线程A从主内存获取size值并+1,但此时A时间片耗尽并挂起,更新后的size值并未同步到主内存,线程B也从主内存读取size值并+1,完成put操作并将更新后的size写回主内存,后续线程A再次获取CPU并继续执行,完成put操作后也将size写回主内存。这样,线程A和B都插入了一个元素,但是size只增加了1。文章来源地址https://www.toymoban.com/news/detail-711584.html

参考资料

  1. HashMap 的 7 种遍历方式与性能分析
  2. HashMap 源码分析
  3. HashMap源码分析(jdk1.8,保证你能看懂)
  4. HashMap线程不安全的体现

到了这里,关于详解Java HashMap的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 详解Java HashMap

    HashMap是Map接口的实现类,基于哈希表来存储键值对。 HashMap可以存储null的key和value,可以允许多个value为null,但是只能允许一个key为null。 JDK1.8之前的HashMap底层数据结构采用 数组+链表 实现,JDK1.8之后采用 数组+链表/红黑树 实现。数组是HashMap的主体,采用拉链法(链表)解

    2024年02月08日
    浏览(44)
  • HashMap如何解决哈希冲突

    Hash算法就是把任意长度的输入通过 散列算法 编程固定长度的输出。这个输出结果就是一个 散列值 。 Hash表又称为“ 散列表 ”,它是通过key直接访问到内存存储位置的数据结构。在具体的实现上,我们通过Hash函数把key映射到表中的某个位置,来获取这个位置的数据,从而去

    2023年04月26日
    浏览(46)
  • HashMap如何解决哈希冲突?

    了解Hash冲突首先了解Hash算法和Hash表 Hash算法就是把任意长度的输入通过散列算法变成固定长度的输出,这个输出结果就是一个散列值 Hash表又叫做“散列表”,它是通过key直接访问到内存存储位置的数据结构,在具体的实现上,我们通过Hash函数,把key映射到表中的某个位置

    2023年04月12日
    浏览(44)
  • Rust 笔记:Rust 语言中哈希结构(哈希映射,HashMap)、集合(哈希集,HashSet)及其使用

    Rust 笔记 Rust 语言中映射(HashMap)与集合(HashSet)及其用法 作者 : 李俊才 (jcLee95):https://blog.csdn.net/qq_28550263?spm=1001.2101.3001.5343 邮箱 : 291148484@163.com 本文地址 :https://blog.csdn.net/qq_28550263/article/details/130876735 【介绍】:本文介绍 Rust 中哈希结构相关概念及其使用。在 R

    2024年02月09日
    浏览(53)
  • 面试题(2)-HashMap 是怎么解决哈希冲突的

    Hash函数指将哈希表中元素的关键键值映射为元素存储位置的函数。 把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。 散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。因此,散

    2024年02月14日
    浏览(42)
  • 【JavaSE专栏55】Java集合类HashTable解析,基于哈希表实现的唯一性键值对存储数据结构

    作者主页 :Designer 小郑 作者简介 :3年JAVA全栈开发经验,专注JAVA技术、系统定制、远程指导,致力于企业数字化转型,CSDN学院、蓝桥云课认证讲师。 主打方向 :Vue、SpringBoot、微信小程序 本文讲解了 Java 中集合类 HashTable 的语法、使用说明和应用场景,并给出了样例代码。

    2024年02月15日
    浏览(57)
  • C++初阶:容器适配器介绍、stack和queue常用接口详解及模拟实现

    介绍完了list类的相关内容后:C++初阶:适合新手的手撕list(模拟实现list) 接下来进入新的篇章,stack和queue的介绍以及模拟: stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。 stack是作为容器适配器

    2024年02月19日
    浏览(44)
  • 基于Python简单实现接口自动化测试(详解)

    本文从一个简单的登录接口测试入手,一步步调整优化接口调用姿势,然后简单讨论了一下接口测试框架的要点,最后介绍了一下我们目前正在使用的接口测试框架pithy。期望读者可以通过本文对接口自动化测试有一个大致的了解。 为什么要做接口自动化测试? 在当前互联网

    2024年01月20日
    浏览(51)
  • java集合之List接口实现类常用方法详解

    目录 一、List集合概述 二、ArrayList类 三、ArrayList常用方法实例 四、LinkedList类 五、Linkedist常用方法实例         java.util.List接口继承自Collection接口,是单列集合的一个分支,通常将实现了List接口的对象称为List集合,在List集合中允许出现重复的元素,所有的元素是以一种线

    2024年02月08日
    浏览(51)
  • [JDK8下的HashMap类应用及源码分析] 数据结构、哈希碰撞、链表变红黑树

    [Java基础] StringBuffer 和 StringBuilder 类应用及源码分析 [Java基础] 数组应用及源码分析 [Java基础] String,分析内存地址,源码 [JDK8环境下的HashMap类应用及源码分析] 第一篇 空构造函数初始化 [JDK8环境下的HashMap类应用及源码分析] 第二篇 看源码了解HashMap的扩容机制 [JDK8环境下的

    2024年02月10日
    浏览(44)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包