【Java多线程】常见锁策略;CAS的ABA问题;synchronized原理;Callable接口;JUC常见类;ConcurrentHashMap;死锁

多线程

1.常见的锁策略

1.1 乐观锁和悲观锁

乐观锁:假设数据一般不会产生并发冲突,所以在数据提交的时候才会检测是否产生了并发冲突,如果发现并发冲突了,就会返回错误的信息,让用户决定该如何去做

悲观锁:假设数据发生并发冲突的概率比较高,每次读写数据的时候都会加锁,这样就不会发生线程不安全,别的线程想要获取锁的时候,就要等待它释放锁

synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换为悲观锁策略

乐观锁的一个重要功能就是要检测出数据是否发生发生冲突,我们可以引入一个"版本号"来解决

比如说线程1和线程2都要对内存中的count进行自增,内存中的count不仅存储了原始数据值0,还会存储一个版本号(version),初始值为1,当线程1,线程2从内存中读取count时,就会将数值和版本号都存储到各自的工作内存中(寄存器),当两个线程在自己的工作内存中对count值进行自增后,也会将version+1,假如线程1先进行修改,修改完成后,此时线程1寄存器中的数据就是count=1,version=2,再将数据写入内存时,会先判断version是否大于内存中的version,内存中的version=1,所以线程1就会将数据写入内存中,线程1写入成功。此时线程2在寄存器中也完成了对数据的修改,count=1,version=2,当线程2将数据写入内存时,判断version是否大于内存中的version,发现version不大于内存中的version,此时线程2就会写入失败。也就是要写入内存的数据的版本号必须大于当前内存中的版本号

在这里插入图片描述

1.2 读写锁

发生线程不安全问题的其中一点原因就是多个线程同时修改一个数据,但是如果多个线程同时读取一个数据,就不会发生线程不安全问题,很多时候我们读取数据的频率高,而修改数据的频率低,此时这两种情况下都用同一个锁,会产生极大的损耗,所以我们需要使用读写锁。

读写锁:就是将读操作和写操作区分对待,读操作之间不互斥,写操作之间互斥,读操作和写操作之间互斥

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

synchronized不是读写锁

1.3 重量级锁&轻量级锁

重量级锁与轻量级锁之间是用加锁开销的大小来区分。

如果加锁解锁是通过操作系统内核完成,会涉及到用户态和内核态之间的切换,也会涉及到一系列的阻塞等待和线程调度,此时,这个锁就称为是重量级锁

如果加锁解锁开销更小,一般在用户态完成,不太容易引发线程调度,这个锁就称为轻量级锁

synchronized开始是轻量级锁,如果锁冲突比较严重,就会变成重量级锁

1.4 自旋锁

如果两个线程发生锁冲突,一个线程没有竞争到锁,就会放弃CPU,进入阻塞等待,不再占用CPU资源,需要过很久才会被再次调度,但一般情况下,锁被释放的时间很快,所以不需要长时间等待,就需要通过自旋锁来完成

自旋锁:如果锁竞争失败,就会一直循环尝试获取锁,直到获取到锁,一旦锁被其他线程释放,就会立即获取到锁,

自旋锁的优缺点:

​ 自旋锁是一种轻量级锁的实现方式,优点是没有放弃CPU,不涉及到线程阻塞和调度,一旦锁被释放,立即获取锁,

​ 缺点是如果锁被其他线程长时间占用,一直尝试获取锁会长时间占用CPU,浪费CPU资源。

synchronized轻量级锁策略大概率是通过自旋锁的方式实现的

1.5 公平锁&非公平锁

公平锁:遵守"先来后到"的原则,多个线程想要获取同一把锁时,就会按照阻塞等待的先后顺序来获取锁。例如有3个线程A,B,C按先后顺序排列,当锁被释放后,A会先获取锁,释放锁后,B再获取锁,然后C再获取锁

非公平锁:A,B,C,3个线程同时竞争同一把锁,但是它们3个获取到锁的概率是相同的,并不区分谁先来的,谁后来的。

区分公平锁非公平锁的条件:是否遵守"先来后到"的原则,

操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁,要想实现公平锁,就需要依赖额外的数据结构,记录线程的先后顺序

synchronized是非公平锁

2. CAS

CAS:全称Compare and swap 字面意思就是比较并交换

假设内存中有一个原始数据V,线程中有一个旧的原始数据A,需要新修改的值B

先比较A是否和V相等,如果相等,就将V改成B,如果不相等,就修改失败

如果修改成功了,返回TRUE,修改失败返回FALSE

CAS相当于将这3个操作封装起来,变成一个整体的操作,CAS操作是原子的

CAS应用

2.1 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}
//CAS比较value和oldvalue是否相等,如果相等,就将oldvalue+1,如果不相等,就将oldvalue替换为value,继续循环CAS操作。

在这里插入图片描述

2.2 实现自旋锁

伪代码:

public class SpinLock {
    private Thread owner = null;//owner为null,表示锁没有被线程获取
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
            //while判断此时的owner和null是否相等,如果相等,就将自己线程的引用赋值给owner,相当于获取锁成功,赋值之后,返回true,由于while判断的是!CAS,所以结果为false,就会退出循环。如果没有赋值成功,返回的是false,!false=true,就会一直循环的尝试获取锁。此时就实现了自旋锁
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

2.3 CAS的ABA问题

ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值, 记录到 oldNum 变量中.
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程

ABA问题引出的BUG

大部分情况下,t2线程这样的一个反复横跳的改动,对于t1是否修改num是没有影响的,但也有一些特殊情况

假如A有200的存款(value),他想要取ATM取出100,由于ATM卡了一下,他按了两次取钱,此时创建了两个对账户减100的线程,正常情况下使用CAS的方式,线程1,线程2都对value进行读取,各自的oldvalue=200,线程1先执行扣款,判断oldvalue是否等于value,200=200,线程1执行成功后,账户余额由200变成了100(value=100),此时线程2再想进行扣款时,对比value和oldvalue,发现不相等,就不会执行扣款

但是异常的情况下,当线程1执行扣款之后,A的好兄弟B,给A账户转账了100,所以此时value=200,那当线程2执行的时候,判断oldvalue=value,所以线程2也执行了扣款操作,那100不就不翼而飞了吗

为了解决ABA问题,可以给要修该的值引入版本号,在CAS比较数据当前值value和旧值oldvalue时,也要对比版本号

CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候,

如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.

如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

例如上面的例子,给A的账户余额添加一个版本号,初始值为1.线程1和线程2都会读取到版本号和value值,线程1先执行时,对于版本号是否相等,版本号相等,就会执行扣款操作,将value改为100,并且将版本号+1,此时版本号为2,当B给A转账后,value=200,版本号为3,此时当线程2执行时,对比value=oldvalue,但是版本号大于自己寄存器中读取到的版本号,就会执行失败。

3. synchronized原理

3.1 基本特点

  1. 开始时是乐观锁,如果锁冲突发生的概率较高,就转变为悲观锁
  2. 开始时是轻量级锁,如果锁被持有的时间过长,锁冲突频繁,就转变为重量级锁
  3. 当synchronized是轻量级锁时,大概率用到自旋锁策略,是重量级锁时,大概率是一个挂起等待锁
  4. synchronized不是读写锁
  5. synchronized是非公平锁
  6. synchronized是可重入锁

3.2 加锁工作过程

JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态。会自适应进行升级

偏向锁

第一个尝试加锁的线程,优先进入偏向锁状态,并不是真的加锁,而是给对象头中做一个"偏向锁的标记",记录这个锁属于哪个线程,但是并没有加锁,如果后续没有其他的线程来竞争这把锁,那就避免了加锁的开销,但是如果后续由其他的线程来竞争这个锁,由于第一个线程已经在对象头中做了"偏向锁的标记",所以这个线程就会率先加锁,取消偏向锁状态,进入轻量级锁状态。

偏向锁也可以理解为"延迟加锁",没有线程竞争锁,就不加锁,有线程竞争,就加锁。

轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现. 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 “自适应”

重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁

  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
    这个线程, 尝试重新获取锁

3.3 其他的优化操作

锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除

锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

锁的粒度:粗和细,粗粒度锁表示锁内代码较多,细粒度锁表示锁内代码较少,意味着代码持有锁的时间段,能更快的释放锁,其他线程冲突的概率也就更小

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.

但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.

例如

方式一:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.

方式二:
打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.

4. Callable接口

4.1 Callable的用法

Callable也是一个创建线程的方式,Callable是一个interface,相当于把线程封装了一个"返回值",方便程序员借助多线程的方式计算结果

  • Callable和Runnable相对,都是描述一个"任务",Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务
  • Callable通常需要搭配FutureTask使用,FutureTask用来保存Callable的返回结果,因为Callable往往实在另一个线程中执行的,啥时候执行结束不确定,FutureTask就可以负责这个等待结果出来的工作

代码示例:创建线程计算1+2+3…+1000,不使用Callable

  • 创建一个类Result,包含一个sum表示最终结果,locker表示线程同步使用的锁对象
  • main方法中创建Result实例,然后创建一个线程t,在线程内部计算结果
  • 主线程同时要使用wait等待线程t计算结束,
  • 当线程t计算结束后,通过notify唤醒主线程,然后打印结果
public class Test{
    static class Result{
    private int sum;
    Object locker = new Object();
}
    public static void main(String [] args){
        Result result = new Result();
        Thread t = new Thread(){
           @Override
            public void run(){
                int sum = 0;
                for(int i = 0; i <= 1000;i++){
                    sum = sum + i;
                }
                result.sum = sum;
                synchronized(locker){
                    result.locker.notify();
                }
            }
        };
        t.start();
        synchronized(locker){
            while(result.sum == 0){
                try{
                result.locker.wait();
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
            }
        }
        System.out.println(result.sum);
    }
}

上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错

使用Callable

  • 创建一个匿名内部类,实现Callable接口,Callable带有泛型参数,泛型参数表示返回值类型
  • 重写Callable的call方法,完成计算后,直接通过返回值返回计算结果
  • 用FutureTask包装callable实例
  • 创建线程,线程的构造方法传入FutureTask,此时新线程就会执行FutureTask内部的Callable的call方法,完成计算,计算结果就放到了FutureTask对象中
  • 在主线程中调用futureTask.get()能够阻塞等待线程计算完毕,并获取到FutureTask中的结果
public class Test{
    public static void main(String [] args)throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable(){
            @Override
            public Integer call() throws Exception{
                int sum = 0;
                for(int i = 0 i <= 1000; i++){
                    sum = sum + i;
                }
                return sum;
            }
        };
    	//由于Thread不能直接传一个Callable类,所以需要一个辅助类来包装一下
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        //futureTask保存了callable返回的结果,此时callable大概率还没执行完,当callable执行完了之后,
        //会把这个结果写入到FutureTask的实例中,
        Thread t = new Thread(futureTask);
        t.start();
        Integer result = futureTask.get();
        //如果futureTask中的结果还没生成,get方法就会阻塞等待,直到结果算出后,get才会返回
        System.out.println(result);
    }
}

5. JUC(java.util.concurrent)的常见类

5.1 ReentrantLock

ReentrantLock,是可重入互斥锁,和synchronized定位类似,都是用来实现互斥效果,保证线程安全

用法:

  • lock()加锁,如果获取不到锁,就一直等待
  • trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就会放弃加锁
  • unlock():解锁

示例:

public class Test3 {
    static class Counter{
        private int count = 0;
        ReentrantLock locker = new ReentrantLock();//创建reentrantlock实例
        private void increase(){
            locker.lock();//加锁
            count++;
            locker.unlock();//解锁
        }
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}
//使用reentrantlock对increase()方法中的count++操作进行加锁,也能保证线程安全,使t1,t2线程能分别自增50000次,保证最后的自增结果为100000

ReentrantLock和synchronized的区别

  • synchronized是一个关键字,ReentrantLock是标准库中的一个类
  • synchronized使用时不需要手动释放锁,ReentrantLock使用时需要手动释放锁,使用起来更加灵活,但也容易遗漏unlock
  • synchronized在申请锁失败时,会一直等待,ReentrantLock可以通过trylock的方式设置等待时间,超过等待时间后,会自动放弃
  • synchraonized是非公平锁,ReentrantLock默认是非公平锁,但是可以通过构造方法传入一个true,开启公平锁模式
  • synchronized是通过Object的wait/notify实现等待-唤醒,notify每次唤醒的是一个随机等待的线程,ReentrantLock搭配Condition类实现等待-唤醒,可以更精确控制唤醒某个特定的线程

如何选择使用哪个锁呢?

  • 锁竞争不激烈时,使用synchronized,效率更高,自动释放更方便
  • 锁竞争激烈的时候,使用ReentrantLock,搭配trylock更灵活的控制加锁的行为,而不是死等
  • 如果需要使用公平锁,使用ReentrantLock传入true

5.2 原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几种

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

以AtomicInteger为例,常见方法有:

addAndGet(int delta); //相当于i += delta;
decrementAndGet(); // --i;
getAndDecrement(); // i--;
incrementAndGet(); // ++i;
getAndIncrement(); // i++;

5.3 线程池

虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效. 线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.

ExecutorService和Executors

  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
   }
});

Executors创建线程池的几种方式

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer

Executors本质上是ThreadPoolExecutor类的封装

ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.

ThreadPoolExecutor 的构造方法

在这里插入图片描述

5.4 信号量Semaphore

信号量用来表示"可用资源的个数",本质上就是一个计数器,P操作就是申请资源,如果资源申请到了,信号量就减一,如果资源没申请到,也就是当信号量为0的时候,P操作就会阻塞等待,直到其他线程释放资源,V操作是释放资源,释放资源后信号量加一,PV操作都是原子的,所以Semaphor可以在多线程环境下直接使用

示例:

  • 创建Semaphore实例,初始化为3,表示有3个可用资源
  • acquire()表示申请资源(P操作),release()表示释放资源(V操作)
  • 创建20个线程,每个线程都尝试获取资源,获取到资源后sleep2秒,然后释放资源
public class Test{
    public static void main(String [] args){
        Semaphore semaphore = new Semaphore(3);
        Runnable runnable = new Runnable(){
            @Override
            public void run(){
                try{
                System.out.println("准备申请资源");
                semaphore.acquire();
                System.out.println("申请到了资源");
                Thread.sleep(2000);
                semaphore.release();
                System.out.println("释放资源了");
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
            }
        };
        for(int i = 0; i < 20; i++){
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}

5.5 CountDownLatch

同时等待N个任务执行结束,就好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

示例:

  • 构造CountDownLatch实例,初始化10表示有10个任务需要执行,
  • 每个任务执行完毕,都调用latch.countDown(),在CountDownLatch内部的计数器会同时自减
  • 主线程种使用latch.await(),阻塞等待所有任务执行完毕,相当于计数器为0
public class Test {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(8);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("起跑");
                try {
                    //Math.random()随机创建[0,1)之间的浮点数
                    Thread.sleep((long) (Math.random()*10000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();
                System.out.println("冲线");
            }
        };
        for (int i = 0; i < 8; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
        latch.await();
        System.out.println("比赛结束");
    }
}

6. 多线程环境使用哈希表

HashMap本身是线程不安全的

6.1 Hashtable

只是把关键方法加上了synchronized关键字,相当于针对Hashtable对象本身加锁,当有两个线程同时访问Hashtable中任意数据都会发生锁竞争,而事实上Hashtable底层是一个数组,每个数组下标都连接了一个链表,可能多个线程访问的数据并不在同一个链表中,所以直接给hashtable本身加锁大大降低了代码的执行效率

  • 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
  • size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
  • 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.

6.2 ConcurrentHashMap

  • 读操作没有加锁,而是使用volatile保证从内存读取结果,只是对写操作进行加锁,加锁的方式仍然是使用synchronized,但是枷锁对象并不是整个对象,而是以每个链表的头节点为对象,相当于对每个哈希桶进行了加锁,如果多个线程访问的对象不在同一个哈希桶中,就不会产生锁冲突,降低了发生锁冲突的概率
  • 利用CAS的特性,比如size属性通过CAS更新,避免出现重量级锁的情况
  • 优化扩容方式:化整为零
    • 发现需要扩容的线程只需要创建一个新的数组,同时只搬运几个数据过去
    • 扩容期间,新老数组同时存在
    • 后续每个来操作ConcurrentHashMap的线程,都会参与搬运数据的过程,每个操作只搬运一小部分数据
    • 搬运完最后一个数据,再把老数组删除掉
    • 扩容期间,插入操作只往新数组中插入,查找操作需要同时查找新老数组

7. 死锁

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

下面列举几个出现死锁的场景:

  1. 如果某个线程尝试对同一把锁加锁两次,而这个锁是不可重入锁时,就会出现死锁
  2. 两个线程,线程1,线程2,同时对两把锁进行加锁操作,线程1先尝试对锁1进行加锁,再尝试对锁2进行加锁,线程2先尝试对锁2进行加锁,再尝试对锁1进行加锁,如果这两个线程并发执行,并且线程1和线程2同时分别对锁1,锁2加锁,此时线程2再想获取锁2时,锁2已经被线程2获取了,就进入阻塞等待,等线程2释放锁2;而线程2也想获取锁1,锁1被线程1获取了,也进入阻塞等待,此时就出现了死锁
//伪代码举例
void func1(){
    synchronized(locker1){
        synchronized(locker2){//此时线程1进入阻塞等待,等待线程2释放locker2
            
        }
    }
}
void func2(){
    synchronized(locker2){//线程2进入阻塞等待,等待线程1释放locker1
        synchronized(locker1){
            
        }
    }
}
  1. 多个线程多把锁(哲学家就餐)

    假设有5个哲学家(相当于线程),5只筷子(锁),哲学家只有两个行为,思考和吃饭,思考时不用筷子,吃饭时需要拿起两只筷子才能吃饭,先拿起左边筷子,再拿起右边筷子,如果当有人想吃饭,但筷子被别人使用时,就会进入阻塞等待。

    此时,如果5个哲学家都想要吃饭,并尝试拿起左边筷子,然后再拿起右边筷子,就发生问题了,每个哲学家右边的筷子都被别人占用着,所有就都进入了阻塞等待,此时就发生了死锁

在这里插入图片描述

死锁产生的四个必要条件

  1. 互斥使用;当一个资源被一个线程使用时,别的线程不能使用这个资源
  2. 不可抢占;资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放,
  3. 请求和保持;当资源请求者在请求其他资源时,同时保持对已占有资源的占有权
  4. 循环等待;即存在一个等待队列,P1占有P2的资源,P2占有P3的资源,P3占有P1的资源,这样就形成了一个等待环路,也就类似于哲学家就餐问题

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失,其中最容易破坏的就是"循环等待"

破坏循环等待

最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待

//约定好先获取 lock1, 再获取 lock2 , 就不会环路等待
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t1.start();
Thread t2 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }

例如哲学家就餐问题:

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值