Hello,大家好!今天的分享的是面试中常问到的集合内容,其中HashMap是集合中的重点,如果是对HashMap感兴趣可以直接到跳到这【我是HashMap】,希望这篇内容能给你带来收获
其实,java中的集合分为
存储单列数据
的集合和存储键和值
这样的双列数据的集合
其中就有list和set,
list有序允许重复
,set无序不允许重复
ArrayList
、LinkedList
、Vector
就是list的三大实现类
ArrayList
基于
数组、有序的、允许重复
的list集合,但是线程不同步
LinkedList
基于
双向链表,有序,允许重复
的list集合,但是线程不同步
Vector
基于
数组、有序的、允许重复
的list集合,并且线程同步
三者的区别
- ArrayList
和
Vector底层用的
数组,
LinkedList使用的是
链表`,- 数组的优点是
查询指定的比较快
,因为有索引,而增删改比较慢
,因为数组在内存中是一块连续的数据集合,每次删除和添加都会影响索引,所以性能上弱点儿- 链表是当前元素中存放下一个或者上一个的元素的地址,但是如果你查询时候需要从开头
一个一个的找,效率比较低
,而插入时不需要移动内存,只需要改变引用指向即可,所以链表增删改效率高
- Vector是线程同步的,其他两个线程不同步
- ArrayList每次扩容是其大小的
1.5倍
,而Vector是其大小的2倍
HashSet
、TreeSet
、LinkedHashSet
是set集合的主要三个实现类
- 但要知道一点的就是如果你把map集合搞懂了,set也就迎刃而解了,因为这三个其实底层都
用到了map集合的实现类
- HashSet对应着
HashMap
、TreeSet对应着TreeMap
、LinkedHashSet对应LinkedHashMap
HashSet
- 基于HashMap的Set集合,
无序
并不允许重复
且线程是非同步
的,底层是哈希表
(数组+单链表)结构。- 保证元素唯一性的原理:
判断元素的hashcode值是否相同。如果相同,还会继续判断元素的equals方法,是否为true
。- HashSet的add()就是往一个HashMap里面put(),只是key一直不同,而value是一直相同的就是上面的那个伪值
PRESENT
,允许有null值
。- HashSet的值是存储在一个HashMap的key里面,而HashMap的key是不能重复的;详细的请看下面的map集合
LinkedHashSet
- 基于
LinkedHashMap
的Set集合,不允许重复
底层是哈希表和双链表
结构- 本身就
继承HashSet
,所以它也根据元素hashCode值来决定元素存储位置,但它同时使用双链表维护元素的次序
,这样使得元素看起来是以插入的顺序保存的
TreeSet
- 基于
TreeMap
并且不重复
的set集合,底层就是二叉树结构
(即红黑树结构,也是一种自平衡
的二叉树),注意底层没有hash算法- 可以对set集合中的元素
进行排序
,如果是引用数据类型一定要实现Comparable接口重写compareTo方法,不然会抛出异常。- comareTo返回正数,负数,0,都代表着结果为 正序,倒序,和只有根元素
- 不重复或者元素唯一性就是 compareTo 方法结果为0,
这里面就要说下两种排序方式了:
1、让元素自身具有比较性(自然排序): 元素实现Comparable接口,重写compareTo方法
2、让容器自身具备比较性(比较器): 定义类去实现Comparator接口,重写compare(Object,Object)方法,然后作为TreeSet的构造参数
那他俩之间的区别是什么嘞?
自然排序
因为实现了Comparable接口,重写方法后,这些元素就由内部的排序规则了,比入Integer,String等等比较器
则是这种自身的排序规则不是我想要的,我想要创建自己的排序规则,但是String是改不了的,所以就有了Comparator接口,来创建外部排序规则
Map 集合可谓是java中相当重要的一个角色了,他涵盖了list和set,其中最熟悉的就是
HashMap
了,不过还有Hashtable,TreeMap,LinkedHashMap
,当然还有ConCurrentHashMap
这个角色,听我11道来
TreeMap底层是
二叉树(红黑树)线程不同步
。可以用于给map集合中的键进行排序
,上面的TreeSet
底层就是用的TreeMap
,只不过TreeMap是一个K-V集合
Hashtable底层是
哈希表数据结构 (数组+链表)
,无序的
、不可以
存入null键和null值,线程同步
JDK1.7底层实现:
- 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
- 在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
- 一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
- 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
- Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
- HashMap底层是
哈希表数据结构 (数组+链表)
,无序的
、可以
存入null键和null值,线程非同步
,jdk1.8 HashMap底层又加入了红黑树(why?)- 因为HashMap为面试中重点,这里多介绍介绍,先附上HashMap的部分源码图
// 数组的默认初始长度,java规定hashMap的数组长度必须是2的次方
// 扩展长度时也是当前长度 << 1。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子,当元素个数超过这个比例则会执行数组扩充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,
// 会将该链表换成红黑树。
static final int TREEIFY_THRESHOLD = 8;
// 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
static final int MIN_TREEIFY_CAPACITY = 64;
// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
transient Node<K,V>[] table;
// 下面三个容器类成员,作用相同,实际类型为HashMap的内部类KeySet、Values、EntrySet。
// 他们的作用并不是缓存所有的key或者所有的value,内部并没有持有任何元素。
// 而是通过他们内部定义的方法,从三个角度(视图)操作HashMap,更加方便的迭代。
// 关注点分别是键,值,映射。
transient Set<K> keySet; // AbstractMap的成员
transient Collection<V> values; // AbstractMap的成员
transient Set<Map.Entry<K,V>> entrySet;
// 元素个数,注意和内部数组长度区分开来。
transient int size;
// 再上一篇文章中说过,是容器结构的修改次数,fail-fast机制。
transient int modCount;
// 阈值,超过这个值时扩充数组。 threshold = capacity * loadfactor,具体看上面的静态常量。
int threshold;
// 装在因子,具体看上面的静态常量。
final float loadFactor;
- 没有阅读过源码的同学可能慌了,没事~跟着博主走,什么都会有
- 根据这个代码图,然后给大家溜下HashMap的运行流程和一些内部机制,可能有的地方不是很对,请大家谅解
- 其实上面就是HashMap1.8版本的 一些重要的成员变量
Node[] table
的初始容量为16,而容量是以2的次方扩充的为什么呢?,一是为了提高性能使用足够大的数组
,二是为了能使用位运算代替取模预算效率更高
。
HashMap的原理
:因为是Hash表结构,Hash表的做法其实很简单,就是把Key通过一 个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标 的数组空间里,而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位
。- 然后根据
put()
和get()
这两个重要的方法来具体分析一下(1.8)
put方法底层实现及分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//判断当前桶是否为空
if ((tab = table) == null || (n = tab.length) == 0)
//空的就需要初始化(resize 中会判断是否进行初始化)。
n = (tab = resize()).length;
//根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,
if ((p = tab[i = (n - 1) & hash]) == null)
//为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//相等就赋值给 e,然后统一返回。
e = p;
//如果当前桶为红黑树,
else if (p instanceof TreeNode)
//那就要按照红黑树的方式写入数据。
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果是个链表,
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
p.next = newNode(hash, key, value, null);
//接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在遍历过程中找到 key 相同时直接退出遍历。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//最后判断是否需要进行扩容。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
总结一下:
- 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
- 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
- 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
- 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
- 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
- 如果在遍历过程中找到 key 相同时直接退出遍历。
- 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
- 最后判断是否需要进行扩容。
get方法底层实现及分析
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//首先将 key hash 之后取得所定位的桶。如果桶为空则直接返回 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果第一个不匹配,
if ((e = first.next) != null) {
//则判断它的下一个是红黑树还是链表。
if (first instanceof TreeNode)
//红黑树就按照树的查找方式返回值。
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//不然就按照链表的方式遍历匹配返回值。
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
总结一下:
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回 null 。
- 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
接下来给大家说几个面试中经常问到有关HashMap的问题
- 因为如果是2的n次幂 那数据应该是这样的
二进制 :10 / 100 / 1000 / 10000 / 100000 / 1000000 / …
十进制 :2 / 4 / 8 / 16 / 32 / 64 / …- 而当他们减1的时候二进制结果是这样的
01 / 011 / 0111 / 01111 / 011111 / 0111111 / …- 而我门看到的HashMap源码中计算或分配数据在数组中的位置用的是
hash & (n - 1)
,其实等价于hash % n
- &这个符号是位运算中的位与,只有当对应位置的数据都为1时,运算结果也为1,看如下:
// 例子:就用HashMap默认的初始容量 16 来说 hash & (16 - 1) ==》 hash % 16 16的二进制为:10000 ; 10000 - 1 = 1111 那结过就成: hash & 1111 ; 这样能快速得到 hash值二进制的后四位,这后四位就是余数
- 总结下:计算机中直接求余
a % b
效率不如位移运算a &(b - 1)
,但是这需要b是2的n次幂才有效
,所以为了存取高效
,要尽量较少碰撞
,HashMap就这样做了
hashCode()方法和equal()方法的作用其实一样,在Java里都是用来对比两个对象是否相等一致,那么equal()既然已经能实现对比的功能了,为什么还要hashCode()呢?
因为重写的equal()里一般比较的比较全面比较复杂,这样效率就比较低,而利用hashCode()进行对比,则只要生成一个hash值进行比较就可以了,效率很高,那么hashCode()既然效率这么高为什么还要equal()呢?
所以对于需要大量并且快速的对比的话如果都用equal()去做显然效率太低,解决方式是,
每当需要对比的时候,首先用hashCode()去对比,如果hashCode()不一样,则表示这两个对象肯定不相等(也就是不必再用equal()去再对比了),如果hashCode()相同,此时再对比他们的equal(),如果equal()也相同,则表示这两个对象是真的相同了
,这样既能大大提高了效率也保证了对比的绝对正确性!那么再回过头来看看HashMap源码是不是更清晰了呢
- String,Integer等这些类都被final修饰,具有不变性;也保证了key的不变性,并且内部重写了equals和hashCode方法,不容易出现hash计算错误等。
- 所以String,Integer等包装类的特性
保证了Hash值的不可变性和准确性
,有效减少了hash碰撞
- 所以作为HashMap的key一定要重写equals和hashCode方法
HashMap1.7中的扩容机制实际上就是用一个新的2倍长度的数组来替代旧的数组,使用具体就是resize方法,里面调用的是transfer方法,这个方法用来转移数组数据,采用的就是头插法,但是存在一个问题:
并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。
结言:最后,感谢你能够看到这里,相信你也是拼搏道路上的追梦人,也欢迎评论区留言 ,喜欢博文可以给个呦,更多文章在这里【jar壳虫】,后续会有更多分享哦