你得先明白一个核心矛盾:每个线程有自己的工作内存(可以理解为CPU高速缓存的一个抽象),里面存放了它需要使用的共享变量的副本。而所有变量都存储在主内存中。线程对变量的所有操作(读、写)都必须在工作内存中进行,不能直接读写主内存的变量。这就带来了经典问题:线程A修改了共享变量X,但这个修改可能还停留在它自己的工作内存里,没有及时刷回主内存;此时线程B去主内存读取X,拿到的还是旧值。这就是所谓的“可见性”问题。
那么JMM是怎么解决这个乱局的呢?它主要围绕着三个核心概念来构建这套规则:原子性、可见性、有序性。
原子性 指的是一个操作是不可中断的,要么全部执行成功,要么全部不执行。最基本的,对基本数据类型变量(除了long和double)的读写是原子的。但像i++这种“读取-修改-写入”组合操作,就不是原子的。要保证更大范围的原子性,就得请出关键字或者接口了,它们能保证被包裹的代码块在同一时刻只有一个线程能执行。
可见性 就是前面提到的,一个线程修改了共享变量,其他线程能够立即得知这个修改。除了(它会在释放锁之前,强制将工作内存中的修改刷新到主内存,并在获取锁时清空工作内存,从主内存重新加载),还有一个更轻量级的武器——关键字。被修饰的变量,任何线程对它进行写操作,都会立即同步到主内存;任何线程对它进行读操作,都会直接从主内存读取。这就保证了多线程环境下变量的可见性。
有序性 这个有点反直觉。为了提升性能,编译器和处理器常常会对指令进行重排序。但在多线程环境下,这种“自作聪明”的优化可能会带来灾难性的后果。JMM通过一套叫做“happens-before”的规则来禁止特定类型的重排序,从而保证有序性。关键字本身就包含了对重排序的限制:写操作之前的行为,不能重排序到写操作之后;读操作之后的行为,不能重排序到读操作之前。则天然保证了一个线程锁住的代码块内的所有操作,对于其他获得同一把锁的线程来说,是有序可见的。
这里必须重点提一下和的区别,这是面试高频考点,也是实际开发中容易用错的地方。解决的是可见性和有序性问题,但它不保证原子性。比如用 然后开10个线程每个执行1000次,最终结果大概率不是10000。因为不是原子操作。而是“万能”的,它同时保证了原子性、可见性和有序性,但代价是性能开销较大,属于重量级操作。所以,能用搞定可见性的场景,就别用。
理解了这些基础规则,我们再来看JMM为程序员提供的一些“武器”,也就是happens-before原则。它不需要任何同步手段协助,就天然保证了一个操作的结果对另一个操作可见。比如:
程序次序规则:一个线程内,书写在前面的操作happens-before于后面的操作。
监视器锁规则:对一个锁的解锁happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile变量的写操作happens-before于后续对这个变量的读操作。
传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
掌握了happens-before,你就能更深刻地理解为什么某些代码是线程安全的,而另一些不是。
最后,给个实际点的建议。平时写代码,尤其是在高并发环境下,对于共享变量,心里要时刻绷着JMM这根弦。单纯靠来修饰一个状态标志位(比如),实现一个线程通知另一个线程终止,这是非常经典和安全的用法。但如果共享变量涉及到多个步骤的复合操作(比如上面的,或者初始化一个对象并赋值),老老实实用或者包下的原子类(如)才是正道。JUC包下的很多工具类,其底层实现都深度依赖了对JMM规则的精确应用。
总之,Java内存模型不是洪水猛兽,它是一套严谨的规范。吃透了它,你才能写出真正健壮、高效的多线程程序,而不是在并发BUG的泥潭里挣扎。希望这篇唠叨能给大家带来一点实实在在的帮助。
2563

被折叠的 条评论
为什么被折叠?



