Java集合-面试知识点

参考: https://gitee.com/SnailClimb/JavaGuide

Java 集合

1.1. 集合概述

1.1.1. Java 集合概览

从下图可以看出,在 Java 中除了以 Map 结尾的类之外, 其他类都实现了 Collection 接口。并且,以 Map 结尾的类都实现了 Map 接口。

img

https://www.javatpoint.com/collections-in-java

1.1.2. 说说 List,Set,Map 三者的区别?

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
  • Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。
  • Map(用 Key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x”代表 key,"y"代表 value,Key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

1.1.3. 集合框架底层数据结构总结

先来看一下 Collection 接口下面的集合。

1.1.3.1. List

  • Arraylist: Object[]数组
  • Vector:Object[]数组
  • LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

1.1.3.2. Set

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSet:LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

再来看看 Map 接口下面的集合。

1.1.3.3. Map

  • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈yu值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》
public class SimpleCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_NODE_NUM = 100;
    private int limit;
    public SimpleCache() {
        this(MAX_NODE_NUM);
    }
    public SimpleCache(int limit) {
        super(limit, 0.75f, true);
        this.limit = limit;
    }
    public V save(K key, V val) {
        return put(key, val);
    }

    public V getOne(K key) {
        return get(key);
    }

    public boolean exists(K key) {
        return containsKey(key);
    }

    /**
     * 判断节点数是否超限
     * @param eldest
     * @return 超限返回 true,否则返回 false
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > limit;
    }
}

测试代码如下:
public class SimpleCacheTest {

    @Test
    public void test() throws Exception {
        SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);

        for (int i = 0; i < 10; i++) {
            cache.save(i, i * i);
        }

        System.out.println("插入10个键值对后,缓存内容:");
        System.out.println(cache + "\n");

        System.out.println("访问键值为7的节点后,缓存内容:");
        cache.getOne(7);
        System.out.println(cache + "\n");

        System.out.println("插入键值为1的键值对后,缓存内容:");
        cache.save(1, 1);
        System.out.println(cache);
    }
}

测试结果如下:
    7=49,8=64,9=81
    8=64,9=81,7=49
    9=81,7=49,1=1

    作者:田小波
    链接:https://www.imooc.com/article/22931
  • Hashtable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  • TreeMap: 红黑树(自平衡的排序二叉树)

1.1.4. 如何选用集合?

主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选用 ConcurrentHashMap。

当我们只需要存放元素值时,就选择实现Collection 接口的集合,需要保证元素唯一时选择实现 Set 接口的集合比如 TreeSet 或 HashSet,不需要就选择实现 List 接口的比如 ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用。

1.1.5. 为什么要使用集合?

数组:长度不可变,数据类型固定单一,数据只能是有序可重复特点单一; 集合:可以存储不同类型不同数量对象,或者是映射关系的数据,可有序可无序;

当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端,因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。

数组的缺点是一旦声明之后,长度就不可变了;同时,声明数组时的数据类型也决定了该数组存储的数据的类型;而且,数组存储的数据是有序的、可重复的,特点单一。但是集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。

1.1.6. Java集合的快速失败机制 “fail-fast”?

是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。

例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。

原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

解决办法:

  1. 在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
  2. 使用CopyOnWriteArrayList来替换ArrayList

怎么确保一个集合不能被修改?

可以使用 Collections. unmodifiableCollection(Collection c) 方法来创建一个只读集合,这样改变集合的任何操作都会抛出 Java. lang. UnsupportedOperationException 异常。

示例代码如下:

List<String> list = new ArrayList<>();
list.add("x");
Collection<String> clist = Collections.unmodifiableCollection(list);
clist.add("y"); // 运行时此行报错
System.out.println(list.size());

1.2. Collection 子接口之 List

1.2.1. Arraylist 和 Vector 的区别?

  • ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
  • Vector 是 List 的古老实现类,底层使用Object[ ] 存储,线程安全的。
  • Vector每次扩容为数组长度的2倍,而ArrayList则是原来数组长度的1.5倍。

1.2.2. Arraylist 与 LinkedList 区别?

  1. 是否保证线程安全: ArrayListLinkedList 都是不同步的,也就是不保证线程安全;
  2. 底层数据结构: Arraylist 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
  3. 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • 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) ,因为需要先移动到指定位置再插入。
  4. 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  5. 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

应用场景:ArrayList的优点在于构造好数组后,频繁的访问元素的效率非常高。遇到数据量略大 或者 需要频繁插入和删除操作的时候,效率就比较低了,如果遇到上述的场景,那么就需要使用LinkedList来代替。ArrayList在小于扩容容量的情况下,其实增加操作效率非常高,在涉及扩容的情况下,添加操作效率确实低,删除操作需要移位拷贝。

下面以增加和删除元素为例比较ArrayList和LinkedList的不同之处:

时间复杂度对比:
ArrayList 是线性表(数组)

  • get()直接读取第几个下标,复杂度 O(1)

  • add(E)添加元素,直接在后面添加,复杂度O(1)

  • add(index,E)添加元素,在第几个元素后面插入,后面的元素需要向后移动,复杂度O(n)

  • remove()删除元素,后面的元素需要逐个移动,复杂度O(n)

    ———————————————————————————————————————————
    LinkedList

  • get()获取第几个元素,依次遍历,复杂度O(n)

  • add(E)添加到末尾,复杂度O(1)

  • add(index,E)添加第几个元素后,需要先查找到第几个元素,直接指针指向操作,复杂度O(n) (这个比较容易想错)

  • remove()删除元素,直接指针指向操作,复杂度O(1)


(1)增加元素到列表尾端:add(E e)
    1.1 可以看到,只要ArrayList的当前容量足够大,add()操作的效率非常高的。只有当ArrayList对容量的需求超出当前数组大小时,
    才需要进行扩容。扩容的过程中,会进行大量的数组复制操作。而数组复制时,最终将调用System.arraycopy()方法,
    因此add()操作的效率还是相当高的。
    1.2 可见,LinkeList由于使用了链表的结构,因此不需要维护容量的大小。从这点上说,它比ArrayList有一定的性能优势,然而,
    每次的元素增加都需要新建一个Entry对象,并进行更多的赋值操作。在频繁的系统调用中,对性能会产生一定的影响。
(2)增加元素到列表任意位置:void add(int index,E element);
    2.1 可以看到每次插入操作,都会进行一次数组复制。而这个操作在增加元素到List尾端的时候是不存在的,大量的数组重组操作
    会导致系统性能低下。并且插入元素在List中的位置越是靠前,数组重组的开销也越大。
    public void add(int index,E element){
        // 将数组index位置后的数据向后移动1位  0 1 2 3 -> 0 1 2 2 3
        // arraycopy(Object src,  int  srcPos, Object dest, int destPos, int length)
        System.arraycopy(elementData,index,elementData,index+1,size-index);
        // 0 1 2 2 3 -> 0 1 element 2 3
        elementData[index]=element;
        size++;
    }

    2.2 对LinkedList来说,在List的尾端插入数据与在任意位置插入数据是一样的,不会因为插入的位置靠前而导致插入的方法性能降低。
    public void add(int index,E element){
        ...
        linkBefore(element,node(index));
    }

    // Inserts element e 'before' non-null Node succ.   
    void linkBefore(E e,Node<E> succ){
        // assert succ != null;
        final Node<E> pred=succ.prev;
        // Node(Node<E> prev, E element, Node<E> next)   new一个新节点有俩个前后指针pred <- e -> succ
        final Node<E> newNode=new Node<>(pred,e,succ);
        // pred <-> e <-> succ
        succ.prev=newNode;
        if(pred==null)
            first=newNode;
        else
            pred.next=newNode;
        size++;
        modCount++;
    }

1.2.2.1. 补充内容:双向链表和双向循环链表

双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。

另外推荐一篇把双向链表讲清楚的文章:https://juejin.im/post/5b5d1a9af265da0f47352f14

双向链表

双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。

双向循环链表

1.2.2.2. 补充内容:RandomAccess 接口

public interface RandomAccess {
}

查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。

在 binarySearch() 方法中,它要判断传入的 list 是否 RamdomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法

    public static <T>
    int binarySearch(List<? extends Comparable<? super T>> list, T key) {
        if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
            return Collections.indexedBinarySearch(list, key);
        else
            return Collections.iteratorBinarySearch(list, key);
    }

ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。为什么呢?我觉得还是和底层数据结构有关!ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的!

1.2.3. 说一说 ArrayList 的扩容机制吧

通过源码一步一步分析 ArrayList 扩容机制

所需的最小容量 minCapacity 如果大于了存放元素的数组elementData长度 那么就需要扩容了

/**
 保存ArrayList数据的数组
 */
transient Object[] elementData;

minCapacity   所需的最小容量

add方法

首先我们来看看ArrayList中的add方法是如何添加元素的

    /**
     * 将指定的元素追加到此列表的末尾。
     */
    public boolean add(E e) {
   //添加元素之前,先调用ensureCapacityInternal方法
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        //这里看到ArrayList添加元素的实质就相当于为数组赋值
        elementData[size++] = e;
        return true;
    }

ensureCapacityInternal方法

当add进一个元素的时候,minCapacity为1,此时取两者的最大值

   //得到最小扩容量
    private void ensureCapacityInternal(int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
              // 获取默认的容量和传入参数的较大值
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

ensureExplicitCapacity方法

我们看到,上述的操作在执行完后,会调用 ensureExplicitCapacity方法,该方法主要就是为了判断是否触发扩容

  //判断是否需要扩容
    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            //调用grow方法进行扩容,调用此方法代表已经开始扩容了
            grow(minCapacity);
    }

grow方法

当添加元素的时候,大于当前数组的长度,就会触发grow操作,该操作将会对数组进行扩容

int newCapacity = oldCapacity + (oldCapacity >> 1)

核心代码是上面这句,将原来的数组长度,进行扩容到1.5倍,然后在执行拷贝命令,将旧数组中的内容,拷贝到新的数组中,实现元素的扩容操作。

elementData = Arrays.copyOf(elementData, newCapacity);

关于:System.arrayCopy()和Arrays.copyOf()方法

看两者源代码可以发现 copyOf() 内部实际调用了 System.arraycopy() 方法

arraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点以及放入新数组中的位置 和长度

copyOf() 是系统自动在内部新建一个数组,并返回该数组

完整代码如下

// 需要分配的数组大小
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private void grow(int minCapacity) {
    // 集合的容量
    int oldCapacity = elementData.length;
    // 新的集合的容量(在这里运用了位运算,位运算是计算机最快的,右移一位,所以新容量是1.5倍)
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果新容量小于添加的集合的容量,则把该容量替换
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;

    /** 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和           
     * MAX_ARRAY_SIZE,如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,           
     * 新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
     */
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    // 将原数组copy到新的数组中
    elementData = Arrays.copyOf(elementData, newCapacity);
}

/** 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和           
 * MAX_ARRAY_SIZE,如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,          
 * 新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
 */
private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0)  // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
}  

总结

通过将上面的方法进行梳理,我们能够总结出以下的几点

  • elementData.length(代表了存放元素的数组的容量) minCapacity(代表了当前元素数量)
  • 当我们add进第一个元素到ArrayList的时候,elementData.length为0(因为还是一个空的list,有种懒加载的感觉??),但是此时执行了ensureCapacityInternal() 方法,通过默认的比较,此时会得到minCapacity为10,此时minCapacity - elementData.length > 0满足,所以会进入grow(minCapacity)方法
  • 当 add 第 2 个元素时,minCapacity 为 2,此时 elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。
  • 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。

1.3. Collection 子接口之 Set

1.3.1. comparable 和 Comparator 的区别

  • comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().

1.3.1.1. Comparator 定制排序

ArrayList<Integer> arrayList = new ArrayList<Integer>();
arrayList.add(-1);
arrayList.add(3);
arrayList.add(3);
arrayList.add(-5);
arrayList.add(7);
arrayList.add(4);
arrayList.add(-9);
arrayList.add(-7);
System.out.println("原始数组:");
System.out.println(arrayList);
// void reverse(List list):反转
Collections.reverse(arrayList);
System.out.println("Collections.reverse(arrayList):");
System.out.println(arrayList);

// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
System.out.println("Collections.sort(arrayList):");
System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {

    @Override
    public int compare(Integer o1, Integer o2) {
        return o2.compareTo(o1);
    }
});
System.out.println("定制排序后:");
System.out.println(arrayList);

Output:

原始数组:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定制排序后:
[7, 4, 3, 3, -1, -5, -7, -9]

1.3.1.2. 重写 compareTo 方法实现按年龄来排序

// person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列
// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
public  class Person implements Comparable<Person> {
    private String name;
    private int age;

    public Person(String name, int age) {
        super();
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    /**
     * T重写compareTo方法实现按年龄来排序
     */
    @Override
    public int compareTo(Person o) {
        if (this.age > o.getAge()) {
            return 1;
        }
        if (this.age < o.getAge()) {
            return -1;
        }
        return 0;
    }
}
    public static void main(String[] args) {
        TreeMap<Person, String> pdata = new TreeMap<Person, String>();
        pdata.put(new Person("张三", 30), "zhangsan");
        pdata.put(new Person("李四", 20), "lisi");
        pdata.put(new Person("王五", 10), "wangwu");
        pdata.put(new Person("小红", 5), "xiaohong");
        // 得到key的值的同时得到key所对应的值
        Set<Person> keys = pdata.keySet();
        for (Person key : keys) {
            System.out.println(key.getAge() + "-" + key.getName());

        }
    }

Output:

5-小红
10-王五
20-李四
30-张三		

1.3.2. 无序性和不可重复性的含义是什么

1、什么是无序性?无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。

2、什么是不可重复性?不可重复性是指添加的元素按照 equals()判断时 ,返回 false,需要同时重写 equals()方法和 HashCode()方法。

1.3.3. 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同

HashSetSet 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null 值;

LinkedHashSetHashSet 的子类,能够按照添加的顺序遍历;

TreeSet 底层使用红黑树,元素是有序的,排序的方式有自然排序和定制排序。

1.4 Collection 子接口之 Queue

1.4.1 Queue 与 Deque 的区别

Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。

Queue 接口 抛出异常 返回特殊值
插入队尾 add(E e) offer(E e)
删除队首 remove() poll()
查询队首元素 element() peek()

Deque 是双端队列,在队列的两端均可以插入或删除元素。

Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:

Deque 接口 抛出异常 返回特殊值
插入队首 addFirst(E e) offerFirst(E e)
插入队尾 addLast(E e) offerLast(E e)
删除队首 removeFirst() pollFirst()
删除队尾 removeLast() pollLast()
查询队首元素 getFirst() peekFirst()
查询队尾元素 getLast() peekLast()

事实上,Deque 还提供有 push()pop() 等其他方法,可用于模拟栈。

1.4.2 ArrayDeque 与 LinkedList 的区别

ArrayDequeLinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持。
  • ArrayDeque 是在 JDK1.6 才被引入的,而LinkedList 早在 JDK1.2 时就已经存在。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。

1.4.3 说一说 PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue 是非线程安全的,且不支持存储 NULLnon-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第K大的数、带权图的遍历等,所以需要会熟练使用才行。

1.4. Map 接口

1.4.1. HashMap 和 Hashtable 的区别

  1. 线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;
  3. 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
  4. 初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

HashMap 中带有初始容量的构造函数:

源码中的一些基本概念:

HashMap中capacity、loadFactor、threshold、size等概念的解释?
    loadFactor=size/capacity     threshold=capacity*loadFactor
// loadFactor译为装载因子。用来衡量HashMap满的程度。loadFactor的默认值为0.75f。
// threshold表示当HashMap的size大于threshold时会执行resize操作。
HashMap就像一个'桶',那么capacity就是这个桶'当前'最多可以装多少元素,而size表示这个桶已经装了多少KV元素。   
  public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
     public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

1.4.2. HashMap 和 HashSet 区别

如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

HashMap HashSet
实现了 Map 接口 实现 Set 接口
存储键值对 仅存储对象
调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

1.4.3. HashMap 和 TreeMap 区别

TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。

img

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:

/**
 * @author shuang.kou
 * @createTime 2020年06月15日 17:02:00
 */
public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }


    public static void main(String[] args) {
        TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}

输出:

person1
person4
person2
person3

可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。

上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:

TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
  int num = person1.getAge() - person2.getAge();
  return Integer.compare(num, 0);
});
1、TreeMap的底层使用了红黑树来实现,像TreeMap对象中放入一个key-value 键值对时,就会生成一个Entry对象,
   这个对象就是红黑树的一个节点,其实这个和HashMap是一样的,一个Entry对象作为一个节点,只是这些节点存放的方式不同。
2、存放每一个Entry对象时都会按照key键的大小按照二叉树的规范进行存放,所以TreeMap中的数据是按照key从小到大排序的。

TreeMap总结:
    程序添加新节点时,总是从树的根节点开始比较,即将根节点当成当前节点。
    如果新增节点大于当前节点并且当前节点的右节点存在,则以右节点作为当前节点,
    如果新增节点小于当前节点并且当前节点的左子节点存在,则以左子节点作为当前节点;
    如果新增节点等于当前节点,则用新增节点覆盖当前节点,并结束循环 直到某个节点的左右子节点不存在,将新节点添加为该节点的子节点。
    如果新节点比该节点大,则添加其为右子节点。如果新节点比该节点小,则添加其为左子节点;

Entry<K,V> parent;
// split comparator and comparable paths
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
            return t.setValue(value);
    } while (t != null);
}

综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

LinkedHashMap

既然LinkedHashMap在HashMap的基础上维护了一个双向链表,那么这个链表的增删修改的逻辑是怎样的?

LinkedHashMap 扩展了 Entry类,新增了before, after, 分别指向该节点在链表中的前后节点

  • LinkedHashMap 存储结构和 HashMap 相同,依然是数组+链表+红黑树

  • LinkedHashMap 额外持有一个双向链表,维护插入节点的顺序

  • 最终的数据结构如下图

  • 实际的元素存储与HashMap一致,依然是数组+链表+红黑树的形式

  • LinkedHashMap 和 HashMap区别在于:

    • 除了维护数组+链表的结构之外,还根据插入Map先后顺序维护了一个双向链表的头尾head,tail
    • Node基本结构,相比较HashMap而言,还增加了 before,after 两个分别指向双向链表中前后节点的属性
    • 即下图中的双向链表中的节点,其实值依然是下面的数组+链表结构中的元素

image-20211006173548247

// 1.HashMap
Node<K,V> implements Map.Entry<K,V>{
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}
transient Node<K,V>[] table;
----------------------------------------------------
// 2.LinkedHashMap
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

/**
 * The head (eldest) of the doubly linked list.
 */
 transient LinkedHashMap.Entry<K,V> head;

/**
 * The tail (youngest) of the doubly linked list.
 */
 transient LinkedHashMap.Entry<K,V> tail;

1.4.4. HashSet 如何检查重复

以下内容摘自我的 Java 启蒙书《Head fist java》第二版:

当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

hashCode()****与 equals() 的相关规定:

  1. 如果两个对象相等,则 hashcode 一定也是相同的
  2. 两个对象相等,对两个 equals() 方法返回 true
  3. 两个对象有相同的 hashcode 值,它们也不一定是相等的
  4. 综上,equals() 方法被覆盖过,则 hashCode() 方法也必须被覆盖
  5. hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。

==与 equals 的区别

对于基本类型来说,== 比较的是值是否相等;

对于引用类型来说,== 比较的是两个引用是否指向同一个对象地址(两者在内存中存放的地址(堆内存地址)是否指向同一个地方);

对于引用类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals()方法被重写(例如 String),则比较的是地址里的内容。

1.4.5. HashMap 的底层实现

1.4.5.1. JDK1.8 之前

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

JDK 1.8 HashMap 的 hash 方法源码:

JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

对比一下 JDK1.7 的 HashMap 的 hash 方法源码.

static int hash(int h) {
    // 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);
}

相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

transient Node<K,V>[] table;  
static class Node<K,V> implements Map.Entry<K,V> {
    Node<K,V> next; 
}

jdk1.8之前的内部结构-HashMap

1.4.5.2. JDK1.8 之后

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

TreeNode<K,V> extends LinkedHashMap.Entry<K,V>

jdk1.8之后的内部结构-HashMap

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

get(Object key)

get(key)方法时获取key的hash值,计算hash&(n-1)得到在链表数组中的位置first=tab[hash&(n-1)],
        先判断first的key是否与参数key相等,相等的话直接返回first节点
不等的话:
1. 如果first是TreeNode就调用getTreeNode-find找出对应的节点
2. 遍历后面的链表找到key值相同的节点,然后返回对应的Value值即可
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
 * Implements Map.get and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @return the node, or null if none
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab;//Entry对象数组
    Node<K,V> first,e; //在tab数组中经过散列的第一个位置
    int n;
    K k;
    //找到插入的第一个Node,方法是hash值和n-1相与,tab[(n - 1) & hash]
    //也就是说在一条链上的hash值相同的
    if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {
        /*检查第一个Node是不是要找的Node*/
        if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))//判断条件是hash值要相同,key值要相同
            return first;
        //检查first后面的node
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);   // find():从根节点开始使用给定的hash和key查找。
            /*遍历后面的链表,找到key值和hash值都相同的Node*/
            do {
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

putVal

1. 根据键值key计算hash值得到插入的数组索引i,如果tab[i]==null,直接new节点添加
   2. 判断当前数组中处理hash冲突的方式为链表还是红黑树(check第一个节点类型即可),分别处理
      2.1 如果key和第一个节点相等 e记录下第一个节点
      2.2 如果第一个节点是TreeNode就调用TreeNode的putval方法
      2.3 然后会不断向下移动根节点,判断next节点【为null则添加,相等则break】
       【最后到达null尾节点的时候要判断是否需要扩容】
       【先判断冲突的节点数是否已经达到8个,若达到则判断长度是否达到64,达到则转换为红黑树否则扩容】
   3. 最后,如果e不为null说明存在相同key, 判断: !onlyIfAbsent 即 only=false这是可以替换value
      onlyIfAbsent if true, don't change existing value  // [onlyIfAbsent 决定是否覆盖旧value] 
        public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
        /**
         * Implements Map.put and related methods
         *
         * @param hash hash for key
         * @param key the key
         * @param value the value to put
         * @param onlyIfAbsent if true, don't change existing value
         * @param evict if false, the table is in creation mode.
         * @return previous value, or null if none
         */
        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;
            // 如果table的在(n-1)&hash的值是空,就新建一个节点插入在该位置
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
                // 表示有冲突,开始处理冲突
            else {
                Node<K,V> e; K k;
                // 检查第一个Node,p是不是要找的值
                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) {
                            p.next = newNode(hash, key, value, null);
                            //如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,             
              //treeifyBin首先判断当前hashMap的长度,如果不足64,只进行
                            //resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树
                            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;
                    }
                }
                // 就是链表上有相同的key值
                if (e != null) { // existing mapping for key,就是key的Value存在
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;//返回存在的Value值
                }
            }
            ++modCount;
            // 如果当前大小大于门限,门限原本是初始容量*0.75
            if (++size > threshold)
                resize(); //扩容两倍
            afterNodeInsertion(evict);
            return null;
        }

1.4.6. HashMap 的长度为什么是 2 的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。

        由' (e.hash & oldCap) == 0 '
        0101 & 1000 = 0
        可以推导出 ' (2oldCap - 1) & e.hash = (oldCap - 1) & e.hash '
        2^4 - 1     01111      2^3 - 1  0111
        0101               0101
                &
                00101               0101
		// 这就是为什么偶数堆放原位置的原因

        由(e.hash & oldCap) != 0    可以推导出--> (2oldCap - 1) & e.hash = (oldCap - 1) & e.hash + oldCap
        这就是为什么奇数堆放j+oldCap位置的原因newTab[j + oldCap] = hiHead


        /**
         * Initializes or doubles table size.  If null, allocates in
         * accord with initial capacity target held in field threshold.
         * Otherwise, because we are using power-of-two expansion, the
         * elements from each bin must either stay at same index, or move
         * with a power of two offset in the new table.
         *
         * @return the table
         */
        final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;

            /*如果旧表的长度不是空*/
            if (oldCap > 0) {
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                /*把新表的长度设置为旧表长度的两倍,newCap=2*oldCap*/
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                        oldCap >= DEFAULT_INITIAL_CAPACITY)
                    /*把新表的门限设置为旧表门限的两倍,newThr=oldThr*2*/
                    newThr = oldThr << 1; // double threshold
            }
            /*如果旧表的长度的是0,就是说第一次初始化表*/
            else if (oldThr > 0) // initial capacity was placed in threshold
                newCap = oldThr;
            else {               // zero initial threshold signifies using defaults
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }



            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;//新表长度乘以加载因子
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                        (int)ft : Integer.MAX_VALUE);
            }
            threshold = newThr;
            @SuppressWarnings({"rawtypes","unchecked"})
            /*下面开始构造新表,初始化表中的数据*/
                    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;//把新表赋值给table
            if (oldTab != null) {//原表不是空要把原表中数据移动到新表中  
                /*遍历原来的旧表*/
                for (int j = 0; j < oldCap; ++j) {
                    Node<K,V> e;
                    if ((e = oldTab[j]) != null) {
                        oldTab[j] = null;
                        if (e.next == null)//说明这个node没有链表直接放在新表的e.hash & (newCap - 1)位置
                            newTab[e.hash & (newCap - 1)] = e;
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                            /*如果e后边有链表,到这里表示e后面带着个单链表,需要遍历单链表,将每个结点重*/
                        else { // preserve order保证顺序
                            // 新计算在新表的位置,并进行搬运
                            Node<K,V> loHead = null, loTail = null;
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;

                            do {
                                next = e.next;//记录下一个结点
                                //新表是旧表的两倍容量,实例上就把单链表拆分为两队,
              //e.hash&oldCap为偶数一队,e.hash&oldCap为奇数一对
                                if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);

                            if (loTail != null) {//lo队不为null,放在新表原位置
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            if (hiTail != null) {//hi队不为null,放在新表j+oldCap位置
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
	————————————————
        版权声明:本文为CSDN博主「tuke_tuke」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
        原文链接:https://blog.csdn.net/tuke_tuke/article/details/51588156

1.4.7. HashMap 多线程操作导致死循环问题

主要原因在于并发下的 Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。

详情请查看:https://coolshell.cn/articles/9606.html

首先HashMap是线程不安全的,其主要体现:

​ 1.在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

​ 2.在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

1.4.8. HashMap 有哪几种常见的遍历方式?

HashMap 的 7 种遍历方式与性能分析!

图片

// 迭代器 EntrySet遍历
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<Integer, String> entry = iterator.next();
    System.out.print(entry.getKey());
    System.out.print(entry.getValue());
}

//迭代器 KeySet 遍历
Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
    Integer key = iterator.next();
    System.out.print(key);
    System.out.print(map.get(key));
}
// ForEach EntrySet遍历
for (Map.Entry<Integer, String> entry : map.entrySet()) {
    System.out.print(entry.getKey());
    System.out.print(entry.getValue());
}
// ForEach KeySet遍历
for (Integer key : map.keySet()) {
    System.out.print(key);
    System.out.print(map.get(key));
}
// Lambda 遍历
map.forEach((key, value) -> {
    System.out.print(key);
    System.out.print(value);
});
//Streams API 单线程 遍历
map.entrySet().stream().forEach((entry) -> {
    System.out.print(entry.getKey());
    System.out.print(entry.getValue());
});
// Streams API 多线程 遍历
map.entrySet().parallelStream().forEach((entry) -> {
    System.out.print(entry.getKey());
    System.out.print(entry.getValue());
});

本文我们讲了 HashMap 4 大类(迭代器、for、lambda、stream)遍历方式,以及具体的 7 种遍历方法,除了 Stream 的并行循环,其他几种遍历方法的性能差别不大,但从简洁性和优雅性上来看,Lambda 和 Stream 无疑是最适合的遍历方式

除此之外我们还从「安全性」方面测试了 4 大类遍历结果,从安全性来讲,我们应该使用迭代器提供的 iterator.remove() 方法来进行删除,这种方式是安全的在遍历中删除集合的方式,或者使用 Stream 中的 filter 过滤掉要删除的数据再进行循环,也是安全的操作方式。然而选择那一种形式的写法,要综合:性能、安全性、使用环境的 JDK 版本以及优雅性和可读性等方面来综合考虑。

1.4.9. ConcurrentHashMap 和 Hashtable 区别

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

两者的对比图:

HashTable:

Hashtable全表锁

http://www.cnblogs.com/chengxiao/p/6842045.html>

JDK1.7 的 ConcurrentHashMap:

img

http://www.cnblogs.com/chengxiao/p/6842045.html>

image-20211006171356131

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {

    // 将整个hashmap分成几个小的map,每个segment都是一个锁;与hashtable相比,这么设计的目的是对于put, remove等操作,可以减少并发冲突,对
    // 不属于同一个片段的节点可以并发操作,大大提高了性能
    final Segment<K,V>[] segments;

    // 本质上Segment类就是一个小的hashmap,里面table数组存储了各个节点的数据,继承了ReentrantLock, 可以作为互拆锁使用
    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        transient volatile HashEntry<K,V>[] table;
        transient int count;
    }

    // 基本节点,存储Key, Value值
    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
    }
}

JDK1.8 的 ConcurrentHashMap:

img

JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。

img

  • 取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,
  • 并发控制使用Synchronized和CAS来操作,
  • 将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构.
        // The table, initialized on first use, and resized as necessary. When allocated, length is always a power of two.
        transient volatile Node<K,V>[] table;



        // Basic hash bin node, used for most entries.
        static class Node<K,V> implements Map.Entry<K,V> {
            //链表的数据结构
            final int hash;    //key的hash值
            final K key;       //key
            //val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序
            volatile V val;   //get操作全程不需要加锁是因为Node的成员val是用volatile修饰
            volatile Node<K,V> next;     //表示链表中的下一个节点,数组用volatile修饰主要是保证在数组扩容的时候保证可见性
            Node(int hash, K key, V val, Node<K,V> next) {
                this.hash = hash;
                this.key = key;
                this.val = val;
                this.next = next;
            }
            public final K getKey()       { return key; }
            public final V getValue()     { return val; }
            public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
            public final String toString(){ return key + "=" + val; }
            //不允许更新value 
            public final V setValue(V value) {
                throw new UnsupportedOperationException();
            }
            public final boolean equals(Object o) {
                Object k, v, u; Map.Entry<?,?> e;
                return ((o instanceof Map.Entry) &&
                        (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                        (v = e.getValue()) != null &&
                        (k == key || k.equals(key)) &&
                        (v == (u = val) || v.equals(u)));
            }
            //用于map中的get()方法,子类重写
            Node<K,V> find(int h, Object k) {
                Node<K,V> e = this;
                if (k != null) {
                    do {
                        K ek;
                        if (e.hash == h &&
                                ((ek = e.key) == k || (ek != null && k.equals(ek))))
                            return e;
                    } while ((e = e.next) != null);
                }
                return null;
            }
        }
————————————————

        // Entry for Tree bins. Extends LinkedHashMap.Entry
        static final class TreeNode<K,V> extends Node<K,V> 

1.4.10. ConcurrentHashMap 线程安全的具体实现方式/底层具体实现

JDK1.7(上面有示意图)

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

JDK1.8 (上面有示意图)

ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))

synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

/** Implementation for put and putIfAbsent */
	final V putVal(K key, V value, boolean onlyIfAbsent) {
    	......
        for (Node<K,V>[] tab = table; ; ) {
            Node<K,V> f; int n, i, fh;

            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                // casTabAt --> compareAndSwapObject
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            ......
                else {
                    V oldVal = null;
                    synchronized (f) {  // 使用 synchronized 锁定当前链表或红黑二叉树的首节点 Node<K,V> f
                        if (tabAt(tab, i) == f) {
                            ......
                        }else if (f instanceof TreeBin) {
                            ......
                        }
                        ......
                    }
                    ......
                }

        }
    	......
	}

ConcurrentHashMap的put操作详解

先定位 f = tabAt(tab, i = (n - 1) & hash)

  1. 如果这个位置没有元素的话,则通过cas的方式[casTabAt]尝试添加,注意这个时候是没有加锁的 [casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null))]

  2. 如果在这个位置有元素的话,先判断节点的hash值是MOVED,则表示正在进行数组扩容的数据复制阶段,调用helpTransfer允许多线程复制

    else就采用synchronized的方式加锁,

2.1 如果是链表的话,就对这个链表的所有元素进行遍历,

​ 如果找到了key一样的节点,则把它的值替换

​ 如果没找到的话,则添加在链表的最后面

2.2 否则,是树的话,则调用putTreeVal方法添加到树中去

* 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,

helpTransfer Helps transfer if a resize is in progress.

transfer Moves and/or copies the nodes in each bin to new table. See

* 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失

ConcurrentHashMap的put操作详解

    
        public V put(K key, V value) {
            return putVal(key, value, false);  // true的时候,只有当这个key的value为空的时候才会设置
        }

        final V putVal(K key, V value, boolean onlyIfAbsent) {
            if (key == null || value == null) throw new NullPointerException();//K,V都不能为空,否则的话跑出异常
            int hash = spread(key.hashCode());    //取得key的hash值
            int binCount = 0;    //用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
            for (Node<K,V>[] tab = table;;) {    //
                Node<K,V> f; int n, i, fh;
                if (tab == null || (n = tab.length) == 0)
                    tab = initTable();    //第一次put的时候table没有初始化,则初始化table
                else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {    //通过哈希计算出一个表中的位置因为n是数组的长度,所以(n-1)&hash肯定不会出现数组越界
                    if (casTabAt(tab, i, null,        //如果这个位置没有元素的话,则通过cas的方式尝试添加,注意这个时候是没有加锁的
                            new Node<K,V>(hash, key, value, null)))        //创建一个Node添加到数组中区,null表示的是下一个节点为空
                        break;                   // no lock when adding to empty bin
                }
                /*
                 * 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
                 * 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失
                 */
                else if ((fh = f.hash) == MOVED)
                    tab = helpTransfer(tab, f);
                else {
                    /*
                     * 如果在这个位置有元素的话,就采用'synchronized'的方式加锁,
                     *     如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
                     *         如果找到了key和key的hash值都一样的节点,则把它的值替换到
                     *         如果没找到的话,则添加在链表的最后面
                     *  否则,是树的话,则调用putTreeVal方法添加到树中去
                     *
                     *  在添加完之后,会对该节点上关联的的数目进行判断,
                     *  如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
                     */
                    V oldVal = null;
                    synchronized (f) {
                        if (tabAt(tab, i) == f) {        //再次取出要存储的位置的元素,跟前面取出来的比较
                            if (fh >= 0) {                //取出来的元素的hash值大于0,当转换为树之后,hash值为-2
                                binCount = 1;
                                for (Node<K,V> e = f;; ++binCount) {    //遍历这个链表
                                    K ek;
                                    if (e.hash == hash &&        //要存的元素的hash,key跟要存储的位置的节点的相同的时候,替换掉该节点的value即可
                                            ((ek = e.key) == key ||
                                                    (ek != null && key.equals(ek)))) {
                                        oldVal = e.val;
                                        if (!onlyIfAbsent)        //当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
                                            e.val = value;
                                        break;
                                    }
                                    Node<K,V> pred = e;
                                    if ((e = e.next) == null) {    //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
                                        pred.next = new Node<K,V>(hash, key,        //为空的话把这个要加入的节点设置为当前节点的下一个节点
                                                value, null);
                                        break;
                                    }
                                }
                            }
                            else if (f instanceof TreeBin) {    //表示已经转化成红黑树类型了
                                Node<K,V> p;
                                binCount = 2;
                                if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,    //调用putTreeVal方法,将该元素添加到树中去
                                        value)) != null) {
                                    oldVal = p.val;
                                    if (!onlyIfAbsent)
                                        p.val = value;
                                }
                            }
                        }
                    }
                    if (binCount != 0) {
                        if (binCount >= TREEIFY_THRESHOLD)    //当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
                            treeifyBin(tab, i);
                        if (oldVal != null)
                            return oldVal;
                        break;
                    }
                }
            }
            addCount(1L, binCount);    //计数
            return null;
        }
	————————————————
        版权声明:本文为CSDN博主「快乐小石头」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
        原文链接:https://blog.csdn.net/weixin_43185598/article/details/87938882

        hash碰撞的频率遵循泊松分布
                * 0:    0.60653066
                * 1:    0.30326533
                * 2:    0.07581633
                * 3:    0.01263606
                * 4:    0.00157952
                * 5:    0.00015795
                * 6:    0.00001316
                * 7:    0.00000094
                * 8:    0.00000006

ConcurrentHashmap size 1.8方法实现

1.8中使用一个volatile类型的变量baseCount记录元素的个数,当插入新数据或则删除数据时,会通过addCount()方法更新baseCount,实现如下:

    if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                        U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    1、初始化时counterCells为空,在并发量很高时,如果存在两个线程同时执行CAS修改baseCount值,则失败的线程会继续执行
    方法体中的逻辑,使用CounterCell记录元素个数的变化;

    2、如果CounterCell数组counterCells为空,调用fullAddCount()方法进行初始化,并插入对应的记录数,通过CAS设置cellsBusy字段,
    只有设置成功的线程才能初始化CounterCell数组,实现如下:

    else if (cellsBusy == 0 && counterCells == as &&
            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
        boolean init = false;
        try {                           // Initialize table
            if (counterCells == as) {
                CounterCell[] rs = new CounterCell[2];
                rs[h & 1] = new CounterCell(x);
                counterCells = rs;
                init = true;
            }
        } finally {
            cellsBusy = 0;
        }
        if (init)
            break;
    }
    3、如果通过CAS设置cellsBusy字段失败的话,则继续尝试通过CAS修改baseCount字段,如果修改baseCount字段成功的话,就退出循环,
    否则继续循环插入CounterCell对象;

    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
        break;
    所以在1.8中的size实现比1.7简单多,因为元素个数保存baseCount中,部分元素的变化个数保存在CounterCell数组中,实现如下:

    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
                        (int)n);
    }

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }
    通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数;
————————————————
    版权声明:本文为CSDN博主「programmeryu」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/programmeryu/article/details/91606487

1.5. Collections 工具类

Collections 工具类常用方法:

  1. 排序
  2. 查找,替换操作
  3. 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合)

1.5.1. 排序操作

void reverse(List list)//反转
void shuffle(List list)//随机排序
void sort(List list)//按自然排序的升序排序
void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑
void swap(List list, int i , int j)//交换两个索引位置的元素
void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面

1.5.2. 查找,替换操作

int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的
int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll)
int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c)
void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素
int frequency(Collection c, Object o)//统计元素出现次数
int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target)
boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素

1.5.3. 同步控制

Collections 提供了多个synchronizedXxx()方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。

我们知道 HashSetTreeSetArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。Collections 提供了多个静态方法可以把他们包装成线程同步的集合。

最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合。

方法如下:

synchronizedCollection(Collection<T>  c) //返回指定 collection 支持的同步(线程安全的)collection。
synchronizedList(List<T> list)//返回指定列表支持的同步(线程安全的)List。
synchronizedMap(Map<K,V> m) //返回由指定映射支持的同步(线程安全的)Map。
synchronizedSet(Set<T> s) //返回指定 set 支持的同步(线程安全的)set。

1.6 同步类容器和并发类容器

在Java中,同步容器主要包括2类:

  1)Vector、Stack、HashTable

  2)Collections类中提供的静态工厂方法创建的类

为了解决同步类容器的性能问题,在Java 1.5之后提供了并发容器,位于java.util.concurrent目录下,这个目录俗称并发包。

1.6.1、ConcurrentMap

1.6.2、CopyOnWirte容器

Cope-On-Write简称COW,是一种用于程序设计中的优化策略,称为写时复制,理解起来很简单,就是执行修改操作时进行底层数组复制,使得修改操作在新的数组上进行,不妨碍原数组的并发读操作,复制修改完成后把原数组引用指向新数组。这样做的好处是可以并发的读而不需要加锁,因为当前容器不会添加任何元素,所以也是一种读写分离的思想。但正是因为写时复制,所以不能保证数据的实时性,而只能保证最终一致性。

image-20211006172530437

image-20211006172608647

在concurrent包下实现CopyOnWrite机制的容器有两种,CopyOnWriteArrayList和CopyOnWriteArraySet。

CopyOnWriteArrayList中有一个Object数组array用来存放数据,对于set()、add()、remove()等修改数据的操作会加上重入锁ReentrantLock,等修改操作完成替换掉array的引用之后才释放锁,从而保证写操作的线程安全,而针对读操作没有任何锁。

​ final ReentrantLock lock = this.lock;

​ lock.lock();

​ lock.unlock();

CopyOnWriteArraySet其实就是一个CopyOnWriteArrayList,不过就是在方法中避免重复数据而已,甚至这些避免重复数据的函数也是在CopyOnWriteArrayList中定义的,CopyOnWriteArraySet中只是包含一个CopyOnWriteArrayList的属性,然后在方法上做个包装,除了equals方法外,其他当前类中的所有函数都是调用的CopyOnWriteArrayList的方法,所以严格来讲可以使用一个CopyOnWriteArrayList作为具有Set特性的写时复制数组(不过就是没有继承AbstractSet)。

根据CopyOnWirte容器的实现原理可知,CopyOnWirte容器保证读写分离,十分适合读多写少的场景,但不适合写多的场景。

public class CopyOnWriteArrayList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private static final long serialVersionUID = 8673264195747942595L;

    /** The lock protecting all mutators */
    final transient ReentrantLock lock = new ReentrantLock();

    /** The array, accessed only via getArray/setArray. */
    private transient volatile Object[] array;

    final Object[] getArray() { return array; }
    final void setArray(Object[] a) { array = a; }




    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            // 获取数组getArray,拷贝一份并在最后面添加新元素,然后再替换数组setArray
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }


    public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

    public E remove(int index) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                        numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

1.6.3、线程安全队列

在并发编程中我们有时候需要使用线程安全的队列。如果我们要实现一个线程安全的队列有两种实现方式一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现,而非阻塞的实现方式则可以使用循环CAS的方式来实现。java.util.concurrent.atomic包相关类就是CAS的实现。

ConcurrentLinkedQueue是一个适用于高并发场景下的非阻塞的队列,通过无锁的方式(采用CAS操作),实现了高并发状态下的高性能,通常ConcurrentLinkedQueue的性能优于BlockingQueue。ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部,当我们获取一个元素时,它会返回队列头部的元素,该队列不允许NULL元素。

阻塞队列当队列是空的时候,再想获取元素就会阻塞进入等待状态,所以非常适合生产者-消费者模式。阻塞队列BlockingQueue接口JDK提供了7种实现:

  • ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。 https://www.cnblogs.com/NathanYang/p/11276428.html
  • LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

————————————————

版权声明:本文为CSDN博主「yjclsx」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/yjclsx/article/details/80924085

		1. LinkedBlockingQueue中的锁是分离的,生产者的锁PutLock,消费者的锁takeLock
   		而ArrayBlockingQueue生产者和消费者使用的是同一把锁;
		/** Lock held by take, poll, etc */
        private final ReentrantLock takeLock = new ReentrantLock();
		/** Lock held by put, offer, etc */
        private final ReentrantLock putLock = new ReentrantLock();


        2.  LinkedBlockingQueue内部维护的是一个链表结构在生产和消费的时候,需要创建Node
        对象进行插入或移除,大批量数据的系统中,其对于GC的压力会比较大,
  	  而ArrayBlockingQueue内部维护了一个数组在生产和消费的时候,是直接将枚举对象
        插入或移除的,不会产生或销毁任何额外的对象实例
        3.
		/** Wait queue for waiting puts */
        private final Condition notFull = putLock.newCondition();
        notFull.signal();
		/** Wait queue for waiting takes */
        private final Condition notEmpty = takeLock.newCondition();
        notEmpty.signal();


        public E take() throws InterruptedException {
            E x;
            int c = -1;
            //获得当前队列的元素数量
            final AtomicInteger count = this.count;
            //获得take锁
            final ReentrantLock takeLock = this.takeLock;
            //执行take操作
            takeLock.lockInterruptibly();
            try {
                while (count.get() == 0) {
                    notEmpty.await();//阻塞当前线程
                }
                x = dequeue();
                //当前队列的数量减1,返回操作前的数量
                c = count.getAndDecrement();
                if (c > 1)
                    notEmpty.signal();
            } finally {
                takeLock.unlock();
            }
            //之前队列元素数量为容量值,取出一个
            //即当前队列中元素数量为capacity-1,即未满,可以调用put方法,需要唤醒阻塞在put锁上的线程
            if (c == capacity)
                signalNotFull();
            return x;
        }

		// Signals a waiting put. Called only from take/poll.
        void signalNotFull() {
            notFull.signal();
        }

应该是调整 + 插入,不断调整,找到插入的位置,给该位置赋值

// 将x插入到堆中,注意这里是不断和父节点比较,最终找到插入位置
private static <T> void siftUpComparable(int k, T x, Object[] array) {
    // 如果不传入Comparable的实现,这里会强转失败,抛出异常
    Comparable<? super T> key = (Comparable<? super T>) x;
    while (k > 0) {
        //a[k]的父节点位置
        int parent = (k - 1) >>> 1;
        Object e = array[parent];
        // 如果比父节点大就不用交换了
        if (key.compareTo((T) e) >= 0)
            break;
        // 将父元素移下来
        array[k] = e;
        // k向上移
        k = parent;
    }
    // 退出循环后,k的位置就是待插入的位置
    array[k] = key;
}
posted @ 2021-10-06 17:58  追梦少年阿飞  阅读(80)  评论(0)    收藏  举报