Java-并发篇-09-浅读java并发

文章目录

1. 前置基础知识

1.1 位运算的知识

Java支持基于二进制的位运算操作。

  • 使用“>>”表示无符号位的右移运算
  • 使用“>>>”表示有符号位的右移运算
  • 使用“<<”表示无符号位的左移运算
  • 使用“<<<”表示有符号位的左移运算。

在Java中,基于整数的位运算相当于整数的乘法运算或除法运算。

  • “x>>1”表示将x除以2
  • “x<<1”表示将×乘以2。

1.2 大致图

在这里插入图片描述
在这里插入图片描述

2. Java集合框架

Java集合框架(Java Collection Framework,JCF)是Java标准平台(J2SE)的重要组成部分;

根据操作特性划分Java集合框架,则可以将其分成四大部分:

  • List集合(线性集合)
  • Set集合(去重集合)
  • Queue集合(队列集合)
  • Map集合(K-V键值对集合)

根据工作场景特性划分Java集合框架,则可以将其分成

  • 支持高并发场景的集合
  • 不支持高并发场景的集合

根据提供者划分Java集合框架,则可以将其分成

  • 原生集合
  • 由第三方组织提供的集合

在这里插入图片描述

2.1 JCF中的 List集合

大概图如下所示:
在这里插入图片描述

2.1.1 接口介绍

2.1.1.1 java.lang.Iterable接口

List、Set、Queue集合的上层都需要继承java.lang.Iterable接口
在这里插入图片描述
实现该接口的类可以使用for each循环语句进行操作处理。但实际上该接口还提供了两个操作方法
,分别为forEach(Consumer<?super T>action)方法和spliterator()方法。
在这里插入图片描述

2.1.1.2 java.util.AbstractList抽象类

该接口并没有直接的实现类。
实现java.util.Collection接口的类都是可以按照某种逻辑结构存储一组数据的集合,这种逻辑结构可以是链表(如LinkedList集合),也可以是固定长度的数组(如Vector集合),还可以是树结构(如TreeMap集合);向外界的输出结果可以是有序的(如ArrayList集合),也可以是无序的(如HashSet集合);可以是保证多线程下的操作安全性的(如CopyOnWriteArayList集合),也可以是不保证多线程下的操作安全性的(如ArrayDeque集合)。

在JCF中,可以根据List集合在各维度上表现出来的工作特点对其进行分类,分类标准有三种:

  • 根据是否支持随机访问的特点进行分类
    • 支持随机访问(读)的List集合;
      • 支持随机访问的List集合可以查询集合中任意位置的数据,并且所花费的时间不会改变(时间复杂度为O(1))。
    • 不支持随机访问(读)的List集合;
  • 根据是否具有数据的可修改权限进行分类
    • 可修改的List集合;
      • 对于可修改的List集合,操作者可以在集合中的指定索引位上指定一个存储值。
    • 不可修改的List集合。
      • 对于不可修改的List集合,操作者可以获取集合中指定索引位上的存储值,但不能对这个索引位上的值进行修改;操作者也可以获取当前集合的大小,但不能对当前集合的大小进行修改。
  • 根据集合容量是否可变进行分类
    • 大小可变的List集合
      • 大小可变的List集合是指在实例化后,大小就固定下来可以再次变化的List集合。
    • 大小不可变的List集合。
      • 大小不可变的List集合是指在实例化后,大小就固定下来不再变化的List集合。
2.1.1.3 java.util.Collection接口

继承该抽象类的各种具体的List集合只需根据自身情况重写java.util.AbstractList抽象类中的不同方法。
例如,set(int)方法的功能是替换指定索引位上的数据对象

如果当前List集合不支持修改,则一定会抛出UnsupportedOperationException异常;
对于不可修改的List集合,开发人员只需重写java.util.Abs-tractList抽象类中的get(int)方法和size()方法;

如果开发人员自行定义一个大小可变的List集合,则只需重写add(int,E)方法和remove(int)方法;如果开发人员不需要实现支持随机访问的List集合,则可以使其继承java.util.AbstractSequentialList 抽象类。

2.1.1.4 java.util.RandomAccess接口

java.util.RandomAccess接口是一种标识接口。
标识接口是指Java中用于标识某个类具有某种操作特性、功能类型的接口。
Java中有很多标识接口,如java.lang.Cloneable接口、java.io.Serializable接口。

List集合中有一组具体集合,支持集合中数据对象的随机访问,包括java.util.ArrayList集合、java.util.Vector集合和java.util.concurrent.CopyOnWriteArrayList集合。
java.util.RandomAccess标识接口主要用于向调用者表示这些List集合支持集合中数据对象的随机访问
在这里插入图片描述
实现java.util.RandomAccess接口的还有很多第三方类库,如一些厂商封装的JSON工具中的JSONArray类。这些实现了java.util.RandomAccess标识接口的List集合在工作时也会被区别对待;

源码位置:java.util.Collections#fill
在这里插入图片描述
如图所示在这个方法中:

  • 如果当前指定的List集合支持随机访问,则优先使用for()循环定位并填充/替换集合中的每个索引位上的数据对象。
  • 如果当前指定的List集合不支持随机访问,但集合中的数据对象数量少于FILL_THRESHOLD
    (常量,默认值为25),则仍然使用for循环依次填充/替换每个索引位上的数据对象;
  • 如果不支持随机访问的集合拥有较多数据对象数量,则使用ListIterator迭代器顺序定位并填充/替换集合中的每个索引位上的数据对象。

为什么会出现这种处理逻辑呢?
这主要是因为支持随机访问的集合对set()方法的实现方式与不支持随机访问的集合对set()方法的实现方式不一样,前者可以基于随机访问特性快速定位到指定的索引位,而后者不能。
比如java.util.ArrayList 是一种支持随机访问集合。
ArrayList 部分源码如下所示:java.util.ArrayList#set

    /**
     * Replaces the element at the specified position in this list with
     * the specified element.
     *
     * @param index index of the element to replace
     * @param element element to be stored at the specified position
     * @return the element previously at the specified position
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        rangeCheck(index);
        //使用elementData方法查询指定索引位上的数据对象,
        // 保证了对随机访问特性的支持,对算法时间复杂度O(1)的支持
        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
    }

ArrayList集合本质上是一个数组,要寻找数组中某个索引位上的数据对象,无须从头依次查询。JVM会根据数据对象在内存空间中的起始位置和数组位置的偏移量直接找到这个索引位上的数据对象引用。因此,在java.util.Collections类的fill()方法中,ArrayList集合的数据对象填充过程如图所示。
在这里插入图片描述

比如LinkedList集合是一种不支持随机访问的集合。
LinkedList部分源码如下所示:java.util.LinkedList#node

  
    /**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @param index index of the element to replace
     * @param element element to be stored at the specified position
     * @return the element previously at the specified position
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        checkElementIndex(index);
        //node()方法可以定位到指定的索引位置上
        LinkedList.Node<E> x = node(index);
        //以下代码对指定索引位上的数据对象进行替换
        E oldVal = x.item;
        x.item = element;
        return oldVal;
    }


    /**
     * 通过该方法遍历定位到LinkedList集合中的指定索引位
     * Returns the (non-null) Node at the specified element index.
     */
    LinkedList.Node<E> node(int index) {
        // assert isElementIndex(index);
        //如果满足条件,则从双向链表的前半部分开始搜索指定的索引位
        if (index < (size >> 1)) {
            LinkedList.Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        //否则从双向链表的后半部分开始搜索指定的索引位
        } else {
            LinkedList.Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

LinkedList集合的内部结构是一个双向链表,要寻找链表中某个索引位上的数据对象,只能从头部或尾部依次查询,如图所示(在实际使用时,综合客观情况后的时间复杂度可能更高)。
在这里插入图片描述
在java.util.Collections类的fill()方法中复盘,如果需要被填充/替换数据对象的LinkedList集合中的数据对象数量并不多(少于25个),则如何进行LinkedList集合中的数据对象填充/替换操作,如图所示。
在这里插入图片描述

很显然,AraylList集合在数据对象填充/替换场景的设计效果要优于LinkedList集合,这主要得益于ArrayList集合对随机访问的支持,本质上得益于ArrayList集合内部数组结构的设计方式。

如果不支持随机访问的集合拥有较多的数据对象数量,使用集合的迭代器功能依次对每个索引位上的数据对象进行填充/替换操作。

使用迭代器的好处在于,可以帮助不支持随机访问的集合避免不必要的索引位查询操作。在每次调用next()方法时,迭代器都可以基于上一次操作的索引位继续寻找下一个索引位,而不需要重新从第一个索引位进行查询。
在这里插入图片描述
根据源码可知,在next()方法中使用全局变量cursor记录了当前处理的索引位,当再次调用next()方法时,只需将cursor代表的索引值加1,如图所示。
在这里插入图片描述

2.1.2 List集合实现—Vector

java.util.Vector集合是从JDK1.0开始就开始提供的一种List集合,其主要的继承体系如图所示。
在这里插入图片描述
Vector集合是支持数据对象的随机访问,大小可变、保证线程安全等。

部分源码:java.util.Vector

public class Vector<E>
        extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    //该数组主要用于存储Vector集合中的所有数据对象
    //该数组的容量可以扩展,并且扩展后的数组容量足以存储已写入Vector集合的所有数据对象
    //该数组的容量值可以大于当前已写入Vector集合的所有数据对象数量,多出来的数组位置上的值都为null
    //该数组的初始化大小由构造方法中的initialCapacity 参数决定,initialCapacity参数的默认值为10
    protected Object[] elementData;

    //这个变量主要用于记录当前Vector集合中的数据对象数量
    //在后续的代码中,可以发现这个值在整个操作过程中起到了验证作用
    //例如,判断数据对象的索引值是否超过了最大索引值
    protected int elementCount;

    //Vector集合支持扩容操作,即对其中的elementData数组进行容量变化操作
    //capacityIncrement 变量表示每次扩容的大小
    //如果capacityIncrement的值小于或等于0,那么在进行扩容操作后,集合容量变为原容量的2倍
    protected int capacityIncrement;

}

Vector集合的基本结构包括一个数组、一个指向当前数组的可验证边界、一个数组扩容的参考值,主要核心是这三个;
在这里插入图片描述

2.1.2.1 扩容操作
2.1.2.1.1 什么时候扩容?

Vector集合需要进行扩容操作的情况有多种。

  • Vector集合在初始化时会进行扩容操作,它的elementData数组会进行初始化;
  • 在Vector集合中,当数据对象数量elementCount的值大于elementData数组的最大容量capacity的值时,也会进行扩容操作;
    这时,elementData数组中的数据对象会被复制到另一个更大的数组中,并且elementData数组变量的引用会重新指向后者;
  • 当Vector集合的调用者明确要求重新确认集合容量时,也可能会进行扩容操作。

如果没有在Vector集合初始化时指定集合的初始化容量(initialCapacity),则会将初始化容量值设置为10
如果没有在Vector集合初始化时指定扩容增量(capacityIncrement),则会将扩容增量值设置为0。
如果扩容增量值被设置成了0,那么在随后进行的每次扩容操作中,elementData数组扩容后的容量都会变为扩容前容量的2倍,即10->20->40->80……以此类推。
在这里插入图片描述

add()方法的操作过程分为以下两种情况。

  • 当集合中对象数据数量elementCount的值小于当前elementData数组容量的值时,不必对elementData数组进行扩容操作,直接在elementData数组中的elementCount号索引位上添加新的数据对象即可。
  • 当集合中数据对象数量elementCount的值大于或等于elementData数组容量的值时,需要先对elementData数组进行扩容操作,再在elementCount号索引位上添加新的数据对象。
  • 当调用者明确要求重新确认Vector集合容量时,也可能会进行扩容操作。例如,Vector集合中的setSize(int)方法允许调用者重新为Vector集合设置一个容量值,如果这个容量值大于当前Vector集合的容量值,则会进行扩容操作;如果新的容量值小于当前容量值,则会将多余的数据对象丢弃
    public synchronized void setSize(int newSize) {
        modCount++;
        //如果新的容量值大于当前数组的容量值,则进行扩容操作
        if (newSize > elementCount) {
            ensureCapacityHelper(newSize);
        } else {
            //如果新的容量值小于当前数组的容量值,那么从新的容量值位置开始,将之后索引位上的数据对象都设置为null
            for (int i = newSize ; i < elementCount ; i++) {
                elementData[i] = null;
            }
        }
        //将新的容量值作为当前数据对象数量计数器的值
        elementCount = newSize;
    }
2.1.2.1.2 扩容详细过程
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //判定数组扩容后的新容量
        //capacityIncrement 和0的比较结果将决定在一般场景中获得的增量值
        int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                capacityIncrement : oldCapacity);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

根据上述源码可知,如果当前Vector集合没有在实例化时指定增量capacityIncrement的值,那么在一般情况下,每次扩容增加的容量都是当前容量的1倍;如果当前Vector集合在实例化时指定了增量capacityIncrement的值,那么在一般情况下,会按照指定的增量capacityIncrement的值进行扩容操作。
在这里插入图片描述

2.1.2.1.3 是否可以缩容?

可通过Vector集合中的trimToSize()方法。
在这里插入图片描述

2.1.2.2 修改操作

set(int,E)方法主要用于在指定索引位上设置新的数据对象,设置的数据对象可以为null。
该方法有两个参数,第一个参数为int型数据,表示索引位;第二个参数为需要在这个索引位上设置的新的数据对象。

  • 可以指定索引位的有效范围上限。不是依据当前Vector集合中elementData数组大小的值,而是依据当前Vector集合中存在的数据量elementCount的值(elementCount的值在Vector集合中的另一个含义就是Vector集合的大小)。
  • 该方法有一个返回值,这个返回值会向调用者返回指定索引位上变更之前的值。
    public synchronized E set(int index, E element) {
        //如果指定的索引值大于或等于elementCount值则抛出超界异常
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);
        //原始的值会在替换操作之前被保存下来,以便进行返回
        E oldValue = elementData(index);
        //将elementData 数组的指定位置的值替换成新的值
        elementData[index] = element;
        return oldValue;
    }
2.1.2.3 删除操作

removeElementAt(int)方法主要用于移除Vector集合中elementData数组指定索引位上的数据对象,并且改变其索引位的指向。
在这里插入图片描述
图展示了removeElementAt(int)方法的运行实质:以当前指定的索引位为起点,将后续数据对象的索引位依次向前移动。

在这里插入图片描述
上述源码中的System.arraycopy(Object src,int srcPos,Object dest,int destPos,int length)方法。
该方法是一种JNI native方法,是JDK提供的用于进行两个数组中数据对象复制的性能最好的方法之一。
该方法的参数如下:

  • src:该参数只能传入数组,表示当前进行数组复制的源数组。
  • srcPos:表示在源数组中进行复制操作的起始位置。
  • dest:该参数同样只能传入数组,表示当前进行数组复制的目标数组。
  • destPos:表示在目标数组中进行复制操作的起始位置。
  • length:用于指定进行复制操作的长度。

上述源码使用System.arraycopy()方法的意图如图所示。
在这里插入图片描述
在图中,虽然完成了数组自身的数据移动,但这时数组中最后一个索引位上的数据对象并没有改变,所以需要手动减小数组中的数据值,并且手动设置最后一个索引位上的数据对象为null。所以上述源码中会出现如下内容。
在这里插入图片描述

2.1.3 List集合实现—ArrayList

ArrayList集合是JCF中非常重要的集合之一,也是实际工作中最常使用的集合之一。
ArrayList集合拥有与Vector集合类似的接口和操作逻辑(从JDK1.2开始提供),但它不支持线程安全操作(Vector集合支持线程安全操作,但是基于线程安全的多线程操作性能不高)。
ArrayList集合也支持随机访问,也就是说,ArrayList集合在单线程下对指定索引位上的数据读取操作的时间复杂度为O(1)。

ArrayList集合的主要继承体系如图所示。
在这里插入图片描述

2.1.3.1 概述

ArrayList集合是在单线程操作场景中最常使用的List集合之一。

该集合的内部结构是一个数组,并且这个数组在需要的时候可以进行扩容操作。

ArrayList集合允许将数据对象添加到数组的任意有效索引位上,并且允许从数组的任意有效索引位上获取数据对象。

描述ArrayList集合中重要全局变量和常量的源码如下。

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private static final long serialVersionUID = 8683452581122892189L;


    /**
     * 该常量表示默认的初始化容量,实际上这个值很少使用
     * 在使用默认的构造方法的情况下,第一次确认容量时
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * 该常量会在初始化集合时使用,用于将elementData初始化为一个空数组
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

    /**
     * 该常量会在第一次添加数据时使用,默认情况下,用作集合第一次扩容的判断依据
     */
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    /**
     * 该数组变量主要存储各个数据变量引用
     * 为了简化嵌套类访问限制,没有使用private修饰
     */
    transient Object[] elementData; // non-private to simplify nested class access

    /**
     * 记录当前集合的容量
     *
     * @serial
     */
    private int size;
...

}

从全局变量的定义方式来看,ArrayList集合和Vector集合的工作思路类似:都支持随机访问,都继承了AbstractList抽象类,都使用数组存储数据对象,但这两种集合在细节处理上存在较大差异。

2.1.3.2 初始化操作和扩容操作
2.1.3.2.1 带参数的初始化方法:
  /**
     * 该构造器方法主要用于ArrayList集合设置一个指定大小的初始容量
     * 并且将集合中所有索引位上的数据对象赋值为null
     * 参数initialCapacity主要用于设置ArrayList集合的初始容量
     */
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            //容量不能小于0
            throw new IllegalArgumentException("Illegal Capacity: "+
                    initialCapacity);
        }
    }
2.1.3.2.2 默认初始化方法
   private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};


    /**
     * 默认设置一个初始容量为0的ArrayList集合
     */
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
2.1.3.2.3 使用一个参照集合初始化
    /**
     * 使用一个参照集合,对ArrayList集合进行初始化操作
     */
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

根据上述源码片段可知,如果在进行初始化时不指定ArrayList集合的容量,那么ArrayList集合会被初始化成一个容量为0的集合
对于上述源码片段中的默认构造方法,官方给出的初始化意义是“Constructs an empty list with an initial capacity of ten”。

这是因为当ArrayList集合处于这种状态时,后续在向ArrayList集合添加新数据对象时,无论是使用add(E)方法,还是使用add(int,E)方法(或其他方法),ArrayList集合都会使用grow(int)方法将elementData数组扩容成一个新的容量为10的数组

grow(int)方法是ArrayList集合实际进行扩容操作的方法。
由于一个数组在完成初始化后,其容量不能改变。因此ArrayList集合实际的扩容机制是通过某种规则创建一个容量更大的数组,并且按照一定的逻辑将原数组中的数据对象依次复制(引用)到新的数组中。grow(int)方法的完整源码如下。
在这里插入图片描述
ArrayList集合的扩容操作主要分为两种情况:

  • 在进行扩容操作前,ArrayList集合容量值不为0。
    处理逻辑为,以传入的minCapacity参数值和原始容量默认的50%增量值的比较结果为依据进行扩容操作。
    一般会按照原始容量默认的50%增量值进行扩容操作。
  • 在进行扩容操作前,ArrayList集合的容量值为0。
    这种情况实际上就是ArrayList集合使用默认的构造方法刚完成初始化操作的情况——只要之前发生了一次添加操作,ArrayList集合就不会是这样的状态。

扩容操作的两种情况图
在这里插入图片描述

2.1.3.3 add(E) 方法

ArraylList集合中的add(E)方法和Vector集合中的add(E)方法功能类似,其处理过程都可以概括如下:

  • 如果集合中还有多余索引位可以存储数据对象,那么直接在数组最后一个有效索引位的下一个索引位上添加新数据对象;
  • 如果集合中没有多余的索引位可以存储数据对象,那么先进行扩容操作,再进行新数据对象的添加操作。

两个add(E)方法都是在当前集合中的有效索引位(尾部)的下一个索引位上进行数据对象的添加操作,但两种集合定义记录尾部的属性不一样。

  • ArrayList集合使用size属性记录尾部
  • Vector集合使用elementCount属性记录尾部。

注意,这里的“尾部”并不是用elementData数组的length属性表示的。

相关源码如下:

   /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

 /**
     * Inserts the specified element at the specified position in this
     * list. Shifts the element currently at that position (if any) and
     * any subsequent elements to the right (adds one to their indices).
     *
     * @param index index at which the specified element is to be inserted
     * @param element element to be inserted
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }


    public void add(E e) {
            checkForComodification();
            try {
                int i = cursor;
                ArrayList.this.add(i, e);
                cursor = i + 1;
                lastRet = -1;
                expectedModCount = modCount;
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

    public void add(int index, E e) {
            rangeCheckForAdd(index);
            checkForComodification();
            parent.add(parentOffset + index, e);
            this.modCount = parent.modCount;
            this.size++;
        }

ArrayList集合中的add(E)方法并不是线程安全的,Vector集合中的add(E)方法,虽然加入了保证线程安全性的机制,但仍然不适合用于高并发场景中。
使用add(int,E)方法,调用者可以在指定的有效索引位上插入一个新的数据对象,在插入新数据对象前,这个索引位上的数据对象会向后移动,

2.1.4 Vector集合与ArrayList集合对比

2.1.4.1 对集合的内部结构进行对比
  • Vector集合和ArrayList集合的内部结构都是数组,甚至代表数组的变量名都一样(elementData)。
    Vector集合中数组的初始化容量值默认为10,并且使用者可以指定Vector集合的初始化容量值。
  • ArrayList集合中数组的默认初始化容量值也为10,也可以指定集合中数组的初始化容量值,但如果使用者没有指定初始化容量值,那么ArrayList集合中的elementData数组会被初始化为一个容量值为0的空数组。
2.1.4.2 对扩容逻辑进行对比
  • Vector集合默认采用当前容量的1倍大小进行扩容操作,而且Vector集合可以指定一个固定的扩容增量(capacityIn-
    crement),但除非使用者很明确Vector集合即将承载的数据量规模,否则不推荐使用这种方法,因为固定的扩容增量要么导致频繁扩容,要么比必要扩容浪费更多的存储空间。
  • ArrayList集合在进行扩容操作时会将当前容量增大50%,并且扩容逻辑不能干预,除非扩容前容量值小于10(如果发生这样的情况,则首先扩容到10)。
    ArrayList集合的扩容逻辑相对动态,这保证了在扩容操作频率和扩容大小之间更好的平衡性。
2.1.4.3 对线程安全性保证进行对比
  • Vector集合的线程安全性体现在该集合提供给外部调用者使用的读/写方法中都会使用synchronized修饰符进行修饰(Object Monitor模式)。
  • ArrayList集合并不是线程安全的,官方也不推荐在多线程场景中使用ArrayList集合。
    如果使用者强行这么做,那么ArrayList集合很可能出现“脏数据”问题(实际上ArrayList集合会在迭代器中通过modCount全局变量标记的操作数进行一些避免“脏读”问题的限制,这是一种CAS思想的借鉴)。
2.1.4.4 对序列化过程进行对比
  • Vector集合并没有对集合的序列化过程和反序列化过程进行特殊优化处理(虽然重写了readObject()方法和writeObject()方法)。在序列化过程中,当前elementData数组中多余的索引位会一起被序列化,这很明显产生了不必要的性能消耗。
  • ArrayList集合会对集合的序列化过程和反序列化过程进行针对性的优化处理,在对ArrayList集合进行序列化时,只会对elementData数组中已使用的索引位进行序列化,未使用的索引位不会被序列化;相对地,在原ArrayList集合中已被序列化的各个数据对象被反序列化成新的ArrayList集合中的数据对象时,新的elementData数组不会产生多余的容量,只有在下一次被要求向该集合中添加数据对象时,才会开始新一轮的扩容操作。

2.1.5 List集合实现—Stack

顾名思义,Stack集合的工作效果符合栈结构的定义。
栈结构是指能使集合中的数据对象具有后进先出(LIFO)操作特性的集合结构
如图所示。最初的JDK版本就已经提供了java.util.Stack集合。
在这里插入图片描述
java.util.Stack集合的主要继承体系如图所示。
在这里插入图片描述
可以看出java.util.Stack集合继承自java.util.Vector集合。
也就是说,Stack集合除了具有Vector集合的所有操作特性,还具有栈结构的操作功能。
例如:

  • Stack集合同样有线程安全操作特性(但同样不适合在高并发场景中使用)
  • Stack集合的扩容增量通常也是当前容量的1倍;
  • Stack集合也不会对集合的序列化过程和反序列化过程进行特殊优化处理。

注意:java.util.Stack集合在JDK1.6后就不再推荐使用,这里也只是出于学习JCF设计思路、演进过程的目的,才对该集合进行介绍。

在实际工作中,如果需要在无须保证线程安全性的场景中使用栈结构(如局部变量),
那么官方推荐使用java.util.ArrayDeque集合;
如果需要在保证线程安全性的场景中使用栈结构,那么官方推荐使用java.util.concurrent.LinkedBlockingDeque集合。


package java.util;


public class Stack<E> extends Vector<E> {
   
    public Stack() {
    }
    
    public E push(E item) {
        addElement(item);

        return item;
    }
    
    public synchronized E pop() {
        E       obj;
        int     len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }
    
    public synchronized E peek() {
        int  len = size();

        if (len == 0)
            throw new EmptyStackException();
        return elementAt(len - 1);
    }
    
    public boolean empty() {
        return size() == 0;
    }

    public synchronized int search(Object o) {
        int i = lastIndexOf(o);

        if (i >= 0) {
            return size() - i;
        }
        return -1;
    }
    
    private static final long serialVersionUID = 1224463164541339165L;
}

Stack集合的内部结构仍然是一个数组,使用数组的尾部模拟栈结构的栈顶,使用数组的头部模拟栈结构的栈底。
以push()方法为例,该方法将传入的数据对象放置在栈结构的栈顶,使其作为新的栈顶数据对象。

注意:由于Stack集合使用elementCount变量的值,在elementData数组的指定索引位上模拟栈结构的栈顶,因此push()方法的实际操作就是调用Vector集合中的addElement(item)方法,在elementCount变量代表的数组尾部添加一个新的数据对象。
数组和栈结构的转换示意图如图所示。
在这里插入图片描述
Stack集合和Vector集合的核心设计思想、实现的功能、稳定性都没有任何问题,但CF已不再推荐使用它们,因为Stack集合和Vector集合的所有主要工作特性都有更合适的集合可以实现。

  • Stack集合和Vector集合虽然是线程安全的,但在适用场景中已被专门用于高并发场景中的处理集合LinkedBlockingDeque、CopyOnWriteArrayList等代替。
  • Stack集合和Vector集合没有针对序列化过程和反序列化过程进行任何优化,而工作效果相似的ArrayDeque集合、ArrayList集合都对序列化过程的反序列化过程进行了优化。
  • Stack集合和Vector集合的内部结构都是数组,所以都存在数组扩容操作的问题,主要表现为每次将当前容量增大1倍的扩容方式不够灵活。如果容量较小,那么这种扩容方式的适应性还好;如果容量基数较大,那么这种扩容方式容易造成不必要的浪费,并且有更高的容量超界风险。相较而言,ArrayList集合提供的按照50%的标准进行扩容操作的方式适应性更好。

2.1.6 List集合实现—LinkedList

LinkedList集合同时具有List集合和Queue集合的基本特性。
java.util.LinkedList集合的主要继承体系如图所示。
在这里插入图片描述

根据图可知,LinkedList集合同时实现了List接口和Queue接口;

2.1.6.1 概述

LinkedList集合的主要结构是双向链表。
双向链表中的节点不要求有连续的内存存储地址,因此在向双向链表中插入新节点时,无须申请一块连续的存储空间,只需按需申请存储空间。
LinkedList集合中的链表的每个节点都使用一个java.util.LinkedList.Node类的对象进行描述。
链表的基本结构如图所示。
在这里插入图片描述
根据图可知,LinkedList.Node类有3个重要属性。

  • item属性:该属性主要用于在当前Node节点上存储具体的数据对象。
  • next属性:该属性的类型也是LinkedList.Node,表示当前节点指向的下一个节点。
  • prev属性:该属性的类型也是LinkedList.Node,表示当前节点指向的上一个节点。

在图中,双向链表的头节点和尾节点的特点如下。

  • 头节点的prev属性在任何时候都为null。
  • 尾节点的next属性在任何时候都为null。

LinkedList.Node类的详细定义源码如下。
在这里插入图片描述
LinkedList集合采用如下手段对内部的双向链表进行描述:

  • 使用first属性记录双向链表的头节点;
  • 使用last属性记录双向链表的尾节点;
  • 使用size变量记录双向链表的当前长度。

LinkedList集合的源码片段如下。

package java.util;

import java.util.function.Consumer;



public class LinkedList<E>
        extends AbstractSequentialList<E>
        implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    //记录当前双向链表的长度
    transient int size = 0;

    //记录当前双向链表的头节点
    transient Node<E> first;

    //记录当前双向链表的尾节点
    transient Node<E> last;

    public LinkedList() {
    }
    
    public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }
}    

LinkedList集合的以下内部状态场景都是正常:

  • first为null&&last为null:
    当双向链表中没有任何数据对象时,first属性和last属性一定都为null
    在这里插入图片描述
  • first和last相同:
    当双向链表中只有一个数据对象时,first属性和last属性一定指向同一个节点;
    在这里插入图片描述
  • first和last节点都不为null
    当双向链表中至少有一个数据对象时,first属性和last属性都不可能为null
    在这里插入图片描述

综上: first和last节点要么都为null,要么都不为null,不可能同时存在一个为null和一个不为null的场景;

2.1.6.2 添加

从JDK1.8开始,LinkedList集合中有3个用于在链表的不同位置添加新的节点的关键方法,分别是

  • linkFirst(E)方法
  • linkLast(E)方法
  • linkBefore(E,Node)方法。

值得注意的是,这3个方法都不是用public修饰的方法
在这里插入图片描述

从源码可以看出,这些方法实际上要通过add(E)、addLast(E)、addFirst(E)等方法进行封装,然后才能对外提供服务。
所以只要了解了以上3个方法的工作过程,就基本可以理解在LinkedList集合中添加数据对象的工作思路了。

2.1.6.2.1 linkFirst 方法

    private void linkFirst(E e) {
        //使用一个临时变量记录操作前 first 属性中的信息
        final LinkedList.Node<E> f = first;
        // 创建一个数据信息为e 的新节点,此节点的前置节点引用为null,后置节点引用指向原来的头节点
        final LinkedList.Node<E> newNode = new LinkedList.Node<>(null, e, f);
        //由于要在双向链表的头部添加新的节点,因此实际会基于newNode节点将first属性中的信息进行重设置
        first = newNode;
        //如果条件成立,则说明在进行添加操作的时候,双向链表中没有任何节点,
        //因此需要将双向链表中的last属性也指向新节点,让first属性和last属性指向同一个节点
        if (f == null)
            last = newNode;
        else
        //如果条件不成立,说明在操作的双向链表中至少有一个节点,
        //因此只需要将原来头部节点的前置节点引用指向新的头节点 newNode
        f.prev = newNode;
        //双向链表长度+1
        size++;
        //LinkedList集合操作次数+1
        modCount++;
    }

简单示意图如下所示:
在这里插入图片描述

2.1.6.2.2 linkLast方法

使用linklast(E)方法可以在当前双向链表的尾节点之后添加一个新的节点,并且调整当前last属性的指向位置。
该方法的源码如下。

  void linkLast(E e) {
        //使用一个临时变量记录操作前last属性中的信息
        final LinkedList.Node<E> l = last;
        //创建一个新节点,其item属性值为e,新节点的前置节点引用为链表原来的尾节点,后置节点引用为null
        final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
        //由于要在双向链表的尾部添加新节点,因此实际上会基于newNode节点将last属性中的信息重设置
        last = newNode;
        //如果条件成立,则说明在进行添加操作时双向链表中没有任何节点,
        //因此需要将双向链表中的first属性和last属性指向同一个节点
        if (l == null)
            first = newNode;
        else
         //如果不成立,则说明至少存在一个节点,那么只需要将原来尾节点的后置节点引用指向新的尾节点newNode
         l.next = newNode;
        //双向链表长度+1
        size++;
        //操作次数+1
        modCount++;
    }

在这里插入图片描述

2.1.6.2.3 LinkBefore(E,Node)方法

使用LinkBefore(E,Node)方法可以在指定的节点前的索引位上插入一个新节点。
需要注意的是,LinkedList集合的操作逻辑可以保证这里的succ入参一定不为null,并且一定已经存储于当前LinkedList集合中的某个位置。该方法的源码如下。

    void linkBefore(E e, LinkedList.Node<E> succ) {
        //创建一个变量pred,用于记录当前succ节点的前置节点引用(可能为null)
        final LinkedList.Node<E> pred = succ.prev;
        //创建一个新的节点newNode,该节点的前置节点引用指向succ节点的前置节点
        final LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, succ);
        //将succ节点的前置节点重新设置为创建的新节点newNode
        succ.prev = newNode;
        //如果条件成立,则说明当前succ节点原本就是双向链表的头节点
        //也就是说,当前操作的实质是在链表的头部增加一个新节点
        //这时将LinkedList集合中指向节点的first属性指向新创建的节点newNode
        if (pred == null)
            first = newNode;
        else
            //在其他情况下,将pred属性指向节点的后置节点设置为当前创建的新节点 newNode
            pred.next = newNode;
        size++;
        modCount++;
    }

在这里插入图片描述

2.1.6.3 删除

LinkedList集合中有3个负责移除集合中数据对象的关键方法,分别为

  • unlinkFirst(Node)方法
  • unlinkLast(Node)方法
  • unlink(Node)方法。
2.1.6.3.1 unlinkFirst(Node)方法

使用unlinkFirst(Node)方法可以移除LinkedList集合中双向链表的头节点,并且重新设置它的后续节点为新的头节点。
需要注意的是,该方法的入参f就是当前双向链表的头节点引用(入参f在经过调用者处理后一定不为nul)。


    private E unlinkFirst(LinkedList.Node<E> f) {
        //定义一个element变量,用于记录当前双向链表头节点中的数据对象
        //以便方法最后将其返回
        final E element = f.item;
        //创建一个next变量,用于记录当前双向链表头节点的后置节点引用.
        //注意该变量的值可能为null
        final LinkedList.Node<E> next = f.next;
        //设置当前双向链表头节点中的数据对象为null,后置节点引用为null
        f.item = null;
        f.next = null; // help GC
        //设置双向链表中新的头节点为当前节点的后续节点
        first = next;
        //如果条件成立,则说明在完成头节点的移除操作后,双向链表中已经没有任何节点了
        if (next == null)
            last = null;
        else
        //在其他情况下,设置新的头节点的前置节点引用为null,
        // 因为原来的前置节点引用指向操作前的头节点    
         next.prev = null;
        //双向链表长度-1
        size--;
        //LinkedList集合的操作次数+1
        modCount++;
        return element;
    }

在这里插入图片描述

2.1.6.3.2 unLinkLast(Node)方法

使用unlinkLast(Node)方法可以移除LinkedList集合中双向链表的尾节点,并且重新设置它的前置节点为新的尾节点。需要注意的是,该方法的入参就是当前双向链表的尾节点引用(入参|在经过调用者处理后一定不为null)。

 private E unlinkLast(LinkedList.Node<E> l) {
        //定义一个element变量,用于记录当前双向链表头节点中的数据对象
        //以便方法最后将其返回
        final E element = l.item;
        //创建一个prev变量,用于记录当前双向链表尾节点的后置节点引用.
        //注意该变量的值可能为null
        final LinkedList.Node<E> prev = l.prev;
        //设置当前双向链表尾节点中的数据对象为null,前置节点引用为null
        l.item = null;
        l.prev = null; // help GC
        //设置双向链表中的前置节点为尾节点
        last = prev;
        //如果条件成立,则说明在完成尾节点的移除操作后,双向链表中已经没有任何节点了
        if (prev == null)
            first = null;
        else
        //在其他情况下,设置新的尾节点的前置节点引用为null,
        prev.next = null;
        //双向链表长度-1
        size--;
        //LinkedList集合的操作次数+1
        modCount++;
        return element;
    }

在这里插入图片描述

2.1.6.3.3 unLink(Node)方法

使用unlink(Node)方法可以从双向链表中移除指定节点。
该方法不能被LinkedList集合的使用者直接调用,集合内部在经过相关调整者处理后,其入参x所指向的节点一定位于双向链表中。
该方法的源码如下。

  E unlink(LinkedList.Node<E> x) {
        //定义一个element变量,用于记录当前节点中的数据对象,该值可能为null
        final E element = x.item;
        //创建一个next变量,用于记录当前节点的前置节点引用,该值可能为null
        final LinkedList.Node<E> next = x.next;
        //创建一个prev变量,用于记录当前节点的后置节点引用,该值可能为null
        final LinkedList.Node<E> prev = x.prev;
        //如果条件成立,则说明被移除的x节点是双向链表的头部
        //这个时候将x节点的后置节点设置为新的头节点
        if (prev == null) {
            first = next;
        } else {
            //其他情况下,将x节点的前置节点的后置节点引用跳过x节点,
            // 将x节点的前置节点引用设置为null
            prev.next = next;
            x.prev = null;
        }
       //如果条件成立,则说明被移除的x节点是双向链表的尾部
        //这个时候将x节点的后置节点设置为新的尾节点
        if (next == null) {
            last = prev;
        } else {
            //其他情况下,将当前x节点的后置节点的前置节点引用跳过x节点
            // 将x节点的后置节点引用设置为null
            next.prev = prev;
            x.next = null;
        }
        //将x节点的数据对象设置为null
        x.item = null;
        //双向链表长度-1
        size--;
        //LinkedList集合的操作次数+1
        modCount++;
        return element;
    }

在这里插入图片描述

2.1.6.4 查询

当java.util.LinkedList集合中的其他方法调用linkBefore(E,Node)方法插入新节点,或者调用unlink(Node)方法移除指定节点时,都需要先找到这个要被操作的节点。
由于双向链表的构造结构,LinkedList集合不可能像ArrayList集合那样,通过指定一个数值便可以定位到指定索引位,因此LinkedList集合一定会涉及查询操作。双向链表查询指定索引位的方式,就是从头节点或尾节点开始进行遍历。
具体的实现方法为java.util.LinkedList集合中的node(int)方法,源码如下

    /**
     * 注意: 该方法的返回结果不为null
     * 入参 index 为当前操作要查询的节点索引值,开始index值为0
     */
    LinkedList.Node<E> node(int index) {
        //如果条件成立,则说明当前指定的index号索引位在双向链表中的前半段
        //则从当前双向链表的头节点开始向后依次查询
        if (index < (size >> 1)) {
            LinkedList.Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            //否则说明当前指定的index号索引位在双向链表中的后半段
            //则从当前双向链表的尾节点开始向前依次查询
            LinkedList.Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

node(int)方法在java.util.LinkedList集合中的使用具有普遍性,addAll(int,Collection)方法、get(int)方法、set(int,E)方法、add(int,E)方法、remove(int)方法等的读/写操作都使用node(int)方法查询双向链表中的指定索引位,如图所示。
在这里插入图片描述
有一些方法比较特殊,如indexof(Object)方法、lastIndexOf(Object)方法、remove(Object)方法,这些方法不使用node(int)方法查询指定索引位,它们使用指定的数据对象引用信息查询指定索引位。

2.1.6.4.1 使用LinkedList集合的栈工作特性

LinkedList集合既实现了List接口,又实现了Deque接口。
它是JCF中为数不多的同时要求具备List集合工作特性和Queue(Deque)集合工作特性的集合。

而Deque接口具有传统队列的特殊定义:我们知道,JCF中的Queue接口代表队列结构的普遍性操作(如入队操作和出队操作),而Deque是双端队列,它支持从队列的两端独立检索和添加节点;
因此Deque接口既支持LIFO(后进先出)操作模式,又支持FIFO(先进先出)操作模式,并且具有由操作者自行决定出入队列顺序的操作方式。

这也解释了为什么在JDK1.2后、JDK1.6前,官方都推荐使用LinkedList集合代替Stack集合——因为LinkedList集合完全适合用于栈结构的所有操作,并且提供了更丰富的操作定义形式。
在这里插入图片描述

2.1.6.5 LinkedList集合与ArrayList集合的对比
2.1.6.5.1 ArraylList集合写操作

基于数组结构的ArraylList集合在进行写操作时,会存在以下可能的处理情况:

  • 当使用add(E)方法在数组的尾部(size变量所指示的位置)添加新的数据对象时,在大部分情况下,数组中都有多余容量,以便直接进行添加操作。由于数组的索引特性,要找到正确的添加索引位,基本上没有时间消耗,时间复杂度为O(1)。
  • 当使用add(E)方法在数组的尾部(size变量所指示的位置)添加新的数据对象时,在数组中没有多余容量(根据elementData.length属性确认)的情况下,数组会先进行扩容操作,这时会有较多的时间消耗。
  • 基于数组构造的ArrayList集合,理论上写操作性能最差的情况是一直在数组中的0号索引位上使用add(int,E)方法添加新的数据对象,即一直在数组中的第一个索引位(头部)上添加新的数据对象。在这种情况下,数组每添加一个新的数据对象,都会将当前数组中的所有数据对象整体向后移动一个索引位,并且在数组中没有多余容量的情况下,会首先进行扩容操作。

以上极端情况完全可以通过编程技巧进行规避:
每次都在数组的末尾添加新数据对象,在进行数据遍历时,从数组尾部开始向数组头部进行读取(反向读)。
这样,在通常情况下,可以保证数组的写操作性能维持在一个较高的、稳定的级别。

2.1.6.5.2 LinkedList集合写操作

基于链表结构的LinkedList集合在进行写操作时,会存在以下可能的处理情况:

  • 如果在双向链表的头节点或尾节点中添加新的数据对象(使用addFirst(E)方法、addlast(E)方法、add(E)方法等),那么只需更改头节点或尾节点的引用信息。在这种操作场景中,没有任何查询索引位的时间消耗,所以无论当前双向链表的容量有多大,操作的时间复杂度均为O(1)。
  • 如果添加操作并不在当前双向链表的头节点或尾节点位置进行,而是在靠近双向链表中部的位置进行,那么无论是从双向链表的头节点开始查询,还是从双向链表的尾开始查询,找到正确索引位的时间复杂度都为O(n)。
2.1.6.5.3 小总结

根据以上比较可知:

  • 在一般情况下,两种集合在尾部顺序添加新数据对象时,ArrayList集合的添加操作并不比LinkedList集合的添加操作性能差,二者的时间复杂度都为O(1);
  • 只有在ArrayList集合的容量为特定值的情况下,ArrayList集合才会先进行扩容操作,这时ArrayList集合的写操作性能低于LinkedList集合的写操作性能。如果在进行添加操作前已经知道数据的规模,那么这种扩容操作也是完全可以避免的一—直接将数组容量初始化为数据规模需要的值。
  • 如果添加操作并不是在两种集合的尾部进行的,那么两种集合的处理情况完全不一样,通常基于数组结构的ArrayList集合会进行数据移动操作。但幸运的是,大部分实际操作场景都不是这样的。
2.1.6.5.4 ArraylList集合读操作

基于数组结构的ArrayList集合在进行读操作时,会存在以下可能的处理情况:

  • 无论操作者是要遍历数组中的数据对象,还是要在数组中的某个指定索引位上读取数据对象,读操作性能的消耗都是相同的,时间复杂度均为O(1),因为ArrayList集合支持随机访问。

ArrayList集合的查询性能非常好,所以在其工作逻辑中并没有对读操作进行独立优化。
例如,ArrayList集合也可以使用Iterator迭代器进行数据遍历,但实际上其操作过程并没有进行特别的优化。

2.1.6.5.5 LinkedList集合读操作

相较而言,基于链表结构的LinkedList集合在进行读操作时,存在的处理情况要复杂得多。

  • 如果操作者从双向链表的头节点或尾节点读取数据
    那么由于头节点和尾节点分别有first属性和last属性进行标识,因此不存在查询过程的额外耗时,直接读取数据即可。
  • 如果操作者并非在双向链表的头节点或尾节点读取数据
    那么肯定存在查询过程,而查询过程都是依据节点间的引用关系遍历双向链表的。不过LinkedList集合对查询过程做了一些优化处理。
    例如,根据当前指定的索引位是在双向链表的前半段,还是在双向链表的后半段,确定是从双向链表的头节点开始查询,还是从双向链表的尾节点开始查询。
  • 双向链表查询性能最差的情况是查询接近双向链表中部的数据对象
    这时双向链表并没有很好的优化方式,无论是从头节点开始查询,还是从尾节点开始查询,性能消耗都差不多。

总而言之,在双向链表中查询指定索引位上数据对象的平均时间复杂度为O(n)。

2.2 JCF中的Queue、Deque集合

Queue(队列)、Deque(双端队列)集合是JCF中另一种重要的集合。

队列存储的数据允许从结构的一端进行添加操作(入队操作),并且从结构的另一端进行移除操作(出队操作)。

  • 进行入队操作的一端称为队列尾部;
  • 进行出队操作的一端称为队列头部。

双端队列是指可以在一端既进行入队操作,又进行出队操作的队列结构。

注意:队列和双端队列都不允许在除了队列头部或队列尾部的其他索引位上进行数据的读/写操作。

  • 具有队列操作特性的集合都实现或间接实现了java.util.Queue接口;
  • 具有双端队列操作特性的集合都实现或间接实现了java.util.Deque接口。

JCF中的java.util.Queue接口、java.util.Deque接口涉及的部分重要接口、抽象类及其在java.util包中的具体实现类如图所示。
在JCF中,还有大量关于Queue(队列)接口和Deque(双端队列)接口的实现集合位于java.util.concurrent包中,这些集合都是可以工作在高并发场景中的队列或双端队列,如LinkedBlockingDeque队列、ArrayBlockingQueue队列、PriorityBlockingQueue队列等。
在这里插入图片描述
java.util.ArrayDeque集合和java.util.PriorityQueue队列分别是Queue(队列)接口和Deque(双端队列)接口在JCF中的基础集合。ArrayDeque集合为了保证对已有数组控件的充分利用,使用的是可循环的双指针数组(但不代表不进行扩容操作);
PriorityQueue队列使用的是小顶堆结构,对基于权值的排序性能进行了优化。

2.2.1 Queue集合实现—ArrayDeque

ArrayDeque集合是从JDK1.6开始推出的,它是一个基于数组(可扩容的数组)结构实现的双端队列。
与普通的数组结构相比,这种数组结构是一种可循环使用的数组结构,可以有效减少数组扩容的次数。
ArrayDeque集合是线程不安全的,不能在多线程场景中使用。

ArrayDeque集合既有队列、双端队列的操作特点,又有栈结构的操作特点。
因此在JDK1.6发布后,ArrayDeque集合是官方推荐的继Stack集合和LinkedList集合后,用于进行栈结构操作的新集合。
ArrayDeque集合实现了Deque接口(间接实现了Queue接口),因此同时具有队列和双端队列的工作特点;

2.2.1.1 主要结构

ArrayDeque集合内部的主要结构是一个数组,ArrayDeque集合的设计者将这个数组设计成了可循环利用的形式,称为循环数组。

循环数组是一个固定大小的数组,并且定义了一个动态的有效数据范围(这个有效数据范围的长度不会大于数组的固定长度),只有在这个有效数据范围内的数据对象才能被读/写,并且这个有效数据范围不受数组的头部和尾部限制,如图所示。
在这里插入图片描述
根据上图可知,ArrayDeque集合的内部结构是一个循环数组,该循环数组在ArrayDeque集合中的变量名为elements;

  • ArrayDeque集合中有一个名为head的属性,主要用于标识下一次进行移除操作的数据对象索引位(队列头部的索引位);
  • ArrayDeque集合中还有一个名为tail的属性,主要用于标识下一次进行添加操作的数据对象索引位(队列尾部的索引位)。
  • head属性和tail属性所标识的有效数据范围在不停地变化,甚至有时tail属性记录的索引值会小于head属性记录的索引值,但这丝毫不影响它们对有效数据范围的标识。

将上图的循环数组展开,如下图所示,可以发现,tail属性记录的索引值小于head属性记录的索引值。
在这里插入图片描述
当tail属性指向数组中的最后一个索引位并进行下一次添加操作时,数组不一定进行扩容操作,更可能发生的情况是,tail属性重新从当前数组的0号索引位开始,循环利用有效数据范围外的数组索引位存储新的数据对象(ArrayDeque集合内部虽然是一个可以循环利用的数组结构,但同样存在扩容场景,并且在扩容时需要考虑的各种情况较为复杂;

ArrayDeque集合中有多组成对出现的操作方法,使用者使用哪一组操作方法,完全取决于使用者以什么样的结构操作ArrayDeque集合。

  • 将ArrayDeque集合作为队列结构使用。
    只允许在ArrayDeque集合的尾部添加数据对象,在ArrayDeque集合的头部移除或读取数据对象,相关方法如表所示。
    在这里插入图片描述
  • 将ArrayDeque集合作为双端队列结构使用。
    ArrayDeque集合实现了java.util.Deque接口,也就是说,它可以使用具有双端队列操作特性的相关方法,如表所示。
    在这里插入图片描述
  • 将ArrayDeque集合作为栈结构使用
    java.util.Deque接口所代表的双端队列具有栈结构的操作特性。与栈结构操作有关的方法如表所示。
    在这里插入图片描述
2.2.1.2 初始化过程

ArrayDeque集合中有3个重要的属性和3个构造方法,源码片段如下:

public class ArrayDeque<E> extends AbstractCollection<E>
        implements Deque<E>, Cloneable, Serializable
{

    //这个数组主要用于存储队列或双端队列中的数据对象
    transient Object[] elements; // non-private to simplify nested class access

    //该变量指向数组所描述的队列头部
    //列如:可以通过该变量确定在remove(),pop()等方法中,从队列/双端队列中移除数据对象的索引位
    transient int head;
    //该变量指向数组所描述的队列尾部的下一个索引位
    //列如:可以通过该变量确定在addLase(E),add(E),push(E)等方法中
    // 在队列/双端队列中添加数据对象的索引位
    transient int tail;

    private static final int MIN_INITIAL_CAPACITY = 8;

    private static int calculateSize(int numElements) {
        int initialCapacity = MIN_INITIAL_CAPACITY;
        // Find the best power of two to hold elements.
        // Tests "<=" because arrays aren't kept full.
        if (numElements >= initialCapacity) {
            initialCapacity = numElements;
            initialCapacity |= (initialCapacity >>>  1);
            initialCapacity |= (initialCapacity >>>  2);
            initialCapacity |= (initialCapacity >>>  4);
            initialCapacity |= (initialCapacity >>>  8);
            initialCapacity |= (initialCapacity >>> 16);
            initialCapacity++;

            if (initialCapacity < 0)   // Too many elements, must back off
                initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
        }
        return initialCapacity;
    }

    
    private void allocateElements(int numElements) {
        elements = new Object[calculateSize(numElements)];
    }

    
    //默认构造器,初始大小为16
    public ArrayDeque() {
        elements = new Object[16];
    }

    //设置初始化容量,如果小于8,则初始化一个为8的容量值的数组
    public ArrayDeque(int numElements) {
        allocateElements(numElements);
    }
    //设置以一个集合为初始的构造器
    public ArrayDeque(Collection<? extends E> c) {
        allocateElements(c.size());
        addAll(c);
    }

...
}
2.2.1.3 添加

在完成ArrayDeque集合的初始化操作后,调用者就可以使用push(E)、offerFirst(E)、addFirst(E)、addLast(E)等方法,在head属性和tail属性所标识的有效数据范围的头部或尾部添加新的数据对象了。

使用什么样的添加操作同样取决于调用者以何种方式(队列、双端队列或栈)使用ArrayDeque集合。总而言之,无论调用哪种添加方法,进行实际工作的方法只有两个,分别是

  • addFirst(E)方法
  • addLast(E)方法。

前者负责在ArrayDeque集合的头部添加新的数据对象,后者负责在ArrayDeque集合的尾部添加新的数据对象。

2.2.1.3.1 addFirst(E)方法

使用addFirst(E)方法可以在双端队列的头部添加数据对象,注意添加的数据对象不能为null,否则会抛出NullPointerException异常。

  // The main insertion and extraction methods are addFirst,
    // addLast, pollFirst, pollLast. The other methods are defined in
    // terms of these.

    /**
     * Inserts the specified element at the front of this deque.
     *
     * @param e the element to add
     * @throws NullPointerException if the specified element is null
     */
    public void addFirst(E e) {
        if (e == null)
            throw new NullPointerException();
        elements[head = (head - 1) & (elements.length - 1)] = e;
        if (head == tail)
            doubleCapacity();
    }

上述源码的主要逻辑实际上就是将循环数组中上一个索引位作为head属性记录的新的索引位,并且进行数据对象添加操作;
如果在添加新的数据对象后,发现head属性记录的索引位(简称head索引位)和tail属性记录的索引位(简称tail索引位)重合,就进行扩容操作。在通常情况下,addFirst(E)方法的工作逻辑如图所示。
在这里插入图片描述
在head索引位上添加新的数据对象后,将head属性值递减。如果在递减后,head属性值小于0,那么将head索引位设置为elements数组中的最后一个有效索引位。
如果在递减后,head索引位刚好和tail索引位一致,那么说明elements数组中已经没有可容纳新数据对象的空余的索引位了,需要进行扩容操作。

2.2.1.3.2 addLast(E)方法

使用addLast(E)方法可以在双端队列的尾部添加数据对象。
需要注意的是,添加的数据对象不能为null,否则会抛出NullPointerException异常。

   /**
     * Inserts the specified element at the end of this deque.
     *
     * <p>This method is equivalent to {@link #add}.
     *
     * @param e the element to add
     * @throws NullPointerException if the specified element is null
     */
    public void addLast(E e) {
        if (e == null)
            throw new NullPointerException();
        elements[tail] = e;
        if ( (tail = (tail + 1) & (elements.length - 1)) == head)
            doubleCapacity();
    }

在这里插入图片描述
在成功添加数据对象后,将tail属性值递增。
如果在递增后,tail属性值超过了数组最大的合法索引值,则inc(int,int)方法可以让tail索引位重新回到elements数组的0号索引位。
如果在递增后,tail索引位刚好和head索引位一致,则说明elements数组中已经没有可容纳新数据对象的空余的索引位了,需要进行扩容操作。

2.2.2 Queue集合实现—PriorityQueue

PriorityQueue队列的基本使用方法PriorityQueue队列是基于堆结构构建的,具体来说,是基于数组形式的小顶堆构建的。
它保证了在每次添加新数据对象、移除已有数据对象后,集合都能维持小顶堆的结构特点。
下面我们来看一下PriorityQueue队列的基本使用方法,示例代码如下。

public class TestJava {


    public static void main(String[] args) {

        PriorityQueue<Integer> priorityQueue = new PriorityQueue<>();
        priorityQueue.add(22);
        priorityQueue.add(333);
        priorityQueue.add(44);
        priorityQueue.add(555);
        priorityQueue.add(1);
        priorityQueue.add(66);
        priorityQueue.add(999);
        priorityQueue.add(33);
        priorityQueue.add(777);

        for (int i = 0; i < priorityQueue.size();) {
            System.out.println("item = " + priorityQueue.poll());
        }
    }
}

输出结果:

Connected to the target VM, address: '127.0.0.1:52043', transport: 'socket'
item = 1
item = 22
item = 33
item = 44
item = 66
item = 333
item = 555
item = 777
item = 999
Disconnected from the target VM, address: '127.0.0.1:52043', transport: 'socket'

PriorityQueue队列存储的数据对象的数据类型要么实现了java.lang.Comparable接口,
要么在实例化PriorityQueue队列时实现了java.util.Comparator接口,
否则在添加数据对象时会发生cannot be cast to java.lang.Comparable异常。

java.lang.Comparable接口主要提供了两个相同类型对象的权值比较过程。
例如,在上述源码片段中,Integer类就实现了Comparable接口,我们常用的String类也实现了Comparable接口。
在这里插入图片描述

2.2.2.1 主要构造
public class PriorityQueue<E> extends AbstractQueue<E> implements java.io.Serializable {

    private static final long serialVersionUID = -7720805057305804111L;


    /**
     * 该常量主要用于设置queue数组默认的容量
     */
    private static final int DEFAULT_INITIAL_CAPACITY = 11;

    /**
     * 这个数组变量是一个完全二叉树的降维表达,其中的数据对象满足以下要求
     * Priority queue represented as a balanced binary heap: the two
     * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
     * priority queue is ordered by comparator, or by the elements'
     * natural ordering, if comparator is null: For each node n in the
     * heap and each descendant d of n, n <= d.  The element with the
     * lowest value is in queue[0], assuming the queue is nonempty.
     */
    transient Object[] queue; // non-private to simplify nested class access

    /**
     * size 变量表示当前PriorityQueue队列中的数据规模
     * size的值表示当前集合中的数据对象数量,不一定是当前queue数组的大小,前者只可能小于或等于后者
     */
    private int size = 0;


    /**
     * PriorityQueue队列的堆(完全二叉树)中各节点上数据对象的比较采用两种方式:
     * 如果当前队列设置了comparator对象引用,则采用该comparator对象进行比较;
     * 如果该队列的comparator对象为nul1,但数据对象本身的类定义实现了Comparator接口,
     * 则采用数据对象本身的比较方式进行操作
     */
    private final Comparator<? super E> comparator;

    /**
     * modCount 变量表示当前PriorityQueqe队列从完成初始化到目前为止,
     * 其数据对象被修改的次数,
     * 即进行数据写入操作的次数,这主要借鉴了CAS思想,即在非线程安全的场景中实现简单的数据安全判定
     */
    transient int modCount = 0; // non-private to simplify nested class access
...}

其中的comparator对象是实现了java.util.Comparator接口的类的对象。
java.util.Comparator接口是一个函数式接口,该接口中要求实现的compare(o1,o2)方法,主要用于比较两个对象的权值大小。
在默认情况下,返回的整数代表第一个入参01和第二个入参02的权值差异。
也就是说:

  • 如果01的权值大于o2的权值,则返回正数;
  • 如果01的权值小于2的权值,则返回负数;
  • 如果01的权值和o2的权值相等,则返回0。

7个构造器源码如下:

    public PriorityQueue() {
        this(DEFAULT_INITIAL_CAPACITY, null);
    }

   
    public PriorityQueue(int initialCapacity) {
        this(initialCapacity, null);
    }

    
    public PriorityQueue(Comparator<? super E> comparator) {
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }

   
    public PriorityQueue(int initialCapacity,Comparator<? super E> comparator) {
        // Note: This restriction of at least one is not actually needed,
        // but continues for 1.5 compatibility
        if (initialCapacity < 1)
            throw new IllegalArgumentException();
        this.queue = new Object[initialCapacity];
        this.comparator = comparator;
    }

   
    @SuppressWarnings("unchecked")
    public PriorityQueue(Collection<? extends E> c) {
        if (c instanceof SortedSet<?>) {
            SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
            this.comparator = (Comparator<? super E>) ss.comparator();
            initElementsFromCollection(ss);
        } else if (c instanceof PriorityQueue<?>) {
            PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
            this.comparator = (Comparator<? super E>) pq.comparator();
            initFromPriorityQueue(pq);
        } else {
            this.comparator = null;
            initFromCollection(c);
        }
    }

    @SuppressWarnings("unchecked")
    public PriorityQueue(PriorityQueue<? extends E> c) {
        this.comparator = (Comparator<? super E>) c.comparator();
        initFromPriorityQueue(c);
    }


    @SuppressWarnings("unchecked")
    public PriorityQueue(SortedSet<? extends E> c) {
        this.comparator = (Comparator<? super E>) c.comparator();
        initElementsFromCollection(c);
    }

以上可以归纳为两种构造器:

  • 第一种构造方法PriorityQueue(int,Comparator)的第一个参数主要用于设置PriorityQueue队列的初始化大小,必须传入一个大于或等于1的整数;
    如果由其他构造方法传入了之前介绍的常量DEFAULT_INITIAL_CAPACITY,则表示初始化大小为11;
    该构造方法的第二个参数是Comparator接口的对象,该参数指定了PriorityQueue队列中两个对象的权值比较方式(该参数可以为nul)。如果第二个参数为null,那么PriorityQueue队列在进行降序、升序操作时,会直接使用对象本身定义的权值比较方式,如果对象没有权值比较方式,即没有实现Comparator接口,则会在操作时抛出异常。
  • 第二种构造方法PriorityQueue(Collection)没有权值比较方式它会根据传入的Collection集合对PriorityQueue队列进行实例化,Collection集合中的数据对象会被赋值(引用)到新的PriorityQueue队列中,并且按照PriorityQueue队列的要求重新排序。该构造方法会根据传入的Collection集合的性质决定处理细节。

在PriorityQueue(Collection)构造方法中还可以使用initElementsFromCollection()方法、initFromPriorityQueue()方法和initFromCollection()方法;

2.2.2.2 核心工作原理

PriorityQueue队列为了保证读/写性能的平衡,始终维护着一个堆结构(默认为小顶堆结构,通过Comparator接口实现干预数据对象比较标准后可以转变成一个大顶堆结构)。也就是说,PriorityQueue队列每进行一次写操作,都会针对集合中新的数据情况进行调整,保证集合中所有数据对象始终按照小顶堆或大顶堆的结构特点进行排序。

PriorityQueue队列使用一个数组变量queue存储数据对象。PriorityQueue队列的主要继承体系如图所示。
在这里插入图片描述

2.2.2.2.1 堆的升序操作

由于PriorityQueue队列内部始终保持堆结构(在未特别说明的情况下,后面提到的堆均表示小顶堆),因此在添加新的数据对象时,需要根据数据对象权值确认要添加的数据对象的索引位,使PriorityQueue队列内部继续维持小顶堆结构。
小顶堆结构的构建过程示例如图所示,其中包括堆的升序操作。
在这里插入图片描述
在数组中已存储数据对象的最后一个有效索引位的下一个索引位上添加新的数据对象,按照完全二叉树的降维原理,这个索引位一定是当前完全二叉树的最后一个叶子节点。

在将数据对象添加到数组中后,PriorityQueue队列会验证该完全二叉树是否还是一个堆结构,如果不是,则需要根据新添加的数据对象的权值调整其索引位,使PriorityQueue队列内部的完全二叉树重新变为小顶堆,这个过程称为堆的升序操作。

PriorityQueue队列中与该升序操作有关的源码如下。
在这里插入图片描述

2.2.2.2.2 堆的降序操作

当从PriorityQueue队列中移除数据对象时,会始终移除根节点上的数据对象(queue数组中0号索引位上的数据对象),并且将完全二叉树上最后一个叶子节点上的数据对象(当前queue数组中最后一个有效索引位上的数据对象)替换到根节点上,
然后PriorityQueue队列会判断当前的完全二叉树是否还能保持堆结构,如果不能,则基于当前的完全二叉树结构,从根节点开始进行降序操作,直到整个完全二叉树重新恢复成堆结构,具体操作示例如图所示。
在这里插入图片描述
在这里插入图片描述

2.2.2.2.3 小顶堆的修复性排序

在一些场景中,PriorityQueue队列中的小顶堆结构需要进行修复性排序,这是因为在某些场景中,PriorityQueue队列中的完全二叉树不一定是小顶堆结构,而且出现这种情况的原因并不是添加或删除某个节点而导致的堆中某部分出现排序偏差,而是整个完全二叉树的排序情况发生了整体性偏差,如PriorityQueue队列在基于一个指定的集合进行初始化操作时就可能出现整体性偏差。

我们知道,PriorityQueue队列在参照其他集合进行实例化时,在参照的集合中,所有数据对象都会被依次赋值(引用)到PriorityQueue队列的queue数组中。
由于参照集合中的数据对象不一定是按照小顶堆结构存储的,因为在完成赋值(引用)操作后,PriorityQueue队列会使用heapify()方法对PriorityQueue队列中的数据对象进行小顶堆排序,源码片段如下。
在这里插入图片描述

重建小顶堆结构的过程,实际上是对queue数组中所有非叶子节点上的数据对象按照权值依次进行降序操作的过程。
PriorityQueue队列中基于小顶堆结构的降序排序方法,比2.2.5节中的实现方法性能更好、空间复杂度更低,具体示例如图所示。
在这里插入图片描述

2.2.2.2.4 扩容操作

PriorityQueue队列中用于描述完全二叉树的结构是一个数组,当数组容量达到上限时,需要进行扩容操作。
PriorityQueue队列中用于进行扩容操作的主要方法为grow(int),具体源码片段如下。

    /**
     * 扩容操作,mincapacity变量表示扩容后的最小容量值
     */
    private void grow(int minCapacity) {
        //oldcapacity变量主要用于记录扩容前的数组容量,即扩容前PriorityQueue队列的原始容量
        int oldCapacity = queue.length;
        // Double size if small; else grow by 50%
        //如果PriorityQueue队列的原始容量值小于64,那么进行双倍扩容(实际上是双倍容量+2)
        //如果PriorityQueue队列的原始容量值大于64,那么进行50%的扩容
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                (oldCapacity + 2) :
                (oldCapacity >> 1));
        // overflow-conscious code
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        //使用Arrays工具,将queue数组中的数据对象复制(引用)到一个新的数组中
        queue = Arrays.copyOf(queue, newCapacity);
    }
2.2.2.2.5 添加操作

PriorityQueue队列中有两个添加操作方法,分别为add(E)方法和offer(E)方法
查看源码可知,offer(E)方法才是实际工作的方法。
在这里插入图片描述
如图所示, add方法调用的是offer方法;
这是因为PriorityQueue队列实现了Queue集合,它具备队列的操作特点,
可以通过特定的方法在集合的尾部添加数据对象,在集合的头部移出数据对象。

    /**
     * offer(E)方法会在PriorityQueue队列尾部(数组当前有效索引位的下一个索引位上)添加指定数据对
     * 因为当前PriorityQueue队列需要保持小顶堆结构,
     * 所以PriorityQueue队列会将新添加的数据对象与指定索引位上的数据对象进行交换
     */
    public boolean offer(E e) {
        //新添加的数据对象不能为null
        if (e == null)
            throw new NullPointerException();
        //因为PriorityQueue队列不是线程安全的,
        //所以需要一个思路验证PriorityQueue队列的线程合法性,这里的思路借鉴了CAS思路
        modCount++;
        //变量i主要用于记录在添加数据对象前,PriorityQueue 队列中的数据对象数量
        int i = size;
        //如果条件成立,则说明当前PriorityQueue队列中存储的数据对象数量已经达到了当前queue数组的存储上限,需要进行扩容操作
        if (i >= queue.length)
            grow(i + 1);
        size = i + 1;
        if (i == 0)
            queue[0] = e;
        else
            //在当前最后一个有效索引位后的索引位上插入新的数据对象,并且进行升序操作
            siftUp(i, e);
        return true;
    }
2.2.2.2.6 移除操作

在这里插入图片描述
如果需要从PriorityQueue队列中的任意索引位上移除数据对象,那么应该怎么处理呢?
首先可以肯定的是,随意移除小顶堆中某个节点上的数据对象,大概率会破坏堆结构的稳定性,甚至可能无法保证PriorityQueue队列内部是一个完全二叉树,如图所示。
在这里插入图片描述
在随意移除任意索引位上的数据对象后,PriorityQueue队列的堆结构可能只是部分被破坏,无须进行全局修复。
如果需要移除任意索引位上的数据对象,那么首先应该使PriorityQueue队列保持完全二叉树结构,然后将完全二叉树修复成一个堆。完整的移除操作步骤如下。

  • 在当前完全二叉树降维表达的数组中,使用最后一个有效索引位上的数据对象替换被移除的数据对象,从而保证移除数据对象后的内部结构满足完全二叉树的要求,如图所示。
    在这里插入图片描述
  • 在保证内部结构仍然是一个完全二叉树结构后,对被破坏的部分堆结构进行修复。
    方法是从移除操作的索引位开始进行降序操作;
    如果无法进行降序操作,则进行升序操作,如图所示。
    在这里插入图片描述
  • 还有一种特殊情况,就是从移除操作的索引位开始,既无法进行降序操作,又无法进行升序操作,这说明该节点的权值刚好可以匹配该节点

2.2.3 堆和堆排序

在JCF中,除了基于线性数据结构工作的集合,还有大量基于树结构工作的集合,这些集合主要基于几类性能较高且稳定的树结构工作,如堆、红黑树及跳跃表(一种树的变形结构)。
比如 PriorityQueue队列,就是一种基于小顶堆结构工作的集合。

2.2.3.1 树和二叉树
2.2.3.1.1 树的层次和树的深度

假设树的根节点层次为1,其他节点的层次是其父节点层次加1。
一棵树中所有节点层次的最大值就是这棵树的深度,示例如图所示
在这里插入图片描述

2.2.3.1.2 儿子节点和双亲节点(父节点)

在一个节点数量为n(r>1)的树结构中,任意节点的上层节点是这个节点的双亲节点(父节点),树结构中的任意节点最多有一个双亲节点。如果以上条件不成立,则这个结构就不是树结构。

2.2.3.1.3 根节点

树结构中没有双亲节点(父节点)的节点就是这棵树的根节点。

2.2.3.1.4 子树

对于树结构中的节点,如果存在儿子节点,那么以儿子节点为新的根节点,可以构成相互独立的新的树结构。
这些新的树结构称为子树。如果以上条件不成立,那么这个结构不是树结构。
例如,如图所示,以B节点为根节点的树结构中有两棵子树,这两棵子树相对独立,没有任何连接关系。
在这里插入图片描述

2.2.3.1.5 度、叶子节点、分支节点

以树中任意节点为子树根节点,其直接拥有的子树数量为当前节点的度。
度为0的节点为叶子节点,度不为0的节点为分支节点。
树的所有节点中度最大的值,就是这棵树的度。

2.2.3.1.6 二叉树

二叉树是指整个树结构中不存在度大于2的节点的子树。
也就是说,二叉树最多有两棵子树,分别为左子树和右子树,如图所示。
在这里插入图片描述

2.2.3.1.7 满二叉树

定义一棵深度为k的二叉树,如果它有2k-1个节点,那么它是满二叉树。
满二叉树的树结构示例如图所示。
在这里插入图片描述

2.2.3.1.8 完全二叉树

完全二叉树也是一种常见的二叉树,由满二叉树引申而来:对于深度为k、有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号从1至n的节点按顺序对应时,称其为完全二叉树。

简单地说,完全二叉树的所有非叶子节点可构成一棵满二叉树,并且所有叶子节点都从最左侧的非叶子节点开始关联。
完全二叉树的树结构示例如图所示。
在这里插入图片描述

2.2.3.2 堆、小顶堆、大顶堆

堆满足以下特点:

  • 堆是一棵完全二叉树。
  • 堆中某个非叶子节点的值,总是不大于或不小于其任意儿子节点的值。
  • 堆中每个节点的子树都是堆。

这里解释一下第二个要点:

  • 如果堆中某个非叶子节点的值总是小于或等于其任意儿子节点的值,那么将这个堆称为小顶堆;
  • 如果堆中某个非叶子节点的值总是大于或等于其任意儿子节点的值,那么将这个堆称为大顶堆。

标准的小顶堆和大顶堆示例如图所示。
在这里插入图片描述

以大顶堆为例,只要是一棵完全二叉树,就可以依据相关算法将其调整为一个大顶堆。
算法过程如下: 从当前完全二叉树的最后一个非叶子节点开始,将基于每个非叶子节点构造的子树都调整为大顶堆;
在这里插入图片描述

2.2.3.3 堆的降维一使用数组表示堆结构

完全二叉树可以使用数组表示,也就是说,树结构可以降维成数组结构。
而堆的基础是完全二叉树,所以对完全二叉树的降维就是对堆的降维。
降维操作的可行性实际上和二叉树的组织特点有关,如图所示。
在这里插入图片描述
由于完全二叉树(节点总数记为n)是在其满二叉树的数值范围内进行的依次存储,因此完全二叉树的根节点位于数组中的0号索引位上,完全二叉树的左儿子节点位于数组中的1号索引位上,以此类推,完全二叉树的第一个叶子节点位于(n>>1))-1号索引位上,并且我们可以推导出以下公式。

  • 当前非叶子节点的左儿子节点索引位=当前节点索引位×2+1,更准确的说法是,当前节点索引位<<1+1。
  • 当前非叶子节点的右儿子节点索引位=当前节点索引位×2+2,更准确的说法是,当前节点索引位<<1+2。
  • 当前节点的父节点索引位=(当前节点索引位-1)/2,更准确的说法是,(当前节点索引位-1)>>1。

需要注意的是,完全二叉树在降维后形成的数组长度为n,并且只有数组的后半段是叶子节点。

2.2.3.4 堆排序

一个显而易见的事实是,可以使用堆结构处理排序问题,这样的排序过程称为堆排序。
另一个事实是,线性的数组结构通常比树结构更易于编程,所以使用完全二叉树的数组结构进行排序,既方便理解,又具有较高的处理性能。

基于大顶堆的排序过程如下。

    1. 假设当前参与排序的数据对象数量为n,首先从堆的最后一个非叶子节点开始排序;
      计算公式为,最后一个非叶子节点索引位=(n>>1)-1。
    1. 使用上面介绍过的大顶堆算法,进行一轮数据调整,将当前数组范围(0~n-1)内值最大的数据对象调整到0号索引位上。
    1. 将目前0号索引位上的数据对象和n-1号索引位上的数据对象进行交换。
    1. 设置当前参与排序的数据对象数量为n-1,使其成为新的n值。如果新的n值仍然大于1,那么继续执行步骤(2)和步骤(3),使直到需要参与排序的数据对象数量为1。

基于大顶堆的排序过程示例如图所示。
在这里插入图片描述
上图是一个节点数为6的完全二叉树进行第一次堆排序过程(大顶堆方式)。
在第一次排序操作过程中,当前数组中的所有索引位都会参与排序操作(记为=6)。
在经过多次基于大顶堆算法的交换操作后,数组中最大的值61放到了完全二叉树的根节点上,即数组中的0号索引位上。
最后将0号索引位上的数值和参与本次排序操作的最后一个索引位上的数值进行交换,保证参与本次排序操作的最后一个索引位上的值是本次排序操作中的最大值。
在上图中,整个数组都参与了排序操作,所以最大值61从当前0号索引位上被交换到了数组中的最后一个索引位上。
下一次大顶堆排序的过程就不需要将当前最后一个索引位纳入排序操作范围了,所以参与排序操作的节点数减1。

接着进行第二次大顶堆排序过程,如下图所示。
在这里插入图片描述
上图中的步骤完成了基于完全二叉树的第二次排序,将参与排序的所有节点中值最大(41)的节点找到,并且将其交换到参与本次排序操作的最后一个有效索引位上。

根据上述操作,我们可以发现大顶堆的排序操作次数明显减少,并且交换操作次数也明显减少,这是因为在第一次排序过程中,大部分数值已经完成了比较和交换操作过程,而且参与排序操作的节点数量减少了。
接下来根据以上方式完成剩余索引位上的数值排序,如下图所示。
在这里插入图片描述
堆排序算法是非常灵活的,只要掌握上述降维方式,以及节点与其左、右儿子节点的对应关系,开发人员就可以根据需要调整堆排序算法的次要步骤。

2.3 JCF中的Map集合

JCF中的Map集合和Set集合之间存在非常密切的关联关系

从相应集合的命名可以看出来,如
HashMap集合和HashSet集合
TreeMap集合和TreeSet集合、
LinkedHashMap集合和LinkedHashSet集合、
ConcurrentSkipListSet集合和ConcurentSkipListMap集合等;

因此,如果搞清楚了JCF中的Map集合结构,就基本搞清楚了JCF中的Set集合结构。

2.3.1 概述

Map集合属于JCF的知识范畴,但是代表Map集合的顶级接口java.util.Map并没有继承JCF的顶级接口java.util.Collection。
这是因为Map集合结构属于映射式结构,即一个Key键对应一个Value值(简称K-V键值对),并且同一个集合中不能出现两个相同的Key键信息。

本节会重点介绍TreeMap集合、HashMap集合和LinkedHashMap集合,其中TreeMap集合是基于红黑树结构构造的,HashMap集合和LinkedHashMap集合是基于数组+链表+红黑树的复合结构构造的,这两种集合的区别仅体现在LinkedHashMap集合中增加了一个虚拟的链表结构。

ConcurentHashMap集合和ConcurrentSkipListMap集合也是Map集合体系中重要的线程安全的集合,在介绍完JUC的必要知识后再介绍ConcurrentHashMap集合的相关结构。
在这里插入图片描述

2.3.2 K-V键值对节点定义—Entry

Map集合中存储的是K-V键值对节点,即用一个Key键信息和一个Value值信息映射关联后的对象描述。
在这里插入图片描述

Map集合中可以有成千上万个K-V键值对节点,每一个K-V键值对都使用实现了Map.Entry〈K,V〉接口的类的对象进行描述
也就是说,一个Map集合中可以有成千上万个Map.Entry<K,V〉接口的实例化对象,如图所示。
在这里插入图片描述

相关片段源码如下所示:

public interface Map<K,V> {
    ...
    interface Entry<K,V> {
        //获取当前entry 描述的k-v键值对节点的key键信息
        K getKey();
        //获取当前entry 描述的k-v键值对节点的value键信息
        V getValue();
        //设置当前entry 描述的k-v键值对节点的value键信息
        V setValue(V value);

        //比较两个k-v键值对是否相同
        boolean equals(Object o);

        //计算当前k-v键值对节点的Hash值
        int hashCode();
        ...}
        }

实际上,从JDK1.8开始,Map.Entry<K,V〉接口中增加了一些其他定义,这里暂时不予介绍。

在一般情况下,实现了Map.Entry〈K,V〉接口的具体类,都会根据自己的结构特点实现Map.Entry〈K,V>接口中的方法。
例如:

  • AbstractMap类中的AbstractMap.SimpleEntry类实现了Map.Entry〈K,V>接口
  • HashMap类中的HashMap.Node类实现Map.Entry〈K,V>接口
  • TreeMap类中的TreeMap.Entry类实现Map.Entry〈K,V〉接口

这些Map.Entry〈K,V>接口的具体实现类,都根据自己存储K-V键值对节点的特性做了不同的扩展或调整。
下面以TreeMap集合为例,看一下TreeMap.Entry<K,V〉类的定义(TreeMap集合中对K-V键值对节点的定义)
源码如下:
在这里插入图片描述
使用K-V键值对节点存储数据对象的java.util.TreeMap集合,内部所有的K-V键值对节点构成一棵红黑树。
也就是说,代表K-V键值对节点的TreeMap.Entry对象需要记录当前树节点的双亲节点(父节点)、左儿子节点、右儿子节点及当前树节点的颜色。

2.3.3 与Map集合有关的重要接口和抽象类

为了便于理解JCF中的TreeMap集合、HashMap集合和LinkedHashMap集合
首先讲解与Map集合有关的几个重要接口和抽象类:

  • java.util.Map接口
  • java.util.SortedMap接口
  • java.util.NavigableMap接口
  • java.util.AbstractMap抽象类。
2.3.3.1 java.util.Map接口

java.util.Map接口是JCF中Map集合的顶层接口,它给出了Map体系中的基本操作功能,并且要求下层具体的Map集合对其进行实现;
具体如下。

  • 向集合中添加一个新的K-V键值对节点。
    如果在操作之前已经存在这样的K-V键值关联,那么新的值会替换原有的值,并且返回之前的值。
V put(K key, V value);
  • 清除当前Map集合中的所有K-V键值对
void clear()
  • 根据指定的K-V键值对中的Key键信息,返回其对应的Value值信息,如果当前Map集合中不存在这个Key键信息,则返回null;
V get(Object key);
  • 返回当前Map集合中存储的K-V键值对节点的数量,如果数量大于Integer.MAX_VALUE(2~31-1),则返回Integer.MAX_VALUE。
   /**
     * Returns the number of key-value mappings in this map.  If the
     * map contains more than <tt>Integer.MAX_VALUE</tt> elements, returns
     * <tt>Integer.MAX_VALUE</tt>.
     *
     * @return the number of key-value mappings in this map
     */
    int size();
  • 判定当前Map集合中是否至少存在一个K-V键值对节点,如果不存在,则返回true,否则返回false。
   /**
     * Returns <tt>true</tt> if this map contains no key-value mappings.
     *
     * @return <tt>true</tt> if this map contains no key-value mappings
     */
    boolean isEmpty();

map所有方法如下所示:(idea快捷键查看:ctrl+F12)
在这里插入图片描述

2.3.3.2 java.util.SortedMap接口

在这里插入图片描述

Map集合中定义的各种K-V键值对读/写方法,不一定能保证Key键的顺序。
但在很多业务场景中,需要存储在Map集合中的K-V键值对节点按照一定的规则有序存储
这时我们可能需要使用实现了SortedMap接口的Map集合。
需要注意的是,这里所说的有序存储不一定是线性存储的,如利用红黑树结构进行的有序存储。

SortedMap接口提供了很多与有序存储有关的方法,具体如下。

  • 既然实现了SortedMap接口的集合可以将K-V键值对节点按照一定的规则进行有序存储,就会用到Comparator比较器
    使用以下方法可以获得当前集合使用的Comparator比较器。
 Comparator<? super K> comparator();
  • SortedMap集合中的K-V键值对节点是有序存储的,可以指定开始位上的Key键信息及结束位上的Key键信息,并且返回一个承载两者之间K-V键值对节点的新的SortedMap集合;
    需要注意的是,由于新的SortedMap集合中存储的是已有的K-V键值对节点的引用,因此对新的SortedMap集合中K-V键值对节点的写操作结果会反映在原有SortedMap集合中,反之亦然。
    此外,如果指定开始位上的Key键信息和指定结束位上的Key键信息相同,则会返回一个空集合。
    /**
     * Returns a view of the portion of this map whose keys range from
     * {@code fromKey}, inclusive, to {@code toKey}, exclusive.  (If
     * {@code fromKey} and {@code toKey} are equal, the returned map
     * is empty.)  The returned map is backed by this map, so changes
     * in the returned map are reflected in this map, and vice-versa.
     * The returned map supports all optional map operations that this
     * map supports.
     *
     * <p>The returned map will throw an {@code IllegalArgumentException}
     * on an attempt to insert a key outside its range.
     *
     * @param fromKey low endpoint (inclusive) of the keys in the returned map
     * @param toKey high endpoint (exclusive) of the keys in the returned map
     * @return a view of the portion of this map whose keys range from
     *         {@code fromKey}, inclusive, to {@code toKey}, exclusive
     * @throws ClassCastException if {@code fromKey} and {@code toKey}
     *         cannot be compared to one another using this map's comparator
     *         (or, if the map has no comparator, using natural ordering).
     *         Implementations may, but are not required to, throw this
     *         exception if {@code fromKey} or {@code toKey}
     *         cannot be compared to keys currently in the map.
     * @throws NullPointerException if {@code fromKey} or {@code toKey}
     *         is null and this map does not permit null keys
     * @throws IllegalArgumentException if {@code fromKey} is greater than
     *         {@code toKey}; or if this map itself has a restricted
     *         range, and {@code fromKey} or {@code toKey} lies
     *         outside the bounds of the range
     */
    SortedMap<K,V> subMap(K fromKey, K toKey);
  • 在以下方法中,通过指定一个Key键信息,返回一个新的SortedMap集合,该SortedMap集合中存储的所有K-V键值对节点的Key键信息都小于指定的Key键信息
    此外,新的SortedMap集合的操作特性和subMap(K,K)方法返回的新的SortedMap集合的操作特性一致。
    需要注意的是,如果指定的Key键信息并不在当前SortedMap集合中,那么该方法会抛出IlegalArgumentException异常。
/**
     * Returns a view of the portion of this map whose keys are
     * strictly less than {@code toKey}.  The returned map is backed
     * by this map, so changes in the returned map are reflected in
     * this map, and vice-versa.  The returned map supports all
     * optional map operations that this map supports.
     *
     * <p>The returned map will throw an {@code IllegalArgumentException}
     * on an attempt to insert a key outside its range.
     *
     * @param toKey high endpoint (exclusive) of the keys in the returned map
     * @return a view of the portion of this map whose keys are strictly
     *         less than {@code toKey}
     * @throws ClassCastException if {@code toKey} is not compatible
     *         with this map's comparator (or, if the map has no comparator,
     *         if {@code toKey} does not implement {@link Comparable}).
     *         Implementations may, but are not required to, throw this
     *         exception if {@code toKey} cannot be compared to keys
     *         currently in the map.
     * @throws NullPointerException if {@code toKey} is null and
     *         this map does not permit null keys
     * @throws IllegalArgumentException if this map itself has a
     *         restricted range, and {@code toKey} lies outside the
     *         bounds of the range
     */
    SortedMap<K,V> headMap(K toKey);
  • 在以下方法中,通过指定一个Key键信息,返回一个新的SortedMap集合,后者存储的所有K-V键值对节点的Key键信息都大于或等于当前指定的Key键信息。此外,新的SortedMap集合的操作特性和headMap()方法返回的新的SortedMap集合的操作特性一致。
    /**
     * Returns a view of the portion of this map whose keys are
     * greater than or equal to {@code fromKey}.  The returned map is
     * backed by this map, so changes in the returned map are
     * reflected in this map, and vice-versa.  The returned map
     * supports all optional map operations that this map supports.
     *
     * <p>The returned map will throw an {@code IllegalArgumentException}
     * on an attempt to insert a key outside its range.
     *
     * @param fromKey low endpoint (inclusive) of the keys in the returned map
     * @return a view of the portion of this map whose keys are greater
     *         than or equal to {@code fromKey}
     * @throws ClassCastException if {@code fromKey} is not compatible
     *         with this map's comparator (or, if the map has no comparator,
     *         if {@code fromKey} does not implement {@link Comparable}).
     *         Implementations may, but are not required to, throw this
     *         exception if {@code fromKey} cannot be compared to keys
     *         currently in the map.
     * @throws NullPointerException if {@code fromKey} is null and
     *         this map does not permit null keys
     * @throws IllegalArgumentException if this map itself has a
     *         restricted range, and {@code fromKey} lies outside the
     *         bounds of the range
     */
    SortedMap<K,V> tailMap(K fromKey);
  • 以下方法会在使用Comparator比较器进行比较后,返回当前SortedMap集合中权值最小的Key键信息;
 /**
     * Returns the first (lowest) key currently in this map.
     *
     * @return the first (lowest) key currently in this map
     * @throws NoSuchElementException if this map is empty
     */
    K firstKey();
  • 以下方法会在使用Comparator比较器进行比较后,返回当前SortedMap集合中权值最大的Key键信息
   /**
     * Returns the last (highest) key currently in this map.
     *
     * @return the last (highest) key currently in this map
     * @throws NoSuchElementException if this map is empty
     */
    K lastKey();

SortedMa所有方法如下所示:(idea快捷键查看:ctrl+F12)
在这里插入图片描述

2.3.3.3 java.util.NavigableMap接口

如果SortedMap接口为有序的K-V键值对节点存储定义了基本操作,那么NavigableMap接口对有序的相关操作进行了细化,如图所示。

NavigableMap接口精确定义了返回上一个Key键信息或K-V键值对节点、下一个Key键信息或K-V键值对节点、最小Key键信息或K-V键值对节点、最大Key键信息或K-V键值对节点等一系列操作。
在这里插入图片描述

  • 以下两个方法会返回一个K-V键值对节点或Key键信息,这个被返回的信息满足如下条件:首先它属于一个集合A,集合A是原集合的子集;其次它所代表的K-V键值对节点的Key键信息是集合A中权值最大的。如果原集合中没有入参key所代表的K-V键值对节点,则返回null
    /**
     * Returns a key-value mapping associated with the greatest key
     * strictly less than the given key, or {@code null} if there is
     * no such key.
     *
     * @param key the key
     * @return an entry with the greatest key less than {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    Map.Entry<K,V> lowerEntry(K key);

    /**
     * Returns the greatest key strictly less than the given key, or
     * {@code null} if there is no such key.
     *
     * @param key the key
     * @return the greatest key less than {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    K lowerKey(K key);
  • 以下两个方法会返回一个K-V键值对节点或Key键信息,这个被返回的信息满足如下条件:首先它属于一个集合A,集合A是原集合的子集;其次它所代表的K-V键值对节点的Key键信息是集合A中权值最大的。如果不存在这样的集合A,则返回入参key所代表的K-V键值对节点或Key键信息;如果原集合中没有入参key所代表的K-V键值对节点,则返回null;
 /**
     * Returns a key-value mapping associated with the greatest key
     * less than or equal to the given key, or {@code null} if there
     * is no such key.
     *
     * @param key the key
     * @return an entry with the greatest key less than or equal to
     *         {@code key}, or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    Map.Entry<K,V> floorEntry(K key);

    /**
     * Returns the greatest key less than or equal to the given key,
     * or {@code null} if there is no such key.
     *
     * @param key the key
     * @return the greatest key less than or equal to {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    K floorKey(K key);
  • 以下两个方法会返回一个K-V键值对节点或Key键信息,这个被返回的信息满足如下条件:首先它属于一个集合A,集合A是原集合的子集;其次它所代表的K-V键值对节点的Key键信息是集合A中权值最小的。如果原集合中没有入参key所代表的K-V键值对节点,则返回null;
/**
     * Returns a key-value mapping associated with the least key
     * strictly greater than the given key, or {@code null} if there
     * is no such key.
     *
     * @param key the key
     * @return an entry with the least key greater than {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    Map.Entry<K,V> higherEntry(K key);

    /**
     * Returns the least key strictly greater than the given key, or
     * {@code null} if there is no such key.
     *
     * @param key the key
     * @return the least key greater than {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    K higherKey(K key);
  • 以下两个方法返回一个K-V键值对节点或Key键信息,这个被返回的信息满足这样的条件:首先它属于一个集合A,集合A是原集合的子集;其次它所代表的K-V键值对节点的Key键信息是集合A中权值最小的。如果不存在这样的集合A,则返回入参key所代表的K-V键值对节点或Key键信息;如果原集合中没有入参key所代表的K-V键值对节点,则返回null;
 /**
     * Returns a key-value mapping associated with the least key
     * greater than or equal to the given key, or {@code null} if
     * there is no such key.
     *
     * @param key the key
     * @return an entry with the least key greater than or equal to
     *         {@code key}, or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    Map.Entry<K,V> ceilingEntry(K key);

    /**
     * Returns the least key greater than or equal to the given key,
     * or {@code null} if there is no such key.
     *
     * @param key the key
     * @return the least key greater than or equal to {@code key},
     *         or {@code null} if there is no such key
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map does not permit null keys
     */
    K ceilingKey(K key);

在这里插入图片描述

2.3.3.4 java.util.AbstractMap抽象类

java.util.AbstractMap抽象类是实现了java.util.Map接口的一个抽象类,主要用于向下层具体的Map集合提供一些默认的功能逻辑,以便减少具体Map集合的构建源码量,从而降低实现具体Map集合的难度。

2.3.3.4.1 基本介绍

AbstractMap抽象类的作用是减少实现具体Map集合时的编码工作量,降低编写具体Map集合的难度。AbstractMap抽象类还为下层不同性质的Map集合实现提供了编码建议。例如,如果基于AbstractMap抽象类实现的具体Map集合是一个不可新增K-V键值对节点的集合,那么只需实现前面提到的entrySet()方法,无须实现put()方法和remove()方法,因为AbstractMap抽象类中存在默认的put()方法和remove()方法实现,并且默认的方法实现会抛出unsupportedOperationException异常,源码片段如下。

 /**
     * {@inheritDoc}
     *
     * @implSpec
     * This implementation always throws an
     * <tt>UnsupportedOperationException</tt>.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws ClassCastException            {@inheritDoc}
     * @throws NullPointerException          {@inheritDoc}
     * @throws IllegalArgumentException      {@inheritDoc}
     */
    public V put(K key, V value) {
        throw new UnsupportedOperationException();
    }

    /**
     * {@inheritDoc}
     *
     * @implSpec
     * This implementation iterates over <tt>entrySet()</tt> searching for an
     * entry with the specified key.  If such an entry is found, its value is
     * obtained with its <tt>getValue</tt> operation, the entry is removed
     * from the collection (and the backing map) with the iterator's
     * <tt>remove</tt> operation, and the saved value is returned.  If the
     * iteration terminates without finding such an entry, <tt>null</tt> is
     * returned.  Note that this implementation requires linear time in the
     * size of the map; many implementations will override this method.
     *
     * <p>Note that this implementation throws an
     * <tt>UnsupportedOperationException</tt> if the <tt>entrySet</tt>
     * iterator does not support the <tt>remove</tt> method and this map
     * contains a mapping for the specified key.
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws ClassCastException            {@inheritDoc}
     * @throws NullPointerException          {@inheritDoc}
     */
    public V remove(Object key) {
        Iterator<Entry<K,V>> i = entrySet().iterator();
        Entry<K,V> correctEntry = null;
        if (key==null) {
            while (correctEntry==null && i.hasNext()) {
                Entry<K,V> e = i.next();
                if (e.getKey()==null)
                    correctEntry = e;
            }
        } else {
            while (correctEntry==null && i.hasNext()) {
                Entry<K,V> e = i.next();
                if (key.equals(e.getKey()))
                    correctEntry = e;
            }
        }

        V oldValue = null;
        if (correctEntry !=null) {
            oldValue = correctEntry.getValue();
            i.remove();
        }
        return oldValue;
    }

AbstractMap抽象类还提供了一个Map.Entry接口的默认实现:AbstractMap.SimpleEntry类。

SimpleEntry类对K-V键值对节点进行了简单的定义(在一般情况下,继承了AbstractMap抽象类的具体Map集合,都自行实现了Map.Entry接口),源码如下:

    public static class SimpleEntry<K,V>
        implements Entry<K,V>, java.io.Serializable
    {
        private static final long serialVersionUID = -8499721149061103585L;
        private final K key;
        private V value;   
        public SimpleEntry(K key, V value) {
            this.key   = key;
            this.value = value;
        }

        public SimpleEntry(Entry<? extends K, ? extends V> entry) {
            this.key   = entry.getKey();
            this.value = entry.getValue();
        }

        public K getKey() {
            return key;
        }

        public V getValue() {
            return value;
        }

       
        public V setValue(V value) {
            V oldValue = this.value;
            this.value = value;
            return oldValue;
        }

        public boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            return eq(key, e.getKey()) && eq(value, e.getValue());
        }

        public int hashCode() {
            return (key   == null ? 0 :   key.hashCode()) ^
                   (value == null ? 0 : value.hashCode());
        }

        public String toString() {
            return key + "=" + value;
        }

    }

2.3.4 红黑树简述

从JDK1.8开始,JCF提供的几个重要Map集合(TreeMap集合、HashMap集合和LinkedHashMap集合)在构造思路上基本未进行大的调整,即

  • TreeMap集合是基于红黑树结构构造的
  • HashMap集合和LinkedHashMap集合是基于数组+链表+红黑树的复合结构构造的。

因此,在正式介绍这些Map集合前,有必要先介绍一下红黑树的结构。
红黑树是一种自平衡二叉查找树,由于其稳定的查找特性,因此JCF中有多个具体的集合都是基于红黑树结构构造的。
与堆的操作原理相比,红黑树的操作原理要复杂一些

2.3.4.1 二叉查找树(二叉搜索树)

二叉查找树的特点如下:

  • 它是一棵二叉树。
  • 如果当前树的根节点存在左子树,那么左子树中的任意节点的权值均小于当前根节点的权值;
  • 如果当前树的根节点存在右子树,那么右子树中的任意节点的权值均大于当前根节点的权值;
  • 以此类推,如果以当前树中任何节点为根节点,则其左子树和右子树中节点的权值特点分别满足第2点和第3点描述;
  • 在二叉查找树中,没有权值相等的两个节点;

在这里插入图片描述
在这里插入图片描述

2.3.4.1.1 二叉查找树-- 查询操作

如果需要在某个二叉查找树中查找权值为x的节点,那么从二叉查找树的根节点开始,递归进行以下步骤。

  • (1)如果当前搜索的节点为空(遇到空树或空子树),则说明要查找的权值为x的节点不在树中,查找失败。
  • (2)如果当前搜索的节点的权值等于要查找的权值x,则说明当前节点是要查找的节点,查找成功。
  • (3)如果要查找的节点权值小于当前节点的权值,则说明要查找的节点如果存在,就只可能存在于当前节点的左子树中,所以取当前节点的左儿子节点,并且返回步骤(1),继续递归执行。
  • (4)如果要查找的节点权值大于当前节点的权值,则说明要查找的节点如果存在,就只可能存在于当前节点的右子树上,所以取当前节点的右儿子节点,并且返回步骤(1),继续递归执行。
    在这里插入图片描述
2.3.4.1.2 二叉查找树-- 添加操作

当向一个二叉查找树中添加一个权值为x的节点时,首先应该查找可以添加新节点的位置,在查找过程中还需要检查二叉查找树中是否已有权值相同的节点,如果有,则不应该再添加这个权值相同的节点,具体过程如下。

  • (1)如果要添加的新节点的权值大于当前节点的权值,则在当前节点的右子树中查找要添加节点的位置。如果当前节点没有右儿子节点,则说明可以在这个位置添加新节点;如果当前节点存在右儿子节点,则以当前节点的右儿子节点为根节点,递归进行步骤(1);
  • (2)如果当前要添加的新节点的权值小于当前节点的权值,则在当前节点的左子树中查找要添加节点的位置。如果当前节点没有左儿子节点,则说明可以在这个位置添加新节点;如果当前节点存在左儿子节点,则以当前节点的左儿子节点为根节点,递归进行步骤(2);

在这里插入图片描述

2.3.4.2 为什么需要红黑树

二叉查找树具有较良好的查找性能,只要不是极端情况,其查找操作的时间复杂度就可以控制为O(logn)。
但是也会出现一些极端情况,使二叉查找树的查找操作性能明显下降。
极端情况的二叉查找树结构示例如图所示。
在这里插入图片描述
上图中树结构满足二叉查找树的定义,但它实际上是一个节点权值有序的链表结构,其查找操作的时间复杂度为O(n)。
出现这种情况的根本原因是二叉查找树并不“平衡”,树中的所有节点明显“右倾”。

注意:平衡二叉树要么是空树,要么树中任意非叶子节点的左、右子树高度差不大于1。

为了避免出现二叉查找树的以上极端情况,需要找到一种方式,使二叉查找树能够引入类似平衡二叉树的约束,并且无论怎样对二叉查找树进行写操作,二叉查找树都可以自行调整并稳定这个平衡结构——这就是红黑树

需要注意的是,红黑树并不是平衡二叉树,二者最大的区别是红黑树放弃了绝对的子树平衡,转而追求一种大致平衡,这保证了在与平衡二叉树的时间复杂度相差不大的情况下,在红黑树中新增的节点最多进行三次旋转操作,就能重新满足红黑树结构要求。

2.3.4.3 红黑树基本结构

为了简化红黑树的处理逻辑,需要引入外部节点(虚拟节点)的概念(部分资料中将外部节点称为叶子节点或哨兵节点)。
需要注意的是,外部节点只是一种记号,并不存储真实数据,也不是红黑树中的实际节点。
外部节点的作用是方便程序员在设计和编程时理解节点的操作理则,在实际应用中并没有实际意义。
红黑树除了具有外部节点,还具有以下特性。

  • 包括外部节点在内的所有节点都带有颜色,要么是黑色的,要么是红色的;
  • 红黑树的根节点一定是黑色的。
  • 如果红黑树中某个节点没有左子树或右子树,则会使用外部节点(虚拟节点)进行补齐,外部节点是黑色的。
  • 每个红节点的儿子节点必须是黑色的,但是黑节点的儿子节点既可以是红色的,又可以是黑色的。这个规则可以理解为“红不相邻”。
  • 在任意节点到其所属子树中的任意外部节点的路径上,标记为黑色的节点数量相同——这个规则可以理解为“黑平衡”。该规则适用于红黑树中的任意子树。
  • 在添加节点时,默认添加的节点是红色的。如果添加的节点是整棵红黑树的根节点,则会涉及改色操作。

在以上红黑树特性中,第4条的“红不相邻”特性和第5条的“黑平衡”特性是最重要的特性。
下图使用圆形表示树的真实节点,使用正方形表示树的外部节点,使用深灰色表示黑节点,使用白色表示红节点。
根据红黑树的特性,图所示为一棵红黑树。
在这里插入图片描述

2.3.4.4 红黑树操作规则

当红黑树受到某些写操作的影响而变得不再满足红黑树的结构要求时,红黑树就会自行修正当前的树结构,使自己重新满足红黑树的结构要求。

红黑树的修正操作有3种,分别为

  • 改色操作
  • 节点左旋操作
  • 节点右旋操作。

无论红黑树在写操作中出现什么情况,都可以通过这3种操作使其重新满足红黑树的结构要求。
这3种操作会对红黑树进行局部调整,调整过程涉及的节点包括当前节点及其父节点、祖父节点、叔节点(父节点的兄弟节点)、侄子节点。
无论红黑树的节点规模有多大,单一操作都可以将时间复杂度控制在一定的范围内。

2.3.4.4.1 改色操作

改色操作可以在红黑树的“红不相邻”特性受到影响时进行。当左右子树的平衡性发生变化时,也需要进行改色操作。

2.3.4.4.2 左旋转操作

以某个节点为旋转节点P,将节点P向其左儿子节点(记为节点LP)的位置移动,节点P原来的右儿子节点(记为节点RP)会占据节点P的原位置,成为节点P的父节点;节点RP的左儿子节点会成为节点P的右儿子节点,如图所示。
在这里插入图片描述
节点左旋操作可以有效减少以当前节点P为根节点的左、右子树的平衡度,并且不会改变当前子树的节点规模。
也就是说,增加左子树的深度(左子树深度+1),减少右子树的深度(右子树深度-1)。

2.3.4.4.3 右旋转操作

以某个节点为旋转节点P,将节点P向其右儿子(记为节点RP)的位置移动,节点P原来的左儿子节点(记为节点LP)会占据节点P的原位置,成为节点P的父节点;节点LP的右儿子节点会成为节点P的左儿子节点,如图所示。
在这里插入图片描述

2.3.4.4.4 添加操作

红黑树的节点添加操作包括两个步骤。

  • (1)根据权值将新的节点添加到红黑树的正确位置,这一步相对简单。
  • (2)从新添加的节点开始,重新递归调整红黑树的平衡性。这是因为节点添加操作可能破坏了红黑树的平衡结构。

为了不出现误读,需要提前针对操作中各种节点的命名给出统一说明,如图所示。
在这里插入图片描述
在这里插入图片描述

1.简单的节点添加操作场景

  • 1)场景一:当前红黑树没有任何节点。
    这种情况表示要向红黑树中添加第一个节点,并且将这个节点作为红黑树的根节点。因为默认当前添加的节点是红色的,而红黑树结构要求根节点必须是黑色的,所以在这种情况下只需更改新增节点的颜色。这是一个必要的过程,无论在哪种添加操作场景中,都需要在每次完成处理后,保证红黑树的根节点是黑色的。
  • 2)场景二:当前节点的父节点是黑色的。
    在这种情况下,新添加的红节点P不会影响红黑树的“红不相邻”及“黑平衡”特性,所以无须进行任何额外处理。将新节点P关联到正确的黑节点下,即可完成添加操作,如图所示。
    在这里插入图片描述
  • 3)场景三:当前节点的父节点和叔节点都是红色的。
    在这种情况下,当前节点的祖父节点必定是黑色的,否则不符合“红不相邻”特性。因为新添加的节点是红色的,所以需要进行改色操作,将当前节点的父节点和叔节点的颜色改为黑色,将当前节点的祖父节点的颜色改为红色,并且以当前节点的祖父节点为下一次递归处理的依据节点,具体操作如图所示。
    图中的4种情况都满足父节点和叔节点颜色是红色的场景,只是P节点所处的位置不一样。这里有一个非常重要的注意事项:在所有递归处理结束后,如果当前节点是整棵树的根节点,则需要在最后将根节点从红色转换成黑色,从而确保整棵树的根节点始终是黑色的。
    在这里插入图片描述

2.较复杂的节点添加操作场景

以上3种场景都是简单处理场景,处理过程要么是直接添加,要么是在添加后进行改色操作,都未涉及当前节点P的旋转操作。
下面介绍几种较复杂的处理场景,这些场景都会涉及当前节点p的旋转操作。

这些场景之所以需要进行节点旋转操作,是因为在添加新的红节点后,“红不相邻”和“黑平衡”特性同时受到了破坏,需要通过改色操作和节点旋转操作使其重新符合红黑树规则。

在这些场景中,新增节点的父节点只能是红色的(因为如果父节点是黑色的,则只需添加新节点,无须进行其他任何操作),也就是说,叔节点是黑色的(因为如果叔节点是红色的,则属于场景三)。这些复杂场景有四种。

  • 1)场景四:当前节点是父节点的右儿子节点,父节点是祖父节点的右儿子节点。该场景的情况如图所示。
    在这里插入图片描述
    在这种情况下,会以当前节点的祖父节点为基点进行节点左旋操作。
    因为在进行节点左旋操作后,以祖父节点为根节点的子树平衡性会被破坏,所以需要在进行节点左旋操作前进行改色操作:
    将当前节点的父节点颜色改为黑色,将当前节点的祖父节点颜色改为红色。具体操作过程如图所示。
    在这里插入图片描述
    在进行节点左旋操作后,会以当前节点P为基点继续进行递归调整操作。

  • 2)场景五:当前节点是父节点的左儿子节点,父节点是祖父节点的左儿子节点。该场景的情况如图所示。
    在这里插入图片描述
    在这种情况下,会以当前节点的祖父节点为基点进行节点右旋操作。
    因为在进行节点右旋操作后,以祖父节点为根节点的子树平衡性会被破坏,所以需要在进行节点右旋操作前进行改色操作:
    将当前节点的父节点颜色改为黑色,将当前节点的祖父节点颜色改为红色。具体操作过程如图所示。
    在这里插入图片描述
    在进行节点右旋操作后,会以当前节点P为基点继续进行递归调整操作。

  • 3)场景六:当前节点是父节点的右儿子节点,父节点是祖父节点的左儿子节点。
    在这里插入图片描述
    在这种情况下,首先需要通过节点旋转(左旋)操作将4个节点的结构转换为场景五中的结构,然后按照场景五的处理方法进行处理。
    需要注意的是,在将场景六转换为场景五后,参照节点换成了当前节点的父节点(节点N),这样才真正满足场景五的操作要求;
    在这里插入图片描述

  • 4)场景七:当前节点是父节点的左儿子节点,父节点是祖父节点的右儿子节点。
    在这里插入图片描述
    在这种情况下,首先需要通过节点旋转(右旋)操作将4个节点的结构转换为场景四中的结构,然后按照场景四的处理方法进行处理。需要注意的是,在将场景七转换为场景四后,参照节点换成了当前节点的父节点(节点N),这样才真正满足场景四的操作要求
    在这里插入图片描述

2.3.4.4.5 删除操作

对于红黑树的节点删除操作,关键是在删除节点后,如何保证整棵红黑树的“红不相邻”特性和“黑平衡”特性。
保证“红不相邻”特性和“黑平衡”特性的基本方式是进行改色操作和节点旋转操作。

红黑树的节点删除操作与堆的节点删除操作类似,但也有一定区别。

  • 堆节点删除堆结构中的叶子节点带来的结构影响是最小的。在删除任意位置X上的节点时,堆采取的节点删除方式是用最后一个有效索引位上的节点A替换当前要删除的节点B(节点B变为节点A,同时将最后一个有效索引位上的节点A删除),接着对位置X上替换后的节点进行升序或降序操作,从而达到重新平衡堆的目的。

  • 红黑树的节点删除操作是用最后一个有效索引位上的节点A替换当前要删除的节点B(节点B变为节点A,但此时最后一个索引位上的节点A并未被删除);为了保证红黑树的“红不相邻”特性和“黑平衡”特性,在必要时还要进行改色操作、节点旋转操作,从而递归修正子树结构;最后将原来的节点A删除。

如何找到替换节点?

  • 1)寻找替换节点的原理。
    在红黑树中按照权值进行排列的节点,遵循二叉查找树的排列特点,如果将一棵二叉查找树降维成线性(数组)结构,那么它可以表现出如图所示的效果.
    在这里插入图片描述
    根据图可知,在将二叉查找树降维成数组结构后,所有节点会按照权值大小在线性平面上有序排列,便于寻找某个节点的前置节点和后置节点.
    我们知道红黑树是满足二查找树的要求的,最直观的现象是,红黑树中某节点右子树中所有节点的权值都大于该节点的权值;红黑树中某节点左子树中所有节点的权值都小于该节点的权值。基于这样的基本原则,在使用新节点替换红黑树中当前被删除的节点后,需要使红黑树满足二又查找树的这个基本原则。
    有4种查找方式可以找到被删除节点的替换节点(相邻节点),如图所示。
    在这里插入图片描述
    TreeMap集合中拥有完整的寻找二又查找树中前置节点和后置节点的方法,分别为predecessor()方法和successor()方法,寻找替换节点的操作是基于successor()方法实现的。如果当前要删除的节点是叶子节点,则不必进行这样的查找操作。
    如果当前将要删除的节点同时存在左子树和右子树,那么查找要删除节点在权值排列上的后置节点,并且将其作为替换节点。在找到替换节点后,可以先使用替换节点(记为节点R)代替当前被删除的节点(记为节点P),如果有必要,则会进行相应改色操作和节点旋转操作,最后删除节点R
    在这里插入图片描述
  • 2)归纳几种节点删除的情况。
  • 情况一:要删除的节点是根节点。
    这种情况最简单,在确定操作者要删除根节点后,直接设置root对象为nul即可。
  • 情况二:要删除的节点只存在右子树或左子树。
    在这种情况下,根据红黑树的构造特点可知,当前节点左、右子树的高度差不会超过1,所以构造情况无非是如图所示的两种情况。
    在这里插入图片描述
    这种情况可能是在删除节点时,找到了相邻的替换节点D;也可能是当前操作者就是要删除节点D。在这种情况下,节点D只可能有一侧的儿子节点(要么只有左儿子节点,要么只有右儿子节点),所以可以使用儿子节点替换节点D。但因为不知道节点D原来的颜色,所以在将其删除后,可能需要对红黑树进行调整;
  • 情况三:要删除的节点没有左子树和右子树。在这种情况下,要删除的节点没有儿子节点,可以直接将其删除。但因为不知道要删除的节点原来的颜色,所以在将其删除后,需要对红黑树进行调整。替换-删除转换关系的三种情况如图所示。
    在这里插入图片描述
    至此,我们完成了红黑树节点删除操作的第一步:找到当前要删除节点,并且根据实际情况,直接删除节点,或者使用其相邻节点进行替换。

下面我们开始进行第二步操作,进行红黑树的调整操作。
在删除或完全替换节点后,可能需要进行改色操作,这主要取决于在删除或替换节点后,会不会影响红黑树的结构。

为了不影响红黑树的结构,需要满足以下要求。

  • 包括外部节点(虚拟节点)在内的所有节点都带有颜色,要么是黑色的,要么是红色的。
  • 根节点一定是黑色的。
  • 如果红黑树中的某个节点没有左、右子树,则会使用外部节点(虚拟节点)进行补齐,外部节点是黑色的。
  • 每个红节点的儿子节点一定是黑色的,但是黑节点的儿子节点既可以是红色的,又可以是黑色的,可以理解为“红不相邻”特性。
  • 在根节点到任意外部节点(虚拟节点)的路径上,标记为黑色的节点数量相同,可以理解为“黑平衡”特性,该特性适用于红黑树中的任意子树。·在进行节点添加操作时,默认添加的节点是红色的。如果添加后的节点是整棵树的根节点,则会涉及改色操作。
  • 在完成了全部的调整过程后,需要将替换节点D删除。

重点是保证红黑树满足“红不相邻”特性和“黑平衡”特性。
要保证红黑树满足这两种特性,就要保证每棵子树都满足这两种特性。

2.3.5 Map集合实现—TreeMap

TreeMap集合是一种基于红黑树构建的K-V键值对集合
其主要的工作逻辑和后面要介绍的HashMap集合中基于红黑树结构部分的工作逻辑相似
(HashMap集合内部是复合结构:数组+链表+红黑树),但又有所区别。
例如,TreeMap集合中没有HashMap集合中的桶概念,也没有数组概念。

2.3.5.1 基本使用方法

TreeMap集合是基于红黑树构建的,其集合内的所有K-V键值对节点都是这棵红黑树上的节点。
这些K-V键值对节点的排列顺序主要基于两种逻辑考虑:

  • 第一种是基于K-V键值对节点中Key键信息的Hash值
  • 第二种是基于使用者设置的java.util.Comparator接口实现的比较结果。

选择哪种排序逻辑取决于TreeMap集合在实例化时使用哪个构造方法。

由于内部是红黑树结构,因此TreeMap集合拥有较低的时间复杂度,在进行节点查询、添加、删除操作时,平均时间复杂度可控制为O(logn)。
此外,一些第三方工具包针对TreeMap集合做了针对性优化,如Apache提供的org.apache.commons工具包中的org.apache.commons.collections.FastTreeMap集合。

TreeMap集合不是线程安全的集合,并且在JCF原生的线程安全的集合中并没有与其结构类似的集合可供选择。
所以如果使用者需要在线程安全的情况下使用TreeMap集合,则可以采用如下方式将一个线程不安全的集合封装为一个线程安全的集合;

(这种封装方式同样适用其他类似集合)。

TreeMap<String, Object> treeMap = new TreeMap<>();
Map<String, Object> stringObjectMap = Collections.synchronizedMap(treeMap);

在上述源码中,转换过程主要是Collections工具类的synchronizedMap()方法在起作用,该方法的源码片段如下。
在这里插入图片描述
虽然JCF提供的SynchronizedMap代理类可以将一个线程不安全的集合封装为一个线程安全的集合,但是由于SynchronizedMap类内部使用的是Object Monitor机制的悲观锁实现,并且锁的粒度过于粗放,因此不推荐;
在这里插入图片描述

2.3.5.2 重要属性和方法
public class TreeMap<K,V>
        extends AbstractMap<K,V>
        implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
    /**
     * 该比较器对象非常重要,记录了红黑树中各个节点排序顺序的判定逻辑
     * 该比较器可以为null,如果为null,那么在判定红黑树节点的排列顺序时,
     * 会采用TreeMap集合原生的基于K-V键值对 key-Hash 值的判定方式
     * The comparator used to maintain order in this tree map, or
     * null if it uses the natural ordering of its keys.
     *
     * @serial
     */
    private final Comparator<? super K> comparator;

    /**
     * 该变量主要用于记录当前TreeMap集合中红黑树的根节点
     */
    private transient Entry<K,V> root;

    /**
     * 该变量主要用于记录当前TreeMap集合中K-V键值对节点的数量
     * The number of entries in the tree
     */
    private transient int size = 0;

    /**
     * 该变量主要用于记录当前TreeMap集合执行写操作的次数
     * The number of structural modifications to the tree.
     */
    private transient int modCount = 0;
...
}

TreeMap集合有4个构造方法,这4个构造方法实际上进行的是同一个工作
即根据入参情况决定comparator变量的赋值情况,以及TreeMap集合在进行初始化操作时的红黑树结构状态。

public class TreeMap<K,V>
        extends AbstractMap<K,V>
        implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
  
    /**
     * 默认的构造方法,会设置TreeMap集合中的比较器对象comparator为nul1
     * 实例化的TreeMap集合会使用K-V键值对节点的Key键信息的Hash值进行排序
     */
    public TreeMap() {
        comparator = null;
    }

    /**
     * 该构造方法可以为当前TreeMap集合对象设置一个比较器对象
     */
    public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }

    /**
     * 该构造方法可以将一个特定Map集合中的所有K-V键值对节点复制(引用)到新的TreeMap集合中
     * 因为源集合没有实现SortedMap接口,所以将当前TreeMap集合的比较器对象comparator设置为null
     */
    public TreeMap(Map<? extends K, ? extends V> m) {
        comparator = null;
        putAll(m);
    }

    /**
     * 该构造方法可以将一个实现了SortedMap接口的集合中的所有对象复制到新的TreeMap集合中
     * 因为原集合实现了SortedMap接口,所以将源集合使用的比较器对象comparator赋值给当前集合
     */
    public TreeMap(SortedMap<K, ? extends V> m) {
        comparator = m.comparator();
        try {
            buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
        } catch (java.io.IOException cannotHappen) {
        } catch (ClassNotFoundException cannotHappen) {
        }
    }
 ...
 }   

需要注意的是,在上述源码片段中,buildFromSorted()方法可以基于一个有序集合(可能是某个有序的Map集合中基于Map.Entries的迭代器,也可能是某个有序的Map集合中基于所有K-V键值对节点中的Key键信息的迭代器)构建一棵红黑树。
这个方法在TreeMap集合的方法定义中有两个多态表达;

2.3.5.3 批量节点添加操作

使用TreeMap集合提供的putAll(Map<?extendsK,?extends V>map)方法可以批量添加K-V键值对节点,具体源码如下。

  public void putAll(Map<? extends K, ? extends V> map) {
        int mapSize = map.size();
        //如果当前TreeMap集合中的K-V键值对节点数量为0,要添加的K-V键值对节点数量不为0,
        //并且当前传入的Map集合实现了SortedMap接口(说明是有序的Map集合),则继续进行后续判断
        if (size==0 && mapSize!=0 && map instanceof SortedMap) {
            Comparator<?> c = ((SortedMap<?,?>)map).comparator();
            //取得传入的有序Map集合的比较器对象comparator
            //如果比较器对象与当前TreeMap集合使用的比较器对象是同一个对象
            //则使用前面介绍的buildFromSorted()方法构建一棵新的红黑树
            //这意味着不再对在执行该方法前,当前TreeMap集合中已有的K-V键值对节点进行维护
            //但在执行该方法前,当前TreeMap集合中并没有K-V键值对节点
            if (c == comparator || (c != null && c.equals(comparator))) {
                ++modCount;
                try {
                    buildFromSorted(mapSize, map.entrySet().iterator(),
                            null, null);
                } catch (java.io.IOException cannotHappen) {
                } catch (ClassNotFoundException cannotHappen) {
                }
                return;
            }
        }
        //如果当前TreeMap集合的状态不能使上述两个嵌套的if条件成立
        //则对当前批量添加的K-V键值对节点逐一进行操作
        super.putAll(map);
    }
2.3.5.4 节点添加操作

TreeMap集合可以使用put(K,V)方法添加新的K-V键值对节点,具体方法参照之前介绍的红黑树添加新节点的方法。
在TreeMap集合中添加新的K-V键值对节点,包含两个关键步骤。

  • (1)通过堆查询的方式找到合适的节点,将新的节点添加成前者的左叶子节点或右叶子节点。
  • (2)节点添加操作可能导致红黑树失去平衡性,需要使红黑树重新恢复平衡性。

在TreeMap集合中添加新的K-V键值对节点的源码如下。

    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     *
     * @return the previous value associated with {@code key}, or
     *         {@code null} if there was no mapping for {@code key}.
     *         (A {@code null} return can also indicate that the map
     *         previously associated {@code null} with {@code key}.)
     * @throws ClassCastException if the specified key cannot be compared
     *         with the keys currently in the map
     * @throws NullPointerException if the specified key is null
     *         and this map uses natural ordering, or its comparator
     *         does not permit null keys
     */
    public V put(K key, V value) {
        Entry<K,V> t = root;
        //如果该条件成立,则表示当前红黑树为nul1,即没有红黑树结构
        if (t == null) {
            //在这种情况下,需要进行compare操作,
            //一个作用是保证当前红黑树使用的compare比较器对方法运行时传入的key是有效的;
            // 另一个作用是确保key不为null
            compare(key, key); // type (and possibly null) check
            //创建一个root节点,修改modCount变量代表的操作次数
            //完成节点添加操作
            root = new Entry<>(key, value, null);
            size = 1;
            modCount++;
            return null;
        }
        int cmp;
        Entry<K,V> parent;
        // split comparator and comparable paths
        //comparator 对象是在TreeMap集合实例化时设置的比较器
        //根据前文中TreeMap集合的实例化过程,compa rator 对象可能为nul1
        Comparator<? super K> cpr = comparator;
        //如果comparator对象不为null,那么基于这个comparator对象寻找添加节点的位置
        if (cpr != null) {
            do {
                parent = t;
                cmp = cpr.compare(key, t.key);
                //如果条件成立,则说明要添加的节点的权值小于当前比较的红黑树中节点的权值
                //在t节点的左子树中寻找添加节点的位置
                if (cmp < 0)
                    t = t.left;
                //如果条件成立,则说明要添加的节点的权值大于当前比较的红黑树中节点的权值
                else if (cmp > 0)
                    t = t.right;
                else
                    //否则说明要添加的节点的权值等于当前比较的红黑树中节点的权值
                    //将节点添加操作转换为节点修改操作
                    return t.setValue(value);
            } while (t != null);
        }
        //如果comparator对象为null,那么基于key自带的comparator对象寻找添加节点的位置
         //寻找节点添加位置的逻辑和以上条件代码块的逻辑相同,此处不再赘述
        else {
            if (key == null)
                throw new NullPointerException();
            @SuppressWarnings("unchecked")
            Comparable<? super K> k = (Comparable<? super K>) key;
            do {
                parent = t;
                cmp = k.compareTo(t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        //如果t为null,则说明找到了添加新节点的位置,
        //parent对象代表的红黑树中的节点就是新节点的父节点
        Entry<K,V> e = new Entry<>(key, value, parent);
        //如果条件成立,则说明应该将新节点添加成pare nt节点的左儿子节点
        //否则就添加成Parent节点的右儿子节点
        if (cmp < 0)
            parent.left = e;
        else
            //因为节点添加操作可能破坏红黑树的“红不相邻”或“黑平衡”特性
            parent.right = e;
        //所以使用fixAfterInsertion()方法进行处理,重新平衡红黑树
        fixAfterInsertion(e);
        size++;
        modCount++;
        return null;
    }

TreeMap集合的节点添加操作还需要处理很多特殊场景。

例如,在查找节点添加位置时发现要添加的K-V键值对节点的Key键信息已经存储于红黑树中,这时将节点添加操作转换为节点修改操作;在当前TreeMap集合中没有任何节点的情况下(root==null),节点添加操作就是对root节点的添加操作。

2.3.5.5 节点删除操作

在讲解红黑树的相关知识时,节点删除操作的关键点是如何找到要删除或替换的节点,以及在删除或替换节点后如何进行红黑树的结构调整。
本节重点讲解这两个步骤在TreeMap集合中的具体表现方式。

  • 1)如何找到替换节点。
    TreeMap集合使用successor()方法寻找当前节点在权值排列上的下一个节点(需要替换的节点),如果返回nul,则说明当前节点没有下一个节点,具体源码如下。

在这里插入图片描述
TreeMap集合中有一个predecessor()的方法,也可以使用该方法寻找指定节点的相邻节点,但该方法主要用于进行节点删除操作;

  • 2)如何进行红黑树的调整操作。
    TreeMap集合主要使用fixAfterDeletion(Entry)方法进行节点正式删除前的红黑树调整操作;
    /** From CLR */
    private void fixAfterDeletion(Entry<K,V> x) {

         //只有在当前操作节点是黑节点且不是父节点的情况下,才会继续进行递归操作
        while (x != root && colorOf(x) == BLACK) {
            //整段代码被分成两个部分,两个部分为对称情况,即同一种场景的两种对称情况
            if (x == leftOf(parentOf(x))) {
                 //这是兄弟节点
                Entry<K,V> sib = rightOf(parentOf(x));
                //操作节点是黑色的,其兄弟节点是红色的
                //在进行改色操作后,以当前父节点为基点进行节点左旋操作
                //在进行左旋操作后,重新确定当前节点的兄弟节点,然后继续进行处理(这只是一个中间状态)
                if (colorOf(sib) == RED) {
                    setColor(sib, BLACK);
                    setColor(parentOf(x), RED);
                    rotateLeft(parentOf(x));
                    sib = rightOf(parentOf(x));
                }
                //操作节点是黑色的,其兄弟节点是黑色的,其侄子节点也是黑色的
                //在按照规则进行改色操作后,继续以其父节点为基点,递归处理上级子树
                if (colorOf(leftOf(sib))  == BLACK &&
                        colorOf(rightOf(sib)) == BLACK) {
                    setColor(sib, RED);
                    x = parentOf(x);
                    //兄弟节点有两个红色的儿子节点,以及兄弟节点的右儿子或左儿子节点为红节点
                } else {
                    //操作节点是黑色的,其兄弟节点是黑色的,靠近自己的侄子节点是红色的
                    //在转换为场景四后,继续进行处理(这只是一种中间状态)
                    if (colorOf(rightOf(sib)) == BLACK) {
                        setColor(leftOf(sib), BLACK);
                        setColor(sib, RED);
                        rotateRight(sib);
                        sib = rightOf(parentOf(x));
                    }
                    //在处理完成后,整个调整过程就可以结束了
                    setColor(sib, colorOf(parentOf(x)));
                    setColor(parentOf(x), BLACK);
                    setColor(rightOf(sib), BLACK);
                    rotateLeft(parentOf(x));
                    x = root;
                }
                //以下是对称情况
            } else { // symmetric
                Entry<K,V> sib = leftOf(parentOf(x));

                if (colorOf(sib) == RED) {
                    setColor(sib, BLACK);
                    setColor(parentOf(x), RED);
                    rotateRight(parentOf(x));
                    sib = leftOf(parentOf(x));
                }

                if (colorOf(rightOf(sib)) == BLACK &&
                        colorOf(leftOf(sib)) == BLACK) {
                    setColor(sib, RED);
                    x = parentOf(x);
                } else {
                    if (colorOf(leftOf(sib)) == BLACK) {
                        setColor(rightOf(sib), BLACK);
                        setColor(sib, RED);
                        rotateLeft(sib);
                        sib = leftOf(parentOf(x));
                    }

                    setColor(sib, colorOf(parentOf(x)));
                    setColor(parentOf(x), BLACK);
                    setColor(leftOf(sib), BLACK);
                    rotateRight(parentOf(x));
                    x = root;
                }
            }
        }

        setColor(x, BLACK);
    }

2.3.6 Map集合实现—HashMap

HashMap集合是指利用Hash(哈希)算法构造的Map集合,这种集合是多种数据结构在JCF中的典型复合应用。
具体来说,就是利用K-V键值对节点中Key键对象的某个属性(默认使用该对象“内存起始位置值”属性)作为计算依据进行哈希计算,然后根据计算结果,将当前K-V键值对节点添加到HashMap集合中的某个位置上,这个位置和上一次添加K-V键值对节点的位置可能没有因果联系。
HashMap集合的继承体系如图所示。
在这里插入图片描述
hashCode()方法是计算Hash值的关键方法,这个方法遵循以下默认原则。

  • 对于同一个对象,无论它的hashCode()方法被调用多少次,返回的值都是一样的。
  • 如果使用对象的equals(Object)方法进行比较,得到两个对象相等的结果,那么调用这两个对象的hashCode()方法会得到相同的返回值;
  • 如果调用两个对象的hashCode()方法得到了不同的返回值,那么对象的equals(object)方法进行比较,会得到两个对象不相等的结果。
  • 程序员可根据对象的使用场景重写hashCode()方法,但基于以上两条原则,除了hashCode()方法,程序员还需要重写equals(Object)方法。

HashMap 简单使用:
在这里插入图片描述
主要有几点:

  • k不能重复,v可以重复,如果k存在,v的值会替换之前的值;
  • k可以为null
  • 无序存储,存入的顺序和输出的顺序可能不一样
2.3.6.1 HashMap的结构

HashMap集合的主要结构包括一个数组结构、一个链表结构和一个红黑树结构,如图所示。
在这里插入图片描述
图展示了HashMap集合的主要结构,可以发现,HashMap集合的基础结构是一个数组(变量名为table),这个数组的长度最小为16,并且可以以2的幂数进行数组扩容操作——这是一个非常有趣的现象,后面我们会对这个数组特性进行说明。

数组索引位上可能存储着K-V键值对节点,也可能没有存储任何对象(null)。
当数组索引位上存储着K-V键值对节点时

  • 如果这个K-V键值对节点是HashMap.Node类的对象,那么会以这个索引位上的节点为开始节点构建一个单向链表;
  • 如果这个K-V键值对节点是HashMap.TreeNode类的对象,那么会以这个索引位上的节点为根节点构建一棵红黑树。
    与单向链表相比,红黑树的时间复杂度更低、平衡性更好。
2.3.6.1.1 HashMap的结构-单链表

HashMap集合使用HashMap.Node类的对象构建单向链表(中的每一个节点),以HashMap集合中数组(前面提到的HashMap集合中table变量所代表的数组)中的每一个索引位上的数据对象为基础,都可以构建一个独立的单向链表,如图所示。
在这里插入图片描述

HashMap.Node类的相关源码如下。

static class Node<K,V> implements Map.Entry<K,V> {
        //该属性主要用于存储当前K-V键值对节点排列在HashMap集合中所依据的Hash计算结果
        //它的赋值过程可以参考HashMap集合中的newNode()方法和replacementNode()方法
        final int hash;
        //记录当前K-V键值对节点的Key键信息
        //因为K-V键值对节点在HashMap集合中的排列位置完全参考Key键对象的hashCode()方法的返回值
        //所以K-V键值对节点一旦完成初始化操作,该变量就不允许变更了
        final K key;
        //记录当前K-V键值对节点的Value值信息
        V value;
        HashMap.Node<K,V> next;
        //因为需要使用Node节点构建单向链表,所以需要next属性存储单向链表中当前节点的下一个节点引用
        Node(int hash, K key, V value, HashMap.Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
        //重写hashCode()方法,为计算当前K-V键值对节点的Hash值提供另一种逻辑
        public final int hashCode() {
            //将Node节点的Key键信息的Hash值和Value值信息的Hash值进行异或运算
            //得到当前K-V键值对节点的Hash值
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        //重写hashCode()方法,意味着其equals()方法也必须被重写
        public final boolean equals(Object o) {
            //如果进行比较的两个K-V键值对节点的内存起始地址相同,则表示两个K-V键值对节点是相同的
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                //如果进行比较的两个K-V键值对节点的Key键信息相等,并且Value值信息也相等则表示两个K-V键值对节点是相同的
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            //在其他情况下,进行比较的两个K-V键值对节点不相同
            return false;
        }
    }

几个详细的点介绍

  • 1)hashCode()方法被重写的要求。
    Java官方对重写对象的hashCode()方法有严格的要求,这个要求前面介绍过,这里再进行一次强调
    —在这种情况下重写hashCode()方法,需要重写相应的equals()方法。如果根据对象的equals(object)方法进行比较,得到两个对象相等的结果,那么调用两个对象的hashCode()方法会得到相同的返回值;
    换句话说,在这种情况下,如果调用两个对象的hashCode()方法得到了不同的返回值,那么根据对象的equals(object)方法进行比较,会得到两个对象不相等的结果。
  • 2)Node类中的hashCode()方法。
    虽然当前HashMap.Node类中的hashCode()方法被重写了,但是该方法的返回值并不会作为HashMap集合中定位某个节点所在位置的依据,确认这个位置依据的是K-V键值对节点中的Key键信息的hashCode()方法的计算结果。
  • 3)Objects工具类。
    从JDK1.7开始,Java为开发者提供了一个工具类—Objects。
    Objects工具类为程序员提供了进行对象比较、检验的基本操作,如两个对象的比较操作(compare(T,S,Comparator))、计算对象Hash值(hashCode(Object))、计算多个对象的Hash值组合(hashCode(Object[]))、校验或确认当前对象是否为空
    (isNull(Object)、nonNull(Object)、requireNonNull(Object)等)、返回对象的字符串信息(toString(Object))。
  • 4)HashMap.Node类的继承体系如图所示。
    在这里插入图片描述
2.3.6.1.2 HashMap的结构-红黑树

当某个索引位上的链表长度达到指定的阈值(默认为单向链表长度超过8)时,单向链表会转化为红黑树;
当红黑树中的节点足够少(默认为红黑树中的节点数量少于6个)时,红黑树会转换为单向链表。

HashMap集合使用HashMap.TreeNode类的对象表示红黑树中的节点,从而构成一棵红黑树,如图所示。
在这里插入图片描述
红黑树相关源码片段如下:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    //当前节点的双亲节点(父节点)
    HashMap.TreeNode<K,V> parent;  // red-black tree links
    //当前节点的左儿子节点
    HashMap.TreeNode<K,V> left;
    //当前节点的右儿子节点
    HashMap.TreeNode<K,V> right;
    HashMap.TreeNode<K,V> prev;    // needed to unlink next upon deletion
    //当前节点的颜色是红色还是黑色
    boolean red;
    TreeNode(int hash, K key, V val, HashMap.Node<K,V> next) {
        super(hash, key, val, next);
    }
}    

在这里插入图片描述

2.3.6.2 HashMap的主要工作过程
2.3.6.2.1.关键常量和属性

HashMap集合的三大基础结构:数组、链表和红黑树,它们是如何进行相互协作的呢?
HashMap集合中有一些关键的常量信息和变量信息,它们会在交互过程中发挥作用,源码如下。

public class HashMap<K, V> extends AbstractMap<K, V>
        implements Map<K, V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;


    /**
     * 默认的数组初始化容量为16,这个容量只能以2的指数倍进行扩容操作
     * <p>
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * 数组最大容量
     * <p>
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 默认的负载因子值为0.75
     * <p>
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 桶的树化阈值,如果一个桶(单向链表)中的节点数量大于该值,则需要将该桶转换成红黑树
     * 该值至少为8
     * 是否需要进行树化,需要依据MIN_TREEIFY_CAPACITY常量的值进行判定
     * <p>
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 桶的反树化阈值,如果一个桶中的红黑树节点数量小于该值,则需要将该桶从红黑树重新转换为单链表
     * <p>
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * 当集合中的K-V键值对节点过多时,是进行树化操作还是进行扩容操作呢?
     * 针对这个问题,HashMap集合使用MIN_TREEIFY_CAPACITY 常量进行控制
     * <p>
     * 只有当集合中K-V键值对节点数大于该值,
     * /并且某个桶中的K-V键值对节点数大于TREEIFY_THRESHOLD的值时,该桶才会进行树化操作
     * <p>
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    static class Node<K, V> implements Map.Entry<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next;

        Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final String toString() {
            return key + "=" + value;
        }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

    /* ---------------- Static utilities -------------- */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    /**
     * Returns x's Class if it is of the form "class C implements
     * Comparable<C>", else null.
     */
    static Class<?> comparableClassFor(Object x) {
        if (x instanceof Comparable) {
            Class<?> c;
            Type[] ts, as;
            Type t;
            ParameterizedType p;
            if ((c = x.getClass()) == String.class) // bypass checks
                return c;
            if ((ts = c.getGenericInterfaces()) != null) {
                for (int i = 0; i < ts.length; ++i) {
                    if (((t = ts[i]) instanceof ParameterizedType) &&
                            ((p = (ParameterizedType) t).getRawType() ==
                                    Comparable.class) &&
                            (as = p.getActualTypeArguments()) != null &&
                            as.length == 1 && as[0] == c) // type arg is c
                        return c;
                }
            }
        }
        return null;
    }

    /**
     * Returns k.compareTo(x) if x matches kc (k's screened comparable
     * class), else 0.
     */
    @SuppressWarnings({"rawtypes", "unchecked"}) // for cast to Comparable
    static int compareComparables(Class<?> kc, Object k, Object x) {
        return (x == null || x.getClass() != kc ? 0 :
                ((Comparable) k).compareTo(x));
    }

    /**
     * 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;
    }

    /* ---------------- Fields -------------- */

    /**
     * 使用该变量记录HashMap集合的数组结构
     * 数组可以扩容,甚至在进行某些操作时允许数组的长度为0
     * <p>
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K, V>[] table;

    /**
     * 该Set集合存储了当前集合中所有K-V键值对节点的引用
     * 可以将该Set集合理解为缓存方案,它不在意每个K-V键值对节点的真实存储位置
     * 还可以有效减少HashMap集合的编码工作量
     * <p>
     * <p>
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set<Map.Entry<K, V>> entrySet;

    /**
     * 记录当前K-V键值对节点的数量
     * The number of key-value mappings contained in this map.
     */
    transient int size;

    /**
     * 记录该集合在初始化后进行写操作的次数
     *
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient int modCount;

    /**
     * table数组下一次进行扩容操作的门槛,这个门槛值=当前集合容量值*loadFactor int threshold;
     *
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

    /**
     * 设置的负载因子,默认为DEFAULT_LOAD_FACTOR,可以设置该值大于1
     * 
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;

    /* ---------------- Public operations -------------- */

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param initialCapacity the initial capacity
     * @param loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *                                  or the load factor is nonpositive
     */
    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);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
  • 1)负载因子loadFactor。
    • 从表面上看,负载因子loadFactor维护着集合内K-V键值对节点的数量与集合中数组大小的平衡,将当前集合容量值与负载因子loadFactor相乘,可以得到数组下一次进行扩容操作的K-V键值对节点数量;
    • 从实质上看,负载因子loadFactor维护着集合存储所需的空间资源和集合操作所需的时间资源之间的平衡。
  • 2)数组table和容量的定义。
    • 需要注意的是,数组table的容量并不是HashMap集合的容量,因为从数组中的任意一个索引位出发,都可能存在一个单向链表或一棵红黑树;即使在数组中的某些索引位上还没有存储任何K-V键值对节点的情况下,数组也会进行扩容操作。
2.3.6.2.2 HashMap集合的初始化

HashMap集合的初始化过程,主要是构建HashMap集合中数组结构、初始化负载因子、确定扩容门槛的过程,相关源码片段如下。


    /**
     * 该构造器有两个参数
     *
     * @param initialCapacity 初始容量
     * @param loadFactor      负载因子
     */
    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);
    }


    /**
     * 该构造器存在一个参数,默认负载因子为0.75
     *
     * @param initialCapacity 初始容量
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 默认构造器,容量为16,负载因子为0.75
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     * 指定一个map作为初始值
     *
     * @param m the map whose mappings are to be placed in this map
     * @throws NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

tableSizeFor(int)方法主要用于返回一个比当前入参cap大且最接近2的幂数的值。

这句话如何理解呢?2的幂数为2、4、8、16、32、64、128、256、512、1024等,当cap的值为500时,该方法会返回512;当cap值为100时,该方法会返回128。tableSizeFor(int)方法中有一个关键的调用语句是“Integer.numberOfLeadingZeros(cap-1);”。
这里的Integer.numberOfLeadingZeros(int)方法是从JDK 1.5开始提供的一个方法,它的作用是根据入参代表的无符号正数,返回这个数最高非零位前面的0的个数(包括符号位在内)。

例如,数值100是一个32位整数,它的二进制表达为“00000000000000000000000001100100”,而它的最高非0位前面,包括符号位在内,有25个0,所以Integer.numberOfLeadingZeros(int)方法会返回25。
前文已经介绍过,负数的二进制表达是其对应的正数的补码,所以-1的二进制表达为“11111111111111111111111111111111”,在将-1的二进制表达左移25位后,可以得到数值127的二进制表达。

2.3.6.2.3 添加K-V键值对节点(链表方式)

HashMap集合使用put(K,V)方法添加新的K-V键值对节点,如果新加入的K-V键值对节点的Key键信息与某个已存在于集合中的K-V键值对节点的Key键信息相同(相同是指Key键对象的Hash值相同),则替换原来的K-V键值对节点中的Value值信息。

put(K,V)方法的源码片段如下。

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
        //如果当前HashMap集合使用的数组为nul1或数组长度为0,则先进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //注意:i = (n - 1) & hash中 n表示当前集合中数组的长度,i的值不会超过n-1
        //这个表达式可以得到新的k-v键值对节点所属的桶,以及当前桶中的第一个节点p
        //如果条件成立,则说明当前数组的索引位上没有任何k-v键值对节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            //直接在这个索引位上添加一个新的K-V键值对节点
            tab[i] = newNode(hash, key, value, null);
        else {
            //如果是其他情况,则说明当前桶中已经存在K-V键值对节点(可能是链表的头节点,也可能是红黑树的根节点)
            HashMap.Node<K,V> e; K k;
            //说明K-V键值对节点的Key键对象的Hash值和当前桶中第一个节点的Key键对象的Hash值相同
            //将节点P赋值给变量e
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果条件成立,则说明当前桶中存储的是红黑树节点,所以使用红黑树方式添加节点
            //该方法的返回值表示是否已存在和当前正在添加的K-V键值对节点中Key键信息相同的红黑树节点
            else if (p instanceof TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //在其他情况下,通过以下for循环依次遍历当前桶中单向链表中的每一个节点
                for (int binCount = 0; ; ++binCount) {
                    //如果当前条件成立,则说明当前节点的K ey键信息和当前添加的K-V键值对节点的Key键信息相同
                    //这时直接退出循环:e变量的赋值操作是通过之前的代码“e=p.next”完成
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果循环执行到这里,则说明以上情况都不成立
                    将p.next节点赋值给p节点,以便进行下一次遍历
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果条件成立,说明集合中已存在和新添加的K-V键值对节点的Key键信息相同的红黑树节点,
            //那么本次操作不是节点添加操作,而是节点更新操作
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

这里对上述源码片段中几个重要的源码信息进行补充:

  • (h=key.hashCode())^(h>>>16)。
    hash(Object)方法通过以上表达式得到指定对象的Hash值,这里是取得当前对象的Hash值,首先进行带符号位的右移16位操作(这时候对象Hash值的高位段就变成了低位段),然后与对象原来的Hash值进行异或运算。
    计算过程示例如图所示。
    在这里插入图片描述
  • i=(n-1)&hash。
    在putVal(int,K,V,boolean,boolean)方法中,通过该表达式得到某个Hash值应该存储在数组中的哪个索引位上(哪一个桶上);需要注意的是,表达式中的n表示当前数组的长度,将长度减一后的与运算结果不可能超过n-1(因为这相当于取余操作),因此可以确定Hash值代表的当前对象在当前数组中的哪个索引位上。
    这个过程示例如图所示。
    在这里插入图片描述
  • (k=p.key)==key ll(key!=null&&key.equals(k))。
    这个表达式在putVal(int,K,V,boolean,,boolean)方法中的两个位置出现过,一个是在得到新增K-V键值对节点应该存储的桶的初始位置后(并且桶中至少有一个节点),在判定新增的K-V键值对节点的Key键信息是否匹配桶中的第一个节点的Key键信息时;
    另一个是在桶结构是单向链表结构的前提下,在循环判定新增的K-V键值对节点的Key键信息是否匹配当前链表中某一个节点的Key键信息时。

判定条件:新增的K-V键值对节点的Key键对象的内存起始位置和当前节点Key键对象的内存起始位置一致;
或者在新增的K-V键值对节点的Key键对象不为nul的情况下,使用equals()方法对两个节点的Key键对象的值进行比对,返回结果为true。

2.3.6.2.4 添加K-V键值对节点(红黑树方式)

在HashMap集合的table数组中的某个索引位上(某个桶上),数据对象可能是按照红黑树结构进行组织的。
所以有时需要基于红黑树进行K-V键值对节点(HashMap.TreeNode<K,V〉类的对象)的添加操作。
在介绍这个操作前,我们首先需要明确一下HashMap集合中红黑树结构中的每个节点是如何构成的,源码片段如下

  static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        ...}

在HashMap集合中,红黑树中的每个节点属性不止包括父级节点引用(parent)、左儿子节点引用(left)、右儿子节点引用(right)和红黑标记(red),还包括一些其他属性,如在双向链表中才会采用的前置节点引用(prev)、后置节点引用(next)、描述当前节点Hash值的属性(hash)、描述当前节点Key键信息的属性(key)、描述当前节点Value值信息的属性(value)。
在HashMap集合中,基于红黑树结构添加K-V键值对节点的源码片段如下。

 /**
     * @param map  当前正在被操作的HashMap集合对象
     * @param tab  当前HashMap集合对象中的tab数组
     * @param h    当前新添加的K-V键值对节点的Hash值
     * @param k    当前新添加的K-V键值对节点的Key值
     * @param v 当前新添加的K-V键值对节点的Value值
     * @return 如果该方法返回的不是nul1,则说明在进行节点添加操作前,
     * //已经在指定的红黑树结构中找到了Key键信息与将要添加的K-V键值对节点的Key键信息
     * 相同的K-V键值对节点,于是后者会被返回,本次节点添加操作终止(会变成修改操作)
     */
    final HashMap.TreeNode<K, V> putTreeVal(HashMap<K, V> map, HashMap.Node<K, V>[] tab, int h, K k, V v) {
        Class<?> kc = null;
        boolean searched = false;
        //注意:parent 变量是一个全局变量,主要用于指示当前操作节点的父节点
        //当前操作节点并不是当前新增的节点,而是被作为新增操作的基准节点
        //如果进行调用过程的溯源,那么当前操作的节点一般是table数组指定索引位上的红黑树节点
        // 使用root()方法可以找到当前红黑树中的根节点
        HashMap.TreeNode<K, V> root = (parent != null) ? root() : this;
        //在找到根节点后,从根节点开始进行遍历,寻找红黑树中是否存在指定的K-V键值对节点
        //“存在”的依据是,在两个参与比较的K-V键值对节点中,Key键信息的Hash值是否一致
        for (HashMap.TreeNode<K, V> p = root; ; ) {
            int dir, ph;
            K pk;
            if ((ph = p.hash) > h)
                dir = -1;
            else if (ph < h)
                dir = 1;
            //如果条件成立,则说明当前红黑树中存在相同的K-V键值对节点
            //将该K-V键值对节点返回,方法结束
            else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                return p;
            else if ((kc == null &&
                    (kc = comparableClassFor(k)) == null) ||
                    (dir = compareComparables(kc, k, pk)) == 0) {
                if (!searched) {
                    HashMap.TreeNode<K, V> q, ch;
                    searched = true;
                    if (((ch = p.left) != null &&
                            (q = ch.find(h, k, kc)) != null) ||
                            ((ch = p.right) != null &&
                                    (q = ch.find(h, k, kc)) != null))
                        return q;
                }
                dir = tieBreakOrder(k, pk);
            }

            HashMap.TreeNode<K, V> xp = p;
            if ((p = (dir <= 0) ? p.left : p.right) == null) {
                HashMap.Node<K, V> xpn = xp.next;
                HashMap.TreeNode<K, V> x = map.newTreeNode(h, k, v, xpn);
                if (dir <= 0)
                    xp.left = x;
                else
                    xp.right = x;
                xp.next = x;
                x.parent = x.prev = xp;
                if (xpn != null)
                    ((HashMap.TreeNode<K, V>) xpn).prev = x;
                moveRootToFront(tab, balanceInsertion(root, x));
                return null;
            }
        }
    }

上面源码有空可以多研究研究

上述源码可以归纳总结为以下步骤。

  • (1)在当前红黑树中查找当前要添加的K-V键值对节点的Key键信息是否已经存在于树中,判断依据是看要添加的K-V键值对节点的Key键对象的Hash值是否和红黑树中的某个K-V键值对节点的Key键对象的Hash值一致。在更细节的场景中,还要看当前Key键对象的类是否规范化地重写了hash()方法和equals()方法,或者是否实现了java.lang.Comparable接口。
  • (2)如果在步骤(1)中,在红黑树中找到了匹配的节点,那么本次操作结束,将红黑树中匹配的K-V键值对节点返回,由外部调用者更改这个K-V键值对节点的Value值信息一一本次节点添加操作就变成了节点修改操作。
  • (3)如果在步骤(1)中,没有在红黑树中找到匹配的K-V键值对节点,那么在红黑树中满足添加位置要求的某个缺失左儿子节点或右儿子节点的节点处添加新的K-V键值对节点(实际上就是红黑树的节点添加操作)。
  • (4)在步骤(3)结束后,红黑树的平衡性可能被破坏了,需要使用红黑树的再平衡算法,重新恢复红黑树的平衡(前面讲解红黑树操作时提到的改色操作和节点旋转操作)。
  • (5)这里红黑树节点的添加过程和我们预想的情况有一些不一样,除了对红黑树相关的父节点引用及左、右儿子节点引用进行操作,还对与双向链表有关的后置节点引用、前置节点引用进行了操作,以便将红黑树转换为链表。

在这里插入图片描述
图中已经进行了说明:
隐含的双向链表中各个节点的链接位置不是那么重要,但是该双向链表和头节点和红黑树的根节点必须随时保持一致。
HashMap.TreeNode.moveRootToFront()方法的作用就是保证以上特性随时成立。

2.3.6.2.5 HashMap集合红黑树、链表互相转换

HashMap集合中table数组中每个索引位上(不同的桶结构上)的K-V键值对节点构成的结构可能是单向链表,也可能是红黑树,在特定的场景中,单向链表和红黑树可以互相转换。
转换原则简单概括如下。单向链表在超过一定长度的情况下会转换为红黑树,红黑树在节点数量足够少的情况下会转换为单向链表。

2.3.6.2.5.1 将单向链表转换为红黑树,转换条件如下
  • 在向单向链表中添加新的节点后,链表中的节点总数大于某个值;
  • HashMap集合中的table数组长度大于64;

这里所说的节点添加操作包括很多种场景,如使用HashMap集合的put(K,V)方法添加新的K-V键值对节点。
而put(K,V)方法内部实质进行K-V键值对节点添加操作的方法是putVal()方法。
在putVal()方法中,将单向链表转换为红黑树的判定逻辑源码片段如下。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            HashMap.Node<K,V> e; K k;
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //遍历当前单向链表,并且使用binCount 计数器记录当前单向链表的长度
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //如果已经遍历到当前链表的最后一个节点,则在这个节点的后面添加一个新的节点
                        p.next = newNode(hash, key, value, null);
                        //如果在添加新节点后,单向链表的长度大于或等于TREEIFY_THRESHOLD(值为8)
                        //也就是说,在添加新节点前,单向链表的长度大于或等于TREEIFY_THRESHOLD-1
                        //则使用treeifyBin()方法将单向链表结构转换为红黑树结构
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

如何将单向链表转换为红黑树,源码如下。

   final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
        int n, index; HashMap.Node<K,V> e;
        //即使转换红黑树的条件成立,也不一定真要转换为红黑树
        //例如,如果HashMap集合中table数组的大小小于MIN_TREEIFY_CAPACITY 常量(该常量为64)
        //则不进行红黑树转换,进行HashMap集合的扩容操作
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            HashMap.TreeNode<K,V> hd = null, tl = null;
            //以下循环的作用是从头节点开始依次遍历当前单向链表中的所有节点
            do {
                //在每次进行遍历时,都会为当前Node节点创建一个新的、对应的TreeNode节点
                //注意:这时所有TreeNode节点还没有构成红黑树,它们首先构成了一个新的双向链表结构
                //这是为以后可能进行的将红黑树转换为单向链表操作做准备
                HashMap.TreeNode<K,V> p = replacementTreeNode(e, null);
                ///如果条件成立,则说明新创建的TreeNode节点是新的双向链表的头节点
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
  /**
     * Forms tree of the nodes linked from this node.
     * @return root of tree
     */
    final void treeify(HashMap.Node<K,V>[] tab) {
        HashMap.TreeNode<K,V> root = null;
        //根据上文可知,this对象指向新的双向链表的头节点
        for (HashMap.TreeNode<K,V> x = this, next; x != null; x = next) {
            next = (HashMap.TreeNode<K,V>)x.next;
            x.left = x.right = null;
            //如果条件成立,则构造红黑树的根节点,根节点默认为双向链表的头节点
            if (root == null) {
                x.parent = null;
                x.red = false;
                root = x;
            }
            //否则基于红黑树的结构要求进行处理
            //以下代码块和之前介绍过的putTreeVal()方法类似
            //简单来说,就是遍历双向链表中的每一个节点,将它们依次添加到新的红黑树中,
            //并且在每次添加完成后重新平衡红黑树
            else {
                K k = x.key;
                int h = x.hash;
                Class<?> kc = null;
                for (HashMap.TreeNode<K,V> p = root;;) {
                    int dir, ph;
                    K pk = p.key;
                    if ((ph = p.hash) > h)
                        dir = -1;
                    else if (ph < h)
                        dir = 1;
                    else if ((kc == null &&
                            (kc = comparableClassFor(k)) == null) ||
                            (dir = compareComparables(kc, k, pk)) == 0)
                        dir = tieBreakOrder(k, pk);

                    HashMap.TreeNode<K,V> xp = p;
                    if ((p = (dir <= 0) ? p.left : p.right) == null) {
                        x.parent = xp;
                        if (dir <= 0)
                            xp.left = x;
                        else
                            xp.right = x;
                        //重新平衡红黑树
                        root = balanceInsertion(root, x);
                        break;
                    }
                }
            }
        }
        //在红黑树构建完成后,使用moveRootToFront()方法保证红黑树的根节点和双向链表的头节点是同一个节点
        moveRootToFront(tab, root);
    }

在这里插入图片描述

2.3.6.2.5.2 将红黑树转换为单向链表

在以下两种情况下,可以将红黑树转换为单向链表。这两种情况可以概括为,在进行某个操作后,红黑树变得足够小。

  • 1)当HashMap集合中的table数组进行扩容操作时。
    在这种情况下,为了保证依据K-V键值对节点中Key键信息的Hash值,HashMap集合仍然能正确定位到节点存储的数组索引位,需要依次对这些索引位上的红黑树进行拆分操作—拆分结果可能形成两棵红黑树,一棵红黑树被引用到原来的索引位上;另一棵红黑树被引用到“原索引值+原数组长度”号索引位上。
    如果以上两棵红黑树的其中一棵中的节点总数小于或等于UNTREEIFY_THRESHOLD常量值(该常量值在JDK1.8+中的值为6),那么可以将这棵红黑树转换为单向链表,相关源码片段如下。
    在这里插入图片描述
  • 2)当使用HashMap集合中的remove(K)方法进行K-V键值对节点的移除操作时。
    在这种情况下,在table数组中,如果某个索引位上移除的红黑树节点足够多,导致根节点的左儿子节点为null,或者根节点的右儿子节点为null,甚至根节点本身为nul,那么可以将这棵红黑树转换为单向链表,相关源码片段如下。
    在这里插入图片描述
    在这里插入图片描述
    前面分析了将红黑树转换为单向链表的两种情况,其转换过程都由untreeify(HashMap<K,V>)方法完成,相关源码片段如下。
  /**
         * Returns a list of non-TreeNodes replacing those linked from
         * this node.
         */
        final Node<K,V> untreeify(HashMap<K,V> map) {
            Node<K,V> hd = null, tl = null;
            for (Node<K,V> q = this; q != null; q = q.next) {
                Node<K,V> p = map.replacementNode(q, null);
                if (tl == null)
                    hd = p;
                else
                    tl.next = p;
                tl = p;
            }
            return hd;
        }

untreeify(HashMap<K,V>)方法的工作过程如图所示
在这里插入图片描述
根据图可知,在将红黑树转换为单向链表的过程中,之前隐藏在红黑树中的双向链表结构发挥了重大作用:
对红黑树的遍历操作不是依据红黑树结构进行的,而是依据红黑树中隐藏的双向链表结构进行的。
然后将这个双向链表结构替换成符合要求的单向链表结构。

2.3.6.2.6 HashMap集合的扩容操作

HashMap集合的扩容操作主要是对HashMap集合中的table数组进行容量扩充操作一将原有的table数组替换成一个容量更大的数组。
在前面讲解HashMap集合的节点添加操作时已经提到,在以下几种场景中,HashMap集合会进行扩容操作。

  • 1)当table数组为nul或长度为0时,需要进行扩容操作。
    实际上这种场景也可以理解为数组的第一次初始化操作。
    例如,在负责添加新的K-V键值对节点的putVal()方法中存在这种场景,相关源码片段如下
    在这里插入图片描述
  • 2)在添加新的K-V键值对节点后,当HashMap集合中K-V键值对节点的数量即将超过扩容门槛值时,需要进行扩容操作。
    在putVal()方法中也存在这种场景,相关源码片段如下。
    在这里插入图片描述
    threshold变量的值即扩容门槛值。
    在上述源码片段中,当HashMap集合的大小已经超过扩容门槛值时,进行扩容操作。threshold变量的值可以使用tableSizeFor()方法计算得到。使用tableSizeFor()方法可以计算出大于当前方法入参值,并且和当前方法入参值最接近的2的幂数。扩容门槛值是可以变化的,具体策略可参考下面介绍的详细扩容过程。
2.3.6.2.6.1 扩容操作过程

HashMap集合的扩容操作过程可以概括如下, 构建一个容量更大的数组,然后将数据对象迁移到新的数组中。

但实际上有很多细节需要进行说明,如在迁移数据对象时为什么涉及桶结构的拆分。

   /**
     * 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 HashMap.Node<K,V>[] resize() {
        //将扩容前的数组引用记为oldTab变量
        HashMap.Node<K,V>[] oldTab = table;
        //该变量主要用于记录扩容操作前的数组容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //该变量主要用于记录扩容操作前的扩容门槛值
        int oldThr = threshold;
         //newcap表示扩容后新的数组容量值,注意区分数组容量和HashMap集合中的数据对象数量
         //newThr表示扩容后新得到的扩容门槛值
        int newCap, newThr = 0;

        //操作步骤一:根据当前HashMap集合的大小,确认新的数组容量值和新的扩容门槛值
        //如果扩容前的数组容量值大于0,则执行以下操作
        if (oldCap > 0) {
            //如果扩容前的数组容量值(数组大小)大于Hash Map集合设置的最大数组容量值(1073741824)
            //则设置下一次扩容门槛值为最大数组容量值,并且不再进行真实的扩容操作
            //在这种情况下,扩容操作会返回原来的数组大小
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果以下条件成立,则说明扩容后新的数组容量值小于HashMap集合设置的最大数组容量值
            //并且扩容前的数组容量值大于DEFAULT_INITI AL_CAPACITY(16),这是扩容操作中最常见的情况
            //在这种情况下,设置新的数组容量值为原容量值的2倍,设置新的扩容门槛值为原扩容门槛值的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //如果扩容前的扩容门槛值大于0(这个判定条件的优先级低于以oldCap参数为依据的判定条件的优先级)
        //这种情况出现在使用类似于HashMap(int,float)的构造方法完成实例化后的第一次扩容时,
        //这时原有的数组容量(oldCap)值为0
        //在使用tableSizeFor(int)方法计算后,th reshold的值大于0
        //那么将新的数据容量值设置为原始的扩容门槛值
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        //如果扩容前数组容量值为0,并且扩容前的扩容门槛值为0
        //这种情况出现在使用HashMap()构造方法进行实例化,并且进行第一次扩容操作时
        else {
            //新的数组容量值默认为16
            //新的扩容门槛值(下一次扩容的门槛值)=默认的负载因子(0.75)*默认的初始化数组容量值(16)
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        ///如果新的扩容门槛值为0,这种情况承接以上源码中“oldThr>0”的场景
        //那么新的扩容门槛值=新的数组容量值*当前的负载因子
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }
        //扩容后新的数组容量值一定为2的幂数
        threshold = newThr;

        //操作步骤二:在进行扩容操作后,需要对原数组中各K-V键值对节点进行调整,使集合结构恢复平衡
        //根据新的数组容量值创建一个新的数组
        @SuppressWarnings({"rawtypes","unchecked"})
        HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
        table = newTab;
       //HashMap集合扩容操作的核心,并不在于重新计算扩容后新的数组容量值和新的扩容门槛值
        //而在于在扩容成新的数组后,原有数组结构如何恢复平衡,恢复平衡的过程如下:
        //只有在扩容前数组不为空的情况下,才进行原数组中各K-V键值对节点的再平衡处理
        if (oldTab != null) {
           //依次遍历扩容前数组中的每一个索引位(桶结构),注意在这些索引位上
            //可能一个K-V键值对节点都没有
            //也可能有多个K-V键值对节点,并且以单向链表形式存在
            //还可能有多个K-V键值对节点,并且以红黑树形式存在
            for (int j = 0; j < oldCap; ++j) {
                HashMap.Node<K,V> e;
                //如果当前遍历的索引位上没有任何K-V键值对节点,则不需要进行再平衡处理
                if ((e = oldTab[j]) != null) {
                    //设置为null,以便于GC
                    oldTab[j] = null;
                    //如果条件成立,则说明基于当前索引位的桶结构上,只有一个K-V键值对节点
                    //这时,通过“e.hash&(newcap-1)”重新计算这个K-V键值对节点在新的数组中的存储位置
                    //注意:因为基于newcap-1的与运算,所以这个计算结果一定不是newCap-1
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果条件成立,说明基于当前索引位的桶结构上是一棵红黑树
                    //那么使用TreeNode.split()方法进行K-V键值对节点的再平衡处理
                    //实际操作是对当前索引位上的红黑树进行拆分
                    else if (e instanceof TreeNode) {
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        //如果是其他情况,说明基于当前索引位的桶结构上是一个单向链表
                        //并且链表中的数据对象数一定大于1
                        //那么对单向链表中的每个K-V键值对节点进行调整
                    }
                    else {
                        //以下代码的作用,是将原数组当前索引位(当前桶)上的链表随机拆分成两个新的链表
                        //其中一个新的链表作为新的数组相同索引位上的链表
                        //另一个新的链表作为新的数组相同索引值+oldCap号索引位上的链表
                        ///以便保证节点的重新分配
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.Node<K,V> next;
                        do {
                            //将当前e节点的next引用(当前节点的后置节点引用)赋值给next变量
                            //注意:在第一次循环过程中,e.next一定不为null,
                            next = e.next;
                            //这个条件有一定的概率成立,根据条件成立情况,
                            //将原来在j号索引位上的单向链表拆分成两个新的链表,
                            //这两个新的链表分别以1oHead、hiHead 为头节点标识,
                            // 以1oTail、hiTail为尾节点标识
                            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);
                        //如果以loTail为尾节点标识的新的单向链表确实存在(至少一个节点)
                        //那么将新的单向链表存储到j号索引位上
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

根据上述源码注释可知,HashMap集合的扩容过程分为两步。

  • 第一步,根据当前HashMap集合的情况,确认HashMap集合新的容量值和新的扩容门槛值。
  • 第二步,在经过计算后,HashMap集合新的容量值一定大于并等于16,并且为2的幂数(如16、32、64、128、256、512…);场景不同,新的扩容门槛值也会有所不同。

1)进行链表节点或红黑树节点调整的原因。
首先看一下在进行扩容操作后,如果不调整原集合中各个节点(包括可能的红黑树节点或链表节点)的位置,那么会有什么样的效果。
一个只进行了数组2倍容量扩容操作,但没有进行节点位置调整操作的集合内部结构如图所示。
在这里插入图片描述
基于计算公式发生的变化,实际上就是“当前容量值减1”的结果发生的变化。
所以在重新确认K-V键值对节点索引位时,一部分存储在原索引位上的节点不再能够匹配正确的索引位。
这些不再能够匹配正确索引位的K-V键值对节点需要在扩容时进行移动一重新移动到另外的索引位上。
这样才能保证在扩容后,操作者在使用get(K)方法获取K-V键值对节点时,HashMap集合能够正确定位到该K-V键值对节点的新索引位。

2)链表中各节点的调整效果。
如果扩容前某个索引位上的K-V键值对节点是以单向链表结构组织的,则需要通过以下方式将当前链表拆分为两个新的单向链表,如图所示。
在这里插入图片描述

在原链表中,满足“(e.hash&oldCap)==0”条件的节点会构成新的单向链表,这个链表中的节点会按照原索引位顺序存储于新的HashMap集合的table数组中;
满足“(e.hash&oldCap)l=0”条件的节点会构成另一个新的单向链表,并且将原索引值+原数组长度的计算结果作为新的索引值存储于新的HashMap集合的table数组中。

在HashMap集合中,table数组都是以2的幂数进行扩容操作的,也就是说,将原容量值左移1位。在这种情况下,在进行扩容操作后,各个K-V键值对节点是否仍能在原索引位上,取决于新增的一位在进行与运算时是否为0。而oldCap的值就是扩容操作后新增的一位。所以满足“(e.hash&oldCap)==0”条件的节点可以继续在原索引位上存储,不满足该条件的节点则需要进行移动操作。

3)红黑树中各节点的调整过程。
如果扩容前某个索引位上的K-V键值对节点是以红黑树结构组织的,则需要根据以上原理,将这棵红黑树拆分成两棵新的红黑树(如果红黑树中的节点数量不大于6,那么会将红黑树结构转换为链表结构)。一棵红黑树(或链表)留在原索引位上,另一棵红黑树(或链表)放到原索引值+原数组容量值的计算结果对应的新索引位上。

相关源码如下。

     /**
         该方法负责完成红黑树结构的拆分工作
         如果拆分后的红黑树节点数不大于6,那么将红黑树结构转换为链表结构
         这里传入的bit值就是扩容前HashMap集合的table数组的原始容量值
         * Splits nodes in a tree bin into lower and upper tree bins,
         * or untreeifies if now too small. Called only from resize;
         * see above discussion about split bits and indices.
         *
         * @param map the map
         * @param tab the table for recording bin heads
         * @param index the index of the table being split
         * @param bit the bit of hash to split on
         */
        final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
            TreeNode<K,V> b = this;
            // Relink into lo and hi lists, preserving order
            TreeNode<K,V> loHead = null, loTail = null;
            TreeNode<K,V> hiHead = null, hiTail = null;
            int lc = 0, hc = 0;
            for (TreeNode<K,V> e = b, next; e != null; e = next) {
                next = (TreeNode<K,V>)e.next;
                e.next = null;
                if ((e.hash & bit) == 0) {
                    if ((e.prev = loTail) == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                    ++lc;
                }
                else {
                    if ((e.prev = hiTail) == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                    ++hc;
                }
            }

            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null) // (else is already treeified)
                        loHead.treeify(tab);
                }
            }
            if (hiHead != null) {
                if (hc <= UNTREEIFY_THRESHOLD)
                    tab[index + bit] = hiHead.untreeify(map);
                else {
                    tab[index + bit] = hiHead;
                    if (loHead != null)
                        hiHead.treeify(tab);
                }
            }
        }

红黑树中隐含的双向链表结构非常重要,在将红黑树结构转换为链表结构的过程中,需要使用这个隐含的双向链表进行遍历;
在红黑树结构的拆分过程中,也需要使用这个隐含的双向链表进行遍历。
上述源码描述的过程如图所示。
在这里插入图片描述

2.3.7 Map集合实现—LinkedHashMap

LinkedHashMap集合是JCF中从JDK1.4就开始提供的一种集合
可以这样理解该集合:LinkedHashMap=HashMap+LinkedList。
LinkedHashMap集合的主要继承体系如图所示。
在这里插入图片描述
LinkedHashMap集合继承自HashMap集合,也就是说,前者和后者的基本结构一致。
在HashMap集合的基础上,LinkedHashMap集合提供了一个新的特性,用于保证整个集合内部各个节点可以以某种顺序进行遍历(迭代器支持)。

  • 可以根据节点添加到集合中的时间确认这个顺序(insertion-order)
    在使用LinkedHashMap集合的迭代器进行遍历时,先添加到集合中的K-V键值对节点先被遍历。
    这个遍历顺序和K-V键值对节点属于哪一个桶结构,以及该桶结构是按照单向链表排列的,还是按照红黑树排列的都没有关系。
    在这里插入图片描述

  • 可以根据节点在集合中最后一次被操作(读操作或写操作)的时间确认这个顺序(access-order)
    在LinkedHashMap集合中指定的K-V键值对节点被进行了操作(修改操作或读取操作)后,它会被重新排列到遍历结果的末尾。
    在这里插入图片描述

采用哪种遍历顺序,取决于LinkedHashMap集合实例化时设置的参数,示例代码如下。
在这里插入图片描述

结果如下所示,当对某个值修改值,还是按照添加顺序进行排序的
在这里插入图片描述
但是如果设置了参数,将 accessOrder =true ,则如果存在更新,会将更新的key重新排在末尾

在这里插入图片描述
在这里插入图片描述

2.3.7.1 LinkedHashMap集合的节点结构

在LinkedHashMap集合中,使用LinkedHashMap.Entry类构造节点,其继承体系如图所示。
在这里插入图片描述
根据图可知,LinkedHashMap.Entry类继承自HashMap.Node类,结合LinkedHashMap.Entry类的源码中的各属性,即可知道LinkedHashMap集合中的节点具有哪些属性。
节点的定义源码如下。

public class LinkedHashMap<K,V>
        extends HashMap<K,V>
        implements Map<K,V>
{
    
    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);
        }
    }

    private static final long serialVersionUID = 3801124242820219131L;
    
    transient LinkedHashMap.Entry<K,V> head;
    transient LinkedHashMap.Entry<K,V> tail;
    final boolean accessOrder;
  • 如果当前LinkedHashMap集合中指定位置(table数组中的某个索引位)的桶中存储的是单向链表
    那么对应的节点结构如图所示。
    在这里插入图片描述
  • 如果LinkedHashMap集合中指定位置(table数组中的某个索引位)的桶中存储的是红黑树,那么对应的节点结构如图所示。
    在这里插入图片描述
2.3.7.2 LinkedHashMap集合的主要结构

由于LinkedHashMap集合继承自HashMap集合,因此LinkedHashMap集合中具有HashMap集合中的属性,如用于存储桶结构的数组变量table。
LinkedHashMap集合的节点存储示意图如图所示。
在这里插入图片描述
根据LinkedHashMap集合中每个节点的before、after属性形成的双向链表,串联LinkedHashMap集合中的所有节点;
这些节点在双向链表中的顺序和这些节点处于哪一个桶结构中,桶结构本身是单向链表结构还是红黑树结构并没有关系,有关系的只是这个节点代表的K-V键值对节点在时间维度上被添加到LinkedHashMap集合中的顺序。

LinkedHashMap集合中的head属性和tail属性可以保证被串联的节点可以跨越不同的桶结构。
需要注意的是,根据LinkedHashMap集合的初始化设置,head属性和tail属性指向的节点可能会发生变化。

2.4 JCF中的Set集合

2.4.1 概述

因为JCF中各种原生Set集合的内部结构都依赖于对应的Map集合进行实现,而JCF中重要的原生Map集合已进行了详细介绍,所以本章内容相对精简,实际上是在Map集合的工作原理之上做一些知识点补充。

在介绍具体的Set集合前,需要介绍一下Set集合中几个重要的接口和抽象类:

  • java.util.SortedSet接口
  • java.util.NavigableSet接口
  • java.util.AbstractSet抽象类。

2.4.2 java.util.SortedSet接口

在一般情况下,Set集合中的数据对象是无序的。
例如,在HashSet集合中,可以使用add()方法添加多个数据对象,这些数据对象在HashSet集合中的位置顺序会受添加顺序的影响,
出现这样的情况,是由HashSet集合的内部结构决定的—HashSet集合的内部结构和HashMap集合的内部结构相同。

如果某个具体的Set集合实现了java.util.SortedSet接口,就表示该集合中的数据对象会按照某种比较方法进行全局性的有序排列。
实现了java.util.SortedSet接口的集合都提供了以下两种比较方法。

  • 使用一个实现了Comparable接口的类的对象进行比较。
    这个对象来源于集合中K-V键值对节点的Key键信息。
  • 通过在集合实例化时设置的Comparator比较器进行比较。
    如果要采用这种方法,那么Set集合需要实现java.util.SortedSet接口。

如果以上两种比较方法都不可用,那么在调用相关方法或对象时,会抛出ClassCastException异常。

public interface SortedSet<E> extends Set<E> {
    
    /**
     * 该方法会返回一个比较器,这个比较器用于在集合的各个操作中进行数据对象的比较
     * 返回的比较器允许为null,如果为null,则表示该Set集合在进行对象比较时,
     * 会采用数据对象自身实现的java.lang.Comparable接口
     * 如果以上两种情况都不成立,则会抛出ClassCastException 异常
     */
    Comparator<? super E> comparator();

    /**
     * 该方法会返回一个有序的子集
     * <p>
     * <p>
     * 这个有序的子集中包括从from数据对象开始(包含)到to数据对象结束(不包含)的所有数据对象
     * 注意:如果from数据对象和to数据对象相同,那么该方法会返回一个空集合
     * 此外,from数据对象或to数据对象不一定存储于集合中
     * 因为取子集的比较操作主要参照数据对象的值进行
     * 注意:返回的子集中的数据对象是原集合中各数据对象的引用
     * 所以对子集进行的操作,都会反映到原集合中
     * 不能进行写操作,否则会抛出IllegalArgumentException 异常
     */
    SortedSet<E> subSet(E fromElement, E toElement);

    /**
     * 返回有序集合中数据对象值小于to数据对象值的子集
     * 该方法的注意事项和subSet()方法的注意事项类似
     */
    SortedSet<E> headSet(E toElement);

    /**
     * 返回有序集合中数据对象值大于或等于from数据对象值的子集
     * 该方法的注意事项和subSet()方法的注意事项类似
     */
    SortedSet<E> tailSet(E fromElement);

    /**
     * 返回当前有序集合中,值最小的数据对象
     * 如果当前集合中没有任何数据对象,则抛出NoSuchELementException 异常
     */
    E first();

    /**
     * 返回当前有序集合中值最大的数据对象
     * 如果当前集合中没有任何数据对象,则抛出NoSu chElementException 异常
     */
    E last();

    @Override
    default Spliterator<E> spliterator() {
        return new Spliterators.IteratorSpliterator<E>(
                this, Spliterator.DISTINCT | Spliterator.SORTED | Spliterator.ORDERED) {
            @Override
            public Comparator<? super E> getComparator() {
                return SortedSet.this.comparator();
            }
        };
    }
}

注意java.util.Comparator接口和java.lang.Comparable接口的区别,具体如下:

  • java.util.Comparator:该接口可以解释为比较器,实现该接口的类类似于一个工具,可以对两个传入的对象进行比较。
    所以Comparator接口中需要被实现的compare(To1,To2)方法有两个入参,分别表示要进行比较的对象01和对象02。
    所说的比较器,通常是指实现了java.util.Comparator接口的类,这些类的对象本身不具有排序功能,它们像工具一样,主要用于帮助其他类的对象完成排序工作。
  • java.lang.Comparable:该接口可以解释为类的对象本身是可比较的,也就是说,实现该接口的类的对象本身是具有排序功能的,所以Comparable接口中需要被实现的compareTo(To)方法只有一个入参,表示与本对象进行比较的目标对象。
    所说的一个类的对象能进行比较,通常是指该类实现了java.lang.Comparable接口。

2.4.3 java.util.NavigableSet接口

java.util.NavigableSet接口是java.util.SortedSet接口的子级接口,可以将其理解成支持基于参照对象进行引导操作的Set集合。

也就是说,在满足集合中对象有序组织的前提下,可以参照指定的数据对象进行集合中各数据对象的读/写操作。
例如,可以参照集合中已有的数据对象X,查询集合中所有值小于或等于该数据对象X权值的数据对象。
保证基于参照数据对象进行引导操作的前提是集合中的数据对象按照一定的顺序排列,这也就解释了为什么java.util.NavigableSet接口是java.util.SortedSet接口的一个子级接口。

java.util.NavigableSet接口中的主要方法源码如下

public interface NavigableSet<E> extends SortedSet<E> {

    
    E lower(E e);

    E floor(E e);

    E ceiling(E e);
    
    E higher(E e);

    E pollFirst();

    E pollLast();

    Iterator<E> iterator();

    NavigableSet<E> descendingSet();

    Iterator<E> descendingIterator();

    NavigableSet<E> subSet(E fromElement, boolean fromInclusive,
                           E toElement,   boolean toInclusive);

    NavigableSet<E> headSet(E toElement, boolean inclusive);

    NavigableSet<E> tailSet(E fromElement, boolean inclusive);

    SortedSet<E> subSet(E fromElement, E toElement);

    SortedSet<E> headSet(E toElement);

    SortedSet<E> tailSet(E fromElement);
}

2.4.4 java.util.AbstractSet抽象类

java.util.AbstractSet抽象类存在的意义和java.util.AbstractMap抽象类存在的意义相似,主要是为了有效降低具体的Set集合的实现复杂度。该抽象类提供了一些通用方法的实现逻辑,包括equals()方法、hashCode()方法、removeAll()方法(在一般情况下,不需要在具体集合中对这些方法进行改动)。
该抽象类的部分源码如下。

public abstract class AbstractSet<E> extends AbstractCollection<E> implements Set<E> {
    protected AbstractSet() {
    }
    
    public boolean equals(Object o) {
        if (o == this)
            return true;

        if (!(o instanceof Set))
            return false;
        Collection<?> c = (Collection<?>) o;
        if (c.size() != size())
            return false;
        try {
            return containsAll(c);
        } catch (ClassCastException unused)   {
            return false;
        } catch (NullPointerException unused) {
            return false;
        }
    }

    public int hashCode() {
        int h = 0;
        Iterator<E> i = iterator();
        while (i.hasNext()) {
            E obj = i.next();
            if (obj != null)
                h += obj.hashCode();
        }
        return h;
    }

    public boolean removeAll(Collection<?> c) {
        Objects.requireNonNull(c);
        boolean modified = false;

        if (size() > c.size()) {
            for (Iterator<?> i = c.iterator(); i.hasNext(); )
                modified |= remove(i.next());
        } else {
            for (Iterator<?> i = iterator(); i.hasNext(); ) {
                if (c.contains(i.next())) {
                    i.remove();
                    modified = true;
                }
            }
        }
        return modified;
    }

}

2.4.5 Set集合实现—HashSet

HashSet集合的内部结构和HashMap集合的内部结构相同。
HashMap集合中K-V键值对节点的Key键信息在HashSet集合中为存储的真实数据,它在HashSet集合中使用一个固定对象进行填充,记为“PRESENT”,源码如下。
在这里插入图片描述
HashSet集合继承了AbstractSet抽象类。AbstractSet抽象类中包含大部分关于Set集合中的通用方法,包括但不限于equals(Object)方法、hashCode()方法、isEmpty()方法、contains(Object)方法。

HashSet集合的主要继承体系如图所示。
在这里插入图片描述
因为HashSet集合是基于HashMap集合的工作原理进行工作的,所以HashSet集合至少具有以下几个工作特点。

  • HashSet集合中不允许存储值相同的数据对象。
  • HashSet集合中存储的数据对象并不是有序的。

HashSet源码如下

public class HashSet<E>  extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
    static final long serialVersionUID = -5024744406713321676L;

    private transient HashMap<E,Object> map;

    private static final Object PRESENT = new Object();

    public HashSet() {
        map = new HashMap<>();
    }

    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }

    public HashSet(int initialCapacity, float loadFactor) {
        map = new HashMap<>(initialCapacity, loadFactor);
    }

    public HashSet(int initialCapacity) {
        map = new HashMap<>(initialCapacity);
    }

    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
        map = new LinkedHashMap<>(initialCapacity, loadFactor);
    }

    public Iterator<E> iterator() {
        return map.keySet().iterator();
    }

    public int size() {
        return map.size();
    }

    public boolean isEmpty() {
        return map.isEmpty();
    }

    public boolean contains(Object o) {
        return map.containsKey(o);
    }

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

    public boolean remove(Object o) {
        return map.remove(o)==PRESENT;
    }

    public void clear() {
        map.clear();
    }
...
}

根据HashSet集合的构造方法的注释可知,可以直接使用HashSet集合的构造方法对其内部的HashMap集合进行初始化。
HashSet集合的主要操作方法都是对其内部HashMap集合主要方法的调用方法;

2.4.5 Set集合实现—LinkedHashSet

和HashSet集合的设计思路类似,LinkedHashSet集合和TreeSet集合也进行了类似的依赖封装。
LinkedHashSet集合继承自HashSet集合,并且依赖于HashSet集合中的构造方法进行实例化。

源码如下所示:

public class LinkedHashSet<E>   extends HashSet<E>  implements Set<E>, Cloneable, java.io.Serializable {

    private static final long serialVersionUID = -2851667679971038690L;

    public LinkedHashSet(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor, true);
    }

    public LinkedHashSet(int initialCapacity) {
        super(initialCapacity, .75f, true);
    }

    public LinkedHashSet() {
        super(16, .75f, true);
    }

    public LinkedHashSet(Collection<? extends E> c) {
        super(Math.max(2*c.size(), 11), .75f, true);
        addAll(c);
    }

    @Override
    public Spliterator<E> spliterator() {
        return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
    }
}

因为LinkedHashSet集合继承自HashSet集合,并且保留了LinkedHashMap集合的所有特性,所以LinkedHashSet集合一般无须额外实现方法。
当然也有例外。例如,LinkedHashSet集合中被覆盖的spliterator()方法,该方法在JDK1.8+中定义,主要用于返回一个可分割集合的(并行)迭代器。

2.4.5 Set集合实现—TreeSet

TreeSet集合的内部结构和TreeMap集合的内部结构相同,也就是说,TreeSet集合的内部结构同样是红黑树结构。

TreeSet集合是一个基于红黑树结构的有序集合,它的内部功能由TreeMap集合实现。
TreeSet集合充分利用了TreeMap集合中K-V键值对节点的Key键信息不相同的特性,用于实现自身集合中没有对象值相同的功能。TreeSet集合的主要继承体系如图所示。
在这里插入图片描述

TreeSet集合的主要属性和构造方法实际上都是对封装的TreeMap集合的描述和初始化操作,相关源码片段如下。

public class TreeMap<K,V>
        extends AbstractMap<K,V>
        implements NavigableMap<K,V>, Cloneable, java.io.Serializable
{
   
    private final Comparator<? super K> comparator;

    private transient Entry<K,V> root;

    private transient int size = 0;

    private transient int modCount = 0;

    public TreeMap() {
        comparator = null;
    }

    public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }

    public TreeMap(Map<? extends K, ? extends V> m) {
        comparator = null;
        putAll(m);
    }

    public TreeMap(SortedMap<K, ? extends V> m) {
        comparator = m.comparator();
        try {
            buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
        } catch (java.io.IOException cannotHappen) {
        } catch (ClassNotFoundException cannotHappen) {
        }
    }
    
    public int size() {
        return size;
    }

    public boolean containsKey(Object key) {
        return getEntry(key) != null;
    }

    public boolean containsValue(Object value) {
        for (Entry<K,V> e = getFirstEntry(); e != null; e = successor(e))
            if (valEquals(value, e.value))
                return true;
        return false;
    }
    ...}

由于TreeSet集合依赖于TreeMap集合进行工作,因此TreeSet集合中的大部分方法直接调用了TreeMap集合中对应的方法(以NavigableMap抽象类的对象进行表达);

总而言之,Java提供的多种原生Set集合的实现都非常简单,都是基于对应的Map集合完成的。例如,TreeSet集合内部依赖于TreeMap集合进行工作,HashSet集合内部依赖于HashMap集合进行工作。

3. JUC与高并发

JCF相关知识中,所有实现Set、Map、List、Queue接口的集合都不推荐在高并发场景中使用,虽然有一些已介绍的集合使用synchronized修饰符或java.util.Collections工具类中的线程安全性封装进行了处理,但这些集合同样不适合在高并发场景中使用。
Java推荐在高并发场景中使用JUC(java.util.concurrent,Java并发工具包)进行编程,并且使用JUC提供的集合在高并发场景中工作。

3.1 线程安全的三要素

  • 内存可见性。
    内存可见性问题是由Java内存模型(JMM)引起的问题,在Java内存模型中,每一个线程都有自己的工作内存区,工作内存区相对独立,其中的值为主内存区中值的副本,概要结构如图所示。
    在这里插入图片描述
    之所以设计JMM,是因为现在的CPU大部分是多核CPU,有自己的一级和二级缓存,要保证JVM在各种计算机硬件环境中都能正常工作,需要对匹配的硬件环境和操作系统进行抽象处理。
    但是这样会引发一些问题。例如,当两个或多个线程共享同一个变量时,这个变量的值会同时存在于多个工作内存区中,在线程A修改这个值后,如何将这个变化通知其他内存工作区?
    这个问题在单线程场景及并发性不是很高的场景中都不是什么大问题,毕竟CPU在切换时间片时,工作内存区会被刷新。
    但如果在高并发场景中,这个问题就会很严重。
    因为在一个CPU时间片中,该共享变量的值可能被读/写操作多次,如果不能及时向其他线程的内存工作区通知这些变化,就会使这些关键数值发生错误。

  • 原子性
    原子性是指一行源码或一段源码是不可分割的一个整体,如一个“赋值运算”过程的原子性,是指这个“赋值运算”操作不会被其他操作干扰,其他操作要么在本运算全部完成后运行,要么在本运算执行前运行。

  • 有序性
    在线程内,两行源码的实际执行顺序和其编写顺序可能不一致,这主要是JIT(可以理解为JVM)出于对执行过程优化的考虑,在编译时(注意:不是在编译class文件时)重新对源码执行顺序进行调整,这个过程称为指令重排

3.2 提应用程序的性能

解决了线程安全性问题并不意味着支持高并发场景,后者还会涉及性能问题。
在保证线程安全性的前提下,尽可能提高应用程序的性能,我们还需要考虑以下问题。

  • 线程平衡性问题。
    并不是线程越多,处理性能越高。
    事实上,单纯增加线程数量反而会降低处理性能,因为线程越多,CPU消耗在切换线程上的时间资源越多。
    在高并发场景中,需要找到一种方法,用于保证线程数量和CPU切换时间的平衡性。

  • 寻找高效的线程互斥/同步方式。
    在支持多线程模式的操作系统中,多线程控制问题的本质是解决线程间的同步问题和互斥问题。
    同步问题是指线程间存在一种协作机制,保证它们能够合作完成一项工作。通过这种协作机制,线程间可以知道彼此的存在和状态。
    互斥问题主要是指描述线程间操作的排他性问题,通过某种竞争方式,线程间可以在彼此不知道对方状态的情况下实现对竞争资源的操作。
    解决多线程控制问题的具体方案可以由操作系统提供,也可以由各种高级语言自行完成(有业界统一的问题解决规范和需要遵循的标准)。

  • 时间复杂度更低、更稳定的结构算法。
    时间复杂度不同,意味着完成同一种计算过程所需的时间代价不同,并且同一种结构算法在不同场景中所需的时间代价不一定稳定。
    例如,由于二又查找树没有平衡性约束,因此其在数据规模相同的情况下查找不同数据所需的时间无法保持稳定。
    对于高并发场景中的性能支持,应尽可能寻找时间复杂度低,并且在不同操作(查询、添加、修改等)场景中可以保证时间复杂度稳定性的算法方案。

  • 4 寻找更灵活的资源控制方式。
    虽然通过资源独占操作权的控制思路,可以保证线程安全性(包括同时满足三性要求)。但在一些场景中,独占方式限制了处理性能进一步提升的可能性。例如,当个线程同时对资源X进行抢占时,只能由一个线程拿到独占操作权,其他线程都需要阻塞等待。因此,我们需要寻找一种更细粒度的控制方式,让这些线程无须完全阻塞,并且可以在某种程度上共享对资源X的操作权,从而提高性能。

3.3 Object Monitor管程实现

3.3.1 悲观锁和乐观锁

为了在不同场景中保证多线程工作的安全性,Java主要基于两种思想进行线程安全性的设计:悲观锁思想和乐观锁思想。

我们可以先假设一种在多线程工作环境中,对共享资源操作绝对安全的操作思想。

  • 该思想假设,在任何时候都会有其他操作者同时要求操作资源,从而产生操作冲突。该思想还假设,如果没有绝对安全的资源独占前提,那么对资源的操作一定会出现问题。基于这种思想,如果要保证在多线程工作环境中资源操作的安全性,就需要在操作资源前独占该资源,最后在操作完成后释放该资源的独占操作权;其他操作者如果需要操作该资源,则需要等待,直到自己获得该资源的独占操作权,才能进行操作。由此可见,这种思想假设带有绝对性,对客观工作环境有严格的前提约束,这种安全性操作思想称为悲观锁思想
    悲观锁在Java中有两种典型的实现方式
    • 一种是基于Object Monitor模式的资源操作方式
    • 另一种是基于AQS技术的资源操作方式

它们的共同特点是,需要操作者获得资源的独占操作权才能对相关资源进行操作。
但是二者在底层实现上存在较大差异;

  • 还有一种思想假设,并不是任何时候都有两个或多个操作者同时操作相同的资源,从而产生操作冲突。该思想还假设,即使操作结果存在错误也没有关系。可以通过对比预期值和实际值来确认操作的正误,如果出现错误,则放弃本次操作,重新操作即可。
    基于这种思想,操作者无须获得资源的操作独占权,也无须等待其他操作者释放资源的独占操作权。简单来说,这是一种无须加锁的,带有并发操作性的思想。在操作结束后,操作者只需比较实际操作结果是否符合预期操作结果,如果不符合,则放弃本次操作并重试。这种线程安全性的操作思想称为乐观锁思想
    Java中的乐观锁思想通常基于CAS(Compare and Swap,比较与交换)技术实现,但CAS进行比较的判定依据及比较后是否要进行重试,往往由操作者自行决定。所以在Java中,基于乐观锁工作的工具类都存在类似于for(;;)或while(true)的源码结构,这并不是BuG,而是为了匹配乐观锁的实现思想。
    例如,在ConcurrentHashMap集合中,基于乐观锁思想添加数据对象的源码片段如下。
    在这里插入图片描述

在通常情况下,基于乐观锁思想完成的逻辑实现比基于悲观锁思想完成的逻辑实现具有更高的性能,因为乐观锁思想的本质是无锁,并且赋予了工作场景更高的宽松性。但是CAS技术往往无法独立完成线程安全中对三性(原子性、有序性和可见性)的控制,它更关注对原子性问题的解决,对于有序性和可见性,需要借助其他控制要素。

例如,JUC中提供了一个用于进行整数原子性操作的工具类AtomicInteger,该工具类的功能实现基本上是基于CAS的乐观锁思想完成的,但它同时使用了volatile修饰符,用于保证数据的有序性和可见性(后面会详细介绍volatile修饰符),从而保证AtomicInteger工具类在高并发场景中的线程安全性。

注意:虽然乐观锁的本质是无锁,但并不代表CAS操作没有多余的性能消耗。事实上,CAS操作也需要执行额外的CPU指令。

3.3.2 synchronized修饰符和线程控制

线程的基本特点

  • 线程是一个操作系统级别的概念。
  • Java(包括其他编程语言)并不能创建线程,它只可以调用操作系统提供的接口进行线程的创建、控制、销毁等操作。
  • Java基于操作系统工作的基本线程结构如图所示。
    在这里插入图片描述不同的操作系统(Windows、UNIX、Linux等)支持的线程底层实现和操作效果不同。
    不过操作系统支持的线程状态至少可以归为四类,分别为就绪、执行、阻塞和死亡,这四种状态可以互相切换。

在创建线程时,操作系统不会为它分配独立的资源。一个应用程序(进程)中的所有线程,都可以共享这个应用程序(进程)中的资源,如这个应用程序的CPU资源、I/O资源、内存资源。

基本上主流操作系统都支持多线程实现,即可以在一个应用程序(进程)中创建多个线程。在一个应用程序中,各个线程之间可以进行通信,可以进行状态互操作。在一个进程中,至少有一个线程存在。

3.3.2.1 线程切换状态示意图

在这里插入图片描述
图中的线程状态切换图展示了Java中的部分方法如何对线程间的切换施加影响。这些方法都和synchronized修饰符有关,后者代表一种经典的线程控制模式——Object Monitor模式。
Object Monitor模式是一种典型的悲观锁实现,使用Java对象模型中的特定区域对线程状态、对象状态的描述进行线程操作。

在Object Monitor模式下,多个线程要对特定的对象进行操作,首先需要获取这个对象的独占操作权,然后进入synchronized修饰符对应的代码块(简称synchronized代码块)进行执行。未获得对象独占操作权的线程会在synchronized代码块外阻塞等待(可理解为一种临界状态),直到获取对象的独占操作权。当然,synchronized代码块内正在执行的线程也可以主动释放对象的独占操作权(如使用wait()方法),并且使自己进入阻塞状态,以便其他处于阻塞状态的线程重新抢占该对象的独占操作权。

基于以上描述,synchronized修饰符最基本的工作原则如下。

  • 在相同的时间内,synchronized代码块中有且最多有一个线程持有对象的独占操作权,并且处于就绪状态。

在Java中,synchronized修饰符可标注的位置很多,可以在方法定义中添加synchronized修饰符,也可以在方法体中添加synchronized修饰符,还可以在static代码块中添加synchronized修饰符。

不同位置的synchronized修饰符代表的意义不同。
在synchronized(){}语句中,可以在小括号中指定需要进行Object Monitor模式检查的对象。下面对以上几种synchronized修饰符的意义进行概要解释。

  • 将synchronized修饰符加载在非静态方法上
    其代表的意义和synchronized(this){}语句代表的意义类似,即对拥有这个方法的对象进行Object Monitor模式下的锁状态检查。但两者的栈帧状态是有所区别的。
  • 将synchronized修饰符加载在静态方法上
    其代表的意义和synchronized(Class.class){}语句代表的意义类似,即对拥有这个方法的类对象(类本身也是对象)进行Object Monitor模式下的锁状态检查。·在Object Monitor模式下需要关注对象的锁粒度。例如,基于synchronized(Class.class){}语句的锁是不被推荐的,包括直接加在静态方法上的synchronized修饰符也不被推荐。因为控制粒度太过粗放,受影响的范围无法得到有效限制。
3.3.2.2 wait()方法

在Object Monitor模式下,要确保一个对象是线程安全的,除了正确使用synchronized修饰符,还需要多种相关方法协助控制(线程间同步),以便实现复杂的控制逻辑。其中一组重要方法是wait()方法、notify()方法、notifyAll()方法。

基本使用:

  • wait()方法由java.lang.Object类提供,该方法使用final修饰符进行修饰,代表不允许子类重载。
  • wait()方法内部直接通过调用JVM内核源码完成工作,即完成指定对象在Object Monitor模式下的状态改变。

使用wait()方法可以使当前线程在获取某个对象独占操作权的情况下,主动释放该对象的独占操作权,并且将当前线程的状态切换为阻塞状态(WAITING状态)。这样,其他需要当前对象独占操作权且进入阻塞状态的线程,就可以重新抢占该对象的独占操作权了。

对象的wait()方法只能在synchronized代码块中调用。如果没有这样做,就会抛出“IlegalMonitorStateException”异常。在这个synchronized代码块中,调用wait()方法的对象必须是Object Monitor模式的检查对象。

在正常情况下,使用wait()方法进入阻塞状态的线程,会主动释放当前对象的独占操作权,如果要再次进入就绪状态,就必须重新获得当前对象的独占操作权。

wait()方法的多态表达:

wait()方法的多态表达包括wait(long)和wait(long,int),这两种方法的具体解释如下:

  • wait(long)阻塞一段时间(单位为毫秒)
    如果这段时间内没有收到notify信号、notifyA信号,也没有收到nterrupt中断信号,则重新允许该线程参与对象独占操作权的抢占工作。如果抢占成功,则该线程继续执行。
  • wait(long,int):该方法和上述方法类似,但要注意第二个参数,该参数传入一个纳秒数(1毫秒=1000000纳秒,CPU一个指令大约需要2到4纳秒),这个入参所代表的纳秒数是在阻塞时间结束后,允许当前线程继续等待的1毫秒内的时间偏移量,它的取值范围为0~999999。

解除wait()方法的阻塞状态:
有几种场景可以将使用wait()方法进入阻塞状态的线程状态重新切换为就绪状态。

  • 获得当前对象独占操作权的线程X在synchronized代码块中调用notify()方法或notifyAll()方法,并且当前线程重新获得对象的独占操作权。
  • 在使用wait()方法时指定一个最长的阻塞等待时间,在到时间后,当前线程会重新参与当前对象独占操作权的抢占工作,并且重新获取当前对象的独占操作权。
  • 当前线程收到interrupt中断信号,并且重新参与当前对象独占操作权的抢占工作。
3.3.2.3 notify()方法和notifyAll()方法

通过调用某个对象的notify()方法或notifyAll()方法,可以通知某个或所有因为没有该对象独占操作权而在Wait Set区域阻塞等待的线程(一般是主动调用wait()方法释放掉该对象独占操作权而进入阻塞状态的线程),可以重新参与该对象独占操作权的抢占工作了。

两个方法的区别是,notify()方法会通知一个相关线程,notifyAll()方法会通知所有相关线程。

但从使用层面来看,这种工作方式具有一定的局限性。
在实际的并发场景中,程序员往往不能严格控制两个线程或多个线程的执行顺序。如果要控制两个线程或多个线程的执行顺序,则需要花费一定的设计成本。例如,需要重新调整程序结构,或者引入其他线程,才能保证线程A调用wait()方法一定在线程B调用notify()方法/notifyAll()方法之前。后面会介绍一种无须严格限制两个同步线程的执行顺序,也能唤醒线程的方式。

3.3.2.4 interupt中断信号

通过向一个指定的线程发送interupt中断信号,可以通知这个线程进行中断,但是否要真的中断线程运行,或者在中断线程运行前是否要完成一些额外的工作,取决于程序员设计的具体处理逻辑。
可以使用以下方法向指定线程发出一次interrupt中断信号(当然向自身线程发出interrupt中断信号也是可以的)。

   public static void main(String[] args) {
        Thread thread = new Thread("Test-01");

        //向该线程发送interrupt中断信号
        thread.interrupt();
        
        //向当前线程发送中断信号(自己发给自己)
        Thread.currentThread().interrupt();

    }
  • 判断是否收到interrupt中断信号的方法。
    线程在收到interrupt中断信号时可能处于就绪状态,也可能处于阻塞状态。
    • 如果线程处于就绪状态,则可以使用static boolean interrupted()方法或boolean isInterrupted()方法进行判断,如果interrupted()方法返回true,则表示当前线程收到了interrupted中断信号,线程中的相关逻辑可以根据实际情况决定是立即中断处理,还是继续处理,示例代码如下。
      在这里插入图片描述
    • 如果线程处于阻塞状态,则会抛出“java.lang.InterruptedException”异常,示例代码如下。
      在这里插入图片描述
  • 判断是否收到interrupt中断信号的注意事项(interupted与isInterrupted 区别)。
  • static boolean interupted()方法
    返回结果后会重置interrupt中断信号的状态,因此,在第二次调用该方法时,返回结果就会变为false,除非在两次调用的间隙又收到新的interrupt中断信号
  • boolean isInterrupted()方法
    在返回结果后不会重置interrupt中断信号的状态

案例
在这里插入图片描述

  • 如果连续收到多次interupt中断信号,那么系统会认为是一次interupt中断信号,更贴切的表述是“线程处于收到interrupt中断信号的状态”。
  • 处于阻塞状态的线程在收到interrupt中断信号后,会继续阻塞等待,直到当前线程获取指定对象的独占操作权,才会执行与异常处理相关的源码,或者向上层抛出异常。
  • 线程在收到interrupt中断信号时,如果处于就绪状态,那么会保持这个interrupt中断信号不被重置,直到该线程调用wait()、join()等方法。这时该线程并不会因为调用wait()、join()等方法而进入阻塞状态,它会直接抛出“java.lang.InterruptedException”异常。
3.3.2.5 join()方法

使用join()方法可以使两个线程的执行过程具有先后顺序

如果线程ThreadA调用线程ThreadB的join()方法,线程ThreadA就会一直阻塞等待(或阻塞等待指定的时间),直到线程ThreadB执行完成(结束/中断状态),线程ThreadA才会继续执行,如图所示。
在这里插入图片描述
下面展示一段示例代码,该代码中创建了两个线程:一个是main()方法执行的线程(记为mainThread),另一个是名为joinThread的线程。接下来我们在线程mainThread中调用线程joinThread的join()方法,让线程mainThread一直阻塞等待,在线程joinThread执行结束后,线程mainThread再继续执行。
在这里插入图片描述

在Java的基本线程操作中,调用join()、join(long)和join(long,int)方法都可以使目标线程进入阻塞状态
这3个方法的区别主要在阻塞时间上。

  • join():相当于调用join(0),调用该方法的线程会一直阻塞等待,直到目标线程执行结束,才会继续执行。
  • join(long millis):调用该方法的线程在等待mills毫秒后,无论目标线程是否执行结束,都会继续执行。
  • join(long mils,int nanos):调用该方法的线程会阻塞millis毫秒+nanos纳秒,无论目标线程是否执行结束,都会继续执行。实际上,第二个参数nanos只是一个参考值(修正值),该参考值主要用于帮助程序进行以毫秒为单位的修正工作。
    public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

在现代计算机体系中,让某种高级语言精确到1纳秒是一件困难的事。可以想象一下,主频为2.4GHz的CPU在1秒内可以产生24亿次高低电压,而一次硬件级别的位运算需要多次高低电压。这就不难理解,为什么在join(long mills,int nanos)方法中,第二个参数只是一个参考值了,因为根本没办法精确控制1纳秒的时间。

3.3.3 synchronized修饰符和锁升级过程

在HotSpot JVM的工作区中将对象结构分为3个区域,分别为对象头(Header)、对象实际数据(Instance Data)和对齐区(可能存在),如图所示。其中,对象头是实现Object Monitor管程控制模式的关键。
在这里插入图片描述

  • 对齐区(Padding):对齐区并不是必须存在的,它最大的作用是占位,因为HotSpotJVM要求被管理的对象的大小是8字节的整数倍,在某些情况下,需要对不足的对象区域进行填充。
  • 对象实际数据:这个区域主要用于描述真实的对象数据,包括对象中的所有成员的属性信息,如其他对象的地址引用、基础数据类型的数据值。
  • 对象头(Header):对象头是本节重点讨论的部分,在不同操作系统中、不同JVM配置(如是否开启指针压缩)下,对象头的结构不完全一致。为了便于讲解,讨论64位JDK在64位操作系统中的内部结构(不考虑对象压缩)。

注意:

  • 整个对象头的结构长度并不是固定不变的,在32位操作系统和64位操作系统中就有结构长度上的差异,并且在启用对象指针压缩和没有启用对象指针压缩的情况下,对象头的结构长度也不一样。例如,在64位操作系统中,原生对象头的结构长度为16字节(不包括数组长度区),压缩后的对象头的结构长度为12字节。

根据具体情况,对象头区域可分为2~3部分。

  • Length(只有数组形式的对象会有这个区域):如果一个对象是数组,那么这个区域表示数组长度,在未压缩时为8字节,共64位。
  • Klass:是一个指针区域,这个指针区域指向元数据区中(JDK1.8+)该对象所代表的类,这样JVM才知道这个对象是哪个类的实例,在未压缩时为8字节,共64位。
  • Markword:在64位JVM中,该区域在未压缩时占据对象头区域中的8字节,共64位,主要用于存储对象在运行时的数据,并且记录对象当前锁机制的相关信息

对象的锁状态不同,Markword区域的存储结构不同。
例如,在对象处于轻量级锁状态的情况下,Mar-kword区域的存储结构是一种;
在对象处于偏向锁状态的情况下,Markword区域的存储结构是另一种。
Markword区域在64位JVM中和在32位JVM中的结构长度不同。

在64位JVM中,常见的Markword区域结构如图所示。
在这里插入图片描述
要深入了解对象结构,最好的方法是自行实践。
这里推荐一个好用、开源的Java对象结构分析工具——JOL。使用JOL工具,可以查看特定对象的结构细节。

注意:从JDK1.8开始,JM在运行时默认开启对象压缩功能,可以尝试在开启和关闭对象压缩功能的情况下,分别对对象结构细节进行分析。在JVM内存空间没有压缩的情况下,使用JOL工具查看到的Java对象在刚完成初始化工作后的内存结构如图所示。
在这里插入图片描述

3.3.3.1 锁状态升级

程序员在Object Monitor模式下协调多个线程抢占同一个对象的独占操作权,就是通过改变该对象Markword区域中的数据实现的。
线程在Object Monitor模式下的执行过程中,为了尽可能保证操作性能,对象的Markword区域还涉及一个锁机制的升级过程(又称为锁膨胀过程),升级顺序为偏向锁→轻量级锁→重量级锁。需要注意的是,偏向锁在JDK15中已经确认被去掉,但不影响版本中偏向锁的介绍。

3.3.3.1.1 偏向锁

偏向锁实际上是在没有多个线程抢占指定对象独占操作权的情况下,完全取消对这个对象独占操作权的抢占工作。
当前唯一请求对象独占操作权的线程,其线程ID会被对象记录到对象头的Markword区域中(使用CAS技术更新记录)。
如果一直没有出现其他线程抢占对象独占操作权的情况,那么在当前synchronized代码块中基本不会出现针对独占权抢占工作的额外处理。

  • 举个例子,当前进程中只有线程A在请求对象Y的独占操作权,以便进入相关的synchronized代码块,这时Object Monitor模式就会将对象Y的对象头标记为“偏向锁”,并且指向线程A,表示为线程A分配了一个偏向锁,之后会取消针对对象Y独占操作权的抢占操作。如果在线程A执行同步代码块时,线程B试图抢占对象Y的独占操作权,并且线程B在通过自旋操作后仍然没有获取对象Y的独占操作权,那么在Object Monitor模式会消除对象Y上针对线程A的偏向锁,将其升级为轻量级锁。
3.3.3.1.2 轻量级锁

按照之前对偏向锁的描述,偏向锁主要用于解决在没有对象抢占的情况下,由单个线程进入同步代码块时的加锁问题。一旦出现了两个或多个线程抢占对象独占操作权的情况,偏向锁就会升级为轻量级锁。
轻量级锁使用CAS技术实现,表示多个需要抢占对象独占操作权的线程,基于CAS技术持续尝试获取对象独占操作权的过程。

由此可知,轻量级锁是一种乐观锁实现,因为它主要依靠CAS+重试(自旋)的方式实现

  • 下面来看一个实例。在将当前对象Y的锁级别升级为轻量级锁后,JVM会在线程A、线程B和之后请求获取对象Y独占操作权的若干个线程的栈帧中,添加一个锁记录空间(记为Key空间),并且将对象头中的Markword区域中的数据复制到锁记录空间中。然后线程会持续尝试使用CAS技术将对象头中的Markword区域中的数据替换为指向本线程锁记录空间的指针。如果替换成功,那么当前线程获得这个对象的独占操作权,如图所示;如果多次CAS操作都失败了,那么说明当前对象的多线程抢占现象比较严重,将该对象的锁级别升级为重量级锁。
    在这里插入图片描述
3.3.3.1.3 重量级锁

synchronized修饰符所代表的线程管理方式从最初的JDK版本就已经提供,在JDK1.6中做了较大的升级。
例如,引入偏向锁,实现轻量级锁,还提供了锁升级机制,用于优化抢占性能。
如果出现了较大规模的对象独占操作权抢占现象,并且轻量级锁不能平衡各线程的抢占冲突,那么锁工作机制最终会被升级为重量级锁。
重量级锁是一种管程模型的实现,它在有操作系统协助的情况下,对线程间的锁竞争进行管理(后面会对该管程进行介绍)。

3.3.3.2 锁粗化和锁消除

JVM对于Object Monitor模式的性能优化不止体现在锁的升级过程中,还体现在对锁的粗化效果和对锁的消除效果上。

在正常情况下,synchronized代码块规模应该越小越好。这个要求很好理解,synchronized代码块规模越小,说明锁的控制粒度越好,需要进行独占操作的冲突区域就越小。但是,如果为了满足这个要求而强制使用多个synchronized修饰符将指定代码片段拆分为多个synchronized代码块,那么不仅达不到优化效果,还会造成多余的性能损耗,代码如下。
在这里插入图片描述
在上述代码中,同一个方法的代码块被人为分割为多个synchronized代码块,在synchronized同步代码块之间只有很少一部分代码逻辑。在这种情况下,JVM会根据语境对synchronized修饰符的修饰效果进行优化,具体操作是将这些代码逻辑挪到同一个synchronized代码块中执行,如下所示。
在这里插入图片描述
锁的消除是JVM另一种针对Object Monitor模式的性能优化方式,如果JVM发现synchronized代码块所参照的对象实际上并不涉及任何多线程工作场景(这种优化方式需要基于逃逸分析,JVM中基于逃逸分析的优化要点涉及多个方面,如标量替换、栈上分配,逃逸分析是JDK1.8+提供的非常有效的优化手段,并且JDK1.8+默认开启逃逸分析功能),那么JVM会在即时编译阶段忽略加锁操作,更不会涉及在Object Monitor模式下的任何操作。

  • 举例来说,Vector集合支持在多线程场景中工作(但不建议在高并发场景中使用),具体操作是在对象方法维度添加synchronized修饰符,这种方法类似于对this对象加锁。如果使用Vector集合作为某个方法内的局部变量,那么JVM在经过逃逸分析后就不会对Vector集合对象进行加锁操作,甚至可能不会在堆区中创建这个对象(这是逃逸分析的另一个知识点,有兴趣的读者可以查看相关资料)。

3.3.4 管程与synchronized修饰符

在支持多线程操作模式的操作系统中,为了保证线程操作的安全性,需要上层的高级语言解决两个基本问题:互斥问题和同步问题。为解决管程问题而存在的几种业界标准技术模型,Java主要遵循其中的MESA模型,并且提供了两种管程实现。

synchronized修饰符背后的工作过程就是其中一种管程实现——Object Monitor模式。
Object Monitor模式可直译为对象检查,实际上它的专业名称应该是基于Object对象状态的单条件变量管程模型实现,Monitor意译为管程(后面为了保持表达的统一,采用Object Monitor模式检查或Object Monitor模式)。

在这个管程实现中,使用synchronized代码块解决互斥问题,即synchronized代码块中的逻辑代码在同一时间只能由一个线程执行;使用wait()、notify()、notifyAll()、join()等方法解决同步问题,即通过调用这些方法,可以达到线程间状态切换和线程间协作通知的目的,如图所示。
在这里插入图片描述
在对象的Object Monitor模式的控制结构中,一共存在三个控制象限。

  • 第一个控制象限为待进入synchronized代码块的区域(Entry Set)
    停留在这个区域内的线程还没有获得对象的独占操作权,因此仍然停留在synchronized代码块外,即代码“synchronized(Object)”的位置。处于Entry Set区域内的线程,其线程状态被标识为BLOCKED,该状态可以使用jstack命令观察到(后面会进行详细介绍)。

  • 第二个控制象限为对象独占操作权持有区域(Owner)
    在对象的Object Monitor模式下,在同一时间最多有一个线程处于这个区域内,所以Object Monitor模式就会出现同一时间只能有一个线程在synchronize代码块内执行的效果。当前持有对象独占操作权的线程互斥量会被记录到该对象的对象头中。

  • 第三个象限为待授权区域(Wait Set)
    没有退出synchronized代码块,并且暂时没有对象独占操作权的线程会被放置到该区域内。
    注意对象独占操作权和抢占权之间的关系:如果某个线程使用wait()等方法释放了对象的独占操作权,那么只要这个线程没有退出synchronized代码块,在未来就有权被通知重新参与对象独占操作权的抢占工作。
    并不是处于待授权区域(Wait Set)的线程都可以重新参与对象独占操作权的抢占工作,只有使用notify()方法或类似方法被通知转移的线程才可以参与。需要注意的是,每个对象的Object Monitor模式检查过程相对独立,但是一个线程可以同时拥有一个或多个对象的独占操作权。

前面提到的所有线程状态,都可以使用Java提供的jstack命令进行观察(还可以使用基于JMX协议工作的各种监控工具进行观察)。
处于不同象限的线程,会表现出不同的线程状态;

3.3.5 对线程状态切换示意图进行细化

根据前面介绍的线程状态,我们可以对线程状态切换示意图进行细化:将Object Monitor模式下的线程状态细分为WAITING状态和BLOCKED状态,如图所示。
在这里插入图片描述
实际上线程状态切换的细分状态还有很多。在图中,线程间状态切换仅限于使用Object Monitor模式的工作场景,并不包括直接或间接使用LockSuppot工具类中相关方法的切换场景。而这些线程状态在java.lang.Thread.State类中都有大致介绍,如图所示。
在这里插入图片描述

  • NEW:新建状态
    该状态表示一个线程刚刚被创建,但是还没有开始运行。

  • RUNNABLE:就绪状态。
    单线程是否在运行,还要看这个线程是否获得了CPU的时间片,这个过程开发者不能直接控制。
    需要注意的是,处于这种状态的线程可能在其方法栈中能看到locked关键字,这表示它获得了某个对象的独占操作权,并且继续进行着后续的处理工作。
    在这里插入图片描述

  • BLOCKED:阻塞状态
    处于Entry Set象限中的线程状态。处于该状态的线程会试图获取指定对象的独占操作权,从而进入synchronized代码块。
    但是出于某种原因,当前线程没有获得指定对象的独占操作权,还在synchronized代码块外处于阻塞状态。

  • WAITING:不限时间的等待状态
    这是一种不限时间的阻塞状态。处于该状态的线程一般停留在park()、wait()、join()等方法位置,并且会一直等待另一个线程执行特定的唤醒操作(如调用notify()方法、发出interupt中断信号等)。
    如果进入这种状态的方式不一样,那么阻塞状态会有细微的差别。
    在这里插入图片描述

  • TIMED_WAITING:有时限的等待状态
    处于该状态的线程会等待另一个线程的特定操作。
    与WAITING状态的区别可能是在wait()方法中加上了时间限制,即使用wait(timeout)方法,也可能是调用了sleep()方法。

  • TERMINATED:终止状态,表示线程已退出。

以上状态是程序员通过SDK可以获取的线程状态。使用各种监控工具观察线程状态通常是无法观察到NEW状态的,因为在NEW状态下,线程还未真正在操作系统中创建出来;TERMINATED状态也很难观察到,这主要取决于监控工具是否支持,因为处于该状态的线程已经在操作系统层面上消亡了。

在通常情况下,当线程处于运行状态时,线程的状态名应该是RUN、RUNNING等单词,但为什么在Java官方描述中,处于运行状态的线程使用RUNNABLE进行标识呢?

  • 这是因为线程和线程调度都是操作系统级别的概念,某个线程是否由CPU运行,不是由开发者直接决定的,也不是由JVM直接决定的(JVM向开发者暴露的接口只对线程优先级、线程调度类型的选择提供支持),而是由操作系统决定的。
3.3.5.1 线程状态及切换方式(仅限Object Monitor模式)
  • 从NEW状态进入RUNNABLE状态
    在使用new关键字创建一个线程(如new Thread(r))时,该线程还没有运行,它处于新建状态。处于新建状态的线程不能使用jstack命令进行观察,因为该线程还没有被托管到操作系统,但是我们可以通过Java代码调出当前线程的状态信息。
  • RUNNABLE状态和BLOCKED状态之间的切换
    如果某个线程需要得到指定Object对象的独占操作权,并且试图进入synchronized代码块,但发现不能获得Object对象的独占操作权,那么该线程会进入BLOCKED状态
  • RUNNABLE状态和WAITING状态之间的切换
    线程从RUNNABLE状态切换到WAITING状态的方法有很多。
    使用wait()方法释放对象的独占操作权,可以使当前线程进入WAITING状态。
    当前线程调用某线程的join()方法,在等待后者执行完成后,可以使当前线程进入WAITING状态。
    当前线程调用sleep()方法、调用LockSupport工具类的park()方法,也可以使当前线程进入WAITING状态。

3.3.6 Object Monitor临界区与内存屏障

Object Monitor模式下保证边界的有序性和可见性呢?

我们可以将某个线程获取对象独占操作权的操作看作volatile读操作,将线程释放对象独占操作权的操作看作volatile写操作。
这个类比很好理解,在线程获取对象独占操作权前,一定需要获得对象的最新状态,这和volatile读操作一样。
在线程释放对象独占操作权前,一定需要使对象的最新状态保持主存可见,以便后续执行的线程能够读取最新值,这和volatile写操作一样,如图所示。
在这里插入图片描述
线程在持有对象独占操作权前,只能在同步代码块的边界外阻塞等待,等待区域可能是Entry Set区域,也可能是Wait Set区域。
只有在获得对象独占操作权后,才能从monitorenter区域进入Owner区域,以便继续执行同步代码块。如果出于某种原因,当前已获得对象独占操作权的线程需要释放对象独占操作权,这个线程就会从monitorexit区域离开Owner区域,至于是回到Wait Set区域还是正式离开同步代码块(不可能再回到Entry Set区域)并不重要。

应用程序会在monitorexit区域遵循volatile写操作的内存屏障插入效果
在monitorenter区域遵循volatile读操作的内存屏障插入效果,从而保证相关场景的有序性和可见性

如图所示。
在这里插入图片描述

3.4 JUC的组成部分

为了给程序员提供更多在高并发场景中进行编程的方法,Java(JDK1.5+)提供了专门的JUC(java.util.concurrent,Java并发工具包)。JUC具有很强的扩展性,并且为解决高并发场景中各种编程问题提供了更好的思路。
在这里插入图片描述
JUC的体系结构分为多层,底层Native JNI外的上层分别由多个关键技术模块构成:AQS、CAS、LockSupport和句柄(JDK9开始提供变量句柄)等。

Object Monitor实际上也是一个支持模块,因为JUC中的一些工具类直接使用Object Monitor模式进行线程间的互斥和同步操作。
但Object Monitor不属于核心技术模块,因为在JCF使用Object Monitor模式的场景中,一般在Object Monitor模式被正式调用前,资源抢占压力已经被前置的处理过程分担掉了。

变量句柄VarHandle是从JDK9开始引入的较新技术,它具有unsafe工具类的部分功能特性,为程序员进行变量的原子性操作、可见性操作(内存屏障方式)提供了一种新的途径。
变量句柄VarHandle可以与任意字段、数组变量、静态变量进行关联,支持在不同访问模型中对这些变量的访问(包括但不限于简单的read/write访问、使用volatile修饰的read/write访问等。
变量句柄VarHandle是Java官方推荐的,可以由程序员直接使用的编程工具,不用担心它像unsafe工具类一样突破Java的安全性限制。

在这些关键技术模块的上层,JUC提供了多种可以在高并发场景中直接使用的工具类,这些工具类主要分为如下五个维度。

  • 信号
    这些工具类主要基于AQS技术,用于解决线程间的同步和互斥问题,即解决多个线程的执行顺序控制问题。
    j.u.c.Semaphore类、j.u.c.CyclicBarrier类、j.u.c.CountDownLatch类都属于这方面的工具类。
  • 高并发场景中的JCF
    这些工具类是Java官方建议在高并发场景中优先考虑使用的工具类
    如j.u.c.ConcurrentHashMap类、j.u.c.ConcurrentSkipListMap类、j.u.c.ArrayBlockingQueue类等。
    换句话说,JCF和JUC存在交集。所以当后文对存在于JUC中的集合进行描述时,也会使用JCF来描述这些集合,这时读者需要清楚这样描述的含义。
  • 线程管理/执行
    这些工具类主要用于帮助应用程序控制线程规模,保证应用程序在高并发场景中不会开启过多线程,从而导致线程切换占用过多CPU资源,即帮助应用程序在线程规模和系统性能之间保持平衡。
    这些工具类包括我们经常使用的各类线程池线程管理工具类,如j.u.c.ThreadPoolExecutor类、j.u.c.ScheduledThreadPoolExecutor类、j.u.c.ForkJoinPool类。
  • 线程/数据控制
    这些工具类主要用于完成数据在各线程间的传递工作,或者帮助调用者完成线程间的异步调用工作,并且跟踪处理的状态和数据。
    这些工具类和信号工具类最大的区别是,前者着眼于一个线程,如果获得其他线程中的数据,并且对这些线程的执行顺序没有过多的要求,那么无论线程的运行先后顺序如何,数据都应该被正确传递。j.u.c.LinkedTransferQueue、j.u.c.Exchanger、j.u.c.Callable等类或接口都属于这个类型的工具类。
  • 原子性操作(无锁)
    高并发场景中的操作原子性是编程过程中需要关注的另一个问题。
    JUC中有一个子工具箱java.util.concurrent.atomic,主要用于解决原子性操作问题,基本设计思想是CAS。
    基于Java的内存结构,Java中要求的原子性操作主要包括值的原子性操作和引用的原子性操作,这些控制都可以由JuC中的此部分工具类完成,如j.u.c.a.AtomicReference类、j.u.c.a.AtomicInteger类、j.u.c.a.AtomicBoolean类。

3.4.1 Unsafe工具类

Uunsafe工具类是Java原子性操作、Park线程控制及其他操作特性的底层支持类。
在JDK1.8中,sun.misc.unsafe工具类存储于sun.misc包路径下,但是从JDK9开始在jdk.internal.misc包路径下添加了另一个unsafe工具类——jdk.internal.misc.unsafe。

出于对封装性和安全性的考虑,Java不允许技术人员直接使用unsafe工具类(包括sun.misc.unsafe工具类和jdk.internal.misc.unsafe工具类)和其中的各种方法。
sun.misc.unsafe工具类也不能直接使用,既不能使用new关键字直接创建该类的对象,又不能直接调用该类的静态方法;
虽然sun.misc.unsafe工具类有一个静态方法getunsafe()能够获取sun.misc.unsafe工具类的对象,但是调用该方法会抛出SecurityException异常,如下所示。
在这里插入图片描述
unsafe工具类的作用是便于Java统一管理对上层具有支撑作用的各种NI方法的工具类,它提供的操作包括内存控制操作、对象属性值获取操作、线程控制操作、原子性操作、可见性控制操作等。这些操作直接跳过了Java提供的安全性封装及访问规范要求,如果开发人员直接调用,那么无法保证程序的可靠性和安全性。因此,Java将unsafe工具类深度隐藏并将其命名为Unsafe,并且在JDK9+中将其隐藏得更深。

如果开发人员遇上了需要使用unsafe工具类提供的各种功能的场景,那么应该怎么办呢?实际上unsa-fe工具类提供的功能,已经通过Java上层工具类进行了再次封装并提供给开发人员使用。这些上层工具类具有unsafe工具类中的大部分功能。

  • 例如,如果需要通过park模式对线程的运行状态进行控制,则可以使用j.u.c.locks.LockSupport工具类;
  • 如果需要对对象或对象中的某个属性进行原子性控制,则可以使用j.u.c.a.AtomicReferenceFieldupdater操作类或j.u.c.a.A-
    tomicReference操作类,甚至可以直接使用JDK9+提供的变量句柄(VarHandle)。
  • 对于可以直接控制内存区域的操作方法(如allocateMemory()方法、freeMemory()方法等),不推荐开发人员直接使用,因为这些方法太危险,稍有偏差就会使运行进程崩溃。
3.4.1.1 objectFieldOffset(FieId)方法及类似方法

在unsafe工具类中,objectFieldOffset(Field)方法主要用于返回类定义中某个属性在内存中设置的偏移量。
还有几个类似的方法,如objectFieldOffset(Class,String)、staticFieldOffset(Fiel-d),它们的功能与objectFieldOffset(Field)。

objectFieldOffset(Field)方法及类似方法的使用场景比较多,通过它们获取的偏移量通常是一些逻辑处理过程的前提条件。例如,使用unsafe工具类对对象中的属性进行操作时,需要先根据内存偏移量找到操作目标的偏移位置。

3.4.1.2 compareAndSetReference(Object,Iong,Object,Object)方法及类似方法

在unsafe工具类中,compareAndSetReference(Object,long,Object,Object)方法及类似方法(如compareAndSetInt(Object,long,int,int)方法、compareAndSetLong(Object,long,long,long)方法)的作用是,对属性进行比较并在满足预设条件的情况下进行替换(这种处理过程称为CompareAnd Swap,简称CAS)。这些方法是JUC原子性操作的底层支持方法。

如果在指定的对象中(第1个入参),指定属性(第2个入参)的值符合预期(第3个入参),那么将这个值替换成一个新的值(第4个入参)并返回true;否则忽略本次操作并返回false。

注意这些方法的第2个入参,这个入参是一个长整型数据,表示这个属性在对象中的内存偏移量(offset)是由调用类似于objectFieldOffset(Field)的方法所取得的返回结果。对unsafe工具类中此类方法的理解非常重要,因为后续对原子性操作的讲解和这些方法有关。

3.4.1.3 putReference(Object,Iong,Object)方法及类似方法

putReference(Object,long,Object)方法可以突破对象中的访问修饰符(public、protected、private和default修饰符)、属性封装性(是否有get()、set()方法)的限制,将指定的引用值赋值给指定对象中的指定属性。类似的方法还有putBoolean(Object,long,boolean)方法、putByte(Object,long,byte)方法、putDouble(Object,long,double)方法等。

putReference(Object,long,Object)方法的相关参数如下。

  • 第0个参数(Object):该参数主要用于指定需要操作的对象。
  • 第1个参数(long):该参数主要用于指定需要进行操作的属性在对象中的偏移量(使用objectFieldOffset(Field)及类似方法获取)。
  • 第2个参数(Object):该参数是要赋给指定属性的值(引用),可以为null。该方法的使用示例代码如下(示例代码的输出结
    果为null)。
3.4.1.4 getReference(Object,Iong)方法及类似方法

getReference(Object,long)方法和putReference(Object,long,Object)方法成对出现,它可以突破对象中的访问修饰符(public、protected、private和default修饰符)、属性封装性(是否有get()、set()方法)的限制,将指定对象中的指定属性的引用值返回。类似的方法还有getFloat(Object,long)方法、getDouble(Object,long)方法、getInt(Object,long)方法等。

  • 第0个参数(Object):该参数主要用于指定需要操作的对象。
  • 第1个参数(long):该参数主要用于指定需要获取的属性在对象中的偏移量(使用objectFieldOffset(Field)及类似方法获取)。
3.4.1.5 putReferenceOpaque(Object,Iong,Object)方法

putReferenceOpaque(Object,long,Object)方法内部调用了unsafe工具类中的putReferenceVolatile(Object,long,Object)方法,它的功能是在保证volatile语义的情况下,将指定对象中(第0个Object参数)内存偏移量(第1个long参数)上的属性拥有的引用值进行变更(第2个Object参数)。

3.4.1.6 park(boolean,Iong)方法和unpark(Object)方法

park(boolean,long)方法主要用于基于park控制模式利用一个“许可”使线程进入阻塞状态,并且这个阻塞状态可以在使用unpark(Object)方法授权“许可”后被解除。

park(boolean,long)方法可以传入两个参数,形参名称分别为isAbsolute(boolean数据类型)和time(long数据类型),这两个参数的意义如下。

  • isAbsolute:park()方法可以指定当前线程进入阻塞状态后的最长阻塞时间,如果超过这个时间,那么该线程会退出阻塞状态。设置的时间可以是一个精确值,也可以是一个非精确值(允许有偏差)。
    • 如果isAbsolute为true,则表示是精确时间,时间单位为毫秒;
    • 如果isAbsolute为false,则表示是非精确时间,时间单位纳秒。
  • time:线程阻塞的最长时间,超出这个时间会退出阻塞状态。isAbsolute参数的设置不同,该参数的单位也不同。
    • 当isAbsolute参数的值为true时,该时间的单位为毫秒,反之为纳秒。
    • 如果该参数的值为0,则表示调用者要求线程一直阻塞下去,直到当前线程获取“许可”。

park()/unpark()方法使线程阻塞/解除阻塞的原理和Object Monitor模式下线程阻塞/解除阻塞的原理不同。如果使用wait()方法使线程进入阻塞状态,那么使用unpark()方法是无法使线程退出阻塞状态的。

park()方法和unpark()方法非常重要,在JUC中,通常使用这两个方法控制线程的阻塞状态。为了可以完成一些对线程对象Thread的附加操作,以及保证清晰的依赖关系,Java提供了LockSupport工具类,用于封装这两个方法及其关联方法。

3.4.2 LockSupport工具类

LockSupport工具类属于AQS框架的底层支持部分,主要用于进行线程元语级别的执行/阻塞控制。

LockSupport工具类的主要方法有两个,分别为park()方法和unpark)方法。

  • park()方法主要用于使当前线程进入阻塞状态
  • unpark()方法(及其重载方法)主要用于使指定线程退出阻塞状态。

park()方法和unpark()方法类似于Object Monitor模式下的wait()方法和notify()方法,但park()方法和unpark()方法更灵活,它们无须关注两个或多个相互作用线程的执行顺序,无须像wait()方法和notify()方法那样需要配合使用(notify()方法要在wait()方法之后调用)。

这是因为LockSupport工具类采用“许可”(permit)的概念控制线程,“许可”(permit)在下层JNI源码中,表现为一个_counter计数器(类型为整型,并且使用volatile修饰符进行修饰)。这个“许可”有两个值,分别为0和1。如果_counter变量的值为0,则表示当前线程没有获得“许可”;如果_counter变量的值为1,则表示当前线程获得了“许可”。在线程进入阻塞状态后,只要JVM判定当前线程已经获得了“许可”(_counter变量的值为1),该线程就会退出阻塞状态,并且“许可”立即被消费掉(_counter变量的值变为0)。

如果在调用park()方法后,发现当前线程已经获得了“许可”,那么该线程不会进入阻塞状态;
如果在调用park()方法后,发现当前线程还未获得“许可”,那么该线程进入阻塞状态,直到获得“许可”。
因此,LockSupport工具类提供的park()方法和unpark()方法只关心要求阻塞的线程是否获得了“许可”,而不关心两个或多个相互作用的线程的执行顺序。

3.4.2.1 演示案例

park()方法和unpark()方法的使用示例代码如下。
在这里插入图片描述

3.4.2.2 LockSupport工具类的主要属性和方法
public class LockSupport {
    private LockSupport() {} // Cannot be instantiated.

    private static void setBlocker(Thread t, Object arg) {
        UNSAFE.putObject(t, parkBlockerOffset, arg);
    }

    public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }

    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        UNSAFE.park(false, 0L);
        setBlocker(t, null);
    }

    public static void parkNanos(Object blocker, long nanos) {
        if (nanos > 0) {
            Thread t = Thread.currentThread();
            setBlocker(t, blocker);
            UNSAFE.park(false, nanos);
            setBlocker(t, null);
        }
    }

    public static void parkUntil(Object blocker, long deadline) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        UNSAFE.park(true, deadline);
        setBlocker(t, null);
    }

    public static Object getBlocker(Thread t) {
        if (t == null)
            throw new NullPointerException();
        return UNSAFE.getObjectVolatile(t, parkBlockerOffset);
    }

    public static void park() {
        UNSAFE.park(false, 0L);
    }

    public static void parkNanos(long nanos) {
        if (nanos > 0)
            UNSAFE.park(false, nanos);
    }

    public static void parkUntil(long deadline) {
        UNSAFE.park(true, deadline);
    }

    static final int nextSecondarySeed() {
        int r;
        Thread t = Thread.currentThread();
        if ((r = UNSAFE.getInt(t, SECONDARY)) != 0) {
            r ^= r << 13;   // xorshift
            r ^= r >>> 17;
            r ^= r << 5;
        }
        else if ((r = java.util.concurrent.ThreadLocalRandom.current().nextInt()) == 0)
            r = 1; // avoid zero
        UNSAFE.putInt(t, SECONDARY, r);
        return r;
    }

    private static final sun.misc.Unsafe UNSAFE;
    private static final long parkBlockerOffset;
    private static final long SEED;
    private static final long PROBE;
    private static final long SECONDARY;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> tk = Thread.class;
            parkBlockerOffset = UNSAFE.objectFieldOffset
                    (tk.getDeclaredField("parkBlocker"));
            SEED = UNSAFE.objectFieldOffset
                    (tk.getDeclaredField("threadLocalRandomSeed"));
            PROBE = UNSAFE.objectFieldOffset
                    (tk.getDeclaredField("threadLocalRandomProbe"));
            SECONDARY = UNSAFE.objectFieldOffset
                    (tk.getDeclaredField("threadLocalRandomSecondarySeed"));
        } catch (Exception ex) { throw new Error(ex); }
    }

}

3.4.2.2.1 park()

该方法主要用于使用parking模式阻塞当前线程,如果当前线程获得“许可”,那么该线程会从阻塞状态切换为就绪状态。

需要注意的是,如果当前线程在阻塞前判断已获取“许可”,那么该线程不会进入阻塞状态。
“许可”的有效期只存在于一次park()方法的调用过程中,在本次“许可”使用完成后,“许可”会被撤销。
该方法内部实际上调用的是Unsafe工具类中的unsafe.park(boolean,long)方法;

使用park()方法进入阻塞状态的线程仍然可以接收interrupt中断信号。线程在收到interupt中断信号后会立即激活(无须等待对象的独占操作权)。
park()方法有许多多态表现,需要重点关注的方法有park(Object)方法和parkuntil(Object,long)方法。
park(Object)方法首先设置当前线程的parkBlocker属性值为当前传入的arg对象(可以为nul,这个对象主要用于进行当前线程和其他线程的数据交换,并且不用担心数据的可见性问题),然后判断“许可”情况,根据“许可”情况决定是否让当前线程进入阻塞状态。
parkuntil(Object,long)方法在上述基础上设置了一个最长阻塞时间(单位为毫秒),如果在达到最长阻塞时间后仍未获得“许可”,那么该线程会退出阻塞状态。实际上,该方法内部调用的也是Unsafe.park(boolean,long)方法,只是传参信息不一样。

3.4.2.2.2 unpark(Thread)

unpark(Thread)方法主要用于为指定的线程提供“许可”,帮助这个线程在下次进行阻塞判定时能正确获取“许可”。
如果指定的线程由于未获得“许可”而进入了阻塞状态,那么使用该方法可以帮助它退出阻塞状态。
“许可”是一个具有原子性的单一值,要么为1(获得“许可”),要么为0(未获得“许可”)。“许可”还具有一次性使用的特点,也就是说,在本次使用完“许可”后,就不再具有“许可”,即使调用者多次提供“许可”,在使用“许可”时,也只会认为只“许可”了一次。
unpark(Thread)方法实际上调用的是unsafe工具类中的unsafe.unpark(Object thread)方法,但要注意以下几个要点

  • 该方法的入参可以为null,这时该方法不会抛出异常,也不会报错,但是不会有任何操作效果。
  • 该方法传入的线程必须是已经启动的线程,如果这个线程只是完成了创建,并没有启动(如没有使用Thread.start()方法启动),则不会对指定的线程产生“许可”作用。
  • 即使多次调用unpark(Thread)方法或其重载方法,在目标线程没有使用“许可”前,也只会被目标线程视为一次“许可”。
3.4.2.2.3 LockSupport.parkNanos(Iong)方法

使用LockSupport.parkNanos(Iong)方法或类似方法进入阻塞状态

LockSupport.parkNanos(long)方法的作用与Lock-Support.parkuntil(ong)方法的作用类似,只不过LockSupport.parkNanos(long)方法的作用是将指定的线程等待获取“许可”的时间单位精确到纳秒(而非毫秒)。
在等待获取“许可”期间,如果当前线程没有获取“许可”,则需要退出阻塞状态。

3.4.3 volatile修饰符

JUC主要依靠volatile修饰符解决内存可见性问题,更确切地说是依靠volatile修饰符背后隐含的各种形式的内存屏障来解决内存可见性问题。

volatile修饰符主要用于以下场景中。

  • 在多线程场景中,需要保证共享数据内存可见性的场景。
  • 需要避免指令重排的场景(实际用于应对有序性问题)。
3.4.3.1 为什么需要Java内存模型

有的读者会想,既然Java内存模型(JMM)会引起各种问题,那为什么会有JMM这样的结构设计呢?
这是不是Java的一个设计缺陷呢?显然不是,这主要是由现代计算机硬件结构决定的。

现代计算机的计算核心是CPU,而且是多核CPU,计算机的数据存储核心是内存(注意不是硬盘),其工作原理为读取内存中的指定数据,将其放入CPU中执行,并且将输出结果重新存储到内存中。
而内存频率和CPU频率在历史上曾经有很大的差距,当然这种差距目前正在缩小,但存取速度差距仍然很大。以2020年流行的基于×86架构的一种CPU(AMD Ryzen 71700X)为例,其标准工作频率为3.4GHz,一级缓存吞吐量为700GB/s,一级缓存存储延迟平均为1.2纳秒;而2020年流行的DDR4-3200型内存,其工作频率通常为3200MHz,数据吞吐量为44GB/s,存储延迟平均为47纳秒。

由于存在较大的速度差距,如果直接让CPU和内存建立通信连接,就会显著拖慢CPU的工作速度,因此CPU一般会有多级高速缓存作用于CPU和内存通信的中间层,用于平衡二者的速度差。CPU一般会在自己独有的或多核共有的高速缓存中读取数据(命中率一般很高),如果没有读取到数据,则逐层下行,最后在内存中读取数据。

同样以CPU(AMD Ryzen 71700X)为例,它一共有8个内核,高速缓存有三级。一级缓存大小为768KB,速度几乎和CPU内核的速度一样(前面提到了,平均延迟为1.2纳秒)。二级缓存大小为4MB,速度稍慢(平均延迟为3.1纳秒)。一、二级缓存都是每个CPU内核独有的。三级缓存大小为16MB,是每4个CPU内核所共有的。该CPU的总线有两个独立通道,用于和内存进行交互,可以使数据吞吐量增加1倍,如图所示。
在这里插入图片描述
虽然CPU的基本工作原理相似,但是不同型号的CPU、不同型号的内存,在不同操作系统中的工作细节是不一样的。
由于Java语言的跨平台特性,因此JVM需要一种工作模型,用于屏蔽这些硬件层面的细节差异,这就是设计JMM的原因。

3.4.3.2 内存可见性问题和MESI协议

在实际工作中,CPU内核、高速缓存和内存中的数据交互过程要复杂得多。
但一个无法避免的问题是,CPU、高速缓存和内存如何保证数据的一致性。

当数据A在多个处于运行状态的线程中进行共享数据读/写操作时(如线程ThreadA、线程ThreadB和线程ThreadC同时对共享数据A进行读/写操作),可能出现多种情况。
例如,多个线程可能在多个独立的CPu内核中同时修改数据A,导致系统不知应该以哪次修改后的数据为准;或者线程ThreadA在对数据A进行修改后,没有及时将其写回内存,线程ThreadB和线程ThreadC没有及时获取最新的数据A,导致最新修改的数据A对线程ThreadB和线程ThreadC不可见。
为了解决这些问题,CPU工程师设计了一套数据状态的记录和更新协议——MESI协议(CPU缓存一致性协议)。

这个协议实际上包含4种数据状态,如图所示。
在这里插入图片描述

  • M(Modified,可修改)
    本地处理器已经修改高速缓存行(脏行),其中的数据与内存中的数据不一样。
  • E(Exclusive,专有)
    高速缓存行中的数据和内存中的数据一样,并且其他处理器的高速缓存行中都没有这种状态数据的副本。
  • S(Shared,共享)
    高速缓存行中的数据和内存中的数据一样,并且其他处理器的高速缓存行中也可能存在这种状态数据的副本。
  • I(Invalid,无效)
    缓存行失效,或者根本没有数据,不能使用。

CPU对缓存状态的记录是以缓存行为单位的。
举个例子,一个CPU独立使用的一级缓存大小为32KB,如果按照标准的一个缓存行为64byte计算,这个一级缓存最多可以有512个缓存行。一个缓存行中可能存储了多个变量值,如一个64byte的缓存行理论上可以存储8(64÷8)个long型变量的值,因此只要这8个long型变量中的任意一个的值发生了变化,就会导致该缓存行的状态发生变化

除了规定高速缓存行的状态,CPU对高速缓存行还有以下四种读/写方式。

  • 本地读:CPU内核从本地高速缓存行中读取数据。
  • 本地写:CPU内核向本地高速缓存行中写入或修改数据。
  • 远端读取:本CPU通过CPU总线监听到其他CPU内核从其后者的缓存行中读取数据。
  • 远端写入:本CPU通过CPU总线监听到其他CPU内核向后者的缓存行中写入或修改数据。

下面对不同状态下的缓存行的关键工作分别进行描述,并且在最后进行总结。

3.4.3.2.1 E状态下的工作要点和状态切换

E状态为专有状态,在这种状态下

  • 如果发生了本地读操作,那么高速缓存行状态不会发生变化,仍然为E;
  • 如果发生了本地写操作,那么高速缓存行状态会从E变为M。

在E状态下,当前高速缓存行X必须对其他高速缓存行0进行监听:

  • 如果监听到发生了远端读操作,那么将当前高速缓存行的状态改为S。在这种情况下,其他高速缓存行0通常会直接从当前高速缓存行X中读取数据,无须从主存中读取。
  • 如果监听到发生了远端写操作,那么将当前高速缓存行的状态改为I,表示本高速缓存行已经失效。
    在这里插入图片描述
3.4.3.2.2 M状态下的工作要点和状态切换

M状态为修改状态,在这种状态下,无论发生本地读操作,还是发生本地写操作,高速缓存行状态都不会发生变化,仍然为M。

在M状态下,当前缓存行X必须对其他高速缓存行o进行监听:

  • 如果监听到远端读操作,那么提示对方中断等待,直到将本地数据更新到与主存中的数据重新一致为止;由于发生了远端读操作,因此数据副本肯定存在于多个缓存行中了,将当前高速缓存行的状态改为S。
  • 如果监听到远端写操作,那么提示对方中断等待,直到将本地数据更新到与主存中的数据重新一致为止;由于发生了远端写操作,因此本缓存行中的数据一定不是最新的,将当前高速缓存行的状态改为I。
    在这里插入图片描述
3.4.3.2.3 S状态下的工作要点和状态切换

S状态为共享状态,在这种状态下

  • 如果发生了本地读操作,那么高速缓存行状态不会发生变化,仍然为S;
  • 如果发生了本地写操作,那么高速缓存行会向CPU总线发出指令,要求当前数据副本所在的其他高速缓存行0将状态改为I,在其他高速缓存行没有反馈该指令的执行结果前,当前高速缓存行都会处于中断等待状态,这实际上是由CPu总线控制的一种缓存事务,用于保证对数据修改的原子性。

由于在修改数据后,本地高速缓存行X中的数据一定发生了变化,因此当前高速缓存行的状态由S变为M。
在S状态下,当前高速缓存行X必须对其他高速缓存行0进行监听:

  • 如果监听到远端读操作,那么状态不会发生变化,仍然为S;
  • 如果监听到远端写操作,那么将当前高速缓存行X的状态改为I。

在这里插入图片描述

3.4.3.2.4 I状态下的工作要点和状态切换

I状态为无效状态,在这种状态下

  • 如果CPU需要利用这个高速缓存行,则会从主存或其他高速缓存行O中重新读取数据到当前高速缓存行X中。处于I状态的高速缓存行不需要监听远端读/写操作。

处于I状态的高速缓存行在进行本地读操作时需要分情况考虑。

  • 第一种情况是读取的数据已经存在于其他高速缓存行0中,并且其他高速缓存行0的状态为E或S。这时为了加快读取性能,当前高速缓存行X会直接通过CPU总线从其他高速缓存行0中读取数据,并且该高速缓存行和具有该数据的其他高速缓存行的状态都会变为S。
  • 第二种情况是读取的数据已经存在于其他高速缓存行0中,并且其他高速缓存行0的状态为M,这时说明其他高速缓存行0已经修改了这条数据,并且拥有这条数据的其他高速缓存行状态都变成了I。这时当前高速缓存行X会进行中断,等待将最新的数据写入主存,再进行读取操作。最终当前高速缓存行X的状态会变为S。
  • 第三种情况是读取的数据不在任何高速缓存行中,这时当前高速缓存行X会从主存中读取数据,并且将当前高速缓存行X的状态改为E。

以上是高速缓存行在I状态下发生本地读操作的情况

那么在I状态下发生本地写操作,状态会如何切换呢?

  • 第一种情况是修改的数据存在于其他高速缓存行中,并且其他高速缓存行0的状态为E或S。这时当前高速缓存行X会通过CPU总线通知其他高速缓存行将状态修改为I(同样是一个缓存事务),最后将当前高速缓存行的状态改为M。
  • 第二种情况是修改的数据存在于其他高速缓存行中,并且其他高速缓存行0的状态为M,这时当前高速缓存行X会进行中断,等待将最新的数据写入主存,再进行写操作。最后将当前高速缓存行的状态改为M。
  • 第三种情况是修改的数据不存在于任何其他高速缓存行中,这时直接从主存中读取数据并进行修改,然后将当前高速缓存行的状态改为M,这种状态无须通知其他高速缓存行修改状态。
    在这里插入图片描述
    需要注意的是,MESI协议是一种为了保证多核CPU数据一致性的解决规则,其核心点在于硬件层面对多核CPU保证数据一致性的支持。不同的操作系统,对MESI协议的实现不同,甚至可以脱离MESI协议进行另一种规范的实现。
3.4.3.3 存储缓存和失效队列

下面总结一下MESI协议的几个工作特点和未解决的问题。

  • MESI协议的关键在于各状态向M状态的转变操作,只有M状态下的写操作,才被认为是有效的写操作。为了保证写操作的数据一致性,MESI协议会将相同时间内具有该数据的其他高速缓存行0的状态设置为I(并且操作过程必须全部成功)。
    这个过程称为ROF指令过程,它由CPU总线控制,是比较消耗CPU性能的一种处理过程。
  • 处于M状态的高速缓存行具有最高处理权限还表现在对失效高速缓存行(状态为I)的处理上,当处于I状态的高速缓存行需要读取副本时,会首先等待CPU总线给它的地址反馈,如果发现该数据已经存在于其他高速缓存行中,并且后者状态为M,则需要中断等待,直到数据被写入主存。
  • MESI协议保证了高速缓存行的操作原子性(但是不能保证跨总线、跨多缓存行等情况的操作原子性)。M状态就是保证这种操作原子性的外在体现。这实际上涉及了锁的概念,只有在获取锁的缓存行后才能进行缓存行的写操作,并且将状态改为M状态;如果没有获取锁的缓存行,那么无论什么时候监控到了远端写操作,状态都会被设置为I状态。
  • ·MESI缓存一致性协议并没有完全解决速度问题,在某些场景中高速缓存行仍然会中断等待。例如,在进行S状态到M状态的本地写操作时,会要求将其他高速缓存行的状态设置为I,这个过程主要由总线控制器发出的RFO指令(总线事务指令)完成,并且需要等待所有相关高速缓存行回执操作结果。这个过程远比写操作本身耗费时间。

为了解决MESI协议的性能问题,CPU设计者为每个CPU内核加入了两种技术,分别为存储缓存(Store Bufferes)技术和失效队列(Invalid Queue)技术。

3.4.3.3.1 存储缓存

前面提到,当前高速缓存行X在进行本地写操作时(在S状态下,E状态因为是独享的,所以不会涉及这个问题),会将拥有相同数据的其他高速缓存行0的状态设置为I。

这个过程会通过CPU总线完成调度,并且需要中断等待,直到收到所有相关高速缓存行的回执结果,最后将值更新到主存中。在这之前,当前CPU内核对当前高速缓存行X的操作会中断等待。

如果引入存储缓存(Store Buffers)技术,那么在发送当前高速缓存行X要求其他高速缓存行将状态设置为I的请求后,当前高速缓存行X无须等待其他高速缓存行0的回执结果,先将修改后的值放入存储缓存,再继续进行后续的执行工作。
将确认结果并提交缓存事务的工作交给存储缓存完成,只有在提交缓存事务后,当前高速缓存行X和主存中的数据才会被更新,如图所示。
在这里插入图片描述

显然,存储缓存中的数据是还未完全提交的缓存事务涉及的数据。存储缓存保证了高速缓存进行本地写操作的性能,但是本地写操作的数据一致性保证,从强一致性保证变成了弱一致性(最终一致性)保证。

在引入存储缓存技术后,还有一个问题没有解决。当一个CPU内核连续读/写相同高速缓存行中的数据时,由于这个高速缓存行中的最新数据很可能还在存储缓存中,如果直接读取当前高速缓存行X中的数据,那么很可能读到旧值,而非新值。因此在读操作场景中,CPU内核会先检索存储缓存中的数据,如果存储缓存中存在所需数据,则直接读取该数据。这种读取方式称为存储转发(Store Bufer Forwarding)。

3.4.3.3.2 失效队列

但是问题仍然没有完全解决。存储缓存有长度限制,如果一个未提交的缓存事务因为客观原因占用存储缓存较长时间,那么存储缓存很快会被全部占用。此外,存储缓存无法加快远端高速缓存行0对I状态(失效状态)的更新速度。因此需要引入失效队列(Inv-
alid Queue)技术,用于提高I状态切换的性能。如果当前高速缓存行X收到了I状态切换指令,则可以立即发送回执信息给CPU总线,无须等待状态彻底切换成功,并且将I状态的切换指令存储于失效队列中,在后续空闲时逐一进行处理,如图所示。
在这里插入图片描述
需要注意的是,存储缓存技术和失效队列技术只对高速缓存行中存在共享数据的场景起作用,如果当前数据所在高速缓存行被标识为E状态(独占),则无须使用这两种技术。

3.4.3.4 内存屏障与数据一致性

存储缓存和失效队列带来的问题存储缓存技术和失效队列技术是进一步提高CPU执行性能的关键技术,但是在引入这两种技术后,各高速缓存行之间只能保证数据的弱一致性(最终一致性),而放弃保证数据的强一致性——因为时间单位是纳秒级别的,所以各CPU内核从高速缓存行中读取的数据可能是不一致的,但这些高速缓存行中的数据最终会变得一致。

如果各CPU内核对各线程的执行顺序、时钟周期不一致,那么在多线程处理共享数据时,存储缓存技术和失效队列技术可能会导致产生的结果不一致,如图所示。
在这里插入图片描述

图中一共有两个线程在两个不同的CPU内核中执行,每个CPU内核都有独立的高速缓存行、存储缓存和失效队列;两个线程共享的数据(变量)是X和Y,并且没有对这些共享数据采取任何保证数据一致性的措施。

存储缓存和失效队列的相互作用会导致在进行输出时没有读取正确的X值。引起这个错误的原因,可能是正确的X值还在CPU0的存储缓存中,也可能是需要执行的失效操作还在CPU1的失效队列中。因此,相关高速缓存行的状态还没有转换为I状态,导致误认为当前处于S状态的高速缓存行中的是最新值。

根据以上描述可知,在高并发场景中,当多个线程频繁进行共享数值的读/写操作时,有一定的概率产生同一时刻各线程中数据不一致的问题。这是比较严重的问题,如果有万分之一的概率发生这种错误,则表示1000万次请求中会出现大约1000个的错误数据。所以在高并发场景中,对于共享数据,特别是要进行高频次读/写操作的关键共享数据,操作系统需要在“保证数据的最终一致性”还是“保证数据的强一致性”之间做出选择。也就是说,有时需要舍去一些性能来“保证数据的强一致性”。

3.4.3.5 基本内存屏障指令

由于指令集、多级高速缓存、总线、内存等各种客观因素的限制,操作系统不可能清楚哪些数据是高并发场景中的关键共享数据,因此操作系统无法自动地在“保证数据的最终一致性”还是“保证数据的强一致性”之间做出选择。

但是程序员应该知道哪些是关键的共享数据,所以一种简单且直接的处理方式是,计算机硬件将以上问题交给程序员解决。
内存屏障(Memory Barrier)是一种CPU命令,它随着ARM指令集一起被提供出来供程序员使用。

在操作系统层面上有很多内存屏障API,它们不仅可以对存储缓存(Store Bufferes)和失效队列(Invalid Queue)进行操作,还可以在操作系统层面禁止特定的指令重排场景。

操作系统层面主要提供了以下4种内存屏障

  • LoadLoad Barrier(Load Memory Barrier)
    该内存屏障主要用于保证高速缓存行在进行本地读操作时的内存可见性,即保证屏障后的本地读操作结果一定是最新的数据结果。
    具体做法如下。一旦本地CPU内核发现执行了LoadLoad Barrier,则本地CPU内核将强制等待,直到失效队列(Invalid Queue)中所有应更新为I状态的操作全部执行完毕,才会继续执行后续读操作指令。
    这样,本地高速缓存行中后续读取的数据和内存中最新的数据就可以保持一致了。
  • StoreStore Barrier(Store Memory Barrier)
    该内存屏障主要用于保证高速缓存行在进行本地写操作时的数据可见性,即保证屏障后的本地写操作对其他高速缓存行可见。
    具体的做法是,一旦本地CPU内核发现执行了StoreStore Barrier,则本地CPU内核会强制等待对存储缓存(Store Bufferes)中所有未提交数据的标记,再继续执行后续写操作指令。
    而CPU会保证存储缓存中被做过标记的数据一定先于发生内存屏障后的数据被提交到高速缓存和主存中,并且保证拥有这些数据副本的其他高速缓存行状态被标记为I(注意:这个过程很可能只是对方的失效队列记录了这次I状态的变化并返回确认信息,而实际目标缓存行是否真的进行了更新,就不再是StoreStore Barrier所保证的了)。
    一部分型号的CPU在执行这种内存屏障时,会同时清理当前CPU的失效队列(Invalid Queue)。这样,本地高速缓存行中后续写入的数据和内存中最新的数据就可以保持一致了。
  • StoreLoad Barrier(Ful Barrier)
    该内存屏障的性能不高,但是可以保证数据一致性,是操作系统提供的通用屏障。CPU在执行过程中,一旦遇到这种屏障指令,就会强制等待,直到所属存储缓存中的数据全部提交,数据被写入当前高速缓存行中,并且当前CPU的失效队列也全部被清理为止。通用屏障是开销较大的一种内存屏障,程序员应尽量避免让CPU执行通用屏障。但是通用屏障确实可以替换StoreStore Barrier、St-
    oreLoad Barrier和LoadStore Barrier的操作效果。
  • LoadStore Barrier
    在对存储缓存和失效队列的操作层面上,LoadStore Barrier和LoadLoad Barrier(Load Memory Barrier)没有太大的区别,但是LoadStore Barrier对指令重排做出了不同的限制,后面会进行相关介绍。
3.4.3.6 内存屏障与指令重排

指令重排典型场景

为了优化执行过程、提高执行效率,Java应用程序在执行过程中会进行指令重排操作。指令重排在部分资料中又称为CPU执行重排,主要分为两类,一类是编译器优化重排,一类是指令集并行重排。其中编译器优化重排是由JVM控制的,指令集并行重排是由操作系统和硬件系统控制的。

  • 指令集并行重排
    产生指令集并行重排的目的是提高单个线程下CPU读/写指令的执行性能。
    前面已经提过,CPU和内存的交互主要通过CPU总线进行。总线根据不同的传送内容,分为数据总线和地址总线,数据总线中传送的内容包括数据本身和操作指令(此处不对冯·诺依曼架构做更深层次的区分)。控制指令在总线中进行传输时,会有一个指令周期的概念。如果在一个指令周期内,CPU总线发现内存地址不可操作,就会将该指令存入指令序列中,并且尝试执行和前置指令不存在因果关系的后续指令,这个过程称为指令集并行重排。

指令集并行重排是CPU体系提供的一种提高执行性能的方法,它可以在单线程场景中提高CPU对指令的执行速度。
最典型的重排效果是,某个线程要求CPU执行的两条不相关的指令的最终执行顺序不一样,如图所示。
在这里插入图片描述
指令集并行重排遵循as-if-serial原则(无论怎么重排,单个线程中程序的执行结果都不能改变,可见asif-serial原则并不是Java独有的原则)。
指令集并行重排确实能够提高CPU指令的执行效率,并且通常不会产生问题(因为在通常情况下,线程都是独立运行且不存在共享数据交互问题的)。
如果两个或多个线程存在共享变量,并且这些共享变量的交互在单个线程中没有因果关系,那么在重排后会影响输出结果。考虑以下伪代码的输出可能性。
在这里插入图片描述
在线程1中,a变量和x变量的写操作并没有因果关系,并且没有任何线程安全机制的限制,所以CPU0在执行时会认为这两个操作指令可以进行重排;在线程2中,b变量和y变量的写操作并没有因果关系,并且同样没有任何线程安全机制的限制,所以CPU1在执行时会认为这两个操作执行也可以进行重排。
因此,在执行时,“x=b”可能优先于“a=1”被执行,“y=a”可能优先于“b=1”被执行,在最后的执行结果中,x变量和y变量的值都可能为0。

  • 编译器优化重排
    这里提到的编译器优化重排,并不是将Java源码编译成class字节码文件的编译器。
    事实上,将Java源码编译成class字节码文件的过程并不会进行任何指令重排操作。
    这里提到的编译器重排是指,JVM内置的JIT(即时编译器)在装载class字节码文件时的执行优化过程。
    JIT编译器内部会经过一个复杂的过程,对源码执行顺序进行优化。在优化过程中,除了需要遵循as-if-serial原则,还需要遵循JRS-133提出的happen-before原则(前面已做概述),happen-before原则既包括单一线程需要遵循的原则,又包括多线程需要遵循的原则。

  • 重排的相关注意事项。
    除了上面介绍的编译器优化重排和指令集并行重排,还有内存重排,但内存重排并不是真正的重排,而是由前面提到的CPU-内存交互原理引起的数据不同步的情况(主要由存储缓存和失效队列共同作用形成)。
    要彻底解决高并发场景中的数据一致性问题,除了要解决存储缓存和失效队列引起的数据一致性问题,还要解决由指令重排引起的执行结果一致性问题。解决由指令重排引起的执行结果一致性问题的有效方法是禁止指令重排,但是又不能全面禁止指令重排,那样会导致执行性能严重下降。此外,如果全面禁止指令重排,那么由于控制粒度过于粗放,会导致对最终执行结果没有影响的非关键数据也受到不必要的影响。所以禁止指令重排应该是分场景的细粒度控制方式。

3.4.3.7 基本内存屏障与禁止指令重排

基础内存屏障可以同时在编译器级别和操作系统级别禁止特定场景中的指令重排。

4种基本内存屏障代表可禁止的4种指令重排场景。这4种内存屏障对应的指令重排特征如下。

  • StoreStore Barrier(Store Memory Barrier)
    该内存屏障可以保证,在内存屏障前的任意写操作不会被重排到该内存屏障后的任意写操作的后面;
    在内存屏障后的任意写操作不会被重排到该内存屏障前的任意写操作的前面。
  • LoadLoad Barrier(Load Memory Barrier)
    该内存屏障可以保证,在内存屏障前的任意读操作不会被重排到该内存屏障后的任意读操作的后面;
    在内存屏障后的任意读操作不会被重排到该内存屏障前的任意读操作的前面。
  • LoadStore Barrier
    该内存屏障对存储缓存和失效队列的操作效果和LoadLoad Barrier的效果类似,但对指令重排层面上的效果是不一样的。
    该内存屏障可以保证,在内存屏障前的任意读操作不会被重排到该内存屏障后的任意写操作的后面;
    保证在内存屏障后的任意写操作不会被重排到该内存屏障前的任意读操作的前面。
  • StoreLoad Barrier(Full Barrier)
    该内存屏障又称为通用屏障,其禁止重排的效果是,可以保证在内存屏障前的任意写操作不会被重排到该内存屏障后的任意读操作的后面;该内存屏障后的任意读操作不会被重排到该内存屏障前的任意写操作的前面。
3.4.3.8 Java提供的内存栅栏

从JDK9开始,Java通过SDK将基本内存屏障指令(组合)完全开放给程序员,称为内存栅栏(Fence)。
从使用的角度来看,JVM层面一共有5种内存栅栏供给程序员使用,具体如下。

  • 存储栅栏(storeStore Fence,VarHandle.storeStoreFence())
    存储栅栏在编译器中的禁止重排效果和Store Memory Barrier基本内存屏障的禁止重排效果一致,即对内存屏障前的任意写操作和当前内存屏障后的任意写操作禁止重排。
  • 加载栅栏(loadLoad Fence,VarHandle.loadLoadFence())
    加载栅栏在编译器中的禁止重排效果和Load Memory Barrier基本内存屏障的禁止重排效果一致,即对内存屏障前的任意读操作和当前内存屏障后的任意读操作禁止重排。
  • 获取栅栏(acquire Fence,VarHandle.acquireFence())
    该栅栏的禁止重排效果是LoadLoad Barrier+LoadStore Barrier的禁止重排效果的组合。它可以对内存屏障后的任意读/写操作和当前内存屏障前的任意读操作禁止重排。
  • 释放栅栏(release Fence,VarHandle.releaseFence())
    该栅栏的禁止重排效果是StoreStore Barrier+LoadStore Barrier的禁止重排效果组合。它可以对内存屏障前的任意读/写操作和当前内存屏障后的任意写操作禁止重排。
  • 全栅栏(full Fence,VarHandle.fulFence())
    该栅栏是存储栅栏(storeStore Fence)+加载栅栏(loadLoad Fence)+通用屏障(StoreLoad Barrier)的禁止重排效果组合,它可以对内存屏障前的任意读/写操作和当前内存屏障后的任意读/写操作禁止重排。

注意:这5种由JM封装或组合后提供的内存栅栏,在Unsafe工具类或VarHandle变量句柄类中都有所体现,读者可以尝试在相关类中寻找这些方法。而后续提到的屏障操作效果,都是指在JM层面上封装实现的重排效果和可见性效果。

3.4.3.9 volatile修饰符和内存屏障

内存屏障具有哪些工作特点:
在这里插入图片描述
volatile修饰符为了达到自己的语义目标,需要对内存屏障的工作特征进行组合,分别在volatile变量的读操作场景中和写操作场景中完成保证可见性和有序性的任务。

3.4.3.10 volatile写操作过程总结

JVM在对volatile共享变量进行写操作后,会要求JMM先将该线程工作区中的数据刷新到主存。
其主要原理是插入相应的屏障指令,即在硬件层面上保证数据的强一致性。
此外,操作系统在发现内存屏障指令后,会从硬件执行层面上禁止指令重排,即禁止指令集并行重排。
最后编译器在发现内存屏障后,会按照内存屏障的要求,禁止编译器优化指令重排。

在对volatile变量进行写操作前,JVM会插入释放栅栏(Release Fence),注意释放栅栏禁止指令重排的意义和可见性操作的意义。由于释放栅栏是两个内存屏障的组合,因此它可以保证在内存屏障前的任意读/写操作不会被重排到内存屏障后的写操作后,也可以保证在正式进行volatile写操作前,所有变量的写操作对其他线程可见(因为冲刷了存储缓存)。这样,其他读操作线程就不需要担心在感知volatile变量发生变化后,在读取其他任意数据时会出现读不到最新值的情况。
在对volatile变量进行写操作后,VM会插入StoreLoad Barrier,该内存屏障对存储缓存(Store Bufferes)和失效队列(Invalid Queue)的冲刷操作,可以保证已完成的volatile写操作的结果立即被其他线程可见,如图所示。
在这里插入图片描述
为什么在进行volatile写操作前插入的StoreStore Barrier在进行存储缓存的冲刷后,只可以达到“自认为”对其他线程可见的效果呢?
这是因为存储缓存中的数据是否完成了提交,主要根据其他高速缓存行是否提交了I状态,而其他高速缓存行在标记I状态时,可能只是在失效队列中记录了标记任务,而没有进行真实更新。

3.4.3.11 volatile读操作过程总结

在对volatile共享变量进行读操作前,JVM会先插入LoadLoad Barrier,该内存屏障可以在硬件层面和编译器层面上禁止对之前的读操作和之后的读操作进行指令重排。
此外,对该指令所代表的失效队列(Invalid Queue)的冲刷操作,其目的是保证后续发生的读操作取得的数据一定和主存中的数据一致。

这个过程刚好和volatile写操作线程的处理过程相对应。前面已经讲过,volatile写操作会插入StoreLoad Barrier,用于保证volatile写操作的数据对其他线程可见。但这种可见只是写操作线程“自认为”的,因为该数据的I状态更新任务可能只存在于读操作线程的失效队列中。所以在进行volatile写操作前,插入的LoadLoad Barrier会帮助冲刷当前读操作线程的失效队列,以便读操作线程真正看到和主存中数据一致的数据。

JVM还会在进行volatile读操作后插入获取栅栏(Acquire Fence),该获取栅栏的工作效果是LoadLoad Barrier+LoadStore Barrier组合的工作效果,可以保证在内存屏障后的任何读/写操作都不会被重排到内存屏障前的读操作前,从而保证volatile读操作一定先读取volatile变量的最新值,再进行后续的读/写操作。此外,获取栅栏对失效队列的冲刷效果可以保证后续的读/写操作一定是基于主存中的最新数据(保证可见性)进行的,如图所示。
在这里插入图片描述
在进行volatile读操作后插入获取栅栏(acquire Fence)的过程,刚好和在进行volatile写操作前插入释放栅栏(release Fence)的过程相对应。它们的配对使用,可以保证volatile变量附近的普通共享变量在多个线程间的可见性和相对的执行有序性,对应关系如图所示。
在这里插入图片描述
图中的volatile变量是关键共享变量FLAG。
基于CPU0运行的线程对FLAG变量进行写操作(简称FLAG写操作)
基于CPu1运行的线程对FLAG变量进行读操作(简称FLAG读操作)。
需要注意的是,获取栅栏一定是配合volatile读操作使用的,释放栅栏一定是配合volatile写操作使用的。

3.4.4 轻量化的原子性操作方法

原子性是指一行代码或一段代码是不可分割执行的整体,要么一次性执行完,要么都不执行。

JUC提供了一种保证执行过程原子性的操作方法,它主要使用乐观锁(无锁)的思想保证操作的原子性——使用Compare And Swap/Compare And Set无锁操作。该方法的底层实现主要借助了unsafe工具类。需要注意的是,无锁并不代表没有CPU性能损耗,实际上在JVM层面上,无锁操作调用了操作系统提供的额外CPU指令。

3.4.4.1 AtomicInteger

在JUC中,有一个原子性操作工具类AtomicInteger,它易于理解和使用,是程序员最常使用的原子性操作工具类之一。
该工具类可以完成一个整数值的原子性操作,包括对整数值的获取、增加、减少等操作。
相关示例代码如下
在这里插入图片描述
对于一个整数i,在没有任何线程安全性保证的前提下,是无法保证类似于“i++”这种操作的原子性的,即使使用volatile修饰符也不行(只可以保证可见性和有序性)。

因为容易导致多个线程在同时进行计+操作后,得到的最终结果和预想的结果不一样。

在上述场景中,要保证操作的原子性,程序员可以使用AtomicInteger等原子性操作工具类,保证“i++”等操作的原子性,最终在多线程同时操作的场景中获得正确的结果。

3.4.4.2 AtomicStampedReference类

CAS原子性操作并非完全没有问题,A-B-A问题就是CAS思想落地过程中需要避免的一种问题。
A-B-A问题是指一个线程X在使用CAS技术更新数据时,将数据从A值换成了B值,然后在下一次CAS操作重新将数值替换回A值。
这时在另一个线程Y看来,数值是没有变化的。线程Y会认为自己的CAS过程判定成功,于是会将数值替换为C。
实际上,当线程X在进行第一次A-B的CAS过程时,已经完成了相关的业务操作,最终导致这两个或多个线程的关联操作出现问题。

一个典型问题是资金使用问题,X线程负责借款业务,它在发现资金池数值匹配后,会将款项借出并开始计息。在资金被使用一段时间后,本金会重新返回资金池中。这时由于没有操作上下文的状态记录,因此如果将资金数值作为“是否已计息”的唯一依据,那么后续业务不可能清楚资金被借出了多少次、产生了多少利息。

要解决CAS中的A-B-A问题,最简单的方法是加入一个版本号,将版本号的值作为操作的上下文,从而保证任何对数值进行CAS操作的线程都可以知晓“当前的A值是否就是自己想要的A值”。
JUC中的AtomicStampedReference原子性操作工具类可以提供这样的功能,示例代码如下。

/**
 * @author wql
 * @date 2023/5/30 22:51
 */
public class Test01 {
    public static void main(String[] args) {

        //第一个参数是用于确认CAS原子性操作的对象,第二个参数是用于确认原子性操作工具类的对象的初始化版本号
        AtomicStampedReference<Float> stampedReference = new AtomicStampedReference<>(10.0f, 1);

        //获取当前的版本号(戳)
        int stamp = stampedReference.getStamp();
        //获取当前要进行CAS 原子性操作的对象
        Float reference = stampedReference.getReference();

        //进行第一次CAS操作
        //第一个参数是: 表示当前要判定的值
        //第二个参数是: 如果版本号和数值都对比成功,那么替换成新的值
        //第三个参数是: 表示当前要判定的版本号(戳)
        //第四个参数是: 如果版本号和数值都对比成功,那么替换成新的版本号(戳)
        boolean isTrue = stampedReference.compareAndSet(reference, 1.0f, stamp, stamp + 1);

    }
}

  • 第1个参数:表示参照进行对比的对象引用,主要用于判断该对象引用是否和当前AtomicStampedReference类的引用对象相同。
  • 第2个参数:如果版本号(戳)和引用对象相同,则将引用对象替换成一个新的对象。
  • 第3个参数:表示参照进行对比的版本号,主要用于判断该版本号是否和当前AtomicStampedReference类的对象值是否相同。
  • 第4个参数:如果版本号(戳)和引用对象相同,则将版本号替换为新的版本号。

基于以上说明,以下示例代码是一个匹配错误的场景。

3.4.5 另一种管程实现—AQS技术

操作系统允许高级编程语言自行实现线程互斥和同步方案。

Java通过管程的方式自行解决线程互斥和同步的问题

  • Object Monitor模式就是一种管程的实现。
  • Java中另一种管程的实现是AQS(Abstract Queued Synchronizer,抽象队列同步器)技术。

这两种管程实现有很多不同之处。

例如,二者的实现原理是不同的,但是驱使Java提供两种管程控制的原因,一定不只是从技术层面产生的,应该从使用场景的角度去思考。

  • Object Monitor模式实现的管程是Java内置的一种控制模式,它处于JVM层面,程序员只能按照特定的方式使用它,程序员不能根据自己的业务形态基于管程原理扩展新的功能。
  • 但是使用AQS技术实现的管程处于SDK层面,程序员可以在了解AQS原理后,基于这种管程的控制思路,对控制功能进行扩展,从而实现自身业务所需的控制功能。

在这里插入图片描述

3.4.5.1 AQS关键定义

AQS内部工作结构的基本组织方式如图所示。
在这里插入图片描述
AQS本质上是一种管程实现,其内部有一个双端队列,称为CLH 队列(CLH队列以其三位主要提出人Craig、Landin、Hagersten命名)

  • 这个队列的主要特点是,利用双向链表的操作特点(FIFO,先入先出)保证资源操作权分配的公平性
  • 利用双向链表的结构特点保证各节点间的操作状态可知
  • 利用Node节点所代表的线程的自旋(由Thread.onSpinWait()方法提供操作基础)操作方式提高资源操作权获取性能。

AQS中实现的CLH队列有以下几个工作特点。

  • 所有状态正常的节点只能由队列头部离开队列。
  • 但是当CLH队列主动清理状态不正常的节点(CANCELLED状态的节点)时,这些状态不正常的节点不一定从队列头部离开队列。
  • CLH队列只能从尾部添加节点,由于存在多个线程同时要求添加节点的场景,因此CLH队列要解决的一个关键问题是如何从尾部正确添加节点。
  • CLH队列在初始化完成的瞬间,其头节点引用head和尾节点引用tail指向同一个节点,这时这个节点没有任何实际意义。也就是说,真正被阻塞等待获取资源操作权的节点,至少是从CLH队列的第二个节点开始的,而不是头节点开始的。头节点引用head要么代表当前已经获取资源操作权的节点,要么没有实际意义。
  • CLH队列中的节点一共有两种工作模式,分别为独占模式(Exclusive)共享模式(Share)
    • 独占模式是指CLH队列中能够在同一时间获取资源操作权的申请者最多只有一个
      如果CLH队列以独占模式工作,那么队列中的Node节点为ExclusiveNode节点;
    • 共享模式是指CLH队列中能够在同一时间获取资源操作权的申请者可以有多个
      如果CLH队列以共享模式工作,那么队列中的Node节点为SharedNode节点。
  • ·在申请资源操作权时,只有没有立即申请到资源操作权的线程,才会有对应的Node节点进入CLH队列,立即申请到资源操作权的线程,不会有对应的Node节点进入CLH队列,甚至在申请过程中都不会产生对应的Node节点(没有申请到资源操作权的线程,会先进行自旋操作,再进行CLH队列的添加操作)。

部分源码片段


public abstract class AbstractQueuedSynchronizer
        extends AbstractOwnableSynchronizer
        implements java.io.Serializable {

    private static final long serialVersionUID = 7373984972572414691L;

    protected AbstractQueuedSynchronizer() { }

    private transient volatile Node head;

    private transient volatile Node tail;

    /**
     * The synchronization state.
     */
    private volatile int state;
...}

上面已经提到,AQS包括两种工作模式:独占模式(Exclusive)和共享模式(Share)。
在这两种模式下AQS具有不同的状态,不同状态对应着不同的state属性值。

  • 需要注意的是,虽然state是AQS工作状态的重要表达,但实际上state的具体值并没有一个统一的业务意义,这是AQS抽象特征的一种表现。

  • AQS将state属性值的业务意义交给了下层具体队列同步器进行定义。但在大部分情况下,state属性的值具有以下约定俗成的意义。

    • state==0,表示AQS并没有被任何线程独占操作权,当然也不排除其他意义。
    • state==1,表示AQS可能已经成功帮助某个(至少一个)线程获取了资源的独占操作权,当然也不排除其他意义。
  • 这样的描述可能有一些难以理解,下面举例说明。

    • CountDownLatch是一种常用的信号量控制器,它是基于AQS实现的,其内部的Sync类继承了AbstractQueuedSynchronizer类。
      • CountDownLatch会在初始化时将state属性设置为一个正数值,表示属性倒数计数的开始值。
      • 每有一个线程进行倒数计数,state属性的值都会减1(由于使用了state属性值的原子性操作支持,因此不用担心state属性的值会因为非原子性操作而产生错误)。
    • Semaphore也是一种常用的信号量控制器(使用AQS模拟操作系统提供的信号量功能),其内部的Sync类也继承了AbstractQueuedSynchronizer类。
      • Semaphore会在初始化时将state属性设置为一个整数值,代表可分配的证书总数量。
      • 当一个线程获取了一个或多个证书时,state属性的值会减去一个相应的数值(在保证原子性的前提下)。
3.4.5.2 AQS中的关键方法
  • 需要选择性覆盖的方法。

    • AQS之所以称为抽象队列同步器,是因为程序员需要基于AQS的基本工作思路进行继承,并且实现自己的操作逻辑。
    • 所以AQS的子类可以根据自己的操作逻辑,选择覆盖AQS中的一些关键方法,从而达到自己对AQS的操作要求
    • 这些关键方法如下。
      • protected boolean tryAcquire(int)
        在独占模式下,当线程尝试获取AQS的独占操作权时,该方法被触发。
        • 如果程序员确认当前线程获取了AQS独占操作权,则返回true,否则返回false。
      • protected boolean tryRelease(int)
        在独占模式下,当线程尝试释放AQS的独占操作权时,该方法被触发。
        • 如果程序员确认当前线程释放了AQS的独占操作权,则返回true,否则返回false。
        • 如果AQS场景没有涉及独占模式,则无须覆盖tryAcquire()方法和tryRelease()方法。
      • protected int tryAcquireShared(int)
        在共享模式下,当线程尝试获取AQS的共享操作权时,该方法被触发。
        • 如果程序员确认当前线程获取了AQS的共享操作权,并且后续请求AQS共享操作权的线程可以继续尝试获得AQS的共享操作权,则返回一个正整数;
        • 如果程序员确认当前线程获取了AQS的共享操作权,但不允许后续请求AQS共享操作权的线程继续尝试获取AQS的共享操作权,则返回0;
        • 如果是其他情况,则返回一个负数(一般为-1)。
      • protected boolean tryReleaseShared(int)
        在共享模式下,当线程尝试释放AQS的共享操作权时,该方法被触发。
        • 如果程序员确认当前线程成功释放了AQS的共享操作权,则返回true,否则返回false。
        • 如果AQS场景没有涉及共享模式,则无须覆盖tryAcquireShared()方法和tryReleaseShared()方法。
      • protected boolean isHeldExclusively()
        当AQS需要检查当前线程是否在独占模式下拥有AQS的独占操作权时,该方法被触发。
        • 如果程序员确认当前线程在独占模式下拥有AQS的独占操作权,则返回true,否则返回false。
        • 需要注意的是,该方法只有在Condition控制的实现逻辑(ConditionObject)中才会被AQS调用,如果读者根据业务实现的AQS逻辑无须进行Condition控制,则不用覆盖该方法。
  • 需要重点理解功能意义的方法。
    除了以上可以由AQS工作逻辑触发、由程序员根据自己的业务逻辑选择性覆盖的方法

    • getState()方法、setState()方法和compareAndSetState()方法

      • getState()方法主要用于获取当前AQS队列的state属性值;
      • setState()方法主要用于为AQS队列设置新的state属性值。
        由于AQS中的state变量为volatile变量,因此可以保证操作的可见性和有序性;
      • compareAndSetState()方法采用CAS技术,在保证了操作原子性的前提下,可以在对比成功后为state属性设置新值。
        • 例如,compareAndSetState(0,1)表示如果当前AQS队列的state属性值为0,则设置state属性值为1并返回true。
    • acquire(int)方法和acquireInterruptibly(int)方法

      • acquire(int)方法主要用于在独占模式下尝试获取锁并忽略线程的interrupt中断信号。
        • 如果没有获取锁,那么当前线程会在AQS队列中排队,并且当前试图获取AQS独占操作权的线程会继续阻塞(parking)或中断阻塞。
        • acquireInterruptibly(int)方法和sync.acquire(int)方法类似,只是前者会检查当前操作线程的interupt中断信号。
    • acquireShared(int)方法和acquireSharedInterruptibly(int)方法

      • acquireShared(int)方法主要用于尝试在共享模式下获取AQS的共享操作权并忽略线程的interrupt中断信号。
        • 如果没有获取AQS的共享操作权,那么当前线程会在AQS队列中排队,并且当前试图获取AQS共享操作权的线程会继续阻塞(parking)或中断阻塞。
        • acquireSharedInteruptibly(int)方法和acquireShared(int)方法类似,只是前者会检查当前操作线程的interrupt中断信号。
        • 在以上方法中,传入的int类型的参数值都会传入开发者可能已经重写的tryAcquire()、tryAcquireShared()方法中。
    • release(int)releaseShared(int)方法

      • release(int)方法主要用于在独占模式下释放AQS的独占操作权。
      • releaseShared(int)主要用于在共享模式下释放AQS的共享操作权。
      • AQS存在两种工作模式,即独占(Exclusive)模式和共享(Share)模式。
      • 为了防止最终调用者胡乱进行过程的调用,使内部工作原理对最终调用者保持透明,基于AQS实现具体管控逻辑的程序员需要对实现逻辑进行封装,然后以一种最终调用者能够理解的业务语言向后者描述服务方法。
      • 类似的设计效果可以参考CountDownLatch、ReentrantLock、Semaphore、ThreadpooExecutor等基于AQS工作的工具类。
3.4.5.3 AQS实现—ReentrantLock类

ReentrantLock(可重入锁)类是一种常用的基于AQS技术的工具类,在工作中,它可以满足多线程控制场景中的大部分控制需求。
或者说,ReentrantLock类的功能是基于AQS技术对Java中另一种管理Object Monitor的工作效果进行模拟。

  • ReentrantLock类使用AQS技术中的独占模式进行工作
    • lock()方法表示当前线程向ReentrantLock对象申请资源独占操作权
      • 如果申请成功,那么当前线程继续执行;
      • 如果申请失败,那么当前线程阻塞等待。
    • 当前获得资源独占操作权的线程调用lock()方法或类似方法正确加锁的次数会被记录下来,当这个线程需要释放资源独占操作权时,必须调用相同次数的unlock()方法,否则不认为释放了资源独占操作权,后续等待资源独占操作权的其他线程也不会退出阻塞状态;
    • ReentrantLock类中提供的getHoldCount()方法,主要用于返回当前拥有资源独占操作权的线程成功加锁的次数(实际上是一个计数器),从而帮助程序员调用相同次数的unlock()方法。
    • 可重入的意思可以理解为,ReentrantLock类会用计数器记录当前获得资源独占操作权的线程进行了多少次加锁操作。理论上只要线程获取了资源独占操作权,就可以进行不限次数的加锁操作(上限是int类型数据的数值上限)。
    • 根据后续的源码分析可知,ReentrantLock类在基于AQS技术工作时,AQS队列中的state属性起到了计数器的作用。
    • ReentrantLock类中的“资源”,并不是类似于Object Monitor模式中使用synchronized修饰符修饰的任意对象,单纯是指ReentrantLock对象本身。
    • ReentrantLock类提供了两种工作模式,分别为公平模式和非公平模式
      • 公平模式:申请资源独占操作权的多个线程,会按照申请操作的顺序进行排队,越先申请的一定越先获得资源独占操作权。
      • 非公平模式(默认):申请资源独占操作权的多个线程,虽然可以按照申请操作顺序进行排队,但并不保证它们获取资源独占操作权的顺序,出于对速度优先的考虑,后申请资源独占操作权的线程有可能先获取资源独占操作权。可以使用ReentrantLock类的构造方法设置ReentrantLock对象的工作模式。
3.4.5.3.1 AQS 如何帮助ReentrantLock工作的?

在这里插入图片描述
图展示了ReentrantLock类内部与AQS的协作关系。

  • ReentrantLock类中有一个Sync子类,后者继承了AQS类,大部分需要由ReentrantLock类的设计者完成的工作逻辑都在Sync子类中进行了编写;
  • 但由于Reen-trantLock对象本来具备两种工作模式,因此两种工作模式的差异逻辑分别由ReentrantLock.NonfairSync子类和ReentrantLock.FairSync子类完成。
  • 实际上,由于ReentrantLock类采用的是AQS技术中的独占模式,并且提供了Condition控制逻辑
  • 因此只需实现AQS中的3个关键方法:tryAcquire()方法、tryRelease()方法和isHeldExclusively()方法。
3.4.5.3.1.1 ReentrantLock的Lock方法
  • 线程通过调用ReentrantLock.lock()方法获取资源的独占操作权;
  • ReentrantLock类在不同的工作模式下,lock()方法的实现逻辑不同,但逻辑步骤是一样的。
    • 首先使用initialTryLock()方法判断当前线程的独占操作权请求,是否无须AQS干预就可以获取成功。
      • 如果不能获取成功,则调用AQS提供的acquire(int)方法,利用CLH队列进行获取。
      • lock()方法的具体实现逻辑在ReentrantLock.Sync子类中,源码如下。
        在这里插入图片描述
3.4.5.3.1.2 ReentrantLock的unLock方法

unlock()方法调用了AQS提供的release(int)方法,可以进行独占操作权的释放操作。

  • 在release(int)方法的执行逻辑中,需要程序员自行实现tryRelease(int)方法,以便告诉AQS,程序员是否认可让当前线程释放资源独占操作权。
  • ReentrantLock.Sync子类实现的tryRelease(int)方法的本质逻辑是,将state属性作为计数器进行减法运算。
  • 只有当state属性值减为0时,才认为资源独占操作权释放成功。tryRelease(int)方法的源码如下。
    在这里插入图片描述
    根据上述源码可知,为什么前面一直强调在一个线程通过ReentrantLock对象获得资源独占操作权后,有多少次成功的加锁次数,就需要有多少次unlock释放动作:只有当计数器的值为0时,才认为当前线程释放资源独占操作权成功。而AQS中的state属性就充当了这个计数器。
3.4.5.3.1.3 ReentrantLock的tryLock方法

ReentrantLock类提供的trylock()方法是我们在实际工作中经常使用的一个方法,它强制工作在非公平模式下。

  • 该方法的意义是立即尝试让当前线程获得资源独占操作权,无论当前CLH队列中是否有线程在排队。
  • 如果获取资源独占操作权成功或持有资源独占操作权的线程就是本线程,则返回true,否则返回false。

该工作逻辑和在非公平模式下initialTryLock()方法的工作逻辑相同。
只有在state值为0(没有任何线程拥有资源独占操作权)时才进行资源独占操作权抢占操作。
如果当前线程之前已经获得资源的独占操作权,则将AQS的state属性值加1。

3.4.5.3.2 AQS实现—Condition控制
  • 既然ReentrantLock类能够基于AQS技术模拟Object Monitor模式的外在功能,那么ReentrantLock类中应该有类似于Object Monitor模式中负责完成线程间同步功能的方法,如wait方法()、notify()方法等。
  • ReentrantLock类基于Condition控制向程序员提供如下控制功能:
    • 使用await()方法,使获得资源独占操作权的线程暂时释放资源独占操作权并进入阻塞状态,但并不放弃该线程重新抢占资源独占操作权的权力;而其他线程在获得资源独占操作权后,也可以向使用await()方法进入阻塞状态的线程发送signal信号,以便后者可以重新抢占资源独占操作权。
  • Condition控制在Java中,特别是在JUC中有很多应用场景,如ThreadPoolExecutor、PriorityBlockingQueue、DelayQueue等。
  • AQS是一个使用abstract修饰的类,不能直接被实例化。要使用AQS提供的Condition控制功能,必须使用一个具体的AQS实现类的对象。
    • 例如,使用ReentrantLock对象获得Condition控制对象,或者通过ReentrantReadWriteLock读/写分离的可重入锁对象获得Condition控制对象,不过读/写分离的可重入锁对象的读锁和写锁的Condition控制是相对独立的,从属于两个不同的AQS,这个需要读者注意区分。
    • Condition控制的功能很好理解
      • await()方法的工作效果与Object Monitor模式下的wait()方法的工作效果类似
      • signal()方法和signalAll()方法的工作效果与Object Monitor模式下的notify()方法和notifyAl()方法的工作效果类似。
      • await()方法同样存在多态表达,如可以传入阻塞的最长等待时间。
3.4.5.3.2.1 AQS实现— ReentrantLock类如何进行Condition控制

Condition控制的核心逻辑通过AQS中的ConditionObject类进行实现,并且使用一种相对独立的ConditionNode(控制节点)类进行描述。

ConditionNode类实现了Java Fork/Join框架中的ManagedBlocker接口。
在实现该接口后,可以将处理逻辑托管给一个Fork/Join线程池,或者在当前线程中形成一个固定的阻塞逻辑,这个逻辑由两个方法构成。

  • boolean isReleasable()方法
    • 该方法主要用于判定一个线程的业务状态是否“不需要阻塞”,如果不需要阻塞,则返回true,否则返回false。
  • boolean block()方法
    • 如果使用isReleasable()方法判定一个线程的业务状态需要阻塞(返回false),则使用该方法进行阻塞。
    • 在block()方法内部,程序员可能需要重复调用isReleasable()方法的判定逻辑。
    • block()方法会阻塞当前线程(通过parking方式),直到判定当前线程不需要继续阻塞了,会返回true。

此外,在ConditionNode类的定义中,读者还可以看到一个叫作nextWaiter的关键属性,该属性连接着相同的Condition控制中的另一个ConditionNode节点,这些节点被nextWaiter属性串联起来,形成一个单向链表。
然后使用ConditionObject类中的firstWaiter属性和lastWaiter属性分别记录这个单向链表的头节点和尾节点。
在这里插入图片描述

  • 通过nextWaiter属性组成的Condition单向链表和AQS内置的CLH队列相对独立。
  • 只要工作过程中没有发生特别的情况(如收到nterrupt中断信号),那么ConditionNode节点最终会进入CLH队列。
  • 每一个ConditionObject对象都能够独立管理这样一个Condition单向链表,也就是说,如果在一个AQS控制中创建多个ConditionObject对象,则表示有多个独立的Condition单向链表。
  • 如果非要用Object Monitor模式的工作结构进行类比的话,那么ReentrantLock类中的CLH队列相当于Object Monitor模式中Wait Set区域的已授权集合,由ConditionNode节点组成的Condition单向链表相当于Object Monitor模式中Wait Set区域的待授权集合。
  • 当使用await()方法阻塞某个线程时,与这个线程对应的ConditionNode节点会被按顺序加入Condition单向链表的尾部,成为新的lastwaiter引用节点;
  • 当使用signal()方法发出一个signal信号时,放在Condition单向链表头部的ConditionNode节点会最先获得这个signal信号,并且优先进入CLH队列。
3.4.5.3.2.2 await()方法及其多态表现
  • await()方法及其多态表现主要用于在一个线程已经获取资源独占操作权的情况下,释放资源独占操作权并进入阻塞状态,直到收到signal信号或收到interrupt中断信号(多种场景)或达到阻塞的最长等待时间,这时代表当前线程的ConditionNode节点会根据实际情况被放入CLH队列,重新参与资源独占操作权的抢占工作。

  • await()方法一共分为三个处理步骤。

    • (1)创建代表本次Condition控制的ConditionNode节点,并且将该节点放入独立的Condition单向链表中。在这个过程中,如果Condition单向链表没有初始化,则需要进行初始化。整个过程由enableWait()方法进行控制。
    • (2)该步是一个循环操作,如果这个ConditionNode节点没有进入CLH队列,则调用ForkJoinPool类提供的阻塞控制方法,使其保持阻塞状态。一旦代表当前Condition控制的node节点进入了CLH队列,或者失去了COND状态,则进入第3步。
    • (3)清理当前节点的状态,并且试图使用acquire()方法请求激活该节点。这三个处理步骤比较清楚,需要重点介绍的是enableWait()方法。enableWait()方法主要用于向Condition单向链表中放入ConditionNode节点,具体过程参见如下源码。
  • 关于enableWait(ConditionNode)方法,有以下几个细节需要注意。

    • enableWait(ConditionNode)方法内部无须使用过多的线程安全性方法或保证原子性操作的方法,因为只有工作在AQS独占模式下,当前拥有资源独占操作权的线程才能执行该方法中的主要逻辑。如果不满足上述条件的线程执行该方法,则会抛出IllegalMonitor-
      StateException异常。
    • 由于enableWait(ConditionNode)方法的主要作用是让代表当前线程的ConditionNode节点进入专门为Condition控制准备的单向链表,并且释放当前线程的独占操作权,因此该方法会调用由程序员覆盖的relea-se(int)方法。如果程序员认为当前线程不能释放独占操作权(release()方法返回false),则该方法同样会抛出IlegalMonitorStateException异常。
    • enableWait(ConditionNode)方法会为ConditionNode节点设置基本属性,其中包括通过代码“node.setStatusRelaxed(COND|WAITING)”对ConditionNode节点的status属性进行设置。前面已经讲过了,在经过这样的设置后,该节点表现为兼容两种状态(COND控制状态和WAITING阻塞状态),换句话说就是,当前ConditionNode节点的状态因为Condition控制而进入了阻塞 状态
3.4.5.3.2.3 signaI()方法和signaIAII()方法

在前面介绍Condition控制实例时已经提到了signal()方法和signa/All()方法的主要作用:

  • 在当前线程已经获得资源独占操作权的前提下,向因为Condition控制调用await()方法(或相似方法)而处于阻塞状态的线程发出signal信号,以便代表这些线程的ConditionNode节点可能重新参与独占操作权的抢占工作。
  • 从工作本质上来说,调用signal()方法和signalAll()方法的工作逻辑是将处于Condition单向链表中的一个或多个ConditionNode节点放入CLH队列。
  • 在发出signal信号的线程调用unlock()方法后,在awiat()方法中阻塞的线程会根据实际情况被激活,并且进入await()方法逻辑中。
  • 与await()方法相比,signal()方法和signalAll()方法的工作逻辑要简单很多,实际上二者都调用了一个doSignal()方法,只是传递的参数值不同

signal()方法和signalAl()方法的本质是将处于Condition单向链表中的ConditionNode节点放入CLH队列,这些节点通常在Condition单向链表中具有两种状态,分别为COND和WAITING,如果在放入CLH队列前,ConditionNode节点不再具有COND状态(通常是因为收到了interrupt中断信号),则放弃本次放入CLH队列的操作。最后,ConditionNode节点在放入的CLH队列中会以独占模式工作

3.4.5.3.2.3 Condition控制的工作总结

.Condition单向链表中的ConditionNode节点进入AQS管理的CLH队列的过程如图所示:
在这里插入图片描述

  • 在AQS技术下,一个ReentrantLock对象管控的Condition单向链表可以有多个,可以这样认为:ReentrantLock对象管控的每个ConditionObject对象都可以管理一个独立的Condition单向链表。
  • Condition单向链表中的ConditionNode节点通常具有两种状态,分别是COND和WAITING,这是一种可借鉴的编程技巧,即使用或运算实现状态叠加效果“node.setStatusRelaxed(COND|WAITING);"。
  • Condition控制可以保证资源的独占操作权,也就是说,如果某个线程因为await()方法(或类似方法)放弃了资源独占操作权,那么即使它在阻塞过程中收到了interrupt中断信号,也需要继续阻塞,直到当前线程重新获得资源独占操作权,才能继续运行。
  • 这个运行效果与Object Monitor模式下相同场景的运行效果一致。其工作本质也是将收到interrupt中断信号的线程所对应的ConditionNode节点放入CLH队列进行排队。
  • 对于Condition控制中的单向链表操作,无须做过多的CAS重试,因为对Condition控制进行操作的前提是当前线程已经获得了共享资源(如ReentrantLock对象)的独占操作权
3.4.5.3 AQS实现—ReentrantLock类

AQS是一种管程技术,可以和Object Monitor管程技术一样,解决前面提到的在高并发场景中应用程序所关注的问题。

3.4.5.3.1 AQS技术是Java实现的另一种管程技术
  • AQS技术虽然是一种悲观锁的工作效果,但其底层实现是基于乐观锁完成的。
    • AQS技术可以完整模拟另一种管程的外在功能(使用ReentrantLock类和Condition控制进行模拟),或者完整模拟操作系统级别提供的高并发场景支持功能,如信号量、互斥锁等(使用基于AQS技术实现的Semaphore工具进行信号量模拟,对于操作系统互斥锁Mutex的模拟,可以通过一个官方示例给出,这个官方示例可以在AbstractQueuedSynchronizer类的注释中找到)。
3.4.5.3.2 AQS技术弥补了Object Monitor管程技术的一些不足
  • 因为Java具有跨平台特性,具体来说,Java使用抽象的JMM屏蔽各种操作系统的处理细节,所以Java需要自行实现对高并发场景的支持。操作系统和硬件只为编程语言提供完成任务的基本要素支持,如内存屏障、原子性操作、线程控制、CPu指令集等。对高并发场景的支持,Java可以选择使用信号量实现,也可以选择使用管程实现,最终Java选择使用两种管程实现(Java选择管程而非信号量的原因可以参考第三方资料)。
  • Object Monitor管程技术在JVM层面进行实现和控制,程序员对其没有过多的干预权限,只能通过Object Monitor管程技术向SDK层提供的特定线程互斥与线程同步方法实现并发编程场景。例如,程序员不能决定在调用notify()方法或notifyAll()方法后,阻塞的线程会以什么顺序被激活,甚至不能控制哪个阻塞的线程会被激活。程序员也不能监控管程中的很多状态。例如,在Object Monitor模式下,程序员不能通过SDK很容易地获取当前阻塞等待资源独占操作权的线程数量。
  • 在JavaSDK层面上实现和控制的AQS管程技术,除了特定的工作逻辑无法改变外(如无法改变CLH队列的结构和状态意义),在此工作逻辑下的控制权和干预权会毫无保留地交给程序员。程序员使用AQS管程技术可以自行控制线程互斥的方式,可以自行决定哪个或哪些线程在什么样的业务场景中需要被激活或阻塞,这些线程的激活顺序是什么样的,可以轻松获取所有应暴露给程序员的监控信息,如可以轻松获取还有多少线程由于没有资源独占操作权而处于阻塞状态。
3.4.5.3.3 两种管程技术的性能差异
  • AQS技术底层使用基于CAS的乐观锁作为实现思想;Object Monitor模式的底层借助操作系统的互斥锁(Mutex)完成工作,但这只是在Object Monitor模式升级为重量级锁的情况下。
  • Object Monitor管程技术在经过JDK1.6、JDK1.8等多个版本优化后(加入了偏向锁、轻量级锁等设计思想),其性能和AQS管程技术差异不大(除非程序员使用错误)。在将要介绍的高并发集合框架中,基于Obje-
    ct Monitor管程技术的synchronized修饰符也有大量应用。但是AQS的控制性、干预性、可调整的控制粒度都要优于前者。因此,如何选择两种管程技术,主要取决于程序员对具体业务的高并发场景匹配要求。

3.5 List集合实现—CopyOnWriteArraylist

CopyOnWriteArrayList集合概述Copy On Write的字面意思是写时复制

  • 当进行指定数据的写操作时,为了不影响其他线程同时在进行的集合数据读操作,可以使用如下策略:
    • 在进行写操作前,首先复制一个数据副本,并且在数据副本中进行写操作;
    • 在副本中完成写操作后,将当前数据替换成副本数据。

很多软件在设计上都存在Copy On Write思想,被技术人员广泛熟知的是Redis中对Copy On Write思想的应用。
Redis为了保证其读操作性能,在周期性进行RDB(持久化)操作时使用了Copy On Write思想
由于RDB的操作时间主要取决于磁盘I/O性能,因此如果内存中需要进行持久化操作的数据量过大,就会产生较长的操作时间,从而影响Redis性能。改进办法是,在进行持久化操作前,先做一个当前数据的副本,并且根据副本内容进行持久化操作,从而使当前数据的状态被固定下来,并且不影响对原始数据的任何操作,如图所示。
在这里插入图片描述
Copy On Write思想在Java中的一种具体实现是CopyOnWriteArrayList集合,该集合在进行写操作时会创建一个内存副本,并且在副本中进行相关操作,最后使用副本内存空间替换真实的内存空间。

  • 但是创建副本内存空间是有性能消耗的,特别是当CopyOnWriteArrayList集合中的数据量较大时。
  • 因此CopyOnWriteArraylList集合适合用于读操作远远多于写操作,并且在使用时需要保证集合读操作性能的多线程场景。

下面详细介绍CopyOnWriteArrayList集合的内部结构和工作原理。

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;
    ...}

在以上源码中,CopyOnWriteArrayList集合中只有两个关键属性。

  • lock属性是前面已经介绍过的ReentrantLock对象,CopyOnWriteArrayList集合在高并发场景中,主要使用lock属性控制线程操作权限,从而保证集合中数据对象在多线程写操作场景中的数据正确性。
  • CopyOnWriteArrayList集合除了直接实现了java.util.List接口,还实现了java.util.RandomAccess接口。java.util.RandomAccess接口是一种标识接口,表示实现类在随机索引位上的数据读取性能不受存储的数据规模影响,即进行数据读操作的时间复杂度始终为O(1)。

因为CopyOnWriteArrayList集合在进行数据写操作时,会依靠一个副本进行操作,所以不支持必须对原始数据进行操作的功能
例如,不支持在迭代器上进行的数据对象更改操作(使用remove()方法、set)方法和add()方法),源码如下。
在这里插入图片描述

3.5.1 主要构造方法

一共有三个构造器方法

//默认的构造方法,当前array数组的集合容量为1,并且唯一的索引位上的数据对象为nul1
  public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }
    //该构造方法可以接受一个外部第三方集合,并且对其进行实例化
    //在进行实例化时,第三方集合中的数据对象会被复制(引用)到新创建的CopyOnwriteArrayList 集合的对象中
    public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] elements;
        if (c.getClass() == CopyOnWriteArrayList.class)
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elements.getClass() != Object[].class)
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        setArray(elements);
    }
    //该构造方法从外部接受一个数组,并且使用Arra ys.copyof()方法
    //形成CopyOnWriteArrayList 集合中基于a rray 数组存储的数据对象(引用)
    public CopyOnWriteArrayList(E[] toCopyIn) {
        setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
    }

3.5.2 主要方法

3.5.2.1 get(int)方法

get(int)方法主要用于从CopyOnWriteArrayList集合中获取指定索引位上的数据对象,该方法无须保证线程安全性,任何操作者、任何线程、任何时间点都可以使用该方法或类似方法获取CopyOnWriteArrayList集合中的数据对象,因为该集合中的所有写操作都在一个内存副本中进行,所以任何读操作都不会受影响,相关源码如下。
在这里插入图片描述
因为这种数据读取方式不需要考虑任何锁机制,并且数组支持随机位置上的读操作,所以其操作方法的时间复杂度在任何时候都为O(1),并且性能很好。

3.5.2.2 add(E)方法

add(E)方法主要用于向CopyOnWriteArraylList集合中数组的最后一个索引位上添加一个新的数据对象,添加的数据对象可以为null
源码片段如下。

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return {@code true} (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        //加锁
        lock.lock();
        try {
            //获取当前集合使用的数组对象
            Object[] elements = getArray();
            //获取当前集合的容量值
            int len = elements.length;
            //使用arrays.copy方法创建一个内存副本newElements
            //注意: 副本的容量比之前的容量大1
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //在newElements数组的最后一个索引位上添加这个数据对象(引用)
            newElements[len] = e;
            //最后将当前数组替换成副本数组,使用副本数组成为CopyOnWriteArrayList集合的内部数组
            setArray(newElements);
            return true;
        } finally {
            //释放锁
            lock.unlock();
        }
    }

注意:

  • 在add()方法所有处理逻辑开始前,先进行CopyOnlriteArrayList集合的操作权获取操作,它并不影响CopyOnWriteArrayList集合的读操作,因为根据前面介绍get()方法的源码可知,CopyOnWriteArrayList集合的读操作完全无视锁权限,也不会有多线程场景中的数据操作问题。
  • 类似于add)方法的写操作方法需要获取操作权的原因是,防止其他线程可能对CopyOnWriteArrayList集合同时进行写操作,从而造成数据错误。根据add(E)方法的详细描述可知,该集合通过Arays.copyOf()方法(其内部是System.arraycopy方法)创建一个新的内存空间,用于存储副本数组,并且在副本数组中进行写操作,最后将CopyOnWriteArrayList集合中的数组引用为副本数组,如图所示。
    在这里插入图片描述
3.5.2.3 set(int,E)方法

set(int,E)方法主要用于替换CopyOnWriteArrayList集合指定数组索引位上的数据对象。
该方法的操作过程和add()方法类似,相关源码如下。

 /**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    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();
        }
    }

如果查看源码,那么注意源码中的注释“Not quite a no-op;ensures volatile write semantics”,当指定数组索引位上的原始数据对象和要新替换的数据对象相同时,从理论上讲,不需要创建副本进行写操作,也不需要使用setArray()方法进行数组替换操作。
但根据以上源码可知,在以上场景中,源码仍然调用了setArray()方法进行数组的设置操作,为什么会这样呢?
这主要是为了保证外部调用者调用的非volatile修饰的变量遵循happen-before原则

3.5.2.4 java.util.Collections.synchronizedList() 方法的补充作用
  • CopyOnWriteArrayList集合工作机制的特点
    • 该集合适合应用于多线程并发操作场景中,如果读者使用集合的场景中不涉及多线程并发操作,那么不建议使用该集合,甚至不建议使用JUC中的任何集合,使用java.util包中符合使用场景的基本集合即可。
    • 该集合在多线程并发操作场景中,优先关注点集中在如何保证集合的线程安全性和集合的数据读操作性能。
    • 因此,该集合以显著牺牲自身的写操作性能和内存空间的方式换取读操作性能不受影响。
    • 这个特征很好理解,在每次进行读操作前,都要创建一个内存副本,这种操作一定会对内存空间造成浪费,并且内存空间复制操作一定会造成多余的性能消耗。·该集合适合应用于多线程并发操作、多线程读操作次数远远多于写操作次数、集合中存储的数据规模不大的场景中。
  • java.util.Collections是Java为开发人员提供的一个和集合操作有关的工具包(从JDK1.2开始提供,各版本进行了不同程度的功能调整),其中提供了一组方法,可以将java.utl包下的不支持线程安全性的集合转变为支持线程安全的集合。
    • 实际上是使用Object Monitor机制将集合方法进行了封装。
    • 在使用经过java.util.Collections工具包封装的集合时,需要特别注意:原始集合的迭代器(iterator)、可拆分的迭代器(spliterator)、处理流(stream)、并行流(parallelStream)的运行都不受这种封装机制的保护,如果用户需要使用集合中的这些方法,则必须自行控制这些方法的线程安全。

下面基于java.util.Collections.synchronizedList()方法,简述对集合操作的线程安全性进行封装的工作原理,源码如下。

 /**
     * @serial include
     */
    static class SynchronizedCollection<E> implements Collection<E>, Serializable {
        private static final long serialVersionUID = 3053995032091335093L;

        //被封装的真实集合,由该属性记录(引用)
        final Collection<E> c;  // Backing Collection
        //在整个集合线程安全性封装的机制中,使用对象管理Object Monitor模式下的锁机制
        final Object mutex;     // Object on which to synchronize

        SynchronizedCollection(Collection<E> c) {
            this.c = Objects.requireNonNull(c);
            mutex = this;
        }

        SynchronizedCollection(Collection<E> c, Object mutex) {
            this.c = Objects.requireNonNull(c);
            this.mutex = Objects.requireNonNull(mutex);
        }

        public int size() {
            synchronized (mutex) {return c.size();}
        }
        public boolean isEmpty() {
            synchronized (mutex) {return c.isEmpty();}
        }
        public boolean contains(Object o) {
            synchronized (mutex) {return c.contains(o);}
        }
        public Object[] toArray() {
            synchronized (mutex) {return c.toArray();}
        }
        public <T> T[] toArray(T[] a) {
            synchronized (mutex) {return c.toArray(a);}
        }

        public Iterator<E> iterator() {
            return c.iterator(); // Must be manually synched by user!
        }

        public boolean add(E e) {
            synchronized (mutex) {return c.add(e);}
        }
        public boolean remove(Object o) {
            synchronized (mutex) {return c.remove(o);}
        }
        ...}

3.6 Map集合实现—ConcurrentHashMap

3.6.1 概述

  • ConcurrentHashMap集合是在高并发场景中最常使用的集合之一,这种集合的内部结构已经介绍过的HashMap集合的内部结构一致,并且在此基础上增加了“桶锁”的概念,用于保证其在高并发场景中正常工作。
  • ConcurrentHashMap集合也是JUC中使用Object Monitor模式(管程)参与线程安全性管控的集合,这并不是出于对性能的考虑,而是因为ConcurrentHashMap集合中的“桶锁”更适合使用Object Monitor模式进行管控。
  • 在从JDK1.7到JDK1.8的升级过程中,ConcurrentHashMap集合的内部结构发生了一些重大变化,主要是取消了难以阅读理解和管理的Segments数组结构(这是进行独占操作权控制的最小单位),而改用了CAS+Object Monitor模式的设计思想
  • 在JDK1.8+中,基本上未对ConcurentHashMap集合的内部工作逻辑进行过多变动,表示CAS+Object Monitor模式的设计思想从结构特点、性能要求等多个方面,都得到了实际应用的验证。
  • ConcurrentHashMap集合的基本继承体系如图所示。就像上面提到的那样,ConcurrentHashMap集合的内部结构和HashMap集合的内部结构一致,也就是说,ConcurrentHashMap集合的内部结构也是数组+链表+红黑树的组合结构,并且在达到关键阈值后,链表和红黑树结构可以互相转换。
    在这里插入图片描述
    数组中的每一个有效数据对象都可以作为链表或红黑树的起始节点,将起始节点的数据结构称为一个桶结构
    一个桶中节点添加、节点修改、结构转换等大多数操作只能由获得这个桶的独占操作权的线程进行,如图所示。
    在这里插入图片描述
    上面提到了“大多数操作”,也就是说,并不是对桶结构的所有操作都需要获得桶的独占操作权,如查询数据对象总量(该操作的设计非常有趣,后文会进行详细讲解)、查询指定K-V键值对节点、对桶的头节点进行添加、协助扩容过程等操作。

3.6.2 ConcurentHashMap集合的主要属性

前面已经提到,ConcurentHashMap集合的工作理完全借鉴HashMap集合,所以ConcurrentHashMap集合中也包括描述红黑树转换、负载因子的常量属性,此外,由于ConcurrentHashMap集合需要基于CAS+Obect Monitor模式的设计思想保证高并发场景中的数安全性,因此它还具备任务所需的其他重要属性,源码如下。

   //如果集合结构正在扩容,并且当前桶已经完成了扩容操作中的桶数据对象迁移工作
    //那么头节点的Hash值为-1
    static final int MOVED     = -1; // hash for forwarding nodes
    //如果当前桶结构是红黑树结构,那么头节点的Hash值为-2
    static final int TREEBIN   = -2; // hash for roots of trees
    //在集合中还有一类节点专门用于进行“占位”操作,这类节点的Hash值为-3
    static final int RESERVED  = -3; // hash for transient reservations
    static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
  • 在ConcurrentHashMap集合的工作过程中,有几个属性会频繁使用,甚至会作为处理过程的主要逻辑依据,所以需要进行详细说明。
    • sizeCtl:多个正在同时操作ConcurrentHashMap集合的线程,会根据该属性值判断当前ConcurrentHashMap集合所处的状态,该属性值会在数组初始化、扩容等处理环节影响处理结果。
      • 0:表示当前集合的数组还没有初始化。
      • -1:表示当前集合正在被初始化。
      • 其他负数:表示当前集合正在进行扩容操作,并且这个负数的低16位可表示参与扩容操作的线程数量(减1)
      • 正整数:表示下次进行扩容操作的阈值(一旦达到这个阈值,就需要进行下一次扩容操作),并且当前集合并没有进行扩容操作
      • transferIndex:后面会进行介绍,ConcurrentHashMap集合的扩容操作基于CAS思想进行设计,并且充分利用了多线程的处理性能。也就是说,当某个线程发现ConcurrentHashMap集合正在进行扩容操作时,可能会参与扩容过程,帮助这个扩容过程尽快完成。扩容过程涉及现有的每个桶中数据对象迁移的问题,而该数值(加1)主要用于帮助这些线程共享下一个进行数据对象迁移操作的桶结构的索引位。
    • 以上源码片段中提到的每个桶结构头节点的Hash值(MOVED、TREEBIN、RESERVED)都为负数,表示特定处理场景中的节点类型,并且桶结构中没有存储真实的K-V键值对节点。而链表结构的头节点的Hash值为正常的Hash值,并且链表结构中存储了真实的K-V键值对节点。
    • baseCount、cellsBusy和counterCells:当前ConcurrentHashMap集合中K-V键值对节点的总数量并不是由一个单一的属性记录的,而是由3个属性配合记录的。如果集合的工作场景并发规模不大,则使用baseCount属性进行记录;如果并发规模较大,则使用counterCelIs数组进行记录。cellsBusy属性主要用于记录和控制counterCells数组的工作状态。

3.6.3 put方法

  • put()方法主要用于将一个不为null的K-V键值对节点添加到集合中,如果集合中已经存在相同的Key键信息,则进行Value值信息的替换。
  • put)方法内部实际上调用了一个putVal()方法,后者主要有3个传入参数,并且主要考虑两种添加场景:
    • 一种场景是添加某个数组索引位上的节点,即链表结构的根节点;
    • 另一种场景是在获得独占操作权的桶结构上添加新的红黑树节点或新的链表节点。

相关源码分析如下。

 public V put(K key, V value) {
        return putVal(key, value, false);
    }
   /*
    *该方法主要由put()方法和putIfAbsent()方法进行调用,是添加K-V键值对节点的主要方法
    * key:本次需要添加的K-V键值对节点的Key键信息,不能为null
    * value:本次需要添加的K-V键值对的Value值信息,不能为null
    * onlyIfAbsent:当发现Key 键信息已经存在时,是否要进行替换操作
    * 如果该值为false,则表示需要进行替换操作
    * */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;

        //整个添加过程基于CAS思路进行设计
        //在多线程并发场景中,在没有得到可预见的正确操作结果前,会不停重试
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //准备集合的内部工作结构,准备符合要求的数组索引位上的第一个Node节点
            //如果成立,说明ConcurrentHashMap集合的内部数组还没有准备好,那么首先初始化内部数组结构
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //通过以下代码,依据取余结果计算出当前K-V键值对节点应该放置于哪一个桶索引位上
            //如果这个索引位上还没有放置任何节点,则通过CAS操作,在该索引位上添加首个节点
            //如果节点添加成功,则认为完成了主要的节点添加过程,跳出for循环,不再重试
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                        new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //如果条件成立,则说明当前集合正在进行扩容操作,并且这个桶结构已经完成了数据对象迁移操作
            //但整个数据对象迁移过程还没有完成,所以本线程通过helpTransfer()方法加入扩容过程
            //从而帮助整个集合尽快完成所有的扩容操作
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                //在符合要求的数组索引位上已经具备第一个Node节点的前提下(在特定的桶结构中)
                //在使用Object Monitor模式保证当前线程得到第一个Node节点的独占操作权的前提下
                //进行链表结构或红黑树结构中新的K-V键值对节点的添加(或修改)操作
                V oldVal = null;
                //synchronized(f)就是对当前桶的操作进行加锁
                //通过获取桶结构中头节点的独占操作权的方式,获取整个桶结构的独占操作权
                synchronized (f) {
                    //如果条件不成立,则说明在本线程获得独占操作权前,该桶结构的头节点已经由其他线程添加完毕
                    //所以本次操作需要回到for循环的位置进行重试
                    if (tabAt(tab, i) == f) {
                        //如果满足条件,则说明以当前i号索引位上的节点为起始节点的桶结构是一个链表结构
                        //使用该if代码块的逻辑结构完成节点添加(或修改)操作
                        //该if代码块中的处理逻辑和HashMap集合中对应的处理逻辑一致,此处不再赘述
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                        ((ek = e.key) == key ||
                                                (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                            value, null);
                                    break;
                                }
                            }
                        }
                        //如果条件成立,则说明以当前i号索引位为起始节点的桶结构是一个红黑树结构
                        //使用该if代码块的逻辑结构完成节点添加(或修改)操作
                        //该if代码块中的处理逻辑和HashMap集合中对应的处理逻辑基本一致,此处不再赘述
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                    value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                //在完成节点添加操作后,如果链表结构中的数据对象数量已经满足链表结构向红黑树结构转换的要求
                //那么进行数据结构的转换(当然,在treeifyBin()方法内部还要进行合规判定)
                //注意:treeifyBin()方法中的转换过程同样需要获取当前桶的独占操作权
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        //增加集合当中的对象数量
        addCount(1L, binCount);
        return null;
    }

使用putVal()方法进行K-V键值对节点的添加操作,主要分为三步。

  • (1)定位验证初始化操作
    • 定位操作是依据当前K-V键值对节点中Key键信息的Hash值取余后(基于目前数组的长度通过与运算进行取余)的结果,确定当前K-V键值对节点在桶结构中的索引位。
    • 验证操作是保证当前集合结构和桶结构处于一个正确的状态,可以进行节点添加操作,如果在验证过程中发现集合正在进行扩容操作,则参与扩容操作(根据条件)。
    • 初始化操作是在集合数组没有初始化的情况下,首先完成集合数组的初始化。这一步主要基于CAS思想进行设计,如果没有达到工作目标,则进行重试。
  • (2)正式的K-V键值对节点添加操作。
    • 这个操作分为两个场景
      • 如果当前桶结构基于链表进行数据组织(判定依据是当前桶结构的头节点拥有一个“正常”的Hash值“fh〉=0”),那么将新的K-V键值对节点添加到链表的尾部;
      • 如果当前桶结构基于红黑树进行数据组织(判定依据是当前桶结构的头节点类型为TreeBin),那么使用putTreeVal()方法,在红黑树的适当位置添加新的K-V键值对节点。
        • 如果线程要进行第(2)步操作,则在Object Monitor模式下获得当前桶结构的独占操作权。
        • 获得桶结构独占操作权的依据是,获得当前桶结构的头节点的独占操作权。
  • (3)验证桶结构并伺机进行桶结构转换操作。
    • 该操作的判定依据和HashMap集合中相关工作的判定依据一致,即当前以链表结构组织的桶中的数据对象数量大于TREEIFY_THRESHOLD常量值,并且集合中的table数组长度大于MIN_TREEIFY_CAPACITY常量值(该判定在treeifyBin()方法中进行)。

第(3)步中所使用的treeifyBin()方法,其核心逻辑已经在介绍HashMap集合时进行了讲解,此处不再赘述。
不过要注意以下几个不同点。

  • 如果要在treeifyBin()方法中进行红黑树的转化工作,则必须获得当前桶结构的独占操作权,也就是说,对于同一个桶结构,要么进行节点添加操作,要么进行数据结构转换操作,不可能同时进行两个处理过程。
  • 和HashMap集合的数据处理过程不同的是,ConcurrentHashMap集合中某个桶结构上如果是红黑树,那么其头节点(红黑树根节点)的节点类型并不是TreeNode而是TreeBin,后者的Hash值减2

在成功完成节点添加操作后,最后需要进行数量计数器的增减操作,并且检查是否需要因为链表中数据对象过多而转换为红黑树,或者是否需要进行数组扩容操作和桶数据对象迁移操作。这些操作在treeifyBin()方法和addCount()方法中进行,并且这些方法在数组长度小于设置常量值的情况下(小于MIN_TREEIFY_CAPACITY的常量值64)优先进行数组扩容操作和桶数据对象迁移操作。

3.6.4 ConcurrentHashMap集合的扩容过程和协助扩容过程

虽然ConcurentHashMap集合在进行扩容操作时,对数组的扩容思路和对每一个桶结构中数据对象的迁移思路都和HashMap集合中相关功能的设计思路是一致的。

但ConcurrentHashMap集合工作在多线程并发场景中,所以ConcurrentHashMap集合在进行扩容操作时还需要考虑以下细节。

  • ConcurrentHashMap集合需要找到一种防止重复扩容的方法。这是因为在连续多次的节点添加操作过程中,很有可能出现两个或更多个线程同时认为ConcurrentHashMap集合需要扩容,最后造成ConcurrentHashMap集合重复进行同一次扩容操作多次的情况。
  • ConcurrentHashMap集合工作在多线程并发场景中,可以利用这个特点,在扩容过程中,特别是在扩容操作的数据对象迁移过程中,让多个线程同时协作,从而加快数据对象迁移过程。
  • ConcurrentHashMap集合在成功完成新K-V键值对节点的添加操作后,还会进行数量计数器的增加操作,但如果数量计数器只是一个单独的属性,那么势必导致多个同时完成节点添加操作的线程都在抢占这个计数器进行原子性操作,最终形成较明显的性能瓶颈。因此ConcurentHashMap集合需要找到一种方法,用于显著降低进行数量计数器操作时的性能瓶颈。

ConcurrentHashMap集合是如何设计扩容操作的呢?

  • 简单来说,就是
    • 使用CAS技术避免同一次扩容操作被重复执行多次
    • 采用数据标记的方式(sizeCtl)在各个参与操作的线程间同步集合状态
    • 同样通过数据标记的方式(transferIndex)指导各个参与线程协作完成集合扩容操作和数据对象迁移操作
    • 使用计数盒子(counterCells)解决计数器操作竞争的问题
3.6.4.1 ConcurrentHashMap集合中如何进行扩容操作

在什么场景中需要进行扩容操作呢?这在ConcurrentHashMap集合中的addCount()方法和treeifyBin()方法中可以找到答案
简单来说主要是两个场景:

  • 第一个场景是在某个桶结构满足红黑树转换的最小数量要求(TREEIFY_THRESHOLD),但是数组容量还没有达到最小容量要求(MIN_TREEIFY_CAPACITY)时,会优先进行扩容操作;
  • 第二个场景是在成功进行了一个新的K-V键值对节点的添加操作后,正在进行数量计数器的增加操作时,发现增加后的计数器值已经大于sizeCti属性的值。sizeCl属性的值最初可以是根据负载因子计算得到的值,也可以是上次扩容操作计算出的下次扩容的阈值。第二个场景更常见。

addCount相关源码
该方法主要是用于在增加数量计时器的值后,基于当前的数据对象数量确认是否需要进行扩容操作,其实核心的扩容操作还是transfer方法
在这里插入图片描述
根据上述源码可知,如果当前线程在容量检查阶段发现需要进行扩容操作,那么会面临两种情况。

  • 第一种情况是发现扩容操作正在进行,此时试图加入这个扩容过程;
  • 第二种情况是扩容操作还没有开始,此时由本线程开始这个操作,并且使用保证原子性操作的方法,将sizeCti变为一个负数。

最核心的扩容逻辑主要由transfer()方法进行控制。

  • transfer()方法主要使用CAS+Obect Monitor模式进行逻辑实现
    • 在确认当前线程负责的桶区域后,检查特定的桶是否完成了处理工作。
    • 在进行清退线程任务等操作时,主要使用CAS思想进行执行;
    • 而在正式进行某个桶结构的数据对象迁移操作时,主要使用Object Monitor模式工作——以当前线程是否获得了桶结构头节点的独占操作权来判断是否获得了当前桶的独占操作权。
  • transfer()方法的主要过程可以划分为四个处理步骤,其中第三步和第四步都放置于CAS重试循环中。
    第四步非常关键,是对指定的桶结构进行真实处理的过程。在完成了处理工作后,即可在集合原来的table数组中对应的索引位上使用ForwardingNode节点进行标识。具体步骤如图所示。
    在这里插入图片描述
  • transfer()方法的第四步有多个处理分支,其中处理分支4.4是在当前桶结构中存在数据对象的情况下,对桶结构中的数据对象正式进行迁移操作,迁移过程已经在介绍HashMap集合的章节中进行了详细说明,这里只需注意处理上的差异。在ConcurrentHashMap集合中,当原有table数组中指定桶索引位上的数据对象完成了向新数组的迁移、拆分操作后,需要将原来table数组中这个桶索引位上的节点类型更换为ForwardingNode,以便向其他试图操作该桶结构的线程表明,该桶已经被迁移,无法再进行任何读/写操作。桶结构中的数据对象迁移过程是一种链表结构中的数据对象迁移过程,如图所示。
    在这里插入图片描述
  • transfer()方法比较有特色的设计思想,就是可以使用多个线程进行同一个数据对象迁移操作,从而加快数据对象迁移过程。
  • 在设计transfer()方法时,DougLea和他的同事提出了桶区域的概念,也就是说,将单个线程负责的数据对象迁移任务确定为多个连续的桶结构(称为stride跨度,最小值为16,通过MIN_TRANSFER_STRIDE常量进行表达)。
  • 在进行桶数据对象迁移操作时,一个桶区域会被分配给一个线程进行处理,在将该桶区域中的数据对象全部迁移后,才会给这个线程分配另一个桶区域。
  • 集合中的transferIndex属性非常重要,它表示下一个桶区域的开始索引值(+1)。
  • 如果transferIndex属性的值在保证操作原子性的前提下被成功减少stride(跨度)的值,则说明一个桶区域被分配给了一个处理线程。
  • 如果transferIndex属性的值小于或等于0,则说明所有的桶区域都已经分配了处理线程并在处理过程中,或者已经处理完成,如图所示。
    在这里插入图片描述
  • stride跨度的值,会在transfer()方法的第一个处理步骤中得到确认,它和当前进程所运行的操作系统下的CPU核心数量有直接关系。
  • 计算公式为当前数组长度×8÷CPU核心数量。如果计算结果小于MIN_TRANSFER_STRIDE常量(值为16),则以后者为跨度标准。
  • 2)使用sizeCti属性巧妙地记录扩容过程。
    • 前面已经提到,sizeCtl属性的值有多种含义。
    • 例如,在ConcurrentHashMap集合还没有初始化时,sizeCtl属性的值为0;
    • 如果ConcurrentHashMap集合正在进行初始化,则sizeCtl属性的值为-1;
    • 如果ConcurrentHashMap集合没有进行任何扩容操作,那么sizeCtl属性的值为一个正数,表示下一次集合扩容所需要达到的K-V键值对节点总数量的阈值;
    • 如果ConcurrentHashMap集合正在进行扩容操作和数据对象迁移过程,那么sizeCtl属性的值为一个小于-1的负数,其高16位和低16位代表的意义是不一样的。
  • 当前正在对ConcurrentHashMap集合进行写操作的各个线程,是通过使用volatile修饰符修饰的、保证可见性和有序性的sizeCti属性进行ConcurrentlashMap集合状态的同步的。

3.6.5 高并发场景中的List、Map、Set集合说明

只介绍了Java中几种工作在高并发场景中的典型集合。事实上JUC中还有很多原生的List集合、Map集合和Set集合,下面对它们进行简要说明。

  • CopyOnWriteArrayList:该集合已经在本章中进行了讲解,此处不再赘述。
  • CopyOnWriteArraySet:该集合是一种Set集合,并且可以工作在高并发场景中。该集合实际上是对另一种集合的封装,不过并不是对某种Map集合的封装,而是对CopyOnWriteArrayList集合的封装,因为该集合需要满足CopyOnWrite工作要求。
  • ConcurrentHashMap:该集合已经在本章中进行了讲解,此处不再赘述。
  • WeakHashMap:如果读者对Java对象引用的高级知识有所了解,就会知道Java对象的引用类型一共有四种:强引用、软引用、弱引用和虚引用。而WeakHashMap集合是Java早期版本就原生提供的一种和弱引用配合使用的集合。其外在工作特性与HashMap集合的外在工作特性一致,不过在此基础上,WealkHashMap集合增加了“弱建”的概念:如果存在于WeakHashMap集合中的K-V键值对节点的Key键对象没有任何外部的强引用(或软引用),那么在GC回收时,会将该Key键对象回收;
  • ConcurrentSkipListMap:基于跳跃表实现。ConcurrentSkipListMap集合结构在外在使用效果上与TreeMap集合类似(注意:这两个集合的工作场景和内在结构都不一样),两种集合都需要添加到集合中的节点支持某种排序逻辑。
  • ConcurrentSkipListSet:该集合是一种Set集合,其内部是对ConcurrentSkipListMap集合的封装。这种封装的设计思路,类似于普通集合包中各种Set集合对Map集合的封装设计思路。

3.7 高并发场景中的Queue集合

JUC中提供了大量的Queue/Deque集合,用于满足程序员在多种高并发场景中的数据管理和数据通信需求,常用的Queue/Deque集合如图所示。
在这里插入图片描述
前面已经介绍了队列的基本工作特点:从队列的头部取出数据对象,并且在队列的尾部添加数据对象,也就是说,先进入队列的数据对象会先从队列中取出(先进先出,FIFO)。此外,图9-1中的队列都有一些自身的工作特点。

  • ArrayBlockingQueue:这是一种内部基于数组的,在高并发场景中使用的阻塞队列,是一种有界队列
    该队列的一个显著工作特点是,存储在队列中的数据对象数量有一个最大值。
  • LinkedBlockingQueue:这是一种内部基于链表的,在高并发场景中使用的阻塞队列,是一种有界队列
    该队列最显著的工作特点是它的内部结构是一个链表,这保证了它可以在有界队列和无界队列之间非常方便地进行转换。
  • LinkedTransferQueue:这是一种内部基于链表的,可以在高并发场景中使用的阻塞队列,是一种无界队列
    可以将它看成LinkedBlockingQueue队列和ConcurrentLinkedQueue队列优点的结合体,既能关注集合的读/写操作性能,又能维持队列的工作特性。在实际应用中,经常使用该队列进行线程间的消息同步操作。
  • PriorityBlockingQueue:这是一种内部基于数组的,采用小顶堆结构的,可以在高并发场景中使用的阻塞队列,是一种无界队列
    该队列最显著的工作特点是,队列中的数据对象按照小顶堆结构进行排序,从而保证从该队列中取出的数据对象是权值最小的数据对象。
  • DelayQueue:这是一种内部依赖PriorityQueue的,采用小顶堆结构的,可以在高并发场景中使用的阻塞队列,是一种无界队列
    该队列的一个显著工作特点是,队列中的数据对象除了会按照小顶堆结构进行排序外
    这些数据对象还会通过实现java.util.concurrent.Delayed接口定义一个延迟时间,只有当延迟时间最小的数据对象的值都小于或等于0时(延迟时间会作为节点的权重值参与排序),该数据对象才会被外部调用者获得。

更多请查看: https://blog.csdn.net/Hmj050117/article/details/115269734

3.7.1 什么是有界队列,什么是无界队列?

  • 有界队列:队列容量有一个固定大小的上限,一旦队列中的数据对象总量达到容量上限时,队列就会对添加操作进行容错性处理。
    例如,返回false,证明操作失败;抛出运行时异常;进入阻塞状态,直到操作条件满足要求。也就是说,不再允许立即添加数据对象了。

  • 无界队列:队列容量没有一个固定大小的上限,或者容量上限值是一个很大的理论上限值(如常量Integer.MAX_VALUE的最大值为2147483647)。
    由于这种队列理论上没有容量上限,因此理论上调用者可以将任意数量的数据对象添加到集合中,而不会使添加操作出现容量异常。

    • 无界队列是不是真的无界呢?显然不是的,根据上面的描述可知,一部分无界队列是可以在进行实例化时设置其队列容量上限的。
    • 例如,LinkedBlockingQu-eue队列默认的容量值是Integer.MAX_VALUE(相当于无界),但是我们也可以将LinkedBlockingQueue队列的容量值设置为一个特定的值。
    • 此外,无界队列不能保证其容量无限大的另一个原因是JVM可管理的堆内存是有上限的,当超过堆内存容量且JVM无法再申请新的内存空间时,应用程序会抛出OutofMemoryError异常。

3.7.2 BlockingQueue核心方法

我们知道,Queue接口是BlockingQueue接口的父级接口,前者定义了一些与队列有关的接口,后者在此基础上补充了一些接口功能;

接口有四组API,一般选用第四组

方法类型抛出异常特殊值阻塞超时
插入add(e)offer(e)put(e)offer(e,time,unit)
移除remove()poll()take()poll(time,unit)
检查element()peek()不可用不可用
类型含义
抛出异常当阻塞队列满时,再往队列里面add插入元素会抛legalStateException: Queue full 当阻塞队列空时,再往队列Remove元素时候回抛出NoSuchElementException
特殊值插入方法,成功返回true失败返回false 移除方法,成功返回元素,队列里面没就返回null
一直阻塞当阻塞队列满时,生产者继续往队列里面put元素,队列会一直阻塞直到put数据or响应中断退出 当阻塞队列空时,消费者试图从队列take元素,队列会一直阻塞消费者线程直到队列可用.
超时退出当阻塞队列满时,队列会阻塞生产者线程一定时间,超过后限时后生产者线程就会退出

3.7.3 Queue集合实现——ArrayBlockingQueue

  • ArrayBlockingQueue队列是一种经常使用的线程安全的Queue集合实现,它是一种内部基于数组的,可以在高并发场景中使用的阻塞队列,也是一种容量有界的队列
  • 该队列符合先进先出(FIFO)的工作原则,也就是说
    • 该队列头部的数据对象是最先进入队列的,也是最先被调用者取出的数据对象;
    • 该队列尾部的数据对象是最后进入队列的,也是最后被调用者取出的数据对象。
  • 在多线程同时读/写ArayBlockingQueue队列中的数据对象时,该队列还支持一种公平性策略,这是一种为生产者/消费者工作模式提供的功能选项(可以将ArrayBlockingQueue队列的读取操作线程看成消费者角色,将写入操作线程看成生产者角色),如果启用了这个功能选项,那么ArrayBlockingQueue队列会分别保证多个生产者线程和多个消费者线程获取ArayBlockingQueue队列操作权限的顺序一—先请求操作的线程会先获得操作权限。

ArrayBlockingQueue队列的基本继承体系如图所示。
在这里插入图片描述
该队列的公平性策略实际上基于ReentrantLock类的公平模式

下面我们介绍几种使用ArrayBlockingQueue队列的基本场景。

  • 单线程场景
    public static void main(String[] args) throws InterruptedException {

        ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(5);
        //add方法发现队列的容量已经达到上限时,就会抛出异常,一般与remove搭配使用

        //add与remove  抛异常
        //offer与peek  特殊值
        //put 与 take 阻塞
        //offer(time) 与poll 超时

        blockingQueue.add("1");
        blockingQueue.add("2");
        blockingQueue.add("3");
        blockingQueue.add("4");
        blockingQueue.add("5");
        //但是put方法就会阻塞线程
        blockingQueue.put("6");
        

    }
  • 多线程场景下
    • 包括ArrayBlockingQueue队列在内的所有实现了BlockingQueue接口的队列,其典型的高并发场景是生产者和消费者操作场景——由一个或多个生产者线程生产数据对象,然后按照业务要求将数据对象放入队列中,并且由一个或多个消费者线程从队列中取出数据对象进行处理。
    • 不同的阻塞队列对生产者线程如何放入数据对象、数据对象在队列中如何排列、消费者线程如何取出数据对象的规则都有不同的设计特点。
    • ArrayBlockingQueue队列对生产者线程如何放入数据对象的规定为,如果当前队列中没有多余的空间可供生产者线程向队列中添加数据对象,那么生产者线程可以进入阻塞状态,直到队列中有新的空间出现;
3.7.3.1 生产者
import java.util.UUID;
import java.util.concurrent.BlockingQueue;

/**
 * @author wql
 * @date 2023/6/8 22:29
 */
public class Producer implements Runnable {
    private BlockingQueue<String> queue;


    public Producer(BlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        String uuid = UUID.randomUUID().toString();
        int count = 0;
        while (count++ < Integer.MAX_VALUE) {
            //如果不能添加到队列,则阻塞等待
            try {
                queue.put(uuid);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

3.7.3.2 消费者
import java.util.concurrent.BlockingQueue;

/**
 * @author wql
 * @date 2023/6/8 22:36
 */
public class Consumer implements Runnable {

    private BlockingQueue<String> queue;

    public Consumer(BlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        int count = 0;

        while (count++ < Integer.MAX_VALUE) {
            try {
                String value = this.queue.take();
                //TODO 处理value值
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

3.7.3.3 测试
    public static void main(String[] args) throws InterruptedException {

        ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue<>(100);


        ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(10,10,1000, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(10));

        poolExecutor.submit(new Producer(arrayBlockingQueue));
        poolExecutor.submit(new Producer(arrayBlockingQueue));
        poolExecutor.submit(new Producer(arrayBlockingQueue));

        poolExecutor.submit(new Consumer(arrayBlockingQueue));
        poolExecutor.submit(new Consumer(arrayBlockingQueue));
        poolExecutor.submit(new Consumer(arrayBlockingQueue));


    }
3.7.3.4 ArrayBTockingQueue队列的公平性策略

使用ArrayBTockingQueue队列的公平性策略在上一节的源码中,当队列中没有多余的存储空间时,所有生产者线程都会先后进入阻塞状态,并且在队列有空余位置时被唤醒,但是ArrayBlockingQueue队列并不保证线程唤醒的公平性,也就是说,队列并不保证最先进入阻塞状态的生产者线程最先被唤醒。如果在一些特定的业务场景中,需要保证生产者线程和消费者线程的公平性原则,则需要启用ArrayBl-
ockingQueue队列的公平性策略,方法如下。
在这里插入图片描述

3.7.3.5 ArrayBlockingQueue队列的工作原理
  • ArrayBlockingQueue队列是一个可循环使用数组空间的有界阻塞队列,使用可复用的环形数组记录数据对象。
  • 其内部使用一个takeIndex变量表示队列头部(队列头部可以是数组中的任何有效索引位),使用一个putIndex变量表示队列尾部(队列尾部不是数组中的最后一个索引位);
  • 从takeIndex到putIndex的索引位,是数组中已经放置了数据对象的索引位,从putIndex到takeIndex的索引位是数组中还可以放置新的数据对象的索引位

相关原理如图所示。
在这里插入图片描述
根据图可知,ArrayBlockingQueue队列的数组是一个环形数组,该数组首尾相连。takeIndex变量指向的索引位是下一个要取出数据对象的索引位,putIndiex变量指向的索引位是下一个要添加数据对象的索引位。为了支撑这个环形数组的工作,ArrayBlockingQueue队列使用了很多辅助的变量信息。

3.7.3.6 ArrayBIockingQueue队列的主要属性
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    private static final long serialVersionUID = -817911632652898426L;

    //这个数组是ArrayBlockingQueue队列用于存储数据的数组
    /** The queued items */
    final Object[] items;

    //该属性记录的索引位是下一次从队列中移除的数据对象的索引位
    //这个移除操作方法可能是take()方法、poll()方法、peek()方法或remove()方法
    /** items index for next take, poll, peek or remove */
    int takeIndex;

    //该属性指向的索引位是下一次添加到队列中的数据对象的索引位
    //这个添加操作方法可能是put()方法、offer()方法或add()方法
    /** items index for next put, offer, or add */
    int putIndex;

    //该属性表示当前在队列中的数据对象总量
    /** Number of elements in the queue */
    int count;

    //ArrayBlockingQueue队列使用基于AQS技术的ReentrantLock类进行线程安全性控制
    //并且采用双条件控制方式对数据对象移除、添加操作进行交互控制
    /** Main lock guarding all access */
    final ReentrantLock lock;

    //主要用于控制数据对象移除操作条件
    /** Condition for waiting takes */
    private final Condition notEmpty;

    //主要用于控制数据对象添加操作条件
    /** Condition for waiting puts */
    private final Condition notFull;
    ...}

这里需要注意两个Condition对象

  • notEmpty对象主要用于在ArrayBlockingQueue队列变为非空的场景中,进行生产者线程和消费者线程的协调工作,具体来说,是给消费者线程发送信号,告诉它们线程队列中又有新的数据对象可以取出了;
  • notFull对象主要用于在队列变为非满的场景中,进行生产者线程和消费者线程的协调工作,具体来说,是给生产者线程发送信号,告诉它们线程队列中又有新的索引位可以放置新的数据对象了。

两个Condition对象表示两个独立的Condition单向链表。

3.7.3.7 ArrayBlockingQueue队列的入队和出队过程
  • 在ArrayBlockingQueue队列中,负责向队列中添加数据对象的核心方法只有一个,就是enqueue()方法;
    • 根据源码可知,enqueue()方法的操作过程为,在putIndex指向的索引位上添加新的数据对象,并且将putIndex指向的索引位向后移动一位,如果在移动后超出了数组边界,则将putIndex重新指向0号索引位。
   /**
     * 该方法主要用于在putIndex变量指定的索引位上添加新的数据对象
     * 该方法内部虽然没有进行线程安全性操作,但是对该方法的调用者都有“持有锁”的要求
     * Inserts element at current put position, advances, and signals.
     * Call only when holding lock.
     */
    private void enqueue(E x) {
        // assert lock.getHoldCount() == 1;
        // assert items[putIndex] == null;
        final Object[] items = this.items;
        //将入参x数据对象添加到指定的数组索引位上
        items[putIndex] = x;
        //在添加数据对象后,如果下一个索引位超出了边界,则将 putIndex重新指向0号索引位
        if (++putIndex == items.length)
            putIndex = 0;
        //集合中数据对象总量的计数器+1
        count++;
        //发出信号,帮助在集合为空时处于阻塞状态的线程(消费者线程)退出阻塞状态
        notEmpty.signal();
    }
  • 从队列中移除数据对象的核心方法也只有一个,就是dequeue()方法。
    • 根据源码可知,dequeue()方法的操作过程为,将takeIndex指向的索引位上的数据对象移除,并且将takeIndex指向的索引位向后移动一位,如果在移动后超出数组边界,则将takeIndex重新指向0号索引位。
/**
     * 该方法主要用于从takeIndex指向的索引位上移除一个数据对象
     * 该方法内部虽然没有进行线程安全性操作,但是对该方法的调用者都有“持有锁”的要求
     *
     * Extracts element at current take position, advances, and signals.
     * Call only when holding lock.
     */
    private E dequeue() {
        // assert lock.getHoldCount() == 1;
        // assert items[takeIndex] != null;
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        E x = (E) items[takeIndex];
        //将takeIndex指向的索引位上的数据对象设置为null,以便帮助可能的GC动作
        items[takeIndex] = null;
        //在移除数据对象后,如果下一个索引位超出了边界,则将takeIndex重新指向0号索引位
        if (++takeIndex == items.length)
            takeIndex = 0;
        //集合中数据对象总量的计数器-1
        count--;
        //如果存在迭代器(们),则迭代器也需要进行数据清理
        if (itrs != null)
            itrs.elementDequeued();
        //发出信号,帮助在集合已满时进入阻塞状态的线程(生产者线程)退出阻塞状态
        notFull.signal();
        return x;
    }

ArrayBlockingQueue队列向外暴露的大部分操作方法,都对上述两个方法进行了封装调用。
enqueue()方法和dequeue()方法都没有进行线程安全性控制,因此需要这两个方法的调用者自行控制线程的安全性

3.7.3.8 ArrayBlockingQueue队列的主要构造方法

ArrayBlockingQueue队列一共有3个构造方法,源码如下。

 /**
     *
     * 该构造方法可以指定一个capacity容量值
     * 用于设置ArrayBlockingQueue队列中环形数组的最大容量
     * 即ArrayBlockingQueue队列的最大容量
     * 注意:如果capacity<1,则会抛出异常
     *
     * Creates an {@code ArrayBlockingQueue} with the given (fixed)
     * capacity and default access policy.
     *
     * @param capacity the capacity of this queue
     * @throws IllegalArgumentException if {@code capacity < 1}
     */
    public ArrayBlockingQueue(int capacity) {
        this(capacity, false);
    }

    /**
     * 该构造方法可以指定两个值,用于进行ArrayBlockingQueue 队列的实例化
     * capacity:表示当前ArrayBlockingQueue队列的最大容量值
     * fair:表示是否启用公平锁方式,在默认情况下不启用
     *
     * Creates an {@code ArrayBlockingQueue} with the given (fixed)
     * capacity and the specified access policy.
     *
     * @param capacity the capacity of this queue
     * @param fair if {@code true} then queue accesses for threads blocked
     *        on insertion or removal, are processed in FIFO order;
     *        if {@code false} the access order is unspecified.
     * @throws IllegalArgumentException if {@code capacity < 1}
     */
    public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        this.items = new Object[capacity];
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
    }

    /**
     * 该构造方法可以指定3个值,用于进行ArrayBlockingQueue队列的实例化
     *
     * capacity:表示当前ArrayBlockingQueue队列的最大容量值
     * fair:表示是否启用公平锁方式,在默认情况下不启用
     * c:这是一个外部集合,这个集合不能为null,否则会报错
     * 这些集合中的数据对象会按照特定的顺序被复制到ArrayBlockingQueue队列中
     *
     * Creates an {@code ArrayBlockingQueue} with the given (fixed)
     * capacity, the specified access policy and initially containing the
     * elements of the given collection,
     * added in traversal order of the collection's iterator.
     *
     * @param capacity the capacity of this queue
     * @param fair if {@code true} then queue accesses for threads blocked
     *        on insertion or removal, are processed in FIFO order;
     *        if {@code false} the access order is unspecified.
     * @param c the collection of elements to initially contain
     * @throws IllegalArgumentException if {@code capacity} is less than
     *         {@code c.size()}, or less than 1.
     * @throws NullPointerException if the specified collection or any
     *         of its elements are null
     */
    public ArrayBlockingQueue(int capacity, boolean fair,
                              Collection<? extends E> c) {
        this(capacity, fair);

        final ReentrantLock lock = this.lock;
        lock.lock(); // Lock only for visibility, not mutual exclusion
        try {
            int i = 0;
            try {
                for (E e : c) {
                    checkNotNull(e);
                    items[i++] = e;
                }
            } catch (ArrayIndexOutOfBoundsException ex) {
                throw new IllegalArgumentException();
            }
            count = i;
            putIndex = (i == capacity) ? 0 : i;
        } finally {
            lock.unlock();
        }
    }
  • ArrayBlockingQueue队列中的方法,与其下的Itr迭代器和Itrs迭代器分组相比,要简单得多。这里只需要注意构造方法中创建的两个Condition控制对象,说明其处理逻辑中有两个独立工作的Condition单向链表。
  • notEmpty对象主要用于在ArrayBlockingQueue队列中至少有一个数据对象的场景中,通知可能处于阻塞状态的消费者线程退出阻塞状态;notFull对象主要用于在ArrayBlockingQueue队列中至少有一个空余的索引位可以放入新的数据对象时,通知可能处于阻塞状态的生产者线程退出阻塞状态。
3.7.3.8 ArrayBlockingQueue队列的主要方法

ArrayBlockingQueue队列实现了java.util.concurrent.BlockingQueue接口,ArrayBlockingQueue队列中的主要方法与BlockQueue接口中的相应方法遵循相同的处理逻辑,区别点主要在于不能正常操作时的处理方式。

下面选择几个具有代表性的操作方法进行介绍。

  • offer(E)方法
    根据官方描述,offer(E)方法的主要工作过程是将特定的数据对象添加到队列尾部,这个数据对象不能为nul。
    如果添加操作成功,则返回true,否则返回false。
  • put(E)方法
    和offer(E)方法类似的还有put(E)方法,二者的区别是,如果ArrayBlockingQueue队列不能(已经没有多余的空间)进行数据对象添加操作,那么put(E)方法会使进行数据对象添加操作的线程(生产者线程)进入阻塞状态,直到这个线程被唤醒并能够进行数据对象添加操作为止。put(E)方法的源码如下。
  • take()方法
    使用take()方法可以从ArrayBlockingQueue队列头部移除一个数据对象,如果当前ArrayBlockingQueue队列中已经没有数据对象可以移除,那么用于移除数据对象的线程(消费者线程)会进入阻塞状态。take()方法中实际移除数据对象的方法是前文中已经介绍过的dequeue()方法,相关源码如下。

3.7.4 Queue集合实现——LinkedBlockingQueue

上一节介绍的ArrayBlockingQueue队列,已经具有了其他支持高并发工作场景的Queue集合的大部分设计特点。

  • 大部分阻塞队列会通过Condition控制条件(如对notEmpty和notFull进行判定)协调对生产者线程和消费者线程的控制。但也有例外,阻塞队列LinkedTransferQueue可以直接通过基于CAS的乐观锁对生产者线程和消费者线程进行控制,这主要和LinkedTransferQueue队列的工作场景有关。
  • 大部分队列都通过类似于count的属性记录队列中的数据对象总量。有界队列需要通过capacity等属性记录队列的容量上限;而无界队列对于容量的记录要求相对较宽松,甚至没有直接记录容量上限的属性。
  • 大部分具有线程安全性的阻塞队列可以通过ReentrantLock对象保证线程安全性(基于AQS的悲观锁方式),但也有具有线程安全性的队列使用CAS的乐观锁保证线程安全性。
  • 为了保证多线程操作场景中多个迭代器的工作稳定性,这些队列结构中的迭代器都做了较复杂的设计,其中ArrayBlockingQueue队列的迭代器具有较强的代表性。
  • 为了保证设计思路的可靠性,Java原生的线程安全队列涉及的数据结构只有几种:数组、链表(单向链表、双向链表)、树(如小顶堆)。这样做的原因主要是这些数据结构在某个或多个工作场景中有较好的稳定性;这样做的目的主要是承接基础JCF的设计思想,以及保证使用者对工作原理的理解具有继承性。

基于介绍ArrayBlockingQueue队列时的设计共性,本节介绍另一个重要的阻塞队列一—LinkedBlockingQueue。LinkedBlockingQueue队列是一种内部基于链表,应用于高并发场景中的阻塞队列,而且该队列可以依据初始化时的传入参数,在有界队列和无界队列的工作模式之间进行切换。LinkedBlockingQueue队列的基本内部结构如图所示。
在这里插入图片描述
即使读者没有接触过LinkedBlockingQueue队列的源码,以上图示也非常容易理解。需要注意的是,LinkedBlockingQueue队列的头节点中的item属性不存储数据对象。

3.7.4.1 LinkedBlockingQueue队列的重要属性

根据图可知,LinkedBlockingQueue队列通过一个单向链表存储数据对象,其中的head属性指向单向链表的头节点,last属性指向单向链表的尾节点,capacity属性表示LinkedBlockingQueue队列的容量上限; LinkedBlockingQueue队列中的重要属性如下。

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.AbstractQueue;
import java.util.Collection;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.function.Consumer;

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    private static final long serialVersionUID = -6903933977591709194L;


    /**
     * 这是一个Node节点的定义,其中包括两个属性:
     * item:主要用于存储当前节点引用的数据对象(可能为null)
     * next:主要用于指向当前节点的后置节点(可能为null)
     */
    static class Node<E> {
        E item;

        Node<E> next;

        Node(E x) { item = x; }
    }

    /**
     * 该属性表示当前LinkedBLockingQueue队列的容量上限,
     * 如果在初始化LinkedBlockingQueue队列时没有设置,就默认为Integer.MAX_VALUE
     * 此时,可以将LinkedBlockingQueue队列看作一个无界队列使用
     */
    private final int capacity;

    /**
     * 当前LinkedBlockingQueue队列中的数据对象总量,使用基于CAS技术的AtomicInteger的原因是
     * LinkedBlockingQueue队列的读/写操作分别由两个独立的可重入锁进行控制
     */
    private final AtomicInteger count = new AtomicInteger();

    /**
     * head 指向单向链表的头节点。注意:head不会为null
     */
    transient Node<E> head;

    /**
     * last 指向单向链表的尾节点。注意:last也不会为null
     * 有的时候head属性和last属性可能指向同一个节点
     */
    private transient Node<E> last;

    /**
     * 这个可重入锁主要用于保证取出数据对象时的安全性,保证类似于take()、poll()的方法的操作正确性
     */
    private final ReentrantLock takeLock = new ReentrantLock();

    /**
     * 这个Condition对象会在队列中至少有一个数据对象时进行通知
     */
    private final Condition notEmpty = takeLock.newCondition();

    /**
     * 这个可重入锁主要用于保证添加数据对象时的安全性,保证类似于put()、offer()的方法的操作正确性
     */
    private final ReentrantLock putLock = new ReentrantLock();

    /**
     * 这个Condition对象会在队列中至少有一个空闲的可添加数据对象的索引位时进行通知
     */
    private final Condition notFull = putLock.newCondition();

    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }
   ...}

在阅读了ArrayBlockingQueue队列的源码后,以上源码片段是不是有一种似曾相识的感觉?这是因为这两种BlockingQueue队列的基本设计思路是相似的。根据以上源码片段可知,后者和前者的区别,除了一个使用单向链表结构,一个使用数组结构,还有一个区别是,LinkedBlockingQueue队列中有两个可重入锁(putLock属性和takeLock属性),分别用于控制数据对象添加过程和数据对象移除过程在并发场景中的正确性,换句话说,LinkedBlockingQueue队列的数据对象添加过程和数据对象移除过程是不冲突的,如图所示。
在这里插入图片描述

3.7.4.2 LinkedBlockingQueue队列的构造方法

LinkedBlockingQueue队列一共有3个可用的构造方法,源码片段如下。

 /**
     * 这是默认的构造方法,其中调用了LinkedBlockingQueue(int)构造方法
     * 设置LinkedBlockingQueue队列的容量上限为Integer.MAX_VALUE(相当于无界队列)
     */
    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }
 /**
     * 该构造方法可以由调用者设置LinkedBlockingQueue队列的容量上限
     * 如果设置的容量上限小于或等于0,则会抛出异常
     */
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        //为LinkedBlockingQueue队列初始化一个单向链表,
        ///单向链表中只有一个Node节点,并且这个节点没有item数据
        last = head = new Node<E>(null);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alan0517

感谢您的鼓励与支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值