Java集合(HashMap核心问题)
集合
1. Java集合类基本概念
在编程中,常常需要集中存放多个数据。从传统意义上讲,数组是我们的一个很好的选择,前提是我们事先已经明确知道我们将要保存的对象的数量。一旦在数组初始化时指定了这个数组长度,这个数组长度就是不可变的,如果我们需要保存一个可以动态增长的数据(在编译时无法确定具体的数量),java的集合类就是一个很好的设计方案了。
集合类主要负责保存、盛装其他数据,因此集合类也被称为容器类。所有的集合类都位于java.util包下,后来为了处理多线程环境下的并发安全问题,java5还在java.util.concurrent包下提供了一些多线程支持的集合类。
在学习Java中的集合类的API、编程原理的时候,我们一定要明白,"集合"是一个很古老的数学概念,它远远早于Java的出现。从数学概念的角度来理解集合能帮助我们更好的理解编程中什么时候该使用什么类型的集合类。
Java容器类类库的用途是"保存对象",并将其划分为两个不同的概念:
-
Collection :一组"对立"的元素,通常这些元素都服从某种规则
- List必须保持元素特定的顺序
- Set不能有重复元素
- Queue保持一个队列(先进先出)的顺序
-
Map :一组成对的"键值对"对象
Collection和Map的区别在于容器中每个位置保存的元素个数:
-
Collection 每个位置只能保存一个元素(对象)
-
Map保存的是"键值对",就像一个小型数据库。我们可以通过"键"找到该键对应的"值"
2. Java集合类架构层次关系
1. Interface Iterable
迭代器接口,这是Collection类的父接口。实现这个Iterable接口的对象允许使用foreach进行遍历,也就是说,所有的Collection集合对象都具有"foreach可遍历性"。这个Iterable接口只有一个方法: iterator()。它返回一个代表当前集合对象的泛型
1.1 Collection(单列集合)
Collection是最基本的集合接口,一个Collection代表一组Object的集合,这些Object被称作Collection的元素。Collection是一个接口,用以提供规范定义,不能被实例化使用
1) Set
1.概述:Set接口 继承自 Collection接口
Set接口中的方法,并没有对Collection接口进行扩充
底层都是依靠Map实现
Set集合类似于一个罐子,"丢进"Set集合里的多个对象之间没有明显的顺序。Set继承自Collection接口,不能包含有重复元素(记住,这是整个Set类层次的共有属性)。
Set判断两个对象相同不是使用"=="运算符,而是根据equals方法。也就是说,我们在加入一个新元素的时候,如果这个新元素对象和Set中已有对象进行注意equals比较都返回false, 则Set就会接受这个新元素对象,否则拒绝。
因为Set的这个制约,在使用Set集合的时候,应该注意两点:
- 为Set集合里的元素的实现类实现一个有效的equals(Object)方法、
- 对Set的构造函数,传入的Collection参数不能包含重复的元素
1.1) HashSet
1.概述:HashSet 实现 Set接口
2.特点:
元素无序
元素唯一(如果元素一样,后面的会把前面的覆盖掉)
没有索引(迭代器遍历,增强for遍历)
3.数据结构:哈希表
jdk8之前:哈希表 = 数组+链表
jdk8之后:哈希表 = 数组+链表+红黑树
加入红黑树的目的:查询快,提高效率
4.方法:
和Collection一样
哈希值
1.概述:计算机计算出来的十进制数,可以理解为对象的地址值
2.获取哈希值:
调用Object类中的hashCode()方法
3.结论:
a.如果想要获取对象内容的哈希值,重写hashCode方法
b.内容一样,算出来的哈希值一定一样
c.内容不一样,算出来的哈希值也有可能一样(哈希碰撞,哈希冲突)
hashSet去重过程
1.先计算元素的哈希值,然后比较哈希值
2.如果哈希值不一样,直接存储
3.如果哈希值一样,再比较元素内容
4.如果哈希值一样,内容不一样,直接存
5.如果哈希值一样,内容也一样,直接去重复,后面的会把前面的覆盖掉
HashSet是Set接口的典型实现,HashSet使用HASH算法来存储集合中的元素,因此具有良好的存取和查找性能。当向HashSet集合中存入一个元素时,HashSet会调用该对象的hashCode()方法来得到该对象的hashCode值,然后根据该HashCode值决定该对象在HashSet中的存储位置。
值得主要的是,HashSet集合判断两个元素相等的标准是两个对象通过equals()方法比较相等,并且两个对象的hashCode()方法的返回值相等
1.1.1) LinkedHashSet
1.概述:LinkedHashSet extends HashSet
2.特点:
元素有序
元素唯一(如果元素一样,后面的会把前面的覆盖掉)
没有索引(迭代器遍历,增强for遍历)
3.数据结构:哈希表+链表
4.方法:
和HashSet一样
LinkedHashSet集合也是根据元素的hashCode值来决定元素的存储位置,但和HashSet不同的是,它同时使用链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的。
当遍历LinkedHashSet集合里的元素时,LinkedHashSet将会按元素的添加顺序来访问集合里的元素。
LinkedHashSet需要维护元素的插入顺序,因此性能略低于HashSet的性能,但在迭代访问Set里的全部元素时(遍历)将有很好的性能(链表很适合进行遍历)
1.2) SortedSet
此接口主要用于排序操作,即实现此接口的子类都属于排序的子类
1.2.1) TreeSet
TreeSet是SortedSet接口的实现类,TreeSet可以确保集合元素处于排序状态
1.3) EnumSet
EnumSet是一个专门为枚举类设计的集合类,EnumSet中所有元素都必须是指定枚举类型的枚举值,该枚举类型在创建EnumSet时显式、或隐式地指定。EnumSet的集合元素也是有序的,
它们以枚举值在Enum类内的定义顺序来决定集合元素的顺序
2) List
1.概述:List接口 extends Collection接口
2.特点:
a.有序
b.元素可重复
c.有索引
List集合代表一个元素有序、可重复的集合,集合中每个元素都有其对应的顺序索引。List集合允许加入重复元素,因为它可以通过索引来访问指定位置的集合元素。List集合默认按元素的添加顺序设置元素的索引
2.1) ArrayList
1.概述:是List接口的实现类
2.特点:
a.有序
b.元素可重复
c.有索引
3.数据结构:数组
4.使用:
ArrayList<泛型> 集合名 = new ArrayList<>()
5.常用方法:
boolean add(E e) -> 将元素添加到集合中->尾部(add方法一定能添加成功的,所以我们不用boolean接收返回值)
void add(int index, E element) ->在指定索引位置上添加元素
boolean remove(Object o) ->删除指定的元素,删除成功为true,失败为false
E remove(int index) -> 删除指定索引位置上的元素,返回的是被删除的那个元素
E set(int index, E element) -> 将指定索引位置上的元素,修改成后面的element元素
E get(int index) -> 根据索引获取元素
int size() -> 获取集合元素个数
a.有序
b.有索引
c.元素可重复
d.数据结构:数组
2.构造方法:
ArrayList()构造一个初始容量为 10 的空列表
并不是一new,长度为10的空列表就创建出来了,而是第一次add的时候才会将ArrayList的列表长度变成10
ArrayList(int initialCapacity) 构造一个具有指定初始容量的空列表
3.问题:
a.ArrayList底层数据结构为数组,数组是定长的,而集合是长度可变的,ArrayList底层是怎么让数组可变的?
数组扩容->elementData = Arrays.copyOf(elementData, newCapacity);
b.超出了默认的容量,会自动扩容,扩容多少倍呢?
1.5倍
ArrayList是基于数组实现的List类,它封装了一个动态的增长的、允许再分配的Object[]数组。
2.2) Vector
Vector和ArrayList在用法上几乎完全相同,但由于Vector是一个古老的集合,所以Vector提供了一些方法名很长的方法,但随着JDK1.2以后,java提供了系统的集合框架,就将Vector改为实现List接口,统一归入集合框架体系中
2.2.1) Stack
Stack是Vector提供的一个子类,用于模拟"栈"这种数据结构(LIFO后进先出)
2.3) LinkedList
1.概述:List接口的实现类
2.特点:
a.有序
b.元素可重复
c.有索引
3.底层数据结构:
链表->双向链表
4.使用:
a.创建对象:LinkedList<泛型> 集合名 = new LinkedList<>()
b.方法:和ArrayList一样
c.特有方法:
- public void addFirst(E e):将指定元素插入此列表的开头。
- public void addLast(E e):将指定元素添加到此列表的结尾。
- public E getFirst():返回此列表的第一个元素。
- public E getLast():返回此列表的最后一个元素。
- public E removeFirst():移除并返回此列表的第一个元素。
- public E removeLast():移除并返回此列表的最后一个元素。
- public E pop():从此列表所表示的堆栈处弹出一个元素。
- public void push(E e):将元素推入此列表所表示的堆栈。
- public boolean isEmpty():如果列表不包含元素,则返回true。
1.概述:LinkedList是List的实现类
2.特点:
元素有序
元素可重复
有索引
3.数据结构:链表(双向链表)
4.特有方法:
- public void addFirst(E e):将指定元素插入此列表的开头。
- public void addLast(E e):将指定元素添加到此列表的结尾。
- public E getFirst():返回此列表的第一个元素。
- public E getLast():返回此列表的最后一个元素。
- public E removeFirst():移除并返回此列表的第一个元素。
- public E removeLast():移除并返回此列表的最后一个元素。
- public E pop():从此列表所表示的堆栈处弹出一个元素。
- public void push(E e):将元素推入此列表所表示的堆栈。
- public boolean isEmpty():如果列表不包含元素,则返回true。
implements List
3) Queue
Queue用于模拟"队列"这种数据结构(先进先出 FIFO)。队列的头部保存着队列中存放时间最长的元素,队列的尾部保存着队列中存放时间最短的元素。新元素插入(offer)到队列的尾部,访问元素(poll)操作会返回队列头部的元素,队列不允许随机访问队列中的元素。结合生活中常见的排队就会很好理解这个概念
3.1) PriorityQueue
PriorityQueue并不是一个比较标准的队列实现,PriorityQueue保存队列元素的顺序并不是按照加入队列的顺序,而是按照队列元素的大小进行重新排序,这点从它的类名也可以看出来
3.2) Deque
Deque接口代表一个"双端队列",双端队列可以同时从两端来添加、删除元素,因此Deque的实现类既可以当成队列使用、也可以当成栈使用
3.2.1) ArrayDeque
是一个基于数组的双端队列,和ArrayList类似,它们的底层都采用一个动态的、可重分配的Object[]数组来存储集合元素,当集合元素超出该数组的容量时,系统会在底层重新分配一个Object[]数组来存储集合元素
3.2.2) LinkedList
1.概述:LinkedList是List的实现类
2.特点:
元素有序
元素可重复
有索引
3.数据结构:链表(双向链表)
4.特有方法:
- public void addFirst(E e):将指定元素插入此列表的开头。
- public void addLast(E e):将指定元素添加到此列表的结尾。
- public E getFirst():返回此列表的第一个元素。
- public E getLast():返回此列表的最后一个元素。
- public E removeFirst():移除并返回此列表的第一个元素。
- public E removeLast():移除并返回此列表的最后一个元素。
- public E pop():从此列表所表示的堆栈处弹出一个元素。
- public void push(E e):将元素推入此列表所表示的堆栈。
- public boolean isEmpty():如果列表不包含元素,则返回true。
1.2 Map(双列集合)
Map用于保存具有"映射关系"的数据,因此Map集合里保存着两组值,一组值用于保存Map里的key,另外一组值用于保存Map里的value。key和value都可以是任何引用类型的数据。Map的key不允许重复,即同一个Map对象的任何两个key通过equals方法比较结果总是返回false。
关于Map,我们要从代码复用的角度去理解,java是先实现了Map,然后通过包装了一个所有value都为null的Map就实现了Set集合
Map的这些实现类和子接口中key集的存储形式和Set集合完全相同(即key不能重复)
Map的这些实现类和子接口中value集的存储形式和List非常类似(即value可以重复、根据索引来查找)
1.概述:双列集合的顶级接口
2.特点:
a.元素都是key和value的形式
b.key唯一,但是value可以重复
c.无索引
d.无序(LinkedHashMap是有序的)
1) HashMap
1.概述:HashMap 是 Map的实现类
2.特点:
a.元素都是key和value的形式
b.key唯一,但是value可以重复-> key重写hashCode和equals方法,去重方式和set一样
c.无索引
d.无序
3.数据结构:
哈希表
4.方法:
V put(K key, V value) -> 存储元素
V remove(Object key) ->根据key删除对应的键值对
V get(Object key) -> 根据key获取对应的value
boolean containsKey(Object key) ->判断Map中是否包含指定的key
Collection<V> values() -> 将Map中所有的value存储到Collection集合中
Set<K> keySet() -> 将Map中所有的key获取出来存到Set集合中
Set<Map.Entry<K,V>> entrySet() -> 获取Map中所有的键值对对象,放到set集合中
和HashSet集合不能保证元素的顺序一样,HashMap也不能保证key-value对的顺序。并且类似于HashSet判断两个key是否相等的标准也是: 两个key通过equals()方法比较返回true、
同时两个key的hashCode值也必须相等
HashMap的两种遍历方式
方式1:获取key,然后根据key获取value
Set<K> keySet() -> 将Map中所有的key获取出来存到Set集合中
方式2:同时获取key和value
Set<Map.Entry<K,V>> entrySet() -> 获取Map中所有的键值对对象,放到set集合中
1.获取Map的内部接口:Map.Entry
2.将Map.Entry放到Set集合中
3.遍历Set集合,将每一个Map.Entry获取出来
4.在调用Map.Entry中的getkey() 和 getValue()方法获取键值对
* 能说下 HashMap 的实现原理吗
其实就是有个 Entry 数组,Entry 保存了 key 和 value。当你要塞入一个键值对的时候,会根据一个 hash 算法计算 key 的 hash 值,然后然后通过数组大小 n-1 & hash 值之后,得到一个数组的下标,然后往那个位置塞入这个 Entry。
hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hashMap 源码
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
然后我们知道,hash 算法是可能产生冲突的,且数组的大小是有限的,所以很可能通过不同的 key 计算得到一样的下标,因此为了解决 Entry 冲突的问题,采用了链表法,如下图所示:
在 JDK1.7 及之前链表的插入采用的是头插法,即在链表的头部插入新的 Entry。
在 JDK1.8 的时候,改成了尾插法,并且引入了红黑树。
当链表的长度大于 8 且数组大小大于等于 64 的时候,就把链表转化成红黑树,当红黑树节点小于 6 的时候,又会退化成链表。
* 为什么 JDK 1.8 要对 HashMap 做红黑树这个改动?
主要是避免 hash 冲突导致链表的长度过长,这样 get 的时候时间复杂度严格来说就不是 O(1) 了,因为可能需要遍历链表来查找命中的 Entry。
*** 为什么定义链表长度为 8 且数组大小大于等于 64 才转红黑树?不要链表直接用红黑树不就得了吗?**
因为[红黑树节点的大小是普通节点大小的两倍,所以为了节省内存空间不会直接只用红黑树只有当节点到达一定数量才会转成红黑树这里定义的是 8
为什么是 8 呢?这个其实 HashMap 注释上也有说的,和泊松分布有关系,这个大学应该都学过。
简单翻译下就是在默认阈值是 0.75 的情况下,冲突节点长度为 8 的概率为 0.00000006,也就概率比较小(毕竟红黑树耗内存,且链表长度短点时遍历的还是很快的)。
这就是基于时间和空间的平衡了,红黑树占用内存大,所以节点少就不用红黑树,如果万一真的冲突很多,就用红黑树],选个参数为 8 的大小,就是为了平衡时间和空间的问题。
* 为什么节点少于 6 要从红黑树转成链表?
也是为了平衡时间和空间,节点太少链表遍历也很快,没必要成红黑树,变成链表节约内存。
为什么定了 6 而不是小于等于 8 就变?
是因为要留个缓冲余地,避免反复横跳。举个例子,一个节点反复添加,从 8 变成 9 ,链表变红黑树,又删了,从 9 变成 8,又从红黑树变链表,再添加,又从链表变红黑树?
所以余一点 ,毕竟树化和反树化都是有开销的。
* 那 JDK 1.8 对 HashMap 除了红黑树这个改动,还有哪些改动?
- hash 函数的优化
- 扩容 rehash 的优化
- 头插法和尾插法
- 插入与扩容时机的变更
-
hash 函数的优化
1.7是这样实现的:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
1.8
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
具体而言就是 1.7 的操作太多了,经历了四次异或,所以 1.8 优化了下,它将 key 的哈希码的高16位和低16位进行了异或,得到的 hash 值同时拥有了高位和低位的特性,这样做得出的码比较均匀,不容易冲突。
这也是 JDK 开发者根据速度、实用性、哈希质量所做的权衡来做的实现:
-
扩容 rehash 的优化
按照我们的思维,正常扩容肯定是先申请一个更大的数组,然后将原数组里面的每一个元素重新 hash 判断在新数组的位置,然后一个一个搬迁过去。
在 1.7 的时候就是这样实现的,然而 1.8 在这里做了优化,关键点就在于数组的长度是 2 的次方,且扩容为 2 倍。
因为数组的长度是 2 的 n 次方,所以假设以前的数组长度(16)二进制表示是 010000,那么新数组的长度(32)二进制表示是 100000,这个应该很好理解吧?
它们之间的差别就在于高位多了一个 1,而我们通过 key 的 hash 值定位其在数组位置所采用的方法是
(数组长度-1) & hash
。我们还是拿 16 和 32 长度来举例:16-1=15,二进制为 001111
32-1=31,二进制为 011111
所以重点就在 key 的 hash 值的从右往左数第五位是否是 1,如果是 1 说明需要搬迁到新位置,且新位置的下标就是原下标+16(原数组大小),如果是 0 说明吃不到新数组长度的高位,那就还是在原位置,不需要迁移。
所以,我们刚好拿老数组的长度(010000)来判断高位是否是 1,这里只有两种情况,要么是 1 要么是 0 。
从上面的源码可以看到,链表的数据是一次性计算完,然后一堆搬运的,因为扩容时候,节点的下标变化只会是原位置,或者原位置+老数组长度,不会有第三种选择。
上面的位操作,包括为什么是原下标+老数组长度等,如果你不理解的话,可以举几个数带进去算一算,就能理解了。
总结一下,1.8 的扩容不需要每个节点重写 hash 算下标,而是通过和老数组长度的&计算是否为 0 ,来判断新下标的位置。
额外再补充一个问题:为什么 HashMap 的长度一定要是 2 的 n 次幂?
原因就在于数组下标的计算,由于下标的计算公式用的是
i = (n - 1) & hash
,即位运算,一般我们能想到的是 %(取余)计算,但相比于位运算而言,效率比较低,所以推荐用位运算,而要满足上面这个公式,n 的大小就必须是 2 的 n 次幂。即:当 b 等于 2 的 n 次幂时,a % b 操作等于 a & ( b - 1 )
-
头插法和尾插法
1.7是头插法,
头插法的好处就是插入的时候不需要遍历链表,直接替换成头结点,但是缺点是扩容的时候会逆序,而逆序在多线程操作下可能会出现环,然后就死循环了。
然后 1.8 是尾插法,每次都从尾部插入的话,扩容后链表的顺序还是和之前一致,所以不可能出现多线程扩容成环的情况。
其实我在网上找了找,很多文章说尾插法的优化就是避免多线程操作成环的问题,我表示怀疑。因为 HashMap 本就不是线程安全的,我要还优化你多线程的情况?我觉得开发者应该不会做这样的优化。
那为什么要变成尾插法呢?
那再延伸一下,改成尾插法之后 HashMap 就不会死循环了吗?
好像还是会,这次是红黑树的问题 ,
-
插入与扩容时机的变更
1.7 是先判断 put 的键值对是新增还是替换,如果是替换则直接替换,如果是新增会判断当前元素数量是否大于等于阈值,如果超过阈值且命中数组索引的位置已经有元素了,那么就进行扩容。
if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(...)
而 1.8 则是先插入,然后再判断 size 是否大于阈值,若大于则扩容。
就这么个差别,至于为什么,好吧,我查了下没查出来,我自己也不知道,我个人觉得两者没差。。可能是重构(引入红黑树)的时候改了下顺序而已...其实没什么影响,
1.1) LinkedHashMap
LinkedHashMap 的父类是 HashMap,所以 HashMap 有的它都有,然后基于 HashMap 做了一些扩展。
首先它把 HashMap 的 Entry 加了两个指针:before 和 after。
LinkedHashMap也使用双向链表来维护key-value对的次序,该链表负责维护Map的迭代顺序,与key-value对的插入顺序一致(注意和TreeMap对所有的key-value进行排序进行区分)
2) Hashtable
是一个古老的Map实现类
2.1) Properties
Properties对象在处理属性文件时特别方便(windows平台上的.ini文件),Properties类可以把Map对象和属性文件关联起来,从而可以把Map对象中的key-value对写入到属性文件中,也可以把属性文件中的"属性名-属性值"加载到Map对象中
3) SortedMap
正如Set接口派生出SortedSet子接口,SortedSet接口有一个TreeSet实现类一样,Map接口也派生出一个SortedMap子接口,SortedMap接口也有一个TreeMap实现类
3.1) TreeMap
TreeMap就是一个红黑树数据结构,每个key-value对即作为红黑树的一个节点。TreeMap存储key-value对(节点)时,需要根据key对节点进行排序。TreeMap可以保证所有的
key-value对处于有序状态。同样,TreeMap也有两种排序方式: 自然排序、定制排序
4) WeakHashMap
WeakHashMap与HashMap的用法基本相似。区别在于,HashMap的key保留了对实际对象的"强引用",这意味着只要该HashMap对象不被销毁,该HashMap所引用的对象就不会被垃圾回收。
但WeakHashMap的key只保留了对实际对象的弱引用,这意味着如果WeakHashMap对象的key所引用的对象没有被其他强引用变量所引用,则这些key所引用的对象可能被垃圾回收,当垃
圾回收了该key所对应的实际对象之后,WeakHashMap也可能自动删除这些key所对应的key-value对
5) IdentityHashMap
IdentityHashMap的实现机制与HashMap基本相似,在IdentityHashMap中,当且仅当两个key严格相等(key1 == key2)时,IdentityHashMap才认为两个key相等
理解这个 map 的关键就在于它的名字 Identity,也就是它判断是否相等的依据不是靠 equals ,而是对象本身是否是它自己。
什么意思呢?
首先看它覆盖的 hash 方法:
可以看到,它用了个 System.identityHashCode(x)
,而不是x.hashCode()。
而这个方***返回原来默认的 hashCode 实现,不管对象是否重写了 hashCode 方法
默认的实现返回的值是:对象的内存地址转化成整数,是不是有点感觉了?
然后我们再看下它的 get 方法:
可以看到,它判断 key 是否相等并不靠 hash 值和 equals,而是直接用了 == 。
而 == 其实就是地址判断!
只有相同的对象进行 == 才会返回 true。
因此我们得知,IdentityHashMap 的中的 key 只认它自己(对象本身)。
即便你伪造个对象,就算值都相等也没用,put 进去 IdentityHashMap 只会多一个键值对,而不是替换,这就是 Identity 的含义。
比如以下代码,identityHashMap 会存在两个 Yes:
Map<String, String> identityHashMap = new IdentityHashMap<>();
identityHashMap.put(new Yes("1"), "1");
identityHashMap.put(new Yes("1"), "2");
为什么返回值是 tab[i+1]
?
这是因为 IdentityHashMap 的存储方式有点不一样,它是将 value 存在 key 的后面。
6) EnumMap
EnumMap是一个与枚举类一起使用的Map实现,EnumMap中的所有key都必须是单个枚举类的枚举值。创建EnumMap时必须显式或隐式指定它对应的枚举类。EnumMap根据key的自然顺序
(即枚举值在枚举类中的定义顺序)
7)TreeMap
TreeMap 内部是通过红黑树]实现的,可以让 key 的实现 Comparable 接口或者自定义实现一个 comparator 传入构造函数,这样塞入的节点就会根据你定义的规则进行排序。
这个用的比较少,我常用在跟加密有关的时候,有些加密需要根据字母序排,然后再拼接成字符串排序,在这个时候就可以把业务上的值统一都塞到 TreeMap 里维护,取出来就是有序的。