Java 从入门到精通(十五):线程同步与 synchronized,为什么多个线程改同一个变量时结果总会乱?
Java 从入门到精通(十五):线程同步与 synchronized,为什么多个线程改同一个变量时结果总会乱?
上一篇我们刚把多线程的入门骨架搭起来:
- 什么是进程,什么是线程
- 为什么程序需要并发
- Java 创建线程的两种经典方式
- start() 和 run() 的区别
- 为什么共享数据会带来线程安全问题
如果说上一篇是在告诉你:并发世界和单线程世界不一样,那么这一篇要解决的,就是很多人第一次真正被多线程“教育”的那个瞬间:
为什么多个线程一起改同一个变量,结果总会乱?
明明代码就一行:
count++;看起来简单得不能再简单,但一到并发环境里,它就可能变成 bug 制造机。
所以这篇文章我们不急着讲太多高级并发包,而是先把线程同步最核心的入门问题讲透:
为什么共享变量会出错
什么叫线程同步
synchronized 到底在做什么
同步方法和同步代码块怎么写
锁对象到底锁的是谁
synchronized 能解决什么,不能解决什么
初学者最容易踩哪些坑
你把这一篇吃透,后面再学 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++ 并不是一个绝对不可分割的原子动作。
从执行过程看,它更像是三步:
读取 count 当前值
把这个值加 1
把新值写回去
如果两个线程同时执行,就可能出现这种情况:
线程 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。
为什么同步代码块很重要?
因为真实项目里,并不是整个方法都需要同步。
比如一个方法里可能有三部分:
参数检查
打日志
修改共享变量
真正需要互斥的,往往只有第 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,那这个“快”本身就是无效的。
所以学习同步的第一阶段,目标不是“把锁优化到极致”,而是:
先知道什么时候必须保护共享数据
先能写出正确的同步代码
再慢慢学习如何缩小锁范围、降低竞争、提升并发性能
正确性先于优化,这是并发学习里非常重要的一条线。
十二、一个更贴近真实开发的例子:卖票为什么会卖重?
计数器例子虽然经典,但很多人还是觉得有点抽象。
我们换一个更生活化的场景。
假设有 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 并发的第一层核心区了。
前一篇我们解决的是:
为什么线程一多,程序就开始不按单线程直觉运行。
这一篇解决的是:
为什么共享变量会出错,以及最基础的同步该怎么做。
