多线程面试

本文详细介绍了Java多线程中的关键概念,包括死锁的四个必要条件及其避免策略,wait和notify在synchronized代码块中的使用原因,线程调用start()两次的后果,线程池的核心参数及作用,线程池的状态,以及拒绝策略。此外,还讨论了execute()和submit()的区别,线程生命周期,synchronized与lock的区别,以及CAS和ReentrantLock的工作原理。
摘要由CSDN通过智能技术生成

1、 死锁的发生原因和怎么避免

死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享
资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源
的线程就称为死锁线程。

导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。
互斥条件,共享资源 X 和 Y 只能被一个线程占用;
请求和保持条件,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不
释放共享资源 X;
不可抢占条件,其他线程不能强行抢占线程 T1 占有的资源;
循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的
资源,就是循环等待。
导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。
所以,只能在写代码的时候,去规避可能出现的死锁问题。
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互
斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来
破坏:

  • 对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等
    待了。
  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申
    请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资
    源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,
    这样线性化后自然就不存在循环了。

2、讲一下 wait 和 notify 这个为什么要在synchronized 代码块中?

wait 和 notify 用来实现多线程之间的协调,wait 表示让线程进入到阻塞状态,
notify 表示让阻塞的线程唤醒。
wait 和 notify 必然是成对出现的,如果一个线程被 wait()方法阻塞,那么必然需
要另外一个线程通过 notify()方法来唤醒这个被阻塞的线程,从而实现多线程之
间的通信。
在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变
量的方法来实现,也就是线程 t1 修改共享变量 s,线程 t2 获取修改后的共享变
量 s,从而完成数据通信。
但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执
行。在这种情况下,线程 t2 在访问共享变量 s 之前,必须要知道线程 t1 已经修
改过了共享变量 s,否则就需要等待。
同时,线程 t1 修改过了共享变量 S 之后,还需要通知在等待中的线程 t2。
所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线
程在什么条件下等待,什么条件下唤醒。

而 Synchronized 同步关键字就可以实现这样一个互斥条件,也就是在通过共享
变量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变
量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线
程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之
前的通信。
所以这也是为什么 wait/notify 需要放在 Synchronized 同步代码块中的原因,有
了 Synchronized 同步锁,就可以实现对多个通信线程之间的互斥,实现条件等
待和条件唤醒。

3、如果一个线程两次调用 start(),会出现什么问题?

在Java 里面,一个线程只能调用一次 start() 方法,第二次调用会抛出IllegalThreadStateException。
一个线程本身是具备一个生命周期的。
在 Java 里面,线程的生命周期包括 6 种状态。

  • NEW,线程被创建还没有调用 start 启动
  • RUNNABLE,在这个状态下的线程有可能是正在运行,也可能是在就绪队列里
    面等待操作系统进行调度分配 CPU 资源。
  • BLOCKED,线程处于锁等待状态。
  • WAITING,表示线程处于条件等待状态,当触发条件后唤醒,比如 wait/notify。
  • TIMED_WAIT,和 WAITING 状态相同,只是它多了一个超时条件触发。
  • TERMINATED,表示线程执行结束。
    当我们第一次调用 start()方法的时候,线程的状态可能处于终止或者非 NEW 状
    态下的其他状态。再调用一次 start(),相当于让这个正在运行的线程重新运行,不管从线程的安全性角度,还是从线程本身的执行逻辑,都是不合理的。
    因此为了避免这个问题,在线程运行的时候会先判断当前线程的运行状态。

4、线程池有哪些参数,各个参数的作用是什么?

线程池主要有如下7个参数:

  • corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。

  • maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。

  • keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。

  • workQueue(队列):用于传输和保存等待执行任务的阻塞队列。

  • unit:指定KeepAliveTime参数的时间单位。

  • threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。

  • handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略

5、线程池都有哪些状态?

线程池一共有五种状态, 分别是:

RUNNING :能接受新提交的任务,并且也能处理阻塞队列中的任务。

SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。在线程池处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。

STOP:不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态。

TIDYING:如果所有的任务都已终止了,workerCount (有效线程数) 为0,线程池进入该状态后会调用 terminated() 方法进入TERMINATED 状态。

TERMINATED:在terminated() 方法执行完后进入该状态,默认terminated()方法中什么也没有做。进入TERMINATED的条件如下:

  • 线程池不是RUNNING状态;

  • 线程池状态不是TIDYING状态或TERMINATED状态;

  • 如果线程池状态是SHUTDOWN并且workerQueue为空;

  • workerCount为0;

  • 设置TIDYING状态成功。

6、谈谈线程池的拒绝策略

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

1、AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。

2、DiscardPolicy:也是丢弃任务,但是不抛出异常。

3、DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)。

4、CallerRunsPolicy:由调用线程处理该任务。

执行execute()方法和submit()方法的区别是什么?

  • execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否
  • submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用get(long timeout, TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,有可能任务没有执行完。

线程池执行任务的流程?

在这里插入图片描述

常见的java线程池有哪几种类型

在这里插入图片描述
在这里插入图片描述

线程生命周期

在这里插入图片描述

synchronized和lock有什么区别

  • synchronized可以给类,方法,代码块加锁;而lock只能给代码块加锁
  • synchronized不需要手动获取锁和释放锁,发生异常会自动释放锁,不会造成死锁;而lock需要自己加锁和释放锁,如果使用不当没有unlock()去释放锁就会造成死锁。
  • 通过lock可以知道有没有成功获取锁,而synchronized无法办到。
    使用tryLock()方法:在Java中,java.util.concurrent.locks.Lock接口提供了tryLock()方法,该方法尝试获取锁并立即返回结果。如果获取成功,返回true;如果获取失败,返回false。通过该方法可以判断是否获得了锁。
Lock lock = new ReentrantLock();
// 尝试获取锁
if (lock.tryLock()) {
    try {
        // 成功获取锁,执行相关逻辑
    } finally {
        // 释放锁
        lock.unlock();
    }
} else {
    // 未能获取锁,执行相应的逻辑
}

synchronized和volatile的区别是什么

synchronized和volatile是Java中两种常用的同步机制,它们在作用和适用场景上有一些区别:

  • synchronized是Java提供的内置关键字,用于确保同一时间只有一个线程能够访问同步代码块,从而避免多线程并发访问引起的数据不一致问题。synchronized关键字可以用于方法或代码块,并且通常与锁对象相关联。
  • 而volatile是一个Java内存模型的关键字,用于确保变量的可见性和一致性。当一个变量被声明为volatile后,它可以确保以下两点:
    a. 可见性:当一个线程修改了一个共享变量的值,该修改对其他线程来说是立即可见的。这保证了多个线程同时访问共享变量时,不会出现数据不一致的情况。

b. 禁止指令重排序:Java虚拟机规范规定,编译器不能对volatile变量的读取和写入进行重排序。这确保了多个线程在访问volatile变量时,能够看到一致的读/写顺序。

总的来说,synchronized和volatile的区别在于它们的作用和适用场景不同。synchronized主要用于确保线程安全,避免多个线程并发访问引起的数据不一致问题;而volatile主要用于确保变量的可见性和禁止指令重排序,避免在多线程环境下出现一些隐式同步的问题。

为什么LockSupport可以突破wait/notify的原有调用顺序?

因为unpark获得了一个凭证,之后再调用park方法,就可以名正言顺的凭证消费,故不会阻塞,先发放了凭证后续可以畅通无阻。(类似高速公路TEC,直接放行)。

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

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

对CAS的了解

CAS 是Java 中Unsafe 类里面的方法,它的全称是CompareAndSwap,比较并交换的意思。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。举个例子,比如说有这样一个场景(如图),有一个成员变量state,默认值是0,定义了一个方法doSomething(),这个方法的逻辑是,判断state 是否为0 ,如果为0,就修改成1。在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write 的操作。一般情况下,我们会在doSomething()这个方法上加同步锁来解决原子性问题。
在这里插入图片描述
但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用CAS机制来进行优化。在doSomething()方法中,我们调用了unsafe 类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,分别是:当前对象实例、成员变量state 在内存地址中的偏移量、预期值0、期望更改之后的值1。CAS 机制会比较state 内存地址偏移量对应的值和传入的预期值0 是否相等,如果相等,就直接修改内存地址中state 的值为1。否则,返回false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。

在这里插入图片描述

CAS有什么缺陷

1、ABA问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改,但是看到的虽然是A,中间可能发生了A变B,B又变回A的 情况,此时A已经不是原来那个A了,数据即使成功修改,也可能有问题。

可以通过AtomicStampedReference解决ABA问题,它是一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
2、循环时间可能很长
自旋CAS,在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
3、只能保证一个变量的原子操作
CAS保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS无法直接保证操作的原子性。

ThreadLocal以及实现原理?

1、ThreadLocal时一种线程隔离机制,它提供了多线程环境下对于变量访问的安全性。
在这里插入图片描述
在这里插入图片描述
4、实现原理
①每个Thread对象内部都有一个ThreadLocalMap类型的成员变量threadLocals。该变量用于存储每个线程 ** 的变量副本。

②ThreadLocalMap是一个自定义的哈希表,它的键为ThreadLocal对象,值为对应线程的变量副本。ThreadLocalMap采用开放地址法(open addressing)来解决哈希冲突。

③当通过ThreadLocal的get()方法获取变量时,首先会获取当前线程的ThreadLocalMap对象,然后以ThreadLocal对象作为键进行查找,找到对应的变量副本并返回。

④当通过ThreadLocal的set()方法设置变量时,同样会获取当前线程的ThreadLocalMap对象,然后以ThreadLocal对象作为键将变量副本存储到ThreadLocalMap中。

⑤在每个线程结束时,会调用ThreadLocal的remove()方法清理ThreadLocalMap中无效的变量副本,避免内存泄漏问题。

通过以上的实现原理,ThreadLocal能够保证每个线程都拥有的变量副本,从而避免了线程安全问题和共享变量的竞争条件。同时,由于ThreadLocal的实现是基于每个线程的特定副本,所以它并不会影响其他线程的变量访问,提高了多线程程序的性能和效率。

对AQS的理解?

AQS是多线程同步器,他是JUC包中多个组件的底层实现,如lock,CountDownLatch,Semaphore,AQS提供了两种锁机制,分别是排他锁和共享锁,所谓排他锁就是存在多个线程去竞争同一共享资源时,同一时刻只允许一个线程去访问共享资源,比如Lock中的ReentrantLock重入锁,他的实现就用到了AQS中的排他锁功能。共享锁称为读锁,允许同一时刻允许多个线程同时获得一个锁的资源,比如CountDownLatch和Semaphore。
AQS作为互斥锁时,需要注意
①互斥变量的设计以及如何保证多线程同时更新互斥变量的时候线程的安全性。
②未竞争到锁资源的线程的等待以及竞争到锁的资源释放锁后的唤醒。
③锁竞争的公平性和非公平性。

AQS采用一个int类型的互斥变量state,用来记录锁竞争的状态,0表示当前没有任何线程竞争到锁资源,大于等于1表示已经有线程正在持有锁资源。多个线程同时去修改state的时候会产生线程安全问题,AQS采用了CAS机制保证state互斥变量更新的原子性,未获得到锁的线程通过Unsafe类中的park方法去进行阻塞,把阻塞的线程按照先进先出的原则加入到双向链表的结构中,获得锁资源的线程释放锁之后,会从双向链表的头部去唤醒下一个现成的等待,再去竞争锁。

关于公平性和非公平性,AQS在竞争锁资源的时候,公平锁需要去判断双向链表中是否有阻塞的线程,如果有需要等待,非公平锁不管双向链表中是否存在等待竞争锁的进程,他都会直接尝试更改互斥变量state去竞争锁。

ReentrantLock的理解?

ReentrantLock是可重入排它锁,主要解决多线程对共享资源竞争的问题,他的核心特性有:
①支持可重入,也即是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
②支持公平和非公平特性。
③提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是Lock()和tryLock()

  • ReentrantLock通过互斥变量使用CAS机制来实现锁的竞争;
  • 没有竞争到锁的资源使用AQS队列同步器来存储,底层通过双向链表来实现,锁被释放后,会从AQS队列头部去唤醒下一个等待线程;
  • 公平和非公平特征体现在竞争的时候是否需要判断AQS队列里面是否有等待的线程,非公平锁是不需要判断的;
  • 锁的可重入性在AQS里有一个成员变量来保存当前获取锁的线程,当同一个线程再次来竞争同一把锁时候,不会去走锁的获取逻辑而是直接增加重复次数。

ReentrantLock 是如何实现锁公平和非公平性的?

公平,指的是竞争锁资源的线程,严格按照请求顺序来分配锁。
非公平,表示竞争锁资源的线程,允许插队来抢占锁资源。
ReentrantLock 默认采用了非公平锁的策略来实现锁的竞争逻辑。其次ReentrantLock 内部使用了AQS 来实现锁资源的竞争,没有竞争到锁资源的线程,会加入到AQS 的同步队列里面,这个队列是一个FIFO 的双向链表。
在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS 同步队列里面有没有等待的线程。如果有,就加入到队列的尾部等待。
而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到AQS 同步队列等待。
ReentrantLock 和Synchronized 默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。
因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS 再把等待队列里面的线程唤醒,这里会涉及到内核态的切换,对性能的影响比较大。如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

pk5515

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值