JAVA内存模型与线程

导读

本文主要介绍JMM的工作原理,通过本文你可以了解到java中是如何使用JMM来协调线程工作的,然后作者会介绍关于线程的一些内容,在本文中不会对如何解决线程安全问题及锁进行过多介绍,这部分内容留到下一章节进行介绍。

一、计算机硬件工作原理

首先我们先来思考几个问题:

  1. 为什么现代计算机都往多任务处理去发展?
    我们都知道,CPU的运算速度远大于其他的存储设备和通讯子系统,如果计算机仅仅是单任务工作,那么当一个任务在进行大量的IO操作时,此时CPU只能等着不能干别的事情,如果有多任务,CPU将不会等待,直接切换到其它任务上去工作,从而提高的计算机的工作效率。
  2. 计算机是如何解决CPU与内存速度上的差异?
    我们都知道计算机可以有多个CPU核心,但是主内存只有一个(逻辑上一块),解决CPU和内存的速度差异,需要在CPU于主内存之间加入高速缓存,这种缓存的速度与CPU的运算速度相当,下面笔者通过一张图来描述一个数据是如何从内存中读到CPU中计算,和CPU中计算后的数据如何写到内存中。
    在这里插入图片描述
    从图中可以看到,使用高速缓存虽然解决了CPU与内存之间速度上的差异问题,但是使用缓存难免会带来数据一致性的问题,那么计算机是如何解决高数换的数据一致性问题呢?接下来跑出第三个思考
  3. 计算机是如何解决缓存一致性问题?
    我们一起思考一下,如果上图中第一颗cpu计算结果20没有从高速缓存中同步到主内存时,第二颗cpu从主存中加载到的a这个变量仍然是10,这样就很不合理,计算机在高速缓存和内存之间的通讯需要遵循一定的协议来解决这一矛盾,最终的图演变为:
    在这里插入图片描述

二、JMM内存模型

JMM内存模型是java虚拟机实现多线程的一种抽象模型,用来屏蔽各种底层硬件和操作系统的内存访问差异。也就是说JVM在处理多线程工作上有自己的实现,而不是使用操作系统的那一套来处理(可以理解为模拟出来的)。java内存模型规定了所有的变量都存储在主存中(这里的主存可以和操作系统的主存进行类比,实际上市JVM的模拟实现,仅仅是虚拟机内存的一部分),每条线程都有自己的工作内存,工作内存中保存的是线程使用到的主内存变量的副本拷贝,线程对变量的操作(读取、赋值等)必须要通过工作内存来完成,而不能直接操作主存,线程之间传递变量只能通过主存来完成,下面通过一张图来描述线程、主存、工作内存三者之间的交互过程:
在这里插入图片描述
2.1 主存与工作内存详细交互过程
主存与工作内存之间具体的交互协议,即一个变量如何从主存拷贝到工作内存、如何从工作内存同步回主存的实现细节,JMM中定义了以下8中操作来完成(也就是上图中的蓝色区域部分会涉及8种不同的操作),通过一张思维导图来罗列:
在这里插入图片描述
这些操作分别代表的含义:

  1. lock(锁定):作用于主内存变量,表示把一个变量标识为一条线程独占。
  2. unlock(解锁):作用于主内存变量,表示把一个处于锁定状态的变量进行解锁释放,释放后的变量,才可以被其它线程锁定。
  3. read(读取):作用于主内存的变量,表示把一个变量的值从主内存传输到线程工作内存,以便后面的load动作使用。
  4. load(载入):作用于工作内存变量,表示把read读取进来的主存中的变量进行一次快照,并把快照结果存放在工作内存的变量副本中(其实就是对主存变量做一次拷贝)。
  5. use(使用):作用于工作内存的变量,表示把工作内存中一个变量的值传递给执行引擎(这里的理解可以联想到栈帧工作过程),每当虚拟机需要执行一条字节码指令,并且该指令需要使用到工作内存中的一个变量时执行此操作。
  6. assign(赋值):作用于工作内存变量,表示把一个从执行引擎中接收到的值赋给工作内存变量,当虚拟机遇到一个需要将变量赋值的字节码指令时会执行这个操作。
  7. store(存储):作用于工作内存变量,表示把工作内存中一个变量的值传送到主内存中,以便后面的write操作。
  8. write(写入):作用于主内存变量,表示把store传送过来的变量存放到主内存的变量中
    下面通过将主内存中的a=10进行a+=10的操作:
    在这里插入图片描述
    2.2 站在JMM上理解volatile
    在理解volatitl修饰的变量在各个线程中保证可见性问题之前,我先抛出一个问题:volatile变量对所有的线程是立即可见的,所volatile变量的所有写操作都会立即反应到其它线程中,换句话说volatile变量在各个线程中是一致的,所以volatile修饰的变量在并发情况下是否是线程安全的?
    答案是否定的,volatile变量解决了多线程环境下变量的可见性问题,但是并不能保证线程安全,原因在于可见性仅仅是指执行引擎use(使用)变量时是一致的(执行引擎在use变量时发现该变量被volatile修饰,那么会从主存中刷新得到最新的值),因为use之后的运算并非原子操,有可能会在操作数栈中来回操作几遍才能得到最终的运算结果,在运算过程中,有可能其他的线程已经将值修改为了其它值了,此时操作数栈顶的值就称为了旧值,最后回刷到主存中就会出现线程安全问题。下面通过一个案例来说明这个问题:

public class VolatitleTest {
    private static volatile int race = 0;
    public static void increase(){
        race++;
    }
    private static  final int THREAD_COUNT=20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for(int i=0;i<THREAD_COUNT;i++){
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0 ;i<10000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        //让其他线程都执行结束
        while (Thread.activeCount()>1)Thread.yield();
        System.out.println(race);
    }

}

这段代码发起20个线程,每个线程对race变量进行1000次累加操作,这段代码如果能正确并发最后输出结果应该为200000,结果测试每次运行输出的结果都不一样,且都小于目标值,这是为什么呢?其实奥秘就存在于会导致并发问题的increase()代码中,首先我们使用javap -v查看increase()字节码:
在这里插入图片描述
可以看到最终虚拟机执行自增这行代码分为4条指令来完成,当getstatic指令执行时候,会执行use(使用)步骤,此时发现该变量被volatile修饰,会重新从主存中获取最新的值压到栈帧的操作数栈栈顶(这个时点上各个线程中的变量值都一致),但是当执行iconst_1、iadd指令时,其它线程已经吧race的值自增了,此时操作数栈顶的值为过期数据,所以putstatic指令执行后,小的值同步刷新会主存,最终导致技术的值总是比目标值要小。
下面还是通过一张图来复述上面的过程吧,毕竟笔者比较喜欢用图形来描述抽象的东西~~
在这里插入图片描述
volatile两大特性:
1.可见性
2.禁止指令重排序
可见性前面笔者已经花费了很长的篇幅去描述了,接下来主要针对第2点,禁止指令重排序。
禁止指令重排序是居于内存屏障实现的,虚拟机会在volatile变量赋值指令前/后,添加一个内存屏障指令,表示,volatile修饰的变量赋值这行代码不会被指令重排序。

2.3 原子性、可见性、有序性
JAVA内存模型是围绕着在并发过程中如何处理原子性、可见性、有序性这3个特征来建立的,我们逐个来看一下那些操作实现了这3个特性:

- 原子性
由java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写都具备原子性(栈帧的局部变量表通过slot作为存储单位,一个slot占32字节,基本数据类型中除了long/double占用64字节以外,其它的都在32字节以内,因此除了long/double都能进行原子读写,而long/double占两个slot其读写的原子性保障有JVM来实现)。
java内存模型还提供了lock、unlock操作来满足原子性,尽管虚拟机未把lock/unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter/monitorexit来隐式使用这两个操作,反应到java代码中即为synchronized修饰的同步代码块。
- 可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,在java中能保证可见性的关键字有:volatile、synchronized、final,volatile前面已经讲过,这里就不进行过多阐述,synchronized对一个变量进行了lock,其他线程不能操作这个变量,只用当前线程unlock,并将变量刷新回主存后,其他线程才能重新获取并操作变量;final关键字的可见性指:被final修饰的字段在构造器中一旦初始化完成,并且没有把this的引用传递出去,器在其他线程都能看见final字段的值
- 有序性
java语言提供了volatile和synchronized关键字来保证线程之间操作的有序。

三、java与线程

3.1 轻量级进程与内核线程工作模式
首先要知道一点:java线程并不等于操作系统线程,java线程仅仅是利用操作系统对上层提供的接口在自家的JVM中实现的,两者有映射关系。
在这里插入图片描述
从上图中可以理解线程.start方法,仅仅是在java层面创建完毕线程,并且调用操作系统向上暴露的接口创建内核线程后进行绑定,此时的线程处于就绪态,至于什么时候被内核调度,这取决于内核自己的策略。

3.2 java线程的生命周期
java语义定义了5中线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这5种状态分别为:

  1. new(新建):创建后尚未启动的线程处于这种状态,此时仅仅是java堆中的一个普通对象。
  2. runable(运行):runable包括内核线程状态中的running和ready,也就是说,处于这种状态下的java线程有可能正在运行,也有可能正在等待CPU的调度。
  3. waiting(无限期等待):处于这种状态下的线程不会被CPU调度,它需要等待被其它线程显示地唤醒,例如在java中没有设置超时时长的Object.wait()和Thread.join()方法。
  4. Timed waiting(限期等待):处于这种状态下的线程同样不会被CPU调度,不过无需其它线程显示唤醒,在一定时间后会自动唤醒,例如java中的Thread.sleep(100)、设置超时时间的Object.wait(500)、Object.join(500)。
  5. blocked(阻塞):“阻塞态”和“等待态”的区别是:“阻塞态”在等待获取一个排他锁,java中同步操作就处于这种状态。
  6. terminated(结束):线程已经结束执行
    下面通过一张图来总结上面这些状态直接的转化过程:

在这里插入图片描述

四、结束语

本篇文章主要介绍了java内存模型原理,是一个知其然的过程,接下来我会抽空写一篇关于java中如何使用锁来解决高并发问题,敬请期待~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值