单例模式是常见的设计模式之一。
什么是设计模式?
设计模式,就相当于“棋谱"中一些固定的代码套路,按照棋谱来下,一般就不会下的很差。软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏。
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.
这一点在很多场景上都需要. 比如 JDBC
中的 DataSource
实例就只需要一个。
单例模式具体的实现方式, 分成 “饿汉” 和 “懒汉” 两种。
这里举一个例子:洗碗
饿汉的单例模式,是比较着急的去进行创建实例的.
懒汉的单例模式,是不太着急的去创建实例,只是在用的时候才真正创建.
类加载的同时,创建实例。
一个Java程序中,一个类对象只存在一份(JVM
保证的)进—步的也就保证了类的static
成员也是只有一份的。
//用过Singleton来实现单例模式,保证Singleton这个类有唯一实例
//饿汉模式
class Singleton{
//static修饰的成员---“类成员”-》“类属性/方法”
//1.使用static来创建一个实例,并且立即进行实例化
//这个instance对应的实例,就是该类的唯一实例
private static Singleton instance = new Singleton();
//2.为了防止在其他地方new这个Singleton,就可以把这个Singleton设为私有的
private Singleton(){};
//构造一个方法,让外面能够拿到唯一实例
public static Singleton getInstance(){
return instance;
}
}
public class Test06 {
public static void main(String[] args) {
Singleton instance = Singleton.getInstance();
}
}
饿汉模式中getlnstance
,仅仅是读取了变量的内容。如果多个线程只是读同一个变量,不修改,此时仍然是线程安全的。
类加载的时候不创建实例. 第一次使用的时候才创建实例。
class Singleton1{
private static Singleton1 in = null;
private Singleton1(){};
public static Singleton1 getInstance(){
//不是原子的,既包含读,又包含修改
if(in == null){
in = new Singleton1();
}
return in;
}
}
懒汉模式中,既包含了读,又包含了修改.而且这里的读和修改,还是分成两个步骤的(不是原子的)存在线程安全问题。
线程安全问题发生在首次创建实例时. 如果在多个线程中同时调用 getInstance
方法, 就可能导致创建出多个实例.
加锁操作,可以改变这里的线程安全问题。使用这里的类对象作为锁对象(类对象在一个程序中只有唯一一份,就能保证多个线程调用getInstance
的时候都是针对同一个对象进行的加锁)。
class Singleton1{
private static Singleton1 instance= null;
private Singleton1(){};
public static Singleton1 getInstance(){
synchronized (Singleton1.class){
if(instance== null){
instance= new Singleton1();
}
}
return instance;
}
}
当前虽然加锁之后,线程安全问题得到解决了,但是又有了新的问题 :对于刚才这个懒汉模式的代码来说。线程不安全是发生在instance
被初始化之前的.未初始化的时候,多线程调用getinstance
,就可能同时涉及到读和修改.但是一旦instance
被初始化之后(一定不是nul
, if
条件一定不成立了),getInstance
操作就只剩下两个读操作也就线程安全了。
而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前,每次调用 getinstance
方法都会进行加锁.也就意味着即使是初始化之后(已经线程安全了),仍然存在大量的锁竞争。
以下代码在加锁的基础上, 做出了进一步改动:
if
判定, 降低锁竞争的频率。改进方案: 让getInstance
初始化之前,才进行加锁,初始化之后,就不再加锁了。在加锁这里再加上一层条件判定即可.条件就是当前是否已经初始化完成 (instance == null
)。
在使用了双重if
判定之后,当前这个代码中还存在一个重要的问题:如果多个线程,都去调用这里的getlnstance
方法,就会造成大量的读instance
内存的操作,这样可能会让编译器把这个读内存操作优化成读寄存器操作。
—旦这里触发了优化,后续如果第一个线程已经完成了针对instance
的修改,那么紧接着后面的线程都感知不到这个修改,仍然把 instance
当成null
。所以这里需要给 instance 加上了 volatile。
class Singleton2{
//不是立即初始化实例
//volatile 保证内存可见性
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 Test07 {
public static void main(String[] args) {
Singleton2 instance = Singleton2.getInstance();
}
}
阻塞队列是什么?
阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列是一种线程安全的数据结构, 并且具有以下特性 : 产生阻塞效果
阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型。
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
生产者消费者模型,是实际开发中非常有用的一种多线程开发手段。尤其是在服务器开发的场景中:
假设有两个服务器AB
,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据。
如果不使用生产者消费者模型。此时A和B的耦合性是比较强的:在开发A代码的时候就得充分了解到B提供的一些接口;开发B代码的时候也得充分了解到A是怎么调用的;—旦想把B换成C,A的代码就需要较大的改动,而且如果B挂了,也可能直接导致A也顺带挂了。
使用生产者消费者模型,就可以降低这里的耦合.
对于请求:A是生产者,B是消费者.对于响应:A是消费者,B是生产者.阻塞队列都是作为交易场所 ,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
A只需要关注如何和队列交互,不需要认识B;
B也只需要关注如何和队列交互,也不需要认识A;
队列是不变的,如果B挂了,对于A没啥影响;如果把B换成C,A也完全感知不到。
未使用生产者消费者模型的时候,如果请求量突然暴涨(不可控)
A暴涨导致B暴涨;
A作为入口服务器,计算量很轻,请求暴涨,问题不大.B作为应用服务器,计算量可能很大,需要的系统资源也更多.如果请求更多了,需要的资源进—步增加,如果主机的硬件不够,可能程序就挂了。
A请求暴涨=>阻塞队列的请求暴涨,由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力.
B这边仍然按照原来的速度来消费数据,不会因为A的暴涨而引起暴涨.B就被保护的很好,就不会因为这种请求的波动而引起崩溃 。
"削峰”这种峰值很多时候不是持续的,就一阵过去了(比如双十一秒杀活动)就又恢复了 。
"填谷"B仍然是按照原有的频率来处理之前积压的数据。
实际开发中使用到的"阻塞队列"并不是一个简单的数据结构了,而是一个/一组专门的服务器程序。并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能(对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数…),这样的队列又起了个新的名字,"消息队列”(未来开发中广泛使用到的组件)。
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
BlockingQueue
是一个接口. 真正实现的类是 LinkedBlockingQueue
.put
方法用于阻塞式的入队列, take
用于阻塞式的出队列.BlockingQueue
也有 offer, poll, peek
等方法, 但是这些方法不带有阻塞特性.import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
BlockingDeque<String> queue = new LinkedBlockingDeque<>();
//入队列
queue.put("hello");
//出队列
String s = queue.take();
System.out.println(s);//hello
}
}
先实现一个普通的队列,再加上线程安全,再加上阻塞(使用wait和notify机制).
对于put来说,阻塞条件,就是队列为满。put 中的wait要由take来唤醒.只要take成功了一个元素,不就队列不满了,就可以进行唤醒了.
对于take来说,阻塞条件,就是队列为空。对于take 中的等待,条件是队列为空.队列不为空,也就是put成功之后,就来唤醒.
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
class MyBlocking{
//基于数组实现阻塞队列
private int[] data = new int[1000];
//队列长度
private int size = 0;
//队首下标
private int head = 0;
//队尾下标
private int tail = 0;
private static Object locker = new Object();
public void put(int value) throws InterruptedException {
synchronized (locker){
if(size == data.length){
//开始时站实现返回return
// return;
//针对哪个对象加锁,就返回哪个对象的wait 如果是针对this加锁,就this.wait
locker.wait();
}
//把新的元素方法tail位置上
data[tail] = value;
tail++;
//处理tail到达元素末尾的情况,需要从头开始,重新循环
//第1种写法
if(tail >= data.length){
tail = 0;
}
//第2种写法
// tail = tail % data.length;
size++;
//如果入队列成功,则队列非空,就唤醒take中的阻塞等待
locker.notify();
}
}
//出队列
//使用包装类
public Integer take() throws InterruptedException {
synchronized (locker){
if(size == 0){
// return null;
locker.wait();
}
int ret = data[head];
head++;
if(head >= data.length){
head = 0;
}
size--;
// take成功之后,就唤醒put中的等待.
locker.notify();
return ret;
}
}
}
public class Test08 {
public static void main(String[] args) {
MyBlocking queue = new MyBlocking();
//实现一个生产者消费者模式
Thread t = new Thread(()->{
int num = 0;
while (true){
System.out.println("生产了:" + num);
try {
queue.put(num);
// 当生产者生产的慢一些的时候, 消费者就得跟着生产者的步伐走.生产一个消费一个
// Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
}
});
t.start();
Thread t2 = new Thread(()->{
int num = 0;
while (true){
System.out.println("消费了:" + num);
try {
num = queue.take();
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t2.start();
}
}
当生产者生产的慢一些的时候, 消费者就得跟着生产者的步伐走,生产一个消费一个:
定时器也是软件开发中的一个重要组件。类似于一个 “闹钟”: 达到一个设定的时间之后, 就执行某个指定好的代码。
Timer
类. Timer
内部是有专门的线程,来负责执行注册的任务的,Timer
类的核心方法为 schedule
.schedule
包含两个参数:第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒)。import java.util.Timer;
import java.util.TimerTask;
public class Test09 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
},3000);
System.out.println("main");
}
}
Timer内部需要什么?
TimerTask
)import java.util.concurrent.PriorityBlockingQueue;
//创建一个类,表示一个任务
class MyTask implements Comparable<MyTask>{ //实现Comparable接口,设定比较规则
//任务具体要干什么
private Runnable runnable;
//任务具体啥时候干,保存任务要执行的毫秒级时间戳
private long time;
//提供一个构造方法
public MyTask(Runnable runnable, long delay) { //delay是一个时间间隔,不是绝对的时间戳的值
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;
}
public void run(){
runnable.run();
}
public long getTime() {
return time;
}
@Override
public int compareTo(MyTask o) {
//让时间小的在前,时间大的在后
return (int)(this.time - o.time);
}
}
//定时器
class MyTimer{
//定时器内部能够存放多个任务
//此处的队列要考虑到线程安全问题 可能在多个线程里进行注册任务.
// 同时还有一个专门的线程来取任务执行.此处的队列就需要注意线程安全问题.
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//使用schedule方法来注册任务到队列中
public void schedule(Runnable runnable,long delay){
MyTask task = new MyTask(runnable,delay);
queue.put(task);
//每次任务插入成功之后,都唤醒一下扫描线程,让线程重新检查一下队首的任务,看是否时间到了要执行
synchronized (locker){
locker.notify();
}
}
private Object locker = new Object();
//创建一个扫描线程
public MyTimer(){
Thread t = new Thread(()->{
while (true){
try {
//先取出队首元素
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
//比较一下看当前时间到了吗
if(curTime < task.getTime()){
//时间没到,把任务塞回到队列中
queue.put(task);
//指定一个等待时间
synchronized (locker){
//wait可以被中途唤醒 sleep不能被中途唤醒
locker.wait(task.getTime() - curTime);
}
}else {
//时间到了,执行任务
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class Test10 {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
},3000);
System.out.println("main");
}
}
1.描述—个任务: runnable + time
2.使用优先队列来组织若干个任务. PriorityBlockingQueue
3.实现schedule方法来注册任务到队列中.
4.创建一个扫描线程,这个扫描线程不停的获取到队首元素,并且判定时间是否到达.
另外要注意,让MyTask
类能够支持比较:实现Comparable
接口,并设定比较规则
注意解决这里的忙等问题:
在扫描线程当中,如果队列中的任务是空着的,就还好,这个线程就在这里阻塞了 (没问题)就怕队列中的任务不空,并且任务时间还没到,此时就称为"忙等"(等确实是等了,但是又没闲着,既没有实质性的工作产出,同时又没有进行休息)
忙等这种操作是非常浪费CPU的,可以基于wait
这样的机制解决忙等问题:
wait有一个版本,指定等待时间.(不需要notify,时间到了自然唤醒)
计算出当前时间和任务的目标之间的时间差,就等待这么长时间即可。
locker.wait(task.getTime() - curTime);
在等待过程中,可能要插入新的任务。新的任务是可能出现在之前所有任务的最前面的在schedule
操作中,就需要加上一个notify
操作。
进程比较重,若果频繁创建销毁,会导致开销大 。
线程虽然比进程轻了,但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的。
解决方案:线程池or协程
把线程提前创建好,放到池子里,后面需要用线程,直接从池子里取,就不必从系统这边申请了;线程用完了,也不是还给系统,而是放回池子里,以备下次再用 ,这回创建销毁过程,速度就更快了 。线程池最大的好处就是减少每次创建、销毁线程的损耗。
为什么线程放在池子里,就比从系统这边申请释放来的更快呢?
操作系统分为两种状态:用户态和内核态
咱们自己写的代码,就是在最上面的应用程序这一层来运行的,这里的代码都称为“用户态"运行的代码 。
有些代码,需要调用操作系统的API,进—步的逻辑就会在内核中执行。
例如,调用一个System.out.println
,本质上要经过write
系统调用,进入到内核中,内核执行一堆逻辑,控制显示器输出字符串。
在内核中运行的代码,称为"内核态"运行的代码。
创建线程,本身就需要内核的支持.(创建线程本质是在内核中搞个PCB,加到链表里)
调用的 Thread.start
其实归根结底,也是要进入内核态来运行 ;而把创建好的线程放到"池子里",由于池子就是用户态实现的,这个放到池子/从池子取的过程不需要涉及到内核态,就是纯粹的用户态代码就能完成.
一般认为,纯用户态的操作,效率要比经过内核态处理的操作,要效率更高。
认为内核态效率低,倒不是说一定就真的低,而是代码进入了内核态,就不可控了。内核啥时候给你把活干完,把结果给你(有的时候快,有的时候慢).
标准库中的线程池叫做:ThreadPoolExecutor
juc(java.util.concurrent): concurrent
并发的意思.Java中很多和多线程相关的组件都在这个concurrent
包里.
ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
int corePoolSize
:核心线程(正式员工的数量)
int maximumPoolSize
:最大线程(正式员工+临时工)
long keepAliveTime
:允许临时工摸鱼的时间
TimeUnit unit
:时间的单位(s, ms, us…)
BlockingQueue
:任务队列.线程池会提供一个submit
方法让程序猿把任务注册到线程池中,即加到这个任务队列中.
ThreadFactory threadFactory
:线程工厂.线程是怎么创建出来的
RejectedExecutionHandler handler
:拒绝策略 ,当任务队列满了,怎么做? 1.直接忽略最新的任务 2.阻塞等待3.直接丢弃最老的任务…
虽然线程池的参数这么多,但是使用的时候最重要的参数,还是第一组参数:线程池中线程的个数。
面试题:有一个程序,这个程序要并发的/多线程的来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适?
没有一个具体的数字,这要通过性能测试的方式,找到合适的值。
例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求,就可以对这个服务器进行性能测试,比如构造一些请求,发送给服务器,这里的请求就需要构造很多,比如每秒发送50/100/20. .…根据实际的业务场景,构造一个合适的值 。
根据这里不同的线程池的线程数,来观察程序处理任务的速度,程序持有的CPU的占用率。
当线程数多了,整体的速度是会变快,但是CPU占用率也会高.
当线程数少了,整体的速度是会变慢,但是CPU占用率也会下降.
需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点。
不同类型的程序,因为单个任务,里面CPU上计算的时间和阻塞的时间是分布不相同的.
因此这里指定一个具体的数字往往是不靠谱。
标准库中还提供了一个简化版本的线程池–Executors
,本质是针对ThreadPoolExecutor
进行了封装,提供了—些默认参数。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test11 {
public static void main(String[] args) {
//创建一个固定线程数目的线程池,参数指定了线程的个数
ExecutorService pool = Executors.newFixedThreadPool(10);
//创建一个自动扩容的线程池,会根据任务量来进行自动扩容
// Executors.newCachedThreadPool();
//创建一个只有一个线程的线程池
// Executors.newSingleThreadExecutor();
//创建一个带有定时器功能的线程池,类似于Timer
// Executors.newScheduledThreadPool();
for (int i = 0; i < 100; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
线程池里面有什么?
Runnable
)BlockingQueue
)import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
class MyThreadPool{
//1.描述一个任务,直接使用Runnable
//2.使用一个数据结构来组织任务
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
//3.描述一个线程,工作线程的功能就是从任务队列中取任务并执行
static class Worker extends Thread{
//当前线程池中有若干个Worker线程,这些线程内部都持有上述的任务队列
private BlockingDeque<Runnable> queue = null;
public Worker( BlockingDeque<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 Test12 {
public static void main(String[] args) {
MyThreadPool myThreadPool = new MyThreadPool(10);
for (int i = 0; i < 100; i++) {
myThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello myThreadPool");
}
});
}
}
}
以上。