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

java内存模型与线程

作者: MadnessXiong | 来源:发表于2020-01-07 18:38 被阅读0次

    1. Java内存模型

    1. 硬件的效率与一致性:
    计算机进行的绝大多数任务不可能只靠处理器计算就能完成,处理器还要与内存交互,如读取运算结果,存储运算结果。但是由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现在计算机都不得不加入一层读写速度尽可能接近处理器运算速度的告诉缓存来作为内存与处理器之间的缓冲:将运算需要用到的数据复制到缓存中,让运算能快速进行,当运算结束后,再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。

    基于高速缓存的存储很好地解决了处理器与内存的速度矛盾,但它引入了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。

    那么当多个处理器的运算都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,那么同步回主内存的数据以谁的为准呢?所以各个处理器访问缓存时都要遵循一些协议。如下图:


    1578224567753.jpg

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

    1578229091699.jpg

    这里的变量包括实例字段,静态字段和构造数字元素的对象,但不包括局部变量与方法参数,因为后者是线程私有的,不会存在竞争问题。

    从变量,主内存,工作内存的定义来看,主内存主要对应Java堆中的对象数据部分,而工作内存则对应虚拟机栈中的部分区域。

    3. 内存间交互操作:
    即一个变量如何从主内存拷贝到工作内存中,如何从工作内存中同步回主内存之类的细节,java内存模型定义了以下8种操作来完成。
    以下每一种操作都是原子的,不可再分的(double和long类型在某些平台下可能会有例外,但不影响,不必关注)

    • Lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态

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

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

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

    • use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作

    • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码操作指令时执行这个操作

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

    • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

    Java内存模型只要求read和load,store和write,这2个操作之间必须按顺序执行,而没有保证是连续执行。它们之间是可以插入其他指令的,如:reada,readb,loadb,loada。

    java内存模型还规定了在执行上述8中基本操作时必须满足如下规则:

    • 不允许read和load,store和write操作之一单独出现。即不允许一个比变量从主内存读取了但工作内存不接受,或从工作内存发起回写了但主内存不接受的情况出现

    • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存

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

    • 一个新的变量只能在主内存诞生,不允许在工作内存中直接使用一个未被初始化(load或ssign)的变量,换句话说,就是对一个变量实施use,store操作之前,必须先执行过ssign和load操作

    所以java中的变量必须都经过初始化,类中的变量在加载过程中会给一个默认值,方法体中的变量必须手动给一个默认值

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

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

    • 如果一个变量事先没有被lock操作锁定,那么就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量

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

    4. 指令重排 原子性,可见性,有序性:

    • 指令重排:
                //线程A
                doSomeThing();//语句1
                isDoSomeThing=true;//语句2
                //线程B
                if (isDoSomeThing){
                    doSomeThingElse();
                }
    

    为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序的结果重组,保证该结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

    所以在以上代码中,线程A做完一些操作,然后更新标记isDoSomeThing,如果线程B的doSomeThingElse()要依赖线程A的doSomeThing()的执行结果,但是由于指令重排的原因,线程A的语句2先执行了,语句1还尚未执行,那么线程B这里的doSomeThingElse(),就会出错。

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

    • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

    除了volatile之外,Java中synchronized和final也能实现可见性。synchronized的可见性是由“对一个变量执行unlock操作之前必须先把此变量同步回主内存中,这条规则获得的”。被final修饰的字段在构造器中一旦初始化完成,那么在其他线程就能看见final字段的值。(final这块不是很明白)

    • 有序性:有序性是指,如果在本线程内观察,所有操作都是有序的。如果在一个线程内观察另一个线程,所有操作都是无序的。前半句是指线程内表现为串行的语义,后半句是指指令重排现象和工作内存与主内存同步延迟现象
      Java语言提供了volatile和synchronize两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排的语义,而synchronize则是由一个变量在同一时刻只允许一条线程对其进行lock操作这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

    5. volatile关键字

    当一个变量定义为volatile之后,它将具备2种特性,第一是保证此变量对所有线程的可见性,不能保证原子性。由于volatile变量只能保证可见性,在不符合以下2条规则的运算场景中,我们仍然要通过加锁来保证原子性。

    • 运算结果不依赖当前变量的值,或者能够确保只有单一的线程修改变量的值

    • 变量不需要与其他的状态变量共同参与不变约束。(大概理解为不与其他变量进行比较之类的操作,如:一个volatile变量和另一个变量进行比较大小,但是这时候另外一个线程修改了它的值,就有可能出错)

    2. Java线程

    1. 线程同步的实现:

    • 一般最基本的同步手段就是synchronized关键字,synchronized关键字经过编译之后会在同步块的前后分别形成monitorenter和monitoreixt这2个字节码指令,这2个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果synchronized明确指定了对象参数,那就是这个对象的reference。如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,然后去获取对应的对象实例或者Class对象来作为锁对象。
      synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的情况。其次同步块在已经进入的线程执行完之前,会阻塞后面其他线程进入。
      如果要阻塞或者唤醒一个线程都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此转换要消耗很多的处理器时间。所以synchronized是一个重量级操作。不过虚拟机会进行一系列优化,比如加入自旋锁等,避免频繁的切入到核心态中。

    • Java还提供了重入锁-ReentrantLock来实现同步。与synchronized相比ReentrantLock增加了一些高级功能。
      1:等待中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
      2:可实现公平锁:公平锁是指多个线程在等待同一个锁时,必修按照申请锁的时间顺序来依次获得锁。而非公平锁不能保证这一点,锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数使用公平锁。
      3:锁可以绑定多个条件:指的是一个ReentrantLock可以绑定多个Condition对象。

    如果一般的多线程情况则直接使用synchronized即可,如果需要用到ReentrantLock功能的再使用ReentrantLock。在性能方面,jdk1.6发布后,通过锁优化他们的性能基本持平了

    2. 锁优化

    • 自旋锁与自适应自旋:互斥同步对性能最大的影响就是阻塞,挂起线程和恢复线程的操作都需要转入内核态中完成。如果持有锁的线程很快就会释放锁,那么让另外需要的线程进行一段时间等待,让它执行一个忙循环(自旋),等另外个线程释放锁后再操作。这就是自旋锁,它避免了从用户态切换到内核态,避免了不必要的开销。
      Jdk 1.6后引入了自适应自旋。自适应意味着自旋的时间不固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待相对更长的时间。另外如果某个锁自旋很少成功获得过,那么就可能会省掉自旋过程。

    • 锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争对锁进行消除。

    • 锁粗化:如果虚拟机探测到零碎的操作都对同一个对象加锁,将会把加锁同步范围扩展(粗化)到整个操作序列之外。如循环体内的加锁,可以放到循环体外。

    • 此外还有轻量级锁,偏向锁,过于复杂,知道就OK,这里不再展开

    相关文章

      网友评论

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

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