天天看点

Java面试总结——Java并发

1、进程与线程的区别:(重点掌握)

答:进程与线程之间的主要区别可以总结如下。

  • 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位
  • 线程是进程的一个实体,一个进程中一般拥有多个线程。线程之间共享地址空间和其它资源(所以通信和同步等操作,线程比进程更加容易)
  • 线程一般不拥有系统资源,但是也有一些必不可少的资源(使用ThreadLocal存储)
  • 线程上下文的切换比进程上下文切换要快很多。

知识点:

线程上下文切换比进程上下文切换快的原因,可以总结如下:

  • 进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置
  • 线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作

应用场景:

进程:需要安全稳定时用进程,需要速度时用进程,既要速度又要安全。

线程:I/O密集型,多核。

解析:

进程与线程的区别算是一个开场题目,旨在考察大家对进程与线程的理解,因为我们的多线程是指在一个进程中的多个线程。

前面我们说线程之间共享一个进程的资源和地址空间,那么线程可以拥有独属于自己的资源吗?

答:可以的,通过ThreadLocal可以存储线程的特有对象,也就是属于当前线程的资源。

进程之间常见的通信方式:

  • 通过使用套接字Socket来实现不同机器间的进程通信
  • 通过映射一段可以被多个进程访问的共享内存来进行通信
  • 通过写进程和读进程利用管道进行通信

2、多线程与单线程的关系:

多线程与单线程之间的关系可以概括如下。

  • 多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能
  • 在单核CPU中,将CPU分为很小的时间片,在每一时刻只能有一个线程在执行,是一种微观上轮流占用CPU的机制。由于CPU轮询的速度非常快,所以看起来像是“同时”在执行一样
  • 多线程会存在线程上下文切换,会导致程序执行速度变慢
  • 多线程不会提高程序的执行速度,反而会降低速度。但是对于用户来说,可以减少用户的等待响应时间,提高了资源的利用效率

解析:

搞清楚多线程和单线程之间的区别,有助于我们理解为什么要使用多线程并发编程。多线程并发利用了CPU轮询时间片的特点,在一个线程进入阻塞状态时,可以快速切换到其余线程执行其余操作,这有利于提高资源的利用率,最大限度的利用系统提供的处理能力,有效减少了用户的等待响应时间。

但是,多线程并发编程也会带来数据的安全问题,线程之间的竞争也会导致线程死锁和锁死等活性故障。线程之间的上下文切换也会带来额外的开销等问题。

3、线程的状态有哪些?

待补充

4、多线程编程中的常用函数的比较和特性总结如下。

sleep 和 wait 的区别:

  • sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
  • wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。

join 方法:当前线程调用,则其它线程全部停止,等待当前线程执行完毕,接着执行。

yield 方法:该方法使得线程放弃当前分得的 CPU 时间。但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。

解析:

这个题目主要是考察 sleep和wait方法所处的类是哪个,并且考察其在休眠的时候对于互斥锁的处理。

5、线程活性故障有哪些?

答:由于资源的稀缺性或者程序自身的问题导致线程一直处于非Runnable状态,并且其处理的任务一直无法完成的现象被称为是线程活性故障。常见的线程活性故障包括死锁,锁死,活锁与线程饥饿。

解析:

每一个线程都有其特定的任务处理逻辑。由于资源的稀缺性或者资源本身的一些特性,导致多个线程需要共享一些排他性资源,比如说处理器,数据库连接等。当出现资源争用的时候,部分线程会进入等待状态。接下来,让我们依次介绍各种形式的线程活性故障吧。

6、线程死锁:(重点掌握)

死锁是最常见的一种线程活性故障。死锁的起因是多个线程之间相互等待对方而被永远暂停(处于非Runnable)。死锁的产生必须满足如下四个必要条件:

  • 资源互斥:一个资源每次只能被一个线程使用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 非抢占条件:线程已经获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

那么,如何避免死锁的发生?

  • 粗锁法:使用一个粒度粗的锁来消除“请求与保持条件”,缺点是会明显降低程序的并发性能并且会导致资源的浪费。
  • 锁排序法:(必须回答出来的点)指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁?通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
  • 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁。ReentrantLock.tryLock(long,TimeUnit)允许我们为锁申请这个操作指定一个超时时间。在超时时间内,如果相应的锁申请成功,那么该方法返回 true; 如果在tryLock(long,TimeUnit)执行的那一刻相应的锁正被其他线程持有,那么该方法会使当前线程暂停,直到这个锁被申请成功(此时该方法返回 true) 或者等待时间超过指定的超时时间(此时该方法返回false)。因此,使用tryLock(long,TimeUnit)来申请锁可以避免一个线程无限制地等待另外一个线程持有的资源,从而最终能够消除死锁产生的必要条件中的“占用并等待资源” 。

死锁总结:

关于线程活性故障中最常见的死锁,我们必须熟悉其产生的4个必要条件,根据必要条件还应该掌握其避免死锁的方法,锁排序法请大家务必熟练掌握。

线程锁死:

线程锁死是另一种常见的线程活性故障,与线程死锁不可以混为一谈。线程锁死的定义如下:

线程锁死是指等待线程由于唤醒其所需的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态(线程并未终止)导致其任务 一直无法进展。

线程死锁和线程锁死的外部表现是一致的,即故障线程一直处于非运行状态使得其所执行的任务没有进展。但是锁死的产生条件和线程死锁不一样,即使产生死锁的4个必要条件都没有发生,线程锁死仍然可能已经发生。

线程锁死分为了如下两种:

  • 信号丢失锁死:

信号丢失锁死是因为没有对应的通知线程来将等待线程唤醒,导致等待线程一直处于等待状态。

典型例子是等待线程在执行Object.wait( )/Condition.await( )前没有对保护条件进行判断,而此时保护条件实际上可能已经成立,此后可能并无其他线程更新相应保护条件涉及的共享变量使其成立并通知等待线程,这就使得等待线程一直处于等待状态,从而使其任务一直无法进展。

  • 嵌套监视器锁死:

嵌套监视器锁死是由于嵌套锁导致等待线程永远无法被唤醒的一种故障。

比如一个线程,只释放了内层锁Y.wait(),但是没有释放外层锁X; 但是通知线程必须先获得外层锁X,才可以通过 Y.notifyAll()来唤醒等待线程,这就导致出现了嵌套等待现象。

活锁:

活锁是一种特殊的线程活性故障。当一个线程一直处于运行状态,但是其所执行的任务却没有任何进展称为活锁。比如,一个线程一直在申请其所需要的资源,但是却无法申请成功。

线程饥饿:

线程饥饿是指线程一直无法获得其所需的资源导致任务一直无法运行的情况。线程调度模式有公平调度和非公平调度两种模式。在线程的非公平调度模式下,就可能出现线程饥饿的情况。

线程活性故障总结:

  • 线程饥饿发生时,如果线程处于可运行状态,也就是其一直在申请资源,那么就会转变为活锁
  • 只要存在一个或多个线程因为获取不到其所需的资源而无法进展就是线程饥饿,所以线程死锁其实也算是线程饥饿

1、Java内存模型是怎么样保证原子性、可见性、有序性?

多线程环境下的线程安全主要体现在原子性,可见性与有序性方面。

Java面试总结——Java并发

原子性:

原子操作是指一系列的操作,要么全部发生,要么全部不发生,JMM 保证对除 long 和 double 外的基础数据类型的读写操作是原子性的。另外关键字 synchronized 也可以提供原子性保证。synchronized 的原子性是通过 Java 的两个高级的字节码指令 monitorenter 和 monitorexit 来保证的。

可见性

JMM 可见性的保证,一个是通过 synchronized,另外一个就是 volatile。volatile 强制变量的赋值会同步刷新回主内存,强制变量的读取会从主内存重新加载,保证不同的线程总是能够看到该变量的最新值。

有序性

有序性是指一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程来看是否有序的问题。对有序性的保证,主要通过 volatile 和一系列 happens-before 原则。volatile 的另一个作用就是阻止指令重排序,这样就可以保证变量读写的有序性。

happens-before 原则包括一系列规则,如:

  • 程序顺序原则,即一个线程内必须保证语义串行性;
  • 锁规则,即对同一个锁的解锁一定发生在再次加锁之前;
  • happens-before 原则的传递性、线程启动、中断、终止规则等。

知识点:

在单处理器中,为什么也会出现可见性的问题呢?

单处理器中,由于是多线程并发编程,所以会存在线程的上下文切换,线程会将对变量的更新当作上下文存储起来,导致其余线程无法看到该变量的更新。所以单处理器下的多线程并发编程也会出现可见性问题的。

可见性如何保证?

  • 当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前的处理器缓存中
  • 当前处理器对共享变量更新之后,需要冲刷处理器缓存,使得该更新可以被写入处理器缓存中

重排序:

为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能会对源代码顺序进行一定的调整,导致程序运行顺序与源代码顺序不一致。

重排序是对内存读写操作的一种优化,在单线程环境下不会导致程序的正确性问题,但是多线程环境下可能会影响程序的正确性。

重排序举例:

Instance instance = new Instance()都发生了啥?

具体步骤如下所示三步:

  • 在堆内存上分配对象的内存空间
  • 在堆内存上初始化对象
  • 设置instance指向刚分配的内存地址

第二步和第三步可能会发生重排序,导致引用型变量指向了一个不为null但是也不完整的对象。(在多线程下的单例模式中,我们必须通过volatile来禁止指令重排序)

2、谈谈你对synchronized关键字的理解。

Java面试总结——Java并发

补充:是一种非公平调度方式,如果新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续执行前释放相应的资源,从而不影响该被唤醒的线程申请资源。唤醒的资源消耗大。

知识点:

JVM对资源的调度分为公平调度和非公平调度方式。公平调度方式:按照申请的先后顺序授予资源的独占权。

3、谈谈你对volatile关键字的理解。

主要有两个方面:

第一个:操作变量,并不会拷贝副本。即对变量的赋值,会被强制刷新到主存中,对变量的读取会从主内存中重新加载。

第二个:可以阻止指令重排,避免读取到引用到未对象初始化的对象。

4、ReentrantLock和synchronized的区别

ReentrantLock是显示锁,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度。

synchronized 内部锁简单,但是不灵活。显示锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显示锁定义了一个tryLock()方法,尝试去获取锁,成功返回true,失败并不会导致其执行的线程被暂停而是直接返回false,即可以避免死锁。

1、Java中的线程池有了解吗?

java.util.concurrent.ThreadPoolExecutor类就是一个线程池。利用线程复用的思想,避免线程反复创建,而造成的资源消耗,同时也便于管理。客户端调用ThreadPoolExecutor.submit(Runnable task)提交任务。

线程池的参数:

Java面试总结——Java并发

JDK对各个字段的解释:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • keepAliveTime :线程空闲但是保持不被回收的时间
  • unit:时间单位
  • workQueue:存储线程的队列
  • threadFactory:创建线程的工厂
  • handler:拒绝策略
Java面试总结——Java并发

线程池的执行流程:核心 -> 阻塞 ->  最大 -> 拒绝

2、线程池有那些?(*)

Java面试总结——Java并发

3、ThreadLocal有了解吗?

使用ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。

ThreadLocal内部实现机制:

  • 每个线程内部都会维护一个类似HashMap的对象,称为ThreadLocalMap,里边会包含若干了Entry(K-V键值对),相应的线程被称为这些Entry的属主线程
  • Entry的Key是一个ThreadLocal实例,Value是一个线程特有对象。Entry的作用是为其属主线程建立起一个ThreadLocal实例与一个线程特有对象之间的对应关系
  • Entry对Key的引用是弱引用;Entry对Value的引用是强引用。

4、Atmoic有了解吗?

介绍Atomic之前先来看一个问题吧,i++操作是线程安全的吗?

i++操作并不是线程安全的,它是一个复合操作,包含三个步骤:

  • 拷贝i的值到临时变量
  • 临时变量++操作
  • 拷贝回原始变量i

这是一个复合操作,不能保证原子性,所以这不是线程安全的操作。那么如何实现原子自增等操作呢?

这里就用到了JDK在java.util.concurrent.atomic包下的AtomicInteger等原子类了。AtomicInteger类提供了getAndIncrement和incrementAndGet等原子性的自增自减等操作。Atomic等原子类内部使用了CAS来保证原子性。

5、CountDownLatch和CyclicBarrier有了解吗?(*)

两个关键字经常放在一起比较和考察,下边我们分别介绍。

CountDownLatch是一个倒计时协调器,它可以实现一个或者多个线程等待其余线程完成一组特定的操作之后,继续运行。

CountDownLatch的内部实现如下:

  • CountDownLatch内部维护一个计数器,CountDownLatch.countDown()每被执行一次都会使计数器值减少1。
  • 当计数器不为0时,CountDownLatch.await()方法的调用将会导致执行线程被暂停,这些线程就叫做该CountDownLatch上的等待线程。
  • CountDownLatch.countDown()相当于一个通知方法,当计数器值达到0时,唤醒所有等待线程。当然对应还有指定等待时间长度的CountDownLatch.await( long , TimeUnit)方法。

CyclicBarrier是一个栅栏,可以实现多个线程相互等待执行到指定的地点,这时候这些线程会再接着执行,在实际工作中可以用来模拟高并发请求测试。

可以认为是这样的,当我们爬山的时候,到了一个平坦处,前面队伍会稍作休息,等待后边队伍跟上来,当最后一个爬山伙伴也达到该休息地点时,所有人同时开始从该地点出发,继续爬山。

CyclicBarrier的内部实现如下:

  • 使用CyclicBarrier实现等待的线程被称为参与方(Party),参与方只需要执行CyclicBarrier.await()就可以实现等待,该栅栏维护了一个显示锁,可以识别出最后一个参与方,当最后一个参与方调用await()方法时,前面等待的参与方都会被唤醒,并且该最后一个参与方也不会被暂停。
  • CyclicBarrier内部维护了一个计数器变量count = 参与方的个数,调用await方法可以使得count -1。当判断到是最后一个参与方时,调用singalAll唤醒所有线程。

什么是happened-before原则?

JVM虚拟机对内部锁有哪些优化?

如何进行无锁化编程?

CAS以及如何解决ABA问题?

AQS(AbstractQueuedSynchronizer)的原理与实现。

Java面试总结——Java并发

继续阅读