跳转至

遇到的面试题(1-25)

1.HashMap的线程不安全体现在什么地方,简述HashMap的扩容过程,jdk1.8中的ConcurrentHashMap是如何实现线程安全的?

2.Synchronized和ReentrantLock的区别?

3.简述自旋锁,偏向锁,轻量级锁,重量级锁的区别,锁是如何升级的?

锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率.

〔自旋锁〕

自旋锁是当一个线程尝试获取一个已经被其他线程持有的锁时,它不会放弃处理器资源,而是循环等待,不断地检查锁是否已经可以被获取。

特点

  • 优点不会引起线程上下文切换,因此在锁竞争不激烈的情况下性能较高。
  • 缺点:如果锁的持有时间较长,可能会导致 CPU 资源的浪费

〔偏向锁〕

偏向锁是为了解决无竞争情况下的锁获取问题而设计的。当一个线程首次访问同步块时,JVM 会偏向于该线程,并在对象的 Mark Word 中记录线程 ID。

特点

  • 优点:加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距。
  • 缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗。
  • 快速获取:如果后续仍然是同一个线程试图获取锁,则可以直接进入临界区而无需任何同步操作。
  • 撤销机制:如果其他线程试图获取锁,则偏向锁会被撤销,并升级为轻量级锁。

〔轻量级锁〕

轻量级锁使用 CAS(Compare And Swap)操作来尝试获取锁。当线程尝试获取锁时,它会在栈中创建一个锁记录空间,并将对象头中的 Mark Word 复制到锁记录中。然后使用 CAS 尝试将对象头中的 Mark Word 替换为指向锁记录的指针。

特点

  • 优点:竞争的线程不会阻塞,提高了程序的响应速度。
  • 缺点:如果始终得不到索竞争的线程,使用自旋会消耗CPU。
  • 非阻塞:如果 CAS 操作成功,则线程获得锁。如果失败,则线程自旋等待。
  • 升级机制:如果自旋多次仍然无法获取锁,则锁会升级为重量级锁。

〔重量级锁〕

重量级锁是使用 操作系统互斥量(Mutex)实现的锁。当线程无法获取锁时,会被挂起,直到锁被释放

特点

  • 优点:线程竞争不使用自旋,不会消耗CPU。
  • 缺点:线程阻塞,响应时间缓慢。
  • 阻塞:重量级锁会阻塞线程,直到锁被释放。
  • 性能开销大:重量级锁涉及线程的挂起和恢复,开销较大。

〔升级过程〕

偏向锁:当第一个线程进入同步块时,会尝试获取偏向锁,当第二个线程尝试获取锁时,偏向锁会被撤销,转为轻量级锁。
轻量级锁:如果 CAS 多次尝试失败,轻量级锁会升级为重量级锁。
自旋锁:在轻量级锁阶段,如果 CAS 多次尝试失败,线程会继续自旋等待,如果自旋超过指定次数后仍未获取到锁,轻量级锁就会升级为重量级锁
重量级锁:无法再升级


4.阻塞队列的实现原理,简述AQS底层原理

5.redis常见数据类型以及对应的应用场景?

String(字符串):Redis中最基础的数据类型,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value的最大长度可达512MB.

应用场景:

  • 缓存:由于字符串类型的灵活性,它经常被用来缓存各种数据,如用户信息、商品详情等,以减少对后端数据库的访问压力。
  • 计数器利用INCR和DECR等命令可以实现计数器的功能,如统计网站访问量、文章点赞数等。
  • 分布式锁使用SET命令的NX选项(只有当键不存在时才设置值)和EX选项(设置过期时间),可以实现简单的分布式锁功能。

Hash(哈希):是键值对(key - value)集合,形式如:value=[{field1,value1},...{fieldN,valueN}],非常适合用来存储对象,如用户信息。

应用场景:

  • 存储对象:哈希类型非常适合存储具有多个属性的对象,如用户信息(用户名、密码、邮箱等)。
  • 部分数据变更:适合存储那些经常变动的信息,如用户的偏好设置。

List(列表):是一个简单的字符串列表,按照插入顺序排序,并支持从头部或尾部插入和删除操作。

应用场景:

  • 消息队列:利用List的PUSH操作将任务存储在List中,然后工作线程使用POP操作取出任务执行。
  • 最新列表:使用LPUSH命令将最新数据插入到列表头部,LTRIM命令限制列表长度,从而实现最新N个元素的列表。

Set(集合):是一个无序并唯一的键值集合,可以对集合执行交集、并集、差集等操作。

应用场景:

  • 标签系统:集合可以用来实现标签功能,一个用户可以对应多个标签,多个用户也可以对应同一个标签。
  • 唯一对象集合:存储唯一对象的集合,如网站的唯一访客。

ZSet(有序集合):与集合类似,保留了集合不能有重复成员的特性,但每个成员都关联了一个score,用于排序

应用场景:

  • 排行榜:维护一个排行榜,如游戏中的得分排行。
  • 按分数排序:根据时间和权重对元素进行排序,如新闻流的时间线。

Bitmaps(位图):虽然不是一种独立的数据类型,但可以通过将字符串视为位图来实现高效的空间使用。

应用场景:

  • 用户行为追踪:记录哪些用户登录过。
  • 大规模数据统计:估算某个时间段内的唯一访客数量。

HyperLogLog:一种专门用来估算大量数据的基数(不重复元素的数量)的数据结构。

应用场景:

  • 唯一访客数估算:在不过多消耗内存的情况下估算网站的唯一访客数。

Streams(流):Redis 5.0引入的一种新的数据类型,用于构建消息队列,支持持久化和消费

应用场景:

  • 实时数据分析:如日志处理。
  • 事件驱动机制:构建微服务架构中的事件驱动机制。

相关链接🔗


6.MQ消息积压如何解决,如何保证消息消费的顺序性,如何保证消息不丢失,如何解决消息重复解决

7.jvm为什么会存在stw的问题?

8.从MVC角度解释不可重复读和幻读的区别?

9.什么情况下会产生表锁,复合索引失效的情况有哪几种?

MySQL中产生表锁的情况

InnoDB存储引擎:

  • 行级锁升级:虽然InnoDB主要使用行级锁,但在某些情况下,如当锁定的行数量过多时,可能会升级为表锁。 如果查询的行数占总行数的比例较高(例如超过20%),MySQL可能会选择使用表锁以提高效率。
  • 复合索引失效导致行锁变表锁:如果复合索引失效,例如WHERE条件中的列没有索引,或者索引选择不当,可能会导致行锁升级为表锁。
  • 显式锁定:使用SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE时,如果没有合适的索引,也可能导致表锁。
  • DDL操作:类似于MyISAM,执行ALTER TABLE等DDL语句时,InnoDB也会使用表级锁。

复合索引失效的情况

复合索引(或联合索引)是指在多个列上创建的索引。当查询条件符合索引的最左侧字段时,索引才能被有效地利用。以下是一些可能导致复合索引失效的情况:

  • 未按照索引列顺序使用:如果查询条件中没有使用索引的第一个字段,那么索引将完全失效。
  • 使用函数或表达式:如果查询条件中包含了对索引列的函数调用或数学运算,那么索引可能不会被使用。
  • 使用OR连接条件:如果WHERE子句中包含OR操作,并且连接的列不在同一个索引上,或者没有按照索引的顺序使用,索引可能不会被使用。
  • LIKE操作符以%开头:当LIKE操作符的模式以通配符%开头时,如LIKE '%abc%',MySQL不能有效地使用索引。
  • 隐式类型转换:如果索引列是整型,而查询条件是字符串,MySQL需要做类型转换,这种情况下索引可能不会被使用。
  • 范围查询:如果查询条件中某个列使用了范围查询(如>或<),那么索引中该列之后的所有列都将失效。
  • 使用IN或NOT IN:当INNOT IN的参数较多时,MySQL可能会选择全表扫描而非使用索引。
  • 使用!=或<>操作符:使用!=或<>操作符时,索引可能不会被使用。
  • ORDER BY使用不当:如果ORDER BY后面的列不是索引的一部分,或者没有正确使用索引,可能会导致索引失效。
  • 数据分布不均:如果索引列的数据分布不均匀,MySQL可能会认为全表扫描更快,从而不使用索引。

10.微服务的优缺点分别是什么?说一下你在项目开发中遇到的坑?

11.所知道的微服务技术栈?

12.给一个数组,其中有一个重复元素占半数以上,找出这个元素。

13.SpringBoot启动流程

第一部分: SpringApplication 初始化模块

配置一些基本的环境变量、资源、监听器、构造器;

第二部分: 实现应用具体的启动方案

包括流程的监听模块、加载配置环境模块以及创建上下文环境模块;

第三部分: 自动化配置模块

这个模块是实现SpringBoot的自动配置。

启动过程:

每个SpringBoot程序都有一个主入口,即main方法。在这个方法里调用了SpringApplication.run()以启动整个SpringBoot程序。该方法所在的类有一个@SpringBootApplication注解,它是一个组合注解:

  1. @EnableAutoConfiguration: SpringBoot根据应用所声明的依赖来对Spring框架进行自动配置;
  2. @SpringBootConfiguration (内部为@Configuration): 标注这个类是一个配置类;
  3. @ComponentScan: 组件扫描,可自动发现和装配Bean,自动扫描并加载符合条件的组件(如@Component@Repository等),并将这些bean定义加载到IoC容器中。

SpringBoot启动类

首先,进入run方法:

  • 创建了一个SpringApplication实例;
  • 使用SpringFactoriesLoader在应用的META-INF/spring.factories中查找并加载所有可用的ApplicationContextInitializer
  • 使用SpringFactoriesLoader在应用的类路径中查找并加载所有可用的ApplicationListener
  • 在构造方法内,调用了一个初始化的方法initialize

该方法中实现了以下关键步骤:

  1. 创建了应用的监听器SpringApplicationRunListeners并开始监听;
  2. 加载SpringBoot配置环境(ConfigurableEnvironment),如果是通过web容器发布,则会加载StandardEnvironment
  3. 将配置环境(Environment)加入到监听器对象(SpringApplicationRunListeners)中;
  4. 创建run方法的返回对象:ConfigurableApplicationContext(应用配置上下文);
  5. 回到run方法内,prepareContext方法将listenersenvironmentapplicationArgumentsbanner等重要组件与上下文对象关联;
  6. 接下来的refreshContext(context)方法是实现sprin-boot-starter-*(如mybatis、redis等)自动化配置的关键,包括sprin.factories的加载,bean的实例化等核心工作。

refresh方法

配置结束后,SpringBoot进行了一些基本的收尾工作,返回了应用环境上下文。回顾整个流程,SpringBoot的启动主要包括创建 配置环境(environment)、事件监听(listeners)、应用上下文(applicationContext),并在容器中开始 实例化所需的Bean。至此,通过SpringBoot启动的程序已经构造完成。


14.MySQL数据库索引作用和优缺点。如何创建索引

➊优缺点

1️⃣虽然索引可以大大提高数据的查询速度,但是提高查询速度的同时,将会降低INSERT、UPDATE、DELETE的速度,因为更新表时,MySQL不仅要保存数据,还要保存索引文件,这样如果索引滥用的话,就会大大降低数据库写入的速度。

2️⃣建立索引会占用磁盘空间,一般情况这个问题不会很严重,但是如果你在一个大表上创建了多种索引,索引文件就会大大增加。索引只是提高效率的一个因素,如果你的MySQL有大数据量的表,就需要你花时间去建立最优秀的索引,或者优化查询语句,分表,或者分区。

➋索引创建

建表时创建:

CREATE TABLE 表名(
字段名 数据类型 [完整性约束条件],
……,
[UNIQUE | FULLTEXT | SPATIAL] INDEX | KEY
索引名(字段名1 [(长度)] [ASC | DESC]) [USING 索引方法]
);

参数说明:

UNIQUE:可选。表示索引为唯一性索引。
FULLTEXT:可选。表示索引为全文索引。
SPATIAL:可选。表示索引为空间索引。

INDEXKEY:用于指定字段为索引,两者选择其中之一就可以了,作用是一样的。
索引名:可选。给创建的索引取一个新名称。
字段名1:指定索引对应的字段的名称,该字段必须是前面定义好的字段。
长度:可选。指索引的长度,必须是字符串类型才可以使用。
ASC:可选。表示升序排列。
DESC:可选。表示降序排列。


15.threadlocal变量及作用

介绍

ThreadLocal是为了线程隔离,它使得每个线程都有自己独立的变量副本,从而避免了多线程环境下的数据竞争和线程安全问题.

  • get(): 获取当前线程的线程局部变量值。
  • set(T value): 设置当前线程的线程局部变量值。
  • remove(): 移除当前线程的线程局部变量值,防止内存泄漏。
  • initialValue(): 返回一个初始值,这是一个受保护的方法,可以在子类中重写以提供初始值。

内存泄漏的原因

用ThreadLocalMap是来存entry,因为key为弱引用,value为强引用,会存在内存泄漏问题:ThreadLocal在保存的时候会把自己当做Key存在ThreadLocalMap中,正常情况应该是key和value都应该被外界强引用才对,但是现在key被设计成弱引用了。这就导致了一个问题,ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。就比如线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal设定的value值被持有,导致内存泄露。

由于Thread中包含变量ThreadLocalMap,因此 ThreadLocalMap与Thread的生命周期是一样长,如果都没有手动删除对应key,都会导致内存泄漏。

​但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set(),get(),remove()的时候会被清除。

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

使用场景

  • 线程安全的对象使用:在多线程环境下,我们可能需要每个线程使用自己独立的对象实例,例如 SimpleDateFormatSimpleDateFormat 不是线程安全的,使用 ThreadLocal 可以为每个线程提供一个独立的 SimpleDateFormat 实例。
  • 数据库连接管理:在基于线程的环境中(如 Web 应用),可以使用 ThreadLocal 来管理数据库连接,为每个线程提供独立的连接实例,以避免连接共享带来的线程安全问题。
  • 日志记录:在日志记录中,ThreadLocal 可以用来存储当前线程的上下文信息,如用户 ID 或事务 ID,以便在日志输出时提供更多的调试信息。
  • 用户会话管理:在 Web 应用中,每个用户的会话数据可以使用 ThreadLocal 存储,从而确保同一用户的多个请求在同一个线程中处理时能够访问到正确的会话数据。

注意事项

1️⃣内存泄漏:如果线程不再需要使用该变量,但忘记调用 remove() 方法来清理,那么由于 ThreadLocalMap 中的 Entry 的 key 是对 Thread 的弱引用,所以 Thread 被回收后,Entry 的 key 会被置为 null,但 value 不会被回收,从而导致内存泄漏。因此,使用完 ThreadLocal 后,最好调用 remove() 方法来清理。

解决方案

①每次使用完ThreadLocal都调用它的 remove() 方法清除数据
将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。

2️⃣线程池中的使用:在线程池中,线程可能会被复用。如果线程之前设置过 ThreadLocal 变量,但在使用后没有清理,那么下一个任务可能会读取到上一个任务设置的值。因此,在线程池中使用 ThreadLocal 时需要特别小心。
3️⃣性能开销:虽然 ThreadLocal 可以简化多线程编程,但是频繁的创建和销毁线程局部变量也会带来一定的性能开销。


16.spring框架bean对象的生命周期

  1. Bean 的创建:当 Spring 容器启动时,根据配置文件中的定义创建 Bean 的实例。
  2. 依赖注入:为新创建的 Bean 注入其所需的属性(即依赖关系)。
  3. 初始化:执行任何必要的初始化工作,比如设置资源或者建立数据库连接等。
  4. 运行时服务:Bean 处于活动状态,提供服务。
  5. 销毁:容器决定销毁 Bean 时执行必要的清理工作。

17.SpringBoot框架中yml和properties文件哪个优先加载

yml->ymal->properties
由里向外加载,所以最外层的最后被加载,会覆盖里层的属性

18.怎么理解多线程,你的项目中哪里用到了多线程

19.10000个数据包含字母和数字,用ASCLL码排序,怎么实现,comparator比较器怎么实现

20.什么是二叉树、红黑树、B+树

R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。 红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。

红黑树的特性

  1. 每个节点或者是黑色,或者是红色。
  2. 根节点是黑色。
  3. 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
  4. 如果一个节点是红色的,则它的子节点必须是黑色的。
  5. 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

红黑树的应用比较广泛,主要是用它来存储有序的数据,它的时间复杂度是O(lgn),效率非常之高。 例如,Java集合中的TreeSet和TreeMap,C++ STL中的set、map,以及Linux虚拟内存的管理,都是通过红黑树去实现的。


21.JVM的内存结构介绍一下

堆、方法区、虚拟机栈、本地方法栈、程序计数器


22.HashMap和ConcurrentHashMap;底层实现原理等

底层的实现方式是数组+链表+红黑树(加红黑树为了解决二叉查找树的缺陷,因为二叉查找树可能会退化成线性结构)

①HashMap基础知识

  1. 数组结构存储区间连续、内存占用严重、空间复杂度大
    优点:随机读取和修改效率高,原因是数组是连续的(随机访问性强,查找速度快)
    缺点:插入和删除数据效率低,因插入数据,这个位置后面的数据在内存中都要往后移动,且大小固定不易动态扩展。

  2. 链表结构存储区间离散、占用内存宽松、空间复杂度小
    优点:插入删除速度快,内存利用率高,没有固定大小,扩展灵活
    缺点:不能随机查找,每次都是从第一个开始遍历(查询效率低)

  3. 哈希表结构结合数组结构和链表结构的优点,从而实现了查询和修改效率高,插入和删除效率也高的一种数据结构

常见的HashMap的数据结构

1️⃣HashMap底层是一个Entry[ ]数组,当存放数据时,会根据hash算法来计算数据的存放位置

2️⃣算法:hash(key)%n , n就是数组的长度,其实也就是集合的容量

3️⃣当计算的位置没有数据的时候,会直接存放数据

4️⃣当计算的位置,有数据时,会发生 hash冲突/hash碰撞 ,

解决的办法链地址法:采用链表的结构,在对应的数据位置存放链表的头节点,对于这个链表来说,每次新加的节点会从头部位置开始加入,也就是说,数组中的永远是新节点.

②HashMap扩容

1️⃣、加载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

前面的讲述已经发现,当你空间只有仅仅为10的时候是很容易造成2个对象的hashcode所对应的地址是一个位置的情况。这样就造成2个对象会形成散列桶(链表)。

这时就有一个加载因子的参数,值默认为0.75 ,如果你hashmap的空间有100那么当你插入了75个元素的时候hashmap就需要扩容了,不然的话会形成很长的散列桶结构,对于查询和插入都会增加时间,因为它要一个一个的equals比较。

但又不能让加载因子很小,如0.01,这样显然是不合适的,频繁扩容会大大消耗你的内存。这时就存在着一个平衡,jdk中默认是0.75,当然负载因子可以根据自己的实际情况进行调整。

2️⃣、为何随机增删、查询效率都很高的原因是? 原因: 增删是在链表上完成的,而查询只需扫描部分,则效率高。(存在链表退化问题)

HashMap集合的key,会先后调用两个方法,hashCode and equals方法,这两个方法都需要重写

JDK1.8后引入了红黑树:当hash表的单一链表长度 超过 8 个 的时候,链表结构就会转为红黑树结构。好处就是避免在最极端的情况下链表变得很长很长,在查询的时候,效率会非常慢。红黑树是一种近似平衡的二叉查找树,其主要的优点就是“平衡“,即 左右子树高度几乎一致 ,以此来防止树退化为链表

③HashMap中的put()和get()的实现原理:

1、map.put(k,v)实现原理

1️⃣首先将k,v封装到Node对象当中(节点)。
2️⃣然后它的底层会调用K的hashCode()方法得出hash值。
3️⃣通过哈希表函数/哈希算法,将hash值转换成数组的下标,下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表, 此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。 如其中有一个equals返回了true,那么这个节点的value将会被覆盖。

put源码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 数组为空的时候
    if ((tab = table) == null || (n = tab.length) == 0)
        // 第一次扩容后的数组长度
        n = (tab = resize()).length;
    // 计算节点的插入位置,如果该位置为空,则新建一个节点插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

2、map.get(k)实现原理

1️⃣先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
2️⃣通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。如果这个位置上什么都没有,则返回null。
如果这个位置上有单向链表,那么它就会拿着K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。 如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。

get源码
final Node<K,V> getNode(int hash, Object key) {
    // 获取当前的数组和长度,以及当前节点链表的第一个节点(根据索引直接从数组中找)
    Node<K,V>[] tab;
    Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 如果第一个节点就是要查找的节点,则直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 如果第一个节点不是要查找的节点,则遍历节点链表查找
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 如果节点链表中没有找到对应的节点,则返回 null
    return null;
}

3、实现原理决定HashMap存在线程安全问题:https://juejin.cn/post/6844903796225605640#heading-3

1️⃣多线程的put可能导致元素的丢失
2️⃣put和get并发时,可能导致get为null
3️⃣JDK7中HashMap并发put会造成循环链表,导致get时出现死循环

④ConcurrentHashMap的底层实现原理:

目前有如下一些方式可以获得线程安全的HashMap:

1️⃣Collections.synchronizedMap
2️⃣HashTable
3️⃣ConcurrentHashMap

前两种方式由于全局锁的问题,存在很严重的性能问题。针对HashTable会锁整个hash表的问题,ConcurrentHashMap提出了分段锁的解决方案。

---------------------------------JDK1.7--------------------------------

1.分段锁 的思想就是:锁的时候不锁整个hash表,而是只锁一部分

如何实现呢?这就用到了ConcurrentHashMap中最关键的 Segment 。ConcurrentHashMap中维护着一个Segment数组,每个Segment可以看做是一个HashMap。而Segment本身继承了ReentrantLock,它本身就是一个锁。在Segment中通过HashEntry数组来维护其内部的hash表。每个HashEntry就代表了map中的一个K-V,用HashEntry可以组成一个链表结构,通过next字段引用到其下一个元素。

整体结构

只要我们的hash值足够分散,那么每次put的时候就会put到不同的segment中去。 而segment自己本身就是一个锁,put的时候,当前segment会将自己锁住,此时其他线程无法操作这个segment, 但不会影响到其他segment的操作。这个就是锁分段带来的好处。

2.线程安全的put

3.线程安全的扩容(Rehash)

HashMap的线程安全问题大部分出在扩容(rehash)的过程中。ConcurrentHashMap的扩容只针对每个segment中的HashEntry数组进行扩容。由上述put的源码可知,ConcurrentHashMap在rehash的时候是有锁的,所以在rehash的过程中,其他线程无法对segment的hash表做操作,这就保证了线程安全。

--------------------------------JDK1.8---------------------------------

https://juejin.cn/post/6844903813892014087


23.*线程池的创建方式,底层原理(源码,理论)

常用的几个创建线程池方法

  • newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  • newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • newScheduledThreadPool :创建一个定长线程池,支持定时及周期性任务执行
  • newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

线程数怎么设置一般看业务场景,分为IO密集型和CPU密集型场景

  • CPU密集型场景:理论上CPU核数和线程数一致最合适,实际工程上线程数会设置成CPU核数+1, 这样当线程因为因为额外的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上。

  • IO密集型场景:可以根据公式计算,一般可设置为2N,
    线程阻塞时间和线程忙碌时间可以通过压测与代码埋点统计获取, 比如本机R7-5800H 8核16线程CPU

公式

\[ 最佳线程数 = CPU核心数 · (1/CPU利用率) = CPU核心数 · (1 + (IO耗时/CPU耗时)) \]

手动创建线程池:

创建线程池的7个参数

  • 1️⃣ corePoolSize:线程池的核心线程数
  • 2️⃣ maximumPoolSize:能容纳的最大线程数,最大线程池数量,当线程数>=corePoolSize,且任务队列已满时,线程池会创建新线程来处理任务;任务队列已满时, 且当线程数=maxPoolSize,,线程池会拒绝处理任务而抛出异常,也一样根据业务来,太大消耗资源,太小容易满
  • 3️⃣ keepAliveTime:空闲线程存活时间
  • 4️⃣ unit: 存活的时间单位
  • 5️⃣ workQueue: 存放提交但未执行任务的队列
  • 6️⃣ threadFactory:创建线程的工厂类
  • 7️⃣ handler: 等待队列满后的拒绝策略

例子

@Bean("taskAsyncExecutor")
public AsyncListenableTaskExecutor taskExector() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    //获取到服务器的cpu内核
    int i = Runtime.getRuntime().availableProcessors();
    // CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为N(CPU 核心数)+1,
    // 多出来的一个线程是为了防止某些原因导致的线程阻塞(如IO操作,线程sleep,等待锁)而带来的影响。
    // 一旦某个线程被阻塞,释放了CPU资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
    // *******************************************************************************
    // I/O 密集型任务(2N): 系统的大部分时间都在处理 IO 操作,此时线程可能会被阻塞,释放CPU资源,
    // 这时就可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务的应用中,可以多配置一些线程,
    // 具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (IO耗时/CPU耗时)),
    // 一般可设置为2N。
    executor.setCorePoolSize(2 * i + 1);
    // 最大线程池数量,当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
    // 任务队列已满时, 且当线程数=maxPoolSize,,线程池会拒绝处理任务而抛出异常
    // 也一样根据业务来,太大消耗资源,太小容易满
    executor.setMaxPoolSize(100);
    // 阻塞队列 当核心线程数达到最大时,新任务会放在队列中排队等待执行
    // 这个也是需要根据业务需要来,太大消耗资源,太小线程池队列就容易满,根据拒绝策略来分情况会出现不一样的可能
    // 如AbortPolicy(),就会抛RejectedExecutionException异常
    executor.setQueueCapacity(1024);
    // 线程空闲时间
    executor.setKeepAliveSeconds(60);
    // spring 提供的 ThreadPoolTaskExecutor 线程池,是有setThreadNamePrefix() 方法的。
    // jdk 提供的ThreadPoolExecutor 线程池是没有 setThreadNamePrefix() 方法的
    executor.setThreadNamePrefix("taskAsyncExecutor->");
    // rejection-policy:拒绝策略:当线程数已经达到maxSize的时候,如何处理新任务
    // CallerRunsPolicy():交由调用方线程运行,比如 main 线程;如果添加到线程池失败,那么主线程会自己去执行该任务,不会等待线程池中的线程去执行, (个人推荐)
    // AbortPolicy():该策略是线程池的默认策略,如果线程池队列满了丢掉这个任务并且抛出RejectedExecutionException异常。
    // DiscardPolicy():如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常
    // DiscardOldestPolicy():丢弃队列中最老的任务,队列满了,会将最早进入队列的任务删掉腾出空间,再尝试加入队列
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
    // 设置线程池中任务的等待时间,如果超过这个时候还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是阻塞住。
    // 具体时间看业务需要,也不能一直等,太久也不合适
    executor.setAwaitTerminationSeconds(60);
    // 设置线程池关闭的时候等待所有任务都完成再继续销毁其他的Bean,这样这些异步任务的销毁就会先于Redis线程池的销毁
    executor.setWaitForTasksToCompleteOnShutdown(true);
    // 如果设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭
    executor.setAllowCoreThreadTimeOut(true);
    executor.initialize();
    return executor;
}
ThreadPoolExecutor executor = new ThreadPoolExecutor(
     5,
     10,
     60, 
     TimeUnit.SECONDS,
     new ArrayBlockingQueue<>(10000),
     threadFactory
);

新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务

jdk中提供了四种工作队列(这个也很重要,实际开发也需要很关注):

1️⃣ArrayBlockingQueue基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。

2️⃣LinkedBlockingQueue基于链表的无界阻塞队列(其实最大容量为Integer.MAX),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。

3️⃣SynchronousQueue一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。

4️⃣PriorityBlockingQueue具有优先级的无界阻塞队列,优先级通过参数Comparator实现。

handler 拒绝策略

​当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,该如何处理呢。这里的拒绝策略,就是解决这个问题的,jdk中提供了4中拒绝策略:

1️⃣CallerRunsPolicy:该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池shutdown,则直接抛弃任务

2️⃣AbortPolicy:该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。

3️⃣DiscardPolicy:该策略下,直接丢弃任务,什么都不做。

4️⃣DiscardOldestPolicy:该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列


24.nginx的配置方式有几种分别?轮询,权重,iphash分别什么场景使用(实操记重点)

负载均衡策略:

  1. 轮询策略:根据配置文件顺序依次访问服务器--默认策略
  2. 权重策略:根据设定的权重大小挑选那台服务器有限访问,使用 **weight**关键字定义权重
  3. IPHASH策略:每个请求 按访问 ip 的 hash 结果分配,这样每个访客固定访问一个后端服务器,可以解决 session 的问题。需要用户与服务器绑定,则使用该策略
  4. least_conn:把请求转发给连接数较少的后端服务器。

IPHASH优先级比较高,会覆盖权重策略 IPHASH算法原理:TODO

25.mybatis框架有几级缓存,默认开启几级?(按场景分析)

二级缓存配置(默认只开启一级)

  • eviction:缓存回收策略(默认是LRU)

    1. LRU-最近最少使用的:移除最长时间不被使用的对象
    2. FIFO-先进先出:按对象进入缓存的顺序移除它们
    3. SOFT-软引用:移除基于垃圾回收器状态和软引用的对象
    4. WEAK-弱引用:更积极的移除基于垃圾回收器状态和弱引用的对象
  • flushInterval:刷新间隔单位毫秒,缓存多少时间清空一次,默认情况是不设置,也就是没有间隔,缓存仅仅调用语句时刷新。

  • size:引用数目正整数,代表缓存最多可以存储多少个对象,太大容易导致内存溢出。
  • readOnly:只读,true/false

    1. true:只读缓存会给所有调用者返回缓存对象的相同实例,因此这次对象不可以被修改,这提供了很重要的性能优势,mybatis会认为所有从缓存中获取数据的操作都是只读操作,mybatis为了加快获取速度,直接就将缓存中的引用交给用户,不安全,速度快
    2. false:读写缓存会返回缓存对象的拷贝(通过序列化),性能稍差,但是安全,默认是false,mybatis会认为获取的数据可能会被修改,就利用序列化&反序列化拷贝一份新的数据。
  • type:指定自定义缓存的全类名,实现Cache接口即可。

①MyBatis一级缓存概述

MyBatis 的一级缓存是 SqlSession 级别的,通过同一个 SqlSession 对象查询的数据会被缓存,下次再查询相同的数据时, 就会从缓存中直接获取,不会从数据库重新访问; 一般我们说到 MyBatis 的一级缓存时,都是针对查询操作而言的;

②MyBatis 的一级缓存是默认开启的。

缓存失效的四种情况

  1. 不同的 SqlSession 对象对应不同的一级缓存,即使查询相同的数据,也要重新访问数据库;
  2. 同一个 SqlSession 对象,但是查询的条件不同;
  3. 同一个 SqlSession 对象两次查询期间执行了任何的“增删改”操作,无论这些“增删改”操作是否影响到了缓存的数据;
  4. 同一个 SqlSession 对象两次查询期间手动清空了缓存(调用了 SqlSession 对象的 clearCache() 方法)。

③几种特殊情况

  1. 如果在同一个 SqlSession 对象两次查询同一数据期间,我们使用另一个 SqlSession 对象修改了这个数据,那么这两次查询返回的结果依旧是相同的(说明 SqlSession 对象还是从一级缓存中获取了数据),即使数据已经发生了变化;
  2. 同理第一种情况,如果在同一个 SqlSession 对象两次查询同一数据期间,我们使用 Navicat、Mysql Workbench 等数据库管理工具修改了这一数据,那么这两次查询返回的结果依旧是相同的,即使数据已经发生了变化;
  3. 如果在同一个 SqlSession 对象两次查询同一数据期间,我们使用该对象“增删改”了与该数据无关的其它数据,并没有进行任何涉及该数据的操作, 数据也没有发生变化,那么 MyBatis 的一级缓存依旧会失效,这延伸自 2 中的第 3 种情况;
  4. 如果在同一个 SqlSession 对象两次查询同一数据期间,我们又多次查询了其它数据(期间没有进行任何的“增删改”操作),那么数据库中的该数据没有发生变化,MyBatis 也会顺利从一级缓存中获取到该数据。

Mybatis二级缓存

①二级缓存开启条件

  1. 在核心配置文件中,设置全局配置属性cacheEnabled="true",默认为true,不需要设置
  2. 映射文件中必须设置 </cache> 标签
  3. 二级缓存必须在SqlSession提交关闭或提交后才会生效,范围是SqlSessionFactory
  4. 查询的数据所转换的实体类类型必须实现序列化接口

②二级缓存失效情况

两次查询之间执行任意增删改,使一级和二级缓存同时失效

③mybatis缓存查询的顺序

  1. 先查询二级缓存,因为二级缓存中可能会有其他程序已经查询出来的数据,可以直接哪来用
  2. 如果二级缓存没有命中,在查询一级缓存
  3. 如果一级缓存也没有命中,查询数据库
  4. SQLSession关闭后,一级缓存中的数据会写入二级缓存