咱从面试角度入手
ConcurrentHashMap是线程安全的数组,是HashTable的替代品,同为线程安全,其性能要比HashTable更好
HashMap不是线程安全:
在并发环境下,可能会形成环状链表(扩容时可能造成,具体原因自行百度google或查看源码分析),导致get操作时,cpu空转,所以,在并发环境中使用HashMap是非常危险的
HashTable是线程安全的:
HashTable和HashMap的实现原理几乎一样,与HashMap的差别:HashTable不允许key和value为null;HashTable是线程安全的。
HashTable线程安全的策略实现代价却比较大,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞
1.ConcurrentHashMap底层实现
JDK1.7
底层数据结构:Segments数组+HashEntry数组+链表,采用分段锁保证安全性
容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这 样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的”分段锁”思想。
一个ConcurrentHashMap中有一个Segments数组,一个Segments中存储一个HashEntry数组,每个HashEntry是一个链表结构的元素。
segment继承自ReentrantLock锁。 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。
可以通过构造函数指定,数组扩容不会影响其他的segment,get无需加锁,volatile保证内存可见性
GET():
HashEntry中的value属性和next指针是用volatile修饰的,保证了可见性,所以每次获取的都是最新值,get过程不需要加锁。
1.将key传入get方法中,先根据key的hashcode的值找到对应的segment段。
2.再根据segment中的get方法再次hash,找到HashEntry数组中的位置。
3.最后在链表中根据hash值和equals方法进行查找。
ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。
PUT():
1.将key传入put方法中,先根据key的hashcode的值找到对应的segment段
2.再根据segment中的put方法,加锁lock()。
3.再次hash确定存放的hashEntry数组中的位置
4.在链表中根据hash值和equals方法进行比较,如果相同就直接覆盖,如果不同就插入在链表中。
JDK1.8
底层数据结构:Synchronized + CAS +Node +红黑树.Node的val和next都用volatile保证,保证可见性,查找,替换,赋值操作都使用CAS
为什么在有Synchronized 的情况下还要使用CAS
因为CAS是乐观锁,在一些场景中(并发不激烈的情况下)它比Synchronized和ReentrentLock的效率要高,当CAS保障不了线程安全的情况下(扩容或者hash冲突的情况下)转成Synchronized 来保证线程安全,大大提高了低并发下的性能.
锁 : 锁是锁的链表的head的节点,不影响其他元素的读写,锁粒度更细,效率更高,扩容时,阻塞所有的读写操作(因为扩容的时候使用的是Synchronized锁,锁全表),并发扩容.
读操作无锁 :
Node的val和next使用volatile修饰,读写线程对该变量互相可见
数组用volatile修饰,保证扩容时被读线程感知
GET():
get操作全程无锁。get操作可以无锁是由于Node元素的val和指针next是用volatile修饰的。
在多线程环境下线程A修改节点的val或者新增节点的时候是对线程B可见的。
1.计算hash值,定位到Node数组中的位置
2.如果该位置为null,则直接返回null
3.如果该位置不为null,再判断该节点是红黑树节点还是链表节点
如果是红黑树节点,使用红黑树的查找方式来进行查找
如果是链表节点,遍历链表进行查找
PUT():
1.先判断Node数组有没有初始化,如果没有初始化先初始化initTable();
2.根据key的进行hash操作,找到Node数组中的位置,如果不存在hash冲突,即该位置是null,直接用CAS插入
3.如果存在hash冲突,就先对链表的头节点或者红黑树的头节点加synchronized锁
4.如果是链表,就遍历链表,如果key相同就执行覆盖操作,如果不同就将元素插入到链表的尾部, 并且在链表长度大于8, Node数组的长度超过64时,会将链表的转化为红黑树。如果是红黑树,就按照红黑树的结构进行插入。
2、JDK1.8中为什么使用synchronized替换可重入锁ReentrantLock?
Segment继承了ReentrantLock,所以Segment是一种可重入锁。
1.Segment可重入锁锁住的是一个HashEntry数组,synchronized锁住的只是发生hash冲突的链表]的头节点或红黑树的节点,提高了并发性能。
2.从JDK1.6开始,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
只要并发的线程可以在一定次数的自旋内拿到锁(偏向锁不用自旋),那么synchronized就不会升级为重量级锁,等待的线程也不会被挂起,减少了线程挂起和唤醒的切换的过程开销。
而ReentrantLock不会自旋,会直接挂起,这样一来就很容易会多出线程上下文开销的代价。
3.减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表]的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
3、ConcurrentHashMap和Hashtable的区别?
1.底层数据结构:
JDK1.7的ConcurrentHashMap底层采用:Segments数组+HashEntry数组+链表
JDK1.8的ConcurrentHashMap底层采用:Node数据+链表+红黑树
Hashtable底层数据结构采用:数组+链表
2.实现线程安全的方式:
在JDK1.7中ConcurrentHashMap采用分段锁实现线程安全。
在JDK1.8中ConcurrentHashMap采用synchronized和CAS来实现线程安全。
Hashtable采用synchronized来实现线程安全。在方法上加synchronized同步锁。
4、HashMap与ConcurrentHashMap的区别?
HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不 一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。
Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。
ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。
它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
5、ConcurrentHashMap是怎么分段分组的?
get操作:
Segment的get操作实现非常简单和高效,先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。get操作的高效之处在于整个get过程都不需要加锁,除非读到空的值才会加锁重读。原因就是将使用的共享变量定义成 volatile 类型。
put操作:
当执行put操作时,会经历两个步骤:
判断是否需要扩容;
定位到添加元素的位置,将其放入 HashEntry 数组中。
插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插通过继承 ReentrantLock 的 tryLock() 方法尝试去获锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该 Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。