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

Java 从入门到精通(十五):线程同步与 synchronized,为什么多个线程改同一个变量时结果总会乱?

Java 从入门到精通(十五):线程同步与 synchronized,为什么多个线程改同一个变量时结果总会乱?

上一篇我们刚把多线程的入门骨架搭起来:

  • 什么是进程,什么是线程
  • 为什么程序需要并发
  • Java 创建线程的两种经典方式
  • start() 和 run() 的区别
  • 为什么共享数据会带来线程安全问题

如果说上一篇是在告诉你:并发世界和单线程世界不一样,那么这一篇要解决的,就是很多人第一次真正被多线程“教育”的那个瞬间:

为什么多个线程一起改同一个变量,结果总会乱?

明明代码就一行:

count++;

看起来简单得不能再简单,但一到并发环境里,它就可能变成 bug 制造机。

所以这篇文章我们不急着讲太多高级并发包,而是先把线程同步最核心的入门问题讲透:

  1. 为什么共享变量会出错

  2. 什么叫线程同步

  3. synchronized 到底在做什么

  4. 同步方法和同步代码块怎么写

  5. 锁对象到底锁的是谁

  6. synchronized 能解决什么,不能解决什么

  7. 初学者最容易踩哪些坑

你把这一篇吃透,后面再学 Lock、原子类、并发容器、线程池,思路会顺很多。

一、先看最典型的问题:两个线程一起加 1,结果为什么不对?

很多人第一次学线程同步时,都会写一个计数器例子。

publicclassCounter{intcount=0;publicvoidincrement(){count++;}}

然后启动两个线程,各自执行很多次加一操作:

publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{Countercounter=newCounter();Threadt1=newThread(()->{for(inti=0;i<10000;i++){counter.increment();}});Threadt2=newThread(()->{for(inti=0;i<10000;i++){counter.increment();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.count);}}

很多初学者会觉得最后结果肯定是 20000。

但实际运行时,你很可能看到:

  • 18673

  • 19321

  • 19984

  • 甚至每次都不一样

这就是经典的线程安全问题。

为什么会这样?

因为 count++ 并不是一个绝对不可分割的原子动作。

从执行过程看,它更像是三步:

  1. 读取 count 当前值

  2. 把这个值加 1

  3. 把新值写回去

如果两个线程同时执行,就可能出现这种情况:

  • 线程 A 读到 5

  • 线程 B 也读到 5

  • A 加 1 后写回 6

  • B 加 1 后也写回 6

于是两次加一,最后却只增长了一次。

这类问题本质上不是语法问题,而是:

多个线程对共享状态的访问发生了竞争。

二、什么叫线程同步?

“线程同步”这个词,新手一开始往往会误解成“让线程同时执行”。

其实恰恰相反。

在很多场景里,同步的真正意思是:

当多个线程访问同一份关键资源时,要按受控的顺序来,一个一个地进。

你可以把它理解成公共卫生间门口加了一把锁。

  • 不是所有人都不能进去

  • 而是同一时刻只允许一个人进去

  • 其他人先等着

放到代码里也是一样:

  • 临界资源:多个线程共同访问的数据

  • 临界区:访问这份数据的那段代码

  • 同步:保证同一时刻只有一个线程进入临界区

这就是为什么线程同步并不神秘,它本质上是在解决:

共享资源不能被“同时乱改”。

三、synchronized 到底在做什么?

Java 里最经典、最基础的同步手段就是 synchronized。

它的核心作用可以先用一句大白话概括:

给一段代码或一个方法加锁,让同一时刻只有一个线程能执行。

比如我们把前面的 increment() 改一下:

publicclassCounter{intcount=0;publicsynchronizedvoidincrement(){count++;}}

这时多个线程再同时调用 increment(),就不会再随便交叉执行这段关键代码了。

因为线程进入这个方法前,得先拿到对应的锁。

拿到锁的线程先执行。

没拿到锁的线程,就只能等。

所以 synchronized 不是在“让代码变快”,而是在:

用排队换正确性。

这点特别重要。

并发里很多时候,第一目标不是快,而是先别错。

四、同步方法怎么写?

synchronized 最容易上手的写法,就是修饰实例方法。

publicsynchronizedvoidincrement(){count++;}

这表示:

  • 调用这个方法前,需要先拿锁

  • 锁是当前对象,也就是 this

换句话说,如果多个线程操作的是同一个 Counter 对象,那么同一时刻只能有一个线程进入这个同步方法。

示例:

publicclassCounter{privateintcount=0;publicsynchronizedvoidincrement(){count++;}publicintgetCount(){returncount;}}
publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{Countercounter=newCounter();Threadt1=newThread(()->{for(inti=0;i<10000;i++){counter.increment();}});Threadt2=newThread(()->{for(inti=0;i<10000;i++){counter.increment();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.getCount());}}

这时结果通常就稳定是 20000 了。

五、同步代码块怎么写?

有时候你不想整个方法都加锁,只想锁住其中最关键的那一小段代码。

这时更常用的是同步代码块:

synchronized(锁对象){// 需要同步的代码}

比如:

publicvoidincrement(){synchronized(this){count++;}}

它和同步实例方法在很多场景下效果类似,因为这里锁的也是 this。

为什么同步代码块很重要?

因为真实项目里,并不是整个方法都需要同步。

比如一个方法里可能有三部分:

  1. 参数检查

  2. 打日志

  3. 修改共享变量

真正需要互斥的,往往只有第 3 部分。

如果整个方法全锁住:

  • 锁粒度太大

  • 并发能力更差

  • 没必要的代码也被串行化了

所以更精细的做法通常是:

只锁关键区,不锁整段流程。

六、锁对象到底锁的是谁?这是最容易混乱的地方

很多初学者学 synchronized 时,语法会写,但心里其实并不清楚:

锁到底加在什么东西上?

这是理解同步机制最关键的一步。

1)实例方法上的 synchronized,锁的是当前对象

publicsynchronizedvoidincrement(){count++;}

等价理解为:

publicvoidincrement(){synchronized(this){count++;}}

也就是说,多个线程如果操作的是同一个实例对象,它们会竞争同一把锁。

但如果它们操作的是不同对象,那就是不同的锁,彼此互不影响。

例如:

Counterc1=newCounter();Counterc2=newCounter();

线程 A 调 c1.increment(),线程 B 调 c2.increment(),这两个线程并不会互斥。

因为锁不是“类名锁”,而是“对象锁”。

2)静态同步方法,锁的是类对象

publicstaticsynchronizedvoidtest(){// ...}

这里锁的不是某个实例,而是这个类对应的 Class 对象。

你可以先粗略理解成:

实例同步方法锁对象,静态同步方法锁类。

这个区别后面写工具类、单例、全局资源控制时会经常遇到。

七、一个非常常见的误区:锁没锁对对象,等于没同步

来看一个新手特别容易写错的例子:

publicvoidincrement(){synchronized(newObject()){count++;}}

很多人第一眼会觉得:

“我不是已经写了 synchronized 吗?”

但这段代码基本没起到同步作用。

因为每次进入方法时,你都 new 了一个全新的对象。

也就是说,每个线程拿到的都不是同一把锁。

那自然就不会形成互斥。

所以同步真正成立的前提是:

多个线程必须竞争同一把锁。

如果锁对象不一致,synchronized 就只是看起来像同步,实际上没有同步效果。

八、synchronized 解决的到底是什么问题?

入门阶段先抓住两个最重要的能力。

1)互斥访问

同一时刻,只允许一个线程进入关键区。

这能避免多个线程同时修改共享数据,导致结果混乱。

2)内存可见性保障

这是很多新手容易忽略,但很重要的一点。

synchronized 不只是“挡住别人”,它还会在进入和退出同步块时,建立一定的内存可见性规则。

你现在不用死记 JVM 规范细节,只需要先知道:

它不仅能管“谁先进去”,也能帮助保证线程之间看见较新的共享数据。

这也是为什么 synchronized 不只是“加一把门锁”那么简单。

九、synchronized 不能解决所有并发问题

这是很重要的边界感。

很多初学者会在心里形成一种错觉:

“学会 synchronized,并发问题就都搞定了。”

其实不是。

1)锁范围写错,还是会出问题

如果关键代码没有被真正包进去,依然不安全。

2)锁对象选错,还是会出问题

你锁了不同对象,就等于没形成互斥。

3)锁粒度太大,性能会变差

如果一个本来只需要保护一行数据更新的方法,你把大量无关逻辑也一起锁进去,并发性能就会下降。

4)还可能出现死锁

如果线程 A 持有锁 1 等锁 2,线程 B 持有锁 2 等锁 1,两个线程就可能互相卡死。

死锁是并发里另一个大坑,后面可以专门展开。

所以要把 synchronized 看成:

解决基础互斥问题的核心工具之一,而不是并发世界的万能药。

十、同步方法和普通方法可以同时执行吗?

这也是很高频的入门问题。

答案是:要看锁的是不是同一个对象,以及访问的是不是同一段受保护逻辑。

例如:

publicclassDemoService{publicsynchronizedvoidmethodA(){// 同步方法}publicvoidmethodB(){// 普通方法}}

如果一个线程在执行 methodA(),另一个线程调用 methodB(),通常是可以同时进行的。

因为 methodB() 没有加锁。

但问题是:

如果 methodB() 也在访问同一个共享变量,那它就绕开保护了。

这说明并发设计不能只看“某个方法有没有 synchronized”,而要看:

  • 哪些数据是共享的

  • 哪些代码会访问这些共享数据

  • 这些访问是否被同一套锁保护住

这才是更工程化的理解方式。

十一、为什么说同步会让程序变慢,但还是必须学?

因为同步意味着排队。

而排队本身就会带来等待。

比如本来两个线程可以同时往下跑,现在访问关键区时变成:

  • 线程 A 先执行

  • 线程 B 等着

  • A 出来后 B 再进去

从吞吐角度看,确实可能慢一些。

但你要注意:

错误的并发快,没有意义。

如果一个计数器理论结果应该是 20000,结果你跑得再快,最后得到 18372,那这个“快”本身就是无效的。

所以学习同步的第一阶段,目标不是“把锁优化到极致”,而是:

  1. 先知道什么时候必须保护共享数据

  2. 先能写出正确的同步代码

  3. 再慢慢学习如何缩小锁范围、降低竞争、提升并发性能

正确性先于优化,这是并发学习里非常重要的一条线。

十二、一个更贴近真实开发的例子:卖票为什么会卖重?

计数器例子虽然经典,但很多人还是觉得有点抽象。

我们换一个更生活化的场景。

假设有 100 张票,3 个窗口同时卖。

publicclassTicketWindowimplementsRunnable{privateinttickets=100;@Overridepublicvoidrun(){while(true){if(tickets<=0){break;}System.out.println(Thread.currentThread().getName()+" 卖出第 "+tickets+" 张票");tickets--;}}}

然后开启 3 个线程:

publicclassDemo{publicstaticvoidmain(String[]args){TicketWindowtask=newTicketWindow();newThread(task,"窗口1").start();newThread(task,"窗口2").start();newThread(task,"窗口3").start();}}

如果不加同步,你可能会看到:

  • 同一张票被卖两次

  • 出现第 0 张票

  • 甚至出现负数票

为什么?

因为“判断还有没有票”和“卖出一张票”这两个动作,不是一个不可分割的整体。

可能线程 A 刚判断还有票,线程 B 也判断还有票,然后它们交叉执行,结果就乱了。

正确写法应该把关键区包起来:

publicclassTicketWindowimplementsRunnable{privateinttickets=100;@Overridepublicvoidrun(){while(true){synchronized(this){if(tickets<=0){break;}System.out.println(Thread.currentThread().getName()+" 卖出第 "+tickets+" 张票");tickets--;}}}}

这时多个窗口会竞争同一个 task 对象上的锁,逻辑就稳定多了。

这个例子特别能说明一个事实:

并发 bug 往往不是某一行代码单独错了,而是多行组合起来没有原子性。

十三、初学者最容易踩的 7 个坑

1)以为 count++ 天然线程安全

这是最经典的错觉。

语法简单,不代表并发安全。

2)只给写操作加锁,却让读操作随便读

如果读写都涉及共享状态,读也可能读到不一致的数据。

3)锁对象不统一

这会导致看起来加了锁,实际上线程根本没在竞争同一把锁。

4)把整个大方法全锁住

能跑是能跑,但性能可能很差。

5)在循环里频繁创建新的锁对象

比如 synchronized(new Object()),几乎等于白写。

6)用 sleep() 代替同步

sleep() 只能延时,不能保证互斥,也不能保证逻辑顺序正确。

7)没有先识别共享资源,就盲目加锁

并发问题不是“看见线程就加锁”,而是先问:

  • 哪个变量是共享的?

  • 哪段代码会同时访问它?

  • 哪些操作必须作为一个整体执行?

先看清问题,再决定怎么锁。

十四、你应该怎么学 synchronized 才不容易乱?

如果你现在刚接触同步,我建议按这个顺序建立理解。

第一步:先认出共享资源

比如:

  • count

  • tickets

  • 余额 balance

  • 库存 stock

这些只要被多个线程共同访问,就要提高警惕。

第二步:再认出临界区

不是整个方法都危险。

真正危险的是那些:

  • 读共享变量

  • 改共享变量

  • 先判断再修改

的关键代码段。

第三步:最后再决定锁谁

常见锁对象有:

  • this

  • 某个共享资源对应的专用锁对象

  • 类对象 XXX.class

不要一上来就背语法,先想明白:

到底要让哪些线程互斥?

这比死记“同步方法、同步代码块、静态同步方法”更重要。

十五、最后总结:synchronized 不是难在语法,而是难在你得先看见“共享”

很多人第一次学 synchronized 时,会觉得它语法并不难。

确实不难。

真正难的是:

你要先能看见哪些数据是共享的,哪些代码会发生竞争。

所以这篇文章真正想让你带走的,不是“会写一个 synchronized 关键字”,而是下面这几件事:

1)并发问题最常见的根源,是多个线程同时修改共享状态

尤其像 count++ 这种看起来简单的操作,恰恰最容易让新手掉坑。

2)线程同步的本质,是让关键资源访问按受控顺序进行

不是为了同时,而是为了别乱。

3)synchronized 的核心作用,是让同一时刻只有一个线程进入关键区

它通过锁来实现互斥访问。

4)实例同步方法锁的是对象,静态同步方法锁的是类

锁到底加在谁身上,这件事必须想明白。

5)真正重要的不是“会不会写锁”,而是“锁有没有锁对”

锁错对象、锁范围不对、关键代码没包进去,都会让同步失效。

从这一步开始,你就正式进入 Java 并发的第一层核心区了。

前一篇我们解决的是:

为什么线程一多,程序就开始不按单线程直觉运行。

这一篇解决的是:

为什么共享变量会出错,以及最基础的同步该怎么做。

http://www.cnnetsun.cn/news/1876528.html

相关文章:

  • 收藏 | 零基础小白也能看懂:Transformer大模型是如何炼成的
  • HJ175 小红的整数配对
  • 短视频商城APP源码开发:技术、功能与运营全链路解决方案
  • 华为OD机试 - 魔法收积木 - 二进制(Python/JS/C/C++ 新系统 200分)
  • VS Code 插件系统深度剖析
  • SpringCloud微服务进阶-Nacos更加全能的注册中心澈
  • 消息队列Kafka与RabbitMQ深度解析:把分布式消息核心讲透,吊打面试官
  • ASTM D4169视网膜下注射套件的包装运输验证方案
  • 三相UVW的时间分配
  • MT6826S磁编码器:高精度与强抗干扰的工业级解决方案
  • AI Agent岗位面试通过率有多低:真实数据
  • 三维地图可视化 ThreeJS vue 开源项目
  • CV算法工程师成长路线:从入门到面试的25个关键节点
  • AI编程工具对比:Claude Code vs Devin vs Copilot
  • 模型解析 | GPT-3:开启上下文学习的1750亿参数巨兽(上)
  • 从模型装配到参数化:HFSS局部坐标系与面坐标系的进阶实战
  • 斯坦福AI开发课程对我帮助有多大:真实反馈
  • 别再羡慕Discord了!用TailChat在莱卡云上自建一个,保姆级图文教程(含Nginx反代配置)
  • 倾斜摄影模型修复避坑指南:从水面修补到道路置平,模方(ModelFun)实战操作全记录
  • 拿下CV算法offer:30+场面试总结的核心知识点
  • YOLO26涨点改进| CVPR 2026 | 独家创新首发、Conv改进篇| 全新TMConv三角掩码卷积模块,轻量化涨点改进,增强特征的空间感知能力,助力目标检测,图像去噪,图像分割有效涨点
  • Python 对象模型与属性访问机制
  • OpenFace 2.2.0:面部行为分析计算机视觉工具深度解析与实战应用指南
  • FFmpeg基础知识速览
  • 前端敏感数据国密SM2加密传输实战:从安全测试到代码落地
  • 获得solidworks 3d零件的包围框 长宽高 boundingbox c#
  • AI驱动学术写作:8款实用工具简化毕业设计流程
  • Joplin大纲插件终极指南:3分钟掌握智能文档导航
  • 该AI系统可智能识别论文重复段落,借助语义转换和结构重组有效增强文章的独特性
  • 2026届最火的十大AI写作助手推荐