多线程案例

多线程案例

  • 实现安全版本的单例模式
    • 饿汉模式
    • 懒汉模式
  • 阻塞队列
    • 让多个服务器之间充分解耦合
    • 能让请求进行“削峰填谷”
    • 标准库当中的阻塞队列
    • 自己实现阻塞队列
  • 定时器
    • 标准库计时器
      • 实现计时器
  • 线程池
    • 用户态和内核态
    • 标准库的线程池
    • 自己实现线程池

实现安全版本的单例模式

单例模式:是设计模式之一。代码当中的某个类,只能有一个实例,不能有多个。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();
    }
}

为了放在程序员在其他地方不小心 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();
    }
}

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

阻塞队列

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

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

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

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

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

使用生产者消费者模型,就可以降低这里的耦合,就像这样:
多线程案例_第2张图片

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

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

多线程案例_第3张图片

如果使用阻塞队列的话,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();
}

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

自己实现阻塞队列

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

用数组就是通过循环队列来实现。如下图:
多线程案例_第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();
    }
}

运行结果如下:
多线程案例_第5张图片
put 和 take 的相互唤醒之间的关系如下:
多线程案例_第6张图片

定时器

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

标准库计时器

通过 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");
}

运行结果如下:
多线程案例_第7张图片
线程并没有结束,因为 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();
    }
}

写代码的时候,要给循环加限制。如果队列中的任务是空着的,就还好,这个线程就阻塞了。就怕队列不为空,并且任务时间还没到,就会一直看任务,浪费资源,也就是忙等。忙等是很浪费 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");
    }
}

运行结果如下:
多线程案例_第8张图片
这样就实现了 Timer 的方法。

线程池

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

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

用户态和内核态

操作系统中的用户态和内核态。操作系统软件结构图:
多线程案例_第9张图片

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

标准库的线程池

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

标准库当中,还有简化版本的线程池: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");
            }
        });
    }
}

运行结果如下:
多线程案例_第13张图片

自己实现线程池

线程池里面要有:

  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");
                }
            });
        }
    }
}

运行结果如下:
多线程案例_第14张图片
这样就实现了线程池。

你可能感兴趣的:(JavaEE,单例模式,java,开发语言,多线程,线程池)