HashMap与HashTable介绍:
Hashmap:用key value键值对的方式存储数据的,每一个键值对也称为Entry,他们被存储在链表(LinkedList)中。HashMap可以接受null键值和值;HashMap是非synchronized,非同步的(线程不安全),没有同步锁,所以HashMap很快,但是可以使用Map m = Collections.synchronizeMap(hashMap);实现上锁功能;
HashTable:HashTable是个过时的集合类,存在于java API中很久了,在java 4 中被重写,实现了Map接口,所以自此以后也成为Java集合框架的一部分;HashTable是线程安全的,有synchronized,多个线程可以共享一个HashTable,但是由于每个操作都加了锁,导致其(在单线程中)速度比HashMap慢。Java 5中提供了ConcurrentHashMap(文章第7点有解释区别),它是HashTable的替代,比HashTable扩展性更好。
除了上面介绍的一些功能上的区别,他们之间还有如下区别:
1.HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。
2.HashMap可以将null值作为一个表的条目的key或value。Hashtable不可以
3.hashMap去掉了HashTable 的contains方法,但是加上了containsValue()和containsKey()方法。下面主要介绍HashMap:
前情铺垫:
数组
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
链表
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。
哈希表(也即是hashmap的存储形式)
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。
1. HashMap的底层实现原理:
真正初始化哈希表(初始化存储数组table
)是在第1次添加键值对时,即第1次调用put()
时
调用put方法时:
每次要存储一个key时,会用一个hash函数算出这个key存储在hashmap的Table数组中的哪个位置(HashMap是用数组table的形式存储数据,这个数组默认初始长度为16,其每个数组的位置bucket又是一个链表,当产生index冲突时就用链表LinkedList存储)。
例如index=hash(“apple”),算出index=5,
index=hash(“orange”),算出index=1,
这时他们就分别存储到table数组中的不同位置上了。
如果出现了index冲突,即另外一个 key=“banana” 经过hash()函数之后出现的index=5与之前插入过的 key=”apple” 的index=5产生了冲突(也可以称为hashCode冲突),则调用equals比较要插入的key与之前插入的key是否一样:
若不一样(equals返回false),则将这个值存储到之前该index=5位置对应的链表中(这是一种解决hash冲突的方式称为链地址法),并将其置于链表头,原来的key=”apple”的entry就被推后一位,为什么这样设计呢?是因为设计者觉得后插入的值被查询的几率更大,位于表头以后查询的速度就快。
若一样(equals返回true),则将key对应的新value覆盖掉原来的value
调用get方法时:
算出要get的key=”banana”值对应的index位置(使用put中的那个hash()函数),算出以后到这个位置去查询链表,一个一个往下找(遍历)直到找到对应的key(用equals对比key,若相同则算是找到)
2.为什么hashmap默认长度为16?
初始长度为16,每次自动扩展或手动初始化的时候,长度必须是2的幂。这是位了服务于从key映射到index的hash算法
其公式为(这里的hashcode()和前面的hash()不一样,前面的hash()函数是一种简写形式)
index = HashCode(Key) & (Length -1) (类似除留余数法形式的hash函数)
下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,1011100011101011101001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
如果长度不是16会发生什么情况呢?
如果长度是10,那么&运算就是与10-1=9=>1001进行运算,那么对于hashcode以后后四位为1001与后四位为1111的hashcode值而言,他们的算出的index位置是一样的,这样有些位置就很容易出现index冲突,故长度为16可以合理分配index位置,而扩展时长度得是2的幂则使得 length-1 以后的二进制值为这种类型的值(1111,11111,111111……),从而使得与hashcode 与运算以后的index值分配的更加均匀
3、“如果HashMap的大小超过了负载因子(loadfactor)定义的容量,怎么办?”
注:负载因子默认为0.75(也称加载因子)
DEFAULT_LOAD_FACTOR = 0.75f
), 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 resize操作次数。如果容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
当一个map填满了75%的bucket时候(如初始为16,被用了12),和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组(也就是上面说的2的幂),来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing(resize),因为它调用hash方法找到新的bucket位置。
注意:rehashing对于存储位置为链表的值,他们是先取出链表头的值,然后去存放在新index位置上的链表,这个过程仍然是置于链表头,故如果原来链表中的两个key值重新hash以后的index位置在新链表中的index位置仍然一样,那么他们的位置会被颠倒,即原来在表头的变成在表尾,原来表尾的在表头
4、“你了解重新调整HashMap大小存在什么问题吗?”(转)
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来(如第三点所述),因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tailtraversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?
5.“为什么String, Interger这样的wrapper类适合作为键?”
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点,。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。所以如果是要用自定义的类对象作为key,不如一个person类,那么这个person类内部必须要重写hashcode、equals方法。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
上面斜线下划线部分分析解释:参考链接:http://blog.jobbole.com/68096/
解析:HashMap 存放的是引用类型,我们在外面把 key 更新了,那也就是说 HashMap 里面的 key 也更新了,也就是这个 key 的 hashCode 返回值也会发生变化。这个时候 key 的 hashCode 和HashMap 对于元素的 hashCode 肯定一样,equals也肯定返回true,因为本来就是同一个对象,那为什么不能返回正确的值呢?
参考get方法的源码
public V get(Object key) { if (key ==null) return getForNullKey(); int hash = hash(key.hashCode()); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; } |
注意红色字体标注处,虽然key.hashCode返回的值也随着我们更改key属性变化了,但是源码计算hash值的时候是将这个得出的hashCode值再进行hash,由于这个hashcode值相比于原来的hashcode值是变化的了,所以最后算出的hash值肯定也就不同了,即指向了不同的index,故我们调用get方法传入从外面更改过的key,其返回结果为null
由上面分析可以引出下面第六点:
6.我们可以使用自定义的对象作为键吗?
这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象是不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。这里要知道Hashmap存储的是引用对象,如果我们改变key的属性,那么其对应的key的hashcode值也会变化,那么当我们用这个变化了的key去查询时,其hashcode也会变化
7.Hashtable和ConcurrentHashMap有什么分别呢?
它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。
8.hashmap的哪种遍历方式比较好:
第一种:利用entrySet()进行遍历
Map map = new HashMap(); Iterator iter = map.entrySet().iterator(); while (iter.hasNext()) { Map.Entry entry = (Map.Entry) iter.next(); Object key = entry.getKey(); Object val = entry.getValue(); } 效率高,以后一定要使用此种方式! |
第二种:利用keySet进行遍历
Map map = new HashMap(); Iterator iter = map.keySet().iterator(); while (iter.hasNext()) { Object key = iter.next(); Object val = map.get(key); } 效率低,以后尽量少使用! |
可是为什么第一种比第二种方法效率更高呢?
HashMap这两种遍历方法是分别对keyset及entryset来进行遍历,但是对于keySet其实是遍历了2次,一次是转为iterator,一次就从hashmap中取出key所对于的value。而entryset只是遍历了第一次,它把key和value都放到了entry中,即键值对,所以就快了。
以上内容的HashMap为JDK7版本,到了JDK8有如下区别:
1.存储形式为:数组+链表+红黑树(当数组长度大于8时自动转换为红黑树存储)
2.重新扩容HashMap时,若原有红黑树处的entry组少于6个,则将红黑树转换为数组
3.数组的插入形式在JDK7为尾插法,JDK8为头插法
4.扩容后存储位置的计算方式不同,JDK7为全部重新计算,JDK8为原位置(新增参与运算的位为0)或者原位置+旧容量(新增参与运算的位为1)
5.插入数据发现容量不足要扩容时:JDK8为扩容前插入数据,转移数据时统一计算hash位置,JDK7为扩容后插入,再单独计算此数据的hash值,即转移数据时无统一计算
6.由于JDK8转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部一次插入,所以不会出现链表逆序、倒置的情况,故不容易出现环形链表的情况。但它还是线程不安全的,因为无加同步锁保护。
先总结到这里,以后有遇到hashmap相关的知识点再做更新
第一次做此类总结,文章不足之处,欢迎各位拍砖指示