• 多线程案例


    实现安全版本的单例模式

    单例模式:是设计模式之一。代码当中的某个类,只能有一个实例,不能有多个。JDBC 的 DataSource 这样的对象就应该是单例的。

    设计模式:就是“棋谱”,就是固定的一些代码套路。写代码的时候,有很多经典场景,经典场景中,也有一些经典的应对手段。

    单例模式有两种:

    1. 饿汉模式
    2. 懒汉模式

    饿汉模式

    饿汉模式就是表示很着急,就像吃完饭剩下很多碗,然后一次性把碗全洗了。就是比较着急的去创建实例。使用 static 来创建实例,并且立即进行实例化。这个 instance 对于的实例,就是该类唯一的实例。代码如下:

    class Singleton {
        private static Singleton instance = new Singleton();
        private Singleton() {
    
        }
        public static Singleton getInstance() {
            return instance;
        }
    }
    public class Test {
        public static void main(String[] args) {
            Singleton instance = Singleton.getInstance();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    为了放在程序员在其他地方不小心 new 这个 Singleton 就可以把构造方法设为 private。

    static 修饰的成员更准确的说,应该叫作“类成员” => “类属性/类方法”。不加 static 的成员,就是“实例成员” => “实例属性/实例方法”。

    在 Java 程序中,一个类对象只存在一份(JVM 来保证),进一步也就保证了类的 static 成员只有一份。

    类对象和对象不是一个东西:
    类:相当于实例的模板,基于模板可以创建出很多对象。
    类对象:类名字.class 文件,被 JVM 加载到内存之后,表现出的模样。
    类对象里面就有 .class 文件中的一切信息。包括:类名,属性。

    懒汉模式

    懒汉模式主要就是,不是立即初始化实例。因为不是立即初始化,所以只有在调用的时候,才会创建实例。

    如何保证懒汉模式的线程安全? 加锁,通过创建实例的代码加锁就可以了,加锁的时候,可以直接指定类对象 .class 作为锁对象。加锁之后,线程安全问题就得到了解决,但是又有了新的问题。多线程调用获取信息的时候,就可能同时涉及到读和修改。但是一旦被初始化之后,就只剩读操作了。代码如下:

    class Singleton2 {
        private static volatile Singleton2 instance = null;
        private Singleton2() {}
        public static Singleton2 getInstance() {
            if (instance == null) {
                synchronized (Singleton2.class) {
                    if (instance == null) {
                        instance = new Singleton2();
                    }
                }
            }
            return instance;
        }
    }
    public class Test2 {
        public static void main(String[] args) {
            Singleton2 instance = Singleton2.getInstance();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    因为如果多个线程,都去读 getInstance 那么就可能导致优化为直接去寄存器读。所以我们加上 volatile 来避免编译器优化。
    和饿汉模式的区别就是,懒汉模式只有在使用的时候,才会创建实例,饿汉模式在类加载的时候就会创建实例。

    阻塞队列

    先进先出,相对于普通队列,又有其他方面的功能:

    1. 线程安全
    2. 产生阻塞效果:
      a. 如果队列为空,尝试出队列,就会出现阻塞,阻塞到队列不为空为止。
      b. 如果队列为满,尝试入队列,就会出现阻塞,阻塞到队列不为满为止。

    通过上面这种特性,就可以实现 “生产者模型” 。就像我们烤串,有人烤,有人吃,然后烤好的放在烤盘上面。对于吃烤串来说,烤盘就是交易场所。此处的阻塞队列就可以作为生产者消费者模型当中的交易场所。

    让多个服务器之间充分解耦合

    生产者消费者模型,是实际开发当中非常有用的一种多线程开发手段,尤其是在服务器开发场景当中。假设有两个服务器 A 和 B,A 作为入口服务器直接接受用户的网络请求,B 作为应用服务器,来给 A 提供一些数据。如图:
    在这里插入图片描述
    如果不使用生产者消费者模型,此时 A 和 B 的耦合性是比较强的。在开发 A 代码的时候,就得充分了解到 B 提供的一些接口,开发 B 代码的时候,也得充分了解到 A 是怎么调用的。一旦想把 B 换成 C ,A 的代码就需要较大的改动。而且如果 B 挂了,也可能直接导致 A 也顺带挂了。

    使用生产者消费者模型,就可以降低这里的耦合,就像这样:
    在这里插入图片描述

    能让请求进行“削峰填谷”

    未使用生产者消费者模型的时候,如果请求量突然暴涨。A 暴涨=> B 暴涨,A 作为入口服务器,计算量较小,不会产生问题。B 作为应用服务器,计算量可能很大,需要的系统资源也更多,如果请求更大了,就可能导致程序挂了。如图:

    在这里插入图片描述

    如果使用阻塞队列的话,A 的请求暴涨 => 阻塞队列的请求暴涨,由于阻塞队列没啥计算量,只是存数据,所以抗压能力就更强。B 这边依然按照原来的速度进行处理数据,就不会受到 A 的暴涨。所以就不会引起崩溃。也就是 “削峰”。这种峰值很多时候不是持续的,过去之后就恢复了。B 仍然是按照原有的频率来处理之前积压的数据,就是 “填谷” 。

    实际开发当中:阻塞队列不是一个简单的数据结构了,而是一个/一组专门的服务器程序,提供的功能不仅仅是队列阻塞。还会在这些基础上面提供更多的功能(数据持久化存储,多个数据通道,多节点备份,支持控制面板,方便配置参数),又叫”消息队列“。

    标准库当中的阻塞队列

    通过 BlockingQueue 来实现阻塞队列,代码如下:

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new LinkedBlockingDeque<>();
        //入队
        queue.put("hello");
        //出队
        String s = queue.take();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    在 new 对象的时候,这里选择用链表实现的阻塞队列。阻塞队列也有 offer poll peek 但是这些没有阻塞功能。

    自己实现阻塞队列

    1. 先实现一个普通队列
    2. 再加上线程安全
    3. 再加上阻塞
    4. 我们通过数组来实现

    用数组就是通过循环队列来实现。如下图:
    在这里插入图片描述
    出队列就是把 head 位置的元素返回去,并且 head++。当 tail 加满的时候,就回到队列头。所以重要的就是区别空队列和满队列。所以我们创建一个变量来记录元素的个数:size == 0 就是空,size == arr.length 就是满。

    保证线程安全:

    1. 在多线程环境下,使用入队出队没有问题。
    2. 入队出队的代码是属于公共操作变量,所有给整个方法加锁。

    实现阻塞效果:通过使用 wait 和 notify 机制来实现阻塞效果。

    阻塞条件

    1. 对于 入队 来说:就是队列为满。
    2. 对于 出队 来说:就是队列为空。

    代码如下:

    class MyBlockQueue {
        private int[] data = new int[1000];
        private int size = 0;
        private int head = 0;
        private int tail = 0;
        private Object locker = new Object();
        //入队列
        public void put(int value) throws InterruptedException {
            synchronized (locker) {
                if (size == data.length) {
                    //队列满了。针对哪个对象加锁,就使用哪个对象 wait
                    //put 当中的 wait 要由 take 来唤醒,只要 take 成功一个元素,就可以唤醒了
                    locker.wait();
                }
                //队列不满,把新的元素放入 tail 位置上
                data[tail] = value;
                tail++;
                //处理 tail 到达数组末尾的情况
                if (tail >= data.length) {
                    tail = 0;
                }
                size++;
                locker.notify();
            }
        }
        //出队列
        public Integer take() throws InterruptedException {
            synchronized (locker) {
                if (size == 0) {
                    //说明队列为空,就需要等待,就需要 put 来唤醒
                    locker.wait();
                }
                int ret = data[head];
                head++;
                if (head >= data.length) {
                    head = 0;
                }
                size--;
                //就说明 take 成功了。然后唤醒 put 中的等待。
                locker.notify();
                return ret;
            }
        }
    }
    public class MyBlockingQueue {
        private static MyBlockQueue queue = new MyBlockQueue();
        public static void main(String[] args) {
    
            //如果有多个生产者和多个消费者,就再多创建几个线程
            Thread producer = new Thread(() -> {
                int num = 0;
                while (true) {
                    try {
                        System.out.println("生产了:" + num);
                        queue.put(num);
                        num++;
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            producer.start();
    
            Thread customer = new Thread(() -> {
                while (true) {
                    int num = 0;
                    try {
                        num = queue.take();
                        System.out.println("消费了:" + num);
                        //消费慢了,但是可以一直生产。1000 之后,
                        // 队列满了,所以就阻塞了。直到消费了一个。
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            customer.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80

    运行结果如下:
    在这里插入图片描述
    put 和 take 的相互唤醒之间的关系如下:
    在这里插入图片描述

    定时器

    像是一个闹钟,在一定时间之后,被唤醒并执行某个之前设定好的任务。就像是长时间网页加载不出来,就显示连接不到网页。

    标准库计时器

    通过 Timer 的 schedule 任务来设计任务计划,Timer 内部是有专门的线程,来负责执行注册的任务,所以执行完之后,并不会马上退出线程。

    管理任务:

    1. 描述任务:创建一个专门的类来表示一个定时器中的任务(Timer Task)。
    2. 组织任务:通过一定的结构来组织。任务是无序的,但是执行的时候是有序的。要快速找到所有任务当中,时间最小的任务。通过堆来解决这样的问题。
    3. 执行时间到了的任务。

    代码如下:

    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Hello Timer");
            }
        }, 3000);//就是在 3 秒之后执行这个任务,
        System.out.println("main");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    运行结果如下:
    在这里插入图片描述
    线程并没有结束,因为 Timer 内部有专门的线程,来负责执行注册的任务的。

    实现计时器

    代码如下:

    class MyTask implements Comparable<MyTask> {
        //任务具体要干什么
        private Runnable runnable;
        //任务具体啥时候干,保存任务要执行的毫秒级时间戳
        private long time;
        public long getTime() {
            return time;
        }
        //after 是一个时间间隔,不是绝对的时间戳的值
        public MyTask(Runnable runnable, long delay) {
            this.runnable = runnable;
            this.time = System.currentTimeMillis() + delay;
        }
        public void run() {
            runnable.run();
        }
        @Override
        public int compareTo(MyTask o) {
            return (int) (this.time - o.time);
        }
    }
    class MyTimer {
        //定时器内可以存放很多任务,要考虑到多线程问题,还要注意到线程安全。
        private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
        private Object locker = new Object();
        public void schedule(Runnable runnable, long delay) {
            MyTask task = new MyTask(runnable,delay);
            queue.put(task);
            //每次任务插入成功之后,都唤醒一下扫描线程,重新检查一下队首的任务时间是否到了
            synchronized (locker) {
                locker.notify();
            }
        }
        public MyTimer() {
            Thread t = new Thread(() -> {
                while (true) {
                    try {
                        MyTask task = queue.take();
                        long curTime = System.currentTimeMillis();
                        if (curTime < task.getTime()) {
                            //说明时间没到
                            queue.put(task);
                            //指定一个等待时间,时间到了之后,等待自然也就唤醒了。
                            // sleep 不能被中途唤醒, wait 是可以被中途唤醒的。
                            synchronized (locker) {
                                locker.wait(task.getTime() - curTime);
                            }
                        } else {
                            //时间到了
                            task.run();
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    写代码的时候,要给循环加限制。如果队列中的任务是空着的,就还好,这个线程就阻塞了。就怕队列不为空,并且任务时间还没到,就会一直看任务,浪费资源,也就是忙等。忙等是很浪费 CPU 的。避免忙等:通过设计查询比率,可以通过 wait 这样的机制来实现。wait 有一个版本,指定等待时间(不需要 notify,时间到了自然唤醒)就不会忙等了。main 代码如下:

    public class Test5 {
        public static void main(String[] args) {
            MyTimer myTimer = new MyTimer();
            myTimer.schedule(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello Timer");
                }
            },3000);
            System.out.println("main");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    运行结果如下:
    在这里插入图片描述
    这样就实现了 Timer 的方法。

    线程池

    因为进程比较重,频繁的创建和销毁,开销就会大,解决方法:进程池 or 线程:

    1. 线程:虽然比进程轻了,但是如果创建和销毁的频率进一步增加,发现开销还是有的,解决方案:线程池 or 协程。
    2. 线程池:把线程提前创建好,放到池子里,需要的话,就从池子里取。不用的话,就放回池子里,下次备用。这样创建销毁线程,速度就快了。

    用户态和内核态

    操作系统中的用户态和内核态。操作系统软件结构图:
    在这里插入图片描述

    1. 我们写的代码就是在最上面的应用程序这一层来运行的,这里的代码都被称为”用户态“运行的代码。
    2. 有些代码,需要调用操作需要的 API,进一步的逻辑就会在内核中执行。
    3. 创建线程本身就需要内核的支持(创建线程的本质是在内核中搞个 PCB 加到链表里),调用 Thread.start 归根结底,也是要进入内核态来运行。
    4. 而把创建好的线程放到“池子里”,由于池子是用户态实现的。这个放到池子/从池子取。这个过程是不需要涉及到内核态,就是存粹的用户态代码就能完成。
    5. 一般认为,纯用户态的操作,效率比经过内核态处理的操作,要效率更高。
    6. 线程池里面的线程,一直保存在里面,不会被内核回收。

    标准库的线程池

    ThreadPoolExecutor 是标准库的线程池,不过使用起来有点麻烦。构造方法很多:
    在这里插入图片描述
    重点看第四个构造方法,参数最全,涵盖了之前所有的:
    在这里插入图片描述
    最重要的还是这两个参数,就是需要指定多少个线程:在这里插入图片描述
    常见问题:有一个程序,这个程序要并发的/多线程的来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适?
    这个问题的答案是不确定的。因为指定线程池的个数的时候,不能直接确定线程数,要通过性能测试的方法找到合适的值。

    标准库当中,还有简化版本的线程池:Executors
    代码如下:

    public static void main(String[] args) {
        //创建固定线程数目的线程池,参数指定了线程个数
        ExecutorService pool = Executors.newFixedThreadPool(10);
        //创建一个自动扩容的线程池,会根据任务量来自动进行扩容
        Executors.newCachedThreadPool();
        //创建只有一个线程的线程池
        Executors.newSingleThreadExecutor();
        //创建一个带有定时器功能的线程池
        Executors.newScheduledThreadPool(2000);
        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello threadpool");
                }
            });
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    运行结果如下:

    自己实现线程池

    线程池里面要有:

    1. 先能够描述任务。(直接使用 Runnable)
    2. 需要组织任务。(直接使用 BlockingQueue)
    3. 能够描述工作线程
    4. 还需要组织这些线程
    5. 需要实现,往线程池里添加任务。

    代码如下:

    class MyThreadPool {
        //1、描述一个任务,直接使用 Runnable 不需要额外创建类了。
        //2、使用一个数据结构来组织若干个任务
        private BlockingQueue<Runnable> queue = new LinkedBlockingDeque<>();
        //3、描述一个线程,工作线程的功能就是从任务队列中获取任务并执行。
        static class Worker extends Thread {
            //当前线程池当中,有若干个 Worker 线程,这些线程内部,都持有了上述的任务队列
            private BlockingQueue<Runnable> queue = null;
    
            public Worker(BlockingQueue<Runnable> queue) {
                this.queue = queue;
            }
            @Override
            public void run() {
                //需要能够拿到上面的队列
                while (true) {
                    try {
                        //循环的去获取队列中的任务,如果队列为空,就直接阻塞。如果不是空,就去获取里面的内容
                        Runnable runnable = queue.take();
                        //获取到之后,就去执行。
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        //4、创建一个数据结构来组织若干个线程
        private List<Thread> workers = new ArrayList<>();
        public MyThreadPool(int n) {
            //在构造方法当中创建若干个线程
            for (int i = 0; i < n; i++) {
                Worker worker = new Worker(queue);
                worker.start();
                workers.add(worker);
            }
        }
        //5、创建一个方法,能够允许程序员放任务到线程池当中
        public void submit(Runnable runnable) {
            try {
                queue.put(runnable);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public class MyThreadP {
        public static void main(String[] args) {
            MyThreadPool pool = new MyThreadPool(10);
            for (int i = 0; i < 100; i++) {
                pool.submit(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("hello thread");
                    }
                });
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59

    运行结果如下:
    在这里插入图片描述
    这样就实现了线程池。

  • 相关阅读:
    Golang之旅——内存管理
    Linux sudo 操作免密码
    SpringBoot自定义starter
    python: 基于种群模拟退火算法解决单目标优化(试用于任意维的测试函数)
    98. 验证二叉搜索树(中等 二叉搜索树 dfs)
    java读取csv文件或者java读取字符串,找出引号内容,采用正则表达式书写
    TCP案例-实时群聊
    养老院一键报警的重要性和应用
    洛谷--欢乐的跳
    学习记忆——宫殿篇——记忆宫殿——记忆桩——工人宿舍
  • 原文地址:https://blog.csdn.net/sjp151/article/details/125577835