多线程基础[下]


下面介绍几个关于线程安全的实例吧

单例模式

啥是设计模式?

设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏.

软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏.

饿汉模式

饿汉模式的代码:

image-20220329141026490

饿汉模式中的getInstance方法仅仅是读取了变量的内容,如果多线程只是读同一个变量,不修改,此时是线程安全的

一个典型的案例:

notepad (记事本)这样的程序,再打开大文件的时候是很慢的(比如你要打开1G的文件,此时notepad就会尝试将这1G的所有内容读到内存中)[饿汉]

而一些其他的程序,再打开大文件的时候会有优化(比如打开一个大文件的时候,同样的有1G,但是只会先加载这一个屏幕中可以显示的部分内容)[懒汉]

懒汉模式

懒汉模式代码:

image-20220329142252830

所以说懒汉模式不是线程安全的,我们要实现的是一个线程安全的单例模式

在多线程环境下,并发的调用getInstance方法,可能会出现bug

image-20220329143006553

如果真出现上图所示的现象,那么就会实例化两个对象,就不再是一个单例模式,如此可知,饿汉模式线程不安全,

懒汉模式(多线程版本)

针对饿汉模式的线程不安全,我们需要针对其进行加锁操作

image-20220329143406051

加锁之后,线程安全问题得到了解决,但是又出现了新的问题

对于一开始的饿汉模式代码来说,线程不安全是发生在instance被初始化之前,未初始化时,多线程调用getInstance ,可能同时涉及读和修改,但是一旦instance被初始化只有,getInstance操作就剩下了两个读操作,线程就安全了

按照上面的方式加锁,无论代码是初始化之后,还是初始化之前,每次调用getInstance都会进行加锁,也就意味着即使初始化之后仍然存在大量锁竞争,但是这样的竞争是没有必要的,同时让代码保证线程安全的同时,也付出了代价(程序的速度就慢了)

改进方案:让getInstance初始化之前进行加锁,初始化之后就不用加锁,在加锁这里加一个条件判定条件即可

image-20220329144352312

增加判定条件之后,发现又出现了一个新的问题,如果多个线程都去调用getInstance 就会造成大量的读instance内存的操作 可能会让编译器自动优化,出现内存可读性的问题.第一层判断就会读寄存里的instance值.所以我们需要给instance 加上volatile

image-20220329144853039

至此,这就是最终的线程安全的懒汉模式的单例模式代.

阻塞式队列

什么是阻塞式队列

阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.

阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:

  • 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
  • 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.

阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型.

生产者消费者模型

生产者消费者模型,是实际开发中非常有用的一种多线程开发手段,尤其是在服务器开发的场景中.

假设两个服务器,A和B,A作为服务器直接接受用户的网络请求,B作为应用服务器,来给A提供一些数据

image-20220329150334368

如果使用生产者消费者模型,就可以降低这里的耦合

image-20220329150418793

以上就体现了生产证消费者模型的第一个优点:能让多个服务器程序之间充分的解耦合

image-20220329150555418

image-20220329150642904

生产者消费者模型的第二个优点:能够对请求进行削峰填谷

“削峰”:这种峰很多时候不是持续的,就一阵,过去就恢复了

“填谷”:B依然可以按照原有的频率来处理之前积压的数据

在实际开发中,“阻塞队列” 并不是一个简答的数据结构,而是一个/一组专门的服务器程序,并且他提供的阻塞队列的功能,还会在一定的基础上人提供更富哦的功能(对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板…)

这样的队列又有一个新名字,叫做"消息队列"

标准库中的阻塞队列

image-20220329152109557

标准库中阻塞队列的基本用法

在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.

  • BlockingQueue 是一个接口. 真正实现的类是LinkedBlockingQueue.
  • put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
  • BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性

认识了标准库中的阻塞队列,自己实现一个基本的阻塞队列

阻塞队列的基本实现

  1. 实现一个普通的队列

    class MyBlockingQueue{
        private int[] data= new int[1000];
        private int head = 0;
        private int tail = 0;
        private int size = 0;
    
        public void put(int value){
            if(size == data.length){
                return;
            }
            data[tail] = value;
            tail++;
            if(tail >= data.length){
                tail = 0;
            }
            size++;
        }
        public Integer take(){
            if(size == 0) {
                return  null;
            }
            int ret = data[head];
            head++;
            if(head >= data.length){
                head = 0;
            }
            size--;
            return  ret;
        }
    }
    
  2. 加上线程安全

    通过看put和take两个方法,发现方法内的每一行代码都是在操作公共变量,既然如此,就将整个方法加锁即可.

    class MyBlockingQueue{
        private int[] data= new int[1000];
        private int head = 0;
        private int tail = 0;
        private int size = 0;
        private  Object locker = new Object();
    
        public void put(int value){
            synchronized (locker){
                if(size == data.length){
                    return;
                }
                data[tail] = value;
                tail++;
                if(tail >= data.length){
                    tail = 0;
                }
                size++;
            }
    
        }
        public Integer take(){
            synchronized (locker){
                if(size == 0) {
                    return  null;
                }
                int ret = data[head];
                head++;
                if(head >= data.length){
                    head = 0;
                }
                size--;
                return  ret;
            }
        }
    }
    
  3. 加上阻塞

关键要点就是使用wait和notify机制

对于put来说,阻塞条件就是队列为满

对于take来说,阻塞条件就是队列为空

class MyBlockingQueue{
    private int[] data= new int[1000];
    private int head = 0;
    private int tail = 0;
    private int size = 0;
    private  Object locker = new Object();

    public void put(int value) throws InterruptedException {
        synchronized (locker){
            if(size == data.length){
                locker.wait();
                //return;
            }
            data[tail] = value;
            tail++;
            if(tail >= data.length){
                tail = 0;
            }
            size++;
            locker.notify();
        }

    }
    public Integer take() throws InterruptedException {
        synchronized (locker){
            if(size == 0) {
                //return  null;
                locker.wait();
            }
            int ret = data[head];
            head++;
            if(head >= data.length){
                head = 0;
            }
            size--;
            locker.notify();
            return  ret;
        }
    }
}

定时器

就像一个闹钟,进行定时,在一定时间之后,被唤醒执行某个之前设定好的任务

标准库中的定时器

  • 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
  • schedule(安排) 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).

image-20220329174927194

Timer里面有专门的线程,来执行注册的任务

实现定时器

Timer内部需要的东西:

  1. 描述任务:创建一个专门的类来表示一个定时器的中的任务(TimerTask)

image-20220329180111384

  1. 组织任务:使用一些数据结构把一些任务给放在一起

image-20220329180325243

这里使用优先级队列PriorityQueue进行组织

但是我们的队列可能要被多线程调用,考虑到线程安全问题,可能在多个线程里进行注册同时还有一个专门的线程来取任务,此处的队列就需要注意线程安全问题

PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

image-20220329180747706

这是一个带有优先级,同时带有阻塞队列

image-20220329181216911

此时,第二部分的工作也完成了

  1. 执行时间到了的任务

需要执行时间最靠前的任务,就需要一个线程,不停的检查当前优先级队列的队首元素,看看说当时最靠前的这个任务的时间是不是到了

image-20220329182052553

我们通过在构造方法里创建一个线程,不断检查时间有没有到执行时间.

最后运行试一下:
image-20220329182236746

错误原因:MyTask不能够被转换为Comparable

刚写的MyTack这个类里面的比较规则并不是默认就存在的,需要我们手动指定,按照时间大小来比较

标准库中的集合列类,很多都是有一定的约束限制的,不能随便拿个类都能往里放

image-20220329182830941

此时的MyTack就符合了比较规则了,通过时间戳进行比较

image-20220329183133089

看似执行完成,其实仍然有缺陷,

image-20220329183454631

使用wait而不是用sleep的原因:

sleep不能中途唤醒

wait可以中途唤醒

在等待过程中,可能要插入新的任务,新的任务可能出现在所有任务的最前面~~

所以我们还需要在schedule中加上notify

最后的完整代码为:

class MyTask implements Comparable<MyTask> {
    //任务具体需要干啥
    private Runnable runnable;
    //任务具体啥时候干,保存任务要执行的毫秒级时间戳
    private long time;
    //after是一个时间间隔,不是绝对时间戳的值
    public MyTask(Runnable runnable, long after){
        this.runnable = runnable;
        this.time = System.currentTimeMillis()+after;
    }

    public void run(){
        runnable.run();
    }
    public long getTime(){
        return this.time;
    }

    @Override
    public int compareTo(MyTask o) {
        return (int)(this.time - o.time);
    }
}
class MyTimer{
    private Object locker = new Object();
    PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable,long delay){
        MyTask myTask = new MyTask(runnable,delay);
        queue.put(myTask);
        //每次任务插入成功之后, 都唤醒一下扫描线程, 让线程重新检查一下队首的任务看是否时间到要执行~~
        synchronized (locker){
            locker.notify();
        }
    }

    public MyTimer(){
        Thread t = new Thread(()->{
           while(true){
               try {
                   MyTask task = queue.take();
                   long curTime = System.currentTimeMillis();
                   if(curTime < task.getTime()){
                        queue.put(task);
                        synchronized (locker){
                            locker.wait(task.getTime() - curTime);
                        }
                   }else{
                       task.run();
                   }
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
    }
}

线程池

进程比较重,频繁创建销毁进程,开销大,解决办法:进程池或线程

线程比较轻量,但是如果频繁创建销毁的频率进一步增加,仍然会发现开销还是有的,解决办法:线程池或协程

线程池的好处

我们将线程创建出来,放在池子里,后面需要线程,直接从池子里取,而不用再从系统这边申请了,线程用完了也不用还给系统,而是放回到池子里.

线程放在池子里,就比从系统那边申请释放来的更快的原因.

操作系统分为用户态内核态

image-20220330095124184

自己写的代码,就是在最上面的应用程序这一层来运行的这里的代码都称为"用户态"运行的代码

有些代码,需要调用操作系统的API,进一步的逻辑在内核中执行

例如:调用一个System.out.println 本质是要经过write系统调用,进入内核中,内核会执行一堆逻辑,控制显示器会输出字符串,在内核中运行的代码,称为"内核态"运行的代码

创建线程,本身就需要内核的支持(创建线程的本质是在内核中搞PCB,然后放在双向链表中),调用的Thread.start其实归根到底,也是要进入内核态来运行的.而放入到池子/从池子中取,这个过程不需要涉及到内核态,就是纯粹的用用户态代码来完成

一般认为,纯用户态的操作,效率要比内核态处理的操作,效率要高很多

标准库中的线程池

ThreadPoolExecutor 它属于java.util.concurrent Java中很多和多线程相关的组件都在这个包中

image-20220330100429622

认识线程池的构造方法

int corePoolSize 核心线程数

int maximumPoolSize 最大线程数

为了更好的了解,我们将核心线程数比作是公司的正式员工的数量,最大线程数比作是正式员工和临时员工的数量 将一个线程池比作一个公司,正式员工允许摸鱼,临时工不允许摸鱼.

开始的时候,假设公司要完成的工作不多,正式工就可以搞定,就不需要临时工

如果公司的工作突然猛增,正式员工加班也搞不定,就需要雇佣一些临时工,过了一段时间,工作量又降低了,现在的活正式员工又可以搞定了,甚至还有富余(正式员工可以摸鱼了,临时工更可以摸鱼了) 于是就把临时工给辞退了

long keepAliveTime 允许临时工摸鱼时间

TimeUnit unit 时间的单位(s,ms,us)

BlockingQueue<Runnable> workQueue 任务队列,线程池会提供一个submit方法让程序员把任务注册到线程池中,加入到任务队列中

ThreadFactory threadFactory 线程工厂,线程是怎么被创建出来的

RejectExecutionHanler hanler 拒绝策略

虽然线程的参数这么多,但是使用的时候最终要的参数,还是第一组参数,线程池中线程的数量

有一个程序,这个程序要并发的/多线程来完成一些任务 如果使用现车池的话,这里的线程设置为多少合适?

要通过性能测试,找到合适的值

例如,写一个服务器程序,服务器通过线程池,多线程用户请求~
就可以对这个服务器进行性能测试,比如构造一些请求,发送给服务器.要测试性能,这里的请求就需要构造很多,比如每秒发送500/1000/2000…根据的业务场景, 构造一个合适的值~~

根据这里不同的线程池的线程数来观察程序处理任务的速
程序持有的CPU占用率
当线程数多了,整体的速度会变快,但是CPU占用率也会高.
当线程数少了,整体的速度会变慢,但是CPU占用率也会下降.
需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点
不同类型的程序,因为单个任务,里面 CPU 上计算的时间和阻塞的时间是分布是不相等的
因此光去拍脑门出来一个数字往往是不靠谱~~

标准库中还提供了一个简化版的线程池Executor ,本质是针对ThreadPoolExecutor进行封装,提供了一些默认参数

Executors 创建线程池的几种方式

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

image-20220330103127466

实现线程池

  1. 先能够描述任务 (使用Runnable)

  2. 需要组织任务 (使用BlockingQueue)

  3. 能够描述工作线程

  4. 需要组织这些线程

  5. 需要实现,往线程池里添加任务

class MyThreadPool{
    //1. 描述一个任务,直接使用Runnable,不需要额外一个类
    //2. 需要一个数据结构,来组织若干个任务
    private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
    //3. 描述一个线程,工作线程的功能就是从工作队列中取出线程并执行
    static class Worker extends Thread{
        //当前线程池中有若干Worker线程,这些线程内部 都持有上述的任务队列
        private  BlockingDeque<Runnable> queue = null;

        public Worker(BlockingDeque<Runnable> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            // 就需要拿到上面的队列
            while(true){
                try {
                    //循环获取任务队列的任务,获取之后就执行任务
                    //如果队列为空,直接阻塞,队列非空,获取到里面的内容
                    Runnable runnable = queue.take();
                    runnable.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    //4. 创建一个数据结构组织若干线程
    private List<Thread> workers = new ArrayList<>();
    public  MyThreadPool(int n ){
        //创建若干线程,放到上述数组中
        for (int i = 0; i < n; i++) {
            Worker worker = new Worker(queue);
            worker.start();
            workers.add(worker);
        }
    }
    // 5 创建一个方法,允许程序员放任无到线程池中
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值