05-Java基础面试实战
面试底层逻辑
面试官其实会根据公司的架构体系,提炼出一套符合公司体系的技术栈,再根据这些技术栈来进行面试,常见的一些如下:
Java 基础方面:Java 并发、Java 网络编程、MySQL、JDK 集合、JVM、Spring 源码、Tomcat、Linux中间件方面:Redis、MQ、Dubbo、分布式、ES可以体现过人之处的方面:生产实践、系统设计、源码了解深度
在面试中,其实面试官问的最多的还是一些基础方面的内容,只有基础方面你回答的非常好了,那么面试官才会考虑去问你更高阶的东西,所以在面试过程中,基础能力 是很重要的,也决定了面试官对你的印象怎样
而在上边列出的这么多的方面中,其中 Java 并发、MySQL、网络编程、JVM 是最基础也是最容易考察的部分,所以在学习的时候,要分好先后顺序,不要只顾上层建筑而忽略了地基
学完之后,可以拿到什么水平的 offer?
从技术角度而言,技术广度 要先达到,其次是 计算级基础的知识 是必须要掌握牢固的,比如基础的数据结构、计算机网络、操作系统方面的内容都要了解,还有就是 技术深度 (源码了解程度、项目挖掘的深度),项目经验(做的什么 2C 还是 2B 项目?哪些业务方面的项目?),以及 架构的一些设计,比如你负责了哪些项目的设计和开发,项目的架构是怎样的?系统的 QPS 是多少?巅峰期 QPS 怎样的呢?架构有多复杂?这些都是进阶内容
从综合角度而言,这方面比较看重你的 学历 以及过往的 履历,还有就是你的表达能力、性格方面是怎样的,以及对薪资的要求
如果学历是 211 本科及之上,或者履历比较优秀,进 BAT 是没有太大的问题的
如果学历一般,履历也一般,掌握这些后,对于中小型的公司的面试都可以拿下 offer
一线城市可以拿到 20k 左右
但是这要求你不仅要去学习上边列出的这些内容,还要学习之后,自己多多思考,提炼,提炼出来的这些东西,才是你真正去超越其他人的地方!
可以自己多去看一些技术书籍,多做一些积累,并且紧跟当前热点,比如 DDD 架构、云原生都比较火,那么都可以去了解一下,拓宽自己的思维
Java 基础面试实战
Java 基础中一些常问的内容如下
- HashMap 底层原理
- ConcurrentHashMap 底层原理
- synchronized 关键字底层原理
- CAS 底层原理
- AQS 实现原理
- 线程池的底层工作原理
- Java 内存模型
- volatile 关键字
- Spring 的 AOP、IOC 机制的理解以及原理
- JDK 中的动态代理
- Spring 事务的原理?事务传播机制?
- Spring 中的设计模式?
- Spring 中的 Bean 是线程安全的吗?
接下来,对于上边这些问题进行解析
HashMap、ConurrentHashMap 底层原理
HashMap 的底层原理之前我写过一篇文章,把相关的面试题都给列了出来,并且有原理解析
HashMap 这一块的内容其实就是问
- 如何计算插入元素的位置?
- 如何扩容操作?
- 插入元素的流程是怎样的?
- 如何扩容了?
- 哈希冲突了怎么办呢?
- 什么条件的时候,会从链表转成红黑树呢?
- HashMap 是线程不安全的,体现在了哪里呢?
这些内容肯定是不要求你全部背会的啊,只要面试的时候,能给面试官讲出来原理或者看着源码可以说出来就好了!
详细解析文章:HashMap源码面试解析
ConcurrentHashMap 的话,也是对于初级开发、应届生面试肯定会问的东西,因为 HashMap 他就是并发不安全的,而 ConcurrentHashMap 是并发安全的,那么你肯定要去了解 ConcurrentHashMap 怎样保证了并发安全的呢?
并且 ConcurrentHashMap 在 1.7 和 1.8 对锁粒度做出了改变,这个也要清楚,JDK 1.7 中使用的是 分段锁,JDK 1.8 之后优化为了 CAS + synchronized
ConcurrentHashMap 如何保证线程安全
这里最重要的就是,了解 ConcurrentHashMap 在插入元素的时候,在哪里通过 CAS 和 synchronized 进行加锁了,是对什么进行加锁
对于 ConcurrentHashMap 来说:
- 在 JDK1.7 中,通过
分段锁来实现线程安全,将整个数组分成了多段(多个 Segment),在插入元素时,根据 hash 定位元素属于哪个段,对该段上锁即可 - 在 JDK1.8 中,通过
CAS + synchronized来实现线程安全,相比于分段锁,锁的粒度进一步降低,提高了并发度
这里说一下在 插入元素 的时候,如何做了线程安全的处理(JDK1.8):
在将节点往数组中存放的时候(没有哈希冲突),通过 CAS 操作进行存放
如果节点在数组中存放的位置有元素了,发生哈希冲突,则通过 synchronized 锁住这个位置上的第一个元素
那么面试官可能会问 ConcurrentHashMap 向数组中存放元素的流程,这里我给写一下(主要看一下插入元素时,在什么时候加锁了):
-
根据 key 计算出在数组中存放的索引
-
判断数组是否初始化过了
-
如果没有初始化,先对数组进行初始化操作,通过 CAS 操作设置数组的长度,如果设置成功,说明当前线程抢到了锁,当前线程对数组进行初始化
-
如果已经初始化过了,判断当前 key 在数组中的位置上是否已经存在元素了(是否哈希冲突)
-
如果当前位置上没有元素,则通过 CAS 将要插入的节点放到当前位置上
-
如果当前位置上有元素,则对已经存在的这个元素通过 synchronized 加锁,再去遍历链表,通过将元素插到链表尾
6.1 如果该位置是链表,则遍历该位置上的链表,比较要插入节点和链表上节点的 hash 值和 key 值是否相等,如果相等,说明 key 相同,直接更新该节点值;如果遍历完链表,发现链表没有相同的节点,则将新插入的节点插入到链表尾即可
6.2 如果该位置是红黑树,则按照红黑树的方式写入数据
-
判断链表的大小和数组的长度是否大于预设的阈值,如果大于则转为红黑树
当链表长度大于 8 并且数组长达大于 64 时,才会将链表转为红黑树
这里推荐一篇文章,源码解析讲的非常清楚:马士兵教育-郑金维老师-ConcurrentHashMap源码解析
并发编程面试实战
面试官为什么都喜欢问并发编程的问题?
如果面试的大一点的公司,用户量上来之后,那么并发包下的东西还是很容易会用到的,并且写代码时,如果对并发安全不算了解,那可能写完的代码存在许多并发上的问题,可能测试的时候没问题,到生产环境中造成严重后果!
我之前面试过唯品会,唯品会的面试官给我的印象就是很在乎你的基础,无论是并发、JVM、MySQL、Redis 原理,还是项目中使用到的技术,都会问你底层原理,我面试之后也问面试官了,为什么偏向于去问这么多技术的底层原理,面试官给的回答是因为只有了解底层的原理,你在使用的过程中才会更加注意他存在哪方面的问题,可以更好的去避免!
对于并发编程这块的内容,synchronized、CAS、AQS 的原理之前也写过一篇文章,详细内容可以点击:Java并发编程-synchronized解析
synchronized 底层原理
说说synchronized关键字的底层原理是什么?
下面来用 大白话 说一下原理:
synchronized 保证线程同步主要是依赖于两个 jvm 的指令:monitorenter、monitorexit 来实现的,比如说 synchronized 修饰一个代码块,那么进入代码块之前,执行 monitorenter 表示上锁,退出代码块之后,执行 monitorexit 表示解锁,以此来保证不同线程顺序执行这个代码块
并且 synchronized 在 jdk1.6 进行了优化,将锁分为了四种状态:无锁、偏向锁、轻量级锁、重量级锁,这 4 个状态会随着竞争激烈而逐渐升级,不过偏向锁在 jdk15 之后逐渐废弃,因为维护的开销比较大
CAS 底层原理
能聊聊你对CAS的理解以及其底层实现原理可以吗?
CAS 操作需要 3 个参数:要写入的内存地址、预期值、要写入的值
CAS 的原理就是,去要写入的内存地址判断,如果这个值等于预期值,那么就在这个位置上写上要写入的值
CAS 存在一些缺陷:
-
循环时间过长:如果 CAS 自旋一直不成功,会给 CPU 带来很大开销
-
只能针对一个共享变量
-
存在 ABA 问题:CAS 只检查了值有没有发生改变,如果原本值为 A,被改为 B 之后,又被改为了 A,那么 CAS 是不会发现值被改编过了的
ABA 问题解决方案:为每个变量绑定版本号,A–>B–>A 加上版本号为:A1–>B2–>A3
AQS 底层原理
了解 AQS 吗?底层原理是什么?
AQS 是抽象队列同步器,其实就是一个队列,存储的是线程,AQS 的作用就是 去管理线程加锁和解锁时的阻塞、唤醒
AQS 的原理:线程在获取锁失败之后,会被封装成 Node 节点假如到 AQS 阻塞等待,当获取锁的线程释放锁之后,会从 AQS 队列中唤醒一个线程,AQS 队列如下:
1705197938228
这里推荐一篇讲解 AQS 源码非常好的文章:AQS源码详细解析参考文章
线程池的底层工作原理面试实战
接下来就不对线程池的细节进行讲解了,如果想要查看可以点击:线程池底层原理细节
线程池其实就是对线程做一个 池化 操作,用于线程不断创建、销毁的开销,可以重复利用线程,节省资源
线程池中的重要参数如下:
corePoolSize:核心线程数量maximumPoolSize:线程池最大线程数量 = 非核心线程数+核心线程数keepAliveTime:非核心线程存活时间unit:空闲线程存活时间单位(keepAliveTime单位)workQueue:工作队列(任务队列),存放等待执行的任务threadFactory:线程工厂,创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等。handler: 拒绝策略 ,如果阻塞队列满了之后,对于新加入的任务该如何处理
除了线程池的核心参数要掌握,任务提交到线程池中的执行流程也要了解:
1705203285744
线程池参数设置攻略
下边以几种设置的例子,来说明一下会出现的情况:
- 如果将 maximumPoolSize 设置为 Integer.MAX_VALUE
这时,如果瞬间任务很多,核心线程都被占用,那么会无限创建线程去处理任务,导致消耗系统不断消耗资源去创建大量线程,如果任务提交速度大于线程处理速度,系统资源很快就会被耗尽,即使内存没有崩溃,也会导致 CPU 负载很高,所以要避免将 maximumPoolSize 设置的无限大
- 如果在线程中使用无界阻塞队列
如果发生了调用超时,导致队列越来越大,那么会导致任务一直向阻塞队列中存放,内存飙升,甚至出现 OOM 问题
- 自定义拒绝策略
其实可以自己去定义拒绝策略,如果线程池无法处理更多的任务了,可以在自定义的拒绝策略中,将拒绝的任务异步化持久化到磁盘中去,之后再通过一个后台线程去定时扫描这些被拒绝的任务,慢慢执行
如果机器宕机,线程池中请求会丢失吗?
如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
如果宕机,重启之后,线程池阻塞队列中的任务就会全部丢失
如果想要解决这种情况的话,有这么一个 解决方案:在将任务提交到线程池中去的时候,先把任务在数据库中存储一份,并记录任务执行的状态:未提交、已提交、已完成,执行完之后的话,将任务状态标记为 已完成,如果宕机后,导致任务丢失,就可以去数据库中扫描任务,重新提交给线程池执行
Java 内存模型面试实战
这块属于是 JVM 中的内容了,JVM 中的面试内容也是比较多的,包括常用的垃圾回收算法、垃圾回收器、堆、栈等等..
Java 内存模型(即 JMM)是在 《Java 虚拟机规范》 中定义的,目的是:定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节
JMM 规定了所有变量存储在主内存,每个线程都有自己的工作内存,线程 A 和线程 B 如果需要通信的话,需要经过 2 个步骤:
- 线程 A 把工作内存 A 中更新过的值刷新到主内存中
- 线程 B 去主内存中读取线程 A 刚更新过的值
1705286474524
原子性、有序性、可见性
Java 内存模型中的原子性、有序性、可见性是什么?
原子性:一个操作以原子的方式执行,要么该操作不执行,要么执行过程中不可以被其他线程中断,就比如多线程环境下,i++ 必须是独立执行的,因为 i++ 不是原子操作,如果多线程同时执行,就会出现问题可见性:多个线程共享一个变量时,需要保证其中一个线程修改变量之后,被其他线程所感知到,并及时读取变量最新值有序性:指程序执行的顺序按照代码的先后顺序执行。在并发环境中,为了提高效率,编译器和处理器可能会对代码进行重排序,但是这种重排序不会影响单线程程序的执行,却可能影响到多线程并发执行的正确性
volatile 底层原理
如果面试中问到了 volatile 关键字,应该从 Java 内存模型开始讲解,再说到原子性、可见性、有序性是什么
之后说 volatile 解决了有序性和可见性,但是并不解决原子性
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,在很多开源框架中,都会大量使用 volatile 保证并发下的有序性和可见性
volatile 实现
可见性和有序性就是基于内存屏障的:
内存屏障是一种 CPU 指令,用于控制特定条件下的重排序和内存可见性问题
- 写操作时,在写指令后边加上 store 屏障指令,让线程本地内存的变量能立即刷到主内存中
- 读操作时,在读指令前边加上 load 屏障指令,可以及时读取到主内存中的值
JMM 中有 4 类内存屏障:(Load 操作是从主内存加载数据,Store 操作是将数据刷新到主内存)
- LoadLoad:确保该内存屏障前的 Load 操作先于屏障后的所有 Load 操作。对于屏障前后的 Store 操作并无影响屏障类型
- StoreStore:确保该内存屏障前的 Store 操作先于屏障后的所有 Store 操作。对于屏障前后的Load操作并无影响
- LoadStore:确保屏障指令之前的所有Load操作,先于屏障之后所有 Store 操作
- StoreLoad:确保屏障之前的所有内存访问操作(包括Store和Load)完成之后,才执行屏障之后的内存访问操作。全能型屏障,会屏蔽屏障前后所有指令的重排
在字节码层面上,变量添加 volatile 之后,读取和写入该变量都会加入内存屏障:
读取 volatile 变量时,在后边添加内存屏障,不允许之后的操作重排序到读操作之前
volatile变量读操作
LoadLoad
LoadStore
写入 volatile 变量时,前后加入内存屏障,不允许写操作的前后操作重排序
LoadStore
StoreStore
volatile变量写操作
StoreLoad
happens-before 原则
接下来了解一下 happens-before 是什么?
happens-before 原则是对 Java 内存模型的简化,帮助编程人员理解并发安全,happens-before 定义了一些规则,只要符合了这些规则,这些执行的先后关系就已经被确定了,不需要再通过 volatile 和 synchronized 来保证有序性,如果不符合这些规则,那么它们的执行就没有顺序性的保障,虚拟机可能会进行重排序!
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而 “后面” 是指时间上的先后顺序。
- volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后顺序。
- 线程启动规则(Thread Start Rule):Thread 对象的 start () 方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join () 方法结束、Thread.isAlive () 的返回值等手段检测到线程已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程 interrupt () 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted () 方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize () 方法的开始。
- 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
对于 happens-before 原则,不需要记它具体有哪些规则,也根本记不住,只要了解 happens-before 包括了一些规则,符合这些规则的情况下,有序性就已经被保证了,那么就不需要通过 volatile 去保证有序性
为什么需要 happens-before 原则呢?
这里也是为了大家理解,说一下为什么会需要这个原则
如果在 Java 内存模型中,所有代码的有序性都依靠 volatile 和 synchronized 去保证,那么很多操作将会变得非常罗嗦
而我们在编写代码时,并没有使用很多的 volatile 和 synchronized 去保证有序性,就是因为 Java 语言中的 happens-before 原则的存在
通过这个原则,就可以很快判断并发环境中,两个操作之间是否会存在冲突的问题
获取更多干货内容,记得关注我哦。