本文已收录至 github,完整图文:https://github.com/HanquanHq/MD-Notes

容器

连老师在公开课里面讲过相关的源码

Collection

  • List

    • CopyOnWriteList:读时不加锁,写时复制,适用于读线程多,写线程少的情况
    • Vector, Stack
    • ArrayList:会有并发问题
    • LinkedList
  • Set

    • HashSet, LinkedHashSet
    • SortedSet, TreeSet
    • EnumSet
    • CopyOnWriteArraySet
    • ConcurrentSkipListSet
  • Queue

    Queue和List的区别?Queue提供了很多在多线程访问下比较友好的API:

    add, offer, peek, poll, remove

    • Deque

      • ArrayDeque
      • BlockingDeque, LinkedBlockingDequeue
    • BlockingQueue:增加了更多API,比如put,take,可以阻塞/指定时间等待,是MQ的基础,MQ的本质,就是一个大型的生产者、消费者模型
      • ArrayBlockingQueue
      • PriorityBlockingQueue:阻塞的 PriorityQueue
      • LinkedBlockingQueue:用链表实现的BlockingQueue。阻塞用await()实现,底层是park
      • TransferQueue, LinkedTransferQueue:装完之后阻塞等待,直到有线程把它取走,再离开。场景:确认收钱完成之后,才能把商品取走。经典的 交替打印 面试题可以用 TransferQueue 实现
      • SynchronousQueue:容量为0,不能往里装东西,只有有一个线程等着的时候,才能把东西递到这个线程手里,是用来一个线程给另外一个线程传数据的。和Exchanger比较相似,也是需要两个线程同步对接,否则都会阻塞。在线程池里面,线程之间进行任务调度的时候,经常会用到。
    • PriorityQueue:内部进行了排序,底层是一个二叉树(小顶堆)的结构
    • ConcurrentLinkedQueue:里面很多方法是CAS实现的
    • DelayQueue:是一种阻塞的队列,需要实现compareTo方法,需要指定等待时间,用来按时间进行任务调度
  • Map

    早期的 Vector 和 Hashtable 都自带锁,设计上有不完善的地方,基本上不用

    • HashMap, LinkedHashMap:多线程用 HashMap 要加锁
    • Hashtable:自带 synchronized,线程安全,但在线程竞争激烈的情况下效率非常低下。HashTable被认为是个遗留的类。如果你寻求在迭代的时候修改Map,你应该使用ConcurrentHashMap
    • ConcurrentHashMap:CAS操作,多线程读取的效率非常高
    • TreeMap:不存在ConcurrentTreeMap,但为了排序,用跳表ConcurrentSkipListMap代替树结构
    • WeakHashMap
    • IdentityHashMap

HashMap

HashMap数据结构

1.8 数组+链表+红黑树

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t7p0Aw9z-1597424575870)(images/2.png)]

JDK1.8 HashMap为什么在链表长度为8的时候转红黑树,为啥不能是9是10?

是不是随便什么情况下只要满足了链表长度为8就转红黑树呢?答案自然不是,为什么不是,看代码:

     /*** The smallest table capacity for which bins may be treeified.* (Otherwise the table is resized if too many nodes in a bin.)* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts* between resizing and treeification thresholds.*/static final int MIN_TREEIFY_CAPACITY = 64; // 当哈希表的容量>该值时,才允许将链表转成红黑树/*** Replaces all linked nodes in bin at index for given hash unless* table is too small, in which case resizes instead.*/final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize();......}

这是HashMap转红黑树的方法代码,可以看到,如果此时的HashMap的长度是小于MIN_TREEIFY_CAPACITY的或者为空,则进行扩容操作,而不是转红黑树,这其实也是容易忽略的点。

为什么要转红黑树?

在JDK8里面,HashMap的底层数据结构已经变为数组+链表+红黑树的结构了,因为在hash冲突严重的情况下,链表的查询效率是O(n),所以JDK8做了优化对于单个链表的个数大于8的链表,会直接转为红黑树结构算是以空间换时间,这样以来查询的效率就变为O(logN)

为什么不直接使用红黑树,而是要先使用链表实在不行再转红黑树呢?

答案自然要在源码和注释里找:在HashMap类中第174行左右有描述:

     Because TreeNodes are about twice the size of regular nodes, weuse them only when bins contain enough nodes to warrant use(see TREEIFY_THRESHOLD)

“因为树节点的大小是链表节点大小的两倍,所以只有在容器中包含足够的节点保证使用才用它”,显然尽管转为树使得查找的速度更快,但是在节点数比较小的时候,此时对于红黑树来说内存上的劣势会超过查找等操作的优势,自然使用链表更加好。但是在节点数比较多的时候,综合考虑,红黑树比链表要好。

为什么是8,而不是9不是10?

其实当时想回答面试官这是基于统计的结果,但是心里很虚还是没有说,再回头看看源码的描述:

Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
理想情况下,在随机哈希码下,bin中节点的频率遵循泊松分布,参数平均约为0.5,默认大小调整阈值为0.75,尽管由于粒度调整的原因方差很大。忽略方差,列表大小k的期望出现次数为(exp(-0.5) * pow(0.5, k) / factorial(k))。第一个值是:0:    0.606530661:    0.303265332:    0.075816333:    0.012636064:    0.001579525:    0.000157956:    0.000013167:    0.000000948:    0.00000006more: less than 1 in ten million

理想情况下,在随机哈希码下,哈希表中节点的频率遵循泊松分布,而根据统计,忽略方差,列表长度为K的期望出现的次数是以上的结果,可以看到其实在为8的时候概率就已经很小了,再往后调整意义并不大。

扩容原理

负载因子:0.75,达到这个容量,则进行 2 倍扩容,复制过去

初始容量:16

初始容量为 2 的 n 次幂:为了方便哈希时进行按位与的取模运算,计算下标位置

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I9Eopmbc-1597424575876)(images/jdk8-hashmap-put.jpg)]

ConcurrentHashMap

几个参数:

  • 默认大小:16
  • 负载因子:0.75
  • 默认并发级别:16
  • put 方法调用的是 Unsafe 类的 CAS 操作

结构变化:

JDK 1.7

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TtCT0I9L-1597424575882)(images/image-20200405151029416.png)]

JDK 1.7 的 ConcurrentHashMap 扩容

HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是 分段锁 的机制,将数据分成一段一段的存储,给每一段数据配一把锁。一个线程访问其中一个段数据时,其他段数据能被其他线程访问。具体实现如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e4ahWge2-1597424575888)(images/image-20200731105418097.png)]

ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。

JDK 1.8 的 ConcurrentHashMap 扩容

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4JlwYLLi-1597424575891)(images/d6ca7bcb0a46f21f603cbd1a488fb9660d33aea6.png)]

在JDK8中彻底抛弃了JDK7的分段锁的机制,新的版本主要使用了Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发,代码可读性稍差。

ConcurrentHashMap的JDK8与JDK7版本的并发实现相比,最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数,在JDK7里面最大并发个数就是Segment的个数,默认值是16,可以通过构造函数改变一经创建不可更改,这个值就是并发的粒度,每一个segment下面管理一个table数组,加锁的时候其实锁住的是整个segment,这样设计的好处在于数组的扩容是不会影响其他的segment的,简化了并发设计,不足之处在于并发的粒度稍粗,所以在JDK8里面,去掉了分段锁,将锁的级别控制在了更细粒度的table元素级别,也就是说只需要锁住这个链表的head节点,并不会影响其他的table元素的读写,好处在于并发的粒度更细,影响更小,从而并发效率更好,但不足之处在于并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点,好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值,其含义如下:

sizeCtl:0(默认):     用来控制table的初始化和扩容操作,具体应用在后续会体现出来。  -1 :    代表table正在初始化  -N :      表示有N-1个线程正在进行扩容操作
其余情况:  1、如果table未初始化,表示table需要初始化的大小。  2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍

扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd,否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后
给旧table原位置赋值fwd。直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-osF0BH1b-1597424575895)(images/u=3502561318,2801799575&fm=26&gp=0.jpg)]

扩容源码如下:

/** (ConcurrentHashMap.java)* Moves and/or copies the nodes in each bin to new table. See* above for explanation.*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
put 操作具体步骤:
  1. 计算key的hash值,即调用speed()方法计算hash值;

  2. 获取hash值对应的Node节点位置,此时通过一个循环实现。有以下几种情况:

    • 如果table表为空,则首先进行初始化操作,初始化之后再次进入循环获取Node节点的位置;
    • 如果table不为空
      • 如果没有找到key对应的Node节点,则直接调用casTabAt()方法插入一个新节点,此时不用加锁;
      • 如果key对应的Node节点也不为空,但Node头结点的hash值为MOVED(-1),则表示需要扩容,此时调用helpTransfer()方法扩容;

    其他情况下,则直接向Node中插入一个新Node节点,此时需要对这个Node链表或红黑树通过synchronized加锁。

  3. 插入元素后,判断对应的Node结构是否需要改变结构,如果需要,则调用treeifyBin()方法将Node链表升级为红黑树结构;

  4. 最后,调用addCount()方法记录table中元素的数量。

在扩容时,读写操作如何进行?

(1)对于get读操作,如果当前节点有数据,还没迁移完成,此时不影响读,能够正常进行。 如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时get线程会帮助扩容。

(2)对于put/remove写操作,如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时写线程会帮助扩容,如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。

对于 size 和迭代器是弱一致性

volatile修饰的数组引用是强可见的,但是其元素却不一定,所以,这导致size的根据sumCount的方法并不准确。

同理Iteritor的迭代器也一样,并不能准确反映最新的实际情况 .

Hastable / ConcurrentHashMap 对比

Hashtable 的任何操作都会把整个表锁住,是阻塞的。

  • 好处:能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。

  • 坏处:是所有调用都要排队,效率较低。

ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。

  • 好处:处是在保证合理的同步前提下,效率很高。

  • 坏处:是严格来说读取操作不能保证反映最近的更新。

例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。

ConcurrentHashMap 总结

Java7 中 ConcruuentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。

Java8 中的 ConcruuentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。

有些同学可能对 Synchronized 的性能存在疑问,其实 Synchronized 锁自从引入锁升级策略后,性能不再是问题,有兴趣的同学可以自己了解下 Synchronized 的锁升级

面试必会系列 - 1.2 Java 集合,源码讲解相关推荐

  1. Java集合源码分析(二)ArrayList

    ArrayList简介 ArrayList是基于数组实现的,是一个动态数组,其容量能自动增长,类似于C语言中的动态申请内存,动态增长内存. ArrayList不是线程安全的,只能用在单线程环境下,多线 ...

  2. 面试必会系列 - 1.6 Java 垃圾回收机制

    本文已收录至 Github(MD-Notes),若博客中有图片打不开,可以来我的 Github 仓库:https://github.com/HanquanHq/MD-Notes,涵盖了互联网大厂面试必 ...

  3. 面试必会系列 - 1.5 Java 锁机制

    本文已收录至 github,完整图文:https://github.com/HanquanHq/MD-Notes 面试必会系列专栏:https://blog.csdn.net/sinat_424833 ...

  4. Java集合源码浅析(一) : ArrayList

    (尊重劳动成果,转载请注明出处:https://yangwenqiang.blog.csdn.net/article/details/105418475冷血之心的博客) 背景 一直都有这么一个打算,那 ...

  5. 面试必会系列 - 1.3 Java 多线程

    本文已收录至 github,完整图文:https://github.com/HanquanHq/MD-Notes 多线程 线程有多少种状态? 指定时刻,线程只可能处于下面 6 种不同状态 的其中一个状 ...

  6. 面试必会系列 - 1.1 Java SE 基础

    本文已收录至 github,完整图文:https://github.com/HanquanHq/MD-Notes Java SE 基础 面向对象 Java 按值调用还是引用调用? 按值调用指方法接收调 ...

  7. Java高级工程师必看系列,从基础到源码统统帮你搞定

    1.Java基础 Java基础务必要有一个非常牢固的根基,尤其是对于JVM和并发编程的掌握情况**(属于进阶内容,但也是Java最为重要的基础内容)**,不论是面试还是工作,基础不好,写不出高质量.漂 ...

  8. Java集合源码系列(1)---- ArrayList详解

    目录 属性 构造函数 无参构造函数 含参构造(int initialCapacity) 含参构造(Collection c) add方法 add(E e) add(int index, E eleme ...

  9. string list 查找_手撕java集合源码——List篇

    阅读list集合观察它们底层是如何实现的,以及集合面试中提出的问题进行实践. list集合中常用的类为Arraylist.LinkedLIst. 两者的区别 区别 Arraylist LinkedLi ...

最新文章

  1. wifi网络结构(上)
  2. maven打包项目的时候找不到jar包,但是项目里面改已经有相关jar包
  3. 代码环复杂度的计算公式
  4. 一个诡异的可见性问题
  5. [vue] Vue.observable你有了解过吗?说说看
  6. 前端学习(2817):全局page配置文件
  7. 计算机的代表性产品,电脑展回顾 十款最具代表性存储产品
  8. dos 操作mysql_dos命令操作数据库
  9. shell 截取某个字符串之后的内容
  10. 医院常用系统简称说明(HIS 、LIS、PACS等)
  11. P2141 [NOIP2014 普及组] 珠心算测验
  12. 如何将陈述句变为疑问句
  13. 计算机科学与专业大学排名,计算机科学与技术专业大学排名
  14. 在Composure去除掉对体积云和雾的捕获
  15. 微信扫码支付流程原理图
  16. 2.zookeeper
  17. centos7下yum出现:已加载插件:fastestmirror Loading mirror speeds from cached hostfile 没有已启用的源。
  18. 在html页面上内容竖着显示
  19. WZOI-263细菌繁殖
  20. hashmap中的key是有序的么_hashmap 是无序的但是实际输出有序。

热门文章

  1. python 逻辑回归权重_Python 逻辑回归
  2. TensorFlow2-卷积神经网络
  3. 词性标注与命名实体识别
  4. EOJ_1039_最长连续公共子序列
  5. 创建 tls 客户端 凭据时发生严重错误。内部错误状态为 10013_kubectl 创建 Pod 背后到底发生了什么?...
  6. Delphi使用ADO组件访问ACCESS数据入门例程
  7. WinSock2编程之打造完整的SOCKET池
  8. VC使用HTTP协议下载文件
  9. 某大佬的20+公司面试题总结和自己的补充
  10. 一个内核网络漏洞详解|容器逃逸