• java 多线程()—— 线程同步=队列+锁


    多线程编程中的3个核心概念

    原子性:加锁

    顺序性:volatile

    可见性:volatile

    同步、异步、互斥的区别

    在很多文章中,直接把同步缩小为互斥,并称之为同步。下面也是这样的。

    一、线程同步 = 队列 + 锁

    同步(这里说的其实是互斥)就是多个线程同时访问一个资源。

    那么如何实现? 队列+锁。

    想要访问同一资源的线程排成一个队列,按照排队的顺序访问。访问的时候加上一个锁(参考卫生间排队+锁门),访问完释放锁。

    二、 不安全案例

    2.1 不安全的抢票系统

    之前我们实现过这个例子。

    1. package Unsafe;
    2. public class RailwayTicketSystem{
    3. public static void main(String[] args) {
    4. BuyTicket buyer = new BuyTicket();
    5. new Thread(buyer,"黑黑").start();
    6. new Thread(buyer,"白白").start();
    7. new Thread(buyer,"黄牛党").start();
    8. }
    9. }
    10. class BuyTicket implements Runnable{
    11. private int ticketNums = 10; //系统里有10张票
    12. //抢票行为
    13. @Override
    14. public void run() {
    15. while(ticketNums>0){
    16. try {
    17. Thread.sleep(100); //模拟延时,放大问题的发生性
    18. } catch (InterruptedException e) {
    19. e.printStackTrace();
    20. }
    21. System.out.println(Thread.currentThread().getName()+"--->抢到了第"+ticketNums+"张票");
    22. ticketNums--;
    23. }
    24. }
    25. }

    2.2 不安全的银行取钱 

    场景: 黑土有一张存款为100万的卡,黑土去银行柜台取钱50万,同一时刻,黑土的老婆白云也要通过网上银行从卡里取走100万。

    因为取钱是用户各自取各自账户里的钱,不存在多个线程操作同一个对象(所有用户都去抢系统里的票),所以可以用extends Thread。

    1. package Unsafe;
    2. public class UnsafeBank {
    3. public static void main(String[] args) {
    4. //黑黑的卡里一共有100万
    5. Account 黑土的卡 = new Account("黑土的卡",100);
    6. //黑黑要从卡里取走50万
    7. DrawMoney 黑土 = new DrawMoney(黑土的卡,50);
    8. 黑土.start();
    9. //同时,白白也来到了银行,白白要从卡里取走100万
    10. DrawMoney 白云 = new DrawMoney(黑土的卡, 100);
    11. 白云.start();
    12. }
    13. }
    14. //银行卡
    15. class Account{
    16. private String name; //持卡人
    17. private int money ; //余额
    18. public Account(String name, int money) {
    19. this.name = name;
    20. this.money = money;
    21. }
    22. public String getName() {
    23. return name;
    24. }
    25. public void setName(String name) {
    26. this.name = name;
    27. }
    28. public int getMoney() {
    29. return money;
    30. }
    31. public void setMoney(int money) {
    32. this.money = money;
    33. }
    34. }
    35. //银行:模拟取款
    36. class DrawMoney extends Thread{
    37. Account account; //账户
    38. int drawMoney; //要取多少钱
    39. public DrawMoney(Account account,int drawMoney){
    40. this.account = account;
    41. this.drawMoney = drawMoney;
    42. }
    43. //取钱
    44. @Override
    45. public void run() {
    46. if(account.getMoney()-drawMoney<0){
    47. System.out.println("余额已不足,【"+Thread.currentThread().getName()+"】无法取钱");
    48. return;
    49. }
    50. //延时,放大问题的发生
    51. try {
    52. Thread.sleep(100);
    53. } catch (InterruptedException e) {
    54. e.printStackTrace();
    55. }
    56. //余额变动
    57. account.setMoney(account.getMoney() - drawMoney);
    58. System.out.println(Thread.currentThread().getName()+"取走了"+drawMoney);
    59. //输出余额
    60. System.out.println(account.getMoney());
    61. }
    62. }

    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关键字。

    1. package Unsafe;
    2. public class RailwayTicketSystem{
    3. public static void main(String[] args) {
    4. BuyTicket system= new BuyTicket(); //镜像
    5. new Thread(system,"黑黑").start(); //容器1
    6. new Thread(system,"白白").start(); //容器2
    7. new Thread(system,"黄牛党").start(); //容器3
    8. }
    9. }
    10. class BuyTicket implements Runnable{
    11. private int ticketNums = 10; //系统里有10张票
    12. private boolean flag = true; //系统初始化是开放的
    13. //抢票行为
    14. @Override
    15. public void run() {
    16. while(flag==true){
    17. try {
    18. Thread.sleep(100);
    19. } catch (InterruptedException e) {
    20. e.printStackTrace();
    21. }
    22. buy();
    23. }
    24. }
    25. public synchronized void buy(){
    26. if(ticketNums>0){
    27. System.out.println(Thread.currentThread().getName()+"--->抢到了第"+ticketNums+"张票");
    28. ticketNums--;
    29. if(ticketNums == 0) flag = false;
    30. }
    31. }
    32. }

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

    3.2 解决不安全的银行取钱系统 

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

    3.3 解决不安全的集合

    四、Lock悲观锁

     记忆点:

    • synchronized是隐式加锁;Lock是显式加锁
    • Lock是一个接口,常用的实现类是ReentrantLock

    • 因为一定要解锁,所以把unlock放入finally代码块里

     还是以抢票系统为例,

    1. import jdk.nashorn.internal.ir.CallNode;
    2. import java.util.concurrent.locks.ReentrantLock;
    3. public class ThreadLock {
    4. public static void main(String[] args) {
    5. BuyTicket buyTicketSystem = new BuyTicket();
    6. new Thread(buyTicketSystem,"幸运儿").start();
    7. new Thread(buyTicketSystem,"黄牛党").start();
    8. new Thread(buyTicketSystem,"是朕!").start();
    9. }
    10. }
    11. class BuyTicket implements Runnable{
    12. private int ticketsNumber = 10;
    13. private final ReentrantLock lock = new ReentrantLock(); //定义lock锁
    14. @Override
    15. public void run() {
    16. while(true){
    17. //先抢一张票
    18. try{
    19. lock.lock(); //加锁
    20. if(ticketsNumber>0) {
    21. System.out.println(Thread.currentThread().getName() + "-----> 第" + ticketsNumber + "张票");
    22. ticketsNumber--;
    23. }else{
    24. break;
    25. }
    26. }finally {
    27. lock.unlock(); //解锁
    28. }
    29. //再睡觉
    30. try{
    31. Thread.sleep(300);
    32. } catch (InterruptedException e) {
    33. e.printStackTrace();
    34. }
    35. }
    36. }
    37. }

    面试题: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的源码理解一下:

    三、重入锁和不可重入锁

    四、重量级锁和轻量级锁

  • 相关阅读:
    Oracle递归查询树形数据
    Docker容器设置自动启动的方法
    K8S -理解StatefulSet - 部署有状态应用
    HTML5期末大作业:基于 html css js仿腾讯课堂首页
    Delphi 快速排序
    规划兼职工作
    批量下载微信公众号要点
    Introduction to Assembly and RISC-V
    c++ grpc 第一个用例
    2022最新阿里Java面经,转疯了
  • 原文地址:https://blog.csdn.net/qq_44886213/article/details/127962887