适可而止,见好就收

来源主要是牛客的Java实习面经。下面的回答直接背就可以,需要一定的Java基础,适合春招实习的同学,但是我会在每个问题下把有助于理解的博客贴出来。如果发现有问题欢迎私聊我或留言我会在下面更新

Map

1. Map的底层结构

腾讯19年秋招

这个题乍一看没有什么思路(因为Map是个集合,当然也有可能是我记错了),所以我们可以先介绍一下Map然后转到HashMap中

Map是一种使用键值对存储的集合。Map会维护与Key有关联的值。两个Key可以引用相同的对象,但Key不能重复,典型的Key是String类型,但也可以是任何对象。

在整个Map系列中,AbstractMap抽象类实现了Map,SortedMap接口继承了Map。而我们常用的HashMap,HashTable,TreeMap和ConcurrentHashMap有继承了AbstractMap类。

其中,HashTable和ConcurrentHashMap是线程安全的。前者是通过synchronized实现的,后者是通过AQS实现的。其中要注意HashTable不能存空值,HashMap是线程不安全的,key可以为空。TreeMap通过二叉树算法实现有序集合,它实现了SortedMap接口

2. HashMap的原理

阿里17年实习,小米19年秋招本科,滴滴19年秋招本科,网易19年秋招本科,bigo19年秋招本科,百度19年秋招本科

对于HashMap的构造函数来说,它有三个重要参数,分别是threshold,loadFactor和initialCapacity,根据阿里巴巴开发手册,由于默认的loadFactor是0.75,所以initialCapacity=(need/loadFactor)+1。而threshold=capacity*loadFactor。这也和阿里手册上的相对应。但是threshold在初始化时并不是容量和负载因子相乘,而是调用了一个tableSizeFor(int cap)使得阈值大于或等于初始容量的最小2的幂

  • 对于HashMap的数据结构来说,底层是基于散列算法实现,散列算法分为散列再探测和拉链式。HashMap 则使用了拉链式的散列算法,即采用数组+链表来解决hash冲突,数组是HashMap的主体,链表主要用来解决哈希冲突。这个数组是Entry类型,它是HashMap的内部类,每一个Entry包含一个key-value键值对。
  • 对于get方法来说,会先查找桶,如果hash值相同并且key值相同(先判断hash,再判断equals,这也说明了为什么重写equals必须要重写hashCode),则返回该node节点,如果不同,则当node.next!=null时,判断是红黑树还是链表,之后根据响应方法进行查找。
  • 对于keySet的遍历来说,首先要获取键集合KeySet对象,然后再通过KeySet 的迭代器KeyIterator进行遍历。KeyIterator 类继承自HashIterator类,再通过HashIterator#nextNode()进行遍历。HashIterator 的逻辑并不复杂,在初始化时,HashIterator 先从桶数组中找到包含链表节点引用的桶。然后对这个桶指向的链表进行遍历。遍历完成后,再继续寻找下一个包含链表节点引用的桶,找到继续遍历。找不到,则结束遍历
  • 对于插入来说,即put方***调用V putVal(int, K, V, boolean, boolean) 方法执行核心逻辑
    • 插入流程,当桶数组为空时,通过resize将threshold赋值给容量。如果桶中不包含键值对节点的引用,则将插入的键值存入桶中即可;如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对,是否要覆盖还需看onlyIfAbsent;如果桶中的引用类型为TreeNode,则调用红黑树的插入方法;如果桶中的引用为链表,则把值插入到链表尾结点,并检查链表长度,如果长度大于等于8,则把链表转为红黑树。之后再检查是否允许覆盖原值。最后一步是看size是否超过threshold,如果超过则用resize扩容
    • 对于插入时的扩容来说,HashMap按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍(如果计算过程中,阈值溢出归零,则按阈值公式重新计算)。扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。源码中一共有三步:
      1. 计算新桶数组的容量 newCap(old<<1)*和新阈值 newThr(oldThr<<1)*;
      2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的;
      3. 将键值对节点重新映射到新的桶数组里。如果节点是TreeNode类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
    • 对于JDK8中的红黑树优化来说,树化要满足两个条件:1. 链表长度大于等于8;2. 桶数组容量大于等于 64。由于HashMap设计之初没有实现比较方法,所以在转红黑树的时候需要先比较hash,如果key实现了Comparable接口,则通过compareTo方法比较,否则通过仲裁方法比较。需要注意的是,虽然链表转换成了红黑树,但是都保留了在链表中每个节点的前置节点和后置节点。正因为如此,在红黑树拆分的中,对红黑树进行重新映射时,完全可以按照映射链表的方式进行。这样就避免了将红黑树转成链表后再进行映射,无形中提高了效率 。同时红黑树链化的时候直接转换成节点就成,方便了很多。我们需要注意,当桶(bucket)上的结点数小于6时树才转链表
    • HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。当然如果是红黑树,则在删除过程中需要重建红黑树

HashMap 的结构,为什么非线程安全,为什么容量是 2 的次幂

小米19年秋招本科

总体上,HashMap是数组+链表的形式。在JDK8中,当链表中元素数目超过8个,就会自动转为红黑树,进一步提高查找效率

  • 之所以说HashMap是非线程安全的,一方面是并发操作的时候会引起数据不一致。第二点就是在多线程环境中put时的rehash会造成元素之间会形成一个循环链表(JDK7)
  • 之所以是2的次幂,是因为在查找桶的时候会通过(n - 1) & hash 来取模,(其中n-1是长度)。如果是容量是2次幂的话用&可以代替&,效率更高。同时再hash的过程中(h = key.hashCode()) ^ (h >>> 16)会将原来的hash异或右移16位的hash,原因是因为当容量不超过16位时,也能利用上原来hash后的高位的值

HashMap并发下的死循环

详细说一下 Hashmap 的 put 过程

pdd19年秋招本科

插入链表的时候是后插

关于put方法,前面原理有细说

1.7和1.8的区别

  • hash方法不同,8中使用了hashCode^hashCode>>>16,而7中只扰动了四次

  • put方法不同,7中只是链表,8中又加了红黑树。最主要的是8中引入了红黑树

  • 扩容方式也不同,对于链表来说,JDK7扩容时是直接重新把元素hash之后put到新桶之中,而JDK8是先将链表分组,然后放到新桶之中。具体分组方式是计算节点的hash & oldCap,如果为0则放入loHead和lotail中,反之则放入hiHead和hiTail中,之后把这两条分好组的链表放入新桶中

HashMap中的概念

3. 如何用HashMap实现Redis

科大讯飞19年秋招本科

众所周知,Redis底层映射了一个大的数据表就是Hash,这个题超出了我的想象范围

弊端:容量有限制;不支持一些特殊的数据结构;并发可能出问题;无法持久化/持久化代价较大;扩容的时候更耗时,redis是渐进式扩容

4. HashMap和TreeMap的区别

pdd19年秋招本科

  • TreeMap在继承了AbstractMap的基础上,又实现了SortMap接口。所以TreeMap是按照一定规则排过序的。 它默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。同时在JDK1.8中也是用的红黑树进行排序。同时,TreeMap的key不能为空,value可以为空。比较器Comparator要么在创建时指定,要么key需要实现Comparable接口的compareTo方法

  • HashMap因为没有排序所以要更快,它的key和value都可以为空

5. LinkedHashMap应用场景

pdd19年秋招

  • LinkedHashMap继承了HashMap,同时在红黑树的基础上增加了一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。 除此之外,LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性很有用,比如缓存或者其他先来后到的场景。在实现上,LinkedHashMap 很多方法直接继承自 HashMap,仅为维护双向链表覆写了部分方法
  • 至于应用场景,可以通过LinkedHashMap实现一个LRU策略的缓存。LinkedHashMap中有一个方法为afeterNodeInsert(),该方***在节点处插入后删除一个节点,而要删除存活时间最长的节点的条件则通过我们自己覆盖方法removeEldestEntry,我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点的存活时间判断是否移除该节点等

6. 讲一下ConcurrentHashMap

字节跳动秋招,阿里19年实习,阿里19年秋招本科,pdd19年实习,滴滴19年秋招本科,bigo19年秋招本科

这个题即和集合相关,又和并发相关。并发中没有说,放到这里来细说

ConcurrentHashMap有一个特别的字段sizeCtl,主要用来控制table的初始化和扩容的操作,不同的值有不同的含义。当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容;当为0时:代表当时的table还没有被初始化;当为正数时:表示初始化或者下一次进行扩容的大小

对于JDK1.8来说,它每次锁只是锁了链表或者红黑树的头结点,大大地降低了锁的粒度。其基本的数据节点是Node,它就是一个链表,但是只允许对数据进行查找,不允许进行修改。TreeNode继承与Node,但是数据结构换成了二叉树结构,它是红黑树的数据的存储结构,用于红黑树中存储数据。TreeBin类用来封装TreeNode,提供转换黑红树的一些条件和锁的控制。和HashMap一样,ConcurrentHashMap是在第一次put的时候初始化容量的

在put时,会对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 如果没有hash冲突就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容,通过helpTransfer()调用多线程一起扩容,真正的扩容方法是transfer(),通过参数ForwardingNode支持扩容操作,将已处理的节点和空节点置为ForwardingNode,并发处理时多个线程经过ForwardingNode就表示已经遍历了,就往后遍历
  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
  5. 最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

对于get来说,可以分为三个步骤来描述

  1. 计算hash值,定位到该table索引位置,如果是首节点符合就返回
  2. 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
  3. 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

看过源码吗?说一下1.7和1.8的结构

  • 在JDK1.7的时候,ConcurrentHashMap采用分段锁对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。Segment继承了ReentrantLock,默认有16个Segment,允许16个线程并发执行,Segment由于采用位运算,所以个数永远是2的N次方,最大值为65536(2^16)。每个Segment维护一个桶数组。
    • 对于put来说,需要进行两次hash来定位数据的储存位置。 从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒
    • 对于get来说,ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null
    • 对于size放来来说,因为他是并发操作的,就是在计算size的时候,他还在并发的插入数据,可能会导致计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案。1. 是使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的; 2. 如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回
  • DK1.8 摒弃了Segment的概念,而是直接用Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化)整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本

总的来说,1.7和1.8有以下区别

  1. JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  2. JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  3. JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
  4. JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
    • 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
    • JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
    • 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

为什么它是线程安全的

小米19年秋招本科

之所以是线程安全,1. 是因为在对ConcurrentHashMap进行操作时候是通过synchronized 和 CAS来保证的,它的锁粒度是针对于每一个Node节点的。2. 内部定义了一些静态变量如sizeCtl等来使多个线程检查是否正在初始化,如果在初始化则调用Thread.yield()方法。3. 同时,对于扩容来说,如果hash之后等于MOVED,则在1.8中会使用多个线程来一起扩容,同时当在进行数组扩容的时候,如果当前节点还没有被处理(也就是说还没有设置为fwd节点),那就可以进行设置操作。如果该节点已经被处理了,则当前线程也会加入到扩容的操作中去。4. 对于put时,没有hash冲突,则使用CAS插入,如果产生hash冲突,则此时应该要加锁(锁的是链表或者红黑树的头结点)

ConcurrentHashMap

ConcurrentHashMap源码分析

7. HashMap和HashTable

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

Set

7. 说一下set

猪场19年秋招本科

Set是一种不允许重复的集合,不会有多个元素引用相同的对象。同时,从结构上来说,Set继承了Collection,和Collection有同样的方法。Set内部基本都是有Map实现的。主要有三种:

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSet: LinkedHashSet 继承于 HashSet,并且其内部是通过 LinkedHashMap 来实现的
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

Set和List的区别

  • Set和List都继承了Collection接口,但是Set和Collection的方法一样,List比Collection多了几个方法
  • List特点的元素可重复;但是Set的元素不可重复,重复元素会覆盖掉
  • List支持下标和迭代器遍历,但是Set只能通过迭代器遍历
  • List有序,但Set是一个无序容器,无法保证每个元素的存储顺序,可TreeSet通过Comparator或者 Comparable维护了一个排序顺序

8. 说一下HashSet

阿里19年秋招

HashSet的底层是基于HashMap实现的,除了 clone()writeObject()readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法

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

在将对象存储在 HashSet 之前,要先确保对象重写 equals()和 hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现,默认的hashCode比较的是对象的地址,这样对业务来说非常不友好

HashSet的应用场景

华为19年社招

快速查找并且唯一的集合场景

9. TreeSet和HashSet区别

滴滴19年秋招本科

  • TreeSet是通过TreeMap实现的,HashSet是通过HashMap实现的
  • Treeset中的数据是自动排好序的,不允许放入null值。HashSet可以放入null值
  • TreeSet和HashSet一样,也有排序,HashMap没有排序,HashMap的性能比TreeSet的性能略高

List

10. 说一下list

猪场19年秋招本科

  • 元素有放入顺序,元素可重复
  • 有三个常用的类ArrayList,LinkedList和Vector,其中Vector是线程安全的

11. ArrayList的底层原理?怎么扩容?

网易19年秋招本科

ArrayList底层是用Object数组实现,有三个构造方法,一个有参,参数是初始容量。一个无参。最后一个是通过Collection集合构造。ArrayList的默认容量是10

对于插入来说,有两种方式,一种是在尾部插入,另一种是在指定位置插入。在尾部插入时,先检查是否扩容,然后再调用 elementData[size++] = e将元素插入数组的尾部;第二种情况是在指定位置插入时,先检查是否扩容,然后将index之后的所有元素向后移一位,最后将新元素插入index处

对于ArrayList的扩容机制来说,当空间用完(minCapacity>element.length)*,其会按照原数组空间的1.5倍进行扩容(如果1.5倍还不够,就按明确要求的尺寸扩容)*。一般扩容之前需要调用add()方法,首先调用了ensureCapacityInternal(size + 1) 。需要强调的是,如果我们已知将要插入大量数据时,可以先调用 ensureCapacity() 方法来提前扩大容量。

ArrayList还具有手动缩容的办法,如果在大量插入又大量删除之后,我们可以考虑调用trimToSize()来缩容。

在ArrayList我们需要知道在add,toArray等方法中都用到了System.arraycopy()Arrays.copyOf()方法

  • 在调用add进行指定位置插入时会调用System.arraycopy()方法:System.arraycopy(elementData, index, elementData, index + 1, size - index);

    elementData:源数组;index:源数组中的起始位置;elementData:目标数组;index + 1:目标数组中的起始位置; size - index:要复制的数组元素的数量;

  • 在调用toArray方法的时候,会调用Object[] Arrays.copyOf(elementData, size);方法。可以通过该方法对原数组进行扩容

  • Arrays.copyOf底层使用System.arraycopy()实现的 。arraycopy() 需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf() 是系统自动在内部新建一个数组,并返回该数组

12. ArrayList如何变得线程安全

华为19年社招

  • 调用Collections.synchronizedList(list)
  • 为ArrayList的方法加上锁
  • 使用CopyOnWriteArrayList
  • 使用ThreadLocal

COW和ReadAndWriteLock

13. ArrayList&LinkedList

pdd19年秋招本科,京东19年秋招本科

  • 都是线程不安全的, ArrayList使用在查询比较多o(1),但是插入和删除o(n-i)比较少的情况,而LinkedList用在查询o(n)比较少而插入删除o(n)(n是因为需要定位)比较多的情况 。
  • LinkedList底层使用的是双向链表(JDK6之前是双向循环链表)
  • LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)
  • ArrayList的空间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)

15. ArrayList的源码各种方法

滴滴19年秋招本科

根据第11题对ArrayList源码的分析,经常使用的方法有

  • add(),先检查是否需要扩容,扩容时的miniCapacity为size+1,如果容量小于miniCapacity,则将容量扩大到1.5倍
  • remove()。如果参数是下标的话,则删除该下标的值,如果参数是元素的话,则删除指定元素,若元素重复,则只删除下标最小的元素
  • toArray(),底层通过System#arraycopy()方法实现,这个方法贯穿了add,remove方法
  • ensureCapacity(),提前手动扩容,在插入大量数据之前使用
  • trimToSize(),手动缩容,在ArrayList中数组有大量浪费空间时使用

Queue

16. 了解什么队列

滴滴19年秋招本科

这个题本应该在并发中说的,因为队列往往和线程池相关。线程池常用的三种阻塞队列是ArrayBlockingQueue,LinkedBlockingQueue和SynchronousQueue。BlockingQueue不接受空值

  • ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小
  • LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE
  • synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务

queue里面都有什么方法

根据JDK11文档的描述:queue中的方法有四 种形式,分别是抛出异常,返回特殊值,无限期阻塞当前队列知道操作成功,在放弃之前只阻塞给定的最大时间限制

<caption>Summary of BlockingQueue methods</caption>
Throws exception Special value Blocks Times out
Insert {add(e)} {offer(e)} {put(e)} {offer(e, time, unit)}
Remove {remove()} {poll()} {take()} { poll(time, unit)}
Examine {element()} {peek()} not applicable not applicable

常用的方法有offer,put,addpoll

对于ArrayBlockingQueue来说,

手写下阻塞队列

这里还是让写生产者消费者模型,Java用的Condition实现的

public class MyBlockQueue {
    //队列容器
    private List<Integer> container = new ArrayList<>();
    private Lock lock = new ReentrantLock();
    //Condition
    //  队列为空
    private Condition isNull = lock.newCondition();
    // 队列已满
    private Condition isFull = lock.newCondition();
    private volatile int size;
    private volatile int capacity;

    MyBlockQueue(int cap) {
        this.capacity = cap;
    }

    public void add(int data) {
        try {
            lock.lock();
            try {
                while (size >= capacity) {
                    System.out.println("队列已满,释放锁,等待消费者消费数据");
                    isFull.await();
                }
            } catch (InterruptedException e) {
                isFull.signal();
                e.printStackTrace();
            }
            ++size;
            container.add(data);
            isNull.signal();
        } finally {
            lock.unlock();
        }
    }

    public int take(){
        try {
            lock.lock();
            try {
                while (size == 0){
                    System.out.println("阻塞队列空了,释放锁,等待生产者生产数据");
                    isNull.await();
                }
            }catch (InterruptedException e){
                isFull.signal();
                e.printStackTrace();
            }
            --size;
            int res = container.get(0);
            container.remove(0);
            isFull.signal();
            return res ;
        }finally {
            lock.unlock();
        }
    }
}