并发编程面试笔记(更新中)

并发编程

1、线程和进程的区别?
  • 进程是正在运行的程序的实例,进程包含线程,每个线程执行不同的任务
  • 每个进程都有自己的独立的内存空间,而当前进程下的所有线程共享同一片内存空间
  • 线程更轻量,线程的上下文切换一般比进程的上下文切换要低
2、 并发和并行的区别?

现在基本上都是多核CPU,在多核CPU下:

  • 并发是指同一时间应对多件事情的能力,多个线程轮流使用一个CPU或多个CPU
  • 并行是指同一时间动手做多件事情的能力,多个线程同时使用多个CPU,比如 4核CPU同时执行4个线程
3、 runnable 和 callable 有什么区别?
  • runnable接口run方法没有返回值
  • callable 接口 call 方法有返回值,是个泛型,和 Future、FutureTask配合使用可以获取异步执行的结果
  • callable 接口 call 方法允许抛出异常;而 runnable 接口的 run()方法不允许,异常只能内部处理
4、线程包括哪些状态,状态之间是如何变化的?

在这里插入图片描述

5、synchronized 的底层原理?

Synchronized 对象锁采用互斥的方式让用一时刻只有一个线程能持有对象锁

底层是由Monitor实现的,monitor 是 JVM级的对象(C++实现的),线程获得锁需要使用对象锁关联 monitor

在 monitor 中有三个属性,分别是owner、entrylist、waitset

  • owner: 关联获得锁的线程,并且只能关联一个线程

  • entrylist: 关联阻塞状态的线程。后续线程尝试获取锁的时候会先看 owner 中是不是空的,如果不是,这些线程会进入 entrylist 中

  • waitset: 关联处于waiting 状态的线程。当有线程 调用 wait 方法时会进入该等待池中

在这里插入图片描述

6、Monitor实现的锁属于重量级锁,你了解过锁升级吗?

java 中的 synchronized 有 偏向锁、轻量级锁、重量级锁三种形式。

  • 重量级锁:底层使用 Monitor 实现,涉及到了用户态和内核态的切换,进程的上下文切换,成本较高,性能较低
  • 轻量级锁:线程加锁的时间是错开的(无竞争),可以用轻量级锁优化,轻量级锁修改了对象头的锁标志,相对重量级锁性能会提升很多。每次修改都是 CAS 操作,保证原子性
  • 偏向锁: 一段时间 锁 只被一个同样的线程使用,可以使用偏向锁。在第一次获取锁时,会有一次CAS操作,之后该线程再次获取锁时,只需要判断 mark word 中是否有自己的线程 id 即可。

一旦发生锁竞争,都会升级为重量级锁

7、谈谈JMM(jave内存模型)
  • JMM(Java Memory Model) java内存模型,定义了 共享内存多线程程序读写操作的行为规范。通过这些规则来规范对内存的读写操作从而保证指令的正确性。
  • JMM将内存分为两块。一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存
  • 线程跟线程之间是互相隔离的,线程跟线程交互需要通过主内存

在这里插入图片描述

8、CAS你知道吗

CAS( Compare And Swap ,比较再交换),体现的一种乐观锁的思想,在==无锁的情况下保证线程操作共享数据的原子性==

在操作共享变量时使用的 自旋锁,效率上更高一些

CAS的底层调用的是 Unsafe类中的方法,都是操作系统提供的,其他语言实现。

如何自旋?

在线程要进行写操作修改变量时,会先判断当前 主内存中的共享变量值和自己工作内存的共享变量副本值进行比较,如果是一样的话,才会进行修改操作。如果不一样的话,就开始自旋首先将自己线程的工作内存中的共享变量副本值修改为主内存中的值,再次去判断当前值和主内存中的值是否一致,依次循环。

在这里插入图片描述

9、谈谈对 Volatile 的理解

共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  • 保证线程间的可见性
  • 禁止进行指令重排序

什么是 保证线程间的可见性?

用 valatile 修饰共享变量,能够防止编译器等优化发生,保证每个线程对共享变量的修改对其他线程可见

编译器优化,如:

boolearn flag = false;
while(!flag){
    ...
}

对于上面的代码,编译器会对其进行优化:

while(true){
    ...
}

编译器会将判断结果直接预先编译优化好,但是这样就会导致,其他线程如果修改了 flag 的值,本线程是无法感知到的

而当变量加上 volatile 修饰后,会告诉当前 jit, 不要对 volite 修饰的变量做优化

什么是 指令重排?

指编译器或处理器在执行程序时对指令的顺序进行重新排序的优化,以充分利用处理器的流水线和缓存机制,**提高指令的执行效率。**但是也正是因为存在指令重排,在多线程编程中,也容易影响程序的正确性和一致性。而 volatile 关键字就可以避免指令重排

volatile 是如何避免指令重排的?

用 volatile 修饰的共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果

  • 写操作加的屏障是针对上方:防止其上方写操作越过屏障排到 volatile 变量之下
  • 读操作加的屏障是针对下方:防止其下方读操作越过屏障排到 volatile 变量之上

所以对于 volatile 的使用技巧一般是:

  • 写操作让 volatile 修饰的变量在代码最后的位置
  • 读操作让 volatile 修饰的变量在代码最开始的位置

在这里插入图片描述

10、什么是 AQS ?

AQS( Abstract Queued Synchronized) , 抽象队列同步器。是构建锁(锁机制)或者其他同步组件的基础框架

AQS与 synchronized 的区别

synchronizedAQS
关键字,C++实现java语言实现
悲观锁,自动释放锁悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差锁竞争激烈的情况下,提高多种解决方案

AQS的常见实现类:

  • ReentrantLock: 阻塞式锁
  • Semaphore: 信号量
  • CountDownLatch: 倒计时锁

AQS的基本工作机制:
在AQS中有一个由 volatile 修饰的int变量 state,state有两个值:0 表示无锁, 1表示有锁

过程:

线程1想要获取锁时,首先要请求修改 state值,将默认值0修改为1,成功就表示当前线程1持有锁;

线程2也想要获取锁,但是 state 值已经是1了,修改值失败,会将 线程2 放入 一个 先进先出的双向队列中进行等待,这个队列内部是双向链表实现的,其中有两个属性,head指头部,tail指尾部

AQS是通过 CAS设置 state 状态,保证操作的原子性的。即在多线程共同抢占这个资源时,并不会造成堵塞状态,而是成功修改state值的获取锁,失败的加入队列尾部 tail

AQS是公平锁吗,还是非公平锁?

  • 新的线程与队列中的线程共同抢占资源,就是非公平锁
  • 新的线程到队列中进行等待,只让队列中的 head 线程获取锁,是公平锁

在这里插入图片描述

11、ReentrantLock的实现原理
  • ReentrantLock 表示支持重新进入的锁,调用 lock()方法获取锁之后,可以再次调用
  • ReentrantLock 主要是利用 CAS + AQS 队列来实现的。在 ReentrantLock中,AQS中的 state 变量即充当了锁的状态,也充当了锁的计数器,每个线程对锁的获取都会增加锁的计数器,释放则会减少锁的计数器。计数为0时,锁才真正被释放
  • 支持公平锁和非公平锁。在提供的 构造器中 无参是非公平锁传参设置为 true 是公平锁
12、synchronized 和 lock 有什么区别?
  • 语法层面
    • synchronized 是关键字,源码在JVM中,底层是 C++实现的
    • Lock 是接口,源码在JDK中,底层是java语言实现的
  • 功能层面
    • synchronized 和 lock 都属于悲观锁,都具备基本的互斥、同步、可重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断(lockInterruptibly)、可超时(tryLock)、多条件变量(condition)
    • lock 有适合不同场景的体现,如 ReentrantLock、ReentrantReadWriteLock(读写锁,允许多线程读,写线程独占)、StampedLock(读写锁的优化,支持乐观)
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如锁升级,就是 偏向锁,轻量级锁
    • 在竞争激烈时,synchronized 必须是重量级锁,而 Lock 的实现通常会提供更好的性能
13、如何进行死锁诊断?
  1. 使用jdk自带的工具,控制台输出两个命令

    • jps: 获取允许的进程及其 id,比如发生死锁时,可以看到 DeadLock 进程

    • jstack -l 进程id: 可以查看线程的堆栈信息,比如输入的是死锁进程id, 可以看到关于死锁发生的详细 信息

  2. 使用 可视化工具 jconsole、VisualVM 也可以检查死锁问题

14、ConcurrentHashMap底层了解吗?

ConcurrentHashMap是一种线程安全的高效的 Map 集合

  1. 数据结构
    • jdk1.7 底层采用的是分段数组 + 链表实现
    • jdk1.8 底层采用的数据结构跟 hashMap 1.8 结构是一样的,数组 + 链表/红黑树
  2. 加锁方式
    • jdk1.7 采用的是 Segment分段锁,底层使用的是 ReentrantLock
    • jdk1.8采用 volatile(保证可见性) + CAS 无锁原子操作添加新节点(put操作),采用 synchronized 锁定单个桶(锁的是链表或者红黑树的首结点),而不会锁整个段,相对于 segment 分段锁粒度更细,性能更好
  3. 扩容机制
    • 采用 渐进式扩容 机制来避免扩容时的全表锁定
    • 渐进式扩容:扩容过程中并不会一次性将数据迁移到新的哈希表中,而是在每次进行插入或删除操作时,逐步迁移部分数据

解释一下jdk1.7的分段数组和分段锁(jdk1.8已经摒弃了这种分段机制):
在这里插入图片描述

对比于 hashMap, 其实是将哈希表再次分为多个段segment,每个段维护一个独立的哈希表和锁,以此提高并发性。

所以每次操作数据都需要进行两次hash,第一次 hash 定位到 Segment, 第二次 hash 才定位到具体的 hashEntry 中,然后链表搜索找到指定节点。而 这里的 segment 就是 分段数组

分段锁:在进行写操作时,锁住的是元素所在的 segment段,这样的锁就称为 分段锁

15、导致并发程序出现问题的根本原因是什么?

要解决并发程序问题,就必须确保java并发编程中的三大特性:

  • 原子性:一个线程在CPU中操作不可暂停也不可中断,要么执行完成,要么不执行。
    • 在多线程竞争条件下,非出现非原子性操作,比如一个数自增10000次,结果理应是10000,但是多线程运行下结果是小于10000的,这就是非原子性操作。
    • 解决办法:加锁,synchronized , lock,让多线程的并行操作变串行操作
  • 可见性:每个线程对共享变量的修改是所有线程可见的。、
    • 由于存在线程缓存(每个线程有独立的工作内存,修改共享变量时实际上是修改自己工作内存中的变量副本,然后再写到主内存中的共享变量中),在这个过程没有完成之前,其他线程对于该线程对共享变量的修改是不可见的,就会造成并发问题
    • 解决办法:首先加锁 synchronized, lock,锁住后修改数据完再释放锁,解决不可见的问题,其次 加 volatile 关键字,防止指令优化导致的不可见。
  • 有序性:程序按照我们想要的顺序执行
    • 由于存在指令重排,导致指令执行的顺序发生变化,影响到了结果
    • 加 volatile 关键字,通过内存屏障解决指令重排问题
二、线程池相关
1、线程池中常见的阻塞队列(任务队列)

属于 使用 ThreadPoolExcutor 创建线程池中的第五个参数:workQueue

当没有空闲核心线程时,新来的任务会加入到此队列排队,队列满时会创建新的线程执行任务

常见的 workQueue 有四种:

  • ArrayBlockingQueue: 基于数组结构的有界阻塞队列,FIFO
  • LinkedBlockingQueue: 基于链表结构的有界阻塞对列,FIFO
  • DelayedWorkQueue: 是一个优先级队列,可以保证每次出队的任务都是当前队列中执行时间最靠前的
  • SynchronousQueue: 不存储任何元素的队列,每个插入操作都对应一个移出操作

ArrayBlockingQueue LinkedBlickingQueue的区别:

ArrayBlockingQueueLinkedBlockingQueue
强制有界(创建时必须指定容量)默认无界(Integet.MAX_VALUE 231- 1), 支持有界
底层是数组底层是单向链表
需要提前初始化 Node 数组懒加载
Node 是提前创建好的入队会生成新的 Node
一把锁两把锁(头和尾)

关于 LinkedBlockingQueue底层两把锁的解释:

LinkedBlockingQueue 底层是单向链表,只能从 head 取元素,从 tail 添加元素。LinkedBlockingQueue 采用 锁分离 技术实现入队和出队互不干扰(相比于 ArrayBlockingQueue 一把锁的锁的粒度就小很多)。添加元素和获取元素都有独立的锁,读写操作可并行执行。

2、线程池的常见种类有哪些?
  • FixedThreadPool: 固定线程数量的线程池
    • 核心线程数 = 最大线程数 (没有空闲线程), 可自己设置多少
    • 阻塞队列是 LinkedBlockingQueue, 最大容量为 Integer.MAX_VALUE
    • 适用于 任务量已知,相对耗时的任务
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
  • SingleThreadExecutor: 单线程化的线程池,保证所有任务按照指定顺序(FIFO)执行
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}
  • CachedThreadPool: 可缓存线程池
    • 核心线程数为0 ,最大线程数是 Integer.MAX_VALUE
    • 空闲线程最大存活时间为 60s
    • 阻塞队列为 SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
    • 适用于 任务数量比较密集,但每个任务执行时间较短的情况
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}
  • ScheduledThreadPool: 可以执行延迟任务的线程池,支持定时及周期性任务执行
    • 核心线程数可设置
    • 最大线程数为 Integer.MAX_VALUE
    • 阻塞队列为 DalayedWorkQueue
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

还有很多重载方法。传的参数不一样,大致相同

3、为什么不建议使用 Executors 创建线程池?

阿里开发手册中规定不允许使用 Executors 创建线程池,而是通过 ThreadPoolExecutor 的方式

使用 Executors 创建线程池对象的弊端有:

  • 对于 FixedThreadPool 和 SingleThreadPool
    • 请求队列固定为 LinkedBlockingQueue, 而其允许的请求队列长度为 231-1 ,可能会堆积大量的请求,从而导致OOM
  • 对于 CachedThreadPool
    • 允许的创建线程的数量为 231-1 , 可能会创建出大量的线程,导致 OOM
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值