接上次博客:JavaEE(3)(由进程到线程、线程的调度、 进程线程的区别、Java 实现多线程编程、创建线程、Thread 类的其他使用方式、线程启动、中断线程、线程等待、获取当前程引用、休眠当前线程)_di-Dora的博客-CSDN博客
目录
线程的状态
线程安全(最复杂最重要)
产生线程安全的原因:
解决线程安全
synchronized 关键字
synchronized 关键字用的锁是存在Java对象头里的
synchronized 的重要特性——可重入
死锁(Deadlock)
解决死锁
volatile 关键字
wait 和 notify
wait( ) 方法
notify()方法
notifyAll()方法
多线程的代码案例
1、单例模式(非常经典的设计模型)
1、饿汉模式(Eager Initialization):
2、懒汉模式(Lazy Initialization):
懒汉模式-多线程版 :
2、阻塞队列
解耦合
削峰填谷
了解标准库的阻塞队列
自己实现阻塞队列
我们之前学进程的时候了解过进程的状态,知道它有就绪状态、阻塞状态等,这些对于线程来说同样适用,毕竟我们是以线程为单位进行调度的。
在Java 中,我们又给线程赋予了一些其他的状态:
NEW:表示线程已经被创建,但尚未启动(还未开始执行),即 start 方法还没被调用。
RUNNABLE:就绪状态,表示线程是可工作的,可以分成两种状态:
BLOCKED:表示线程被阻塞,通常是由于线程试图获取一个已被其他线程持有的锁而被阻塞,锁竞争。
WAITING 和 TIMED_WAITING:这两个状态都表示线程正在等待某些条件的发生,但有些差异:
TERMINATED:表示线程的工作已经完成,线程已经终止。此时内核中的线程已经没了,但是 Thread 对象还在。
如上,我们把三种不同的原因给拎出来了,这样后续我们定位“线程卡死”问题的时候,就很容易通过状态来初步确定卡死的原因。
package thread;
public class Demo12 {
public static void main(String[] args) {
Thread t = new Thread(()->{
while (true){
}
});
//在调用 start 之前获取状态,此时就是NEW状态
System.out.println(t.getState());
}
}
package thread;
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
/* while (true){
}*/
});
//在调用 start 之前获取状态,此时就是NEW状态
System.out.println(t.getState());
t.start();
t.join();
//在线程执行结束之后, 获取线程的状态, 此时是 TERMINATED 状态
System.out.println(t.getState());
}
}
在一个循环中,通过 t.getState() 多次获取线程状态,并打印出来。由于线程在不断循环运行,所以状态一直处于 RUNNABLE 状态。
package thread;
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true){
}
});
//在调用 start 之前获取状态,此时就是NEW状态
System.out.println(t.getState());
t.start();
for (int i = 0; i < 5; i++) {
System.out.println(t.getState());
Thread.sleep(1000);
}
t.join();
//在线程执行结束之后, 获取线程的状态, 将会是 TERMINATED 状态
//但是很显然,此时线程一直在运行,因为我们设定了一个死循环
System.out.println(t.getState());
}
}
package thread;
public class Demo12 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//在调用 start 之前获取状态,此时就是NEW状态
System.out.println(t.getState());
t.start();
for (int i = 0; i < 5; i++) {
System.out.println(t.getState());
Thread.sleep(1000);
}
t.join();
//在线程执行结束之后, 获取线程的状态, 此时是 TERMINATED 状态
System.out.println(t.getState());
}
}
最终,线程 “ t ” 在循环中不断休眠1秒,而且由于主线程需要等待线程 “ t ” 执行完毕后才会最后再获取一次线程的状态,而此时线程 “ t ” 一直是死循环的情况,所以我们不会看到 TERMINATED(终止)状态。
上述内容就是多线程里的四种状态,BLOCKED 和 WATING 以后再详细来说。
线程安全问题指的是在多线程环境下,多个线程并发访问共享资源时可能出现的问题和风险。这些问题主要源于多线程同时修改共享数据的情况,可能导致数据不一致、不确定性和不可预测的行为。
或者简单来说就是,有些代码在单个线程环境下执行是完全正确的,但是同样的代码让多个线程同时去执行,此时就有可能出现 bug ,这就是“线程安全” / “线程不安全”
举例来说:
package thread;
// 线程安全
public class Demo13 {
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的 count 就是个 0
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
}
我们预期的效果是两个线路各自自增5万,最终一共是10万。实际上这个结果和10万相差甚远。而且我们再多运行几次你会发现每次结果还不一样!
上述这样的情况就是非常典型的线程安全问题。 我们对顺序稍作调整:
t1.start();
t1.join();
t2.start();
t2.join();
这样就没问题了。因为这样改动后意味着当t1正在运行过程中,t2是不会启动的。虽然上述代码是写在两个线程中,但是这并不是“同时”执行的。
所以出现问题和“同时执行”关系很大。
站在CPU的角度,count++是由CPU通过三个指令来实现的:
如果是多个线程执行上述代码,由于线程之间的调度顺序是随机的,就会导致在有些调度顺序下,上述逻辑出现问题,
那么这两个线程执行count++,中间会产生多少总不同的执行顺序的情况?
答案是无数种!因为不只是
“线程1先执行,然后线程2执行;线程2先执行,然后线程1执行;交替执行……”这第一次count++的很多种情况,还包括“ t1 执行一次 count++的时候,t2 已经执行了两次count++; t2 执行一次 count++的时候,t1 已经执行了两次count++;……”这些无穷无尽的可能。
结合上述讨论,我们就意识到,在多线程程序中最困难的一点就是,线程的随即调度使两个线程执行逻辑的先后顺序存在诸多可能,我们必须要保证在所有可能的情况下,代码都是正确的……
我们先来一个一个的看看(方框是内存):
看里两个线程分别自增,最终结果是符合预期的。
结果就不正确了。我们预期得到2,但是这种情况只有1。这就相当于在自增过程中,两个线程的结果没有向上累加,而是各自独立运行。
事实上,在所有情况里,只有
和
可以满足我们预期。
由于我们也不知道这5万次自增的过程中有多少次是按照以上两种情况自增,又有多少次是不正确的自增……因此我们最终得到的就是一个随机值,并且这个随机值一定是小于10万的。
那么随机值是否是一定大于等于5万的?
不一定!也存在小于5万的情况。比如在错误的自增方法下,t1自增1次的过程中,t2自增了2次,相当于总共自增了3次,但是只有一次生效了:
(1) 一个线程针对一个变量修改 ok
(2) 两个线程针对不同变量修改 ok
(3) 两个线程针对一个变量读取 ok
要想解决线程安全,我们就需要从这几个原因一一着手解决。
1、调度随机性:这个是系统内核里面的,我们无能为力。最初搞多任务操作系统的大佬制定了“抢占式执行”的大的基调,在这个基调下,我们想要做出调整是非常困难的。
2、多个线程同时修改同一个变量:有些情况下我们可以通过调整代码结构来规避上述问题。但是也有很多情况下我们是调整不了的,比如count++的代码。
3、修改操作不是原子的:这个还是有办法的,我们可以使这个“三步走”变成原子的,避免出现操作穿插——“加锁”。
如何给Java中的代码加锁呢?
其中最常用的方法就是是用 synchronized 关键字。使用 synchronized 关键字是一种常见的方法来保护共享资源,使其操作变为原子操作,从而避免线程安全问题。 synchronized 关键字可以用在方法级别或代码块级别,以控制对共享资源的访问。
synchronized 关键字在使用的时候要搭配一个代码块{},进入 { 就会加锁,出了 } 就会解锁。在已经加锁的状态中,另一个线程尝试同样加这个锁就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待,一直等到前一个线程解锁为止。阻塞就避免了其中一个线程的操作和第一个线程的操作出现穿插,就可以形成类似“串行”执行的效果。线程安全就可以迎刃而解了。
在代码块级别,我们使用 synchronized 关键字来锁定一个对象,如下所示:
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
// 线程安全的操作
}
}
注意 synchronized 后面的()里需要表示一个用来加锁的对象,这个对象是啥并不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。
在这个示例中,我们创建了一个对象 lock ,然后在需要保护的代码块中使用 synchronized(lock) 来锁定这个对象。只有一个线程能够获得 lock 对象的锁,从而保证了代码块中的操作是原子的。
需要注意的是,过度使用 synchronized 可能会导致性能问题,因为它会引入锁竞争。因此,在设计多线程程序时,需要谨慎选择何时使用 synchronized ,以避免不必要的性能开销。
package thread;
// 线程安全
public class Demo13 {
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的 count 就是个 0
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
}
注意,但是如果创建了两个对象,那么就无法解决线程安全!!!
package thread;
// 线程安全
public class Demo13 {
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
synchronized (locker2) {
count++;
}
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的 count 就是个 0
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
}
当然,如果你很细心,或者你还记得我上次博客提到的内容,那么你可能会问:Object locker = new Object(); 这里为什么前面我们不加上“final”?
这是因为 Object locker = new Object(); 不加 final 也是可以正常工作的,因为 locker 变量在匿名内部类中被使用,但没有发生变化。在这种情况下,即使不将 locker 声明为 final ,它仍然可以被正确捕获。
在早期的 Java 版本中,内部类(包括匿名内部类)只能访问外部类的 final 局部变量。这是因为内部类的实例可能在外部方法执行完毕后仍然存在,如果没有 final 修饰符,编译器无法确保内部类对外部变量的访问是安全的。
然而,从 Java 8 开始,编译器可以自动推断局部变量是否是事实上的 final ,即变量的值不会发生更改。因此,你可以看到在现代的 Java 版本中,即使没有显式声明为 final ,只要局部变量的值不发生更改,仍然可以在匿名内部类中访问它们。
所以,Object locker 不加 “ final ” 在这个例子中也是可以的,因为它的值没有发生更改。然而,如果你要确保局部变量不会被修改,显式地将其声明为 final 仍然是一种良好的做法,可以增强代码的可读性和可维护性。
synchronized 关键字除了修饰代码块之外,还可以用来修饰一个实例方法或者静态方法。
在方法级别,我们可以直接将 synchronized 关键字添加到方法的定义中:
public synchronized void increment() {
// 线程安全的操作
}
这将使整个方法成为一个临界区,只有一个线程能够执行该方法,从而保证了操作的原子性。
我们用synchronized 关键字修饰一个实例方法或者静态方法,实际上就是将“ this”作为锁的对象了,此时就等价于如下代码:
public void increment() {
synchronized (this) {
count++;
}
}
如果是修饰静态方法,等价于针对类对象加锁。
这俩写法也是等价的,上面的写法相当于下面的写法的简化。
synchronized public static void increment() {
}
public static void increment2() {
synchronized (Counter.class) {
}
}
}
这里的Counter.class是一个类对象。
咱回顾一下吧,类对象(Class Object)是指在Java中表示类的实例对象。我们的一个.java 文件会 ---> .class 文件,然后JVM会把它加载到内存中,也就是我们的类对象。每个类在Java中都有一个对应的类对象,它包含了关于该类的各种信息,如类的结构、成员变量、方法、构造函数等。类对象可以用于获取有关类的信息,也可以用于创建该类的实例。类对象在一个Java进程中是唯一的。
在Java中,类对象是通过Java的反射机制来获取的。通过类对象,可以做以下操作:
我们之前在数据结构的反射那里讲过这个东西。反射本质上就是依靠类对象作为支撑的。
package thread;
class Counter {
public int count;
//以下四种只是对我们刚刚讲的做一个总结
synchronized public void increase() {
count++;
}
public void increase2() {
synchronized (this) {
count++;
}
}
synchronized public static void increase3() {
}
public static void increase4() {
synchronized (Counter.class) {
}
}
}
// synchronized 使用方法
public class Demo14 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
再次强调,这个锁对象是谁不重要,重要的是两个线程中的锁对象是否是同一个对象。
我们来讲一下 synchronized 关键字 和 锁 的原理:
在Java中,synchronized 关键字用于实现线程同步,确保多个线程对共享资源的访问是安全的。锁是用来控制对被锁定对象的访问的。
每个Java对象都有一个内置的锁(也称为监视器锁或互斥锁),它存储在对象头(Object Header)中。
Java对象头包含了一些用于管理对象的元信息,其中包括用于锁定的信息。在默认情况下,每个Java对象都有一个关联的锁,称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。当一个线程进入一个 synchronized 方法或代码块时,它会尝试获取对象的内置锁。如果锁已经被其他线程占用,那么线程将被阻塞,直到锁被释放。
内置锁的作用范围是对象级别的,而不是方法或代码块级别的。这意味着多个线程可以同时访问同一个对象的不同 synchronized 方法,但同一时间只能有一个线程执行其中一个 synchronized 方法。
public class MyClass {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
在上面的示例中,两个 synchronized 方法(increment 和 getCount)都使用了对象的内置锁,以确保对 count 变量的访问是线程安全的。
总之,synchronized 关键字用的锁是存在Java对象头中的内置锁,它用于协调多个线程对共享资源的访问,以确保线程安全。
所谓的可重入,所指的是一个线程连续针对同一把锁,加锁两次,不会出现死锁。满足这个要求就是“可重入”,不满足就是“不可重入”
或者说得明白一点,“可重入性"是指一个线程在持有锁的情况下,可以再次进入同一个锁的临界区而不会被阻塞,也不会导致死锁。
这是 synchronized 关键字的一个重要特性,也叫做重入性(Reentrancy)。
重入性的概念是为了解决一个线程在执行一个被 synchronized 修饰的方法或代码块时,如果方法或代码块内部还有其他 synchronized 方法或代码块,该线程可以继续获得锁,而不会被阻塞。这使得代码可以更加灵活地组织,可以在一个方法内调用另一个 synchronized 方法而不用担心死锁。
这里我们先来解释一下“死锁”这个含义:
假设 t 线程中存在下列代码:
synchronized (locker){
synchronized (locker){
//...
}
}
第一次加锁,假设能够加锁成功,此时 locker 就处于是被锁定的状态,然后我们进行第二次加锁,很明显 locker 已经是锁定的状态了,所以第二次的加锁操作,原则上来说是应该要“阻塞等待”的。 我们应该要等待锁被释放之后才能加锁成功。
但是实际上,一旦第二次加锁的时候“阻塞”了,就会出现死锁的情况。换句话说,就是线程卡死了。
从代码的角度来看,
第二次要想加锁成功,就需要第一次的枷锁释放锁;
而第一次加锁要想释放锁,就需要执行到 } ;
而要执行到 } ,就需要让第二次加锁能够成功代码,才能继续执行;
由于第二次加锁导致代码阻塞住了,当前就没有办法执行到 } ,也就无法释放锁了。
综上,这就导致了一个死锁的情况。
上述现象很明显是 bug,但是日常开发中又难以避免上述代码,比如:
package thread;
public class Demo15 {
private static Object locker = new Object();
public static void func1() {
synchronized (locker) {
func2();
}
}
public static void func2() {
func3();
}
public static void func3() {
func4();
}
public static void func4() {
synchronized (locker) {
}
}
public static void main(String[] args) {
}
}
而我们把 synchronized 设计成 “可重入锁” 就可以有效的解决上述死锁问题。
“可重入锁”是怎么实现的呢?
我们让锁记录一下是哪个线程给它锁住的,后续再加锁的时候,如果加锁线程就是持有锁的进程,就直接加锁成功。
如果还是这个代码:
synchronized (locker){
synchronized (locker){
//...
}
}
但是现在 synchronized 是可重入锁,没有因为第二次加锁而死锁,那么当代码执行到中间大括号的 } 的时候,此时锁是否应该释放?
不可以提前释放!因为两个 } 之间可能还有很多代码,如果提前释放,那么这些代码都将无法受到锁的保护,可能就不再线程安全了。
进一步的,如果上述加锁过程有 N 层释放,我们该如何判定时机呢?
无论有多少层,我们都要在最外层才能够释放锁。
但是你要怎么判定我们现在已经到了最最外面那一层?这就要引入一个概念——“引用计数”。
锁对象中,不光要记录谁拿到了锁,还要记录锁被加了几次。每加锁一次,计数器就+1,每解锁一次,计数器就-1。出了最后一个大括号,恰好就是0了,才能够真正地释放锁。
其实,我们刚刚提到的这个只是“死锁”的其中一种情况。接下来我们还要去认识一下其他的“死锁”情况。
1、一个线程,针对一把锁,连续加锁两次。如果是不可重入锁,就死锁了(如上);
2、两个线程,两把锁,此时无论是不是可重入锁,都会死锁;
总结一下就是,两个线程(t1和t2)尝试获取两个不同的锁(locker1和locker2),但它们获取锁的顺序不一样,导致彼此互相等待对方释放锁,从而陷入了无法继续执行的状态。
想象一对情侣正在为了一台电视遥控器争吵。他们坐在沙发的两端,每个人都有自己的电视遥控器和电视机。他们想要看对方手里的电视节目,但只有对方手里的遥控器可以切换频道。
情侣A(线程t1)拿着自己的遥控器,希望通过情侣B手里的遥控器来切换频道。情侣B(线程t2)也拿着自己的遥控器,希望通过情侣A手里的遥控器来切换频道。
现在,两个人都在等待对方放下手里的遥控器,以便自己能够切换频道。他们都不肯主动放下手里的遥控器,因为他们想要看对方的节目。这导致了一个僵局,两人都无法切换频道,而且他们也不肯放弃自己手里的遥控器。
这个情景类比了死锁的情况,其中两个线程(情侣A和情侣B)都在等待对方释放资源,导致了程序的停滞,就像情侣们无法切换电视节目一样。这个例子强调了死锁的本质:多个线程相互等待对方释放资源,最终导致无法继续执行。
我们写代码的时候记得要让进程休眠一下,不然进程 t1 把两把锁都锁上了,t2 还在旁边傻乎乎的看着呢。
package thread;
public class Demo16 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
// 此处的 sleep 很重要. 要确保 t1 和 t2 都分别拿到一把锁之后, 再进行后续动作.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) {
System.out.println("t1 加锁成功!");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) {
System.out.println("t2 加锁成功!");
}
}
});
t1.start();
t2.start();
}
}
上述代码很明显啥都打印不出来,两个线程都没有成功获取第二把锁,只能干瞪眼。
为了观察,我们打开 jconsole 来看看:
需要注意的是,上述死锁代码中,两个 synchronized 是嵌套关系,不是并列关系。这说明是在占用一把锁的前提下获取另一把锁。
而并列关系则是先释放前面的锁,再获取下一把锁(不会死锁)。
3、N 个线程,M 把锁(相当于第2种情况的扩充),此时,就更加容易出现死锁的情况了。
有一个经典的描述 “N 个线程,M 把锁死锁” 的模型——哲学家就餐问题。
假设有五位哲学家坐在一个圆桌周围,每位哲学家都需要进行思考和用餐。在圆桌上有五把椅子,每两把椅子之间放置了一把餐具。哲学家要进行如下活动:
思考:哲学家可以思考一段时间,不需要使用餐具。在思考期间,哲学家不占用餐具和椅子。
用餐:哲学家需要两把餐具才能用餐。他们可以同时拿起左边和右边的餐具,然后用餐。此时相邻的哲学家如果也想用餐,就需要阻塞等待。
当然,还有一些规则:哲学家啥时候用餐,啥时候思考都是随机的,而且哲学家用餐的时长是不确定的。
哲学家就餐问题的挑战在于如何避免死锁。如果所有哲学家同时拿起左边的餐具,然后等待右边的餐具,那么就会发生死锁,因为每位哲学家都无法释放左边的餐具,而等待右边的餐具。
为了解决这个问题,可以使用各种同步机制,例如信号量或互斥锁,来确保哲学家用餐时不会发生死锁。这些机制可以规定哲学家只有在同时拿到左边和右边的餐具时才能用餐,否则就必须放下已经拿到的餐具,等待其他哲学家用餐完毕后再次尝试。
看了这么多“死锁”的情况,不得不说,死锁属于是比较严重的 bug,它会直接导致线程卡住,也就无法执行后续的工作了。
那么我们应该如何解决/避免死锁呢?
死锁的成因涉及到四个必要条件:
互斥条件(Mutual Exclusion):至少有一个资源是独占的,即只能由一个线程使用。如果多个线程同时请求这个资源,其中一个必须等待。(当一个线程持有一把锁之后,另一个线程也想要获取到锁,就要阻塞等待。)【锁的基本特性】
请求和保持条件(Hold and Wait):线程已经持有至少一个资源,但同时还在请求其他资源,而且不释放已经持有的资源。(先拿到锁1,再尝试获取锁2,尝试的过程种不释放锁1。)【与代码结构有关】
不可剥夺条件(No Preemption):资源不能被强行剥夺,只能由持有它的线程主动释放。(当锁已经被线程1拿到之后,线程2只能等线程1主动释放,不能强行抢过来。)【锁的基本特性】
循环等待条件(Circular Wait):存在一种循环等待的情况,每个线程都在等待另一个线程所持有的资源。(等待的依赖关系,形成环了)【与代码结构有关】
因为是必要条件,所以其实要想出现死锁也不是一个容易的事情,它必须满足上述四个条件。
那么,由这四条必要条件,我们可以倒推逻辑,解决死锁,核心就是要破坏上述必要条件。只要破坏一个,死锁就形成不了了。
不幸的是,1 和 3 都是锁的基本特性。
破坏哪一个呢?显然不会是 1 和 3。
随机挑选一个幸运观众,破坏第2条好了,破坏掉它的嵌套结构,改为并列即可:
package thread;
public class Demo16 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
// 此处的 sleep 很重要. 要确保 t1 和 t2 都分别拿到一把锁之后, 再进行后续动作.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2) {
System.out.println("t1 加锁成功!");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2) {
System.out.println("t2 加锁成功!");
}
});
t1.start();
t2.start();
}
}
但是这个 2 不一定好使,因为有的时候就是要求我们获取多个锁以后再操作。
对 4 来说,我们可以通过约定加锁的顺序,避免循环等待。 比如针对锁进行编号,然后约定加多把锁的时候先加编号小的锁,再加编号大的锁。要求所有线程都遵守这个规定。
那么将餐具的编好号后,哲学家拿起自己左右手旁边编号偏小的,然后等待右手的餐具归位。当最后一位哲学家发现自己左边是 5 ,右边是 1,本来应该拿 1 ,但是 1 被第一位哲学家拿走了,那么最后一位哲学家只能等待。这个时候他旁边的那个倒数第二个哲学家就刚好可以拿到两套餐具,然后用餐。等他用完餐,他旁边的倒数第三个哲学家就又可以开始用餐了……第一个哲学家用完餐后最后一个哲学家终于得以开始用餐。此时循环等待就被破除了。
package Thread;
class Philosopher implements Runnable {
private final int id;
private final Object leftFork;
private final Object rightFork;
public Philosopher(int id, Object leftFork, Object rightFork) {
this.id = id;
this.leftFork = leftFork;
this.rightFork = rightFork;
}
private void think() {
System.out.println("Philosopher " + id + " is thinking.");
}
private void eat() {
System.out.println("Philosopher " + id + " is eating.");
}
@Override
public void run() {
try {
while (true) {
think();
// 拿起左边的餐具(锁)
synchronized (leftFork) {
System.out.println("Philosopher " + id + " picked up left fork.");
// 拿起右边的餐具(锁)
synchronized (rightFork) {
System.out.println("Philosopher " + id + " picked up right fork.");
eat();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class DiningPhilosophers {
private static final int NUM_PHILOSOPHERS = 5;
public static void main(String[] args) {
// 创建哲学家和餐具数组
Philosopher[] philosophers = new Philosopher[NUM_PHILOSOPHERS];
Object[] forks = new Object[NUM_PHILOSOPHERS];
// 初始化每个餐具(锁)
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
forks[i] = new Object();
}
// 创建并启动每个哲学家线程
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
Object leftFork = forks[i];
Object rightFork = forks[(i + 1) % NUM_PHILOSOPHERS];
// 为每个哲学家分配唯一的ID
int philosopherId = i + 1;
philosophers[i] = new Philosopher(philosopherId, leftFork, rightFork);
Thread t = new Thread(philosophers[i]);
t.start();
}
}
}
在这个程序中,虽然哲学家的线程是按照循环方式创建的,但线程的执行顺序是由操作系统的线程调度器决定的,而不是按照线程创建的顺序。因此,哲学家线程的启动顺序可能是随机的,它们之间的相对执行顺序也是不确定的。
即使我们已经按照一定顺序创建了哲学家线程,操作系统的线程调度机制仍然会决定哪个线程何时获得执行时间。这可能会导致不同哲学家在不同的时间点尝试获取餐具(锁),因此哲学家的行为可能会交织在一起,而不是按照编号顺序依次执行。
所以就像我们之前说的那样,在并发程序中,线程的执行顺序通常是不确定的,因此需要使用同步机制来确保线程安全,而不依赖于线程的启动顺序。在这个程序中,通过使用锁对象来保证哲学家按照规定的方式获取餐具,从而避免了死锁情况。虽然线程启动的顺序是不确定的,但通过适当的同步,我们可以确保程序的正确性。
还有一个“银行家算法”来解决死锁问题,但是这个方案比较复杂且并不实用。
我们可以简单看一下:
银行家算法(Banker's Algorithm)是一种用于避免死锁的算法,特别适用于多进程共享有限资源的环境,如操作系统中的进程管理。它的主要目标是确保系统能够分配资源以避免进入不可恢复的死锁状态。
银行家算法基于以下原则:
每个进程在启动时必须声明其最大资源需求和当前已分配的资源数量。
系统维护一个可用资源向量,表示系统当前可用的每种资源数量。
当进程请求资源时,系统会检查是否可以满足该请求,如果满足,则分配资源;否则,将进程阻塞,直到资源可用或放弃请求。
当进程释放资源时,系统会更新可用资源向量,并唤醒等待的进程。
系统周期性地检查是否有进程可以满足其最大需求,如果可以,就分配资源,否则保持等待状态。
银行家算法通过检查资源分配状态和每个进程的最大需求来确保系统的安全性,从而避免死锁。如果分配资源会导致系统不再安全,系统会拒绝分配,直到分配不会引发死锁为止。
这个算法的名字来源于类比,系统就像一个银行家一样,负责分配资源,确保资源的合理分配,以避免进入资源不足的死锁状态。
虽然银行家算法可以有效地避免死锁,但它也有一些限制,如需要提前知道进程的最大需求、不支持动态分配等。因此,它主要用于一些特定的环境中,例如操作系统中的资源管理。
volatile 关键字是 Java 中的关键字,它主要用于两个方面:
保证内存可见性: 当一个变量被声明为volatile 时,多个线程在访问这个变量时会从主内存中读取其最新值,而不会使用线程自己的本地缓存。这确保了当一个线程修改了 volatile 变量的值后,其他线程可以立即看到这个变化,从而保证了多线程之间对变量的可见性。这对于需要多线程协同工作的场景非常重要。
禁止指令重排序:volatile 还可以防止指令重排序。在 Java 中,编译器和处理器可能会对指令进行重排序以提高性能,但这可能会导致多线程程序中的问题。使用 volatile 修饰的变量会告诉编译器和处理器不要对其进行重排序。这对于需要保持一定的执行顺序的代码块非常重要。
我们先来看一下“保存内存可见性”:
我们已经知道,计算机运行的程序/代码,经常要访问数据。这些被依赖的数据往往都会存储在内存中。
比如我们定义一个变量,变量就是在内存中。 CPU使用这个变量的时候,就会把这个内存中的数据先读取出来,放到CPU的寄存器中,然后再参与运算(load)。
但是CPU读取内存的这个操作其实是非常慢的!(当然,我们这里说的“慢”是相对的,读内存相比于读硬盘快上几千倍、几万倍;读寄存器相比于读内存又要快上几千倍、几万倍)
所以CPU进行大部分的操作都很快,但是一旦操作到读/写内存的时候,速度就一下子降下来了。
为了解决上述问题、提高效率,此时编译器就可能对代码做出优化,从而把一些本来要读内存的操作优化成读取寄存器,减少读内存的次数,也就可以提高整体程序的效率了。
先来看个代码:
package thread;
import java.util.Scanner;
public class Demo17 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
// 循环体里啥都没干.
// 此时意味着这个循环, 一秒钟就会执行很多很多次.
}
System.out.println("t1 退出!");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入 isQuit: ");
Scanner scanner = new Scanner(System.in);
// 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
isQuit = scanner.nextInt();
});
t2.start();
}
}
程序一直无法结束,不符合我们的预期,出现了线程安全问题!
而且你会发现这个项目的状态是:RUNNABLE
为什么会出现这种问题呢?
此处的问题就是“内存可见性”情况引起的!
1、load 读取内存中 isQuit 的值到寄存器中;
2、通过 cmp 指令比较寄存器的值是否是0,来决定是否要继续循环;
由于这个循环的速度飞快,短时间内就会进行大量的循环,也就是大量的 load 和 cmp 操作。
此时,编译器/JVM 就发现了,虽然进行了那么多次 load ,但是每次的结果都是相同的。然后 load 这个操作又是非常费时间的,一次 load 操作相当于上万次 cmp 了。
所以这个时候编译器就做了一个大胆的决定——只第一次循环的时候才读取内存了,后续都不再读取内存了,而是直接从寄存器中取出 isQuit 的值。
编译器的初心是好的,它希望能够提高程序的效率,但是提高效率的前提是保证逻辑不变!
此时由于修改 isQuit 的值的是另一个线程的操作,所以编译器就没有正确的判定(后续,t2 修改 isQuit 的值之后,t1 感知不到 isQuit 的变量的变化/感知不到内存的变化)。它认为没人修改 isQuit 的值,所以就做出了上述优化。进而引起了 bug 的出现。
这个问题就称为“内存可见性”问题。
怎么解决呢?
volatile 就是解决方案:
在多线程环境下,编译器对于是否要进行这样的优化的判定不一定准确,此时我们就需要通过volatile 关键字来告诉编译器——“你不要优化!”(编译器也不是万能的,也会有一些自己短板的地方,此时就需要我们自己补充了)。只需要给 isQuit 加上volatile 关键字修饰,此时编译器就会禁止上述优化过程。
package thread;
import java.util.Scanner;
public class Demo17 {
private static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
// 循环体里啥都没干.
// 此时意味着这个循环, 一秒钟就会执行很多很多次.
}
System.out.println("t1 退出!");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入 isQuit: ");
Scanner scanner = new Scanner(System.in);
// 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
isQuit = scanner.nextInt();
});
t2.start();
}
}
此时,程序就可以顺利退出了:
当然,还有一个办法:
package thread;
import java.util.Scanner;
public class Demo17 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
// 循环体里啥都没干.
// 此时意味着这个循环, 一秒钟就会执行很多很多次.
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 退出!");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入 isQuit: ");
Scanner scanner = new Scanner(System.in);
// 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
isQuit = scanner.nextInt();
});
t2.start();
}
}
此时我们没有加 volatile ,但是我们给循环里面加了一个 sleep,此时线程是可以顺利退出的。
究其原因,加了 sleep之后,while 循环执行速度就慢了。由于次数少了,load 操作的开销就不呢么大了,因此优化也就没有必要进行了。
既然没有触发优化,也就不会存在内存可见性问题了。
但是,什么时候代码有优化,什么时候又没有?我们也说不清啊。
所以最靠谱的方法还是使用关键字 volatile。
关于内存可见性,还涉及到一个关键概念:
Java内存模型(Java Memory Model,简称JMM,JMM是Java规范文档上的叫法)是确保多线程程序正确执行的关键概念之一,它将存储空间划分为:主内存 (就是我们平时说的内存) 和 工作内存(CPU寄存器和缓存)。:
Java内存模型(JMM):
内存可见性:
线程工作内存:
上述代码中 t1 线程对应的 isQuit 变量,本身是在主内存中的。由于此处的优化,会把 isQuit 变量放到工作内存中。进一步的 t2 修改主页的 isQuit 变量,不会影响到 t1 的工作内存。volatile 保证了 t1 线程在读取 isQuit 变量时会直接从主内存中获取其最新值,而不会使用工作内存中的缓存值。这确保了 t1 线程能够立即看到 t2 线程对 isQuit 变量的修改,从而正确地退出循环。
需要注意的是,volatile 只能保证单个变量的原子性操作,而不能保证复合操作的原子性。如果需要进行复合操作的原子性保证,可以考虑使用 synchronized 或 java. util. concurrent 包中提供的锁机制。
volatile 和 synchronized 都能对线程安全起到一定的积极作用,但是它们是各司其职的。一定记得 volatile 是不能保证原子性的。
volatile:
synchronized:
当说“ volatile不能保证原子性”时,我指的是,虽然volatile可以确保变量的可见性,但它不能保证复合操作(比如递增、递减、检查然后设置等)在多线程环境中的原子性。这意味着,即使一个变量被声明为volatile,在多个线程同时尝试修改它时,仍然可能发生竞态条件(race condition)。
还是用我们的 count++操作:
我们这里把 counter 设置成 volatile变量,确保了可见性,但在多线程情况下,以下情况仍然可能发生:
这就是竞态条件的例子,尽管 counter 是 volatile 的,但仍然无法保证递增操作的原子性。
多线程中一个比较重要的机制,它门是用于协调多个线程的执行顺序的。
本身多个线程的执行顺序是随机的(系统随即调度,抢占式执行的)。很多时候我们是希望能够通过一定的手段来协调执行顺序。
我们之前学的 join 是影响到线程结束的先后顺序。相比之下,此处是希望线程不结束,也能够有先后顺序的控制。
wait 和 notify 则用于线程之间的协作和同步。这两个方法通常与 synchronized 关键字一起使用,以确保线程之间按照特定的顺序执行或共享资源。
wait:
notify:
注意: wait, notify, notifyAll 都是 Object 类的方法。
这些方法的主要用途之一是实现线程间的协作,例如生产者-消费者问题,其中生产者线程在生产了数据后通知消费者线程来消费。另一个常见的用途是等待特定条件的发生,例如等待某个共享资源变为可用。
public class MyThread {
private boolean flag = false;
public synchronized void waitForFlagChange() throws InterruptedException {
while (!flag) {
wait(); // 当flag为false时,线程等待
}
// 执行其他操作
}
public synchronized void setFlag() {
flag = true;
notify(); // 唤醒等待的线程
}
}
package thread;
public class Demo19 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait 之前");
object.wait();
System.out.println("wait 之后");
}
}
为什么会抛出这个异常?
我们要明确,wait 要做的三件事情:
1、使当前执行代码的线程进行等待.(把线程放到等待队列中) :
wait方法会将当前线程置于等待状态,并将其放入对象的等待队列中,直到其他线程调用了相同对象上的notify或notifyAll方法,或者等待时间超时,或者线程被中断。
synchronized 加锁其实就是把对象头的标记进行操作了。但是解锁的前提是先加上锁。
public class Demo19 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object){
System.out.println("wait 之前");
//把 wait 放到synchronized里面来调用,保证确实拿到锁了
object.wait();
System.out.println("wait 之后");
}
}
}
此时,wait 会持续的阻塞等待下去,直到其他线程调用 notify 唤醒,
此处就是处于 WAITING状态。
2、释放当前的锁 :当线程调用wait方法时,它会释放当前持有的锁,允许其他线程进入同步块(synchronized块),这是为了避免死锁的情况发生。
3、满足一定条件时被唤醒, 重新尝试获取这个锁:
线程可以被唤醒的条件:
注意,wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常。
通常,wait方法应该在synchronized块内使用,以确保线程安全。因为wait会释放锁,然后进入等待状态。这是为了让其他线程有机会获得相同的锁并继续执行,以避免死锁的情况发生,导致线程之间的相互阻塞。如果不在synchronized块内使用,首先释放锁的条件是有锁,其次可能会导致竞态条件(竞态条件是一种多线程环境下的问题,其中线程的执行顺序和操作的时机会影响程序的行为,可能导致不一致或错误的结果)或非预期的行为。
wait 除了默认的不带参数的版本,还有一个带参数的版本,用于设置等待超时时间,避免 wait 无休止的等待下去。
不带参数的 wait 方法:
带参数的 wait 方法:
notify 方法是唤醒等待的线程。notify()方法用于通知等待在同一个对象上的其他线程,告诉它们可以继续执行了。这些线程必须先调用了该对象上的wait()方法,进入了等待状态。
调用位置:notify()方法应该在同步方法或同步块中调用,因为它必须在持有对象锁的情况下才能执行。如果在没有持有对象锁的情况下尝试调用notify(),会导致IllegalMonitorStateException异常。
唤醒哪个线程:如果有多个线程等待某个对象上的锁,调用notify()方法会唤醒其中一个呈 wait 状态的线程。哪一个线程被唤醒是不确定的,取决于线程调度器的策略。这是随机选择的,不保证"先来后到"。
释放对象锁:在执行notify()方法之后,当前线程不会立即释放对象锁。它会继续执行同步块内的代码,直到退出同步块才会释放对象锁。这确保了在唤醒线程之前,当前线程可以执行必要的操作,以免竞争条件的发生。
package thread;
public class Demo20 {
public static void main(String[] args) {
Object object=new Object();
Thread t1 = new Thread(()->{
synchronized (object){
System.out.println("wait 之前");
//把 wait 放到synchronized里面来调用,保证确实拿到锁了
try {
object.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait 之后");
}
});
Thread t2=new Thread(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (object){
System.out.println("进行通知");
object.notify();
}
});
t1.start();
t2.start();
}
}
使用 wait 和 notify 也可以避免“线程饿死”:
使用 wait 和 notify 可以有助于避免线程饥饿(Thread Starvation)的问题,这是一种多线程执行时的不公平情况,其中某些线程可能因为长时间等待而无法获得执行机会,即使它们具备执行条件。
解释一下就是,多个线程在等待进入CPU,里面已经有了一个进程 t1 ,此时因为 t1 在里面,所以加了锁,别的线程无法进入。这个时候 t1 释放锁了,所有进程都想要抢占CPU,但是 t1 也拥有抢占资格,而且由于它原来已经在CPU上了,而其他线程想去CPU还需要一个系统调度的过程,所以 t1 大概率又可以到达CPU。其他线程就处于长时间等待状态无法到达CPU。
线程饥饿问题:
使用 wait 和 notify 来避免饥饿:
例如,考虑一个生产者——消费者问题,在这个问题中,生产者线程需要等待直到缓冲区不为空,消费者线程需要等待直到缓冲区不满。通过使用 wait 和 notifyAll ,可以实现一个更公平的执行顺序,确保等待线程在条件满足时能够及时获得执行机会,避免了某些线程长时间等待的问题。
public class MyThread {
private boolean flag = false;
public synchronized void waitForFlagChange() throws InterruptedException {
while (!flag) {
wait(); // 当flag为false时,线程等待
}
// 执行其他操作
}
public synchronized void setFlag() {
flag = true;
notify(); // 唤醒等待的线程
}
}
总之, wait 和 notify 可以用来改善线程饥饿问题,通过明确地控制线程的等待和唤醒,以确保等待线程在合适的时机得以执行,从而提高多线程程序的公平性和效率。
调用 wait 不一定就只有一个线程调用,N 个线程都可以调用。此时,当有多个线程都调用的时候,这些线程都会进入阻塞状态。唤醒的时候也就有两种方式了。
notify 和 notifyAll:
调用 wait 的线程:
唤醒过程的串行性:
虽然我们提供了notifyAll()方法,但是notify()方法更可控,我们用的更多一点。
设计模式是一种在软件开发中广泛使用的解决特定问题的通用设计方案或模板。它们是经过多年经验总结和验证的,可以帮助开发者解决常见的设计和编程问题,并提供了一种通用的方法来构建可维护和可扩展的软件系统。
单例模式是设计模式中的一种,它的主要目的是确保某个类在应用程序中只有一个唯一的实例,而不会创建出多个实例。以便全局访问。这种模式在以下情况下非常有用:
单例这个事情,还需要设计模式吗?我们写代码的时候只给一个类 new 一个对象不就好了?
还是有必要的!因为人是不靠谱的,需要让编译器把那我们做监督,强制要求一下,确保这个对象不会出现多个,出现的时候直接编译报错,减少了人为错误的机会。语法层面上没有对单例做出支持,我们就只能通过一些编程技巧来达成类似的效果了。
单例模式具有以下优点:
确保唯一性:单例模式确保了在整个应用程序生命周期内只有一个实例存在,无论多少次尝试创建它,都只会返回同一个实例。这是通过限制构造函数的访问性、延迟实例化等方式来实现的。
全局可访问性:通过单例模式,我们可以轻松地从应用程序的任何地方访问该实例,而无需手动传递它。
懒加载:许多单例模式的实现都采用懒加载方式,即在需要时才创建实例。这有助于节省资源,因为在应用程序启动时不会创建不必要的实例。
线程安全性:经过正确设计的单例模式可以确保在多线程环境中只创建一个实例,并保证线程安全性。
可维护性:通过将单例实例的创建和管理放在一个单独的类中,可以提高代码的可维护性和可读性。
全局配置:单例模式常用于全局配置对象(例如日志记录器、数据库连接池、应用程序配置等)的管理,以确保整个应用程序使用相同的配置。
单例模式有两种主要的实现方式:
在类加载时就创建实例。这意味着无论是否需要该实例,它都会被创建。这种方式简单明了,但可能会浪费资源,因为可能永远不会使用这个实例。
package thread;
//期望这个类能够有唯一实例
class Singleton{
private static Singleton instance = new Singleton();
//通过这个方法获取到刚才的实例
//后续如果想要使用这个类的实例,都通过这个方法获取
public static Singleton getInstance(){
return instance;
}
//把构造方法设置为私有,此时类外面的其他代码,就无法 new 出这个类的对象了
private Singleton() { }
}
public class Demo21 {
public static void main(String[] args) {
//此处又有一个实例了,这不就不是单例了?
Singleton s1 = new Singleton(); //会报错
}
}
1、在类的内部,提供一个现成的实例;
2、把构造方法设为 private ,避免其他代码能够创建出实例。
通过上述方式,强制了其他程序员在使用这个类的时候,就不会创建出多个对象。
借助现有的语法规则,精妙的设置代码,使这些代码可以达到强制检查这样的要求。
package thread;
//期望这个类能够有唯一实例
class Singleton{
private static Singleton instance = new Singleton();
//通过这个方法获取到刚才的实例
//后续如果想要使用这个类的实例,都通过这个方法获取
public static Singleton getInstance(){
return instance;
}
//把构造方法设置为私有,此时类外面的其他代码,就无法 new 出这个类的对象了
private Singleton() { }
}
public class Demo21 {
public static void main(String[] args) {
//此处又有一个实例了,这不就不是单例了?
//Singleton s1 = new Singleton(); //会报错
//即使调用多次,都是同一个对象
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1==s2);
}
}
这里的创建时机是在类加载的时候,是在比较早的时候。 我们形象的把这个模式称为“饿汉模式”。
对应的还有一个“懒汉模式”,它就比较从容,在第一次使用的时候再去创建实例。
在类加载时不创建实例,只有在首次使用时才创建实例。这种方式避免了不必要的实例化,但需要考虑线程安全性,以确保在多线程环境中不会创建多个实例。
“懒汉模式”虽然听名字不咋好听,但是其实使一种更加高效率的做法,因为像“饿汉”,它不管会不会真的用到这个实例,只要你写了它就创建;但是“懒汉”只有当你真正调用的时候才会给你创建出实例。
像是“文本编辑器——记事本”也分为两种情况:
比如我们现在需要打开一个非常大的文件(10GB):
一种记事本:先把所有的内容都加载到内存中,然后再显示内容,但是加载的过程会很慢;
另一种记事本:只加载一小部分数据到内存,立即就可以显示内容,随着用户的翻页再动态地加载其他内容。
现在我要提一个问题:
我们刚刚写了“饿汉模式”和“懒汉模式”两种单例的写法,但是这两种写法是否是线程安全的?
或者这样问,如果是多个线程,同时调用 getInstance,是否会出问题?
答案是,这里面“饿汉模式”是安全的,“懒汉模式”是不安全的。
如果多个线程同时修改同一个变量,此时就可能出现线程安全问题;
而如果多个线程同时读取同一个变量,这个时候就不会有线程安全问题。
“饿汉模式”只是读取,不进行修改:
public static Singleton getInstance(){
return instance;
}
而“懒汉模式”既会读取,也会修改:
public static SingletonLazy getInstance(){
if(instance==null){
instance = new SingletonLazy();
}
return instance;
}
上面的懒汉模式的实现是线程不安全的。
(线程安全问题发生在首次创建实例时,如果在多个线程中同时调用 getInstance 方法,就可能导致创建出多个实例。一旦实例已经创建好了, 后面在多线程环境调用 getInstance 就不再有线程安全问题了(不再修改 instance 了))(
首次创建实例的线程安全问题:在这种懒汉式单例模式中,当多个线程同时调用 getInstance 方法时,它们都会检查 instance 是否为null。如果在这个检查之后,有一个线程通过了这个条件判断并创建了一个新的实例,而其他线程还没有来得及执行创建,那么就会导致多个实例的创建,违反了单例模式的要求。这是因为多线程环境下,多个线程可以同时进入 if 语句块内,然后各自创建一个实例。
后续调用的线程安全:一旦一个实例被成功创建并赋值给 instance 变量,后续的多线程调用 getInstance 方法就不再有线程安全问题了。这是因为 instance 变量已经不再为null,所以线程不会再进入if语句块内创建新的实例。此后,所有的线程都会返回已经创建好的实例,而不会再次创建。
)
那么我们该如何保证“懒汉模式”是线程安全的?——加锁,加上 synchronized 可以改善这里的线程安全问题
但是这个锁该怎么加?加到哪里合适?
所以我们应该加到这里:把 if 和 new 合并成一个整体,此时线程安全问题就得到了改善。
class SingletonLazy{
private static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
synchronized (SingletonLazy.class){
if(instance==null) {
instance = new SingletonLazy();
}
}
return instance;
}
private SingletonLazy() { }
}
但是当我们的代码这样写了,就又引入了一个新的问题:
一旦代码这么写,后续我们每次调用 getInstance 都需要先加锁了。
实际上,“懒汉模式”的线程安全问题只出现在最开始的时候(对象还没有new的时候),一旦对象 new 出来,不修改只读,就不会线程不安全了,这个时候,if 语句就进不去了,直接 return,所以 if 和 return 都只涉及到读操作,不涉及修改,天然就是线程安全的。
总结来说就是,单例模式的代码,只是在首次使用的时候,会涉及到线程不安全的问题。
我们现在把锁加在这里,前面是安全了,但是后面本来就安全的地方你也加了锁,这就有些画蛇添足了。加锁又是一个开销很大的操作,可能会涉及到锁冲突,一冲突就会引起阻塞等待。
一旦某个代码涉及到加锁,基本上就可以宣告这个代码和“高性能”无缘了……
那么是否有办法 ,既可以让代码线程安全,又不会对执行效率产生太大影响?
在加锁语句的外层,再加一个 if 语句,判断一下,看看这个锁是否要加?
如果对象已经有了,线程就安全了。此时就可以不加锁。
如果对象还没有,那么这个时候存在线程不安全的风险。要加锁。
第一个 if 用来判定是否需要加锁,第二个 if 用来判定是否需要 new 对象,只不过凑巧这俩条件是一样的写法罢了。
现在看似所有问题都解决了,但是“指令重排序”可能对咱们的上述代码产生影响。
“指令重排序”好像在哪里见过?前面我们介绍 volatile 关键字的时候提到了一下。
“指令重排序”也是编译器优化的一种,编译器为了执行效率,可能会调整原有代码的执行顺序,调整的前提是保持逻辑不变。
通常情况下,“指令重排序”能够保证逻辑不变的前提下,把程序执行效率大幅度提高。但是在多线程下可能会出现误判。
现在我有一个问题:t1执行到new的过程中,其实是先加了锁的,既然已经加锁,t2还能执行吗?还会穿插进来吗?
t2 线程执行的第一个 if 没有涉及到任何加锁操作,这个 if 是完全可以执行的。所得阻塞等待一定是两个线程都加锁的时候才会触发。但是由于 t2 执行第一个 if 的时候,条件不满足,没有进入 if 的内部,此时枷锁操作没有真正的执行,直接返回了,这个过程中就没有涉及到任何的阻塞等待!!!
针对上述问题,解决方案仍然是 volatile ,让它修饰 instance,此时就可以保证instance在修改的过程中就不会出现指令重排序的现象了。
class SingletonLazy{
private static volatile SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance==null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() { }
}
这个单例模式看起来很美好了,但其实可能还是有问题。
1、使用反射,能否打破单例?
2、使用序列化/反序列化,能否打破单例?
……
还能延伸出一些写法,我们就不做过多的讨论了。
阻塞队列是一种多线程编程中常用的数据结构,它具有线程安全性和阻塞特性,用于在多线程环境下安全地共享数据。
阻塞队列的两个主要特性是:
阻塞出队列(Blocking Dequeue):如果队列为空,当一个线程尝试出队列(取元素)时,它会被阻塞,直到队列中有元素可供取出。这种特性确保了线程不会尝试访问空队列,从而避免了空指针异常和不必要的忙等待。
阻塞入队列(Blocking Enqueue):如果队列已满,当一个线程尝试入队列(添加元素)时,它会被阻塞,直到队列中有空间可用。这确保了线程不会尝试向已满的队列添加元素,从而避免了队列溢出和数据丢失。
阻塞队列最大的意义就是用于解决生产者-消费者问题和线程间的协作问题。
“生产者-消费者模型”是一种常见的多线程代码编写方式,通常涉及两种类型的线程:
生产者线程:负责生产数据或任务,并将它们放入共享队列中。
消费者线程:负责从共享队列中取出数据或任务,并进行处理。
生产者和消费者线程之间需要协调和同步,以确保以下条件:
阻塞队列提供了一种天然的机制来满足这些条件。它在队列为空时会阻塞消费者线程,直到有数据可用。同样,在队列已满时会阻塞生产者线程,直到有空间可用。
为啥要使用“生产者-消费者模型”呢?这样做有什么好处?
任务分离:生产者负责生成数据或任务,而消费者负责处理这些数据或任务。这种分离使得系统的设计更加模块化,各个部分之间的耦合度降低。生产者和消费者之间只需关注各自的任务,而无需了解彼此的具体实现。
并发性:生产者-消费者模型允许多个生产者和多个消费者同时工作。这种并发性可以更好地利用多核处理器和提高系统的吞吐量。生产者和消费者可以并行执行,从而提高了系统的效率。
这种方式使得生产者和消费者线程可以安全地并发执行,而不需要显式的锁或手动的线程同步。
解耦合:通过引入一个共享的缓冲区(阻塞队列),生产者和消费者之间的直接通信被解耦合。这意味着生产者和消费者可以以不同的速度工作,而不会导致阻塞或性能问题。这种解耦合也使得系统更加灵活,可以轻松地添加或删除生产者和消费者。(两个模块,联系越紧密,耦合就越高。尤其是对于分布式系统来说,是更加有意义的。)
等待和通知:生产者-消费者模型使用等待和通知机制来协调线程之间的工作。当队列为空时,消费者线程等待直到有数据可用;当队列已满时,生产者线程等待直到有空间可用。这种机制可以有效减少了线程的忙等待,节省了系统资源。
可伸缩性:生产者-消费者模型可以轻松扩展以满足不同的需求。如果需要更多的生产者或消费者,只需添加更多的线程即可。这种可伸缩性使得模型适用于各种不同的应用场景。
总之,"生产者-消费者模型"提供了一种有效的方式来协调多个线程之间的工作,减少了线程间的竞争和冲突,提高了系统的效率和可维护性。它在多线程编程中被广泛应用,特别适用于需要处理并发和异步任务的场景。除了解决生产者-消费者问题,阻塞队列还用于许多其他多线程协作问题,例如线程池任务管理、消息传递和事件处理等。它们提供了一种高效和可靠的线程同步和通信机制,减少了编写多线程代码的复杂性和错误的机会。
详细说说解耦合:
以此还可以引出一个思考—— “ 削峰填谷 ”
所谓“峰”:短时间内请求量比较多;所谓“谷”:短时间内请求量比较少。
第一个耦合性较强的结构下,一旦客户端发起请求很多,每个A收到的请求都会立即发给B,A的访问量和B一样,但是不同的服务器上面跑的业务不同。虽然访问量一样名单各方位消耗的硬件资源却不一样,可能A承担这些并发量没问题,但是B就会崩溃(比如B操作数据库,分布式系统,很脆弱)
如果引入生产者-消费者模型,上述问题就可以得到很大改善。
A受到较大的请求量,A会把对应的请求写入到队列中。B仍然可以按照之前的节奏来处理请求。与其直接把B搞崩溃,不如让它慢点搞。虽然A得到响应的速度会慢,但是也好过没有响应。此时就会有一定的请求在队列中积压。
像上述的峰值情况,一般不会持续存在,只会短时间出现。过了峰值之后,A的请求了就会恢复正常,B就可以逐渐的把积压的数据给处理掉。
有了这样的机制之后,就可以保证在突发情况来临的时候,整个服务器系统仍然可以正确的执行。
它通过缓冲请求,平衡生产者和消费者之间的速度差异,以确保系统在高峰时段能够稳定运行,避免过多的请求导致性能下降或服务器崩溃。
这种结构通常使用队列作为缓冲区,来存储请求并平滑地将它们分发给处理程序。
在Java中标准库里,已经提供了现成的阻塞队列。Java标准库提供了 BlockingQueue 接口以及多个实现类,用于创建阻塞队列,这些队列在多线程环境中非常有用。它继承于Queue这个接口。里面的各种方法对于阻塞队列都可以使用,但是我们不建议,因为这些方法都不具备阻塞特性。
针对BlockingQueue有两种实现方法:
1、基于数组的实现:ArrayBlockingQueue 是一个由数组支持的有界阻塞队列,需要指定队列的容量。它使用固定大小的数组来存储元素,当队列满时,入队操作将被阻塞。
BlockingQueue queue = new ArrayBlockingQueue<>(10);
2、基于链表的实现:LinkedBlockingQueue 是一个由链表支持的有界或无界阻塞队列。可以选择指定容量或不指定容量(无界队列)。无界队列不会限制队列的大小。
BlockingQueue queue = new LinkedBlockingQueue<>();
BlockingQueue的方法:
BlockingQueue 接口不提供直接的方法来实现“阻塞式”获取队首元素。但是你可以用 take( )方法。这个方法会在队列为空时阻塞等待,直到有元素可取。
BlockingQueue queue = new LinkedBlockingQueue<>();
String element = queue.take();
简单的运用一下:
public class Demo23 {
public static void main(String[] args) throws InterruptedException {
BlockingDeque queue = new LinkedBlockingDeque<>();
queue.put("111");
queue.put("222");
queue.put("333");
queue.put("444");
String elem = queue.take();
System.out.println(elem);
elem = queue.take();
System.out.println(elem);
elem = queue.take();
System.out.println(elem);
elem = queue.take();
System.out.println(elem);
elem = queue.take();
System.out.println(elem);
}
}
结果是按照进队列的顺序依次出队列,最后一次队列为空,形成阻塞:
我们可以用数组实现一个普通的队列稍作修改,再加上线程安全和阻塞,就完成了一个阻塞队列。
在学习数据结构的时候,我们提到,用数组实现的队列是一个环形队列:
入队列,把新的元素放到tail的位置上,同时 tail++;
出队列,把 head 指向的元素给删除掉,head++。
初始情况下,队列为空时,head 和 tail 重合。但是当队列满了的时候,又重合了?这就不科学了。
解决方案有两种:
1、浪费一个格子,让 tail 指向 head 的前一个为止,就算满了;
2、专门搞一个变量 size 表示元素个数,size 为 0 就表示是初始情况。
我们采取第二种解决方案:
//不写成泛型了,直接让这个队列存储字符串
class MyBlockingQueue{
//此处这里的最大长度,也可以指定构造方法,由构造方法的参数来判定
private String[] data = new String[1000];
//队列的起始位置
private int head = 0;
//队列的结束位置的下一个位置
private int tail = 0;
//队列中有效元素的个数
private int size;
//提供核心方法,入队列和出队列
public void put(String elem){
if(size==data.length){
//队列满了
//如果是普通的队列,就直接return
return;
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
if(tail == data.length){
tail = 0;
}
size++;
}
public String take(){
if(size==0){
//队列空了
//对于普通队列,直接就返回数组
return null;
}
//队列不空,就可以把队首元素(head位置的元素)删除掉,并进行返回
String ret = data[head];
head++;
if (head == data.length){
head = 0;
}
size--;
return ret;
}
}
好了,这样子我们最基础的队列就完成了,接下来就是通过加锁解决线程安全问题:
这两个核心方法内部都设计了大量的修改操作,所以最好的方法就是把整个方法都加锁(而且我们后面写上 wait 和 notify 了之后要确保它们在synchronized里面):
package thread;
//不写成泛型了,直接让这个队列存储字符串
class MyBlockingQueue{
//此处这里的最大长度,也可以指定构造方法,由构造方法的参数来判定
private String[] data = new String[1000];
//队列的起始位置
private int head = 0;
//队列的结束位置的下一个位置
private int tail = 0;
//队列中有效元素的个数
private int size;
//提供核心方法,入队列和出队列
public void put(String elem){
synchronized(this){
if(size==data.length){
//队列满了
//如果是普通的队列,就直接return
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
if(tail ==data.length){
tail =0;
}
size++;
}
}
//使用 locker 也可以
/* public final Object locker = new Object();
public void put(String elem){
synchronized(locker){
if(size==data.length){
//队列满了
//如果是普通的队列,就直接return
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
size++;
}
}*/
public synchronized String take(){
if(size==0){
//队列空了
//对于普通队列,直接就返回数组
return null;
}
//队列不空,就可以把队首元素(head位置的元素)删除掉,并进行返回
String ret = data[head];
head++;
if (head == data.length){
head = 0;
}
size--;
return ret;
}
}
一个队列,要么是空,要么是满。take 和 put 只有一边能阻塞。
如果 put 阻塞了,其他线程继续调用 put 也都会阻塞,只有靠 take 唤醒;
如果 take 阻塞了,其他线程继续调用 take 也都会阻塞,只有靠 put 唤醒。
所以使用 wait 的时候一定要注意!我们要好好考虑当前wait是通过notify唤醒还是通过Interrupt唤醒的。如果是前者:说明其他线程已经调用了take,此时队列不满了,可以继续添加元素;如果是后者:说明此时队列其实还是满着的,继续添加元素肯定会出问题!!!
关键要点,当wait返回的时候需要进一步的确认一下,看当前队列是不是满着呢。本来是因为队列满才进入阻塞,解除阻塞之后,要再确认一次队列满不满。如果经过确认之后队列还是满着的,那么我们继续进行 wait。
package thread;
//不写成泛型了,直接让这个队列存储字符串
class MyBlockingQueue{
//此处这里的最大长度,也可以指定构造方法,由构造方法的参数来判定
private String[] data = new String[1000];
//队列的起始位置
private volatile int head = 0;
//队列的结束位置的下一个位置
private volatile int tail = 0;
//队列中有效元素的个数
private volatile int size;
//提供核心方法,入队列和出队列
public void put(String elem) throws InterruptedException {
synchronized(this){
/* if(size==data.length){
//队列满了
//如果是队列满了,继续插入,就会阻塞
this.wait();
if(size==data.length){
this.wait();
}
}*/
while(size==data.length){
this.wait();
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
if(tail ==data.length){
tail =0;
}
size++;
//这个notify用来唤醒 take 中的wait
this.notify();
}
}
//使用 locker 也可以
/* public final Object locker = new Object();
public void put(String elem){
synchronized(locker){
if(size==data.length){
//队列满了
//如果是普通的队列,就直接return
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
size++;
}
}*/
public synchronized String take() throws InterruptedException {
while (size==0){
//队列空了
//对于普通队列,直接就返回数组
this.wait();
}
//队列不空,就可以把队首元素(head位置的元素)删除掉,并进行返回
String ret = data[head];
head++;
if (head == data.length){
head = 0;
}
size--;
//这个notify用来唤醒 put 中的wait
this.notify();
return ret;
}
}
构成了一个基本的 “生产者-消费者模型” :
package thread;
//不写成泛型了,直接让这个队列存储字符串
class MyBlockingQueue{
//此处这里的最大长度,也可以指定构造方法,由构造方法的参数来判定
private String[] data = new String[1000];
//队列的起始位置
private volatile int head = 0;
//队列的结束位置的下一个位置
private volatile int tail = 0;
//队列中有效元素的个数
private volatile int size;
//提供核心方法,入队列和出队列
public void put(String elem) throws InterruptedException {
synchronized(this){
while(size==data.length){
this.wait();
}
//队列没满,真正往里面添加元素
data[tail]=elem;
tail++;
//如果tail自增之后到到数组末尾,这个时候需要让它直接回到开头(环形队列)
if(tail ==data.length){
tail =0;
}
size++;
//这个notify用来唤醒 take 中的wait
this.notify();
}
}
public synchronized String take() throws InterruptedException {
while (size==0){
//队列空了
//对于普通队列,直接就返回数组
this.wait();
}
//队列不空,就可以把队首元素(head位置的元素)删除掉,并进行返回
String ret = data[head];
head++;
if (head == data.length){
head = 0;
}
size--;
//这个notify用来唤醒 put 中的wait
this.notify();
return ret;
}
}
public class Demo24 {
public static void main(String[] args) {
//生产者,消费者,分别使用一个线程表示(也可以使用多个线程)
MyBlockingQueue queue = new MyBlockingQueue();
//消费者
Thread a = new Thread(()->{
while (true){
try {
String result = queue.take();
System.out.println("消费元素"+result);
//暂时不sleep,这里也就代表了生产者慢,消费者慢
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//生产者
Thread b = new Thread(()->{
int num = 1;
while(true){
try {
queue.put(num + "");//加上一个空字符串,把它转成一个字符串类型
System.out.println("生产元素" + num);
//休眠一下代表生产者生产元素比较慢
//一秒钟最多循环两次
num++;
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
a.start();
b.start();
}
}
这里由于生产者生产的慢,生产者每生产出来一个产品消费者就会立刻消耗掉。
现在我们把 sleep 放到消费者那里,让生产者生产的速度快于消费者消费的速度:
你会看到生产者生产的很快,一下子就到达我们设置的上限1000,所以消费者陷入阻塞,消费者每消耗一个,生产者就立马生产出来一个。