多线程学习笔记:7.java内存模型与线程

7 篇文章 0 订阅
3 篇文章 0 订阅

Java内存模型(Java Memory Model,JMM):

JMM是一种规范,用来屏蔽各硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能达到一致的内存访问效果。

1.主内存与工作内存:

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

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

è¿éåå¾çæè¿°

此处所说的内存划分区别于Java的内存区域,堆、栈、工作区,两者是不同层次的的定义。没有直接联系,勉强对应关系如下

内存间的相互操作:

JMM定义了8种原子操作

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

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。

Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,但其他指令不是对当前变量的操作,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

原子性、可见性与有序性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。JMM定义的8种操作都是原子性的。

可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

有序性即程序执行的顺序按照代码的先后顺序执行。对于java的天然有序性为如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。这是指令重排序引起的。所以为了保证多线程的有序性,java提供了volatile和synchronized两个关键字来保证。

指令重排序

编译器和处理器为了提高程序的运行性能,对指令进行重新排序。但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。如一下代码:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;	 //语句4

   这段代码有4个语句,那么可能的一个执行顺序是:

  

  

  不可能执行顺序: 语句2   语句1    语句4   语句3。这破坏了数据依赖性。

  所以重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep() 
}
doSomethingwithconfig(context);

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

   从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

内存屏障

 内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
      内存屏障可以被分为以下几种类型
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。  在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

volatile关键字

volatile是JVM提供的最轻量级的同步机制。当共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

          通过之前的内存模型知道,一个线程修改的是它的工作内存中的值,然后同步到主内存,之后其他线程重主内存中读取新值到它的工作内存。对普通变量而言,将值更新到主内存的时机是不确定的,无法保证其他线程在当前线程更新变量值后,读取主内存获取的就是最新值。但对volatile变量修饰的变量,线程的工作内存虽然还存在,但已经失去意义,它会保证当某个线程更新了volatile变量时,它的工作内存更新完后会立马刷新到主内存中,而且其他线程的工作内存中的变量缓存失效。这一系列操作是原子性的,由底层硬件实现。之后其他线程要使用该变量时,必须重新从主内存中获取。

volatile能保证可见性,却不能保证原子性。如下例子:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

 运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

因为自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存三步,反编译一个自增操作的字节码指令如下:

//自增方法
public void increase(){
       i++;
    }
//对应的字节码指令
 public void increase();
    Code:
       0: getstatic     #2                  // Field a:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field a:I
       8: return

 JVM中除了主内存,工作内存,还有操作数栈。假如某个时刻变量i的值为10,

  线程1对变量进行自增操作,线程1使用到了i,先从主内存中读取i的值到它的工作内存;然后通过getstatic指令将i的值压入栈顶。然后线程1阻塞了。

  然后线程2对变量进行自增操作,整个过程没有阻塞,顺利完成,将i的值改成了11,由于i是被volatile修饰的变量,所以在条用putstatic方法更新了线程2的工作内存后,会同步更新主内存中的值,线程1的工作内存中的i的缓存也失效了。

  然后线程1接着进行加1操作,由于已经读取了i的值,虽然工作内存中的i已失效,但栈中的i的值还是10,接下来的操作也没有要重新去工作内存获取i,所以完成加1后更新到它的工作内存中的值还是11,更新到主内存中还是11.自加两次,结果只加了1.

这里的可见性是指某个线程执行改变volatile变量操作后,其他线程在该操作之后读取改变量时能感受到它的变化,对它的赋值操作是不需要先读取的。

2)禁止进行指令重排序。当执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

 举例说明如下:

//x、y为非volatile变量

//flag为volatile变量


x = 2;        //语句1

y = 0;        //语句2

flag = true;  //语句3

x = 4;         //语句4

y = -1;       //语句5

 使用场景有懒汉式单例模式:


class Single {
    private volatile static Single s ;
    private  Single(){}
    //  Thread2
    private static Single getInstanceSingle(){
        if(s==null){
            synchronized(Single.class) {
                //Thread1 
                if(s == null) {
                    return s  = new Single();
                }
            }
            //Thread0
        }
        return s;
    }

 使用两层if判断是为了不用每次都判断锁,判断锁是比较费时的。

使用volatile修饰就是使用了它禁止重排序的特性。假设线程A获得了锁,创建了一个singleton实例,但是还在初始化。由于重排序,在初始化的过程中,先执行了return s。而线程2获得了这个实例,但此时这个实例初始化不完全,使用可能会有问题.使用volatile修饰后,在没初始化完之前,是不会执行return s的。

使用场景2:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

如果inited没有被volatile修饰,有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

  这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

使用volatile必须具备以下2个条件:即能保证对volatile变量的操作是原子性的。

  1)对变量的写操作不依赖于当前值

  2)该变量没有包含在具有其他变量的不变式中

如下:

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;//该操作是原子性的
}

先行发生原则

先行发生原则是指:如果说操作A先行发生于操作B,也就是发生在操作B之前,操作A产生的影响能被操作B观察到。是用来判断数据是否存在竞争、线程是否安全的主要依据。

java中天然存在的先行原则:

1、程序次序规则 
一个线程内,按照程序代码的顺序,书写在前面的操作先行发生于(逻辑上)书写在后面的操作。 
2、管程锁定规则 
一个unlock操作先行发生于后面对同一个锁的lock操作。后面指时间上的先后顺序。 
3、volatile变量规则 
对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的后面指时间上的先后顺序。 
4、传递性 
如果操作A先行发生于操作B,操作B先行发生于操作C,那么,操作A也就先行发生于操作C。 
5、线程启动规则 
Thread对象的start方法先行发生于此线程的每个动作; 
6、线程终止规则 
线程中的所有操作都先行发生于对此线程的终止检测; 
7、线程中断规则 
对线程的interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生; 
8、对象终结规则 
一个对象的初始化完成先行发生于它的finalize方法的开始; 

依照以上规则推测两个操作是否存在先行关系,没有的话,他们的执行顺序没有保障,虚拟机可以对它们随意地重排序。

private int value = 0;

public void setValue(int value) {
    this.value = value;
}

public int getValue () {
    return value;
}

假设存在线程A调用setValue方法,线程B调用getValue方法。线程B返回的数据是什么?

通过以上规则分析两个操作是否有先行发生关系。

1.这两个操作在两个线程中,所以程序次序规则无效。

2.没有同步代码块,管程锁定规则无效。

3.没有被volatile修饰的变量,volatile变量规则无效。

4.这两个操作与线程状态无关,线程启动、终止、中断原则都无效。

5.不涉及对象的初始化和finalize()调用,对象终结原则无效。

6.无法确定谁先发生,所以传递性无效。

这两个操作不存在先行发生关系,所以线程b得到的值是不确定的,即这两个操作是线程不安全的。

先行发生原则与时间先后顺序是没有关系的,如第一条,之前讲过,在同一线程内,指令也是会重排序,但会保证数据的依赖性。

java与线程

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。

  主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理。

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

一、使用内核线程实现

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

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

  由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。这就是重量级锁,其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

二、使用用户线程实现

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

  而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图12-4所示。

  使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,很少使用。

 三、使用用户线程加轻量级进程混合实现

   线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,如图12-5所示,这种就是多对多的线程模型。

  许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了N:M的线程模型实现。

四、Java线程的实现

  Java线程基于操作系统原生线程模型来实现。因此,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的。

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

五、Java线程调度

  线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative  Threads-Scheduling)和抢占式线程调度(Preemptive  Threads-Scheduling)。

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

  如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度 。

参考资料:

《深入理解JVM》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值