一、说说synchronized关键字的底层原理是什么?
synchronized底层说白了就是两句指令,分别是:
- monitorenter:加锁
- monitorexit:释放锁
先来看下这段代码:
WaitTest waitTest = new WaitTest();
//进入同步代码块1
synchronized (waitTest){
//业务逻辑
//进入同步代码块2
synchronized (waitTest){
//业务逻辑
}
}
咱们利用上面这段代码来讲解synchronized加锁原理,首先每个对象都有一个monitor和一个计数器,每次进行获取锁操作计数器都会+1,反之进行释放锁操作就会-1;
所以当程序走到同步代码块1时会先判断waitTest这个对象的计数器是否为0,如果不为0则说明锁被占用,就只能进入等待,反之执行monitorenter指令获取锁,并且在计数器上+1,此时计数器数字为1;
然后程序走到同步代码块2时,发现这里也需要获取,waitTest对象的锁,但是这里不需要等待,可以直接获取,因为synchronized锁是有可重入性的,也就是说当你的上级持有该锁时,他可以把锁给你,你不需要去等待,所以在同步代码块2这里直接可以获取锁,并且此时计算器又+1,此时为2;
然后等到同步代码块2执行完业务逻辑后退出时,会执行monitorexit指令释放锁,并且计数器-1,此时计数器为1,最后同步代码块在退出时,同样也会执行monitorexit指令释放锁,计数器-1,此时计数器为0,其他线程就可以去获取waitTest对象的锁了。
二、能聊聊你对CAS的理解以及其底层实现原理可以吗?
我们在平常开发中不会直接用到CAS操作,都是通过一些JDK 封装好的并发工具类来使用的,在 java.util.concurrent 包下,一般如果我们在多线程环境下操作i++,需要这么写:
//使用synchronized锁来保证线程安全
public class WaitTest {
int i=0;
public synchronized void test(){
i++;
}
}
如果使用JDK封装好的并发工具来操作i++,只需要这样:
//基于CAS保证线程安全
public class WaitTest {
private AtomicInteger atomicInteger=new AtomicInteger(0);
public void test(){
atomicInteger.incrementAndGet();
}
}
那CAS到底是如何保证线程安全的呢?我们来讲解一下atomicInteger.incrementAndGet();这个逻辑:
- 线程1和线程2同时对atomicInteger进行incrementAndGet()操作;
- 两个线程同时读取atomicInteger,此时都为0;
- 两个线程同时将atomicInteger+1;
- 这一步就是进入CAS操作了,假设线程1进入了CAS操作,那么线程2就无法进入,因为CAS操作是有原子性的,底层是由硬件这一级别类似于锁来保证CAS操作是原子的,所以同一时间只能由一个线程进行CAS操作,线程1会先在此获取atomicInteger的值和原先获取的旧值对比一下是否都等于0,如果相等,那么就可以将1赋值给atomicInteger了,如果不相等,那么就重新从第一个步骤开始轮回一遍,这就是CAS,即compare and set,比较后在设置,非常形象,这也是CAS的原理;
- 线程1处理完之后呢,线程2此时就可以进行操作啦;
三、ConcurrentHashMap实现线程安全的底层原理到底是什么?
在JDK1.7的时候呢,对ConcurrentHashMap采用的是一个分段加锁的方式来保证线程安全,什么意思呢?我们知道Map的底层是一个数组,所以呢ConcurrentHashMap就将这个数组分为很多个小数组,像这样:
原始HashMap结构:[<>,<>,<>,<>]
ConcurrentHashMap分段后的结构:[[数组1],[数组2],[数组3]]
ConcurrentHashMap会将每个小数组都加上一把锁,如果此时两个线程操作的分别是数组1和数组2,那他们可以并行执行,互不影响,这样比将整个HashMap都加上锁性能来的好;
在JDK1.8时对ConcurrentHashMap进行了优化,抛弃了原先将数组分段的这种设计,底层还是变为原来的HashMap结构,但是他的细粒度更小了,此时的锁范围是每个元素,并且采用的是CAS+synchronized的方式;
假设现在线程1和线程2同时对index为2个元素进行put操作(线程1先于线程2执行),此时如果该元素为null,那么线程1在put时会进行CAS操作,CAS在上面咱们讲过了,是原子的,所以线程2只能等待;
等到线程1执行完后,此时线程2开始put,但是现在该元素已经被线程1插入数据,不再是null了,所以线程2就不再使用CAS操作,进而对整个元素加上synchronized锁;
所以总结一下就是当元素为空时,采用CAS,不为null时,使用synchronized;
四、你对JDK中的AQS理解吗?AQS的实现原理是什么?
AQS全称Abstract Queue Synchronizer,即抽象队列同步器,咱们先来看下它如何使用:
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
//加锁
reentrantLock.lock();
//业务逻辑
//释放锁
reentrantLock.unlock();
}
没错,就是lock加锁,ReentrantLock就是基于AQS实现的加锁方式,咱们接下来讲解一下AQS到底是如何保证线程安全的:
咱们看下图说话,state初始为0,加锁线程初始为null;
- 线程1抢在线程2前面进行加锁,由于是CAS操作,所以线程2无法执行加锁操作;
- 线程1加锁完成后会将加锁线程改为自己;
- 线程2发现线程1加锁完成了,那自己也会进行加锁操作,但是此时发现state不为0,所以只能进入等待队列;
- 当线程1完成业务逻辑并调用unlock()去释放锁后,还会去唤醒在等待队列中的线程,
- 此时线程2被唤醒发现state为0,就开始加锁了;
以上就是AQS的一个加锁的逻辑和原理;
这里在补充一点,ReentrantLock默认是非公平锁,什么意思呢,当线程2被唤醒时,正要去加锁操作,突然半路冒出了一个线程3抢在线程2前面加锁成功了,结果线程2一看state被线程3设置成了1,自己又得回到队列去等待了,这就是非公平锁;
当然也有公平锁,在公平锁下,半路出现的线程3不会直接去执行加锁操作,而是先判断等待队列中是否有线程在等待,如果有,那么线程3会接在背后乖乖的去排队,没有的话才会去执行加锁操作;
ReentrantLock设置为公平锁的方式也很简单,只要在构造函数中添加true即可,如下:
ReentrantLock reentrantLock = new ReentrantLock(true);
五、说说线程池的底层工作原理可以吗?
系统是不能无限制的创建线程的,并且创建线程和销毁线程都需要消耗一定的时间,所以引入了线程池的概念,咱们先来看下线程如何使用:
public static void main(String[] args) {
//创建一个容量为3的线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
//提交任务
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("业务逻辑");
}
});
}
这里以FixedThreadPool作为一个例子,咱们来讲一下线程池是如何去执行任务的:
- 当线程池提交任务时会先判断当前线程池的线程数是否小于corePoolSize,即创建线程池时填入的参数,这里是3,如果小于,则直接创建一个线程来执行任务;
- 当线程执行完任务时不会直接销毁,它会尝试从一个无界的LinkedBlockingQueue里面获取新任务,如果没有任务,那就阻塞住,等待新任务;
- 此时线程池又提交了新任务,这时候发现当前线程池的线程数量等于corePoolSize了,那就会将该任务给放到LinkedBlockingQueue中去;
- 一直阻塞在LinkedBlockingQueue的线程发现有新任务加入到队列中了,直接就去拿过来执行;
以上就是线程池的工作原理;补充一点,咱们这里是用FixedThreadPool来做例子,所以它的队列是LinkedBlockingQueue,这个队列的特点是无界的,所以任务可能会越来越多,并且没有长度限制;
六、那你再说说线程池的核心配置参数都是干什么的?平时我们应该怎么用?
下面试线程池的构造方法,包含了许多的参数,咱们接下来就先对这些参数进行一一的解释:
- corePoolSize:核心线程数量,当线程池提交任务时,会先判断当前线程池的线程数是否小于corePoolSize,如果小于,则直接创建线程来执行该任务,反之放入任务等待队列中;
- maximumPoolSize:线程不够用时能够创建的最大线程数,当核心线程数满和任务等待队列都满了,此时又有新的任务提交,那么就会判断当前线程池的线程数是否小于maximumPoolSize,如果小于就会创建额外线程来执行任务,并且这些额外线程在完成任务后还会从任务等待队列中继续去获取任务来执行;
- workQueue:任务等待队列,有无界的,也有有界的,像咱们上面提到的LinkedBlockingQueue就是无界的,意思是可以添加无限个任务;
- keepAliveTime:闲置线程被销毁的时间,当额外线程处于空闲状态的时间达到keepAliveTime时,就会被销毁,注意,核心线程不会被销毁,只有额外线程才会;
- threadFactory:创建新线程的工厂,默认是Executors.defaultThreadFactory()
- handler:线程池的饱和策略,当任务等待队列满了,并且额外线程也处于忙碌状态,此时新提交的任务已经没有地方可以处理和安放了,那么就会采取该策略,JDK默认提供了4种策略,分别是:
(1).AbortPolicy:直接抛出异常,这是默认策略;
(2).CallerRunsPolicy:用调用者所在的线程来执行任务;
(3).DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务;
(4)DiscardPolicy:直接丢弃任务;
(5)如果以上策略不满足咱们的业务需求,也可以自定义策略,只需要实现RejectedExecutionHandler接口自定义handler即可;
具体这些参数该怎么设置需要看各自的项目业务需求而定,没有最完美的设计,只有最合适的合计。
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
this.ctl = new AtomicInteger(ctlOf(-536870912, 0));
this.mainLock = new ReentrantLock();
this.workers = new HashSet();
this.termination = this.mainLock.newCondition();
if (corePoolSize >= 0 && maximumPoolSize > 0 && maximumPoolSize >= corePoolSize && keepAliveTime >= 0L) {
if (workQueue != null && threadFactory != null && handler != null) {
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
} else {
throw new NullPointerException();
}
} else {
throw new IllegalArgumentException();
}
}
七、如果在线程池中使用无界阻塞队列会发生什么问题?
这个面试题还有一个问法就是:在远程服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
意思是说,远程服务异常时,你的线程又刚好需要去调用该远程服务,那么势必会造成你的线程一直阻塞,线程执行任务的时间变长,从而导致任务等待队列里堆积越来越多的任务,那么你的内存肯定是会飙升的,严重的还会出现OOM即内存溢出,就算使用GC回收也无法回收这些任务。
八、你知道如果线程池的队列满了之后,会发生什么事情吗?
假设咱们现在创建一个线程池,其中的一些配置如下:
corePoolSize:10
maximumPoolSize:Integer.MAX_VALUE
workQueue:ArrayBlockingQueue(200)
当核心线程数和等待任务队列都满的时候,此时如果大量的任务被提交,那么线程池就会无限的创建新的线程来执行任务,每一个线程都会创建属于自己的栈内存空间,占据一定的内存,从而导致内存资源被耗尽,造成系统奔溃,就算不会奔溃,也会导致CPU Load过高。
九、如果线上机器突然宕机,线程池的阻塞队列中的请求怎么办?
首先阻塞队列中的任务肯定是会丢失的,那该怎么办呢?
有一个办法,线程池在提交任务时将任务信息保存到数据库中,更新任务状态:未提交、已提交、已完成这些,服务器宕机之后势必要重启,重启后创建一个后台线程去将数据库中未提交和已提交的任务读取出来在重新提交到任务队列里去,这样就可以解决线上机器突然宕机导致任务队列被清空的问题啦。