共享模型之管程
共享问题
共享带来的问题
如果有两个线程需要操作同一个变量,在线程A对该变量进行操作时,由于时间片结束,让出线程 ,并没有将对变量的操作(线程A对于变量的中间状态)保存回该变量,此时线程B获得时间片,对尚未更新的变量进行操作,并将结果进行保存,随后让出时间片,线程A获得时间片,随后将上次运行的中间状态进行存储(覆盖了线程B的运行结果),造成最终数据不一致
Java的体现
两个线程对初始值为0的静态变量一个做自增,一个做自减,各做5000次,结果是0嘛?
public class Main {
public static int cuounter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
cuounter--;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
cuounter++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(cuounter);
}
}
问题分析
以上结果可能是正数、负数、零,因为Java中对静态变量的自增,自减并不是原子操作,要彻底理解,需要从字节码来分析
例如对于i++而言(i为静态变量),实际会产生如下的JVM字节码指令
9 getstatic #29 <org/example/test/Main.cuounter : I> //获取静态变量i的值
12 iconst_1 //准备常量1
13 iadd //自增
14 putstatic #29 <org/example/test/Main.cuounter : I> //将修改后的值存入静态变量i
而相应的i--也是类似
Java完成静态变量的自增,自减操作需要在主存和工作内存中进行数据交换
如果是单线程执行上述8行代码(自增自减)是顺序执行的 ,不会交错(没有问题)
临界区
一个程序运行多个程序本身是没有问题的,问题出现在多个线程访问共享资源,多个线程读共享资源其实也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码为临界区
例如,下面代码中的临界区
static int count = 0;
static void increment()
//临界区
{
count++;
}
static void decrement()
//临界区
{
count--;
}
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
synchronized
为了避免临界区的竞态条件发生,有多种手段可以达到目的
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
此处使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其他线程再想获得这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
注意虽然Java中互斥和同步都可以采用synchronized关键字来完成,但它们还是有区别的,互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码,同步是由于线程执行的先后,顺序不同、需要一个线程等待其他线程运行到某个点
语法
synchronized(对象) {
临界区
}
原理
简单来说,代码中如果存在synchronized,第一个线程A运行到synchronized时会拿到一个synchronized参数中的“钥匙”,这个钥匙只有一把,线程A拿到之后进入代码块中执行代码,如果没有执行完时间片到了,其他线程也陆续到达synchronized时,会尝试获取这个唯一的“钥匙”,但是该钥匙已经被线程A拿到,所以后续线程进入阻塞状态,线程A接着运行时,将同步代码块中的代码执行完毕后将锁对象中的“钥匙”还给同步代码块
思考
synchronized实际是使用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断
方法上的synchronized
普通方法上的synchronized实际上是锁住的this,静态方法的synchronized实际上锁住的是类的字节码对象
线程安全分析
成员变量和静态变量
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能改变,分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
局部变量线程安全分析
public static void test1() {
int i = 10;
i++;
}
每个线程调用test1()方法时局部变量i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
局部变量的引用稍有不同
public class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
//临界区,会产生竞态条件
method2();
method3();
}
}
private void method3() {
list.remove(0);
}
private void method2() {
list.add("1");
}
}
会存在一种问题,如果线程2还未add,线程1remove就会报错
分析:
- 无论哪个线程的
method2引用的都是同一个对象中的list成员变量 method3与method2分析相同- 如果在
method1中创建list对象,method2与method3改为参数传递,则不会存在线程安全问题(此时没有暴露给外部)、
常见的线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- Java.util.concurrent包下的类
这里说的线程安全是指,多个线程调用它们同一个实例的某个方法时,是线程安全的
线程安全类方法的组合
Hashtable table = new Hashtable();
//线程1,线程2
if(table.get("key" == null)) {
table.put("key",value);
}
像上述代码会存在线程安全问题
线程1和线程2有可能会覆盖存储键为key的值
不可变类线程安全性
String、Integer等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
买票实例
@Slf4j(topic = "c.ExerciseSell")
public class ExerciseSell {
public static void main(String[] args) throws InterruptedException {
//模拟多人买票
TicketWindow window = new TicketWindow(1000);
//卖出的票数统计
List<Integer> amountList = new Vector<>();
//所有线程的集合
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 2000; i++) {
Thread thread = new Thread(() -> {
//买票
int amount = window.sell(randomAmount());
amountList.add(amount);
});
thread.start();
threadList.add(thread);
}
//等待所有的线程执行完毕
for (Thread thread : threadList) {
thread.join();
}
//统计卖出的票数和剩余票数
log.debug("余票:{}",window.getCount());
log.debug("卖出的票:{}",amountList.stream().mapToInt(i->i).sum());
}
public static int randomAmount(){
return new Random().nextInt(5) + 1;
}
}
class TicketWindow {
//票数
private int count;
//构造函数
TicketWindow(int count){
this.count = count;
}
//获取余票
public int getCount(){
return count;
}
//售票
public synchronized int sell(int amount){
if (this.count >= amount){
this.count -= amount;
return amount;
}else {
return 0;
}
}
}
对于上述代码中,卖出票统计的结果使用Vector,因为其在多线程内部使用,有线程安全问题,使用Vector解决线程安全问题,而线程集合只在主线程中使用,不存在多线程问题,故使用ArrayList
sell方法可以看到对共享变量进行了读写操作,属于临界区,需要使用synchronized进行修饰,防止其出现线程问题
Monitor概念
Java对象头
以32位虚拟机为例
普通对象
| Object Header | (64bits) |
|---|---|
| Mark Word(32bits) | Klass Word(32bits) |
数组对象
| Object | Header | (96bits) |
|---|---|---|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
其中Mark Word结构为
- hashcode:哈希值
- age:GC分代年龄
- biased_lock:偏向锁标记
- XX:锁状态标记
- thread:持有偏向锁的线程id
- epoch:偏向锁的时间戳
- ptr_to_lock_record:轻量级状态下,指向栈中锁记录的指针
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针
Monitor
Monitor被翻译为监视器或管程
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级锁)之后,该对象头的Mark Word中就被设置指向Monitor对象指针
| Monitor |
|---|
| WaitSet:指已经获取一次锁,对象调用了wait方法,将当前线程挂起就进入了等待队列,等待时间到期的时候唤醒,或者其他线程唤醒,重新开始锁竞争 |
| EntryList:是队列用来获取用来获取锁的缓冲区,用来将cxq和waitSet中的数据,移动到entryList进行排队,这个统一获取锁的入口,一般是cxq或者waitSet数据复制过来进行统一排队 |
| Owner:指向的是当前获得线程的地址,用来判断当前锁是被哪个线程持有 |
- 刚开始Monitor中Owner为null
- 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
- 在Thread-2上锁的过程中,如果Thread3,Thread4,Thread5也来执行synchronized(obj),就会进入RntryList BLOCKED
- Thread2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的
- WaitSet是之前获得过锁,但条件不满足进入WAITING状态的线程
注:
- synchronized必须是进入同一个对象的monitor才有上述的效果
- 不加synchronized的对象是不会关联监视器,不遵从以上规则
synchronized的原理
static final Object lock = new Object();
static int count = 0;
public static void main(String args[]) {
synchronized(lock){
count++;
}
}
对应的字节码为:
static final Object lock = new Object();
static int count = 0;
0 getstatic #7 <org/example/test1/Main.lock : Ljava/lang/Object;> //lock引用
3 dup
4 astore_1 //lock引用 -> slot1
5 monitorenter //将lock对象MarkWord置为Monitor指针
6 getstatic #13 <org/example/test1/Main.count : I> //i
9 iconst_1 //准备常数1
10 iadd //+1
11 putstatic #13 <org/example/test1/Main.count : I> //返回给i
14 aload_1 //slot1 -> lock引用
15 monitorexit //将lock对象MarkWord重置,唤醒EntryList
16 goto 24 (+8) //正常返回
19 astore_2 //将异常信息 e -> slot2
20 aload_1 //slot1 -> lock引用
21 monitorexit //将lock对象MarkWord重置,唤醒EntryList
22 aload_2 //将异常信息 slot2 -> e
23 athrow //抛出异常信息e
24 return
异常捕获表如下
| Nr. | 起始PC | 结束PC | 跳转PC | 捕获类型 |
|---|---|---|---|---|
| 0 | 6 | 16 | 19 | any |
| 1 | 19 | 22 | 19 | any |
我们发现,在同步代码块中出现异常,锁依然会正确释放开
锁原理
- 实验室-对象
- 实验室管理员-操作系统
- A班-线程
- B班-线程
- 门锁-Monitor
- 班级档案-轻量级锁
- 写班级名称-偏向锁
- 批量写班级名称-一个类的偏向锁撤销达到20阈值
- 不能写班级名字-批量撤销该类对象的偏向锁,设置该类为不可偏向
A班要使用实验室做实验,保证不被其他人打扰(原子性),最初使用的是门锁,当上下文切换时,锁住门,这样即使离开了实验室,别人也进不了门,实验就是安全的
但是很多情况下,没有别人和他竞争,每次使用门锁就比较麻烦,B班会用实验室,但是使用的时间是错开的,于是两个班就商量,在门口放个盒子,里面放上自己班的档案,以后只需要进门前看看盒子里是哪个班的档案,如果是自己的,那么就可以进门,这样就省的上锁了,万一盒子里档案不是自己班的,那么就在门外等,并通知对方下次用锁门的方式。
后来,A班放假了,B班还是每次都放盒子,开盒子,虽然比锁门省事,但是还是觉得麻烦,于是B班就在实验室门上写上B班的名字。以后只要名字还在,就是没有人来过,如果有别的班级来过,只需要将名字擦除,然后升级为放盒子的方式
后来,B班发现没有别的班跟自己用同一间实验室后,B班就膨胀了,在20个实验室都写上了B班的名字,想进哪个进哪个,后来A班回来了,结果就是需要一个一个地擦掉B班的名字,升级为放盒子的方式,实验室管理员觉得这陈本有点高,于是就让A班也不用放盒子了,可以直接写上自己班的名字
后来,写名字的现象越来越频繁,实验室管理员受不了了,就不再让班级写名字,只能挂书包
原理进阶
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是synchronized
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized(obj) {
//同步块A
method2();
}
}
public static void method2() {
synchronized(obj) {
//同步块B
}
}
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的MarkWord
-
让锁记录中Object reference指向锁对象,并尝试使用cas替换Object的Mark Word,将Mark Word的值存入锁记录
-
如果cas替换成功,对象头中存储了
锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下: -
如果cas失败,有两种情况
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
- 当退出synchronized代码块(解锁时)如果有取值为null的记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
- 这时Thread-1加轻量级锁失败,进入锁膨胀流程
- 即为Object对象申请Monitor锁,让Object指向重量级锁地址
- 然后自己进入Monitor的EntryList BLOCKED
- 当Thread-0退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头,失败。这时会进入重量级锁解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功,(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
自旋重试成功的情况
| 线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步代码块 | 10(重量锁)重量锁指针 | - |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 访问同步块,获取monitor |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行完毕 | 01(无锁) | 自旋重试 |
| - | 10(重量锁)重量锁指针 | 成功(加锁) |
| - | 10(重量锁)重量锁指针 | 执行同步块 |
自旋重试成功的情况
| 线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步代码块 | 10(重量锁)重量锁指针 | - |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 访问同步块,获取monitor |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步代码块 | 10(重量锁)重量锁指针 | 自旋重试 |
| - | 10(重量锁)重量锁指针 | 阻塞 |
- 在Java6之后自旋锁时自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之就少自旋甚至不自旋,总之,比较智能
- 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
- Java7之后不能控制是否开启自旋功能
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作
Java6中引入了偏向锁来做一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Work头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程所有
偏向状态
一个对象创建时:
- 如果开启了偏向锁(默认开启,在JDK15后默认关闭,可以通过-XX:-UseBiasedLocking进行配置启用还是禁用),那么对象创建后,markword值为0x05即最后3位101,这时它的thread、epoch、age都是0
- 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword值为0x01即最后3位为001,这时它的hashcode、age都为0,第一次用到hashcode才会赋值
在调用偏向锁对象的hashcode方法时,会撤销该对象的偏向锁状态,因为偏向锁的markword无法存下hashcode值,hashcode值也是懒加载的,只有在使用到的时候才会体现在markword头中
轻量级锁的hashcode会存在线程栈帧的锁记录里,重量级锁的hashcode会存在Monitor中
撤销-其他线程使用对象
当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
撤销-调用wait/notify
只有重量级锁有wait/notify,所以肯定会升级
撤销-全局安全点
偏向锁的撤销需要等到全局安全点,安全点定义如下:
- 安全点是在程序执行期间的所有
GC Root已知并且所有堆对象的内容一致的点 - 从全局的角度来看,所有线程必须在GC运行之前在安全点阻塞
- 从本地的角度来看,安全点是一个显着的点,它位于执行线程可能阻止GC的代码块中。大多数调用点都能当作安全点
- 在每个安全点都存在强大的不变量永远保持true不变,而在非安全点可能会被忽视。编译的Java代码和C++代码都在安全点之间进行了优化,但跨安全点时却不那么优化。JIT编译器在每个安全点发出GC映射。VM中的C/C++代码使用程式化的基于宏的约定来标记潜在的安全点
总的来说,安全点就是指,当线程运行到这类位置时,堆对象状态是确认一致的,JVM可以安全的进行操作,如GC,偏向锁解除等
在偏向锁撤销时,若当前锁偏向线程A,而线程B需要持有该锁,会在安全点进行判断线程A是否执行完
- 线程A执行完,锁重新偏向线程B(批量重偏向情况)
- 线程A没有执行完,锁升级为轻量级锁,线程A仍然持有轻量级锁,继续执行同步代码块中的其余代码
批量重偏向
如果对象虽然被多个线程访问,但是没有竞争,这时偏向线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
当撤销偏向锁阈值超过20次后,jvm会认为,偏向有问题,于是会在给这些对象加锁时重新偏向至加锁线程
一个类有30个对象,线程A对这30个对象进行加锁(偏向锁),随后线程A停止对这30个对象进行访问(释放锁资源),线程B需要访问这30个对象,逐步进行加锁操作,会将撤销偏向锁,当撤销偏向锁的次数超过20次后,jvm会进行批量重偏向,将后续(已经置为轻量级锁的不会重新回到偏向锁,不能锁降级)的锁对象重新开始置为偏向锁,偏向的线程为最后申请锁的线程
maven依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>
工具类
public class JolUtils {
public static String toPrintableSimple(Object o) {
return getHeader64Bit(o);
}
public static void main(String[] args) {
System.out.println( getHeader64Bit(new Object()));
}
public static String getHeader64Bit(Object o) {
VirtualMachine vm = VM.current();
long word = vm.getLong(o, 0);
List<String> list = new ArrayList<>(8);
for (int i = 0; i < 8; i++) {
list.add(toBinary((word >> i * 8) & 0xFF) );
}
Collections.reverse(list);
return String.join(" ",list);
}
// very ineffective, so what?
private static String toBinary(long x) {
StringBuilder s = new StringBuilder(Long.toBinaryString(x));
int deficit = 8 - s.length();
for (int c = 0; c < deficit; c++) {
s.insert(0, "0");
}
return s.toString();
}
}
测试类
@Slf4j(topic = "c.Main")
public class Main {
public static void main(String[] args) throws InterruptedException {
List<Dog> dogList = new ArrayList<>();
for (int i = 0; i < 30; i++) {
dogList.add(new Dog());
}
Thread t1 = new Thread(()->{
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (Dog dog : dogList) {
synchronized (dog){
// log.debug(JolUtils.getHeader64Bit(dog));
}
// log.debug(JolUtils.getHeader64Bit(dog));
}
synchronized (Main.class){
Main.class.notifyAll();
}
},"t1");
Thread t2 = new Thread(()->{
synchronized (Main.class){
try {
Main.class.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
for (Dog dog : dogList) {
log.debug(JolUtils.getHeader64Bit(dog));
synchronized (dog){
log.debug(JolUtils.getHeader64Bit(dog));
}
log.debug(JolUtils.getHeader64Bit(dog));
}
},"t2");
t1.start();
t2.start();
t1.join();
t2.join();
for (Dog dog : dogList) {
log.debug(JolUtils.getHeader64Bit(dog));
}
}
}
class Dog{
}
批量撤销
当撤销偏向锁阈值超过40次后,jvm会认为不应该设置为偏向锁,于是将整个类的所有对象都会变为不可偏向,新建的对象也不可以偏向
撤销偏向锁是对于偏向锁升级为轻量级锁的过程,而不是重偏向的过程,所以需要不同的线程之间进行
锁消除
public void a() throws Exception {
i++;
}
public void b() throws Exception {
Object o = new Object();
synchronized(o) {
i++;
}
}
以上两个方法在进行充分预热后,运行效率基本相同
底层有JIT(即时编译器),会对热点代码(反复执行)进行优化,对方法b()进行优化,其中的o不能逃离作用域,所以JIT进行优化,直接将对象o消除,随后的synchronized也会被优化掉
wait/notify
继续上文中提到的例子,由于开学了,实验比较多,又开始使用门锁(重量级锁),在A班进入实验室后发现需要的实验材料还没有到齐,不能继续进行实验,实验室也不能就干耗着,于是A班就去隔壁休息室(WaitSet)休息并将实验室的门锁打开,在休息室内一直等到实验材料备齐
过了一阵子,C班把实验材料送到实验室了,就通知一下在等待室中的A班,A班也比较讲理,没有直接进去就开始做实验,而是和各个班级一起重新竞争实验室使用权(进入EntryList)
原理
- Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁时唤醒
- WAITING线程会在Owner线程调用notify或notify All时唤醒,但唤醒后并不意味着立即获得锁,仍需进入EntryList重新竞争
API介绍
- obj.wait()让进入object监视器的线程到waitSet等待
- obj.wait(long timeout)有时限的等待,到timeout毫秒后结束等待,或被唤醒
- obj.notify()在object上正在WaitSet等待的线程中挑一个唤醒
- obj.notifyAll()让object上正在WaitSet等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于Object对象的方法。必须获得此对象的锁,才能调用这几个方法
wait和notify的正确使用方式
sleep(long n)和wait(long n)的区别
- sleep是Thread方法,而wait是Object的方法
- sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用
- sleep在睡眠的同时,不会释放对象锁,但wait在等待的时候会释放锁对象
- 它们的状态都是TIMEED_WAITING
同步模式之保护性暂停
定义:
即Guarded Suspension,用在一个线程等待另一个线程的执行结果
要点
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
- JDK中,join的实现、Future的实现,采用的就是此模式
- 因为要等待另一方的结果,因此归类到同步模式
@Slf4j(topic = "c.Main")
public class Main {
//线程1等待线程2的下载结果
public static void main(String[] args) throws InterruptedException {
GuardeObject guardeObject = new GuardeObject();
new Thread(() -> {
//等待结果
log.debug("等待结果");
String s = (String) guardeObject.get();
log.debug("结果:{}",s);
}).start();
Thread.sleep(100);
new Thread(() -> {
log.debug("执行下载");
guardeObject.complete("数据");
}).start();
}
}
class GuardeObject {
//结果
private Object response;
//获取结果
public Object get() {
synchronized (this) {
while (response == null){
try {
this.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
return response;
}
}
//产生结果
public void complete(Object response) {
synchronized (this) {
//给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
//增加超时效果
public Object get(long timeout) {
synchronized (this) {
//开始时间
long begin = System.currentTimeMillis();
//经历时间
long passedTime = 0;
while (response == null){
//此轮循环应该等待的时间
long waitTime = timeout-passedTime;
//经历的时间超过了最大等待时间,退出循环
if (waitTime <= 0){
break;
}
try {
//考虑虚假唤醒
this.wait(waitTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
passedTime = System.currentTimeMillis() - begin;
}
return response;
}
}
}
join原理
底层使用了保护性暂停的设计模式,具体实现方式上文代码
生产者消费者模型
要点
- 与保护性暂停中的GuardeObject不同,不需要产生结果和消费结果的线程一一对应
- 消费队列可以用来平衡生产者和消费的线程资源
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费之专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
- JDK中各种阻塞队列,采用的就是这种模式
public class Main {
public static void main(String[] args) {
MessageQueue messageQueue = new MessageQueue(10);
for (int i = 0; i < 3; i++) {
int id = i;
new Thread(() -> {
while (true) {
messageQueue.put(new Message(id,"值" + id));
}
},"生产者" + i).start();
}
new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
messageQueue.take();
}
},"消费者").start();
}
}
//消息队列类 Java线程之间通信
@Slf4j(topic = "c.MessageQueue")
class MessageQueue {
//消息队列集合
private LinkedList<Message> list = new LinkedList<>();
//队列容量
private int capcity;
//获取消息
public Message take() {
synchronized (list){
//检查队列是否为空
while (list.isEmpty()){
try {
log.debug("队列为空,消费者线程等待");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//从队列头部获取消息返回
Message message = list.removeFirst();
log.debug("已消费消息{}",message);
list.notifyAll();
return message;
}
}
//存入消息
public void put(Message message) {
synchronized (list) {
//检查队列是否满了
while (list.size() == capcity){
try {
log.debug("队列已满,生产者线程等待");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//将新消息加入到队列尾部
list.addLast(message);
log.debug("已生产消息{}",message);
list.notifyAll();
}
}
public MessageQueue(int capcity) {
this.capcity = capcity;
}
}
final class Message {
private int id;
private Object value;
public Message(int id, Object value) {
this.id = id;
this.value = value;
}
@Override
public String toString() {
return "Message{" +
"id=" + id +
", value=" + value +
'}';
}
public int getId() {
return id;
}
public Object getValue() {
return value;
}
}
Park&Unpark
基本使用
它们是LockSupport类中的方法
//暂停当前线程
LockSupport.park();
//恢复某个线程的运行
LockSupport.unpark(暂停线程对象);
先park再unpark
Thread t1 = new Thread(() -> {
log.debug("start...");
Thread.sleep(1000);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
},"t1");
t1.start();
Thread.sleep(2000);
log.debug("unpark...");
LockSupport.unpark(t1);
在park之前是可以调用unpark,将来可以恢复由于park造成的等待
特点
与Object的wait和notify相比
- wait,notify和notify必须配合Object Monitor一起使用,而unpark不必
- park&unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
- park&unpark可以先unpark,而wait¬ify不能先notify
原理
每个线程都有自己的一个Parker对象,由三部分组成_counter,_cond,和_mutex,打个比喻
- 线程就像一个旅人,Parker就像为他随身携带的背包,条件变量就好比背包中的帐篷,
_counter就好比背包中的备用干粮(0为耗尽,1为充足) - 调用park就是要看需不需要停下来休息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需要停留,继续前进
- 调用unpark,就好比令干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续前进
- 如果这时线程还在运行,那么下次他调用park时,仅是消耗掉备用干粮,不需要停留继续前进
- 因为背包空间有限,多次调用unpark仅会补充一份备用干粮
-
当前线程调用Unsafe.park()方法
-
检查
_counter,本情况为0,这时,获得_mutex互斥锁 -
线程进入
_cond条件变量阻塞 -
设置
_counter = 0 -
当前线程调用Unsafe.park()方法
-
检查
_counter,本情况为1,这时线程无需阻塞,继续运行 -
设置
_counter = 0 -
调用Unsafe.unpark(Thread_0)方法,设置
_counter为1 -
唤醒
_cond条件变量中的Thread_0 -
Thread_0恢复运行
-
设置
_counter为0
线程状态切换
-
NEW -> RUNNABLE
- 调用线程的start方法,线程从NEW状态变为RUNNABLE
-
RUNNABLE <-> WAITING
-
线程用synchronized(obj)获取了对象锁后
-
调用obj.wait()方法时,线程会从RUNNABLE状态变为WAITING
-
调用obj.notify(),obj.notifyAll(),t,interrupt()时
- 竞争锁成功,t线程从WAITING状态变为RUNNABLE状态
- 竞争锁失败,t线程从WAITING状态变为BLOCKED状态
-
-
RUNNABLE <-> WAITING
-
当前下称调用t.join()方法时,当前线程从RUNNABLE状态变为WAITING
-
注意是当前线程在t线程对象的监视器上等待
-
t线程运行结束,或调用了当前线程的interrupt()时,当前线程从WAITING状态变为RUNNABLE状态
-
-
RUNNABLE <-> WAITING
- 当前线程调用了LockSupport.park()方法会让当前线程从RUNNABLE状态变为WAITING
- 调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),会让目标线程从WAITING状态变为RUNNABLE状态
-
RUNNABLE <-> TIMED_WAITING
-
t线程用synchronized(obj)获取了对象锁后
-
调用obj.wait(long n)方法时,t线程从RUNNABLE状态变为TIMED_WAITING
-
t线程等待时间超过了n毫秒,或调用obj.notify(),obj.notifyAll(),t.ubterrupt()时
- 竞争锁成功,t线程从TIMED_WAITING状态变为RUNNABLE状态
- 竞争锁失败,t线程从TIMED_WAITING状态变为BLOCKED状态
-
-
RUNNABLE <-> TIMED_WAITING
-
当前线程调用t.join(long n)方法时,当前线程从RUNNABLE状态变为TIMED_WAITING
-
注意是当前线程在t线程对象的监视器上等待
-
当前线程等待时间超过了n毫秒,或t线程运行结束,或调用了当前线程的interrupt()时,当前线程从TIMED_WAITING状态变为RUNNABLE状态
-
-
RUNNABLE <-> TIMED_WAITING
-
当前线程调用Thread.sleep(long n),当前线程从RUNNABLE状态变为TIMED_WAITING状态
-
当前线程等待时间超过了n毫秒,当前线程从TIMED_WAITING状态变为RUNNABLE状态
-
-
RUNNABLE <-> TIMED_WAITING
-
当前线程调用LockSupport.parkNanos(long nanos)或LockSupport.parkUnitil(long millis)时,当前线程从RUNNABLE状态变为TIMED_WAITING状态
-
调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),或是等待超时,会让目标线程从TIMED_WAITING状态变为RUNNABLE状态
-
-
RUNNABLE -> BLOCKED
-
t线程用synchronized(obj)获取了对象锁时如果竞争失败,从RUNNABLE状态变为BLOCKED状态
-
持obj锁线程的同步代码块执行完毕,会唤醒该对象上所有BLOCKED的线程重新竞争,如果其中t线程竞争成功,从BLOCKED状态变为RUNNABLE状态,其它失败的线程仍然BLOCKED
-
-
RUNNABLE -> TERMINATED
- 当前线程所有代码运行完毕,进入TERMINATED状态
多把锁
一间大屋子有两个功能:睡觉、学习,互不相干
现在A要学习,B要睡觉,但如果只用一间屋子(一个对象锁)的话,那么并发度很低
解决方法是准备多个房间(多个对象锁),这样可以将锁的粒度细分,但同时也带来了一些问题
锁的粒度细分
- 好处 ,可以增强并发
- 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁
活跃性
死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁
t1线程获得A对象锁,接下来想获取B对象的锁
t2线程获得B对象锁,接下来想获取A对象的锁
死锁的四个条件
-
互斥条件
独占资源在一段时间内只能由一个进程占有,不能同时被两个及以上的进程占有
-
占有且等待
进程至少已经占有一个资源,但又申请新的资源。由于该资源已被另外线程占有,此时该进程阻塞。但是它在等待新资源时,仍继续占有已分到的资源
-
不可抢占条件
一个进程所占有的资源在用完之前,其他进程不能强行夺走该资源,只能由该进程用完之后主动释放
-
循环等待条件
存在一个进程等待序列{p1,p2,p3,p4...},其中p1等待p2所占有的某个资源,p2等待p3所占有的某个资源,而pn等待p1所占有的某个资源
定位死锁
- 检测死锁可以使用jconsole工具,或者使用jps定位进程id,再用jstack定位死锁
哲学家就餐问题
有五位哲学家,围坐在圆桌旁
- 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考
- 吃饭时要用两根筷子吃,桌上共有5根筷子,每位哲学家做右手边各有一根筷子
- 如果筷子被身边的人拿着,自己就得等待
public class Main {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底",c1,c2).start();
new Philosopher("柏拉图",c3,c2).start();
new Philosopher("亚里士多德",c3,c4).start();
new Philosopher("赫拉克利特",c4,c5).start();
new Philosopher("阿基米德",c1,c5).start();
}
}
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread{
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right){
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
//尝试获得左手筷子
synchronized (left) {
synchronized (right) {
try {
eat();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
private void eat() throws InterruptedException {
log.debug("eating...");
Thread.sleep(1000);
}
}
class Chopstick {
private String name;
public Chopstick(String name) {
this.name = name;
}
}
上述代码出现死锁后,使用jconsole检测死锁发现:
Java stack information for the threads listed above:
===================================================
"阿基米德":
at org.example.test7.Philosopher.run(Main.java:40)
- waiting to lock <0x000000076dea9eb8> (a org.example.test7.Chopstick)
- locked <0x000000076dea9fb8> (a org.example.test7.Chopstick)
"苏格拉底":
at org.example.test7.Philosopher.run(Main.java:40)
- waiting to lock <0x000000076dea9ef8> (a org.example.test7.Chopstick)
- locked <0x000000076dea9eb8> (a org.example.test7.Chopstick)
"柏拉图":
at org.example.test7.Philosopher.run(Main.java:40)
- waiting to lock <0x000000076dea9f38> (a org.example.test7.Chopstick)
- locked <0x000000076dea9ef8> (a org.example.test7.Chopstick)
"亚里士多德":
at org.example.test7.Philosopher.run(Main.java:40)
- waiting to lock <0x000000076dea9f78> (a org.example.test7.Chopstick)
- locked <0x000000076dea9f38> (a org.example.test7.Chopstick)
"赫拉克利特":
at org.example.test7.Philosopher.run(Main.java:40)
- waiting to lock <0x000000076dea9fb8> (a org.example.test7.Chopstick)
- locked <0x000000076dea9f78> (a org.example.test7.Chopstick)
Found 1 deadlock.
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
@Slf4j(topic = "c.Main")
public class Main {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
while (count > 0) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count--;
log.debug("count:{}",count);
}
},"t1").start();
new Thread(() -> {
while (count < 20) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count++;
log.debug("count:{}",count);
}
},"t2").start();
}
}
饥饿
一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束,饥饿的情况不易演示
ReentrantLock
相对于synchronized它具备如下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量(可以解决虚假唤醒问题)
与synchronized一样,都支持可重入
基本语法
//获取锁
reentrantLock.lock();
try {
//临界区
} finally {
//释放锁
reentrantLock.unlock();
}
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁如果是不可重入锁,那么第二次获取锁时,自己也会被锁挡住
@Slf4j(topic = "c.Main")
public class Main {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
lock.lock();
try {
log.debug("enter main");
m1();
} finally {
lock.unlock();
}
}
public static void m1() {
lock.lock();
try {
log.debug("enter m1");
m2();
} finally {
lock.unlock();
}
}
public static void m2() {
lock.lock();
try {
log.debug("enter m2");
} finally {
lock.unlock();
}
}
}
可打断
@Slf4j(topic = "c.Main")
public class Main {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
//如果没有竞争那么此方法就会获取lock对象锁
//如果有竞争就进入阻塞队列,可以被其他线程用interrutp方法打断
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("t1线程没有获得锁,返回");
return;
}
try {
log.debug("t1线程获取到锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock();
t1.start();
Thread.sleep(1000);
log.debug("打断");
t1.interrupt();
}
}
锁超时
立刻失败
@Slf4j(topic = "c.Main")
public class Main {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
try {
if (! lock.tryLock()) {
log.debug("获取不到锁");
return;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
},"t1");
lock.lock();
log.debug("获取到锁");
t1.start();
}
}
延迟失败
@Slf4j(topic = "c.Main")
public class Main {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
try {
if (! lock.tryLock(5, TimeUnit.SECONDS)) {
log.debug("获取不到锁");
return;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
},"t1");
lock.lock();
log.debug("获取到锁");
t1.start();
}
}
公平锁
ReentrantLock默认为不公平锁,可以在创建时指定为公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
条件变量
synchronized中也有条件变量,就是waitSet,当瞧见不满足时进入waitSet等待,ReentrantLock的条件变量比synchronized强大之处在于,它是支持多个条件变量的
- synchronized是那些不满足条件的线程都在意见休息室等消息
- 而ReentrantLock支持多见休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒
使用流程
- awit前需要获得锁
- awit执行后,会释放锁,进入conditionObject等待
- await的线程被唤醒(或打断、或超时)去重新竞争lock锁
- 竞争lock锁成功后,从await后继续执行
@Slf4j(topic = "c.Main")
public class Main {
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
//创建一个条件变量(休息室)
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
lock.lock();
//进入休息室等待
condition1.wait();
//唤醒所有等待的线程
condition1.signalAll();
}
}