2023春招面试题:Java并发相关知识
Posted 编程指南针
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2023春招面试题:Java并发相关知识相关的知识,希望对你有一定的参考价值。
1.基础知识回顾
1.1 什么是多线程?
在没有线程的年代,在同一个进程中,程序的处理流程都是顺序的,下一个流程的开始必须等待上
一个流程的结束,如果其中某一个流程非常耗时,那么会影响整个流程的处理时间
cpu执行过程中并不是一个程序执行完之后 cpu 才切换 ,cpu 时间片用完, 就会切换到下个线程执
行,给人一种多程序同时执行的感觉
有了进程以后,为什么还要发明线程呢?
1. 在多核CPU中,利用多线程可以实现并行 执行
2. 同步处理的流程容易发生阻塞,可以用线程来实现异步处理,提高程序处理实时性
3. 线程可以认为是轻量级的进程,所以线程的创建、销毁 比进程更快 (性能开销更小)
1.2.线程解决了什么问题?
单位时间内处理复杂且庞大的数据或业务时提升效率
1)如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创
建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。
2)如果大量线程在执行,会涉及到线程间上下文的切换,会极大的消耗CPU运算资源
1.3 如何创建线程?
1) 继承Thread
2)实现Runnable接口
3) 使用 Callable接口 (可以使用CompletableFuture )
注意:
我们项目中使用多线程编程一定要使用线程池,否则可能会导致线程创建过多发生异常
1.4 线程安全
多个线程在对共享数据进行读改写的时候,可能导致的数据错乱就是线程的安全问题了
如何判断当前程序中是否存在线程安全问题?
1. 是否存在多线程环境
2. 在多线程环境下是否存在共享变量
3. 在多线程环境下是否存在对共享变量 “写” 操作
2.线程的生命周期?线程有几种状态
1.线程通常有五种状态,创建,就绪,运行、阻塞和死亡状态。
- 新建状态(New):新创建了一个线程对象。
- 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于
可运行线程池中,变得可运行,等待获取CPU的使用权。
3 .运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
- 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进
入就绪状态,才有机会转到运行状态。
- 死亡状态(Dead):线程执行完了或者因异常退出了run方法,该线程结束生命周期。
2.阻塞的情况又分为三种:
(1)、等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待
池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤
醒,wait是object类的方法
(2)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放
入“锁池”中。
(3)、其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状
态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
sleep是Thread类的方法
3. wait 和 sleep 的区别
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
- 方法归属不同
-
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
- 醒来时机不同
-
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同(重点)
-
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
4. volatile的作用和原理
4.1 JMM内存模型
JMM让java程序与硬件指令进行了隔离
由于JVM运行程序的实体是线程,创建每个线程时,java 内存模型会为其创建一个工作内存(我们一般称为栈),工作内存是每个线程的私有数据区域。
Java内存模型规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。
但线程对变量的操作(读取、赋值等)必须在工作内存中进行。因此首先要将变量从主内存拷贝到自己的工作内存,然后对变量进行操作,操作完成后再将变量写会主内存中。
4.2 Java并发编程要解决的三个问题(三大特征)
原子性
一个线程在CPU中操作不可暂定,也不可中断,要不执行完成,要不不执行
内存可见性
默认情况下变量,当一个线程修改内存中某个变量时,主内存值发生了变化,并不会主动通知其他线程,即其他线程并不可见
有序性
程序执行的顺序按照代码的先后顺序执行。
4.3 Volatile
volatile帮我们解决了:
内存可见性问题
指令重排序问题
不能保证变量操作的原子性(Atomic)
1. 被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修
饰共享变量的值,新值总是可以被其他线程立即得知。(会主动通知)
我们可以通过如下案例验证
import java.util.Date;
public class MyData
private boolean flag=false;
public void setFlag(boolean flag)
this.flag = flag;
public boolean isFlag()
return flag;
public static void main(String[] args) throws Exception
MyData myData = new MyData();
// 线程1 修改值
new Thread(new Runnable()
@Override
public void run()
try
Thread.sleep(3000);
catch (InterruptedException e)
e.printStackTrace();
// 子线程3s 后 修改为true
myData.setFlag(true);
).start();
System.out.println(new Date());
while (!myData.isFlag())
// (如果不用 volatile) 理论上 3s 这里的死循环会结束,但是 实际上3s 后主线程一直在死循环
// 如果不用 volatile 主线程并没有感知到 子线程修改了变量
System.out.println("已经被修改了"+new Date());
注意: volatile 并不保证线程安全的,即多个线程同时操作某个变量依旧会出现线程安全问题
如下案例
public class MyData
private volatile int number=1;
public void addNum()
number++;
public static void main(String[] args)
MyData myData = new MyData();
// 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
for (int i=0; i<20; i++)
new Thread(() ->
for (int j=0; j<1000; j++)
myData.addNum();
).start();
// 程序运行时,有主线程和垃圾回收线程也在运行。如果超过2个线程在运行,那就说明上面的20个线程还有没执行完的,就需要等待
while (Thread.activeCount()>2)
Thread.currentThread().getThreadGroup().activeCount();
Thread.yield();// 交出CPU 执行权
System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);
2. 禁止指令重排序优化。
int a = 0; bool flag = false; public void write() a = 2; //1 flag = true; //2 public void multiply() if (flag) //3 int ret = a * a;//4
write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,
再到线程1,这时候a才赋值为2,很明显迟了一步。
但是用 flag 使用 volatile修饰之后就变得不一样了
使用volatile关键字修饰后,底层执行时会禁止指令重新排序,按照顺序指令
5.为什么用线程池?解释下线程池参数?
1、降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗。
2、提高响应速度;任务来了,直接有线程可用可执行,而不是先创建线程,再执行。
3、提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。
/* corePoolSize 代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会 消除,而是一种常驻线程 maxinumPoolSize 代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程 数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但 是线程池内线程总数不会超过最大线程数 keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会 消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间, 也就是核心线程不会 setKeepAliveTime 来设置空闲时间 workQueue 用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放 入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程 ThreadFactory 实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建 工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择 自定义线程工厂,一般我们会根据业务来制定不同的线程工厂 Handler 任务拒绝策略,有两种情况,第一种是当我们调用 shutdown 等方法关闭线程池后,这 时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程 池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提 交的任务时,这是也就拒绝 */ public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0) throw new IllegalArgumentException(); if (workQueue == null || threadFactory == null || handler == null) throw new NullPointerException(); this.acc = System.getSecurityManager() == null ? null : AccessController.getContext(); this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler;
java 中常见的几种线程池
// 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程, //若无可回收,则新建线程。 Executors.newCachedThreadPool();// //创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。 Executors.newFixedThreadPool(10); //创建一个定长线程池,支持定时及周期性任务执行。 Executors.newScheduledThreadPool(10);// 核心线程数10 //创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务, //保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。 Executors.newSingleThreadExecutor();
//看源码,解释一下这三个创建线程池方法的作用 Executors.newFixedThreadPool(1); Executors.newCachedThreadPool(); Executors.newSingleThreadExecutor();
6.项目中线程池的使用?
- tomcat 自带线程池
- CompletableFuture 创建线程时指定线程池,防止创建线程过多
CompletableFuture<Integer> task1 = CompletableFuture.supplyAsync(()-> result.setCluesNum(reportMpper.getCluesNum(beginCreateTime, endCreateTime, username)); return null; ,指定线程池);
案例:CompletableFuture异步和线程池讲解 - 不懒人 - 博客园
7 synchronized
synchronized 锁释放时机
● 当前线程的同步方法、代码块执行结束的时候释放
1) 正常结束
2) 异常结束出现未处理的error或者exception导致异常结束的时候释放
● 程序执行了 同步对象 wait 方法 ,当前线程暂停,释放锁
8. Sychronized和ReentrantLock的区别
- sychronized是⼀个关键字,ReentrantLock是⼀个类
- sychronized的底层是JVM层⾯的锁(底层由C++ 编写实现),ReentrantLock是API层⾯的锁 (java 内部的一个类对象)
- sychronized会⾃动的加锁与释放锁,ReentrantLock需要程序员⼿动加锁与释放锁
- sychronized是⾮公平锁,ReentrantLock可以选择公平锁或⾮公平锁
注: 假设多个线程都要获取锁对象,满足先等待的线程先获得锁则是公平锁,否则是非公平锁
- sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识
来标识锁的状态
- sychronized底层有⼀个锁升级的过程(访问对象线程数由少到多,竞争由不激烈到激烈,底层会通过一种锁升级机制 无锁->偏向锁->轻量级锁->重量级锁,保证性能) ,会使用自旋 线程频繁等待唤醒会浪费性能,特别是锁的获取也许只需要很短的时间 ,不限于等待,直接执行简单代码while(true)执行完抢锁 来优化性能
代码演示
可重入演示
public static void main(String[] args) // 可重入锁演示 save(); public synchronized static void save() System.out.println("save"); update(); public synchronized static void update() System.out.println("update");
ReentrantLock 使用演示
public class TestDemo public static void main(String[] args)throws Exception ReentrantLock lock = new ReentrantLock(); // 线程1 new Thread(()-> lock.lock(); // 加锁 add(); lock.unlock();// 解锁 ).start(); // 线程2 new Thread(()-> lock.lock(); // 加锁 add(); lock.unlock();// 解锁 ).start(); Thread.sleep(3000); System.out.println(i); static int i =0; public static void add() i++;
公平/非公平锁演示
package com.huike; import java.util.concurrent.locks.ReentrantLock; public class TestDemo public static void main(String[] args)throws Exception ReentrantLock lock = new ReentrantLock(false); // 线程1 new Thread(()-> lock.lock(); // 加锁 add(); try Thread.sleep(2000); catch (InterruptedException e) e.printStackTrace(); lock.unlock();// 解锁 ,"t1").start(); // 线程2 new Thread(()-> lock.lock(); // 加锁 add(); lock.unlock();// 解锁 ,"t2").start(); new Thread(()-> lock.lock(); // 加锁 add(); lock.unlock();// 解锁 ,"t3").start(); for (int j = 0; j < 100000; j++) new Thread(()-> lock.lock(); // 加锁 add(); lock.unlock();// 解锁 ).start(); Thread.sleep(30000); System.out.println(i); static int i =0; public static void add() i++; System.out.println(Thread.currentThread().getName()+"获得了锁");
10. 悲观锁 vs 乐观锁
要求
- 掌握悲观锁和乐观锁的区别
对比悲观锁与乐观锁
- 悲观锁的代表是 synchronized 和 Lock 锁
-
- 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
- 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
- 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
- 乐观锁的代表是 AtomicInteger AtomicStampReference,使用 cas 来保证原子性
-
- 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
- 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
- 它需要多核 cpu 支持,且线程数不应超过 cpu 核数
12. ConcurrentHashMap的原理
12.1 JDK1.7
- 数据结构:Segment(大数组) + HashEntry(小数组) + 链表,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突
- 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了(默认16,可以指定)
- 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍
- 其他Segment首次创建小数组时,会以Segment[0] 为原型为依据,数组长度,扩容因子都会以原型为准
12.2 JDK1.8
- 数据结构:Node 数组 + 链表或红黑树,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突
- 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
- 扩容条件:Node 数组满 3/4 时就会扩容(0.75 扩容因子)
- 扩容时并发 get
-
- 根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
- 扩容时并发 put
-
- 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
13. Hashtable 和 ConcurrentHashMap 有什么区别?其底层实现是什
么?
- Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
- Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
- ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
14. ThreadLocal
作用
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
原理
1. ThreadLocal是Java中所提供的线程本地存储机制,可以利⽤该机制将数据缓存在某个线程内部,
该线程可以在任意时刻、任意⽅法中获取缓存的数据
2. ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对
象)中都存在⼀个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的
值
3. 如果在线程池中使⽤ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使⽤完之后,应该要
把设置的key,value,也就是Entry对象进⾏回收,但线程池中的线程不会回收,,⽽一般我们使用ThreadLocal时都是使用static 修饰,导致线程对象是通过 强引⽤指向ThreadLocalMap,ThreadLocalMap也是通过强引⽤指向Entry对象,线程不被回收,
Entry对象也就不会被回收,从⽽出现内存泄漏,解决办法是,在使⽤了ThreadLocal对象之后,⼿
动调⽤ThreadLocal的remove⽅法,⼿动清除Entry对象
4. ThreadLocal经典的应⽤场景就是连接管理(⼀个线程持有⼀个连接,该连接对象可以在不同的⽅
法之间进⾏传递,线程之间不共享同⼀个连接)
- 项目中的使用: 项目中使用拦截器拦截请求后获取用户信息后放入ThreadLocal,然后在controller或service 获取用户信息
public class UserHolder // 这里使用static 修饰,会导致存储在 ThreadLocal 对象不会被回收,需要每次用完都 remove private static final ThreadLocal<Long> tl = new ThreadLocal<>(); public static void setUser(Long userId) tl.set(userId); public static Long getUser() return tl.get(); public static void removeUser() tl.remove(); ----------------我们曾经写的拦截器中是有remove 的----------------------------------------------------- public class UserInterceptor implements HandlerInterceptor @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception // 前置逻辑校验 UserHolder.setUser(xxxx); // 放行 return true; @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception // 因为Tomcat 有线程池,多个线程会公用同一个 变量,为防止内存泄漏,使用完毕后 需要清理 UserHolder.removeUser();
ThreadLocalMap 的一些特点
- key 的 hash 值统一分配
- 初始容量 16,扩容因子 2/3,扩容容量翻倍
- key 索引冲突后用开放寻址法解决冲突
弱引用 key
ThreadLocalMap 中的 key 被设计为弱引用,原因如下
- Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
内存释放时机
- 被动 GC 释放 key
-
- 仅是让 key 的内存释放,关联 value 的内存并不会释放
- 懒惰被动释放 value
-
- get key 时,发现是 null key,则释放其 value 内存
- set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
- 主动 remove 释放 key,value
-
- 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
- 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收
以上是关于2023春招面试题:Java并发相关知识的主要内容,如果未能解决你的问题,请参考以下文章