线程概述与JMM

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/y3over/article/details/88422229

线程与进程概念

在现代操作系统中,进程支持多线程。
         1.进程是资源管理的最小单元;
         2.线程是程序执行的最小单元。
即线程作为调度和分配的基本单位,进程作为资源分配的基本单位
一个进程的组成实体可以分为两大部分:线程集和资源集。进程中的线程是动态的对象;代表了进程指令的执行。资源,包括地址空间、打开的文件、用户信息等等,由进程内的线程共享。

一开始的系统是没有线程的概念的,只有进程(也可称为单线程进程)。
传统单线程进程的缺点:
        现实中有很多需要并发处理的任务,如数据库的服务器端、网络服务器、大容量计算等。而单线程进程意味着程序必须是顺序执行,不能并发;既在一个时刻只能运行在一个处理器上,因此不能充分利用多处理器框架的计算机。
如果采用多进程的方法,则有如下问题:
        创建一个子进程是一个很大的消耗,系统要给新的进程分配各种资源。另外进程的协作需要复杂的技术 ,如消息传递(要从用户态到内核态再到用户态,消耗资源)和共享内存(进程本来是按靠独立资源设计的,如要共享内存,要同步一步地址的映射数据)等。
多线程的优点和缺点:
      1.可以更充分的利用多CPU
      2.由于共享进程的代码和全局数据,故线程间的通信是方便的
      3.它的缺点也是由于线程共享进程的地址空间,因此可能会导致竞争,加大了编程难度。

线程之间的通信

 线程之间的通信机制有两种,共享内存消息传递
     1. 在共享内存的并发模型里

        线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。
    2.在消息传递的并发模型里

        线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。


线程间的同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
    1.在共享内存并发模型里,

       同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
   2.在消息传递的并发模型里,

       由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

 

现代计算机的内存模型

处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

线程模型

名称

描述

理解

 

用户级线程

(User-LevelThread, ULT)

 

        由应用程序所支持的线程实现, 内核意识不到用户级线程的实现

用户自己模仿CPU调度模型实现,当CPU轮到本生线程时,然后运行调度程序,调度自己的线程

 

内核级线程

(Kemel-LevelThread, KLT)

内核级线程又称为内核支持的线程

用户调用系统函数让内核创建。创建出来的线程,和程序本生线程相同等级。对内核可见,可直接由CPU直接调度。

1.线程发生I/O引起的阻塞时,而会阻塞整个进程从而阻塞所有线程。
2.用户的进程内部,没有时钟中断,所以不能用轮转的方式调度线程
3.资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用

1.在高并发场景会莫名的出现问题,程序员不易排查

1.线程资源完全归程序员管控。
 
SUN JDK中: 对于官方JDK来说,因为Windwos和Linux操作系统(主流)只提供一对一线程模型 ,所以SUN JDK采用了一对一线程模型 。其它JDK可以有不同实现(如Solaris JDK可以采用N对M模型)。
 

Java内存模型(JMM)

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。


JVM对Java内存模型的实现

在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区
JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化

线程栈(线程之间都是独立的):

    所有原始类型(boolean,byte,short,char,int,long,float,double,引用)的局部变量
堆区(线程之间都是共享的):

    Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的局部变量,它都会被存储在堆区。  

1.一个局部变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,对象本身仍然存储在堆区
2.对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使它们所属的对象在堆区。 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。


Java内存模型带来的问题

并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

原子性:一个操作或多个操作要么全部执行完成且执行过程不被中断,要么就不执行。

可见性:当多个线程同时访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性:程序执行的顺序按照代码的先后顺序执行。

可见性问题

如图所示,程序输出了,sdf但程序却没有结束。

因为flag=false这个更改还没有flush到主存中:要解决共享对象可见性这个问题,我们可以使用java volatile关键字或者是加锁.

java语言对可见的保证

Java提供了volatile,filnal关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

原子性引发的问题

上图的sum变量已用volatile声明他的可见性(只要sum变量一变就会写到主存),但输入结果为什么是 9977不是 10000,因为 sum++不是一个原子操作

java语言对原子性的保证

Java中的原子操作包括: 
1)除long和double之外的基本类型的赋值操作 (八种基本类型)
2)所有引用reference的赋值操作 
3)java.concurrent.Atomic.* 包中所有类的一切操作。 
但是java对long和double的赋值操作是非原子操作!!long和double占用的字节数都是8,也就是64bits。在32位操作系统上对64位的数据的读写要分两步完成,每一步取32位数据。这样对double和long的赋值操作就会有问题:如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。因此需要使用volatile关键字来防止此类现象。volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的。

有序性引发的问题

很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。然而,这段代码的执行结果也可能是(0,0).

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

  • 1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

java语言对顺序的保证

    1.as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

        为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序

      数据依赖性

            如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

{
   int a = 1;//1
   int b = 2;//2
   a + b; //3
}
1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。
因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。
但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。

  控制依赖性

if (flag){//3
   int i = a * a;//4
}
flag变量是个标记,用来标识变量a是否已被写入,上述方法中比变量i依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了

2.内存屏障——禁止重排序(多线程)

   一.编译器引起的内存屏障
        从寄存器里面取一个数要比从内存中取快的多,所以有时候编译器为了编译出优化度更高的程序,就会把一些常用变量放到寄存器中,下次使用该变量的时候就直接从寄存器中取,而不再访问内存,这就出现了问题
   二.缓存引起的内存屏障
       CPU会把数据取到一个叫做cache的地方,然后下次取的时候直接访问cache,写入的时候,也先将值写入cache。在多CPU的系统里面,每个CPU都有自己的cache,当同一个内存区域同时存在于两个CPU的cache中时,CPU1改变了自己cache中的值,但是CPU2却仍然在自己的cache中读取那个旧值,这种结果是不是很杯具      
   三.乱序执行引起的内存屏障
        CPU拥有多条独立的流水线,一次可以发射多条指令,因此,很多允许指令的乱序执行

JMM把内存屏障指令分为4类,解释表格,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

Happens-Before

  1. 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  2.  管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  4. 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。
  5. 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。 
  6.  线程中断规则:对线程interrupt( )方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt( )方法检测线程是否中断
  7. 对象终结规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。
  8. 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second) 。

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(对程序员来说)

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的(对编译器和处理器 来说)

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

实现原理

  • 内存语义:可以简单理解为 volatile,synchronize,atomic,lock 之类的在 JVM 中的内存方面实现原则

volatile的内存语义

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

JMM内存屏障插入策略:
1.在每个volatile写操作的前面插入一个StoreStore屏障
2.在每个volatile写操作的后面插入一个SotreLoad屏障
3.在每个volatile读操作的后面插入一个LoadLoad屏障
4.在每个volatile读操作的后面插入一个LoadStore屏障

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序且后面加LoadLoad,LoadStore屏障。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。保证了被 volatile声明的变量读时一直是最新的值,其后面的读写操作可见

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序且前面StoreStore屏障及后面SotreLoad。保证了被volatile声明的变量前的操作都已是内存最新值,且变量赋值后对所有可见

final的内存语义

编译器和处理器要遵守两个重排序规则:

  • 构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

final域为引用类型:

  • 增加了如下规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

final语义在处理器中的实现:

  • 会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。
  • 读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障

synchronized的实现原理

使用monitorenter和monitorexit指令实现的(依赖于底层的操作系统的Mutex Lock来实现的):

  • monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处
  • 每个monitorenter必须有对应的monitorexit与之配对
  • 任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态

使用Mutex Lock需要将从用户态切换到内核态来执行,这种切换的代价是非常昂贵的.
1.用户态程序执行(monitorenter)的过程中调用了一个系统调用(Mutex Lock)陷入了内核态当中,系统调用只是一种特殊的中断。
2.中断发生的后的第一件事就是保护现场,保护现场就是进入中断的程序保存需要用到的寄存器数据,并把调用命令从用户内存复制进来
3.恢复现场就是退出中断程序,恢复、保存寄存器的数据,并把结果复制回用户内存。 

monitor 运行详情隐式锁:synchronized​​​​​​​


同样的,在进入锁之后  都会屏障的同步一波,在出锁之前, 也会加个屏障同步一波.

 

 

猜你喜欢

转载自blog.csdn.net/y3over/article/details/88422229
JMM