Java内存模型与线程

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,519评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,842评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,544评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,742评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,646评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,027评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,513评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,169评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,324评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,268评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,299评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,996评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,591评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,667评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,911评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,288评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,871评论 2 341

推荐阅读更多精彩内容