目录
三、线程安全问题(接上一篇博客)
5.Monitor
①Java对象头
②monitor原理(重点)
③monitor字节码角度
6.synchronized 原理进阶(重点)
①轻量级锁
②锁膨胀
③自旋优化
④偏向锁
偏向状态
撤销偏向-调用对象hashcode
撤销偏向-其他线程使用对象
撤销-调用wait/notify
批量重偏向
批量撤销
锁消除
7.wait notify
①原理(重点)
②api
③wait和notify的正确姿势
8.同步模式之保护性暂停
保护性暂停模式
原理之join
9.异步模式之生产者/消费者(重要)
10.park&unpark
11.重新理解线程状态转换(重点)
12.多把锁(了解)
1.活跃性
死锁
定位死锁(要会用)
活锁
饥饿
13.ReentrantLock(重点)
①可重入
②可打断
③锁超时
④公平锁
⑤条件变量
14.设计模式之顺序控制
①固定运行顺序
使用wait-notify
使用ReentrantLock
使用park&unpark
②交替输出(待补充)
使用wait¬ify(需要再次理解)
使用await&signal(需要再次理解)
使用park&unpark(需要再次理解)
15.总结
以 32 位虚拟机为例,普通对象的对象头结构如下,其中的 Klass Word 为指针,指向对应的 Class 对象;后面讲monitor这个Mark word会经常用到对象头;
普通对象:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
其中 Mark Word 结构为:(后面讲偏向锁,锁升级的时候会用到)
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
|thread:23|epoch:2| age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|
一个对象的结构大概如下:
这个对齐填充是为了保证对象与操作系统的位数相对应;从这里我们可以知道基本数据类型的包装类所花的空间要远远大于基本数据类型;因为包装类是一个对象,对象中会存储对象头等数据;
Monitor 被翻译为监视器或者说管程; 每个 java 对象都可以关联一个 Monitor【只要是同一个对象那么就会跟同一个monitor相关联;如果锁的对象不相同那么锁对象它们关联的monitor就不是同一个,就不会有阻塞线程的效果】; 如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。可以理解为加了重量级锁的Java对象会被monitor给监控;
刚开始时 Monitor 中的 Owner 为 null
当 Thread-2 执行 synchronized(obj){} 代码时就会将 Monitor 的所有者Owner 设置为 Thread-2,上锁成功,Monitor 中同一时刻只能有一个 Owner
当 Thread-2 占据锁时,如果线程 Thread-3 ,Thread-4 也来执行synchronized(obj){} 代码,就会进入 EntryList(阻塞队列) 中变成BLOCKED(阻塞) 状态
Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的所有线程来竞争锁,竞争时是非公平的
图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析
注意:
synchronized 必须是进入同一个对象的 monitor 才有上述的效果
不加 synchronized 的对象不会关联监视器,不遵从以上规则
这个需要jvm相关的知识,这里先了解一下就行;
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
上面代码的字节码:这里先了解一下就行;以后自己学了JVM再来关注字节码层面的东西;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized ,就是你是用synchronized 加锁的时候会优先使用synchronized 里面的轻量级锁方式来加锁,如果轻量级锁失败了才会使用重量级加锁;
假设有两个方法同步块,利用同一个对象加锁:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
1.线程每次指向到 synchronized 代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的 Mark Word 和对象引用 reference
2.让锁记录中的 Object reference 指向对象,并且尝试用 cas(compare and sweep) 的方式让Object 对象的 Hashcode Age Bias 01和lock record 地址 00 进行交换 ,将 Mark Word 的值存入锁记录中来完成加锁。
3.如果 cas 替换成功,那么对象的对象头储存的就是 锁记录的地址和状态 00 表示轻量级锁,如下所示:
4.如果cas失败(cas后面会讲),有两种情况:
如果是其它线程已经持有了该 Object 的轻量级锁(就是对象中的锁记录值不是01啦,01表示无锁的正常状态),那么表示有竞争,首先会进行自旋锁,自旋一定次数后,如果还是失败就进入锁膨胀阶段。
如果是自己的线程已经执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数(这里不再和object进行值交换,lock record的值是null)。
5.当线程退出 synchronized 代码块的时候,如果获取的是取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一;
6.当线程退出 synchronized 代码块的时候,如果获取的锁记录取值不为 null,那么使用 cas 将 Mark Word 的值恢复给对象
成功则解锁成功
失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
如果在尝试加轻量级锁的过程中,cas 操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。
1.当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁:
2.这时 Thread-1 加轻量级锁失败,进入锁膨胀流程:
即为对象申请Monitor锁(也就是说在之前对象里面是没有这个Monitor的引用地址的)(轻量级锁是没有阻塞这种说法的),让Object指向重量级锁地址
然后自己进入Monitor 的EntryList 变成BLOCKED状态
3.当 Thread-0 退出 synchronized 同步块时,使用 cas 将 Mark Word 的值恢复给对象头,此时会失败!这时会进入重量级解锁 流程,即通过object里面存储的 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 的线程;
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步 块,释放了锁),这时当前线程就可以避免阻塞。 就是当有线程来获取锁时发现锁记录对象中owner不是null,那么这个线程不会立马就进行锁膨胀,而是先通过自旋来获取锁(或者是通过自旋来等待持锁的线程释放锁),这样就可以先不用进入阻塞队列,这样可以不用进行上下文切换就获得了锁,大大的减少了性能的开销;
线程1 ( core 1上) | 对象Mark | 线程2 ( core 2上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
- | 10(重量锁)重量锁指针 | 成功(加锁) |
- | 10(重量锁)重量锁指针 | 执行同步块 |
- | ... | ... |
不建议在单核cpu中使用自旋,因为自旋是会占用cpu的,那么这个时候其他工作就没办法进行;
并且自旋的次数不宜太多,因为当有很多个线程在竞争获取锁,那么这些线程都进入自旋,那对cpu也是一个很大的开销!
自旋重试失败的情况:
线程1 ( core 1上) | 对象Mark | 线程2( core 2上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
- | ... | ... |
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会 高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
Java 7 之后不能控制是否开启自旋功能
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS ,会【将线程 ID 】设置到对象的 Mark Word 头,之后重入的时候发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有 ;
分析代码:比较轻量级锁与偏向锁
static final Object obj = new Object();
public static void m1() {
synchronized(obj) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized(obj) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized(obj) {
// 同步块 C
}
}
分析过程:
回忆一下对象头格式:
|--------------------------------------------------------------------|-------------------
| Mark Word (64 bits) | State
|--------------------------------------------------------------------|-------------------
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal
|--------------------------------------------------------------------|-------------------
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased
|--------------------------------------------------------------------|-------------------
| ptr_to_lock_record:62 | 00 | Lightweight Locked
|--------------------------------------------------------------------|-------------------
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked
|--------------------------------------------------------------------|-------------------
| | 11 | Marked for GC
|--------------------------------------------------------------------|-------------------
nomal表示锁是正常状态,没有被使用;
biased 表示锁是否为偏向状态,biased_lock:1 表示锁启用了偏向锁;biased_lock:0表示该锁没有启用偏向锁;
ptr_to_lock_record表示锁记录;
一个对象创建时:
如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数- XX:BiasedLockingStartupDelay=0
来禁用延迟
如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值;
禁用偏向锁:
在上面测试代码运行时在添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁 ;
调用了对象的 hashCode方法,会导致偏向锁被撤销,从而这个锁的状态变成normal ,这是因为偏向锁的对象 MarkWord 中存储了线程的 id,这个线程id在操作系统层面是占用54位的,如果你调用了该对象的hashcode,那么此时hashcode会被赋值并且需要存储到mark word,而且hashcode是占31位,如果不撤销偏向状态(会把偏向锁存储的线程id给清除),那么这个对象的hashcode将不能存放到该对象的mark word中;
轻量级锁的hashcode会存储在线程栈帧中的锁记录中
重量级锁的hashcode会存储在 Monitor对象中(重量级锁的mark word 在64位操作系统中最多也就只能存64位的数据)
在调用 hashCode 后使用偏向锁,记得去掉-XX:-UseBiasedLocking
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁;
轻量级锁和偏向锁的一个前提是:两个线程使用锁对象的时间是错开的,如果这两个线程直接来竞争锁,那么就会导致锁升级为重量级锁;
调用wait/notify也会导致偏向状态被撤销,因为wait和notify都是重量级锁才会有点方法;
调用这两个方法会导致锁膨胀变成重量级锁;
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象 的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至 另外的加锁线程;
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象 都会变为不可偏向的,新建的对象也是不可偏向的;
Java是一门解释加编译型语言,对字节码使用解释的方式进行执行,但是会对经常使用的热代码会使用 JIT(即时编译器)对我们的字节码进一步的优化,那么这个时候这个JIT就会判断你加的锁有没有生效,如果JIT判断到你这个锁加与不加都一样,那么它就会在优化的时候帮你把锁消除,那么在执行这个字节码文件的时候其实是没有这个锁的;当然我们可以通过配置(-XX:-EliminateLocks)来设置这个锁消除是否打开,默认是打开的;
比如:java -XX:-EliminateLocks -jar test.jar
Owner 线程发现条件不满足,调用 wait 方法(让条件不够(比如线程a工作需要到线程b初始化的对象,如果没有这个对象,那线程a就不能正常工作,所以为了提高效率就可以让线程a释放锁,去等待需要的条件被创建)的线程先释放锁去等待),即可进入 WaitSet 变为 WAITING 状态
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
BLOCKED 线程会在 Owner 线程释放锁时唤醒
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
注意区分:blocked中的线程是在等待锁的释放,而waiting状态的线程是已经获得过锁,但是由于一些条件又放弃了锁;
obj.wait()
让进入 object 监视器的线程到 waitSet 等待
obj.notify()
在 object 上正在 waitSet 等待的线程中【随机】挑一个唤醒
obj.notifyAll()
让 object 上正在 waitSet 等待的线程【全部】唤醒
wait和notify它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法;
代码测试:
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test1")
public class Test1 {
//创建一个锁对象
static final Object lock = new Object();
public static void main(String[] args) {
try {
//没有获取锁,直接运行代码
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果:
Exception in thread "main" java.lang.IllegalMonitorStateException
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:502)
at thread.Test1.main(Test1.java:18)
这样就不会报错了:
//要先获取锁,才能调用wait和notify
synchronized (lock){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
wait()方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到 notify 为止;
wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify;
开始之前先看看 sleep(long n)和wait(long n)的区别:
从api角度来看,sleep 是 Thread 方法,而 wait 是 Object 的方法
sleep 不需要强制和 synchronized 配合使用,但 wait 需要 和 synchronized 一起用
sleep 在睡眠的同时,如果加了synchronized,那么它是不会释放对象锁的(这个时候其他线程来获取锁对象就需要去entryList中等待锁的释放),但 wait 在等待的时候会释放对象锁;但是它们都会释放 CPU 资源。
它们 状态 TIMED_WAITING
使用 wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。
什么时候适合使用wait?
当线程的工作工程中需要到一些其他条件或者是数据时,此时这个线程又正在使用锁,如果它不释放锁那其他现场就只能一直等待,但是吧这个有锁的线程完成工作又需要用到其他线程初始化的条件,所以这个时候我们让这个有锁的线程使用 wait ,这样就会将对象的锁释放,让其他线程能够继续运行。如果此时使用 sleep,会导致所有线程都进入阻塞,导致所有线程都没法运行,直到当前线程 sleep 结束后,运行完毕,才能得到执行。
使用wait/notify需要注意什么? 当有多个线程在运行时,对象调用了 wait 方法,此时这些线程都会进入 WaitSet 中等待。如果这时使用了 notify 方法,可能会造成【虚假唤醒】(唤醒的不是满足条件的等待线程),这时就需要使用 notifyAll 方法
正确使用wait和notify:
synchronized (lock) {
while(//不满足条件,一直等待,避免虚假唤醒) {
lock.wait();
}
//满足条件后再开始干活
}
或者是直接唤醒所有waiting的线程
synchronized (lock) {
//唤醒所有等待线程,避免虚假唤醒
lock.notifyAll();
}
保护性暂停中的暂停就是条件不满足的时候就一直施行等待;
为什么要使用这个保护性暂停?
以前我们单纯的使用join来交互结果,那就必须要等待线程运行结束才能继续往下运行,如果我们使用这个保护性暂停,那么可以让执行完任务的线程去干其他的事情不用继续等待join的线程完成任务后才能运行;
join的局限性只能等待另一个线程运行完成后,才能继续去干其他事情;比如t1.join();那么当t2线程完成任务后执行到t1.join();这行代码,那么t2线程只能等待t1线程执行完成后才能继续去干其他事情;
使用join的话,等待结果的那个变量只能设置为全局变量;但是使用保护性暂停就可以把变量设置为局部变量;
模拟这个设计模式的大致实现:
class GuardedObject {
//结果
private Object response;
public Object get() {
synchronized (this) {
// 条件不满足则等待
while (response == null) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}
public void complete(Object response) {
synchronized (this) {
// 条件满足,通知等待线程
this.response = response;
this.notifyAll();
}
}
}
模拟测试:
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
try {
// 子线程执行下载
List response = download();
log.debug("download complete...");
guardedObject.complete(response); //这个时候其他线程可以继续去干其他事情了
} catch (IOException e) {
e.printStackTrace();
}
}).start();
log.debug("waiting...");
// 主线程阻塞等待
Object response = guardedObject.get();
log.debug("get response: [{}] lines", ((List) response).size());
}
对上面的GuardedObject进行增强,添加一个超时参数:
@Slf4j(topic = "c.GuardedObjectV2")
class GuardedObjectV2 {
private Object response;
public Object get(long TimeOut) { //传的参数表示需要等待多久 最大等待时间
synchronized (this) {
//记录最初时间
long begin = System.currentTimeMillis();
//已经经历的时间
long PassedTime = 0;
while (response == null) {
long waitTime = TimeOut - PassedTime;//这样可以算上虚假唤醒重新等待的时间
log.debug("waitTime: {}", waitTime);
//如果经历的时间超过了最大等待时间就退出循环,直接去执行get下面的方法
if (PassedTime >= TimeOut) {
log.debug("break...");
break;
}
//等待时间在最大等待时间范围类
try {
this.wait(waitTime); //如果超过等待时间该线程就会进入阻塞状态,在阻塞队列中去和其他线程抢锁,就是不等了
} catch (InterruptedException e) {
e.printStackTrace();
}
//求得经历的时间
PassedTime = System.currentTimeMillis() - begin;
log.debug("PassedTime: {}",PassedTime);
}
return response;
}
}
public void complete(Object response) {
synchronized (this) {
// 条件满足,通知等待线程
this.response = response;
log.debug("notify...");
this.notifyAll();
}
}
}
代码测试:
package thread;
import lombok.extern.slf4j.Slf4j;
/**
* @author LJM
* @create 2022/4/30
*/
@Slf4j(topic = "c.Test2")
public class Test2 {
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(()->{
log.info("t1线程开始");
Object response = guardedObject.get(2000);
log.debug("结果是:{}",response);
},"t1").start();
new Thread(()->{
log.debug("t2线程开始");
try {
Thread.sleep(1000); //如果再把1000该为3000测试一下
} catch (InterruptedException e) {
e.printStackTrace();
}
guardedObject.complete(new Object()); //然后测试一下虚假唤醒 把这个参数该为null
},"t2").start();
}
}
在等待时间内的结果:主要是看运行的时间差
11:21:19 [t1] c.Test2 - 开始
11:21:19 [t2] c.Test2 - 开始 //超过最大等待时间后,就不等待了,直接往下继续执行
11:21:20 [t1] c.Test2 - 结果是:java.lang.Object@176414fd
把睡觉时间改为3000,超过最大等待时间:
11:23:04 [t2] c.Test2 - 开始
11:23:04 [t1] c.Test2 - 开始
11:23:06 [t1] c.Test2 - 结果是:null
源码:等待时长的实现类似于之前的保护性暂停,就是条件不满足的时候就一直施行等待;
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
//只要是这个线程还是活跃的,那就一直等待
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
//区别就是没有去唤醒其他等待的线程
}
}
}
与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
消费队列可以用来平衡生产和消费的线程资源
生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
JDK 中各种阻塞队列,采用的就是这种模式
“异步”的意思就是生产者产生消息之后消息没有被立刻消费,而“同步模式”中,消息在产生之后被立刻消费了。
这个消息队列和我们以后学的中间件,比如MQ是不一样的,MQ这个是用于进程之间的通讯的,我们今天实现的这个是用于线程之间的通讯的,比较简单;
线程之间通讯一般id是非常重要的,所以我们要先创建一个用来存储和交互数据用的类:
package thread;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedList;
@Slf4j(topic = "c.MessageQueue") //如果这里不用这个(topic = "c.MessageQueue"),那么控制台是打印不出这里的日志消息的
public class MessageQueue {
//消息队列集合
private LinkedList list;
//队列容量
private int capacity;
public MessageQueue(int capacity) {
this.capacity = capacity;
list = new LinkedList<>();
}
//获取消息
public Message take() {
synchronized (list) {
//检测队列是否为空,如果为空,就等待
while (list.isEmpty()) {
log.debug("队列为空,消费者线程等待");
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//从队列的头部获取消息返回
Message message = list.removeFirst();
log.debug("以消费消息{}",message);
list.notifyAll();
return message;
}
}
//存入消息
public void put(Message message) {
synchronized (list) {
while (list.size() == capacity) {
log.debug("队列以满,生产者线程进入等待");
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
list.addLast(message);
log.debug("以生产消息{}",message);
list.notifyAll();
}
}
}
//封装用来传输数据用的实体类
class Message {
private int id;
private Object message;
public Message(int id, Object message) {
this.id = id;
this.message = message;
}
public int getId() {
return id;
}
public Object getMessage() {
return message;
}
}
测试:
package thread;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedList;
import java.util.List;
@Slf4j(topic = "c.QueueTest") //如果这里不用这个(topic = "c.QueueTest"),那么控制台是打印不出东西来的
public class QueueTest {
public static void main(String[] args) {
MessageQueue messageQueue = new MessageQueue(3);
// 6个生产者线程, 下载任务
for (int i = 0; i < 4; i++) {
int id = i; //这个变量不能放在这个lambda表达式中,否则会报错
new Thread(() -> {
try {
log.debug("download...");
//让线程睡眠模拟下载东西
Thread.sleep(10000);
//模拟下载的结果
List response = new LinkedList<>();
log.debug("try put message({})", id);
messageQueue.put(new Message(id, response));
} catch (Exception e) {
e.printStackTrace();
}
}, "生产者" + i).start();
}
// 1 个消费者线程, 处理结果
new Thread(() -> {
//只要消息队列中有消息,消费者就继续消息
while (true) {
Message message = messageQueue.take();
List response = (List) message.getMessage();
log.debug("take message({}): [{}] lines", message.getId(), response.size());
}
}, "消费者").start();
}
}
park & unpark 是 LockSupport 线程通信工具类的静态方法。
使用了park方法的线程也是wait状态;
基本使用:
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark;
与 Object 的 wait & notify 相比
wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必;
park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】;
park & unpark 可以先 unpark也能恢复park之后的线程,而 wait & notify 不能先 notify;
park unpark 原理:
每个线程都有自己的一个 Parker 对象(由C++编写,java中不可见),由三部分组成 _counter
, _cond
和 _mutex
打个比喻
线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中 的备用干粮(0 为耗尽,1 为充足)
调用 park 就是要看需不需要停下来歇息
如果备用干粮耗尽,那么钻进帐篷歇息
如果备用干粮充足,那么不需停留,继续前进
调用 unpark,就好比令干粮充足
如果这时线程还在帐篷,就唤醒让他继续前进
如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
情况:
1.先调用park:
当前线程调用 Unsafe.park() 方法
检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
线程进入 _cond 条件变量阻塞
设置 _counter = 0
2.调用unpark
调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
唤醒 _cond 条件变量中的 Thread_0
Thread_0 恢复运行
设置 _counter 为 0
3.先调用upark再调用park的过程
调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
当前线程调用 Unsafe.park() 方法
检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
设置 _counter 为 0
假设有线程 Thread t
情况 1 NEW --> RUNNABLE
【new的状态是创建了线程,但是创建的线程此时还没有和操作系统关联起来】
当调用 t.start()
方法时,状态会由由 NEW --> RUNNABLE 【此时线程和操作系统开始关联起来】
情况 2 RUNNABLE <--> WAITING
(重要)
t 线程用 synchronized(obj)
获取了对象锁后
调用 obj.wait()
方法时,t 线程从 RUNNABLE --> WAITING
调用obj.notify(),obj.notifyAll(),t.interrupt()时,需要重新进入entryList和其他线程竞争锁
竞争锁成功,t 线程从 WAITING --> RUNNABLE
竞争锁失败,t 线程从 WAITING --> BLOCKED
(blocked是线程去竞争锁时没有获取到锁,从而进入了这个状态)
//测试代码:不过注意点是在debug启动的时候,可能显示的状态和我们前面讲的不一样,这是调试工具的问题,它自己对相关的线程状态又取了一个名字
public class TestWaitNotify {
final static Object obj = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码...."); // 断点
}
},"t1").start();
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码...."); // 断点
}
},"t2").start();
sleep(0.5);
log.debug("唤醒 obj 上其它线程");
synchronized (obj) {
obj.notifyAll(); // 唤醒obj上所有等待线程 断点
}
}
}
情况 3 RUNNABLE <--> WAITING
当前线程调用 t.join()
方法时,当前线程从 RUNNABLE --> WAITING
注意是当前线程在t 线程对象的监视器上等待,这里的当前线程是指调用join()方法的这个线程,并不是指t线程!!!
t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
情况 4 RUNNABLE <--> WAITING
当前线程调用 LockSupport.park()
方法会让当前线程从 RUNNABLE --> WAITING
调用 LockSupport.unpark
(目标线程) 或调用了线程 的 interrupt()
,会让目标线程从 WAITING --> RUNNABLE
情况 5 RUNNABLE <--> TIMED_WAITING
t 线程用 synchronized(obj)
获取了对象锁后
调用 obj.wait(long n)
方法时,t 线程从 RUNNABLE --> TIMED_WAITING
t 线程
等待时间超过了 n 毫秒,或调用 obj.notify(),obj.notifyAll(),t.interrupt()时
竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
情况 6 RUNNABLE <--> TIMED_WAITING
当前线程调用 t.join(long n)
方法时,当前线程从 RUNNABLE --> TIMED_WAITING
注意是当前线程在t 线程对象的监视器上等待
当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt()
时,当前线程从 TIMED_WAITING --> RUNNABLE
情况 7 RUNNABLE <--> TIMED_WAITING
当前线程调用 Thread.sleep(long n)
,当前线程从 RUNNABLE --> TIMED_WAITING
当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE
情况 8 RUNNABLE <--> TIMED_WAITING
当前线程调用 LockSupport.parkNanos(long nanos)
或 LockSupport.parkUntil(long millis)
时,当前线程从 RUNNABLE --> TIMED_WAITING
调用 LockSupport.unpark
(目标线程) 或调用了线程 的 interrupt()
,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE
情况 9 RUNNABLE <--> BLOCKED
t 线程用 synchronized(obj)
获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED
的线程重新竞争,如果其中 t 线程竞争 成功,从 BLOCKED --> RUNNABLE
,其它失败的线程仍然 BLOCKED
情况 10 RUNNABLE <--> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
将锁的粒度细分(就是许多方法使用的锁都是同一把锁,然后容易导致线程的并发度变低,不同线程来访问需要等使用锁的线程释放锁,其他线程才能继续使用,然后为提高并发度我们就把不同方法或者是加了锁的代码块使用不同的锁,这种操作就是把锁的粒度细分了)
好处,是可以增强并发度
坏处,如果一个线程需要同时获得多把锁,就容易发生死锁
前提:两把锁锁住的两段代码互不相关
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁 如:
t1 线程已经获得了 A 对象锁,接下来想获取 B 对象的锁。
t2 线程已经获得了 B 对象锁,接下来想获取 A 对象的锁。
案例:
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
log.debug("lock A");
sleep(1);
synchronized (B) {
log.debug("lock B");
log.debug("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
log.debug("lock B");
sleep(0.5);
synchronized (A) {
log.debug("lock A");
log.debug("操作...");
}
}
}, "t2");
t1.start();
t2.start();
运行结果:导致代码一直卡在一个地方,不继续往下执行;
检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁:
使用jps:
1.cmd > jps 或者是在idea的local窗口输入 jps (一样的效果)
2.上面的命令行执行成功后,就会在控制台打印正在运行的Java程序的id
3.我们对发生死锁的类的id进行更加深入的了解,在控制台找到发生死锁的类的进程id,然后使用命令:
jstack 要查看的java进程id 回车 然后就可以在控制台看见所有的Java线程的信息和运行状态;
使用jconsole工具:
在电脑的左下角的位置输入jconsole,然后就可以打开这个工具;
然后就可以直接选择我们要连接的进程,然后等待连接就可以,连接成功后就可以在左下角看见线程运行的一些信息,并且还有一个位置的按钮就是检查死锁,点击该按钮就可以自动帮我们进行死锁检测;
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如:
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}
解决方式:
错开线程的运行时间,使得一方不能改变另一方的结束条件。
将睡眠时间调整为随机数。
某些线程因为优先级太低,导致一直无法获得资源的现象。在使用顺序加锁时,可能会出现饥饿现象
说明:
顺序加锁可以解决死锁问题,但也会导致一些线程一直得不到锁,产生饥饿现象。
解决方式:ReentrantLock
介绍:先对于synchronized它具备如下条件:这个是api层面的
可中断
可以设置超时时间
可以设置为公平锁
支持多个条件变量
与 synchronized 一样,都支持可重入;
基本语法:
1.首先要创建一个reentrantlock对象,synchronized是在关键字级别保护线程安全,而reentrantlock是在对象级别保护线程安全;
2.然后在调用创建的reentrantlock的lock方法;
3.再然后try ....catch...
// 获取ReentrantLock对象 注意真正的锁是对象关联的monitor
private ReentrantLock lock = new ReentrantLock();
// 加锁 如果线程获取锁成功那么该线程就会成为这个线程的主人,如果获得不了,那么线程也会进入这个lock中的一个阻塞队列中等待 这个lock锁对象取代了原来的 普通对象加monitor
lock.lock();
try {
// 需要执行的代码
}finally {
// 释放锁 一定要记得释放锁,这个需要自己主动来释放锁
lock.unlock();
}
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住,就是说需要重新去和其他线程竞争锁的使用
代码演示:
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
method1();
}
public static void method1() {
lock.lock();
try {
log.debug("execute method1");
method2();
} finally {
lock.unlock();
}
}
public static void method2() {
lock.lock();
try {
log.debug("execute method2");
method3();
} finally {
lock.unlock();
}
}
public static void method3() {
lock.lock();
try {
log.debug("execute method3");
} finally {
lock.unlock();
}
}
可打断指的是处于阻塞状态等待锁的线程可以被打断等待。注意lock.lockInterruptibly()
和lock.trylock()
方法是可打断的,lock.lock()
不是。
可打断的意义在于避免得不到锁的线程无限制地等待下去,防止死锁的一种方式。
处于阻塞状态的线程,被打断了就不用阻塞了,我们可以在捕获打断异常后直接停止该线程的运行;
注意如果是不可中断模式,其他线程即使使用了 interrupt 也不会让等待中断;
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
try {
System.out.println("尝试获取锁");
// 加锁,可打断锁 开启可打断模式
lock.lockInterruptibly();//如果没有竞争那么此方法就会获取到lock对象锁,如果有竞争那就进入阻塞队列,【可以被其他线程用interrupt方法打断】;注意如果是不可中断模式,那么即使使用了 interrupt 也不会让等待中断
} catch (InterruptedException e) {
e.printStackTrace();
// 被打断,返回,不再向下执行
System.out.println("没有获取到锁,返回");
return;
}finally {
// 释放锁
lock.unlock();
}
});
//让主线程获取到锁
lock.lock();
try {
t1.start();
Thread.sleep(1000);
// 在主线程打断t1线程
t1.interrupt();
System.out.println("打断t1线程");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
使用 lock.tryLock() 方法会返回获取锁是否成功。返回一个布尔值,如果成功则返回 true ,反之则返回 false 。 并且 tryLock 方法可以指定等待时间,参数为:tryLock(long timeout, TimeUnit unit), 其中 timeout 为最长等待时间,TimeUnit 为时间单位
如果tryLock()获取锁失败了、获取超时了或者被打断了,不再阻塞,线程直接停止运行
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j(topic = "c.Test3")
public class Test3 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
if (!lock.tryLock()) {
log.debug("获取立刻失败,返回");
return; //当然这里可以不return,但是也需要把finally中的代码块lock.unlock();给注释掉否则会报锁对象的monitor错误,因为人家压根没有获取到锁,你还去释放锁。。。。
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
//让主线程获取锁
lock.lock();
log.debug("获得了锁");
t1.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
log.debug("释放了锁");
lock.unlock();
}
}
}
10:37:02 [main] c.Test3 - 获得了锁
10:37:02 [t1] c.Test3 - 启动...
10:37:02 [t1] c.Test3 - 获取立刻失败,返回
在线程获取锁失败,进入阻塞队列时,先进入的线程会在锁被释放后先获得锁。这样的获取方式就是公平的。
但是ReentrantLock 默认是不公平锁;
// 默认是不公平锁,需要在创建时指定为公平锁
ReentrantLock lock = new ReentrantLock(true);
公平锁一般没有必要,会降低并发度,后面分析原理时会讲解;
synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比
synchronized 是那些不满足条件的线程都在一间休息室等消息
而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤 醒
使用要点
await 前需要获得锁
await 执行后,会释放锁,进入 conditionObject 等待
await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
竞争 lock 锁成功后,从 await 后继续执行
比如,必须先 2 后 1 打印;
// 用来同步的对象
static Object obj = new Object();
// t2 运行标记, 代表 t2 是否执行过
static boolean t2runed = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (obj) {
// 如果 t2 没有执行过
while (!t2runed) {
try {
// t1 先等一会
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
System.out.println(1);
});
Thread t2 = new Thread(() -> {
synchronized (obj) {
System.out.println(2);
// 修改运行标记
t2runed = true;
// 通知 obj 上等待的线程(可能有多个,因此需要用 notifyAll)
obj.notifyAll(); //如果只有两个线程的话使用obj.notify就行
}
});
t1.start();
t2.start();
}
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j(topic = "c.Test3")
public class Test3 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
try {
if (!lock.tryLock() || !lock.tryLock(2000, TimeUnit.SECONDS)) {
log.debug("获取失败,返回");
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
log.debug("获得了锁");
System.out.println("1");
} finally {
lock.unlock();
}
}, "t1");
//让主线程先获取锁
try {
lock.lock();
log.debug("获得了锁");
System.out.println("2");
} finally {
lock.unlock();
}
t1.start();
}
}
Thread t1 = new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) { }
// 当没有『许可』时,当前线程暂停运行;有『许可』时,用掉这个『许可』,当前线程恢复运行
LockSupport.park();
System.out.println("1");
});
Thread t2 = new Thread(() -> {
System.out.println("2");
// 给线程 t1 发放『许可』(多次连续调用 unpark 只会发放一个『许可』)
LockSupport.unpark(t1); //恢复暂停的线程
});
t1.start();
t2.start();
park 和 unpark 方法比较灵活,他俩谁先调用,谁后调用无所谓。并且是以线程为单位进行『暂停』和『恢复』, 不需要『同步对象』和『运行标记』;
题目:
线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现?
在上面的题目通过使用布尔变量来实现两个状态的输出,但是现在是有三个状态,所以使用布尔是不合适的,但是我们可以借用试用版布尔的思想,使用布尔是为了标记,那我们可以自己定义标记;
定义标记类:
class SyncWaitNotify {
private int flag; //当前线程的等待标记
private int loopNumber; //下一个线程需要的标记,可以 通过这个值来设置循环次数
public SyncWaitNotify(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}
//等待标记 下一个标记 打印内容
public void print(int waitFlag, int nextFlag, String str) {
for (int i = 0; i < loopNumber; i++) {
synchronized (this) {
//条件不成立
while (this.flag != waitFlag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(str);
flag = nextFlag;
this.notifyAll();
}
}
}
}
测试代码:
SyncWaitNotify syncWaitNotify = new SyncWaitNotify(1, 5);
new Thread(() -> {
syncWaitNotify.print(1, 2, "a");
}).start();
new Thread(() -> {
syncWaitNotify.print(2, 3, "b");
}).start();
new Thread(() -> {
syncWaitNotify.print(3, 1, "c");
}).start();
本章我们需要重点掌握的是
分析多线程访问共享资源时,哪些代码片段属于临界区
使用 synchronized 互斥解决临界区的线程安全问题
掌握 synchronized 锁对象语法
掌握 synchronzied 加载成员方法和静态方法语法
掌握 wait/notify 同步方法
使用 lock 互斥解决临界区的线程安全问题
掌握 lock 的使用细节:可打断、锁超时、公平锁、条件变量
学会分析变量的线程安全性、掌握常见线程安全类的使用
线程安全类的方法是原子性的,但方法之间的组合要具体分析。
了解线程活跃性问题:死锁、活锁、饥饿。
解决死锁、饥饿的方式:ReentranLock
应用方面
互斥:使用 synchronized 或 Lock 达到共享资源互斥效果
同步:使用 wait/notify 或 Lock 的条件变量来达到线程间通信效果
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。 同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。
原理方面(必须掌握)
monitor、synchronized 、wait/notify 原理
synchronized 进阶原理
park & unpark 原理
模式方面
同步模式之保护性暂停
异步模式之生产者消费者
同步模式之顺序控制