多线程(三)

单例模式

基本概念

单例模式是设计模式中一种非常典型的模式,也是比较简单的模式

ps:设计模式属于软性要求,框架属于硬性要求

单例模式强制要求某个类在某个程序中,只有唯一一个实例(不允许创建多个实例),只有一个实例,这样的要求是开发中非常常见的场景,通过机器或者程序来实现强制要求的目的

在代码中,如果创建了多个实例,就会直接编译失败

一般来说,我们通过饿汉方式和懒汉方式来实现单例模式

饿汉方式和懒汉方式实现

饿汉方式

因为instance是静态成员,静态成员的初始化,是在类加载阶段触发的.类加载往往是在程序一启动就会触发

饿汉方式的精髓在于无法new一个Singleton对象 我们后续通过getInstance这个方法来获取实例

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

    }
}

懒汉方式

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

    }
}

饿和懒是相对的,饿是尽早创建实例(在类加载的时候就创建),而懒是尽可能晚的创建实例(甚至有可能不创建),延迟创建

懒在计算机中是褒义词,意味着高效率

懒汉模式下,创建实例的时机是第一次使用的时候

懒汉模式和饿汉模式都是通过私有化构造方法使外部无法通过new直接创建对象,只能通过提供的getInstance来获取实例instance(静态私有成员变量),但这样的方式存在缺陷,比如我们可以通过反射的使用来创建该类的实例(但反射本身就不是常规的编程手段,在日常开发中,我们并不推荐使用)

线程安全

单例模式是否线程安全?

饿汉模式获取实例只涉及读操作,是线程安全的,而懒汉模式获取实例的方法是线程不安全的(=操作是原子的,但这里不只有赋值,还有判断并且创建的操作,是线程不安全的)

如何解决线程安全问题

1.解决线程安全问题的常规思路是加锁(把条件和赋值打包成原子的)

2.但加锁之后又引入了新的问题.当我们把实例创建好之后,后续再调用getInstance此时都是直接执行return操作,如果只是判定if+return,不涉及线程安全问题,但是由于加锁就会相互阻塞,影响程序的执行效率

如何解决这一问题呢?

按需加锁

(实例还没创建,涉及线程安全,加锁,如果已经创建,就不需要加锁)

这段代码由于两个instance==null的判断而看起来别扭,可实际上这是因为我们之前写的都是单线程代码,而且仔细分析会发现这两个if的目的并不相同,第一个是判断是否要加锁,第二个是判断是否需要new对象

3.这里还可能涉及一些问题

当一个线程读取instance的时候,另一个线程可能正在修改,这里是否会存在"内存可见性问题"呢?

这是可能会存在的,编译器的优化是非常复杂的,我们无法预测,但为了保险起见,我们可以给instance加上一个volatile,这样就避免了内存可见性问题出现的可能

但这里更关键的问题是指令重排序问题,

我们分析getInstance方法,发现这里的指令可分为三步

1`申请内存空间

2`在空间上构造对象(初始化)

3`内存空间的首地址,赋值给引用变量

正常情况下,是按照1 2 3的顺序来执行的,但是在指令重排序的情况下,可能会变成1 3 2,如果是1 3 2,在多线程情况下可能会出现bug,很可能拿着一个未初始化的对象来进行操作(这与双重if和指令重排序有关)

这也表名了加volatile的必要性

volatile的功能有两方面:1.确保每次读取操作都是读内存 2.关于改变量的读取和修改操作,不会触发重排序

阻塞队列

基本概念

阻塞队列是一种更加复杂的队列

1.线程安全

2.阻塞特性

a)队列为空,尝试出队列,出队列操作就会阻塞,阻塞到其他线程添加元素为止

b)队列为满,尝试入队列,入队列操作就会阻塞,阻塞到其他线程取走元素为止

Java标准库中提供了现成的阻塞队列BlockingQueue

当我们想要new 一个blocking queue是会发现他的构造方法都是多线程的(多线程是并发的解决方案)

我们通常使用ArrayBlockingQueue,LinkedBlockingQueue,PriorityBlockingQueue来构造

当用这个队列进行入队列出队列操作时,只有put和take才带有阻塞功能

并且我们在new一个blockingqueue时最好设置最大容量,如果不设置的话,就会默认一个非常大的数值

阻塞队列,没有提供一个"阻塞队列获取队首元素"的方法

主要应用场景--"生产者消费者模型"

阻塞队列一个最常用的场景就是实现"生产者消费者模型"(这是多线程编程中一个典型的编码技巧)

生产者消费者模型的两个重要优势

1.解耦合(不一定是两个线程之间,也可以是两个服务器之间)

2.削峰填谷

(也正是因为这样的优势,生产者消费者模型在日常开发中经常看到,并且消息队列这个东西也很常见)

生产者消费者模型付出的代价

1.引入队列之后,整体的结构会更加复杂,此时就需要更多的机器进行部署,生产环境的结构会更加复杂,管理起来更加麻烦

2.效率会有影响

java 复制代码
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue queue=new LinkedBlockingQueue(100);
        Thread producer=new Thread(()->{
            int n=0;
            while(true){
                try {
                    System.out.println("生产元素: "+n);
                    queue.put(n);
                    n++;
                }
                catch (InterruptedException e) {
                        throw new RuntimeException(e);
                }
            }
        },"producer");

        Thread consumer=new Thread(()->{
            while(true){
                try{
                    Object n=queue.take();
                    System.out.println("消费元素 :"+n);
                    Thread.sleep(1000);

                }catch(InterruptedException e){
                    throw new RuntimeException(e);
                }
            }
        });
        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
    }

}

模拟实现一个简单的阻塞队列

java 复制代码
class MyBlockingQueue{
    private String[] queue;
    //队首
    private int head;
    //队尾
    private int tail;
    public int size=0;
    //提供构造方法
    public  MyBlockingQueue(int x){
        this.queue=new String[x];
    }
    public void put(String n) throws InterruptedException {
        synchronized (this){
            if(size>=queue.length){
                this.wait();
            }
            queue[tail]=n;
            tail++;
            if(tail>=queue.length){
                tail=0;
            }
            size++;
            this.notify();
        }
    }
    public String take() throws InterruptedException {
        synchronized (this){
            if(size==0){
                this.wait();
            }
            String ret=queue[head];
            if(head>=queue.length){
                head=0;
            }
            size--;
            this.notify();
            return ret;
        }
    }
}

这里可以直接使用this来作为take和put的锁对象是因为put和take是在互相唤醒,不可能有一些线程阻塞在take,有一些阻塞在put(也就是说队列不可能既是满又是空)

最开始,我们的判断条件时if但深入了解发现wait建议我们使用while循环(这是因为wait不一定只是被notify唤醒,也有可能被interrupt这样的方法唤醒,if作为判断条件,此时wait就存在被提前唤醒的风险)

于是我们就把这里的判断条件用while来设置,这样循环的目的是为了"二次验证",判断这里的条件是否成立,wait先判定一次,wait唤醒再判定一次,(再确认一下,队伍是否不为空)

如果只是一个线程take,一个线程put,不会出现自己唤醒自己的情况,多个线程take,多个线程put,会有这种风险,但可以通过while循环判定条件,避免这样的唤醒给程序带来风险

线程池

基本概念

常量池:字符串常量,在JAVA程序最初构建的时候,就已经准备好了,等程序运行的时候,这样的常量也就加载到内存中,省下了创建/销毁的开销

线程池的概念与之类似,就是为了让我们高效的创建销毁线程的

解决线程频繁创建销毁的方案:1.线程池 2.协程(轻量级线程)

线程池就是把线程提前创建好,放在一个地方(类似于数组)需要的时候随时去取,用完了还到池子里

这里我们了解一下操作系统的用户态和内核态

一个操作系统=内核+配套的应用程序(内核:包含操作系统中的各种核心功能(1.管理硬件设备 2.给软件提供稳定的运行环境))

一个软件系统,内核就是一份,一份内核要给所有的应用程序提供服务和支持的

如果一段代码是应用程序自主完成的,整个执行过程是可控的,如果有一段代码需要进入到内核中那么他就是不可控的

从线程池中提取线程,纯应用程序就可以完成(可控的)

从操作系统创建线程,就需要内核配合(不可控的)

核心方法

JAV标准库中也提供了直接使用的线程池,ThreadPoolExecutor

核心方法是submit(Runnable) 通过Runnable描述一段要执行的任务,通过submit任务放到线程池中,此时线程池就会这样的任务

线程池中的参数是什么意思(经典面试题)

我们只需要看最后一个参数最多的构造方法即可

1.int corePoolSize 核心线程数 至少有多少个线程,线程池一创建,这些线程也随之创建,直到整个线程池销毁,这些线程才被销毁

2.int maximumPoolSize 最大线程数 核心线程数+非核心线程数(自适应)(不繁忙就销毁,繁忙时就创建)

3.long keepAliveTime 非核心线程允许空闲的最大时间

4.TimeUnit unit 时间单位 (timeUnit是枚举)

5.BlockingQueue<Runnable> workQueue 工作队列,可以选择使用数组/链表,可以指定capacity,可以指定是否要有带有优先级,也可以指定比较规则

线程池的本质是什么?

线程池本质上就是生产者消费者模型,调用submit就是在生产任务,线程池中的线程就是在消费任务

6.ThreadFactory threadFactory 工厂模式(统一构造并初始化线程) 工厂模式也是一种设计模式,和单例模式是并列的关系(用来弥补构造方法的缺陷)

工厂模式(与单例模式一样,也是一种设计模式)

工厂模式给线程提供的工厂类,线程中有一些属性可以设置,线程池是一组线程

构造方法的重载要求参数类型和个数是不同的(这是C++和JAVA一个共用的问题),构造方法的名字是固定的,要想提供不同的版本,就要通过重载,但有时候又不一定能构成重载,这时候就要通过工厂模式来解决这个问题

工厂方式的的核心就是通过静态方法,把构造对象的new的过程各种属性初始化的过程封装起来了,提供多组静态方法,实现不同情况的构造

提供工厂方法的类,就可以称之为工厂类

java 复制代码
class Point{

}
class Factory{
    public static Point makePointByXY(double x, double y){
        Point p=new Point();
        return p;
    }
    public static  Point makePointByRA(double r,double a){
        Point p=new Point();
        return p;
    }
}
public class Demo25 {
    public static void main(String[] args) {
        Point p=Factory.makePointByRA(10,20);
    }
}

7.RejectedExecutionHandler handler 拒绝策略

这是整个线程池参数中最为重要,最复杂的一个参数

submit把任务添加到任务队列中,任务队列就是阻塞队列,队列满了,再添加就会阻塞,一般不希望程序阻塞太多,对于线程池来讲,发现入队列操作时队列满了,并不会真的阻塞,而是执行拒绝策略相关的代码

|---------------------|---------------------|
| 拒绝策略 | 具体描述 |
| AbortPolicy | 线程池直接抛出异常(可能无法继续工作) |
| CallerRunsPolicy | 让调用submit的线程自己执行任务 |
| DiscardOldestPolicy | 丢弃队列中最早的任务 |
| DiscardPolicy | 丢弃队列中最新的任务 |

线程池的参数很多,为了方便使用,JAVA标准库也提供了另一组类,针对ThreadPoolExcutor进行进一步的封装,简化线程池的使用(这个也是基于工厂设计模式)

Executors.newFixedThreadPool (nThread )核心线程数和最大线程数一样

Executors.newCashedThreadPool() 最大线程是一个很大的数字(可以无限增加)

虽然这样的使用更加方便了,但Executors线程数目/拒绝策略都是隐式的,不好控制,在日后的应用中,应该根据需求来选择

模拟实现线程池

java 复制代码
class MyThreadPool{
    private BlockingQueue<Runnable> queue=null;
    public MyThreadPool(int n){
        //初始化线程池,创建固定个数的线程
        queue=new ArrayBlockingQueue<>(1000);
        for(int i=0;i<n;i++){
            Thread t=new Thread(()->{
                while(true){
                    try {
                        Runnable task=queue.take();
                        task.run();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            });
            t.start();
        }
    }
    public void submit(Runnable task) throws InterruptedException {
        queue.put(task);
    }
}
public class Demo26 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool=new MyThreadPool(10);
        for (int i = 0; i < 100; i++) {
            int id=i;
            myThreadPool.submit(()->{
                System.out.println(Thread.currentThread().getName()+"  id  "+  id+"running");
            });
        }
    }
}

这里我们要注意线程池中的线程是前台线程,阻止线程的结束

shutdown能把线程池中的线程全部关闭,但无法确定线程池中的每个任务全部执行完毕

如果我们想要全部执行完毕,就需要awaitTermination方法

定时器

基本概念

定时器类似闹钟,时间到了,执行一定的逻辑

标准库中的定时器

标准库中提供了一个Timer类,Timer类的核心方法为schedule

schedule包含两个参数,第一个参数即要执行的任务代码,第二个参数指定多长时间之后执行(单位为毫秒)

正常描述任务是Runnable,timer又进行封装timerTask

但本质核心还是重写run方法

Timer中也包含前台线程,这也意味着他会阻止进程结束

模拟实现一个定时器

1.创建一个类,表示一个任务

2.定时器中,能够管理多个任务,必须使用一些集合类把多个任务管理起来 (优先级队列)

3.实现schedule方法,把任务添加到队列中

4.额外创建一个线程,负责队列中的任务(要看任务的时间是否到了)

java 复制代码
class MyTimerTask implements Comparable<MyTimerTask>{
    private Runnable task;
    private  long time;

    public MyTimerTask(Runnable task, long time) {
        this.task=task;
        this.time=time;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int)(this.time-o.time);
    }
    public long getTime(){
        return time;
    }
    public void run(){
        task.run();
    }
}
//自己实现一个计时器
class MyTimer{
    private PriorityQueue<MyTimerTask>queue=new PriorityQueue<>();
    //这里我们也可以使用this作为锁对象
    private Object locker=new Object();
    public void schedule(Runnable task,long delay){
        synchronized (locker){
            MyTimerTask timerTask=new MyTimerTask(task,System.currentTimeMillis()+delay);
            queue.offer(timerTask);
            locker.notify();
        }
    }
    public MyTimer(){
        Thread t=new Thread(()->{
           try{
               while(true){
                   synchronized(locker){
                       while(queue.isEmpty()){
                           locker.wait();
                       }
                       MyTimerTask task=queue.peek();
                       if(System.currentTimeMillis()< task.getTime()){
                           locker.wait(task.getTime()-System.currentTimeMillis());
                       }else{
                           task.run();
                           queue.poll();
                       }
                   }
               }
           }catch (InterruptedException e){
               throw new RuntimeException(e);
           }
        });
        t.start();
    }
}
public class Demo27 {
    public static void main(String[] args) {
        MyTimer timer=new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        },3000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 2000");
            }
        },2000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 1000");
            }
        },1000);
        Executors.newScheduledThreadPool(4);
    }
}

一些注意的细节:

1.计算机中以时间戳表示时刻,以1970.1.1 0时0分0秒为基准,计算当前时刻与基础时刻的秒数之差

(由于是我们自定义的类必须自定义比较规则)

2.wait和notify的合理应用,如果不用的话由于不知道delay时间是多少,就有可能涉及到忙等,用sleep是不太合理的,因为sleep无法设置合理的时间,所以我们推荐使用wait和notify

定时器除了基于堆方式来实现的定时器之外,还有一种方案,基于"时间轮"

类似搞了一个循环队列(数组)每个元素是一个时间单位,每个元素是一个链表,每到一个时间单位,光标指向下一个元素,同时把这个元素对应的链表中的任务执行一遍

优势:性能更高 缺点:时间精度不如优先队列

应用

由于定时器是一个非常重要的组件,在分布式系统中,把定时器专门提取出来,封装成了一个单独的服务器(和消息队列很像)

相关推荐
VBA63371 小时前
VBA之Excel应用第十节:用Union和Intersect方法获得单元格区域
开发语言·自然语言处理
klzdwydz1 小时前
注解与反射
java·开发语言
ULTRA??1 小时前
C语言简化版本开辟动态内存的万能MALLOC宏封装
c语言·开发语言
talenteddriver2 小时前
java: 分页查询(自用笔记)
java·开发语言
enjoy编程2 小时前
Spring-AI 利用KeywordMetadataEnricher & SummaryMetadataEnricher 构建文本智能元数据
java·人工智能·spring
繁华似锦respect2 小时前
lambda表达式中的循环引用问题详解
java·开发语言·c++·单例模式·设计模式·哈希算法·散列表
我要升天!2 小时前
QT -- 网络编程
c语言·开发语言·网络·c++·qt
Unlyrical2 小时前
为什么moduo库要进行线程检查
linux·服务器·开发语言·c++·unix·muduo
GIS阵地2 小时前
Qt实现简易仪表盘
开发语言·c++·qt·pyqt·qgis·qt5·地理信息系统