JavaEE 多线程进阶

常见的锁策略

锁策略 实现锁的人需要重点理解
如果只是单纯的使用锁不太需要知道

乐观锁和悲观锁

乐观锁 :预测场景中不太会出现冲突的情况
悲观锁 :预测场景非常容易出现锁冲突

重量级锁和轻量级锁

重量级锁:加锁开销比较大(花的时间多占用系统资源多)
一个悲观锁,很有可能是重量级锁(不绝对)
轻量级锁:加锁开销比较小(花的时间少占用系统资源少)
一个乐观锁也很可能是轻量级锁

自旋锁和挂起等待锁

自旋锁(Spin Lock)
是轻量级锁的一种典型实现
在用户态下通过自旋方式(whlie循环)实现类似于加锁的效果
这样做就可以在锁被释放的时候第一时间就会拿到锁 会消耗一定cpu的资源

挂起等待锁,是一种重量级锁的典型实现
通过内核态 借助系统提供的锁机制 当出现锁冲突的时候 会牵扯到内核对于线程的调度 是冲突线程出现挂起(阻塞等待)
锁被占用,这样做会更迟发现锁被解锁了 不能第一时间发现 (消耗的cpu资源更少)

读写锁

读写锁:
把读操作和写操作加锁分开了
如果两个线程 两个线程都是读加锁 不会产生锁竞争
如果两个线程 一个写加锁 另一个线程写加锁 会产生锁竞争
如果两个线程 一个线程写加锁 另一个线程读加锁 会产生锁竞争

Java标准库里 也提供了读写锁

在这里插入图片描述

互斥锁

互斥锁:在加锁时不能进行其他操作

公平锁 和 非公平锁

**公平锁:**遵守"先来先到" B比C先来 当A释放锁之后B就能比C先得到锁
**非公平锁:**不遵守先来后到功能 B和C都能获得锁
操作系统自带的锁(pthread_mutex)属于非公平锁
如果想实现公平锁 就要数据结构支持(例如 记录每个线程的阻塞等待时间)

可重入锁 和 不可重入锁

如果一个线程对一把锁加锁两次会出现死锁就是不可重入锁
不出现死锁就是可重入锁
在这里插入图片描述

死锁(重点)

死锁
针对以下情况
在这里插入图片描述

  1. 调用方法针对this加锁 此时假设加锁成功了
  2. 接下来执行到代码块中的synchronized 此时 还是针对this来进行加锁
  3. 然后就会产生锁竞争当前this已经处于加锁状态了 此时线程就会进行阻塞 一直阻塞锁释放的时候才能拿到锁
  4. 在这个代码中this上的锁 在increase 方法执行完毕之后才能释放 第二次要成功加锁获得锁代码才能继续往下执行 因此陷入了无线阻塞的状态这种称为死锁

如果出现’卡死’的情况 是非常不科学的 因为稍微一疏忽会出现连续加锁两次的代码

这里关键与两次加锁都是 同一个线程 第二次尝试加锁的时候 该线程已经有了这个锁的权限了 这个时候 不应该加锁失败 不应该阻塞等待

如果当前是一个不可重入锁 第一把锁就不会保存 那个线程对它进行加锁 只要它当前处于加锁状态 收到了加锁请求 就会拒绝当前加锁
不管当下线程是那个 从而参数死锁

如果当前是一个可重入锁 就会让第一把锁保存 那个线程加的锁 后续收到加锁请求 就会先识别当前加锁请求的锁是否是当前持有这把锁的线程
若是就可以重复加锁了

所以 因为synchronized是一个可重入锁 实际上代码并不会出现死锁的情况
在这里插入图片描述
可重入锁 是如何记录当前那个线程持有了锁?
在这里插入图片描述
如果在最里面层的括号处释放了锁 意味着最外面的synchronized’中间的synchronized后续的代码部分就没有处在锁的保护中了

如果加锁是N层,在遇到 }, JVM咋知道当前这个 }是最后一个(最外层的一个呢)?

让锁这里持有一个“计数器"就行了
让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整型变量记录当前这个线程加了几次锁!!
每遇到一个加锁操作,就计数器+1,每遇到一个解锁操作,就-1 当计数器被减为0的时候,才真正执行释放锁操作.其他时候不释放

“引用计数”
死锁的三种典型情况

  1. 一个线程 一把锁 但是是不可重入锁 该线程针对这个锁连续加锁两次 就会出现死锁
  2. 两个线程 两把锁 这两个线程先分别获取到一把锁 然后再同时尝试获取对方的锁
    示例:
    我们创建两个线程让其先对一个加锁 再对对方进行加锁
package thread;

public class Demo25 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();


    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            synchronized(locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized(locker2){
                    System.out.println("t1 两把锁加锁成功");
                }
            }
        });
        Thread t2 = new Thread(()->{
           synchronized (locker2){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               synchronized(locker1){
                   System.out.println("t2 两把锁加锁成功");
               }
           }
        });
        t1.start();
        t2.start();
    }
}

运行后可以看到发生了阻塞
谁都没有获取到对面的锁
在这里插入图片描述
用jconsole查看
线程一被锁定到了代码的十七行
在这里插入图片描述
在这里插入图片描述

线程二被锁定到了代码的29行
在这里插入图片描述
在这里插入图片描述

N个线程M把锁(哲学家就餐问题)

有五个科学家每个科学家左右手有一根筷子
在这里插入图片描述

基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作的 但是,如果出现了极端情况,就会出现死锁 比如,同一时刻,五个哲学家都想吃面,并且同时伸出左手拿起左边的筷子再尝试伸右手拿右边的筷子 此时就会出现每个人手上都有一根筷子 都在等其他人放下筷子 出现"死锁"的状态

5个哲学家就是5个线程
5个筷子就是5把锁

避免死锁的条件?

产生死锁的四个必要原因:

  1. 互斥使用: 一个线程获取到一把锁之后,别的线程不能获取到这个锁实际使用的锁,一般都是互斥的(锁的基本特性)

  2. 不可抢占:锁只能是被持有者主动释放,而不能是被其他线程直接抢走(锁的基本的特性).

  3. 请求和保持:这个一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态(取决于代码结构)

  4. 循环等待:t1尝试获取locker2,需要t2执行完,释放locker2, t2尝试获取 locker1,需要t1执行完,释放locker1

第四点是解决死锁问题的最关键要点
介绍一个,更简单,也非常有效的解决死锁的方法.针对锁进行编号.并且规定加锁的顺序
比如,约定,每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁.
只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现循环等待!!

例如:
约定每个线程如果要获取多个锁必须先获取编号小的锁后获取编号大的
在这里插入图片描述

synchronized具体采用了那些锁策略呢?

  1. synchronized 既是悲观锁,也是乐观锁.(自适应)
  2. synchronized既是重量级锁,也是轻量级锁.
  3. synchronized重量级锁部分是基于系统的互斥锁实现的;轻量级锁部分是基于自旋锁实现的
  4. synchronized是非公平锁(不会遵守先来后到.锁释放之后,哪个线程拿到锁,各凭本事)
  5. synchronized是可重入锁.(内部会记录哪个线程拿到了锁,记录引用计数)
  6. synchronized不是读写锁.

锁升级

synchronized内部实现策略(内部原理)
代码中写了一个synchronized之后 这里可能会产生一系列的"自适应的过程"锁升级(锁膨胀)
无锁->偏向锁->轻量级锁->重量级锁
偏向锁: 不是真的加锁,只是做了一个标记 如果有别的锁来竞争了就会真正加锁 如果没有别的锁竞争就不会真的加锁
加锁本身有一定的开销 能不加就不加 有竞争才加

轻量级锁:
sychronized通过自旋锁的方式来实现轻量级锁~~
我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了.
但是,后续,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量级锁,升级成重量级锁

锁消除

编译器,会智能的判定,当前这个代码,是否有必要加锁.
如果你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉
比如,在单个线程中,使用StringBuffer .编译器进行优化,是要保证优化之后的逻辑和之前的逻辑一致

锁粗化

关于锁的粒度
如果加锁操作里包含的实际要执行的代码越多 就认为锁的粒度越大
在这里插入图片描述

CAS (Compare and swap比较交换)

原子的指令
能够比较和交换 某个寄存器中的值和内存中的值 看是否相等 如果相等 则把另一个寄存器的值和内存进行交换
在这里插入图片描述

在这里插入图片描述
CAS有那些应用
实现原子类比如多线程针对一个count++变量进行++
在java标准库中给我们实现了一组原子类
在这里插入图片描述

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo26 {
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //count++
                count.getAndIncrement();
                //++count
                count.incrementAndGet();
                //count--
                count.getAndDecrement();
                //--count
                count.decrementAndGet();
            }
        });
    }
}
package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo26 {
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //count++
                count.getAndIncrement();
               /* //++count
                count.incrementAndGet();
                //count--
                count.getAndDecrement();
                //--count
                count.decrementAndGet();*/
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //count++
                count.getAndIncrement();
              /*  //++count
                count.incrementAndGet();
                //count--
                count.getAndDecrement();
                //--count
                count.decrementAndGet();*/
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.get());
    }
}

在这里插入图片描述
注意:
在这里插入图片描述
上诉的原子类是基于CAS来实现的
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

通过CAS实现自旋锁

实现自旋锁
基于CAS实现更灵活的锁,获取更多的控制权
自选锁伪代码

public class SpinLock{
	private Thread owner = null;
	//使用owner表示当前持有这把锁的线程 null表示解锁状态
	pubilc void lock(){
	//通过CAS看当前锁是否被某个线程持有
	//如果这个锁已经被别的锁持有,那么就会自旋等待
	//如果这个锁没有被别的线程持有,那么就把owner 设为当前尝试加锁的线程
	whlie(!CSA(this.owner,null,Thread.currentTherad())){
	//获取当前线程引用 那个线程调用lock 这里就会得到那个线程的引用
	//当该锁处于加锁状态 就会返回false cas不会进行实际的交换状态接下来循环条件成立继续进行下一个循环
		}
	}
	public void unlock(){
		this.owner = null;
	}
}

CAS的ABA问题

cas的关键是比较寄存器1和内存的值 通过是否相等来判定内存的值是否进行了改变
如果内存变了 存在其他线程进行了修改
如果内存值不变 没有别的线程修改 接下来进行的修改就是安全的
问题:
如果这里的值没变 就一定没有别的线程进行修改吗?
另一个线程 把变量的值从A变为B 又从 B变为A
此时本线程区分不了 这个值是始终没变的 还是变成B后又变回A的情况
大部分情况下就算是出现ABA问题 不会影响
但是遇到一些极端场景 就会出现问题
示例:
账户有100 希望取款50
第一次取款的时候卡了
再点一次这时就会出现两个线程
假设按照CAS的方式进行取款 每个线程怎么操作
在这里插入图片描述

如是当前情况 线程1读取时余额时100 然后换到线程2执行读取M变量也是100 第3步将M改成M-50 此时变量M为50 最后换到1线程执行第4步经过CAS判定M为50 就放弃操作
目前看来这两个线程并没有出现问题

但是此时要是有第3个线程在2线程结束时对余额进行加50的操作
在这里插入图片描述

在第五步的时候就会判断变量M时100 就会将M变量减去50 从而造成损失

CAS判定的是"值相同" 实际上期望的是 值未成变化过
此时如果约定 值只能单向变化(比如只能增长,不能减少)

但是账户余额不能单向变化所以我们可以设置一个版本号来衡量余额是否发生改变
给账户余额安排一个版本号 (只增加不减少)
使用CAS 判定版本号 相同 数据就没有修改过 版本号就就一定要增加

JUC(java.util.concurrent)的常见类(并发多线程)

Callable interface

Callable interface 也是一种创建线程的方式
Runnable 能表示一个任务(run方法) 返回void
Callable 也能表示一个任务(call方法) 返回一个具体的值,类型可以通过泛型参数来指定(Object)
如果进行多线程操作 如果只关心多线程执行的过程 使用Runnable即可(例如 线程池 定时器)
如果是关心多线程计算结构使用Callable更合适(通过多线程的方式 计算一个公式 比如创建一个线程 让这个线程计算1+2+3…+1000)

使用Callable不能直接作为Thread的构造方法参数
此时可以使用FutureTask来接受

public class Demo27 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

这个结果什么时候算出来是我们需要关心的
futureTask.get();获取callable方法的返回结果
这里的get类似于join一样 如果call方法没执行完全 就会阻塞等待

至此有五种创建线程的方式

  1. 直接继承Thread
  2. 实现Runnable
  3. 使用lambad
  4. 使用线程池
  5. 使用Callable

ReentrantLock(可重入锁)

这个锁没有synchronized那么常用 但也是一个可选的加锁的组件

   ReentrantLock locker = new ReentrantLock();
        locker.lock();
        //代码逻辑
        locker.unlock();

但是这会出现unlock调用不到的问题 例如 中间return就会抛出异常

ReentrantLock比起synchronized的优势

Reentrantloc具有一些特点 是synchronized不具备的功能

  1. tryLock:提供了一个tryLock方法进行加锁 对于lock操作如果加锁不成功 就会出现阻塞等待(死等)对于tryLock 如果加锁失败 就会直接返回false 也可以设置等待的时间 tryLock 给加锁操作提供了更多的可操作空间
  2. ReentrantLocck 有两种工作模式 可以工作在公平锁状态下 也可以工作在非公平锁的状态下
    构造方法种通过参数设定 公平和非公平模式
  3. ReentrantLock 也有通知等待机制 类似于wait notify 是搭配Condition的类完成的 比起wait notify功能更强

注意:
Reentrantloc的unlock容易遗忘使用finally来执行unlock
synchronized 锁对象是任意对象
ReentrantLock 锁对象就是自己本身
如果多个线程针对不同的ReentrantLock调用方法 是不会产生锁竞争
实际开发中会首选synchronized

信号量Semaphore

Semaphore是并发编程中的一个重要的概念
准确来说Semaphore是一个计数器(变量),描述了"可用资源的个数"
描述的是这个当前这个线程是否"有临界资源可以用"

临界资源:多个线程/进程等并发执行的实体可以使用的公共使用到的资源
(多个线程修改同一个变量,这个变量就可以认为是临界资源)

例如:
开车去停车
进入一个空车位就消耗了一个资源 此时计数器就要-1 称为p操作
离开车位就释放了一个资源 此时计数器就要+1 称为v操作

如果这时计数器为0时 继续进行p操作就会阻塞等待 一直等待带其他线程执行了v操作 释放了一个空闲资源为止

信号量与锁

锁 本质上是一个特殊的信号量(里面的数值不是0 就是 1 二元信号量)
信号量 要比锁更加广义 可以描述n个资源

使用信号量示例:
import java.util.concurrent.Semaphore;

public class Demo28 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(4);
        semaphore.acquire();//计数器-1
        System.out.println("实现p操作");
        semaphore.acquire();
        System.out.println("实现p操作");
        semaphore.acquire();
        System.out.println("实现p操作");
        semaphore.acquire();
        System.out.println("实现p操作");
        semaphore.acquire();
        System.out.println("实现p操作");

    }
}

在构造semaphore对象时可以指定计数器的初始值
semaphore.acquire();等于p操作 占用一个资源
在这里插入图片描述
可以发现当到第五次p操作时 计数器为0 线程就会进入阻塞状态

CountDowLatch(下载组件)

针对特定场景一个组件
当我们在网络下载某个资源时,很多时候下载速度并不是受到自家网络的影响而是服务器存在限制
有一些多线程下载器 把一个大的文件拆分成多个小的部分使用多个线程分别下载 假设分成10 个线程 10个部分下载 10个部分下载完了才算下载完
那如何判断10个部分全部下载完了呢?
CountDownLatch 就是用来衡量任务的完成情况的
模拟CountownLach工作情况
构造方法中 指定创建几个任务
使用线程休眠代替任务(下载)
在这里插入图片描述
这里的i报错是因为变量捕获的问题
这里线程t里面的 i 是捕获到for循环的 i 而 i 要进行i++ i要进行修改
java变量捕获要求变量是final修饰的 或者事实上是fianl的变量 要解决问题重新创建一个变量来接收i就ok了
在这里插入图片描述
**countDownLatch.countDown();countDownLatch.await();
主线程如何知道所有线程都完成了呢?
难道要在主线程中调用10次join吗?
万一要是任务结束 但线程不需要结束 join不就行不通了吗?
此时我们需要调用
countDownLatch.countDown();**和 countDownLatch.await();
countDown()
每个任务结束调用一个countDown
await();
等待所有任务结束 当调用countDown的次数<初始设置的次数 await就会进入阻塞等待
package thread;

import java.util.concurrent.CountDownLatch;

public class Demo29 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法中 指定创建几个任务
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(()->{
                System.out.println("线程"+ id +"开始工作");
                try{
                    //使用线程休眠代替任务(下载)
                    Thread.sleep(2000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                System.out.println("线程"+ id +"结束工作");
                //每个任务执行结束 调用方法
                //每个任务结束调用一个countDown
                countDownLatch.countDown();
            });
            t.start();
        }
        //主线程如何知道所有线程都完成了呢?
        //难道要在主线程中调用10次join吗?
        //万一要是任务结束 但线程不需要结束 join不就行不通了吗?
        //等待所有任务结束 当调用countDown的次数<初始设置的次数 await就会进入阻塞等待
        countDownLatch.await();
        System.out.println();
    }
}

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值