1.多线程的风险 —— 线程安全1.1 观察线程不安全示例如以下的代码两个线程每个线程自增5w次那么预期结果 count100000但是输出结果并不是预期的值原因线程是并发执行的调度是随机的看到0说明 main 线程先执行打印了。但是我们希望的是先把 t1 和 t2 执行完再执行 main 的打印那么我们就需要使用join 方法。t1 和 t2 这两个线程谁先join谁后join无所谓无非就两种情况且结果都一样1. t1 先结束t2 后结束main 先在 t1.join 阻塞等待待 t1 结束main 再在 t2.join 阻塞等待待到 t2 结束main 继续执行后续的打印 —— 最终结果打印的值就是 t1 和 t2 都执行完的值。2. t2 先结束t1 后结束main 先在 t1.join 阻塞等待此时 t2 已经结束t1.join 继续阻塞t1 结束main 执行到 t2.join 由于 t2 已经结束了此处的 t2.join 不会阻塞main继续执行后续的打印结果一样。以上两种情况主要区别于是分两个join各自阻塞一会还是在一个join 全都阻塞完。结果一样的核心原因join() 只阻塞 main 线程不影响子线程的并发执行不管 main 先等谁t1 和 t2 从 start() 调用后就已经开始并发执行了两个线程的执行是完全独立的不受 main 线程 join() 顺序的影响。join() 的作用只是保证main 线程必须等两个子线程都执行完才会执行 System.out.println(count)而不是改变子线程的执行顺序。此时执行这段代码发现输出结果仍然不是预期的结果而且每次运行结果都不一样出现这样的结果是多线程并发执行引起的问题如果把两个线程变成串行执行即一个执行完了再执行另一个就能避免这样的问题很明显当前bug(实际结果与预期结果不符)是由于多线程的并发执行代码引起的bug这样的bug就称为 线程安全问题即为“线程不安全”。反之如果一个代码在多线程并发执行的环境下也不会出现类似于上述的bug这样的代码就叫做线程安全。1.2 线程安全概念如果多线程环境下代码运行的结果是符合我们预期的即在单线程环境应该的结果则说这个程序是 线程安全的。1.3 线程不安全的原因例如上述例子中的 count 操作这个操作看起来是一行代码实际上对应了3个CPU指令(关于指令这部分内容不熟悉请看计算机的工作过程)LOAD把内存中的值(count变量) 读取到CPU寄存器上ADD把指定寄存器中的值进行 1 操作(结果还是存在这个寄存器中)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和保存操作此时count1再次执行一个count流程此时count2然后调度切换回到 t1线程继续执行1操作此时count1保存回到寄存器的结果就是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 进去后就把门锁上其他人是不是就进不来了。这样就保证了这段代码的原子性了。加锁后的这个现象叫做同步互斥表示操作是互相排斥的。把锁 “锁上” 称为 “加锁”。把锁 “解开” 称为 “解锁”。一旦把锁加上了其他的线程要想加锁就得阻塞等待不可插队。这样就解决了线程不安全的问题。2. synchronized 关键字那么我们就使用 锁 把不是原子的 count 包裹起来打包成一个原子操作而加锁/解锁 本身就是操作系统提供的 API很多编程语言对这样的API进行了封装大多数的封装风格都采用了两个函数lock() 和 unlock()即加锁和解锁的方法在这两个方法的中间执行一些要保护起来的逻辑lock();//加锁 //执行一些要保护起来的逻辑 unlock();//解锁但是在Java中使用的是synchronized这个关键字搭配代码块来实现类似上述的操作synchronized (锁对象引用) { //进入代码块相当于加锁 //执行一些要保护的逻辑 }//出代码块相当于解锁()内就表示的是一把锁要加锁/解锁首先就要有一把锁在Java中任何一个对象都可以用作锁即不限制锁的类型(String,int等)。这个对象的类型不重要重要的是是否有多个线程尝试针对这同一个对象加锁即是否竞争用一把锁。多个线程针对同一个对对象加锁才会产生互斥效果一个线程加上锁了另一个线程就得阻塞等待等到第一个线程解锁后才有机会加锁。如果是不同的锁对象此时不会有互斥效果那么线程安全问题就没有得到解决。但是一般使用Object类创建一个专门作为锁的对象Object locker new Object();解决 count 的线程安全问题如果把 for 循环也加入到锁中与锁中只有count有什么区别锁中只有 count那么意味着线程t1 和 线程t2 是可以并发执行的不过由于 count 加了锁那么只有这个操作涉及到互斥只有当 t1 或者 t2 的 count 执行了一次完整的指令才可以进行随机调度执行 (for循环里的条件—— i 50000和i 这两个操作不涉及互斥t1和t2可以随机调度)即并发执行。锁中包含有 forcount那么代表都涉及到互斥意味着只有 线程t1 完整执行了 5万次 count 完整指令后线程t2 才能结束阻塞等待开始执行 t2 的5万次count即串行执行。明显第一种的效率更高。总结解决线程安全问题需要正确的使用锁synchronizedsynchronized { } 代码块要合适synchronized ( ) 指定的锁对象也要合适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可重入观察以下的代码重复加了两次锁其实这样的情况是很常见的就像以下的代码我们在调用类方法的时候很有可能这个方法内部也有内容加了锁这样就造成了重复加锁的情况第一次进行加锁操作能够成功此时的锁没有被使用。第二次进行加锁此时的锁对象已经是被占用的状态那么第二次加锁就会触发阻塞等待等到前一次加锁被释放第二次加锁的阻塞才会被解除继续执行。这种情况就很容易出现矛盾要想解除阻塞需要往下执行才可以要想往下执行就需要等到第一次的锁被释放这样的问题就称为 死锁 (dead lock)。死锁是一个非常严重的 bug因此Java的 synchronized 就引入了可重入的概念运行上述的代码虽然造成了 死锁但是 synchronized 的可重入使得该代码可以正确的运行不会出现自己把自己锁死的问题进一步验证再加入多层的 synchronized 的嵌套依然正确运行当某个线程针对一个锁对象加锁成功后后续该线程再次针对这个锁对象进行加锁不会触发阻塞而是直接往下走因为当前这把锁就是被这个线程持有的但是如果其他线程尝试加锁就会正常的阻塞。可重入的实现原理关键在于让锁对象内部保存当前是哪个线程持有的这把锁后续有线程针对这个锁加锁的时候对比一下锁持有者的线程是否和当前加锁的线程是同一个即synchronized 的可重入是针对同一个线程的可重入。通过上图我们可以清晰了解真正加锁的位置那么此时有一个问题如图中画绿色方框的地方真正解锁的是哪一层———— 同理最外层是真正的加锁对应的最外层也是真正的解锁。站在JVM的角度看到多个 } 要执行JVM如何知道哪些 } 是真正解锁的那些———— 它是通过引入一个变量作为计数器每次触发 { 的时候计数器 每次触发 } 的时候计数器 -- 当计数器为0时就是真正的需要解锁的时候。如何自己实现一个可重入1. 在锁内部记录当前是哪个线程持有的锁后续每次加锁都进行判断2. 通过计数器记录当前加锁的次数从而确定何时真正进行解锁2.3死锁的常见情况 - 不可重入a. 锁重入不当即 一个线程一把锁连续加锁。按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第二个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就⽆法进行解锁操作. 这时候就会死锁。这样的锁称为不可重入锁。但是Java 中的 synchronized 是 可重⼊锁, 因此没有上面的问题。b. 双向锁死锁最常见即 两个线程两把锁每个线程获取到一把锁之后尝试获取对方的锁。就像你拿了一杯可乐朋友拿了一杯雪碧你在不放下可乐的前提下还想喝朋友的雪碧而朋友在不放下雪碧的前提下还想喝你的可乐构成死锁。示例以上的代码逻辑是t1线程必须要先拿到第一把锁locker1在不释放该锁的前提下(嵌套)去拿第二把锁locker2而 t2线程必须要先拿到第二把锁locker2在不释放该锁的前提下去拿第一把锁locker1。这样的结果必然会造成死锁两个线程都互相持有对方需要的锁导致程序永远卡住而 死锁 是线程的阻塞状态之一因竞争锁而导致的阻塞如果上述的代码不加 sleep是否还会出现死锁的现象———— 加上sleep是为了确保 t1 拿到 locker1t2 拿到locker2然后等待1st1尝试拿取locker2t2 尝试拿取locker1。如果不加sleept1 很有可能一口气就把 locker1和locker2 都拿到了这个时候 t2 还没有开动这样就无法构成死锁。c.环形等待死锁多个线程连环等即 N个线程M把锁。例如线程 1 等线程 2线程 2 等线程 3线程 3 等线程 1本质是循环等待。如何避免代码中出现死锁构成死锁的必要条件锁是互斥的一个线程拿到锁之后另一个线程再尝试去获取锁必须阻塞等待。锁是不可抢占的线程1 拿到锁线程2 的也尝试获取这个锁线程2必须阻塞等待而不是线程2 直接把锁抢过来。持有并等待一个线程拿到锁1 之后不释放锁1 的前提下尝试获取 锁2。循环等待多个线程多把锁之间的等待过程构成循环。只要破坏任意一个死锁就不会发生。但是其中12点是锁的基本性质Java的synchronized 是遵循这两点的因此我们无法去破坏那么我们要做的是破坏 3 或者 4 任意一个条件就能过打破死锁。1打破 - 持有并等待要打破这个条件只要让代码中加锁的时候不要去嵌套把嵌套的锁改成并列的锁。即我先放下可乐再拿朋友的雪碧朋友放下雪碧再拿我的可乐。但是这种做法并不通用有些情况下确实需要拿到多个锁再进行某个操作嵌套很难避免。2打破 - 循环等待这是比较通用的做法。要打破这个条件要做的就是约定好加锁的顺序。即我和朋友约定好喝饮料的相同顺序我们两先喝可乐等到我们都喝完可乐后再一起喝雪碧就是约定完共同的加锁顺序后所有线程都按照这个顺序去阻塞等待加锁这样就能避免死锁。另一个例子此时有5个线程5把锁此时约定好所有线程加锁都按照先获取序号小的锁再获取序号大的锁的顺序此时1~5线程都会想先去获取 序号1 的锁那么就会阻塞等待先获取用完的线程接着去获取下一个锁这样就不会触发死锁。3.Java标准库中的线程安全类Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.ArrayListLinkedListHashMapTreeMapHashSetTreeSetStringBuilder但是还有⼀些是线程安全的. 使用了⼀些锁机制来控制.Vector (不推荐使用)HashTable (不推荐使用)ConcurrentHashMapStringBuffer (不推荐使用)示例StringBuffer 的核心方法都带有 synchronized .以上三个线程安全类不推荐使用的原因加锁不是没有代价的一旦代码中使用了锁意味着可能会因为锁的竞争而产生阻塞那么程序执行的效率就会大大折扣线程阻塞就从CPU上调度走什么时候调度回来继续执行很不好说。因此是否加锁需要考虑清楚不要乱加锁。相比于HashTableConcurrentHashMap是它高优化后的版本后续再讲。还有的虽然没有加锁, 但是不涉及 修改, 仍然是线程安全的String4.volatile 关键字volatile 能保证 内存可见性(前面说过的 造成线程安全的原因之一)volatile 修饰的变量能够保证 内存可见性 。与synchronized 不同的是volatile 只能修饰变量且 volatile 解决的是 内存可见性 的问题而synchronized 解决的是 原子性 的问题。示例看运行结果虽然输入了非0的值但是此时 t1 线程循环并没有结束而是继续执行很明显这也是一个 bug是线程安全问题一个线程读取一个线程修改修改线程修改的值并没有被读取线程读取到这就是 内存可见性 的问题。造成内存可见性问题是由于编译器优化导致的我们写的代码 —— javac 把 .java 文件 编译生成 .class 字节码文件最后 JVM将字节码转化成平台能够理解的形式来运行研究JDK的大佬希望通过让编译器 和 JVM 对程序员写的代码自动进行优化本来写的代码是进行 xxxxx编译器/JVM 会在你原有逻辑不变的前提下对你的代码进行调整使得程序效率更高。编译器声称优化操作是能够保证逻辑不变的但是尤其是在多线程的程序中编译器的判断可能出现失误可能导致编译器优化后的逻辑和优化前的逻辑出现细节上的偏差。就像上述的示例代码线程 t1 中的 while 循环的执行需要2条指令load cmp其中 cmp指令指的是 flag0 这样的条件跳转指令。在短时间内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%以上但是如果引入 sleepsleep 直接占用了 1ms 此时优不优化 flag 变得无足轻重了。就像你的全部身家是500块拿丢了 100块影响还是非常大的但是如果身家是500万那你丢了 100块也就无所谓了。针对内存可见性问题也不能指望通过 sleep 来解决因为 sleep 会大大影响程序的效率在编译器优化的角度也很难去解决那么即不用 sleep 也不用调整编译器优化就能解决内存可见性问题 —— 就是使用 volatile 关键字。通过这个关键字来修饰某个变量此时编译器对这个变量的读取操作就不会被优化成读寄存器而是每次必须重新读取内存中的数据。此时 t2 线程修改了 flagt1 线程就能及时读取到————————————————在Java的官方文档即JMM(java内存模型)中也有对编译器优化做有官方描述每个线程有一个自己的工作内存同时这些内存共享一个主内存当一个线程循环进行上述的读取变量操作的时候就会把主内存中的数据拷贝到该线程的工作内存中后续另一个线程修改也是先修改自己的工作内存然后拷贝到主内存里由于第一个线程仍然在读自己的工作内存因此感知不到主内存的变化。我们前面讲的是把读内存的操作优化成了读寄存器的操作其实是同一个意思它们在抽象目标上是完全一致的——用更快的私有存储寄存器/工作内存替代对慢速共享主内存的重复访问从而提升性能但同时也引入了可见性问题。这里的主内存(main memory) 就基本的等同于 内存(RAM)而工作内存(work memory) 是 CPU寄存器缓存。寄存器虽然很快但是空间小存不了很多东西于是开发CPU的大佬就在CPU上创建了一些存储空间称为缓存。在CPU中一般都有三级缓存从 L1 到 L3速度越来越慢(再慢也比内存快)存储空间越来越大。在不同的CPU上用来缓存上述例子的内存数据的区域是不同的 —— 具体是存在寄存器里还是L1L2L3 上是不知道的但是对于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 永远阻塞。正确顺序应该是t1 先获得锁 → wait() → 释放锁并等待。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这样就能全部唤醒3notifyAll() 方法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.示例有三个线程分别只能打印AB和C要求按顺序打印ABC打印10次。