引子
常见的有数据结构有三种结构:1、数组结构 2、链表结构 3、哈希表结构
各自的数据结构的特点:
- 数组结构: 存储区间连续、内存占用严重、空间复杂度大
优点:随机读取和修改效率高,原因是数组是连续的(随机访问性强,查找速度快)
缺点:插入和删除数据效率低,因插入数据,这个位置后面的数据在内存中都要往后移动,且大小固定不易动态扩展。 - 链表结构:存储区间离散、占用内存宽松、空间复杂度小
优点:插入删除速度快,内存利用率高,没有固定大小,扩展灵活
缺点:不能随机查找,每次都是从第一个开始遍历(查询效率低) - 哈希表结构:结合数组结构和链表结构的优点,从而实现了查询和修改效率高,插入和删除效率也高的一种数据结构。
增删是在链表上完成的,而查询只需扫描部分,则效率高。
HashMap的底层实现原理
(JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hashcode的元素都存储在一个链表里。但是当位于一个桶中的元素较多,即hashcode相等的元素较多时,通过key值依次查找的效率较低。)
Jdk1.8:HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值8时,将链表转换为红黑树,这样大大减少了查找时间。
实现原理主要讲一下put(key, val)的实现原理:
- 首先调用key所在类的hashCode()计算key哈希值,此哈希值经过哈希函数计算以后,得到在Entry数组中的存放位置。(由于哈希值比较大,不能作为数组下标,经过hash算法(高位运算和取模运算)后设为数组中的下标位置)
- 如果此位置上的数据为空,此时的key1和value1添加成功。
- 如果此位置上数据不为空,意味着此位之上存在一个或多个数据(以链表形式存在),这时候比较key和已经存在的一个或多个数据的哈希值:如果key的哈希值与已经存在的数据的哈希值都不一样,此时key1-value1添加成功;
- 如果与某一个存在(key2-value2)的哈希值相同,继续比较:调用key所在类的equals()方法,比较:如果返回false,则添加成功;若返回true,使用value1替换相同key的value值。
get(key)的实现原理:
- 首先调用key所在类的hashCode()计算key哈希值,此哈希值经过某种算法计算以后,得到在Entry数组中的存放位置。
- 如果此位置上的数据为空,返回null
- 如果此位置上数据不为空,意味着此位之上存在一个或多个数据(以链表形式存在)。拿着参数key和链表上的每一个节点的key进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的key和参数key进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
Java8与Java7的底层实现区别
- new HashMap():底层没有立刻创建一个长度为16的数组
- jdk8底层的数组是:Node[],而非Entry[],Node<k,v>实现了Entry<k,v>
- 首次调用put()方法示时,才底层创建长度为16的数组
- jdk7底层数据结构只有数组+链表,jdk8中底层结构:数组+链表+红黑树。当数组的某一个索引位置上的元素以链表形式存在的数据个数 >8且当前 数组的长度>64时,此时此索引位置上的所有数据改为使用红黑树存储。
HashMap与Hashtable的区别
- 继承的父类不同。HashMap继承自AbstractMap类。Hashtable继承自Dictionary类。但二者都实现了Map接口。
- HashMap线程不安全,在多线程并发的环境下,可能会产生死锁等问题。HashTable线程安全,它的每个方法中都加入了Synchronize方法。
虽然HashMap不是线程安全的,但是它的效率会比Hashtable要好很多。这样设计是合理的。在我们的日常使用当中,大部分时间是单线程操作的。HashMap把这部分操作解放出来了。当需要多线程操作的时候可以使用线程安全的ConcurrentHashMap。ConcurrentHashMap虽然也是线程安全的,但是它的效率比Hashtable要高好多倍。因为ConcurrentHashMap使用了分段锁,并不对整个数据进行锁定。或者用集合类的SynchronizedMap方法使其线程安全化。 - HashMap是允许key和value为null值的,用containsValue和containsKey方法判断是否包含对应键值对;HashTable键值对都不能为空,否则包 空指针异常。(因此在HashMap中不能由get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()方法来判断。)
- 计算hash值方式不同,为了得到元素的位置,首先需要根据元素的 KEY计算出一个hash值,然后再用这个hash值来计算得到最终的位置。
HashMap先调用hashCode方法计算出来一个hash值,再将hash与右移16位后相异或,从而得到新的hash值。而Hashtable直接用hashcode。
计算索引位置的方式不同,HashMap是index = (n - 1) & hash。将哈希表的大小固定为了2的幂,因为是取模得到索引值,故这样取模时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。而Hashtable是index = (hash & 0x7FFFFFFF) % tab.length。 - 初始容量大小和每次扩充容量大小
Hashtable默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。
创建时,如果给定了容量初始值,那么Hashtable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小。也就是说Hashtable会尽量使用素数、奇数。而HashMap则总是使用2的幂作为哈希表的大小。
因为Hashtable和HashMap设计时的侧重点不同。Hashtable的侧重点是哈希的结果更加均匀,使得哈希冲突减少。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。而HashMap则更加关注hash的计算效率问题。在取模计算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法。HashMap为了加快hash的速度,将哈希表的大小固定为了2的幂。当然这引入了哈希分布不均匀的问题,所以HashMap为解决这问题,又对hash算法做了一些改动(见第4点)。
HashMap与HashSet的区别
所以HashSet存储的对象所在类,一定要重写hashcode()和equals()方法
HashMap扩容机制
JDK1.7:①空参数的构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组。有参构造函数:根据参数确定容量、负载因子、阈值等。②第一次put时会初始化数组,其容量变为不小于指定容量的2的幂数,根据负载因子确定阈值。③如果不是第一次扩容,则新容量是2倍旧容量;新阈值是负载因子乘新容量。
JDK1.8:①空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。②有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让阈值自己乘上负载因子。③如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。即首次put时,先会触发扩容(初始化),然后存入数据,然后判断是否需要扩容;不是首次put,则不再初始化,直接存入数据,然后判断是否需要扩容。
负载因子的选取
负载因子过大,虽然空间利用率高,但时间效率低,容易发生哈希冲突。
负载因子太小,虽然时间效率高,但是空间利用率降低。
负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
其他面试题:
- 为什么放在hashMap集合key部分的元素需要重写equals方法?
因为equals默认比较是两个对象内存地址 - 为什么String, Interger这样的wrapper类适合作为键?
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
部分内容参考博客、书籍,如有侵权,立刻删除