(备战春招,每天进步亿点点)常见面试题总结——Java并发(1)

备战春招,每天进步亿点点,继续学习Java并发
在这里插入图片描述

线程和进程有什么区别?

线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。

根本区别: 进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

资源开销: 每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

包含关系: 如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

内存分配: 同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

影响关系: 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

执行过程: 每个独立的进程有程序运行的入口. 顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

说一下创建线程的几种方式?

创建线程有四种方式,分别是:继承Thread类、实现Runnable接口、实现Callable接口、创建线程池
方式一.继承Thread类步骤:
1.创建一个继承于Thread类的子类
2.重写Thread类的run()方法——>将此线程执行的操作声明在run()中
3.创建子类对象
4.通过此对象调用start()
方式二.实现Runnable接口:
1.创建一个实现了Runnable接口的类
2.实现类去实现Runnable中的抽象方法:run()方法
3.创建实现类对象
4.将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
5.通过Thread类的对象调用start()方法
方式三:实现Callable接口:
1.创建一个实现了Callable接口的类
2.实现类去实现抽象方法call(),将此线程需要执行的操作放在里面
3.创建实现类的对象
4.创建FutureTask类对象,将实现类对象传入构造器中
5.将FutureTask对象放入Thread类构造器中,创建Thread类对象,调用start()
6.获取Callable中call方法的返回值,get()返回值即为Future Task构造器参数Callable实现类重写的call()的返回值。

Runnable和Callable的区别?

  • Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。
  • Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
  • Call方法可以抛出异常,run方法不可以。
  • 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

为什么要使用多线程呢?

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

从计算机底层来说:

  • 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。 举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
  • 多核时代:多核时代多线程主要是为了提高 CPU 利用率。 举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。

线程的有几种状态?

Java线程具有五种基本状态,分别是:

  1. 新建状态(New): 当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
  2. 就绪状态(Runnable): 当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;
  3. 运行状态(Running): 当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
  4. 阻塞状态(Blocked): 处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
    1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
    2.同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
    3.其他阻塞 — 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时. join()等待线程终止或者超时. 或者I/O处理完毕时,线程重新转入就绪状态。
  5. 死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

什么是线程死锁?如何避免死锁?

死锁:

  • 多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

死锁必须具备以下四个条件:

  • 互斥条件: 该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件: 一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件: 线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件: 若干进程之间形成一种头尾相接的循环等待资源关系。

如何避免线程死锁?
让以上四个条件其中一个不成立即可:

  • 破坏互斥条件 这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)
  • 破坏请求与保持条件 一次性申请所有的资源。
  • 破坏不剥夺条件 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
  • 锁排序法:(必须回答出来的点) 指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁? 通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
  • 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

  • new 一个 Thread,线程进入了新建状态; 调用start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,(调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。)这是真正的多线程工作。
  • 直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

线程阻塞的三种情况

当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,知道线程进入可运行状态(Runnable),才有机会再次获得 CPU 时间片转入 RUNNING 状态。一般来讲,阻塞的情况可以分为如下三种:

  1. 等待阻塞(Object.wait -> 等待队列)
    RUNNING(运行) 状态的线程执行 Object.wait() 方法后,JVM 会将线程放入等待序列(waitting queue);
  2. 同步阻塞(lock -> 锁池)
    RUNNING 状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入锁池(lock pool)中;
  3. 其他阻塞(sleep/join)
    RUNNING 状态的线程执行 Thread.sleep(long ms)Thread.join() 方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep() 状态超时,join() 等待线程终止或超时. 或者 I/O 处理完毕时,线程重新转入可运行状态(RUNNABLE);

守护线程是什么?

守护线程, 是指在程序运行时 在后台提供一种通用服务的线程,这种线程并不属于程序中不可或缺的部分。通俗点讲,任何一个守护线程都是整个JVM中所有非守护线程的"保姆"。当非守护线程终止时,守护线程就没了可以保护的对象,程序就会终止,同时会杀死所有的守护线程,但是只要有任何非守护线程还在运行,程序就不会终止。

CAS知道吗?

CAS:全称 Compare and swap,即比较并交换,它是一条 CPU 同步原语。是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。
CAS 是一种无锁的非阻塞算法的实现。,CAS包含了三个操作数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。(他的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。)
CAS 并发原语体现在 Java 语言中的 sum.misc.Unsafe 类中的各个方法。调用 Unsafe 类中的 CAS 方法, JVM 会帮助我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于 CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,CAS 是一条 CPU 的原子指令,不会造成数据不一致问题。

CAS有什么缺陷?

在这里插入图片描述

1. ABA 问题

并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况,那CAS操作就会误认为它从来没有被修改过。此时A已经非彼A,数据即使成功修改,也可能有问题。针对这种情况,java并发包中提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。

可以通过AtomicStampedReference解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。

2. 循环时间长开销

自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。

很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~

3. 只能保证一个变量的原子操作。

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。

可以通过这两个方式解决这个问题:

  • 使用互斥锁来保证原子性;
  • 将多个变量封装成对象,通过AtomicReference来保证原子性。

synchronized 和 volatile 的区别是什么?

volatile 解决的是内存可见性问题,会使得所有对 volatile 变量的读写都直接写入主存,即保证了变量的可见性。

synchronized 解决的是执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被 synchronized 关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized 还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得这个锁的线程的所有操作都 happens-before 于随后获得这个锁的线程的操作。
两者的区别主要有如下:

  1. volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile 仅能使用在变量级别synchronized 则可以使用在 变量. 方法. 和类级别的
  3. volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以保证变量的修改可见性和原子性
  4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
  5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

synchronized 和 ReentrantLock 区别是什么?

  1. 两者都是可重入锁
    可重入锁: 重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
  2. synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
    ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
  3. ReentrantLock 比 synchronized 增加了一些更高级功能
    相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
    1.等待可中断。 通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    2.ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁。 所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
    3.ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现”选择性通知”

说一下 synchronized 底层实现原理?

synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令。 其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。

其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。


今天的常见面试题总结(Java并发篇)就这些了,如果能帮助到你,还望三连支持一下,后续会亿点点更新😁

在这里插入图片描述

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值