深入理解Java并发编程(一):JMM(Java内存模型)详解

一、JMM是什么?

JMM是一个抽象的概念:描述的是一组围绕原子性、有序性、可见性的规范。其定义程序中各个变量的访问规则,即虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量是共享变量。

JMM规定:所有共享变量存储在主内存中,每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存上进行,线程不能直接读写主内存的共享变量。不同的线程之间也无法访问对方工作内存中的变量,线程间的变量值的传递均需通过主内存来完成。

深入理解Java并发编程(一):JMM(Java内存模型)详解_第1张图片

共享变量:所有实例域,静态域和数组元素都是放在堆内存中(即所有线程均可以访问到,可共享)。共享数据会出现线程安全的问题
非共享变量:局部变量,方法定义参数和异常处理器参数不会线程共享。非共享数据不会出现线程安全的问题

二、如何定义一个对象线程安全?

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要额外的同步,或者在调用方式进行任何其他的协调操作,调用这个对象的行为都可以获取到正确的结果。出现线程安全的问题一般是因为主存和工作内存数据不一致性和重排序导致的。

三、线程通信与其问题

并发编程主要解决两个问题:

  1. 线程之间如何通信
  2. 线程之间如何完成同步

JMM规定了一个线程对共享变量的写入何时对其他线程是可见的。如果线程A改了主存中的某一数据,而线程B不知道同时并发修改,这样在写回主存中就有一些问题。如果线程A要和线程B进行通信,要经过这两步:

  1. 线程A从主内存中将共享变量读入线程A的工作内存后并且进行操作,之后将数据重新写回到主存中
  2. 线程B从主内存中读取最新的共享变量(线程A修改过的)

但如果线程A更新数据后并没有及时写回到主存,而此时线程B读到了原本的数据,也就是过期的数据,这就出现了脏读现象。这个问题可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次修改遍历都能够强制刷新到主存,从而对每个线程都可见。

例:

主内存中i = 0
线程1: load i from 主存    // i = 0
		i + 1  // i = 1
线程2: load i from主存  // 线程1还没将i的值写回主存,所以i还是0
		i + 1 //i = 1
线程1:  save i to 主存
线程2:  save i to 主存
现在主存中的值还是1,可我们的预期值是2

四、内存间的相互操作

JMM定义了8个操作来完成主内存和工作内存的互相操作:

  1. lock(锁定):作用于主内存中的变量,把一个变量表示为一个线程独占的状态
  2. unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量的值从主内存读取到线程的工作内存中,以便于后面的load操作
  4. load(载入):作用于工作内存中的变量,把read操作从主存中得到的变量值放入工作内存中的变量副本
  5. use(使用):作用于工作内存中的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  6. assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就执行这个操作
  7. store(存储):作用于工作内存中的变量,把工作内存中一个变量的值传送给主存中以便于后面的write操作
  8. write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
    深入理解Java并发编程(一):JMM(Java内存模型)详解_第2张图片

五、JMM的三大特性

1.原子性(Atomicity)

对于基本数据类型的读取和赋值操作都是原子性操作,即这些操作是不可中断的,要么做完,要么不做 。如果有两个线程同时对i进行赋值,一个赋值为1,另一个为-1,则i的值要么为1要么为-1。

i = 2;      //1
j = i;      //2
i++;        //3
i = i + 1; //4
其中,1是赋值操作,是原子操作,而234都不是原子操作。
2是读取赋值
3和4都是读取,修改,赋值

JMM只是保证了单个操作具有原子性,并不保证整体原子性。synchronize关键字具有原子性:

public class AtomicExample {

    /*private AtomicInteger cnt = new AtomicInteger();

    public void add() {
        cnt.incrementAndGet();
    }

    public int get() {
        return cnt.get();
    }*/

    private int cnt = 0;

    public synchronize void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }

    public static void main(String[] args) throws InterruptedException {
        final int threadSize = 1000;
        AtomicExample example = new AtomicExample();
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executorService.execute(() -> {
                example.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println(example.get());
    }
}
输出结果:1000
如果不加synchronize关键字,每次输出结果都小于1000

2.可见性(Visibility)

一个线程修改了共享变量的值,其他线程能够立即得知这个修改。 JMM是通过在遍历修改后将新值同步回主存,在遍历读取前从主内存刷新遍历值来实现可见性。

实现方式:

  1. volatile:通过在指令中添加lock指令,以实现内存可见性。其特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,其保证了多线程操作时变量的可见性。
  2. synchronized:当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主存中。
  3. final:被final关键字修饰的字段在构造器中一旦初始化完成,并且没有发生this逃逸(其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final字段的值。

3.有序性(Ordering):

在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序和工作内存与主内存同步延迟。在JMM中,允许编译器和处理器对指令进行重排序,重排序的过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
实现方式:

  1. volatile关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。关于内存屏障本篇就不讲了,在详解volatile关键字中会说
  2. synchronized关键字同样可以保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码

synchronized具有原子性、可见性、有序性
volatile具有有序性和可见性
final具有可见性

六、指令重排序与数据依赖性

为什么要进行指令重排序?

现在的CPU都是采用流水线来执行指令的,一个指令的执行有:取指、移码、执行、访存、写回五个阶段,多条指令可以同时存在流水线中同时被执行。流水线是并行的,也就是说不会在一条指令上耗费很多时间而导致后续的指令都卡在执行之前的阶段。我们编写的程序都要经过优化后(编译和处理器对我们编写的程序进行优化后以提高效率)才被运行。优化分为很多种,其中一种就是重排序。即重排序就是为了提高性能。

重排序的两大规则:as-if-serial规则和happens-before规则

1.as-if-serial规则

定义:不管怎么进行重排序,单线程程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
为了遵守该规则,编译器和处理器不会对存在数据依赖关系的操作做重排序,如果不存在数据依赖关系,那么这些操作可能被编译器和处理器重排序,就比如一个求长方体面积:

int a = 2;  //A
int b = 4;  //B
int c = a * b;  //C

其中,AC存在数据依赖关系,BC也存在,而AB不存在,所以在最终执行指令序列的时候,C不能排在AB的前面(这样会改变程序的结果),但是AB并没有数据依赖性关系。也就是说编译器和处理器可以重排AB之间的执行顺序,先B后A,先A后B都可以。as-if-serial规则把单线程程序保护了起来,这也就就是说遵守as-if-serial语义的编译器、runtime和处理器给了我们一个幻觉:单线程的程序是按照顺序来执行的。其实并不是,as-if-serial语义使程序员无需担心重排序的影响,也无须单行内存可见性的问题。

2.happens-before(先行发生)规则

1.JMM对程序员的保证:如果操作A先行发生与操作B,在操作B发生之前,操作A的影响(修改主内存中共享变量的值、调用方法等)是操作B可见的

2.JMM对编译器和处理器重排序的约束规则:两个操作之间存在happens-before关系,并不意味具体实现时必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致。那么这种重排序在JMM之中是被允许的。

总结一下就是:只要不改变程序的执行结果(单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行

这么说来,如果线程A的写操作write和线程B的读操作read之间存在happens-before关系,尽管write和read在不同的线程中执行,但JMM向程序员保证write操作对read操作可见。

以下是JMM天然的先行发生关系,如果两个操作之间没有下面的关系,并且无法从下面的关系推导,则jvm可以对其随意的进行重排序:

  1. 程序次序规则:在一个线程内,控制流(循环,分支)顺序在程序前面的操作先行发生于后面的操作
  2. 管程锁定原则:一个unlock操作先行发生与后面对用一个锁的lock操作
  3. volatile变量规则:对一个volatile遍历的写操作先行发生于后面对这个变量的读操作
  4. 线程启动规则(start规则):Thread对象的start()方法调用先行发生于此线程的每一个动作
  5. 线程加入规则(join规则):Thread对象的结束先行发生于join()方法返回
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupted()方法检测到是否有中断发生
  7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
  8. 传递性:如果操作A先行发生于操作B,操作B先行发生与操作C,则操作A先行发生于操作C。

重排序的分类:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  3. 内存系统的重排序:由于处理器使用缓存和IO缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

3.数据依赖性

定义:如果两个操作访问同一个变量,且这两个操作有至少有一个为写操作,此时这两个操作就存在数据依赖性
三种情况:读后写、写后写、写后读。只要重排序两个操作的执行顺序,那么程序的执行结果将会被改变。
如果重排序会对最终执行结果产生影响,编译器和处理器在重排时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。例如:刚才的计算长方形面积的程序,长宽变量没有任何关系,执行顺序改变也不会对最终结果造成任何的影响,所以可以说长宽没有数据依赖性。

4.重排序带来的问题

class ReorderExample {
	int a = 0;
	boolean flag = false;

	public void writer() {
		a = 1;                   //1
		flag = true;             //2
	}

	public void reader() {
		if (flag) {              //3
			int i =  a * a;      //4
			……
		}
	}
}

我们开两个线程AB,分别执行writer和reader,flag为标志位,用来判断a是否被写入,则我们的线程B执行4操作时,能否看到线程A对a的写操作?不一定,12操作并没有数据依赖性,编译器和处理器可以对这两个操作进行重排序,也就是说可能A执行2后,B直接执行3,判断为true,接着执行4,而此时a还没有被写入。这样多线程程序的语义就被重排序破坏了。

编译器和处理器可能会对操作重排序,这个是要遵守数据依赖性的,即不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。所以在并发编程下这就有一些问题了。

你可能感兴趣的:(Java并发与多线程)