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

张开发
2026/4/14 2:01:24 15 分钟阅读

分享文章

Java 从入门到精通(十五):线程同步与 synchronized,为什么多个线程改同一个变量时结果总会乱?
Java 从入门到精通十五线程同步与 synchronized为什么多个线程改同一个变量时结果总会乱上一篇我们刚把多线程的入门骨架搭起来什么是进程什么是线程为什么程序需要并发Java 创建线程的两种经典方式start() 和 run() 的区别为什么共享数据会带来线程安全问题如果说上一篇是在告诉你并发世界和单线程世界不一样那么这一篇要解决的就是很多人第一次真正被多线程“教育”的那个瞬间为什么多个线程一起改同一个变量结果总会乱明明代码就一行count;看起来简单得不能再简单但一到并发环境里它就可能变成 bug 制造机。所以这篇文章我们不急着讲太多高级并发包而是先把线程同步最核心的入门问题讲透为什么共享变量会出错什么叫线程同步synchronized 到底在做什么同步方法和同步代码块怎么写锁对象到底锁的是谁synchronized 能解决什么不能解决什么初学者最容易踩哪些坑你把这一篇吃透后面再学 Lock、原子类、并发容器、线程池思路会顺很多。一、先看最典型的问题两个线程一起加 1结果为什么不对很多人第一次学线程同步时都会写一个计数器例子。publicclassCounter{intcount0;publicvoidincrement(){count;}}然后启动两个线程各自执行很多次加一操作publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{CountercounternewCounter();Threadt1newThread(()-{for(inti0;i10000;i){counter.increment();}});Threadt2newThread(()-{for(inti0;i10000;i){counter.increment();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.count);}}很多初学者会觉得最后结果肯定是 20000。但实际运行时你很可能看到186731932119984甚至每次都不一样这就是经典的线程安全问题。为什么会这样因为 count 并不是一个绝对不可分割的原子动作。从执行过程看它更像是三步读取 count 当前值把这个值加 1把新值写回去如果两个线程同时执行就可能出现这种情况线程 A 读到 5线程 B 也读到 5A 加 1 后写回 6B 加 1 后也写回 6于是两次加一最后却只增长了一次。这类问题本质上不是语法问题而是多个线程对共享状态的访问发生了竞争。二、什么叫线程同步“线程同步”这个词新手一开始往往会误解成“让线程同时执行”。其实恰恰相反。在很多场景里同步的真正意思是当多个线程访问同一份关键资源时要按受控的顺序来一个一个地进。你可以把它理解成公共卫生间门口加了一把锁。不是所有人都不能进去而是同一时刻只允许一个人进去其他人先等着放到代码里也是一样临界资源多个线程共同访问的数据临界区访问这份数据的那段代码同步保证同一时刻只有一个线程进入临界区这就是为什么线程同步并不神秘它本质上是在解决共享资源不能被“同时乱改”。三、synchronized 到底在做什么Java 里最经典、最基础的同步手段就是 synchronized。它的核心作用可以先用一句大白话概括给一段代码或一个方法加锁让同一时刻只有一个线程能执行。比如我们把前面的 increment() 改一下publicclassCounter{intcount0;publicsynchronizedvoidincrement(){count;}}这时多个线程再同时调用 increment()就不会再随便交叉执行这段关键代码了。因为线程进入这个方法前得先拿到对应的锁。拿到锁的线程先执行。没拿到锁的线程就只能等。所以 synchronized 不是在“让代码变快”而是在用排队换正确性。这点特别重要。并发里很多时候第一目标不是快而是先别错。四、同步方法怎么写synchronized 最容易上手的写法就是修饰实例方法。publicsynchronizedvoidincrement(){count;}这表示调用这个方法前需要先拿锁锁是当前对象也就是 this换句话说如果多个线程操作的是同一个 Counter 对象那么同一时刻只能有一个线程进入这个同步方法。示例publicclassCounter{privateintcount0;publicsynchronizedvoidincrement(){count;}publicintgetCount(){returncount;}}publicclassDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{CountercounternewCounter();Threadt1newThread(()-{for(inti0;i10000;i){counter.increment();}});Threadt2newThread(()-{for(inti0;i10000;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;}}也就是说多个线程如果操作的是同一个实例对象它们会竞争同一把锁。但如果它们操作的是不同对象那就是不同的锁彼此互不影响。例如Counterc1newCounter();Counterc2newCounter();线程 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{privateinttickets100;Overridepublicvoidrun(){while(true){if(tickets0){break;}System.out.println(Thread.currentThread().getName() 卖出第 tickets 张票);tickets--;}}}然后开启 3 个线程publicclassDemo{publicstaticvoidmain(String[]args){TicketWindowtasknewTicketWindow();newThread(task,窗口1).start();newThread(task,窗口2).start();newThread(task,窗口3).start();}}如果不加同步你可能会看到同一张票被卖两次出现第 0 张票甚至出现负数票为什么因为“判断还有没有票”和“卖出一张票”这两个动作不是一个不可分割的整体。可能线程 A 刚判断还有票线程 B 也判断还有票然后它们交叉执行结果就乱了。正确写法应该把关键区包起来publicclassTicketWindowimplementsRunnable{privateinttickets100;Overridepublicvoidrun(){while(true){synchronized(this){if(tickets0){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 才不容易乱如果你现在刚接触同步我建议按这个顺序建立理解。第一步先认出共享资源比如counttickets余额 balance库存 stock这些只要被多个线程共同访问就要提高警惕。第二步再认出临界区不是整个方法都危险。真正危险的是那些读共享变量改共享变量先判断再修改的关键代码段。第三步最后再决定锁谁常见锁对象有this某个共享资源对应的专用锁对象类对象 XXX.class不要一上来就背语法先想明白到底要让哪些线程互斥这比死记“同步方法、同步代码块、静态同步方法”更重要。十五、最后总结synchronized 不是难在语法而是难在你得先看见“共享”很多人第一次学 synchronized 时会觉得它语法并不难。确实不难。真正难的是你要先能看见哪些数据是共享的哪些代码会发生竞争。所以这篇文章真正想让你带走的不是“会写一个 synchronized 关键字”而是下面这几件事1并发问题最常见的根源是多个线程同时修改共享状态尤其像 count 这种看起来简单的操作恰恰最容易让新手掉坑。2线程同步的本质是让关键资源访问按受控顺序进行不是为了同时而是为了别乱。3synchronized 的核心作用是让同一时刻只有一个线程进入关键区它通过锁来实现互斥访问。4实例同步方法锁的是对象静态同步方法锁的是类锁到底加在谁身上这件事必须想明白。5真正重要的不是“会不会写锁”而是“锁有没有锁对”锁错对象、锁范围不对、关键代码没包进去都会让同步失效。从这一步开始你就正式进入 Java 并发的第一层核心区了。前一篇我们解决的是为什么线程一多程序就开始不按单线程直觉运行。这一篇解决的是为什么共享变量会出错以及最基础的同步该怎么做。

更多文章