阻塞队列+定时器+常见的锁策略

1)阻塞队列:是一个线程安全的队列,是可以保证线程安全的

1.1)如果当前队列为空,尝试出队列,进入阻塞状态,一直阻塞到队列里面的元素不为空

1.2)如果当前队列满了,尝试入队列,也会产生阻塞,一直阻塞到队列中的元素不为满为止

1.3)所以在Java的标准库中内置了一个BlockingQueue(是一个接口)这样的类来实现阻塞队列这样的功能,它的用法与普通的入队列和出队列很相似,没有取队首元素的操作;

1.4)Java.util.concurrent这个包里面包含了很多与多线程并发相关的组件操作,简称JUC

  BlockingQueue<String> blockingQueue=new LinkedBlockingQueue<>();//基于链表来实现,可以指定阻塞队列的大小
  blockingQueue.put("hello");
  String str=blockingQueue.take();

阻塞队列的知识点补充:

1)add方法和offer方法可以将指定的元素放到BlockingQueue里面,此时阻塞队列可以容纳,那么直接返回true,否则直接返回false,不会使阻塞队列阻塞

2)但是put方法也是将我们制定的元素存放到blockingQueue里面,如果说这个阻塞队列没有空间那么调用该方法的线程会阻塞等待

3)poll(time):取出BlockingQueue排在首位的元素,如果不能立即取出,那么会等到time规定的时间内取,规定时间到还没有取到那么直接返回null;

4)take():取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到Blocking有新的对象被加入为止

5)BlockingQueue不接受null 元素,试图add、put 或offer 一个null 元素时,某些实现会抛出NullPointerException,null 被用作指示poll 操作失败的警戒值;

  BlockingQueue<String> queue1=new ArrayBlockingQueue<String>(10);
  BlockingQueue<String> queue2=new LinkedBlockingQueue<String>(10);
  BlockingQueue<String> queue3=new PriorityBlockingQueue<>(10);      

1)手动实现一个阻塞队列:

在实现循环队列的时候,有一个重要的问题,如何判断使空队列,还是满的队列?

1)head==tail来进行判断,这是并不靠谱的

由于一直进行插入元素,导致的head==tail,就说明是队列满了

由于一直进行删除元素,导致的head==tail,就说明此时队列是空的

所以我们这么做:size=0就是空,size==数组长度就是满,在这里,必须要加一个锁对象,给谁加锁就锁哪一个对象

2)数组实现队列,就是一个循环队列,我们用[head,tail)这个范围来表示数组的一个有效元素范围

3)当head或者tail到达数组元素的末尾之后,就需要从头开始,重新进行循环

进行入队列:就是把新的元素放到tail位置上面,并且让tail++(元素不满)

进行出队列:就是把随手元素取出来,让head++(元素不为空)

1)可以浪费一个格子,直接浪费,head==tail认为是空,head=tail+1认为是满

2)可以是用一个变量来进行记录元素的个数,size==0认为是空size==array.length认为是满

  public static void main(String[] args) {
            myqueue queue=new myqueue();//作为交易场所
            Thread t1=new Thread(){//搞一个这样的线程作为生产者
                public void run()
                {
                    for(int i=0;i<1000;i++)
                    {
                        try{
                            queue.put(i);
                            System.out.println("生产元素生产了"+i+"个");
                             sleep(1000);/每秒钟生产一个元素
                        }catch(InterruptedException e)
                        {
                           e.printStackTrace();
                        }
                    }
                }
            };
            t1.start();
        Thread t2=new Thread() {//搞一个这样的线程作为消费者
            public void run() {
                while (true) {
           //频繁取队首元素
                    int num = queue.take();
                    System.out.println("消费元素为" + num);
                }
            }

        };
        t2.start();
    }
}
public class MyBlockingQueue {
    public int[] array=new int[10];
    public int tail=0;
    public int head=0;
    public int count=0;
    Object object1=new Object();
    Object object2=new Object();
    public void put(int data) throws InterruptedException {
        synchronized (Object.class){
            if(count==array.length){
                object1.wait();
            }
            array[tail]=data;
            tail++;
            count++;
            if(tail==array.length){
                tail=0;
            }
        }
    }
    public int take() throws InterruptedException {
        int result=0;
        synchronized (Object.class){
            if(count==0){
                object2.wait();
            }
            result=array[head];
            head++;
            count--;
            if(head==array.length){
                head=0;
            }
            object1.notify();
        }
        return result;
    }
}
数组实现队列,就是一个循环队列
入队列,就是把新的元素放到tail位置上,并且tail++;
出队列,就是把队首元素取出来,也就是说把head位置的元素返回回去,如果是引用数据类型,要手动置为空,并且head++;
class MyQueue
    {
//保存数据的本体
        Object object=new Object();
        private int []arr1;
//队首元素下标
        private int head=0;
//队尾元素下标
        private int tail=0;
//有效数据元素的个数
        private int count=0;
        MyQueue() {
            this.arr1 = new int[1000];
        }
        public void put(int data) {
            synchronized (object) {
                if (count == arr1.length) {
//此时队列中的值已经满了
//此时的条件,最好写成while
                    因为有可能会出现第一个线程放入元素后,第二个线程又继续放,就有会放满的情况,使用while的目的是为了让wait唤醒之后,再次去判断一下条件是否成立;
                    try{
                        object.wait();
                    }catch(InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                }
                arr1[tail] = data;
                tail++;
//处于tail到达数组末尾的情况
                if (tail == arr1.length) {
                    tail = 0;
                }
//上面的这个条件判定可以写成tail=tail%array.length
                count++;
//我们put成功了,就可以进行唤醒take中的wait操作,因为此时队列一定是不为空的
                object.notify();
            }
        }
        public int take(){
            synchronized (object)
            {
                if (count == 0) {
//head==tail有一个元素,count=0一个元素都没有
                    try {
                        object.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                int ret = arr1[head];
                head++;
                if (head == arr1.length) {
                    head = 0;
                }
//我们take成功了,就可以唤醒put中的wait,因为此时队列一定不为满
                count--;
                object.notify();
                return ret;
            }
        }
    } 

1)要是想要这个队列支持线程安全,一定要保证在多线程环境下面调用这里面的put和take是没有任何问题的;

1.1)看了这里面的代码之后,put和take里面的每一行代码都是在操作公共的变量可以给整个方法来进行加锁或者是通过同步代码块的方式,指定this来加锁,或者指定一个专门的锁对象,锁这个对象和调用wait都是这个对象,所以说要是想保证线程安全,就需要进行使用synchronized来将若干个非原子性的操作,打包成原子性的操作

如果说要是想精准唤醒某一个线程,就需要使用不同的锁对象:

1)要是想唤醒t1,就必须o1.notify(),让t1进行o1.wait()

2)要是想唤醒t2,就必须o2.notify(),让t2进行o2.wait()

2)要想实现阻塞效果,我们就需要搭配对象等待集来进行使用

2.1)我们使用哪一个对象来进行加锁,就需要使用哪一个对象来进行wait操作,如果是针对this加锁就使用this.wait();

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

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

2.4)put中的wait要靠take来进行唤醒,条件是队列为满,只要队列不为满,只要我们take成功取走了一个元素,队列不就不为满了吗,就可以唤醒了

2.5)take中的wait要靠put来进行唤醒,条件是队列为空,只要我们的队列不为空,也就是说只要我们put成功放进去了一个元素,队列不就不为空了吗,就可以唤醒了

2.6)当前代码中,put操作和take操作两种操作不会同时wait,等待条件是截然不同的

注意:tail=tail%array.length这种写法十分的不建议

1)这种写法非常的不直观

2)取%操作,这一种操作对于计算机来说的开销是非常大的,相当于除法操作,比较操作就是一个跳转指令;既不利于提高开发效率,也不能提高运行效率

1)生产者生产元素的速度是小于消费者消费的速度

2)put操作和take操作有可能都会出现阻塞的情况,但是此时由于这两个代码中的阻塞条件是对立的,因此我们两边的wait不会同时触发

put操作就会唤醒take的阻塞,put操作就破坏了take的阻塞条件

take操作就会唤醒put的阻塞,take操作也就破坏了put的阻塞条件

3)下面的if操作最好换成while操作,如果是多个线程出现阻塞等待的时候,万一同时唤醒了多个线程,就很有可能出现,第一个线程放入元素,第二个线程又放的时候,就会出现满的情况,所以我们使用while就是为了让wait被唤醒之后,再次确定一下条件是否成立

4)如果说有人等待,那么notify是可以唤醒的,如果说没有等待,那么notify没有任何副作用 

2)关于生产者消费者模型的理解: 

生产者消费者模型,拿一个包饺子的例子来说

第一种方式:每一个人,擀完一个饺子一个包一个饺子,擀一个饺子包一个饺子,但是擀面杖只有一个,就会导致锁的冲突比较激烈,况且提高了门槛,只有先获取到这把锁才能进行擀饺子皮,其他人获取不到这把锁,就会阻塞等待,整体的效率并不会很高
第二种方式:一个人负责擀饺子,这个人擀出一堆皮,三个人负责取出这些皮,进行包饺子
1)生产者:擀皮的人;
2)消费者:包饺子的人;
3)交易场所:盖帘(放饺子皮的盖帘);

擀饺子皮的人就是饺子皮的生产者,要进行源源不断地生成饺子皮

包饺子的人就是及饺子皮的消费者,我们要不断地进行使用和消耗饺子皮

上述模型中一般生产者也只有一个,盖帘是交易场所,消费者确实有很多个

在计算机中,生产者就是一组线程,消费者是另一组线程,阻塞队列就是生产者消费者模型中的交易场所

所以说在我们平时写代码的过程中,代码一定要高内聚,低耦合

高内聚的意思就是说:希望不同模块之间,联系要尽量的少, 所以说我们使用生产者消费者模型就可以降低这里面的耦合

最大的用处解耦合:写了两个代码,一个代码中的两个代码块的关联关系很复杂,这样耦合就比较高,两个模块的关联关系尽量小,简单,整体的代码是可以相互理解的,耦合比较低,

1)例如A要传输一定的数据给B,如果直接传输,此时就要求,要么是A向B传输数据,要么是B向A拉取数据,都是需要A和B进行相互交互的,A和B之间存在着一些关联关系,如果B挂了,A也就会有太大的影响;

2)在开发A代码的时候就必须充分了解B提供的一些接口,开发B代码的时候要充分了解A是怎么调用的;

3)未来如果需要进行扩展,扩展也搞一个C,让A也给C传输数据,这个改动就可能比较复杂,因为本来是A和B进行传输的,多了一个C,那么就是A想C传输数据,或者是C和B来向A拉取数据,改动比较复杂,就认为A和B的耦合比较高,B挂了,对A没啥影响

1)A把数据写到队列里面,B再从队列里面取出元素进行消费,

2)A不知道数据要发送给谁,只需要向队列中添加元素,B不知道这个数据是谁发送过来的,只需要从队列中取元素即可

3)如果在后面需要进行扩展(再来C),也不需要直接从A要元素,只需要在队列中取出元素即可,这个阻塞队列就好似于变成了中转站一样的东西;
4)这样我们就做到了,让生产者和消费者可以不知道他们彼此之间是谁,这个数据是谁生产的,是谁消费的,都不重要,能生产,能消费就可以了,这样还是我们的系统变得更加灵活,可以随意替换A,B,C的任意一个模块,修改更方便,耦合耕地,让代码程序的维护性变得更高;

2)销峰填谷:
我们来举一个三峡大坝的例子
汛期:如果没有大坝,那么到了雨季,那么下游的水就会很大,就会造成水灾可能会发生灾难
罕期:如果没有大坝,那么下游的水就很少,有可能就会发生旱灾
于是就有了大坝:
1)汛期:关闸蓄水,并不是全部关了,让水按照一定的速率向下流,有节奏,按照一定的速率来进行放水,避免突然一波把下游带走(当突然下暴雨的时候);
2)罕期:开闸放水,让水也按照一定的速率向下流,避免下游太缺水,避免造成旱灾;

3)首先我们让消费者消费得快一些,让生产者生产的慢一些,此时我们会看到,消费者线程会阻塞等待,每当有新的生产者的元素时,消费者才会执行;

4)但是如果消费者消费得慢一些,让生产者生产的快一些,就会出现,一开始大量的迸发出生产元素,等到生产了100多个才开始消费;(代码是第二种情况

1)互联网上面过来的请求数量,是多还是少,是不可控的;突然来了大量请求,如果没有入口服务器,这些什么商家服务器,直播服务器就有可能会挂掉,操作数据库,效率比较低,况且需要的系统资源可能会更多,如果主机的硬件不够,程序就有可能直接挂了,咱们的入口服务器一般是不会垮掉的,因为入口服务器是不会不会处理数据请求的

2)通过一个队列来进行缓存请求,建立一个生产者消费者模型,此时即使网络这边过来一大波请求,这些请求只是冲击了队列服务器,对于后续的业务服务器,仍然是按照固定的速率来消费数据,阻塞队列是没有什么计算量的,就单纯的存个数据,就能抗住更大的压力

3)实际上,网管是不可以直接与各个服务器进行进行相连的,通过一个队列来实现生产者消费者模型

1)现在即使说现在网络上面过来了一大波请求,此时这些请求指示冲击了队列服务器,但是对于后面的业务服务器,任然是以固定的速率来进行消费数据,如果互联网这边的请求少了,后面的这些服务器也不会闲着,就会把之前队列积压的数据,来取出来进行处理

2)这里此时的请求的压力直接给到了阻塞队列这里面,此时针对我们的队列的请求是暴涨的,但是我们的阻塞队列没有做过多的计算,没啥计算量,就是单纯的存储一个数据,它是可以承受住一定的压力的;

咱们在实际开发中使用到的阻塞队列并不是一个简单的数据结构,而是一组专门的服务器程序,它所提供的功能也并不仅仅是阻塞队列的功能,还会在这上面的队列中增加新的功能,比如说对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便于配置参数,这样的队列又起了一个新的名字,叫做消息队列,但是本质上还是阻塞队列的功能,kafak,mq就是业界常见使用的消息队列

2)定时器:​​​​​​​

1)前面学习过的阻塞队列,相比于普通的队列线程安全,相比于普通的队列起到一个更好的阻塞效果

2)虽然使用阻塞队列,可以达到销峰填谷这样的一个效果,但是峰值中有大量的数据涌入到队列中,如果后续的服务器消费很慢的话,队列中的某些元素就会滞留很久,那么此时就可以使用定时器,让滞留太久的请求直接消费掉;

3)定时器就是类似于一个闹钟,我们可以进行定时,在一定时间之后,就可以被唤醒并执行之前我们已经设定好的某一个任务

4)咱们的join可以指定超时时间,咱们的sleep也可以指定休眠时间,他们都是依靠当前系统中内部的定时器来进行实现的

5)系统的定时任务是依靠java.util.Timer包底下的,核心方法只有一个schedule;

咱们的TimerTask这个类是一个抽象类,这个抽象类的底层实现了Runnable接口

        Timer timer=new Timer();
        timer.schedule()里面有两个参数,一个是描述一个任务,一个参数是多长时间后执行这个任务
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("触发定时器");
            }
        },3000);
    

一个定时器里面是可以安排很多任务的,这些任务就会按照时间,谁先到了时间,就会先执行谁

1)描述任务:使用一个runnable来进行描述任务

2)组织任务:就需要有一个数据结构,把这里面的很多任务给放到一起
2.1)在定时器里面要有工作线程不断进行扫描是从一大堆任务中找到那个最先要到点的任务,使用优先队列,最好使用带有阻塞队列的数据结构,为了线程安全;(数组还需要遍历)

2.2)谁时间越靠前,我们就把这个任务放到最前面,快要到时间的时候,我们再把这个任务取出来,执行就可以了
2.3)此处最好使用带阻塞功能的优先队列,这有可能涉及到多线程操作,就可以保证线程安全了

3)定时器里面提供一个schedule方法,这个方法就是向的阻塞队列里面添加元素

4)还需要让Timer内部有一个工作线程,我们就需要让这个线程可以一直去扫描队首元素,看看队首元素是否到点了,如果到点,那么就执行这个任务,如果没有到点,就需要把这个队首元素塞回到队列里面,继续进行扫描

Timer内部都需要啥东西? 

1)管理很多的任务

2)使用线程扫描执行时间到了的任务

啥叫管理任务?

管理任务,就是通过描述+组织的形式,描述是通过一个类来描述一个任务的属性,组织是通过一定的数据结构将这些任务放到一起

1)直接创建一个Task类(与其他代码没有耦合),这个类就表示了的定时器中的一个一个的具体任务,这个类描述了具体的要执行什么任务?多长时间执行?如何执行这个任务的工作内容?这个类的实例最后是阻塞队列中的一个一个的元素

建立一个Task类,直接传一个runnable来描述这个任务(任务内容在runnable里面),以及要执行的时间,后续再把这个任务放到阻塞队列中,从阻塞队列中拿出来的时候调用Task里面的run方法来执行里面的任务即可,时间要写一个毫秒级的时间戳,Task里面有run方法,在这里面直接执行runnable里面的方法;

2.创建一个Timer类,里面包含了阻塞队列(将所有任务放到阻塞队列里面来进行保存),还有schedule方法,作用就是说根据传入的Runnable接口和指定时间创建一个Task类并把这个Task类装到阻塞队列里面,相当于是注册一个任务

2.1)假设我们现在有多个任务过来了,10小时之后去做作业,11个小时之后去上课,10分钟之后去休息一会,当我们进行安排任务的时候,这些任务的顺序是无序的,但是当我们执行任务的时候,这就不是无序的了,我们的需求就是在我们能够安排的任务里面可以找到时间最小的任务,我们就需要创建一个小根堆,所以说在咱们的JAVA标准库里面就有一个专门的数据结构叫做PriorityQueue(派奥瑞忒),叫做优先级队列,不能使用顺序表或者链表

2.2)里面有一个构造方法,里面就有一个工作线程来进行循环扫描定时器里面的任务(扫描阻塞队列中的队首元素),对于构造方法来说,我们可以这么想,每当我们启动一个定时器的时候,这个定时器都会创建一个工作线程,来进行循环扫描阻塞队列里面的任务来进行执行

3.有一个工作线程,来循环进行扫描阻塞队列中的任务,循环取出队首元素中的任务来进行和当前时间进行对比,看看时间是否已经到了,看看是否要执行这个任务,要进行循环扫描,如果时间到了,就执行,时间不到,就把这个元素带回去

import javafx.scene.layout.Priority;
import java.util.concurrent.PriorityBlockingQueue;
class HelloWorld {
   static Object object1=new Object();
    static class Task implements Comparable<Task> {
        public long time;//任务具体什么时候执行
        Runnable runnable;//表示任务具体要干啥
        public Task(Runnable runnable, long after) {
            this.runnable = runnable;
            this.time = after + System.currentTimeMillis();
        }
        public void run() {
            runnable.run();
        }
        public int compareTo(Task o) {
            return (int) (this.time - o.time);
        }
    }
//我们进行创建一个扫描线程,这个扫描线程的主要作用就是说扫描阻塞队列,看看这个任务是不是到时间就可以进行执行
    static class Worker extends Thread {
        PriorityBlockingQueue<Task> queue = null;
        public Worker(PriorityBlockingQueue<Task> queue) {
            this.queue = queue;
        }
        public void run() {
            while (true) {//这里我们是需要进行循环扫描的,因为我们在不停的向定时器里面添加任务,队首可能在不断发生变化
                try {
//去除阻塞队列里面的元素看看执行时间是否到了
                    Task task = queue.take();
                    long currenttime = System.currentTimeMillis();
                    if (task.time > currenttime) {
//这就表示当前队列的队首元素的时间还没有到,我们暂时先不执行,前面的take操作会把队首元素删除掉,这种情况是不可以进行删除的,我们需要重新插回到队列里面
                        queue.put(task);
                        synchronized (object1){
                            object1.wait(task.time-currenttime);
                        }
                    } else{
                        task.run();
                    }
                } catch(InterruptedException e){
                    e.printStackTrace();
//如果说我们这个循环被异常退出了,此时我们break就可以结束循环
                    break;
                }
            }
        }
    }
    static class Timer {
        PriorityBlockingQueue<Task> queue=new PriorityBlockingQueue<>();
        public Timer()
        {
            Worker worker=new Worker(queue);
            worker.start();
        }
        void schedule(Runnable runnable,long after)
        {
            Task task =new Task(runnable,after);
            queue.put(task);
            synchronized (object1)
            {
                object1.notify();
            }
        }
    }
    public static void main(String[] args) {
        Timer time = new Timer();
        time.schedule(new Runnable() {
            public void run() {
                System.out.println("任务正在执行");
            }
        }, 200);
    }
}

1)此时所进行选取的队列一定要进行注意线程安全问题,可能会在多个线程中增加任务这样子的操作,同时我们还有一个专门的线程来进行取出任务执行,此时的队列就需要进行注意线程安全问题,下面的这个队列带有有优先级又具有阻塞效果

BlockingQueue<Task> queue=new PriorityBlockingQueue<>()

2)在这里面其实的take()操作其实是不如peek()操作高效的,因为我们每一次take()操作都会取出队首元素,并且把整个堆继续向下调整成小堆,这里面的时间复杂度就是O(logN)

3)咱们目前是把这个元素放到优先级队列里面,而我们的优先级队列的内部是一个堆,是堆的话我们就需要进行调整,而调整就需要知道元素之间的大小关系,而随便写一个类,他们的大小关系是不明确的,像咱们之前写的普通的一个Task类,他的比较规则并不是默认就存在的,这个需要我们手动指定按照时间来进行比较大小;

4)当使用PriorityBlockingQueue对队列的元素进行排列的时候,就会把所有的元素向Comparable这个方向来进行转换,转换之后再调用compareTo方法来进行比较,此时发现无法转化成功,转化不成功,就会出现类型转化异常

1)其实这个代码加锁解决一个很严重的问题,那就是忙等问题,例如现在队列中有一个元素,他的执行时间是8:30;

2)当前时间8:01,时间还没到,取出队首元素又放回去,又看了一眼时间,显然这个循环不会执行当前的任务,代码中又会把这个任务放回到阻塞队列里面了,这个时候这个循环会转得非常快,1s能转几万次,就会白白的浪费CPU,虽然CPU在干活,但是CPU干的事情没有任何意义,只是进行循环取队列元素,进行比较,然后直接放回去了,如果说当前我们的队列中没有任何元素,那还好,我们的这个线程只是在这里面阻塞了,没有问题,我们怕的就是说任务队列里面的任务不为空,还有队首元素时间和现实时间差很大,一直循环取元素放元素,这样的操作就没有任何意义,还一直吃CPU资源,即没有实质性的工作产出,也没有休息,像这种操作,是非常浪费CPU资源的;

3)8:01发现时间还没到,去除队首元素又放回去,还有一个小时呢,在循环中频繁的取出元素,放回去,这不是频繁占用CPU资源吗?

解决方案:假设当前队首元素是在8:30来进行执行的

Thread.MyTask can not be cast to JAVA.lang.Comparable

​​​​​​​1)如果在8:30之前没有其他Task元素进入队列,就让他一直阻塞等待到执行时间,然后被唤醒,不需要notify,时间到了自动被唤醒,计算当前时间和目标时间的时间差,就让他等待这么长的时间就可以了;

2)如果有Task元素新入队的话(例如插入的时间为8:05),就唤醒wait操作此时出的队首元素就是8:05

既然说了是指定一个等待时间,那么为什么不去使用sleep,而是去使用wait呢?

1)因为咱们的sleep是不可以被中途唤醒的,但是咱们的wait是可以被中途唤醒的

2)因为我们在等待的过程中,可能会插入新的任务,新的任务是很有可能出现在所有任务的最前面的,而我们的sleep操作使唤不醒的,所以我们无法支持最新的任务操作吧,所以我们需要在最新的schedule操作中,我们每一次进行新增任务的时候,都需要进行唤醒操作

3)进行唤醒一下扫描线程,让线程重新检查一下队首的任务看看时间到了是否要执行,有可能我们进行新插入进去的任务比原来队首元素的任务还要靠前呢,如果没到,还是要重新计算计算一下新的等待时间

4)使用wait可以指定一个时间作为参数,可以通过当前时刻和首个任务之间的执行间隔来进行计算,wait的唤醒有哪些条件呢?

1)指定时间进行唤醒,比如说现在有一个7:00的任务,但是现在是6:30,我们就可以通过wait来进行指定时间,时间到了,我们就开始执行这个任务

2)我们可以直接通过notify方法来唤醒wait操作,比如说现在有一个7:00的任务,但是现在此时才6:30,我们是通过wait来进行指定半个小时,但是说如果现在有一个6:50的任务插入了进来,我们就要唤醒wait操作,让我们的线程就重新进行扫描;

5)使用wait就是解决新插入的任务比原有的任务还要往前

 线程等待wait()和通知notify(),主要用于多线程之间的通信协作,而且这两个方法都是属于Object类,说明任何对象都可以调用这两个方法。 当在一个实例对象上调用了wait()方法之后,当前线程就会在这个对象上等待,直到另外的线程调用了该对象的notify()方法,处于等待状态的线程才得以继续进行, 这样,多线程之间就可以用这两个方法进行通信协作了

6)定时器总结:

1)先进行描述一个任务,Runnable+time

2)使用优先级队列来进行组织若干个任务,通过PriorityBlockingQueue

3)通过schedule方法来注册任务到我们的阻塞队列里面

4)创建一个扫描线程,让这个线程可以不停的来进行获取队首元素,并且判定时间是否到达

5)况且说可以让MyTask类支持比较,并且能够解决这里面的忙等问题

三)线程池的引入:

1)进程一般来说是比较重量的频繁创建和销毁进程,开销是很大的,这个时候的解决方案就是:线程池或者线程

2)咱们的线程虽然比进程要轻量很多,但是如果说创建和销毁的频率进一步增加,那么此时我们会发现开销还是很大的,解决方案就是:线程池或者是协程

3)所以说可以把线程提前创建好放到一个池子里面,后面再次想要用到线程直接就从池子里面取出来,就不需要在从系统这边申请了;线程使用完毕之后,也不是直接还给系统,而是说放回到池子里面,以备下次继续使用,这就会比频繁创建线程和销毁线程速度要更快了

为什么线程放到池子里面,比从系统中这边申请来得更快呢?

操作系统里面一共分成了两种状态:

用户态VS内核态

一部分逻辑是应用程序里面执行的,一部分是在操作系统内核里面控制硬件来执行的

1)咱们自己写的代码,就是在最上面的应用程序这一层来进行运行的,咱们在这里面的代码就被称为用户态运行的代码;

2)有些代码,需要我们进行调用操作系统的API,进一步的逻辑就会在内核里面执行,比如说当我们调用System.out.println()的时候,本质上要经过write()系统调用,进入到内核里面,在内核里面执行一堆逻辑,控制显示器输出一个字符串,咱们的上面的那一个简单代码,一部分是咱们的应用程序里面执行的,一部分逻辑就是要在操作系统内核里面控制硬件来去完成的,在内核里面运行的代码,我们就称之为内核态运行的代码;

3)在进行创建线程的时候,本身就需要内核的支持因为我们创建线程的本质就是在内核中创建一个PCB,加到双向链表里面,我们调用的Thread.start()本身就是如此,也是要在内核态来运行的;

4)我们把创建好的线程放到池子里面,因为池子就是用户态来进行实现的,把这个放到池子里面,从池子里面取,这个过程是不需要涉及到内核态,我们只需要单纯纯粹的用用户态代码就可以完成;

内核态的操作:当我们进行调用start()方法的时候,调用内核态代码,就会在操作系统内核里面进行创建出PCB,效率比较低

用户态的操作:当我们用到线程的时候,就从池子里面取,不用线程,就放回到池子里面;

5)所以说,一般我们认为纯用户态的操作,效率要比经过内核态处理的操作,效率要更高;

6)我们线程池里面的线程,是一直保存在线程池里面,不会被内核回收;

7)平常认为内核效率低,并不是真的低,而是代码进入了内核态,就不可控了,内核啥时候可以把活干完,把结果给你,这些都是不可控的;

8)而的线程池中的线程会一直保存在线程池里面,不会被内存回收,会一直等待程序调用;

内核态或者用户态的工作过程就是类似于银行办事:

1)滑稽老铁,自己带着身份证来到大厅的复印机这里来进行复印,这是纯用户态的事情,自己很着急的就进行忙完了,这个操作完全是由自己完成的,整体的过程是可控的

2)我们的滑稽老铁,把身份证交给银行的柜台,让柜员帮助他来进行复印,这个过程就是类似于交给了内核态来进行完成一些工作,这个操作不是自己完成的,整体是不可控的

因为咱们也不知道咱们的柜员到底是去做什么样的工作去了

3)可能说是给你复印去了,因为咱们的操作系统内核也是很忙的,也有可能顺手去做一些其他的事情,比如说,数一下钱,清点一下票据,上个厕所,给女神发一个消息,确实它可以把这个东西给你复印完拿回来,但是你没有办法知道它是否是立即就去复印了,去办你自己想要干的事情

4)不知道这个操作系统内核再复印的过程中又做了哪些其他的事情,你没有办法对这个柜员的行为作出任何的控制的,你不能控制它约束他立刻就去给你进行复印,一旦他从柜台这里消失了,整个过程就处于一个不可控的状态,所以这个柜员可能在一分钟之内就给你复印回来了,也有可能5min回来,也有可能10min回来,还有可能一个小时回来;

1)认为内核态效率低,并不是说真的低,而是代码进入到了内核态,就不可控了,内核有可能会捎带着去完成一些其他的事情;

2)不知道内核什么时候把活干完,把结果给你,有的时候快,有的时候慢

用户态的代码咱们是可控的,但是内核态的代码都是内核实现好的,谁知道他是怎么跑的,不知道他是否除了我们自己想做的工作还是否具有其他的任务,就是十分的不稳定,谁知道什么时候把活干好

class Student implements Comparable<Student>{
    public int age;
    public String name;
    public Student(int age, String name) {
        this.age = age;
        this.name = name;
    }
    @Override
    public int compareTo(Student o) {
        return o.age-this.age;
    }
    @Override
    public String toString() {
        return "Student{" +
                "age=" + age +
                ", name='" + name + '\'' +
                '}';
    }
}
class Solution{
    public static void main(String[] args) {
          Student student1=new Student(19,"李佳鑫");
          Student student2=new Student(20,"李树全");
          Student student3=new Student(21,"李佳伟");
          Student[] studentDemo=new Student[]{student1,student2,student3};
          Comparable<Student>[] students=(Comparable<Student>[])studentDemo;
          Arrays.sort(students);//底层会调用compareTo方法
        System.out.println(Arrays.toString(students));
    }
}

 


1)在我们的优先级队列里面,如果实现了Comparator接口,那么会默认使用这个比较器调用里面的compareTo方法

2)在我们的优先级队列里面,如果说实现了Comparable接口,那么默认会将这种类型转化成Comparable类型

sychronized public void increase()
{
    sychronized(this){//在这里阻塞了,无法释放第一把锁
        count++;
     }
}
进入increase方法后加了一次锁,进入代码块以后又加了一次锁,按理说会出现问题;
因为第一次加锁后,此时对象头的所标记已经是true,线程就要被阻塞等待,等待这个锁标记改成false,才可以继续竞争这把锁,他会内部记录当前的哪个线程持有这把锁

四)常见的锁策略:

加锁是一个开销比较大的事情,希望在特定场景下,针对一些场景进行取舍,好让锁更高效一些,策略,就是解决问题的具体思路,是一个更加通用更加高效的级别;

使用乐观锁:就要引入版本号这个策略,就可以使用这个方案来代替阻塞等待的锁

1)假设当前余额剩了100,也引入一个版本号,verson初始值是1,并且规定,提交版本必须大于当前版本记录才能执行更新余额,如果提交的版本小于当前版本,就说明其他线程已经把这个数据给修改了,直接贸然直接提交就会引起线程不安全,这样的情况我们就应该就放弃修改;

2)把内存的数据读到寄存器里面之后,要先进行修改数据操作,再将版本号加1,然后进行对比两个版本号,内存中的版本号和CPU中的版本号进行比较,然后再进行判断是否要把CPU中修改的数据写回到内存里面;​​​​​​​

3)一开始,线程一和线程二,以及读入的内存中的版本号都是1,当线程一想要修改数据的时候,先将版本号进行加1,就变成了2,此时内存中的版本号是1,发现当前线程1提交的版本号大于内存中的读取的版本号(2>1),就可以进行修改;

4)线程二也想进行修改数据,但是当前线程的版本号是1,内存中的版本号是2,线程二提交数据之前会将版本号加一,变成二,但是当前内存中的版本号是2,提交版本必须大于当前版本才可以执行更新的乐观锁策略(说明之前线程已经把数据进行了修改),所以就认为这次操作失败,不可以进行修改数据;

基于版本号这样的实现,就是基于乐观锁的一种典型实现,这是光改版本号,就是在用户态里面完成即可

1)对于这个机制来说,如果写入失败就需要重试,要是一直重试的话,效率其实就不高,但是我们是把锁放在冲突概率很低的程序中使用的,基本就很少涉及到重试的,之前单纯的互斥的锁(会涉及到线程的阻塞和唤醒),是会涉及到用户态和内核态的切换的;

2)但是这个乐观锁很少涉及到重试,像一些用户量不多的,小门小户的网站,就可以优先使用乐观锁;

3)锁冲突概率比较低,就很少可能会发生版本不符合要求,修改数据失败的情况;

读写锁:介绍一下读写锁(处理锁冲突的态度)VS普通互斥锁

1)有些场景中,本来就是写的情况比较少,读的情况比较多
两个读线程之间,其实不会涉及到线程安全问题,就不必进行互斥
两个写线程之间,其实存在线程安全问题,就存在互斥
2)一个读线程和写线程之间,其实也存在线程安全问题,因此也需要互斥
因此,在实际情况下,就可以根据读和写的不同场景,给读和写分别进行加锁

3)对于普通的互斥锁来说,是只有两个操作的,加锁和解锁,只要两个线程针对同一个对象进行加锁,就会出现互斥现象;

4)但是读写锁,分成了三个操作

4.1)加读锁:如果我们的代码只是针对读操作,就加读锁;

4.2)加写锁:如果代码当中只是进行修改操作,那么就加写锁;

4.3)解锁;

1)正常情况下,多线程之间的同时读取同一个变量不会涉及到线程安全问题,但数据的写入方之间以及读者之间都用到同一个锁,就会产生极大的性能消耗;

2)就是进行读和写操作分别进行加锁,对他们的操作分开了,对于读写锁来说,读锁和读锁之间不会进行互斥,因为多个线程同时读取同一个变量就不会出现线程安全问题,读写锁只存在两种效果写锁和读锁之间存在互斥,写锁与写锁之间存在互斥,多线程同时读取同一个变量,很多场景,都是读多写少;

3)读写锁最适用于读的情况下比较多,写的情况下比较少,一旦进行冲突,避免进行阻塞等待,一旦进行阻塞等待,比较影响程序的效率,很多场景都是读多写少,读的场景就需要进行互斥的,这样程序运行的速度就会更多;

4)这种读写锁比synchronized的效率更好,synchronized对于咱们的无脑并发读操作也是会进行加锁的;

Synchronized不是读写锁,注意,只要涉及到互斥,就会产生线程的阻塞等待,再次唤醒就不知道隔了多久了,因此尽可能减少互斥的机会,这是提高效率的重要途径;

在Java的标准库中,提供了一个类来创建读锁实例和写锁实例

ReentrantReadWriteLock.ReadLock 能够创建一个读锁实例                                                ReentrantReadWriteLock.WriteLock 可以创建一个写锁实例

假设现在有10个线程,t0和t9是写线程,t1到t8是读线程,

1)如果此时 t1和t2两个读线程同时访问数据,两个读加锁并不会互斥,完全并发的执行,就好像从来没有加过锁一样;                                                                                                        2)但是如果是t0和t1线程同时进行访问,此时读锁和写锁之间就会进行互斥,要么是读完在写,要么是写完再读;

3)如果要是t0和t9两个写线程同时访问,那么此时就会出现锁竞争的操作,必须等到一个线程写完了,下一个线程在写;

 重量级锁和轻量级锁(处理锁结构之后)

1)咱们可以认为乐观锁,悲观锁这是原因;

2)重量级锁轻量级锁这是一个结果;

这些锁策略量级锁:加锁解锁开销并不是全部互不相关的,可能也会有部分的重叠

1)重量级锁:加锁解锁开销很大,通常是在操作系统的内核中进行的,悲观锁做的工作往往更多,开销也很大,因此悲观锁很有可能是重量级锁,就是做了更多的事情,开销更大,操作系统中的锁会在内存中做很多的事情,比如说让线程阻塞等待;

2)轻量级锁:加锁解锁开销更少,通常是由用户态来做的,乐观锁做的工作要少一些,开销要少一些,所以乐观锁很有可能是轻量级锁,用户态的代码更可控,也会更高效;

3)乐观锁和悲观锁描述的是应用场景,看锁的冲突概率高不高;但是重量级锁和轻量级锁描述的是,加锁解锁的开销力度大不大;

4)也就是说咱们的乐观锁悲观锁是处理所冲突的态度,也是原因,咱们的重量级锁轻量级锁是处理锁冲突的结果,是我们已经把代码实现好了,你此时发现,这种实现方式有一点重,或者是实现方式比较轻;

1)咱们一般认为在所使用的锁当中,如果说锁是基于内核中的一些功能来进行实现的,比如说调用了操作系统提供的mutex接口,此时一般认为这是重量级锁,因为此时操作系统的锁会在内存中做很多事情,比如说让线程阻塞等待;

2)如果说的锁是在用户态实现的,那么认为是一个轻量级锁,因为用户态的代码更可控,也更高效;

3)加锁这里的互斥能力是怎么来的呢?     

1)归根结底,是CPU的能力,CPU提供了一些特殊的指令,通过这些指令来完成互斥,操作系统内核对这些指令完成了封装,并实现了阻塞等待,CPU提供了一些特殊指令(原子),操作系统对这些指令封装了一层,提供了一个互斥量(mutex)(API接口),像linux就会提供一个mutex接口(这个能力来自于硬件,CPU,操作系统),让用户代码进行加锁解锁;

2)Java中的JVM就会对操作系统提供的mutex再进行封装一层,就形成了synchronized这样的锁,如果当前的锁,是通过内核中的mutex来完成的,这样锁的开销就比较大;

3)但是如果是通过用户态,通过一些其他的手段来完成的,这样的锁的开销就比较小

4)synchronized即是轻量级锁,又是重量级锁,根据场景,来进行自适应;

介绍自旋锁和挂起等待锁 

1)咱们的挂起等待锁,就是通过操作系统内核的一些机制来进行实现的,往往比较重,这是重量级锁的典型实现;

2)而咱们的自旋锁往往就是通过用户态的代码来进行实现的,往往比较轻,这是轻量级锁的一种典型实现;

​​​​​​​

自旋锁的伪代码:
1)while(抢锁失败)
{
}

自旋锁:按照之前的方式,线程在抢锁失败后会进入阻塞等待,会放弃CPU需要等到好久才会被调度自旋锁,如果获取到锁失败就立即尝试再获取到锁,无限循环,直到获取到锁为止,第一次获取到锁失败,第二次的尝试会在极短的时间内到来; 

自旋锁是一种轻量级锁的实现方式:

1)一旦有被释放,就可以第一时间获取到锁;没有放弃CPU,不涉及线程等待和线程调度;

2)锁被其他线程占用的时间太长,就会持续地消耗CPU资源,但是挂起等待的时候不会消耗CPU资源;

公平锁和非公平锁 

正是有这样的需求,才产生了实时操作系统的,线程调度花的时间是可控的,我们平常使用的都不是实时操作系统,synchronized是非公平锁 

引入可重入概念的时候,就要解决死锁问题,让当前的锁记录一下这个锁是当前哪个线程持有的,如果当前发现当前有同一个线程尝试获取锁,这个时候就让代码能够继续运行而不是出现阻塞等待,同时也在这个锁里面维护一个计数器 ,这个计数器就要记录当前这个线程针对这把锁加了几次锁,每次加锁就让计数器++,每次解锁就让计数器减减,直到计数器是0了,此时才真的释放锁,才可以真的让其他线程获取到锁,synchronized就是一个可重入锁

 static synchronized void run1()
    {
       run2();
    }
    static synchronized void run2()
    {
        System.out.println(1);
    }
这个代码会存在很多问题,这两个方法都在针对同一个对象(this)在加锁,run1获得锁加锁成功,接下来执行fun2,尝试获取到锁,但是run2获取到锁的前提是run1先释放锁呀!但是run2方法在run1方法内部一直阻塞等待,run1方法也无法获取到锁,线程此时就很尴尬

ThreadLocal介绍: 

ThreadLocal是用来提供线程内部的局部变量,这种变量在多线程环境下访问(通过get和set来进行访问,这样就可以保证各个线程的变量可以独立于其他线程的变量,这种ThreadLocal的实例一般都是private static类型的,用于关联线程和上下文

所以说ThreadLocal里面提供线程内的局部变量,不同的线程之间不会相互干扰,这样的变量在线程的生命周期中起作用,减少同一个线程内多个函数或者组件之间一些公共变量传递的复杂度         

   public class HelloWorld {
        private String content;
        ThreadLocal<String> local=new ThreadLocal<>();

        public String getContent() {
            String content=local.get();
            return content;
        }

        public void setContent(String content) {
            local.set(content);
            this.content = content;
        }

        public static void main(String[] args) {
          HelloWorld helloWorld=new HelloWorld();
            for(int i=0;i<5;i++)
            {
                Thread thread=new Thread(new Runnable() {
                    @Override
                    public void run() {
             helloWorld.setContent(Thread.currentThread().getName()+"数据");
             System.out.println("_____________________");
             System.out.println(Thread.currentThread().getName()+"---->"+helloWorld.getContent());
                    }
                });
                thread.setName("线程"+i);
                thread.start();
            }

        }
    }

       但是当我们使用synchronized操作的时候,也是可以实现上述效果的:

package com.example.demo;


    public class HelloWorld {
        private String content;
        ThreadLocal<String> local=new ThreadLocal<>();
        public String getContent() {
            return content;
        }
        public void setContent(String content) {
            this.content = content;
        }
        public static void main(String[] args) {
          HelloWorld helloWorld=new HelloWorld();
            for(int i=0;i<5;i++)
            {
                Thread thread=new Thread(new Runnable() {
                    @Override
                   public void run() {
                        synchronized (Object.class) {
                            helloWorld.setContent(Thread.currentThread().getName() + "数据");
                            System.out.println("_____________________");
                            System.out.println(Thread.currentThread().getName() + "---->" + helloWorld.getContent());
                        }
                    }
                });
                thread.setName("线程"+i);
                thread.start();
            }

        }
    }

             ​​​​​​​

 

​​​​​​​

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值