多线程(三)

单例模式

基本概念

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

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

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

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

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

应用

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

相关推荐
Scott9999HH4 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
腻害兔4 小时前
【若依项目-产品经理视角】深度拆解 RuoYi-Vue-Pro 商城模块:从商品管理到交易引擎,50 张表撑起一整套电商系统
java·大数据·vue.js·产品经理·ai编程
码智社5 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海5 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖6 小时前
JDK 26 新特性详解
java·开发语言
马优晨6 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
人邮异步社区8 小时前
怎么把C语言学到精通?
c语言·开发语言
心平气和量大福大8 小时前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
ttwuai9 小时前
Cursor 生成 CRUD 后,Go 后台接口别只测 200:JWT、RBAC 和 tenant_id 怎么验
开发语言·后端·golang
维天说9 小时前
CLI-Switch 2026年3月版历史设计:Hook、TTY 隔离与 JSON 状态
java·服务器·json