美文网首页
Java内存模型与线程

Java内存模型与线程

作者: JBryan | 来源:发表于2020-02-23 17:16 被阅读0次

    1.概述

    在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大,还有一个很重要的原因是,计算机的运算速度和他的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘I/0,网络通信或者数据库访问上。如果不希望处理器,在大部分时间里,都处于等待其他资源的状态,就必须使用一些手段,把处理器的运算能力压榨出来,否则就会造成很大的浪费,而让计算机同时处理几项任务,是最有效的压榨手段。
    一个服务端同时对多个客户端提供服务,则是另一个更具体的并发应用场景,衡量一个服务性能的好坏,每秒事务处理数(Transactions Per Second ,TPS)是最重要的指标之一,代表一秒内,服务端平均能响应的请求总数,而TPS与并发又有非常密切的关系。

    2.Java内存模型

    2.1主内存与工作内存

    Java内存模型的主要目标是,定义程序中各个变量的访问规则,即在虚拟机中,将变量存储到内存和,从内存取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,包括实例字段,静态字段和构成数组对象的元素等,但不包括局部变量和方法参数,因为后者是线程私有的,不存在竞争问题。

    Java内存模型规定了,所有变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中,保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作,都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间,也无法直接访问对方工作内存中的变量,线程间变量值的传递,需要通过主内存来完成。线程,主内存和工作内存三者的关系如图所示:


    12-1.jpg
    2.2内存间交互操作

    关于主内存与工作内存之间具体的交互协议,即一个变量,如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了8种操作来完成,虚拟机实现时,必须保证每一种操作都是原子的,不可再分的。

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

    如果要把一个变量,从主内存复制到工作内存中,就要顺序的执行load和read操作,如果要变量从工作内存同步回主内存,就要顺序的执行store和write操作。Java内存模型只要求上述两个操作按顺序执行,而没有保证是连续执行。Java内存模型还规定了上述8种基本操作时,必须满足如下规则:
    1.不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取到了工作内存,而工作内存不接受,或者工作内存发起回写了,但主内存不接收的情况。
    2.不允许一个线程丢弃它的assign操作,即变量在工作内存中改变了之后,必须把该变化同步回主内存。
    3.不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存,同步回主内存。
    4.一个新的变量,只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load和assign)的变量。换句话说,就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
    5.一个变量在同一时刻,只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock,才能释放锁。
    6.如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load和assign操作初始化变量的值。
    7.如果一个变量事先没有被lock操作锁定,那就不允许它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
    8.对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(store和write操作)。
    这8种内存访问操作以及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作,在并发下是安全的。

    2.3对于volatile型变量的特殊规则

    关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是并不容易被正确完整的理解,以至于许多程序员都习惯不去用它。
    当一个变量被定义为volatile之后,它将具备两种特性。

    1.保证此变量对所有线程的可见性,这里的可见性是指,当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。volatile变量的运算,在并发下一样是不安全的。
    代码演示:

    package com.ljessie.jvm;
    
    public class VolatileTest {
    
        public static volatile int race = 0;
        public static void increase(){
            race++;
        }
    
        public static void main(String[] args) throws InterruptedException {
            for (int i = 0; i <20 ; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int j = 0; j <1000; j++) {
                            increase();
                        }
                    }
                }).start();
            }
            Thread.sleep(1000);
            System.out.println(race);
        }
    }
    

    这段代码启动20个线程,每个线程对race执行1000次累加操作,但是执行结果都不会超过20000,且每次都不一样。使用javap反编译这段代码之后,发现只有一行代码的increase()方法,在Class文件中是由4条字节码指令构成的。当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1,iadd这些指令的时候,其他线程可能把race的值加大了,而在操作栈顶的值,就变成了过期的数据,所以putstatic指令执行后,就可能把较小的race值同步回主内存中。
    increase()方法字节码

     public static void increase();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=0, args_size=0
             0: getstatic     #2                  // Field race:I
             3: iconst_1
             4: iadd
             5: putstatic     #2                  // Field race:I
             8: return
          LineNumberTable:
            line 7: 0
            line 8: 8
    

    用字节码分析并发问题,仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味着这条指令,就是一个原子操作。
    由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁来保证原子性。
    1.运算结果并不依赖变量的值,或者能够确保只有单一的线程修改变量的值。
    2.变量不需要与其他的状态变量,共同参与不变约束。
    而像如下的代码所示的应用场景,就适合用volatile来控制并发,当shutdown()被调用时,能保证线程中执行的doSomething()方法都立刻停下来。

    volatile boolean shutdownRequested = false;
        public void shutdown(){
            shutdownRequested = true;
        }
    
        public void doSomething(){
            while(!shutdownRequested){
                //doSomething
            }
        }
    
    

    2.禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中,所有依赖赋值结果的地方,都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”。
    禁止指令重排序应用(单例):

    package com.ljessie.jvm;
    
    public class Singleton {
        private volatile static Singleton instance;
        public static Singleton getInstance(){
            if(instance == null){
                synchronized (Singleton.class){
                    if(instance == null){
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    

    从硬件架构上讲,指令重排序是指CPU采用了,允许将多条指令不按程序的规定的顺序,分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况,以保证程序能得出正确的执行结果。譬如指令1把A的值加上10,指令2把A的值乘以2,指令3把B的值减去3,这时指令1和指令2是有依赖的,他们之间的顺序不能重排((A+10)2 != A2+10),但指令3可以重排到指令1和指令2之前或者中间,所以在CPU中,重排序看起来依然是有效的。

    volatile变量读操作的性能消耗与普通变量几乎没有差别,但是写操作会慢一些,因为它需要在本地代码中插入许多内存屏障,来保证处理器不会发生乱序执行。不过即便如此,大多数情况下,volatile的总开销仍要比锁低,我们在锁与volatile之中选择的唯一依据仅仅是,volatile能否满足使用场景的需求。

    假定T表示一个线程,V和W表示两个volatile变量,那么在进行8种操作时,需要满足如下规则:
    1.只有当线程T对变量V执行的前一个动作是load时,T才能对V执行use动作;并且,只有T对V的后一个动作时use时,T才能对V执行load操作。也就是说use和load,read动作必须连续一起出现。(这条规则要求在工作内存中,每次使用V前,都必须先从主内存刷新最新的值,用于保证能看到其他线程对变量V操作的修改后的值)
    2.只有T对V执行的前一个动作是assign时,T才能对V执行store;并且,只有T对V的后一个动作是store时,T才能对V执行assign操作。也就是说assign和store,write动作必须连续一起出现。(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到,自己对变量V的修改)
    3.假定A是T对V的use操作,F是与A关联的load操作,P是与F关联的read操作;B是对W的use操作,G是与B关联的load操作,Q是与P关联的read操作。如果A先于B,那么P先于Q。(这条规则要求volatile修饰的变量,不会被指令重排优化,保证代码的执行顺序与程序的顺序相同)


    12-规则3.jpg
    2.4对于long和double的特殊规则

    Java内存模型要求lock,unlock,read,load,assign,use,store,write这8个操作都有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作,划分为两次32位的操作来进行,即允许虚拟机实现,选择可以不保证64位数据类型的load,store,read和write这4个操作的原子性,这点就是long和double的非原子性协定。
    在实际开发中,各平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此编写代码时,一般不需要把用到的long和double变量专门声明为volatile。

    2.5原子性,可见性和有序性

    原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read,load,assign,use,store和write,我们大致可以认为,基本数据类型的访问读写是具备原子性的。
    如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这些需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令反映到Java代码就是同步代码块——synchronized关键字,因此在synchronized块之间的操作,也具备原子性。

    可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后,将新值同步回主内存,在变量读取前,从主内存刷新刷新变量值,这种依赖主内存作为传递媒介的方式来实现的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了,新值能立即同步回主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时,变量的可见性。
    除了volatile之外,synchronized和final也能实现可见性,同步块的可见性是由“对一个变量执行unlock之前,必须先把此命令同步回主内存中”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final的值。

    有序性(Ordering):如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻,只允许一条线程对其进行lock操作”这条规则获得的。

    2.6先行发生原则

    先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等。
    示例1:

    //在A线程中进行
    i = 1;
    //在B线程中进行
    j = i;
    //在C线程中进行
    i = 2;
    

    假设A线程的操作先行发生于线程B,那么可以确定在线程B的操作执行后,j的值等于1。得出这个结论的依据有两个:一是根据先行发生原则,i=1的结果可以被观察到;二是线程C还没有登场,线程A操作之后,没有其他线程会修改变量i的值。
    现在再来考虑线程C,依然保持线程A和B的先行发生关系,而线程C出现在A和B之间,但是线程C与线程B没有先行发生关系,那j的值是不确定的,可能是1也可能是2。因为线程C对变量i的影响,可能会被B观察到,也可能不会被观察到,这时候,线程B就存在读取到过期数据的风险,不具备多线程安全性。

    Java内存模型下一些天然的先行发生关系,可以在编码中直接使用,如果两个操作之间的关系不在此列,并且无法从下列关系中推导出来,虚拟机可以对他们随意的进行重排序:

    1.程序次序规则,在一个线程内,按照程序代码顺序,书写在前面的操作,先行发生于后面的操作。准确的说应该是控制流顺序,而不是程序代码顺序,因为要考虑分支循环等结构。

    2.管程锁定规则,一个unlock操作先行发生于后面同一个锁的lock操作。这里必须强调的是同一个锁,而后面是指时间上的先后顺序。

    3.volatile规则,对一个volatile变量的写操作,先行发生于后面对这个变量的读操作。这里的后面,同样指的是时间的先后顺序。

    4.线程启动规则,Thread的start()方法,先行发生于此线程的每一个动作。

    5.线程终止规则,线程中的所有操作,都先行发生于对此线程的终止检测,可以通过Thread.join()结束结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行。

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

    7.对象终结规则,一个对象的初始化完成,先行发生于finalize()方法的开始

    8.传递性,如果A先行发生于B,操作B先行发生于C,那么A先行发生于C。
    时间先后顺序与先行发生原则之间,基本没有太大的关系,所以我们衡量并发安全问题的时候,不要收到时间顺序的干扰,一切必须以先行发生原则为准。

    3.Java与线程

    3.1线程的实现

    实现线程主要有3种方式:使用内核线程实现,使用用户线程实现,使用用户线程加轻量级线程混合实现。

    1.使用内核线程实现
    内核线程(KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。

    程序一般不会直接使用内核线程,而是去使用内核线程的一个高级接口——轻量级进程(LWP),轻量级进程就是我们通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程间的1:1关系称为一对一的线程模型。

    12-2.jpg
    2.使用用户线程实现
    从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(UT)。因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现,始终是建立在内核上的,许多操作都要进行系统调用,效率会受到限制。

    侠义上的用户线程是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。这种进程与用户线程之间的1:N的关系称为1对多线程模型。

    12-3.jpg
    3.使用用户线程与轻量级进程混合实现

    用户线程还是完全建立在用户空间种,因此用户线程的创建,切换,析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为,用户线程和内核线程之间的桥梁,这样可以使用内核线程提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被阻塞的风险。这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M,就是多对多的线程模型。

    12-4.jpg
    4.Java线程的实现
    对于Sun JDK来说,它的Windows和Linux版都是使用一对一的线程模型实现的,一条Java线程,就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。
    3.2Java线程调度

    线程调度是指,系统为线程分配处理器使用权的过程,主要分配方式有两种,分别是协同式线程调度抢占式线程调度

    协同式线程调度,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。好处就是实现简单,而且由于线程要把自己的事情干完之后,才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。坏处也很明显,线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

    抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来确定。Java使用的线程调度方式,就是抢占式调度。
    虽然Java线程调度是系统自动完成的,但是我们可以使用优先级,优先级越高的线程,越容易被系统选择执行。不过线程优先级并不太靠谱,原因是Java的线程是通过映射到系统的原生线程来实现的,所以线程调度最终还是取决于操作系统。

    3.3状态转换

    Java线程定义了6中线程状态,在任意一个时间点,一个线程只能有且只有一种状态。这6种状态分别如下:
    1.新建(New):创建后尚未启动的线程

    2.运行(Runnable):线程有可能正在执行,也有可能正在等待CPU为它分配时间。

    3.等待(Waiting):不会被CPU分配时间,要等待被其他线程显式的唤醒。

    4.限期等待(Timed Waiting):也不会被CPU分配时间,在一定时间之后,由系统自动唤醒,无需被显式唤醒。

    5.阻塞(Blocked):线程被阻塞了,在等待一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。在程序将进入同步区域时,线程将进入这种状态。

    6.结束(Terminated):线程已经结束执行。

    12-5.jpg

    相关文章

      网友评论

          本文标题:Java内存模型与线程

          本文链接:https://www.haomeiwen.com/subject/lsjcqhtx.html