线程 & JMM & Volatile

一、线程

1、什么是线程?

  进程是系统分配资源的基本单位,线程是调度CPU的基本单位,一个进程至少包含一个执行线程,线程寄生在进程当中。每个线程都有一个程序计数器(记录要执行的下一条指令),一组寄存器(保存当前线程的工作变量),堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)。

  JVM的线程依赖于底层的操作系统。

2、线程的实现可以分为两类:

(1)用户级线程(User-Level Thread) ULT

  指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换,速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
(2)内核级线程(Kernel-Level Thread) KLT

  线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。大多数市场上的操作系统,如Windows,Linux等都支持内核级线程。

讲到用户级线程、内核线程就需要讲到内核空间和用户空间:

 

用户空间去操作CPU,则使用内核空间提供的接口,然后去操作CPU;

CPU的特权级别,只有Ring 0 级别才能去操作CPU,内核空间是Ring 0级别,可以去创建内核线程;

用户级线程

 

  用户级线程的主进程运行在CPU上,用户级线程的主进程中创建了多个线程,用户级线程创建的线程是由它自己去维护的。用户级线程创建线程的时候不需要去依附于内核,实际上它创建的是一个伪进程不是真正的线程;主进程运行在一个CPU上,线程1到线程4的运行要切换,若线程3阻塞了,则主进程就阻塞了。但是它可以避免创建过量的线程,过大的上下文切换。

  当一个进程从用户空间进入内核空间时,它就不再有自己的进程空间了。这也就是为什么我们经常说线程上下文切换会涉及到用户态到内核态的切换原因所在。

用户级线程进入到内核空间是以进程的方式进去,内核级线程进入到内核空间可以保持真正的线程和进程。

 JAVA在1.2版本之前使用的 用户级线程,在1.2版本之后使用的 内核级线程;

Java线程与系统内核线程关系

3、为什么用到并发?

充分利用多核CPU的计算能力    2、方便进行业务拆分,提升应用性能;

并发产生的问题:
(1)高并发场景下,导致频繁的上下文切换
(2)临界区线程安全问题,容易出现死锁的,产生死锁就会造成系统功能不可用等;

试着自己写个死锁程序

4、Java线程的生命周期

       线程创建之后,调用start()方法开始运行。当线程执行wait()方法之后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将会返回到运行状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞状态。线程在执行Runnable的run()方法之后将会进入到终止状态。

注意:java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于阻塞的实现均使用了LockSupport类中的相关方法。

二、JMM的介绍

1、什么是JMM模型?

  Java线程之的通信由Java内存模型(本文JMM)控制,JMM决定一个线共享量的写入何时对另一个线程可见 。
       Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。 

       JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

  JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

2、JMM的抽象结构示意图

 从上图来看,如果线A线B要通信的,必经历下面2个步骤:

(1线A把本地内存A中更新的共享量刷新到主内存中去。  

2线B到主内存中去线A之前已更新的共享量。

根据JVM虚拟机规范主内存与工作内存的数据存储类型以及操作方式:

I. 对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中。

II. 若本地变量是引用类型,那么该变量的引用会存储在工作内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。

III. 对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。

  需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。

 

疑问解答:JMM 与 JVM内存区域模型 什么?

        JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM的规则是围绕原子性,有序性、可见性展开,为了解决这些问题。

  JVM进程去申请的空间,大部分的时候操作的是逻辑空间,不是物理空间。逻辑空间是系统已经划分好的,逻辑空间1和逻辑空间2有可能映射是同一块物理空间。

3、JMM 同步的八种操作

(1)线程之间的通信

         本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

        从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

(2)那JMM是怎样控制的呢?

JMM定义了以下八种操作来完成从工作内存同步到主内存之间的实现细节。

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态;

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

(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用;

(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎;

(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量;

(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作;

(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中;

  如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。 read、load必须连续,store、write必须连续;

同步规则

1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中 

2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。

3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。

5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作) 。

4、Volatile 关键字

volatile是Java虚拟机提供的轻量级的同步机制。

(1)volatile语义有如下两个作用

  • 可见性:保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
  • 有序性:禁止指令重排序的优化。

      volatile  无法保证原子性;

(2)volatile缓存可见性实现原理

  • JMM内存交互层面:volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步会主内存,使用时必须从主内存刷新,由此保证volatile变量的可见性
  • 底层实现:通过汇编lock前缀指令,它会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存内存会导致其他处理器的缓存无效

(3)汇编代码查看

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp

(4)可见性

A. 内存可见性的代码:

public class VolatileVisibilitySample {
    private boolean initFlag = false;
    static Object object = new Object();

    public void refresh(){
        this.initFlag = true; //普通写操作,(非volatile写)
        String threadname = Thread.currentThread().getName();
        System.out.println("线程:"+threadname+":修改共享变量initFlag");
    }

    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){
            /*synchronized (object){
                i++;
            }*/
            //i++;
        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }

    public static void main(String[] args){
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{ sample.refresh(); },"threadA");
        Thread threadB = new Thread(()->{ sample.load(); },"threadB");

        threadB.start();
        try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
}

运行结果:

线程:threadA:修改共享变量initFlag

从以上的结果可以看到,线程B不会结束,一直在执行 while 语句;尽管线程A已经将主存中的 initFlag 刷新了,但是线程B的 while 语句的优先级比较高,一直占用着CPU的使用权,CPU一直在执行该语句,不能再次从主存中获取数据,则从线程B的工作内存中获取 initFlag 的值一直为 false,所以线程B一直在执行 while 语句不会停止。

B. 将以上的代码 load 方法的 while 语句中增加了 synchronized 同步块,代码如下:

public void load(){
     String threadname = Thread.currentThread().getName();
     int i = 0;
     while (!initFlag){
        synchronized (object){
            i++;
        }
        //i++;
     }
    System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
}

运行结果:

线程:threadA:修改共享变量initFlag
线程:threadB当前线程嗅探到initFlag的状态的改变51161863

分析:线程B在执行 while 块的时候,里面有 synchronized 同步块,这就会造成 线程B 暂时的阻塞,从而引起线程的上下文切换;线程上下文切换会将线程里面的数据都回写到任务状态段中;当线程B再次拿到CPU的执行权的时候,会将这些数据再次读到工作内存中,这就有可能触发它从主存中再重新去读取一下 initFlag 的值;

C. 在A的基础上再次修改代码,使用 volatile 关键字修饰 initFlag 变量;

运行结果:

线程:threadA:修改共享变量initFlag
线程:threadB当前线程嗅探到initFlag的状态的改变51161863

分析:通过汇编lock前缀指令,它会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。

(5)volatile无法保证原子性 

public class VolatileVisibility {
    public static volatile int i =0;
    public static void increase(){
         i++;
    }
}

        在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性。

       i++的操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用 synchronized 修饰方法后,由于synchronized本身也具备与 volatile 相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

(6)Volatile禁止指令重排

  volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。 
  内存屏障(Memory Barrier ,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化

  通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化 。
  volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化 。
(7)volatile内存语义的实现

重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。

< 1 > JMM制定的 volatile 重排序规则表:

  • 当第二个操作是 volatile写时,不管第一个操作是什么,都不能重排序。
  • 当第一个操作是 volatile读时,不管第二个操作是什么,都不能重排序。
  • 当第一个操作是 volatile写时,第二个操作是 volatile 操作时,不能重排序。 

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

< 2 > 基于保守策略的JMM内存屏障插入策略:

  • ∙在每个volatile写操作的前面插入一个StoreStore屏障。
  • ∙在每个volatile写操作的后面插入一个StoreLoad屏障。
  • ∙在每个volatile读操作的后面插入一个LoadLoad屏障。
  • ∙在每个volatile读操作的后面插入一个LoadStore屏障。

a. 下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图: 

上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。volatile写后面的 StoreLoad 屏障作用是避免volatile写与 后面可能有的volatile读/写操作重排序。

b. 下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

 上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

5、 什么情况下会导致总线风暴?

Volatile 变量的大量的线程通过总线和主内存进行大量的交互,通过总线和其他的cpu进行通信,导致会产生大量无效的工作内存变量,占用了总线大多的带宽,造成其他的功能通信的延迟;因为并发行的过高会造成总线风暴,所以要限制过高的并发,使用 synchronized 关键字。

看一下单例模式:(双重检测锁)

public class Singleton {

    /**
     * 查看汇编指令
     * -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
     */
    private volatile static Singleton myinstance;

    public static Singleton getInstance() {
        if (myinstance == null) {
            synchronized (Singleton.class) {
                if (myinstance == null) {
                    myinstance = new Singleton();//对象创建过程,本质可以分为三步
                    //对象延迟初始化
                    //
                }
            }
        }
        return myinstance;
    }
}

对象的创建过程可以分为3步:申请内存空间,实例化对象,填充数据(设置instance指向刚分配的内存地址,此时instance != null);

这3步不是原子性的操作,它们直接会发生指令重排的操作,所以给 instance 增加了 volatile 关键字;

posted @ 2020-01-01 23:39  风止雨歇  阅读(450)  评论(0编辑  收藏  举报