资讯专栏INFORMATION COLUMN

Week 1 - Java 多线程 - Java 内存模型

zhaofeihao / 2849人阅读

摘要:目的是解决由于多线程通过共享内存进行通信时,存在的原子性可见性缓存一致性以及有序性问题。最多只有一个线程能持有锁。线程加入规则对象的结束先行发生于方法返回。

前言

学习情况记录

时间:week 1

SMART子目标 :Java 多线程

学习Java多线程,要了解多线程可能出现的并发现象,了解Java内存模型的知识是必不可少的。

对学习到的重要知识点进行的记录。

注:这里提到的是Java内存模型,是和并发编程相关的,不是JVM内存结构(堆、方法栈这些概念),这两个不是一回事,别弄混了。

Java 内存模型
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
主内存与工作内存

先看计算机硬件的缓存访问操作:

​ 处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

​ 加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

Java的内存访问操作与上述的硬件缓存具有很高的可比性:

​ Java内存模型中,规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

内存间交互操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作

read:把一个变量的值从主内存传输到线程的工作内存中

load:在 read 之后执行,把 read 得到的值放入线程的工作内存的变量副本中

use:把线程的工作内存中一个变量的值传递给执行引擎

assign:把一个从执行引擎接收到的值赋给工作内存的变量

store:把工作内存的一个变量的值传送到主内存中

write:在 store 之后执行,把 store 得到的值放入主内存的变量中

lock:作用于主内存的变量,把一个变量标识成一条线程独占的状态

unlock: 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

内存模型三大特性 原子性

Java 内存模型保证了 readloaduseassignstorewritelockunlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(longdouble)的读写操作划分为两次 32 位的操作来进行,也就是说基本数据类型的访问读写是原子性的,除了longdouble是非原子性的,loadstoreread write 操作可以不具备原子性。书上提醒我们只需要知道有这么一回事,因为这个是几乎不可能存在的例外情况。

虽然上面说对基本数据类型的访问读写是原子性的,但是不代表在多线程环境中,如int类型的变量不会出现线程安全问题。详细的例子可以参考范例一

想要保证原子性,可以尝试以下几种方式:

如果是基础类型的变量的话,使用Atomic类(例如AtomicInteger)

其他情况下,可以使用synchronized互斥锁来保证 限定临界区 内操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit

可见性

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

可见性的错误问题范例比较难以模拟,有兴趣的可以借助此篇文章更好的理解。

想要保证可见性,主要有三种实现方式:

volatile

Java的内存分主内存和线程工作内存,volatile保证修改立即由当前线程工作内存同步到主内存,但其他线程仍需要从主内存取才能保证线程同步。

synchronized

当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。最多只有一个线程能持有锁。

final

被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

范例一中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。

有序性

有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

想要保证可见性,主要以下实现方式:

volatile

volatile的真正意义在于产生内存屏障,禁止指令重排序。即重排序时不能把后面的指令放到内存屏障之前。

synchronized

它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

有序性这块比较难比较深的内容实际上是指令重排序这块的知识。我这就借花献佛,引一篇我认为讲的比较清楚的文章。内存模型之重排序

先行发生原则

JVM 内存模型下,规定了先行发生原则,让一个操作无需任何同步器协助就能先于另一个操作完成。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对他们随意的进行重排序。

单一线程规则 - Single Thread Rule

在其他书上又叫 Program Order Rule - 程序次序规则

在一个线程中, 在线程前面的操作先行发生于后面的操作。(准确的来说,是控制流顺序,而不是代码顺序,因为或有逻辑判断分支)

管道锁定规则 - Monitor Lock Rule

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

volatile 变量规则 - Volatile Variable Rule

对一个volatile 变量的写操作先行发生于 后面对这个变量的读操作

线程启动规则 - Thread Start Rule

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

线程加入规则 - Thread Join Rule

Thread 对象的结束先行发生于 join() 方法返回。

线程中断规则 - Thread Interruption Rule

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

对象终结规则- Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

传递性 - Transitivity

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

在多线程情况下,时间先后顺序和先行发生原则之间基本没有太大的关系,我们衡量并发安全问题的时候不要受到时间顺序的告饶,一切必须以先行发生原则为准。

插入案例帮助理解 案例一 代码
/**
 * 内存模型三大特性 - 原子性验证对比
 *
 * @author Richard_yyf
 * @version 1.0 2019/7/2
 */
public class AtomicExample {

    private static AtomicInteger atomicCount = new AtomicInteger();

    private static int count = 0;

    private static void add() {
        atomicCount.incrementAndGet();
        count++;
    }

    public static void main(String[] args) {
        final int threadSize = 1000;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executor.execute(() -> {
                add();
                countDownLatch.countDown();
            });
        }
        System.out.println("atomicCount: " + atomicCount);
        System.out.println("count: " + count);

        ThreadPoolUtil.tryReleasePool(executor);
    }
}
Outout
atomicCount: 1000
count: 997
分析

可以借助下图帮助理解。

count++这个简单的操作根据上面的原理分析,可以知道内存操作实际分为读写存三步;因为读写存这个整体的操作,不具备原子性,count被两个或多个线程读入了同样的旧值,读到线程内存当中,再进行写操作,再存回去,那么就可能出现主内存被重复set同一个值的情况,如上图所示,两个线程进行了count++,实际上只进行了一次有效操作。

案例二 代码
class Foo {
    private int x = 100;

    public int getX() {
        return x;
    } 

    public int fix(int y) {
        x = x - y; 
        return x;
    } 
}


 public class MyRunnable implements Runnable {
    private Foo foo =new Foo(); 

    public static void main(String[] args) {
        MyRunnable r = new MyRunnable();
        Thread ta = new Thread(r,"Thread-A"); 
        Thread tb = new Thread(r,"Thread-B"); 
        ta.start(); 
        tb.start(); 
    } 

    public  void run() {
        
        for (int i = 0; i < 3; i++) {
            this.fix(30);
            try {
                Thread.sleep(1); 
            } catch (InterruptedException e) {
                e.printStackTrace(); 
            } 
            System.out.println(Thread.currentThread().getName() + " :当前foo对象的x值= " + foo.getX());
        } 
    } 

    public int fix(int y) {
        return foo.fix(y);
    } 
}
Output
Thread-A:当前foo对象的的x值= 70
Thread-B:当前foo对象的的x值= 70
Thread-A:当前foo对象的的x值= 10
Thread-B:当前foo对象的的x值= 10
Thread-A:当前foo对象的的x值= -50
Thread-B:当前foo对象的的x值= -50
分析

这个案例是案例一的变体,只是代码有点复杂有点绕而已,实际上就是存在两个线程,对一个实例的共享变量进行-30的操作。

read 的操作发生在x-y的x处,相当于两个线程第一次fix(30)的时候,对x变量做了两次100-30的赋值操作。

案例三
public class Test {
       // 是否是原子性?
    int i = 1;
    public static void main(String[] args) {
        Test test = new Test();
    }
}

请问上述 int i = 1是否是原子性的呢?

实际上很微妙。

本案例中的int a = 1在java中叫显式初始化,它实际上包含两次赋值,第一次java自动将a初始化为0,第二次再赋值为1。从这个角度看,这条语句包含了两步操作,并不是原子的。

但是由于这句代码是在构造方法中,而从类的实例化角度看,一般认为构造方法中对当前实例的初始化过程是原子的。这是因为在实例化完成之前,一般是无法从别的代码中访问到当前实例的。所以从这个角度看,int a = 1实际上是原子的。

参考

《深入理解Java虚拟机》

https://juejin.im/post/5bd971...

http://ifeve.com/concurrency-...

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/75208.html

相关文章

  • Week 1 - Java 线程 - CAS

    摘要:前言学习情况记录时间子目标多线程记录在学习线程安全知识点中,关于的有关知识点。对于资源竞争严重线程冲突严重的情况,自旋的概率会比较大,从而浪费更多的资源,效率低于。 前言 学习情况记录 时间:week 1 SMART子目标 :Java 多线程 记录在学习线程安全知识点中,关于CAS的有关知识点。 线程安全是指:多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表...

    ZweiZhao 评论0 收藏0
  • Week 2 - Java 容器 - 详细剖析 List 之 ArrayList, Vector,

    摘要:底层使用的是双向链表数据结构之前为循环链表,取消了循环。快速随机访问就是通过元素的序号快速获取元素对象对应于方法。而接口就是用来标识该类支持快速随机访问。仅仅是起标识作用。,中文名为双端队列。不同的是,是线程安全的,内部使用了进行同步。 前言 学习情况记录 时间:week 2 SMART子目标 :Java 容器 记录在学习Java容器 知识点中,关于List的需要重点记录的知识点。...

    MartinDai 评论0 收藏0
  • 来,了解一下Java内存模型(JMM)

    摘要:因为管理人员是了解手下的人员以及自己负责的事情的。处理器优化和指令重排上面提到在在和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。有没有发现,缓存一致性问题其实就是可见性问题。 网上有很多关于Java内存模型的文章,在《深入理解Java虚拟机》和《Java并发编程的艺术》等书中也都有关于这个知识点的介绍。但是,很多人读完之后还是搞不清楚,甚至有的人说自己更懵了。本文,就来整体的...

    kviccn 评论0 收藏0
  • 来,了解一下Java内存模型(JMM)

    摘要:因为管理人员是了解手下的人员以及自己负责的事情的。处理器优化和指令重排上面提到在在和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。有没有发现,缓存一致性问题其实就是可见性问题。 网上有很多关于Java内存模型的文章,在《深入理解Java虚拟机》和《Java并发编程的艺术》等书中也都有关于这个知识点的介绍。但是,很多人读完之后还是搞不清楚,甚至有的人说自己更懵了。本文,就来整体的...

    eccozhou 评论0 收藏0

发表评论

0条评论

zhaofeihao

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<