一、设计模式(单例模式+工厂模式)
在软件开发过程中,会遇见很多的问题场景,对于经常遇到的问题场景,一些大佬总结出一些针对特有场景的固有套路,按照这些套路,将帮助我们将问题简单化,条理清楚的解决问题,这也是设计模式的初衷;
设计模式(Design Pattern) 是软件开发过程中面临通用问题的解决方案。这些解决方案是经过长时间试验和错误总结出来的,它们被反复使用,大多数人知晓,并且经过分类编目和代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。
首先,我们讲讲多线程中的设计模式:单例模式
1、单例模式
单例模式是一种常见的设计模式,单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例。,并提供一个全局访问点来访问该实例。这种模式在需要频繁创建和销毁同一实例的场景中非常有用,例如配置管理、日志记录等。
Java中实现单例模式的方式有很多种,但他们本质上都是一样的,这里主要介绍两种,即饿汉模式 和懒汉模式。
首先,饿汉和懒汉有什么区别?
- 初始化的时间:饿汉式和懒汉式的区别在于初始化的时间。饿汉式在类加载时就会创建实例,因此是立即初始化的,而懒汉式则是在类加载后,首次调用该类时才创建实例。
- 线程安全性:饿汉式由于在类加载时就已经创建好实例,因此在多线程环境下是线程安全的,无需额外的同步措施。而懒汉式则需要在调用getInstance方法时进行同步,以防止多个线程同时创建多个实例。
- 性能开销:懒汉式的实现方式会延迟实例的创建,只有在需要时才创建实例,因此可能会增加第一次调用的时间。而饿汉式由于提前创建了实例,因此在第一次调用时无需额外的开销。
- 内存占用:懒汉式由于是延迟加载,因此不会占用内存空间,只有当需要时才会创建实例。而饿汉式则会在类加载时就已经创建好实例,会占用一定的内存空间。
饿汉式
java
public class Singleton {
// 在类加载时就实例化对象
private static final Singleton instance = new Singleton();
// 构造函数私有化,防止在其他类中创建此类的实例
private Singleton() {}
// 提供公开的静态方法 getInstance 来获取该单例对象
public static Singleton getInstance() {
return instance;
}
}
说明:Singleton类在加载时就会创建其实例,因此无论何时调用Singleton.getInstance()
方法,都会返回已经创建好的单例对象。由于构造函数被私有化,外部无法创建新的Singleton实例,只能通过getInstance()
方法获取已经存在的实例。这种方式简单易懂,适用于线程安全和性能要求较高的场景。
懒汉式
简单实现:
java
public class Singleton {
// 声明一个静态的实例对象,初始化为null
private static Singleton instance = null;
// 构造函数私有化,防止在其他类中创建此类的实例
private Singleton() {}
// 提供一个公共的静态方法 getInstance 来获取该单例对象
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
说明 :Singleton类在加载时不会创建实例,而是在调用Singleton.getInstance()
方法时才创建实例。这种方式可以减少内存占用,适用于内存资源较紧的场景。同时,由于实例的创建是延迟的,因此可以减少不必要的初始化操作。但是,由于存在线程不安全的问题,需要使用同步措施来保证线程安全性。常见的同步方式包括使用synchronized
关键字、双重检查锁定等。
单例模式线程安全问题
-
饿汉模式:本身是线程安全的,只读操作
-
懒汉模式:线程不安全,有读有写
(1)加锁,把 if 和 new 变成原子操作
(2)双层检查锁两个if ,减少不必要的加锁操作
(3)使用 volatile 禁止指令重排序,保证后续线程肯定拿到的是完整对象。
2、工厂模式
工厂模式最主要解决的问题就是创建者和调用者的耦合,那么代码层面其实就是取消对new的使用。是一种设计模式,它提供了一种创建对象的接口,使得创建对象的具体逻辑与客户端代码分离。工厂模式通过将对象的创建逻辑封装在工厂类中,使得客户端代码只需要与工厂类进行交互,而不需要直接创建对象。
工厂模式的优点在于可以将对象的创建逻辑封装在工厂类中,使得客户端代码更加简洁和可维护。此外,工厂模式还可以实现对象的延迟加载和缓存,提高应用程序的性能。
java
public interface UserDao {
public User getUserById(int userId);
}
public class H2UserDao implements UserDao {
public User getUserById(int userId) {
// 实现具体的逻辑
}
}
public class MyBatisUserDao implements UserDao {
public User getUserById(int userId) {
// 实现具体的逻辑
}
}
public class UserDaoFactory {
public static UserDao createUserDao(String type) {
if (type.equalsIgnoreCase("h2")) {
return new H2UserDao();
} else if (type.equalsIgnoreCase("mybatis")) {
return new MyBatisUserDao();
} else {
throw new IllegalArgumentException("Invalid type: " + type);
}
}
}
在上面的一段代码中,UserDao是一个接口 ,定义了获取用户信息的操作。H2UserDao 和MyBatisUserDao 分别实现了UserDao接口,并实现了具体的逻辑。UserDaoFactory是一个工厂类,它负责根据传入的类型创建相应的UserDao对象。客户端代码只需要调用UserDaoFactory.createUserDao()方法,就可以获得相应的UserDao对象,而不需要直接创建对象。
通过上诉代码实现,我们可以很轻松的实现切换不同的数据库连接方式或者ORM框架。并且我们还在这个工厂类中添加了缓存逻辑,可以进一步提高应用程序的性能。
二、阻塞式队列
一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。
阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源
阻塞式队列(Blocking Queue)具有以下特性:
- 先进先出(FIFO):阻塞式队列按照先进先出的原则对元素进行排序。也就是说,最早进入队列的元素会最先被移出队列。
- 阻塞操作:当队列为空时,从队列中获取元素的操作会被阻塞,直到队列中有新的元素;当队列已满时,向队列中添加元素的操作会被阻塞,直到队列中有元素被移除。
- 唤醒操作:当队列不为空时,可以从队列中获取元素;当队列有剩余空间时,可以向队列中添加元素。
- 多线程支持:阻塞式队列通常用于多线程编程,可以有效地协调生产者和消费者的同步问题。在多线程环境中,生产者线程和消费者线程可以同时操作阻塞式队列,实现数据的共享和同步。
- 线程安全:阻塞式队列是线程安全的,多个线程可以同时对队列进行操作,而不会导致数据不一致或者其他并发问题。
- 可缓存多个元素:阻塞式队列可以缓存多个元素,当队列已满时,如果还有新的元素需要添加到队列中,可以先将已有的元素保存在队列中,等到队列中有元素被移除时再将保存的元素添加到队列中。
1、生产者消费者模型
生产者和消费者之间,如何实现数据同步和数据交互,需要用到一个交易场所,这个交易场所就是"阻塞队列"。
生产者和消费者之间都是不直接通讯,而是通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里拿。
生活中的一个例子:超市购物;有一个超市,这个超市有一个收银台(生产者)和一个顾客等待队列(消费者),以及一个存放购物篮的区域(阻塞队列)。
- 生产者:收银台
生产者是超市中的收银台。每个收银员负责扫描商品并处理付款。这就像生产者线程在生成数据并添加到队列中。如果队列已满(即购物篮已满),收银员将等待直到队列有空间。
- 阻塞队列:购物篮
购物篮在这里起到了阻塞队列的作用。当所有的购物篮都已满时,新的顾客(即新的消费者)会被阻塞,直到有购物篮可用。同时,如果所有的顾客都完成了购物并离开了超市,而购物篮中仍有商品未被拿走,那么这些商品将留在购物篮中,直到有新的顾客进入超市并取走它们。
- 消费者:顾客
顾客在超市中就是消费者。他们从队列中获取购物篮,选择他们需要的商品,然后将购物篮归还给收银台。如果所有的购物篮都已经被使用,那么新的顾客将被阻塞,直到有购物篮可用。
2、阻塞对列在生产者消费者之间的作用
阻塞队列在生产者和消费者之间起到了一个桥梁的作用。它既能够让生产者生产的商品(数据)存储起来,又能让消费者购买的商品(数据)被取走。
具体来说,当生产者生产商品后,会将商品放入阻塞队列中。如果队列已满,生产者会等待直到队列有空间。而消费者则从队列中取出商品进行消费。如果队列为空,消费者会被阻塞直到队列中有新的商品。
阻塞队列在这里起到了缓冲区的作用,它有效地衔接了生产者和消费者之间的速度差异,提供了一种协调和安全的数据交互方式。平衡了生产者和消费者的处理能力,起到削峰填谷的作用。
3、用标准库阻塞队列实现生产者消费者模型
BlockingQueue
是一个接口,真正实现的类是 LinkedBlockingQueue。put
方法用于阻塞式的入队列,take
用于阻塞式的出队列。- BlockingQueue 也有
offer
,poll
,peek
等方法, 但是这些方法不带有阻塞特性。
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumerExample {
public static void main(String[] args) {
// 创建一个容量为10的阻塞队列
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
// 创建一个生产者线程和一个消费者线程
Thread producer = new Thread(new Producer(queue));
Thread consumer = new Thread(new Consumer(queue));
// 启动线程
producer.start();
consumer.start();
}
}
class Producer implements Runnable {
private BlockingQueue<Integer> queue;
public Producer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
for (int i = 0; i < 100; i++) {
queue.put(i); // 添加元素到队列中,如果队列已满则会被阻塞
System.out.println("Producer produced " + i);
Thread.sleep(100); // 模拟生产数据的耗时操作
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Consumer implements Runnable {
private BlockingQueue<Integer> queue;
public Consumer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (true) {
Integer i = queue.take(); // 从队列中获取元素,如果队列为空则会被阻塞
System.out.println("Consumer consumed " + i);
Thread.sleep(100); // 模拟处理数据的耗时操作
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
三、定时器
在软件开发中,定时器是一种用于在特定时间执行某项任务的功能。它可以在指定的时间间隔后触发一个事件,或者在特定的时间点执行某个操作。定时器在软件开发中有很多应用,例如在操作系统中实现任务调度、在应用程序中定时执行某些操作、在网络通信中定时发送数据等。
1、标准库中的定时器
- 标准库中提供了一个
Timer
类,表示定时器。 - Timer 类的核心方法为
schedule
。用来为定时器安排任务。 - schedule 包含两个参数,第一个参数指定即将要执行的任务
TimerTask
, 第二个参数指定多长时间之后执行 (单位为毫秒)。
简单的Timer定时器:
java
import java.util.Timer;
import java.util.TimerTask;
public class SimpleTimerTaskExample {
public static void main(String[] args) {
// 创建一个Timer对象
Timer timer = new Timer();
// 创建一个TimerTask对象,这里我们使用匿名内部类的方式创建
TimerTask task = new TimerTask() {
@Override
public void run() {
// 这里是定时任务的具体逻辑,这里我们简单打印一条消息
System.out.println("执行定时任务");
}
};
// 定时器任务每隔1秒执行一次,这里设置的是1000毫秒,即1秒
timer.schedule(task, 0, 1000);
}
}
该任务每隔1秒(1000毫秒)就会执行一次。
2、模拟实现定时器
使用优先队列模拟实现一个定时器是一个很好的选择,因为优先队列可以让我们根据元素的优先级对它们进行排序。在这个定时器的实现中,我们可以将延迟时间作为元素的优先级。每次我们从队列中取出具有最小延迟时间的元素,然后执行它。
最终实现代码:
java
import java.util.PriorityQueue;
import java.util.concurrent.TimeUnit;
public class Timer {
private final PriorityQueue<Task> queue;
public Timer() {
this.queue = new PriorityQueue<>((t1, t2) -> Long.compare(t2.getDelay(), t1.getDelay()));
}
public void schedule(Task task) {
queue.add(task);
}
public void run() {
while (true) {
Task task = queue.poll();
if (task == null) break;
long delay = task.getDelay();
try {
TimeUnit.SECONDS.sleep(delay); // 等待指定的延迟时间
System.out.println("任务执行"); // 执行任务,这里仅仅是打印一条消息,你可以替换为实际的任务执行逻辑
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
class Task {
private final long delay;
private final Runnable task;
public Task(long delay, Runnable task) {
this.delay = delay;
this.task = task;
}
public long getDelay() {
return delay;
}
public void run() {
task.run();
}
}
代码说明:
-
Timer类:
- Timer类负责定时任务的调度和执行。它包含一个成员变量queue,是一个优先队列,用于存储待执行的任务。队列中的任务按照延迟时间进行排序,延迟时间越短的任务优先级越高。
- 构造函数中初始化了优先队列queue,并定义了一个比较器函数,用于比较两个任务的延迟时间。这里使用Lambda表达式实现了Comparator接口的比较方法。
- schedule(Task task)方法用于向队列中添加一个新的任务。它将任务对象作为参数传入,并将任务添加到队列中。
- run()方法是定时器的主要执行逻辑。它在一个无限循环中不断地从队列中取出具有最小延迟时间的任务,然后等待该延迟时间后执行任务。如果任务被中断,则会捕获InterruptedException异常并重新开始循环。执行任务的逻辑可以通过替换System.out.println语句来实现。
-
Task类:
- Task类表示一个待执行的任务。它包含两个成员变量:delay表示任务的延迟时间,task是一个Runnable对象,表示实际的任务逻辑。
- 构造函数用于初始化任务的延迟时间和Runnable对象。
- getDelay()方法返回任务的延迟时间。
- run()方法负责执行实际的Runnable任务。当Timer类调用task.run()时,就会执行这里定义的Runnable对象的run()方法。
为什么不使用 PriorityBlockingQueue?
- 实现简单性:
PriorityQueue
的使用相对简单,它是一个非阻塞队列,不支持多线程之间的协作。而PriorityBlockingQueue
则是一个阻塞队列,可以用于多线程环境,如果多个线程试图从队列中取出元素,将会导致阻塞,直到队列中有元素可用。 - 适用性:由于这段代码实现了一个简单的定时器,它并不涉及多线程间的协作,因此使用
PriorityQueue
更为合适。使用PriorityBlockingQueue
可能会增加不必要的复杂性,并且在这个场景中可能并不需要它的特性。 - 性能:
PriorityQueue
和PriorityBlockingQueue
在性能上可能有一些差异。非阻塞的PriorityQueue
可能在某些情况下提供更好的性能,尤其是在处理大量任务时。 - 由于它自带阻塞,在加上定时器需要 wait 本身需要加锁,因此很容易就形成了死锁
四、线程池
1、线程池概述
线程池是一种用于优化线程管理的技术,它可以在应用程序启动时预先创建一组线程并保存在内存中,以避免频繁地创建和销毁线程。线程池通过控制并发线程的数量来提高系统的效率和性能,它能够限制线程的数量,以便在执行任务时避免过度创建线程。
为什么在线程池里取线程比直接创建线程更高效?
从线程池中拿线程是用户级别操作,从系统创建线程,涉及用户态和核心态的切换,一旦涉及切换,效率大打折扣。
2、ThreadPoolExecutor 参数
参数 | 作用 |
---|---|
corePoolSize | 核心线程池大小,核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程 |
maximumPoolSize | 最大线程池大小,最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数 |
keepAliveTime | 线程池中超过 corePoolSize 数目的空闲线程最大存活时间;超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过 setKeepA7iveTime来设置空闲时间 |
TimeUnit | keepAliveTime 时间单位 |
workQueue | 阻塞任务队列,用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程 |
threadFactory | 新建线程工厂,是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂 |
RejectedExecutionHandler | 拒绝策略。当提交任务数超过 maxmumPoolSize+workQueue 之和时,任务会交给RejectedExecutionHandler 来处理,任务拒绝策略,有两种情况,第一种是当我们调用shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝 |
3、RejectedExecutionHandler 拒绝策略
线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。
JDK 内置的拒绝策略如下:
AbortPolicy: 直接抛出异常,阻止系统正常运行。可以根据业务逻辑选择重试或者放弃提交等策略。
CallerRunsPolicy : 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。
不会造成任务丢失,同时减缓提交任务的速度,给执行任务缓冲时间。
DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。
DiscardPolicy : 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。
4、模拟实现线程池
java
public class MyThreadPool {
// 管理任务的阻塞队列(本身就是多线程安全)
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();
// 添加任务方法
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
// 实现一个固定线程个数的线程池
public MyThreadPool(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();
}
}
}
5、创建线程池的两种方式
- 通过
ThreadPoolExecutor
构造函数来创建(推荐)。 - 通过
Executor
框架的工具类Executors
来创建。
6、拓展:实际开发中应该如何确定线程池中线程的数量?
- CPU 密集型(n+1)
CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。
CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。
- IO 密集型(2*n)
由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2
也可以使用公式:CPU 核心数 *(1+平均等待时间/平均工作时间)。