Java岗大厂面试百日冲刺 - 日积月累,每日三题【Day19】—— 集合框架3

《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!

  • 面试题3:我们可以使用CocurrentHashMap来代替Hashtable吗?

    • 追问1:那ConcurrentHashMap有哪些缺陷?
  • 每日小结

本栏目Java开发岗高频面试题主要出自以下各技术栈:Java基础知识集合容器并发编程JVMSpring全家桶MyBatis等ORMapping框架MySQL数据库Redis缓存RabbitMQ消息队列Linux操作技巧等。


上回问到HashMap的线程安全问题,我们已经知道,在Java中有HashTableSynchronizedMapConcurrentHashMap这三种是实现线程安全的Map。而ConcurrentHashMap也是最常用的并发场景下Map的选择,相信面试官对其理论和实战知识也是在熟悉不过,因此如果不能深入了解,或许会轻易被问住。

面试题1:先说一下大家为什么要选择ConcurrentHashMap?

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

在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会

  • 1)线程不安全的HashMap

在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。HashMap在并发执行put操作时会引起死循环,是因为多线程环境下会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,调用.next()时就会产生死循环获取Entry。

  • 2)效率低下的HashTable

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下(类似于数据库中的串行化隔离级别)。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,读写操作均需要获取锁,竞争越激烈效率越低。

因此,若未明确严格要求业务遵循串行化时(如转账、支付类业务),建议不启用HashTable。

在这里插入图片描述

  • 3)ConcurrentHashMap的分段锁技术可有效提升并发访问率

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在严重锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的分段锁技术。首先将数据分成一段一段地存储(一堆Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

对于 ConcurrentHashMap 你至少要知道的几个点:

  • 默认数组大小为16
  • 扩容因子为0.75,扩容后数组大小翻倍
  • 当存储的node总数量 >= 数组长度*扩容因子时,会进行扩容(数组中的元素、链表元素、红黑树元素都是内部类Node的实例或子类实例,这里的node总数量是指所有put进map的node数量)
  • 当链表长度>=8且数组长度<64时会进行扩容
  • 当数组下是链表时,在扩容的时候会从链表的尾部开始rehash
  • 当链表长度>=8且数组长度>=64时链表会变成红黑树
  • 树节点减少直至为空时会将对应的数组下标置空,下次存储操作再定位在这个下标t时会按照链表存储
  • 扩容时树节点数量<=6时会变成链表
  • 当一个事物操作发现map正在扩容时,会帮助扩容
  • map正在扩容时获取(get等类似操作)操作还没进行扩容的下标会从原来的table获取,扩容完毕的下标会从新的table中获取

在这里插入图片描述

课间休息,又来秀一下来自咱们群里同学的搬砖工地,坐标:河北 秦皇岛

作者:云野.


面试题2:ConcurrentHashMap在JDK1.7、1.8中都有哪些优化?

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

其实,JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。

  • JDK1.7:ReentrantLock+Segment+HashEntry

  • JDK1.8:Synchronized+CAS+Node(HashEntry)+红黑树

从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

在这里插入图片描述

数据结构上跟HashMap很像,从1.7到1.8版本,由于HashEntry从链表红黑树所以 concurrentHashMap的时间复杂度从O(n)到O(log(n)) ↓↓↓;

在这里插入图片描述

同时,也把之前的HashEntry改成了Node作用不变,当Node链表的节点数大于8时Node会自动转化为TreeNode,会转换成红黑树的结构。把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

归纳一下:

  • JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)

  • JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念(jdk1.8),也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了

  • JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,成功代替了一定阈值的链表。

追问1:JDK1.8为什么使用Synchronized来代替ReentrantLock?


JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,主要有以下几点:

  1. 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了

  2. JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然

  3. 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个原因之一。

追问2:讲讲ConcurrentHashMap的 get put 过程?


JDK1.7版本的get put

在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组多个HashEntry组成,如下图所示:

在这里插入图片描述

Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是上面的提到的锁分段技术,而每一个Segment元素存储的HashEntry数组+链表,这个和HashMap的数据存储结构一样。

初始化

ConcurrentHashMap的初始化是会通过位与运算来初始化Segment的大小,用ssize来表示,源码如下所示

private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

private void writeObject(java.io.ObjectOutputStream s)

throws java.io.IOException {

// For serialization compatibility

// Emulate segment calculation from previous version of this class

int sshift = 0;

int ssize = 1;

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

在这里插入图片描述


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

该图片

  • 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;

}

}

最后

小编在这里分享些我自己平时的学习资料,由于篇幅限制,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!

程序员代码面试指南 IT名企算法与数据结构题目最优解

这是” 本程序员面试宝典!书中对IT名企代码面试各类题目的最优解进行了总结,并提供了相关代码实现。针对当前程序员面试缺乏权威题目汇总这一-痛点, 本书选取将近200道真实出现过的经典代码面试题,帮助广“大程序员的面试准备做到万无一失。 “刷”完本书后,你就是“题王”!

image.png

《TCP-IP协议组(第4版)》

本书是介绍TCP/IP协议族的经典图书的最新版本。本书自第1版出版以来,就广受读者欢迎。

本书最新版进行」护元,以体境计算机网络技不的最新发展,全书古有七大部分共30草和7个附录:第一部分介绍一些基本概念和基础底层技术:第二部分介绍网络层协议:第三部分介绍运输层协议;第四部分介绍应用层协议:第五部分介绍下一代协议,即IPv6协议:第六部分介绍网络安全问题:第七部分给出了7个附录。

image.png

Java开发手册(嵩山版)

这个不用多说了,阿里的开发手册,每次更新我都会看,这是8月初最新更新的**(嵩山版)**

image.png

MySQL 8从入门到精通

本书主要内容包括MySQL的安装与配置、数据库的创建、数据表的创建、数据类型和运算符、MySQL 函数、查询数据、数据表的操作(插入、更新与删除数据)、索引、存储过程和函数、视图、触发器、用户管理、数据备份与还原、MySQL 日志、性能优化、MySQL Repl ication、MySQL Workbench、 MySQL Utilities、 MySQL Proxy、PHP操作MySQL数据库和PDO数据库抽象类库等。最后通过3个综合案例的数据库设计,进步讲述 MySQL在实际工作中的应用。

image.png

Spring5高级编程(第5版)

本书涵盖Spring 5的所有内容,如果想要充分利用这一领先的企业级 Java应用程序开发框架的强大功能,本书是最全面的Spring参考和实用指南。

本书第5版涵盖核心的Spring及其与其他领先的Java技术(比如Hibemate JPA 2.Tls、Thymeleaf和WebSocket)的集成。本书的重点是介绍如何使用Java配置类、lambda 表达式、Spring Boot以及反应式编程。同时,将与企业级应用程序开发人员分享一些见解和实际经验,包括远程处理、事务、Web 和表示层,等等。

image.png

JAVA核心知识点+1000道 互联网Java工程师面试题

image.png

image.png

企业IT架构转型之道 阿里巴巴中台战略思想与架构实战

本书讲述了阿里巴巴的技术发展史,同时也是-部互联网技 术架构的实践与发展史。

image.png
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!

Spring5高级编程(第5版)

本书涵盖Spring 5的所有内容,如果想要充分利用这一领先的企业级 Java应用程序开发框架的强大功能,本书是最全面的Spring参考和实用指南。

本书第5版涵盖核心的Spring及其与其他领先的Java技术(比如Hibemate JPA 2.Tls、Thymeleaf和WebSocket)的集成。本书的重点是介绍如何使用Java配置类、lambda 表达式、Spring Boot以及反应式编程。同时,将与企业级应用程序开发人员分享一些见解和实际经验,包括远程处理、事务、Web 和表示层,等等。

[外链图片转存中…(img-NgLszzC5-1714684933748)]

JAVA核心知识点+1000道 互联网Java工程师面试题

[外链图片转存中…(img-Mgf7NDuE-1714684933748)]

[外链图片转存中…(img-NCtqYB73-1714684933749)]

企业IT架构转型之道 阿里巴巴中台战略思想与架构实战

本书讲述了阿里巴巴的技术发展史,同时也是-部互联网技 术架构的实践与发展史。

[外链图片转存中…(img-kNHMjMlB-1714684933749)]
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!

  • 11
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值