Java并发编程高频面试题整理
什么是进程?什么是线程?进程与线程的区别?
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是动态的。
线程是处理器任务调度和执行的基本单位。一个进程可以包含多个线程。线程共享进程的堆和方法区。
进程与线程的运行时状态不同,进程的状态包括:创建状态、就绪状态、运行状态、阻塞状态、结束状态。线程的状态包括:创建状态、运行状态、阻塞状态、等待状态、超时等待状态、终止状态。
进程与线程的通信方式不同。进程间通信的方式有:匿名管道、有名管道、信号、信号量、套接字、消息队列、共享内存等。线程间通信方式有:锁(如synchronized)、信号量、事件(如wait、notify)
- 匿名管道。如
ps aux | grep mysql
,数据传输是单向的。由于匿名管道不存在于文件系统中,只能用于父子进程。 - 有名管道。先创建
mkfifo demoPipe
,传数据给管道echo 'hello' > demoPipe
,取数据cat < demoPipe
,在取出数据之前,管道无法再次写入。管道这种通信方式效率低,不适合进程间频繁地交换数据。 - 消息队列。消息队列是保存在内核中的消息链表。类型进程之间发邮件,对大小有限制且处理不及时。消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
- 共享内存。共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。
- 信号量。在使用共享内存的时候,如果两个进程同时写入同一个地址,会造成数据冲突。为了防止数据冲突,采用信号量。信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
- 信号。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。如
kill -9
。 - 套接字socket。套接字一般用于不同主机之间的进程通信。
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。
Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「 | 」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。 |
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢?
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:
互斥的方式,可保证任意时刻只有一个线程访问共享资源; 同步的方式,可保证线程 A 应在线程 B 之前执行;
线程死锁必要条件?如何避免死锁?
死锁:两个或两个以上线程相互竞争对方资源,而同时不释放自己的资源,导致所有线程同时被阻塞。
死锁必要条件:
- 互斥条件:一个资源在同一时刻只由一个线程占用。
- 请求与保持条件:一个线程在请求被占用资源时发生阻塞,并对已获得的资源保持不放。
- 循环等待条件:发生死锁时,线程之间形成的头尾相接的循环等待资源关系。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程剥夺,只能由自己释放资源。
只要破坏死锁的4个必要条件中的任意一个,死锁即不会产生。
- 破坏请求与保持条件:一次性申请所有资源
- 破坏循环等待条件:按顺序申请资源
- 破坏不剥夺条件:线程在申请不到所需资源时,主动放弃所持有的资源。
创建线程的方法有哪些?
- 继承Thread类创建线程
public class Main extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
class Test {
public static void main(String[] args) {
Main target = new Main();
target.start();
}
}
- 实现Runnable接口创建线程
public class Main implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
class Test {
public static void main(String[] args) {
Main target = new Main();
Thread thread = new Thread(target);
thread.start();
}
}
- 使用Callable和Future创建线程
public class Main implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println(Thread.currentThread().getName());
return 0;
}
}
class Test {
public static void main(String[] args) {
FutureTask futureTask = new FutureTask<>(new Main());
Thread thread = new Thread(futureTask);
thread.start();
}
}
- 使用线程池
public class Main {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("1");
}
});
}
}
runnable 和 callable 有什么区别?
相同点:
- 两者都是接口
- 都需要调用Thread.start启动
区别:
- callable的核心call方法,允许返回值,runnable的核心run方法,没有返回值
- call方法可以抛出异常,run方法不行
线程的start和run有什么区别?为什么不直接执行run方法?
线程是通过start启动的,即通过调用start方法,线程进入就绪状态,当分配到时间片后就可以开始运行了。start会执行线程的相应准备工作,然后自动执行run方法。直接执行run方法,会把run方法当做main线程下的普通方法去执行,并不会在某个线程中执行。
sleep和wait区别和共同点
最主要的区别是sleep没有释放锁,wait释放锁。
两者都可以暂停线程执行。
wait通常用于线程间交互,sleep用于暂停。
wait的线程如果没有设置时间,则不会自动苏醒,需要别的线程调用同一个对象上的notify或notifyall唤醒。sleep则会自动苏醒。
线程安全问题
原子性:一个或多个操作在CPU执行的过程中不被中断。线程切换带来的原子性问题。可用原子类、synchronized等锁解决。
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。synchronized、volatile等解决。
有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。happens-before规则解决。
Synchronized关键字
多线程环境下,多个线程访问共享资源会出现死锁,synchronized关键字则是用来保证线程同步的。
Java的内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这就会导致内存不可见。假设有两个线程A和B,一个共享变量X,首先A获取共享变量X,修改其值为1,然后写入到主内存中,此时A的本地内存中X的值为1,主内存中X的值也为1,然后线程B修改共享变量X的值为2,此时A的本地内存中的X值仍然为1,出现了内存不可见问题。
这个问题可以用synchronized或volatile解决。synchronized的原理是在synchronized块内使用的变量从线程本地内存中擦除,这样线程就不能从本地内存中获取了,需要从主内存中获取,解决了内存不可见问题。
synchronized三大特性
原子性、可见性、有序性
synchronized关键字可以实现什么类型的锁
- 悲观锁:每次访问共享资源都会上锁
- 非公平锁:线程获取锁的顺序不一定是按照线程阻塞的顺序
- 可重入锁:获取锁的线程可以再次获取锁
- 排他锁:该锁只能被一个线程所持有,其他线程均被阻塞。
底层原理
Java虚拟机是通过进入和退出monitor对象来实现代码同步和方法同步的,代码块同步使用的是monitorenter和monitorexit指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法
优化
JDK1.6对synchronized做了优化,因为monitor是依靠底层操作系统的mutex lock来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。所以JDK1.6对synchronized做了优化。
JDK1.6引入了偏向锁、轻量级锁,锁的状态变成了四种,无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态会随着竞争激烈逐渐升级。
volatile关键字
volatile是轻量级的synchronized,一般作用于变量,保证内存可见性。volatile具有有序性和可见性。
编译器在运行过程中会进行指令重排,提高性能,在单线程下,指令重排并不会影响程序运行结果,但在多线程场景下就会有问题。
happens-before规则
一个操作发生在另外一个操作之前,表示第一个操作的结果对第二个操作可见,并且第一个操作的执行顺序也在第二个操作之前。但这并不意味着Java虚拟机必须按照这个顺序来执行程序。如果重排序后的执行结果与按happens-before关系执行的结果一致,Java虚拟机也会允许重排发生。
volatile实现内存可见性原理
volatile读写实现了缓存一致性。volatile修饰的变量,指令会比普通变量多一个lock,主要作用是将当前处理器缓存的数据刷新到主内存,刷新主内存时会使得其他处理器缓存的该内存地址的数据无效。
volatile实现有序性原理
编译器在生成字节码时会通过插入内存屏障来禁止指令重排。
内存屏障:内存屏障是一种CPU指令,其作用是对指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。
ThreadLocal
ThreadLocal为每个线程创建了一个副本,实现了线程间数据隔离。
内存泄漏
ThreadLocal的key是弱引用,value是强引用,当key被清理掉时,value永远不会被回收,产生内存泄漏。
解决方式:每次调用set、get、remove等方法时就会清理掉key为null的记录,所以在使用完ThreadLocal之后手动调用remove就行。
CAS
CompareAndSwap。Java可以用过CAS操作保证原子性。CAS主要包含三个参数,要更新的变量,旧值,新值。首先比较要更新的变量和旧值是否相等,如果相等,用新值去更新,不相等说明发生了更新,不更新。
ABA问题
CAS如果要更新的变量和旧值一样,也不一定没有发生更新,可能先更新成了B,再更新为A(假设一开始的值是A),也即ABA问题。
解决方式非常简单,给每个变量加上一个版本号,这样可以使用版本号来判断有没有发生更新。乐观锁也是这个原理。