线程安全-应用篇
文章目录
一、单例模式
单例模式,属于Java设计模式中的一种常用的设计模式,设计模式简单来说就是对一些解决通用问题的、经常书写的代码片段的总结与归纳。而单例模式,就是通过代码来保护一个类,使得类在整个进程(应用)运行过程中,有且只有一个对象。
1、饿汉模式
顾名思义,饿的不行了,所以一开始就 new 了一个对象,看看代码吧👇👇👇
/**
* 单例模式
* (饿汉模式)——天生线程安全
*/
public class StarvingMode {
// 是线程安全的
// 静态属性的初始化是在类加载的时候执行
// JVM 保证了类加载的过程是线程安全的
private static StarvingMode instance = new StarvingMode();
public static StarvingMode getInstance() {
return instance;
}
private StarvingMode() {}
}
因为单例模式要使得进程运行过程中该类只有一个对象,所以用 private 修饰构造方法,表示只有在当前类中才能调用,这样的话就不能在别的类中随意的实例化当前类对象了。
此外,因为静态属性的初始化是在类加载的时候执行,而JVM又保证了类加载过程是线程安全的,所以**饿汉模式天生就是线程安全的**。
2、懒汉模式
看名字也大概能猜出是什么意思哈哈哈哈,在需要的时候才实例化对象。
//懒汉模式
public class LazyModeV1 {
private static LazyModeV1 instance = null;
public static LazyModeV1 getInstance() {
// 第一次调用这个方法时,说明我们应该实例化对象了
if (instance == null) {
instance = new LazyModeV1(); // 只在第一次的时候执行
}
return instance;
}
private LazyModeV1() {}
}
代码很好理解,现在我们应该考虑这个懒汉模式是不是线程安全的。首先, instance 是一个共享数据,其次,instance = new LazyModeV1() 这个操作并不是原子性的,所以懒汉模式并不是线程安全的。
前置知识
实例化对象过程大概分为三步:
①根据类计算对象的大小,在堆上为对象开辟空间;
②对象的初始化过程,比如执行构造代码块、构造方法、属性的初始化赋值等等;
③将引用指向该对象。
所以对象实例化过程并不是原子性的,因此就是线程不安全的。
问题就来了,懒汉模式不是线程安全的,我们之前又学习了锁( synchronized 和 lock ),那么我们该如何把他改造成线程安全的呢?
3、改造懒汉模式
public class LazyModeV2 {
private static LazyModeV2 instance = null;
public synchronized static LazyModeV2 getInstance() {
// 第一次调用这个方法时,说明我们应该实例化对象了
if (instance == null) {
instance = new LazyModeV2(); // 只在第一次的时候执行
}
//锁加在这里也可以保证原子性
// synchronized (LazyModeV2.class) {
// if (instance == null) {
// instance = new LazyModeV2(); // 只在第一次的时候执行
// }
// }
return instance;
}
private LazyModeV2() {
}
}
上上一篇中我们提到,违反原子性的两个场景:
- read-write
- check-update
所以在这段代码中,要保证线程安全,那么需要对 if 语句以及 instance = new LazyModeV2() 加锁,所以如上所示,对整个方法加锁,就可以保证原子性,做到了线程安全。
既然保证了线程安全,那么我们再来考虑一下性能问题。既然只需要实例化一个对象,也就意味着加锁和解锁的操作只需要在第一次真正实例化对象的进行,而我们这样些的话,每一次拿对象(不管对象有没有被实例化)都需要加锁解锁,显然性能不是很好,那么再优化优化吧💪💪💪
4、继续优化懒汉模式
public class LazyModeV3 {
private volatile static LazyModeV3 instance = null;
public static LazyModeV3 getInstance() {
// 第一次调用这个方法时,说明我们应该实例化对象了
if (instance == null) {
// 只有 instance 还没有初始化时,才会走到这个分支
// 这里没有锁保护,所以理论上可以有很多线程同时走到这个分支
synchronized (LazyModeV3.class) {
// 通过上面的条件,让争抢锁的动作只在 instance实例化之前才可能发生,实例化之后就不再可能
// 加锁之后才能执行
// 第一个抢到锁的线程,看到的 instance 是 null
// 其他抢到锁的线程,看到的 instance 不是 null
// 保证了 instance 只会被实例化一次
if (instance == null) {
instance = new LazyModeV3(); // 只在第一次的时候执行
// 当重排序成 1 -> 3 -> 2 的时候可能出问题
// 通过 volatile 修复
}
}
}
return instance;
}
private LazyModeV3() {}
}
当多个线程同时调用该方法拿对象时,首先判断 instance = null ,所以线程们都尝试加锁,但只有一个幸运的线程能加锁成功,幸运线程加锁成功后,此时 instance 任然是 null ,所以它就实例化对象,完成后 instance 就不为空了,并且幸运线程解锁,剩下的线程此时继续尝试加锁,加锁成功的线程首先判断 instance 是否为空,由于现在的 instance 已经被幸运线程实例化成功了,不为 null 了 ,所以它就不用再次 new 了,直接解锁就好了。这样就能使得加锁解锁操作只有再第一次真正实例化对象的执行。(运用到了二次判断的小技巧)
画个图从整体角度看一下吧
千万别忘记,用 volatile 修饰 instance,保证对象实例化过程的原子性!
private volatile static LazyModeV3 instance = null;
二、阻塞队列(BlockingQueue)
1.阻塞队列介绍
接口详情:
方法详情:
- put(e):往队列中放元素,若队列已满,则阻塞(允许被中断,被中断时以异常形式结束,抛出 InterruptedException)
- take():从队列中取元素,若队列为空,则阻塞(允许被中断,被中断时以异常形式结束,抛出 InterruptedException)
实现类:
阻塞队列常用于生产者消费者模型,生产者生产东西放入阻塞队列,,当队列已满时进入阻塞状突;消费者从阻塞队列中拿走产品,当队列为空时进入阻塞状态。所以此时的线程和线程之间就需要通信,队列已满时进入阻塞状态,消费者不停的从队列中拿东西,直到队列为空 ,消费者通知生产者继续生产……
2.前置知识之wait()和notify()
Object类中定义的许多方法,Java中的对象都有,其中 wait() 和 notify() 方法是负责线程等待和唤醒的。
要使用 wait() 和 notify() ,必须先对“对象”用 synchronized 加锁
(1)没加锁
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
o.wait();
}
}
我们直接使用了wait()方法,没有加锁,程序会以异常形式结束( IllegalMonitorStateException:非法的监视器状态异常 ),因为我们没有给当前对象加锁。
(2)加锁后
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
synchronized (o){
o.wait();
System.out.println("永远不会到达!");
}
}
}
对当前对象加锁之后调用wait()方法,可以看到程序一直在运行,但处于阻塞状态。打开 jconsole 工具看一下,有点模糊大概能看到,主线程处于 WAITING 状态。
那么如何唤醒当前线程呢?
(3)唤醒
public class Demo1 {
static class MyThread extends Thread {
private Object o;
MyThread(Object o) {
this.o = o;
}
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o) {
System.out.println("唤醒主线程");
o.notify();
}
}
}
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
MyThread t = new MyThread(o);
t.start();
synchronized (o) {
o.wait();
//让另外一个线程唤醒它,所以下面这句话就会输出
System.out.println("永远不会到达");
}
}
}
看一下运行结果吧🥰🥰🥰
(5S之后)
可以看到,子线程成功将主线程唤醒。
那么,又有一个问题,之前学 synchronized 的时候说过,只有 synchronized 对当前对象解锁之后另一个线程才能继续对 当前对象继续加锁,但是代码中,主线程对 o 加synchronized 锁,然后子线程在 notify() 的时候又对 o 加锁,这是怎么回事儿呢😮😮😮😮😮
🚩其实是因为 wait() 这个方法,在 wait() 过程中,会有三个操作:
- 释放 o 对象的锁
- 等待被唤醒(无限等待)
- 被唤醒后再次加锁
也就是说,在 wait() 过程中是不持有锁的,所以子线程才能对 o 对象加锁成功。
再来思考一个问题,wait() 释放锁,释放的是所有对象的锁还是某一个对象的锁?
public class Demo2 {
static Object lock1 = new Object();
static Object lock2 = new Object();
static Object lock3 = new Object();
static class MyThread1 extends Thread {
@Override
public void run() {
synchronized (lock1) {
System.out.println("unlock1 成功");
}
}
}
static class MyThread2 extends Thread {
@Override
public void run() {
synchronized (lock2) {
System.out.println("unlock2 成功");
}
}
}
static class MyThread3 extends Thread {
@Override
public void run() {
synchronized (lock3) {
System.out.println("unlock3 成功");
}
}
}
public static void main(String[] args) throws InterruptedException {
synchronized (lock1) {
synchronized (lock2) {
synchronized (lock3) {
MyThread1 t1 = new MyThread1();
MyThread2 t2 = new MyThread2();
MyThread3 t3 = new MyThread3();
t1.start();
t2.start();
t3.start();
// 主线程持有 3 把锁
lock3.wait(); // 释放 lock3 锁
}
}
}
}
}
如代码所示,主线程持有三把锁,那么wait()释放的是哪一把锁呢?
看结果,只有 lock3 被释放了。然后打开 jconsole 工具看一下:
主线程处于WAITING状态:
子线程1处于BLOCKED状态:
子线程2也处于BLOCKED状态:
实质上对于 wait() 来说,它根本不知道自己持有几把锁,但是由于我们是在 lock3 中进行的 wait() 操作,所以它一定知道自己必须得持有 lock3 的锁,所以它也只会释放 lock3 锁。
(4)唤醒(notify)规则
实质上,在进行 notify 时是随机的,也就是说任意一个线程都有可能被唤醒。举个例子💻
public class Demo3 {
static Object o = new Object();
static class MyThread extends Thread {
@Override
public void run() {
synchronized (o) {
try {
o.wait();
System.out.println(getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
MyThread t = new MyThread();
t.start();
}
// 保证了子线程们先 wait,主线程就先休眠一会儿
TimeUnit.SECONDS.sleep(5);
synchronized (o) {
o.notify();
// o.notifyAll();
}
}
}
可以看到,notify() 是随机唤醒的。
还有一个 notifyAll() 方法,顾名思义,唤醒所有线程。还是以上面的代码为例,看看效果:
(5)wait 被唤醒的几种情况
- notify()唤醒
- 以异常形式被唤醒(线程被中止)
- 假唤醒(感兴趣的可以查看官方文档)
- 超时时间到达
关于假唤醒:点击查看官方文档
(6)关于 wait 和 notify 的先后顺序
wait 和 notify 是没有状态保存的,先 wait 再 notify ,线程会被唤醒;先 notify 再 wait ,wait 可不知道 之前有过 notify ,所以会一直等待下去。
public class Demo4 {
static Object o = new Object();
static class MyThread extends Thread {
@Override
public void run() {
synchronized (o) {
System.out.println("notify");
o.notify();
}
}
}
public static void main(String[] args) throws InterruptedException {
MyThread t = new MyThread();
t.start();
// 保证了子线程先 notify,主线程就先休眠一会儿
TimeUnit.SECONDS.sleep(2);
synchronized (o) {
System.out.println("wait");
o.wait();
}
}
}
为了让子线程先执行,我们先让主线程休眠一会儿,保证让子线程先 notify ,再让主线程 wait 。看一下结果:
可以看到,子线程先 notify ,主线程再 wait ,然后程序处于WAITING状态,因为它无法感知之前的 notify 。
3、关于condition条件变量
结合上面的介绍,我们可以清楚的知道,wait() 和 notify() 是与 synchronized 结合在一起使用的,juc 工具包下的 lock 锁其实也有和 wait() 、 notify() 功能相似的方法,就是 condition 条件变量下的 await() 和 signal() 。
- wait() 等价于 await()
- notify() 等价于 signal()
lock 锁比 synchronized 更加灵活,所以 await() 和 signal() 也就比 wait() 和 notify() 更加灵活,其实大致用法是一样的。
三、实现一个简单的阻塞队列
简简单单实现一个 1 V 1 的阻塞队列(即只有一个生产者和一个消费者)。
1、简单实现
public class MyArrayBlockingQueue {
private long[] array;
private int frontIndex; // 永远在队列的第一个元素位置
private int rearIndex; // 永远在队列的最后一个的下一个位置
private int size;
public MyArrayBlockingQueue(int capacity) {
array = new long[capacity];
frontIndex = 0;
rearIndex = 0;
size = 0;
}
public synchronized void put(long e) throws InterruptedException {
// 判断队列是否已经满了
while (array.length == size) {
this.wait();
}
// 预期:队列一定不是满的
array[rearIndex] = e;
rearIndex++;
if (rearIndex == array.length) {
rearIndex = 0;
}
size++;
//notify();
notifyAll();
}
public synchronized long take() throws InterruptedException {
while (size == 0) {
wait();
}
long e = array[frontIndex];
frontIndex++;
if (frontIndex == array.length) {
frontIndex = 0;
}
size--;
//notify();
notifyAll();
return e;
}
}
2、需要注意的细节
1、加锁保证操作的原子性
放元素和取元素的操作都不是原子的,所以要通过加锁来保证这两个操作的原子性。
2、while 循环判断队列是否为空或为满
当队列为空时线程进入阻塞状态(wait()),我们期待别的线程放进来元素然后唤醒当前阻塞线程;当队列为满时进入阻塞状态(wait()),我们期待别的线程别的线程拿走元素然后唤醒当前阻塞线程。但只有正常notify() 来唤醒才可以满足我们的期待或者要求,线程被中断、假唤醒、超时唤醒都不能到达我们的预期,所以此时用循环来判断队列的空满,可以避免假唤醒、线程中断唤醒以及超时唤醒的情况。
3、有BUG
但是这个代码有BUG哦,因为我们的预期是生产者唤醒消费者,但是由于 notify() 是随机唤醒的,做不到精确唤醒,所以当生产者消费者很多而队列容量很小时,很有可能出现等待集全是消费者,或者全是生产者等等一系列的极端情况,所以修改这个BUG最简单的做法就是使用notifyAll(),将所有等待线程都唤醒,即使生产者唤醒的是生产者,也通过**while(array.length == size)**让生产者继续 wait(),消费者也是同理。
官方用的是Condition,有时间可以去看看源码。
总结
学了一些关于锁的机制和应用:
- 机制1:锁+volatile
- 机制2:wait() 和 notify()
- 应用1:单例模式
- 应用2:生产者-消费者(阻塞队列的使用及实现细节)
明天见,拜拜🙋♀️