一文搞懂Java并发容器相关面试题

本文深入探讨了Java并发容器,包括CopyOnWriteArrayList的特性、源码分析及面试题,解释了为何它适用于读多写少的场景。接着,文章详细介绍了ConcurrentHashMap的实现机制,对比了JDK1.7和1.8的区别,并讨论了线程安全问题。最后,讲解了阻塞队列的概念、主要方法和各类阻塞队列的特性,如ArrayBlockingQueue和LinkedBlockingQueue。通过对源码的分析,展示了这些并发容器的内部工作原理和线程安全策略。
摘要由CSDN通过智能技术生成

我们常用的Java并发容器类是由java.util.concurrent包为我们提供的
java.util.concurrent包提供的并发容器主要分为三类:Concurrent*、CopyOnWrite*、Blocking*

其中Concurrent*的特点大部分通过CAS+synchronized实现的,CopyOnWrite*则是通过复制一份原数据来实现的,而Blocking*是通过AQS实现的

面试常见的并发容器如ConcurrentHashMapCopyOnWriteArrayListBlockQueue的实现类等均是来自juc包,我们只是简单的知道它们是线程安全的是完全不够的,所以,让我们一起来从底层认识下Java并发容器吧!

本文会从常见问题,源码分析,面试题总结三个部分来展开

CopyOnWriteArrayList

常见问题

诞生的历史和原因

  • 代替Vector和SynchronizedList,就像ConcurrentHashMap代替SynchronizedMap一样
  • Vector和SynchronizedList的锁的粒度太大了,并发效率相对较低,并且迭代时无法编辑
  • Copy-On-Right并发容器还包括CopyOnWriteArray用来替代SynchronizedSet

整体架构

从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,只不过 CopyOnWriteArrayList 在对数组进行操作的时候,基本会分四步走:

  • 加锁
  • 从原数组中拷贝出新数组
  • 在新数组上进行操作,并把新数组赋值给数组容器
  • 解锁。

适用场景

  • 读操作快,写就算慢一点也太大问题
  • 读操作多,写操作少

如:
黑名单,每日一次更新就够了
监听器,监听迭代操作次数远高于修改操作

读写规则

对比读写锁的规则:读读共享、读写互斥、写读互斥、写写互斥

CopyOnWriteArrayList的读写规则为:

  • 读取不需要加锁(读读共享
  • 写入不会阻塞读取操作(读写共享、写读共享
  • 写入与写入之间需要同步等待(写写互斥

特征

  1. 线程安全的,多线程环境下可以直接使用,无需加锁;
  2. 通过锁 + 数组拷贝 + volatile 关键字保证了线程安全;
  3. 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去
  4. 修改过程中:读取的数据是原来的数据,不存在线程安全;迭代的数据是迭代器生成时的数据,之后的修改不可见

缺点

  • 数据不一致问题:CopyOnWrite容器只能保证数据最终一致性,不能保证数据实时一致性。所以希望写入数据马上看到就不要用CopyOnWrite容器
  • 内存占用问题:因为CopyOnWrite的写是复制机制,所以进行写操作时,内存会同时有两个对象,如果对象较大,会占用较大内存

案例演示

案例一:
演示下使用ArrayList和CopyOnWriteArrayList迭代时进行修改操作

首先使用ArrayList

	public static void main(String[] args) {
   
        ArrayList<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        list.add("4");
        list.add("5");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()){
   
            System.out.println("list is: "+list);
            String next = iterator.next();
            System.out.println("cur is: "+next);

            if (next.equals("2")){
   
                list.remove("5");
            }
            if (next.equals("3")){
   
                list.add("find 3");
            }
        }
    }

运行抛出异常

list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
	at java.util.ArrayList$Itr.next(ArrayList.java:859)
	at collection.CopyOnWriteArrayListDemo.main(CopyOnWriteArrayListDemo.java:27)

将ArrayList修改为CopyOnWriteArrayList

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

运行结果如图

list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
cur is: 3
list is: [1, 2, 3, 4, find 3]
cur is: 4
list is: [1, 2, 3, 4, find 3]
cur is: 5

可以很惊奇的发现,最后一个是cur is:5而不是预想的find 3
CopyOnWriteArrayList在迭代的时候如果有修改是不可见的,会保持开始迭代时的内容
案例二:演示迭代时迭代数据的确定时间

创建一个迭代器之后对容器进行修改,然后再创建一个迭代器,打印两个迭代器的数据

	public static void main(String[] args) {
   
        CopyOnWriteArrayList<Integer> list =
                new CopyOnWriteArrayList<>(new Integer[]{
   1, 2, 3});
        Iterator<Integer> iterator1 = list.iterator();
        list.add(4);
        Iterator<Integer> iterator2 = list.iterator();
        iterator1.forEachRemaining(System.out::print);
        System.out.println();
        iterator2.forEachRemaining(System.out::print);
    }

打印结果如下:

123
1234

从结果可以得知,迭代器的数据在迭代器生成时就已经确定了,对生成迭代器之后的数据修改时不可见的

源码分析

1. 新增
新增包括新增到数组尾部,新增到数组某一个索引位置,批量新增等等,操作的思路都是那四步:加锁、拷贝、操作后赋值、解锁

新增到数组尾部的源码

// 添加元素到数组尾部
public boolean add(E e) {
   
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
   
        // 得到所有的原数组
        Object[] elements = getArray();
        int len = elements.length;
        // 拷贝到新数组里面,新数组的长度是 + 1 的,因为新增会多一个元素
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 在新数组中进行赋值,新元素直接放在数组的尾部
        newElements[len] = e;
        // 替换掉原来的数组
        setArray(newElements);
        return true;
    // finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁   
    } finally {
   
        lock.unlock();
    }
}

从源码中可以看出,整个add过程都在持有锁的状态下进行的,通过锁保证了只能有一个线程同时对一个数组进行add操作

add过程中会创建一个老数组长度+1的新数组,然后把老数组的值拷贝到新数组内,再添加值到尾部

question:为什么加锁了不在原数组直接操作呢?

  1. volatile关键字修饰的是数组的引用,如果只是修改数组内元素的值是无法触发可见性的,必须修改数组的地址,也就是对数组进行重新赋值才能使修改内容对其他线程可见
  2. 在新数组上进行拷贝,对老数组没有影响,保证了修改过程中,其他线程可以访问原数据

新增到指定下标位置的源码:

// len:数组的长度、index:插入的位置
int numMoved = len - index;
// 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
if (numMoved == 0)
    newElements = Arrays.copyOf(elements, len + 1);
else {
   
// 如果要插入的位置在数组的中间,就需要拷贝 2 次
// 第一次从 0 拷贝到 index。
// 第二次从 index+1 拷贝到末尾。
    newElements = new Object[len + 1];
    System.arraycopy(elements, 0, newElements, 0, index);
    System.arraycopy(elements, index, newElements, index + 1,
         numMoved);
}
// index 索引位置的值是空的,直接赋值即可。
newElements[index] = element;
// 把新数组的值赋值给数组的容器中
setArray(newElements);

从源码可以看出,如果插入的位置是数组末尾,只需要拷贝一次。当插入的位置是中间,就会把原数组分成两部分进行复制,然后添加新值到新数组

2. 删除

指定数组索引位置删除的源码:

// 删除某个索引位置的数据
public E remove(int index) {
   
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
   
        Object[] elements = getArray();
        int len = elements.length;
        // 先得到老值
        E oldValue = get(elements, index);
        int numMoved = len - index - 1;
        // 如果要删除的数据正好是数组的尾部,直接删除
        if (numMoved == 0)
            setArray(Arrays.copyOf(elements, len - 1));
        else {
   
            // 如果删除的数据在数组的中间,分三步走
            // 1. 设置新数组的长度减一,因为是减少一个元素
            // 2. 从 0 拷贝到数组新位置
            // 3. 从新位置拷贝到数组尾部
            Object[] newElements = new Object[len - 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
   
        lock.unlock();
    }
}

步骤分为三步:

  1. 加锁

  2. 判断索引位置

    • 如果删除在数组尾部,直接复制长度为len-1的数组返回
    • 如果删除数据在中间,创建长度为len-1的新数组,分两段复制到新数组
  3. 解锁

批量删除的源码:

// 批量删除包含在 c 中的元素
public boolean removeAll(Collection<?> c) {
   
    if (c == null) throw new NullPointerException();
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
   
        Object[] elements = getArray();
        int len = elements.length;
        // 说明数组有值,数组无值直接返回 false
        if (len != 0) {
   
            // newlen 表示新数组的索引位置,新数组中存在不包含在 c 中的元素
            int newlen = 0;
            Object[] temp = new Object[len];
            // 循环,把不包含在 c 里面的元素,放到新数组中
            for (int i = 0; i < len; ++i) {
   
                Object element = elements[i];
                // 不包含在 c 中的元素,从 0 开始放到新数组中
                
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值