文章目录
- 多线程案例
- 1、单例模式
- 1)饿汉模式
- 2)懒汉模式
- 3)线程安全吗??
- 4)解决懒汉模式线程安全问题
- 5)解决懒汉模式内存可见性问题
- 2、阻塞队列
- 1) 阻塞队列是什么?
- 2) 生产者消费者模型
- 1.生产者消费者模型的优势
- 2.标准库中的阻塞队列
- 3) 拟实现阻塞队列
- 3、定时器
- 1) 标准库中的定时器
- 2) 模拟实现定时器
- 4、线程池
- 1) 工厂模式
- 2) 标准库中的线程池
- 1.ThreadPoolExecutor类
- 3) 模拟实现线程池
多线程案例
1、单例模式
单例模式是校招中最常考的设计模式之一。
啥是设计模式?
设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有
一些固定的套路. 按照套路来走局势就不会吃亏.
软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照
这个套路来实现代码, 也不会吃亏.
应用场景:有时候,希望对象在程序中只有一个实例(对象),也就是说只能new一次。由此就出现了单例模式。
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.
单例模式具体的实现方法,分成“懒汉”和“饿汉”两种。
1)饿汉模式
含义:当前代码中是比较迫切的,在类加载的时候就立刻,把实例给创建出来。
class Singleton {private static Singleton instance = new Singleton();private Singleton(){}public static Singleton getInstance() {return instance;}
}
public class Demo3 {public static void main(String[] args) {Singleton s1 = Singleton.getInstance();Singleton s2 = Singleton.getInstance();//Singleton s3 = new Singleton(); 无法创建出来,由于上面的构造方法是private的System.out.println(s1 == s2);//System.out.println(s1 == s3);}
}
上述代码的执行时机,是 Singleton 类被 jvm 加载的时候。Singleton类会在 JVM 第一次使用的时候被加载。也不一定是在程序一启动的时候.
用static修饰的雷属性,由于每个类的类对象是单例的。类对象的属性(static),也就是单例的了。
单例模式中对我们的代码做出一个限制:禁止别人去new这个实例,也就是把构造方法改成private。
此时,我们要是去new这个实例,就会报错。
这时,我们能够使用的实例有且只有一个,就是类字被加载出来的同时,会进行实例化。
2)懒汉模式
含义:在真正用到的时候来进行创建实例,不用到则不会产生其他开销。(更推荐使用)
class SingletonLazy {private static SingletonLazy instance = null;private SingletonLazy() {}public static SingletonLazy getInstance() {if(instance == null) {instance = new SingletonLazy();}return instance;}
}
public class Demo4 {public static void main(String[] args) {SingletonLazy s1 = SingletonLazy.getInstance();SingletonLazy s2 = SingletonLazy.getInstance();System.out.println(s1 == s2);}
}
懒汉模式与饿汉模式极其相似,只是在实例化的时机上有所不同。
3)线程安全吗??
接下来我们要去考虑 懒汉模式 和 恶汉模式 在多线程模式下会不会出问题呢????在多线程调用 getInstacne 的情况下,哪个模式是线程安全的(没有Bug)??
对于饿汉模式:多个线程同时读取同一个变量,就不会出现线程安全问题。
对于懒汉模式:则会出现线程安全问题。
🎋 简单理解:当我们按照顺序来执行,就会发现,此时创建出两个对象,程序不再是单例模式了!!
🎋 深入解读:第二次 new 操作,就把原来 instacne 的引用给修改了,之前 new 出来的对象就立刻被 gc 回收了,最后只剩下一个对象。
在通常印象中,new 一个对象并不是一个很复杂的过程,但实际上 new 一个对象开销是非常大的!!!!如果服务器一启动就需要加载 100G 的数据存到内存中,都是通过一个对象来管理,那我们 new 这个对象时,就需要消耗 100G 的内存,花费很长时间。
4)解决懒汉模式线程安全问题
提到如何解决线程安全问题,想到的就是加锁以及内存可见性问题。
先考虑一种加锁方式:
这种写法是错误的,没有解决上述线程安全问题。因为这个操作本身就是原子的,再加锁也起不到实质上的作用。
正确加锁方式是把锁加到外面
如图所示,此时再执行线程2的时候,就能发现 instance 是一个非 null 的值。
上述代码还会出现一个问题,程序中频繁的出现加锁操作。
加锁是一个成本很高的操作,加锁可能会引起阻塞等待。因此得到加锁的基本原则就是:非必要不加锁。不能无脑加锁,频繁加锁会影响程序执行效率。
思考:后续每次调用getInstance都要加锁了,是必要的嘛???
不是的。每次调用,都会加锁。实际上只有第一次调用有必要加锁,后面都不需要。这里把不该加锁的地方给加锁了,就会很影响程序的效率。
懒汉模式线程不安全,主要是在首次 new 对象的时候,才存在问题。一旦把对象new好了之后,后续再调用getInstance,都没事了!!
上述代码在首次调用和后续调用都加锁了,因此降低了程序的执行效率。
对代码进行调整:先判定是否要加锁,再决定是不是真的加锁。
public static SingletonLazy getInstance() {if (instacne == null) {synchronized (SingletonLazy.class){if(instance == null) {instance = new SingletonLazy();}}}return instance;
}
可以看到,在外层又加了一个判断条件。第一个条件是为了判断是否要加锁,第二个条件是判断是否要创建对象。
同一个条件,写了两遍,这个合理嘛???
合理的不得了。
如果在单线程中,两个同样的判定,结果一定是一样的。但对于多线程来说,可能会涉及到阻塞。阻塞多久并不知道,第二个条件和第一个条件之间可能会间隔非常长的时间(沧海桑田),在这个很长的时间间隔里,可能别的线程就把 instance 给改了。
5)解决懒汉模式内存可见性问题
public static SingletonLazy getInstance() {if (instance == null) {synchronized (SingletonLazy.class) {if (instance == null) {instance = new SingletonLazy();}}}return instance;
}
在多线程模式中,第一个线程,在修改完 instance 之后,就结束了代码块,释放了锁。第二个线程就能够获取到锁了,从阻塞中恢复了。
问题出现了,第二个线程这里进行的“读操作”一定能读取到第一个线程修改之后的值吗??
答案是不一定,可能会出现内存可见性问题。这里只是分析了一下,可能存在这样的风险。编译器实际上在这个场景中是否会触发优化,打上问号。因此,给 instance 加上一个 volatile 是更为稳妥的做法。
加上 volatile 还有另外一个用途,就是避免此处赋值操作的指令重排序!
指令重排序:也是编译器优化的一种手段,是保证原有执行逻辑不变的前提下,对代码执行顺序进行调整。使调整之后的执行效率提高。
单线程中,这样的重排序一般没事。但是多线程下,就会出现问题。
instance = new SingletonLazy()
像上面这句代码,一句代码在编译器中由三句指令构成。
- 给对象创建出内存空间,得到内存地址。
- 在空间上调用构造方法,对对象进行初始化。
- 把内存地址,赋值给 instance 引用。
给 instacne 加上 volatile 之后,此时针对 instance 进行的赋值操作,就不会产生上述的指令重排序了。必须按照 1 2 3 的顺序执行,不会出现 1 3 2 的执行顺序。
2、阻塞队列
1) 阻塞队列是什么?
阻塞队列是一种特殊的队列,带有阻塞功能。也遵守 “先进先出” 的原则。
阻塞队列能是一种线程安全的数据结构,并且具有以下特性:
- 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素。
- 当队列空的时候,,继续出队列也会阻塞,直到有其他线程往队列中插入元素。
阻塞队列的一个典型应用场景就是 “生产者消费者模型”。这是一种非常典型的开发模型。
2) 生产者消费者模型
在该模型中,生产者负责生产数据,并将其放入一个缓冲区中。消费者负责从缓冲区中取出数据并进行消费。
本质上来说就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
1.生产者消费者模型的优势
减少任务切换开销,减少锁竞争。
-
阻塞队列会使生产者和消费者之间 解耦合。
-
平衡了生产者和消费者的处理能力。(削峰填谷)
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力.
服务器收到的来自于客户端/用户的请求,不是一成不变的。可能会因为一些突发事件,引起请求数目暴增~~
一台服务器,同一时刻能处理的请求数量是有上限,不同的服务器承担的上限是不一样的。
在一个分布式系统中,就经常会出现,有的机器能承担的压力更大,有的则更小~~
正因为生产者消费者模型,这么重要,虽然阻塞队列只是一个数据结构,但我们会把这个数据结构(阻塞队列)单独实现成一个服务器程序并且使用单独的主机/主机集群来部署。
此时,这个所谓的则塞队列,就进化成了“消息队列”。
2.标准库中的阻塞队列
java标准库中已经提供了现成的阻塞队列的实现了,有基于链表、堆、优先级、数组实现的。
Array 这个版本的速度要更快,前提是得先知道最多有多少元素。(对于 Array 版本来说,频繁扩容是一个不小的开销)
如果不知道有多少元素,使用 Linked 更合适。
对于BlockfingQueue来说,offer 和 poll 不带有阻塞功能,而 put 和 take 带有阻塞功能。
3) 拟实现阻塞队列
这里我们基于数组,循环队列来简单实现一个阻塞队列。
循环队列就像是把数组首尾连接起来,组成一个环状,以此提高使用效率。
下面简单实现阻塞队列的两个主要功能put
和take
。
put
:入队列。take
:出队列。
阻塞队列的构成:
-
循环队列
- 用 size 来标记队列长度,可以判断队列是否为满。
- 实现循环效果,头(head) 尾(hail) 相连。
-
实现阻塞
- 入队列时。当队列满的时候,再进行 put 就会产生阻塞。
- 出队列时。当队列空的时候,再进行 take 也会产生阻塞。
-
写完代码,需要考虑线程安全问题。(内存可见性、加锁)
- 内存可见性
- 加锁
-
wait 搭配 while 循环使用
原因:
- 意外被 interrupted 唤醒。
- 多线程下,出现线程安全问题。
下面是实现代码:
class MyBlockingQueue {private String[] items = new String[1000];volatile private int head = 0;volatile private int tail = 0;volatile private int size = 0;public void put (String elem) throws InterruptedException {synchronized (this) {while (size >= items.length) {//队列满,开始阻塞等待。System.out.println("队列满,开始阻塞等待!!!");this.wait();//return;}items[tail] = elem;tail ++;if (tail >= items.length) {tail = 0;}size ++;this.notify();}}public String take () throws InterruptedException {synchronized (this) {//判断队列是否为空,若为空则不能出队列。while (size == 0) {//队列空,开始阻塞等待System.out.println("队列空,开始阻塞等待!!");this.wait();//return null;}String elem = items[head];head ++;if (head >= items.length) {head = 0;}size --;//使用 notify 来唤醒队列满的阻塞情况。this.notify();return elem;}}
}public class Demo1 {public static void main(String[] args) throws InterruptedException {MyBlockingQueue queue = new MyBlockingQueue();//生产者Thread t1 = new Thread(() ->{int count = 0;while (true) {try {queue.put(count + "");System.out.println("生产元素:" + count);count ++;//Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});//消费者Thread t2 = new Thread(() -> {while (true) {try {String count = queue.take();System.out.println("消费元素:" + count);Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t1.start();t2.start();}
}/*结果
先生产1000个元素,阻塞等待消费元素。
之后消费一个元素即生产一个元素。
*/
3、定时器
什么是定时器?
⏰ 定时器也是软件开发中的一个重要组件。类似于一个 “闹钟”。达到一个设定的时间之后,就执行某个指定好的代码。
1) 标准库中的定时器
- 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
- schedule 包含两个参数。第一个参数 (TimerTask) 指定即将要执行的任务代码, 第二个参数 (delay) 指定多长时间之后执行 (单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("定是任务执行");}
}, 3000);System.out.println("程序开始运行");
当我们运行代码,就会发现,打印完 hello 程序并没有结束,而是在持续执行。
原因就在于:Timer 内部,有自己的线程。为了保证随时可以处理新安排的任务,这个线程会持续执行。并且这个线程还是个前台线程。
上述代码中,可以看到 schedule 有两个参数, 其中的一个参数 TimeTask 类似于 Runnable。在那里会记录时间,以及执行什么样的任务。
下图中的源代码 TimerTask 也实现了 Runnable 接口。
2) 模拟实现定时器
一个定时器里,是可以有很多个任务的!!
先要能够把一个任务给描述出来,再使用数据结构把多个任务组织起来。
定时器的构成思路:
-
创建一个TimerTask这样类,表示一个任务。这个任务,就需要包含两方面,任务的内容,任务的实际执行时间。
-
使用一定的数据结构,把多个 TimerTask 给组织起来。
如果使用 List (数组,链表)组织 TimerTask 的话。在任务特别多的情况下,如何确定哪个任务,何时能够执行呢?这样就需要搞一个线程,不停的对上述的List进行遍历。看这里的每个元素,是否到了时间,时间到就执行,时间不到,就跳过扫描下一个。
这个思路并不科学!!如果这些任务的时间都还为时尚早。在时间到达之前,此处的扫描线程就需要一刻不停的反复扫描。
这里可以使用优先级队列,来组织所有的任务。因为队首元素,就是时间最小的任务。
-
搞一个扫描线程,负责监控队首元素的任务是否时间到,如果到时间了,就执行 run 方法来完成任务。
注意问题:
-
代码中所使用的优先级队列不是线程安全的!!在此需要针对 queue 的操作,进行加锁。
-
扫描线程中(阻塞等待),直接使用 sleep 进行休眠,是否合适呢?? 非常不合适的!!!!
- sleep 进入阻塞后,不会释放锁。影响其他线程去执行这里的 schedule.
- sleep 在休眠的过程中,不方便提前中断。(虽然可以使用 interrupt 来中断。但是 interrupt 意味着线程应该要结束了)
- 这里可以使用 wait 和 notify 来阻塞和唤醒程序。
- 当队列为空时,需要阻塞等待。当确定了最早需要执行任务时,也需要阻塞等待(等待任务执行)
- 当 queue 中添加新任务时,就需要唤醒正在阻塞等待的程序,来重新进行判定哪个是最新的任务。
-
随便写一个类,它的对象都能放到优先级队列中嘛?有没有什么特别的要求?
要求放到优先级队列中的元素,是“可比较的"。
通过 Comparable 或者 Comparator 定义任务之间的比较规则。此处,我们在 MyTimerTask 这里,让他实现 Comparable。
下面是实现代码:
import java.util.PriorityQueue;//模拟实现定时器
class MyTimerTask implements Comparable<MyTimerTask>{//执行任务时间private long time;//执行任务内容private Runnable runnable;public MyTimerTask(Runnable runnable, long delay) {time = System.currentTimeMillis() + delay;this.runnable = runnable;}public long getTime() {return time;}public Runnable getRunnable() {return runnable;}@Overridepublic int compareTo(MyTimerTask o) {return (int)(o.time - this.time);}
}class MyTimer {//使用优先级队列private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();//定义锁对象private Object locker = new Object();public void schedule (Runnable runnable, long delay) {synchronized (locker) {MyTimerTask task = new MyTimerTask(runnable, delay);queue.offer(task);locker.notify();}}public MyTimer() {Thread t = new Thread(() -> {while (true) {synchronized (locker) {try {if (queue.isEmpty()) {locker.wait();}long curTime = System.currentTimeMillis();MyTimerTask task = queue.peek();if (curTime >= task.getTime()) {queue.poll();task.getRunnable().run();} else {locker.wait(task.getTime() - curTime);}} catch (InterruptedException e) {throw new RuntimeException(e);}}}});t.start();}
}
public class Demo2 {public static void main(String[] args) {MyTimer myTimer = new MyTimer();myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("hello 3");}},3000);myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("hello 2");}},2000);myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("hello 1");}},1000);System.out.println("开始执行!!!");}
}
4、线程池
池(Poll) 是一个非常重要的思想方法。包括内存池、进程池、连接池、常量池……这些“池”概念上都是一样的。
线程池可以管理和重用多个线程,以提高应用程序的性能和资源利用率。线程池维护了一个线程队列,当需要执行任务时,可以从线程池中获取一个空闲线程来执行任务,而不需要每次都创建新的线程。这样可以减少线程创建和销毁的开销,并且可以限制并发线程的数量,避免资源耗尽。
为啥,从池子里取,就比从系统这里创建线程更快更高效呢?
- 如果是从系统这里创建线程,需要调用系统api。由操作系统内核,来完成线程的创建过程。(这里的内核是给所有的进程来提供服务的,除了创建线程这个任务外,还有其他任务。因此效率低下)不可控的!!
- 如果是从线程池这里获取线程,上述的内核中进行的操作,都提前做好了,现在的取线程的过程,纯粹的用户代码完成(纯用户态)可控的!!
因此线程池最大的好处就是减少每次启动线程、销毁线程的损耗。
1) 工厂模式
什么是工厂模式??
工厂模式是一种创建对象的设计模式,它通过将对象的创建委托给一个工厂类来解决对象创建的问题。工厂模式提供了一种灵活的方式来创建对象,使得客户端不需要使用new关键字来创建对象,而是通过调用工厂类的方法来获得所需的对象。
说完概念后,再来简单描述一下工厂模式。
一般创建对象,都是通过 new ,通过构造方法。但是构造方法存在重大缺陷!!此时就可以使用工厂模式来解决问题了。
使用工厂模式来解决上述(无法重载)问题。不使用构造方法了。而是使用普通的方法来构造对象。这样的方法名字就可以是任意的了。普通方法内部,再去 new 对象。由于普通方法目的是为了创建出对象来,因此这样的方法一般得是静态的。
//工厂模式
class Point {public static Point makePointXY(double x, double y) { //工厂方法return new Point();}public static Point makePointRA(double r, double a) { //工厂方法return new Point();}
}
public class Demo1 {public static void main(String[] args) {Point point = Point.makePointXY(5, 6);Point point = Point.makePointAR(7, 8);}
}
我们再来看一看下面源代码中是如何运用工厂模式的。
在使用的时候,就直接去调用方法即可。
Executors
:被称为“工厂类“。
newFixedThreadPool
:被称为“工厂方法”。
2) 标准库中的线程池
- 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
- 返回值类型为 ExecutorService.
- 线程池对象创建好后,可以通过 submit方法,把任务添加到线程池中.
ExecutorService executorService = Executors.newFixedThreadPool(4);for (int i = 0; i < 1000; i++) {executorService.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello");}});
}
Methods | Description |
---|---|
newCachedThreadPool() | 创建出一个线程数目动态变化的线程池. |
newFixedThreadPool(int nThreads) | 创建一个固定线程数量的线程池. |
newScheduledThreadPool(int corePoolSize) | 类似于定时器,在后续的某个时刻再执行。 |
newSingleThreadExecutor() | 包含单个线程(比原生创建的线程 api 更简单一点) |
1.ThreadPoolExecutor类
除了上述这些线程池之外,标准库还提供了一个接口更丰富的线程池类。
为了方便使用,将上述的几个方法进一步封装,形成一个新的 ThreadPoolExecutor 类,以此更好的满足实际的需求。
我们可以在官方文档中查看 ThreadPoolExecutor 类,在 java.util.concurrent
这个包中,
谈谈Java标准库里的线程池构造方法的参数和含义。
当线程池无法接受新任务时,会调用一下方式来拒绝执行任务。
第一个拒绝策略
ThreadPoolExecutor.AbortPolicy
默认的拒绝策略,会抛出RejectedExecutionException异常
第二个拒绝策略
ThreadPoolExecutor.CallerRunsPolicy
会将任务回退给调用者线程来执行
第三个拒绝策略
ThreadPoolExecutor.DiscardOldestPolicy
会丢弃最早加入队列的任务,然后尝试重新添加新任务
第四个拒绝策略
ThreadPoolExecutor.DiscardPolicy
会直接丢弃该任务
上述谈到的线程池
- 一组线程池,是封装过的Executors
- 一组线程池,ThreadPoolExecutor 原生的
二者用哪个都可以,主要还是看实际需求。
3) 模拟实现线程池
线程池中所用到的数据结构还是阻塞队列。
实现其主要功能 submit.
下面是代码实现:
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;//简单模拟实现 线程池
class MyPollThread {static BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();public void submit (Runnable runnable) throws InterruptedException {queue.put(runnable);}public static MyPollThread newMyPollThread(int n) {for (int i = 0; i < n; i++) {Thread t = new Thread(()-> {while (true) {try {Runnable runnable = queue.take();runnable.run();} catch (InterruptedException e) {e.printStackTrace();}}});t.start();}return new MyPollThread();}
}public class Demo2 {public static void main(String[] args) throws InterruptedException {MyPollThread myPollThread = MyPollThread.newMyPollThread(4);for (int i = 0; i < 1000; i++) {myPollThread.submit(new Runnable() {@Overridepublic void run() {System.out.println(Thread.currentThread().getName() + " hello");}});}}
}
关于线程池还有一个问题,在创建线程池的时候,线程个数是咋来的呢??
不同的项目中,线程要做的工作,是不一样的~~
-
有的线程的工作,是"CPU密集型",线程的工作全是运算
大部分工作都是要在CPU上完成的.
CPU得给他安排核心去完成工作才可以有进展~~如果CPU是N个核心,当你线程数量也是N的时候
理想情况下,每个核心上一个线程.
如果搞很多的线程,线程也就是在排队等待,不会有新的进展. -
有的线程的工作,是"IO密集型",读写文件,等待用户输入,网络通信
涉及到大量的等待时间。等的过程中,没有使用 cpu 这样的线程就算更多一些,也不会给CPU造成太大的负担。
比如CPU是16个核心,写32个线程。
由于是IO密集的,这里的大部分线程都在等,都不消耗CPU,反而CPU的占用情况还很低~~
实际开发中,一个线程往往是一部分工作是cpu密集的,一部分工作是io密集的。此时,一个线程,几成是在cpu上运行,几成是在等待io,这说不好!
这里更好的做法,是通过实验的方式,来找到合适的线程数。通过性能测试,尝试不同的线程数目。实验过程中,找到性能和系统资源开销比较均衡的数值。