前言
今天打算跟大家聊一聊java中的容器,这个话题很大,可以从数据结构来讲,可以从线程安全方面来讲,可以从效率来讲,我思考了半天,小编公众号的名字是《杂讲java》,那我就杂讲吧(皮一下),想到哪里说到哪里,从大的方面来分,java的容器分为两部分,Map和Conllection,Conllection又分为List、Set以及Queue,接下来喝杯茶水的功夫,简单唠一唠java的容器家族成员们。
正文
首先放上一张小编手绘的一张java容器分类图,请大家品鉴:
HashTable:效率低但是线程安全
从jdk1.0版本jdk引进的一个线程安全的容器,当我们查看他的源码的时候,天啊!!大部分的方法上面全部使用sync保证线程安全,我们的程序大多数时候只有一个线程占用,但是同样也需要从用户态转到内核态申请OS锁,带来的后果就是效率太低。所以被大部分人所摒弃。
源码示例如下:
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的次幂的一个值,他也会给你转化成最近的一个值,源码示意图如下:
HashMap的特点:没有任何一个地方加锁,效率高,但是线程不安全。容器内的元素没有顺序。
Collections.synchronizedMap(new HashMap()):
在Collections添加一个方法可以使不安全的Map变得安全,但是锁用的都是Sync,从源码上面看只是在锁的粒度上变小了,效率也不是很高。
同步的集合包装器 synchronizedMap 和 synchronizedList,有时也被称作有条件地线程安全,所有单个的操作都是线程安全的,但是多个操作组成的操作序列却可能导致数据争用,因为在操作序列中控制流取决于前面操作的结果。提供的有条件的线程安全性也带来了一个隐患——开发者会假设,因为这些集合都是同步的,所以它们都是线程安全的,这样一来他们对于正确地同步混合操作这件事就会疏忽。其结果是尽管表面上这些程序在负载较轻的时候能够正常工作,但是一旦负载较重,它们就会开始抛出NullPointerException 或 ConcurrentModificationException。
ConcurrentHashMap:
使用了Cas的锁,效率高,线程安全,效率高只是在读的方面,但是在写的操作,效率比hashtable低。但是对于元素是没有排序的。由于它是由cas保证线程安全的,但是cas实现有序的树复杂度太高,所以ConcurrentHashMap是无序的。
ConcurrentSkipListMap(跳表)是有序的,线程安全的map。
注释:
ConcurrentHashMap以及HashMap在后面会单独拿出一篇文章来聊一聊。
ConcurrentSkipListMap:数据结构是链表(跳表)。
跳表的数据结构图示如下:
总结:
链表对半查找的算法,当数据量比较大,查询比较频繁的情况下比一般的链表遍历效率高,但是在新增的时候因为需要维护前后端引用以及层数的计算,稍微耗时,所以适用于查询的操作。
CopyOnWriteList:
写时复制,适用于读多写少的情况。读操作不加锁,直接get。
源码示意图如下:
写操作的时候先加一把可重入锁,保证写操作是原子执行的,然后复制出一个list出来,保证查询的服务正常可以访问,并且不会读到脏数据。源码如下:
由上面两个图可以看出来,CopyOnWriteList读数据操作的效率是很高的,但是写操作的效率因为加上了同步锁,效率较慢,所以才有了适用于读多写少的说法。
BlockingQueue:
- LinkedBlockingQueue:
超级大的链表,大小为Interger的最大值。他在父类Queue的原有方法中新增了两个方法:put():添加元素,如果满了就会阻塞(无界的不会满);take():取出数据,如果Queue空了会阻塞。这两个方法都是阻塞队列,所以是对生产者消费者友好的队列模型。
在Executors工厂类中,Java默认提供了四种类型的线程池:FixedThreadPool、CachedThreadPool、SingleThreadExecutor和ScheduledThreadPool,其中FixedThreadPool和SingleThreadExecutor两种线程池在使用队列的时候,使用的就是LinkedBlockingQueue,在生产环境中,高并发大吞吐的情况下,系统拼命的创建线程,最终导致内存溢出。源码如下:
FixedThreadPool:
SingleThreadExecutor:
这也是在阿里规范中禁止使用Java默认提供的四种类型线程池的原因之一。
- ArrayBlockingQueue:
链表大小是有界的,其中两个有趣的方法如下:
put()方法添加元素,如果满了就会阻塞;add()方法添加元素,在队列满了的情况下就会报异常。
使用示例如下:
- DelayQueue:
时间上的排序.也是BlockingQueue的一种。用PriorityQueue实现的,在延时处理任务的时候会使用得到,java提供的默认的线程池中ScheduledThreadPool就是由其实现的,图示如下:
and
思考:
他的使用场景有哪些?例举一个,比如在电商中的订单的失效就可以使用DelayQueue,订单创建之后一定时间才能获取,然后设置状态为已过期。
- PriorityQueue:二叉树结构,有序的。
使用示例如下:
result:
- SynchronusQueue:线程之间传递任务,传一个。
容量为0,只是为其他线程交换数据。当只有有线程在等着take的时候才能进行put,不然会一直阻塞,如果调用add方法,会报 Queue full的错误。线程池中任务调度用的就是这个作为对列。
CachedThreadPool:
- 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。
点关注不迷路,请搜索《杂讲java》微信公众号,上面会持续更新更多技术分享文章!!