详细剖析多线程3----代码案例分析



一、单例模式(校招中最常考的设计模式之⼀)

单例模式是一种设计模式,其核心思想是保证一个类只有一个实例,并提供一个全局访问点来访问这个实例。通过单例模式,可以确保在程序运行过程中只有一个实例被创建并且被不同模块共享使用。
单例模式具体的实现⽅式有很多. 最常⻅的是 “饿汉” 和 “懒汉” 两种.

1.1饿汉模式

下面这段代码,称为”饿汉模式“,是单例模式中一种简单的写法,实例在类加载的时候就创建了,创建时机非常早,相当于程序一启动,实例就创建了,就使用”饿汉“形容创建实例非常迫切,非常早。

class Singleton{
    //期望这个类只能有唯一的实例(一个进程中)
    private static Singleton instance=new Singleton();
    //static这个引用就是期望创建出唯一的实例的引用
    //static静态的指的是”类属性“,instance就是Singleton类对象里持有的属性
    //类对象就是.class文件,Singleton.class()从.class文件加载到内存中表示这个类的一个数据结构
    //每个类的类对象只存在一个,类对象的static属性自然也就只有一个了
    //因此,instance指向的这个对象就是唯一的一个对象
    private Singleton(){
        //构造方法设为私有的,其他代码就没法new了,从根本上让其他代码只能使用getInstance()方法
    }
    public static Singleton getInstance(){
        //其他代码要想使用这个类的实例,就需要通过这个getInstance()方法获取
        //不应该在其他代码中重新new这个对象,而是使用这个方法获取到现成的对象
        return instance;
    }
}
public class ThreadDemo22 {
    public static void main(String[] args) {
        //Singleton s=new Singleton();不能new,会报错
        Singleton s=Singleton.getInstance();
        Singleton s2=Singleton.getInstance();
        System.out.println(s==s2);//true,多次调用也不会产生多个实例,始终是那一个实例
    }
}
  • 第一步:先创建好这个实例,通过一个静态成员持有
  • 第二步:提供一个静态的public方法,能够获取到刚才那个实例
  • 第三步:构造方法设为私有,外界没法进行new操作,让编译器真正进入检查之中,避免由于人的约定产生误会

上述代码线程是否安全?
对于”饿汉模式“,getInstance直接返回Instance实例,这个操作本质上是读操作,多个线程读取同一个变量,线程是安全的。

1.2懒汉模式

下面这段代码,是"懒汉模式"的方式实现单例模式,“懒汉模式”,创建实例的时间比较晚,只到第一次使用的时候才会创建实例。

class Singleton {
比特就业课
 private static Singleton instance = null;
 private Singleton() {}
 public static Singleton getInstance() {
 if (instance == null) {
 instance = new Singleton();
 }
 return instance;
 }
}

上面代码线程是否安全呢?
这段代码是线程不安全的,因为在getInstance方法中没有对instance的赋值操作进行同步处理,多个线程同时调用getInstance方法时可能会导致多个实例被创建。可以通过给getInstance方法加上synchronized关键字来保证线程安全。

改进后的代码:

class Singleton {
 private static Singleton instance = null;
 private Singleton() {}
 public synchronized static Singleton getInstance() {
 if (instance == null) {
 instance = new Singleton();
 }
 return instance;
 }
}

现在这段代码线程安全吗?
这段代码并不是完全线程安全的。在并发环境下,多个线程同时调用getInstance()方法时,可能会同时通过instance == null的判断,从而多个实例被创建出来。
为了改进这个问题,可以使用双重检查锁定(Double-Checked Locking)机制来确保线程安全。原因是在双重检查锁定中,只有第一次创建实例的时候才会进入同步块,其他线程都会在第一次创建实例后直接返回实例,避免了不必要的同步开销。同时,在instance变量前加上volatile关键字,可以保证多个线程之间对instance变量的可见性,避免出现指令重排的问题。

改进后的线程安全代码:

class SingletonLazy{
    //这个引用指向唯一实例,这个引用先初始化为null,而不是立即创建实例
    private static volatile SingletonLazy instance=null;
    public static SingletonLazy getInstance(){
        //如果instance为null,说明是首次调用,首次调用要考虑线程安全问题,就要加锁
        //如果非null,就说明是后续的调用,就不必加锁了
        //双重校验锁
        //外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了
        if(instance==null){
            //如果首次调用getInstance,那么此时instance引用为空,就会进入if条件,从而把实例创建出来
            //如果是后续再次调用getInstance,由于instance已经不再是null了,此时不会进入if,直接返回之前创建好的引用了(其他竞争到锁的线程就被⾥层 if 挡住了. 也就不会继续创建其他实例.)
            //这样设定,仍然可以保证该类的实例是唯一一个
            //与此同时,创建实例的时机就不是程序驱动时了,而是第一次调用getInstance的时候
            synchronized ((Singleton.class){
                if(instance==null){
                    instance=new SingletonLazy();
                }
            }
        }
        return instance;

    }
    private SingletonLazy(){}
}
public class ThreadDemo23 {
    public static void main(String[] args) {
        SingletonLazy s1=SingletonLazy.getInstance();
        SingletonLazy s2=SingletonLazy.getInstance();
        System.out.println(s1==s2);//true,指向同一个实例
    }
}

在这里插入图片描述

理解双重 if 判定 / volatile:(面试题)
1. 假设有三个线程, 开始执行 getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同⼀把锁.
2. 其中线程1 率先获取到锁, 此时线程1 通过里层的 if (instance == null) 进⼀步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.
3. 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过里层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了.
4. 后续的其他线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了,从而不再尝试获取锁了. 降低了开销.
5. 在instance变量前加上volatile关键字,可以保证多个线程之间对instance变量的可见性,避免出现指令重排的问题.

二、阻塞队列

阻塞队列是⼀种特殊的队列. 也遵守 “先进先出” 的原则。
他是基于普通队列做出的扩展–
a)如果针对一个已经满了的队列进行入队列,此时入队列操作就会阻塞,一直阻塞到队列不满(其他线程出队列元素)之后。
b)如果针对一个已经空了的队列进行出队列,此时出队列操作就会阻塞,一直阻塞到队列不空(其他线程入队列元素)之后。

生产者消费者模型

生产者消费者模型是一种并发模式,其中生产者负责生产数据,而消费者负责消费数据。通过阻塞队列,生产者将生产的数据放入队列中,而消费者从队列中取出数据进行消费。当队列为空时,消费者将被阻塞直到队列中有数据;当队列满时,生产者将被阻塞直到队列有空间可用。

阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒。 (削峰填⾕)
阻塞队列可以有效地解耦生产者和消费者。

阻塞队列实现:

//实际开发中,生产者消费者模型,往往是多个生产者多个消费者
//这里的生产者和消费者往往不仅仅是一个线程,也可能是一个独立的服务器程序,甚至是一组服务器程序
class MyBlockingQueue1{
    private String[] elems=null;
    private int head=0;
    private int tail=0;
    private int size=0;
    //准备锁对象
    private Object locker=new Object();
    public MyBlockingQueue1(int capacity){
        elems=new String[capacity];
    }
    public void put(String elem) throws InterruptedException {
        //锁加到这里和加到方法上本质是一样的,加到方法上是给this加锁,此处是给locker加锁
        synchronized (locker){
            //改为while循环意味着wait唤醒之后再判定一次条件
            //wait之前判定一次,唤醒之后判定一次,再次确认,发现队列还是满着的,就继续等待
            while(size>=elems.length){
                //队列满了
                //后续需要这个代码阻塞
                locker.wait();
            }
            //新的元素要放到tail指向的位置上
            elems[tail]=elem;
            tail++;
            if(tail>=elems.length){
                tail=0;
            }
            size++;
            //入队列成功之后唤醒
            locker.notify();
        }
    }
    public String take() throws InterruptedException {
        String elem=null;
        synchronized (locker){
            while(size==0)
            {
                //队列空了
                //后续也需要让这个代码阻塞
                locker.wait();
            }
            //取出head位置的元素并返回
            elem=elems[head];
            head++;
            if(head>=elems.length){
                head=0;
            }
            size--;
            //元素出队列成功,加上唤醒
            locker.notify();
        }
        return elem;
    }
}
public class TreadDemo26 {
    public static void main(String[] args) {
        MyBlockingQueue1 queue=new MyBlockingQueue1(1000);
        // 生产者
        Thread t1 = new Thread(() -> {
            int n = 1;
            while (true) {
                try {
                    queue.put(n + "");//入队列
                    System.out.println("生产元素 " + n);
                    n++;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        // 消费者
        Thread t2 = new Thread(() -> {
            while (true) {
                try {
                    String n = queue.take();//出队列
                    System.out.println("消费元素 " + n);

                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
        t2.start();
    }
}

三、定时器

标准库中的定时器
• 标准库中提供了⼀个 Timer 类. Timer 类的核⼼⽅法为 schedule .
• schedule 包含两个参数. 第⼀个参数指定即将要执⾏的任务代码, 第⼆个参数指定多⻓时间之后执⾏ (单位为毫秒).

实现定时器
定时器的构成
• ⼀个带优先级队列(不要使⽤ PriorityBlockingQueue, 容易死锁!)
• 队列中的每个元素是⼀个 Task 对象.
• Task 中带有⼀个时间属性, 队⾸元素就是即将要执⾏的任务
• 同时有⼀个 worker 线程⼀直扫描队⾸元素, 看队⾸元素是否需要执⾏

package Thread;

import java.util.PriorityQueue;
//定时器
class MyTimerTask implements Comparable<MyTimerTask> {
    // 在什么时间点来执行这个任务.
    // 此处约定这个 time 是一个 ms 级别的时间戳.
    private long time;
    // 实际任务要执行的代码.
    private Runnable runnable;

    public long getTime() {
        return time;
    }

    // delay 期望是一个 "相对时间"
    public MyTimerTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        // 计算一下真正要执行任务的绝对时间. (使用绝对时间, 方便判定任务是否到达时间的)
        this.time = System.currentTimeMillis() + delay;
    }

    public void run() {
        runnable.run();
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.time);
        // return (int) (o.time - this.time);
    }
}

// 通过这个类, 来表示一个定时器
class MyTimer {
    // 负责扫描任务队列, 执行任务的线程.
    private Thread t = null;
    // 任务队列
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
    // 搞个锁对象, 此处使用 this 也可以.
    private Object locker = new Object();

    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
            MyTimerTask task = new MyTimerTask(runnable, delay);
            queue.offer(task);
            // 添加新的元素之后, 就可以唤醒扫描线程的 wait 了.
            locker.notify();
        }
    }

    public void cancel() {
        // 结束 t 线程即可
        // interrupt
    }

    // 构造方法. 创建扫描线程, 让扫描线程来完成判定和执行.
    public MyTimer() {
        t = new Thread(() -> {
            // 扫描线程就需要循环的反复的扫描队首元素, 然后判定队首元素是不是时间到了.
            // 如果时间没到, 啥都不干
            // 如果时间到了, 就执行这个任务并且把这个任务从队列中删除掉.
            while (true) {
                try {
                    synchronized (locker) {
                        while (queue.isEmpty()) {
                            // 暂时先不处理
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        // 获取到当前时间
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            // 当前时间已经达到了任务时间, 就可以执行任务了.
                            queue.poll();
                            task.run();
                        } else {
                            // 当前时间还没到, 暂时先不执行
                            // 不能使用 sleep. 会错过新的任务, 也无法释放锁.
                            // Thread.sleep(task.getTime() - curTime);
                            locker.wait(task.getTime() - curTime);
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        // 要记得 start !!!!
        t.start();
    }
}

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

四、线程池

线程池是一种线程管理技术,用于管理和控制多个线程的执行。线程池中包含了多个线程,可以根据任务的需求动态地分配线程,从而实现线程的复用和减少线程的频繁创建和销毁,提高系统的性能和稳定性。
线程池最大的好处就是减少每次启动、销毁线程的损耗。

写一个简单的线程池代码:

1)提供构造方法,指定创建多少个线程
2)在构造方法中,把执行线程都创建好
3)有一个阻塞队列,能够持有要执行的任务
4)提供submit方法,可以添加新的任务

package Thread;

//写一个简单的线程池(更高效地利用多线程完成一系列的操作)
//直接写一个固定数目的线程池,暂时不考虑线程的增加和减少
//1)提供构造方法,指定创建多少个线程
//2)在构造方法中,把这些线程都创建好
//3)有一个阻塞队列,能够持有要执行的任务
//4)提供submit方法,可以添加新的任务

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;

class  MyThreadPoolExecutor{
    //用来保存任务的队列
    private List<Thread> threadList=new ArrayList<>();
    //通过n指定创建多少个线程
    private BlockingDeque<Runnable> queue=new LinkedBlockingDeque<>(1000);
    public MyThreadPoolExecutor(int n){
        for (int i = 0; i < n; i++) {
            Thread t=new Thread(()->{
                //线程要做的事情就是把队列中的任务不停的取出来,并且进行执行
                while(true) {
                    Runnable runnable;
                    //此处的take带有阻塞功能
                    //如果队列为空,此处的take就会阻塞
                    try {
                        runnable = queue.take();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    //取出一个任务就执行一个任务即可
                    runnable.run();
                }
            });
            t.start();
            threadList.add(t);
        }
    }
    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }
}

public class ThreadDemo29 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPoolExecutor executor = new MyThreadPoolExecutor(4);
        for (int i = 0; i < 1000; i++) {
            int n = i;
            //此处的n是一个“事实final”变量
            //每次循环,都是一个新的n,n本身没有改变,就可以被捕获了
            executor.submit(new Runnable() {
                @Override
                public void run() {
                    //System.out.println("执行任务" + i + " , 当前线程为: " + Thread.currentThread().getName());
                    //上面那样写是会报错的,变量捕获要保证i不变,但是i会改变
                    //回调函数访问当前外部作用域的变量就是变量捕获
                    System.out.println("执行任务" + n + " , 当前线程为: " + Thread.currentThread().getName());
                }
            });
        }
    }
}

//关于执行效果,多个线程间的执行是不确定的,某个线程取到了某个任务,但是并非立即执行,这个过程中另一个线程就插到前面了

ThreadPoolExecutor 提供了更多的可选参数, 可以进⼀步细化线程池⾏为的设定.
在这里插入图片描述
如果举例来理解,就是公司招聘员工忙不过来,准备招聘实习生做事。

int corePoolSize: 核心线程数(正式员⼯, ⼀旦录⽤, 永不辞退)
int maximumPoolSize:最大线程数(正式员⼯ + 实习生)
long keepAliveTime: 保存存活时间(实习生允许的空闲时间)
TimeUnit unit: 时间单位(s,min,ms,hour…)

在这里插入图片描述
这是ThreadPoolExecutor最重要的参数,面试常考。
RejectedExecutionHandler: 拒绝策略

如果任务量超出负荷了新的任务接下来怎么处理?此时就需要用到这个拒绝策略。
下面是四种拒绝策略----
在这里插入图片描述

◦ AbortPolicy(): 超过负荷, 新旧任务都不执行,继续添加任务,直接抛出异常.
◦ CallerRunsPolicy(): 新的任务会执行,但不是线程池执行,而是由任务调⽤者(添加新任务的线程)负责执行.
◦ DiscardOldestPolicy(): 丢弃队列中最⽼的任务,执行新任务.
◦ DiscardPolicy(): 丢弃新来的任务.线程池不执行,调用者也不执行.


五、总结

线程的优点

  1. 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
  2. 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
  3. 线程占⽤的资源要⽐进程少很多
  4. 能充分利⽤多处理器的可并⾏数量
  5. 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
  6. 计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
  7. I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

最后,码字不易,如果觉得对你有帮助的话请点个赞吧,关注我,一起学习,一起进步!

  • 26
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值