细说Java的内存模型

    多线程之所以复杂,是因为在多线程环境下,稍有不慎就会导致诡异的问题。这个诡异表现在,他不是一定会出现,他不知道会在什么时候会以什么样的姿态出现在我们的生产环境中。为了避免犯错误,所以我们在所有可能出问题的地方都加上synchronize,那当然也是欠妥的,所以我们要搞清楚其中的细节,对我们写程序就有比较大的帮助了。

    要知道Java为什么容易出问题,首先就要看看他的内存模型了。我们先看一下硬件方面的问题。cpu在设计的时候,都设计了一级、二级缓存,甚至有三级缓存,就是为了加速数据的访问。因为cpu访问主存相比于访问cpu的寄存器的速度,不是一个数量级的。如果所有的数据都从主存中去取固然是没有了同步的问题,但是效率也就下降了。如果针对一个读比较多的业务程序来说,命中了cpu缓存的读取,会大大加速程序的速度,这也是设计cpu的初衷,就是为了加速。这必然就带来了同步的问题,我们抽象一下cpu的缓存模型如下(图片为引用)
                                        
当cpu将一个变量的值更新之后,只在自己的cpu缓存里生效了,另外的cpu却看不到,这便是可见性问题。
为了应对这个问题,硬件工程师们提出了一个解法,他就是Cache一致性协议MESI。大概说说mesi协议。他的做法就是在变量身上打一个标,一共有S\E\M\I四种状态。分别是
第一种,当读的时候。
    如果变量是I状态,他就会向总线发送一条消息,想知道当前的变量到底是啥。现在其他cpu如果收到了消息,就会看自己的标志位,如果是M的则会把M的值发给他,否则的话,由主存或者其他cpu把当前的值发送给他。
    如果不是I状态,而是其他三种状态,则会直接使用当前缓存里的值。
第二种,当写的时候。
    写的时候,他会先发送消息给其他cpu,让他们失效当前的变量值。等到其他cpu都回答了他,告诉他失效完成,他就会尝试抢写锁,也就是把变量进入E状态。如果抢锁成功,他会写入变量到自己缓存里进入M状态,最后再刷新进主存,整个写过程就完成了。
    但是我们发现,如果干等着其他cpu回复他失效成功的消息,实在是太慢了一点,硬件工程师又想了一个办法。他们让每一个cpu有一个失效队列,失效的时候,先放进失效队列。然后那个cpu也不等他们消息,直接把值写进一个缓存区,等到一定时间,把这段时间修改的变量一起刷进主存。
    我们可以看到,在追求性能的时候,工程师们无所不用其极。但是在引入一个解决方案的同时,又会引入另外一个问题。当然为了彻底解决,可以一刀切,不用缓存,这显然就把想要优化的程序的人拒之门外了。所以他引入了缓存,并且把这些机制留给写代码的人,让你来决定怎么做。也就是为了速度,让程序变的复杂了。你想要更快就得接受复杂的事实,所以事情都是有两面性的。
另外一个导致问题的根源就是cpu的重排序导致。重排序有三种,编译期的重排序,指令重排序,内存重排序。重排序,也是为了让程序更快,我们可以举例说明。如下程序,
class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;//1
        flag = true;//2
    }
    public void read(){
        if(flag){//3
            int i = a + 1;//4
        }
    }
}
解释下这段代码,1和2重排序其实是不改变代码的执行结果的,所以在执行的时候,就能重排序。另外,3和4,通常cpu在执行的时候,会先执行4,因为他不会干等着3的结果再去执行4,他可以先执行4,再去计算3,等3计算出来了4也执行完了,并行执行岂不是更快。
在单线程的程序中,程序总是能保证按照你预期的顺序执行(即使被重排序了),因为最终结果和顺序执行是一样的,所以你感觉不到。但是在多线程的环境下可能就有问题了。
现在假设有两个线程分别在执行write和read两个方法,可能在执行a+1的时候,a还未被赋予1,也就是a还是0,这个时候程序也就出错了。
为了解决上面的问题,Java引入了JMM,也就是内存模型,试图向Java程序员屏蔽掉这些底层的细节,让程序更容易写。
在JDK5中,Java实现了jsr-133协议,他主要制订了一下的四条规则,也就是非常重要的happens-before原则,依靠这个原则,我们就能判断我们的程序是否是线程安全的。
a. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
b. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
c.volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
d. 传递性 A先于B ,B先于C 那么A必然先于C
那么volatile和所具体是如何保证线程安全性的呢?我们来仔细看一看。
volatile: volatile通过内存屏障保证了volatile的重排序,这就保证数据的写入一定在读取之前。另外一个方面,volatile是读写都是直接跟主存打交道的,所以是牺牲了性能的。所以你为了线程安全,能把所有的变量都定义成volatile的吗?显然是要根据具体情况具体分析嘛。值得注意的是,volatile虽然是原子性的,但是volatile的变量自增显然不是原子性的,因此也就会有数据一致性问题。
锁: 锁的释放会给获取锁的线程发送信息,并且将自己的变量刷回到主存中。同时根据happens-before原则,则可以看出以下的模型(转载自http://ifeve.com/java-memory-model-5/)
ReentrantLock底层则是使用volatile变量来共享状态,同时通过CAS来保证原子性。
Final: final修饰的变量能保证这个变量不会被重排序到构造函数之外,也就是说一定会把这个变量写入主存后,才会return出构造函数,而没有被final修饰的就没有这个保证了,所以当对象构造玩,没有final修饰的变量,可能还未被写入值。

例如上面这个代码,当wirte里的new FinalExample之后,j一定是2,而i就不能保证了,很可能还是0。
如果是读一样的,当调用reader方法的时候,另外一个线程正在执行构造函数。此时因为重排序,可能普通变量i被重排序到了reader函数之外,所以是一个不确定的值,而final则一定是正确的值。

上面的图显示,在读取引用对象obj之前,可能已经完成了普通域i的读取。而j就因为有final修饰而不会被重排序。
 
说完了多线程下的JMM,我们同时来回忆一下jvm层面的内存模型。
Java将内存分为以下区域,堆、方法区、栈。前两个是所有线程共享的,堆里面存放的所有的实例,方法区是类的信息,静态变量等。栈则是每一个线程独占的。
而堆是gc主要作用的地方,他又分为老年代和新生代。新生代又分为伊甸区和幸存区。伊甸区的数据经过几轮的gc进入到幸存区,最后进入到老年代。如图所示
说到gc就要看gc的几个算法。
第一种算法,标记清除法,缺点是会形成内存碎片
第二种算法,标记复制法,虽然没有了碎片,但是会使用两倍的内存空间,属于以空间换时间。
第三种算法,标记移动算法。也就是标记后,不是马上清楚,而是向一边移动,最后把边缘的不需要的清除掉。
第四种算法,分代收集法。例如,如果老年代用标记复制法,那就是灾难,因为老年代会遗留有很多未被清除的对象,使用这个标记复制法,得占用多少空间啊,比较适合新生代。而新生代如果使用标记清除法,也会是灾难,因为会导致很多内存碎片。那么就提出了,新生代用标记复制法,老年代使用标记清除法就是这种分代收集法。
那么在jdk里有哪些实现呢
  1. 串行清理,效率最低下。
  2. parnew,实际上就是串行的多线程版本
  3. parallel等同于parnew,不过他更关注吞吐量,可以根据系统的负载,自我调节参数
  4. CMS收集器
  5. G1收集器
总结如下 :

细说Java的内存模型》有1个想法

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注