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,结果就丢了数据。
这办法的毛病
- 看不见 :A 改了
num
,B 完全不知道,还是用老数据。 - 抢着干:两个线程一块儿改,互相覆盖,谁也不让谁。
- 纯靠天:没啥控制,随缘运行,效率和正确性都不靠谱。
第一步优化:加点料------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 在代码里加了锁的开关,线程进来先锁门,改完数据再开门。
- 效果:既让改动立刻被别人看到,又保证一次只能一个线程干活。
新麻烦
- 有点慢:锁太重,线程得排队,2 个线程还行,20 个就拖后腿了。
- 锁得粗:整个方法都锁住,其实没必要锁那么多。
逼近高招:精细化与高效化
从啥也不管到加锁,咱们再往厉害的方向走几步。
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,数字准,方案扎实,拿去用绝对靠谱!