java 容器_java中的容器家族成员

f23f2b6b74a9820b046a164684d33062.png

前言

今天打算跟大家聊一聊java中的容器,这个话题很大,可以从数据结构来讲,可以从线程安全方面来讲,可以从效率来讲,我思考了半天,小编公众号的名字是《杂讲java》,那我就杂讲吧(皮一下),想到哪里说到哪里,从大的方面来分,java的容器分为两部分,Map和Conllection,Conllection又分为List、Set以及Queue,接下来喝杯茶水的功夫,简单唠一唠java的容器家族成员们。

正文

首先放上一张小编手绘的一张java容器分类图,请大家品鉴:

dcb8738cad9cc411437a79b6120c0968.png

HashTable:效率低但是线程安全

从jdk1.0版本jdk引进的一个线程安全的容器,当我们查看他的源码的时候,天啊!!大部分的方法上面全部使用sync保证线程安全,我们的程序大多数时候只有一个线程占用,但是同样也需要从用户态转到内核态申请OS锁,带来的后果就是效率太低。所以被大部分人所摒弃。

源码示例如下:

99514b91d6a724690d70c4f6caf7a157.png

f144f4b231276ceef7bc37770671b279.png

HashMap:效率高但是线程不安全(jdk.8):

这个容器是工作中经常会使用的数据类型,也是面试中的高频考点,首先是他是数组+链表+红黑树的数据结构。当然容器并不是在声明初始化的时候就是这样的结构,而是首先是初始化一个数组,当发生hash冲突的时候,在数组下面挂载一个双向链表,在插入的时候为了节省移动数据的成本采用的是尾插法的插入方式,当链表的长度超过8的时候,便会转换成一个红黑树,当删除数据在红黑树节点小于6个的时候回退到链表的数据结构。当然有一些杠精会问这个6和8有什么讲究,当然不会是他的作者拍脑门拍出来的,也不是抽签抽出来的,这里边有一个考虑是为了防止链表与数组结构转化的频率太高,从而影响HashMap的效率。

HashMap为了存取高效,要尽量减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1);问题就在这里,如果不满足前提条件“HashMap的初始容量和扩容都是以2的次方来进行的”,会发生什么问题呢?

假设当前table的length是15,二进制表示为1111,那么length-1就是1110,此时有两个hash值为8和9的key需要计算索引值,计算过程如下:

8的二进制表示:1000

8&(length-1)= 1000 & 1110 = 1000,索引值即为8;

9的二进制表示:1001

9&(length-1)= 1001 & 1110 = 1000,索引值也为8;

这样一来就产生了相同的索引值,也就是说两个hash值为8和9的key会定位到数组中的同一个位置上形成链表,这就产生了碰撞。而查询的时候需要遍历这个链表,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hash值会与length-1(1110)进行按位与,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,会造成严重的空间浪费,更糟的是这种情况下,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率。

因此可以看出,只有当数组长度为2的n次方时,不同的key计算得出的index索引相同的几率才会较小,数据在数组上分布也比较均匀,碰撞的几率也小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。此外,位运算快于十进制运算,hashmap扩容也是按位扩容,这样同时也提高了运算效率。

但是如果你给HashMap设置了一个并不是2的次幂的一个值,他也会给你转化成最近的一个值,源码示意图如下:

11324a249a26a52d2790b78a11afc805.png

HashMap的特点:没有任何一个地方加锁,效率高,但是线程不安全。容器内的元素没有顺序。

Collections.synchronizedMap(new HashMap()):

在Collections添加一个方法可以使不安全的Map变得安全,但是锁用的都是Sync,从源码上面看只是在锁的粒度上变小了,效率也不是很高。

同步的集合包装器 synchronizedMap 和 synchronizedList,有时也被称作有条件地线程安全,所有单个的操作都是线程安全的,但是多个操作组成的操作序列却可能导致数据争用,因为在操作序列中控制流取决于前面操作的结果。提供的有条件的线程安全性也带来了一个隐患——开发者会假设,因为这些集合都是同步的,所以它们都是线程安全的,这样一来他们对于正确地同步混合操作这件事就会疏忽。其结果是尽管表面上这些程序在负载较轻的时候能够正常工作,但是一旦负载较重,它们就会开始抛出NullPointerException 或 ConcurrentModificationException。

ConcurrentHashMap:

使用了Cas的锁,效率高,线程安全,效率高只是在读的方面,但是在写的操作,效率比hashtable低。但是对于元素是没有排序的。由于它是由cas保证线程安全的,但是cas实现有序的树复杂度太高,所以ConcurrentHashMap是无序的

ConcurrentSkipListMap(跳表)是有序的,线程安全的map。

注释:

ConcurrentHashMap以及HashMap在后面会单独拿出一篇文章来聊一聊。

ConcurrentSkipListMap:数据结构是链表(跳表)。

跳表的数据结构图示如下:

f10a44f9a18aa43551fe2965575c9c75.png

总结:

链表对半查找的算法,当数据量比较大,查询比较频繁的情况下比一般的链表遍历效率高,但是在新增的时候因为需要维护前后端引用以及层数的计算,稍微耗时,所以适用于查询的操作。

CopyOnWriteList:

写时复制,适用于读多写少的情况。读操作不加锁,直接get。

源码示意图如下:

638a4467f0e71c2feb82fd5a67ab9734.png

写操作的时候先加一把可重入锁,保证写操作是原子执行的,然后复制出一个list出来,保证查询的服务正常可以访问,并且不会读到脏数据。源码如下:

2cd8a1f9c086d4318816d717d00dc491.png

由上面两个图可以看出来,CopyOnWriteList读数据操作的效率是很高的,但是写操作的效率因为加上了同步锁,效率较慢,所以才有了适用于读多写少的说法。

BlockingQueue:

  • LinkedBlockingQueue:

超级大的链表,大小为Interger的最大值。他在父类Queue的原有方法中新增了两个方法:put():添加元素,如果满了就会阻塞(无界的不会满);take():取出数据,如果Queue空了会阻塞。这两个方法都是阻塞队列,所以是对生产者消费者友好的队列模型。

在Executors工厂类中,Java默认提供了四种类型的线程池:FixedThreadPool、CachedThreadPool、SingleThreadExecutor和ScheduledThreadPool,其中FixedThreadPool和SingleThreadExecutor两种线程池在使用队列的时候,使用的就是LinkedBlockingQueue,在生产环境中,高并发大吞吐的情况下,系统拼命的创建线程,最终导致内存溢出。源码如下:

FixedThreadPool:

ca563ac9908c4bf3624a772ccfc76af3.png

SingleThreadExecutor:

ae63576ff46d6d4d2869357a8cc72260.png

这也是在阿里规范中禁止使用Java默认提供的四种类型线程池的原因之一。

  • ArrayBlockingQueue:

链表大小是有界的,其中两个有趣的方法如下:

put()方法添加元素,如果满了就会阻塞;add()方法添加元素,在队列满了的情况下就会报异常。

使用示例如下:

bed2a2c25a7fbdf38a6b4a11ecc703a0.png
  • DelayQueue:

时间上的排序.也是BlockingQueue的一种。用PriorityQueue实现的,在延时处理任务的时候会使用得到,java提供的默认的线程池中ScheduledThreadPool就是由其实现的,图示如下:

ddf66bacf85d8bc258bba9cc36f89f2e.png

and

d132eedc535b01d5e5bbdcf6491763c6.png

思考:

他的使用场景有哪些?例举一个,比如在电商中的订单的失效就可以使用DelayQueue,订单创建之后一定时间才能获取,然后设置状态为已过期。

  • PriorityQueue:二叉树结构,有序的。

使用示例如下:

1d3c12ecef02582731247230e7ac6fb4.png

result:

fa9ae5fa1b1b825b11d1056f5bede1f5.png
  • SynchronusQueue:线程之间传递任务,传一个。

容量为0,只是为其他线程交换数据。当只有有线程在等着take的时候才能进行put,不然会一直阻塞,如果调用add方法,会报 Queue full的错误。线程池中任务调度用的就是这个作为对列。

CachedThreadPool:

6b5e8211880f03fa13fd5ef5d36205f4.png
  • TransferQueue:上面Queue的组合。线程之间传递任务,传好多个。

有一个transfer(E e)方法,在其中一个线程添加一个元素,那么这个线程会进行阻塞直到有线程来将其取走。put()方法是满了才阻塞,transfer()不管满不满,添加就阻塞。

注释:

父类Queue的方法:

offer(E e):添加元素,添加成功与否有一个boolean的返回值。成功:true;失败:false

add(E e):添加元素

peek():取元素,但是不会删除掉取出的元素。

poll():取元素,并且会删除掉取出的元素。

思考:

Queue和list有什么区别?

两者都是java的容器,功能也差别也不是非常大,只是Queue比list多一些对于线程友好的Api。

3609a8c6bd5408a2c471ecb749e987fb.png

点关注不迷路,请搜索《杂讲java》微信公众号,上面会持续更新更多技术分享文章!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值