Map及HashMap原理简述
Map简述:
Map是一个接口,也称查找表(映射表),它给我们规定了,两行多列的一个表格,以键值对的形式保存数据,要求Key是不可以重复的,键必须是唯一的,而值可以有重复。
对Map中使用的键的要求与对Set中的元素的要求一样。任何键都必须具有一个equals()方法;如果键被用于散列Map,那么它必须还具有恰当的hashCode()方法;如果键被用于TreeMap,那么它必须实现Comparable;
Map要求分别对key和value单独去指定泛型;
Map对象中的操作有存、取、删、判断等,分别对应put(),get(),remove()和containsKey()方法;
Map的遍历有3种方式,分别是遍历所有的key,遍历所有的value,以及每一组键值对,分别对应的操作为set<K> keySet(),Collection<V> values() 和 Set<Entry> entrySet();
Map中keySet()方法返回由Map的键组成的Set;values()方法会产生一个包含Map中所有"值"的Collection;由于这些Collection背后是由Map支持的,所以对Collection的任何改动都会反映到与之相关联的Map。
Map的遍历之键值对:
需要注意的是,当遍历每一组键值对时,因为在Map当中,它存元素的时候,一组键值对,实际上是用一个Entry实例来保存的,而Entry其实就是Map当中的一个内部类。
即当Map每存一组键值对的时候,底层都把它放到了一个Entry上,而Entry也有两个属性,就是key和value,所以可以理解为,每一个Entry实例,就表示着Map当中的一组键值对;
比如我们存了5组键值对时,实际上,Map当中就有5个Entry实例;或者理解为Entry就相当于是Map当中的一行。
具体的操作:
首先调用Map的一个方法叫entrySet(),这个方法产生一个由Map.Entry的元素构成的Set集合entrySet,并且这个Set是一个Iterable,因此它可以用于foreach循环;
[Java SE5中,大量的类都是Iterable类型,主要包括所有的Collection类(但是不包括各种Map)]。
因为Entry本身表示的是Map当中的一组键值对,所以也必须为其指定泛型,和我们要遍历的Map保持一致。
遍历这个entrySet的每一个Entry,就相当于得到了Map当中的每一组键值对。如果想分别获取这组键值对里面的key和value,Entry给我们提供了两个方法,一个叫做getKey(),另一个叫做getValue(),来分别获取key和value的值。
Map.Entry是一个接口,用来描述依赖于实现的结构: interface Entry<K,V> {...}
/* * 遍历Map中的每一组键值对,Map有一个内部类Entry,其每一个实例用于表示Map中的一组键值对。 * Set<Entry> entrySet() * 该方法会将当前Map中每一组键值对(若干Entry实例)存入到一个Set集合后返回。 */ Set<Entry<String, Integer>> entrySet = map.entrySet(); for(Entry<String, Integer> e : entrySet) { String key = e.getKey(); Integer value = e.getValue(); System.out.println(key+":"+value); }
-------------------------------------------------------------------------------------
基本的Map实现:
1.HashMap: Map基于散列表的实现(它取代了Hashtable)。插入和查询"键值对"的开销是固定的。可以通过构造器设置容量和负载因子,以调整容器的性能;
2.LinkedHashMap:类似于HashMap,但是迭代遍历它时,取得"键值对"的顺序是其插入次序,或者是最近最少使用(LRU)的次序。只比HashMap慢一点;而在迭代访问时反而更快,因为它使用链表维护内部次序;
3.TreeMap:基于红黑树的实现。查看"键"或"键值对"时,它们会被排序(次序由Comparable或Comparator决定)。TreeMap的特点在于,所得到的结果是经过排序的。TreeMap是唯一的带有subMap()方法的Map,它可以返回一个子树;
4.WeakHashMap:弱键(weak key) 映射,允许释放映射所指向的对象;这是为解决某类特殊问题而设计的。如果映射之外没有引用指向某个"键",则此"键"可以被垃圾收集器回收;
5.ConcurrentHashMap:一种线程安全的Map,它不涉及同步加锁。我们将在"并发"一章中讨论;
6.IdentityHashMap:使用==代替equals()对"键"进行比较的散列映射。专为解决特殊问题而设计的。
简述HashMap及其原理:
Map最常用的实现类是HashMap,也叫散列表,就是由散列算法实现的Map(查找表),本质是数组加链表;
当要存储一组键值对时,可以调用Map的put方法传入键值对,首先它会根据散列算法,计算当前要存储key的hashcode值;
然后用这个hashcode值对数组的长度length进行取余,计算出数组的位置;
然后,再用equals进行对比,首先判断当前位置的key值是否为null,或已有的键值对的key值是否相同,如果为null,则直接将这组键值对存入到这个位置;如果相同,它就会覆盖;如果不同,就会形成链表;(如果用结构上表示,把它抽象出来的话,应该是新的会挂在旧的上面,倒着挂,往下顶,最老的会放在最下面。)
然后还有,就是在JDK1.8之后,如果链表长度超过七,就会变成红黑树结构,因为红黑树查起来次数更少;比如一个3层完全二叉树,可以放7个数据,这时候只需要查询最多3次就出来了,如果是7个数据组成一个链表,那就很不理想了。
将来我们想去获取一个key所对应的value值的时候,我们可以调用这个Map的get方法,传入参数key,来获取对应的value,这个时候底层还是首先通过散列算法,得到当前key的hashcode值,再进行取余,计算出在数组中的位置,就找到了这个key-value,然后就把这个key所对应的value值得到,返回给我们。
HashMap通过散列算法直接告诉了我们元素在数组中的位置,避免了遍历数组的过程,从而可以更高效的去实现查询操作,而且数据量越大,效果越理想。
重写hashCode()和equals()方法及意义:
hashCode()和equals()方法对HashMap的作用:
在实际应用中,对于HashMap键值对中的key,我们通常会重写它的hashCode()方法,并且它的hashcode值就直接影响了这组键值对在数组当中的位置;
那么在Map中,对于散列算法,可能会影响性能的地方,跟key的两个方法是息息相关的,一个就是hashCode()方法,一个就是equals()方法,因为key是不允许重复的,而判定是否重复,就是Map中key元素equals比较的结果所决定;即Map中不能出现两个key,它们的equals比较是true的。
因此equals()决定着Map里key是否重复;而key的hashCode值决定着这组键值对在Map当中数组里面的位置;
为什么要成对重写hashCode()和equals()方法:
那么这两个方法,通常情况下,我们是需要成对重写的,这个在API手册上也有明确的说明,如果我们要重写一个类的equals()方法,就应当连同去重写hashCode()方法。
还有,在hashCode()方法并不保证总是返回唯一的hashcode值,如果出现两组键值对key的hashcode值恰巧相等,算法是固定的,如果它们的hashcode值也一样;
那么它们在数组中算出的位置也一定一样,而一个数组里面的一个位置不可能同时放两个元素,这时,会进行equals比较;
如果equals比较就是相同的key,就是替换value的操作;如果equals不是true,就说明它们其实不是一个key;
那么一个数组格里不能存有两个元素,而每一组key-value,在Map中都是由一个Entry实例表示,这个Entry除了保存有键值对之外,它还有一个属性就是记录它下一个元素地址;
所以Entry本身也算是一个链表,这时就会形成链表,会影响性能,要避免这种情况出现;
所以java手册上说,你要重写equals(),就应该连同重写hashCode(),否则的话,在Map中去应用会有问题,当然,如果自己定义的那个类,这个元素不会做为Map中的key,那就无所谓了。
重写hashCode()和equals()方法的两个原则:
所以对hashCode()和equals()方法的重写,应该有一个对照关系,应遵循如下两个原则:
原则一是,如果两个对象equals,比较是true,那么hashCode值就应当相同;反之,则没有一个强制要求;即如果两个对象的hashCode值一样的话,并不要求equals比较必须是true,但最好是true;因为如果两个key的hashCode值一样,它们算出来在数组中位置就一样,而如果equals比较不是true,那么Map就认为这两个key是不同的,就会产生链表,而只要产生链表,就可能降低性能,链表越多,性能越差。
原则二是,hashCode应该是一个稳定的值,就是对于同一个对象,即便多次调用hashCode()的时候,返回的数据不能是一个随机数,它应该返回的都是同一个值;那hashCode值可不可以变呢,是可以的,但它有一个前提,就是这个对象,如果参与equals比较的那些属性值变了,那hashCode值是可以变的;所以一个对象的hashCode值,其实是根据这个类里面的那些属性,参与equals比较的那些属性计算出来的一个值,也就是说,如果属性的值不变,那hashCode的值就不应该变,如果参与equals比较的那些属性值变了,那hashCode值可以变。
基于以上两原则,重写equals()和hashCode(),并不轻松,所以一般都是通过开发工具自动生成即可。
-----------------------------------------
另外,如果没有妥善的成对重写hashCode()和equals(),会不会出现两个对象,它们的hashCode值不一样,但equals比较是true;
按理来说,这样的情况是可能有的,但Map有一个前提,Map的key是不允许重复的,所以它的keySet()方法得到的是一个Set集合,而Set集合判断元素是否重复的依据就是通过equals()方法进行比较;
那设计hashCode()方法最重要的一个要求就是,无论何时,对同一个对象调用hashCode()都应该生成同样的值(见TIJ-Overriding hashCode()),所以Map中不可能出像两个不同对象equals比较为true,但它们的hashCode值不一样的情况,否则,这个Map就是有问题的。一句话,Map中不允许有重复的元素。
重写hashCode()和equals()方法的细节:
比如,我们这个类,有两个属性x和y,然后利用工具重写它的hashCode()方法和equals()方法,它们的重写的具体细节:
重写equals(),如果是自己返回true,是null返回false,然后再看是不是跟自己相同类型的,不是返回false,是的话,先造型一下,然后再分别去判断;
重写hashCode(),它这有一个prime,有一个result,那这两个值,其实是死值,那么唯一在里面变化的,就是x和y这两个变量,所以就是说,如果你的x和y值,如果不变,算出来的永远是一个值,但是如果你x和y,有一个变了,那它这个返回的hashCode值,就是可变的。
public class Key { private int x; private int y; @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + x; result = prime * result + y; return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Key other = (Key) obj; if (x != other.x) return false; if (y != other.y) return false; return true; } }
------------------------------------------------------------------------------------------
以下节选自TIJ关于重写hashCode()论述,也是非常不错的!
Overriding hashCode()
//: containers/StringHashCode.java
public class StringHashCode {
public static void main(String[] args) {
String[] hellos = "Hello Hello".split(" ");
System.out.println(hellos[0].hashCode());
System.out.println(hellos[1].hashCode());
}
} /* Output: (Sample)
69609650
69609650
*///:~
The hashCode( ) for String is clearly based on the contents of the String.
There’s one other factor: A good hashCode( ) should result in an even distribution of values. If the values tend to cluster, then the HashMap or HashSet will be more heavily loaded in some areas and will not be as fast as it can be with an evenly distributed hashing function.
In Effective Java™ Programming Language Guide (Addison-Wesley, 2001), Joshua Bloch gives a basic recipe for generating a decent hashCode( ):(见book-TIJ)
HashMap的负载因子与性能优化:
另外,就是HashMap里面,
- Load factor : 加(/负/装)载因子 = 尺寸/容量;默认值0.75(就是75%), 当向散列表增加数据时,如果 size/capacity 的值大于Load factor,则发生扩容并且重新散列(rehash)。
- Capacity : 容量,hash表里bucket(桶)的数量(表中的桶位数),也就是散列数组大小;
- Size : 大小(尺寸),当前散列表中存储数据的数量(表中当前存储的项数)。
- Initial capacity : 初始容量,创建hash表时,初始bucket的数量,默认构建容量是16. 也可以使用特定容量。
- 性能优化:加载因子较小时,散列查找性能会提高,同时也浪费了散列桶空间容量。0.75是(时间)性能和空间(代价)(达到了)相对平衡结果。在创建散列表时指定合理容量,减少rehash提高性能。
比如这里有16个格子,当存储到第12个之后,12就是75%,这时就会扩容,也就是数组的空间被占用75%以上才会扩容。
那扩容之后,在Map中存储的元素,它依然会根据key去先算位置,那实际上散列算法是不变的,变化的是参与equals计算的参数属性,所以此时的hashCode值也和扩容之前的数值是一样的;
但hashCode值算出来了,还要根据数组的长度,进行计算出数组的位置,那么扩容后数组的长度就一定变化了,所以所有元素的位置也一定发生了改变,就必须重新散列,而且每一次扩容,都会重新散列,这个过程的开销是非常可观的;
所以在实际工作中,使用HashMap时,一定要先有一个大致的推算,它可能会存储多少数据,从而指定相应的容量大小。而这个四分之三的加载因子,是经过大量的测算和科学统计,得出来的一个结果;它不是性能最优,也不是资源利用最好,而是性价比最高的一个数值,即这是的Map,产生链表的几率会相对低一些,查询的速度也是比较好的。
如何创建一个HashMap:
Map<k,v> map = new HashMap();
注意,这里new后面的HashMap泛不泛型无所谓,在1.8之后,自动就识别了,1.7之前还得写;
这样写还不够,因为如果这么写,默认初始长度是16,也就是当它存过12个值之后,就会扩容;
因为存储数据的算法是模length,扩容之后length会改变,那所有的数据,它的位置就不对了,需要全部拿出来重新计算,重新去存放,这个过程是非常浪费资源的;
所以在开发中,一定要预测存储数据的长度,并指定初始长度。比如一般如果存20个元素,大概需要四五十的初始长度,正常开发一定要这么写:
Map<k,v> map = new HashMap(50);
对于HashMap的底层源码:
如果自己去实现一个HashMap的话,初始长度16,首先需要定义一个常量属性,类型为int,和final修饰:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
1左移四位
0000 0000 0000 0001
0000 0000 0001 0000
1<<4 数值是16
还有加载因子是0.75,也是一个常量属性,类型为float,final修饰:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
它还有个最大容量,大小是2的30次方。
static final int MAXIMUM_CAPACITY = 1 << 30;
TIJ中关于散列的一些细节解释:
HashMap为什么要重写hashCode()和equals()?
java中的对象都会自动地继承自基类Object中的所有方法,而Object的hashCode()方法生成散列码,默认是使用对象的地址计算散列码,这使得put时和get时的键,即便equals比较为true,因为生成的散列码并不相同,也无法正确的查找;
而默认的Object.equals()只是比较对象的地址,因为HashMap使用equals()判断当前的键是否与表中存在的键相同,因此,如果要使用自己的类作为HashMap的键,必须同时重写hashCode()和equals().正确的equals()方法必须满足5个条件(了解见TIJ)。
hashCode()和equals()在HashMap中的应用细节:
hashCode()并不需要总是能够返回唯一的标识码,但是equals()方法必须严格地判断两个对象是否相同。
用equals()方法对当前位置键值对的key进行比较时,还检查了此key对象是否为null,因为如果instanceof左边的参数为null,它会返回false。
如果equals()的参数不为null且类型正确,则基于每个对象中实际的值进行比较。
散列的价值:
散列的价值在于速度:散列使得查询得以快速进行。由于瓶颈位于键的查询速度,因此解决方案之一就是保持键的排序状态,然后使用Collections.binarySearch()进行查询。
散列则更进一步,它将键保存在某处,以能够快速找到。存储一组元素最快的数据结构是数组,所以使用它来表示键的信息,数组并不保存键本身,而是通过键对象生成一个数字,将其作为数组的下标。这个数字就是散列码,由定义在Object中的、且可能由你的类覆盖的hashCode()方法生成。
因为数组不能调整容量,而我们希望在Map中保存数量不确定的值,为解决数组容量被固定的问题,不同的键可以产生相同的下标。也就是说,可能会有冲突。
因此,数组多大就不重要了,任何键总能在数组中找 到它的位置。
于是查询一个值的过程首先就是计算散列码,然后使用散列码查询数组。如果能够保证没有冲突(如果值的数量是固定的,那么就有可能),那可就有了一个完美的散列函数,但是这种情况只是特例(见TIJ)。
通常,冲突由外部链接处理:数组并不直接保存值,而是保存值的list。然后对list中的值使用equals()方法进行线性的查询。这部分的查询自然会比较慢,但是,如果散列函数好的话,数组的每个位置就只有较少的值了。
因此,不是查询整个list,而是快速地跳到数组的某个位置,只对很少的元素进行比较。这便是HashMap会如此快的原因。
数组实现的散列冲突处理:
由于散列表中的"槽位"(slot)通常称为桶位(bucket),因此我们将表示实际散列表的数组命名为bucket。为使散列分布均匀,桶的数量通常使用质数。
注意,为了能够自动处理冲突,使用了一个LinkedList的数组;每一个新的元素只是直接添加到list末尾的某个特定桶位中。
即使Java不允许你创建泛型数组,那你也可以创建指向这种数组的引用。
这里,向上转型为这种数组是很方便的,这样可以防止在后面的代码中进行额外的转型。
(事实证明,质数实际上并不是散列桶的理想容量。近来,(经过广泛的测试)Java的散列函数都使用2的整数次方。对现代的处理器来说,除法与求余数是最慢的操作。使用2的整数次方长度的散列表,可用掩码代替除法。因为get()是使用最多的操作,求余数的%操作是其开销最大的部分,而使用2的整数次方可以消除此开销(也可以能对hashCode()有些影响)).
关于HashMap的put和get方法的实现:
对于put()方法,hashCode()将针对键而被调用,并且其结果被强制转换为正数。
为了使产生的数字适合bucket数组的大小,取模操作符将按照该数组的尺寸取模。
如果数组的某个位置是null,这表示还没有元素被散列至此,所以,为了保存刚散列到该定位的对象,需要创建一个新的LinkedList.
一般的过程是,查看当前位置的list中是否有相同的元素,如果有,则将旧的值赋给oldValue,然后用新的值取代旧的值。
标记found用来跟踪是否找到(相同的)旧的键值对,如果没有,则将新的对添加到list的末尾。
get()方法按照与put()方法相同的方式计算在buckets数组中的索引 (这很重要,因为这样可以保证两个方法可以计算出相同的位置) 如果此位置有Linked存在,就对其进行查询 。
java容器对象:
1.Set:集合中的对象没有重复,并且不按特定方式排序;
2.List:集合中的对象按照索引位置排序,可以有重复;
3.Queue:集合中的对象按照先进先出的规则来排序,可以有重复。