原子性:加锁
顺序性:volatile
可见性:volatile

在很多文章中,直接把同步缩小为互斥,并称之为同步。下面也是这样的。
同步(这里说的其实是互斥)就是多个线程同时访问一个资源。
那么如何实现? 队列+锁。
想要访问同一资源的线程排成一个队列,按照排队的顺序访问。访问的时候加上一个锁(参考卫生间排队+锁门),访问完释放锁。
之前我们实现过这个例子。
- 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--;
- }
- }
- }

场景: 黑土有一张存款为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());
- }
- }

这里以ArraryList为例,我们知道ArraryList的底层是用数组存储的。当多个线程同时执行add方法时,会出现多个线程向数组的同一个位置存放数据的情况。
由于我们可以用private关键字来保证变量只能被方法访问,所以我们只需要针对类似于getXx()方法提出一套机制,这套机制就是synchronized关键字,synchronized就能实现队列+锁机制。它包括两种用法
所以说,synchronized 锁的既是对象(的资源/成员变量)(临界资源)(当synchronized 锁的是方法的时候,“对象”指的是方法的 调用者也就是这个synchronized 方法所在类的实例,当synchronized 锁的是块的时候,那么“对象”指的就是括号里填充的),也是一段代码(临界区)
1. synchronized 方法
在方法前面加synchronized 关键字。
同步方法所属类所创建的每个对象,都有一把锁。
2. synchronized 块
如何使用?中括号括起来临界区,小括号内填上临界资源。
一个方法中同时存在读取和增删改的代码,但是读取不属于同时操作资源。假如一个方法有1000行,里面只有5行代码是增删改,需要同步,剩下的995行不需要同步,那么使用synchronized声明整个方法会造成线程不必要的等待,浪费时间。所以出现了synchronized块。
顾名思义就是把一个代码段声明为synchronized。
可以指定要锁定的对象,如果不指定的话默认锁的是this。

我们给将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;
- }
- }
- }

注意,睡眠代码的位置也值得思考:

在需要同步的代码中,发生变动(增删改)的是account,而不是run方法所在的DrawMoney类。所以要指定锁的对象account,如果不指定的话默认锁的是所在类。此时我们不能给方法加synchronized了,因为方法无法指定被锁的对象。我们使用同步块:


记忆点:

还是以抢票系统为例,
- 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会抢过来,不会傻傻等待。
首先什么是内存模型?这就是内存模型。

JMM是一套规则,一套保证子线程数据同步的规则(一个线程修改了某个变量,其他线程能够及时知道)(有些时候即使不加volatile也能及时知道)。
主线程和各个子线程开辟不同的内存空间,子线程在创建时会拷贝主内存,得到初步的本地运行内存。如果主内存定义了一个int a = 1,那么所有的本地运行内存也有这个a,如果在本地运行内存中改了a的值,如何同步到所有线程中去呢?volatile关键字!
有点类似于cache的写策略。

我们在写代码的时候只需要加一个volilate关键字就可以实现子线程内存的同步。那么底层是如何帮我们实现的呢?java内存模型,其实就是一种规定
1)规定了如果一个变量程序员想让其作为各个线程的共享变量,那么就要给它加一个volatile关键字。加了这个关键字的变量,一旦被线程A修改,就要立马写回主内存中。一旦线程B、C、D想要用这个共享变量,就一定要去主内存当中读。

2)具体的实现就是规定了一系列数据同步的操作
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):
【JVM】Java内存模型这块彻底玩儿明白了_哔哩哔哩_bilibili
JVM可以对指令进行重排序,没想到吧。之前学过指令的重排,有编译器级别的重排序,也有CPU级别的重排。编译器级别重排的指的是“中间代码优化”,CPU级别重排的是指令。
指令重排在单线程环境下不会出现问题,但是在多线程环境下。。。
volatile可以通过加内存屏障的方式禁止指令重排序。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三个指令执行:
uniqueInstance 分配内存空间uniqueInstanceuniqueInstance 指向分配的内存地址jvm会对指令进行重排,比如把123的执行顺序改为132。如果现在开启了两个线程A和B,A进行到了
uniqueInstance = new Singleton()
这行代码,而由于jvm的指令重排,先走的1和3,并没有给uniqueInstance初始值,但是却给了地址,这样uniqueInstance就不为空了。如果此时线程B走到了
if(uniqueInstance==null)
这行代码,那么会立马return uniqueInstance,但是这个uniqueInstance并没有初始值。

这就出错咯。
volatile和synchronized是两个不同的机制,千万不要以为加了volatile就不用加synchronized或者加了synchronized就不用加volatile了。
volatile:加了volatile是保证线程对router的更改能够及时写回主内存中去,如果不加的话只会在线程的本地内存修改router的值,这样其他线程无法得知最新的数据。这一点是synchronized做不到的,synchronized只能保证router这个变量被互斥访问,但是修改后的结果没法刷到主内存中。但是volatile没法保证操作的原子性。
很多人会误认为自增操作 ticket-- 是原子性的,实际上,ticket--其实是一个复合操作,包括三步:
ticket的值。ticket减 1。ticket的值写回内存。volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
ticket进行读取操作之后,还未对其进行修改。线程 2 又读取了 ticket的值并对其进行修改(-1),再将ticket的值写回内存。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 的全称是 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的源码理解一下:
