浅谈Jmm的应用:并发安全-从volatile+粗/细锁到原子类

Java 内存模型(JMM)是多线程编程里绕不开的核心,决定了线程怎么跟共享数据打交道。今天咱们就从最基础的玩法聊起,一步步推演,找到问题,再优化到如今主流的高效方案。过程尽量轻松易懂,但逻辑一点不含糊,走着瞧!


JMM 是啥?内存咋分的

JMM 把内存分成两部分,简单粗暴:

  • 主内存:线程共享的地盘,放的是"官方"数据,像对象啊、静态变量啊。
  • 工作内存:每个线程的小天地,里面存的是从主内存抄过来的数据副本。

线程要干活,得先从主内存把数据拉到自己的工作内存,用完再写回去。打个比方,主内存是大账本,工作内存是每个线程的小抄本。

举个栗子 :有个变量 int num = 0 在主内存里。线程 A 抄一份到自己小抄本,改成 1,但没写回去;线程 B 也抄了一份,还是 0。这就暴露了 JMM 的关键挑战:线程间的数据咋保持一致?


最原始的办法:啥也不干

先看一段代码:

java 复制代码
public class NumberGame {
    public static int num = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) num++; });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) num++; });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num);
    }
}

你可能觉得 num 最后会是 2000,毕竟两个线程各加了 1000 次。但跑几回,结果可能是 1975、1998,咋回事呢?原因出在 num++ 不是一气呵成的,它分三步:读 num、加 1、写回去。线程 A 和 B 可能同时读到 100,都加到 101,写回去还是 101,结果就丢了数据。

这办法的毛病

  1. 看不见 :A 改了 num,B 完全不知道,还是用老数据。
  2. 抢着干:两个线程一块儿改,互相覆盖,谁也不让谁。
  3. 纯靠天:没啥控制,随缘运行,效率和正确性都不靠谱。

第一步优化:加点料------volatile

咱们给 num 加个 volatile 试试:

java 复制代码
public static volatile int num = 0;

volatile 是干啥的?它能:

  • 保证看得到 :一个线程改了 num,立刻刷到主内存,其他线程读的时候也得从主内存拿最新值。
  • 别乱动顺序:编译器和 CPU 不会随便调整指令顺序。

咋做到的?JVM 在背后搞了点小动作,写数据时加个"写墙",确保改完就更新主内存;读时加个"读墙",逼着从主内存拿新鲜数据。

但再跑代码,num 还是不到 2000。为啥?volatile 只管让大家看到最新值,可 num++ 这三步还是分开走,线程抢着写照样会丢数据。

这步的短板

  • 抢夺没解决:数据还是会被覆盖。
  • 效果有限:性能开销不大,但问题没根治。

再升级:锁起来------synchronized

换个思路,把 num++ 塞进 synchronized 里:

java 复制代码
public class NumberGame {
    public static int num = 0;
    public static synchronized void addOne() { num++; }

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) addOne(); });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) addOne(); });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num); // 稳稳的 2000
    }
}

这回成了!synchronized 是咋搞定的?

  • 底层逻辑:靠的是监视器锁。JVM 在代码里加了锁的开关,线程进来先锁门,改完数据再开门。
  • 效果:既让改动立刻被别人看到,又保证一次只能一个线程干活。

新麻烦

  1. 有点慢:锁太重,线程得排队,2 个线程还行,20 个就拖后腿了。
  2. 锁得粗:整个方法都锁住,其实没必要锁那么多。

逼近高招:精细化与高效化

从啥也不管到加锁,咱们再往厉害的方向走几步。

1. 锁得更聪明

别锁整个方法,改用对象锁,只锁关键部分:

java 复制代码
public class NumberGame {
    private int num = 0;
    private final Object lock = new Object();

    public void addOne() {
        synchronized(lock) { num++; }
    }
}

好处:锁的范围小了,线程不用等太久,效率高了不少。现在很多框架都喜欢这么干。

2. 不锁也行------原子类

直接上 AtomicInteger,扔掉锁:

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class NumberGame {
    public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) num.incrementAndGet(); });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) num.incrementAndGet(); });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num.get()); // 2000,没毛病
    }
}

咋实现的 ?靠 CAS(比较并交换),直接用 CPU 的原子指令,效率高还不堵车。 接轨主流:Java 的并发包里全是这种玩法,像线程池、并发容器都靠它。

3. 锁的进化

JVM 也没闲着,给 synchronized 加了优化:

  • 偏向锁:线程少时几乎没开销。
  • 轻量级锁:竞争稍微多点时用。
  • 重量级锁:真打起来才上。

更现代的路子 :用 ReentrantLock,比 synchronized 灵活,能搞公平锁、条件等待,功能更强。


总结一下

JMM 把内存分成主内存和工作内存,线程间数据同步靠 volatile 管可见性,synchronized 管独占性。从啥也不干到加锁,再到原子类和锁的精细优化,线程安全一步步稳如老狗。两个线程各加 1000 次,最后就是 2000,数字准,方案扎实,拿去用绝对靠谱!

相关推荐
千叶寻-32 分钟前
正则表达式
前端·javascript·后端·架构·正则表达式·node.js
小咕聊编程2 小时前
【含文档+源码】基于SpringBoot的过滤协同算法之网上服装商城设计与实现
java·spring boot·后端
追逐时光者8 小时前
推荐 12 款开源美观、简单易用的 WPF UI 控件库,让 WPF 应用界面焕然一新!
后端·.net
Jagger_8 小时前
敏捷开发流程-精简版
前端·后端
苏打水com9 小时前
数据库进阶实战:从性能优化到分布式架构的核心突破
数据库·后端
间彧10 小时前
Spring Cloud Gateway与Kong或Nginx等API网关相比有哪些优劣势?
后端
间彧10 小时前
如何基于Spring Cloud Gateway实现灰度发布的具体配置示例?
后端
间彧10 小时前
在实际项目中如何设计一个高可用的Spring Cloud Gateway集群?
后端
间彧10 小时前
如何为Spring Cloud Gateway配置具体的负载均衡策略?
后端
间彧10 小时前
Spring Cloud Gateway详解与应用实战
后端