JMM Java内存模型的三个特性以及实现

330

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

详细介绍了Java 内存模型的原子性、可见性和有序性这 3个特性的含义以及解决办法。

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这 3个特性来建立的,归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性。这三个特性(也可以说是问题),是人们抽象定义出来的,而这个抽象的底层问题就是前面提到的处理器优化问题、缓存一致性问题和指令重排问题等。

1 原子性(Atomicity)

这个概念和数据库事务中的原子性大概一致。表明此操作是不可分割的,不可中断,要全部执行,要么全部不执行。这个特性的重点在于不可中断,如下代码:

int a=0;  //1
int b=0;  //2
int c=0;  //3

线程A执行上述代码,从内存中读取这三个变量的值,在读取的过程中,此时线程B也读取了某一个变量的值,此时虽然线程B的这个操作并不会对线程A的结果产生影响,但是线程A的原子性已经不存在了,在底层CPU执行的时候,就会涉及到切换线程A、B。并且,对A要进行中断,所以线程A的原子性就被破坏了。理解这一点,也就会理解关键字volatile并不能保证原子性,保证原子性需要加锁。

在单例模式中,如果不是使用加锁的方法,就会因为没有保证原子性,而使得对象会可能被创建多个。

又如,在设计计数器时一般都先读取当前值,然后+l,再更新。这个过程是读改写的过程,该过程不是天然原子性的,如果不能保证这个过程是原子性的,那么就会出现线程安全问题。

1.1 Java内存模型的实现

如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java 内存模型提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是synchronized 关键字,因此在 synchronized 块/方法之间的操作也具备原子性。

Java中的天然原子操作包括:

  1. 除long和double之外的基本类型的赋值操作(32位虚拟机会分两次读、写long、double类型变量)
  2. 所有引用reference的赋值操作
  3. java.concurrent.Atomic.* 包中所有类的一切操作。

1.2 32位虚拟机long型变量多线程实验

对于32位虚拟机来说,对long型数据的读写不是原子性的。对int型数据读写是原子性的。如下案例,使用32位虚拟机运行:

/**
 * 32位虚拟机下演示
 */
public class MultiThreadLong {
    public volatile static long t = 0;

    public static class ChangeT implements Runnable {
        private long to;
        public ChangeT(long to) {
            this.to = to;
        }
        @Override
        public void run() {
            while (true) {
                MultiThreadLong.t = to;     //赋值临界区的t
                Thread.yield();            //让出资源
            }
        }
    }
    public static class ReadT implements Runnable {
        @Override
        public void run() {
            while (true) {
                long tmp = MultiThreadLong.t;
                if (tmp != 111L && tmp != -999L && tmp != 333L && tmp != -444L) {
                    System.out.println(tmp);    //打印非正常值
                }
                Thread.yield();            //让出资源
            }
        }
    }
    public static void main(String[] args) {
        new Thread(new ChangeT(111L)).start();
        new Thread(new ChangeT(-999L)).start();
        new Thread(new ChangeT(333L)).start();
        new Thread(new ChangeT(-444L)).start();
        new Thread(new ReadT()).start();
        //在32位虚拟机下运行,将可能输出:
        //-4294966963
        //4294966852
        //-4294966963
    }
}

如果我给出这几个数值的2进制(补码)表示, 大家就会有更清晰的认识了:

+111=0000000000000000000000000000000000000000000000000000000001101111 -999=1111111111111111111111111111111111111111111111111111110000011001 +333=0000000000000000000000000000000000000000000000000000000101001101 -444=1111111111111111111111111111111111111111111111111111111001000100 +4294966852=0000000000000000000000000000000011111111111111111111111001000100 -4294967185=1111111111111111111111111111111100000000000000000000000001101111

上面显示了这几个相关数字的补码形式,也就是在计算机内的真实存储内容。不难发现,这个奇怪的4294966852, 其实是111 或者333的前32位,与-444的后32位夹杂后的数字 。而-4294967185只是-999或者-444的前32位与111夹杂后的数字。换句话说,由于并行的关系,数字被写乱了,或者读的时候,读串位了。

2 可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

2.1 Java内存模型的实现

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,底层使用了内存屏障。而普通变量则不能保证心智能够立即同步会主内存中。

除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。synchronized的可见性是由“对一个变量执行lock(加锁)操作之前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中 重新获取最新的值,对一个变量执行unlock(解锁)操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就一定能看见final字段的值,且该值不可修改。

3 有序性(Ordering)

先看重排序的概念:Java 内存模型允许编译器和处理器对指令重排序以提高运行性能, 并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。 关于重排序的原理,在前面的文章中已经有过深入探讨:硬件的效率与缓存一致性概述,大家可以看看这篇文章。

3.1 Java内存模型的实现

有了重排序,并且重排序在多线程环境下可能出现问题,那么自然有了有序性的概念。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象,如果不加额外的限制多线程下程序的有序性就不能得到保证。

因此,Java语言额外提供了volatile和synchronized两个关键字来保证多线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义(通过加入内存屏障指令),而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入,相当于单线程了。 这两个区别就是,synchronized可以修饰一段代码,或者一个方法。但是volatile只能修饰一个变量。

4 总结

Java内存模型不会为我们自动处理为原子性、可见性和有序性这 3个特性(问题),但是均提供了相应的解决办法。在开发过程中,需要开发人员自己根据场景选择合适的手段去解决这些问题,常用手段包括synchronized、volatile、final、使用并发包、Threadlocal等方式。本文只是浅显的介绍了这些方法,相当于一个在总结,具体这些方式的底层实现和使用方法,将在后面的文章中一一分析。

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!