单例模式是校招中最常考的设计模式之一。
啥是设计模式?
设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有
一些固定的套路. 按照套路来走局势就不会吃亏.
软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照
这个套路来实现代码, 也不会吃亏.
应用场景:有时候,希望对象在程序中只有一个实例(对象),也就是说只能new一次。由此就出现了单例模式。
单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例.
单例模式具体的实现方法,分成“懒汉”和“饿汉”两种。
含义:当前代码中是比较迫切的,在类加载的时候就立刻,把实例给创建出来。
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这个实例,就会报错。
这时,我们能够使用的实例有且只有一个,就是类字被加载出来的同时,会进行实例化。
含义:在真正用到的时候来进行创建实例,不用到则不会产生其他开销。(更推荐使用)
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);
}
}
懒汉模式与饿汉模式极其相似,只是在实例化的时机上有所不同。
接下来我们要去考虑 懒汉模式 和 恶汉模式 在多线程模式下会不会出问题呢????在多线程调用 getInstacne 的情况下,哪个模式是线程安全的(没有Bug)??
对于饿汉模式:多个线程同时读取同一个变量,就不会出现线程安全问题。
对于懒汉模式:则会出现线程安全问题。
简单理解:当我们按照顺序来执行,就会发现,此时创建出两个对象,程序不再是单例模式了!!
深入解读:第二次 new 操作,就把原来 instacne 的引用给修改了,之前 new 出来的对象就立刻被 gc 回收了,最后只剩下一个对象。
在通常印象中,new 一个对象并不是一个很复杂的过程,但实际上 new 一个对象开销是非常大的!!!!如果服务器一启动就需要加载 100G 的数据存到内存中,都是通过一个对象来管理,那我们 new 这个对象时,就需要消耗 100G 的内存,花费很长时间。
提到如何解决线程安全问题,想到的就是加锁以及内存可见性问题。
先考虑一种加锁方式:
这种写法是错误的,没有解决上述线程安全问题。因为这个操作本身就是原子的,再加锁也起不到实质上的作用。
正确加锁方式是把锁加到外面
如图所示,此时再执行线程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 给改了。
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()
像上面这句代码,一句代码在编译器中由三句指令构成。
给 instacne 加上 volatile 之后,此时针对 instance 进行的赋值操作,就不会产生上述的指令重排序了。必须按照 1 2 3 的顺序执行,不会出现 1 3 2 的执行顺序。
阻塞队列是一种特殊的队列,带有阻塞功能。也遵守 “先进先出” 的原则。
阻塞队列能是一种线程安全的数据结构,并且具有以下特性:
阻塞队列的一个典型应用场景就是 “生产者消费者模型”。这是一种非常典型的开发模型。
在该模型中,生产者负责生产数据,并将其放入一个缓冲区中。消费者负责从缓冲区中取出数据并进行消费。
本质上来说就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
减少任务切换开销,减少锁竞争。
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力.
服务器收到的来自于客户端/用户的请求,不是一成不变的。可能会因为一些突发事件,引起请求数目暴增~~
一台服务器,同一时刻能处理的请求数量是有上限,不同的服务器承担的上限是不一样的。
在一个分布式系统中,就经常会出现,有的机器能承担的压力更大,有的则更小~~
正因为生产者消费者模型,这么重要,虽然阻塞队列只是一个数据结构,但我们会把这个数据结构(阻塞队列)单独实现成一个服务器程序并且使用单独的主机/主机集群来部署。
此时,这个所谓的则塞队列,就进化成了“消息队列”。
java标准库中已经提供了现成的阻塞队列的实现了,有基于链表、堆、优先级、数组实现的。
Array 这个版本的速度要更快,前提是得先知道最多有多少元素。(对于 Array 版本来说,频繁扩容是一个不小的开销)
如果不知道有多少元素,使用 Linked 更合适。
对于BlockfingQueue来说,offer 和 poll 不带有阻塞功能,而 put 和 take 带有阻塞功能。
这里我们基于数组,循环队列来简单实现一个阻塞队列。
循环队列就像是把数组首尾连接起来,组成一个环状,以此提高使用效率。
下面简单实现阻塞队列的两个主要功能put
和take
。
put
:入队列。take
:出队列。阻塞队列的构成:
循环队列
实现阻塞
写完代码,需要考虑线程安全问题。(内存可见性、加锁)
wait 搭配 while 循环使用
原因:
下面是实现代码:
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个元素,阻塞等待消费元素。
之后消费一个元素即生产一个元素。
*/
什么是定时器?
⏰ 定时器也是软件开发中的一个重要组件。类似于一个 “闹钟”。达到一个设定的时间之后,就执行某个指定好的代码。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定是任务执行");
}
}, 3000);
System.out.println("程序开始运行");
当我们运行代码,就会发现,打印完 hello 程序并没有结束,而是在持续执行。
原因就在于:Timer 内部,有自己的线程。为了保证随时可以处理新安排的任务,这个线程会持续执行。并且这个线程还是个前台线程。
上述代码中,可以看到 schedule 有两个参数, 其中的一个参数 TimeTask 类似于 Runnable。在那里会记录时间,以及执行什么样的任务。
下图中的源代码 TimerTask 也实现了 Runnable 接口。
一个定时器里,是可以有很多个任务的!!
先要能够把一个任务给描述出来,再使用数据结构把多个任务组织起来。
定时器的构成思路:
创建一个TimerTask这样类,表示一个任务。这个任务,就需要包含两方面,任务的内容,任务的实际执行时间。
使用一定的数据结构,把多个 TimerTask 给组织起来。
如果使用 List (数组,链表)组织 TimerTask 的话。在任务特别多的情况下,如何确定哪个任务,何时能够执行呢?这样就需要搞一个线程,不停的对上述的List进行遍历。看这里的每个元素,是否到了时间,时间到就执行,时间不到,就跳过扫描下一个。
这个思路并不科学!!如果这些任务的时间都还为时尚早。在时间到达之前,此处的扫描线程就需要一刻不停的反复扫描。
这里可以使用优先级队列,来组织所有的任务。因为队首元素,就是时间最小的任务。
搞一个扫描线程,负责监控队首元素的任务是否时间到,如果到时间了,就执行 run 方法来完成任务。
注意问题:
代码中所使用的优先级队列不是线程安全的!!在此需要针对 queue 的操作,进行加锁。
扫描线程中(阻塞等待),直接使用 sleep 进行休眠,是否合适呢?? 非常不合适的!!!!
随便写一个类,它的对象都能放到优先级队列中嘛?有没有什么特别的要求?
要求放到优先级队列中的元素,是“可比较的"。
通过 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;
}
@Override
public 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() {
@Override
public void run() {
System.out.println("hello 3");
}
},3000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 2");
}
},2000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 1");
}
},1000);
System.out.println("开始执行!!!");
}
}
池(Poll) 是一个非常重要的思想方法。包括内存池、进程池、连接池、常量池……这些“池”概念上都是一样的。
线程池可以管理和重用多个线程,以提高应用程序的性能和资源利用率。线程池维护了一个线程队列,当需要执行任务时,可以从线程池中获取一个空闲线程来执行任务,而不需要每次都创建新的线程。这样可以减少线程创建和销毁的开销,并且可以限制并发线程的数量,避免资源耗尽。
为啥,从池子里取,就比从系统这里创建线程更快更高效呢?
因此线程池最大的好处就是减少每次启动线程、销毁线程的损耗。
什么是工厂模式??
工厂模式是一种创建对象的设计模式,它通过将对象的创建委托给一个工厂类来解决对象创建的问题。工厂模式提供了一种灵活的方式来创建对象,使得客户端不需要使用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
:被称为“工厂方法”。
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (int i = 0; i < 1000; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
Methods | Description |
---|---|
newCachedThreadPool() | 创建出一个线程数目动态变化的线程池. |
newFixedThreadPool(int nThreads) | 创建一个固定线程数量的线程池. |
newScheduledThreadPool(int corePoolSize) | 类似于定时器,在后续的某个时刻再执行。 |
newSingleThreadExecutor() | 包含单个线程(比原生创建的线程 api 更简单一点) |
除了上述这些线程池之外,标准库还提供了一个接口更丰富的线程池类。
为了方便使用,将上述的几个方法进一步封装,形成一个新的 ThreadPoolExecutor 类,以此更好的满足实际的需求。
我们可以在官方文档中查看 ThreadPoolExecutor 类,在 java.util.concurrent
这个包中,
谈谈Java标准库里的线程池构造方法的参数和含义。
当线程池无法接受新任务时,会调用一下方式来拒绝执行任务。
第一个拒绝策略
ThreadPoolExecutor.AbortPolicy
默认的拒绝策略,会抛出RejectedExecutionException异常
第二个拒绝策略
ThreadPoolExecutor.CallerRunsPolicy
会将任务回退给调用者线程来执行
第三个拒绝策略
ThreadPoolExecutor.DiscardOldestPolicy
会丢弃最早加入队列的任务,然后尝试重新添加新任务
第四个拒绝策略
ThreadPoolExecutor.DiscardPolicy
会直接丢弃该任务
上述谈到的线程池
二者用哪个都可以,主要还是看实际需求。
线程池中所用到的数据结构还是阻塞队列。
实现其主要功能 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() {
@Override
public 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,这说不好!
这里更好的做法,是通过实验的方式,来找到合适的线程数。通过性能测试,尝试不同的线程数目。实验过程中,找到性能和系统资源开销比较均衡的数值。