在集合(上)中我们详细的讲解了List的实现类的一些重要原理,Set的实现类还没有讲,但是我们简要的看下Set下的两个主要实现类的代码:
我们会看到Set的底层是Map的实现类,所以我们讲完Map再来看Set会变得简单些。
Map对于Map,得先知道它的一些特点:在Map中数据是以"key-value"的形式存在的,一个key对应一个value,俗称键值。就如同我们的饮料一样,一种饮料对应一个价格。
而对于需要了解的Map结构,先看看以下的"简单"关系图:
对于Map的常见接口主要有Map和TreeMap两个,而常用的实现类就看HashMap、Propertied、TreeMap这三个就行了。接下来还是一样,先看下常见接口的常用方法:
Map清空集合的所有元素。
判断集合中是否包含某个键或值。(底层调用equals方法,自定义类需要重写)
通过指定的键获取键包含的值。判断集合是否为空。
往集合中添加键值。
通过指定键来删除对应的键值。
返回集合中键值的个数。
这两个方法都返回了一个Set集合,之后遍历Map时候讲。
Map中还有一个重要的内部类:
这个是Map的实现类存取键值的重要的父类。
SortedMap重点就看这个comparator方法,之后到TreeMap讲。
Map的实现类和Collection集合一样,我们要了解几个实现类的一些底层原理,而且Map的原理会更复杂:
HashMap作为Map中最具有代表性的实现类,HashMap常用方法基本都在Map里面了。而HashMap底层的结构实际上是由数组加单向链表(红黑树)组成的。这也解释了为什么Map是无序的,而存储键值使用HashMap的Node内部类。那怎么定位到指定的数组,链表又怎么和数组连接起来构成一个HashMap呢?先看以下代码:
我们新建一个HashMap集合,然后在里面添加键值,这时候会根据键的哈希值(hashcode)来确定键值添加到数组的位置。这也是Map无序的体现了,因为根据哈希值的不同,每个键值不知道会存储到数组的哪个地方。而键的哈希值是有可能相同的,于是就会添加到数组的同一个索引下,而数组的一个索引只能存放一个元素,多个键值在同一个数组的索引下就变成了链表。当有着同一个哈希值的键值添加进链表的时候会对链表中的所有的键进行比较(equals),如果相同就覆盖已有键的值。这就是Map不可重复的由来了(所以上述的代码键为3的键值的值其实是只有乐虎而已)。于是乎就形成了大致如下的结构:
然后JDK8的新特性:为了加快检索效率加了一个红黑树。当数组的一个链表中的键值超过8个之后就会转换为红黑树,当数组的一个链表中的键值少于6个之后就会恢复成链表。并且遵循左小右大原则(比当前节点小的元素放在节点左边,反之右边)。
大致上了解了HashMap的一些原理之后,我们就看看源代码是如何实现的:
HashMap的常量和属性
上图截取了常见的常量及属性:
DEFAULT_INITIAL_CAPACITY表示默认初始容量;
MAXIMUM_CAPACITY表示最大容量;
DEFAULT_LOAD_FACTOR表示默认加载因子(扩容讲解);
TREEIFY_THRESHOLD表示转变为红黑树的临界值;
UNTREEIFY_THRESHOLD表示还原为链表的临界值;
在属性中用一个table的数组加一个节点类来表示Map的数据结构,用entrySet来缓存键值(用于entrySet方法);size和modCount和Collection一样来表示键值个数和集合被修改的次数;loadFactor表示加载因子(没有赋值就是上述的DEFAULT_LOAD_FACTOR)。threshold表示阈值,等于容量*加载因子。
静态内部类——Node
HashMap的实现链表的节点类:
可以看到它是实现了Map的内部类Map.Entry。
HashMap常用构造方法
如果是无参构造,加载因子为默认值,且其他属性都是默认值。
如果传入一个容量和加载因子,那么会进行判断,都合法(容量>=0且如果容量大于最大值,容量变为最大值;加载因子是数字且>0)就进行赋值。
如果传入一个容量,和上述一样,就是加载因子是默认值。
扩容机制
扩容一般发生在添加键值的时候,所以我们看put方法:
底层又调用了putVal方法,值得注意的是它先用一个hash方法获得了一个哈希值:
hash方法中对哈希值的处理主要是为了减少哈希碰撞(简要理解哈希碰撞就是对key转换后的哈希值相同,链表就是来解决哈希碰撞的)。底层调用了key对象的hashCode方法。如果key为null,返回0;否则进行一些特殊运算转换为哈希值。再看putVal方法方法:
看起来很复杂,但大体上就是如下图:
主要看最后的元素个数大于阈值,扩容的if语句,这才是重点。通过源代码我们可以发现,通过key的哈希值确定数组下标的表达式为:
(数组长度-1) & key的哈希值
HashMap在元素个数达到阈值(容量*加载因子)的时候使用resize方法扩容。而因为HashMap底层结构复杂,扩容也复杂,这里就截取一部分说明:
我们从这可以看出,当oldCap进行左移一位运算没有超过最大容量,并且oldCap已经大于默认容量时,新容量就变成原容量的两倍。也就是说,如果初始化HashMap用无参构造的话,初始容量为16,加载因子为默认加载因子0.75,当元素个数>16*0.75=12的时候,就会扩容成容量为32的集合。
JDK8前的HashMap
以上说的都是JDK8后的HashMap,那之前的HashMap有什么不同呢?JDK8前的HashMap不是用节点Node存储键值的,而是用Entry来储存;并且没有红黑树结构。只有数组+链表;发生哈希碰撞时在链表的头部插入(JDK8后从链表的尾部插入)。由于没有红黑树,我们在寻找存于某个链表中的键值时,最糟的时间复杂度为O(n),但红黑树因为左小右大的特性,最糟的时间复杂度为O(logn)。【n表示元素个数】
其他
经过上述我们知道了HashMap在进行元素的一些操作时都会调用key元素的hashCode和equals方法,所以如果是我们自定义的类,需要重写hashCode和equals方法。而重写hashCode这里就不再讲解,编译器可以自动生成(两者一般一起生成)。
equals之前提过是Object的方法,hashCode也是,但当时我们没有说,现在我们简要看看:
看常规协定中的前两点我们可以知道equals和hashCode
方法一般是一起重写的,而且如果equals方法比较两个对象返回true,那么两个对象的hashCode返回值也是一样的。
在HashMap中允许key和value为null,但因为key为null的时候hash(key)返回0,任何数与0进行&(与)运算都为0,所以key为null的键值只会存储在数组下标为0的位置。所以在HashMap中key为null的键值只有一个,但value为null的键值可以多个。
因为HashMap的无序不可重复,所以又有同一个链表/红黑树中的key的哈希值是一样的,同一个链表/红黑树下的key调用equals方法进行比较一定返回false。
HashMap的底层是数组加链表的这种结构也被称为哈希表/散列表。之所以要重写hashCode是因为如果哈希值如果变成了定值,那么哈希表就会变成一个纯单向链表/红黑树;哈希值如果都不一样,那么哈希表就会变成一个纯数组。而没有重写hashCode的话哈希表就会变成一个纯数组。以上两种情况我们都称为散列分布不均匀。
我们在看HashMap的常量和属性中,对于初始化容量中的注释我们需要知道:
The default initial capacity - MUST be a power of two.
默认初始化的容量-必须是2的幂。这是不仅为了提高效率,而且是为了达到散列分布均匀。
HashtableHashtable大致上都和HashMap一致,这里重点讲解两者的区别:
底层不是Node数组,而是Entry数组。
默认初始容量为11,默认加载因子为0.75
扩容机制用rehash方法,新容量是原容量的两倍+1.
看注释的@exception那里,会发现在Hashtable中不能向key或value储存null,会报空指针异常。而且所有供外部使用的方法都有synchronized关键字修饰。也即是说Hashtable是线程安全的,而HashMap是非线程安全的。所以Hashtable的效率比HashMap低。并且Hashtable的哈希值直接由key的hashCode方法确定。
Properties作为Hashtable的子类,Hashtable有的它都有了,直接看它与Hashtable的不同:
从该方法我们可以知道Properties是专门用来储存String类型的key和value。因此也称为属性类。我们可以通过以上的setProperty来设置键值。
用getProperty来通过key获取value。如果我们在设置键值的时候调用put方法传入非字符串value的时候,那么调用getProperty就会把value转换为null。如果调用put方法传入非字符串key的时候那getProperty就不能用了。不过是一般是没有人用Properties来放非字符串类型数据的。
TreeMap对于TreeMap,也是一个挺重要的部分。作为SortedMap下的实现类,不仅实现了无序不可重复,而且实现了自动排序。其底层是一个红黑树。
鉴于红黑树出现那么多次,现在简单介绍下红黑树的一些特点。先看看简单的二叉查找树:
二叉查找树的特点就是只有左小右大的原则进行排序,因此可能会出现以下的情况:
这时候说它是二叉查找树也不像,更像一个链表。这时候,红黑树就可以极大程度的解决这个问题:
从以上图片就可以知道红黑树的每个节点不是红就是黑的;根节点总是黑色的;如果节点是红色的,那么字节点一定是黑色(反之不一定);为了保证树的平衡性,先根据左小右大的原则进行排序后,会对树进行检测其平衡性,如果不平衡,会进行一系列的旋转,改色等操作来保持平衡。感兴趣的读者可以进入
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
这个网站,里面模拟了很多数据结构。
接下来我们就慢慢看TreeMap这个实现类。
基本属性
comparator,比较器。上面的SortedMap的comparator
方法可以返回它。不过重点不是这个,是等下怎么实现这个比较器。root是红黑树的根。size红黑树的节点个数。modCount统计TreeMap结构改变次数。
Entry静态内部类
可以看到在TreeMap中,一个Entry有着六个属性。分别对应着我们上述的结构。left表示左节点,right表示右节点,parent表示上层节点,color表示当前节点是红还是黑(黑为true,红为false)。
构造方法
无参的构造方法是没有比较器的,我们可以通过在构造方法中传入比较器使TreeMap集合有比较器,那么有比较器和没有比较器有什么区别呢?我们先看put方法。
排序机制
在讲解排序机制前,我们先看看TreeMap能不能添加自定义的类:
我们会发现居然报了类型转换异常,而且是不能转换为Comparable类异常。
但是添加进去String类或者Integer为什么就可以了呢?其实很简单,它们底层都实现了上述的Comparable接口。
接下来我们就通过put的源代码来弄清楚为什么会报错,并了解TreeMap底层是如何实现排序的:
这里就不像HashMap那样可以看下简单的扩容和数组下标计算方法就行,这里设计比较的机制,所以我们需要将整个代码都看一遍。里面也有一个重要方法compare:
看上图put方法的第14行+第17行和第26行+第30行,可以发现当TreeMap中有比较器的时候会调用比较器的compare方法来确定key的位置,而当TreeMap中没有比较器的时候会先把key强制转换为Comparable类,并使用Comparable类的compareTo方法来确定key的位置。所以到这里我们大概就可以总结出TreeMap实现排序的两种方式:
1、添加进去的key对应的类必须实现Comparable接口。
2、自己写一个类实现Comparator接口。在构造TreeMap的时候传进去。
那里面的排序规则怎么写呢?我们先看有比较器和没有比较器put方法是怎么添加的我们再来确定怎么写规则:
可以看出就是红黑树的结构,这时候我们就开始思考要怎么写排序比较好了。
1
先从第一种开始:假如我有一个猫类,确定它的大小想要根据年龄的大小来排,我们就可以像以下的格式写:
仔细思考下,是不是就和源代码的排序规则对上了。不同的规则实现方式也有所不同,比如:判断猫类对象的大小我们想先根据年龄的大小排,但如果年龄相同,想根据它的体重排,那么代码就可以改成如下的形式:
当然如果比较规则中需要比较的有字符串类型的,可以直接调用String类实现的compareTo方法就可以。
2
第二种就是实现一个比较器了,还是按以上的猫类来看,我们简单点,就根据年龄来确定大小,于是就有:
有没有读者感觉这样实现Comparator接口有点麻烦呢?确定有点,但我们可以使用匿名内部类来简化:
是不是感觉好多了?但光说没用,我们通过Map的两种遍历方式来看上述实现比较之后的效果。
Map实现类的遍历方式
keySet第一种就是用Map的方法keySet
这个方法返回一个关于key的Set集合,这时候map可以根据key获取value,因此我们就可以用foreach或iterator进行遍历:
注意用iterator的时候需要将next指向的key先取出来,如果直接用next:
因为每调用一次next方法,迭代器的指针会向后移,所以我们也发现了第一次输出的cat对象对应的value是下一个cat对象对应的value。而走第二次while的时候,代码块里又执行了两次next,加上第一次while的时候走的两次,总共有4下next,所以指针往后移了4次,当移动最后一次的时候指针没有指向元素,因此就会报NoSuchElementException异常。
entrySet这个方法返回一个Map.Entry的Set集合。而Map.Entry是Map的一个内部类:
需要知道的是,这里返回的是一个接口,说明用了多态,其内部是Map.Entry的实现类,方法肯定也实现了,在这里我们只需要知道我们可以通过getKey和getValue方法来获取Map.Entry的key和value:
也是一样用迭代器或foreach遍历。Iterator的遍历注意事项和上面的keySet一致。
相同的元素,把TreeMap改成HashMap就没有排序的效果,而且key相同覆盖value:
TreeMap续看完了遍历方式,我们会发现输出TreeMap的键值的时候是根据key的从小到大规则排的,原因之一是我们在实现比较的时候就是实现左小右大,还有一点就是TreeMap在遍历的时候是采用中序遍历(左根右)。这里就不拓展了。而且后者我们是无法修改的,但前者我们可以修改,就是在实现比较的时候两者的位置调换:
这时候就实现了从大到小排了。
对于TreeMap还有一点需要知道的,在以上的代码中我们并没有发现TreeMap有调用key的equals方法,所以在TreeMap中,存放自定义类的时候可以不写equals方法,但最好还是写上。
终于!到这Map终于是差不多完结了,这时候我们回过头看Set中的HashSet和TreeSet。不用慌,就看两者底层的add方法就能说明问题(HashSet和TreeSet的add底层差不多,直接看HashSet的):
可以看到底层调用Map的put方法那个PRESENT其实是一个用处不大的Object类:
而put方法返回的是value的值,这里直接赋值为null。
因此,我们可以认为Set是value为null的Map。所以Set的无序不可重复更多的是依赖于Map的key无序不可重复。不过还要记得Set的常用方法是在Collection里面。
ArrayList | HashMap | TreeMap | |
自定义类 要求 | 最好重写equals | 一定需要 重写equals和hashCode | 不一定重写 equals, 但一定要实现比较。 |