Java多线程基础-10:代码案例之定时器(二)

简介: Java 多线程基础中,定时器(Timer)的实现通常使用 `PriorityBlockingQueue` 和 `wait/notify` 机制来管理任务。

Java多线程基础-10:代码案例之定时器(一)+  https://developer.aliyun.com/article/1520548?spm=a2c6h.13148508.setting.14.75194f0ethWdBZ



c.给队列中的MyTask元素设定优先级


PriorityBlockingQueue与PriorityQueue指定建堆顺序的方式是类似的。既可以通过将比较器Comparator传入构造器,也可以直接在元素中实现Comparable接口和compareTo方法。


这里我们只需让MyTask类实现Comparable接口,并以时间time为依据实现compareTo()方法即可。


回顾:PriorityQueue指定比较顺序的方式



在优先级队列中,元素的排序依赖于它们的比较结果。可以通过实现Comparable接口并定义compareTo()方法来指定对象之间的排序规则。这样,优先级队列就能根据对象的比较结果对元素进行自动排序。



如果不希望修改原始对象的类(或无法修改它),也可以创建一个实现了Comparator接口的单独类用于比较对象,并将该比较器作为参数传递给优先级队列的构造函数。使用比较器可以在不修改原始类的情况下定义对象之间的排序规则。


如果在构造Priorityqueue时没有提供比较器,而是使用实现了Comparable接口的对象的compareTo方法进行比较,那么优先级队列将根据该方法的比较结果进行排序。

如果对象没有实现Comparable接口或没有定义compareTo方法,也没有额外提供比较器,那么在添加元素时可能会抛出classCastException,因为优先级队列无法确定对象之间的顺序。


class MyTask implements Comparable<MyTask>{
    public Runnable runnable;
    public long time;   //为了方便后续判定,使用的是绝对的时间戳
 
    public MyTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay; //转换:相对时间delay + 当前时间戳 => 绝对的时间戳
    }
 
    //指定比较规则
    @Override
    public int compareTo(MyTask o) {
        return (int)(this.time-o.time);
    }
}


(tips:在写compareTo()时,可以不可以记忆到底是谁减去谁。可以先随便写一种,然后运行看看效果。如果不对再改成另一种相减方式。)


d.解决CPU的忙等问题


忙等,即虽然CPU确实在“等待”,但它也没捞着休息。就好比原本规定早上8点出门,我早上醒来后看了一眼手表,此时是7点,发现时间还没到;而过1分钟我又看一次表,发现是7点01,时间还没到;又过了1分钟我又去看表,此时是7点02,时间还没到……剩下的时间里我光顾着看表,也没有好好休息。


忙等在上述代码中表现为,while (true) 转的太快了,造成了无意义的 CPU 浪费。比如第一个任务设定的是 1 min 之后执行某个逻辑,但是这里的 while (true) 会导致每秒钟访问队首元素几万次,而当前距离任务执行的时间还有很久,剩下的时间里CPU光顾着进进出出访问队首元素了。


我们需要在等待的过程中释放CPU。


有同学可能会提出使用sleep(),但sleep()不是一个好的选择,因为sleep()的时间必须是固定的。如果sleep()的时间过长,恰好错过了任务的执行时间(睡过头了),就不妙了。


使用wait()就比较合适,可以随时提前结束。在等待过程中随时有新的任务过来,CPU就可以随时去处理。


所以代码逻辑更改为,如果时间还没到,则将刚取出的队首元素放回队列,并进入wait()等待直到时间到。而在插入队列元素时,必须调用notify()方法唤醒锁对象。代码如下:


//自定义定时器类
class MyTimer {
    //显式地指定锁对象:locker
    private Object locker = new Object();
 
    //核心数据结构,带有优先级的阻塞队列
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
 
    //此处的delay是一个相对时间,表示间隔多少时间后执行该任务
    public void schedule(Runnable runnable, long delay) {
        //根据参数构造MyTask,插入队列即可
        MyTask myTask = new MyTask(runnable,delay);
        queue.put(myTask);
        //唤醒正在等待的线程*********************************
        synchronized (locker) {
            locker.notify();
        }
    }
 
    // 在这里构造线程,负责不停地扫描队首元素,判断该任务是否可以执行
    public MyTimer() {
        Thread t = new Thread(()->{
            while (true) {
                try {
                    //wait 必加锁
                    synchronized (locker) {
                        MyTask myTask = queue.take();
                        long curTime = System.currentTimeMillis();  //获取当前时间
                        if(curTime < myTask.time) {
                            //当前时间小于任务时间:时间还没到
                            //暂不执行,要把刚才取出的任务塞回队列中
                            queue.put(myTask);
                            //等待该任务的时间到*****************************************
                            locker.wait(myTask.time - System.currentTimeMillis());
                        }else{
                            //时间到了,执行任务
                            myTask.runnable.run();  //执行任务
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}


为什么要在schedule方法中唤醒线程?其实也不难理解。


打个比方,比如当前时刻是 14:00 ,约定了 14:30 要执行上课这个任务。取出队首元素,发现时间是14:00,还没到,按逻辑就需要 wait 等待 30 分钟。


而在等待过程中,一个新的任务来了,14:10 要去接水。这样一来,就不能放任刚才的 wait 继续等了,而是需要唤醒 wait,此时工作线程就会重新取队首元素,这时取到的元素就是14:10去接水这个任务。这样做能够保证无论什么时候插入新任务,工作线程都能正确地把最小时间的任务取到。


在上述代码中,在schedule 方法中使用notify的目的是通知等待在locker对象上的线程。这是为了确保当添加新任务时,如果有线程正在等待队列中的任务执行完成,它能够被唤醒并重新检查队列。

因为在定时器类中的线程通过locker.wait(myTask.timeSystem.currentTimeMillis())进行等待,以等待下一任务的执行时间到来。如果没有通知等待的线程,即使有新任务加入队列,等待的线程也会继续等待,而不会重新检查队列是否有更早需要执行的任务。

因此,在schedule方法中调用notify是为了确保正在等待的线程能够及时得到通知,以重新检查队列并执行更早的任务。


2、完整代码


import java.util.*;
import java.util.concurrent.PriorityBlockingQueue;
 
 
class MyTask implements Comparable<MyTask>{
    public Runnable runnable;
    public long time;   //为了方便后续判定,使用的是绝对的时间戳
 
    public MyTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay; //转换:相对时间delay + 当前时间戳 => 绝对的时间戳
    }
 
    @Override
    public int compareTo(MyTask o) {
        return (int)(this.time-o.time);
    }
}
 
//自定义定时器类
class MyTimer {
    //显式地指定锁对象:locker
    private Object locker = new Object();
 
    //核心数据结构,带有优先级的阻塞队列
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
 
    //此处的delay是一个相对时间,表示间隔多少时间后执行该任务
    public void schedule(Runnable runnable, long delay) {
        //根据参数构造MyTask,插入队列即可
        MyTask myTask = new MyTask(runnable,delay);
        queue.put(myTask);
        synchronized (locker) {
            locker.notify();
        }
    }
 
    // 在这里构造线程,负责不停地扫描队首元素,判断该任务是否可以执行
    public MyTimer() {
        Thread t = new Thread(()->{
            while (true) {
                try {
                    //wait 必加锁
                    synchronized (locker) {
                        MyTask myTask = queue.take();
                        long curTime = System.currentTimeMillis();  //获取当前时间
                        if(curTime < myTask.time) {
                            //当前时间小于任务时间:时间还没到
                            //暂不执行,要把刚才取出的任务塞回队列中
                            queue.put(myTask);
                            locker.wait(myTask.time - System.currentTimeMillis());
                        }else{
                            //时间到了,执行任务
                            myTask.runnable.run();  //执行任务
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}
 
public class ThreadDemo {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        //注册任务
        myTimer.schedule(()->{
            System.out.println("AAA");
        },4000);
        myTimer.schedule(()->{
            System.out.println("BBB");
        },3000);
        myTimer.schedule(()->{
            System.out.println("CCC");
        },2000);
        myTimer.schedule(()->{
            System.out.println("DDD");
        },1000);
        System.out.println("EEE");
    }
}


运行,查看结果:



三、MyTimer中加锁位置的问题


前面提到,要用wait()和notify()来解决CPU忙等的问题。但是,synchronized的书写位置是会影响结果的正确性的。

现在有以下两种加锁方式:


第一种:



第二种:




其中,第一种方式是正确的加锁方式,第二种方式则会引发线程安全问题。第二种方式会引发何种线程安全问题?为什么?


1、分析


分析这个问题的要点是:线程的调度是随机的、无序的。

在第二种代码书写的情况下,假设执行到put之后就切走了:



t2执行完后,再调度回t1,接下来t1再继续执行到wait()。此时t1将要wait的时间仍是30分钟!这里的wait就导致新加入的要求8:10执行的任务无法及时执行了!


也就是说,t1错过了notify,只会一直等下去直到时间到,但这样就完全忽略了新加入的比当前更早的任务。


而正常的情况下(第一种代码书写的情况下),还是假设执行到put就切走了:


加锁保证了黄色框中的所有操作都是原子的。




2、知识点补充


a.为什么有wait就必须加锁?


在Java中wait()方法是Object类的方法,用于将当前线程置于等待状态,并释放对象的锁。在使用wait()方法之前,必须先获得对象的锁(即在synchronized代码块中)。


wait()方法必须在synchronized代码块中调用:


1. 锁的拥有者才有资格等待和被唤醒:只有获得对象的锁,即当前线程是锁的拥有者,才能调用wait()方法。这是为了避免在没有持有锁的情况下调用wait()方法导致的不确定行为。


2. 释放对象的锁:wait()方法被调用后,当前线程会释放对象的锁,以便其他线程有机会获得锁并执行相应的同步代码。


3. 防止竞态条件:在多线程环境下,wait()方法和唤醒操作(notify()或notifyAll())之间可能存在竞态条件。通过将wait()方法的调用放在同步代码块中,可以确保只有一个线程能够调用wait()方法,避免了竞态条件的发生。


b.什么是竞态条件?


竞态条件(Race Condition)是指多个线程或进程在并发执行时,由于执行顺序的不确定性而导致的结果依赖于线程或进程执行的相对速度和时序的现象。


在并发编程中,竞态条件可能会导致意外的结果,甚至破坏程序的正确性。竞态条件通常发生在多个线程同时访问和操作共享资源时,其中至少一个线程进行写操作。


下面是一些常见的竞态条件情况:


1. 读-修改-写操作:多个线程同时读取某个共享变量的值,然后基于该值进行修改并写回。由于线程之间的执行时序不确定,可能会导致竞争条件和不一致的结果。


2. 检查-执行操作:多个线程同时检查某个条件,如果满足条件则执行相应的操作。如果多个线程同时检查条件,并根据条件结果进行操作,可能会导致竞态条件和操作冲突。


3. 线程间通信问题:当多个线程之间进行通信或协调时,如果没有适当的同步机制,可能会导致竞态条件。例如,一个线程等待另一个线程的完成信号,但无法确保在接收到信号之前线程已经完成。


竞态条件可能导致不正确的结果、数据损坏、死锁或其他意外行为。为了避免竞态条件,需要使用同步机制(如锁、互斥量、信号量等)来协调线程之间的访问和操作共享资源的顺序。同步机制可以确保在访问共享资源时,只有一个线程能够进行操作,从而避免竞态条件的发生。


c.对方法加锁,锁对象如何确定?


当一个方法被声明为synchronized时,它将被视为一个临界区,只有一个线程可以进入该方法执行,其他线程必须等待。


1、对于非静态方法,锁对象是实例对象(即调用该方法的对象),通常使用this关键字作为锁对象。当一个线程进入synchronized方法时,它会自动获取该方法所属对象的锁(即锁定当前实例对象),其他线程需要等待锁释放才能执行相同实例对象的synchronized方法。


public class MyClass {
    public synchronized void synchronizedMethod() {
        // 方法体
    }
}


在上面的示例中,锁对象是调用SynchronizedMethod()方法的实例对象(即this)。


2、对于静态方法,锁对象是该类的Class对象。静态方法属于类级别,与实例对象无关,因此使用类的Class对象作为锁对象。


示例代码如下:


public class MyClass {
    public static synchronized void synchronizedStaticMethod() {
        // 方法体
    }
}


在上面的示例中,锁对象是MyClass.class。


需要注意的是,锁对象的选择应根据具体的需求和同步策略来确定。有时候,需要使用特定的对象作为锁对象,而不是默认的实例对象或类对象。如多个方法共享同一个锁对象。


d.调用wait会释放锁


当线程在锁对象上调用wait()方法时,它会释放该对象上持有的锁(也称为监视器),从而允许其他线程获取该锁并执行synchronized代码。wait()方法还会将调用线程置于等待状态,直到被通知或中断。


调用wait()方法时涉及的步骤如下:


1. 线程释放它在对象上持有的锁。

2. 线程进入等待状态,直到被其他线程通知或中断。

3. 一旦线程被通知(通过同一对象上的notify()或notifyAll()方法)或被中断,它将尝试重新获取锁。

4. 当线程成功重新获取锁时,它可以从上次离开的地方继续执行。


在wait()期间释放锁,允许其他线程获取锁并执行同步代码,从而促进线程之间的并发性和协调性。


需要注意的是,为了确保正确的同步并避免出现非法监视器状态异常,应该始终在同步块或同步方法中使用相同的对象作为锁,并在其中调用wait()方法。



相关文章
|
1天前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
11 3
|
1天前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
10 2
|
1天前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
1天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
9天前
|
前端开发 Java 测试技术
java日常开发中如何写出优雅的好维护的代码
代码可读性太差,实际是给团队后续开发中埋坑,优化在平时,没有那个团队会说我专门给你一个月来优化之前的代码,所以在日常开发中就要多注意可读性问题,不要写出几天之后自己都看不懂的代码。
48 2
|
18天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
7月前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第22天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个主题,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。
64 0
|
7月前
|
存储 安全 Java
深入理解Java并发编程:线程安全与锁机制
【5月更文挑战第31天】在Java并发编程中,线程安全和锁机制是两个核心概念。本文将深入探讨这两个概念,包括它们的定义、实现方式以及在实际开发中的应用。通过对线程安全和锁机制的深入理解,可以帮助我们更好地解决并发编程中的问题,提高程序的性能和稳定性。
|
4月前
|
存储 安全 Java
解锁Java并发编程奥秘:深入剖析Synchronized关键字的同步机制与实现原理,让多线程安全如磐石般稳固!
【8月更文挑战第4天】Java并发编程中,Synchronized关键字是确保多线程环境下数据一致性与线程安全的基础机制。它可通过修饰实例方法、静态方法或代码块来控制对共享资源的独占访问。Synchronized基于Java对象头中的监视器锁实现,通过MonitorEnter/MonitorExit指令管理锁的获取与释放。示例展示了如何使用Synchronized修饰方法以实现线程间的同步,避免数据竞争。掌握其原理对编写高效安全的多线程程序极为关键。
71 1
|
5月前
|
安全 Java 开发者
Java并发编程中的线程安全问题及解决方案探讨
在Java编程中,特别是在并发编程领域,线程安全问题是开发过程中常见且关键的挑战。本文将深入探讨Java中的线程安全性,分析常见的线程安全问题,并介绍相应的解决方案,帮助开发者更好地理解和应对并发环境下的挑战。【7月更文挑战第3天】
109 0