多线程
1. 并行和并发有什么区别?
并发(concurrency) 和 **并行(parallellism)**是:
- 并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。
- 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
- 在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如hadoop分布式集群
所以并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。
2. 线程和进程的区别?
进程:是执行中一段程序,即一旦程序被载入到内存中并准备执行,它就是一个进程。进程是表示资源分配的的基本概念,又是调度运行的基本单位,是系统中的并发执行的单位。
线程:单个进程中执行中每个任务就是一个线程。线程是进程中执行运算的最小单位。
-
一个线程只能属于一个进程,但是一个进程可以拥有多个线程。多线程处理就是允许一个进程中在同一时刻执行多个任务。
-
线程是一种轻量级的进程,与进程相比,线程给操作系统带来侧创建、维护、和管理的负担要轻,意味着线程的代价或开销比较小。
-
线程没有地址空间,线程包含在进程的地址空间中。线程上下文只包含一个堆栈、一个寄存器、一个优先权,线程文本包含在他的进程的文本片段中,进程拥有的所有资源都属于线程。所有的线程共享进程的内存和资源。 同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,寄存器的内容,栈段又叫运行时段,用来存放所有局部变量和临时变量。
-
父和子进程使用进程间通信机制,同一进程的线程通过读取和写入数据到进程变量来通信。
-
进程内的任何线程都被看做是同位体,且处于相同的级别。不管是哪个线程创建了哪一个线程,进程内的任何线程都可以销毁、挂起、恢复和更改其它线程的优先权。线程也要对进程施加控制,进程中任何线程都可以通过销毁主线程来销毁进程,销毁主线程将导致该进程的销毁,对主线程的修改可能影响所有的线程。
-
子进程不对任何其他子进程施加控制,进程的线程可以对同一进程的其它线程施加控制。子进程不能对父进程施加控制,进程中所有线程都可以对主线程施加控制。
相同点:
进程和线程都有ID/寄存器组、状态和优先权、信息块,创建后都可更改自己的属性,都可与父进程共享资源、都不鞥直接访问其他无关进程或线程的资源。
3. 守护线程是什么?
Java线程分为用户线程和守护线程。守护线程是程序运行的时候在后台提供一种通用服务的线程。所有用户线程停止,进程会停掉所有守护线程,退出程序。Java中把线程设置为守护线程的方法:在start线程之前调用线程的setDaemon(true)方法。
注意:
- setDaemon(true) 必须在 start() 之前设置,否则会抛出IllegalThreadStateException异常,该线程仍默认为用户线程,继续执行
- 守护线程创建的线程也是守护线程
- 守护线程不应该访问、写入持久化资源,如文件、数据库,因为它会在任何时间被停止,导致资源未释放、数据写入中断等问题
4. 创建线程有哪几种方式?
Java创建线程主要有三种方式:
- 继承Thread类创建线程类
- 定义Thread类的子类,并重写该类的run方法,该run方法的方法就代表线程要完成的任务,因此把run()方法称为执行体
- 创建Thread子类的实例,即创建了线程对象
- 调用线程对象的start()方法来启动该线程
public class FirstThreadTest extends Thread {
int i = 0;
//重写run方法,run方法的方法体就是现场执行体
public void run() {
for (; i < 100; i++) {
System.out.println(getName() + " " + i);
}
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " : " + i);
if (i == 50) {
new FirstThreadTest().start();
new FirstThreadTest().start();
}
}
}
}
- 通过实现Runnable接口创建线程类
- 定义Runnable接口的实现类,并重写接口的run()方法,该run()方法同样也是该线程的线程执行体
- 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象
- 调用线程对象的start()方法来启动该线程
public class RunnableThreadTest implements Runnable{
private int i;
public void run()
{
for(i = 0;i <100;i++)
{
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
public static void main(String[] args)
{
for(int i = 0;i < 100;i++)
{
System.out.println(Thread.currentThread().getName()+" "+i);
if(i==20)
{
RunnableThreadTest rtt = new RunnableThreadTest();
new Thread(rtt,"新线程1").start();
new Thread(rtt,"新线程2").start();
}
}
}
}
- 通过Callable和Future创建线程
- 创建Callable接口的实现类,并实现call()方法,该方法作为线程的执行体,并且有返回值
- 创建Callable实现类的实例,使用FutureTask类包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值
- 使用FutureTask对象作为Thread对象的target创建并启动新线程
- 调用FutureTask对象get()方法来获得子线程执行结束后的返回值
public class CallableThreadTest implements Callable<Integer> {
public static void main(String[] args) {
CallableThreadTest ctt = new CallableThreadTest();
FutureTask<Integer> ft = new FutureTask<>(ctt);
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " 的循环变量i的值" + i);
if (i == 20) {
new Thread(ft, "有返回值的线程").start();
}
}
try {
System.out.println("子线程的返回值:" + ft.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
@Override
public Integer call() throws Exception {
int i = 0;
for (; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
return i;
}
}
5. 说一下runnable和callable有什么区别?
不同点:
- 两者最大的不同点是:实现Callable接口的任务线程能返回执行结果,而实现Runnable接口的任务线程不能返回结果
- Callable接口的call()方法允许抛出异常,而Runnable即可偶的run()方法的一场只能在内部消化,不能继续上抛
注意点
- Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程知道获取将来结果,当不调用此方法时,主线程不会阻塞
6. 线程有哪些状态?
线程状态有5中,新建、就绪、运行、阻塞、死亡,关系图如下:
线程start方法执行后,并不代表该线程运行了,而是进入就绪状态,意思是准备运行,但是真正何时运行,是由操作系统决定的,代码不能控制。
同样的,从运行状态的线程,有可能由于失去了CPU资源,回到就绪状态,也是由操作系统决定的,这一步中,我们也可以有程序主动失CPU资源,只需调用yield方法。
线程运行完毕,或者运行中发生异常,或者主动调用线程的stop方法,那么就进入死亡,死亡的线程不可逆转。
下面行为会引起线程阻塞
- 主动调用sleep方法,时间到了会进入到就绪状态
- 主动调用suspend方法,主动调用resume方法,会进入就绪状态
- 调用了阻塞式IO方法,调用完成后,会进入就绪就绪状态
- 试图获取锁,成功获取锁了之后,会进入就绪状态
- 线程在等待某个通知,其他线程发出通知后,会进入就绪状态
7. sleep()和wait()有什么区别?
sleep方法属于Thread类中方法,表示让一个线程进入睡眠状态,等待一段时间后,自动醒来进入就绪状态,一个线程对象调用sleep方法后,不会释放它所持有的所有对象锁,所以也就不会影响其他进程对象的运行。但在sleep的过程中有可能被其他对象调用它的interrupt(),产生InterruptedException异常,如果你的程序不捕获这个异常,线程就会异常终止,进入TEMRMINATED结束状态,如果你的程序捕获了这个异常,那么程序就会继续执行catch代码块以及后续代码。
wait属于Ojbect的成员方法,一旦一个对象调用了wait方法,必须要采用notify或者notifyAll方法唤醒该线程,如果线程拥有某些对象的同步锁,那么在调用wait后,这个线程会释放它所持有的所有同步资源,而不限制于被这个调用wait方法的对象,wait方法也同样会在wait过程中有可能被其他对象调用interrupt方法而产生InterruptedException,效果以及处理方式和sleep方法相同
区别
- 这两个方法来自不同的类分别是Thread和Object,sleep方法属于Thread的静态方法,wait属于Object的成员方法
- sleep方法没有释放锁,wait方法释放了锁,使得其他线程可以使用同步代码块或者方法
- wait、notify、notifyAll只能在同步方法或者同步代码块中使用,sleep可以在任何地方使用
8. notify()和notifyAll()有什么区别?
等待池
假设一个线程A调用了某个对象的
wait()方法,线程A就会释放该对象的锁,进入到了该对象的等待池,等待池中的线程不会去竞争该对象的锁。
锁池
只有获取了对象的锁,线程才能执行对象的
synchronized代码,对象的锁每次只有一个线程可以获得,其他线程只能在锁池中等待。
区别
notify()方法随机唤醒对象的等待池中的一个线程;notifyAll()唤醒对象的等待池中的所有线程,进入锁池。
9. 线程的run()和start()有什么区别?
- 启动一个线程需要调用Thread对象的start()方法
- 调用线程的start()方法后,线程处于可运行状态,此时它可以有JVM调度并执行,这并不意味线程会立刻被执行
- run()方法是线程运行时有JVM回调的方法,无需手动写代码调用
- 直接调用线程的run()方法,想到与在调用线程里继续调用这个方法,并为启动一个新的线程
10. 创建线程池有哪几种方式?
通常都是利用Executors提供的通用线程池创建方法去创建线程池。
Executors目前提供了5中不同的线程池创建配置:
newCachedThreadPool(): 它是用来处理大量短时间工作任务的线程池,具有几个鲜明的特点,它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程,如果线程闲置时间超过60秒,则被终止并移除缓存,长时间闲置时,这种线程池,不会消耗什么资源,其内部使用SynchronousQueue作为工作队列。newFixedThreadPool(int nThreads): 重用指定数目(nThreads)的线程,其背后使用的无界的工作队列,任何时候最多有nThreads个工作线程时活动的,这意味着,如果任务数量超过活动线程数目,将在工作队列中等待空闲线程出现,如果工作线程退出,将会有新的工作线程被创建,以补足指定数目nThreads。newSingleThreadExecutor(): 它的特点在于工作线程数目限制为1,操作一个无界的工作队列,所以它保证了所有任务都被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程实例,因此可以避免改变线程数目。newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize): 创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。newWorkStealingPool(int parallelism): 这是一个经常被人忽略的线程池,Java 8才引入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行处理任务,不保证处理顺序。
Executor框架的基本组成
各种类型的设计目的
Executor是一个基础的接口,其初衷是将任务提交和任务执行细节解耦,这一点可以从其定义唯一的方法看出来:
void execute(Runnable command);
2) ExecutorService则更加完善,不仅提供了service管理,比如shutdown方法,也提供了更加全面的提交任务机制,如返回Future而不是void的submit方法
<T> Future<T> submit(Callable<T> task);
-
Java标准库提供了几种基本的实现,比如
ThreadPoolExecutor、ScheduledThreadPoolExecutor、ForkJoinPool。这些线程池的设计在于高度的可调节行和灵活性,以尽量满足复杂多变的实际应用场景。 -
Executors则从简化使用的角度,为我们提供了各种方便的静态工厂方法。
阿里发布的Java开发手册中强制线程池不允许使用
Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池运行规则,规避资源耗尽的风险。
分析线程池的设计与实现,主要围绕ThreadPoolExecutor进行,ScheduledThreadPoolExecutor是ThreadPoolExecutor的扩展,主要增加了调度逻辑;而ForkJoinPool则是为了ForkJoinTask定制的线程池。
简单理解一下:
- 工作队列负责存储用户提交的各个任务,这个工作队列,也可以为容量为0的
SynchronousQueue(使用newCachedThreadPool),也可以是像固定大小线程池(newFixedThreadPool)那样使用LinkedBlockingQueue。
private final BlockingQueue<Runnable> workQueue;
- 内部的线程池,是指保持工作线程的集合,线程池需要运行过程中管理线程创建、销毁。比如,带有缓存的线程池,当任务压力比较大时,线程池会创建新的工作线程;当业务压力退去,线程池会闲置一段时间(默认60秒)后结束线程。
//线程池的工作线程被抽象为静态内部类Worker, 基于AQS实现
private final HashSet<Worker> workers = new HashSet<Worker>();
ThreadFactory提供上面所需要的创建线程逻辑- 如果任务提交时被拒绝,比如线程池已处于SHUTDOWN状态,需要为其提供处理逻辑,Java标准库提供了类似
ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需求自定义。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}
corePoolSize: 核心线程数,可以大致理解为长期驻留的线程数maximumPoolSize: 线程不够时能创建的最大的线程数keepAliveTime和TimeUnit: 指定额外线程能够闲置多久workQueue: 工作队列,必须是BlockingQueue
11. 线程池都有哪些状态?
线程池的5种状态:Running、ShutDown、Stop、Tidying、Terminated。
线程池各个状态切换框架图:
- RUNNING
- 状态说明:线程池处在RUNNING状态时,能够接收新任务,以及对已添加的任务进行处理。
- 状态切换:线程池的初始化状态是RUNNING。换句话说,线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0!
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
- SHUTDOWN
- 状态说明:线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
- 状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。
- STOP
- 状态说明:线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
- 状态切换:调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP。
- TIDYING
- 状态说明:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态 时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为 TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
- 状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。
- TERMINATED
- 状态说明:线程池彻底终止,就变成TERMINATED状态。
- 状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
12. 线程池中submit()和execute()方法有什么区别?
| 参数 | 返回值 | 异常处理 | |
|---|---|---|---|
| execute | Runnable | 无 | 不能捕获异常,只能内部消化 |
| submit | Runnable或(Runnable, T) 或 Callable | 有 | 在使用Future调用get()方法时,可以捕获处理异常 |
13. 在Java程序中怎么保证多线程的运行安全?
- 使用
volatile关键字,防止指令重排,被volatile修饰的变量的值,将不会被本地缓存,所有对该变量的读写都是直接操作共享内存,从而确保多个线程能正确的处理该变量 - 使用
synchronized关键字,可以用于代码块、方法(静态方法,同步锁为当前字节码对象,实例方法,同步锁为实例对象) lock锁机制- 使用线程安全的类,比如
Vector、HashTable,StringBuffer
拓展
线程安全性问题体现在:
- 原子性:一个或者多个操作在CPU执行过程中不被终端的特性,线程切换带来的原子性问题。
- 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,缓存导致的可见性问题。
- 有序性:程序执行的顺序按照代码的先后顺序执行,编译优化带来的有序问题。
解决办法:
- 原子性问题:JDK Atomic开头的原子类、synchronized、lock
- 可见性问题:synchronized、volatile、lock
- 有序性问题:Happens-Before规则
Happens-Before规则如下:
- 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作先行发生于书写在后面的操作
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
14. 多线程锁的升级原理是什么?
什么是锁升级(锁膨胀)?JVM优化synchronized的运行机制,当JVM检测到不同的竞争状态,就会根据需要自动切换合适的锁,这种切换就是锁的升级。升级时不可逆的,也就是说只能从低到高,也就是偏向锁-->轻量级-->重量级。 锁的级别:无锁-->偏向锁-->轻量级锁-->重量级锁
- 无锁:没有对资源进行锁定,所有线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试知道修改成功
- 偏向锁:对象的代码一直被同一个线程执行,不存在多个线程竞争,该线程在后续的执行中自动获取锁,降低获取锁带来的性能开销,偏向锁,指的就是偏向第一个加锁线程,该线程不会主动释放偏向锁,只有当其他线程尝试竞争偏向锁才会被释放,偏向锁的撤销,需要在某个时间点上没有字节码正在执行,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态,如果线程不处于活动状态,则对象头设置成无锁状态,并撤销偏向锁,如果线程处于活跃状态,升级为轻量锁
- 轻量级锁:轻量锁时指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能,当前只有一个等待线程,则该线程将通过自旋进行等待,但是当自旋超过一定次数时,轻量级锁便会升级为重量级锁,当一个线程已持有锁,另外一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁
- 重量级锁:指当有一个线程获取锁之后,其余所有等待获取该锁的线程都处于阻塞状态。重量级锁通过对象内部的监视器(monitor)实现,而其中monitor的本质时依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高
锁状态对比:
15. 什么是死锁?怎么防止死锁?
什么是死锁?
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。是操作系统层面的一个错误,是进程死锁的简称。
死锁产生四个必要条件
- 互斥使用,即当资源被一个线程使用时,其他的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由占有者主动释放
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
- 循环等待,即存在一个等待队列,P1占有P2的资源,P2占有P3的资源,P3占有P1的资源,这样就存在一个等待环路
产生的原因
-
竞争资源引起进程死锁,当系统中供多个进程共享的资源,如打印机、公用队列等,其数目不足以满足这些进程的需要时,会引起这些进程对资源的竞争而产生死锁
-
可剥夺资源和不可剥夺资源,系统中的资源可以分为两类,一类是可剥夺资源,是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺。例如,优先权高的进程可以剥夺优先权低的进程的处理机。又如,内存区可由存储器管理程序,把一个进程从一个存储区移到另一个存储区,此即剥夺了该进程原来占有的存储区,甚至可将一进程从内存调到外存上,可见,CPU和主存均属于可剥夺性资源。另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
-
竞争不可剥夺资源,在系统中所配置的不可剥夺资源,由于它们的数量不能满足诸进程运行的需要,会使进程在运行过程中,因争夺这些资源而陷于僵局。例如,系统中只有一台打印机R1和一台磁带机R2,可供进程P1和P2共享。假定PI已占用了打印机R1,P2已占用了磁带机R2,若P2继续要求打印机R1,P2将阻塞;P1若又要求磁带机,P1也将阻塞。于是,在P1和P2之间就形成了僵局,两个进程都在等待对方释放自己所需要的资源,但是它们又都因不能继续获得自己所需要的资源而不能继续推进,从而也不能释放自己所占有的资源,以致进入死锁状态。
-
竞争临时资源,面所说的打印机资源属于可顺序重复使用型资源,称为永久资源。还有一种所谓的临时资源,这是指由一个进程产生,被另一个进程使用,短时间后便无用的资源,故也称为消耗性资源,如硬件中断、信号、消息、缓冲区内的消息等,它也可能引起死锁。例如,SI,S2,S3是临时性资源,进程P1产生消息S1,又要求从P3接收消息S3;进程P3产生消息S3,又要求从进程P2处接收消息S2;进程P2产生消息S2,又要求从P1处接收产生的消息S1。如果消息通信按如下顺序进行: P1: ···Relese(S1);Request(S3); ··· P2: ···Relese(S2);Request(S1); ··· P3: ···Relese(S3);Request(S2); ··· 并不可能发生死锁。但若改成下述的运行顺序: P1: ···Request(S3);Relese(S1);··· P2: ···Request(S1);Relese(S2); ··· P3: ···Request(S2);Relese(S3); ··· 则可能发生死锁。
-
进程推进顺序不当引起死锁 由于进程在运行中具有异步性特征,这可能使P1和P2两个进程按下述两种顺序向前推进。 1) 进程推进顺序合法 当进程P1和P2并发执行时,如果按照下述顺序推进:P1:Request(R1); P1:Request(R2); P1: Relese(R1);P1: Relese(R2); P2:Request(R2); P2:Request(R1); P2: Relese(R2);P2: Relese(R1);这两个进程便可顺利完成,这种不会引起进程死锁的推进顺序是合法的。 2) 进程推进顺序非法 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁。例如,当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁。
如何预防和解决死锁?
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
所以,在系统设计、进程调度等方面注意如何不让这四个必要条件成立,如何确定资源的合理分配算法,避免进程永久占据系统资源。此外,也要防止进程在处于等待状态的情况下占用资源,在系统运行过程中,对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,若分配后系统可能发生死锁,则不予分配,否则予以分配。因此,对资源的分配要给予合理的规划。
下面几种方法可用以避免重装死锁的发生:
-
允许目的节点将不完整的报文递交给目的端系统;
-
一个不能完整重装的报文能被检测出来,并要求发送该报文的源端系统重新传送;
-
为每个节点配备一个后备缓冲空间,用以暂存不完整的报文。
1)、2)两种方法不能很满意地解决重装死锁,因为它们使端系统中的协议复杂化了。一般的设计中,网络层应该对端系统透明,也即端系统不该考虑诸如报文拆、装之类的事。3)方法虽然不涉及端系统,但使每个节点增加了开销。
有序资源分配法
这种算法资源按某种规则系统中的所有资源统一编号(例如打印机为1、磁带机为2、磁盘为3、等等),申请时必须以上升的次序。
系统要求申请进程:
-
对它所必须使用的而且属于同一类的所有资源,必须一次申请完;
-
在申请不同类资源时,必须按各类设备的编号依次申请。例如:进程PA,使用资源的顺序是R1,R2; 进程PB,使用资源的顺序是R2,R1;若采用动态分配有可能形成环路条件,造成死锁。采用有序资源分配法:R1的编号为1,R2的编号为2;PA:申请次序应是:R1,R2 PB:申请次序应:R1,R2这样就破坏了环路条件,避免了死锁的发生
银行家算法
避免死锁算法中最有代表性的算法是Dijkstra E.W 于1968年提出的银行家算法: 银行家算法是避免死锁的一种重要方法,防止死锁的机构只能确保上述四个条件之一不出现,则系统就不会发生死锁。通过这个算法可以用来解决生活中的实际问题,如银行贷款等。程序实现思路银行家算法顾名思义是来源于银行的借贷业务,一定数量的本金要应多个客户的借贷周转,为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其是否能限期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源。如果资源分配不得到就会发生进程循环等待资源,则进程都无法继续执行下去的死锁现象。把一个进程需要和已占有资源的情况记录在进程控制中,假定进程控制块PCB其中“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的资源申请。“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的资源需求总量不能超过系统拥有的资源总数, 银行算法进行资源分配可以避免死锁。
解决方法
在系统中已经出现死锁后,应该及时检测到死锁的发生,并采取适当的措施来解除死锁。
- 死锁预防
这是一种较简单和直观的事先预防的方法。方法是通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或者几个,来预防发生死锁。预防死锁是一种较易实现的方法,已被广泛使用。但是由于所施加的限制条件往往太严格,可能会导致系统资源利用率和系统吞吐量降低。
- 死锁避免
系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源;如果分配后系统可能发生死锁,则不予分配,否则予以分配。这是一种保证系统不进入死锁状态的动态策略。
- 死锁检测和解除
先检测:这种方法并不须事先采取任何限制性措施,也不必检查系统是否已经进入不安全区,此方法允许系统在运行过程中发生死锁。但可通过系统所设置的检测机构,及时地检测出死锁的发生,并精确地确定与死锁有关的进程和资源。检测方法包括定时检测、效率低时检测、进程等待时检测等。 然后解除死锁:采取适当措施,从系统中将已发生的死锁清除掉。
这是与检测死锁相配套的一种措施。当检测到系统中已发生死锁时,须将进程从死锁状态中解脱出来。常用的实施方法是撤销或挂起一些进程,以便回收一些资源,再将这些资源分配给已处于阻塞状态的进程,使之转为就绪状态,以继续运行。死锁的检测和解除措施,有可能使系统获得较好的资源利用率和吞吐量,但在实现上难度也最大。
如果我们在死锁检查时发现了死锁情况,那么就要努力消除死锁,使系统从死锁状态中恢复过来。消除死锁的几种方式:
-
最简单、最常用的方法就是进行系统的重新启动,不过这种方法代价很大,它意味着在这之前所有的进程已经完成的计算工作都将付之东流,包括参与死锁的那些进程,以及未参与死锁的进程;
-
撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。这时又分两种情况:一次性撤消参与死锁的全部进程,剥夺全部资源;或者逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。一般来说,选择逐步撤消的进程时要按照一定的原则进行,目的是撤消那些代价最小的进程,比如按进程的优先级确定进程的代价;考虑进程运行时的代价和与此进程相关的外部作业的代价等因素;
-
进程回退策略,即让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行,以求再次执行时不再发生死锁。虽然这是个较理想的办法,但是操作起来系统开销极大,要有堆栈这样的机构记录进程的每一步变化,以便今后的回退,有时这是无法做到的。
16. ThreadLocal是什么?有哪些使用场景?
ThreadLocal是线程本地存储,在每个线程都创建一个ThreadLocalMap对象,每个线程都可以访问自己内部的ThreadLocalMap对象内的value,通过这种方式避免资源在多线程中共享。
如果使用ThreadLocal管理变量,则每个使用该变量的线程都会获得该变量的副本,副本之间相互独立,这样每个线程都可以随意更改自己的变量副本,而不会对其他线程产生影响。
/***
ThreadLocal(); //创建一个线程本地变量
get(); //返回此线程局部变量的当前线程副本中的值
initialValue(); //返回此线程局部变量的当前线程的"初始值"
set(T value): //将此线程局部变量的当前线程副本中的值设置为value
***/
class TestThreadLocal{
//线程本地存储变量
static int n = 0;
private static final ThreadLocal<Integer> THREAD_LOCAL_NUM = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return n;
}
};
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
//启动三个线程
Thread t = new Thread(){
@Override
public void run() {
add10byThreadLocal();
}
};
t.start();
}
Thread.sleep(500);
System.out.println(n);
}
/**
* 线程本地存储变量+5
*/
private static void add10byThreadLocal(){
for (int i = 0; i < 5; i++) {
Integer copy = THREAD_LOCAL_NUM.get();//返回此线程局部变量的当前线程副本中的值
copy += 1;
THREAD_LOCAL_NUM.set(copy);//将此线程局部变量的当前线程副本中的值设置为value
System.out.println(Thread.currentThread().getName() + ": ThreadLocal num : " + copy);
}
}
}
经典的使用场景是为每个线程分配一个JDBC连接Connection,这样就可以保证每个线程都在各自的Connection上进行数据库操作,不会出现A线程关了B线程正在使用的Connection,还有Session的管理等问题。
注意:ThreadLocal与同步机制 a. ThreadLocal与同步机制都是为了解决多线程中相同变量的访问冲突问题。 b. 前者采用空间换时间的方法,后者采用时间换空间的方法。
17. 说一下synchronized底层实现原理?
synchronized简介
Java中提供了两种实现同步的基础语义:synchronized方法和synchronized代码块:
public class SyncTest {
public void syncBlock(){
synchronized (this){
System.out.println("hello block");
}
}
public synchronized void syncMethod(){
System.out.println("hello method");
}
}
当上述代码被编译成class文件的时候,sychronized代码块和synchronized方法的字节码略有不同,我们可以使用javap -v命令查看class文件对应的JVM字节码信息:
{
public void syncBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // monitorenter指令进入同步块
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit // monitorexit指令退出同步块
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // monitorexit指令退出同步块
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED //添加了ACC_SYNCHRONIZED标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hello method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
从上面的注释中可以看到,对于synchronized关键字而言,javac在编译时,会生成对应的monitorenter和monitorexit指令分别对应synchronized同步代码块的进入和退出,有两个monitorexit指令的原因:为了保证抛异常的情况下也能释放锁,所以javac为同步代码添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁,而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,防线调用方法被ACC_SYNCHRONIZED修饰,则会尝试先获得锁。
锁的几种形式
传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖于futex,关于futex可以看我之前的文章,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。
在JDK 1.6之前,synchronized只有传统的锁机制,因此给开发者留下了synchronized关键字相比于其他同步机制性能不好的印象。
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
对象头
因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。
所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
类型指针是指向该对象所属类对象的指针,mark word用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word长度为32字节,64位系统上长度为64字节。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下
可以看到锁信息也是存在于对象的mark word中的。当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。
重量级锁
重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。
重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。
一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程。
如果一个线程在同步块中调用了Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中,然后释放锁。当wait的线程被notify之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。
18. synchronized和volatile的区别是什么?
作用:
synchronized表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。volatile表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。
区别:
synchronized可以作用于变量、方法、对象;volatile只能作用于变量。synchronized可以保证线程间的有序性(猜测是无法保证线程内的有序性,即线程内的代码可能被 CPU 指令重排序)、原子性和可见性;volatile只保证了可见性和有序性,无法保证原子性。synchronized线程阻塞,volatile 线程不阻塞。
19. synchronized和Lock有什么区别?
原始构成
- synchronized是关键字属于JVM层面,monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖monitor对象只有在同步代码块和同步方法中才能调用wait/notify等方法);
- Lock是具体的类,是api层面的锁。
使用方法
- synchronized不需要用户手动释放锁,synchronized代码执行完成以后系统会自动让线程释放对锁的占有
- ReentrantLock则需要用户手动去释放锁,若没有主动释放锁,就有可能导致死锁现象。需要使用lock()和unlock()方法配合try finally语句块来完成。
等待是否可以中断
- synchronized不可中断,除非抛出异常或者正常运行完成。
- ReetrantLock可中断,
- 设置超时方法tryLock(long timeout, TimeUnit unit);
- lockInterruptibly()放入代码块中,调用interrupt()方法可中断;
加锁是否公平
- synchronized是非公平锁
- ReentrantLock默认是非公平锁,可设置为公平锁。
锁绑定多个条件condition
- synchronized没有;
- ReentrantLock用来实现分组唤醒需要唤醒的线程们,可以精确唤醒,而不是像synchronized要么随机唤醒一个,要么唤醒全部线程。
20. synchronized和ReentrantLock区别是什么?
- synchronized 竞争锁时会一直等待;ReentrantLock 可以尝试获取锁,并得到获取结果
- synchronized 获取锁无法设置超时;ReentrantLock 可以设置获取锁的超时时间
- synchronized 无法实现公平锁;ReentrantLock 可以满足公平锁,即先等待先获取到锁
- synchronized 控制等待和唤醒需要结合加锁对象的 wait() 和 notify()、notifyAll();ReentrantLock 控制等待和唤醒需要结合 Condition 的 await() 和 signal()、signalAll() 方法
- synchronized 是 JVM 层面实现的;ReentrantLock 是 JDK 代码层面实现
- synchronized 在加锁代码块执行完或者出现异常,自动释放锁;ReentrantLock 不会自动释放锁,需要在 finally{} 代码块显示释放
注意:都可以做到同一线程,同一把锁,可重入代码块。
21. 说一下atomic的原理?
Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
Atomic系列的类中的核心方法都会调用unsafe类中的几个本地方法。我们需要先知道一个东西就是Unsafe类,全名为:sun.misc.Unsafe,这个类包含了大量的对C代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过unsafe分配内存的时候,如果自己指定某些区域可能会导致一些类似C++一样的指针越界到其他进程的问题。