1.HashMap 的底层数据结构以及扩容机制是怎样的?
底层数据结构:
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一 起的,数组里每个存储的对象是Entry<key,value>对象,每个Entry<key,value>对象都包含一个对下个元素的引用,类似于拉链. 当HashMap 调用put方法增加元素时,首先调用 key 的 hashCode方法,然后再通过位运算处理过后得到 hash 值.,这是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞,能够最大限度使用数组空间,减少浪费
int hash = hash(key) //调用方法算出hash//求出Hash值
final int hash(Object k) {int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode();// This function ensures that hashCodes that differ only by// constant multiples at each bit position have a bounded// number of collisions (approximately 8 at default load factor).h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);}
然后通过 hash & (n - 1) 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在 元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突,把最新的数据放在最前面老的数据往后面移位,因为操作系统总是认为最近使用的数据会最容易被使用。
同样get方法调用时是通过key的hash值经过位运算,然后再&运算求出数组下标。取出链条。链条只有一条数据就是这个数据。如果不是就需要遍历链条上的数据(通过hashCode和equls方法判断)取出对应的key的value值。
public V put(K key, V value) {if (table == EMPTY_TABLE) {inflateTable(threshold);}if (key == null)return putForNullKey(value);int hash = hash(key); //算出hash值int i = indexFor(hash, table.length); //求出数组下标for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;//通过这个key的hashCode和equals方法来判断当前的key是否存在if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;addEntry(hash, key, value, i);return null;}int i = indexFor(hash, table.length)//调用方法获取数组下标值//通过hash值和数组长度length-1的与运算求出数组下标值
static int indexFor(int h, int length) {// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";return h & (length-1);}//创建数组元素ENtry对象
void createEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex];table[bucketIndex] = new Entry<>(hash, key, value, e);size++;}
HashMap的内存数组结构,每个数组元素下面会链条元素,数组加链表的结构,最新put进来的元素总是放在最上面,老的元素往后面移位。
JDK1.8 之后 开始默认数据结构也是数组和链表,数组里面的元素不再是Entry<key,value>对象,而是Node<key,value>对象(Node<key,value>实现了Map.Entry<key,value>接口,属性跟原来差不多)。当链表长度大于阈值(默认为 8)减1时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会 执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。之所以要达到64长度是因为只有达到这个长度时才能体现红黑树的搜索效率比之前的结构要快。当放入元素产生hash冲突时是往链条下面放元素(jdk1.7之前是使用头插法,JDK1.8使用尾插法)
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)n = (tab = resize()).length; //初始化长度为16if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;//判断元素是否存在的条件跟JDK8之前一样if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))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) {//新放入的元素是往链条下面移位,而不是在JDK8之前新放进去数据在最前面。p.next = newNode(hash, key, value, null);//当链表长度到达阈值才会调用treeifyBin(tab, hash)方法if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}//这个方法最终判断是否需要转红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//数组长度是否大于64,比 //64小就扩容resize();else if ((e = tab[index = (n - 1) & hash]) != null) {TreeNode<K,V> hd = null, tl = null;do {TreeNode<K,V> p = replacementTreeNode(e, null);if (tl == null)hd = p;else {p.prev = tl;tl.next = p;}tl = p;} while ((e = e.next) != null);if ((tab[index] = hd) != null)hd.treeify(tab);//转换红黑树}}
扩容机制:
jdk8之前和之后扩容机制差不多是一样的,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的 2 倍HashMap 的容量是有上限的,必须小于 1<<30,即 1073741824。如果容量超出了这个 数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE。这个阈值是数组的长度length * 负载因子(默认是0.75f).扩容后会重新计算旧数组中元素的存储位置。元素在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小
值得注意的是 jdk1.8中红黑树和链表会相互转换,当链表小于8时会转换为链表,反之则转换红黑树。
后记:之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢
void addEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex];table[bucketIndex] = new Entry<K,V>(hash, key, value, e);if (size++ >= threshold)//threshold 值等于数组长度 * 负载因子resize(2 * table.length); //扩容是2倍长度}
解决hash冲突的办法有哪些?HashMap用的哪种?
解决Hash冲突方法有:开放定址法、再哈希法、链地址法(HashMap中常见的拉链法)、简历公 共溢出区。HashMap中采用的是链地址法。
-
开放定址法也称为再散列法,基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,p1=H(p),如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点
-
再哈希法(双重散列,多重散列),提供多个不同的hash函数,R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。这样做虽然不易产生堆集,但增加了计算的时间。
-
链地址法(拉链法),将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行,链表法适用于经常进行插入和删除的情况。
-
建立公共溢出区,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区
注意开放定址法和再哈希法的区别是
-
开放定址法只能使用同一种hash函数进行再次hash,再哈希法可以调用多种不同的hash函数进行再次hash
HashMap为什么线程不安全?
-
多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
-
多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK1.7和JDK1.8中都存在
-
put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8中都存在
2.ConcurrentHashMap 的存储结构是怎样的?
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) {if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)throw new IllegalArgumentException();if (concurrencyLevel > MAX_SEGMENTS)concurrencyLevel = MAX_SEGMENTS;// Find power-of-two sizes best matching argumentsint sshift = 0;int ssize = 1;while (ssize < concurrencyLevel) {++sshift;ssize <<= 1;}segmentShift = 32 - sshift;segmentMask = ssize - 1;this.segments = Segment.newArray(ssize);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;int c = initialCapacity / ssize;if (c * ssize < initialCapacity)++c;int cap = 1;while (cap < c)cap <<= 1;for (int i = 0; i < this.segments.length; ++i)//初始化Segments数组中每个对象this.segments[i] = new Segment<K,V>(cap, loadFactor);}//put方法存入元素
V put(K key, int hash, V value, boolean onlyIfAbsent) {lock();//每个Segment对象进来是锁住try {int c = count;if (c++ > threshold) // ensure capacityrehash();//扩容机制和HahshMap一样HashEntry<K,V>[] tab = table;int index = hash & (tab.length - 1);HashEntry<K,V> first = tab[index];HashEntry<K,V> e = first;while (e != null && (e.hash != hash || !key.equals(e.key)))e = e.next;V oldValue;if (e != null) {oldValue = e.value;if (!onlyIfAbsent)e.value = value;}else {oldValue = null;++modCount;tab[index] = new HashEntry<K,V>(key, hash, first, value);count = c; // write-volatile}return oldValue;} finally {unlock();}}
LinkedHashMap 保存了记录的插入顺序,维护了一个双向链表(跟LinkedList有点类似)在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢。一般在需要输出的顺序和输入的顺序相同的情况下使用。
TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指 定排序的比较器,compare)。
HashTable是方法是用synchronized关键修饰的,所以是线程安全。也是因为这它比HashMap效率 会低
LinkedList是双向链表集合,每一个元素都两个属性指向前面和后面的元素,LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。使用链表实现,无需扩容。 LinkedList 采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影 响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst() 、 removeLast()),近似 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element),remove(Object o)) 时间复杂度近似为 O(n) ,因为需要先移动到指定位置再插入。
ArryList 是数组存储,当需要查询比较多时,使用这个比较快,直接查询下标就能找到元素。使用数组实现,需要扩容。ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。比如:执行 add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
HashSet底层是用HashMap来实现的,添加的元素时不能重复的,因为key的唯一性决定的。
4、 Queue 与 Deque ,PriorityQueue 的区别
Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循先进先出 (FIFO) 规则。Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。Queue 接口抛出异常 返回特殊值
插入队尾 add(E e) offer(E e)
删除队首 remove() poll()
查询队首元素 element() peek()
Deque 是双端队列,在队列的两端均可以插入或删除元素。 Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 抛出异常 返回特殊值133
插入队首 addFirst(E e) offerFirst(E e)
插入队尾 addLast(E e) offerLast(E e)
删除队首 removeFirst() pollFirst()
删除队尾 removeLast() pollLast()
查询队首元素 getFirst() peekFirst()
查询队尾元素 getLast() peekLast()
事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。
PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先 级相关的,即总是优先级最高的元素先出队。这里列举其相关的一些要点: PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。 PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。
5.TreeMap 和 TreeSet 在排序时如何比较元素,Collections 工具类中的 sort()方法如何比较元素?
TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo()方法,当插入元素时会回调该方法比较元素的大小。TreeMap 要求存放的键值对映射的键必须实现 Comparable 接口从而根据键对元素进行排序。Collections 工具类的 sort 方法有两种重载的形式:
第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比 较。第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是 Comparator 接口的子类型(需要重写 compare 方法实现元素的比较),相当于一个临时定 义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。
6.Collection 和 Collections 有什么区别?
java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进 行基本操作的通用接口方法。Collection 接口在 Java 类库中有很多具体的实现。Collection 接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有 List 与 Set。
Collections 则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合 中元素进行排序、搜索以及线程安全等各种操作。