关于单例模式中的多线程

单例模式是我们在使用中常用到的一种设计模式。

那么什么是“设计模式”呢?简单来说,“设计模式”就是一种“套路”/“模板”。按照这个“模板”来书写代码可以完成特殊的开发需求。相当于,我们下象棋时的“棋谱”,按着高手的思路下棋,肯定能占据优势。因此“设计模式”其实就是计算机圈子里大佬总结出的“套路”,大家依据“套路”写代码就能解决特定的开发问题

那么什么又是单例模式呢?单例模式本质上就是保证某个类在一个程序执行过程中,只会有一个实例对象,不会创建多个实例对象。关于整个类的实例方法调用,就只依靠于这一个实例对象。

常见的单例模式模板

一般情况,我们有两种单例模式的模板:“饿汉式”和“懒汉式”。接下来我们分别给大家介绍一下这两种模板,说清楚这奇奇怪怪的名字是如何而来。

首先,我们要先明确一个东西:

  • 单例模式的构造方法是private修饰的,外界不能调用(保证了只能本类中创建的一个实例,外界不能干预创造多个实例对象)
  • 在外部获取使用单例模式的实例对象,是通过该类提供的get方法,来获取单例模式的实例对象

饿汉模式

我们先来看一段“饿汉式”的代码

//单例模式的实现饿汉
class SingletonHungry{
    private static SingletonHungry singletonHungry=new SingletonHungry();
    private SingletonHungry(){

    }
    //饿汉模式是天生线程安全的
    //由于我们获取单例对象时,是通过调用getSingleton的方法
    //饿汉模式只有return操作,相当于一次读取变量的操作
    //对于多线程操作读取变量操作,多个线程一起读取是没有问题的(同时return是一个原子性的操作)
    public static SingletonHungry getSingleton(){
        return singletonHungry;
    }
}
public class demo24 {
    public static void main(String[] args) {
        SingletonHungry s1=SingletonHungry.getSingleton();
        SingletonHungry s2=SingletonHungry.getSingleton();
        System.out.println(s1.equals(s2));
    }
}

可以看到,我们创建的两个实例对象本质是同一个实例对象,满足了单例模式的要求。

再来分析一下,为什么叫“饿汉式”。

可以看到我们在类的开头,就创建了一个实例对象,后续的get方法直接就是返回这个实例方法(就像一个饿汉,看见食物就急不可耐地直接开吃,我们也是直接就创建了实例对象)。

懒汉模式

同样我们也通过一段代码来了解

//单例模式懒汉式
class SingletonLazy{
    private static SingletonLazy singletonLazy;
    private SingletonLazy(){

    }

    public static SingletonLazy getSingletonLazy() {
        if(singletonLazy==null){
            singletonLazy=new SingletonLazy();
        }
        return singletonLazy;
    }
}
public class demo25 {
    public static void main(String[] args) {

        SingletonLazy s1=SingletonLazy.getSingletonLazy();
        SingletonLazy s2=SingletonLazy.getSingletonLazy();

        System.out.println(s1.equals(s2));

    }
}

通过代码执行的结果,我们能发现获取到的实例对象依旧是同一个,满足了单例模式的要求

接下来,我们解释一下为什么叫做“懒汉模式”

可以看到我们单例类的实例对象是在get方法调用时创建的(同时要先判断是否已经创造过了,避免创建多个实例对象),我们是在“要使用时,才创造”,因此比较“懒”,等到迫在眉睫时,才执行,因此称为“懒汉模式”。

一般来说,我们更推荐使用“懒汉式”。正是由于“懒汉式”的特性:“使用时,才会创造”,这样会节省很多的资源,减少我们cpu的资源占用,提高效率。

关于两种单例模式线程安全的讨论

在多线程介绍的基础篇中,我们有谈到引发多线程安全问题的原因之一“多个线程对同一个对象进行修改操作”。因为我们单例模式天生就只有一个实例对象,因此很有可能会引发“多线程安全”问题。我们在此对两种模板进行分析,讨论多线程的安全问题。

首先通过理论上的分析,我们得出了结论:

“饿汉式”是天生线程安全的,“懒汉式”会引发线程安全问题。


那么我们来看一看多线程在怎样的一个时间线下会导致我们的单例模式出现问题

可以看到,在线程随机调度执行的情况下,由于创建实例对象的这整个过程中的操作,并不是原子的,因此可能造成紊乱,从而在这种时间线下,创建了两个实例对象,虽说后续因为后者替代了前者,前者没有指向对象后(自动销毁),但是我们创造实例对象就一定会浪费资源,就会降低我们的执行效率,因此这种情况需要我们避免。

回想我们上一篇文章中如何解决该问题的???对!我们可以使用锁,更改后代码如下:

class SingletonL{
    private static  SingletonL instance;
    private static Object locker=new Object();
    private SingletonL(){

    }
    public static SingletonL getInstance(){
        if(instance==null) {
            synchronized (locker) {
                instance = new SingletonL();
            }
        }
        return instance;
    }
}

加上锁后,由于锁的互斥特性,就会先执行完当前锁过程,再解锁给另一个:

由此我们就解决了这个问题


聪明的你或许还会想到一个问题,如果我们遇到下列这种时间线该怎么办呢?

很明显,我们这种情况是在判断完之后,但是被其他线程插入后,继续执行先前的操作,(但是可能已经创建好实例对象了),因此我们需要两个if来判定解决这个问题

public static SingletonL getInstance(){
        if(instance==null) {
            synchronized (locker) {
                if(instance==null){
                instance = new SingletonL();
            }
            }
        }
        return instance;
    }

接下来我们补充一个(在多线程的背景下)也可能造成单例模式有问题的隐藏因素:指令重排序-->编译器的优化手段,调整指令执行的顺序,从而使执行效率提高。

具体这个因素是如何影响到我们单例模式的呢?通过下面的例子来引入:

就像我们去买菜,肯定是一次性顺路买完最省时间,而非是在不同摊位东窜西窜。我们指令重排序也是这样通过调整指令执行的顺序(就像调整买菜顺序一样)来使我们执行效率最高

就针对这短短的一行代码来说:

我们CPU在执行这行代码时,会有三步走:

  1. 分配内存空间
  2. 对内存空间进行初始化
  3. 将内存空间首地址,赋值给引用变量

其中最占用资源的是初始化,因此编译器调整顺序使得这一步最后才执行,就会出现下面的问题:

主要是因为一个没有被初始化的对象(空白的什么都没有,但是不是null),被接收了,由此造成我们获取单例实例对象的操作失败,对此我们加入volatile关键词即可。

因为我们是介绍多线程在具体例子中的使用,解决完这些引入多线程会造成的问题即可,因此我们这里也不再对单例模式进行过多的介绍,有兴趣的小伙伴可以自行去了解。

阻塞队列

接下来我们介绍一种非常重要的数据结构--阻塞队列。那么什么是阻塞队列呢?

阻塞队列是一种特殊的队列,同样遵循“先进先出”的队列原则。

但是阻塞队列是一种线程安全的数据结构,并且主要特征如下:

  • 当队列满的时候,入队列操作会进入阻塞状态,直到有其他线程从队列中取走元素,才会解除阻塞
  • 当队列空的时候,出队列操作会进入阻塞状态,直到有其他线程向队列中插入元素,才会解除阻塞

阻塞队列的经典使用场景就是“生产者消费者模型”,一个非常典型的开发模型。

生产者消费者模型

我们先来了解一下,为什么会出现“生产者消费者模型”,它的前身是怎样的。

这是一个普通的网购订单系统,通常包含以下功能实现:

  1. 网关:转发请求给商品服务器
  2. 订单管理服务器:买东西的用户信息以及买的商品的备注需求信息等等
  3. 库存管理服务器:记录商品的库存还有多少

正常我们网上购物,商家的服务器处理流程就类似于图中:“订单管理服务器根据客户需求向库存管理服务器发送请求查询相应商品库存,库存管理服务器发送商品库存以及具体信息给订单管理服务器”,这样我们可以看出一个特点:“A和B一定有互相关联的发送和接收相关的代码”,得到结论-->“A和B的耦合度很高”,从而就会有情况发生:

  • A服务器有bug时可能会牵连B服务器
  • B服务器有bug时可能会牵连A服务器
  • 添加新的C服务器时,也可能导致上述情况,并且耦合度增高

这样耦合度很高,非常容易造成大规模的服务器挂掉的情况,因此我们就设计了一个重要的模型-->生产者消费者模型。


生产者消费者模型

可以看到,相较于上面的最古早的电商模式,“生产者消费者模型”在各个服务器中间使用了一个“媒介”,通过媒介来控制A,B甚至C之间的交互,相当于A,B,C之间不再互相含有对方的代码,只含有自己的与媒介交互的代码。


阻塞队列与生产者消费者模型的结合

可以看到,我们使用“生产者消费者模型”需要一个媒介,而我们的阻塞队列就起到了这个媒介的作用,主要是因为有以下的两个原因:

1.解耦合

可以看到MQ(阻塞队列)使得每个服务器都不会存有对方的交互代码,从而每个服务器都只需要专注于自身的功能(库存管理服务器专注于提供商品数据即可,不用在乎用户的喜爱偏好),不用在乎别的服务器(其他服务器崩了,只用修崩的就行,不会引起雪崩式的服务器崩溃)。

2.削峰填谷

由于阻塞队列自身的特性:“队列满时,入队列阻塞;队列空时,出队列阻塞”。

因此,运用于我们电商大战中有如下表现:

  • 在支付请求多时,阻塞,从而减少服务器的负载,不至于崩溃

使得服务器能根据自身的速率来处理数据,而非因数据量太大而崩溃。(就像三峡大坝控制汛期时流水一样,这样不会造成下游洪涝灾害)。

一个简单的生产者消费者模型

说了这么多,我们自己也来创建一个简单的生产者消费者模型。

我们可以使用Java官方提供的阻塞队列来创建生产者消费者模型。那么我们首先就要先了解Java官方给出的阻塞队列到底有什么特别的地方。

1.官方给出的BlockingQueue是一个接口,真正实现的类有三种

        //根据顺序表创建的阻塞队列
        BlockingQueue<Integer> blockingQueue=new ArrayBlockingQueue(100);
        //根据链表创建的阻塞队列
        //BlockingQueue<Integer> blockingQueue1=new LinkedBlockingQueue<>(100);
        //根据堆创建的阻塞队列
        //BlockingQueue<Integer> blockingQueue2=new PriorityBlockingQueue<>(100);

2.BlockingQueue中put方法用于阻塞时的入队列,take用于阻塞式的出队列

3.BlockingQueue也有正常队列的offer,poll,peek方法,但是这些方法不带有阻塞特性


生产者消费者模型的实现

//我们来写一个简单的生产者消费者模型
    //一个线程作为生产者提供一个整数
    //另一个线程作为消费者使用这个整数
    public static int n=0;
    public static void main(String[] args) {
        //根据顺序表创建的阻塞队列
        BlockingQueue<Integer> blockingQueue=new ArrayBlockingQueue(100);
        //根据链表创建的阻塞队列
        //BlockingQueue<Integer> blockingQueue1=new LinkedBlockingQueue<>(100);
        //根据堆创建的阻塞队列
        //BlockingQueue<Integer> blockingQueue2=new PriorityBlockingQueue<>(100);
        //生产者生产数
        Thread t1=new Thread(()->{
            while (n<1000){
                try {
                    blockingQueue.put(n);
                    System.out.println("生产者生产了"+n);
                    n++;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        Thread t2=new Thread(()->{
            while (true){
                try {
                    int count=blockingQueue.take();
                    System.out.println("消费者使用了"+count);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
        t2.start();
    }

可以根据执行结果,我们在入队列满了之后,明显是出队列一个后,才会继续入队列,可以看出阻塞效果是存在的。

实现自己的阻塞队列

阻塞队列相较于普通的队列而言,主要就是多出了“阻塞”。

对于阻塞,我们可以使用锁的互斥来实现,那么关于等待解锁的操作我们可以使用wait和notify来解决。

那么我们实现阻塞队列的步骤就很简单了:

  • 通过“循环队列”的方式来实现
  • 使用synchronized进行加锁控制
  • put插入元素的时候,判定如果队列满了,就进行wait(注意,要在循环中进行wait。被唤醒时不一定队列就不满了,因为同时可能是唤醒了多个线程)
  • take取出元素的时候,判定如果队列为空,就进行wait(也是循环wait)

得到如下代码:

//实现自己的阻塞队列,此处我们的队列采取环形队列
class MyBlockingQueue{
    Object locker=new Object();
    //目前我们只考虑存储整型的阻塞队列,不使用泛型
    int[] data;
    int head=0;
    int tail=0;
    int size=0;
    public MyBlockingQueue(int capacity){
        if(capacity<=0){
            throw new IllegalArgumentException("要创建的队列长度不符合实际");
        }
        data=new int[capacity];
    }
    //先实现正常的队列入队列方法
    //测试正常后,开始加入阻塞操作
    public void put(int x) throws InterruptedException {
        synchronized (locker) {
            while (size == data.length) {
                //如果满了,就要阻塞,等有元素出队列后才能解除阻塞
                locker.wait();
            }
            data[tail] = x;
            tail = (tail + 1) % (data.length);
            size++;
            locker.notify();
        }
    }
    //先正常实现出队列
    //测试正常后开始加入阻塞操作
    public int take() throws InterruptedException {
        synchronized (locker) {
            while (size == 0) {
                //如果是空的就要等入队列后,才能解除阻塞状态
                locker.wait();
            }
            int ret = data[head];
            head = (head + 1) % (data.length);
            size--;
            locker.notify();
            return ret;
        }
    }

}
public class demo28 {
    private static int n=0;
    public static void main(String[] args) throws InterruptedException {
        //测试环形队列是否可以使用
        MyBlockingQueue myBlockingQueue=new MyBlockingQueue(100);

//        myBlockingQueue.put(10);
//        myBlockingQueue.put(20);
//        myBlockingQueue.put(30);
//        myBlockingQueue.put(40);
//        myBlockingQueue.put(50);
//       // myBlockingQueue.put(60);
//        int s1= myBlockingQueue.take();
//        System.out.println(s1);
        //测试阻塞队列的使用
        Thread t1=new Thread(()->{
            while (n<1000){
                try {
                    myBlockingQueue.put(n);
                    System.out.println("生产者已生产"+n);
                    n++;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        Thread t2=new Thread(()->{
            int count=0;
            while (true) {
                try {
                    count = myBlockingQueue.take();
                    System.out.println("消费者已消费" + count);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
t1.start();
t2.start();
    }
}

同样可以得到类似于阻塞队列的效果。

线程池

衔接很久之前的前文,我们因为任务量的增多从而使用线程来代替进程进行并发编程,但是随着现在互联网时代的到来,我们任务量越来越大每次处理任务都要创建一个线程去处理一个任务,每次一个任务执行完后都要销毁一个线程,这样在同一个时间段内不停地创建和销毁线程会占用大量资源,给CPU造成大量负担。于是聪明的人们想到了,我们可以提前创造一批线程(作为备用),需要的时候直接调用,不需要的时候就让它们在池子里时刻准备。这就引出了我们接下来的话题--线程池。

为了让大家更加了解,我们通过一个与我们有关的例子帮助大家理解:

假设一个公司接到了很多业务,需要大量的人手做项目,那么它就会在原有的员工基础上,再招聘一些个实习生来解决,等到业务完成后,公司就会裁掉这些招来的实习生。

其中这个例子,大家需要注意的几点有这些:

  • 很多业务:服务器处理的任务很多
  • 原有的员工:线程池里原先创建好的“核心线程”
  • 实习生:创建的临时线程
  • 裁员:将(在任务太多线程不足以处理时,创造临时线程)临时线程销毁

这些就是我们线程池涉及到的工作流程,接下来我们来详细了解线程池。

Java标准库中的线程池

Java标准库中自有一套已经完成的线程池类--ThreadPoolExecutor

对此,我们使用只用了解它的方法,按需挑选即可,那么我们来介绍一下ThreadPoolExecutor类中重要的那些方法。

提交任务

submit方法比较简单,具体作用就是提交任务线程池中,从而线程池会调配线程来处理我们添加进去的任务。

构造方法的详解(重点)

由于ThreadPoolExecutor类中的构造方法参数都是基本重复的,我们直接来分析参数最完全的一个构造方法即可

可以看到我们构造方法中有这么多的参数,我们接下来一一介绍:

1.corePoolSize和maximumPoolSize

(1):corePoolSize(核心线程数):->线程池机制:自动扩容机制:

任务多时,线程多(创建一些);任务少时,线程少(销毁掉非核心线程)

  • 核心线程:线程池创建的时候,创建出来的线程
  • 非核心线程:任务过多时,创建出来的额外的线程

(2):maximumPoolSize(最大线程数):

最大线程数=核心线程数+非核心线程数。

2.keepAliveTime和unit

(1):keepAliveTime(允许非核心线程空闲的时间):、

如果非核心线程在这个时间限制内一直是“空闲”的,就会被销毁

(2):unit(时间的单位:s,ms等等)


3.workQueue

workQueue(接受传递的任务的阻塞队列):

  • 线程池繁忙时,阻塞队列进入阻塞状态,不允许任务提交进来
  • 实现阻塞队列的数据结构:数组,链表,堆

4.threadFactory(线程工厂)

这个实际就是传入一个Thread的工厂类(这个类提供一个/N个工厂方法,创建Thread对象),当然,此处也可以传入我们自己实现的工厂方法

那么可能又有小伙伴们疑惑--什么是工厂类???工厂类实际上就是工厂模式(一种设计模式)的体现,我们接下来插入一个小章节介绍一下工厂模式


工厂模式

我们通过一个例子引入:描述一个二维的点的相对位置

这里我们一般有两种方法:

1.平面直角坐标系

2.极坐标

于是,我们就能创造两种构造方法来代表这两种方法,但是我们都是使用两个参数,就会出现下面的现象

由于构造方法的问题(因为两个方法都是需要两个参数,并且它们所需求的类型一样)无法实现“重载”。

上述方法本质是:依赖构造方法,实现两种描述方式

我们完全可以不依赖构造方法,通过不同名字的方法,实现不同的构造方式

//实现一个我们自己的工厂方法
class Print{
     double x;
     double y;
    //初始化平面直角坐标系
    public void setCCS(double a,double b){
        this.x=a;
        this.y=b;
    }
    //初始化极坐标
    public void setPC(double r,double o){
        this.x=r*Math.cos(o);
        this.y=r*Math.sin(o);
    }
}
class printFactory{
    //平面直角坐标系的描述方法
    public  Print Cartesiancoordinatesystem(double a,double b){
        Print print=new Print();
        print.setCCS(a,b);
        return print;
    }

    //极坐标的描述方法
    public Print Polarcoordinates(double r,double o){
        Print print=new Print();
        print.setPC(r,o);
        return print;
    }
}

现在我们就实现了自己的“描述二维的点的相对位置”的一个工厂类,通过这个工厂类,可以批量生产点这个对象


回到线程工厂这里来,那么为什么我们要使用线程工厂呢?关键就是:

线程池中有很多线程,我们可能需要对线程进行统一的设置:

例如,统一的设置线程名字;统一的设置线程的优先级;统一的设置线程是后台线程;


5.handler(拒绝策略)

我们有提到,提交给线程池的任务是进入一个阻塞队列,线程池中的线程阻塞队列中取出执行。但是,我们知道阻塞队列满了之后是会进入阻塞状态,如果一直阻塞,我们就可能会错过提交的重要任务(不能无时限的等待下去),因此,我们需要有策略来应对这种情况该如何取舍任务(在阻塞队列满时)。于是,便引入了我们要介绍的--拒绝策略。


在Java标准库中,我们共有四种拒绝策略,我们来一一介绍:

  1. 在任务队列满时,如果提交任务直接抛出异常(有可能造成问题)
  2. 线程1来提交新任务给任务队列,结果任务队列满了,则安排线程1直接处理执行任务
  3. 将任务队列中最老的线程(最早进入的)抛弃,空出来的位置交与提交的新线程
  4. 将任务队列中上一个最新的线程抛弃,空出来的位置交与提交的新线程

这就是四个标准库中的拒绝策略,我们可以用例子来帮助大家更好理解其意义:

例子背景:你现在手头上工作安排已经满了,你的上司过来给你忙碌的档期里面,又加上了一个新的任务,此时,面对这个新任务和你满到爆炸的任务队列,你该如何抉择???

例子说明:

  1. 你直接情绪失控,心理崩溃,大哭了起来(抛出异常),领导懵逼了
  2. 你说领导“你这么闲,自己处理去吧”,让领导自己去处理这个任务
  3. 你把你任务清单中,最早的那个任务给抛弃,把这个新任务加入任务清单
  4. 你把任务清单中,最晚加入的任务给抛弃,把这个新任务加入任务清单

具体运用标准库线程池

由于ThreadPoolExcutor类使用起来参数需求很多,因此官方又进一步对其进行封装,我们一般是使用ExecutorService类作为类型,通过Executors类(本质就是一个工厂类,生产各种需求的线程池)来创建多种功能的线程池

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public class demo32 {
    //使用Java标准库中提供的线程池
    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPoolExecutor= Executors.newFixedThreadPool(5);
        //提交给线程池处理?
        for (int i = 0; i <1000; i++) {
            int id=i;
            threadPoolExecutor.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+"正在输出"+id);
                }
            });
            Thread.sleep(1000);
        }
    }
}

这里的代码就实现了一个固定线程数的线程池,调用线程池中线程输出变量。

扩展创建自己的线程池

对于这个模块,我们要体会的就是线程池的两大基本重要要素:提交任务到任务队列,等待线程来取+线程池中线程要时刻存在并且工作。

那么我们只要完成这两个要素就能实现一个自己的线程池,同时有一个注意点:

如果线程池中所有线程都有自己处理的任务,那么任务队列就要等待空闲的线程--明显是阻塞队列实现。

那么我们来看自己创建的线程池,功能同我们使用Java官方的线程池实现的功能相同:

可能需要注意的就是while循环要在线程的任务中(保证核心线程不会执行完一个任务就销毁,要一直进行到线程池结束)

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

//创建一个自己的线程池
class MyThreadPool{
    BlockingQueue<Runnable> blockingQueue=new LinkedBlockingQueue<>();

    //创建一个提交任务给线程池的方法
    public void submit(Runnable task) throws InterruptedException {
        blockingQueue.put(task);
    }

    //先创建一个线程数目固定的线程池
    public MyThreadPool(int n){
        for (int i = 0; i <n; i++) {
            Thread t=new Thread(()->{
                //让线程池中的线程不停地取出队列中任务执行
                while (true){
                    try {
                        Runnable task=blockingQueue.take();
                        task.run();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            });
            t.setDaemon(true);
            t.start();
        }
    }
}

public class demo31 {
    //测试线程池
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool=new MyThreadPool(4);
        for (int i = 0; i <1000; i++) {
            int id=i;
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    Thread t=Thread.currentThread();
                    System.out.println(t.getName()+"此时处理的数字是"+id);
                }
            });
            //加上sleep确保线程池的执行
            Thread.sleep(1000);
        }
    }

}

定时器

定时器在相当于是一个“闹钟”一样的存在,我们把要执行的任务传给它,然后定下在“什么时候”开始执行(给定一个时间参数),就能实现定时执行任务。

一般来说对于标准库中的定时器(使用Timer类实现),我们一般只用关心核心方法schedule即可:

核心方法schedule

可以看到schedule方法含有两个参数:

  • 要执行的任务(TimerTask是实现了Runnable接口,通过重写run方法来决定“这个任务到底是要干嘛”)
  • 给定间隔的时间(到时间开始执行任务)

通过我们的示例代码来理解使用schedule方法:

public class demo33 {
    //使用Java中的定时器
    public static void main(String[] args) {
        Timer timer=new Timer();
        TimerTask task1=new TimerTask() {
            @Override
            public void run() {
                int n=0;
                while (n<100){

                    try {System.out.println("n是"+n);
                        n++;
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        timer.schedule(task1,10000);
    }
}

如果我们在电脑编译器上执行这串代码,就会发现在等待了10秒之后,控制台才会开始打印,因此定时器真正发挥了功效

扩展:实现自己的定时器

可能大家会好奇,目前看来,定时器除了可以让多个任务定时执行,似乎没有什么与多线程相关联的地方,那么我们接下来给大家介绍,定时器与多线程的相关之处(与内核实现有关)。

import java.util.Comparator;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.PriorityBlockingQueue;

//实现一个自己的定时器
class MyTimer{
    BlockingQueue<MyTask> blockingQueue=new PriorityBlockingQueue();
    Object locker=new Object();
    //主要功能就是schedule方法
    public void schedule(Runnable task,long delay) {
        synchronized (locker) {
            MyTask t = new MyTask(task, delay);
            //插入队列等待解除阻塞(相当于到时间了自己解锁执行)
            blockingQueue.offer(t);
            locker.notify();
        }
    }
    //构造方法
    //检测出队列中马上到时间要执行的任务
    public MyTimer(){
        Thread t=new Thread(()->{
            while (true){
                try {
                    synchronized (locker){
                        //阻塞队列,只有阻塞的入队列和阻塞的出队列
                        while (blockingQueue.isEmpty()){
                            locker.wait();
                        }
                        MyTask myTask=blockingQueue.peek();
                        long curTime=System.currentTimeMillis();
                        if(curTime>=myTask.delaytime){
                            //时间到了,可以执行任务了
                            blockingQueue.poll();
                            myTask.task.run();
                        }
                        else {
                            //时间还没到
                            locker.wait(myTask.delaytime-curTime);
                        }
                    }
            }catch (InterruptedException e){
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

//类似于实现Runnable接口的TimerTask
class MyTask implements Comparable<MyTask> {
    //继承比较谁先要执行(有一个时间戳属性)
    Runnable task;
    long delaytime;
    public MyTask(Runnable task,long delay){
        this.task=task;
        this.delaytime=System.currentTimeMillis()+delay;
    }

    @Override
    public int compareTo(MyTask o) {
        return (int)(this.delaytime-o.delaytime);
    }
}

总结:

保证线程安全的思路

1.使用没有共享资源的模型

2.适用共享资源只读,不写的模型

  • 不需要写共享资源的模型
  • 使用不可变对象

3.直面线程安全

  • 保证原子性
  • 保证顺序性
  • 保证可见性

更多推荐