【JavaEE】多线程02—线程安全

1.多线程的风险 ------ 线程安全

1.1 观察线程不安全

示例:如以下的代码,两个线程,每个线程自增5w次,那么预期结果 count=100000

但是,输出结果并不是预期的值:

原因:线程是并发执行的,调度是随机的,看到0说明 main 线程先执行打印了。

但是我们希望的是先把 t1 和 t2 执行完,再执行 main 的打印,那么我们就需要使用join 方法

t1 和 t2 这两个线程,谁先join,谁后join无所谓,无非就两种情况,且结果都一样:

    1. t1 先结束,t2 后结束
    • main 先在 t1.join 阻塞等待,待 t1 结束,main 再在 t2.join 阻塞等待,待到 t2 结束,main 继续执行后续的打印 ------ 最终结果打印的值就是 t1 和 t2 都执行完的值。
    1. t2 先结束,t1 后结束
    • main 先在 t1.join 阻塞等待,此时 t2 已经结束,t1.join 继续阻塞,t1 结束,main 执行到 t2.join ,由于 t2 已经结束了,此处的 t2.join 不会阻塞,main继续执行后续的打印,结果一样。
  • 以上两种情况主要区别于:是分两个join各自阻塞一会,还是在一个join 全都阻塞完。

结果一样的核心原因join() 只阻塞 main 线程,不影响子线程的并发执行

  1. 不管 main 先等谁,t1 和 t2 从 start() 调用后就已经开始并发执行了,两个线程的执行是完全独立的,不受 main 线程 join() 顺序的影响。
  2. join() 的作用只是保证:main 线程必须等两个子线程都执行完,才会执行 System.out.println(count),而不是改变子线程的执行顺序。

此时执行这段代码:

发现输出结果仍然不是预期的结果,而且每次运行结果都不一样:

出现这样的结果,是多线程并发执行引起的问题,如果把两个线程变成串行执行,即一个执行完了,再执行另一个,就能避免这样的问题:

很明显,当前bug(实际结果与预期结果不符)是由于++多线程的并发执行代码++ 引起的bug,这样的bug,就称为 "线程安全问题",即为 "线程不安全" 。反之,如果一个代码,在多线程并发执行的环境下,也不会出现类似于上述的bug,这样的代码就叫做 "线程安全"

1.2 线程安全概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是 线程安全的。

1.3 线程不安全的原因

例如上述例子中的 count++ 操作,这个操作看起来是一行代码,实际上对应了3个CPU指令:

(关于指令这部分内容,不熟悉请看:计算机的工作过程)

  1. LOAD:把内存中的值(count变量) 读取到CPU寄存器上
  2. ADD:把指定寄存器中的值进行 +1 操作(结果还是存在这个寄存器中)
  3. SAVE:把寄存器中的值写回到内存中

CPU在执行这三条指令的过程中,随时可能触发线程的随机调度切换,即由于操作系统调度是随机的,执行任何一个指令的过程中,都可能触发下面的 "线程切换/调度" 操作。

这种线程随机调度,抢占式执行的方式,就是线程不安全的罪魁祸首。

可以通过以下count的执行图解更好了解count为何结果不符合预期,即并发执行原因 - 随即调度:

如下图,以下列举了部分 t1和t2 线程由于操作系统随即调度 count 3个命令可能出现的执行顺序:

t1 和 t2 的 count 3个指令具体流程,以情况一为例:

(两个线程在CPU上执行时,可能是并发,也可能是并行,这里我们画成两个CPU)

如上图,当两个线程的随即调度是按照以上的调度形式执行的,会发现count最终的结果是正确。但是,当以以下的情况二的调度形式执行,结果就不正确了:

两个线程最终随机调度执行的结果是1,这明显是不正确的:明明是两个++,结果最后结果还是1。由此可以说明,在前面的代码中,t1 和 t2 各执行 5万次 的count++,那么在随即调度面前,就会出现这种情况,这也是为何结果达不到预期结果 10万次 的原因

那么通过上述的两个例子,我们也可以总结出:

  • 如果两个线程 load 到的数据都是0,意味着一定会少++一次
  • 如果两个线程 load 到一个 0 和 一个1,结果才是正确的,也就是说,一个线程的 load 要在另一个线程的 save 之后,也就是串行执行。

就像之前列举出来的部分调度可能性(调度次序有无数种可能),就只有情况一是能有正确结果的,那么在 5万次 的过程中,调度的可能性就更多了,很难保证调度后的结果是正确的,因此最终执行的结果一定是 <=10万次的

还有更极端的情况,甚至是小于 5万次,不过很少见,例如以下的情况:

会出现,一共执行了三次++,最终结果却是1的情况,这就可能造成小于 5 万次 的可能。

  • t1 线程加载完count数据到内存后,随机调度切换到 t2 线程,执行加载操作,然后是+1和保存操作,此时count=1,再次执行一个count流程,此时count=2,然后调度切换回到 t1线程,继续执行+1操作,此时count=1,保存回到寄存器的结果就是1,最终的结果也是1。

如果我们把 t1 和 t2 线程的执行次数变成各 50 次,那么其实出现线程不安全问题的概率变小了

看运行结果,得到的是正确的答案:

但是不代表就没有问题了,50次和5w次,线程执行的时间长短是不同的,如果循环50次,很有可能在执行 t2.start 之前,t1就已经算完了,等到后续 t2 执行,就变成纯串行了,因此结果可能是对的。当再次运行,结果可能就出现问题了。

线程安全产生的原因

  • 1.操作系统对于线程的调度是随机的,抢占式执行(根本)。
  • 2. 多个线程同时修改一个变量。
    • 就像 t1 和 t2 同时对 count内存空间 进行修改。
    • 如果是一个线程修改一个变量,多个线程不是同时修改一个变量,多个线程修改不同变量,多个线程读取同一个变量,这些是没有问题的,不会产生线程安全问题。
  • 3. 修改操作不是原子的。
    • 如果修改操作只是对应一个CPU指令,就可以认为是原子的,CPU不会出现 "一条指令执行一半"这样的情况。
    • 如果对应到多个CPU指令,就不是原子的 。就像对于count的修改操作。
      • ++,--,-=,+=等的操作都不是原子的,在Java中,=(赋值) 是原子的。
  • **4.**内存可见性问题引起的线程不安全。(后续再讨论)
  • **5.**指令重排序引起的线程不安全。(后续再讨论)

如何解决线程安全问题 :像抢占式执行这种操作系统的底层设计,我们左右不了,而像多个线程同时修改一个变量,这和代码结构有关,可以通过调整来规避一些线程不安全的代码,但是不通用,有些情况下,需求就是要多线程同时修改一个变量。那么其实 原子性 是Java解决线程安全问题的最主要方案。是通过 加锁 操作,让不是原子的操作,打包成一个原子的操作。

原子性

我们把一段代码想象成⼀个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来,那 B 是不是也可以进入房间,这样 A 就没有隐私了,这个就是不具备原子性的。

那我们应该如何解决这个问题呢?

------------ 只要给这个房间加⼀把锁,A 进去后就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

加锁后的这个现象叫做同步互斥,表示操作是互相排斥的

  1. 把锁 "锁上" 称为 "加锁"。
  2. 把锁 "解开" 称为 "解锁"。

一旦把锁加上了,其他的线程要想加锁,就得阻塞等待,不可插队。这样就解决了线程不安全的问题。

2. synchronized 关键字

那么,我们就使用 锁 ,把不是原子的 count++ 包裹起来,打包成一个原子操作,而加锁/解锁 本身就是操作系统提供的 API ,很多编程语言对这样的API进行了封装,大多数的封装风格都采用了两个函数 lock() 和 unlock() ,即加锁和解锁的方法,在这两个方法的中间执行一些要保护起来的逻辑:

复制代码
lock();//加锁

//执行一些要保护起来的逻辑

unlock();//解锁

但是在Java中,使用的是 synchronized 这个关键字,搭配代码块来实现类似上述的操作:

java 复制代码
synchronized (锁对象引用) {  //进入代码块相当于加锁
    //执行一些要保护的逻辑
}//出代码块相当于解锁

()内就表示的是一把锁,要加锁/解锁,首先就要有一把锁,在Java中,任何一个对象都可以用作锁,即不限制锁的类型(String,int等)。这个对象的类型不重要,重要的是,是否有多个线程尝试针对这同一个对象加锁,即是否竞争用一把锁

  • 多个线程,针对同一个对对象加锁,才会产生互斥效果:一个线程加上锁了,另一个线程就得阻塞等待,等到第一个线程解锁后,才有机会加锁。
  • 如果是不同的锁对象,此时不会有互斥效果,那么线程安全问题就没有得到解决。

但是一般使用Object类创建一个专门作为锁的对象:

java 复制代码
Object locker = new Object();

解决 count++ 的线程安全问题:

如果把 for 循环也加入到锁中,与锁中只有count有什么区别?

  • 锁中只有 count,那么意味着线程t1 和 线程t2 是可以并发执行的,不过由于 count++ 加了锁,那么只有这个操作涉及到互斥,只有当 t1 或者 t2 的 count++ 执行了一次完整的指令,才可以进行随机调度执行 (for循环里的条件------ i < 50000和i++ 这两个操作不涉及互斥,t1和t2可以随机调度),即并发执行。
  • 锁中包含有 for+count,那么代表都涉及到互斥,意味着只有 线程t1 完整执行了 5万次 count++ 完整指令后,线程t2 才能结束阻塞等待,开始执行 t2 的5万次count++,即串行执行。
  • 明显第一种的效率更高。

总结:解决线程安全问题,需要正确的使用锁synchronized:

  1. synchronized { } 代码块要合适
  2. synchronized ( ) 指定的锁对象也要合适
  3. Java采用 synchronized 关键字,能确保只要出了代码块,即 } ,一定能释放锁/解锁:无论是因为 return 还是因为 异常 ,无论里面调用了哪些其他代码,都可以确保 unlock() 操作执行到,如果是按照lock()和unlock() 的写法,很容易忘记解锁unlock的操作。

2.1 synchronized 的变种写法

  • 使用 synchronized 修饰方法:

或者直接在add方法中加上锁,使用当前类对象this,直接作为锁对象:

上述方法的变形,直接在方法的开头就加上锁,也相当于针对 this 当前类对象进行加锁:

  • 使用 synchronized 修饰静态方法:

我们知道,static 修饰的方法,不存在 this,那么此时synchronized 修饰 静态方法,相当于针对类对象加锁 或者 在静态方法中使用 Counter.class 也可以获取类对象:

2.2 synchronized 的特性

1)互斥

synchronized 会起到互斥 效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行 到同⼀个对象 synchronized 就会阻塞等待.

  • 进⼊ synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

synchronized⽤的锁是存在Java对象头里的。

可以粗略理解成,每个对象在内存中存储的时候, 都存有⼀块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人").

如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有⼈" 状态.

如果当前是 "有⼈" 状态, 那么其他人无法使用, 只能排队

2)可重入

观察以下的代码:重复加了两次锁

其实这样的情况是很常见的,就像以下的代码:我们在调用类方法的时候,很有可能这个方法内部也有内容加了锁,这样就造成了重复加锁的情况

  1. 第一次进行加锁操作,能够成功,此时的锁没有被使用。
  2. 第二次进行加锁,此时的锁对象已经是被占用的状态,那么第二次加锁就会触发阻塞等待,等到前一次加锁被释放,第二次加锁的阻塞才会被解除,继续执行。

这种情况就很容易出现矛盾:要想解除阻塞,需要往下执行才可以,要想往下执行,就需要等到第一次的锁被释放,这样的问题,就称为 "死锁" (dead lock)。

++死锁++ 是一个非常严重的 bug,因此,Java的 synchronized 就引入了可重入的概念

运行上述的代码,虽然造成了 "死锁",但是 synchronized 的可重入,使得该代码可以正确的运行,不会出现自己把自己锁死的问题:

进一步验证,再加入多层的 synchronized 的嵌套,依然正确运行:

当某个线程针对一个锁对象加锁成功后,后续该线程再次针对这个锁对象进行加锁,不会触发阻塞,而是直接往下走,因为当前这把锁就是被这个线程持有的,但是,如果其他线程尝试加锁,就会正常的阻塞。

可重入的实现原理,关键在于让锁对象内部保存当前是哪个线程持有的这把锁,后续有线程针对这个锁加锁的时候,对比一下锁持有者的线程是否和当前加锁的线程是同一个,即 synchronized 的可重入,是针对同一个线程的可重入

通过上图,我们可以清晰了解,真正加锁的位置,那么,此时有一个问题,如图中画绿色方框的地方,真正解锁的是哪一层?

------------ 同理,最外层,是真正的加锁 ,对应的,最外层也是真正的解锁

站在JVM的角度,看到多个 } 要执行,JVM如何知道哪些 } 是真正解锁的那些?

------------ 它是通过引入一个变量作为计数器,每次触发 { 的时候,计数器 ++,每次触发 } 的时候,计数器 -- ,当计数器为0时,就是真正的需要解锁的时候。

  • 如何自己实现一个可重入?
    1. 在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判断
    1. 通过计数器记录当前加锁的次数,从而确定何时真正进行解锁

2.3死锁的常见情况 - 不可重入

a. 锁重入不当

即 一个线程,一把锁,连续加锁。

按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第二个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就⽆法进行解锁操作. 这时候就会死锁 。这样的锁称为不可重入锁。但是Java 中的 synchronized 是 可重⼊锁, 因此没有上面的问题。

b. 双向锁死锁(最常见)

即 两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁。就像你拿了一杯可乐,朋友拿了一杯雪碧,你在不放下可乐的前提下还想喝朋友的雪碧,而朋友在不放下雪碧的前提下,还想喝你的可乐,构成死锁。

示例:

以上的代码逻辑是,t1线程必须要先拿到第一把锁locker1,在不释放该锁的前提下(嵌套 ),去拿第二把锁locker2,而 t2线程必须要先拿到第二把锁locker2,在不释放该锁的前提下,去拿第一把锁locker1。这样的结果必然会造成死锁,两个线程都互相持有对方需要的锁,导致程序永远卡住

而 死锁 是线程的阻塞状态之一,因竞争锁而导致的阻塞:

如果上述的代码,不加 sleep,是否还会出现死锁的现象

------------ 加上sleep,是为了确保 t1 拿到 locker1,t2 拿到locker2,然后等待1s,t1尝试拿取locker2,t2 尝试拿取locker1。如果不加sleep,t1 很有可能一口气就把 locker1和locker2 都拿到了,这个时候 t2 还没有开动,这样就无法构成死锁。

c.环形等待死锁(多个线程连环等)

即 N个线程,M把锁。例如,线程 1 等线程 2,线程 2 等线程 3,线程 3 等线程 1,本质是循环等待。

如何避免代码中出现死锁

构成死锁的必要条件:
  1. 锁是互斥的:一个线程拿到锁之后,另一个线程再尝试去获取锁,必须阻塞等待。
  2. 锁是不可抢占的:线程1 拿到锁,线程2 的也尝试获取这个锁,线程2必须阻塞等待,而不是线程2 直接把锁抢过来。
  3. 持有并等待:一个线程拿到锁1 之后,不释放锁1 的前提下,尝试获取 锁2。
  4. 循环等待:多个线程,多把锁之间的等待过程,构成循环。

只要破坏任意一个,死锁就不会发生。但是,其中1,2点是锁的基本性质,Java的synchronized 是遵循这两点的,因此我们无法去破坏,那么,我们要做的是破坏 3 或者 4 任意一个条件,就能过打破死锁。

1)打破 - 持有并等待

要打破这个条件,只要让代码中加锁的时候,不要去嵌套,把嵌套的锁改成并列的锁。

即我先放下可乐,再拿朋友的雪碧,朋友放下雪碧,再拿我的可乐。

但是,这种做法并不通用,有些情况下,确实需要拿到多个锁,再进行某个操作,嵌套,很难避免。

2)打破 - 循环等待

这是比较通用的做法。要打破这个条件,要做的就是约定好加锁的顺序。即我和朋友约定好喝饮料的相同顺序,我们两先喝可乐,等到我们都喝完可乐后,再一起喝雪碧,就是约定完共同的加锁顺序后,所有线程都按照这个顺序去阻塞等待加锁,这样就能避免死锁。

另一个例子:此时有5个线程,5把锁,此时约定好所有线程加锁都按照先获取序号小的锁再获取序号大的锁的顺序,此时1~5线程都会想先去获取 序号1 的锁,那么就会阻塞等待,先获取用完的线程接着去获取下一个锁,这样就不会触发死锁。

3.Java标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有⼀些是线程安全的. 使用了⼀些锁机制来控制.

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer (不推荐使用)

示例:StringBuffer 的核心方法都带有 synchronized .

以上三个线程安全类不推荐使用的原因:加锁不是没有代价的,一旦代码中使用了锁,意味着可能会因为锁的竞争,而产生阻塞,那么程序执行的效率就会大大折扣,线程阻塞,就从CPU上调度走,什么时候调度回来继续执行,很不好说。

因此,是否加锁需要考虑清楚,不要乱加锁。相比于HashTable,ConcurrentHashMap是它高优化后的版本,后续再讲。

还有的虽然没有加锁 , 但是不涉及 "修改", 仍然是线程安全的

  • String

4.volatile 关键字

volatile 能保证 内存可见性(前面说过的 造成线程安全的原因之一)

  • volatile 修饰的变量,能够保证 "内存可见性" 。
  • 与synchronized 不同的是,volatile 只能修饰变量,且 volatile 解决的是 内存可见性 的问题,而synchronized 解决的是 原子性 的问题。

示例:

看运行结果,虽然输入了非0的值,但是此时 t1 线程循环并没有结束,而是继续执行:

很明显,这也是一个 bug,是线程安全问题:一个线程读取,一个线程修改,修改线程修改的值并没有被读取线程读取到,这就是 "内存可见性" 的问题。

造成内存可见性问题,是由于 编译器优化 导致的:

  • 我们写的代码 ------ javac 把 .java 文件 编译生成 .class 字节码文件,最后 JVM将字节码转化成平台能够理解的形式来运行
  • 研究JDK的大佬,希望通过让编译器 和 JVM 对程序员写的代码,自动进行优化,本来写的代码是进行 xxxxx,编译器/JVM 会在你原有逻辑不变的前提下,对你的代码进行调整,使得程序效率更高。
  • 编译器声称优化操作,是能够保证逻辑不变的,但是,尤其是在多线程的程序中,编译器的判断可能出现失误,可能导致编译器优化后的逻辑,和优化前的逻辑出现细节上的偏差。就像上述的示例代码:

线程 t1 中的 while 循环的执行,需要2条指令:load + cmp,其中 cmp指令指的是 flag==0 这样的条件跳转指令

在短时间内,while 这个循环就会循很多次,load 是读取内存操作,而 cmp 是纯CPU寄存器操作,load 的时间开销可能是 cmp 的几千倍,在 while 执行的过程中,JVM 就能感知到 load 反复执行的结果好像都是一样的。

  • 对于 flag 的修改,取决于用户的输入,但是不知道用户过多久才能输入
  • JVM 觉得执行了这么多次读 flag 的操作,发现值始终都是 0,既然都是一样的结果,那就没必要再反复执行这么多次,于是就把 读取内存的操作 ,优化成 读取寄存器 这样的操作,即把内存的值读取到寄存器中了,后续再 load ,不再重新读取内存,而是直接从寄存器中来取值
  • 于是,等到很多秒之后,用户真正输入 flag 新的值,真正修改 flag,此时 t1线程就感知不到了 (编译器优化,使得 t1 线程的读取操作,不是真正的读内存)
  • 因此,t1 线程读取到的 flag 就一直是 0。

如果稍微调整一下代码:让 while 在每次 休眠 1ms 后开始循环

查看运行结果,发现,t1 线程 读取到了用户输入的 flag 的值:

原因:本来 while 这个循环,仅仅在 1s 内就执行了几千万次,甚至上亿次,但是,加了 slee(1) 之后,虽然只是 1ms ,也使得循环的次数大幅度降低了。且当引入 sleep 之后,sleep 消耗的时间相比于 load flag 的操作,就高了很多了。

  • 假设本身 读取flag 的操作的时间是 1ns ,如果把读内存操作优化成读寄存器,1ns => 0.xxns,优化了 50%以上,但是如果引入 sleep,sleep 直接占用了 1ms ,此时优不优化 flag 变得无足轻重了。
  • 就像你的全部身家是500块,拿丢了 100块,影响还是非常大的,但是如果身家是500万,那你丢了 100块,也就无所谓了。

针对内存可见性问题,也不能指望通过 sleep 来解决,因为 sleep 会大大影响程序的效率,在编译器优化的角度也很难去解决,那么即不用 sleep 也不用调整编译器优化,就能解决 内存可见性问题 ------ 就是使用 volatile 关键字

通过这个关键字来修饰某个变量,此时编译器对这个变量的读取操作,就不会被优化成读寄存器,而是每次必须重新读取内存中的数据。

此时 t2 线程修改了 flag,t1 线程就能及时读取到:


在Java的官方文档,即 JMM (java内存模型)中,也有对编译器优化做有官方描述:

每个线程,有一个自己的**"工作内存"** ,同时这些内存共享一个**"主内存"**

当一个线程循环进行上述的读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中,后续另一个线程修改,也是先修改自己的工作内存,然后拷贝到主内存里

由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化。

我们前面讲的是,把读内存的操作,优化成了读寄存器的操作,其实是同一个意思,它们在抽象目标上是完全一致的------用更快的私有存储(寄存器/工作内存)替代对慢速共享主内存的重复访问,从而提升性能,但同时也引入了可见性问题。

这里的**"主内存"(main memory) 就基本的等同于 内存(RAM)**,

而**"工作内存"(work memory) 是 CPU寄存器+缓存**。

寄存器虽然很快,但是空间小,存不了很多东西,于是开发CPU的大佬就在CPU上创建了一些存储空间,称为缓存。在CPU中一般都有三级缓存:

从 L1 到 L3,速度越来越慢(再慢也比内存快),存储空间越来越大

在不同的CPU上,用来缓存上述例子的内存数据的区域是不同的 ------ 具体是存在寄存器里,还是L1,L2,L3 上是不知道的,但是对于Java代码来说,没有区别。

5.wait 和 notify

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知, 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

那么能做到协调线程之间执行的逻辑顺序,就是使用 wait 和 notify 方法,即等待和通知 ,这两个方法是Object 类的方法,也就是说,Java中任何的对象都有这两个方法。

  • 即可以让后执行的逻辑等待先执行的逻辑先跑,虽然无法直接干预调度器的调度顺序,但是可以让后执行的逻辑(线程)等待,等待到先执行的逻辑跑完了,通知一下当前的线程,让它继续执行。
  • 与 join 对比:join 也是等,不过它是等另一个线程彻底执行完了,才继续走,而 wait 的等,是等到另一个线程执行了 notify ,才继续走,不需要另一个线程彻底执行完。

示例:当排队在银行取款时,先进入取款机的线程就会加锁,等取完钱后就会解锁释放,但是如果该线程此时在取款过程中取不到钱,那么此时这个线程可以先等待,释放锁让其他的线程获取这个锁进入取款机尝试取款,等到可以取钱的时机了,再通知该线程去取款。

【此时就有很多线程去竞争这一把锁,那么被哪个线程获取到了,是随机的,这些线程其实都处于阻塞等待的状态,而刚刚释放锁的那个线程,其实是处于就绪状态,那么这个线程就有很大的可能再次拿到锁,其他的线程可能会一直等不到去CPU执行,就会发生 线程饥饿/饿死。】

以上的场景示例,是 wait和notify 的典型场景,当拿到锁的线程,发现要执行的任务时机还不成熟时,就是用 wait 阻塞等待,等待时机成熟了,再 通知(notify) 这个线程继续执行。

5.1wait()方法

wait 做的事情:

  • 使当前执行代码的线程进行等待.(把线程放到等待队列中)
  • 释放当前的锁
  • 满足⼀定条件时被唤醒,重新尝试获取这个锁.

注意:在Java标准库中,每个阻塞方法,都会抛出 InterruptedException 异常,意味着随时都有可能被 interrupt 方法唤醒。

wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常:

object.wait() 第一件事,就是先释放 object 对象对应的锁,而能够释放锁的前提是 object 对象应该处于加锁的状态,才能释放,即先加上锁,才能谈释放:

注意:synchronized 的锁对象必须与 wait 的对象是同一个。

这样在执行到object.wait()之后就⼀直等待下去,那么程序肯定不能⼀直这么等待下去了。这个时候就 需要使用到了另外⼀个方法唤醒的方法 notify()。

5.2notify()方法

notify 方法是唤醒等待的线程。wait 操作必须搭配锁来进行,而 notify 操作,原则上不涉及到加锁解锁的操作,但是在Java中,也强制要求 notify 搭配 synchronized ,还有,wait和notify针对的锁对象必须是相同的对象,才会生效,且 要保证 notify 在 wait 之后执行

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供⼀个带有 timeout 参数的版本, 来指定等待时间,与join类似,提供了 死等 和 超时时间 两个版本).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

示例:当代码进入 wait ,就会先释放锁,并且阻塞等待,如果其他线程做完了必要的工作,可以调用 notify 唤醒 这个 wait 线程,wait 就会解除阻塞,重新获取到这个锁,继续执行并返回:

但是,上述代码是存在问题的,我们查看运行结果:

发现虽然我们步骤都对了,但是 wait 并没有因为 notify 的通知而被唤醒,原因就是没有保证 notify 在 wait 之后执行

  • t1 启动后,需要先获得 locker 锁,进入 synchronized 块,然后调用 wait() 释放锁,并进入等待队列。
  • t2 启动后,也需要获得 locker 锁,然后调用 notify() 唤醒一个等待线程。
  • 而两个线程并发执行,谁先获得 locker 锁是不确定的。
  • 如果t2 先获得锁 → 调用 notify() → 此时还没有任何线程在 locker 上等待,所以 ++notify() 是空操作++ → t2 释放锁。t1 后获得锁 → 调用 wait() → 进入等待队列 → 但再也没有其他线程调用 notify() 了 → t1 永远阻塞。

正确顺序应该是:

  1. t1 先获得锁 → wait() → 释放锁并等待。
  2. t2 获得锁 → notify() → 唤醒 t1。

1)那么如何让 线程t1 先执行呢

------ 可以通过Scanner 输入操作 :scanner.next 输入操作,其中 next 就是一个带有阻塞的操作(等待IO进入的阻塞),等待用户在控制台输入。即 scanner.next() 的作用是人为制造一个延迟,让 t2 停下来等待用户按键,给 t1 足够的时间先进入 wait 状态。等你按下回车,t2 再执行 notify,就能保证 t1 被唤醒。

简单说:输入是为了确保执行顺序 ------ 先 wait,后 notify。

2)一个 notify 只能唤醒一个 wait :

两个线程加同一个锁且都使用了wait的情况,

如以下的代码: notify() 应该唤醒哪一个 wait() 线程呢?

看上述的运行结果,如果有多个线程在同一个对象上 wait ,进行 notify 的时候是随机唤醒其中一个的线程的。

如果想要全部都能够唤醒,那么有多少个 wait,就用多少个 notify ,这样就能全部唤醒:

3)notifyAll() 方法

notify方法法只是唤醒某⼀个等待线程,使用 notifyAll 方法可以⼀次唤醒所有的等待线程.

但是,虽然同时唤醒了 t1 和 t2 线程,由于 wait 唤醒后要重新加锁,那么这两个线程就会竞争这个锁 ,其中某一个线程会先加上锁,开始执行,而另一个线程加锁失败,再次阻塞等待,等待先走的线程解锁,后走的线程才能加上锁,继续执行,所以并不是同时执行,而仍然是有先有后的执行

  • notify 只唤醒等待队列中的⼀个线程. 其他线程还是乖乖等着
  • notifyAll ⼀下全都唤醒, 需要这些线程重新竞争锁

6.wait 和 sleep 对比

前面说过,wait 有超时时间的版本,例如,locker.wait(100000);

而 wait 引入超时时间后,直观看起来和 sleep 很像:

  • 两者都有等待时间
  • wait 可以通过 notify 提前唤醒,而 sleep 可以通过 interrupt 提前唤醒,但其实 interrupt 看起来是唤醒了 sleep ,其实本身的作用是通知线程终止

wait 和 sleep 的主要区别:

  • wait 必须搭配锁使用,先加锁,才能用 wait,而 sleep 不需要
  • 如果都在 synchronized 内部使用,wait 会释放锁,sleep 不会释放锁
  • wait 是 Object 类的方法,sleep 是 Thread 的静态方法

7.示例

有三个线程,分别只能打印A,B和C,要求按顺序打印ABC,打印10次。

相关推荐
手握风云-2 小时前
JavaEE 初阶第三十期:JVM,一次Full GC的架构级思考(上)
java·java-ee
洛_尘13 小时前
Java EE进阶:Linux的基本使用
java·java-ee
潘宸 .17 小时前
接口幂等性设计
程序人生·java-ee
二进制person20 小时前
JavaEE进阶 --Spring Framework、Spring Boot和Spring Web MVC(3)
spring boot·spring·java-ee
鸽鸽程序猿3 天前
【JavaEE】【SpringAI】Tool Calling(工具调用)
java·java-ee
她说..3 天前
Java 对象相关高频面试题
java·开发语言·spring·java-ee
她说..4 天前
Java Object类与String相关高频面试题
java·开发语言·jvm·spring boot·java-ee
计算机学姐4 天前
基于SpringBoot的宠物店管理系统
java·vue.js·spring boot·后端·spring·java-ee·intellij-idea
Arya_aa5 天前
Web基础+JavaEE+容器
java·java-ee