java 多线程(四)—— 线程同步/互斥=队列+锁

多线程编程中的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(); 这段代码其实是分为三个指令执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 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--其实是一个复合操作,包括三步:

  1. 读取 ticket的值。
  2. ticket减 1。
  3. ticket的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 ticket进行读取操作之后,还未对其进行修改。线程 2 又读取了 ticket的值并对其进行修改(-1),再将ticket的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 ticket的值进行修改(-1),再将ticket的值写回内存。

这也就导致两个线程分别对 ticket进行了一次自减操作后,ticket实际上只减少了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以,不就是抢票系统嘛。

↓↓↓↓↓↓↓↓↓锁的分类↓↓↓↓↓↓↓↓↓

        如果面试官让你介绍一下java里的锁,你只需要把锁分成悲观锁和乐观锁两类然后展开介绍即可。因为“公平锁和非公平锁”,“重入锁和不可重入锁”以及“重量级锁和轻量级锁”只是锁的其他划分形式而已。

一、悲观锁和乐观锁

噢噢噢!这个破锁从2020学到2023,今天TMD终于大彻大悟什么叫悲观锁和乐观锁了!

悲观锁就是认为每一次访问资源都会出错,哪怕可能这次没人跟他抢他都会这么认为,所以每次访问资源都要加锁。乐观锁就是认为每一次访问资源不会出错,先不加锁,等出错了再说。

悲观锁:

  • synchronized

  • Lock的实现类ReentranceLock。

乐观锁就是总是考虑最好的情况,居然允许一个线程写的时候另一个线程一起写,比如

  • 版本号机制

  • CAS

版本控制

        区别于MySQL的多版本控制,java级别的乐观锁只是单版本控制,MySQL之所以需要多版本是因为涉及到事务提交和未提交的问题。

首先这个版本号是资源/数据/变量的版本号。

在资源上加一个隐藏字段 version 版本号,表示资源被修改的次数。当资源被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前资源的 version 值相等时才更新,否则重新来一遍(再去读最新版本的数据),直到更新成功。

举一个简单的例子 :假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。

  1. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。

  1. 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。

  1. 操作员 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的源码理解一下:

三、重入锁和不可重入锁

四、重量级锁和轻量级锁

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值