news 2026/5/13 19:12:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【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代码块内,就会出现“抱着锁睡”的情况,休眠期间其他线程也不能拿到这把锁。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/13 19:08:14

从技术段子到工程实践:构建无歧义的硬件开发沟通体系

1. 从“有点恼火”到“相当烦躁”&#xff1a;一则经典技术圈段子的深度拆解如果你在半导体、FPGA或者嵌入式开发这个行当里摸爬滚打有些年头了&#xff0c;大概率在某个技术论坛的边角&#xff0c;或者同事转发的邮件里&#xff0c;见过一篇名为《2011年欧洲恐怖威胁警报》的短…

作者头像 李华
网站建设 2026/5/13 19:07:13

ChatGPT Windows客户端生产力革命:12个Power Automate+Python脚本组合技,实现文档自动摘要、会议纪要实时转录与Excel智能填充

更多请点击&#xff1a; https://intelliparadigm.com 第一章&#xff1a;ChatGPT Windows客户端的核心架构与生产力定位 ChatGPT Windows 客户端并非简单网页封装&#xff0c;而是基于 Electron 与原生 Windows API 深度协同构建的混合架构应用。其核心由三层组成&#xff1a…

作者头像 李华
网站建设 2026/5/13 19:07:11

如何轻松掌握KMS智能激活:三步实现Windows和Office稳定激活

如何轻松掌握KMS智能激活&#xff1a;三步实现Windows和Office稳定激活 【免费下载链接】KMS_VL_ALL_AIO Smart Activation Script 项目地址: https://gitcode.com/gh_mirrors/km/KMS_VL_ALL_AIO 还在为系统激活弹窗而烦恼吗&#xff1f;是否遇到过Office突然变成只读模…

作者头像 李华
网站建设 2026/5/13 19:06:10

终极指南:彻底解决Cursor API限制,实现无限免费使用

终极指南&#xff1a;彻底解决Cursor API限制&#xff0c;实现无限免费使用 【免费下载链接】cursor-free-vip [Support 0.45]&#xff08;Multi Language 多语言&#xff09;自动注册 Cursor Ai &#xff0c;自动重置机器ID &#xff0c; 免费升级使用Pro 功能: Youve reached…

作者头像 李华