Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specifification中

描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的

简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。

JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。

JMM线程操作内存的基本的规则:

第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写

第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成

主内存

  主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题

本地内存

  主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题

 

多线程并发的3个特性:

多线程并发开发中,要知道什么是多线程的原子性,可见性和有序性,以避免相关的问题产生

原子性

 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

一个很经典的例子就是银行账户转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作

突然中止。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

可见性

 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

举个简单的例子,看下面这段代码:

//线程1执行的代码 
int i = 0;
i = 10;

//线程2执行的代码
j = i;

当线程1执行 int i = 0 这句时, i 的初始值0加载到内存中,然后再执行 i = 10 ,那么在内存中 i 的值变为10了。

如果当线程1执行到 int i = 0 这句时,此时线程2执行 j = i,它读取 i 的值并加载到内存中,注意此时内存当中i的值是0,那么就会使得 j 的值也为0,而不是10。

这就是可见性问题,线程1对变量 i 修改了之后,线程2没有立即看到线程1修改的值。

 有序性

程序执行的顺序按照代码的先后顺序执行
int count = 0; 
boolean flag = false; 
count = 1; 
//语句1 
flag = true; 
//语句2

以上代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语

句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。什么是重排序?一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序

中各个语句的执行先后顺序同代码中的顺序一致。

as-if-serial(像串行):无论如何重排序,程序最终执行结果和代码顺序执行的结果是一致的。Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语意)

因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。虽然重排序不会影响单个线程内程序执行的结果,但是多线程会有影响

下面看一个例子:
//线程1: 
init = false 
context = loadContext(); //语句1 
init = true; //语句2 
//线程2: 
while(!init){//如果初始化未完成,等待 
    sleep(); 
}
execute(context);
//初始化完成,执行逻辑

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行

execute(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

内存可见性

可见性: 一个线程对共享变量值的修改,能够及时的被其他线程看到

共享变量: 如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。

2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线

程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也

变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证

可见性问题

前面讲过多线程的内存可见性,现在我们写一个内存不可见的问题。

案例如下:

public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }
    static class JmmDemo implements Runnable {
        public boolean flag = true;

        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) {
            }
            System.out.println("子线程结束。。。");
        }
    }
}

执行结果:

按照main方法的逻辑,我们已经把flag设置为false,那么从逻辑上讲,子线程就应该跳出while死循环,因为这个时候条件不成立,但是我们可以看到,程序仍旧执行中,并没有停止。

原因:线程之间的变量是不可见的,因为读取的是副本,没有及时读取到主内存结果。 解决办法:强制线程每次读取该值的时候都去“主内存”中取值

解决可见性问题 

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块(原子性)。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。 

JMM关于synchronized的两条规定:

线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的最新值刷新到主内存中。

线程加锁时(进入同步代码块时):将清空本地内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

做如下修改,在死循环中添加同步代码块 
while (flag) { 
    synchronized (this) { }
 }
public class Demo1Jmm {
    public static void main(String[] args) throws InterruptedException {
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();
        Thread.sleep(100);  //暂停当前线程,把cpu片段让出给其他线程,减缓当前线程的执行
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);
    }
    static class JmmDemo implements Runnable {
        public boolean flag = true;

        public void run() {
            System.out.println("子线程执行。。。");
            while (flag) {
                synchronized (this) { }
            }
            System.out.println("子线程结束。。。");
        }
    }
}

执行结果:

synchronized实现可见性的过程

1. 获得互斥锁(同步获取锁),进入同步代码块,执行同步代码。

2. 清空本地内存,

3. 从主内存拷贝变量的最新副本到本地内存

4. 执行代码

5. 将更改后的共享变量的值刷新到主内存

6. 释放互斥锁

同步原理:

synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

1. 普通同步方法,锁是当前实例对象this

2. 静态同步方法,锁是当前类的class对象

3. 同步方法块,锁是括号里面的对象

当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁

由于是死循环,子线程不停的获取锁执行同步代码,也不停的清空本地内存和从主内存拷贝变量的最新副本到本地内存。当主线程修改了flag的值为false后,子线程从主内存拷贝flag变量的最新副本到本地内存,执行代码后释放锁。由于此时子线程本地内存的值为false,死循环停止运行。

synchronized的同步操作主要是monitorenter和monitorexit这两个jvm指令实现的,先写一段简单的代码:
public class Demo2Synchronized { 
    public void test2() { 
        synchronized (this) { } 
    } 
}

在cmd命令行执行javac编译和javap -c Java 字节码的指令 

从结果可以看出,同步代码块是使用monitorenter和monitorexit这两个jvm指令实现的: 

原子性:

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。这两个字节码,在Java中对应的关键字就是synchronized

可见性:

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronizedfinal两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

有序性:

在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

  好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。

但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

 

 

 

 



 
 
 
posted on 2021-02-25 10:41  周文豪  阅读(796)  评论(0编辑  收藏  举报