2.1java有哪些集合类?

java中的集合类主要是由Collections接口和Map接口派生出来的,其中Collections接口又派生是三个子接口分别是:List、Set、Queue接口,所有的java集合类都是Set、List、Queue、Map接口的实现类。
List:表示有序的、可重复元素的集合。
Set:表示无序的、不可重复元素的集合。
Queue:表示先进先出的队列。
Map:表示key无序且不可重复,具有映射关系的key-value键值对集合。
image.png
image.png

2.2java中的容器,线程安全和线程不安全的分别有哪些?

java.util包下的集合类大多数都是线程不安全的,比如我们经常使用的 ArrayList,LinkedList,hashSet,hashMap,treeMap,ArrayQueue等等,这些集合类因为是线程不安全的,所以性能相对比线程安全的集合类更好。
(1)如果需要使用线程安全的集合类,可以使用Collections.synchronizedXxx()方法将线程不安全的集合类包装成一个线程安全的包装类。
(2)当然java古老的api中Vector与hashTable也是线程安全的集合类,但是他们的性能很差。通常需要使用线程安全的集合类,也通常不考虑这些古老api。
(3)java5以后,java.util.concurrent包下提供了大量的线程安全且性能较高,支持并发访问的集合类。以Concurrent开头的集合类代表了支持并发访问的集合,支持一个或多个线程并发写入访问,同时读取操作不加锁。以ConcurrentHashMap为例,多个线程同时进行put操作时,在初始化数组时使用了乐观锁cas操作来决定哪个线程有资格进行初始化,其他线程只能等待。使用volatile关键字来修饰sizeCtl变量,告诉其他线程这个坑位有没有线程已经在操作了,可见性问题由volatile来保证。cas操作保证了只有一个线程能设置成功。

2.3Map接口有哪些实现类?

Map接口很多实现类,常用的有HashMap、TreeMap、ConcurrentHashMap、LinkedHashMap等。
在不需要保证线程安全的场景下,对于不需要排序的情况,优先考虑使用HashMap,因为它是性能最好的Map实现。如果需要保证线程安全,则可以使用ConcurrentHashMap,它的性能优于HashTable。
对于需要排序的场景下,如果需要按照插入排序进行排序的话,可以使用LinkedHashMap。如果需要定制排序的话,可以使用TreeMap。如果上述场景需要保证线程安全,可以使用Collections工具类将上述的实现类包装成线程安全的Map。

2.4描述一下Map put的场景

以jdk1.7为例,当初始化一个hashMap时,底层创建一个长度为16的数组。
调用put方法时需要传入key,value键值对,底层首先调用传入这个key的hashCode()方法,推算出这个键值对在底层entry[] table数组中的存储位置,如果这个位置上没有其他元素则添加成功。如果这个位置上存在一到多个以链表形式存在的元素,底层依次调用他们hashCode()方法来比较put的key是否与已存在元素的key哈希值相同,如果返回false,就添加在链表头上。如果返回true,就调用这俩key的equals()方法,如果返回false,也添加成功,添加在链表头上。如果返回true,就用传入元素的value替换原来的value。在不断put操作中,会涉及到扩容问题,默认的扩容方式是扩容为原来容量的2倍,并把原来的数据copy过来。
在jdk1.8以后,因为在多线程环境下使用头插法会导致死链问题,就改用了尾插法的方式。另外初始化数组时是首次调用put方式时才进行初始化的。
jdk1.7的底层结构是数组+链表,而在jdk1.8以后底层结构是数组+链表+红黑树,当数组的某一个索引位置上链表的元素>=8,会把这个链表上所有元素改为使用红黑树的结构来存储,链表转变成树之前,还会有一次判断,只有数组长度大于等于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。如果数组中某个索引位置上元素<6时又会转换为链表,前提是它的红黑树结构。
特点:
(1)HashMap是线程不安全的实现。
(2)HashMap可以使用null作为key或者value。

2.5介绍一下HashMap的扩容机制

以jdk1.8为例
(1)当首次调用put方法时,底层会创建一个长度为16的entry数组,扩容时的容量是以2的幂次方进行的,一是为了提供性能使用足够大的数组,二是为了能使用位运算替换效率较低的取模运算。
(2)数组是否需要扩容是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩容数组。这个0.75是默认的负载因子,可由构造器传入,我们当然也可以设置大于1的负载因子,这样数组就不会扩容,牺牲性能,节省内存。
(3)为了解决哈希冲突,同一个哈希桶中的key-value键值对元素以链表的形式来组织。当链表长度到达一个阈值时(当单向链表中的元素个数达到8个时),会将链表转换成红黑树提升性能。而当红黑树元素个数缩小到另一个阈值(6时),又会将红黑树转换回单向链表提升性能。
(4)在检查链表转换成红黑树之前,还会先检查当前数组中所有元素个数是否达到阈值(64),如果没有达到这个容量,会放弃转换,先去扩容数组。

2.6为什么HashMap的长度都是2的幂次方?

(1)定位哈希桶下标的时候调用hashcode(),hashcode的范围值比较大,使用之前需要先对数组长度进行取模运算,得到的余数才是元素存放的桶下标。
(2)桶下标的计算方式是(n-1)&hashcode;
(3)将HashMap的数组长度规定为2的幂次方一来是为了提升性能使用足够大的数组,二来是为了使用效率高的位运算替换效率低的取模运算。
使用(n-1)&hash位运算的好处是,不需要重新计算一次哈希值。以16扩容为32位例。
image.png

2.7为什么HashMap的默认负载因子是0.75?

(1)0.75是对内存空间和时间效率的一个平衡选择,根据泊松分布,负载因子取0.75的情况下,链表达到8个元素的概率是非常小的,这样不会频繁得在链表与红黑树之间相互转换而造成性能损耗,同时也能够解决特殊情况下链表元素达到8个时间复杂度是O(n)的低性能问题。
(2)如果内存资源充足,并且对时间复杂度要求高的场景下,可以调低负载因子的值。
(3)如果内存资源紧张,并且对时间复杂度要求不高的场景下,可以增加负载因子的值。把负载因子的值设置成>1,就不会触发扩容,以时间换空间。

2.8说说HashSet的底层原理?

HashSet的底层就是通过HashMap来实现的,value存放的是一个Object类型的静态常量用来占位。之所以使用静态常量而不使用null值,是因为调用remove()返回的布尔值也是使用的Map的逻辑来实现的,如果使用null来占用的话,不管删除什么元素它是否存放都会返回true。

2.9说说HashMap为什么线程不安全?

hashMap的线程不安全问题主要是体现在死链,数据丢失和数据覆盖的问题上。
其中死循环和数据丢失主要体现在jdk1.7中,在jdk1.8中使用尾插法已经解决了死链和数据丢失的问题,但仍有数据覆盖的问题。
首先说jdk1.7中的死链问题吧,打个比方一个hashMap中已经有12个元素了,其中桶下标为1有元素1,35,16,每个元素都以链表相连;在扩容以后,还存在于桶下标1的元素有1,35;此时有两个线程同时对hashMap进行扩容操作,插入第13个元素50,就会触发hashMap的扩容机制。扩容操作会重新定位每个桶元素的下标,并采用头插法迁移到新数组中,头插法是后进来的元素位于链表头,也就是说头插法会将原来的链表反转,这也是形成死链的关键点。此时,线程1时间片用完,线程2去执行扩容和桶元素迁移的工作,线程2执行完扩容操作后,桶下标为1的元素有35->1,之前的元素16去了其他的桶下标去了。而此时线程1恢复运行,此时线程1根本就不知道此时的元素1链向null,35链向1;那此时线程1再去执行扩容操作,把1加到头加点,1.next是35,然后把35加到头节点,35.next又是1,35链着1,1又链着35就产生并发死链了。
image.png
image.png
而产生数据丢失的情况是这样的,还是原来的桶下标和元素,不过是1->16->35->null,16的元素被线程2扩容后进入其他的桶,此时桶下标扩容完以后的链表是35->1;而此时线程1恢复运行,而且元素1放入桶1,然后下次循环去放16,发现16.next是null,所以此次扩容结束,对于线程1和线程2,他俩有各自线程私有的newTable,其中2是正确的,线程2先执行了table=newTable进行赋值,线程1后执行,导致1覆盖了2,产生数据丢失。
jdk1.8中存在数据覆盖的问题,假设两个线程都在执行put操作,并且hash函数计算出插入元素桶下标相同,当线程1执行完判断如果没有hash碰撞则直接插入元素时的恰好由于时间片耗尽被挂起,而线程2得到时间片后在下标处插入了元素,完成了正常的插入操作,然后1线程获得时间片,由于之前判断没有哈希碰撞所以直接插入元素,使得线程2插入的元素被线程2的操作所覆盖,所以线程不安全。

2.10说说HashMap和HashTable的区别?

(1)HashTable是一个线程安全的Map实现,HashMap是线程不安全的Map实现,所以HashMap的性能会比HashTable高一点。
(2)HashTable不允许使用null作为key和value,如果试图把null值放入HashTable中,将会引发空指针异常,但HashMap可以使用null作为key或value。

2.11说说ConcurrentHashMap是怎么实现的?

【Jdk1.7为例】:
ConcurrentHashMap在jdk1.7中,底层采用segment数组+hashEntry数组+lock分段锁的方式来实现。对比HashTable或者是使用synchronized()方法包装得到的hashMap,直接锁表,jdk1.7采用的是分段锁的思想,减少锁的粒度。segment数组长度固定为16并且不可变,而且在初始化map时就会初始化数组。
Segment是ConcurrentHashMap的静态内部类,并且Segment继承了ReentrantLock。每个Segment里面拥有自己的HashEntry类型的Table数组,hashEntry作为Segment的成员变量,使用了volatile关键字来修饰,保证多线程环境下的可见性问题。并且这个Table数组也有阈值门限和负载因子等等。所以每个Segment内部相等于一个带了锁的HashMap,然后一个Segment数组就相当于是多个带锁的HashMap。对比我们之前将所有的K-V放入一个HashMap中,这样如果一个线程获得了整个HashMap的锁,别的线程就不能做其他任何的K-V操作了,这样锁的粒度就非常大。
Jdk1.7的concurrentHashMap,当执行put()方法插入数据时,根据key的hash值,进行移位和掩码运算,计算它在Segment数组中存储位置,如果Segment中的table数组还未初始化,则通过CAS进行数组的初始化,接着通过加锁机制来往table数组中插入数据。在执行put方法时,需要先获取该Segment的独占锁。如果在该segment插入元素时触发了扩容机制,会将该segment内部的hashEntry数组先扩容,再插值。该方法不需要考虑并发问题,因此是持有该Segment独占锁的线程自己完成的,其他线程都在外边等待。以上是写操作加了独占锁,而concurrentHashMap的底层读操作是不加锁的。需要注意的是在扩招操作中会遍历整个桶的所有链表的节点,尽可能把扩容后的链表节点重用,比如说7-1-3-5,重新计算的节点1-3-5依然在同一个桶中就会直接把节点1迁移过去,这点是hashmap的头插法不一样
当执行get()方法时,在扩容时如果get先发生就从旧table取元素,如果后发生就从新table取元素,在底层我们可以看到使用了很多Unsafe类的方法,因为需要保证可见性的问题,单单在segment数组和entry数组中使用volatile关键字还不够,因为需要获取的是桶中的元素。
(1) 首先获取value,我们要先定位到segment,使用了UNSAFE的getObjectVolatile具有读的volatile语义,也就表示在多线程情况下,我们依旧能获取最新的segment.
(2) 获取hashentry[],由于table是每个segment内部的成员变量,使用volatile修饰的,所以我们也能获取最新的table.
(3) 然后我们获取具体的hashentry,也时使用了UNSAFE的getObjectVolatile具有读的volatile语义,然后遍历查找返回.
【以jdk1.8为例】
jdk1.8的concurrentHashMap,底层摈弃了Segment数组的概念,而是采用Node数组+链表+红黑树的数据结构来存储元素。
concurrentHashMap的成员变量sizeCtl(下次扩容后的阈值大小,node[]数组,new Table[]数组都使用了volatile关键字来修饰,配合cas来保证线程安全。
get()方法全程不加锁,因为在扩容时,会从后往前处理每一个桶时,处理完的桶会给它加一个FwardingNode节点来表示该桶已经处理完成,如果其他线程想要读取这个桶中的元素发现头结点是FwardingNode就会去新的table中去get。
put(),因为底层node[]数组是懒惰初始化的,只有在第一次put()操作时,才会去初始化这个node[]数组,所以这个初始化操作会有并发问题,concurrentHashmap底层采用cas的操作判断如果数组为空或者数组长度为0,就去cas操作初始化这个数组,cas操作会尝试把sizeCtl改成-1,如果成功那当前线程就执行初始化逻辑,其他线程发现sizeCtl为-1,就会调用Thread.yield()方法,让出当前cpu使用权。如果数组不为空,根据key的哈希值计算桶下标,然后去看桶中链表头结点是否为空;如果链表头节点为空,用cas的操作添加链表头结点,如果这次cas成功,那就直接break,如果在这期间其他线程已经为该桶添加了头结点,那就继续进入下一次循环。如果头结点为FwardingNode,说明有线程正在为该数组进行扩容操作,我们知道在hashMap中有线程在为数组扩容时会产生并发问题,而在concurrentHashMap中会调用helpTranfer()方法锁住当前链表来帮忙扩容,在扩容操作中也是按桶来进行迁移操作的,如果桶中头节点为null就会设头结点为FwardingNode,如果头节点是FwardingNode就continue,如果非以上两种情况就使用synchronized锁链表头节点的方式来进行迁移操作,如果是链表就走链表迁移操作,如果是treeBin就走treeBin的。
另外还有一种情况是,put操作链表头节点既不为空,也不是FwardingNode,那这种情况就说明桶下标冲突了,桶下标冲突的话就会使用synchronized关键字来锁住链表头节点,进行put的逻辑操作,逻辑与hashMap的操作类似,如果key的hashCode、equals()方法都返回true那就替换原来的value,如果遍历到最后一个节点那就说明虽然哈希冲突但是key的当前链表元素的所有key都不相等,那就在末尾追加节点。

2.12jdk1.7的ConcurrentHashMap是怎么分段分组的?

get操作:
Segment的get操作实现非常简单高效,先经过一次hashcode()求出该key的哈希值,然后使用这个哈希值通过哈希运算运算定位到Segment,再通过哈希算法定位到元素。get操作的高效之处在于整个get过程都不需要加锁,除非读到空的值才会加锁冲读。原因就是将使用的共享变量定义成volatile类型。
put操作:
当执行put操作时,会经历两个步骤:
(1)判断是否需要扩容;
(2)定位到添加元素的位置,将其放入HashEntry数组中;
插入过程会进行一次key的hash来定位Segment的位置,如果该segment还没有初始化,通过cas操作来进行初始化,然后进行第二次hash操作,找到响应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时,会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。

2.13说说你对LinkedHashMap的理解

LinkedHashMap继承了HashMap,它在HashMap的基础上,通过维护一条双向链表,解决了HashMap不能随时保持遍历顺序与插入顺序一致的问题。在实现上,LinkedHashMap很多方法直接继承自HashMap,并且为维护双向链表重写了部分方法。
每当有的新的键值对节点插入时,tail.next=newNode,newNode.pre=tail;tail=newNode;
LinkedHashMap使用双向链表来维护key-value对的顺序,该链表负责维护Map的迭代顺序,迭代顺序与key-value的插入顺序保持一致。
LinkedHashMap需要维护元素的插入顺序,因此性能比HashMap稍微低一点。但因为它用链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。

2.14说说TreeMap的底层原理

TreeMap基于红黑树实现。映射根据其键的自然顺序进行排序,或者根据创建映射时提供的Comparator进行排序,具体取决于使用的构造方法。
TreeMap通过put、deleteEntry两个方法来实现红黑树增加、删除节点的。
【put方法】
在TreeMap的put()的实现方法中主要分为两个步骤,1:构造排序二叉树 2:平衡二叉树
构建排序二叉树的实现比较简单:
(1)以根节点为初始节点进行检索;
(2)与当前节点进行对比,如果新增节点值较大,则作为当前节点的右子节点;否则作为当前节点的左子节点;
(3)递归步骤2直到搜索出合适的叶子节点位置。
【红黑树的基本概念】
(1)每个节点只能是红色或黑色
(2)根节点是黑色
(3)每个叶子点(NIL节点,空节点)是黑色的
(4)如果一个节点是红色的,则它的两个子节点都是黑色的。也就是说在一条路径上不可以出现相邻的两个红色节点。
(5)从任一节点到每个叶子节点的所有路径都含有相同数目的黑色节点(也称为黑色完美平衡)
(6)新加入的节点一定是红色的,因为如果是黑色的,不管插入到哪里都会破坏黑色完美平衡。
【红黑树与AVL树的比较】
(1)红黑树的插入删除比AVL树更便于控制操作。
(2)红黑树整体性能稍高于AVL树。(因为红黑树的旋转情况少于AVL树)。
(3)如果在查询多,增删少的情况下,AVL树是优于红黑树的;但如果增删频繁,那红黑树绝对是优于AVL树的。
【红黑树的左旋、右旋、变色】
左旋:
(1)以某个节点作为固定支撑点(围绕该节点旋转)。
(2)记录固定支撑点右子节点的左子树temp。
(3)固定支撑点的右子节点变为旋转树的父节点。
(4)固定支撑点作为旋转树根节点的左子节点,它的左子树保持不变
(5)将固定支撑点的右子树设置为temp;
image.pngimage.png
右旋:
(1)以某个节点作为固定支撑点(围绕该节点旋转)。
(2)记录固定支撑点左子节点的右子树temp。
(3)固定支撑点的左子节点变为旋转树的父节点。
(4)固定支撑点作为旋转树根节点的右子节点,它的右子树保持不变
(5)将固定支撑点的左子树设置为temp;
image.png
image.png
变色:节点的颜色由红色变成黑色,或者是由黑色变成红色。

2.15ArrayList与LinkedList有什么区别?

(1)ArrayList的实现基于数组,LinkedList的实现基于双向链表;
(2)对于随机访问ArrayList要优于LinkedList,ArrayList实现了RandomAccess接口,支持快速随机访问。可以根据下标以O(1)的时间复杂度对元素进行随机访问,而LinkedList的每一个元素都依靠地址指针和它的后一个元素连接在一起,查找某个元素的时间复杂度是O(N);
(3)对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时候,都不需要像ArrayList那样重新计算元素数量,或者引发扩容机制,即使不扩容也需要将整个数组中的元素进行移动;
(4)LinkedList比ArrayList更占内存,因为LinkedList的节点除了从存储数据,还存储了两个引用,分别指向前继节点和后驱节点。

2.16说说ArrayList的数据结构

ArrayList底层是基于数组的数据结构来实现的,首次调用add()方法时,底层创建长度为10的数组,当数组长度超越阈值时,会引发扩容机制,扩容为原数组长度的1.5倍。打个比方,首次创建长度为10的数组,当往ArrayList中添加第11个元素时,就引发了扩容,newSize=oldSize+oleSize>>1;创建一个新的长度为15的数组,再把底层原来的数组的元素复制到新数组中,用新数组替换原来的旧数组。ArrayList的随机访问操作时间复杂度是o(1),按下标来删除和插入操作时间复杂度是o(n).
另外遍历ArrayList的时候,如果使用的是增强for循环或者是迭代器,不要在遍历的时候对List的结构进行增删改的操作,否则会报错。因为Java设计迭代器设计模式的时候,初衷是为了解耦容器代码与遍历代码,初始化ArrayList时,它会定义一个成员变量modCount,用来记录集合被修改的次数,每调用一次增加或删除元素的函数,modCount都会加1。当通过调用集合的iterator()方法来创建迭代器的时候,我们把modCount传递给迭代器的expectedModCount成员变量,之后调用迭代器上的hasNext()、next()、currentItem()方法时,都会检查modCount与expectedModCount是否相等。也就是说,在遍历的同时会检查,集合是否被修改过,modCount是否有变化,如果有变化就会抛出异常。所以这也是为什么普通for循环的执行效率比迭代器以及增强for循环效率高的原因了。

2.17有哪些线程安全的List?

(1)Vector是古老的api,虽然保证了线程安全,但是性能较低一般不推荐使用。
(2)使用Collections.synchronizedList()方法可以将线程不安全的List包装成线程安全的List,机制是使用加了synchronized重量级锁的方式来保证线程安全,但这种操作一般性能和吞吐量较低。
(3)使用java.util.concurrent包下的CopyOnWriteArrayList类来生成安全线程的List。
读取操作不加锁,增删改操作使用ReenTrantLock互斥锁来保证线程安全,ReentrantLock是它的成员变量,并且使用了final关键字来修饰。能够做到读读并发,读写并发,写写互斥,但CopyOnWriteArrayList适合用于读多写少的应用场景,因为频繁的写会导致不断创建新数组,用空间来换取线程安全。
因为读操作是没有加锁的,所以一直都能读取,但是会存在弱一致性的问题。写操作的时候,加入了ReentrantLock互斥锁,底层会先将原来的数组拷贝一份,并获取原数组的长度。然后再创建一个新数组,长度为原数组长度+1,再把需要添加的元素加入到新数组的最后一个位置,最后用新数组替换掉原来的旧数组。性能比读写都加锁更加高效一点。

2.18谈谈CopyOnWriteArrayList的原理

CopyOnWriteArrayList是并发包里提供的并发类,简单来说它就是一个线程安全且读操作无锁的ArrayList。在写操作时会复制一份新的List,在新的List完成写操作,然后再将原引用指向新的List。这样就保证了些操作的线程安全。
CopyOnWriteArrayList允许线程并发访问读操作,这个时候是没有加锁限制的,性能较高,一直都不会阻塞,但是会存在弱一致性的问题。如果读操作发生在写操作之前,则会读取到旧数组中的数据;如果发生在写操作完成之后发生,则会读取到新数组中的数据;
而写操作的时候,则首先将容器复制一份,然后在新的副本上执行写操作,这个时候写操作是上锁的,使用ReentrantLock锁住当前实例对象;结束之后再将原容器的引用指向新的实例对象即可。在写操作的时候可以进行读操作,能够做到读写并发、读读并发、写写互斥。
【优点】
读操作的性能很高,因为无需任何同步措施,比较适合读多写写少的并发场景。在遍历传统的List时,若中途有别的线程对其进行修改则会抛出ConcurrentModificationException异常、而CopyOnWriteArrayList由于其”读写分离”的思想,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器进行遍历的时候,不会抛出ConcurrentModificationException异常。
【缺点】
(1)内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC。
(2)只能保证弱一致性,Vector对于读写操作均加锁同步,可以保证读写的强一致性。

2.19说说TreeSet和HashSet的区别

HashSet、TreeSet的元素都是不能重复的,并且它们都是线程不安全的,二者的区别是:
(1)HashSet的底层基于哈希表来实现,而TreeSet的底层基于红黑树来实现;
(2)HashSet不保证元素的排列顺序,而TreeSet支持自然排序、定制排序两种方式;
(3)HashSet中的元素可以是null,但TreeSet中的元素不能是null。

2.20BlockingQueue中有哪些方法,为什么这样设计?

为了应对不同的业务场景,BlockingQueue提供了4组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能立即得到执行的话,每组方法的表现是不同的。这些方法如下:
image.png
抛异常:如果操作无法立即执行,则抛出一个异常;
特定值:如果操作无法立即执行,返回一个特定的值(一般是true/false);
阻塞:如果操作无法立即执行,则该方法调用将会发生阻塞,直到能够执行;
超时:如果操作无法立即执行,则该方法调用将会发色执行,直到能够执行。但等待时间不会超过给定值,并返回一个特定值以告知该操作是否成功(true/false)。

2.21BlockingQueue是怎么实现的?

BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等。它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于put与take操作的原理是类似的。以ArrayBlockingQueue为例。
c291b4590b1970d33b91f84c4f3f71e.jpg
构造方法,它初始化了put和take函数中的关键成员变量,分别是ReentrantLock和Condition。ReentrantLock是AQS(AbstractQueuedSynchronizer)的子类,它的new Condition函数返回Condition的实例,是定义在AQS类内部的ConditionObject类,该类可以调用AQS相关的函数。

  1. public ArrayBlockingQueue(int var1, boolean var2) {
  2. this.itrs = null;
  3. if (var1 <= 0) {
  4. throw new IllegalArgumentException();
  5. } else {
  6. this.items = new Object[var1];
  7. this.lock = new ReentrantLock(var2);
  8. this.notEmpty = this.lock.newCondition();
  9. this.notFull = this.lock.newCondition();
  10. }
  11. }

生产者:put函数会在队列末尾添加元素,如果队列已经满了,无法添加元素的话,就一直阻塞等待到可以加入为止。同步队列使用ReentrantLock和Condition相结合的机制,先获得锁,再等待,在finally块中再释放锁。

  1. public void put(E var1) throws InterruptedException {
  2. checkNotNull(var1);
  3. ReentrantLock var2 = this.lock;
  4. var2.lockInterruptibly();
  5. try {
  6. while(this.count == this.items.length) {
  7. this.notFull.await();
  8. }
  9. this.enqueue(var1);
  10. } finally {
  11. var2.unlock();
  12. }
  13. }

消费者:take函数在队列尾空时会被阻塞,一直到阻塞队列加入了新的元素。

  1. public E take() throws InterruptedException {
  2. ReentrantLock var1 = this.lock;
  3. var1.lockInterruptibly();
  4. Object var2;
  5. try {
  6. while(this.count == 0) {
  7. this.notEmpty.await();
  8. }
  9. var2 = this.dequeue();
  10. } finally {
  11. var1.unlock();
  12. }
  13. return var2;
  14. }

await操作:
我们发现ArrayBlockingQueue并没有使用Object.wait,而是使用的Condition.await。Condition对象可以提供和Object的wait/notify一样的行为,后者必须先获取synchronized这个内置的monitor锁才能调用,而Condition必须先获取ReentrantLock。这两种方式都能在阻塞等待时将相应的锁释放掉,但是Condition的等待可以中断。
主要流程:
(1)调用addConditionWaiter函数,在condition wait queue(条件等待队列)中添加一个节点,代表当前线程等待一个消息。
(2)调用release函数,将持有的锁释放掉,调用的是AQS的函数。
(3)最后如果当前节点被唤醒,一直调用isOnSyncQueue函数判断节点是否被转移到sync queue队列上,如果没有,则进入阻塞;如果已经在队列上,就调用acquireQueued函数重新获取锁。
signal操作:
signal函数就是不断尝试调用transferForSignal函数,将condition wait queue队列中队首的线程节点转移到等待获取锁的sync queue(同步队列)队列中。这样的话,await函数中调用isOnSyncQueue函数就会返回true,导致await函数进入最后一步重新获取锁的状态。
condition wait queue和sync queue两个队列的设计原理:condition wait queue是等待消息的队列;而sync queue队列是等待获取锁的队列。对于生产者来说如果队列满了,当消费者消费一个消息后就可以重新运行了,但是它还必须等待获取锁才能真正运行。

2.22Stream有哪些方法?

java8中的Stream是对集合对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作,或者大批量数据操作。StreamAPI借助于Lambda表达式,而且使用并发模式,极大提高编程效率和程序可读性。
image.png
Stream提供了大量的方法进行聚合操作,这些方法既可以是”中间的”,也可以是”末端的”。
中间方法:中间操作允许流保持打开状态,并允许调用后续方法,中间方法的返回值是另外一个流。例如map()方法就是中间方法。
末端方法:末端方法是对流的最终操作。当对某个Stream执行末端方法后,该流将会被”消费”且不可再用。例如sum()、count()、average()等方法就是末端方法。
【中间方法】:
filter(Predicate predicate):过滤Stream中所有不符合predicate的元素。
mapToXxx(ToXxxFunction mapper):使用ToXxxFunction对流中的元素执行一对一的转换,该方法返回新流中包含了ToXxxFunction转移生成的所有元素。

  1. public static void main(String[] args) {
  2. List<User> userList = new ArrayList<>();
  3. userList.add(new User(3, "Jack"));
  4. userList.add(new User(1, "Many"));
  5. userList.add(new User(2, "Amy"));
  6. // 提取User的Name转成List集合
  7. List<String> nameList = userList.stream()
  8. .map(User::getName).collect(Collectors.toList());
  9. System.out.println(nameList); // 输出[Jack, Many, Amy]
  10. }
  11. public static void main(String[] args) {
  12. List<User> userList1 = new ArrayList<>();
  13. userList1.add(new User(3, "Jack"));
  14. userList1.add(new User(1, "Many"));
  15. userList1.add(new User(2, "Amy"));
  16. List<User> userList2 = new ArrayList<>();
  17. userList2.add(new User(11, "张三"));
  18. userList2.add(new User(12, "李四"));
  19. List<String> nameList = Stream.of(userList1, userList2)
  20. .flatMap(list -> list.stream()).map(User::getName)
  21. .collect(Collectors.toList());
  22. System.out.println(nameList); // 输出[Jack, Many, Amy, 张三, 李四]
  23. }

peek(Consumer action):依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的元素,该方法主要用于调试。
distinct():返回该流中不含重复元素的流。
sorted():保证流中所有元素有序。
limit(long maxSize):用于保证对流的后续访问中最大允许访问的元素个数。
【末端方法】:
forEach(Consumer action):遍历流中所有元素,对每个元素执行action。
toArray():将流中所有元素转换为一个数组。
reduce():通过某种操作来合并流中的元素。
min():返回流中所有元素的最小值。
max():返回流中所有元素的最大值。
count():返回流中所有元素的数量。
anyMatch(Predicate predicate):判断流中是否至少包含一个元素符合Predicate条件。
noneMatch(Predicate predicate):判断流中是否所有元素都不符合Predicate条件。
findFirst():返回流中的第一个元素。
findAny():返回流中的任何一个元素。
collect():收集数据到指定的集合中。