多线程编程中的3个核心概念
原子性:加锁
顺序性:volatile
可见性:volatile
同步、异步、互斥的区别
在很多文章中,直接把同步缩小为互斥,并称之为同步。下面也是这样的。
一、线程同步 = 队列 + 锁
同步(这里说的其实是互斥)就是多个线程同时访问一个资源。
那么如何实现? 队列+锁。
想要访问同一资源的线程排成一个队列,按照排队的顺序访问。访问的时候加上一个锁(参考卫生间排队+锁门),访问完释放锁。
二、 不安全案例
2.1 不安全的抢票系统
之前我们实现过这个例子。
package Unsafe;
public class RailwayTicketSystem{
public static void main(String[] args) {
BuyTicket buyer = new BuyTicket();
new Thread(buyer,"黑黑").start();
new Thread(buyer,"白白").start();
new Thread(buyer,"黄牛党").start();
}
}
class BuyTicket implements Runnable{
private int ticketNums = 10; //系统里有10张票
//抢票行为
@Override
public void run() {
while(ticketNums>0){
try {
Thread.sleep(100); //模拟延时,放大问题的发生性
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"--->抢到了第"+ticketNums+"张票");
ticketNums--;
}
}
}
2.2 不安全的银行取钱
场景: 黑土有一张存款为100万的卡,黑土去银行柜台取钱50万,同一时刻,黑土的老婆白云也要通过网上银行从卡里取走100万。
因为取钱是用户各自取各自账户里的钱,不存在多个线程操作同一个对象(所有用户都去抢系统里的票),所以可以用extends Thread。
package Unsafe;
public class UnsafeBank {
public static void main(String[] args) {
//黑黑的卡里一共有100万
Account 黑土的卡 = new Account("黑土的卡",100);
//黑黑要从卡里取走50万
DrawMoney 黑土 = new DrawMoney(黑土的卡,50);
黑土.start();
//同时,白白也来到了银行,白白要从卡里取走100万
DrawMoney 白云 = new DrawMoney(黑土的卡, 100);
白云.start();
}
}
//银行卡
class Account{
private String name; //持卡人
private int money ; //余额
public Account(String name, int money) {
this.name = name;
this.money = money;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
}
//银行:模拟取款
class DrawMoney extends Thread{
Account account; //账户
int drawMoney; //要取多少钱
public DrawMoney(Account account,int drawMoney){
this.account = account;
this.drawMoney = drawMoney;
}
//取钱
@Override
public void run() {
if(account.getMoney()-drawMoney<0){
System.out.println("余额已不足,【"+Thread.currentThread().getName()+"】无法取钱");
return;
}
//延时,放大问题的发生
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
//余额变动
account.setMoney(account.getMoney() - drawMoney);
System.out.println(Thread.currentThread().getName()+"取走了"+drawMoney);
//输出余额
System.out.println(account.getMoney());
}
}
2.3 不安全的集合
这里以ArraryList为例,我们知道ArraryList的底层是用数组存储的。当多个线程同时执行add方法时,会出现多个线程向数组的同一个位置存放数据的情况。
多线程实现线程安全的3个方面(面试题)
- 原子性:加锁 (悲观锁或乐观锁)
- 可见性:volatile 实现子线程之间内存同步
- 有序性:volatile 禁止指令重排
三、synchronized悲观锁解决线程不安全问题
由于我们可以用private关键字来保证变量只能被方法访问,所以我们只需要针对类似于getXx()方法提出一套机制,这套机制就是synchronized关键字,synchronized就能实现队列+锁机制。它包括两种用法
所以说,synchronized 锁的既是对象(的资源/成员变量)(临界资源)(当synchronized 锁的是方法的时候,“对象”指的是方法的 调用者也就是这个synchronized 方法所在类的实例,当synchronized 锁的是块的时候,那么“对象”指的就是括号里填充的),也是一段代码(临界区)
1. synchronized 方法
在方法前面加synchronized 关键字。
同步方法所属类所创建的每个对象,都有一把锁。
2. synchronized 块
如何使用?中括号括起来临界区,小括号内填上临界资源。
一个方法中同时存在读取和增删改的代码,但是读取不属于同时操作资源。假如一个方法有1000行,里面只有5行代码是增删改,需要同步,剩下的995行不需要同步,那么使用synchronized声明整个方法会造成线程不必要的等待,浪费时间。所以出现了synchronized块。
顾名思义就是把一个代码段声明为synchronized。
可以指定要锁定的对象,如果不指定的话默认锁的是this。
3.1 解决不安全的抢票系统
我们给将run方法声明为synchronized,发现虽然结果不会出现负数的情况。
但是票都被同一个人抢去了。
我们来看一下这是为什么。给run方法上锁,意味着所有进入run方法的对象都要把run方法执行完才能释放这个锁给下一个排队的对象用。在我们的代码中,一旦某个对象进入了run方法就要一直抢票,直到 ticketNums<0,也就是意味着一张票也没有了,才会退出run方法。所以,除了第一个被执行的线程能抢到票且抢走了所有票,其他的线程一张票都抢不到。
可是这不是我们的目的呀!
错就错在,我们想要锁的操作是“抢一张票”,而我们上面锁的是“抢完所有票”。
所以应该把抢一张票的逻辑单独写成一个方法,然后加上synchronized关键字。
package Unsafe;
public class RailwayTicketSystem{
public static void main(String[] args) {
BuyTicket system= new BuyTicket(); //镜像
new Thread(system,"黑黑").start(); //容器1
new Thread(system,"白白").start(); //容器2
new Thread(system,"黄牛党").start(); //容器3
}
}
class BuyTicket implements Runnable{
private int ticketNums = 10; //系统里有10张票
private boolean flag = true; //系统初始化是开放的
//抢票行为
@Override
public void run() {
while(flag==true){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
buy();
}
}
public synchronized void buy(){
if(ticketNums>0){
System.out.println(Thread.currentThread().getName()+"--->抢到了第"+ticketNums+"张票");
ticketNums--;
if(ticketNums == 0) flag = false;
}
}
}
注意,睡眠代码的位置也值得思考:
3.2 解决不安全的银行取钱系统
在需要同步的代码中,发生变动(增删改)的是account,而不是run方法所在的DrawMoney类。所以要指定锁的对象account,如果不指定的话默认锁的是所在类。此时我们不能给方法加synchronized了,因为方法无法指定被锁的对象。我们使用同步块:
3.3 解决不安全的集合
四、Lock悲观锁
记忆点:
- synchronized是隐式加锁;Lock是显式加锁
- Lock是一个接口,常用的实现类是ReentrantLock
- 因为一定要解锁,所以把unlock放入finally代码块里
还是以抢票系统为例,
import jdk.nashorn.internal.ir.CallNode;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadLock {
public static void main(String[] args) {
BuyTicket buyTicketSystem = new BuyTicket();
new Thread(buyTicketSystem,"幸运儿").start();
new Thread(buyTicketSystem,"黄牛党").start();
new Thread(buyTicketSystem,"是朕!").start();
}
}
class BuyTicket implements Runnable{
private int ticketsNumber = 10;
private final ReentrantLock lock = new ReentrantLock(); //定义lock锁
@Override
public void run() {
while(true){
//先抢一张票
try{
lock.lock(); //加锁
if(ticketsNumber>0) {
System.out.println(Thread.currentThread().getName() + "-----> 第" + ticketsNumber + "张票");
ticketsNumber--;
}else{
break;
}
}finally {
lock.unlock(); //解锁
}
//再睡觉
try{
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
面试题:synchronized和Lock的区别
首先最主要的区别:① synchronized是一个关键字, 而lock是一个接口;
② 所以Lock的灵活性更好,最主要的实现类ReentranLock可以设置是公平锁还是非公平锁;而synchronized是死的,就是非公平锁(java默认都采用非公平,这样效率高)
② synchronized可以加在方法上,可以加在代码块上,lock更加灵活想加哪加哪; synchronized是自动加锁,而且不需要手动解锁,lock是手动加锁解锁
③ Lock可以判断是否获取到了锁,synchronized无法得知是否获取到了锁
④ 加了synchronized的线程1一旦阻塞(回想高级计网Router收发的那个实验,收线程获取到router后,等待接受一个包,也不释放router,别人也没法用),那么线程2就会一直等下去陷入死锁,但是lock提供了一个tryLock()方法,当线程1阻塞的时候线程2会抢过来,不会傻傻等待。
五、java内存模型 JMM —— java memory model
首先什么是内存模型?这就是内存模型。
JMM是一套规则,一套保证子线程数据同步的规则(一个线程修改了某个变量,其他线程能够及时知道)(有些时候即使不加volatile也能及时知道)。
5.1 volatile子线程内存同步
主线程和各个子线程开辟不同的内存空间,子线程在创建时会拷贝主内存,得到初步的本地运行内存。如果主内存定义了一个int a = 1,那么所有的本地运行内存也有这个a,如果在本地运行内存中改了a的值,如何同步到所有线程中去呢?volatile关键字!
有点类似于cache的写策略。
我们在写代码的时候只需要加一个volilate关键字就可以实现子线程内存的同步。那么底层是如何帮我们实现的呢?java内存模型,其实就是一种规定
1)规定了如果一个变量程序员想让其作为各个线程的共享变量,那么就要给它加一个volatile关键字。加了这个关键字的变量,一旦被线程A修改,就要立马写回主内存中。一旦线程B、C、D想要用这个共享变量,就一定要去主内存当中读。
2)具体的实现就是规定了一系列数据同步的操作
- 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
- 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
- use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
- ......
【JVM】Java内存模型这块彻底玩儿明白了_哔哩哔哩_bilibili
5.2 volatile禁止指令重排序
JVM可以对指令进行重排序,没想到吧。之前学过指令的重排,有编译器级别的重排序,也有CPU级别的重排。编译器级别重排的指的是“中间代码优化”,CPU级别重排的是指令。
指令重排在单线程环境下不会出现问题,但是在多线程环境下。。。
volatile可以通过加内存屏障的方式禁止指令重排序。
uniqueInstance
采用 volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三个指令执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
jvm会对指令进行重排,比如把123的执行顺序改为132。如果现在开启了两个线程A和B,A进行到了
uniqueInstance = new Singleton()
这行代码,而由于jvm的指令重排,先走的1和3,并没有给uniqueInstance
初始值,但是却给了地址,这样uniqueInstance就不为空了。如果此时线程B走到了
if(uniqueInstance==null)
这行代码,那么会立马return uniqueInstance,但是这个uniqueInstance并没有初始值。
这就出错咯。
5.3 Volatile不能保证对变量的操作是原子性的
volatile和synchronized是两个不同的机制,千万不要以为加了volatile就不用加synchronized或者加了synchronized就不用加volatile了。
volatile:加了volatile是保证线程对router的更改能够及时写回主内存中去,如果不加的话只会在线程的本地内存修改router的值,这样其他线程无法得知最新的数据。这一点是synchronized做不到的,synchronized只能保证router这个变量被互斥访问,但是修改后的结果没法刷到主内存中。但是volatile没法保证操作的原子性。
很多人会误认为自增操作 ticket--
是原子性的,实际上,ticket--
其实是一个复合操作,包括三步:
- 读取
ticket
的值。 - 对
ticket
减 1。 - 将
ticket
的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
ticket
进行读取操作之后,还未对其进行修改。线程 2 又读取了ticket
的值并对其进行修改(-1),再将ticket
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
ticket
的值进行修改(-1),再将ticket
的值写回内存。
这也就导致两个线程分别对 ticket
进行了一次自减操作后,ticket
实际上只减少了 1。
其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以,不就是抢票系统嘛。
↓↓↓↓↓↓↓↓↓锁的分类↓↓↓↓↓↓↓↓↓
如果面试官让你介绍一下java里的锁,你只需要把锁分成悲观锁和乐观锁两类然后展开介绍即可。因为“公平锁和非公平锁”,“重入锁和不可重入锁”以及“重量级锁和轻量级锁”只是锁的其他划分形式而已。
一、悲观锁和乐观锁
噢噢噢!这个破锁从2020学到2023,今天TMD终于大彻大悟什么叫悲观锁和乐观锁了!
悲观锁就是认为每一次访问资源都会出错,哪怕可能这次没人跟他抢他都会这么认为,所以每次访问资源都要加锁。乐观锁就是认为每一次访问资源不会出错,先不加锁,等出错了再说。
悲观锁:
-
synchronized
-
Lock的实现类ReentranceLock。
乐观锁就是总是考虑最好的情况,居然允许一个线程写的时候另一个线程一起写,比如
-
版本号机制
-
CAS
版本控制
区别于MySQL的多版本控制,java级别的乐观锁只是单版本控制,MySQL之所以需要多版本是因为涉及到事务提交和未提交的问题。
首先这个版本号是资源/数据/变量的版本号。
在资源上加一个隐藏字段 version 版本号,表示资源被修改的次数。当资源被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前资源的 version 值相等时才更新,否则重新来一遍(再去读最新版本的数据),直到更新成功。
举一个简单的例子 :假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
-
操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
-
在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
-
操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
-
操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。
CAS机制
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到三个操作数:
-
V :要更新的变量值(Var),记录变量当前的值
-
E :预期值(Expected),记录A线程一开始读取变量时的值
-
N :拟写入的新值(New)
当且仅当 V 的值等于 E 时,也就是当前的值依然等于线程最初读这个变量的值时(说明没有被其他线程修改过),CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新或者再次尝试更新。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
底层原理:Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作
使用场景
悲观锁通常多用于写多比较多的情况下(多写场景),避免频繁失败和重试影响性能。
乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。
二、公平锁和非公平锁
首先说明,公平锁和非公平锁的概念只针对悲观锁,因为乐观锁不需要排队,直接上来就改。
公平锁:先来先服务。锁被释放之后,先申请的线程先得到锁(也就是等候队列中排在前面的)。但是比如一个要执行30min的线程排在一个只需要执行3s的线程前面,这看起来就有点不公平了,影响程序的执行效率,所以java默认使用的是非公平锁。
非公平锁(java默认使用):锁被释放之后,后申请的线程可能会先获取到锁,是随机一上来就尝试占有锁或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
我们看一下ReentrantLock的源码理解一下: