Java 并发面试系列-01

2022年7月17日
大约 18 分钟

Java 并发面试系列-01

1. 什么是并发?

并发是针对服务器而言,是否并发的关键是看用户操作是否对服务器产生了影响。并发是指在同一时刻与服务器进行了交互的在线用户数量。这些用户的最大特征是和服务器产生了交互,这种交互既可以是单向的传输数据,也可以是双向的传送数据。

2. 什么是进程?

进程是指运行中的应用程序,每个进程都有自己独立的地址空间(内存空间)。

比如用户点击桌面的IE浏览器,就启动了一个进程,操作系统就会为该进程分配独立的地址空间。当用户再次点击左边的IE浏览器,又启动了一个进程,操作系统将为新的进程分配新的独立的地址空间。目前操作系统都支持多进程。

3. 什么是线程?

进程是表示自愿分配的基本单位。而线程则是进程中执行运算的最小单位,即执行处理机调度的基本单位。

通俗来讲:一个程序有一个进程,而一个进程可以有多个线程。

4. 并发和并行有什么区别?

并行(parallellism)是指两个或者多个事件在同一时刻发生,而并发(parallellism)是指两个或多个事件在同一时间间隔发生。

并行是在不同实体上的多个事件,而并发是在同一实体上的多个事件。

并发是在一台处理器上同时处理多个任务(Hadoop分布式集群),而并行在多台处理器上同时处理多个任务。

补充:并发和并行的区别就是一个处理器同时处理多个任务和多个处理器或者是多核的处理器同时处理多个不同的任务。

5. 进程与线程之间有什么区别?

进程是系统中正在运行的一个程序,程序一旦运行就是进程。

进程可以看成程序执行的一个实例。进程是系统资源分配的独立实体,每个进程都拥有独立的地址空间。一个进程无法访问另一个进程的变量和数据结构,如果想让一个进程访问另一个进程的资源,需要使用进程间通信,比如管道,文件,套接字等。

一个进程可以拥有多个线程,每个线程使用其所属进程的栈空间。线程与进程的一个主要区别是,统一进程内的一个主要区别是,同一进程内的多个线程会共享部分状态,多个线程可以读写同一块内存(一个进程无法直接访问另一进程的内存)。同时,每个线程还拥有自己的寄存器和栈,其他线程可以读写这些栈内存。

线程是进程的一个实体,是进程的一条执行路径。

线程是进程的一个特定执行路径。当一个线程修改了进程的资源,它的兄弟线程可以立即看到这种变化。

6. 线程的生命周期包括哪几个阶段?

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)五种状态。当线程启动以后,它不能一直占用着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。

线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、死亡。

新建(new Thread)

当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。

就绪(runnable)

线程已经被启动,正在等待被分配给CPU时间片,也就是说此时线程正在就绪队列中排队等候得到CPU资源

运行(running)

线程获得CPU资源正在执行任务(run()方法),此时除非此线程自动放弃CPU资源或者有优先级更高的线程进入,线程将一直运行到结束。

堵塞(blocked)

由于某种原因导致正在运行的线程让出CPU并暂停自己的执行,即进入堵塞状态。

正在睡眠:用sleep(long t) 方法可使线程进入睡眠方式。一个睡眠着的线程在指定的时间过去可进入就绪状态。

正在等待:调用wait()方法。(调用motify()方法回到就绪状态)

被另一个线程所阻塞:调用suspend()方法。(调用resume()方法恢复)

死亡(dead)

当线程执行完毕或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行。

自然终止:正常运行run()方法后终止

异常终止:调用stop()方法让一个线程终止运行

7. 什么是乐观锁,什么是悲观锁?

乐观锁

乐观锁的意思是乐观思想,即认为读多写少,遇到并发写的可能性低,每次获取数据时都认为不会被修改,因此不会上锁,但是在更新操作时会判断有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读比较写的操作。

Java中的乐观锁基本上都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,反之失败。

悲观锁

悲观锁的意思是悲观思想,即认为写多,遇到并发写的可能性高,每次获取数据时都认为会被修改,因此每次在读写数据时都会上锁,在读写数据时就会block直到拿到锁。

Java中的悲观锁就是Synchronized、AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

8. 什么是多线程?

多线程是指从软件或者硬件上实现多个线程并发执行的技术,包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。

具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能。

9. notify() 和 notifyAll() 方法有什么区别?

Java中提供了notify()和notifyAll()两个方法来唤醒在某些条件下等待的线程。

当调用notify()方法时,只有一个等待线程会被唤醒而且它不能保证哪个线程会被唤醒,这取决于线程调度器。

当调用notifyAll()方法时,等待该锁的所有线程都会被唤醒,但是在执行剩余的代码之前,所有被唤醒的线程都将争夺锁。

如果线程调用了对象的wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

当有线程调用了对象的notifyAll()方法(唤醒所有 wait 线程)或notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。

也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争优先级高的线程竞争到对象锁的概率大,若某线程没有竞争到该对象锁,它将会留在锁池中,唯有线程再次调用wait()方法,才会重新回到等待池中。

而竞争到对象锁的线程则继续往下执行,直到执行完了synchronized代码块,释放掉该对象锁,此时锁池中的线程会继续竞争该对象锁。

因此,notify()和notifyAll()之间的关键区别在于notify()只会唤醒一个线程,而notifyAll()方法将唤醒所有线程。

10. 什么是线程局部变量?

ThreadLocal并非是一个线程本地实现版本,它并不是一个Thread,而是thread local variable(线程局部变量)。也许把它命名为ThreadLocalVar更合适。

线程局部变量(ThreadLocal)功能非常简单,就是为每一个使用该变量的线程都提供了一个变量值副本,是Java中一种较为特殊的线程绑定机制,使得每一个线程都独立地改变所具有的副本,而不会和其他线程的副本冲突。

11. 常用的并发工具类有哪些?

CountDownLatch闭锁

CountDownLatch是一个同步计数器,初始化时传入需要计数线程的等待数,可能是等于或大于等待执行完的线程数。调用多个线程之间的同步或说起到线程之间的通信(不是互斥)一组线程等待其他线程完成工作后在执行,相当于加强的join。

CyclicBarrier栅栏

CyclicBarrier字面意思是栅栏,是多线程中重要的类,主要用于线程之间互相等待的问题,初始化时传入需要等待的线程数。

作用:让一组线程达到某个屏障被阻塞直到一组内最后一个线程达到屏蔽时,屏蔽开放,所有被阻塞的线程才会继续运行。

Semophore信号量

semaphore称为信号量是操作系统的一个概念,在Java并发编程中,信号量控制的是线程并发的数量。

作用:semaphore管理一系列许可每个acquire()方法阻塞,直到有一个许可证可以获得,然后拿走许可证,每个release方法增加一个许可证,这可能会释放一个阻塞的acquire()方法,然而并没有实际的许可保证这个对象,semaphore只是维持了一个可获取许可的数量,主要控制同时访问某个特定资源的线程数量,多用在流量控制。

Exchanger交换器

Exchange类似于交换器可以在队中元素进行配对和交换线程的同步点,用于两个线程之间的交换。

具体来说,Exchanger类允许两个线程之间定义同步点,当两个线程达到同步点时,它们交换数据结构,因此第一个线程的数据结构进入到第二个线程当中,第二个线程的数据结构进入到第一个线程当中。

12. Java 中常见的阻塞队列有哪些?

ArrayBlockingQueue

最典型的有界队列,其内部是用数组存储元素的,利用ReentrantLock实现线程安全,使用Condition来阻塞和唤醒线程。

LinkedBlockingQueue

内部用链表实现BlockingQueue。如果不指定它的初始容量,那么它容量默认就为整型的最大值Integer.MAX_VALUE,由于这个数非常大,通常不可能放入这么多的数据,所以LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限。

SynchronousQueue

相比较其他,最大的不同之处在于它的容量为0,所以没有地方暂存元素,导致每次存储或获取数据都要先阻塞,直至有数据或有消费者获取数据。

PriorityBlockingQueue

支持优先级的无界阻塞队列,可以通过自定义类实现compareTo()方法来指定元素排序规则或初始化时通过构造器参数Comparator来指定排序规则。需主要的是插入队列的对象必须是可以比较大小的值,否则会抛出ClassCastException异常。

DelayQueue

具有“延迟”的功能。队列中的可以设定任务延迟多久之后执行,比如“30分钟后未付款自动取消订单”等需要执行的场景。

13. 创建线程池的有几种方式?

newCachedThreadPool

创建一个可缓存的线程池,如果线程池长度超过处理需求,可灵活回收空闲线程,如果没有可回收线程,则新建线程。

newFixedThreadPool

创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

newScheduledThreadPool

创建一个定长线程池,支持定时及周期性任务执行。

newSingleThreadExecutor

创建一个单线程化的线程池,它只会唯一的工作线程来执行任务,保证所有任务按照指定执行。

14. 什么是线程死锁?

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

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

互斥条件:该资源任意一个时刻只由一个线程占用;

请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源持有不释放;

不剥夺条件:线程已获得的资源,在末使用完之前不能被其他线程强行剥夺,只有使用完毕后才释放资源;

循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

15. 如何避免线程死锁?

死锁有四个必要条件:互斥条件,请求和保持条件,不剥夺条件,循环等待条件。

只需要破坏产生死锁的四个条件中任意一个就可以避免线程死锁,但是互斥条件是没有办法破坏的,因为锁的意义就是想让线程之间存在资源互斥访问。

1)破坏请求与保持条件,一次性申请所有的资源;

2)破坏不剥夺条件,占用部分资源的线程进一步申请其他资源时如果申请不到,使其主动释放占有的资源;

3)破坏循环等待条件,按序申请资源来预防线程死锁,按某一顺序申请资源,释放资源则反序释放。

16. Java 中线程阻塞都有哪些原因?

阻塞指的是暂停一个线程的执行以等待某个条件发生(如某资源就绪),Java提供了大量方法来支持阻塞。

方法名方法说明
sleep()sleep()方法允许指定以毫秒为单位的一段时间作为参数,它使得线程在指定的时间内进入阻塞状态,不能得到CPU时间,指定的时间一过,线程重新进入可执行状态。典型地,sleep()被用在等待某个资源就绪的情形:测试发现条件不满足后,让线程阻塞一段时间后重新测试,直到条件满足为止
suspend()和resume()两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume()被调用,才能使得线程重新进入可执行状态。典型地,suspend()和resume()被用在等待另一个线程产生的结果的情形:测试发现结果还没有产生后,让线程阻塞,另一个线程产生了结果后,调用resume()使其恢复。
yield()yield()使当前线程放弃当前已经分得的CPU时间,但不使当前线程阻塞,即线程仍处于可执行状态,随时可能再次分得CPU时间。调用yield()的效果等价于调度程序认为该线程已执行了足够的时间从而转到另一个线程
wait()和notify()两个方法配套使用,wait()使得线程进入阻塞状态,它有两种形式,一种允许指定以毫秒为单位的一段时间作为参数,另一种没有参数,前者当对应的notify()被调用或者超出指定时间时线程重新进入可执行状态,后者则必须对应的notify()被调用。

17. Callable 和 Runnable 有什么区别?

Callable接口比Runnable接口要新一点,它是在JDK1.5版本时发行的。

Callable接口和Runnable接口都是设计来代表一个任务(task),这个任务可以被任意线程执行, 但两者间还是有一些明显的差异。最主要的差异体现在在Callable接口可以在内部的call()方法返回执行的结果,而Runnable接口则不行。

通俗易懂的解释就是Callable接口和Runnable接口都能用来编写多线程,但实现Callable接口的任务线程能返回执行结果,而实现Runnable接口的任务线程不能返回结果。另一个明显的差别是Callable接口可以抛出checked exception,因为它的call()方法抛出了这个异常。

Callable接口通常需要和Future/FutureTask结合使用,用于获取异步任务中的结果。

总结

Callable接口比Runnable接口要新一些,前者从JDK1.5版本开始,而后者从JDK1.0版本开始。

Runnable接口使用run()方法来描述一个任务(task),而Callable接口使用call()方法。run()方法不会返回结果, 因为它的返回类型是void,而Callable是个支持泛型的接口,当要实现(implement)一个Callable接口时就会提供一个返回值类型。run()方法不会抛出checked exception异常, 而call()方法可以。

18. 什么是线程安全?

如果代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的且其他变量的值也和预期的是一样的,就是线程安全的。

或者也可以理解成一个类或程序所提供的接口对于线程来说是原子操作,多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说不用考虑同步的问题,那就是线程安全的。

19. Thread 类中 start() 和 run() 方法有什么区别?

Thread类中通过start()方法来启动一个线程,此时线程处于就绪状态,可以被JVM来调度执行,在调度过程中,JVM通过调用Thread类的run()方法来完成实际的业务逻辑,当run()方法结束后,此线程就会终止,所以通过start()方法可以达到多线程的目的。

如果直接调用线程类的run()方法,会被当做一个普通的函数调用,程序中仍然只有主线程这一个线程,即start()方法呢能够异步的调用run()方法,但是直接调用run()方法确实同步的,无法达到多线程的目的。

20. Java 中 ++ 操作符是线程安全的吗?

1)如果是方法内定义的局部变量,因为每个方法栈是线程私有的,所以一定是线程安全的。

2)如果是类的成员变量,++i就是非线程安全的,这是因为++i相当于i=i+1。

实现线程安全可以使用synchronize关键字修饰提供同步或使用AtomicInteger原子操作类,因++i同步体比较小,可以使用自旋CAS的AtomicInteger类实现线程安全。

注意:因为volatile只能保证可见性,不能保证原子性,所以volatile不能解决这个线程安全存在的问题。

AtomicInteger保证线程安全

在JDK1.5版本之后,Java程序才可以使用CAS操作,该操作由sun.misc.Unsafe类中compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,虚拟机编译出来的结果就是一条平台相关的处理器CAS指令。

Unsafe类中getUnsafe()方法中限制了只有启动类加载器Bootstrap ClassLoader加载的Class才能访问它,因此Unsafe类不提供给用户程序调用,如果不使用反射机制的话只能通过其他的Java API来使用它,比如JUC包中AtomicInteger类,其中incrementAndGet()等方法都使用了Unsafe类的CAS操作。

JDK1.8源码如下:

public final int incrementAndGet() {
	return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object o, long offset, int delta) {
	int v;
	do {
		v = getIntVolatile(o, offset);
	} while (!compareAndSwapInt(o, offset, v, v + delta));
	return v;
}

通过源码可以看出AtomicInteger类是通过自旋CAS实现了线程安全的数量变化。