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

发布时间:2026/6/8 19:27:52

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(ticketNums0){ 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()-drawMoney0){ 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方法就要一直抢票直到 ticketNums0也就是意味着一张票也没有了才会退出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(flagtrue){ try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } buy(); } } public synchronized void buy(){ if(ticketNums0){ 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(ticketsNumber0) { 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就是针对java编译器模型。JMM是一套规则一套保证子线程数据一致性的规则一个线程修改了某个共用的变量其他线程能够及时知道有些时候即使不加volatile也能及时知道。1、JMM定义了以下操作数据的操作锁定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 一个被其他线程锁定住的变量。......2、JMM还定义了4种内存屏障但是内存屏障只能保证可见性和禁止指令重排序无法保证原子性。5.1 volatile 保证可见性主线程和各个子线程开辟不同的内存空间子线程第一个用到某个变量的时候时会从主内存拷贝走到本地运行内存。如果主内存定义了一个int a 1那么所有拷贝走的本地运行内存也有这个a如果某个线程在本地运行内存中改了a的值如何同步到所有线程中去呢volatile关键字有点类似于cache的写策略。我们在写代码的时候只需要加一个volilate关键字就可以实现子线程内存的同步。那么底层是如何帮我们实现的呢java内存模型其实就是一种规定——规定了如果一个变量程序员想让其作为各个线程的共享变量那么就要给它加一个volatile关键字。加了这个关键字的变量一旦被线程A修改就要立马写回主内存中。一旦线程B、C、D想要用这个共享变量就一定要去主内存当中读。5.2 volatile禁止指令重排序JVM可以对指令进行重排序没想到吧。之前学过指令的重排有编译器级别的重排序也有CPU级别的重排。编译器级别重排的指的是“中间代码优化”CPU级别重排的是指令。指令重排在单线程环境下不会出现问题但是在多线程环境下。。。volatile可以通过加内存屏障的方式禁止指令重排序。uniqueInstance采用volatile关键字修饰也是很有必要的uniqueInstance new Singleton();这段代码其实是分为三个指令执行为uniqueInstance分配内存空间初始化uniqueInstance将uniqueInstance指向分配的内存地址jvm会对指令进行重排比如把123的执行顺序改为132。如果现在开启了两个线程A和BA进行到了uniqueInstance new Singleton()这行代码而由于jvm的指令重排先走的1和3并没有给uniqueInstance初始值但是却给了地址这样uniqueInstance就不为空了。如果此时线程B走到了if(uniqueInstancenull)这行代码那么会立马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终于大彻大悟什么叫悲观锁和乐观锁了悲观锁就是比较悲观认为每一次访问资源都会有人抢哪怕可能这次没人跟他抢他都会这么认为所以每次访问资源都要加锁。乐观锁就是比较乐观认为每一次访问资源不会有人抢等有人来抢再说。注意乐观锁虽然叫锁但是根本不加锁所以乐观锁不会发生死锁悲观锁会。悲观锁synchronizedLock的实现类ReentranceLock。乐观锁就是总是考虑最好的情况居然允许一个线程写的时候另一个线程一起写比如版本号机制CAS版本控制区别于MySQL的多版本控制java级别的乐观锁只是单版本控制MySQL之所以需要多版本是因为涉及到事务提交和未提交的问题。首先这个版本号是资源/数据/变量的版本号。在资源上加一个隐藏字段 version 版本号表示资源被修改的次数。当资源被修改时version 值会加一。当线程 A 要更新数据值时在读取数据的同时也会读取 version 值在提交更新时若刚才读取到的 version 值为当前资源的 version 值相等时才更新否则重新来一遍再去读最新版本的数据直到更新成功。举一个简单的例子假设数据库中帐户信息表中有一个 version 字段当前值为 1 而当前帐户余额字段 balance 为 $100 。操作员 A 此时将其读出 version1 并从其帐户余额中扣除 $50 $100-$50 。在操作员 A 操作的过程中操作员 B 也读入此用户信息 version1 并从其帐户余额中扣除 $20 $100-$20 。操作员 A 完成了修改工作将数据版本号 version1 连同帐户扣除后余额 balance$50 提交至数据库更新此时由于提交数据版本等于数据库记录当前版本数据被更新数据库记录 version 更新为 2 。操作员 B 完成了操作也将版本号 version1 试图向数据库提交数据 balance$80 但此时比对数据库记录版本时发现操作员 B 提交的数据版本号为 1 数据库记录当前版本也为 2 不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略因此操作员 B 的提交被驳回。这样就避免了操作员 B 用基于 version1 的旧数据修改的结果覆盖操作员 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 语言并没有直接实现 CASCAS 相关的实现是通过 C 内联汇编的形式实现的JNI 调用。因此 CAS 的具体实现和操作系统以及 CPU 都有关系。sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作使用场景悲观锁通常多用于写多比较多的情况下多写场景避免频繁失败和重试影响性能。乐观锁通常多于写比较少的情况下多读场景避免频繁加锁影响性能大大提升了系统的吞吐量。二、公平锁和非公平锁首先说明公平锁和非公平锁的概念只针对悲观锁因为乐观锁不需要排队直接上来就改。公平锁先来先服务。锁被释放之后先申请的线程先得到锁也就是等候队列中排在前面的。但是比如一个要执行30min的线程排在一个只需要执行3s的线程前面这看起来就有点不公平了影响程序的执行效率所以java默认使用的是非公平锁。非公平锁java默认使用锁被释放之后后申请的线程可能会先获取到锁是随机一上来就尝试占有锁或者按照其他优先级排序的。性能更好但可能会导致某些线程永远无法获取到锁。我们看一下ReentrantLock的源码理解一下三、重入锁和不可重入锁四、重量级锁和轻量级锁

相关新闻