Java岗大厂面试百日冲刺 - 日积月累,每日三题,【面试总结】

    while (ssize < DEFAULT_CONCURRENCY_LEVEL) {

        ++sshift;

        ssize <<= 1;

    }

    int segmentShift = 32 - sshift;

    int segmentMask = ssize - 1; 



  由此可以看出:因为ssize用位于运算来计算(`ssize <<=1`),所以Segment的大小取值都是以2的N次方,无关concurrencyLevel的取值,当然concurrencyLevel最大只能用16位的二进制来表示,即65536,换句话说,`Segment的大小最多65536个`,没有指定concurrencyLevel元素初始化,Segment的大小ssize默认为:`DEFAULT_CONCURRENCY_LEVEL =16`。



  每一个Segment元素下的HashEntry的初始化也是按照位于运算来计算,用cap来表示,如下:



int cap = 1;

while (cap < c)

cap <<= 1 



  如上所示,HashEntry大小的计算也是2的N次方(cap <<=1), cap的初始值为1,所以HashEntry最小的容量为2



### [](
)JDK1.7 —— put操作



对于ConcurrentHashMap的数据插入,这里要进行两次Hash去定位数据的存储位置



static class Segment<K,V> extends ReentrantLock implements Serializable {

private static final long serialVersionUID = 2249069246763182397L;

final float loadFactor;

Segment(float lf) { this.loadFactor = lf; }

}




  从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能,`当执行put操作时`,会进行`第一次key的hash来定位Segment的位置`,如果该Segment还没有初始化,即通过`CAS操作进行赋值`,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(`链表的尾端`),会通过继承 `ReentrantLock 的 tryLock()` 方法尝试去获取锁,如果获取成功就直接插入相应的位置,`如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁`,超过指定次数就挂起,等待唤醒。



### [](
)JDK1.7 —— get操作



  ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap`第一次需要经过一次hash定位到Segment的位置`,`然后再hash定位到指定的HashEntry`,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null



![在这里插入图片描述](https://img-blog.csdnimg.cn/20210705133956668.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM5MzkwNTQ1,size_16,color_FFFFFF,t_70#pic_center)



* * *



**JDK1.8版本的get put**



*   `改进一`:取消`segments`字段,直接采用`transient volatile HashEntry<K,V>[] table`保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

    

*   `改进二`:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。

    



> `对于改进二的详细分析`:  

>   

>   对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。`如果hash之后散列的很均匀`,那么table数组中的`每个队列长度基本都为0或者1才对`。  

>   但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是`会存在一些队列长度过长的情况`,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为`O(n)`;  

>   因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),从而针对该种情况,改进了性能。



  `JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现`,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。



  在深入JDK1.8的put和get实现之前要知道一些常量设计和数据结构,这些是构成ConcurrentHashMap实现结构的基础,下面看一下基本属性:



// node数组最大容量:2^30=1073741824

private static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认初始值,必须是2的幕数

private static final int DEFAULT_CAPACITY = 16

//数组可能最大值,需要与toArray()相关方法关联

static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

//并发级别,遗留下来的,为兼容以前的版本

private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

// 负载因子

private static final float LOAD_FACTOR = 0.75f;

// 链表转红黑树阀值,> 8 链表转换为红黑树

static final int TREEIFY_THRESHOLD = 8;

//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))

static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;

private static final int MIN_TRANSFER_STRIDE = 16;

private static int RESIZE_STAMP_BITS = 16;

// 2^15-1,help resize的最大线程数

private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

// 32-16=16,sizeCtl中记录size大小的偏移量

private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

// forwarding nodes的hash值

static final int MOVED = -1;

// 树根节点的hash值

static final int TREEBIN = -2;

// ReservationNode的hash值

static final int RESERVED = -3;

// 可用处理器数量

static final int NCPU = Runtime.getRuntime().availableProcessors();

//存放node的数组

transient volatile Node<K,V>[] table;

/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义

*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容

*当为0时:代表当时的table还没有被初始化

当为正数时:表示初始化或者下一次进行扩容的大小/




  基本属性定义了ConcurrentHashMap的一些边界以及操作时的一些控制,下面看一些内部的一些结构组成,这些是整个ConcurrentHashMap整个数据结构的核心。



  _结构图改自:https://blog.csdn.net/ZOKEKAI/article/details/90085517_



![该图片](https://img-blog.csdnimg.cn/20210705130017244.png?x-oss-process=image,size_16,color_FFFFFF,t_70#pic_center)



*   Node



> `HashEntry == Node`



  Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据,`Node就是一个链表`,但是只允许对数据进行查找,不允许进行修改;



*   TreeNode



  TreeNode继承与Node,但是数据结构换成了二叉树结构,它是红黑树的数据的存储结构,用于红黑树中存储数据,当链表的节点数大于8时会转换成红黑树的结构,他就是通过TreeNode作为存储结构代替Node来转换成黑红树。源代码如下



*   TreeBin



  TreeBin从字面含义中可以理解为存储树形结构的容器,而树形结构就是指TreeNode,所以TreeBin就是封装TreeNode的容器,它提供转换黑红树的一些条件和锁的控制。



  现在通过一个简单的例子以debug的视角看看ConcurrentHashMap的具体操作细节



public class TestConcurrentHashMap{

public static void main(String[] args){

    ConcurrentHashMap<String,String> map = new ConcurrentHashMap(); //初始化ConcurrentHashMap

    //新增个人信息

    map.put("id","1");

    map.put("name","andy");

    map.put("sex","男");

    //获取姓名

    String name = map.get("name");

    Assert.assertEquals(name,"andy");

    //计算大小

    int size = map.size();

    Assert.assertEquals(size,3);

}

}




我们先通过new ConcurrentHashMap()来进行初始化



public ConcurrentHashMap() {

}




  由上你会发现ConcurrentHashMap的初始化其实是一个`空实现`,并没有做任何事,这里后面会讲到,这也是和其他的集合类有区别的地方,`初始化操作并不是在构造函数实现的`,而是`在put操作中实现`,当然ConcurrentHashMap还提供了其他的构造函数,有指定容量大小或者指定负载因子,跟HashMap一样。



### [](
)JDK1.8 —— put操作



  在上面的例子中我们新增个人信息会调用put方法,我们来看下



public V put(K key, V value) {

return putVal(key, value, false);

}

/** Implementation for put and putIfAbsent */

final V putVal(K key, V value, boolean onlyIfAbsent) {

if (key == null || value == null) throw new NullPointerException();

int hash = spread(key.hashCode()); //两次hash,减少hash冲突,可以均匀分布

int binCount = 0;

for (Node<K,V>[] tab = table;;) { //对这个table进行迭代

    Node<K,V> f; int n, i, fh;

    //这里就是上面构造方法没有进行初始化,在这里进行判断,为null就调用initTable进行初始化,属于懒汉模式初始化

    if (tab == null || (n = tab.length) == 0)

        tab = initTable();

    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//如果i位置没有数据,就直接无锁插入

        if (casTabAt(tab, i, null,

                     new Node<K,V>(hash, key, value, null)))

            break;                   // no lock when adding to empty bin

    }

    else if ((fh = f.hash) == MOVED)//如果在进行扩容,则先进行扩容操作

        tab = helpTransfer(tab, f);

    else {

        V oldVal = null;

        //如果以上条件都不满足,那就要进行加锁操作,也就是存在hash冲突,锁住链表或者红黑树的头结点

        synchronized (f) {

            if (tabAt(tab, i) == f) {

                if (fh >= 0) { //表示该节点是链表结构

                    binCount = 1;

                    for (Node<K,V> e = f;; ++binCount) {

                        K ek;

                        //这里涉及到相同的key进行put就会覆盖原先的value

                        if (e.hash == hash &&

                            ((ek = e.key) == key ||

                             (ek != null && key.equals(ek)))) {

                            oldVal = e.val;

                            if (!onlyIfAbsent)

                                e.val = value;

                            break;

                        }

                        Node<K,V> pred = e;

                        if ((e = e.next) == null) {  //插入链表尾部

                            pred.next = new Node<K,V>(hash, key,

                                                      value, null);

                            break;

                        }

                    }

                }

                else if (f instanceof TreeBin) {//红黑树结构

                    Node<K,V> p;

                    binCount = 2;

                    //红黑树结构旋转插入

                    if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,

                                                   value)) != null) {

                        oldVal = p.val;

                        if (!onlyIfAbsent)

                            p.val = value;

                    }

                }

            }

        }

        if (binCount != 0) { //如果链表的长度大于8时就会进行红黑树的转换

            if (binCount >= TREEIFY_THRESHOLD)

                treeifyBin(tab, i);

            if (oldVal != null)

                return oldVal;

            break;

        }

    }

}

addCount(1L, binCount);//统计size,并且检查是否需要扩容

return null;

}




  这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述:



1.  如果没有初始化就先调用`initTable()`方法来进行初始化过程

2.  如果没有hash冲突就直接CAS插入

3.  如果`还在进行扩容操作就先进行扩容`

4.  如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,`一种是链表形式就直接遍历到尾端插入`,`一种是红黑树就按照红黑树结构插入`。

5.  最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环,默认的链表大小,超过了这个值就会转换为红黑树;

6.  如果添加成功就`调用addCount()方法统计size`,并且检查是否需要扩容。



  put的流程你可以从中发现,他在并发处理中使用的是`乐观锁`,当`有冲突的时候才进行并发处理`。



### [](
)JDK1.8 —— get操作



  我们现在要回到开始的例子中,我们对个人信息进行了新增之后,我们要获取所新增的信息,使用 String name = map.get(“name”) 获取新增的 name 信息,现在我们依旧用debug的方式来分析下 ConcurrentHashMap 的获取方法: `get()`



public V get(Object key) {

Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;

int h = spread(key.hashCode()); //计算两次hash

if ((tab = table) != null && (n = tab.length) > 0 &&

    (e = tabAt(tab, (n - 1) & h)) != null) {//读取首节点的Node元素

    if ((eh = e.hash) == h) { //如果该节点就是首节点就返回

        if ((ek = e.key) == key || (ek != null && key.equals(ek)))

            return e.val;

    }

    //hash值为负值表示正在扩容,这个时候查的是ForwardingNode的find方法来定位到nextTable来

    //查找,查找到就返回

    else if (eh < 0)

        return (p = e.find(h, key)) != null ? p.val : null;

    while ((e = e.next) != null) {//既不是首节点也不是ForwardingNode,那就往下遍历

        if (e.hash == h &&

            ((ek = e.key) == key || (ek != null && key.equals(ek))))

            return e.val;

    }

}

return null;

}




ConcurrentHashMap 的 get 操作的流程很简单,也很清晰,可以分为三个步骤来描述



1.  计算hash值,`定位到该table索引位置`,如果是首节点符合就返回

2.  如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回

3.  以上都不符合的话,就往下遍历节点,匹配就返回,`否则最后就返回null`



[](
)追问3:ConcurrentHashMap 的 get 方法是否要加锁,为什么?

-------------------------------------------------------------------------------------------------------



  `get 方法不需要加锁`。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的(可见性),在多线程环境下`线程A修改节点的 value 或者新增节点的时候是对线程B可见的`。



  这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。



* * *



![在这里插入图片描述](https://img-blog.csdnimg.cn/20210704233913267.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM5MzkwNTQ1,size_16,color_FFFFFF,t_70#pic_center)



课间休息,又来秀一下来自咱们群里同学的搬砖工地,坐标:**??**。



作者:`xlikec`



* * *



[](
)面试题3:我们可以使用CocurrentHashMap来代替Hashtable吗?

========================================================================================================



  我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。它们都可以用于多线程的环境,但是`当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间`。



  因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,`ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map`。



[](
)追问1:那ConcurrentHashMap有哪些缺陷?

-------------------------------------------------------------------------------------------



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



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

*   坏处是:`严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据`,而未必是最新的数据。



因此,若需要严格按照串行事务定需求的话,如转账、支付类业务还是使用HashTable。



> 集合框架下一篇 `(四)`会继续沿着CocurrentHashMap深入讲解,包括CAS乐观锁原理、volatile、自旋锁等相关问题;  



> *   ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?

> *   Volatile 关键字干了那些事?Volatile的特性是什么?

> *   不安全会导致哪些问题?如何解决?

> *   有没有线程安全的并发容器?

> *   ConcurrentHashMap并发度为啥好这么多?

> *   CAS是啥?ABA是啥?场景有哪些,怎么解决?

> *   自旋锁是什么?解决什么问题?

> *   CAS性能很高,但是为什么jdk1.8之后还是会用Synchronized?

> *   快速失败(fail-fast)是啥,应用场景有哪些?



[](
)每日小结

===================================================================


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值