【架构师面试-JUC并发编程-4】-并发容器

1. 并发容器

1:ConcurrentHashMap

线程安全的HashMap

2:CopyOnWriteArrayList:线程安全的List

3:BlockingQueue

这是一个接口,表示阻塞队列,适用于作为数据共享的通道。

4:ConcurrentLinkedQueue

高效的非阻塞并发队列,使用链表实现。可以看做一个线程安全的LinkedList

2. ConcurrentHashMap

1:结构图

1.7结构图

Java7中的ConcurrentHashMap最外层是多个segment,每个segment的底层数据结构与HashMap类似,仍然是数组和链表组成的拉链法。

每个segment独立上ReentrantLock锁,每个segment之间互不影响,提高并发效率。

默认有16个segment,最多可以同时支持16个线程并发写(操作分别分布在不同的Segment上)。这个默认值可以在初始化时设置,但一旦初始化以后,就不可以再扩容了。

 

1.8结构图

ConcurrentHashMap是一个存储 key/value 对的容器,并且是线程安全的。  

改进一: 取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

改进二: 将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。查询的时间复杂度可以降低到O(logN)

这是经典的数组加链表的形式。 并且在链表长度过长时转化为红黑树存储( Java 8 的优化) , 加快查找速度。

存储结构定义了容器的“形状”, 那容器内的东西按照什么规则来放呢? 换句话讲, 某个 key 是按照什么逻辑放入容器的对应位置呢?

总结

1、 ConcurrentHashMap 采用数组+链表+红黑树的存储结构

2、 存入的Key值通过自己的 hashCode 映射到数组的相应位置

3、 ConcurrentHashMap 为保障查询效率, 在特定的时候会对数据增加长度, 这个操作叫做扩容

4、 当链表长度增加到 8 时, 可能会触发链表转为红黑树(数组长度如果小于 64, 优先扩容)

2:put方法

主线流程梳理如下图:

这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述:

1、如果没有初始化,就先调用initTable()方法来进行初始化过程

2、如果没有hash冲突,就直接CAS插入

3、如果还在进行扩容操作,就先进行扩容

4、如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,

5、最后,如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环

6、如果添加成功,就调用addCount()方法统计size,并且检查是否需要扩容

3:spread方法

1:原理解释

哈希算法的逻辑, 决定 ConcurrentHashMap 保存和读取速度。 hash 算法是 hashmap 的核心算法, JDK 的实现十分巧妙, 值得我们学习

spreed方法源代码如下:

static final int spread(int h) {
return (h ^(h >>> 16)) & HASH_BITS;
}

传入的参数h为 key 对象的 hashCode, spreed 方法对 hashCode 进行了加工。 重新计算出 hash。 我们先暂不分析这一行代码的逻辑, 先继续往下看如何使用此 hash 值。

hash 值是用来映射该 key 值在哈希表中的位置。 取出哈希表中该 hash 值对应位置的代码如下。

tabAt(tab, i = (n - 1) & hash)

我们先看这一行代码的逻辑, 第一个参数为哈希表, 第二个参数是哈希表中的数组下标。 通过 (n - 1) & hash 计算下标。 n 为数组长度, 我们以默认大小 16 为例, 那么 n-1 = 15, 我们可以假设 hash 值为 100, 那么 15 & 100为多少呢? & 把它左右数值转化为二进制, 按位进行与操作, 只有两个值都为 1 才为 1, 有一个为 0 则为 0。 那么我们把 15 和 100 转化为二进制来计算, java中 int 类型为 8 个字节, 一共 32 个bit位。

n的值15转为二进制:

0000 0000 0000 0000 0000 0000 0000 1111

hash的值100转为二进制:

0000 0000 0000 0000 0000 0000 0110 0100。

计算结果:

0000 0000 0000 0000 0000 0000 0000 0100

对应的十进制值为 4

是不是已经看出点什么了? 15的二进制高位都为0, 低位都是1。 那么经过&计算后, hash值100的高位全部被清零, 低位则保持不变, 并且一定是小于(n-1) 的。 也就是说经过如此计算, 通过hash值得到的数组下标绝对不会越界。

2:数组大小必须为 2 的 n 次方

第一个问题的答案是数组大小必须为 2 的 n 次方, 也就是 16、 32、 64….不能为其他值。 因为如果不是 2 的 n 次方, 那么经过计算的数组下标会增大碰撞的几率, 例如数组长度为 21, 那么 n-1=20, 对应的二进制为:10100

那么hash值的二进制如果是 10000(十进制16) 、 10010(十进制18) 、 10001(十进制17) , 和10100做&计算后, 都是10000, 也就是都被映射到数组16这个下标上。 这三个值会以链表的形式存储在数组16下标的位置。 这显然不是我们想要的结果。

但如果数组长度n为2的n次方, 2进制的数值为10, 100, 1000, 10000……n-1后对应二进制为1, 11, 111, 1111……这样和hash值低位&后, 会保留原来hash值的低位数值, 那么只要hash值的低位不一样, 就不会发生碰撞。

其实如果数组长度为 2 的 n 次方, 那么 (n - 1) & hash 等价于 hash%n。

如果为了保证不越界为什么不直接用 % 计算取余数?

3:为什么不直接用hash%n

按位的操作效率会更高, 经过我本地测试, & 计算速度大概是 % 操作的 50 倍左右。所以 JDK 为了性能, 而使用这种巧妙的算法, 在确保元素均匀分布的同时, 还保证了效率。

4:get方法

ConcurrentHashMap的get操作的流程很简单,可以分为三个步骤来描述

1、计算hash值,定位到该table索引位置,如果是首节点符合就返回

2、如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回

3、以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

3:CopyOnWriteArrayList

1:实现原理

CopyOnWrite 思想:是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下。

collections/CopyOnWriteArrayListDemo.java

package collections.copyonwrite;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
/**
 * 描述:     对比两个迭代器
 */
public class CopyOnWriteArrayListDemo {
    public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
 
        System.out.println(list);
 
        Iterator<Integer> itr1 = list.iterator();
 
        list.remove(2);
        Thread.sleep(1000);
        System.out.println(list);
 
        Iterator<Integer> itr2 = list.iterator();
 
        itr1.forEachRemaining(System.out::println);
        itr2.forEachRemaining(System.out::println);
 
    }
}

2:优缺点

1:优点

对于一些读多写少的数据,写入时复制的做法就很不错,例如配置、黑名单、物流地址等变化非常少的数据,这是一种无锁的实现。可以帮我们实现程序更高的并发。

CopyOnWriteArrayList 并发安全且性能比 Vector 好。Vector 是增删改查方法都加了synchronized 来保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而 CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于 Vector。

2:缺点

数据一致性问题。这种实现只是保证数据的最终一致性,在添加到拷贝数据而还没进行替换的时候,读到的仍然是旧数据。

内存占用问题。如果对象比较大,频繁地进行替换会消耗内存,从而引发 Java 的 GC 问题,这个时候,我们应该考虑其他的容器,例如 ConcurrentHashMap。

4:并发队列

1:为什么要用队列

使用队列可以在线程间传递数据:生产消费者模式,银行转账。

队列中的读写等线程安全问题由队列负责处理。

2:常用并发队列

3:阻塞队列

阻塞队列的一端是给生产者放数据用,另一端给消费者拿数据用。阻塞队列是线程安全的,所以生产者和消费者都可以是多线程的。

take()方法获取并移除队列的头结点,一旦执行take时,队列里无数据则阻塞,直到队列里有数据。

put()方法是插入元素,但是如何队列已满,则无法继续插入,则阻塞,直到队列中有空闲空间。

是否有界(容量多大),这是非常重要的属性,无界队列Integer.MAX_VALUE,认为是无限容量。

4:ArrayBlockingQueue

有界,可以指定容量

公平:可以指定是否需要保证公平,如果想要保证公平,则等待最长时间的线程会被优先处理,不过会带来一定的性能损耗。

场景:有10个面试者,只有1个面试官,大厅有3个位子让面试者休息,每个人面试时间10秒,模拟所有人面试的场景。

collections/ArrayBlockingQueueDemo

package collections.queue;
 
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
 
public class ArrayBlockingQueueDemo {
 
 
    public static void main(String[] args) {
 
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<String>(3);
 
        Interviewer r1 = new Interviewer(queue);
        Consumer r2 = new Consumer(queue);
        new Thread(r1).start();
        new Thread(r2).start();
    }
}
 
class Interviewer implements Runnable {
 
    BlockingQueue<String> queue;
 
    public Interviewer(BlockingQueue queue) {
        this.queue = queue;
    }
 
    @Override
    public void run() {
        System.out.println("10个候选人都来啦");
        for (int i = 0; i < 10; i++) {
            String candidate = "Candidate" + i;
            try {
                queue.put(candidate);
                System.out.println("安排好了" + candidate);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            queue.put("stop");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
 
class Consumer implements Runnable {
 
    BlockingQueue<String> queue;
 
    public Consumer(BlockingQueue queue) {
 
        this.queue = queue;
    }
 
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        String msg;
        try {
            while(!(msg = queue.take()).equals("stop")){
                System.out.println(msg + "到了");
            }
            System.out.println("所有候选人都结束了");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不要迷恋发哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值