1.线程安全的单例模式
场景引入:
什么是线程安全?如何判断一个方法是线程安全的?方法只要加了锁就是线程安全的吗?
当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类是线程安全的。
并不是只要加了锁就是安全的,只有满足了那把锁的对象是锁同一个对象才是线程安全的。比如synchronized关键字中:
- synchronized修饰非static方法等同于synchronized(this){};
- synchronized修饰static方法等同于synchronized(xxx.class);
并发编程三要素(线程的安全性问题体现在):
- 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
出现线程安全问题的原因:
-
线程切换带来的原子性问题
-
缓存导致的可见性问题
-
编译优化带来的有序性问题
解决办法:
- JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
- synchronized、volatile、LOCK,可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
class Singleton{
private volatile static Singleton uniqueInstance;
private Singleton(){}
public Singleton getUniqueInstance(){
if(uniqueInstance == null) {
synchronized (Singleton.class) {
if(uniqueInstance == null){
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
问:为什么要两次判断
uniqueInstance == null
?
- 第一次判断对象是否已经创建实例化过,没有实例化过才进入加锁代码,否则直接返回实例(提高效率)
- 第二次的话,如果没有没这判断,假设两个线程t1、t2同时经过了第一次判断,进入了6、7行之间,这时t1获得锁,创建了一个实例刚好释放后,t2立即获得锁,就会再一次创建一个uniqueInstance。
问:为什么要使用volatile关键字呢?
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance,即调用invokespecial构造方法为其成员变量赋值
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。(使用了半初始化状态的对象)
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境 下也能正常运行。
2.手写一个死锁
场景引入:
什么是死锁?
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
比如,线程1 持有资源A,线程 2持有资源B,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
死锁有哪些产生条件?如何避免死锁?
- 互斥条件:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
- 请求与保持条件:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞
/**
* 手写死锁
*/
public class Main {
public static void main(String[] args) {
Object obj1 = new Object();
Object obj2 = new Object();
Thread t1 = new Thread(()->{
synchronized (obj1){
System.out.println("got obj1");
//为了保证实验发生,阻塞当前线程让给另个线程运行
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj2){
System.out.println("got obj2");
}
}
});
Thread t2 = new Thread(()->{
synchronized (obj2){
System.out.println("got obj2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj1){
System.out.println("got obj1");
}
}
});
t1.start();
t2.start();
}
}
3.手写一个生产者消费者模式
sleep() 和 wait() 有什么区别?
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里?
Java中,任何对象都可以作为锁,并且 wait(),notify()等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在Object类中。
wait(), notify()和 notifyAll()这些方法在同步代码块中调用
有的人会说,既然是线程放弃对象锁,那也可以把wait()定义在Thread类里面啊,新定义的线程继承于Thread类,也不需要重新定义wait()方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。
综上所述,wait()、notify()和notifyAll()方法要定义在Object类中。
为什么调用wait方法时要放在while中?
处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。
public class ProducerAndConsumer<T> {
private LinkedList<T> list = new LinkedList<>();
private static int MAX = 10;
private int size = 0;
public synchronized void put(T t) throws InterruptedException {
while(list.size() == MAX){
this.wait();
}
list.add(t);
size ++;
this.notifyAll(); //通知消费者线程可以消费了
}
public synchronized T get() throws InterruptedException {
T t = null;
while (list.size() == 0){
this.wait();
}
t = list.removeFirst();
size --;
this.notifyAll();
return t;
}
}