java集合Collection之Map

HashMap的工作原理包括计算key的hashCode定位value,通过扩容算法处理数据增长,put方法涉及的元素插入逻辑,以及基于hashCode和equals方法的键值对比较。文章还讨论了线程安全问题,特别是在1.7和1.8版本的ConcurrentHashMap中的不同实现。此外,还提到了TreeMap的有序性及其对Key的要求。
摘要由CSDN通过智能技术生成

传送门

SpringMVC的源码解析(精品)
Spring6的源码解析(精品)
SpringBoot3框架(精品)
MyBatis框架(精品)
MyBatis-Plus
SpringDataJPA
SpringCloudNetflix
SpringCloudAlibaba(精品)
Shiro
SpringSecurity
java的LOG日志框架
Activiti(敬请期待)
JDK8新特性
JDK9新特性
JDK10新特性
JDK11新特性
JDK12新特性
JDK13新特性
JDK14新特性
JDK15新特性
JDK16新特性
JDK17新特性
JDK18新特性
JDK19新特性
JDK20新特性
JDK21新特性
其他技术文章传送门入口

一、前导

既然HashMap内部使用了数组,通过计算key的hashCode()直接定位value所在的索引,那么第一个问题来了:hashCode()返回的int范围高达±21亿,先不考虑负数,HashMap内部使用的数组得有多大?

实际上HashMap初始化时默认的数组大小只有16,任何key,无论它的hashCode()有多大,都可以简单地通过:
int index = key.hashCode() & 0xf; // 0xf = 15
把索引确定在0~15,即永远不会超出数组范围,上述算法只是一种最简单的实现。

第二个问题:如果添加超过16个key-value到HashMap,数组不够用了怎么办?

添加超过一定数量的key-value时,HashMap会在内部自动扩容,每次扩容一倍,即长度为16的数组扩展为长度32,相应地,需要重新确定hashCode()计算的索引位置。例如,对长度为32的数组计算hashCode()对应的索引,计算方式要改为:
int index = key.hashCode() & 0x1f; // 0x1f = 31
由于扩容会导致重新分布已有的key-value,所以,频繁扩容对HashMap的性能影响很大。如果我们确定要使用一个容量为10000个key-value的HashMap,更好的方式是创建HashMap时就指定容量:
Map<String, Integer> map = new HashMap<>(10000);
虽然指定容量是10000,但HashMap内部的数组长度总是2n,因此,实际数组长度被初始化为比10000大的16384(214)。

最后一个问题:如果不同的两个key,例如"a"和"b",它们的hashCode()恰好是相同的(这种情况是完全可能的,因为不相等的两个实例,只要求hashCode()尽量不相等),那么,当我们放入:
map.put(“a”, new Person(“Xiao Ming”));
map.put(“b”, new Person(“Xiao Hong”));
时,由于计算出的数组索引相同,后面放入的"Xiao Hong"会不会把"Xiao Ming"覆盖了?

当然不会!使用Map的时候,只要key不相同,它们映射的value就互不干扰。但是,在HashMap内部,确实可能存在不同的key,映射到相同的hashCode(),即相同的数组索引上,肿么办?

我们就假设"a"和"b"这两个key最终计算出的索引都是5,那么,在HashMap的数组中,实际存储的不是一个Person实例,而是一个List,它包含两个Entry,一个是"a"的映射,一个是"b"的映射:
┌───┐
0 │ │
├───┤
1 │ │
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> List<Entry<String, Person>>
├───┤
6 │ │
├───┤
7 │ │
└───┘

在查找的时候,例如:

Person p = map.get(“a”);
HashMap内部通过"a"找到的实际上是List<Entry<String, Person>>,它还需要遍历这个List,并找到一个Entry,它的key字段是"a",才能返回对应的Person实例。

我们把不同的key具有相同的hashCode()的情况称之为哈希冲突。在冲突的时候,一种最简单的解决办法是用List存储hashCode()相同的key-value。显然,如果冲突的概率越大,这个List就越长,Map的get()方法效率就越低,这就是为什么要尽量满足条件二:
如果两个对象不相等,则两个对象的hashCode()尽量不要相等。
hashCode()方法编写得越好,HashMap工作的效率就越高。

二、超级概念

key数组下标index的计算:(n-1)& hashCode ; n为当前map容量大小,和hashCode进行与操作,比如n=16的时候,就是15&hashCode,二级制的15为 00001111,在与操作的时候,不管hashCode是多少,由于前面都是4个0000,所以前面都为0,(与操作,1&1=1 其他都为0),最大值就是 1111,最小值0000,低四位就是hashCode的低四位,而0000-1111的二进制值就是0-15范围,刚好就是16这个长度数组的index下标范围。
在这里插入图片描述
四种key存储。key是一个Node<k,v>类,第一种:原数组的数据为空,直接放到数组上。 第二种:原数组有值并且对比新老Node对象的泛型k都一样,就去覆盖value。第三种:原数组有值为红黑树,插入value到红黑树中。第四种:原数组有值为链表,插入value到链表中。
链表转红黑树时,链表上有9个Node
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

三、hashCode

一、hashcode是什么?
1、hash和hash表是什么?
hash是一个函数,该函数中的实现就是一种算法,就是通过一系列的算法来得到一个hash值。这个时候,我们就需要知道另一个东西,hash表,通过hash算法得到的hash值就在这张hash表中,也就是说,hash表就是所有的hash值组成的,有很多种hash函数,也就代表着有很多种算法得到hash值

2、hashcode
有了前面的基础,这里讲解就简单了,hashcode就是通过hash函数得来的,通俗的说,就是通过某一种算法得到的,hashcode就是在hash表中有对应的位置。

每个对象都有hashcode,对象的hashcode怎么得来的呢?

首先一个对象肯定有物理地址,在别的博文中会hashcode说成是代表对象的地址,这里肯定会让读者形成误区,对象的物理地址跟这个hashcode地址不一样,hashcode代表对象的地址说的是对象在hash表中的位置,物理地址说的对象存放在内存中的地址,那么对象如何得到hashcode呢?

通过对象的内部地址(也就是物理地址)转换成一个整数,然后该整数通过hash函数的算法就得到了hashcode。所以,hashcode是什么呢?就是在hash表中对应的位置。

这里如果还不是很清楚的话,举个例子,hash表中有 hashcode为1、hashcode为2、(…)3、4、5、6、7、8这样八个位置,有一个对象A,A的物理地址转换为一个整数17(这是假如),就通过直接取余算法,17%8=1,那么A的hashcode就为1,且A就在hash表中1的位置。

肯定会有其他疑问,接着看下面,这里只是举个例子来让你们知道什么是hashcode的意义。

二、hashcode有什么作用呢?
前面说了这么多关于hash函数,和hashcode是怎么得来的,还有hashcode对应的是hash表中的位置,可能大家就有疑问,为什么hashcode不直接写物理地址呢,还要另外用一张hash表来代表对象的地址?接下来就告诉你hashcode的作用,

1、HashCode的存在主要是为了查找的快捷性,HashCode是用来在散列存储结构中确定对象的存储地址的(后半句说的用hashcode来代表对象就是在hash表中的位置)

为什么hashcode就查找的更快,比如:我们有一个能存放1000个数这样大的内存中,在其中要存放1000个不一样的数字,用最笨的方法,就是存一个数字,就遍历一遍,看有没有相同得数,当存了900个数字,开始存901个数字的时候,就需要跟900个数字进行对比,这样就很麻烦,很是消耗时间,用hashcode来记录对象的位置,来看一下。

hash表中有1、2、3、4、5、6、7、8个位置,存第一个数,hashcode为1,该数就放在hash表中1的位置,存到100个数字,hash表中8个位置会有很多数字了,1中可能有20个数字,存101个数字时,他先查hashcode值对应的位置,假设为1,那么就有20个数字和他的hashcode相同,他只需要跟这20个数字相比较(equals),如果每一个相同,那么就放在1这个位置,这样比较的次数就少了很多,实际上hash表中有很多位置,这里只是举例只有8个,所以比较的次数会让你觉得也挺多的,实际上,如果hash表很大,那么比较的次数就很少很少了。

通过对原始方法和使用hashcode方法进行对比,我们就知道了hashcode的作用,并且为什么要使用hashcode了

三、equals方法和hashcode的关系?
通过前面这个例子,大概可以知道,先通过hashcode来比较,如果hashcode相等,那么就用equals方法来比较两个对象是否相等。

用个例子说明:上面说的hash表中的8个位置,就好比8个桶,每个桶里能装很多的对象,对象A通过hash函数算法得到将它放到1号桶中,当然肯定有别的对象也会放到1号桶中,如果对象B也通过算法分到了1号桶,那么它如何识别桶中其他对象是否和它一样呢,这时候就需要equals方法来进行筛选了。

1、如果两个对象equals相等,那么这两个对象的HashCode一定也相同

2、如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置

四、为什么equals方法重写的话,建议也一起重写hashcode方法?
举个例子,其实就明白了这个道理,

比如:有个A类重写了equals方法,但是没有重写hashCode方法,看输出结果,对象a1和对象a2使用equals方法相等,按照上面的hashcode的用法,那么他们两个的hashcode肯定相等,但是这里由于没重写hashcode方法,他们两个hashcode并不一样,所以,我们在重写了equals方法后,尽量也重写了hashcode方法,通过一定的算法,使他们在equals相等时,也会有相同的hashcode值。

原文链接:https://blog.csdn.net/weixin_44364444/article/details/120054230

四、put方法

1、概念

扩容算法概述:
resize是数组为空的时候,初始化数组。也是数组扩容的方法(链表和红黑树没有扩容)。数组扩容必须new一个新的数据,因为旧数组后面的紧紧挨着的空间地址可能被别的程序用了,所以不能在旧数组后面直接拼接,只能new一个新的出来,并且是原来数组的两倍。然后就是旧数组的数据转移到新数组上。
【jdk1.7的算法:扩容(n-1)& hashCode中n变大了(n=16的时候是hashCode的低四位,所以只要低四位一样,就能放一块形成链表,但是n 扩大以后,就不止低四位一样,比如n=32就要低五位一样了才能放一块),所有key在数组上的index会重新计算,这种会导致链表上挂的9个节点的index不一样,间接的减少了链表的长度。由于每个链表节点都重新计算了一次,映射到新数组上了,可是链表节点是有next指向下一个节点的,这种重新计算以后在新数组上很大可能形成一种闭环了,当map取值循环遍历链表的时候会出现死循环。】
【jdk1.8的算法:扩容有规律,如后图说明,比如n=16变为n=32,假如有个Node节点在 原来n=16的时候,是index=0的位置,也就是第一个 位置,n=32以后,这个节点要么还在index=0的位置也是源码中说的低位,要么就在index=16的位置也是源码中的高位。这个规律可以不用像jdk1.7那样每一个节点都简单粗暴要算一次。而且依靠高低位,将链表的next指向也变了,变成两组了,低位和低位的在一起,高位和高位的在一起,而且还避免了闭环问题。】
在这里插入图片描述

HashMap<Object, Object> map = new HashMap<>(32);
Object aaAaAa = map.put(“AaAaAa”, “2”);// 返回值为null,因为一开始没存值
Object aaAaAa1 = map.put(“AaAaAa”, “3”);// 返回值为2,因为3覆盖了2,返回旧值
map.putIfAbsent(“AaAaAa”, “4”);// 当这个key存在的时候就不往进存了,当不存在的时候,4才能存进去,所以 这边没有存进去,没有覆盖成功。put和putIfAbsent的区别。

2、源码分析

在这里插入图片描述
Node<K,V> p 是原来数组旧数据,是旧Node对象;线路1情况下K k 是原来数组旧Node<K,V>对象泛型中的k; Node<K,V> e 是代表数组将来存的数据,是新Node对象;key,value都是传过来的新数据值。
线路1截图说明;特别注意:很多赋值是if小括号里面完成的,是有效的,并且影响后续else代码的。
resize是数组为空的时候,初始化数组。也是数组扩容的方法(链表和红黑树没有扩容)。
在这里插入图片描述
Node<K,V> p 是原来数组旧数据,是旧Node对象;线路3情况下K k 是新Node<K,V> e对象泛型中的k; Node<K,V> e 是代表数组将来存的数据,是新Node对象;key,value都是传过来的新数据值。
线路3截图说明;说白了就是依靠判断p.next为不为空,间接遍历了整个链表,有相同Node对象泛型k的就覆盖value,没有就在末端插入进去。没有对比顺序一说,后来的就插入到链表最后端。链表数量大于8的时候变为红黑树。链表太长的话,插入很快,但是查询非常慢,所以变为红黑树提高性能。
在这里插入图片描述
线路3中的红黑树644行代码说明截图。
注意757行代码,判断数组长度如果小于64,不会去改成红黑树,只会扩容。【jdk1.7的算法:扩容(n-1)& hashCode中n变大了,所有key在数组上的index会重新计算,这种会导致链表上挂的9个节点的index不一样,间接的减少了链表的长度。】当然扩容最多扩展到64数组长度。
截图里面的prev和next就是让链表为双向链表了,既知道自己的父节点,也知道下一个节点;每一个节点都是这样,就形成了双向链表。
注意772行代码,截图里面do while循环以后,只是个双向链表,还不是树,这个772行代码就是让双向链表变为红黑树,给每个节点增加left、right属性。主要是红黑树插入的一些处理(平衡插入,左旋,右旋等等,很复杂)(图灵的周瑜老师没时间讲了,没讲)。

五、get方法

首先将 key hash 之后取得所定位的桶=数组。

如果桶为空则直接返回 null 。

否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。

如果第一个不匹配,则判断它的下一个是红黑树还是链表。

红黑树就按照树的查找方式返回值。

不然就按照链表的方式遍历匹配返回值。

从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。

六、红黑树

在这里插入图片描述
左旋、右旋,树会自己动态变化来符合红黑树的定义。跟会变儿子,儿子会变跟,红色会变黑色,黑色也会变红色,反正要符合上面规则。
在这里插入图片描述

七、线程安全理解

jdk1.7:(本质,扩容的时候导致循环链表出现) 两个线程都往map里面放值,刚好让map扩容,map会生成一个新数组是原来数组的两倍,两个线程的时候就是两个新两倍数组了。线程A往新数组A-Array重新计算index移动链表的节点过去,线程B可能执行了一部分,有些东西刚好指向了A-Array了,然后B是等待状态,当新数组A-Array完成以后,线程B又开始工作,这个时候线程B是从A-Array往过B-Array移动节点,这种next指向在多线程的情况下很容易混乱,结合源码,会导致next指向B-Array自己链表的父节点或者父父节点,导致一个链表成环。put或者get链表中的节点的时候会导致死循环。(put的时候也要循环找,也会死循环)。
1.7解决方案:
1、在map容量很确定的情况下,写死加载因子大于容量,让扩容永远不发生。
2、让上游加锁来控制线程安全。
3、HashTable直接在put方法加了synchronized,这会导致是在对象上面加锁,太重了,效率太低了。这种不行的,但是也是解决方案。
4、ConcurrentHashMap的分段锁,它将原来往数组里面放的Entry改为了Segment类,并且Segment里面有HashEntry[]属性,每一个HashEntry就是一个键值对。可以说ConcurrentHashMap是一个二级哈希表,在一个总的哈希表下面有若干二级哈希表。采取了锁分段技术,每个Segment各自持有一把锁,Segment继承了ReentrantLock(底层Unsafe)。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。不同Segment的可以并发写入;同一Segment的一写一读可以同时进行;同一Segment的并发写入要上锁只能一个写入。缺点:1.7版本的也是查询遍历链表效率太低。所以在1.8也引入了红黑树,并且不用分段锁了。用CAS+synchronized了。1.7的缺点是分段不连续的时候容易导致内存空间的浪费,还有就是分段特别大的时候会影响效率(更新要长时间等待),1.7是没有红黑树的。

1.8的ConcurrentHashMap在链表的头节点加了synchronized,并结合CAS自旋来保证安全,树化那边也是跟节点加了synchronized,总结就是 对tab[i]加了synchronized,通过CAS往tab[i]插入值保证并发安全。1.8的代码把数组中每个元素看成一个桶,可以看到大部分的CAS操作,加锁部分是对桶的头结点进行加锁粒度很小,而且CAS不阻塞。(说白了就是为了更好的性能和更少的内存空间)

为什么弃用Segement而用Synchroniized
减少内存开销,如果使用ReentrantLock则需要节点继承AQS来获取同步支持,增加内存开销,而1.8只有头部节点需要进行同步
内部优化:synchronized是jvm直接支持的,jvm能够运行时做出相应的优化措施,锁粗化,锁消除,锁自旋,这使得synchronized能够随着JDK版本升级而不用改动代码前提下获得性能提升。

作者:星空怎样
链接:https://www.jianshu.com/p/5ee960a318c5
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1.7和1.8的不同:1.7用的Entry放数组里面,1.8用的Node;1.7的链表用的头部插入法后移动,1.8的链表用的尾部插入法不用移动;1.7没有红黑树;1.7是直接重新计算移动节点,1.8是计算低位和高位规律,再去移动

八、ConcurrentHashMap的1.7版本

Get方法:

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.再次通过hash值,定位到Segment当中数组的具体位置。

Put方法:

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.获取可重入锁

4.再次通过hash值,定位到Segment当中数组的具体位置。

5.插入或覆盖HashEntry对象。

6.释放锁

九、遍历Map

Set实际上相当于只存储key、不存储value的Map。我们经常用Set用于去除重复元素。

因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。

最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装,它的核心代码如下:

public class HashSet<E> implements Set<E> {
    // 持有一个HashMap:
    private HashMap<E, Object> map = new HashMap<>();

    // 放入HashMap的value:
    private static final Object PRESENT = new Object();

    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }

    public boolean contains(Object o) {
        return map.containsKey(o);
    }

    public boolean remove(Object o) {
        return map.remove(o) == PRESENT;
    }
}

十、编写equals和hashCode

我们知道Map是一种键-值(key-value)映射表,可以通过key快速查找对应的value。

以HashMap为例,观察下面的代码:
Map<String, Person> map = new HashMap<>();
map.put(“a”, new Person(“Xiao Ming”));
map.put(“b”, new Person(“Xiao Hong”));
map.put(“c”, new Person(“Xiao Jun”));

map.get(“a”); // Person(“Xiao Ming”)
map.get(“x”); // null
HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引:

┌───┐
0 │ │
├───┤
1 │ ●─┼───> Person(“Xiao Ming”)
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> Person(“Xiao Hong”)
├───┤
6 │ ●─┼───> Person(“Xiao Jun”)
├───┤
7 │ │
└───┘
如果key的值为"a",计算得到的索引总是1,因此返回value为Person(“Xiao Ming”),如果key的值为"b",计算得到的索引总是5,因此返回value为Person(“Xiao Hong”),这样,就不必遍历整个数组,即可直接读取key对应的value。
当我们使用key存取value的时候,就会引出一个问题:

我们放入Map的key是字符串"a",但是,当我们获取Map的value时,传入的变量不一定就是放入的那个key对象。

换句话讲,两个key应该是内容相同,但不一定是同一个对象。测试代码如下:

public class Main {
public static void main(String[] args) {
String key1 = “a”;
Map<String, Integer> map = new HashMap<>();
map.put(key1, 123);

    String key2 = new String("a");
    map.get(key2); // 123

    System.out.println(key1 == key2); // false
    System.out.println(key1.equals(key2)); // true
}

}
因为在Map的内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。

我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。

我们再思考一下HashMap为什么能通过key直接计算出value存储的索引。相同的key对象(使用equals()判断时返回true)必须要计算出相同的索引,否则,相同的key每次取出的value就不一定对。

通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value。

因此,正确使用Map必须保证:

作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回true;

作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:

如果两个对象相等,则两个对象的hashCode()必须相等;
如果两个对象不相等,则两个对象的hashCode()尽量不要相等

即对应两个实例a和b:

如果a和b相等,那么a.equals(b)一定为true,则a.hashCode()必须等于b.hashCode();
如果a和b不相等,那么a.equals(b)一定为false,则a.hashCode()和b.hashCode()尽量不要相等。
上述第一条规范是正确性,必须保证实现,否则HashMap不能正常工作。

而第二条如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode(),会造成Map内部存储冲突,使存取的效率下降。

正确编写equals()的方法我们已经在编写equals方法一节中讲过了,以Person类为例:
public class Person {
String firstName;
String lastName;
int age;
}

把需要比较的字段找出来:

firstName
lastName
age
然后,引用类型使用Objects.equals()比较,基本类型使用==比较。

在正确实现equals()的基础上,我们还需要正确实现hashCode(),即上述3个字段分别相同的实例,hashCode()返回的int必须相同:
public class Person {
String firstName;
String lastName;
int age;

@Override
int hashCode() {
    int h = 0;
    h = 31 * h + firstName.hashCode();
    h = 31 * h + lastName.hashCode();
    h = 31 * h + age;
    return h;
}

}

注意到String类已经正确实现了hashCode()方法,我们在计算Person的hashCode()时,反复使用31*h,这样做的目的是为了尽量把不同的Person实例的hashCode()均匀分布到整个int范围。
和实现equals()方法遇到的问题类似,如果firstName或lastName为null,上述代码工作起来就会抛NullPointerException。为了解决这个问题,我们在计算hashCode()的时候,经常借助Objects.hash()来计算:
int hashCode() {
return Objects.hash(firstName, lastName, age);
}

所以,编写equals()和hashCode()遵循的原则是:

equals()用到的用于比较的每一个字段,都必须在hashCode()中用于计算;equals()中没有使用到的字段,绝不可放在hashCode()中计算。

另外注意,对于放入HashMap的value对象,没有任何要求。

十一、HashMap扩容

1.初始容量为1<<4 =2的4次方=16(源码是左移四位),下次自动扩容为32,64都是原来的一倍,最大容量为2的30次方,在当前元素所占容量达到75%的时候就会自动扩容,阀值是0.75(比如16个,达到12个就会自动扩容,当然扩容前后都是16的倍数,目的为减少调整元素的个数)
2.new HashMap的时候,没有元素的时候size=0,table=null,当加入一个元素以后size就变成16了,这样的设计是为了节约空间
3.红黑树:当链表长度大于8,并且同时数组长度大于64的时候(两个条件同时满足,如果链表先到8了就会触发扩容重新计算index,间接简短 链表长度),就会变为红黑树,效率极大的提高。
4.链表转红黑树时,链表上有9个Node。

十二、TreeMap

我们已经知道,HashMap是一种以空间换时间的映射表,它的实现原理决定了内部的Key是无序的,即遍历HashMap的Key时,其顺序是不可预测的(但每个Key都会遍历一次且仅遍历一次)。

还有一种Map,它在内部会对Key进行排序,这种Map就是SortedMap。注意到SortedMap是接口,它的实现类是TreeMap。

   ┌───┐
   │Map│
   └───┘
     ▲
┌────┴─────┐
│          │

┌───────┐ ┌─────────┐
│HashMap│ │SortedMap│
└───────┘ └─────────┘


┌─────────┐
│ TreeMap │
└─────────┘

SortedMap保证遍历时以Key的顺序来进行排序。例如,放入的Key是"apple"、“pear”、“orange”,遍历的顺序一定是"apple"、“orange”、“pear”,因为String默认按字母排序:
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new TreeMap<>();
map.put(“orange”, 1);
map.put(“apple”, 2);
map.put(“pear”, 3);
for (String key : map.keySet()) {
System.out.println(key);
}
// apple, orange, pear
}
}

使用TreeMap时,放入的Key必须实现Comparable接口。String、Integer这些类已经实现了Comparable接口,因此可以直接作为Key使用。作为Value的对象则没有任何要求。

如果作为Key的class没有实现Comparable接口,那么,必须在创建TreeMap时同时指定一个自定义排序算法:
public class Main {
public static void main(String[] args) {
Map<Person, Integer> map = new TreeMap<>(new Comparator() {
public int compare(Person p1, Person p2) {
return p1.name.compareTo(p2.name);
}
});
map.put(new Person(“Tom”), 1);
map.put(new Person(“Bob”), 2);
map.put(new Person(“Lily”), 3);
for (Person key : map.keySet()) {
System.out.println(key);
}
// {Person: Bob}, {Person: Lily}, {Person: Tom}
System.out.println(map.get(new Person(“Bob”))); // 2
}
}

class Person {
public String name;
Person(String name) {
this.name = name;
}
public String toString() {
return "{Person: " + name + “}”;
}
}

注意到Comparator接口要求实现一个比较方法,它负责比较传入的两个元素a和b,如果a<b,则返回负数,通常是-1,如果a==b,则返回0,如果a>b,则返回正数,通常是1。TreeMap内部根据比较结果对Key进行排序。

从上述代码执行结果可知,打印的Key确实是按照Comparator定义的顺序排序的。如果要根据Key查找Value,我们可以传入一个new Person(“Bob”)作为Key,它会返回对应的Integer值2。

另外,注意到Person类并未覆写equals()和hashCode(),因为TreeMap不使用equals()和hashCode()。
我们来看一个稍微复杂的例子:这次我们定义了Student类,并用分数score进行排序,高分在前:
public class Main {
public static void main(String[] args) {
Map<Student, Integer> map = new TreeMap<>(new Comparator() {
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
});
map.put(new Student(“Tom”, 77), 1);
map.put(new Student(“Bob”, 66), 2);
map.put(new Student(“Lily”, 99), 3);
for (Student key : map.keySet()) {
System.out.println(key);
}
System.out.println(map.get(new Student(“Bob”, 66))); // null?
}
}

class Student {
public String name;
public int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
public String toString() {
return String.format(“{%s: score=%d}”, name, score);
}
}
在for循环中,我们确实得到了正确的顺序。但是,且慢!根据相同的Key:new Student(“Bob”, 66)进行查找时,结果为null!

这是怎么肥四?难道TreeMap有问题?遇到TreeMap工作不正常时,我们首先回顾Java编程基本规则:出现问题,不要怀疑Java标准库,要从自身代码找原因。

在这个例子中,TreeMap出现问题,原因其实出在这个Comparator上:
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
在p1.score和p2.score不相等的时候,它的返回值是正确的,但是,在p1.score和p2.score相等的时候,它并没有返回0!这就是为什么TreeMap工作不正常的原因:TreeMap在比较两个Key是否相等时,依赖Key的compareTo()方法或者Comparator.compare()方法。在两个Key相等时,必须返回0。因此,修改代码如下:
public int compare(Student p1, Student p2) {
if (p1.score == p2.score) {
return 0;
}
return p1.score > p2.score ? -1 : 1;
}
或者直接借助Integer.compare(int, int)也可以返回正确的比较结果。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

蓝影铁哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值