[JavaEE系列] 详解部分多线程案例(内含单例模式+阻塞队列+定时器+线程池)

文章目录

  • 一. 单例模式
    • 1. 饿汉模式
    • 2. 懒汉模式
    • 3. 引出线程安全问题
  • 二. 阻塞队列
    • 1. 使用阻塞队列实现生产者消费者模型
    • 2. 模拟实现阻塞队列(BlockingQueue)
  • 三. 定时器
    • 1. 标准库中的定时器(Timer类)
    • 2. 模拟实现定时器(MyTask+MyTimer)
  • 四. 线程池
    • 1. 标准库中的线程池
    • 2. 模拟实现线程池

        在本篇文章中, 会整理部分常见的多线程案例, 也是比较重要的部分, 属于是面试中较高频考点.

一. 单例模式

        所谓的"单例模式", 就是对对象的实例进行了一定的限制, 使其在一个程序中只能够创建唯一一个实例, 一旦不小心创建了多个, 程序就会立马报错.
        光看上面的定义, 就会感觉"单例模式"不怎么好用, 但是在一些场景中, "单例模式"还是会被频繁地使用到的. Java中"单例模式"的实现方法有很多中, 下面主要总结两类最常见的模式: 饿汉模式 & 懒汉模式.

1. 饿汉模式

        该模式的特点: 程序一旦启动, 就会立即创建实例.

以下是饿汉模式的实现代码:

//饿汉模式
class Singleton{
    private static Singleton instance = new Singleton();
    private Singleton(){}
    public static Singleton getInstance(){
        return instance;
    }
}

这段代码中有几点是非常值得学习的(包括下面懒汉模式实现上也是相同的):

  1. 这段代码中使用静态成员来表示实例, 确保了"单例模式"中的唯一性(只能创建唯一一个实例, 就算再怎么"创建", 最终也还是指向原来的这个实例).
  2. 将构造方法改成 private 私有的, 这样就可以完完全全地禁用了 new 创建实例的方法.
  3. 通过上面的这两点, 就可以完美地保证了在实现上只能是单例的.

        当然, 这段代码最重点的还是体现在"饿汉"的效果(也就是程序一旦启动就会马上创建实例). 重点代码: private static Singleton instance = new Singleton(); 由于在前面基础语法的学习中, 完美就已经知道了 static 修饰的类属性会在类加载之前就准备好了的, 这行代码在赋初始值的时候就直接 new 了, 也就体现出了"饿汉模式"的特点.

2. 懒汉模式

        该模式的特点: 即使程序启动, 也不会马上创建实例, 只有在真正使用到的时候, 才会创建实例.

以下是懒汉模式的实现代码:

//懒汉模式
class SingletonLazy{
    private static SingletonLazy instance = null;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        if(instance == null){
            instance = new SingletonLazy();
        }
        return instance;
    }
}

        懒汉模式的基本骨架与饿汉模式并无两别, 都是为了保证只能创建一个实例.
        但是与饿汉模式不同的是: 懒汉模式只有在使用到的时候, 也就是调用 getInstance() 方法的时候才会创建实例. private static SingletonLazy instance = null; 在对 instance 进行赋初值并不直接 new , 而是把 new 放到了 getInstance() 方法里面, 这样的设计模式显然会比饿汉模式好很多, 不会一开始就浪费巨大的空间(在工程量大的情况下).

3. 引出线程安全问题

        上面写的这两段代码都仅仅是以单线程的角度来看待, 但是在实际开发的过程中, 几乎都是在多线程环境下来运行的, 那么上面的这两类单例模式的代码在调用 getInstance() 方法能否保证其线程安全呢?

  • 对于饿汉模式: 由于在调用 getInstance() 方法之前, 就已经把示例创建好了, 所以在进行多线程调用 getInstance() 方法的时候, 只会执行多线程的读操作, 对于读操作是不存在有线程安全问题的.
  • 对于懒汉模式: 由于在调用 getInstance() 方法的时候, 会出现既读(判断并返回实例)又写(创建实例)的情况, 在多线程的随机调度下, 很有可能就会出现线程安全问题, 当然, 在实例创建完毕之后就不再会执行新的实例的创建, 也就是说在实例创建之后是线程安全的.

解决懒汉模式下创建实例之前的线程安全问题:
        通过上述的一通分析之后, 我们可以确定在懒汉模式下会出现线程不安全的现象, 而在饿汉模式下是不会发生的. 那么我们又应该如何解决这个问题呢?
        解决线程安全问题的方法在前面文章中也已经总结过, 在这个地方也是不例外的, 可以使用加锁操作来解决. 我们可以对读操作和写操作进行加锁, 保证这两个操作的原子性, 进一步地保证了线程安全. 修改之后的代码如下:

//懒汉模式
class SingletonLazy{
    private static SingletonLazy instance = null;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        synchronized (SingletonLazy.class){
            if(instance == null){
                instance = new SingletonLazy();
            }
        }
        return instance;
    }
}

        到这一步, 虽然我们已经解决了线程安全问题, 但是我们将读操作和写操作都放在同一个锁内, 这样的话, 如果在已经创建好实例之后, 只进行读操作来说, 是会非常影响代码的运行效率, 因为在多线程的环境下, 一个线程获取到锁之后, 其他线程就必须进行阻塞等待了.
        对此, 我们可以在原来的锁之前进行一次判断(这个判断只对创建实例之后进行只读操作的提高效率有效), 最终修改之后代码如下(线程安全版本的单例模式):

//懒汉模式
class SingletonLazy{
    private static volatile SingletonLazy instance = null;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (SingletonLazy.class){
                if(instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}

        注意: 在最终这段代码中我还加上了 volatile 来禁止指令重排序, 虽然在这段代码中可能不会出现什么问题, 但是为了线程安全, 还是比较建议在多线程的情况下都加上 volatile.

二. 阻塞队列

        在前面数据结构的学习中, 有一种数据结构叫做"队列", 这里的阻塞队列也可以说是一种特殊的队列, 也是遵循"先进先出"的规则, 当然既是队列也是阻塞, 这里的阻塞主要还是保证了线程安全: 1. 当队列为满时, 如果再进行入队就会发生阻塞等待, 直到有线程出队列; 2. 当队列为空时, 如果再继续出队也会阻塞, 直到有线程进入队列.

生产者消费者模型
        阻塞队列最经典的应用场景就是"生产者消费者模型"了, 这个模型在实际开发中也是用的比较多的一个.


使用"生产者消费者模型"的优点:

  • 能够更好地"解耦合", 如果像下面这张图一样(生产者直接将数据传输给消费者), 那么这样的结构耦合度就比较高了, 因为如果生产者中的数据出现了异常, 那么传输给消费者的数据也将会是异常的数据.
    [JavaEE系列] 详解部分多线程案例(内含单例模式+阻塞队列+定时器+线程池)_第1张图片
    但是如果能够使用阻塞队列, 如下图所示:
    [JavaEE系列] 详解部分多线程案例(内含单例模式+阻塞队列+定时器+线程池)_第2张图片
    通过一个阻塞队列, 让生产者和消费者不再直接进行交互. 在开发阶段: 生产者和消费者分别只需要考虑自己和阻塞队列之间如何进行交互即可, 生产者和消费者两者之间并不需要要交互. 在进行部署的时候, 即使有一方挂了, 也不会影响到另外一方, 这样就可以很好地做到"解耦合"了. 即使有多个消费者来获取这些数据, 该模型也依旧是成立的.
  • 能够做到"削峰填谷", 提高整个系统的抗风险能力. 先举个反例:
    [JavaEE系列] 详解部分多线程案例(内含单例模式+阻塞队列+定时器+线程池)_第3张图片
    当有大规模用户同时来访问A的时候, 而A的责任是将这些请求的数据同步到B中进行计算处理, 这时候B就很可能会因为抗压太大而导致崩溃, 所以这时候也可以使用阻塞队列来承担一部分的压力.
    [JavaEE系列] 详解部分多线程案例(内含单例模式+阻塞队列+定时器+线程池)_第4张图片
    这样的话, 即使B又遇到了需要进行大规模运算的时候, A传过来的一些请求就会先放到阻塞队列当中去, B也就不会出现崩溃的现象了, 保证了整个系统的抗风险能力.

1. 使用阻塞队列实现生产者消费者模型

public class Main {
    public static void main(String[] args) {
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
        Thread customer = new Thread(() -> {
            while(true){
                try {
                    int value = blockingQueue.take();
                    System.out.println("消费元素:" + value);
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread producer = new Thread(() -> {
            int value = 0;
            while(true){
                try {
                    System.out.println("生产元素:" + value);
                    blockingQueue.put(value);
                    value++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        customer.start();
        producer.start();
    }
}

这段代码就可以很好地观察出上面说到的这两个效果:

  1. 当队列为满时, 如果再进行入队就会发生阻塞等待, 直到有线程出队列
  2. 当队列为空时, 如果再继续出队也会阻塞, 直到有线程进入队列.

2. 模拟实现阻塞队列(BlockingQueue)

//实现阻塞队列
class MyBlockingQueue{
    //设数组最大存储1000个元素
    private int[] items = new int[1000];

    //设置队首队尾位置
    private int head = 0;
    private int tail = 0;

    //队列元素个数
    private volatile int size = 0;

    //入队列
    public void put(int value) throws InterruptedException {
        synchronized (this){
            while(size == items.length){
                //表示这时候队列已经满了, 需要进行阻塞等待
                this.wait();
            }
            items[tail] = value;
            tail++;
            if(tail == items.length){
                tail = 0;
            }
            size++;
            this.notify();
        }
    }

    //出队列
    public Integer take() throws InterruptedException {
        int res = 0;
        synchronized (this){
            while(size == 0){
                //表示这时候队列为空, 需要进行阻塞等待
                this.wait();
            }
            res = items[head];
            head++;
            if(head == items.length){
                head = 0;
            }
            size--;
            this.notify();
        }
        return res;
    }
}

public class Main {
    //使用阻塞队列实现生产者消费者模型
    public static void main(String[] args) {
        //BlockingQueue blockingQueue = new LinkedBlockingQueue<>();
        MyBlockingQueue blockingQueue = new MyBlockingQueue();
        Thread customer = new Thread(() -> {
            while(true){
                try {
                    int value = blockingQueue.take();
                    System.out.println("消费元素:" + value);
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread producer = new Thread(() -> {
            int value = 0;
            while(true){
                try {
                    System.out.println("生产元素:" + value);
                    blockingQueue.put(value);
                    value++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        customer.start();
        producer.start();
    }
}

三. 定时器

        定时器(标准库中使用 Timer 类)其实是一个带优先级的阻塞队列(队列中的每个元素都是一个 Task 对象), 这是因为 Timer 内部是呀组织很多任务的, 其中 Timer 里的每一个任务都需要通过一些方式来描述出来的(比如自己定义一个Task), 那么这么多任务应该如何执行呢? 其实这些任务都是按照时间顺序来进行执行的, 这也正解释了为什么使用优先级的阻塞队列(因为阻塞队列中的任务都有自己的执行时间 delay, 最先执行的任务的 delay 一定是最小的, 这时候使用优先级队列就可以非常快速地, 高效地把 delay 值最小的任务给找出来)

1. 标准库中的定时器(Timer类)

public class Main {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        }, 3000);
    }
}

        注意: Timer 类的核心方法就是 schedule(), 该方法有两个参数 — 第一个参数是即将要执行的的任务代码; 第二个参数是指定多长时间后开始执行.

2. 模拟实现定时器(MyTask+MyTimer)

        以下的这段代码还是非常值得学习, 总结的, 建议都可以多敲几遍这段代码.

//模拟实现定时器(MyTask+MyTimer)
class MyTask implements Comparable<MyTask>{
    private Runnable task;

    private long time;

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

    public void run(){
        this.task.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 task, long delay){
        MyTask myTask = new MyTask(task, delay);
        synchronized (locker){
            queue.put(myTask);
            locker.notify();
        }
    }

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

public class Main {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("111");
            }
        }, 1000);
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        }, 2000);
    }
}

四. 线程池

        在此之前, 我们已经在Java基础语法部分学习了字符串常量池, 以及在MySQL部分学习了数据库连接池. 相信都已经对池结构非常熟悉了吧! 下面来总结一种新的池结构 — 线程池.

        在前面文章中, 我已经总结了进程和线程之间的区别, 主要就是进程创建和销毁的成本太高了, 于是就出现了线程来进行优化, 在这里, 又对线程做了进一步的优化 — 线程池. (其实后面还能再进一步优化成轻量级线程: 协程, 但是本文不对其进行总结).
        线程池的工作流程就是: 把线程创建好, 放到一个池子里面, 如果需要使用线程的话, 就可以直接从池子中取出, 而不是通过系统来进行创建; 当线程使用完了之后, 也还是归还到池子里面, 而不是通过系统来进行销毁. 这样就可以大大减少每次启动和销毁线程的损耗.


总结:

  • 降低资源消耗: 减少线程的创建和销毁带来的性能开销.
  • 提高响应速度: 当任务来时可以直接使用, 不用等待线程创建.
  • 可管理性: 进行统一的分配, 监控, 避免大量的线程间因互相抢占系统资源导致的阻塞现象.

        在这个地方, 网上会出现一些问题: 为什么将创建好的线程放到线程池里面后, 从线程池中取出线程要比系统创建线程更快呢?
        因为在线程池中取出的操作是纯用户态操作, 而从系统中来创建的话是涉及到内核态的操作. 正常来说, 纯用户态是会比内核态要更高效一些的.

        在很多情况下, 线程池存在的目的是为了在开发的过程中不需要创建那么多新的线程, 直接使用线程池中已经存在的线程完成想要的工作即可. 其归根结底还是为了提高效率.

1. 标准库中的线程池

public class Main {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }
}

        相信有很多人第一次在看到上面这行代码: ExecutorService pool = Executors.newFixedThreadPool(10); 之后多多少少都会有些疑惑: 为什么这样的代码能够创建出实例呢? 创建实例不应该是使用 new 来创建吗?
        其实类似这种借助静态方法那创建实例的方法就称为是"工厂方法", 去对应的设计模式就称为"工厂模式".
        这里举一个简单的例子来说明"工厂方法": 在通常情况下创建对象都是要借助 new 调用构造方法来进行实现的, 但是在Java中, 构造方法的名字必须是与类名是一样的, 这时候如果再想实现不同版本的构造, 类名就势必需要进行重载, 但是又会出现一个新的问题 — 重载要求参数类型和个数是需要不相同的. 这一点就使得代码实现起来非常地麻烦(正如下面这段代码).

class Point{
    //使用直角坐标系来构造点
    public Point(double x, double y){}
    //使用极坐标系来构造点
    public Point(double a, double b){}
}

        上面这段代码在编译器中直接就会报错(语法不通过), 这时候为了解决这样的问题, 就引入了"工厂模式", 将代码改为如下代码:

class Point{
    //使用直角坐标系来构造点
    public static Point makePointXY(double x, double y){
        Point p = new Point();
        p.setX(x);
        p.setY(y);
        retunr p;
    }
    //使用极坐标系来构造点
    public static Point makePointAB(double a, double b){
        Point p = new Point();
        p.setX(x);
        p.setY(y);
        retunr p;
    }
}

        同理, 以上的 Executors.newFixedThreadPool(10) 代码正是使用"工厂方法".

2. 模拟实现线程池

        以下的这段代码也是非常值得学习, 总结的, 建议都可以多敲几遍这段代码.

//模拟实现线程池
class MyThreadPool{
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }

    public MyThreadPool(int nThreads){
        for(int i = 0; i < nThreads; i++){
            Thread thread = new Thread(() -> {
                while(!Thread.currentThread().isInterrupted()){
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                        break;
                    }
                }
            });
            thread.start();
        }
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool = new MyThreadPool(10);
        for(int i = 0; i < 100; i++){
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("main");
                }
            });
        }
    }
}

你可能感兴趣的:(JavaEE初阶系列,单例模式,java-ee,java)