当前位置: 首页 > news >正文

【Java SE】多线程(二):线程安全、synchronized、volatile与wait/notify详解

文章目录

  • 一、线程不安全
    • 1.1 线程不安全的直观现象
    • 1.2 线程不安全的原因
      • (1)原子性缺失:操作被拆分打断
      • (2)可见性缺失:数据更新互相看不见
      • (3)有序性缺失:指令被乱序优化
  • 二、synchronized
    • 2.1 三大特性
      • (1)互斥性(原子性)
      • (2)可见性
      • (3)可重入性
    • 2.2 三种使用方式
      • (1)修饰代码块(锁自定义对象)
      • (2)修饰实例方法(锁当前对象)
      • (3)修饰静态方法(锁类对象,全局唯一)
    • 2.3 修复计数器案例
    • 2.4 死锁
      • 构成死锁的必要条件
      • 如何避免死锁
    • 2.5 Java标准库中的线程安全类
  • 三、volatile
    • 3.1 内存可见性问题
    • 3.2 volatile的作用
      • (1)保证可见性:数据更新立即同步
      • (2)禁止指令重排序:避免逻辑错乱
  • 四、wait 等待 / notify 通知
    • 4.1 方法详解
      • (1)wait():让线程等待并释放锁
      • (2)notify():随机唤醒一个等待线程
      • (3)notifyAll():唤醒所有等待线程
    • 4.2 wait()与sleep()的区别

一、线程不安全

1.1 线程不安全的直观现象

多线程的优势是提升效率,但多个线程同时操作共享数据时,极易出现数据错乱的问题,这就是线程不安全。

计数器案例:

privatestaticintcount=0;publicstaticvoidmain(String[]args)throwsInterruptedException{// 线程1:自增5万次Threadt1=newThread(()->{for(inti=0;i<50000;i++)count++;});// 线程2:自增5万次Threadt2=newThread(()->{for(inti=0;i<50000;i++)count++;});t1.start();t2.start();t1.join();t2.join();// 预期10万,实际永远小于10万System.out.println("count: "+count);}

运行结果永远小于预期的10万,这就是典型的线程不安全问题。

1.2 线程不安全的原因

操作系统对线程的调度是随机的,这也是线程不安全的罪魁祸首。

(1)原子性缺失:操作被拆分打断

原子性指一段操作不可分割,要么全部执行,要么全部不执行

count++看似一行代码,实则对应3个CPU的指令:

  1. load:把内存中的值加载到CPU寄存器中
  2. add:把寄存器的内容+1
  3. save:把寄存器中的内容保存回内存中

执行这三个指令时不一定能一次执行完,很有可能1和2执行完,调度走;过了很久,再调度回来执行3。

如果两个线程对于同一个count进行操作,极大概率t1还没来得及保存新结果(1),t2就已经加载并修改,保存了数据(0 -> 1),此时再调度t1,t1接下来该保存新数据(1),t1的修改覆盖了t2的修改,对于这两次自增,count的结果是1。这样多次覆盖,就会导致count最终的结果小于100000

(2)可见性缺失:数据更新互相看不见

Java内存模型(JMM)规定线程有独立工作内存,共享数据存主内存

  • 线程修改共享变量时,先改工作内存副本,再同步到主内存。
  • 线程A修改了变量,但未及时同步到主内存,线程B读取的还是旧值,导致逻辑错误。

(3)有序性缺失:指令被乱序优化

为提升效率,编译器和CPU会对指令重排序(不影响单线程结果),但多线程下会打乱逻辑:

  • 典型场景:双重检查锁单例模式中,instance = new Singleton()可能被重排序,导致线程获取未初始化的对象,引发空指针。

二、synchronized

synchronized是Java内置的互斥锁,能同时保证原子性、可见性、有序性,是解决线程安全最常用的关键字。

  • 进入 synchronized 修饰的代码块, 相当于加锁
  • 退出 synchronized 修饰的代码块, 相当于解锁

加锁操作不是把线程锁死在CPU上,不让这个线程被调度走,而是禁止其他线程重新加这个锁,避免其他线程的操作,在当前线程的执行过程中插队。

2.1 三大特性

(1)互斥性(原子性)

synchronized用的锁是存在Java对象里的,可以粗略的理解为每个对象在内存中存储时,都有一块内存表示当前锁定的状态(类似于厕所的有人/无人)

  • 如果是无人状态,就可以使用,使用时设置为“有人”状态
  • 如果是有人状态,其他人无法使用,只能等待。

理解阻塞等待
针对每一把锁,操作系统内部都维护了一个等待队列,当这个锁被某个线程占有时,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直到之前的线程解锁后,由操作系统唤醒一个新线程,再来获取这个锁。

  • 上个一个线程解锁后,下一个线程不是立即就能获取,而是靠操作系统来“唤醒”,这也是操作系统线程调度的一部分工作
  • 假设A B C三个线程,线程A先获得锁,然后B尝试获取,C再尝试获取。此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来,B不一定立即获得锁,而是重新与C竞争。
  • 同一时刻,只有一个线程能获取同一把锁,执行临界区代码。
  • 其他线程尝试获取锁时,会进入阻塞等待状态,直到锁被释放。

(2)可见性

  • 线程进入synchronized代码块时,清空工作内存,从主内存加载最新数据
  • 线程退出代码块时,强制将修改后的数据刷新回主内存,其他线程能立即看到最新值。

(3)可重入性

理解“把自己锁死”
一个线程没有释放锁,又尝试重新加锁
例如第一次加锁,成功上锁;第二次加同一把锁,锁已经被占用,就会阻塞等待。
按照之前的锁的设定,第二次加锁,阻塞等待,直到第一次的锁释放;而释放第一个锁也是由该线程完成,这样就陷入了死循环,把自己锁死了。
这样的锁称为“不可重入锁”

Java的synchronized引入了可重入的概念,同一线程可重复获取同一把锁,不会自己锁死自己。

底层通过线程持有者+计数器实现:加锁时计数器+1,解锁时计数器-1,计数器为0时真正释放锁。

2.2 三种使用方式

(1)修饰代码块(锁自定义对象)

// 锁任意对象privatestaticfinalObjectlock=newObject();publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1=newThread(()->{for(inti=0;i<50000;i++){synchronized(lock){// 加锁count++;}// 解锁}});}

对于锁来说任意对象都可以,一般情况下,我们会专门定义一个Object类给锁使用。

(2)修饰实例方法(锁当前对象)

// 锁当前实例对象publicsynchronizedvoidincrement(){count++;}

(3)修饰静态方法(锁类对象,全局唯一)

// 锁当前类的Class对象,所有实例共享一把锁publicsynchronizedstaticvoidincrement(){count++;}

2.3 修复计数器案例

count++synchronized锁,保证原子性:

privatestaticintcount=0;privatestaticfinalObjectlock=newObject();publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1=newThread(()->{for(inti=0;i<50000;i++){synchronized(lock){count++;}}});Threadt2=newThread(()->{for(inti=0;i<50000;i++){synchronized(lock){count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count: "+count);// 输出10万}

2.4 死锁

死锁是指两个或多个线程,各自拿着对方需要的锁,又互相等待对方释放锁,谁都不放手、谁都走不了,程序永久阻塞卡死。

构成死锁的必要条件

  1. 锁是互斥的。一个线程拿到锁后,另一个线程想要拿锁必须阻塞等待
  2. 锁不可剥夺。线程1拿到锁,线程2也想获取这个锁,必须阻塞等待,而不能直接抢占
  3. 请求和保持。一个线程拿到锁A,不释放锁1的前提下,获取锁B
  4. 循环等待。 多个线程的等待过程构成了循环,例如A等B释放,B也在等A释放

如何避免死锁

前两个条件是锁的基本特性,想要避免死锁的出现就要破坏掉3或4.

  1. 统一锁的获取顺序
    所有线程都按固定顺序拿锁,例如约定从序号小的锁开始获取,如果锁已经被获取,就要阻塞等待
  2. 放弃请求与保持
    要么一次性把需要的锁全部拿到,一把都不拿不到就不执行。
  3. 设置超时
    尝试拿锁等待一段时间,拿不到就放弃,不永久阻塞。
  4. 减少嵌套加锁

2.5 Java标准库中的线程安全类

Java标准库中很多是线程不安全的,这些类可能会涉及多线程修改共享数据,又没有加锁措施。

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

线程安全类:

  • StringBuffer是线程安全的,正是因为其中的方法都有synchronized。

    但是由于synchronized的限制,代码中可能出现锁的竞争导致阻塞,会使代码的效率大打折扣

  • String:虽然没有加锁,但是不涉及修改,仍然是线程安全的

三、volatile

3.1 内存可见性问题

以下面代码为例:t2改变flag的值来影响t1的执行,当输入1时,理论上t1内while条件不成立,应该跳出循环,线程结束。然而实际上输入1后t1线程还在继续

publicclassdemo18{privatestaticintflag=0;publicstaticvoidmain(String[]args){Threadt1=newThread(()->{while(flag==0){}System.out.println("t1 线程结束");});Threadt2=newThread(()->{//修改flagScannerscan=newScanner(System.in);System.out.println("输入flag的值:");flag=scan.nextInt();});t1.start();t2.start();}}

很明显,这也是由于线程安全导致的bug。一个线程在读取,一个线程在修改,修改的值没有被另一个线程读取到,这就是“内存可见性问题”。

这涉及到编译器优化

我们写的代码,都会通过javac.java文件编译成.class字节码文件,由jvm执行。编译器可以保持代码逻辑不变的情况下,对代码进行优化,提升效率。

而在多线程场景,编译器很有可能判断错误,导致优化前后的逻辑不完全相同。

分析编译器如何误判

t1中是空循环,对于CPU主要就是两个操作:load(加载flag的值),cmp(条件跳转)

  • 对于cmp:cpu的寄存器操作,速度快很多
  • 对于load:需要到内存中访问,时间可能是cmp的几千倍。

每轮循环执行速度非常快,短时间内就可以执行很多次,每次读取flag的值都是不变的。经过多次循环,JVM认为这个读取操作可以被优化(正是因为load在循环中时间消耗是cmp的几千倍),因此把读内存操作改成了读寄存器操作。

而用户输入值可能要经过好几秒,与上述的操作时间完全不是一个量级。等到用户真的输入flag的值,t1已经感知不到了(编译器优化使得t1的读操作不是真正的读内存)

如果在循环中加入一些语句

while(flag==0){sleep(1);}

加入sleep后,使循环的速度大大大大幅度下降,此时load时间占比对于整个循环小了很多,JVM认为这个优化没有必要,每次都是读内存操作。因此t2的修改可以被t1感知到,结果正确。

3.2 volatile的作用

volatile轻量级并发关键字不保证原子性,仅保证可见性和禁止指令重排序,适合解决“一个线程写、多个线程读”的场景。

(1)保证可见性:数据更新立即同步

  • volatile变量:修改后立即刷新到主内存
  • volatile变量:强制从主内存读取最新值,不读工作内存缓存。

解决线程感知不到变量更新的问题:

publicclassdemo18{privatevolatilestaticintflag=0;publicstaticvoidmain(String[]args){Threadt1=newThread(()->{while(flag==0){}System.out.println("t1 线程结束");});Threadt2=newThread(()->{//修改flagScannerscan=newScanner(System.in);System.out.println("输入flag的值:");flag=scan.nextInt();//输入后t1就可以感知到,跳出循环,线程结束});t1.start();t2.start();}}

(2)禁止指令重排序:避免逻辑错乱

  • volatile变量前后会加内存屏障,禁止编译器和CPU对其前后指令重排序。
  • 应用:双重检查锁(DCL)单例模式,防止指令重排序导致的空指针。

volatile适合无复合操作(如count++)、仅需可见性/有序性的场景;复合操作必须用synchronizedAtomicInteger

四、wait 等待 / notify 通知

多线程不仅要“互斥”,还要“协作”——比如生产者生产完数据,通知消费者消费;消费者无数据时等待。wait()notify()notifyAll()是实现线程等待-唤醒的核心方法,定义在Object类中。

4.1 方法详解

(1)wait():让线程等待并释放锁

  • 作用:当前线程进入阻塞等待状态,释放持有的锁,允许其他线程获取锁执行任务。
  • 重载:wait()(无限等待)、wait(long timeout)(超时等待,毫秒)。
  • 必须在synchronized代码块/方法中调用,否则抛IllegalMonitorStateException

(2)notify():随机唤醒一个等待线程

  • 作用:随机唤醒一个在当前对象锁上等待的线程
  • 唤醒后不立即释放锁,需当前线程退出synchronized代码块后,被唤醒线程才能竞争锁。

(3)notifyAll():唤醒所有等待线程

  • 作用:唤醒所有在当前对象锁上等待的线程
  • 所有线程被唤醒后竞争同一把锁,同一时刻只有一个线程能执行。

注意:

  1. 调用wait(),notify()以及这两个方法所在的synchronized代码块内必须是同一对象才能生效
  2. 要确保先wait 再notify,才会有作用。如果先notify再wait,不会对notify所在的线程有影响,但是没啥用

4.2 wait()与sleep()的区别

特性wait()sleep()
所属类Object类Thread类
锁行为释放锁不释放锁
唤醒方式需notify()/notifyAll()唤醒超时自动唤醒
使用场景线程协作(等待-唤醒)线程休眠(暂停执行)

如果sleep()在synchronized代码块内,就会出现“抱着锁睡”的情况,休眠期间其他线程也不能拿到这把锁。

http://www.jsqmd.com/news/810576/

相关文章:

  • 5分钟彻底解决Windows激活难题:KMS_VL_ALL_AIO智能激活完全指南
  • 同相比例、反相比例、差分、加减运算放到大电路基础知识及Multisim电路仿真
  • 陈,无干扰恒温加热鼠台 无干扰恒温加热兔台 鼠兔解剖台 鼠兔二用解剖台
  • 汽车电子冗余设计|全网独家复现,MSA注意力创新改进篇 从芯片架构到系统级功能安全,从原理、代码到量产落地
  • 在无代码平台中通过Webhook接入Taotoken大模型
  • Docker容器化高可用架构部署方案(三)
  • 别再死记硬背了!用5个工业现场案例,帮你彻底搞懂液压与气动系统
  • 什么是Docker
  • ARM-2D:为Cortex-M GUI注入“灵魂”的2D加速库
  • 半导体并购新范式:从外科手术到生态位投资的战略演变
  • MCP与n8n集成:AI智能体调用自动化工作流实战指南
  • 技术媒体进化论:从行业记录者到工程师社区的40年蜕变
  • HexHub全面支持国产数据库以及AI助手
  • 连锁不平衡热图绘制神器:LDBlockShow快速入门与实战指南
  • ArcGIS线要素编辑进阶:除了画线,这5个高效编辑技巧让你事半功倍
  • 滚珠丝杆反向间隙全解:从产生机理到工程补偿(附盘岩科技PKH40实测数据与选型指南)
  • 第27章 案例25:网页随机抽奖效果【JS常用全局方法】【JS进阶篇】
  • Day1 3D的方块翻滚
  • 从‘采样失败’到‘波形光滑’:单电阻FOC电流重构的全流程避坑指南
  • 技术会议价值挖掘:从ESC嵌入式会议看高ROI参会方法论
  • 【c++笔记】类和对象流食般投喂(上)
  • Beyond Compare 5密钥生成器技术架构与实现原理深入解析
  • 智能卫浴行业品牌升级咨询:以奇正沐古与恒洁卫浴为例 - 品牌速递
  • 动态标定自修正·跨镜ID恒定·轨迹时序无断链 技术专项解析
  • 利用Taotoken多模型聚合能力优化AI应用选型成本
  • 物联网操作系统Zephyr(蓝牙篇)之6.1 实战解析:Zephyr蓝牙协议栈的三种构建模式与应用选型
  • CodeBuddy 客户端 ECONNREFUSED 错误排查实录
  • NoFences:免费开源桌面分区工具,让Windows桌面告别混乱的终极解决方案
  • 用ccagents优雅管理Claude Code智能体:符号链接与声明式配置实战
  • CTF SHOW WEB入门79