多线程之五:并发集合

目录

一、Java中的并发集合类有哪些?

二、并发集合概述

三、ConcurrentHashMap

3.1 存储的结构

3.2 存储操作

3.2.1 put方法

3.2.2 putVal方法-散列算法

3.2.3 putVal方法-添加数据到数组&初始化数组

3.2.4 putVal方法-添加数据到链表

 3.3 扩容操作

3.4 红黑树操作

3.5 查询数据

3.5.1 get方法-查询数据的入口

3.6 ConcurrentHashMap其他方法

3.6.1 compute方法

3.6.2 replace方法

3.6.3 merge方法

3.7 ConcurrentHashMap计数器

3.7.1 addCount方法(putVal内部方法)

1.7.2 size方法

补充:JDK1.7的HashMap的环形链表

四、CopyOnWriteArrayList

4.1 概述

4.2 核心属性&方法

4.3 读操作

4.4 写操作

4.5 移除数据

4.6 覆盖数据&清空集合

4.7 迭代器


一、Java中的并发集合类有哪些?

一些常用的并发集合类包括:

  • ConcurrentHashMap:线程安全的哈希表,适用于高并发读写的场景。
  • CopyOnWriteArrayList:线程安全的动态数组,适用于读多写少的场景。
  • ConcurrentLinkedQueue:线程安全的无界队列,适用于高并发的生产者-消费者场景。
  • ConcurrentSkipListSet:线程安全的有序集合,基于跳表实现,支持高效的插入、删除和查询操作。
  • ConcurrentSkipListMap:线程安全的有序映射表,基于跳表实现,支持高效的插入、删除和查询操作。
  • BlockingQueue接口的实现类(如ArrayBlockingQueue、LinkedBlockingQueue):线程安全的阻塞队列,提供了阻塞的插入和删除操作,用于实现生产者-消费者模式。
  • BlockingDeque接口的实现类(如LinkedBlockingDeque):线程安全的双端阻塞队列,提供了阻塞的插入和删除操作,支持从两端插入和删除元素。

这些并发集合类提供了线程安全的操作,能够在多线程环境中安全地进行并发访问和修改,减少了编写线程安全代码的复杂性。根据具体的需求和场景,选择合适的并发集合类可以提高程序的性能和并发性能。

以下是一些常见的并发集合及其特点:

  1. ConcurrentHashMap(并发哈希表):它是一个线程安全的哈希表实现,支持高并发的读操作和部分并发的写操作。它采用分段锁的方式来提高并发性能,不同的线程可以同时访问不同的段,从而减少了竞争。

  2. ConcurrentLinkedQueue(并发链表队列):它是一个线程安全的队列实现,支持高并发的入队和出队操作。它使用无锁的算法来实现并发性,每个节点都包含一个指向下一个节点的引用,通过CAS(Compare and Swap)操作来保证数据的一致性。

  3. CopyOnWriteArrayList(写时复制数组列表):它是一个线程安全的动态数组实现,支持高并发的读操作和低并发的写操作。它在写操作时,会创建一个新的数组,并将原数组的内容复制到新数组中,从而实现写时复制的效果,避免了读写冲突。

  4. BlockingQueue(阻塞队列):它是一个支持阻塞操作的队列实现,可以在队列为空时阻塞等待元素的到来,或者在队列已满时阻塞等待空间的释放。常见的实现有LinkedBlockingQueue和ArrayBlockingQueue等。

  5. ConcurrentSkipListMap(并发跳表映射):它是一个线程安全的有序映射表实现,支持高并发的读操作和部分并发的写操作。它采用跳表的数据结构,通过多层索引来提高查找效率,同时使用CAS操作来保证数据的一致性。

  6. ConcurrentSkipListSet(并发跳表集合):它是一个线程安全的有序集合实现,支持高并发的读操作和部分并发的写操作。它采用跳表的数据结构,通过多层索引来提高查找效率,同时使用CAS操作来保证数据的一致性。ConcurrentSkipListSet提供了类似于TreeSet的接口,可以用于存储有序的元素集合,并且支持并发的访问和修改。

  7. ConcurrentLinkedDeque(并发链表双端队列):它是一个线程安全的双端队列实现,支持高并发的入队和出队操作。它使用无锁的算法来实现并发性,每个节点都包含一个指向前后节点的引用,通过CAS操作来保证数据的一致性。ConcurrentLinkedDeque可以同时支持队列和栈的操作,并且可以在队列的两端进行插入、删除和检索操作。

第十五章Java并发集合-CSDN博客

Java 常用并发集合 - 知乎

Java并发集合 - 动力节点

二、并发集合概述

郑老师:常见的并发集合分为Concurrent系列和CopyOnWrite系列。阻塞队列其实也属于并发集合,不过已经讲过了,再次不再赘述。

三、ConcurrentHashMap

3.1 存储的结构

扩展:java最早的线程安全的HashMap是HashTable,但现在不用,因为它在每个方法中都加了一个synchronized,效率很低。(synchronized锁的是当前对象)

ConcurrentHashMap是线程安全的HashMap,在JDK1.7中用的是Segment分段锁,Segment继承了ReentrantLock,实现机制也很简单,其实就是把HashTable的锁细粒度话了。

当我们用HashTable操作时,无论用哪个线程操作,用的都是同一把锁,因为毕竟它锁的是同一对象;如果用了JDK1.7的Segment分段锁,他会分成好多个ReentrantLock锁,这样下来效率比HashTable有了一点提高。

ConcurrentHashMap在JDK1.8中做了进一步优化,基于CAS+synchronized做了“桶”锁实现的线程安全:

  • CAS:在没有hash冲突时(Node要放在数组上时);
  • synchronized:在出现hash冲突时(Node存放的位置已经有数据了);【尾插法】

存储的结构:数组+链表+红黑树 (JDK1.8)

JDK1.8中什么时候将链表结构转化为红黑树呢?当链表的长度≥8,且数组长度≥64时。为啥还需要数组长度≥64呢?因为尽量保证数据是分布在数组上的,这样查询效率才会高。

3.2 存储操作

3.2.1 put方法

public V put(K key, V value) {
    // 在调用put方法时,会调用putVal,第三个参数默认传递为false
    // 在调用putIfAbsent时,会调用putVal方法,第三个参数传递的为true
    // 如果传递为false,代表key一致时,直接覆盖数据
    // 如果传递为true,代表key一致时,什么都不做,key不存在,正常添加(类似于Redis,setnx)
    return putVal(key, value, false);
}

3.2.2 putVal方法-散列算法

本节视频老师讲的很清楚:为什么HashMap、ConcurrentHashMap,都要求数组长度为2^n ?一句话概括:为了减少哈希冲突。具体说来,代码中有个 (n - 1) & hash 这样的运算,尽可能保证二进制中低位的数字都是1,这样才能尽量打散数据放到数组上,减少哈希冲突。

(本节视频即序号142节再看看,还有其他内容)

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ConcurrentHashMap不允许key或者value出现为null的值,跟HashMap的区别
    if (key == null || value == null) throw new NullPointerException();
    // 根据key的hashCode计算出一个hash值,后期得出当前key-value要存储在哪个数组索引位置
    int hash = spread(key.hashCode());
    // 一个标识,在后面有用!
    int binCount = 0;
    // 省略大量的代码……
}

// 计算当前Node的hash值的方法
static final int spread(int h) {
    // 将key的hashCode值的高低16位进行^运算,最终又与HASH_BITS进行了&运算
    // 将高位的hash也参与到计算索引位置的运算当中
    // 为什么HashMap、ConcurrentHashMap,都要求数组长度为2^n
    // HASH_BITS让hash值的最高位符号位肯定为0,代表当前hash值默认情况下一定是正数,因为hash值为负数时,有特殊的含义
    // static final int MOVED     = -1; // 代表当前hash位置的数据正在扩容!
    // static final int TREEBIN   = -2; // 代表当前hash位置下挂载的是一个红黑树
    // static final int RESERVED  = -3; // 预留当前索引位置……
    return (h ^ (h >>> 16)) & HASH_BITS;
    // 计算数组放到哪个索引位置的方法   (f = tabAt(tab, i = (n - 1) & hash)
    // n:是数组的长度
}
00001101 00001101 00101111 10001111  - h = key.hashCode

运算方式
00000000 00000000 00000000 00001111  - 15 (n - 1)
&
(
(
00001101 00001101 00101111 10001111  - h
^
00000000 00000000 00001101 00001101  - h >>> 16
)
&
01111111 11111111 11111111 11111111  - HASH_BITS
)

3.2.3 putVal方法-添加数据到数组&初始化数组

看到了如何基于CAS的方式将数据放在数组中,以及数组的初始化。

将底层数组 Node<K,V>[] table 初始化为大小16的数组,同时将下次扩容的volatile类型的sizeCtl属性改为12;

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 省略部分代码…………
    // 将Map的数组赋值给tab,死循环
    for (Node<K,V>[] tab = table;;) {
        // 声明了一堆变量~~
        // n:数组长度
        // i:当前Node需要存放的索引位置
        // f: 当前数组i索引位置的Node对象
        // fn:当前数组i索引位置上数据的hash值
        Node<K,V> f; int n, i, fh;
        // 判断当前数组是否还没有初始化
        if (tab == null || (n = tab.length) == 0)
            // 将数组进行初始化。
            tab = initTable();
        // 基于 (n - 1) & hash 计算出当前Node需要存放在哪个索引位置
        // 基于tabAt获取到i位置的数据
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 现在数组的i位置上没有数据,基于CAS的方式将数据存在i位置上
            if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
                // 如果成功,执行break跳出循环,插入数据成功
                break;   
        }
        // 判断当前位置数据是否正在扩容……
        else if ((fh = f.hash) == MOVED)
            // 让当前插入数据的线程协助扩容
            tab = helpTransfer(tab, f);
        // 省略部分代码…………
    }
    // 省略部分代码…………
}


sizeCtl:是数组在初始化和扩容操作时的一个控制变量
-1:代表当前数组正在初始化
小于-1:低16位代表当前数组正在扩容的线程个数(如果1个线程扩容,值为-2,如果2个线程扩容,值为-3)
0:代表数组还没初始化
大于0:代表当前数组的扩容阈值,或者是当前数组的初始化大小
// 初始化数组方法
private final Node<K,V>[] initTable() {
    // 声明标识
    Node<K,V>[] tab; int sc;
    // 再次判断数组没有初始化,并且完成tab的赋值
    while ((tab = table) == null || tab.length == 0) {
        // 将sizeCtl赋值给sc变量,并判断是否小于0
        if ((sc = sizeCtl) < 0)
            Thread.yield(); 
        // 可以尝试初始化数组,线程会以CAS的方式,将sizeCtl修改为-1,代表当前线程可以初始化数组
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            // 尝试初始化!
            try {
                // 再次判断当前数组是否已经初始化完毕。
                if ((tab = table) == null || tab.length == 0) {
                    // 开始初始化,
                    // 如果sizeCtl > 0,就初始化sizeCtl长度的数组
                    // 如果sizeCtl == 0,就初始化默认的长度
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    // 初始化数组!数组长度n为DEFAULT_CAPACITY即等于16
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    // 将初始化的数组nt,赋值给tab和table
                    table = tab = nt;
                    // sc赋值为了数组长度 - 数组长度 右移 2位    16 - 4 = 12
                    // 将sc赋值为下次扩容的阈值
                    sc = n - (n >>> 2);
                }
            } finally {
                // 将赋值好的sc,设置给sizeCtl
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

3.2.4 putVal方法-添加数据到链表

  • 覆盖的细节;
  • 出现hash冲突时用的是synchronized锁, synchronized锁的是Node节点对象;
  • 1.8中链表是尾插法(即:7上8下),链表转化红黑树的条件。
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 省略部分代码…………
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        // n:数组长度
        // i:当前Node需要存放的索引位置
        // f: 当前数组i索引位置的Node对象
        // fn:当前数组i索引位置上数据的hash值
        // 省略部分代码…………
        else {
            // 声明变量为oldVal
            V oldVal = null;
            // 基于当前索引位置的Node,作为锁对象……
            synchronized (f) {
                // 判断当前位置的数据还是之前的f么……(避免并发操作的安全问题)
                if (tabAt(tab, i) == f) {
                    // 再次判断hash值是否大于0(不是树)
                    if (fh >= 0) {
                        // binCount设置为1(在链表情况下,记录链表长度的一个标识)
                        binCount = 1;
                        // 死循环,每循环一次,对binCount加1
                        for (Node<K,V> e = f;; ++binCount) {
                            // 声明标识ek
                            K ek;
                            // 当前i索引位置的数据,是否和当前put的key的hash值一致
                            if (e.hash == hash &&
                                // 如果当前i索引位置数据的key和put的key == 返回为true
                                // 或者equals相等
                                ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                // key一致,可能需要覆盖数据!
                                // 当前i索引位置数据的value赋值给oldVal
                                oldVal = e.val;
                                // 如果传入的是false,代表key一致,覆盖value
                                // 如果传入的是true,代表key一致,什么都不做!
                                if (!onlyIfAbsent)
                                    // 覆盖value
                                    e.val = value;
                                break;
                            }
                            // 拿到当前指定的Node对象
                            Node<K,V> pred = e;
                            // 将e指向下一个Node对象,如果next指向的是一个null,可以挂在当前Node下面
                            if ((e = e.next) == null) {
                                // 将hash,key,value封装为Node对象,挂在pred的next上
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    // 省略部分代码…………
                }
            }
            // binCount长度不为0
            if (binCount != 0) {
                // binCount是否大于8(链表长度是否 >= 8)
                if (binCount >= TREEIFY_THRESHOLD)
                    // 尝试转为红黑树或者扩容
                    // 基于treeifyBin方法和上面的if判断,可以得知链表想要转为红黑树,必须保证数组长度大于等于64,并且链表长度大于等于8
                    // 如果数组长度没有达到64的话,会首先将数组扩容
                    treeifyBin(tab, i);
                // 如果出现了数据覆盖的情况,
                if (oldVal != null)
                    // 返回之前的值
                    return oldVal;
                break;
            }
        }
    }
    // 省略部分代码…………
}

// 为什么链表长度为8转换为红黑树,不是能其他数值嘛?
// 因为泊松分布
 * The main disadvantage of per-bin locks is that other update
 * operations on other nodes in a bin list protected by the same
 * lock can stall, for example when user equals() or mapping
 * functions take a long time.  However, statistically, under
 * random hash codes, this is not a common problem.  Ideally, the
 * frequency of nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average, given the resizing threshold
 * of 0.75, although with a large variance because of resizing
 * granularity. Ignoring variance, the expected occurrences of
 * list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The
 * first values are:
 *
 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

 3.3 扩容操作

3.4 红黑树操作

什么是红黑树?

红黑树是一种特殊的平衡二叉树,首先具备了平衡二叉树的特点:左子树和右子数的高度差不会超过1,如果超过了,平衡二叉树就会基于左旋和右旋的操作,实现自平衡。

红黑树在保证自平衡的前提下,还保证了自己的几个特性:

  • 每个节点必须是红色或者黑色。

  • 根节点必须是黑色。

  • 如果当前节点是红色,子节点必须是黑色

  • 所有叶子节点都是黑色。

  • 从任意节点到每个叶子节点的路径中,黑色节点的数量是相同的。

当对红黑树进行增删操作时,可能会破坏平衡或者是特性,这时红黑树就需要基于左旋、右旋、变色来保证平衡和特性。

红黑树的插入动画网址:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html 

3.5 查询数据

3.5.1 get方法-查询数据的入口

在查询数据时,会判断当前key对应的value,是否在数组上;其次会判断当前位置是否属于特殊情况:数据被迁移、位置被占用、红黑树结构;最后判断链表上是否有对应的数据。找到返回指定的value,找不到返回null即可。【get整个操作没有加锁】

// 基于key查询value
public V get(Object key) {
    // tab:数组,  e:查询指定位置的节点  n:数组长度
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 基于传入的key,计算hash值
    int h = spread(key.hashCode());
    // 数组不为null,数组上得有数据,拿到指定位置的数组上的数据
    if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {
        // 数组上数据恩地hash值,是否和查询条件key的hash一样
        if ((eh = e.hash) == h) {
            // key的==或者equals是否一致,如果一致,数组上就是要查询的数据
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 如果数组上的数据的hash为负数,有特殊情况,
        else if (eh < 0)
            // 三种情况,数据迁移走了,节点位置被占,红黑树
            return (p = e.find(h, key)) != null ? p.val : null;
        // 肯定走链表操作
        while ((e = e.next) != null) {
            // 如果hash值一致,并且key的==或者equals一致,返回当前链表位置的数据
            if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    // 如果上述三个流程都没有知道指定key对应的value,那就是key不存在,返回null即可
    return null;
}

若代码进入了上面的 else if 分支中,会走e.find(h,key)的代码,显然e是Node<K,V>类型的,而find方法有四个实现子类(如下图),具体走哪个find方法,就取决于e更细粒度上说是哪种类型的,比如如果是ForwardingNode<K,V>类型的,则走ForwardingNode<K,V>内部类里面的find方法。

(1)ForwardingNode<K,V>的find方法:表示正在查询的元素迁移到了新数组;

(2)ReservationNode<K,V>的find方法:直接返回null,因为当前桶位置被占用的话,说明数据还没放到当前位置,当前位置可以理解为就是null;

(3)TreeBin<K,V>的find方法:在红黑树中检索数据,会有两个情况:

  • 如果有线程在持有写锁或者等待获取写锁,当前查询就要在双向链表中锁检索(为了避免读线程等待) ;

  • 如果没有线程持有写锁或者等待获取写锁,完全可以对lockState + 4,然后去红黑树中检索,并且在检索完毕后,需要对lockState - 4,再判断是否需要唤醒等待写锁的线程。

总结:整个ConcurrentHashMap并不会让读线程阻塞。

3.6 ConcurrentHashMap其他方法

3.6.1 compute方法

使用场景:根据指定的key对其value进行一定的运算时。注意空指针异常(试试key不存在时,当前的计算会不会空指针;再试试即便key存在但值为null时,当前的计算会不会报空指针)。

compute方法源码分析:

  • 整个流程和putVal方法很类似,但是内部涉及到了占位的情况RESERVED
  • 整个compute方法和putVal的区别就是,compute方法的value需要计算,如果key存在,基于oldValue计算出新结果,如果key不存在,直接基于oldValue为null的情况,去计算新的value

computeIfPresent、computeIfAbsent、compute区别:

(1)compute的BUG,如果在计算结果的函数中,又涉及到了当前的key,会造成死锁问题。

public static void main(String[] args) {
    ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap();

    map.compute("key",(k,v) -> {
        return map.compute("key",(key,value) -> {
            return 1111;
        });
    });
    System.out.println(map);
}

(2)computeIfPresent和computeIfAbsent其实就是将compute方法拆开成了两个方法

compute会在key不存在时,正常存放结果,如果key存在,就基于oldValue计算newValue

computeIfPresent:要求key在map中必须存在,需要基于oldValue计算newValue

computeIfAbsent:要求key在map中不能存在,必须为null,才会基于函数得到value存储进去

3.6.2 replace方法

涉及到类似CAS的操作,需要将ConcurrentHashMap的value从val1改为val2的场景就可以使用replace实现。

replace内部要求key必须存在,替换value值之前,要先比较oldValue,只有oldValue一致时,才会完成替换操作。

3.6.3 merge方法

merge(key,value,Function<oldValue,value>);

在使用merge时,有三种情况可能发生:

  • 如果key不存在,就跟put(key,value);

  • 如果key存在,就可以基于Function计算,得到最终结果

    • 结果不为null,将key对应的value,替换为Function的结果

    • 结果为null,删除当前key

3.7 ConcurrentHashMap计数器

3.7.1 addCount方法(putVal内部方法)

addCount方法本身就是为了记录ConcurrentHashMap中元素的个数。

两个方向组成:

  • 计数器,如果添加元素成功,对计数器 + 1

  • 检验当前ConcurrentHashMap是否需要扩容

计数器选择的不是AtomicLong,而是类似LongAdder的一个功能

1.7.2 size方法

size获取ConcurrentHashMap中的元素个数,是基于sumCount()方法去获取大小的。线程安全。

问:size方法是如何获取元素个数的呢?怎么保证线程安全呢?

答:它本身就是线程安全的,因为计数器就是线程安全的;它只需要将baseCount和CounterCell[]数组元素个数累加,就是size的大小。

public int size() {
    // 基于sumCount方法获取元素个数
    long n = sumCount();
    // 做了一些简单的健壮性判断
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

// 整体CounterCell数组数据到baseCount
final long sumCount() {
    // 拿到CounterCell[]
    CounterCell[] as = counterCells; CounterCell a;
    // 拿到baseCount
    long sum = baseCount;
    // 循环走你,遍历CounterCell[],将值累加到sum中,最终返回sum
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

说明:以上所有内容都是基于JDK1.8分析的哦。下面补充个1.7的内容。

补充:JDK1.7的HashMap的环形链表

环形链表的发生,是因为并发扩容,加上头插法导致的。于是在JDK1.8中,头插法被替代,换成了尾插法。

如果面试被问到了:

因为JDK1.7中的HashMap是线程不安全的,可能会出现并发扩容的操作。

同时JDK1.7中的HashMap在迁移数据时,采用的是头插法,导致节点的next指针会有变化。

先迁移完的线程,可能会导致其他线程在扩容时,扩容到最后,将最开始的节点重新的插入到了头节点的位置,导致指针再次变化,从而形成了一个环形链表。

四、CopyOnWriteArrayList

4.1 概述

CopyOnWriteArrayList是一个线程安全的ArrayList。

CopyOnWriteArrayList是基于lock锁和数组副本的形式去保证线程安全。

在写数据时,需要先获取lock锁,需要复制一个副本数组,将数据插入到副本数组中,将副本数组赋值给CopyOnWriteArrayList中的array。

因为CopyOnWriteArrayList每次写数据都要构建一个副本,如果你的业务是写多,并且数组中的数据量比较大,尽量避免去使用CopyOnWriteArrayList,因为这里会构建大量的数组副本,比较占用内存资源。

CopyOnWriteArrayList是弱一致性的,写操作先执行,但是副本还有落到CopyOnWriteArrayList的array属性中,此时读操作是无法查询到的。

4.2 核心属性&方法

主要查看2个核心属性,以及2个核心方法,还有无参构造

/** 写操作时,需要先获取到的锁资源,CopyOnWriteArrayList全局唯一的。 */
final transient ReentrantLock lock = new ReentrantLock();

/** CopyOnWriteArrayList真实存放数据的位置,查询也是查询当前array */
private transient volatile Object[] array;

// 获取array属性
final Object[] getArray() {
    return array;
}

// 替换array属性
final void setArray(Object[] a) {
    array = a;
}

/**
 *  默认new的CopyOnWriteArrayList数组长度为0。
 *  不像ArrayList,初始长度是10,每次扩容1/2, CopyOnWriteArrayList不存在这个概念
 *  每次写的时候都会构建一个新的数组
 */
public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

4.3 读操作

CopyOnWriteArrayList的读操作就是get方法,基于数组索引位置获取数据。

方法之所以要差分成两个,是因为CopyOnWriteArrayList中在获取数据时,不单单只有一个array的数组需要获取值,还有副本中数据的值。

// 查询数据时,只能通过get方法查询CopyOnWriteArrayList中的数据
public E get(int index) {
    // getArray拿到array数组,调用get方法的重载
    return get(getArray(), index);
}
// 执行get(int)时,内部调用的方法
private E get(Object[] a, int index) {
    // 直接拿到数组上指定索引位置的值
    return (E) a[index];
}

4.4 写操作

CopyOnWriteArrayList是基于lock锁和副本数组的形式保证线程安全。

// 写入元素,不指定索引位置,直接放到最后的位置
public boolean add(E e) {
    // 获取全局锁,并执行lock
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 获取原数组,还获取了原数组的长度
        Object[] elements = getArray();
        int len = elements.length;
        // 基于原数组复制一份副本数组,并且长度比原来多了一个
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 将添加的数据放到副本数组最后一个位置
        newElements[len] = e;
        // 将副本数组,赋值给CopyOnWriteArrayList的原数组
        setArray(newElements);
        // 添加成功,返回true
        return true;
    } finally {
        // 释放锁~
        lock.unlock();
    }
}

// 写入元素,指定索引位置。(不会覆盖数据)
public void add(int index, E element) {
    // 拿锁,加锁~
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 获取原数组,还获取了原数组的长度
        Object[] elements = getArray();
        int len = elements.length;
        // 如果索引位置大于原数组的长度,或者索引位置是小于0的。
        if (index > len || index < 0)
            throw new IndexOutOfBoundsException("Index: "+index+
                                                ", Size: "+len);
        // 声明了副本数组
        Object[] newElements;
        // 原数组长度 - 索引位置等到numMoved
        int numMoved = len - index;
        // 如果numMoved为0,说明数据要放到最后面的位置
        if (numMoved == 0)
            // 直接走了原生态的方式,正常复制一份副本数组
            newElements = Arrays.copyOf(elements, len + 1);
        else {
            // 数组要插入的位置不是最后一个位置
            // 副本数组长度依然是原长度 + 1
            newElements = new Object[len + 1];
            // 将原数组从0索引位置开始复制,复制到副本数组中的前置位置
            System.arraycopy(elements, 0, newElements, 0, index);
            // 将原数组从index位置开始复制,复制到副本数组的index + 1往后放。
            // 这时,index就空缺出来了。
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        }
        // 数据正常放到指定的索引位置
        newElements[index] = element;
        // 将副本数组,赋值给CopyOnWriteArrayList的原数组
        setArray(newElements);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

4.5 移除数据

关于remove操作,要分析两个方法

  • 基于索引位置移除指定数据

  • 基于具体元素删除数组中最靠前的数据

    • 当前这种方式,嵌套了一层,导致如果元素存在话,成本是比较高的。

    • 如果元素不存在,这种设计不需要加锁,提升写的效率

// 删除指定索引位置的数据
public E remove(int index) {
    // 拿锁,加锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 获取原数组和原数组长度
        Object[] elements = getArray();
        int len = elements.length;
        // 通过get方法拿到index位置的数据
        E oldValue = get(elements, index);
        // 声明numMoved
        int numMoved = len - index - 1;
        // 如果numMoved为0,说明删除的元素是最后的位置
        if (numMoved == 0)
            // Arrays.copyOf复制一份新的副本数组,并且将最后一个数据不要了
            // 基于setArray将副本数组赋值给array原数组
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            // 删除的元素不在最后面的位置
            // 声明副本数组,长度是原数组长度 - 1
            Object[] newElements = new Object[len - 1];
            // 从0开始复制的index前面
            System.arraycopy(elements, 0, newElements, 0, index);
            // 从index后面复制到最后
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            setArray(newElements);
        }
        // 返回被干掉的数据
        return oldValue;
    } finally {
        // 释放锁
        lock.unlock();
    }
}

// 删除元素(最前面的)
……

4.6 覆盖数据&清空集合

覆盖数据就是set方法,可以将指定位置的数据替换。清空就是清空了~~~

4.7 迭代器

用ArrayList时,如果想在遍历的过程中去移除或者修改元素,必须使用迭代器才可以。

但是CopyOnWriteArrayList这哥们即便用了迭代器也不让做写操作。

不让在迭代时做写操作是:不希望迭代操作时会影响到写操作,还有,不希望迭代时还需要加锁。

// 获取遍历CopyOnWriteArrayList的iterator。
public Iterator<E> iterator() {
    // 其实就是new了一个COWIterator对象,并且获取了array,指定从0开始遍历
    return new COWIterator<E>(getArray(), 0);
}

static final class COWIterator<E> implements ListIterator<E> {
    /** 遍历的快照 */
    private final Object[] snapshot;
    /** 游标,索引~~~ */
    private int cursor;

    // 有参构造
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    // 有没有下一个元素,基于遍历的索引位置和数组长度查看
    public boolean hasNext() {
        return cursor < snapshot.length;
    }
    // 有没有上一个元素
    public boolean hasPrevious() {
        return cursor > 0;
    }

    // 获取下一个值,游标动一下
    public E next() {
        // 确保下个位置有数据
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

    // 获取上一个值,游标往上移动
    public E previous() {
        if (! hasPrevious())
            throw new NoSuchElementException();
        return (E) snapshot[--cursor];
    }

    // 拿到下一个值的索引,返回游标
    public int nextIndex() {
        return cursor;
    }

    // 拿到上一个值的索引,返回游标
    public int previousIndex() {
        return cursor-1;
    }

    // 写操作全面禁止!!
    public void remove() {
        throw new UnsupportedOperationException();
    }

  
    public void set(E e) {
        throw new UnsupportedOperationException();
    }

  
    public void add(E e) {
        throw new UnsupportedOperationException();
    }

    // 兼容函数式编程
    @Override
    public void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        Object[] elements = snapshot;
        final int size = elements.length;
        for (int i = cursor; i < size; i++) {
            @SuppressWarnings("unchecked") E e = (E) elements[i];
            action.accept(e);
        }
        cursor = size;
    }
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值