本人根据B站视频总结的Java面试题,初步打算分为初级篇,中级篇和高级篇,因为篇幅比较长所以分开总结。顺便吐槽一下现在的面试面试造火箭,很多不会用到,但是你得会。看完总体下来还是收获很大的,在这里博主呼吁大家不要做API调用工程师,Ctrl C + Ctrl V 程序员!

字符串常量Java内部加载

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到过HotSpot和JDK7开始逐步去永久代的计划,并在JDK8中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用永久代还是元空间来实现方法区,对程序有什么实际的影响

String:intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用,否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量

1
2
3
4
5
6
7
8
9
String str1 = new StringBuilder("58").append("tongcheng").toString();
System.out.println(str1);
System.out.println(str1.intern());
System.out.println(str1 == str1.intern());

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2);
System.out.println(str2.intern());
System.out.println(str2.intern() == str2);

58tongcheng 58tongcheng true
java java false

上面这段代码除了java是false其他都是true。这是因为 有一个初始化的java字符串(JDK出娘胎自带的),在加载sun.misc.Version这个类的时候进入常量池

两数之和

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。这是力扣第一题

  • 暴力解法
1
2
3
4
5
6
7
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < nums.length; j++) {
if (target - nums[i] == nums[j]) {
return new int[]{i, j};
}
}
}
  • 优化解法
1
2
3
4
5
6
7
8
Map<Integer, Integer> map = new HashMap<>(10);
for (int i = 0; i < nums.length; i++) {
int partnerNumber = target - nums[i];
if (map.containsKey(partnerNumber)) {
return new int[]{map.get(partnerNumber), i};
}
map.put(nums[i], i);
}

AQS

可重入锁

可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用并且不发生死锁,这样的锁就叫做可重入锁。可重入锁又分为两类:

  • 隐式锁(即synchronized关键字使用的锁)默认是可重入锁

在一个Synchronized修饰的方法或代码块内部调用本类的其他Synchronized修改的方法或代码块时,是永远可以得到锁的

1
2
3
4
5
6
7
8
9
10
11
new Thread(() -> {
synchronized (objectLockA) {
System.out.println(Thread.currentThread().getName() + "\t" + "外层调用");
synchronized (objectLockA) {
System.out.println(Thread.currentThread().getName() + "\t" + "中层调用");
synchronized (objectLockA) {
System.out.println(Thread.currentThread().getName() + "\t" + "内层调用");
}
}
}
}, "t1").start();

t1 外层调用 t1 中层调用 t1 内层调用

Sync可重入的实现机制

每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。当执行monitorenter时,如果目标对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置当前线程,并且将其计数器加1。在目标对象的计数器不为零的情况下,如果锁对象持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1,计数器为零代表锁已经被释放

  • 显式锁(即Lock)也有ReentrantLock这样的可重入锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
new Thread(() -> {
lock.lock();
try {
System.out.println("外层");
lock.lock();
try {
System.out.println("内层");
} finally {
lock.unlock();
}

} finally {
lock.unlock();
}
}, "t2").start();

外层 内层

LockSupport

用于创建锁和其他同步类的基本线程阻塞原语。LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(Permit),Permit只有两个值0和1,默认是0。可以把许可看成是一种(0, 1)的信号量(Semaphore),但与Semaphore不同的是,许可的累加上限是1

三种让线程等待唤醒的方法:

  • 使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程
1
2
3
4
5
6
7
8
9
10
11
new Thread(() -> {
synchronized (objectLock) {
System.out.println(Thread.currentThread().getName() + "\t" + "come in");
try {
objectLock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "\t" + "被唤醒");
}
}, "A").start();
1
2
3
4
5
6
new Thread(() -> {
synchronized (objectLock) {
objectLock.notify();
System.out.println(Thread.currentThread().getName() + "\t" + "通知");
}
}, "B").start();

A come in
B 通知
A 被唤醒

将notify放在wait方法前先执行,程序一直无法结束。所以先wait后notifynotifyAll方法等待中的线程才会被唤醒否则无法唤醒。wait和notify方法必须要在同步块或者方法里面且成对出现使用并且先wait后notify才ok

  • 使用JUC包中Condition的await()方法,使用signal()方法唤醒线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t" + "come in");
try {
condition.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "\t" + "被唤醒");
} finally {
lock.unlock();
}
}, "A").start();
1
2
3
4
5
6
7
8
9
new Thread(() -> {
lock.lock();
try {
condition.signal();
System.out.println(Thread.currentThread().getName() + "\t" + "通知");
} finally {
lock.unlock();
}
}, "B").start();

A come in
B 通知
A 被唤醒

传统的Synchronized和Lock实现等待唤醒通知的约束: 1. 线程先要获得并持有锁,必须在锁块(Synchronized或Lock)中 2. 必须要先先等待后唤醒,线程才能够被唤醒

  • LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程
1
2
3
4
5
6
Thread threadA = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "come in");
LockSupport.park();
System.out.println(Thread.currentThread().getName() + "\t" + "被唤醒");
}, "A");
threadA.start();
1
2
3
4
5
Thread threadB = new Thread(() -> {
LockSupport.unpark(threadA);
System.out.println(Thread.currentThread().getName() + "\t" + "通知");
}, "B");
threadB.start();

A come in
B 通知
A 被唤醒

LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport调用的Unsafe中的native代码。LockSupport提供park()和unpark()方法实现阻塞线程和解除线程阻塞的过程。LockSupport和每个使用它的线程都有一个许可(permit)关联。permit相当于1,0的开关,默认是0,调用一次unpark就加1变成1,调用一次park会消费permit,也就是将1变成0,同时park立即返回。如再次调用park会变成阻塞(因为permit为0了会阻塞在这里,一直到permit变成1),这时调用unpark会把permit置为1。每个线程都有一个相关的permit,permit最多只有一个,重复调用unpark也不会积累凭证。

为什么可以先唤醒线程后阻塞线程?

因为unpark获得了一个凭证,之后再调用park方法,就可以名正言顺的凭证消费,故不会阻塞

为什么唤醒两次后阻塞两次,但最终结果还是会阻塞线程?

因为凭证的数量最多为1,连续调用两次unpark和调用一次unpark效果一样,只会增加一个凭证。而调用两次park却需要消费两个凭证,证不够,不能放行

AQS理论初步

是用来构建锁或者其他同步器组件的重量级基础框架及整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类型变量表示持有锁的状态

和AQS有关的:

  • ReentrantLock

ReentrantLock源码

  • CountDownLatch

CountDownLatch源码

  • ReentrantReadWriteLock

ReentrantReadWriteLock源码

  • Semaphore

Semaphore源码

AQS解释说明

抢到资源的线程直接使用处理业务逻辑,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去侯客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(侯客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。

既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS,自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的控制效果

AQS源码体系

有阻塞就需要排队,实现排队必然需要队列。AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改

AQS同步队列的基本体系

非公平锁 lock()

对比公平锁和非公平锁的tryAcquire()方法的实现代码,其实差别就在于非公平锁获取锁时比公平锁中少了一个判断!hasQueuedPredecessors,hasQueuedPredecessors()中判断了是否需要排队,导致公平锁和非公平锁的差异如下:

  • 公平锁: 公平锁讲究先来后到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中
  • 非公平锁: 不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)

共平锁和非公平锁源码

小案例模拟AQS

带入一个银行办理业务的案例来模拟我们的AQS如何进行线程的管理和通知唤醒机制
3个线程模拟3个来银行网点,受理窗口办理业务的顾客
A顾客就是第一个顾客,此时受理窗口没有任何人,A可以直接去办理

  • 第一个顾客A
1
2
3
4
5
6
7
8
9
10
11
12
13
new Thread(() -> {
lock.lock();
try {
System.out.println("-------- A thread come in");
try {
TimeUnit.MINUTES.sleep(20);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} finally {
lock.unlock();
}
}, "A").start();
  • 第二个顾客B
1
2
3
4
5
6
7
8
new Thread(() -> {
lock.lock();
try {
System.out.println("-------- B thread come in");
} finally {
lock.unlock();
}
}, "B").start();
  • 第三位顾客C
1
2
3
4
5
6
7
8
new Thread(() -> {
lock.lock();
try {
System.out.println("-------- C thread come in");
} finally {
lock.unlock();
}
}, "C").start();
  • 由于A线程先抢占,所以后面的线程只能走acquire()方法

lock源码分析

  • AQS acquire主要有三大流程

acquire源码

  1. 调用tryAcquire,交由子类FairSync实现
  2. 调用addWaiter
  3. 调用acquireQueued

tryAcquire源码

首先获取到当前线程也就是B,getState == 1首先当前线程A在工作,并且线程B != 线程A,最终返回false

addWaiter源码

将当前线程封装成Node对象,并加入到排队队列中。根据排队队列是否执行过初始化,执行两种不同的处理逻辑

  1. 表示排队队列不为空,即之前已经初始化过了,此时只需将新的Node加入排队队列末尾即可
  2. 表示排队队列为空,需执行队列初始化。enq会初始化一个空的Node,作为排队队列的head,然后将需要排队的线程,作为head的next节点插入。

enq源码

队列尚未初始化,调用enq方法。该方法生成一个空的Node对象(new Node()),插入到AQS队列头部,然后将参数node作为其后继节点插入队列,方法执行完毕。注意: 双向链表中第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位,真正的第一个有数据的节点,是从第二个节点开始的

acquireQueued源码

整个AQS的核心的难点之一。注意这里使用了for(;;)。首先判断node的前辈节点,是不是head,如果是,说明它是一个可以获得锁的线程,则调用一个tryAcquire尝试获取锁,若获取到,则将链表关系重新维护(Node设置为head,之前的head从链表移出),然后返回。如果node的前辈节点不是head或获取锁失败,再判断其前辈节点的waitState,是不是SIGNAL,如果是,则当前线程调用park,进入阻塞状态。如果不是:

  1. == 0,则设置为SIGNAL
  2. 大于0 ( == 1) 则表示前辈节点已经被取消了,将取消的节点从队列移出,重新维护下排队链表关系

然后再次进入for循环,上面的逻辑重新执行一遍,注意和doAcquireInterruptibly方法对比,二者区别主要在,发现线程被中断之后的处理逻辑

shouldParkAfterFailedAcquire源码

先获取头节点的状态,如果是SIGNAL状态,即等待被占用的资源释放,直接返回true,准备继续调用parkAndCheckInterrupt方法。如果ws > 0说明是CANCELLED状态,循环判断前驱节点是否也为CANCELLED状态,忽略该状态的节点,重新连接队列。将当前节点的前驱节点设置为SIGNAL状态,用于后续唤醒操作。程序第一次执行到这返回为false,还会进行外层第二次循环,最终返回

parkAndCheckInterrupt源码

线程挂起,程序不会继续向下执行。根据park方法API描述,程序在下述三种情况下会继续向下执行

  1. 被unpark
  2. 被中断(interrupt)
  3. 其他不可逻辑的返回才会继续向下执行

因上述三种情况程序执行到Thread.interrupted,返回当前线程的中断状态,并清空中断状态。如果由于被中断,该方法返回true

Spring

Spring AOP顺序

AOP常用注解

  • Before 前置通知: 目标方法之前执行
  • After 后置通知: 目标方法之后执行(始终执行)
  • AfterReturning 返回后通知: 执行方法结束前执行(异常不执行)
  • AfterThrowing: 异常通知: 出现异常时候执行
  • Around 环绕通知: 环绕目标方法执行
  • Spring4
    • 正常执行: Before -> After -> AfterReturning
    • 异常执行: Before -> After -> AfterThrowing
  • Spring5
    • 正常执行: Before -> AfterReturning -> After
    • 异常执行: Before -> AfterThrowing -> After

Spring循环依赖

什么是循环依赖

多个Bean之间相互依赖,形成了闭环。比如A依赖于B,B依赖于C,C依赖于A。通常来说,如果问Spring容器内部如何解决循环依赖,一定是指默认的单例Bean中,属性互相引用的场景

两种注入方式

下面是Spring官网对循环依赖的解释,只允许用Set方法注入不允许用构造方法!所以我们AB循环依赖问题只要A的注入方式是setter且singleton,就不会有循环依赖的问题

If you use predominantly constructor injection, it is possible to create an unresolvable circular dependency scenario. For example: Class A requires an instance of class B through constructor injection, and class B requires an instance of class A through constructor injection. If you configure beans for classes A and B to be injected into each other, the Spring IoC container detects this circular reference at runtime, and throws a BeanCurrentlyInCreationException. One possible solution is to edit the source code of some classes to be configured by setters rather than constructors. Alternatively, avoid constructor injection and use setter injection only. In other words, although it is not recommended, you can configure circular dependencies with setter injection. Unlike the typical case (with no circular dependencies), a circular dependency between bean A and bean B forces one of the beans to be injected into the other prior to being fully initialized itself (a classic chicken-and-egg scenario).

代码验证

  • ServiceA方法
1
2
3
public ServiceA(ServiceB serviceB) {
this.serviceB = serviceB;
}
  • ServiceB方法
1
2
3
public ServiceB(ServiceA serviceA) {
this.serviceA = serviceA;
}
  • 实现类

构造方法循环依赖出现的问题

这里我们可以看到构造方法实现循环依赖永远报错A依赖B,B依赖A,A里面需要new一个B,B里面需要new一个A,如此反复就出现了死循环

  • ServiceC
1
2
3
4
public void setServiceD(ServiceD serviceD) {
this.serviceD = serviceD;
System.out.println("C里面引用了D");
}
  • ServiceD
1
2
3
4
public void setServiceC(ServiceC serviceC) {
this.serviceC = serviceC;
System.out.println("D里面引用了C");
}
  • main方法
1
2
3
4
ServiceC serviceC = new ServiceC();
ServiceD serviceD = new ServiceD();
serviceC.setServiceD(serviceD);
serviceD.setServiceC(serviceC);

C里面引用了D
D里面引用了C
这里我们可以看到使用setter方法是可以实现循环依赖的

Spring循环依赖BUG

1
2
3
<bean id="a" class="com.example.interview.spring.circulardepend.A" scope="prototype">
<property name="b" ref="b"/>
</bean>
1
2
3
<bean id="b" class="com.example.interview.spring.circulardepend.B" scope="prototype">
<property name="a" ref="a"/>
</bean>
1
2
3
ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
A a = context.getBean("a", A.class);
B b = context.getBean("b", B.class);

循环依赖报错信息

注意: 默认的单例(singleton)的场景是支持循环依赖的不报错原型(Prototype)的场景是不支持循环依赖的会报错

三级缓存

Spring内部通过三级缓存解决循环依赖的BUG

  • 第一级缓存(也叫单例池)singletonObjects: 存放已经经历了完整生命周期的Bean对象
  • 第二级缓存: earlySingletonObjects,存放早期暴露出来的Bean对象,Bean的生命周期未结束(属性还未填充完整)
  • 第三级缓存: Map> singletonFactories,存放可以生成Bean的工厂

注意: 只有单例的bean会通过三级缓存提前暴露来解决循环依赖的问题而非单例的bean每次从容器中获取都是一个新的对象都会重新创建吗所以非单例的bean是没有缓存的的不会将其放到三级缓存中

三级缓存源码

循环依赖前置知识

实例化/初始化

实例化: 内存中申请一块内存空间 -> 租赁好房子,自己的家具东西好没有搬家进去
初始化: 完成属性的各种赋值 -> 装修,家电家具进场

3个Map和四大方法 总体相关对象

getSingleton doCreateBean populateBean addSingleton
第一层singletonObjects存放的是已经初始化好了的Bean
第二层earlySingletonObjects存放的是实例化,但是未初始化的Bean
第三层是singletonFactories存放的是FactoryBean。假如A类实现了FactoryBean,那么依赖注入的时候不是A类,而是A类产生的Bean

A/B两对象在三级缓存中的迁移说明

A创建过程中需要B,于是A将自己放到三级缓存里面,去实例化B
B实例化的时候发现需要A,于是B先查一级缓存,没有,再查二级缓存,还是没有,再查三级缓存,找到了A然后把缓存里面的这个A放到二级缓存里面,并删除缓存里面的A
B顺利初始化完毕,将自己放到一级缓存里面(此时B里面的A依然是创建状态)然后回来接着创建A,此时B已经创建结束,直接从一级缓存里面拿到B,然后完成创建,并将A自己放到一级缓存里面

源码解读

Spring创建bean主要分为两个步骤,创建原始bean对象,接着去填充对象属性和初始化。每次创建bean之前,我们都会从缓存中查下有没有该bean,因为是单例,只能有一个。当我们创建beanA的原始对象A,并把它放到三级缓存中,接下来就该填充对象属性了,这时候发现依赖了beanB,接着就又去创建beanB,同样的流程,创建完beanB填充属性时又发现它依赖了beanA又是同样的流程。不同的是: 这时候可以在三级缓存中查到刚放进去的原始对象beanA,所以不需要继续创建,用它注入beanB,完成beanB的创建。既然beanB创建好了,所以beanA就可以完成填充属性的步骤了,接着执行剩下的逻辑,闭环完成

Spring解决循环依赖依靠的是bean的中间态这个概念,而这个中间态指的是已经实例化但还没初始化的状态,实例化的过程又是通过构造器创建的,如果A还没创建好出来怎么可能提前曝光,所以构造器的循环依赖无法解决。Spring为了解决单例的循环依赖问题,使用了三级缓存。其中一级缓存为单例池(singletonObjects),二级缓存为提前曝光对象(earlySingletonObjects),三级缓存为提前曝光对象工厂(singletonFactories)。假设A,B循环引用,实例化A的时候就将其放入到三级缓存中,接着填充属性的时候,发现依赖B,同样的流程也是实例化后放入三级缓存,接着去填充属性时又发现自己依赖A,这时候从缓存中查到早期暴露的A,没有AOP代理的话,直接将A的原始对象注入B,完成B的初始化后,进行属性填充和初始化,这时候B完成后,就去完成剩下的A的步骤,如果有AOP代理,就进行AOP处理获取代理后的对象A,注入B,走剩下的流程

查看源码流程

Spring解决循环依赖过程

先创建beanA

getBean(beanA)

doGetBean(beanA)

getSingleton(beanA) == NULL 尝试从各级缓存获取bean

getSingleton(beanA, singletonFactory) 开始创建bean实例

createBean(beanA, mbd, args)

createBeanInstance(beanA) 创建bean对象

populateBean(beanA) 属性注入

populateBean(beanA) 属性注入属性注入时发现依赖B,接着去找B

创建依赖的beanB

getBean(beanB)

doGetBean(beanB)

getSingleton(beanB) == NULL

getSingleton(beanB

createBean(bean

doCreateBean(beanB)

createBeanInstance(beanB)

addSingletonFactory(beanB

populateBean(beanB)

getBean(beanA) beanB获取beanA的早期引用

doGetBean(beanA) 由于第一步已经添加了缓存所以这里已经不为空并将三级缓存移到二级

getSingleton(beanA) 返回beanA的原始对象

initializeBean(beanB)

addSingleton(beanB

getObjectForBeanInstance beanB完成实例化和初始化

initializeBean(beanA) 最后完成beanA的实例化

addSingleton(beanA, singletonObject)

getObjectForBeanInstance

  1. 调用doGetBean()方法,想要获取beanA,于是getSingleton方法从缓存中查找beanA
  2. 在getSingleton()方法中,从一级缓存中查找,没有返回NULL
  3. doGetBean()方法中获取到的beanA为NULL,于是走对应的处理逻辑,调用getSingleton()的重载方法(参数为ObjectFactory的)
  4. 在getSingleton()方法中,先将beanA_name添加到一个集合中,用于标记该bean正在创建中。然后回调匿名内部类的creatBean方法
  5. 进入AbstractAutowireCapableBeanFactory doGetBean,先反射调用构造器创建出beanA实例,然后判断是否单例,是否允许提前暴露引用(对于单例一般为true),是否正在创建中(即是否在第四部的集合中)。判断为true则将beanA添加为三级缓存中
  6. 对beanA进行属性填充,此时检测到beanA依赖于beanB,于是开始查找beanB
  7. 调用doGetBean()方法,和上面beanA的过程一样,到缓存中查找beanB,没有则创建,然后给beanB填充属性
  8. 此时beanB依赖于beanA,调用getSingleton()获取beanA,依次从一级,二级,三级缓存中找,此时从三级缓存获取到beanA的创建工厂,通过创建工厂获取到了beanA的依赖,于是beanB顺利完成实例化,并将beanA从三级缓存移动到二级缓存中
  9. 随后beanA继续他的属性填充工作,此时也获取到了beanB,beanA也随之完成了创建,回到getSingleton()方法中继续向下执行,将beanA从二级缓存移动到一级缓存中