【Java】吊打大多面试官的集合详解

343 阅读1小时+

看过很多集合的资料,最后总结出来的。希望对大家有帮助,应付面试当然没有问太够了,更多是使用时候的注意,比如队列为什么不推荐使用take,put,这些只有懂原理才能在实际高并发下不出问题。

image.png

面试题:说说集合与数组的区别?

  • 数组的长度是固定的。集合的长度是可变的
  • 数组采用连续存储空间,删除和添加效率低下
  • 数组无法直接保存映射关系,集合有多种数据结构(顺序表、链表、哈希表、树等)、多种特征(是否有序,是否唯一)、不同适用场合(查询快,便于删除、有序),不像数组仅采用顺序表方式。
  • 数组缺乏封装,操作繁琐
  • 集合存储的元素必须是引用类型,数组可以存放任意唯一类型
  • 数组无法判断其中实际存有多少元素,length只告诉了array容量;集合可以判断实际存有多少元素,而对总的容量不关心

JDK9 创建不可变的集合

几个 add方法 调用,使得代码重复。Java 9,添加了几种集合工厂方法,更方便创建少量元素的集合、map实例。新的List、Set、Map的静态工厂方法可以更方便地创建集合的不可变实例。

  • 注意返回的集合是不可以添加元素的。
  • of 方法是接口的静态方法,子类都是没有实现的。
public class HelloJDK9 {  
    public static void main(String[] args) {  
        Set<String> str1=Set.of("a","b","c");  
        //str1.add("c");这里编译的时候不会错,但是执行的时候会报错,因为是不可变的集合  
        System.out.println(str1);  
        Map<String,Integer> str2=Map.of("a",1,"b",2);  
        System.out.println(str2);  
        List<String> str3=List.of("a","b");  
        System.out.println(str3);  
    }  
}

集合与Collection与Map

集合按照其存储结构可以分为两大类,分别是:单列集合**java.util.Collection**、双列集合**java.util.Map**

  • Collection:单列集合类的根接口,用于存储一系列符合某种规则的元素,它有两个重要的子接口,分别是java.util.Listjava.util.Set

java集合.webp

Collection 和 Map 接口的抽象方法

image.png

常见获取大小方法:

数组.length 返回值 int 字符串.length() 返回值int 集合.size()方法 返回值int

java.util.List

数据的存储结构

  • 栈结构:FILO (first in last out)
  • 队列结构:FIFO(first in first out)
  • 数组结构:查询快,增删慢,开辟新数组耗费资源
  • 双向链表结构:查询慢,增删快
  • 红黑树:二叉查找树,将节点插入后,该树仍然是一颗二叉查找树。也就意味着,树的键值仍然是有序的。趋近于平衡树。查找叶子元素最少和最多次数不多于二倍
    • 节点可以是红色的或者黑色的
    • 根节点是黑色的
    • 叶子节点(特指空节点)是黑色的
    • 每个红色节点的子节点都是黑色
    • 任何一个节点到其每一个叶子节点的所有路径上黑色节点数相同

List接口方法

 booleanadd(E e)向列表的尾部添加指定的元素(可选操作)。
 voidadd(int index, E element) 在列表的指定位置插入指定元素(可选操作)。
 booleanaddAll(Collection<? extends E> c) 添加指定 collection 中的所有元素到此列表的结尾,顺序是指定 collection 的迭代器返回这些元素的顺序(可选操作)。
 booleanaddAll(int index, Collection<? extends E> c) 将指定 collection 中的所有元素都插入到列表中的指定位置(可选操作)。
 voidclear()   从列表中移除所有元素(可选操作)。
 booleancontains(Object o)  如果列表包含指定的元素,则返回 true。
 booleancontainsAll(Collection<?> c) 如果列表包含指定 collection 的所有元素,则返回 true。
 Eget(int index) 返回列表中指定位置的元素。
 booleanisEmpty()  如果列表不包含元素,则返回 true。
 Iteratoriterator()  返回按适当顺序在列表的元素上进行迭代的迭代器。
 Eremove(int index) 移除列表中指定位置的元素(可选操作)。
 booleanremove(Object o) 从此列表中移除第一次出现的指定元素(如果存在)(可选操作)。
 booleanremoveAll(Collection<?> c) 从列表中移除指定 collection 中包含的其所有元素(可选操作)。
 booleanretainAll(Collection<?> c) 仅在列表中保留指定 collection 中所包含的元素(可选操作)。
 Eset(int index, E element) 
 用指定元素替换列表中指定位置的元素(可选操作)。
 intsize()  返回列表中的元素数。

迭代器的并发修改异常

引发数据的不确定性

迭代器的并发修改异常 java.util.ConcurrentModificationException。不允许在遍历的时候修改集合,因为容易引发数据的不确定性。

public class ListDemo1 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        list.add("abc1");
        list.add("abc2");
        list.add("abc3");
        list.add("abc4");

        //对集合使用迭代器进行获取,获取时候判断集合中是否存在 "abc3"对象
        //如果有,添加一个元素 "ABC3"
        Iterator<String> it = list.iterator();
        while(it.hasNext()){
            String s = it.next();
            //对获取出的元素s,进行判断,是不是有"abc3"
            if(s.equals("abc3")){
                list.add("ABC3");
            }
            System.out.println(s);
        }
    }
}

运行上述代码发生了错误 java.util.ConcurrentModificationException

并发修改异常解决办法:

  • 在迭代时,不要使用集合的方法操作元素。
  • 通过ListIterator迭代器操作元素是可以的,ListIterator的出现,解决了使用Iterator迭代过程中可能会发生的错误情况。

java.util.ArrayList

存储的结构是数组结构。元素增删慢,查找快。

ArrayList al=new ArrayList();
//创建了一个长度为0的Object类型数组     

al.add("abc");
//底层会创建一个长度为10的Object数组 
// Object[] obj=new Object[10]                   
// obj[0]="abc"                  

// 如果添加的元素的超过10个,底层会开辟一个1.5*10的长度的新数组                  
// 把原数组中的元素拷贝到新数组,再把最后一个元素添加到新数组中   
// 原数组:     a b c d e f g h k l   
// 添加m:     a b c d e f g h k l m null null null null

ArrayList的常见方法

基本就是List的通用方法。

源码剖析

ArrayList 底层数据结构是个数组,里面维护了一个Object 数组 elementData 存放数据,其 API 都做了一层对数组底层访问的封装,允许null值,modCount记录数据被修改的版本,有变化就会+1,用于迭代的时候发现并发修改快速失败。 image.png

初始容量是如何设置的?

  • 无参数直接初始化:JDK1.7中,使用无参数构造方法创建ArrayList对象时,默认底层数组长度是10。JDK1.8中,使用无参数构造方法创建ArrayList对象时,默认底层数组长度是0;第一次添加元素,容量不足就要进行扩容了。
  • 指定大小初始化:>0 直接初始化对应大小数组,=0 使用空数组,容量为0
  • 指定初始数据初始化:原本就是ArrayList就直接将内部的element指向即可,原本不是就要使用Arrays.copyOf(要拷贝的数组 a, 拷贝大小 size, 元素类型 Object[] )拷贝,底层还是使用的 System.arraycopy的native方法。

add 与 扩容

add的时候,先计算一下希望的容量大小,容量不足时进行扩容,默认扩容50%。如果扩容50%还不足容纳新增元素,就直接使用期望值作为大小复制新的数组返回。 image.png 修改modCount是在判断是否扩容的时候,不管扩不扩容都会+1 image.png image.png

  • 问题:new ArrayList(),add 一个值进去,此时数组的大小和最大可用大小是多少?
    • 此处数组的大小是 1,下一次扩容前最大可用大小是 10,因为 new ArrayList 之后是0,但是第一次扩容时,默认初始化大小是 10,在第一次 add 一个值进去时,数组的可用大小被扩容到 10 了。
  • 问题:如果连续往 list 里面新增值,增加到第 11 个的时候,数组的大小是多少?
    • oldCapacity + (oldCapacity>>1),10 + 10 /2 = 15,然后我们发现 15 已经够用了,所以数组的大小会被扩容到 15。
  • 问题:数组初始化,被加入一个值后,使用 addAll 方法加入 15 个值,那么最终数组的大小是多少?
    • 数组在加入一个值后,实际大小是 1,最大可用大小是 10 ,现在一下加入 15 个值,此时数组最大可用大小只有 10,扩容后的大小是:10 + 10 /2 = 15,扩容后的值15 < 我们的期望值16,我们的期望值就等于本次扩容的大小 16
  • 为什么说扩容会消耗性能?
    • 扩容底层使用的是 System.arraycopy 方法,会把原数组的数据全部拷贝到新数组上。
  • 源码扩容过程有什么值得借鉴的地方?
    1. 通过自动扩容的方式,使用者不用关心底层数据长度的变化,封装得很好,1.5 倍的扩容速度,可以让扩容速度在前期缓慢上升,在后期增速较快,大部分工作中要求数组的值并不是很大,所以前期增长缓慢有利于节省资源,在后期增速较快时,也可快速扩容。
    2. 扩容过程中,有数组大小溢出的意识,比如要求扩容后的数组大小,不能小于 0,不能大于 Integer 的最大值。大于的话add时候直接越界异常。

迭代器

ArrayList中提供了一个内部类Itr,实现了Iterator接口,实现对集合元素的遍历 image.png image.png

remove 与数据的拷贝

根据数组索引删除、根据值删除或批量删除等等 image.png 注意删除的时候是从待删除元素开始复制到结尾,然后向前覆盖,帮助GC。 image.png

  • 问题:有一个 ArrayList,数据是 2、3、3、3、4,中间有三个 3,现在我通过 for (int i=0;i<list.size ();i++) 的方式,想把值是 3 的元素删除,请问可以删除干净么?最终删除的结果是什么,为什么?删除代码如下:
    • 不能删除干净,最终删除的结果是 2、3、4,有一个 3 删除不掉,每次删除一个元素后,该元素后面的元素就会往前移动,而此时循环的 i 在不断地增长,最终会使每次删除 3 的后一个 3 被遗漏,导致删除不掉。
List<String> list = new ArrayList<String>() {{
    add("2");
    add("3");
    add("3");
    add("3");
    add("4");
}};
for (int i = 0; i < list.size(); i++) {
    if (list.get(i).equals("3")) {
        list.remove(i);
    }
}
  • 还是上面的 ArrayList 数组,我们通过增强 for 循环进行删除,可以么?
    • 会报错。调用 list#remove () ,modCount 的值会 +1,而这时候迭代器中的 expectedModCount 的值却没有变,导致在迭代器下次执行 next () 方法时,expectedModCount != modCount 就会报 ConcurrentModificationException 的错误。
  • 还是上面的数组,如果删除时使用 Iterator.remove () 方法可以删除么,为什么?
    • 可以的,因为 Iterator.remove () 方法在执行的过程中,会把最新的 modCount 赋值给 expectedModCount,这样在下次循环过程中,modCount 和 expectedModCount 两者就会相等。image.png
  • 以上三个问题对于 LinkedList 也是同样的结果么?
    • 是的,虽然 LinkedList 底层结构是双向链表,但对于上述三个问题,结果和 ArrayList 是一致的。

java.util.LinkedList

LinkedList是一个双向链表,LinkedList的索引决定是从链头开始找还是从链尾开始找。如果该元素小于元素长度一半,从链头开始找起,如果大于元素长度的一半,则从链尾找起。

LinkedList特有方法

子类的特有功能,不能多态调用

  • public void addFirst(E e):将指定元素插入此列表的开头。
  • public void addLast(E e):将指定元素添加到此列表的结尾。
  • public E getFirst():返回此列表的第一个元素。
  • public E getLast():返回此列表的最后一个元素。
  • public E removeFirst():移除并返回此列表的第一个元素。
  • public E removeLast():移除并返回此列表的最后一个元素。
  • public E pop():从此列表所表示的堆栈处弹出一个元素。
  • public void push(E e):将元素推入此列表所表示的堆栈。
  • public boolean isEmpty():如果列表不包含元素,则返回true。

源码剖析

双向链表实现List接口和Deque接口

双向链表结构

有Node节点的定义,内部包含了next 和 prev 指针,和 first 节点以及 last 节点的声明。

add的时候可以直接add到尾部,也可以addFirst到头部,remove的时候可以按照index remove尾部,也可以按照Object为remove,查找的时候index在前半部分从头开始找,在后半部分从结尾开始找。

头节点的前一个节点是 null,尾节点的后一个节点是 null,如果链表数据为空的话,头尾节点是同一个节点,本身是 null,指向前后节点的值也是 null。

ListIterator 双向迭代遍历

Iterator 只支持从头到尾的访问。Java 新增了一个迭代接口提供了向前和向后的迭代方法,叫做:ListIterator。 image.png

迭代顺序方法
从尾到头迭代方法hasPrevious、previous、previousIndex
从头到尾迭代方法hasNext、next、nextIndex

单向、双向队列

LinkedList 实现了Deque接口,Deque 实现了 Queue 接口,所以除了可以作为线性表来使用外,还可以当做队列和栈来使用,在新增、删除、查询等方面增加了很多新的方法,

  • 单向队列在添加的是添加到list的结尾,弹出的时候弹出链表的开头。
  • 实现了 Deque 接口,add和remove都提供首尾的操作,比如 remove 方法,Deque 提供了 removeFirst 和 removeLast 两种方向的使用方式,但当链表为空时的表现都和 remove 方法一样,都会抛出异常。

image.png

新增add()不是队列的方法offer(e)
删除remove()poll()
查找element(e)peek()

栈和队列的实现类

public  class **Stack**<E> extends **Vector**<E>   Vector过时了,被ArrayList替代了,Stack也就过时了

public interface **Queue**<E> extends Collection<E> public interface **Deque**<E> extends Queue<E>

DequeQueue的实现类: ArrayDeque  顺序栈  数组 LinkedList  链栈  链表

public class TestQueue{
	public static void func(){
    	Deque<String> deque1 = new LinkedList<String>();
        deque1.push("盘子1");
        deque1.push("盘子2");
        deque1.push("盘子3");
        
        System.out.println(deque1.size());
        System.out.println(deque1.peek());//get 获取栈顶元素,不移除
        System.out.println(deque1.peek());//get 获取栈顶元素,不移除
        while(!deque1.isEmpty()){
            String elem = deque1.pop();
            System.out.println(elem);
        }
        System.out.println(deque1.size());
    }
}

面试题:ArrayList 和 LinkedList 有何不同?

从底层数据结构开始说起,然后以某一个方法为突破口深入

  • 最大的不同是两者底层的数据结构不同,ArrayList 底层是数组,LinkedList 底层是双向链表,导致了操作的 API 实现有所差异,add 来说,ArrayList 会先计算并决定是否扩容,然后把新增的数据直接赋值到数组上,而 LinkedList 仅仅只需要改变插入节点和其前后节点的指向位置关系即可。
  • 应用场景上,ArrayList 更适合于快速的查找匹配,不适合频繁新增删除,像工作中经常会对元素进行匹配查询的场景比较合适,LinkedList 更适合于经常新增和删除,对查询反而很少的场景。
  • ArrayList 有最大容量的,为 Integer 的最大值,大于这个值 JVM 是不会为数组分配内存空间的,LinkedList 底层是双向链表,理论上可以无限大。但源码中,LinkedList 实际大小用的是 int 类型,这也说明了 LinkedList 不能超过 Integer 的最大值,不然会溢出。
  • ArrayList 允许 null 值新增,也允许 null 值删除。删除 null 值时,是从头开始,找到第一值是 null 的元素删除;LinkedList 新增删除时对 null 值没有特殊校验,是允许新增和删除的。

java.util.concurrent.CopyOnWriteArrayList

  • lock 锁 + 数组拷贝 + volatile image.png
  • 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。操作都是在新拷贝数组上进行的;迭代过程不会被其他线程干扰,也不会抛 ConcurrentModificationException
  • 读操作不加锁

源码剖析

面试题:什么是写时复制 COW?

当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。 

对于读操作远远多于写操作的应用非常适合,特别在并发情况下,可以提供高性能的并发读取。 只能保证数据的最终一致性,不能保证数据实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用。

新增到数组尾部:锁 + 追加 + 拷贝

通过加锁,来保证同一时刻只能有一个线程能够对同一个数组进行 add 操作。对数组进行操作的时候,基本会分四步走:

  1. 加锁;
  2. 从原数组中拷贝出新数组;
  3. 在新数组上进行操作,并把新数组赋值给数组容器;
  4. 解锁。

image.png

指定位置添加元素:锁 + 切割 + 拷贝

  • 当插入的位置正好处于末尾时,只需要拷贝一次
  • 当插入的位置处于中间时,此时我们会把原数组一分为二,进行两次拷贝操作。

image.png

  1. 加锁:保证同一时刻数组只能被一个线程操作;
  2. 数组拷贝:保证数组的内存地址被修改,修改后触发 volatile 的可见性,其它线程可以立马知道数组已经被修改;

去掉 2,这样当我们修改数组中某个值时,并不会触发 volatile 的可见特性的,只有当数组内存地址被修改后,才能触发把最新值通知给其他线程的特性。

指定位置删除:锁 + 切割 + 数组拷贝,

锁被 final 修饰的,保证了在加锁过程中,锁的内存地址肯定不会被修改 image.png

批量删除:选出不删的到新数组 + 拷贝

单个删除每次都会拷贝数据,导致性能消耗,大量加解锁。批量删除不会直接对数组中的元素进行挨个删除,而是先对数组中的值进行循环判断,把我们不需要删除的数据放到临时数组中,最后临时数组中的数据就是我们不需要删除的数据。 image.png

迭代:COW 避免了并发修改异常

CopyOnWriteArrayList 迭代持有的是老数组的引用,而 CopyOnWriteArrayList 每次的数据变动,都会产生新的数组,对老数组的值不会产生影响,所以迭代也可以正常进行。 image.png

面试题:为什么 CopyOnWriteArrayList 迭代过程中,数组结构变动,不会抛出ConcurrentModificationException 了

答:每次操作时,都会产生新的数组,而迭代时,持有的仍然是老数组的引用,即使替换完迭代指向的也是原来的数组,老数组的结构并没有发生变化,所以不会抛出异常了。

面试题:插入的数据正好在 List 的中间,请问ArrayList和CopyOnWriteArrayList 分别拷贝数组几次?为什么?

答:ArrayList 只需拷贝一次,假设插入的位置是 2,只需要把位置 2 (包含 2)后面的数据都往后移动一位即可,所以拷贝一次。CopyOnWriteArrayList 拷贝两次,因为把老数组 0 到 2 的数据拷贝到新数组上,预留出新数组 2 的位置,再把老数组 3~ 最后的数据拷贝到新数组上

面试题:为什么需要拷贝数组,而不是在原来数组上面加锁进行操作呢?

  1. 保证可见性生效:volatile 关键字修饰的是数组,如果我们简单的在原来数组上修改其中某几个元素的值,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值才行。
  2. 提高并发:避免由于老数组用于其他操作中导致的阻塞,效率低下。

面试题:ArrayList 和 LinkedList 是线程安全的么,为什么?怎么解决?

image.png 共享变量时,会有线程安全问题。在迭代的过程中,会频繁报 ConcurrentModificationException 的错误,意思是在我当前循环的过程中,数组或链表的结构被其它线程修改了。ArrayList 自身的 elementData、size、modConut 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况。

Collections#synchronizedList 可以解决,SynchronizedList 是通过在每个方法的方法体里面使用代码块加锁来实现,保证了在同一时刻,数组和链表只会被一个线程所修改,虽然实现了线程安全,但是性能大大降低

或者采用 CopyOnWriteArrayList 并发 List 来解决。此实现中数组被 volatile 关键字修饰,保证了数组内存地址被任意线程修改后,都会通知到其他线程;对数组的所有修改操作,都进行了加锁,保证了同一时刻,在 add 时,就无法 remove;修改过程中对原数组进行了复制,是在新数组上进行修改的,修改过程中,不会对原数组产生任何影响,避免了并发修改异常。

Vector类(旧的)

实现原理和ArrayList相同,功能相同,都是长度可变的数组结构,很多情况下可以互用

  • Vector集合数据存储的结构是数组结构,为JDK中最早提供的集合,它是线程同步的,ArrayList是替代Vector的新接口
  • Vector线程安全,效率低下;ArrayList重速度轻安全,线程非安全
  • 长度需增长时,Vector默认增长一倍,ArrayList增长50%
  • Vector中提供了一个独特的取出方式,就是枚举Enumeration,它其实就是早期的迭代器。此接口Enumeration的功能与 Iterator 接口的功能是类似的。Vector集合已被ArrayList替代。枚举Enumeration已被迭代器Iterator替代。
public class TestVector {
    public static void main(String[] args) {
        // 泛型是1.5开始的,重新改写了Vector,ArrayList
        Vector<Integer> v = new Vector<Integer>();        
        v.addElement(123);
        v.addElement(456);
        v.addElement(345);
        v.addElement(100);        
        Enumeration<Integer> en = v.elements();
        while(en.hasMoreElements()){
            Integer elem = en.nextElement();
            System.out.println(elem);
        }
    }
}

面试题:Vector和ArrayList的联系和区别

实现原理和ArrayList相同,功能相同,都是长度可变的数组结构,很多情况下可以互用

两者的主要区别如下

  • Vector是早期JDK接口,ArrayList是替代Vector的新接口
  • Vector线程安全效率低下;ArrayList重速度轻安全,线程非安全
  • 长度需增长时,Vector默认增长一倍,ArrayList增长50%

java.util.Set

它是个不包含重复元素的集合。无索引 Set集合取出元素的方式可以采用:迭代器、增强for。 image.png

数据的存储结构

  • HashSet  哈希表  唯一  无序
  • LinkedHashSet  哈希表+链表  唯一 有序(添加顺序)
  • TreeSet  红黑树 一种二叉平衡树 唯一  有序(自然顺序)

接口方法

  • List针对Collection增加了一些关于索引位置操作的方法 get(i)、add(i,elem)、remove(i)、set(i,element)
  • Set是无序的,不可能提供关于索引位置操作的方法,set针对Collection没有增加任何方法

image.png

Set集合存储和迭代

  • List的遍历有三种方式:for循环、for-each循环、Iterator迭代器
  • Set的遍历有两种方式: for-each循环、Iterator迭代
public class HashSetDemo {
    public static void main(String[] args) {
        Set set = new HashSet();
        set.add("java");
        set.add("java");
        set.add("deltaqin");      
        Iterator<String> it = set.iterator();
        while(it.hasNext()){
            System.out.println(it.next());
        }
        System.out.println("==============");

        for(String s : set){
            System.out.println(s);
        }
    }
}

java.util.HashSet

存储的元素是不可重复的,并且元素都是无序的(即存取顺序不一致)。java.util.HashSet底层的实现其实是一个**java.util.HashMap**支持,HashSet是根据对象的哈希值来确定元素在集合中的存储位置,因此具有良好的存取和查找性能。保证元素唯一性的方式依赖于:hashCodeequals方法。

源码剖析

HashSet 如何组合 HashMap 的?

image.png HashSet 使用的就是组合 HashMap,使用组合而不是继承是因为:

  1. 继承表示父子类是同一个事物,而 Set 和 Map 本来就是想表达两种事物,所以继承不妥
  2. 继承难以扩展,组合更加灵活,可以任意的组合现有的基础类,并且可以在基础类方法的基础上进行扩展、编排等,而且方法命名可以任意命名,无需和基础类的方法名称保持一致。尽量多用组合,少用继承。组合就是把 HashMap 当作自己的一个局部变量。
public boolean add(E e) {
    // 直接使用 HashMap 的 put 方法,进行一些简单的逻辑判断
    return map.put(e, PRESENT)==null;
}

从这两行代码中,我们可以看出两点:

  1. add 方法**使用默认值来代替了 Map 中的 Value 值,**可以把底层复杂实现包装一下,一些默认实现可以自己吃掉,使吐出去的接口尽量简单好用。
  2. 如果 HashSet 是被共享的,当多个线程访问的时候,就会有线程安全问题,因为在后续的所有操作中,并没有加锁。

HashSet存储结构(哈希表)

HashSet的底层使用的是HashMap,所以底层结构也是哈希表,hashtable 也叫散列表。相当于顺序表+链表。每个顺序表的节点在单独引出一个链表。

  • 在无序数组中按照内容查找,效率低下,时间复杂度是O(n) 
  • 在有序数组中按照内容查找,可以使用折半查找,时间复杂度O(log2n) 
  • 在二叉平衡树中按照内容查找,时间复杂度O(log2n) 
  • 在数组中按照索引查找,不进行比较和计数,直接计算得到,效率最高,时间复杂度O(1)

hashCode和equals作用

  • hashCode(): 计算哈希码,是一个整数,根据哈希码可以计算出数据在哈希表中的存储位置 
  • equals():添加时出现了冲突,需要通过equals进行比较,判断是否相同;查询时也需要使用equals进行比较,判断是否相同   

如何计算 hashCode() 

int   取自身 看Integer的源码  double  3.14 3.15  3.145  6.567  9.87  取整不可以  看Double的源码 

字符串的hash值计算
// 字符串对象的哈希值
public int hashCode() {
    // 初始值为 0 
    int h = hash;
    if (h == 0 && value.length > 0) {
        // 字符串对应的 Unicode 编码字符
        char val[] = value;
        // 每一个字符都会对hash值产生一些影响
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

之所以使用 31,是因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。

使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能:31*i == (i<<5) - i,现代的 VM 可以自动完成这种优化。这个公式可以很简单的推导出来。

默认的对象hash值
/*
   *  对象的哈希值,普通的十进制整数,其实就是对象的地址
   *  父类Object,方法 public int hashCode() 计算结果int整数
   */
public class HashDemo {
    public static void main(String[] args) {
        Person p = new Person();
        int i = p.hashCode();
        System.out.println(i);

        String s1 = new String("abc");
        String s2 = new String("abc");
        System.out.println(s1.hashCode());
        System.out.println(s2.hashCode());
    }
}
自定义对象重写hashCode和equals

给HashSet中存放自定义类型元素时,需要重写对象中的hashCode和equals方法,建立自己的比较方式,才能保证HashSet集合中的对象唯一

public class Student {
    private String name;
    private int age;

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Student student = (Student) o;
        return age == student.age &&
               Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

如何减少冲突 

  • 哈希表的长度和表中的记录数的比例--装填因子: 如果Hash表的空间远远大于最后实际存储的记录个数,则造成了很大的空间浪费, 如果选取小了的话,则容易造成冲突。 在实际情况中,一般需要根据最终记录存储个数和关键字的分布特点来确定Hash表的大小。还有一种情况是可能事先不知道最终需要存储的记录个数,则需要动态维护Hash表的容量,此时可能需要重新计算Hash地址。装填因子=表中的记录数/哈希表的长度, 4/ 16  =0.25   8/ 16=0.5。如果装填因子越小,表明表中还有很多的空单元,则添加发生冲突的可能性越小;而装填因子越大,则发生冲突的可能性就越大,在查找时所耗费的时间就越多。 有相关文献证明当装填因子在0.5左右时候,Hash性能能够达到最优。 因此,一般情况下,装填因子取经验值0.5。
  • 哈希函数的选择 :直接定址法    平方取中法  折叠法   除留取余法(y = x%11)
  • 处理冲突的方法 :链地址法  开放地址法  再散列法   建立一个公共溢出区

JDK1.8 之前哈希表实现

  • 在JDK1.8之前,哈希表底层采用数组+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。
  • 加载因子:加载因子是0.75 代表,数组中的16个位置,其中存入160.75=12个元素。如果在存入第十三个(>12)元素,导致存储链子过长,会降低哈希表的性能,那么此时会扩充哈希表(在哈希),底层会开辟一个长度为原长度2倍的数组,把老元素拷贝到新数组中,再把新元素添加数组中。当存入元素数量>哈希表长度加载因子,就要扩容,因此加载因子决定扩容时机

JDK1.8 中哈希表实现

  • 而JDK1.8中,哈希表存储采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

哈希表的存储过程

  1. 首先调用本类的hashCode()方法算出哈希值
  2. 在容器中找是否与新元素哈希值相同的老元素, 如果没有直接存入,如果有转到第三步
  3. 新元素会与该索引位置下的老元素利用 equals 方法一一对比。一旦新元素.equals(老元素)返回true,停止对比,说明重复,不再存入。如果与该索引位置下的老元素都通过equals方法对比返回false,说明没有重复,存入。

保证HashSet集合元素的唯一,其实就是根据对象的hashCode和equals方法来决定的。 如果我们往集合中存放自定义的对象,那么保证其唯一,就必须复写hashCode和equals方法建立属于当前对象的比较方式。

java.util.LinkedHashSet

它是链表和哈希表组合的一个数据存储结构。存储和取出的顺序相同的。

public class LinkedHashSetDemo {
	public static void main(String[] args) {
		Set<String> set = new LinkedHashSet<String>();
		set.add("bbb");
		set.add("aaa");
		set.add("abc");
		set.add("bbc");
        Iterator<String> it = set.iterator();
		while (it.hasNext()) {
			System.out.println(it.next());
		}
	}
}
结果:
  bbb
  aaa
  abc
  bbc

java.util.TreeSet

采用二叉树(红黑树)的存储结构,有序,查询速度比List快(按照内容查询),没有HashSet快。

创建TreeSet对象就是创建一个TreeMap对象,底层的TreeMap的引用。TreeSet的元素e是作为TreeMap的key存在的,value统一为new Object()

适用场景:一般都是在需要把元素进行排序的时候使用 TreeSet,使用时需要我们注意元素最好实现 Comparable 接口,这样方便底层的 TreeMap 根据 key 进行排序。

所有元素都需要实现 Comparable 接口

所有加入到 TreeSet 的对象都需要实现 Comparable 接口。TreeSet不允许null对象

定义外部比较器:按照姓名逆序排序,如姓名相同,按学号逆序排列

public class StudentNameDescComparator implements Comparator<Student> {
    @Override
    public int compare(Student stu1, Student stu2) {
        int n = stu1.getName().compareTo(stu2.getName());
        if(n !=0){
            return n;
        }else{
            return -(stu1.getSno()-stu2.getSno());
        }
    }
}

为什么TreeSet是可浏览的(Navigable)

image.png TreeSet通过NavigableSet提供找到这个元素的前继元素后继元素:

  • E lower(E e) 提供小于元素e的最大元素
  • E floor(E e) 提供小于等于元素e的最大元素
  • E higher(E e) 提供大于元素e的最大元素
  • E ceiling(E e) 提供大于等于元素e的最大元素

称之为浏览的能力。当然,之所以提供浏览的能力。是因为树的结构提供浏览的能力。开销很小,速度很快。哈希表不具有这样的能力。

复用 TreeMap 的思路:

  1. TreeSet 直接使用 TreeMap 的某些功能,自己包装成新的 api。
    1. 功能的定义和实现都在 TreeMap,TreeSet 只是简单的调用而已
    2. 像 add 这些简单的方法没有复杂逻辑,所以 TreeSet 自己实现起来比较简单;
  2. TreeSet 定义自己想要的 api,自己定义接口规范,让 TreeMap 去实现。
    1. 接口是 TreeSet 定义的,所以实现一定是 TreeSet 最想要的,TreeSet 甚至都不用包装,可以直接把返回值吐出去都行。
    2. 迭代场景,TreeMap 底层结构比较复杂,TreeSet 可能并不清楚 TreeMap 底层的复杂逻辑,这时候让 TreeSet 来实现如此复杂的场景逻辑,TreeSet 就搞不定了,不如接口让 TreeSet 来定义,让 TreeMap 去负责实现,TreeMap 对底层的复杂结构非常清楚,实现起来既准确又简单。

简单的逻辑直接包装TreeMap方法

TreeSet 的 add 方法, 底层直接使用的是 HashMap 的 put 的能力,直接拿来用就好了。

public boolean add(E e) {
    return m.put(e, PRESENT)==null;
}

复杂的逻辑定义接口让TreeMap实现

  1. NavigableSet 接口,定义了迭代的接口:Iterator<E> iterator();
  2. TreeSet 实现了iterator方法:return m.navigableKeySet().iterator(); 其中的 m 是NavigableMap接口,真正的实现类是 TreeMap,实现方法navigableKeySet中调用了TreeMap内一个子类 KeySet,该类实现了NavigableSetiterator方法
    1. 所以m.navigableKeySet().iterator()调用的是 TreeMap中定义的KeySet实现的iterator 方法。

image.png image.png

面试题:TreeSet 和 HashSet 两个 Set 内部实现的区别?

HashSet 底层对 HashMap 的能力进行封装,比如说 add 方法,是直接使用 HashMap 的 put 方法,比较简单,但在初始化的时候,HashMap 初始化大小值的模版公式:取括号内两者的最大值(期望的值 / 0.75+1,默认值 16)。

TreeSet 主要是对 TreeMap 底层能力进行封装复用,TreeSet 直接使用 TreeMap 的某些功能,自己包装成新的 api。**TreeSet 定义自己想要的 api,自己定义接口规范,让 TreeMap 去实现,**TreeMap 对底层的复杂结构非常清楚,实现起来既准确又简单。

CopyOnWriteArraySet:CopyOnWrite+Lock锁 

线程安全的HashSet。  CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet; 但是,HashSet是通过"散列表(HashMap)"实现的,而CopyOnWriteArraySet则是通过"动态数组(CopyOnWriteArrayList)"实现的,并不是散列表    CopyOnWriteArraySet在CopyOnWriteArrayList 的基础上使用了Java的装饰模式,所以底层是相同的。而CopyOnWriteArrayList本质是个动态数组队列,所以CopyOnWriteArraySet相当于通过动态数组实现的"集合"!

CopyOnWriteArrayList额外提供了addIfAbsent()和addAllAbsent()这两个添加元素的API,通过这些API来添加元素时,只有当元素不存在时才执行添加操作! 

java.util.Map

面试题:Java 8 在 List、Map 接口上新增了很多方法,为什么 Java 7 中这些接口的实现者不需要强制实现这些方法呢?

这些新增的方法被 default 关键字修饰了,default 一旦修饰接口上的方法,我们需要在接口的方法中写默认实现,并且子类无需强制实现这些方法,所以 Java 7 接口的实现者无需感知。

面试题:Java 8 中有新增很多实用的方法,你在平时有使用过么?

答:有的,比如说 getOrDefault、putIfAbsent、computeIfPresent 方法等等,比如 computeIfPresent 是可以对 key 和 value 进行计算后,把计算的结果重新赋值给 key,并且如果 key 不存在时,不会报空指针,会返回 null 值。

面试题:Java 8 集合新增了 forEach 方法,和普通的 for 循环有啥不同?

答:新增的 forEach 方法的入参是函数式的接口,封装了 for 循环的代码,让使用者关注实现循环的业务逻辑,简化了重复的 for 循环代码,使代码更加简洁,普通的 for 循环,每次都需要写重复的 for 循环代码。

数据的存储结构

  • HashMap<K,V>:哈希表结构,元素的存取顺序不能保证一致。由于要保证键的唯一、不重复,需要重写键的hashCode()方法、equals()方法。
  • LinkedHashMap<K,V>:存储数据采用的哈希表结构+链表结构。通过链表结构可以保证元素的存取顺序一致;通过哈希表结构可以保证的键的唯一、不重复,需要重写键的hashCode()方法、equals()方法。
  • TreeMap:采用二叉树(红黑树)的存储结构,key有序  查询速度比List快(按照内容查询) 查询速度没有HashSet快

Map 接口方法

Map接口中的集合都有两个泛型变量<K,V>。Map中的集合不能包含重复的键,值可以重复;每个键只能对应一个值。 image.png

Map集合Entry对象

Map中一对对象又称做Map中的一个Entry(项)Entry将键值对的对应关系封装成了对象。即键值对对象,这样我们在遍历Map集合时,就可以从每一个键值对(Entry)对象中获取对应的键与对应的值。

interface Map{
    interface Entry{
        //Entry是Map的一个内部接口
        //由Map的子类的内部类实现 
    }
}


class HashMap{
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
        ...
    }
}
  • public K getKey():获取Entry对象中的键。
  • public V getValue():获取Entry对象中的值。

在Map集合中也提供了获取所有Entry对象的方法:

  • public Set<Map.Entry<K,V>> entrySet(): 获取到Map集合中所有的键值对对象的集合(Set集合)。

遍历Map的几种方式:

  1. keySet方法获取Map集合中所有的键,由于键是唯一的,所以返回一个Set集合存储所有的键。根据键利用get(key)去Map找所对应的值
  2. entrySet方法:getkey() getValue()。Map集合不能直接使用迭代器或者foreach进行遍历。但是转成Set之后就可以使用了。
// entrySet
public static void entrySet(HashMap<String,HashMap<String,String>> qiantao){
    //调用qiantao集合方法entrySet方法,将qiantao集合的键值对关系对象,存储到Set集合
    Set<Map.Entry<String, HashMap<String,String>>> classNameSet = 
        qiantao.entrySet();
    
    //迭代器迭代Set集合
    Iterator<Map.Entry<String, HashMap<String,String>>> classNameIt = 
        classNameSet.iterator();
    while(classNameIt.hasNext()){
        //classNameIt.next方法,取出的是qiantao集合的键值对关系对象
        Map.Entry<String, HashMap<String,String>> classNameEntry =  
            classNameIt.next();
        //classNameEntry方法 getKey,getValue
        String classNameKey = classNameEntry.getKey();
        //获取值,值是一个Map集合
        HashMap<String,String> classMap = classNameEntry.getValue();
        //调用班级集合classMap方法entrySet,键值对关系对象存储Set集合
        Set<Map.Entry<String, String>> studentSet = classMap.entrySet();
        //迭代Set集合
        Iterator<Map.Entry<String, String>> studentIt = studentSet.iterator();
        while(studentIt.hasNext()){
            //studentIt方法next获取出的是班级集合的键值对关系对象
            Map.Entry<String, String> studentEntry = studentIt.next();
            //studentEntry方法 getKey getValue
            String numKey = studentEntry.getKey();
            String nameValue = studentEntry.getValue();
            System.out.println(classNameKey+".."+numKey+".."+nameValue);
        }
    }
    System.out.println("==================================");

    for (Map.Entry<String, HashMap<String, String>> me : qiantao.entrySet()) {
        String classNameKey = me.getKey();
        HashMap<String, String> numNameMapValue = me.getValue();
        for (Map.Entry<String, String> nameMapEntry : numNameMapValue.entrySet()) {
            String numKey = nameMapEntry.getKey();
            String nameValue = nameMapEntry.getValue();
            System.out.println(classNameKey + ".." + numKey + ".." + nameValue);
        }
    }
}

// keySet
public static void keySet(HashMap<String,HashMap<String,String>> qiantao){
    //调用qiantao集合方法keySet将键存储到Set集合
    Set<String> classNameSet = qiantao.keySet();
    //迭代Set集合
    Iterator<String> it = classNameSet.iterator();
    while(it.hasNext()){
        //it.next获取出来的是Set集合元素,qiantao集合的键
        String nameKey = it.next();
        //qiantao集合的方法get获取值,值是一个HashMap集合
        HashMap<String,String> classMap = qiantao.get(nameKey);
        //调用classMap集合方法keySet,键存储到Set集合
        Set<String> studentNum = classMap.keySet();
        Iterator<String> studentIt = studentNum.iterator();

        while(studentIt.hasNext()){
            //studentIt.next获取出来的是classMap的键,学号
            String numKey = studentIt.next();
            //调用classMap集合中的get方法获取值
            String nameValue = classMap.get(numKey);
            System.out.println(nameKey+".."+numKey+".."+nameValue);
        }
    }

    System.out.println("==================================");
    for(String nameKey: qiantao.keySet()){
        HashMap<String, String> hashMap = qiantao.get(nameKey);	
        for(String numKey : hashMap.keySet()){
            String nameValue = hashMap.get(numKey);
            System.out.println(className+".."+numKey+".."+nameValue);
        }
    }
}

java.util.HashMap

面试题:说说对 HashMap 的理解

HashMap 用来存储键值对,是线程不安全的,允许 null 值。底层是数组 + 链表 + 红黑树的数据结构,数组的主要作用是方便快速查找,时间复杂度是 O(1),所以get、put 的实现达到了常数的时间,默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素叫做 Node,当多个 key 的 hashcode 一致,但 key 值不同时,单个 Node 就会转化成链表,链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化成红黑树,红黑树的查询复杂度是 O(log(n)),此时,最坏的查询次数相当于红黑树的最大深度。当红黑树的大小小于等于 6 时,红黑树会转化成链表。

面试题:HashMap的初始容量,最大容量是多少?何时转换为红黑树,何时转换为链表?

image.png

面试题:为什么使用红黑树?

红黑树是一种二叉平衡树,二叉树的查找插入都是O(logn),如果树退化为链表就会退化为O(n),红黑树在插入的时候可以保证自己是一个接近平衡的二叉树。红黑树的排序主要是依靠hashcode,compareTo **红黑树的节点大小是普通节点的两倍,所以只有在桶里面有足够多的数才用。**在删除节点的时候数量变少的时候又会变成普通节点。

面试题:负载因子为什么是0.75不是0.8或者0.6?

load factor 是0.75 权衡了空间和时间消耗,高的话减少了空间但是增加了查询开销,hash冲突增加,链表长度变长,小的话浪费空间

面试题:如何设置初始容量?如何拷贝大的集合?

不扩容的条件:initial capacity > 需要的数组大小 / load factor。如果有很多数据需要储存到 HashMap 中,建议 HashMap 的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能。给 HashMap 赋初始值的公式为:取括号内两者的最大值(期望的值/0.75+1,默认值 16)。

面试题:HashMap 7 和 8 的主要区别?

1. 数据结构不同

数据结构的变化,多出了红黑树提高查询的效率

2. 初始化的时机和容量不同

初始化方面,7 不传参数默认就是初始化16大小数组, 8 不传参数默认就是空的,用的时候才会初始化数组;如果用户初始化的时候容量不是2 的幂,7 不会在构造的时候转换而是在 put 的时候,8 会在初始化的时候就变为2的幂。 image.png

3. 扩展用户自定义的容量为2的幂不同算法

在7里面是取(当前数字 - 1)的二进制位是1的最高位置,将其左移一位,得到 floor 的 2 的幂: image.png 在8里面是取(当前数字 - 1)之后,将最高位为1的位置右侧所有值变为1,之后 + 1 ,就是当前值的更高的2 的幂。 image.png

4. hash 值的计算方式不同

7 里面为了避免hash碰撞频繁发生,计算hash的方法比较繁琐。 8 里面引入了红黑树之后,即使hash碰撞频繁发生,转换为红黑树查询效率也很高,没有必要使用复杂的hash 函数增加计算负担,所以直接将高位的数据和低位的数据异或,保证高位的信息保留下来同时简化计算。 image.png image.png

链表退化的bug,借助自定义的hash函数,不使用String自带的来修复

CVE-2011-4858,Tomcat邮件组的讨论(链表使得性能退化) 当使用巧妙设计的字符串来获取哈希值,可以获取相同的,这个时候哈希表就会退化成链表,如果大量使用这样的哈希值,就会使得查找变难,因为是链表,就会恶意请求DoS攻击,Tomcat的接收request的参数使用的是HashMap,为了防止这样的情况导致服务器下降,Tomcat限制了参数的个数,防止恶意哈希的攻击导致服务器在查找上消耗过大

面试题:为什么hash函数不使用key自己的要另外实现?为什么需要右移 16 位而不是15,17?
  1. 因为计算index只和hash值的低n位有关,所以要把高位的变化反应到低位上。
  2. hashcode 一共32位,hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移 16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能性。
面试题:为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小。

如果 key 是数字没有问题的,但 key 是字符串、复杂对象,这时候用字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的 hash 值。

面试题:为什么把取模操作换成了 & 操作?

取模操作处理器计算比较慢,计算机计算取余是一个一个减去的,处理器对 & 操作就比较擅长,提高了处理器处理的速度。

面试题:为什么提倡数组大小是 2 的幂次方?

因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立。

5. put 数据过程不同

7 里面扩容头插法

7 里面先初始化,之后null直接放第一个,之后有的话直接替换,之后没有的话就moundCount++头插添加新的节点,期间不够的话会扩容,扩容一般默认不会重新计算hash值的,直接hashcode重新对新容量取余得到index,会导致环形链表,下次访问的时候找不到的话直接就死循环了。 JDK7 put 方法.png

8 里面扩容尾插法
  1. hash 冲突处理:桶里第一个就和要放的key相等。直接赋值给e,等待处理
  2. hash 冲突处理:当前桶节点是红黑树节点,当做红黑树处理:
    1. 单向链表转换为红黑树的时候会先变化为双向链表最终转换为红黑树,双向链表跟红黑树是**共存**的,切记。
    2. 链表转红黑树后会努力将红黑树的root节点和链表的头节点 跟table[i]节点融合成一个
    3. 在删除的时候是先判断删除节点红黑树个数是否需要转链表,不转链表就跟RBT类似,找个合适的节点来填充已删除的节点。
    4. 红黑树的root节点不一定跟table[i]也就是链表的头节点是同一个哦,三者同步是靠MoveRootToFront实现的。而HashIterator.remove()会在调用removeNode的时候movable=false。
  3. hash 冲突处理:链表处理,发现相等的,直接赋值给e,没发现相等的,就在末尾追加,追加完判断链表的长度是否超过阈值,超过则尝试转换红黑树:
    1. 当链表长度大于等于 8 时,此时的链表就会尝试转化成红黑树,转化的方法是:treeifyBin,此方法有一个判断,当链表长度大于等于 8,并且整个数组大小大于 64 时,才会转成红黑树,当数组大小小于 64 时,只会触发扩容 resize,不会转化成红黑树,转化成红黑树的过程也比较简单。
  4. 最后修改modCount ,处理扩容
    1. 转移的时候是,尾插法,这样就转移的时候顺序不会翻转。而且循环内部负责将原本的一个链表拆分为两个链表,一个是在原来的oldindex,一个是在新的 = oldindex + oldCapacity,两个到数组上的操作是在循环外部进行的,就不会有循环发生。

JDK8 put 方法.png

面试题:说说7里面扩容转移数据时候头插法如何导致循环链表的产生?

image.png 线程12同时put的时候可能会同时扩容条件,线程2阻塞在 if,Entry<K, V> next = e.next;都已经执行,假设最开始是321,线程1头插法转移后32,最后新数组里链表得到了23,

此时线程2开始转移:但是此时线程2刚刚赋值的e还在3,刚刚赋值的next还是2,e.next = newTable[i],3的next指向了当前的null,newTable[i]=e之后3到达新数组链表头。执行e = next,此时next是2,2变成e。

再进来时候由于线程1执行完2的next就是3,所以 EntrysK, V> next = e.next之后 next又变成3。e.next = newTable[i]2的next本来也是3,没用的语句。newTable[i] = e: 链表头变成2,e变成3。

再进来,3的next是null,next变成null。之后将3指向2,3头插,此时环就形成了。 此时e是null了,退出循环 原来的链表里面还有丢失的节点。 image.png

面试题:说说8里面扩容转移数据时有两个临时链表怎么回事?

节点插在了链表的尾部,不是7里面插在头部下移,这样就扩容的时候不会顺序翻转, 而且插入节点到数组上的操作是在循环外部进行的,循环内部负责将原本的一个链表拆分为两个链表,一个是在原来的oldindex,一个是在新的 = oldindex + oldCapacity 就不会有循环发生

示例1:e.hash= 10 0000 1010 oldCap=16 0001 0000 &   =0  0000 0000       比较高位的第一位 0结论:元素位置在扩容后数组中的位置没有发生改变 示例2:e.hash= 17 0001 0001 oldCap=16 0001 0000 &   =1  0001 0000       比较高位的第一位   1 结论:元素位置在扩容后数组中的位置发生了改变,新的下标位置是原下标位置+原数组长度

看下面的代码,新老链表分开构建,构建好之后直接整体加到数组上去 image.png blog.csdn.net/u010425839/… image.png

面试题:为什么链表长度达到8开始尝试转换为红黑树?为什么等到数组长度64而不是直接变红黑树
  1. 好的hash函数桶里面很难达到8的节点,此时链表和树的区别不大,转换为树还有性能和空间(节点占用2倍)开销,超过8就要转换了,因为这个小概率事件发生了,碰撞严重。通过泊松分布公式计算,正常情况下,链表个数出现 8 的概率不到千万分之一,所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然能够快速遍历。
  2. 红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况下冲突严重,我们可以先尝试扩容,看看能否通过扩容来解决冲突的问题。避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

image.png

面试题:红黑树转变成链表为什么是6?

节点的个数小于等于 6 时,红黑树会自动转化成链表,主要还是考虑红黑树的空间成本问题,当节点个数小于等于 6 时,遍历链表也很快,所以红黑树会重新变成链表。如果也将该阈值设置于8,那么当hash碰撞在8时,会反生链表和红黑树的不停相互激荡转换,白白浪费资源。 是6不是8是为了避免频繁的切换。可能长度就在8之间来回浮动就惨了。

面试题:HashMap 在 put 时,如果数组中已经有了这个 key,我不想把 value 覆盖怎么办?
  • 如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法,这个方法有个内置变量 onlyIfAbsent,内置是 true ,就不会覆盖,我们平时使用的 put 方法,内置 onlyIfAbsent 为 false,是允许覆盖的。
面试题:JDK8中HashMap 扩容的时机?
  1. put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
  2. put时候,发现当前链表的长度大于8而数组长度小于64时候,扩容数组。
  3. put 成功后,发现现有数组大小大于扩容的阀值时,进行扩容,扩容为老数组大小的 2 倍;扩容的门阀是 threshold,每次扩容时 threshold 都会被重新计算,门阀值等于数组的大小 * 影响因子(0.75)。

6. get 的过程不一样

7 只查询链表
public V get(Object key) {
    //根据key找到Entry(Entry中有key和value)
    Entry<K,V> entry = getEntry(key);
    //如果entry== null,返回null,否则返回value
    return null == entry ? null : entry.getValue();
}
8 还查询红黑树
  • 如果不存在直接返回null
  • 如果桶的首个节点存在,且是要查询的,直接返回
  • 如果桶的首个节点没有next就返回null
  • 如果桶的首个节点有next,判断是红黑树还是链表,是链表就循环查找

image.png 查找红黑树的节点getTreeNode:

  1. 先获得根节点,左节点,右节点。
  2. 根据 左节点 < 根节点 < 右节点 对对数据进行逐步范围的缩小查找。
  3. 如果实现了Comparable方法则直接对比。
  4. 否则如果根节点不符合则递归性的调用find查找函数。

面试题:解决 hash 冲突,大概有哪些办法?

  • 好的 hash 算法,比如hashMap中的高低位异或。
  • 自动扩容,当数组大小快满的时候,采取自动扩容,可以减少 hash 冲突;
  • hash 冲突发生时,采用链表来解决,如果桶中元素原本只有一个或已经是链表了,新增元素直接追加到链表尾部。如果桶中元素已经是链表,并且链表个数大于等于 8 时,此时有两种情况:
    • 如果此时数组大小小于 64,数组再次扩容,链表不会转化成红黑树。
    • 如果数组大小大于 64 时,链表就会转化成红黑树。

面试题:Entry、Node、TreeNode关系是什么,是如何实现的?

image.png 主要数据结构

  • java.util.Map.Entry
  • java.util.HashMap.Node 链表节点
  • java.util.LinkedHashMap.Entry
  • java.util.HashMap.TreeNode:继承了LinkedHashMap 的 Entry,因此有了指向父节点的能力。红黑树的节点大小是普通节点的两倍,所以只有在桶里面有足够多的数才用

并发修改异常:HashMap的快速失败机制

fail-fast:在迭代过程中,如果 HashMap 的结构被修改,会快速失败。

HashMap<String,String > map = Maps.newHashMap();
map.put("1","1");
map.put("2","2");
map.forEach((s, s2) -> map.remove("1"));

不行,会报错误 ConcurrentModificationException,建议使用迭代器的方式进行删除,原理同 ArrayList 迭代器原理

面试题:DTO 作为 Map 的 key 时,有无需要注意的点?

一定需要覆写 equals 和 hashCode 方法,因为在 get 和 put 的时候,需要通过 equals 方法进行相等的判断;

  • 如果是 TreeMap 的话,DTO 需要实现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小;
  • 如果是 LinkedHashMap 的话,和 HashMap 一样的。

如果hashcode一样,可能是因为碰撞,需要使用equals验证,hashcode不一样就一定是不一样了。前提是重写了。

HashMap、TreeMap、LinkedHashMap 三者有啥相同点,有啥不同点?

答:相同点:

  1. 三者在特定的情况下,都会使用红黑树;
  2. 底层的 hash 算法相同;
  3. 在迭代的过程中,如果 Map 的数据结构被改动,都会报 ConcurrentModificationException 的错误。

不同点:

  1. HashMap 数据结构以数组为主,查询非常快,TreeMap 数据结构以红黑树为主,利用了红黑树左小右大的特点,可以实现 key 的排序,LinkedHashMap 在 HashMap 的基础上增加了链表的结构,实现了插入顺序访问和最少访问删除两种策略;
  2. HashMap 是无序的,TreeMap 可以按照 key 进行排序,LinkedHashMap维护插入的顺序:由于三种 Map 底层数据结构的差别,导致了三者的使用场景的不同,
    1. TreeMap 适合需要根据 key 进行排序的场景,
    2. LinkedHashMap 适合按照插入顺序访问,或需要删除最少访问元素的场景,
    3. 剩余场景我们使用 HashMap 即可,我们工作中大部分场景基本都在使用 HashMap;

面试题:HashMap 8 一些好用的方法

  1. 新增了一些好用的方法,比如 getOrDefault,我们看下源码,非常简单:
    1. 如果 key 对应的值不存在,返回期望的默认值 defaultValue。public V getOrDefault(Object key, V defaultValue)
    2. putIfAbsent(K key, V value) 方法,意思是,如果 map 中存在 key 了,那么 value 就不会覆盖,如果不存在 key ,新增成功。
    3. compute 方法,意思是允许把存在的key的 key 和 value 的值拿出来进行计算后,再 put 到 map 中,为防止 key 值不存在造成未知错误,map 还提供了 computeIfPresent 方法,表示只有在 key 存在的时候,才执行计算,demo 如下:
@Test
public void compute(){
    HashMap<Integer,Integer> map = Maps.newHashMap();
    map.put(10,10);
    log.info("compute 之前值为:{}",map.get(10));
    map.compute(10,(key,value) -> key * value);
    log.info("compute 之后值为:{}",map.get(10));
    // 还原测试值
    map.put(10,10);

    // 为了防止 key 不存在时导致的未知异常,我们一般有两种办法
    // 1:自己判断空指针
    map.compute(11,(key,value) -> null == value ? null : key * value);
    // 2:computeIfPresent 方法里面判断
    map.computeIfPresent(11,(key,value) -> key * value);
    log.info("computeIfPresent 之后值为:{}",map.get(11));
}
结果是:
compute 之前值为:10
compute 之后值为:100
computeIfPresent 之后值为:null(这个结果中,可以看出,使用 computeIfPresent 避免了空指针)

面试题:线程安全吗?

HashMap 是非线程安全的,7里面会循环链表,8里面比如两个线程同时插入key为null的元素,会前者的覆盖掉。我们可以:

  • 自己在外部加锁,
  • 使用Hashtable
  • 通过Map m = Collections.synchronizedMap(new HashMap(...)); 来实现。
  • 通过CHM来实现

java.util.concurrent.ConcurrentHashMap

面试题:什么是Unsafe

Unsafe简介

Unsafe类相当于是一个java语言中的后门类,提供了硬件级别的原子操作,所以在一些并发编程中被大量使用。jdk已经作出说明,该类对程序员而言不是一个安全操作,在后续的jdk升级过程中,可能会禁用该类。所以这个类的使用是一把双刃剑,实际项目中谨慎使用,以免造成jdk升级不兼容问题。

Unsafe Api

image.png

  • arrayBaseOffset:获取数组的基础偏移量
  • arrayIndexScale:获取数组中元素的偏移间隔,要获取对应所以的元素,将索引号和该值相乘,获得数组中指定角标元素的偏移量
  • getObjectVolatile:获取对象上的属性值或者数组中的元素
  • getObject:获取对象上的属性值或者数组中的元素,已过时
  • **putOrderedObject**:设置对象的属性值或者数组中某个角标的元素,更高效,使用较多
  • putObjectVolatile:设置对象的属性值或者数组中某个角标的元素
  • putObject:设置对象的属性值或者数组中某个角标的元素,已过时

代码演示

public class Test02 {

    public static void main(String[] args) throws Exception {
        Integer[] arr = {2,5,1,8,10};

        //获取Unsafe对象,路面是反射方式获取
        Unsafe unsafe = getUnsafe();
        //获取Integer[]的基础偏移量
        int baseOffset = unsafe.arrayBaseOffset(Integer[].class);
        //获取Integer[]中元素的偏移间隔
        int indexScale = unsafe.arrayIndexScale(Integer[].class);

        //获取数组中索引为2的元素对象
        Object o = unsafe.getObjectVolatile(arr, (2 * indexScale) + baseOffset);
        System.out.println(o); //1

        //设置数组中索引为2的元素值为100
        unsafe.putOrderedObject(arr,(2 * indexScale) + baseOffset,100);

        System.out.println(Arrays.toString(arr));//[2, 5, 100, 8, 10]

        // cas 拿着2号角标的值,判断是否是1,如果是设置为101,并且返回true
        boolean b = unsafe.compareAndSwapObject(arr, (2 * indexScale) + baseOffset, 100, 101);
        System.out.printin (b);
        System.out.printin (Arrays. toString(arr));
    }

    //反射获取Unsafe对象
    public static Unsafe getUnsafe() throws Exception {
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        return (Unsafe) theUnsafe.get(null);
    }
}

面试题:简单说说 CHM 的实现原理是什么?

CHM 和HashMap 在设计上有很多相似之处:

  • 使用哈希表存放键值对。数组中每个元素是一个哈希桶。
  • 使用键值对的key的哈希值对数组长度取模,得到该键值对对应的哈希桶下标。CHM中使用的是key的优化后的hash值对数组长度 n-1取逻辑与操作得到。
  • 当数组长度小于64的时候,如果哈希桶内的元素个数因为哈希碰撞达到了8个元素,则通过扩容解决哈希碰撞。当数组长度达到64,并且哈希桶元素个数大于等于8的时候,将链表转换为红黑树。
  • 扩容后如果哈希桶内元素个数小于等于6的时候,红黑树转换为链表。

CHM 不同版本在处理并发上有不同的实现思路:

  • JDK 7 使用分段锁Segment(继承了 可重入锁ReentrantLock)支持并发,并发度是哈希数组的长度。相当于是给每一个桶加了一把锁。
  • JDK 8 减小了锁的粒度,使用synchronized 加锁,提高了并发度,内存占用也更少了。
  • 读是不需要加锁的,只有写的时候才需要加锁。

CHM 为什么不继承 HashMap 实现?

ConcurrentHashMap 都是在方法中间进行一些加锁操作,也就是说加锁把方法切割了,继承就很难解决这个问题。

面试题:CHM 和 HashMap 的相同点和不同点?

  • 相同点:
    • 数组 + 链表 +红黑树的数据结构,所以基本操作的思想相同;
    • 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以两者的方法大多都是相似的,可以互相切换。
  • 不同点:
    1. 红黑树结构略有不同,HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁;
    2. 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全。
    3. 不允许空的值和键,都会抛异常

面试题:CHM 7 和 8 的主要区别?

数据类型结构不同

  • JDK7:ConcurrentHashMap 就是 Segment数组,数组元素内部包含一个 HashEntry 数组组成。每一个 HashEntry 数组的元素都是一个链表。
    • segment锁继承了ReentrantLock ,每个 Segment守护一个 HashEntry 数组里的元素,当对 HashEntry数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
    • 并发程度是 segment的个数来决定的,也就是Segment[]的数组长度。并发度一旦初始化无法扩容。concurrencyLevel:并发度,默认16。
    • 问题:如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
     transient volatile HashEntry<K,V>[] table; //可以理解为包含一个HashMap 
}
  • JDK8:ConcurrentHashMap由普通数组组成,类似HashMap,没有复杂的数据结构。

初始化容量不同

  • 1.7 里面 ConcurrentHashMap 中保存了一个默认长度为16的Segment[],每个Segment元素中保存了一个默认长度为2的HashEntry[],我们添加的元素,是存入对应的Segment中的HashEntry[]中。所以ConcurrentHashMap中默认元素的长度是32个,而不是16个
    • 初始化时候传入多少就是多少,但是8里面不是这样的
//initialCapacity 定义ConcurrentHashMap存放元素的容量
//concurrencyLevel 定义ConcurrentHashMap中Segment[]的大小
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
   
    int sshift = 0;
    int ssize = 1;
    //计算Segment[]的大小,保证ssize是2的幂次方数
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1; // 每次都是乘2,左移1位
    }
    //这两个值用于后面计算Segment[]的角标
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;
    
    //计算每个Segment中存储元素的个数
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    //最小Segment中存储元素的个数为2
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    ////矫正每个Segment中存储元素的个数,保证是2的幂次方,最小为2
    while (cap < c)
        cap <<= 1;
    //创建一个Segment对象,作为其他Segment对象的模板
    Segment<K,V> s0 =
        new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    //利用Unsafe类,将创建的Segment对象存入0角标位置
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}
  • jdk8的ConcurrentHashMap的数组初始化是在第一次添加元素时完成,但是默认的容量就是16。
    • 全局的成员变量 **sizeCtl**的含义(重要)
      • 0 的时候(空参数构造),代表是没有初始化的,默认的容量就是16
      • 正数的时候(new 的时候有参数),如果数组 没有初始化,记录的是数组的初始容量,如果数组已经初始化了,记录的是数组的扩容阈值(数组 的容量 * 0.75)。
      • -1的时候,正在初始化(多线程)
      • 小于0不是-1,记录的是正在扩容的线程的个数,-(1+n),在1.8里面是有协作扩容的操作的。n个线程正在扩容。
    • 初始容量是传递的值吗?
      • 不是,注意这里计算的时候传递的参数是1.5倍+1,再计算对应值的2的幂,所以是传入参数的高一个2的幂作为最后的容量。最后赋值给sizeCtl,16是32,17也是32,32 是64。32+(16+1)调用tableSizeFor,变为2的幂image.png

线程安全的实现方式不同

拿 put 方法为例,Java 7 把数组进行分段,**找到当前 key 对应的一段;将当前段锁住,然后再比对寻找对应的值,进行赋值操作。**做法比较简单,锁住的这一段数据可能会有很多,导致这一段的其他数据都不能进行写入操作,大大的降低了并发性的效率。

Java 8 解决了这个问题,从锁住某一段,修改成锁住某一个槽点,提高了并发效率。不仅仅是 put,删除也是,仅仅是锁住当前槽点,缩小了锁的范围,增大了效率。

get 方式不同

  • JDK7中,get 通过hash找到对应的segment,继续通过 hash 在HashEntry数组找到对应的 链表桶,然后就是遍历这个链表看是否可以找到,并且要注意 get的时候是没有加锁。
  • JDK8中,get 先获取数组的下标,然后判断数组下标的 key 是否和我们的 key 相等,相等的话直接返回,如果下标的槽点是链表或红黑树的话,分别调用相应的查找数据的方法,整体思路和 HashMap 很像。

put 方式不同

  • JDK7中:
    • put 通过hash找到对应的segment**((hash >>> segmentShift) & segmentMask,相当于是取了最高四位作为角标位),然后看该segment位置是否初始化了(因为segment是懒加载模式)。选择性初始化(初始化的时候使用 双重检查 + 自旋+ CAS 来实现线程安全),最终执行put操作(此时需要tryLock and unlock操作当前的Segment,使用**(tab.length - 1) & hash** 计算当前的HashEntry的索引,也就是key的高位计算Segment的index,低位计算HashEntry的index)**
    • **获得不到锁也会进行自旋的tryLock或者lock阻塞排队进行等待(同时获得锁前提前new出新数据),**调用scanAndLockForPut,完成查找或新建节点的工作。当获取到锁后直接将该节点加入链表即可,提升了put操作的性能,这里涉及到自旋。
  • JDK8中:
    1. 如果数组为空,初始化。初始化通过自旋 + CAS + 双重 check(之前的线程已经完成了初始化自己才进来就没必要了) 等手段保证了数组初始化时的线程安全。
    2. 初始化完成之后,计算当前槽点有没有值,没有值的话,CAS 创建,失败继续自旋(for 死循环)
    3. 槽点有值的话,并且是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增,
    4. 槽点有值的话,并且不是转移节点,先锁定当前槽点,保证其余线程不能操作,如果是链表(hash值>=0),新增值到链表的尾部,如果是红黑树,使用红黑树新增的方法新增;
    5. 新增完成之后 check 需不需要链表转换为红黑树
    6. addCount 维护集合的长度 size
面试题:JDK8 put 数组初始化时的线程安全(重要)

通过自旋 + CAS + 双重 check(之前的线程已经完成了初始化自己才进来就没必要了) 等手段保证了数组初始化时的线程安全。注意最后的finally,所写的,sizeCtl初始化结束之后,记录的是阈值。 image.png

面试题:JDK8 put 新增值时的线程安全

**自旋 + CAS** 或者 **锁**。通过自旋死循环保证一定可以新增成功。在新增之前,通过 for (Node<K,V>[] tab = table;;) 这样的死循环来保证新增一定可以成功,一旦新增成功,就可以退出当前死循环,新增失败的话,会重复新增的步骤,直到新增成功为止。

  1. 当前槽点为空时,通过 CAS 新增。在判断槽点为空和赋值的瞬间,很有可能槽点已经被其他线程赋值了,当前 CAS 操作失败,for 自旋,再走槽点有值的 put 流程,这里就是 自旋 + CAS 的结合。
  2. 当前槽点有值,此时槽点上可能是链表或红黑树,锁住当前槽点(锁住也是为了避免其他线程扩容冲突)来保证同一时刻只会有一个线程能对槽点进行修改。

image.png 注意这里是当前桶的第一个节点对应的的对象为锁。 image.png

面试题:JDK8 CHM 如何保证扩容安全?**addCount**** -> ****transfer**

ConcurrentHashMap 扩容的方法叫做 **transfer**,从 put 方法的 **addCount** 方法进去,就能找到 transfer 方法。

**addCount**** 方法中:**

  • 检测到当前节点是转移节点,会协助扩容。对于第一个线程就是直接转移扩容。

transfer 方法的主要思路是:

  1. 当前数组会被切分几个小的任务块,方便不同线程一起扩容。任务块的数量和CPU的个数有关系。
  2. 给线程分配任务,开始拷贝,判断自己和其他线程的扩容是否完成
  3. 从数组的队尾开始拷贝;
  4. 从数组的尾部拷贝到头部,先把原数组槽点锁住每拷贝成功一次,就把原数组的槽点设置成转移节点,其Hash值就是MOVED;如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在扩容完成之前,该槽点对应的数据是不会发生变化的。
  5. 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。之前等待 put 的数据才能继续 put。

扩容方式不同

  • 1.7 里面,发现当前个数大于Segment内部HashEntry阈值的时候,先扩容原来的二倍新数组, 并且新位置要么是原始值,要么加上原本的长度。部分是直接迁移。其余都是创建新的节点计算位置,采用头插法,将新元素加入到数组中。
  • 1.8里面,多线程协助扩容。从尾部往前依次分段分任务迁移。
面试题:协助扩容的时机

添加的时候发现是迁移节点就帮助扩容;addCount计数的时候也会协助扩容

获取 size 方式不同

  • JDK 7 中是先无锁的统计下所有的数据量看下前后两次是否数据一样,如果一样则返回数据,如果不一样(无锁重试4次)则要把全部的segment进行加锁,统计,解锁。所以至少会比较两次,最多五次。并且size方法只是返回一个统计性的数字,因此size谨慎使用哦。
  • **addCount()**** 方法内部,如果可以CAS加 BaseCOUNT 成功就返回,如果失败了,就要给**CounterCell[]中的某一个位置的值自旋 + CAS去累加(没有CounterCell的话先创建),失败的话就算一个新的位置累加,最后返回size的时候就把BaseCOUNT和CounterCell[]数组中的所有的值累加返回即可。

面试题:总结下 CHM 如何保证线程安全?

  1. 储存 Map 数据的数组被 **volatile** 关键字修饰,一旦被修改,立马就能通知其他线程,因为是数组,所以需要改变其内存值,才能真正的发挥出 volatile 的可见特性;
  2. put 时,如果计算出来的数组下标索引没有值的话,采用**无限 for 循环 + CAS 算法**,来保证一定可以新增成功,又不会覆盖其他线程 put 进去的值(HashMap JDK8 就是这里就无法保证安全)
  3. 当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 put 对应桶正好在扩容,也就是有**转移节点**,会等待或者协助扩容完成之后,再进行 put ,保证了在扩容时,老数组的值不会发生变化;
  4. 对数组的槽点进行操作时,会**先锁住槽点**,保证只有当前线程才能对槽点上的链表或红黑树进行操作;
  5. 对红黑树旋转时,会锁住根节点,保证旋转时的线程安全。

描述一下 CAS 算法在 ConcurrentHashMap 中的应用?

CAS 是一种乐观锁,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。

一般有三个值:赋值对象,原值,新值。在执行的时候,会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都是原子性操作,没有线程安全问题。

ConcurrentHashMap 的 put 方法中,CAS+ for 循环:

  1. 计算出数组索引下标,拿出下标对应的原值;
  2. CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出循环,否则不赋值,转到 3;
  3. 进行下一次 for 循环,重复执行 1,2,直到成功为止。

java.util.Hashtable

实现原理和 HashMap 相同,底层都是哈希表结构

  • Hashtable是早期JDK提供,HashMap是新版JDK提供
  • Hashtable继承Dictionary类,HashMap实现Map接口
  • **Hashtable 线程安全,HashMap线程非安全。**Hashtable中的所有对用户的方法都用synchronized关键字上了锁(因此线程安全)。HashMap没有做任何控制。
  • **Hashtable 不允许null值,HashMap允许null值 **
  • Hashtable 命运和Vector是一样的,从JDK1.2开始,被更先进的HashMap取代
  • Hashtable 他的子类 Properties 依然活跃在开发舞台
public class HashtableDemo {
    public static void main(String[] args) {	
        Map<String,String> map = new Hashtable<String,String>();
        map.put(null, null);
        System.out.println(map);
    }
}

java.util.LinkedHashMap

保证迭代的顺序。使用的是双向链表。

源码剖析

双向链表结构定义

每一个 Map 都维护一个双向链表,也就是新增头节点、尾节点。新增时都把节点追加到尾节点。给每个节点增加 before、after 属性。 image.png

put 如何按照顺序新增

插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用 newNode/newTreeNode afterNodeAccess 方法。newNode/newTreeNode 方法,控制新增节点追加到LinkedHashMap内部维护的链表的尾部,这样每次新节点都追加到尾部,即可保证插入顺序了。

多态的好处:HashMap 将这些 newNode/newTreeNode 和 afterNodeAccess 方法 封装,就是为了子类直接改写,而不需要写重复的调用逻辑,使用时候子类没有使用父类,子类有就用子类。

下面两个方法会被父类HashMap的putVal方法调用 image.png

迭代器按照顺序访问

LinkedHashMap 只提供了单向访问,即按照插入的顺序从头到尾进行访问,不能像 LinkedList 那样可以双向访问。主要通过迭代器进行访问,迭代器初始化的时候,默认从头节点开始访问,在迭代的过程中,不断访问当前节点的 after 节点即可。 LinkedHashMap.entrySet().iterator() 返回 LinkedHashIterator 迭代器,调用迭代器的 nextNode 方法就可以得到下一个节点,迭代器的源码如下: image.png

LRU 动态维护内部链表

最近最少访问删除策略

LinkedHashMap 最早加入的在内部的链表的头部,所以删除的时候也是从头部删除,添加的时候追加。

经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后我们可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除:覆写 removeEldestEntry 方法,设定删除策略自动删除头节点

如果 map 中元素个数大于 3 时,我们就把队头的元素删除,当 put(1, 1) 执行的时候,正好把队头的 10 删除, 当我们调用 map.get(9) 方法时,元素 9 移动到队尾,调用 map.get(20) 方法时, 元素 20 被移动到队尾,这个体现了经常被访问的节点会被移动到队尾。

public void testAccessOrder() {
    // 新建 LinkedHashMap
    // true 按照访问顺序排列,每次访问都会将被访问放到队尾
    // LRU
    LinkedHashMap<Integer, Integer> map = 
        new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
        {
            put(10, 10);
            put(9, 9);
            put(20, 20);
            put(1, 1);
        }
        @Override
        // 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
        protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
            return size() > 3;
        }
    };
    log.info("初始化:{}",JSON.toJSONString(map));
    Assert.assertNotNull(map.get(9));
    log.info("map.get(9):{}",JSON.toJSONString(map));
    Assert.assertNotNull(map.get(20));
    log.info("map.get(20):{}",JSON.toJSONString(map));
}

打印出来的结果如下:

初始化:{9:9,20:20,1:1}
map.get(9):{20:20,1:1,9:9}
map.get(20):{1:1,9:9,20:20}
频繁访问元素的位置维护

get 元素被转移到队尾:afterNodeAccess 方法把当前访问节点移动到了队尾,执行 get、getOrDefault、compute、computeIfAbsent、computeIfPresent、merge 方法时,也会这么做,通过不断的把经常访问的节点移动到队尾,那么靠近队头的节点,自然就是很少被访问的元素了。 image.png

删除策略的调用

LinkedHashMap 实现了 put 方法中的调用 afterNodeInsertion 方法,这个方式实现了删除超出删除策略的节点: image.png

java.util.TreeMap

完全的红黑树

TreeMap 利用了红黑树左节点小,右节点大的性质,根据 key 进行排序,使每个元素能够插入到红黑树大小适当的位置,维护了 key 的大小关系,适用于 key 需要排序的场景。

因为底层使用的是平衡红黑树的结构,所以 containsKey、get、put、remove 等方法的时间复杂度都是 log(n)。

源码剖析

节点定义

红黑树,节点结构: image.png

类定义

public class TreeMap<K, V> implements NavigableMap<K, V> {
    private final Comparator<? super K> comparator;//外部比较器
    private transient Entry<K, V> root = null; //红黑树根节点的引用
    private transient int size = 0;//红黑树中节点的个数
    public TreeMap() {
        comparator = null;//没有指定外部比较器
    }
    public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;//指定外部比较器
    }
}

添加原理 

插入流程:

  1. 如果红黑树根节点为空,新增节点直接作为根节点
  2. 优先使用外部比较器Comparator,然后再使用自己的Comparable
  3. 找到自己应该挂在红黑树那个节点,通过比较大小即可。红黑树左边小,右边大,如果key比较相等的话,直接赋值原来的值
  4. 新建节点到 3 找到的父节点上
  5. 着色旋转

平衡离不开比较:外部比较器优先,然后是内部比较器。如果两个比较器都没有,就抛出异常

public V put(K key, V value) {
    Entry<K,V> t = root;
    //如果是添加第一个节点,就这么处理
    if (t == null) {
        //即使是添加第一个节点,也要使用比较器
        compare(key, key); // type (and possibly null) check
        //创建根节点
        root = new Entry<>(key, value, null);
        //此时只有一个节点
        size = 1;
        return null;
    }
    //如果是添加非第一个节点,就这么处理
    int cmp;
    Entry<K,V> parent; 
    Comparator<? super K> cpr = comparator;
    //如果外部比较器存在,就使用外部比较器
    if (cpr != null) {
        do {
            parent = t;
            cmp = cpr.compare(key, t.key);
            if (cmp < 0)
                t = t.left;//在左子树中查找
            else if (cmp > 0)                
                t = t.right; //在右子树查找
            else
                //找到了对应的key,使用新的value覆盖旧的value                 
                return t.setValue(value);
        } while (t != null);
    }
    else {
        //如果外部比较器没有,就使用内部比较器
        ....
        }
    //找到了要添加的位置,创建一个新的节点,加入到树中
    Entry<K,V> e = new Entry<>(key, value, parent);
    if (cmp < 0)  
        parent.left = e;
    else
        parent.right = e;       
    size++;
    return null;
}

查询原理基本同添加

public V get(Object key) {
    //根据key(cn)找Entry(cn--China)
    Entry<K,V> p = getEntry(key);
    //如果Entry存在,返回value:China
    return (p==null ? null : p.value);
}

final Entry<K, V> getEntry(Object key) {
    //如果外部比较器存在,就使用外部比较器
    if (comparator != null)
        return getEntryUsingComparator(key);
    if (key == null)
        throw new NullPointerException();
    @SuppressWarnings("unchecked")
    //如果外部比较器不存在,就使用内部比较器
    Comparable<? super K> k = (Comparable<? super K>) key;
    Entry<K, V> p = root;
    while (p != null) {
        int cmp = k.compareTo(p.key);
        if (cmp < 0)
            p = p.left;
        else if (cmp > 0)
            p = p.right;
        else
            //如果找到了,就返回Entry
            return p;
    }
    //如果没有找到,就返回null
    return null;
}

java.utils.Collections 工具类

关于集合操作的工具类,好比Arrays,Math 。唯一的构造方法private,不允许在类的外部创建对象。提供了大量的static方法,可以通过类名直接调用。

常用集合操作api

  • public static <T> boolean ``addAll``(Collection<T> c, T... ``elements``):往集合中添加一些元素。
  • public static void ``shuffle``(List<?> list) 打乱顺序:打乱集合顺序。
  • public static <T> void ``sort``(List<T> list):将集合中元素按照默认规则排序。
  • public static <T> void ``sort``(List<T> list,Comparator<? super T> ):将集合中元素按照指定规则排序。
//调用工具类方法shuffle对集合随机排列
Collections.shuffle(list);
System.out.println(list);

/*
  	 * Collections.binarySearch静态方法
  	 * 对List集合进行二分搜索,方法参数,传递List集合,传递被查找的元素
  	 */
int index = Collections.binarySearch(list, 16);
System.out.println(index);

/*
  	 *  Collections.sort静态方法
  	 *  对于List集合,进行升序排列
  	 */

Collections.sort(list);
System.out.println(list);

//添加元素
List<Integer> list = new ArrayList();
Collections.addAll(list, 10, 50, 30, 90, 85, 100);//6
System.out.println(list);

//获取最大值和最小值
int max = Collections.max(list);
int min = Collections.min(list);
System.out.println(max + "   " + min);

//填充集合
Collections.fill(list, null);
System.out.println(list);

//复制集合
List list2 = new ArrayList();
Collections.addAll(list2, 10, 20, 30, 50);
System.out.println(list2);
Collections.copy(list, list2);//dest.size >= src.size  目标列表的长度至少必须等于源列表。
System.out.println(list);

//同步集合
//StringBuffer 线程安全效率低 StringBuilder 线程不安全,效率高
//Vector 线程安全  效率低  ArrayList 线程不安全,效率高
//难道是要性能不要安全吗,肯定不是。在没有线程安全要求的情况下可以使用ArrayList
//如果遇到了线程安全的情况怎么办
//方法1:程序员手动的将不安全的变成安全的   
//方法2:提供最新的线程安全并且性能高的集合类
List list3 = new ArrayList();
Collections.addAll(list3, 10, 90, 30, 40, 50, 23);
System.out.println(list3);
//将list3转换成线程安全的集合类
list3 = Collections.synchronizedList(list3);
//下面再操作,就线程安全了

如果想要规则更多一些,可以参考下面代码:

Collections.sort(list, new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        // 年龄降序
        int result = o2.getAge()-o1.getAge();//年龄降序

        if(result==0){//第一个规则判断完了 下一个规则 姓名的首字母 升序
            result = o1.getName().charAt(0)-o2.getName().charAt(0);
        }

        return result;
    }
});

并发安全的集合操作

synchronized开头的方法,传入不安全的集合实例,返回对应类型的安全的集合实例。 image.png 返回的安全集合的方法都有synchronized修饰,使用的锁是 静态类SynchronizedCollection 的对象锁,所以相当于是全局唯一的,只要使用了放前方法获取的集合,就是串行的,导致效率是比较低的。 image.png

获取不可变集合

image.png image.png

java.util.Iterator

Iterator专门为遍历集合而生,集合并没有提供专门的遍历的方法,Iterator实际上迭代器设计模式的实现。Iterator接口也是Java集合中的一员,但它与CollectionMap接口有所不同,Collection接口与Map接口主要用于存储元素,而Iterator主要用于迭代访问(即遍历)Collection中的元素,因此Iterator对象也被称为迭代器。

基本使用

想要遍历Collection集合,那么就要获取该集合迭代器完成迭代操作

  • public Iterator iterator(): 获取集合对应的迭代器,用来遍历集合中的元素的。

Iterator的常用方法

  • boolean hasNext(): 判断是否存在另一个可访问的元素
  • Object next(): 返回要访问的下一个元素
  • void remove(): 删除上次访问返回的对象。

for-each循环和Iterator的联系

  • for-each循环(遍历集合)时,底层使用的是Iterator
  • 凡是可以使用for-each循环(遍历的集合),肯定也可以使用Iterator进行遍历

for-each循环和Iterator的区别

  • for-each还能遍历数组,Iterator只能遍历集合
  • 使用for-each遍历集合时不能删除元素,会抛出异常ConcurrentModificationException。但是使用Iterator遍历合时能删除元素。所以在任意循环删除的场景下,都建议使用迭代器进行删除;

Iterator是一个接口,它的实现类在哪里?

  • 在相应的集合实现类中 ,比如在ArrayList中存在一个内部了Itr implements Iterator

为什么Iterator不设计成一个类,而是一个接口?

  • 不同的集合类,底层结构不同,迭代的方式不同,所以提供一个接口,让相应的实现类来实现

在进行集合元素取出时,如果集合中已经没有元素了,还继续使用迭代器的next方法,将会发生java.util.NoSuchElementException没有集合元素的错误。

迭代器的实现原理

Iterator迭代器对象在遍历集合时,内部采用指针的方式来跟踪集合中的元素。在调用Iterator的next方法之前,迭代器的索引位于第一个元素之前,不指向任何元素,当第一次调用迭代器的next方法后,迭代器的索引会向后移动一位,指向第一个元素并将该元素返回,当再次调用next方法时,迭代器的索引会指向第二个元素并将该元素返回,依此类推,直到hasNext方法返回false,表示到达了集合的末尾,终止对元素的遍历。

//cursor记录的索引值不等于集合的长度返回true,否则返回false
public boolean hasNext() {       
    return cursor != size; //cursor初值为0
}
//next()方法作用:
//①返回cursor指向的当前元素 
//②cursor++
public Object next() {            
    int i = cursor; 
    cursor = i + 1;  
    return  elementData[lastRet = i]; 
}

// for循环迭代写法
for (Iterator<String> it2 = coll.iterator(); it2.hasNext();  ) {
    System.out.println(it2.next());
}

增强for循环

JDK1.5版本后。出现新的接口 java.lang.Iterable。Collection 继承 Iterable,实现增强for循环。 它的内部原理其实是个Iterator迭代器,所以在遍历的过程中,不能对集合中的元素进行增删操作。

数组和集合都是可以的

public static void function_1(){
    // 数组和集合都是可以的
    String[] str = {"aaa","ddd","eee"};
    for(String s : str){
        System.out.println(s.length());
    }
}

java.util.ListIterator接口

ListIterator和Iterator的关系

  • public interface ListIterator extends Iterator
  • 都可以遍历List

ListIterator和Iterator的区别

  • 使用范围不同
  • Iterator可以应用于更多的集合,Set、List和这些集合的子类型。
  • ListIterator只能用于List及其子类型。

遍历顺序不同

  • Iterator只能顺序向后遍历; ListIterator还可以逆序向前遍历 hasPrevious() 、previous()
  • Iterator可以在遍历的过程中remove();ListIterator可以在遍历的过程中remove()、add()、set()
  • ListIterator可以定位当前的索引位置,nextIndex()和previousIndex()可以实现。Iterator没有此功能。

java.util.concurrent.BlockingQueue

核心就是 锁 + Condition。和AQS 挺像的,这里的锁包含了同步队列,条件队列用Condition自己实现。

Queue 接口三大类操作

抛异常特殊值一直阻塞阻塞一段时间
新增操作–队列满add**offer** 返回 falseputoffer 过超时时间返回 false
查看并删除操作–队列空remove**poll** 返回 nulltakepoll 过超时时间返回 null
只查看不删除操作–队列空element**peek** 返回 null

队列有哪些使用场景,如何选择?

线程池、读写锁、消息队列等,底层原理都是队列:

  • 数据时高时低的时候:linkedBlockingQueue
  • 数据固定:ArrayBlockingQueue
  • 延迟执行:DelayQueue

面试题:队列和其他集合的区别是什么?

  • 和集合的相同点,队列(部分例外)和集合都提供了数据存储的功能,底层的储存数据结构是有些相似的
  • 和集合的区别:
    1. 为了完成不同的事情,提供的 API 和其底层的操作实现是不同的。
    2. 队列提供了阻塞的功能,
    3. 解耦了生产者和消费者

面试题:不同队列的底层数据结构有什么不同?

  • LinkedBlockingQueue 的底层是链表:初始大小默认是 Integer 的最大值,也可以设置初始大小;新增是从链表的尾部新增,拿是从链表头开始拿。
  • ArrayBlockingQueue的底层是数组,容量大小是固定的,不能动态扩容。有 takeIndex 和 putIndex 两个索引记录下次拿和新增的位置,当 takeIndex 和 putIndex 到达数组的最后一个位置时,下次都是从 0 开始循环。每次 take 和 put 完成之后,都会往后加一,虽然底层是数组,但和 HashMap 不同,并不是通过 hash 算法计算得到的。
  • SynchronousQueue 有着两种数据结构,分别是队列,队列保证了先入先出的数据结构,体现了公平性。栈是先入后出的数据结构,是不公平的,但性能高于先入先出。

面试题:哪些队列具有阻塞的功能,阻塞的类型有何不同?如何实现?

  1. LinkedBlockingQueue 和 ArrayBlockingQueue 阻塞队列是一类,前者容量是 Integer 的最大值,后者数组大小固定,两个阻塞队列都可以指定容量大小,当队列满时,如果有线程 put 数据,线程会阻塞住,直到有其他线程进行消费数据后,才会唤醒阻塞线程继续 put当队列空时,如果有线程 take 数据,线程会阻塞到队列不空时,继续 take。
  2. SynchronousQueue 同步队列,当线程 put 时,必须有对应线程把数据消费掉,put 线程才能返回当线程 take 时,需要有对应线程进行 put 数据时,take 才能返回,反之则阻塞。
  3. 队列本身并没有实现阻塞的功能,而是利用 Condition 的等待唤醒机制,阻塞底层实现就是更改线程的状态为沉睡

面试题:如何保证队列修改数据时候的线程安全?

  • LinkedBlockingQueue 在 put 的时候会对队尾加上put锁。take的时候会对队头加上take锁,因为take完数据会被删除,需要加锁保证安全。**注意两者的锁是不一样的,所以两者互不影响,可以同时进行的。**remove 的时候,会同时加 put 和 take 锁。
  • ArrayBlockingQueue 的** put 和 take 是同一个锁,所以同一时刻只能运行一个方法。**

面试题:高并发下,代码中使用队列 put、take 方法有什么问题?

当队列满时,使用 put 方法,会一直阻塞到队列不满为止。当队列空时,使用 take 方法,会一直阻塞到队列有数据为止。大流量时,导致机器没有线程可用,**所以最好使用 offer 和 poll 方法来代替,因为可以设置超时阻塞时间,会返回默认值。**这种情况普通测试是无法发现的,只有压测才能发现。

java.util.concurrent.LinkedBlockingQueue

可以使用 Collection 和 Iterator 两个接口的所有操作,因为实现了两者的接口。

基本实现原理

  • 链表结构定义:定义单向链表节点Node,节点类型是一个泛型,被应用到线程池时,节点就是线程,比如队列被应用到消息队列中,节点就是消息。
  • 容量默认值为 Integer.MAX_VALUE,已有数据的size大小用 AtomicInteger 表示。
  • take 和 put 是两把不同的ReentrantLock,每一把锁都会有自己的Condition,就相当于AQS中阻塞之后的条件队列
  • 自己内部实现了迭代器。

面试题:LinkedBlockingQueue空了 take 与 poll 怎么处理?

take就阻塞到条件队列里面, poll就根据等待时间是否为0直接返回或者等待固定时间,然后不为空的时候首先是唤醒自己条件队列中阻塞的消费线程。再唤醒放数据的条件队列的阻塞线程。 image.png

面试题:LinkedBlockingQueue满了 put与offer 怎么处理?

put阻塞,offer是直接返回false,或者等待一段时间返回false。阻塞的底层使用是锁的能力Condition,都会调用条件队列 condition的 await方法,只是 put 内部没有传递过期时间,offer 可以提供条件等待的时间,如果超时直接返回false。注意也是先唤醒自己放数据的条件队列的线程,最后唤醒取数据的条件队列等待的线程。 image.png

面试题:peek 操作和take一样也要加锁吗?

是的,为了防止读取数据的时候有并发问题。被修改了就会读取到其他的数据。所以也会加上一把take锁。

java.util.concurrent.ArrayBlockingQueue

基本实现原理

不能够动态扩容的,如果队列满了或者空时,take 和 put 都会被阻塞。使用**Object[]**数组实现,当 takeIndex、putIndex 到队尾的时候,都会重新从 0 开始循环,if (++putIndex == items.length) putIndex = 0;。只有一把可重入锁,所以take和put是冲突的。但是有两个 Condition,也就是take 和 put 分别是不同的条件队列。

ArrayBlockingQueue 初始化的时候可以设置是否是公平。默认是不公平的,可以借助 ReentrantLock 实现公平还是非公平,随机唤醒还是按照顺序唤醒线程。 image.png image.png

java.util.concurrent.SynchronousQueue

像附带缓冲区的管程。管程就是等待被取走才可以返回,缓冲区是因为还附带了队列和栈这样的内部类来缓存阻塞的请求。其本身是没有容量大小,不能存放数据,所以不能迭代,和容量相关的操作都没有实现。

面试题:如何查看 SynchronousQueue 队列的大小?

实际上 SynchronousQueue 是没有容量的,无法查看其容量的大小,其内部的 size 方法都是写死的返回 0。

面试题:SynchronousQueue 底层数据结构怎么设计的?

底层有两种数据结构,分别是队列和堆栈。最先进去队列的元素会最先被消费,是公平的,而堆栈则是先入后出的顺序,不公平的。初始化的时候,传递是否公平boolean,决定是用队列还是栈实现,true是队列公平的,false、默认的就是不公平堆栈,堆栈的效率比队列更高。

堆栈和队列都有一个共同的接口,叫做 Transferer,有公平和不公平的实现类: image.png 该接口有个方法:transfer,会承担 take 和 put 的双重功能;栈和队列都实现了这个方法,都把自己的put和take合在了一起,这不算策略模式,策略模式是附加实现了一个策略器,这个是直接就继承了。 image.png

面试题:SynchronousQueue 中 take阻塞之后,put如何将数据传递给take?

1. 非公平栈实现的 transfer(put + take )

栈元素使用内部类**SNode**定义,内部的match 属性用来存放接收到数据,当有 take 被阻塞,会将对应的栈节点压栈。此时新线程执行 put 操作时,会把数据赋值给堆栈头的元素内部 match 属性,并唤醒当前阻塞线程(引用也定义在SNode中)。

首先判断是 put 还是 take ,之后判断当前栈里面阻塞的是put 还是 take 还是空的,

  • 如果栈顶是空的或者是相同的操作,判断栈顶操作有无设置超时时间,如果设置了超时时间并且已经超时,返回 null。否则直接新建一个match为空的节点,设置好自己的下一个节点,然后压进去,看看其它线程能否满足自己,不能满足则阻塞自己。可能栈顶一样但是栈下面有不一样的,就可以返回。阻塞的策略,并不是一上来就阻塞住,而是在自旋一定次数后,仍然没有其它线程来满足自己的要求时,才会真正的阻塞住。
  • 如果栈顶就是不同的操作,那么就把自己的操作包装为一个节点赋值给当前的栈顶的节点的match属性,之后唤醒节点的阻塞的线程。是put就返回成功,是take就返回刚刚赋的值

2. 公平队列实现的 transfer(put + take )

队列元素使用内部类**QNode**定义,内部的 item 属性用来存放接收到数据,没有match属性。

首先判断是 put 还是 take ,然后队尾部是旧的,需要被先处理的,队首是新来的,后处理,所以:

  • 如果和队尾的操作一样,就加到队首
  • 如果和队尾的操作不一样,就和队尾的搞一波唤醒赋值返回

java.util.concurrent.DelayQueue

**底层使用了排序和超时阻塞实现了延迟队列,排序使用的是 PriorityQueue 排序能力,超时阻塞使用得是锁的等待能力。**获取的是队列的首部的延时,队列顶部是时间最小的,越靠近队头,越早过期。他都不超时别人肯定不超时。只需要判断他是否超时即可。没有超时就等待。

队列不允许空元素。数据保存在内存中,所以定时的时间一般都是几秒内,如果定时的时间需要设置很久的话,可以考虑采取中间件实现。

DelayQueue 把 String 放到队列中可以么?

DelayQueue 有泛型定义的,必须是 Delayed 接口的子类才行。Delayed 本身又实现了 Comparable 接口,Delayed 接口用来定义超时时间,Comparable 接口用来排序。

DelayQueue 队列中的元素必须是实现 Delayed 接口和 Comparable 接口的,并覆写了 getDelay 方法和 compareTo 的方法才行,不然在编译时,编译器就会提醒元素必须强制实现 Delayed 接口。所以把 String 放到 DelayQueue 中是不行的,编译都无法通过。

内部实现借助优先级队列

优先级队列,并且是小堆,自己减去对方(结果是负数自己在前,正数自己在后)。compareTo 方法中实现过期时间和当前时间的差,这样越快过期的元素,计算出来的差值就会越小,就会越先被执行。 image.png

队列如何拿数据?

take 取数据时,如果发现有元素的过期时间到了,就能拿出数据来,如果没有过期元素,那么线程就会一直阻塞,直到队头的过期时间到了才会返回。阻塞等待的功能底层使用的是锁的能力, available.await();

take 方法是会无限阻塞,如果不想无限阻塞,可以尝试 poll 方法,设置超时时间,在超时时间内,队头元素还没有过期的话,就会返回 null。

使用案例

  • 新建队列的元素,如 DelayedDTO,实现 Delayed 接口,getDelay 方法中实现了现在离过期时间还剩多久的方法。
  • 定义队列元素的生产者,和消费者,对应着代码中的 Product 和 Consumer。
  • 对生产者和消费者就行初始化和管理,对应着我们的 main 方法。
public class DelayQueueDemo {
    @Data
    // 队列元素,实现了 Delayed 接口
    static class DelayedDTO implements Delayed {
        Long s;
        Long beginTime;
        public DelayedDTO(Long s,Long beginTime) {
            this.s = s;
            this.beginTime =beginTime;
        }
        @Override
        public long getDelay(TimeUnit unit) {
            // s 是延迟后的时间,
            return unit.convert(s - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }
        @Override
        // 通过每个元素的过期时间进行排序
        public int compareTo(Delayed o) {
            return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
        }
        public void run(){
            log.info("现在已经过了{}秒钟",(System.currentTimeMillis() - beginTime)/1000);
        }
    }
    
    // 队列消息的生产者
    static class Product implements Runnable {
        private final BlockingQueue queue;
        public Product(BlockingQueue queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            try {
                log.info("begin put");
                long beginTime = System.currentTimeMillis();
                // 延迟时间,开始时间
                queue.put(new DelayedDTO(System.currentTimeMillis() + 2000L,beginTime));//延迟 2 秒执行
                queue.put(new DelayedDTO(System.currentTimeMillis() + 5000L,beginTime));//延迟 5 秒执行
                queue.put(new DelayedDTO(System.currentTimeMillis() + 1000L * 10,beginTime));//延迟 10 秒执行
                log.info("end put");
            } catch (InterruptedException e) {
                log.error("" + e);
            }
        }
    }
    // 队列的消费者
    static class Consumer implements Runnable {
        private final BlockingQueue queue;
        public Consumer(BlockingQueue queue) {
            this.queue = queue;
        }
        @Override
        public void run() {
            try {
                log.info("Consumer begin");
                ((DelayedDTO) queue.take()).run();
                ((DelayedDTO) queue.take()).run();
                ((DelayedDTO) queue.take()).run();
                log.info("Consumer end");
            } catch (InterruptedException e) {
                log.error("" + e);
            }
        }
    }
    
    // demo 运行入口
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue q = new DelayQueue();
        DelayQueueDemo.Product p = new DelayQueueDemo.Product(q);
        DelayQueueDemo.Consumer c = new DelayQueueDemo.Consumer(q);
        new Thread(c).start();
        new Thread(p).start();
    }
}
打印出来的结果如下:
06:57:50.544 [Thread-0] Consumer begin
06:57:50.544 [Thread-1] begin put
06:57:50.551 [Thread-1] end put
06:57:52.554 [Thread-0] 延迟了2秒钟才执行
06:57:55.555 [Thread-0] 延迟了5秒钟才执行
06:58:00.555 [Thread-0] 延迟了10秒钟才执行
06:58:00.556 [Thread-0] Consumer end

源码中的BlockingQueue应用

队列和线程池的结合

一个固定大小10的线程池。一下子来了 100 个请求,这时候就需要队列把线程无法消化的数据放到队列中

  • LinkedBlockingQueue:newSingleThreadExecutor 和 newFixedThreadPool 都是固定的线程数,使用的LinkedBlockingQueue,都是用的Integer容量,不推荐使用:
    • 可能会OOM。
    • 避免接口超时,队列中排队的请求还没有执行完。
  • SynchronousQueue :**newCachedThreadPool 使用,**如果请求量大,而消费能力较差时,就会导致大量请求被夯住,慎重。
  • DelayedWorkQueue :**newScheduledThreadPool **和 **newSingleThreadScheduledExecutor **使用 DelayedWorkQueue 的延迟功能来实现定时执行线程池。新的延迟请求都先到队列中去,延迟时间到了,线程池自然就能从队列中拿出线程进行执行了。

队列和锁的结合

AQS里面 获取不到锁的线程都去等待队列。获取不到锁的线程都会到同步队列中去排队,当锁被释放后,同步队列中的线程就又开始去竞争锁。同步队列并没有使用现有的队列的 API 去实现,但底层的结构,思想和目前队列是一致的

自己实现一个队列

  • 数据结构的选择
  • 节点定义
  • 容量大小(使用原子数据)
  • 实际大小(使用原子数据)
  • 增加删除时候上锁
  • 节点删除时候设置为null帮助gc
  • 使用链表的时候还需要定义链表头和尾
@Slf4j
public class MyLinkedBlockingQueue<T> implements Queue<T> {

	private volatile Node<T> head;
	private volatile Node<T> tail;

	class DIYNode extends Node<T> {
		public DIYNode(T item) {
			super(item);
		}
	}

	private AtomicInteger size = new AtomicInteger(0);
	private final Integer capacity;

	private ReentrantLock putLock = new ReentrantLock();
	private ReentrantLock takeLock = new ReentrantLock();

	public DIYLinkedBlockingQueue() {
		capacity = Integer.MAX_VALUE;
		head = tail = new DIYNode(null);
	}

	public DIYLinkedBlockingQueue(Integer capacity) {
		if (null == capacity || capacity < 0) {
			throw new IllegalArgumentException();
		}
		this.capacity = capacity;
		head = tail = new DIYNode(null);
	}

	@Override
	public boolean put(T item) {
		if (null == item) {
			return false;
		}
		try {
			// 尝试加锁
			boolean lockSuccess = putLock.tryLock(300, TimeUnit.MILLISECONDS);
			if (!lockSuccess) {
				return false;
			}
			// 校验队列大小
			if (size.get() >= capacity) {
				log.info("queue is full");
				// 这里其实可以改为阻塞
				return false;
			}
			// 追加到队尾
			tail = tail.next = new DIYNode(item);
			size.incrementAndGet();
			return true;
		} catch (InterruptedException e) {
			log.info("put error", e);
			return false;
		} catch (Exception e) {
			log.error("put error", e);
			return false;
		} finally {
			putLock.unlock();
		}
	}

	@Override
	public T take() {
		// 队列是空的
		if (size.get() == 0) {
			return null;
		}
		try {
			// 拿数据我们设置的超时时间更短
			boolean lockSuccess = takeLock.tryLock(200, TimeUnit.MILLISECONDS);
			if (!lockSuccess) {
				throw new RuntimeException("加锁失败");
			}
			// 把头结点的下一个元素拿出来
			Node expectHead = head.next;
			// 把头结点的值拿出来
			T result = head.item;
			// 把头结点的值置为 null,帮助 gc
			head.item = null;
			// 重新设置头结点的值
			head = (DIYNode) expectHead;
			size.decrementAndGet();
			// 返回头结点的值
			return result;
		} catch (InterruptedException e) {
			log.info(" tryLock 200 timeOut", e);
		} catch (Exception e) {
			log.info(" take error ", e);
		} finally {
			takeLock.unlock();
		}
		return null;
	}
}

源码里面的复用技巧

LinkedHashMap 复用 HashMap 的能力,Set 复用 Map 的能力,还有此处的 DelayQueue 复用 PriorityQueue 的能力。

如果想要复用需要做到哪些:

  1. 需要把能遇见可复用的功能尽量抽象,并开放出可扩展的地方,比如说 HashMap 在操作数组的方法中,都给 LinkedHashMap 开放出很多 after 开头的方法,便于 LinkedHashMap 进行排序、删除等等;
  2. 采用组合或继承两种手段进行复用,比如 LinkedHashMap 采用的继承、 Set 和 DelayQueue 采用的组合,组合的意思就是把可复用的类给依赖进来。

集合面试综合类问题

hashCode和equals方法关系

  • 两个对象  Person  p1 p2

  • 问题: 如果两个对象的哈希值相同 p1.hashCode()==p2.hashCode(),两个对象的equals一定返回true吗

  • 正确答案:不一定。

  • 如果两个对象的equals方法返回true,p1.equals(p2)==true,两个对象的哈希值一定相同吗

  • 正确答案: 一定

在 Java 应用程序执行期间, 1.如果根据 equals(Object) 方法,两个对象是相等的,那么对这两个对象中的每个对象调用 hashCode 方法都必须生成相同的整数结果。 2.如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么对这两个对象中的任一对象上调用 hashCode 方法不 要求一定生成不同的整数结果。

所以说两个对象哈希值无论相同还是不同,equals都可能返回true

Arrays.asList(array) 报错

我们把数组转化成集合时,常使用 Arrays.asList(array),这个方法有两个坑:

  • 数组被修改后,会直接影响到新 List 的值。
  • 不能对新 List 进行 add、remove 等操作,否则运行时会报 UnsupportedOperationException 错误。
public void testArrayToList(){
    Integer[] array = new Integer[]{1,2,3,4,5,6};
    List<Integer> list = Arrays.asList(array);
    // 坑1:修改数组的值,会直接影响原 list
    log.info("数组被修改之前,集合第一个元素为:{}",list.get(0)); // 1
    array[0] = 10;
    log.info("数组被修改之后,集合第一个元素为:{}",list.get(0));  // 10
    // 坑2:使用 add、remove 等操作 list 的方法时,
    // 会报 UnsupportedOperationException 异常
    list.add(7);
}

Arrays.asList 方法返回的 List 并不是 java.util.ArrayList,而是自己内部的一个静态类,该静态类直接持有数组的引用,并且没有实现 add、remove 等方法,这些就是坑 1 和 2 的原因。

List.toArray 却得到了空数组

集合 List 转化成数组,我们通常使用 toArray 这个方法,这个方法很危险:

  • List<Integer> list2 = list.toArray(); 的无参方法,无法向下强转成具体类型,这个编译的时候,就会编译都无法通过
  • 我们一般都会去使用带有参数的 toArray 方法,这时就有一个坑,如果参数数组的大小不够,这时候返回的数组值竟然是空
  • 如果返回的数组大小和申明的数组大小一致,那么就会正常返回,否则,一个新数组就会被分配返回。所以我们在使用有参 toArray 方法时,申明的数组大小一定要大于等于 List 的大小,如果小于的话,你会得到一个空数组。
public void testListToArray(){
    List<Integer> list = new ArrayList<Integer>(){{
        add(1);
        add(2);
        add(3);
        add(4);
    }};
    // 下面这行被注释的代码这么写是无法转化成数组的,无参 toArray 返回的是 Object[],
    // 无法向下转化成 List<Integer>,编译都无法通过
    // List<Integer> list2 = list.toArray();

    // 演示有参 toArray 方法,数组大小不够时,得到数组为 null 情况
    Integer[] array0 = new Integer[2];
    list.toArray(array0);
    log.info("toArray 数组大小不够,array0 数组[0] 值是{},数组[1] 值是{},",array0[0],array0[1]);

    // 演示数组初始化大小正好,正好转化成数组
    Integer[] array1 = new Integer[list.size()];
    list.toArray(array1);
    log.info("toArray 数组大小正好,array1 数组[3] 值是{}",array1[3]);

    // 演示数组初始化大小大于实际所需大小,也可以转化成数组
    Integer[] array2 = new Integer[list.size()+2];
    list.toArray(array2);
    log.info("toArray 数组大小多了,array2 数组[3] 值是{},数组[4] 值是{}",array2[3],array2[4]);
}

19:33:07.687 [main] INFO demo.one.ArrayListDemo - toArray 数组大小不够,array0 数组[0] 值是null,数组[1] 值是null,
19:33:07.697 [main] INFO demo.one.ArrayListDemo - toArray 数组大小正好,array1 数组[3] 值是4
19:33:07.697 [main] INFO demo.one.ArrayListDemo - toArray 数组大小多了,array2 数组[3] 值是4,数组[4] 值是null

List、map、set的共同点

image.png

  1. add、remove、contanins、size 等方法的耗时性能,是不会随着数据量的增加而增加的,这个主要跟数组数据结构有关,不管数据量多大,不考虑 hash 冲突的情况下,时间复杂度都是 O (1);
  2. 线程不安全的,如果需要安全请自行加锁,或者使用 Collections.synchronizedSet;
  3. 迭代过程中,如果数据结构被改变,会快速失败的,会抛出 ConcurrentModificationException 异常。
  4. 复杂功能通过接口的继承来实现,比如 ArrayList 通过实现了 Serializable、Cloneable、RandomAccess、AbstractList、List 等接口,从而拥有了序列化、拷贝、对数组各种操作定义等各种功能;假设我们想再实现一个数据结构类,我们就可以从这些已有的能力接口中,挑选出能满足需求的能力接口,进行一些简单的组装,从而加快开发速度。

image.png

三代集合类发展过程

  1. 第一代线程安全集合类 Vector、Hashtable 使用synchronized修饰方法,但是效率低下
  2. 第二代线程非安全集合类(主流) ArrayList、HashMap 。线程不安全,但是性能好,用来替代Vector、Hashtable 。使用ArrayList、HashMap。需要线程安全怎么办呢? 使用 :Collections.synchronizedList(list)Collections.synchronizedMap(m); 方法底层使用synchronized代码块锁,但并发效率低,因为使用的锁是同一把static class的实例。
  3. 第三代线程安全集合类,在大量并发情况下依旧高效率和安全,底层大都采用Lock锁(1.8的ConcurrentHashMap不使用Lock锁),保证安全的同时,性能也很高。                

footer.png

更多干货笔记合集,第一时间发布在公众号,欢迎关注!