并发编程面试知识点总结

文章目录

    • 线程相关问题
      • 线程有哪些状态?
      • 为什么要使用线程池
      • 最佳线程数如何确定?
      • 核心线程池ThreadPoolExecutor的参数
      • ThreadPoolExecutor的工作流程
      • 线程池空闲的时候里面线程是怎么从最大线程数回收到核心线程数的()
      • 操作系统层面线程实现?线程调度算法有哪些?
    • Java并发
      • final不可变对象对多线程有什么帮助
      • Java内存模型
      • Atomic类如何保证原子性(CAS操作)
        • 缺点:
      • volatile实现原理?
      • synchronized底层如何实现?
      • happens-before原则?
      • 阻塞队列实现原理
    • 锁&AQS
      • 乐观锁、悲观锁区别
      • 锁优化与升级?
      • synchronized和Lock的区别
      • AQS简介
      • ReentrantLock公平、非公平加锁实现过程?
      • Semaphore实现原理(共享锁)?
      • CountDownLatch实现原理(共享锁)?
      • CyclicBarrier实现原理?
      • Condition实现原理
      • 公平锁、非公平锁区别?
      • StampedLock

线程相关问题

线程有哪些状态?

并发编程面试知识点总结_第1张图片

为什么要使用线程池

减少资源的开销,可以减少每次创建销毁线程的开销提高响应速度,由于线程已经创建成功提高线程的可管理性

最佳线程数如何确定?

1、计算密集型: 设置线程数为CPU数+1,多了也没有更多的CPU核心来执行
2、IO密集型:通常设置为CPU数*2,因为大部分时间系统在等待IO,CPU空闲时间多,可以适当调大CPU数

也有公式可以计算(图片来自并发编程实战)
并发编程面试知识点总结_第2张图片

核心线程池ThreadPoolExecutor的参数

  1. corePoolSize:基本线程数量 它表示你希望线程池达到的一个值。线程池会尽量把实际线程数量保持在这个值上下。
  2. maximumPoolSize:最大线程数量 这是线程数量的上界
  3. keepAliveTime:空闲线程的存活时间 当实际线程数量超过corePoolSize时,若线程空闲的时间超过该值,就会被停止。 PS:当任务很多,且任务执行时间很短的情况下,可以将该值调大,提高线程利用率。
  4. timeUnit:keepAliveTime的单位
  5. runnableTaskQueue:任务队列 这是一个存放任务的阻塞队列,可以有如下几种选择:
    1. ArrayBlockingQueue 它是一个由数组实现的阻塞队列,FIFO。
    2. LinkedBlockingQueue 它是一个由链表实现的阻塞队列,FIFO。 吞吐量通常要高于ArrayBlockingQueue。fixedThreadPool使用的阻塞队列就是它。 它是一个无界队列。
    3. SynchronousQueue 它是一个没有存储空间的阻塞队列,任务提交给它之后必须要交给一条工作线程处理;如果当前没有空闲的工作线程,则立即创建一条新的工作线程。 cachedThreadPool用的阻塞队列就是它。
    4. PriorityBlockingQueue 它是一个优先权阻塞队列。
  6. handler:饱和策略 当实际线程数达到maximumPoolSize,并且阻塞队列已满时,就会调用饱和策略。
    1. AbortPolicy 默认。直接抛异常。
    2. CallerRunsPolicy 调用者所在的线程执行任务。
    3. DiscardOldestPolicy 丢弃任务队列中最久的任务。
    4. DiscardPolicy 丢弃当前任务

ThreadPoolExecutor的工作流程

并发编程面试知识点总结_第3张图片

线程池空闲的时候里面线程是怎么从最大线程数回收到核心线程数的()

try {
                boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }

看到这里就应该知道了,我们的线程在获取任务时,如果队列中已经没有任务,会在此处阻塞keepALiveTime的时间,如果到时间都没有任务,就会return null(不是直接返回null,是最终),然后在runWorker()方法中,执行
processWorkerExit(w, completedAbruptly);

操作系统层面线程实现?线程调度算法有哪些?

先进先出算法(FIFO,First-In-First-Out)
最短耗时任务优先算法(Shortest Job First, SJF) - 缺点:容易饥饿
时间片轮转算法(Round Robin)
最大最小公平算法( Max-Min Fairness )

Java并发

final不可变对象对多线程有什么帮助

  1. 不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。

  2. Final 变量在并发当中,原理是通过禁止cpu的指令集重排序,保证了对象的内存可见性, final 域能确保初始化过程的安全性, 防止对象引用在对象被完全构造完成前被其他线程拿到并使用( final 可以保证正在创建中的对象不能被其他线程访问到)

    final域的写规则要求编译器在final域的写之后,构造函数return之前插入store-strore屏障
    final域的读规则要求编译器在final域的读操作之前插入load-load屏障

Java内存模型

Java内存模型(Java Memory Model ,JMM),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性,解决多线程之间数据正确同步的问题

Atomic类如何保证原子性(CAS操作)

采用了 CAS 操作,每次从内存中读取数据然后将此数据和+1 后的结果进行,CAS 操作,如果成功就返回结果,否则重试直到成功为止。而 compareAndSet 利用 JNI 来完成CPU 指令cmpxchg的操作

程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)
lock前缀指令说明

  1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
  2. 禁止该指令与前面和后面的读写指令重排序。
  3. 把写缓冲区的所有数据刷新到内存中。

现代cpu的寄存器与内存之间存在L1,L2,L3高速缓存,频繁使用的内存会缓存在高速缓存中,此时以缓存锁定来代替总线锁定,利用缓存一致性机制来保证操作的原子性。从上面二三点我们也可以看出cas同时包含了volatile读和写的内存语义

缺点:

  1. ABA问题:通过AtomicStampedReference来解决,类似版本号思路
  2. CPU自旋开销大:PAUSE指令。PAUSE指令会在循环等待时提示处理器,处理器利用这个提示可以避免在大多数情况下的内存顺序违规,这将大幅提升性能
  3. 只能保证一个共享变量的原子操作:将多个变量合并成一个对象的成员变量

volatile实现原理?

volatile可以保证线程可见性且禁止指令重排序,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的, 加入volatile关键字时,汇编后会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障

1、LOCK前缀指令使得当前缓存行的数据写回主存
2、写回主存的操作使得其它CPU中缓存了该内存地址的数据无效(缓存一致性原则,嗅探总线上传播的信号来检查自己缓存中的数据是否过期,当发现自己的数据被修改时,强制从主存中重新读取数据)

volatile的优化: linkedTransferQueue,利用字节追加的方式,使得一个节点占64字节,每次锁定只锁定一个缓存行,优化出队入队效率,解决了伪共享的问题

上面说到一个缓存行的大小一般为64个字节大小,这里我们来做一个假设,当缓存行中有两个变量a,b,当一个线程对a变量进行写操作的时候,会将另一个处理器中缓存行中b设置为无效,另一个线程在对b进行写操作的时候,会将其他处理器缓存行中的a变量设置为无效,这样就产生了写冲突,也叫伪共享,填充到64字节后,不同节点的写不会冲突

synchronized底层如何实现?

原理:
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性

底层实现:
1)同步代码块是使用monitorenter和monitorexit指令实现的, ,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;
2)同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。

最终都是通过JVM层面监视器锁的获取实现

Java对象头和monitor是实现synchronized的基础!synchronized存放的位置:synchronized用的锁是存在Java对象头里的。
其中, Java对象头包括:

  1. Mark Word(标记字段): 用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。它是实现轻量级锁和偏向锁的关键
  2. Klass Pointer(类型指针): 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
  3. monitor: 可以把它理解为一个同步工具, 它通常被描述为一个对象。 是线程私有的数据结

happens-before原则?

As-if-serial语义保证单线程中执行结果不会改变,happens-before原则保证正确同步的多线程之间执行结果不会改变

  1. 程序顺序规则:单线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unlock操作先行发生于对同一个锁的lock操作;
  3. volatile变量规则:对一个Volatile变量的写操作先行发生于对这个变量的读操作;
  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的其他动作;
  5. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

阻塞队列实现原理

  1. ArrayBlockingQueue 实现并发同步的原理就是(lock+condition),读操作和写操作都需要获取到 AQS 独占锁才能进行操作。如果队列为空,这个时候读操作的线程进入到读线程队列排队,等待写线程写入新的元素,然后唤醒读线程队列的第一个等待线程。如果队列已满,这个时候写操作的线程进入到写线程队列排队,等待读线程将队列元素移除腾出空间,然后唤醒写线程队列的第一个等待线程
  2. LinkedBlockingQueue:LinkedBlockingQueue底层相比ArrayBlockingQueue要复杂,LinkedBlockingQueue采用了双锁队列,针对put和offer方法单独的使用一个锁,针对take和poll则采用了take锁
  3. PriorityBlockingQueue使用一个ReentrantLock锁和一个控制消费者空的时候的condition条件队列,底层是堆结构,大多数操作都通过重入锁来保证互斥操作,唯一有一点特殊的地方在于,数组扩容的时候采用了自旋锁来控制,为了避免在扩容期间导致其他的并发操作不能进行。注意扩容是新生成一个容量更大的数组,等生成完毕之后,还是需要以独占锁的方法,先替换引用,然后在拷贝老数组的数据到扩容后的数组中
  4. DelayQueue这个类的大部分与PriorityBlockingQueue类似,不同点在于消费者消费数据的时候,会先通过peek方法取头部的元素出来,然后判断是否超时。如果没有超时,就调用Condition.awaitNanos(ns)方法阻塞到该数据超时时间,在此期间的其他消费者现场都必须阻塞等待,因为头部的元素的还没超时

锁&AQS

乐观锁、悲观锁区别

乐观锁:比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

悲观锁:比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。

乐观锁:乐观锁的特点先进行业务操作,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,因此在进行完业务操作需要实际更新数据的最后一步再去拿一下锁就好。

悲观锁:悲观锁的特点是先获取锁,再进行业务操作,即“悲观”的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常所说的“一锁二查三更新”即指的是使用悲观锁。

锁优化与升级?

锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。 注意锁可以升级不可降级

  1. 偏向锁:当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。

  2. 自旋锁:自旋锁 for(;;)结合cas确保线程获取取锁。就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。

  3. 轻量级锁:当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁

  4. 重量级锁:重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高

    synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级

synchronized和Lock的区别

  1. 实现层面:synchronized(JVM层面)、Lock(JDK层面)
  2. 响应中断:Lock 可以让等待锁的线程响应中断,而使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
  3. 立即返回:可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间,而synchronized却无法办到;
  4. 读写锁:Lock可以提高多个线程进行读操作的效率
  5. 可实现公平锁:Lock可以实现公平锁,而sychronized天生就是非公平锁
  6. 显式获取和释放:synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

AQS简介

队列式同步器。这个抽象类对于JUC并发包非常重要,JUC包中的ReentrantLock,,Semaphore,ReentrantReadWriteLock,CountDownLatch 等等几乎所有的类都是基于AQS实现的。
AQS 中有两个重要的东西,一个以Node为节点实现的链表的队列(CHL队列),还有一个STATE标志,并且通过CAS来改变它的值

ReentrantLock公平、非公平加锁实现过程?

state状态如下(暂时只需要知道如果这个值 大于0 代表此线程取消了等待,0为初始化状态):

static final int CANCELLED =  1:// 代码此线程取消了争抢这个锁
    static final int SIGNAL    = -1;:    // 官方的描述是,其表示当前node的后继节点对应的线程需要被唤醒
    static final int CONDITION = -2;    // 本文不分析condition,所以略过吧,下一篇文章会介绍这个
    static final int PROPAGATE = -3;    // 同样的不分析,略过吧

独占非公平锁加锁过程:

public final void acquire(int arg) { // 此时 arg == 1
        // 首先调用tryAcquire(1)一下,名字上就知道,这个只是试一试
        // 因为有可能直接就成功了呢,也就不需要进队列排队了,
        // 对于公平锁的语义就是:本来就没人持有锁,根本没必要进队列等待(又是挂起,又是等待被唤醒的)
        if (!tryAcquire(arg) &&
            // tryAcquire(arg)没有成功,这个时候需要把当前线程挂起,放到阻塞队列中。
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
              selfInterrupt();
        }
    }
  1. tryAcquire尝试加锁

  2. 如果state == 0
    公平锁锁 判断等待队列为空 再cas设置state
    非公平锁锁 直接cas设置state

    如果state>0 ,再判断当前获取锁的线程(current == getExclusiveOwnerThread()) 是不是当前线程 是, state++(重入)

  3. addWaiter(加锁失败,存在锁竞争,尝试CAS入阻塞等待队列,CAS设置自己为队尾),即enq操作是个循环CAS入等待队列的操作
    循环中, 若队列为空,创建头节点,再循环CAS入队直到成功

    3、acquireQueued方法中,判断如果当前节点是阻塞队列队头,尝试抢锁,
    抢锁成功时,设置自己为队列头结点,同时返回
    抢锁失败时,判断是否需要挂起(根据前驱节点waitStatus判断)
    waitStatus=-1 标识状态正常,需要被挂起 返回true
    waitStatus>0 标识前驱节点已经取消抢锁,往前找,直到找到节点的waitStatus<0 返回false
    waitStatus = 0 此时CAS将前驱节点的waitStatus设置为Node.SIGNAL(也就是-1)

    4、如果需要被挂起,利用LockSupport挂起线程

独占锁解锁过程:

public final boolean release(int arg) {
    // 往后看吧
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

// 回到ReentrantLock看tryRelease方法

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    // 是否完全释放锁
    boolean free = false;
    // 其实就是重入的问题,如果c==0,也就是说没有嵌套锁了,可以释放了,否则还不能释放掉
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

Semaphore实现原理(共享锁)?

大概大家也可以猜到,Semaphore 其实也是 AQS 中共享锁的使用,因为每个线程共享一个池嘛。

套路解读:创建 Semaphore 实例的时候,需要一个参数 permits,这个基本上可以确定是设置给 AQS 的 state 的,然后每个线程调用 acquire 的时候,执行 state = state - 1,release 的时候执行 state = state + 1,当然,acquire 的时候,如果 state = 0,说明没有资源了,需要等待其他线程 release

CountDownLatch实现原理(共享锁)?

countDown() 方法每次调用都会将 state 减 1,直到 state 的值为 0;而 await 是一个阻塞方法,当 state 减为 0 的时候,await 方法才会返回。await 可以被多个线程调用,所有调用了 await 方法的线程阻塞在 AQS 的阻塞队列中,等待条件满足countdown将state-1使得(state == 0),将线程从队列中一个个唤醒过来

CyclicBarrier实现原理?

CyclicBarrier 相比 CountDownLatch 来说,要简单很多,其源码没有什么高深的地方,它是 ReentrantLock 和 Condition 的组合使用

   /** The lock for guarding barrier entry */
    private final ReentrantLock lock = new ReentrantLock();

    // CyclicBarrier 是基于 Condition 的
    // Condition 是“条件”的意思,CyclicBarrier 的等待线程通过 barrier 的“条件”是大家都到了栅栏上
    private final Condition trip = lock.newCondition();

    // 参与的线程数
    private final int parties;

await方法

  1. lock.lock()先获取锁
  2. 判断count
// 注意到这里,这个是从 count 递减后得到的值
        int index = --count;

        // 如果等于 0,说明所有的线程都到栅栏上了,准备通过
        if (index == 0) {  // tripped
  1. 调用condition.await()等待,直到最后一个线程调用 await

Condition实现原理

  1. 每个 condition 有一个关联的条件队列,如线程 1 调用 condition1.await() 方法即可将当前线程 1 包装成 Node 后加入到条件队列中,然后阻塞在这里,不继续往下执行,条件队列是一个单向链表;
  2. 调用condition1.signal() 触发一次唤醒,此时唤醒的是队头,会将condition1 对应的条件队列的 firstWaiter(队头) 移到阻塞队列的队尾,等待获取锁,获取锁后 await 方法才能返回,继续往下执行

公平锁、非公平锁区别?

非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。

非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面

StampedLock

StampedLock的特点
所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为0表示获取失败,其余都表示成功;
所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致;
StampedLock是不可重入的;(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
支持锁升级跟锁降级
可以乐观读也可以悲观读
使用有限次自旋,增加锁获得的几率,避免上下文切换带来的开销
乐观读不阻塞写操作,悲观读,阻塞写得操作

StampedLock的优点
相比于ReentrantReadWriteLock,吞吐量大幅提升

StampedLock的缺点
api相对复杂,容易用错
内部实现相比于ReentrantReadWriteLock复杂得多

你可能感兴趣的:(面试,并发,Java,面试)