1 异步编程模型
多线程并发,线程t1和线程t2,各自执行各自的,t1不管t2,t2不管t1, 谁也不需要等谁,效率较高。
2 同步编程模型
线程排队执行, 线程t1和线程t2,在线程t1执行的时候,必须等待t2线程执行结束,或者说在t2线程执行的时候,必须等待t1线程执行结束, 两个线程之间发生了等待关系,这就是同步编程模型,效率较低。
2.1 线程同步机制(锁机制synchronized)
2.1.1 同步代码块
将处理共享资源的代码放置在一个代码块中,使用synchronized关键字来修饰,被称作同步代码块。
synchronized (lock) {
// 操作共享资源代码块
}
// synchronized括号后的数据必须是多线程共享的数据,才能达到多线程排队。
lock 是一个锁对象,称之为同步监视器,它是同步代码块的关键。
当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1。
如果此时标志位为1,线程会执行同步代码块,同时将锁对象的标志位置为0。
当一个新的线程执行到这段同步代码块时,由于锁对象的标志位为0,新线程会发生阻塞,
等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进人同步代码块执行其中的代码。循环往复,直到共享资源被处理完为止。
案例:
// 以下代码的执行原理
// 1、假设t1和t2线程并发,开始执行以下代码的时候,肯定有一个先一个后。
// 2、假设t1先执行了,遇到了synchronized,这个时候自动找“后面共享对象”的对象锁,
// 找到之后,并占有这把锁,然后执行同步代码块中的程序,在程序执行过程中一直都是
// 占有这把锁的。直到同步代码块代码结束,这把锁才会释放。
// 3、假设t1已经占有这把锁,此时t2也遇到synchronized关键字,也会去占有后面
// 共享对象的这把锁,结果这把锁被t1占有,t2只能在同步代码块外面等待t1的结束,
// 直到t1把同步代码块执行结束了,t1会归还这把锁,此时t2终于等到这把锁,然后
// t2占有这把锁之后,进入同步代码块执行程序。
//
// 这样就达到了线程排队执行。
// 这里需要注意的是:这个共享对象一定要选好了。这个共享对象一定是需要排队
// 的这些线程的对象所共享的。
synchronized (this){
double before = this.getBalance();
double after = before - money;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.setBalance(after);
}
// 可以显式/手动指定锁对象。
// 进入代码块就加锁,出了代码块就解锁。
// 锁当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
// 锁类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
// 一个加锁一个不加锁:和没加锁一样
public void add1 () {
// 修饰代码块,
synchronized(this) {
count++;
}
}
public void add2() {
count++;
}
}
2.1.2 同步方法
修饰符 synchronized 返回值类型 方法名(形参列表){
// 方法体
}
被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行方法。
synchronized出现在实例方法上,一定锁的是this(此方法),不能是其他的对象了, 所以这种方式不灵活。另外还有一个缺点:synchronized出现在实例方法上, 表示整个方法体都需要同步,可能会无故扩大同步的 范围,导致程序的执行效率降低,所以这种方式不常用。
案例:
public synchronized void withdraw(double money){
double before = this.getBalance(); // 10000
// 取款之后的余额
double after = before - money;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 更新余额
this.setBalance(after);
2.1.3 同步静态方法
修饰符 synchronized static 返回值类型 方法名(形参列表){
// 方法体
}
public class SynchronizedDemo {
public synchronized static void method() {
}
}
静态方法中不能使用this,进入方法就加锁,离开方法就解锁,锁对象就是类对象,类锁永远只有1把。
2.1.4 synchronized的作用
互斥——保证原子性
synchronized的底层使用操作系统的mutex lock实现,synchronized用的锁是存在Java对象头里的。
加了synchronized之后能够保证原子性,进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当于解锁。
针对每一把锁, 操作系统内部都维护了一个等待队列。当这个锁被某个线程占有的时候, 其他线程尝试进行加锁,就加不上了, 就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒等待队列中一个新的线程, 再来获取到这个锁。这也就是操作系统线程调度的一部分工作。
系统中的锁具有“不可剥夺”特性,一旦一个线程得到锁除非它主动释放,否则无法强占。
如果两个线程同时尝试对同一个对象加锁,就会出现锁竞争,此时一个能获锁取成功,另一个只能阻塞(BLOCKED),一直阻塞到刚才线程释放锁当前线程才能加锁。
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁,
此时 B 和 C 都在阻塞队列中排队等待,但是当 A 释放锁之后, 虽然 B 比 C 先来的,但是 B 不一定就能获取到锁,而是和 C 重新竞争, 并不遵守先来后到的规则,由操作系统根据线程的优先级来进行调度。
两个线程分别尝试获取两把不同的锁, 不会产生竞争。如果两个线程针对不同队形加锁,此时不会发生锁竞争,这两个线程都能获取到各自的锁,不会有阻塞等待。
刷新内存——保证内存可见性
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性。
案例:
// 对上面的代码进行调整:
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (true) {
synchronized (counter) {
if (counter.flag != 0) {
break;
}
}
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息,如果某个线程加锁的时候, 发现锁已经被人占用,但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增,解锁的时候计数器递减为 0 的时候, 才真正释放锁,才能被别的线程获取到。
案例:
static class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
synchronized void increase2() {
increase();
}
}
// increase 和 increase2 两个方法都加了 synchronized,
// 此处的 synchronized 都是针对 this 当前对象加锁的.
// 在调用 increase2 的时候, 先加了一次锁,
// 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)
// 这个代码是完全没问题的. 因为 synchronized 是可重入锁。
2.1.5 锁原理
常见的锁策略:乐观锁 vs 悲观锁
悲观锁:悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁:乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
例如:
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
乐观锁的实现可以引入一个 “版本号” 来解决,检测出数据是否发生访问冲突
设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”。
- 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1, balance=100 )
- 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 )。
- 线程 A 完成修改工作,将数据版本号加1( versinotallow=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中。
- 线程 B 完成了操作,也将版本号加1( versinotallow=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
读写锁:一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据。
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁。
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁。
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁
读写锁就是把读操作和写操作区分对待。
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多
久了. 因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径,读写锁特别适合于 “频繁读, 不频繁写” 的场景中。
重量级锁 vs 轻量级锁:
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的:
CPU 提供了 “原子操作指令”.
操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。
用户态 vs 内核态:
想象去银行办业务.
在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
重量级锁:
加锁机制重度依赖了 OS 提供了 mutex,大量的内核态用户态切换很容易引发线程的调度。这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.
轻量级锁:
加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。少量的内核态用户态切换. 不太容易引发线程调度。
自旋锁(Spin Lock) vs 挂起等待锁:
自旋锁:
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放, 就能第一时间获取到锁。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
理解自旋锁 vs 挂起等待锁:
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的 轻量级锁 的实现方式:
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度. 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。
公平锁 vs 非公平锁:
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
可重入锁 vs 不可重入锁:
可重入锁:“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的, Linux 系统提供的 mutex 是不可重入锁。
不可重入锁:“把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁.,第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁。
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();