线程安全问题&使用范例--多线程(中篇)

一. 线程安全问题

1.1 观察线程不安全

来看下面一串代码~

public class Demo6 {

    private static int count=0;

    //创建两个线程,每个线程中让count自增50000次
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        Thread thread1=new Thread(()->{
            for(int i=0;i<50000;i++){
                count++;
            }
        });
        thread.start();
        thread1.start();

        thread.join();
        thread1.join();

        System.out.println(count);
    }
}

我们的预期结果应该是count==100000,但并非如此,idea运行出来的是54256!并且每次运行的结果都不相同.


让我们来分析一下原因~

count++实际上是三个指令:

1. count的值从内存传递给CPU(load)

2. CPU寄存器中count的值自增(add)

3. count的值从CPU传回内存(save)

上面的两个进程执行的都是这三个指令,但是由于系统调度具有"不确定性",所以就有可能出现多种组合情况

上图只是其中两种,但是也足够我们进行分析了

来看第一种,相当于两个进程串行执行,因此count自增2次;第二种情况下,thread线程将count自增后还未放入内存,thread1就从内存中下载了count自增前的值,因此count最终只自增了1次

1.2 线程不安全的原因

先来给出线程安全的定义:

如果多线程环境下代码的运行结果是符合我们预期的,即等同于在单线程下的运行结果,则称为是线程安全的 

来根据上面的一个例子分析下线程不安全~

1. 抢占式执行

线程的调度是由操作系统内核自行决定的,站在用户的角度来看,我们无法预测下一个抢到CPU的线程是谁

2. 多个线程操作同一个变量

不难想象~如果两个线程只读取同一变量,并不会产生线程安全问题;或者两个线程修改的是不同的变量,也不会引发线程安全问题

3. 针对变量的操作不是原子性的

从刚才的代码中我们可以看到,之所以count的结果并不是100000,是因为count++分为3个单独的步骤.如果将这个三个步骤打包成一个,就不会触发bug

 1.2.1 监视器锁--synchronized

因为前两条原因是我们无法改变的,但是我们通过加锁的方式,可以将线程对变量的操作变成原子性的.(此处的原子性和事务一样,但是实现方式是不同的,事务机制的原子性是由"回滚"来保证的)

1.2.1.1 synchronized的原理

synchronized用的锁是存在java对象头里的--每个new出来的对象,都会有一个对象头,用来存储一些属性,加锁就是在对象头里设置了标记位

synchronized加的锁是互斥的,某个线程给一个对象加锁后,其他线程如果想给同一对象加锁,就会阻塞等待,知道该线程将锁释放.

synchronized加的锁可重入,不会出现自己把自己锁死的问题. 当一个线程给一个对象加锁之后,这个线程仍然可以给这个对象继续加锁

public static void main(String[] args) {

        Object locker=new Object();

        Thread thread=new Thread(()->{//thread线程给locker加锁两次
            synchronized (locker){
                synchronized (locker){
                    System.out.println("hello");
                }
            }
        });

        thread.start();
    }

 什么是不可重入?来看下面图解~

 上图中的锁称为"不可重入锁",synchronized就不会发生这种问题

在可重入锁的内部,包含了"线程持有者"和"计数器"两个信息

  •  如果某个线程加锁的时候,发现锁已经被占用,但占用锁的恰好是自己,那么仍可以获取到锁,并且让计数器的值自增
  • 每解锁一次,计数器的值会自减,直到减到0的时候,该线程才算真正释放锁
1.2.1.2 synchronized的使用

synchronized本质上是要修改指定对象的对象头,因此synchronized也要搭配一个对象来使用

(1) 直接修饰普通方法: 锁this指向的对象

public class Demo7 {
    synchronized public void lock(){//synchronized锁的是当前Demo7类的实例对象
        
    }
}

(2) 修饰静态方法: 锁类对象

类对象,简单来说就是.class文件被加载到内存中的形态(详情参见http://t.csdn.cn/21KRh)

public class Demo7 {
    synchronized public static void lock1(){//锁的是Demo7类对象
        
    }
}

(3) 修饰代码块: 明确指定锁哪个对象

和其他语言不同,java中随便一个对象都可以被当成锁对象 

public class Demo7 {
    
    public void lock2(){
        Object locker=new Object();
        synchronized (locker){//锁的是locker对象
            
        }
    }
}

其实前两种方式只是第三种方式的变形

public class Demo7 {
    public void lock(){
        synchronized (this){//等同于第一种方式
            
        }
    }
    public static void lock1(){
        synchronized (Demo7.class){//等同于第二种方式
            
        }
    }
 
}

 有了synchronized,我们就可以解决开头的线程安全问题了--只需要让两个线程在锁住同一个对象

,然后再线程内部让count自增

public static void main1(String[] args) throws InterruptedException {
        Object locker =new Object();//定义锁对象locker

        Thread thread=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){//在count自增前加锁,使其它想要对locker加锁的线程阻塞
                    count++;
                }

            }
        });
        Thread thread1=new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){//同上
                    count++;
                }

            }
        });
        thread.start();
        thread1.start();

        thread.join();
        thread1.join();

        System.out.println(count);
    }

再运行一下结果,就会发现和预料中的一样啦!

关于synchronized,有以下两点需要进行说明:

1. synchronized锁的是对象头,当两个线程竞争同一把锁(两个线程锁的是同一个对象)时,才会发生阻塞等待

2. 当一个线程锁住一个对象时,并不代表它要操作这个对象

比如刚才的代码,锁住的是locker对象,但并未对它进行操作,locker本质上就是一个"工具人"

1.2.2 volatile关键字

 上文中提到了三点线程不安全的原因,实际上还有两点----"内存可见性"和"指令重排序"

1.2.2.1 JMM内存模型

在解释"内存可见性"之前,需要先了解JMM(Java Memery Model),即java的内存模型

  • java把内存分为工作内存(Working Memory)和主内存(Main Memory)
  • 每个线程都拥有自己的工作内存
  • 当一个线程要获取共享变量时,会先加载到自己的工作内存中,然后再从工作内存中获取数据
  • 当一个线程要修改共享变量时,也会先修改工作内存中的副本,然后再同步到主内存中 

 WM和MM听起来是不是很高大上?实际上主内存就是我们平时说的内存,工作内存就是Cache+CPU内部的寄存器.这是java为了保证跨平台性所起的专有名词

1.2.2.2 内存可见性

当thread1线程频繁读取一个共享变量,而thread2在某一时刻对该变量进行了修改,thread1未必能察觉到

来图解释~

1) thread1和thread2 从主内存中读取到了a=5 

2) 在thread1频繁地读取主内存后,编译器发现变量a的值好像并没有改变~于是它"自作聪明"地优化了这一步骤,直接让thread1从自己的工作内存中读取

3)  闷声干大事的thread2突然背刺,修改了变量a的值~然鹅thread1却不知道自己读的a早就变成"过去时"了

理解完内存可见性,不知道童鞋们作何感想,你说我一个线程读取个数据,编译器为啥要掺和一脚呢?

我们知道,CPU从主存中读取数据的速度是相当慢的,因此编译器会进行优化

虽然编译器的优化会带来内存可见性的问题,但不可否认的是它还是利大于弊的.因为编译器是由最顶尖的大佬们写的,如果我们这些程序猿小白写的代码过于垃圾,编译器就会在代码原有逻辑不变的情况下,尽可能提高CPU的工作效率,可以说编译器就是在为我们"擦屁股" .

1.2.2.3 volatile保证"内存可见性"

为了解决内存可见性,java提供了volatile关键字,可以阻止编译器的优化

来看下面一段代码~

 private static boolean isQuit=false;//当isQuit设为true时,thread线程停止运行

    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            while(!isQuit){//不停地读取isQuit

            }
        });
        thread.start();

        Thread.sleep(3000);
        isQuit=true;//在main线程中修改isQuit
    }

 从jconsole中可以看到,即使main把isQuit修改为true,thread线程仍在运行

只要在isQuit变量前加上volatile,就会解决这个问题!

 由volatile修饰的变量,会阻止编译器的优化,使线程每次都从主内存中读取

volatile还可以解决"指令重排序"问题

1.2.2.4 指令重排序

指令重排序也是编译器优化带来的另一问题.编译器为了提高性能,会改变指令的执行顺序

举个栗子来说~

妈妈给了滑稽同学一张购物清单,让他去菜市场买菜

而滑稽老铁为了少走几步路,必然不会按照购物清单上的顺序来购买,于是他的购买顺序为

这种情况在单线程的情况下不会出现什么问题,但在多线程情况下有可能引发Bug,下文中会提到~

二. 线程交互--wait()和notify()

 由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.

但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

完成这个协调工作 , 主要涉及到三个方法:
  • wait() / wait(long timeout): 让当前线程进入等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.
注意 : wait, notify, notifyAll 都是 Object 类的方法

2.1 wait()方法

对象名.wait()

wait()方法可以避免"线程饿死"问题

什么是"线程饿死"?如果某个线程一直占着cpu,那么造成的结果就是别的线程一直没有机会运行,从而导致饿死

 举个栗子来讲~

滑稽老铁去超市买菜,收银员结账时发现没有1毛钱可以找给滑稽了,于是这位老铁就一直询问收银员啥时候能找给他...收银员只能不停地给滑稽解释原因,于是其他顾客就在后面站了一大堆~

这种做法当然是不理智的,应该让滑稽在旁边等待,知道经理拿着硬币过来

 借助代码消化一下wait的使用~

public static void main(String[] args) {

        Object locker=new Object();

        Thread thread=new Thread(()->{
            try {
                locker.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread.start();

    }

然鹅运行之后发现,该代码向你抛出了一个异常!

 还记得监视器锁synchronized嘛?就是上图中的MonitorState

wait()方法做的事情:

  1.  释放对象头上的锁
  2. 把当前线程放在等待队列中
  3. 满足一定条件被唤醒后,尝试重新获取这个锁

这就不难理解异常出现的原因了,对象名.wait()方法在使用前,该线程必须要先给这个对象加锁

 public static void main(String[] args) {
        Object locker=new Object();

        Thread thread=new Thread(()->{

            synchronized (locker){//锁住locker对象
                try {
                    locker.wait();//暂时把锁让出去,然后该线程等待被唤醒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread.start();
    }

肯定有童靴要问了~wait方法等待的条件究竟是什么呀?

wait ()被唤醒的条件

  •  其他线程调用该对象的notify()/notifyAll()方法
  • 如果使用wait(long timeout)方法,超过timeout(ms)后便不再等待
  • 其他线程调用该线程的interrupt方法,直接终止该线程

2.2 notify()方法

notify()方法是唤醒等待的线程

  • notify()也要搭配synchronized代码块使用(java中的要求,系统本身提供的api并没有这个规定)
  • 如果有多个线程等待,则会随机挑选一个wait()状态的线程(并没有"先来后到"的规则)
  • notify()方法执行后,当前线程并不会马上释放该对象的锁,而是等到synchronized代码块执行完后才会释放

 现在我们来用thread1唤醒刚才的 thread 线程

public class Demo8 {
    public static void main(String[] args) throws InterruptedException {
        Object locker=new Object();

        Thread thread=new Thread(()->{

            synchronized (locker){
                System.out.println("thread拿到锁");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("thread线程wait完毕");
            }
        });
        thread.start();

        Thread thread1=new Thread(()->{
            synchronized (locker){
                System.out.println("thread1获取到锁");
                locker.notify();
                System.out.println("thread1执行了notify方法");
            }
        });
        Thread.sleep(3000);//等待3s后让thread1执行notify方法
        thread1.start();

    }
}

 注意: 执行notify()方法的对象只能唤醒等待给该对象加锁的线程

 2.3 notify和notifyAll的区别

从方法名上就可以看出来,notify只是唤醒一个正在等待的线程,notifyAll是唤醒所有等待给该对象加锁的线程

举个通俗点的栗子~

貂蝉小姐一开始是有蓝朋友的,就好像有了一把锁.即便如此,她还是有很多迷弟追求

 

貂蝉小姐和吕布分手后,滑稽老铁们集体出动.于是貂蝉小姐有了两个选择:

1. 随机挑选一位做自己的男朋友(notify方法)

2. 让这些老铁自己竞争(notifyAll方法)

 我们平常使用的一般是notify()

三. 多线程使用案例

3.1 单例模式

单例模式是常见的设计模式之一.

设计模式好比象棋中的 " 棋谱 ". 红方当头炮 , 黑方马来跳 . 针对红方的一些走法 , 黑方应招的时候有 一些固定的套路. 按照套路来走局势就不会吃亏 . 软件开发中也有很多常见的 " 问题场景 ".
针对这些问题场景 , 大佬们总结出了一些固定的套路 . 按照 这个套路来实现代码, 也不会吃亏 .

单例模式能保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例

比如,JDBC编程中的DataSource实例就只需要一个

 单例模式的具体实现方式,分为"饿汉""懒汉"

 举个栗子来解释下~

妈妈逛菜市场买了一些葡萄,身为一个讲究卫生的好孩纸,我们肯定是要先把这些葡萄洗干净才可以吃的~~如果我们是"饿汉",就会一次性把所有葡萄洗干净;如果是"懒汉",就会先考虑自己要吃多少,然后就只洗一部分.

3.1.1 饿汉模式

类在加载的同时,会把这唯一的一个实例也加载出来

class Hunger{
    private static Hunger instance=new Hunger();
    public static Hunger getInstance(){
        return instance;
    }
    private Hunger(){}
}

别看上面的代码比较简单,有一些点需要注意!

1. 因为实例对象会随者类加载被创建出来,因此要将它设置为静态成员变量

2. 为了防止创建出Hunger类的其他对象,要把该类的构造方法设置为私有

3. 从代码规范上,成员变量是私有的,要设置一个公开的方法用于获取这个变量

3.1.2 懒汉模式

类加载的时候不创建实例,第一次需要该实例的时候才会创建出来

class Lazy{
    private static Lazy instance=null;
    private Lazy(){}

    public static Lazy getInstance(){
        if(instance==null){
            instance=new Lazy();
        }
        return instance;
    }
}

上面的代码逻辑很简单,先将instance的引用指向null,当第一次获取这个实例时,再将它创建出来.

但是我们不得不思考一个问题,如果多个线程同时获取实例,就会同时new出来很多对象...虽然最终instance引用指向的只有一个对象,其他的对象会被gc回收,但是创建/销毁对象的代价是比较大的,我们要保证这个类只能有一个对象

所以要将创建对象的代码加锁~

来看一下下图中的getInstace方法是否正确?

肯定是不对哒!设想一下下图的情况

这个锁不就白加了吗!因此要把判断和new对象的过程都加上锁

 线程安全版

class Lazy{
    private static Lazy instance=null;
    private Lazy(){}
    public static Lazy getInstance(){
        synchronized (this){
            if(instance==null){
                instance=new Lazy();
            } 
        }
        return instance;
    }
}

为了精益求精,更好地提高运行效率,我们还要思考一个问题,每次想获取这个实例,都需要加锁吗?

 实际上并不是,如果Instance还未指向一个对象的时候,是需要进行加锁操作的.因此可以再对代码稍加修改~


    public static Lazy getInstance(){
        if(instance==null){
            synchronized (this){
                if(instance==null){
                    instance=new Lazy();
                }
            }
        }
        return instance;
    }

 注意!第一个if语句用来判断是否需要加锁,第二个if用来判断是否需要创建对象.

肯定有同学会问,两个相同的if语句放在一起,不是多此一举吗?

 不要忘记中间还有一个加锁的操作.假设两个线程同时判断出来instance=null,thread1抢到了锁,并创建出实例.thread2就需要阻塞等待,当锁被释放的时候,对象已经被创建好了,所以进行第二次判断

是很有必要的

肯定有细心的同学发现,判断Instance是否为空存在一个"内存可见性"的问题,因此有必要给instacne加上一个volatile

进阶版

class Lazy{
    volatile private static Lazy instance=null;
    private Lazy(){}
    public static Lazy getInstance(){
        if(instance==null){
            synchronized (this){
                if(instance==null){
                    instance=new Lazy();
                }
            }
        }
        return instance;
    }
}

 3.2 阻塞队列

3.2.1 阻塞队列是什么?

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

阻塞队列能是一种线程安全的数据结构 , 并且具有以下特性 :
  • 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
  • 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.

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

问题又来了,什么是"生产者消费者模型"? 

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等 待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.

举个栗子~ 

 博主的家乡和烧饼有点渊源,街上随处可见一个烧饼摊.假设摊主十分钟做出来一炉烧饼,但是没有顾客来购买,摊主就会把烧饼放在桌子上的箩筐里用布盖住.顾客来购买的时候从箩筐里拿就可以了.

 这个箩筐就是摊主和顾客之间的"阻塞队列",有以下两个作用~

1. 解耦合

如果摊主做一个烧饼,需要交给顾客之后才能做下一个,每当有一个顾客来购买,就要等上个十分钟.不难想象这个摊子应该很快就玩完了...

2. 削峰填谷

如果客流量比较小,摊主可以把先做好的烧饼放在箩筐里;客流量比较大的时候就可以消耗提前做好的烧饼;这样就不会影响顾客的消费体验 

3.2.2  标准库中的阻塞队列

Java 标准库中内置了阻塞队列 . 如果我们需要在一些程序中使用阻塞队列 , 直接使用标准库中的即可 .
  • BlockingQueue 是一个接口. 真正实现的类是LinkedBlockingQueue/ArrayBlockingQueue.
  • put 方法用于入队列,take用于出队列.
  • BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.

 下面简单演示下这两个方法的使用

BlockingQueue<String> queue=new LinkedBlockingQueue<>();
queue.put("1");
System.out.println(queue.take());

这样我们就可以用它来实现生产者消费者模型了~

public static void main(String[] args) {
        BlockingQueue<String> queue=new LinkedBlockingQueue<>();

        Thread producer=new Thread(()->{
            for (int i = 0; i < 100; i++) {
                try {
                    queue.put(i+" ");
                    Thread.sleep(1000);//生产者每隔1s生产出来一个元素
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        Thread customer=new Thread(()->{
            while(true){//此处的判断条件不能为isEmpty(),如果生产速度没有消费速度快,消费者就会跳出循环,停止消费
                try {
                    String elem=queue.take();
                    System.out.println(elem);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        customer.start();
        producer.start();
    }

童靴们可以尝试运行这段代码,可以发现,只有当生产者生产出来一个元素后,消费者才会将该元素输出 

3.2.3 阻塞队列的实现

 我们先来创建一个普通的循环队列.

class BlockQueue{
    private String[] elems=new String[10];//假设该队列容量为1000
    private int tail=0;//指向下一个要插入的元素位置
    private int head=0;
    private int size=0;//用来记录队列的有效元素个数

    public void put(String s){
        if(size==elems.length){
            return;
        }
        elems[tail]=s;
        tail++;
        if(tail>= elems.length){
            tail=0;
        }
        size++;
    }

    public String take(){
        if(size==0){
            return null;
        }
        String result=elems[head];
        head++;
        if(head>= elems.length){
            head=0;
        }
        size--;
        return result;
    }
}

 如果两个线程同时取/存元素的话,可能取的元素是同一个,或者一个线程存的元素会被另一个线程覆盖

因此要对put和take方法加锁~

同时,因为涉及到size,tail,head变量的判断问题,为了避免一个线程判断,同时另一个线程修改而引起的"内存可见性"问题,需要对这三个变量加上volatile.

class BlockQueue{
    private String[] elems=new String[10];//假设该队列容量为1000
    volatile private int tail=0;//指向下一个要插入的元素位置
    volatile private int head=0;
    volatile private int size=0;//用来记录队列的有效元素个数

    synchronized public void put(String s){
        if(size==elems.length){
            return;
        }
        elems[tail]=s;
        tail++;
        if(tail>= elems.length){
            tail=0;
        }
        size++;
    }

    synchronized public String take(){
        if(size==0){
            return null;
        }
        String result=elems[head];
        head++;
        if(head>= elems.length){
            head=0;
        }
        size--;
        return result;
    }
}

接下来我们要把它变成"阻塞式队列".

当队列中没有元素的时候,take()方法要进行等待,直到put方法被调用将它唤醒.

当队列满的时候,put()方法要进行等待,直到take()方法取出元素才可以被唤醒.

synchronized public void put(String s) {
        if(size==elems.length){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        elems[tail]=s;
        tail++;
        if(tail>= elems.length){
            tail=0;
        }
        size++;
        notify();
    }

    synchronized public String take() {
        if(size==0){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        String result=elems[head];
        head++;
        if(head>= elems.length){
            head=0;
        }
        size--;
        notify();
        return result;
    }

现在还有一个小小的问题 ---- wait一定是被notify唤醒的吗?

自然不是,还有可能是因为这个线程被interrupt了.按照上面代码的逻辑,即使线程是因为被中断唤醒的,仍然会添加/删除元素.

因此,我们要在线程被唤醒之后再做一次判断,代码就变成了这样

经典的"俄罗斯套娃"~

其实,只要使用一个while,就可以解决这个问题了

class BlockQueue{
    private String[] elems=new String[10];//假设该队列容量为1000
    volatile private int tail=0;//指向下一个要插入的元素位置
    volatile private int head=0;
    volatile private int size=0;//用来记录队列的有效元素个数

    synchronized public void put(String s) {
        while(size==elems.length){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        elems[tail]=s;
        tail++;
        if(tail>= elems.length){
            tail=0;
        }
        size++;
        notify();
    }

    synchronized public String take() {
        while(size==0){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        String result=elems[head];
        head++;
        if(head>= elems.length){
            head=0;
        }
        size--;
        notify();
        return result;
    }
}

 这个小问题并非本人发现的,而是官方文档给出的建议~

当然,如果采用抛异常的方式处理中断,使用if也未尝不可.读者可自行思考其中逻辑.

3.3 定时器

定时器,直白一点的解释就是闹钟,给它规定一个时间内该做什么事.

3.3.1 标准库中的定时器

标准库中提供了一个 Timer . Timer 类的核心方法为 schedule .
schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后
执行 (单位为毫秒).
下面简单进行演示~
public static void main(String[] args) {

        Timer timer=new Timer();
        timer.schedule(new TimerTask() {//TimerTask类,可以简单地理解为带有时间规定的Runnable
            @Override
            public void run() {
                System.out.println("十二点了,你妈喊你回家吃饭");
            }
        },3000);
    }

从控制台的界面上可以看到,编译器在3s后打印出了如下结果

 3.3.2 定时器的实现

分为以下步骤:

  1.  描述一个任务(要干的事情+执行时间)
  2. 用一定的数据结构组织这些任务
  3. 在任务的执行时间执行该任务

我们先来完成第一步

class Task{
    private Runnable runnable;//要干的事情
    private long time;//任务的执行时间

    public Task(Runnable runnable,long delay){
        this.runnable=runnable;
        time=System.currentTimeMillis()+delay;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    public long getTime() {
        return time;
    }
}

既然要在某一时间点执行某任务,就需要有一个线程不停地对任务序列进行扫描.如果我们使用普通的LinkedLIst/ArrayLIst的方式,这个线程就需要扫描整个序列,有没有更高效的方法呢?

可以使用优先级队列,这样每次只检查时间最小的任务就可以了

下面是一个半成品Timer类

class MyTimer{
    private PriorityQueue<Task> queue=new PriorityQueue<>();

    public void schedule(Runnable runnable, long delay){
        Task task=new Task(runnable,delay);
        queue.offer(task);
        notify();
    }

    public MyTimer(){
        Thread thread=new Thread(()->{
            while(true){
                Task task=queue.peek();//取出时间最小的任务
                long curTime=System.currentTimeMillis();
                if(curTime>=task.getTime()){//比较当前时间和任务规定的时间,如果超过规定时间就执行
                    task.getRunnable().run();
                    queue.poll();//任务执行完成后将它抛出队列

                }else{
                    try {
                        Thread.sleep(task.getTime()-curTime);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        thread.start();
    }
}

上面的"半成品"存在几个问题...

1. 注册任务的不止有一个线程,同时还有一个线程可能正在删除任务,这样就会存在线程安全问题,因此需要对queue的操作加锁

2. 如果队列为空,无法取出任务,会抛出异常,因此需要提前判断一下,并让扫描线程等待一段时间

3.  如果规定时间最小的任务还没到执行时间,扫描线程一直等待会出现什么问题吗?

如果这时候新添加的任务执行时间小于当前任务的最小时间,扫描线程需要被唤醒.因此else代码块那里使用sleep是不合适的

下面是修改后的代码

class MyTimer{
    private PriorityQueue<Task> queue=new PriorityQueue<>();

    synchronized public void schedule(Runnable runnable, long delay){
        Task task=new Task(runnable,delay);
        queue.offer(task);
        notify();
    }

    public MyTimer(){
        Thread thread=new Thread(()->{
            while(true){
                try {
                    synchronized (this) {
                        while (queue.isEmpty()) {
                            wait();
                        }
                        Task task = queue.peek();
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            task.getRunnable().run();
                            queue.poll();
                        } else {
                            wait(task.getTime() - curTime);
                        }
                    }
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
            }
        });
        thread.start();
    }
}

4. 还有一个比较基础的问题,Task类还没有实现Comparable接口(也可以使用Comparator比较器)

 下面是最终版本

class Task{
    private Runnable runnable;
    private long time;

    public Task(Runnable runnable,long delay){
        this.runnable=runnable;
        this.time=System.currentTimeMillis()+delay;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    public long getTime() {
        return time;
    }
}
class TaskCom implements Comparator<Task>{
    @Override
    public int compare(Task o1, Task o2) {
        return (int)(o1.getTime()- o2.getTime());
    }
}
class MyTimer{
    private PriorityQueue<Task> queue=new PriorityQueue<>(new TaskCom());

    synchronized public void schedule(Runnable runnable, long delay){
        Task task=new Task(runnable,delay);
        queue.offer(task);
        notify();
    }

    public MyTimer(){
        Thread thread=new Thread(()->{
            while(true){
                try {
                    synchronized (this) {
                        while (queue.isEmpty()) {
                            wait();
                        }
                        Task task = queue.peek();
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            task.getRunnable().run();
                            queue.poll();
                        } else {
                            wait( task.getTime() - curTime);
                        }
                    }
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
            }
        });
        thread.start();
    }
}

3.4 线程池

 3.4.1 什么是"线程池"?

因为创建/销毁一个线程的开销比较大,因此"线程池"会提前创建好线程,需要的时候直接从池里取即可;一个线程被销毁时,并不是真正的销毁,而是把它放回池里以备下次使用.

那么问题来了,为啥从池子里拿/放线程更快呢?

这就涉及到了"用户态""内核态".

来拿餐厅举个栗子~

自助餐厅就好比是用户态代码,顾客想享用的食物自己去拿就可以了;如果是普通的餐馆,顾客就需要告诉服务员自己想吃什么,然后交给后台的大厨去做.但是大厨收到的菜单并不是只有我们这一份,因此我们点的菜什么时候去做是由大厨随机决定的.

一般认为,用户态代码要比内核态代码效率高.并不是执行速度上存在差距,而是站在用户的角度来看,用户态代码的执行是由自己掌握的;如果交给操作系统(内核态代码),就不知道操作系统会什么时候执行程序猿安排的任务了.

除此之外,线程池最大的好处就是减少每次启动、销毁线程的损耗.

3.4.2 标准库中的线程池

  • 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
  • 返回值类型为 ExecutorService
  • 通过 ExecutorService.submit 可以注册一个任务到线程池中.
public static void main(String[] args) {

        ExecutorService pool= Executors.newFixedThreadPool(10);//固定包含10个线程的线程池
        pool.submit(new Runnable() {//安排任务
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }

Executers是"工厂类"的一种. 

"工厂模式"是指使用普通方法创建对象,且这种方法一般是静态的.

因为构造方法创建对象是存在缺陷的,构造方法的名字固定,只能靠重载区分.比如下面这个类的构造

class Point{
    double x;
    double y;
    Point(double x,double y){}//按照直角坐标系确定点
    Point(double r,double a){}//按照极坐标确定点
}
      

如果使用普通方法就可以直接用名字来区分了

class Point{
    double x;
    double y;

    public static Point makeXY(double x, double y){}
    public static Point makeAR(double a,double r){}
}

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

Executors 本质上是 ThreadPoolExecutor 类的封装.能提供的创建线程池的方法有限,虽然使用简单,但作用范围不如ThreadPoolExecutor类

 上图为ThreadPoolExecutor的构造方法.下面来逐一解释每个参数的含义.

  1. corePoolSize,核心线程数,也就是创建的线程池中最少的线程数(公司里的正式员工)
  2. maximumPoolSize,最大线程数(公司招的临时工)
  3. KeepAliveTime,非必要线程的最长存在时间(任务指标少的时候,临时工能够摸鱼的最长时间,时间一到就会被辞退)
  4. unit,时间单位
  5. workQueue,线程池对任务管理使用的阻塞队列(可以通过传入不同的阻塞队列执行不同的功能,例如传入一个时间优先级阻塞队列)
  6. threadFactory,线程工厂,用来创建线程
  7. handler,阻塞队列满的时候采用的拒绝策略

拒绝策略分为以下几种

  1. AbortPolicy,任务非常多的时候,所有的线程都快干冒烟了,于是集体罢工,抛出异常
  2. CallerRunsPolicy,由哪个线程接收的任务交给哪个线程执行
  3. DiscardOldestPolicy,丢弃最早的任务,执行新的任务
  4. DiscardPolicy,放弃执行新任务

3.4.3 线程池的实现

下面是参照Executors.newFixedThreadPool方法实现的线程池,比较简单,读者可自行思考~

public class MyPool {
    private BlockingQueue<Runnable> queue=new LinkedBlockingQueue<>();//用于存放线程的阻塞队列
    public MyPool(int n){
        for (int i = 0; i < n; i++) {//创建固定的线程
            Thread thread=new Thread(()->{
                while (true){
                    try {
                        Runnable runnable=queue.take();//取出任务并执行
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            thread.start();
        }
    }
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
//检验代码
class Test{
    public static void main(String[] args) {
        MyPool pool=new MyPool(10);
        for(int i=0;i<100;i++){
            int copy=i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("当前是"+Thread.currentThread().getName()+"执行的任务"+copy);
                }
            });
        }
    }

}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不 会敲代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值