0. 什么是并发?什么是并行?
- 并发:把时间分成一段一段,每个线程轮流抢占时间段。 如果时间段非常短,线程切换非常快,被称为伪并行。
- 并行:多个线程可以同时运行。
并发与并行造成的影响?
多线程问题:线程安全,死锁等
1. 如何预防死锁?
先说明死锁发生的四个必要条件:
- 互斥条件:同一时间只能有一个线程获取资源(因为资源有限)。
- 不可剥夺条件:一个线程已经占有的资源,在释放之前不会被其它线程抢占。(@&@20230130)
- 请求和保持条件:线程等待过程中不会释放已占有的资源。
- 循环等待条件:多个线程互相等待对方释放资源。
死锁预防,破坏这四个必要条件:
- 由于资源互斥是资源使用的固有特性,无法改变,我们不讨论
- 破坏不可剥夺条件:
- 一个进程不能获得所需要的全部资源时就进入等待状态,此时释放调自己持有的资源,让其它进程使用。再等待结束时,重新获取原有资源。
- 破坏请求和持条件:
- 方法一:静态分配,即每个进程在开始执行时就申请它所需要的全部资源。
- 方法二:动态分配,即每个进程在申请所需要的资源时,它本身不占用系统资源。
- 破坏循环等待条件:
- 采用资源有序分配,将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。
2. 多线程有哪几种创建方式?
- extend Thread Override run()
- implement Runnable Override run() and new Tread(Runnable r)
- implement Callable Override call() and FutureTask
- ThreadPool
- new Tread(lambda).start()
3. 描述一下线程安全活跃态问题,竞态条件?(@&@)
活跃性问题可以分为:死锁、活锁、饥饿
活锁:**
- 概念:线程没有被阻塞,由于某些条件没有满足,导致一直重复尝试。
- 场景1:异步的消息队列就有可能造成活锁。在消息队列的消费端,如果没有正确的ack消息,并且执行过程中报错了,就会再次放回到消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的ack之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。
- 场景2:递归没有写退出条件。
- 解决:尝试等待一个随机的时间就可以,会按时间轮去重试。
饥饿:**
- 概念:线程因无法访问所需资源,而无法执行下去的情况。
- 两种情况:
- 线程在临界区做了无限循环或无限制等待资源的操作,让其它的线程一直不能拿到锁进入临界区,对其它线程来说,就进入了饥饿状态。(现在理解了20230130)
- 线程优先级不合理的分配,导致部分线程始终无法获取到CPU资源,而一直无法执行。
- 场景:读写锁,一直在读,写锁一直等待。
- 解决:
- 保证资源充足,很多场景下,资源的稀缺性无法解决。
- 公平分配资源,在并发编程里使用公平锁,例如FIFO策略,线程等待是有顺序的,排在等待队列前面的线程会优先获得资源。
- 避免持有锁的线程长时间执行,很多场景下,持有锁的线程的执行时间也很难缩短。
死锁:
- 概念:线程在对同一把锁进行竞争时,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁。
竞态条件:**
- 概念:
- 同一个程序的多线程访问同一个资源,如果对资源的访问顺序敏感,就称存在竞态条件。
- 代码区成为临界区。
- 与大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序。
- 最常见的竞态条件:
- 先检测后执行的操作,执行依赖于检测结果,而检测结果依赖于多个线程的执行时序,多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种的问题。一种可能的解决办法就是:在一个线程修改访问一个状态时,要防止其他线程访问修改,也就是加锁机制,保证原子性。
- 延迟初始化(典型的赖汉单例)
4. 描述一下进程与线程区别?
进程(Process)是系统进行资源分配和调度(管理)的基本单位,进程是资源分配的最小单位,是程序一次动态执行的过程。
线程:是执行的最小单位,一般不具有资源。线程是由进程产生的。
5. 描述一下Java线程的生命周期?
总:
Java线程的生命周期有5种状态,分别是:新建、就绪、运行、阻塞和销毁。在Linux底层的线程只有:运行、阻塞和销毁。
Java线程的生命周期:
- 新建:new出来的线程。
- 就绪:调用线程的start()后,线程处于等待CPU分配资源的阶段,谁先抢到CPU资源,谁开始执行。
- 运行:当就绪的线程被调度,并获得CPU资源时,便进入运行状态,run()定义了线程的操作和功能。
- 阻塞:线程调用 sleep()、wait()就处于了阻塞状态,处于阻塞状态的线程需要调用某种机制唤醒,比如调用notify()、notifyAll()。唤醒的线程不会立刻执行run(),它们要再次等待CPU分配资源进入运行状态。
- 销毁:线程正常执行完毕后、线程被提前强制性的终止、出现异常导致结束,那么线程就要被销毁,释放资源。
Linux底层线程三种状态:**
运行、阻塞、销毁。JVM是虚拟出来的一套操作系统,在创建Thread对象时,还没有调用Linux的线程,所以新增了一个新建状态。调用Thread.start()时,调用Linux的线程,但是不能立即执行,需要等待CPU分配资源,所以有了就绪状态。
JDK的源码的Thread状态(6种):
- NEW:尚未启动的线程的线程状态
- RUNNABLE:处于可运行状态的线程,它正在JVM中执行,但它可能正在等待来自操作系统(例如处理器)的其他资源。它有细分READY (准备状态)和RUNNING (可运行状态)。
- BLOCKED:被阻塞,正在等待着锁。
- WAITING: 等待被唤醒。例如:在对象上调用Object.wait()的线程等待另一个线程调用Object.notify()或Object.notifyAll();调用Thread.join()的线程正在等待指定的线程终止。
- TIMED_WAITING : 具有指定等待时间的等待线程的线程状态。使用如下:
- Thread.sleep(long)
- Object.wait(long)
- Thread.join(long)
- LockSupport.parkNanos(long...)
- LockSupport.parkUntil(long...)
- TERMINATED:线程已完成执行。
6. 线程池设置多少线程数合适?
首先确认业务是CPU密集型还是IO密集型的,根据不同情况去判断。
CPU密集型程序:
- 特点:I/O操作时间短, CPU运算处理多。
- 单核CPU:处理CPU密集型程序,不太适合多线程。
- 多核 CPU:处理CPU密集型程序,最大化的利用CPU核心数,应用并发编程来提高效率。
- 理论上:线程数量 = CPU 核数(逻辑)
- 实际上:线程数量 = CPU 核数(逻辑)+ 1(经验值)
- 如果恰好某一个线程出问题而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。
I/O 密集型程序:
- 特点: I/O 操作占比很大部分,等待时间较长。线程等待时间所占比例越高,需要越多线程。
- 公式:CPU 核数 / (1 - 阻塞系数);阻塞系数 在 0.8 ~ 0.9 之间。
- 一般是:线程数量 = 2*CPU核数 + 1
- 实际上:在2N+1上,进行压测逐步添加
实际情况看压测:
看系统负载:-> Linux使用uptime命令 -> 系统负载与CPU核心数量有关,理想情况下,一个核心被一个进程占用。如果4个核心,跑4个进程,此时Load是4但是也不高。
7. wait()和sleep()的区别与联系?
区别:
- 所属类:wait()是Object的方法,sleep()是Thread的方法(拓展一下为什么在Object、Thread中)。
- 作用范围:sleep没有释放锁,只是休眠,而wait释放了锁后等待,使得其他线程可以使用同步控制块或方法。
- 使用范围: wait、notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用。
- 异常范围:sleep必须捕获异常,而wait,notify和notifyAll不需要强制地捕获异常。
联系:
sleep、wait调用后都会暂停当前线程并让出CPU的执行时间,并且都能被唤起。
8. 描述一下notify()和notifyAll()区别?
- notifyAll():唤醒所有,notify():随机唤醒一个。
- 使用流程:
- 线程调用了对象的wait(),线程进入该对象的等待池中,等待池中的线程,不会去竞争该对象的锁。
- 如果其它线程调用了对象的notifyAll()或notify(),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。竞争成功则继续执行。
拓展:"锁池"和"等待池"的概念:
- 锁池:线程A已经拥有了某个对象(不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(块),但是该对象的锁现在正被线程A拥有,这些其他线程就进入了该对象的锁池中。
- 等待池:
- 线程A调用了某个对象的wait(),线程A就会释放该对象的锁(因为wait()必须在synchronized中,在执行wait()之前,线程A就已经拥有该对象的锁),同时该线程A进入到该对象的等待池中。
- 如果另外的一个线程调用了相同对象的notifyAll(),处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。
- 如果另外的一个线程调用了相同对象的notify(),仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池。
9. 描述一下synchronized和lock区别 ?
- 层次:Lock是一个接口,底层使用AQS框架实现;Synch是关键字,底层通过JVM实现的(monitor对象和对象头Mark Word)。
- 锁的获取:Lock可以使用tryLock判断有没有锁。
- 锁的释放:Lock需要在finally中自己释放,发生异常时,如果没有主动去释放锁,会导致死锁;Synch会自动释放,发生异常时,不会死锁。
- 响应中断:Lock等待锁过程中可以用interrupt来中断等待,而Synch只能等待锁的释放,不能响应中断。
- 锁的状态:Lock可以通过trylock()知道有没有获取锁,而Synch不能。
- 公平锁:Lock可以设置为公平锁与非公平锁,而Synch只能是非公平锁。
- 性能:Lock大量同步,Synch少量同步。Lock可以提高多个线程进行读操作的效率。(可以通过ReadWriteLock实现读写分离)
对象头:(主要背一下锁标志位)
拓展Synch的加锁流程:*****
- HotSpot研究发现,大多数情况下,锁总是由同一线程多次获得,引入了偏向锁。
- 偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活。
- 装逼点(不要瞎BB):批量重偏向与批量撤销
https://blog.csdn.net/u022812849/article/details/108531031
- 获取偏向锁流程:
- I. 判断是否为可偏向状态:MarkWord中锁标志是否为‘01’,是否偏向锁是否为‘1’。
- II. 如果是可偏向状态,则查看MarkWord中线程ID是否为当前线程,如果是,则进入步骤 V,否则进入步骤III。
- III. 通过CAS操作竞争锁,如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行V;竞争失败,则执行IV。
- IV. CAS获取偏向锁失败表示有竞争。当达到safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。
- V. 执行同步代码。
- 轻量级锁是自旋锁,减少CPU上下文的切换,在线程交替执行同步块时,会提高性能。
- 轻量级锁获取过程:
- I. 进行加锁操作时,如果没有创建锁记录(Lock Record),则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中(偏向锁的时候也会生成)。
- II. 复制成功之后,JVM使用CAS操作将对象头的MarkWord更新为指向当前线程的锁记录指针,并将锁记录里的Owner指针指向对象头的MarkWord。如果成功,则执行III,否则执行IV
- III. 更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态。
- (IV. 更新失败,JVM先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行V,否则执行VI。
- V. 表示锁重入;然后当前线程栈帧中增加一个锁记录:第一部分(Displaced Mark Word)为null,并指向Mark Word的锁对象,起到一个重入计数器的作用。)
- VI. 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁。
- 当有多个锁竞争轻量级锁则会升级为重量级锁,重量级锁正常会进入一个CXQ队列(竞争队列),有资格成为候选资源的线程进入EntryList,通过竞争获取锁资源(成为Owner)。在调用wait()之后,则会进入一个waitSet队列park等待,而当调用notify()唤醒之后,则有可能进入EntryList(看图,背图)。
- 重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的。
- 重量级锁加锁过程:
- I . 分配一个ObjectMonitor对象,把Mark Word锁标志置为10,然后MarkWord存储指向ObjectMonitor对象的指针。每个需要获取锁的线程都包装成ObjectWaiter对象。
- II . 多个线程同时执行同一段同步代码时,ObjectWaiter先进入EntryList队列,当某个线程获取到对象的monitor以后进入Owner区域,并把monitor中的owner变量设置为当前线程同时monitor中的计数器count+1;
10. 简单描述一下ABA问题?
ABA过程:
- 有两个线程同时去修改一个变量的值,比如线程1和线程2,都将变量值从A更新成B。
- 首先线程1获取到CPU的时间片,线程2发生阻了塞进行等待,线程1通过CAS把值从A更新成B。
- 线程1更新完后,恰好有线程3想要把变量的值从B更新成A。
- 线程3更新成功后,线程2获取到CPU的时间片,进行CAS,发现值是预期的A,然后有更新成了B。
- 但是线程1并不知道,该值已经有了A->B->A这个过程,这也就是我们常说的ABA问题。
解决:
可以通过加版本号或者加时间戳解决,或者保证单向递增或者递减就不会存在此类问题。Java中也有相关引用原子类。
11. 实现一下DCL(双重检测)?
用单例的方式说明DCL,加锁前后都判断对象是否为空。为了解决并发可能导致实例两个对象。自己代码中也用到过。(凤凰、神州租车用到过)
拓展点:DCL单例需要使用volatile关键字,防止指令重排序的优化,导致拿到半初始化的对象。
具体代码如下:
public class Singleton {
// volatile是防止指令重排
private static volatile Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Signletion();
}
}
}
return singleton;
}
}
12. 实现一个阻塞队列?
三种方法:
- 使用Synchronized中wait() + notify()
- 使用Lock中Condition的await() + signal()
- 使用ArrayBlockingQueue队列的put() + take()
具体见代码:
《springboot-multi-thread》 com.hanxiaozhang.threadbase1ndedition.no98producerandconsumer
13.实现多个线程顺序打印abc?
public class PrintABC {
ReentrantLock lock = new ReentrantLock();
Condition conditionA = lock.newCondition();
Condition conditionB = lock.newCondition();
Condition conditionC = lock.newCondition();
volatile int value = 0;
public void printABC() {
new Thread(new ThreadA()).start();
new Thread(new ThreadB()).start();
new Thread(new ThreadC()).start();
}
class ThreadA implements Runnable{
@Override
public void run() {
lock.lock();
try {
while (value % 3 != 0) {
conditionA.await();
}
System.out.print("A");
conditionB.signal();
value ++;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
... ...
14.多线程之间是如何通信的?
- 通过共享变量,变量需要volatile修饰。
- 使用wait()和notifyAll(),但是由于需要使用同一把锁,所以必须通知线程释放锁,被通知线程才能获取到锁,这样导致通知不及时。
- 使用Condition的await()和signalAll()方法。
- 使用CountDownLatch实现,通知线程到指定条件,调用cdl.countDown(),被通知线程进行cdl.await()。
15.描述一下Synchronized底层实现,以及和Lock的区别?
对像头可以说明锁的四种状态:
结合第9题开始说。