javaEE:多线程,单列模式和生产者消费者模型

单列模式

单列模式的特点是:确保一个类在整个程序运行期间只会被创建一个实例,并且提供一个全局访问点供所有地方使用

单列模式的三要素
  1. 构造函数私有:不允许外部用new来创建对象
  2. 内部创建唯一实例:类自己创建并保存唯一实例
  3. 提供一个全局访问点 :通常是一个静态方法getInstance()
单列模式的创建方法

最简单的单列模式示例(饿汉式):

java 复制代码
class Singleton {
    private static Singleton singleton = new Singleton();
    private Singleton(){}
    public static Singleton getInstance(){
        return singleton;
    }
}

代码解析:

第一行代码用来创建一个实例.由于被static修饰,所以这个实例会在类生成后的第一时间创建.是内部创建的唯一实例

第二行代码构造函数私有 .不允许外部进行new Singleton()操作

第三行代码提供一个全局访问点 ,任何外部类都可以通过getInstance()方法来获得唯一实例

最简单的单列模式示例(懒汉式):

java 复制代码
class SingletonLazy {
    private static SingletonLazy singletonLazy = null;
    public static SingletonLazy getInstance()() {
        if (singletonLazy == null) {
            return singletonLazy = new SingletonLazy();
        }
        return singletonLazy;
    }
    private SingletonLazy(){}
}

饿汉式和懒汉式的区别

饿汉式如同它名字一样,很饿,所以一上来就会创建实例

懒汉式如同它的名字一样,很懒,只在实例第一次被调用的时候才创建

懒汉式中的线程安全问题

对于懒汉式代码.因为它是在调用时就创建实例,所以当两个不同的线程同时调用了它,那么就会同时创建两个实例.这是不符合单列模式的预期的,所以我们需要修复线程安全问题

既然是预防同时创建新实例,我们只要对创建实例这个代码加锁就可以了吧

java 复制代码
class SingletonLazy {
    private static SingletonLazy singletonLazy = null;
    public static SingletonLazy getInstance()() {
        synchronized (SingletonLazy.class) {
            if (singletonLazy == null) {
                return singletonLazy = new SingletonLazy();
            }
        }
        return singletonLazy;
    }
    private SingletonLazy(){}
}

这段代码正确实现了线程安全,不会创建多个实例.但是这个代码性能差.

为什么?

原因是每一次调用getInstance()时,即使对象已经创建了,也必须要进入加锁环节.要知道加锁的开销是非常大的.并且在多线程环境访问性能更会进一步下降(两个进程同时访问必有一个进程拿不到锁而等待)

所以我们应该在没有创建实例的时候进行加锁,如果已经创建了实例,那么就没必要再浪费性能加锁了

因此,优化后的代码如下

java 复制代码
class SingletonLazy {
    private static SingletonLazy singletonLazy = null;
    public static SingletonLazy getInstance() {
        if (singletonLazy == null) {
            synchronized (SingletonLazy.class) {
                if (singletonLazy == null) {
                    return singletonLazy = new SingletonLazy();
                }
            }
        }
        return singletonLazy;
    }
    private SingletonLazy(){}
}

这段代码完美实现了我们想要的逻辑.即只在需要没有创建实例时进行加锁保护,实例已创建就无需加锁保护

但我注意到这段代码

java 复制代码
    public static SingletonLazy getInstance() {
        if (singletonLazy == null) {
            synchronized (SingletonLazy.class) {
                if (singletonLazy == null) {
                    return singletonLazy = new SingletonLazy();
                }
            }
        }

同时出现了两个相同的if判断 if (singletonLazy == null).那是不是我们可以省略掉第二个if,这样还能减少一次if判断,进一次提高性能?

先说答案.不行.当我们删掉第二个个if,原代码会变成这样

java 复制代码
    public static SingletonLazy getInstance() {
        if (singletonLazy == null) {
            synchronized (SingletonLazy.class) {
                return singletonLazy = new SingletonLazy();
            }
        }
    }

其实这个段代码直接不满足线程安全了.原因是第一个if没加锁,在没创建实例的情况下,如果有两个线程同时进入了if.那么最后还是会创建两个实例.此时synchronized加锁的只是新建一个实例的过程,作用只是让两个线程先后创建新实例

  • volatile关键字的必要性
    对象初始化(new Singleton())不是原子操作.JVM会将这一步骤拆分为三个步骤:
  1. 分配内存
  2. 调用构造方法初始化对象
  3. 将instance指向这段内存
    但由于指令重排序,实际顺序可能会变成:
  4. 分配内存
  5. 将instance指向这段内存(此时对象未初始化)
  6. 调用构造方法初始化对象
    如果另一个线程此时恰好调用getInstance()方法.会返回一个"半成品对象".使用这个半成品对象可能会导致以下问题
  • 空指针异常
  • 业务逻辑异常
  • 难以发现的随机错误
    对于这样的问题,由于出现概率极小,所以排查难度非常大.一但发生就容易产生致命的问题
    而volatile就可以解决这个问题
    volatile的主要作用就是禁止指令重排
    当我们对instance修饰上volatile后,就不会出现上述问题了

只有在懒汉式中才需要使用volatile.饿汉式不需要,原因是饿汉式在类加载阶段就进行实例化,在类加载阶段中JVM为了保证线程安全,不会进行指令重排,所以不需要volatile

阻塞队列 Blocking Queue

队列的特点是先进先出.阻塞队列和队列的基本逻辑是一样的,也是先进先出,而它与队列的不同点就是会阻塞.具体来说

当队列满时,队列会自动阻塞等待,不会丢数据,也不会忙等

java 复制代码
queue.put(item);
//如果此时queue中队列满了会一直等到队列有空时再向其添加item

当队列空时,队列会自动阻塞等待到队列右元素

Java 复制代码
queue.take();
//如果此时queue队列中没有元素会一直等待到有元素时再拿走元素

put()和take()方法做好了自动化,无须我们手动使用wait/notify

为了便于理解阻塞队列的put()和take()方法.我们可以自己写一个put()和take()

手动实现阻塞队列

首先我们得知道put()和take()的逻辑

  • 当队列满时,put()操作会被阻塞等待
  • 当队列空时,take()操作会被阻塞等待
    我们一开始需要做的就是创造一个环形数组用来临时存放数据
java 复制代码
public class MyBlockingQueue {
    private final int[] elem;
    private int head = 0;
    private int tail = 0;
    private int size;
    public MyBlockingQueue() {
        this(10);
    }

    /***
     * 可以手动指定要创建的数组大小
     * @param length 数组大小
     */
    public MyBlockingQueue(int length) {
        elem = new int[length];
    }
}

上述代码就是环形数组的实现.另外我添加了构造方法,通过构造方法我们能够自定义环形数组的大小

接下来就是配置put()和take()的逻辑了.既然是阻塞,那么一定得用上锁.这次我们使用的是更高级的ReentrantLock.因为我将会使用Condition来完成阻塞逻辑

java 复制代码
Lock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();

上面这一段就是锁的逻辑.Condition就类似于synchronized的wait()/notify()方法.但COndition更加灵活.因为我们可以通过notEmpty来只管理这个条件的wait()/notify(),而不会影响到notFUll的wait()/notify()

简单来说就是通过Condition.可以将一个wait()/notify()分割成多个wait()/notify(),每一个wait()/notify()都可以只管理特定的条件

下面是put()逻辑的具体实现

java 复制代码
public void put(int data) {
        lock.lock();
        try {
            while(size == elem.length) {
                notFull.await();
            }
            elem[tail++] = data;
            if(tail == elem.length) {
                tail = 0;
            }
            size++;
            notEmpty.signal();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

需要注意的是由于我们使用的是环形数组,所以别忘了维护数组不越界(就是if(tail == elem.length)的逻辑)

take()逻辑和put()几乎无差别

java 复制代码
    public int take() {
        lock.lock();
        try {
            while(size == 0) {
                notEmpty.await();
            }
            int tmp = elem[head++];
            if(head == elem.length) {
                head = 0;
            }
            size--;
            notFull.signal();
            return tmp;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

汇总代码如下

java 复制代码
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyBlockingQueue {
    private final int[] elem;
    private int head = 0;
    private int tail = 0;
    private int size;
    public MyBlockingQueue() {
        this(10);
    }

    /***
     * 可以手动指定要创建的数组大小
     * @param length 数组大小
     */
    public MyBlockingQueue(int length) {
        elem = new int[length];
    }
    Lock lock = new ReentrantLock();
    Condition notEmpty = lock.newCondition();
    Condition notFull = lock.newCondition();

    public void put(int data) {
        lock.lock();
        try {
            while(size == elem.length) {
                notFull.await();
            }
            elem[tail++] = data;
            if(tail == elem.length) {
                tail = 0;
            }
            size++;
            notEmpty.signal();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

    public int take() {
        lock.lock();
        try {
            while(size == 0) {
                notEmpty.await();
            }
            int tmp = elem[head++];
            if(head == elem.length) {
                head = 0;
            }
            size--;
            notFull.signal();
            return tmp;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
BlockingQueue 提供的四类方法
add() remove() 队列的原始方法

add()和remove()和原本的队列方法一模一样,使用方式也是相同的.当队列满时add()会抛出异常:当队列空时take()也会抛出异常

offer() poll() 队列的原始方法

offer()和poll()和原本的队列方法一模一样.当队列为满时使用offer()会返回false:当队列为空时使用poll()方法会返回null

put() take() 阻塞等待

当队列为满时使用put()会阻塞等待,直到有空时才会向其添加元素:当队列为空时使用take()方法也是同理

offer(data,time,unit) poll(time.unit) 超时等待

unit是时间单位

超过指定时间还未完成对应添加/提取会返回false;如果在指定时间完成对应添加/提取会返回true

实现生产者消费者模型

在实现生产者消费者模型之前我们应该知道它是干什么的,为什么要使用它

生产者消费者模型是什么?

通过生产者消费者模型可以解决多线程之间如何安全且高效的传递数据的问题,让生产数据的线程和消费的线程能够解耦,并行,互不干扰

  • 生产者:负责产生数据
  • 消费者:负责处理数据
  • 阻塞队列:负责存放数据和调节生产者/消费者速度
为什么要使用生产者消费者模型?
  1. 当生产速度和消费速度不一致时:
    生产者可能生产更快,消费者处理慢,导致数据堆积,内存爆炸
    而当消费者快生产者慢时,消费者经常拿到空数据
    而生产者消费者模型正好解决了这个问题:
  • 队列满了,让生产者阻塞,停止生产
  • 队列空了,消费者阻塞,停止处理数据
    通过这样的调节,我们就能解决生产和消费速率不均的问题
  1. 线程间可以更安全的传递数据
    当多个线程同时操作同一组数据时,容易出现线程安全问题
    而使用BlockingQueue时会自动加锁,不需要我们手动使用synchronized

还有一些其它的好处(解耦合,防止cpu空转等等)

  • 当然不光只有好处,也有一部分代价:
  1. 效率降低:加锁解锁,阻塞等待等等,都会降低代码的执行效率
  2. 系统结构更复杂,运维成本增加:多了一个生产者消费者模型,多了一段代码
    但总的来说,在大部分情况下使用生产者消费者模型都是利大于弊的
生产者消费者模型的具体实现

因为前文已经写了一个MyBlockingQueue类,所以我们可以直接使用自己手写的阻塞队列来实现一个生产者消费者模型,非常简单

java 复制代码
public class Main {
    public static void main(String[] args) {
        MyBlockingQueue queue = new MyBlockingQueue();
        Thread t1 = new Thread(() -> {//生产者
            for (int i = 0; i < 500; i++) {
                queue.put(i);
                System.out.println("生产元素" +
                        i);
            }
        });
        Thread t2 = new Thread(()-> {//消费者
            for (int i = 0; i < 500; i++) {
                System.out.println("消费元素" +
                        queue.take());
            }
        });
        t1.start();
        t2.start();
    }
}
相关推荐
启山智软2 小时前
【单体系统与分布式系统是两种根本不同的软件架构模式】
java·vue.js·spring boot·后端·spring
奈何不吃鱼2 小时前
【安装配置教程】在linux部署java项目
java·linux·intellij-idea·jar
AAA简单玩转程序设计2 小时前
Java里的空指针
java·前端
虎子_layor2 小时前
Spring 循环依赖与三级缓存:我终于敢说把这事儿讲透了
java·后端·spring
better_liang2 小时前
每日Java面试场景题知识点之-单例模式
java·单例模式·设计模式·面试·企业级开发
okseekw2 小时前
递归:不止是 “自己调用自己”,看完这篇秒懂
java·后端
琢磨先生David2 小时前
Java算法题:移除数组中的重复项
java·数据结构·算法
Java天梯之路2 小时前
手撸 Spring 简易版 AOP
java·spring boot·面试