(看过之前的 面试系列 | HashMap 和 面试系列 | ConcurrentHashMap)今天我们再来巩固一下)
构建场景: 此时此刻你的面前坐着一位和蔼可亲的面试官, 哎呀我去, 这不是楼上老王嘛, 刚想开口, 突然意识到事情没有那么简单, 什么时候老王的发量如此荒无人烟, 稳住~, 一看就是个高手; 看着他, 不经意间觉得自己头顶有一丝丝凉意...
老王: 你就是来面试的哇?(好一句飘准的川普, 瞬间拉近了面试感jio)
小N: 是的, 我是来面试Java的
老王: 那好, 肥话不多说 (只见老王顺着发际线摸了一把, 一个漂亮的45度甩头, 差点就被他迷住了~)
老王: 这样嘛, 就先说哈HashMap嘛?
小N: (心里不禁回忆~此时,脉冲通过间隙的电发射触发了被称为神经传递素的化学信使的释放...)
1、它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度。
2、因为HashMap是散列映射,也就是它不会记录数据存储时的顺序, 所以在取数的顺序是不确定的。
3、HashMap最多只允许一条记录的键为null,允许多条记录的值为null。(而Hashtable则不能)
4、HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
老王: 你说到这个HashMap是非线程安全, 那你一般都是咋个处理这种情况的安?
小N: 我们一般使用Hashtable 和 ConcurrentHashMap去做处理, 因为他们是线程安全的嘛, 还有一个就是使用Collections.SynchronizedMap(Map)创建线程安全的map集合。不过呢, 因为并发性能和效率的原因使用ConcurrentHashMap。另外2种明显要低于它。
老王: 你说哈为啥子ConcurrentHashMap性能和效率明显比另外两种高安?
小N: (还好有准备) ConcurrentHashMap使用了锁分段技术, 在JDK1.7中是采用Segment + HashEntry + ReentrantLock的方式进行实现的,而JDK1.8中放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现, 不会像Hashtable全局同步锁, 不管是put还是get都要做同步处理。
JDK1.7ConcurrentHashMap数据结构
JDK1.8ConcurrentHashMap数据结构
记住上面2个图, 就可以多聊聊了:
1.7主要使用的是Segment分段锁,内部拥有一个Entry数组,每个数组的每个元素又有一个链表
同时Segment继承ReetrantLock来进行加锁
默认Segment有16个,也就是说可以支持16个线程的并发,在初始化是可以进行设置,一旦初始化就无法修改(Segment不可扩容),但是Segment内部的Entry数组是可扩容的。
1.8摒弃了分段锁的概念,启用 node + CAS + Synchronized 代替Segment
当前的 table[ (n - 1) & hash ] == null 时,采用CAS操作, 把数组中的每个元素看成一个桶, 加锁的部分是对桶的头节点进行加锁,这样锁的粒度更小了。
当产生hash冲突时,采用synchronized关键字锁住 first节点,判断是链表还是红黑树,遍历插入
内部结构和HashMap相同,仍然使用:数组 + 链表 + 红黑树
默认sizeCtl = 16,初始化时可以进行设置
关于sizeCtl 属性
-1 代表正在初始化
N 代表有N-1有二个线程正在进行扩容操作,这里不是简单的理解成n个线程,sizeCtl就是-N,这块后续在讲扩容的时候会说明0标识Node数组还没有被初始化,正数代表初始化或者下一次扩容的大小
至于Collections.SynchronizedMap(Map)维护了一个普通对象Map,排斥锁mutex, 我们在调用这个方法的时候就需要传入一个Map,可以看到有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象, 在操作map时, 会上锁, 看源码里就会看到多个synchronized(mutex), 可以去看看源码了解一下
(眼里逐渐深邃起来, 眼睛微微一眯, 我猜老王又要搞事情了)
老王: 那你说说为啥不用ReentrantLock而用synchronized?
小N: 减少内存开销: 如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步, 而且对synchronized优化了不少。内部优化: synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。(诶, 这是嘛呢, 这老王咋不安套路出牌安?什么红黑树不问问?CAS不问问? ABA问题不问问? 不继续问问HashMap咩? 这个问题很明显抚摸到了的知识盲区了哒, 心头咯噔一下, 这句话里面的锁名词, 是要让我成为有声百科全书呀)
老王: 嗯, 小伙子还不错, 咱们先不扯远了, 继续来谈谈HashMap和Hashtable的区别?
小N: (还好没有问继续问, 心想回去一定多看看多线程相关的东西) 说到HashMap (面试系列 | HashMap)
1. Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null;
2. HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75;
3. 当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量的2倍,Hashtable 扩容规则为当前容量2倍 + 1;
4. 从实现方式来看 Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类;
5. HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的; (说完这点才发现fail-fast是啥玩意儿, 只是看到这句话就背下来了, 不会问这玩意儿吧, 此时已莺飞草长爱的人已在路上~卧糟这是什么鬼,走错场了对不起, 此时已手里全是汗)
(老王似乎看出了端倪继续说道~)
老王: fail-fast是啥? 你说说看
小N: (我去, 这玩意儿, 已经不是对我知识盲区的抚摸我了, 而是在蹂躏)于是乎说, 不知道
老王: 快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
安全失败(fail—safe)也可以了解下,是J.U.C(即java.util.concurrent)包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
(果然, 看着老王的头发是有道理的, 老王继续开口道)
老王: 上面都说到了这么多, 那就来聊聊ConcurrentHashMap 的 put 和 get 过程吧?
小N: (JDK1.7与JDK1.8对比系列面试系列 | ConcurrentHashMap)这里就不再多写了
老王: 使用volatile修饰Node, 你讲哈这个volatile关键字的作用呐?
小N: 1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(可见性)
2. 禁止进行指令重排序。(有序性)
3. volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性
老王: 好, 今天 ConcurrentHashMap & Hashtable & HashMap 三剑客就问到这里
(老王会心一笑, 一个漂亮的45度甩头, 还是那么的迷人~, 只是发量让人担忧, 怕最后的倔强都被甩没了...)
下面是相关部分, 可以了解下哦
关于分段锁:
段Segment继承了重入锁ReentrantLock,有了锁的功能,每个锁控制的是一段,当每个Segment越来越大时,锁的粒度就变得有些大了。
分段锁的优势在于保证在操作不同段 map 的时候可以并发执行,操作同段 map 的时候,进行锁的竞争和等待。这相对于直接对整个map同步synchronized是有优势的。
缺点在于分成很多段时会比较浪费内存空间(不连续,碎片化); 操作map时竞争同一个分段锁的概率非常小时,分段锁反而会造成更新等操作的长时间等待; 当某个段很大时,分段锁的性能会下降。
CAS是什么?
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
关于CAS的问题?
1. CAS容易造成ABA问题。一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次version加1。在java5中,已经提供了AtomicStampedReference来解决问题。
2. CAS造成CPU利用率增加。之前说过了CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu资源会一直被占用。