关于死锁的那些事
死锁的定义
所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。
比如如下情形:
线程A当前持有互斥所锁lock1,线程B当前持有互斥锁lock2。接下来,当线程A仍然持有lock1时,它试图获取lock2,因为线程B正持有lock2,因此线程A会阻塞等待线程B对lock2的释放。如果此时线程B在持有lock2的时候,也在试图获取lock1,因为线程A正持有lock1,因此线程B会阻塞等待A对lock1的释放。二者都在等待对方所持有锁的释放,而二者却又都没释放自己所持有的锁,这时二者便会一直阻塞下去。这种情形称为死锁。
Java中常见的锁类型
常见的锁分类大致有:排它锁、共享锁、乐观锁、悲观锁、分段锁、自旋锁、公平锁、非公平锁、可重入锁等。
Java多线程加锁机制,有两种:
- Synchronized
是一种互斥锁 一次只能允许一个线程进入被锁住的代码块
当方法(代码块)执行完毕后会自动释放锁,不需要做任何的操作。
当一个线程执行的代码出现异常时,其所持有的锁会自动释放。
问题的提出:现在通过Runnable接口实现多线程,并产生3个线程对象,同时卖5张票
public static class MyThread implements Runnable{
private int ticket = 5; //一共五张票
public void run() {
for ( int i = 0; i < 100; i++) {
if ( ticket>0) {
try {
Thread. sleep(300);
} catch (Exception e) {
e.printStackTrace();
}
System. out.println( "卖票:ticket="+ticket --);
}
}
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
Thread t1 = new Thread(mt);
Thread t2 = new Thread(mt);
Thread t3 = new Thread(mt);
t1.start();
t2.start();
t3.start();
}
}
运行结果:
卖票:ticket=5
卖票:ticket=4
卖票:ticket=3
卖票:ticket=2
卖票:ticket=1
卖票:ticket=0
卖票:ticket=-1
可以发现。程序中加入了延时操作,所以在运行最后出现了负数的情况。 解决这样的问题就需要用到同步的操作。
同步
同步代码块:
synchronized (this) {}
同步方法:
synchronized 方法返回值 方法名称(参数列表){ }
2. 显式Lock
Lock方式来获取锁支持中断、超时不获取、是非阻塞的
提高了语义化,哪里加锁,哪里解锁都得写出来
Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁
支持Condition条件对象
允许多个读线程同时访问共享资源
死锁原理
根据操作系统中的定义:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
为了解决程序因占用资源,出现资源争抢,而出现的程序进入等待的状态(死锁)。
死锁的四个必要条件:
-
互斥条件(Mutual exclusion)
:资源不能被共享,只能由一个进程使用。 -
请求与保持条件(Hold and wait)
:已经得到资源的进程可以再次申请新的资源。 -
非剥夺条件(No pre-emption)
:已经分配的资源不能从相应的进程中被强制地剥夺。 -
循环等待条件(Circular wait)
:系统中若干进程组成环路,该环路中每个进程都在等待相邻进程正占用的资源。
死锁 举例:有A和B两个线程,有CD 两把锁, A和B嵌套CD锁,A线程中有C,D锁,B线程中有D C两把锁,当两个线程运行时,就可能会出现死锁导致程序停滞的情况。
class Test{
final Object objA = new Object();
final Object objB = new Object();
public void a(){
//注意这里 先A后B
synchronized(objA){
synchronized(objB){
//sth....
}
}
}
public void b(){
//注意这里 先B后A
synchronized(objB){
synchronized(objA){
//sth....
}
}
}
}
处理死锁的基本方法
-
预防死锁:通过设置一些限制条件,去破坏产生死锁的必要条件,比如尝试
在获取锁的时候加超时时间
- 避免死锁:在资源分配过程中,使用某种方法避免系统进入不安全的状态,从而避免发生死锁
- 检测死锁:允许死锁的发生,但是通过系统的检测之后,采取一些措施,将死锁清除掉(检测的方法可以通过检测有向图是否存在环来判断)
上图为资源分配图,其中方框表示资源,圆圈表示进程。资源指向进程表示该资源已经分配给该进程,进程指向资源表示进程请求获取该资源。图 a 可以抽取出环,如图 b,它满足了环路等待条件,因此会发生死锁。每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。
- 解除死锁:该方法与检测死锁配合使用(比如可以通过抢占资源,或者回滚恢复,或者杀死进程恢复)
加锁时限
这玩意就是在锁上加个超时,没啥技术含量,有点技术含量的东西是这玩意要自定义一个加锁机制
Lock lock = new ReentrantLock();
真正意义上来说,死锁是不能被解决的,死锁是多线程中的一个需要避免的重大的问题,当我们在编写程序时,可以给共享的资源加上另外一个把锁,控制资源的动态,同时可以设置线程的优先级使线程之间协调合理的利用CPU的时间。
线程间通信的方法
interrupt
我们比较熟知的 thread.stop 方法虽然很有效,一旦调用就会让线程终止,但是这样容易引发状态问题,比如你给一个 model 设置5个值,但是设置了2 个值之后就中断了,就会引起状态问题。用 interrupted 终止线程并不会立即终止而只是类似一个通知,通知这个线程你要结束了,然后这个线程内部通过 isInterrupted 来判断。
public class ThreadInteractionDemo implements TestDemo {
@Override
public void runTest() {
Thread thread = new Thread() {
@Override
public void run() {
for (int i = 0; i < 1_000_000; i++) {
if (Thread.interrupted()) {
// 收尾
return;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
// 收尾
return;
}
}
}
};
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.interrupt();
}
}
wait & notifyAll
下面这个例子是线程锁的一些应用。
线程1 是在 500ms 的时候打印String ,线程2是在1000ms 的时候初始化String 如何通过锁的 wait 和 notify 机制让其正确打印出初始化之后的值?
public class WaitDemo implements TestDemo{
private String sharedString;
private synchronized void initString(){
sharedString = "Rengwuxian!";
notifyAll();
}
private synchronized void printString(){
while (sharedString == null) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("String:"+sharedString);
}
@Override
public void runTest() {
new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
printString();
}).start();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
initString();
Thread.yield();
}).start();
}
}
notify 和 notifyAll 的区别
notify方法
- 方法调用之前由于也需要获取该对象的锁,所以使用的位置: synchronized方法中或者synchronized代码块中
- 通知那些可能等待该对象的对象锁的其他线程。如果有多个线程等待,则线程规划器任意挑选出其中一个wait()状态的线程来发出通知
- wait()方法执行完毕之后,会立刻释放掉锁,如果没有再次使用notify,其他wait()的线程由于没有得到通知,会继续阻塞在wait()的状态,等待其他对象调用notify或者notifyAll来唤醒。
notifyAll方法- 使用位置和notify一样
- notifyAll唤醒所有处于wait的线程
volatile
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
也就是说原来线程A和线程B都持有一个对象1,当对象1被线程A改变之后,线程B再读取发现和线程A中的值是不一样的,这是因为线程在引用对象1的时候都是各自复制了一份到自己的线程中,改变的也只是自己线程中的备份,而使用valatile就是将这个对象共享了,是在主内存中的。
volatile int a;
public void set(int b) {
a = b;
}
public void get() {
int i = a;
}
线程A执行set()后,线程B执行get(),就相当于线程A向线程B发送了消息。
synchronized
synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。synchronized既可以加在一段代码上,也可以加在方法上。
synchronized锁住的是代码还是对象。答案是:synchronized锁住的是括号里的对象,而不是代码。对于非static的synchronized方法,锁的就是对象本身也就是this。
当synchronized锁住一个对象后,别的线程如果也想拿到这个对象的锁,就必须等待这个线程执行完成释放锁,才能再次给对象加锁,这样才达到线程同步的目的。即使两个不同的代码段,都要锁同一个对象,那么这两个代码段也不能在多线程环境下同时运行。
比如线程a和b都需要对象1和2,这个时候a获取到了1,b获取到了2,但是因为她们都加了锁(加锁之后,就没获取到两个对象就会阻塞),并且只有一个对象1和2,于是就出现了死锁。
public synchronized void add() {
a++;
}
public synchronized void get() {
int i = a;
}
当线程A执行 add(),线程B调用get(),由于互斥性,线程A执行完add()后,线程B才能开始执行get(),并且线程A执行完add(),释放锁的时候,会将a的值刷新到共享内存中。因此线程B拿到的a的值是线程A更新之后的。
volatile 和 synchronized 的对比
加锁和volatile变量volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。
当且仅当满足以下所有条件时,才应该使用volatile变量:
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
该变量没有包含在具有其他变量的不变式中。