1.HashMap的底层原理
JDK1.7版本之前,HashMap的底层数据结构是数组+链表,HashMap通过哈希算法会将元素的key映射待数组的的槽位(Bucket)。如果多个键映射到同一个槽位,就会以链表的形式存储在同一个槽位上。但是链表的查询的复杂度O(n),所有冲突比较严重,当链表的长度很长时,效率就很低了;
所以在JDK1.8之后,一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。
2.Synchronized关键字
1. 基本概念
synchronized 是 Java 中实现线程同步的核心机制,通过内置锁(监视器锁,Monitor Lock)确保多线程环境下对共享资源的互斥访问。其底层实现涉及 对象头、锁状态升级、JVM 指令 等关键机制。
2. 对象头与 Monitor
对象头结构:
Java 对象在内存中分为三部分:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)。
对象头中的锁信息:存储锁标志位(Lock Flag)、偏向线程ID、轻量级锁指针、重量级锁指针等。
锁标志位(Mark Word):标识当前对象的锁状态(无锁、偏向锁、轻量级锁、重量级锁)。
Monitor 机制:
每个 Java 对象都与一个 Monitor 关联。
线程执行 synchronized 代码块时,需先通过 monitorenter 指令获取对象的 Monitor。
执行完毕时,通过 monitorexit 指令释放 Monitor。
Monitor内部具体的存储结构:
- Owner:存储当前获取锁的线程的,只能有一个线程可以获取
- EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
- WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
具体的流程:
- 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
- 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待
3.回答
synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
monitor内部维护了三个变量
- WaitSet:保存处于Waiting状态的线程
- EntryList:保存处于Blocked状态的线程
- Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的
syncronized锁升级的过程讲一下
无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。
最开始系统时无锁的状态,当有第一个线程来访问时,jvm将对象头的MarkWord锁标志设置为偏向锁,然后将线程id记录到MarkWord里面,这个时候这个线程进入这个同步代码块就不需要其他的同步操作了,就非常的快了;
当第二个线程来抢锁,就会升级到轻量级锁,第二个线程拿不到锁,就会采用cas自旋的方式不断重新尝试获取锁,轻量级锁考虑的时竞争锁线程不多,而且线程持有锁的时间也不长的一个情景。
当第二个线程自旋到一定的次数之后还是没有拿到锁或者有更多线程来抢锁了就会升级为重量级锁,重量级锁加锁就需要调用操作系统的底层mutex,所以每次切换线程都需要操作系统切换内核态,开销很大。
这时候MarkWord的指针就会指向锁监视器monitor。
锁监视器主要是用来负责记录锁的拥有者,记录锁的重入次数,负责线程的阻塞唤醒,可以来说锁监视器就是一个对象有这几个字段:
owner(持有锁的线程)
waitSet(等待池)
Eetrylist(锁池--管理竞争锁失败而阻塞的线程)
recursions(记录锁的重入次数)
其他的不重要
3.强引用、软引用、弱引用、虚引用的区别?
强引用: new一个对象就是强引用
软引用: 被扫描到并且内存不足的时候就会被回收,用于实现内存敏感的缓存。比如图片缓存
弱引用: 被扫描到就直接回收的可以避免内存泄漏。比如ThearLocal
虚引用: 用于监控对象回收过程
4.TheadLocal
ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。
ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组,Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。
弱引用的目的是为了防止内存泄露,如果是强引用那么ThreadLocal对象除非线程结束否则始终无法被回收,弱引用则会在下一次GC的时候被回收。 但是这样还是会存在内存泄露的问题,假如key,ThreadLocal对象被回收之后,entry中就存在key为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。
但是只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。
ThreadLocal 是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和lock(这里的lock 指通过synchronized 或者Lock 等实现的锁) 是有本质的区别的:
- lock 的资源是多个线程共享的,所以访问的时候需要加锁。
- ThreadLocal 是每个线程都有一个副本,是不需要加锁的。
- lock 是通过时间换空间的做法。
- ThreadLocal 是典型的通过空间换时间的做法
5.线程池
- corePoolSize 核心线程数目
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
6.拒绝策略有哪些?
1. AbortPolicy (默认策略)
直接抛出异常,拒绝新任务
2.CallerRunsPolicy
将任务回退给提交任务的线程执行
3.DiscardPolicy
·静默丢弃新提交的任务,不抛出异常,也不执行任务。
4.DiscardOldestPolicy
丢弃队列中最旧的任务(即队列头部的任务),然后尝试重新提交当前任务。
5.自定义拒绝策略
一般可以把任务持久化到数据库/消息队列
记录日志并触发警告
7.JVM内存模型
- 程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,生命周期与线程相同。
- Java 虚拟机栈:每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。
- 本地方法栈:与 Java 虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。
- Java 堆:是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。
- 方法区(元空间):在 JDK 1.8 及以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有 “非堆” 的别名。方法区可以选择不实现垃圾收集,内存不足时会抛出 OutOfMemoryError 异常。
- 运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出 OutOfMemoryError 异常。
- 直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常。
8.双亲委派机制
1. 概念解释
双亲委派机制(Parent Delegation Model)是 Java 类加载器(ClassLoader)中的一种工作机制。它规定了类加载器在加载类时,优先将加载任务委托给父类加载器,只有当父类加载器无法完成加载时,子类加载器才会尝试自己加载。
这种机制的核心思想是通过层级关系来管理和隔离类加载过程,从而确保类的唯一性和安全性。
2. 工作原理
双亲委派机制的工作流程可以分为以下几个步骤:
- 委托父类加载器:当一个类加载器收到加载类的请求时,它首先不会自己尝试去加载这个类,而是将请求委派给父类加载器。
- 递归向上委托:父类加载器继续将请求向上传递给它的父类加载器,直到到达顶层的启动类加载器(Bootstrap ClassLoader)。
- 尝试加载:如果父类加载器能够加载该类,则直接返回加载结果;如果父类加载器无法加载(例如找不到类),则子类加载器会尝试自己加载。
- 加载失败:如果所有类加载器都无法加载目标类,则抛出
ClassNotFoundException
。
3. 优点与作用
双亲委派机制的设计具有以下优点和作用:
- 类的唯一性:通过双亲委派机制,确保同一个类只会被加载一次,避免重复加载和冲突。例如,核心类库(如
java.lang.String
)由启动类加载器加载,保证其全局唯一性。 - 安全性:防止用户自定义的类冒充核心类库中的类。例如,用户不能通过自定义类加载器加载一个名为
java.lang.String
的恶意类,因为这类请求会被委派到启动类加载器处理。 - 模块化隔离:不同类加载器负责加载不同的模块或应用,避免类之间的冲突,增强了系统的模块化设计。
4. 实际应用场景
- Java 核心类库加载:启动类加载器负责加载核心类库(如
rt.jar
中的类),而扩展类加载器和应用类加载器分别负责加载扩展类库和应用程序类。 - Web 容器中的类加载:在 Tomcat 等 Web 容器中,每个 Web 应用都有自己独立的类加载器,遵循双亲委派机制,既能加载共享的类库(如 Servlet API),又能隔离各个应用的私有类。
- 插件化开发:在一些插件化系统中,主程序和插件使用不同的类加载器,通过双亲委派机制实现类的隔离和共享。
5. 可能的扩展问题
面试官可能会进一步提问一些相关问题,提前准备可以帮助你更好地应对:
- 如何打破双亲委派机制?
- 自定义类加载器,重写
loadClass
方法,改变默认的加载逻辑。 - 例如,OSGi 框架中通过自定义类加载器实现模块间的动态加载和卸载。
- 自定义类加载器,重写
- 为什么需要打破双亲委派机制?
- 在某些场景下,需要支持热部署、模块化加载或动态更新,这时需要绕过双亲委派机制。
- 常见的类加载器有哪些?
- 启动类加载器(Bootstrap ClassLoader)
- 扩展类加载器(Extension ClassLoader)
- 应用类加载器(Application ClassLoader)
- 自定义类加载器(Custom ClassLoader)
9.JVM垃圾回收算法
在面试中回答“JVM垃圾回收算法”时,建议从以下几个方面入手:先概述垃圾回收的基本概念,然后详细介绍主流的垃圾回收算法,最后结合实际应用场景进行分析。这样可以让面试官感受到你对JVM垃圾回收机制有全面且深入的理解。
1. 垃圾回收的基本概念
垃圾回收(Garbage Collection, GC)是JVM自动管理内存的一种机制,用于释放不再使用的对象所占用的内存空间。它的主要目标是:
- 自动管理堆内存,减少内存泄漏和手动管理内存的复杂性。
- 提高程序的稳定性和性能。
判定对象是否可回收: - 引用计数法:通过记录对象被引用的次数来判断是否可回收。这种方法简单但容易产生循环引用问题,因此现代JVM通常不使用。
- 可达性分析(GC Roots Tracing):通过一组称为“GC Roots”的对象作为起点,遍历对象图,标记所有可达的对象,其余不可达的对象即为垃圾。
常见的GC Roots包括: - 虚拟机栈中的局部变量表。
- 方法区中的类静态属性。
- 本地方法栈中的JNI引用。
2. 主流的垃圾回收算法
JVM中常用的垃圾回收算法主要包括以下几种:
(1)标记-清除算法(Mark-Sweep)
- 过程:
- 标记:从GC Roots开始,遍历并标记所有存活对象。
- 清除:遍历整个堆,将未标记的对象回收。
- 优点:实现简单。
- 缺点:
- 效率较低,尤其是对象较多时。
- 会产生内存碎片,导致后续大对象分配失败。
(2)复制算法(Copying)
- 过程:
- 将堆内存分为两个相等的区域:From Space 和 To Space。
- 每次只使用其中一个区域(From Space),当该区域满时,将存活对象复制到另一个区域(To Space),并清空原区域。
- 优点:
- 解决了内存碎片问题。
- 回收效率高,适合存活对象较少的场景。
- 缺点:
- 需要双倍的内存空间。
- 如果存活对象较多,复制成本较高。
(3)标记-整理算法(Mark-Compact)
- 过程:
- 标记:与标记-清除类似,标记所有存活对象。
- 整理:将存活对象向一端移动,清理掉边界外的垃圾。
- 优点:
- 避免了内存碎片问题。
- 适合老年代(Old Generation)这种存活对象较多的场景。
- 缺点:
- 由于需要整理内存,效率比标记-清除低。
(4)分代收集算法(Generational Collection)
- 背景:根据对象的生命周期特点,将堆内存划分为新生代(Young Generation)和老年代(Old Generation)。
- 新生代:
- 大部分对象都是“朝生夕死”,采用复制算法。
- 细分为Eden区和两个Survivor区(S0、S1)。
- 老年代:
- 存活时间较长的对象,采用标记-清除或标记-整理算法。
- 优点:
- 针对不同生命周期的对象选择不同的回收策略,提升效率。
- 减少不必要的扫描和复制操作。
3. 常见的垃圾回收器
JVM提供了多种垃圾回收器,每种回收器适用于不同的场景。以下是几种主流的垃圾回收器及其特点:
- Serial GC:单线程回收,适合小型应用或单核环境。
- Parallel GC(吞吐量优先):多线程并行回收,适合对吞吐量要求较高的场景。
- CMS(Concurrent Mark-Sweep):以最短停顿时间为目标,适合对响应时间敏感的应用。
- G1(Garbage First):基于分区的垃圾回收器,兼顾吞吐量和停顿时间。
- ZGC 和 Shenandoah:低延迟垃圾回收器,适合超大规模内存的应用。
10.IOC和AOP
IOC:即控制反转的意思,它是一种创建和获取对象的技术思想,依赖注入(DI)是实现这种技术的一种方式。传统开发过程中,我们需要通过new关键字来创建对象。使用IoC思想开发方式的话,我们不通过new关键字创建对象,而是通过IoC容器来帮我们实例化对象。 通过IoC的方式,可以大大降低对象之间的耦合度。
AOP: 面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为“切面”(Aspect),减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性
11.Mysql事务
事务就是一组操作的集合,他是不可分割的一部分,这些操作要么同时成功,要么同时失败。
- 原子性:是不可分割的最小操作单元,要么全部成功,要么全部失败。
- 一致性:事务完成时,必须使所有的数据都保持一致状态。
- 持久性:一旦提交或回滚,它对数据库中的数据的改变就是永久的。
- 隔离性:事务在不受外部并发操作影响的独立环境下运行
12.索引
索引的定义:
索引时数据库在存储引擎中,帮助存储引擎高效获取数据的一种数据结构。。例如书中的目录,如果没有索引要找内容就需要全书查找,有了目录(也就输索引)就可以提高查找效率
为什么InnoDb存储引擎中索引使用B+树?
首先对于B树:B+Tree 只在叶子节点存储数据,而 B 树的非叶子节点也要存储数据,所以在相同的磁盘I/O 次数下,就能查询更多的节点。另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点;
然后二叉树而言:对于N个叶子节点的B+树,他的搜索复杂度为O(log dN),d表示节点允许的最大节点数。当d为100是,干万数据,B+树的高度维持在3,4层,一般3,4次磁盘IO就可以得到数据。而二叉树高度很高,需要的磁盘IO次数要更多
最后对于Hash:Hash不能做范围查找和排序,更适合等值查询
13.MVCC
MVCC主要依赖以下机制实现:
- 数据库行记录中的隐藏字段:最近修改本行数据的事务id、回滚指针指向本行数据的上个版本(其实是指向undo,通过undo计算上一个版本)
- undo log:回滚日志,在insert、delete、update时产生,记载了与操作相反的操作,比如delete操作对应了一条insert日志
- readview:快照读SQL执行时产生的读视图,生成的一个快照,记录了以下字段,用于数据的可见性判断:
- 当前活跃的事务id集合
- 最小活跃事务id
- 预分配事务id,即应该分配的下一个事务id
- readview创建者id,即本事务id
MVCC如何判断数据的可见性?
首先根据本条数据的是否是由本事务生成的,如果是,那可见,毕竟自己做的修改自己肯定是能看见的
再判断本条数据的事务id是否比最小活跃事务id要小,如果是,那可见,因为这说明这条数据是已经被其它事务提交的,本事务应该可见
再判断本条数据的事务id是否不在活跃事务id集合中,如果不在,那可见,因为这说明也是其它事务提交了的数据
如果本条数据对本事务不可见,就根据隐藏字段 + undo log计算出上一个版本的数据,然后继续判断,直到找到某条对本事务可见的数据
14.Redis常见的数据结构
String(字符串):
Hash(哈希表)
List(列表)
Set(集合)
Sorted Set(有序集合)
HyperLogLog(基数统计)
Bitmaps(位图)
Geospatial(地理位置)
Stream(流)
15.redis三大件
16.消息可靠性投递,消息丢失和重复消费问题
消息可靠性:
1.消息持久化,确保消息队列的持久化
2.消息确认,消费者成功处理消息后,会向消息队列发送确认。消息队列只有收到确认消息才将消息删除。没有收到消息,消息队列要在一定时间重发。
3.消息重试,消息者处理消息失败后,要合理的重试策略
消息丢失:
1.消息生产阶段: 生产消息到MQ的过程中,只要能正常收到MQ的ACK确认响应。
2.消息存储阶段: 可以部署多个集群,MQ也要开启持久化功能保证消息不丢失
3.消息消费阶段: 消费者接收消息+消息处理之后,才回复ACK。
重复消费:
唯一标识:客户端为每一个请求都生成一个全局唯一id,服务端校验id是否被处理(接口调用)
数据库事务/乐观锁:通过版本号或者状态字段来控制并发更新,确保多次更新等同一次操作(余额扣减)
分布式锁:通过分布式锁保证同一时刻只能有一个请求执行(秒杀业务)
消息去重:消息队列生产者为每条消息生成唯一id,消费者处理消息先校验id是否处理过。
17.什么情况会产生死锁问题?如何解决?
互斥条件:多个线程不能同时使用同一个资源
持有并等待条件:线程在持有至少一个资源的同时,请求获取其他被占用的资源,并等待而不释放已持有的资源
不可剥夺条件:线程已获取的资源没有使用完之前,不能被其他线程强行剥夺,只能等持有者主动释放
循环等待条件:俩个线程获取资源的顺序构成了环形链
解决方法
最常用:使用资源有序分配法,来破环环路等待条件
线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源
Redis的持久化
RDB()
AOF()