美文网首页Android进阶之路Android开发经验谈Android技术知识
关于Java内存模型,Android开发需要了解的

关于Java内存模型,Android开发需要了解的

作者: zackyG | 来源:发表于2020-05-14 21:49 被阅读0次

    物理机的并发问题

    • 硬件的效率问题,简单来说就是CPU处理数据的速度,比内存读写数据的速度要快得多,导致CPU的利用率不够高,所以在每个处理器中设置了高速缓存。
    • 缓存一致性的问题,因为每个处理器都有一个高速缓存,那么当多个处理器同时操作同一个数据时,就需要考虑缓存一致性的问题
    • 代码乱序执行优化问题 为了使得处理器内部的运算单元尽量被充分利用,提高运算效率,处理器可能会对输入的代码进行乱序(非顺序)执行,处理器会在计算之后将乱序(非顺序)执行的结果重组,这就是乱序优化。乱序优化可以保证在单线程下执行的结果和顺序执行的结果是一致的。但不保证程序中各语句执行的先后顺序和代码的编写顺序一致。 代码乱序优化

      已上图为例说明,CPU core2的逻辑B依赖于CPU core1的逻辑A先执行,正常顺序下,逻辑A执行完之后,flag才为true,逻辑B才得以执行。在处理器乱序执行优化的情况下,有可能flag = true被提前执行,导致逻辑B先于逻辑A执行。

    Java内存模型的意义

    因为不同架构的计算机,其内存模型不一样。JVM希望设计出一套通用的内存模型,来屏蔽掉各种硬件和操作系统内存访问的差异,以实现让Java程序能够达到一致的内存访问效果。

    Java内存模型的概念

    可以理解为在特定操作协议下,对特定内存或高速缓存进行读写操作的过程抽象。它决定了一个线程对主内存中的共享变量的写入何时被其他线程可见。
    Java内存模型提出的目标在于,定义程序中各个变量的访问规则,即在JVM中,将变量存储到内存和从内存中读取变量这样的底层细节。这里说的变量包含了:实例字段,静态字段和构成数值对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的。如果局部变量是引用(reference)类型,它引用的对象在堆内存中可被各个线程共享,但这个reference本身存放在虚拟机栈的局部变量表中,它是线程私有的。

    Java内存模型的组成

    • 主内存——Java内存模型规定了所有变量都存储在主内存中,这里说的主内存与物理硬件的主内存概念一样,两者可以互相类比。但此处仅仅指JVM内存的一部分。
    • 工作内存——每个线程都有自己的工作内存,又称为本地内部才能,可以用物理机的高速缓存概念类比。工作内存中保存了该线程使用到的,主内存的共享变量的副本拷贝。 image.png

      如图所示,每个线程都有一个工作内存,里面保存的是共享变量的副本,变量的实体保存在主内存中。当执行引擎执行线程任务,对共享变量进行读写操作时,先对工作内存中的副本进行操作,然后由工作内存和主内存之间进行变量的同步。

    需要注意的是,工作内存只是Java内存模型中的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。在理解Java内存模型时,不要和JVM的运行时内存结构做对应。二者是不同的概念。Java内存模型概念的提出,是为了更好的理解JVM实现和处理多线程并发问题的机制。

    Java内存操作的并发问题

    Java内存模型的执行处理主要围绕解决两个问题展开:

    • 工作内存的数据一致性——各个线程操作数据时会使用到主内存的共享变量副本,当多个线程的操作涉及到同一个共享变量的修改时,将导致各个线程的工作内存中副本的值不一致。如果出现这种情况,数据同步回主内存时以哪个线程的副本数据为准?Java内存模型通过一系列的数据同步协议、规则来保证数据的一致性。
    • 指令重排序优化——Java中的重排序通常是编译器或者运行时环境为了优化程序性能而采取的对程序指令进行重排序的一种手段。重排序分两类:编译时重排序和运行时重排序,分别对应于编译时环境和运行时环境。但是指令重排序也会带来潜在的问题,比如在多线程环境下,如果线程的执行逻辑之间存在依赖关系,有可能因为指令重排序导致执行结果和预期不同。
    指令重排序需要满足的条件
    • 在单线程执行场景中,不能改变程序运行的结果。即时编译器和处理器需要保证程序的执行能够遵守as-if-serial属性。通俗来讲,就是在单线程情况下,要呈现程序顺序执行的假象,即经过指令重排序的执行结果和顺序执行的结果一致。

    Java内存间的交互操作

    以线程间通信为例,看看线程间如何进行共享变量的同步: image.png

    如图,线程1和线程2的工作内存中都保存了主内存中共享变量x的副本,初始时,这三个内存空间中都保存x的值为0,。当线程1执行相关操作将x的值改为1之后,x的值同步到线程2,需要经过两个步骤

    • 线程1将其工作内存中的值同步到主内存,主内存中x的值更新为1
    • 线程2从主内存中读取线程1更新后x的值
      从整体看,经过这两个步骤,实现了线程1和线程2的线程间通信,这个通信过程必须经过主内存。一般情况下,线程对变量的所有读写操作都必须在工作内存进行,不同线程之间不能直接访对方工作内存中的共享变量副本。线程间变量值的传递需要通过主内存来完成,实现各个线程之间共享变量的可见性。

    内存交互操作的三个基本特性

    Java内存模型是围绕着多线程并发过程中,如何处理这三个特性来建立的。

    原子性

    一个操作或者多个操作要么全部执行并且执行过程中不会因为任何因素并打断;要么都不执行。即使在多线程并发情况下,一个原子性操作一旦开始,就不会被其他线程所干扰。Java对基本数据类型的变量的读写操作都是原子性操作,long和double类型除外,所以为了保证long和double类型的变量读写操作的原子性,可以用volatile关键字修饰。

    可见性

    当多个线程同时访问一个变量时,若其中某个线程修改了变量的值,其他线程能够立刻看到修改后的值。如线程1和线程2的例子所示,Java内存模型是通过在线程1中变量修改后,从工作内存将新值同步回主内存,线程2在读取变量前先从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性的。

    有序性

    有序性表现在线程内和线程间两种场景:

    • 线程内——从单个线程角度来看方法的执行,指令会按照一种叫做“串行”(as-if-serial)的方式执行。
    • 线程间——某个线程“观察”到其他线程也在并发执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的是:对于同步方法和同步代码块(synchronized修饰)以及volatile修饰的变量操作仍维持相对有序。

    内存交互的8个基本操作

    关于主内存和工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存,如果从工作内存同步回主内存的过程细节,Java内存模型中定义了8种基本操作来完成。虚拟机实现时必须保证这8种基本操作都是原子性的,不可分的。 image.png
    • lock——作用于主内存的变量,它把一个变量标识为一条线程独占的状态
    • unlock——作用于主内存的变量,它把一个锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。
    • read——作用于主内存的变量,它把一个变量的值从主内存传递到线程的工作内存,以便随后的load操作使用
    • load——作用于工作内存的变量,把read操作从主内存得到的共享变量的值存放到工作内存的变量副本中。
      use——作用于工作内存的变量,把工作内存存放的变量值传递给执行引擎,每当虚拟机遇到使用该变量的字节码的指令时,就会执行此操作。
      assign——作用于工作内存的变量,把从执行引擎接收到的修改后的值传递给工作内存的变量副本,每当虚拟机遇到一个给变量赋值的字节码指令时,就会执行此操作。
      store——作用于工作内存的变量,把工作内存中变量副本的值发送给主内存,以便随后的write操作使用。
      write——作用于主内存的变量,把store操作从工作内存传递过来的变量值更新到主内存的共享变量中。
    基本操作的同步规则

    Java内存模型在执行上述8种基本操作时,为了保证内存间的数据一致性,规定了以下规则:

    • 规则①——如果要把一个变量从主内存复制到工作变量,需要按顺序执行read和load操作。如果要把一个变量从工作内存同步回主内存,需要按顺序执行write和store操作。但是Java内存模型只要求上述操作按顺序执行,但没有保障是连续执行。
    • 规则②——不允许read和load、store和write操作之一单独出现。
    • 规则③——不允许一个线程丢弃最近的assign操作,即变量在工作内存中修改之后必须同步回主内存
    • 规则④——不允许一个线程无原因的(没有发生任何assign操作)将变量从工作内存同步回主内存。
    • 规则⑤——一个新的变量只能在主内存中创建,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。也就是对一个变量执行use或者store操作之前,必须先执行过load或者assign操作。
    • 规则⑥——一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一个线程执行多次,在执行多次lock操作后,需要执行相同次数的unlock操作才能将变量解锁释放,所以lock和unlock操作必须成对出现。
    • 规则⑦——如果线程对一个变量执行lock操作,将会清空该线程的工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或者assign操作初始化变量的值。值得注意的是,未初始化的变量,经过assign操作后,执行引擎可以直接通过use操作从工作内存中读取变量副本的值来使用,也就是说,assign操作后,并不是马上执行store和write操作。
    • 规则⑧——如果一个变量事先没有被lock操作锁定,就不允许对它执行unlock操作,也不允许对其他线程锁定的变量执行unlock操作。
    • 规则⑨——对一个变量执行unlock操作之前,必须先将副本的值同步回主内存中(执行store和write操作)。

    这些规则看起来繁琐,其实不难理解:

    • 规则①和②:工作内存里的共享变量作为主内存的副本,主内存的变量值同步到工作内存必须read和load操作一起使用,工作内存的变量值同步回主内存必须store和write操作一起使用。这两组操作各自都是固定的有序搭配,不能单独出现。
      规则③和④:为保证工作内存和主内存共享变量的数据一致性,工作内存的变量值被执行引擎修改后,必须同步回主内存。如果没有被修改,不允许无原因的同步回主内存。
      规则⑤:由于工作内存中保存的只是主内存的共享变量副本,共享变量必须从主内存中诞生。
      规则⑥⑦⑧⑨:为了在并发情况下安全使用共享变量,线程可以通过lock操作独占主内存中的共享变量,其他线程不允许使用该变量也不能对该变量执行unlock操作,直到该线程通过unlock操作解锁该变量。

    happen-before原则

    概念:happen-before原则用来描述两个操作的内存可见性,如果操作A happen-before 操作B,则操作A的执行结果对操作B可见。happen-before关系的分析需要分为两种情况:

    • 单线程下的happen-before——字节码指令的先后顺序天然包含happen-before关系,因为单线程内共享一份工作内存,不存在数据可见性问题。在程序控制流路径中靠前的字节码指令happen-before靠后的字节码指令。即靠前的字节码指令的执行结果对靠后的字节码指令可见,但这并不意味靠前的字节码指令一定比靠后的指令先执行。实际上如若靠后的指令不依赖于靠前的指令,那么它们的执行就可能被重排序。
      多线程先的happen-before——由于各线程的工作内存保证的是贡献变量的副本,如果没有对变量做同步处理,那么线程1执行操作A,修改了变量值之后,线程2执行操作B,操作A的执行结果不一定对操作B可见,即操作A不是happen-before操作B。

    Java内存模型中定义了以下支持happen-before关系的操作规则:

    • 程序次序规则——单线程情况下,根据程序的编写顺序,写在前面的代码操作 happen-before 写在后面的代码操作。
    • 锁定规则——一个unlock操作 happen-before 之后对同一个变量的lock操作
    • volatile规则——volatile变量的写操作 happen-before 之后的volatile变量读操作
    • 传递规则——如果操作A happen-before 操作B,操作B happen-before 操作C。那么操作A happen-before 操作C
    • 线程启动规则——一个线程的start()方法 happen-before 该线程的其他操作
    • 线程中断规则——对线程interrupt()方法的调用 happen-before 对该线程中断事件发生后的处理操作。
    • 线程终结规则——线程中所有其他操作 happen-before 线程的终结检测操作,我们可以通过Thread.join()方法的结束,Thread.isAlive()的返回值检测到线程已经终止执行。
    • 对象终结操作——一个对象的初始化完成 happen-before 该对象的finalize()方法的开始。

    内存屏障

    Java通过内存屏障来保证底层操作的可见性和有序性。内存屏障是插入到两条CPU指令之间的一种指令,用来禁止CPU指令发生重排序,像屏障一样,保证了指令执行的有序性。另外,为了达到屏障的效果,他也会使CPU读写变量值之前,将主内存中的值先写入高速缓存,清空无效队列,从而保证了可见性:

    • 一旦完成写入指令,任何访问该数据(变量)的CPU将会得到最新的值
    • 在写入指令执行之前,会保证之前的指令都执行完成,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值同步到CPU缓存。
    Store1;
    Store2;   
    Load1;   
    StoreLoad;  //内存屏障
    Store3;   
    Load2;   
    Load3;
    

    对于上面的一组CPU指令,Store代表写入指令,Load代表读取指令,StoreLoad屏障之前的Store2指令不能和屏障之后的Load2指令交换位置,即重排序。但StoreLoad屏障之前和之后的指令之间是可以交换位置的,即Store1和Store2指令可以交换位置,Load2和Load3指令可以交换位置。
    常见的内存屏障有4种:

    • LoadLoad屏障——对于Load1;LoadLoad;Load2;这样的语句,对于Load2以及后续的读取操作执行之前,保证Load1指令先执行完成。
    • StoreStore屏障——对于Store1;StoreStore;Store2;这样的语句,对于Store2以及后续的写入操作执行之前,保证Store1的写入操作对所有CPU可见。
    • LoadStore屏障——对于Load1;LoadLoad;Store2;这样的语句,对于Store2以及后续的写入操作执行之前,保证Load1的读取操作先执行完成。
    • StoreLoad屏障——对于Store1;LoadLoad;Load2;这样的语句,对于Load2以及后续的读取操作执行之前,保证Store1的写入操作对所有CPU可见。它的开销是四种内存屏障中最大的(冲刷写缓存器,清空无效队列)。在大多数CPU的实现中,这个屏障是万能屏障,兼具其他三种屏障的功能。

    Java对内存屏障的使用在一般的代码中不太容易看到,常见的有volatile和synchronize关键字修饰的代码,还可以通过Unsafe类来使用内存屏障。具体介绍可以参考此处

    volatile关键字

    它的作用是保持多线程并发情况下,对该变量操作的可见性和有序性。它有两方面的语义:

    可见性

    保证各线程对该变量操作的内存可见性,不等同于保证该变量并发操作的安全性,保证可见性是指:

    • volatile变量的写操作过程:线程对工作内存中的volatile变量进行写操作,是对变量副本的值进行修改,修改后的副本值会被立刻同步到主内存中,这样保证了其他线程对该变量的可见性。而普通的共享变量副本里的值被修改后,不会立刻同步回主内存。
    • volatile变量的读操作过程:每次都会从主内存中读取volatile变量的最新值到线程的工作内存中,然后从工作内存中读取变量的副本值。即每次volatile变量的读操作都包含read、load、use三个基本操作。
      volatile不保证变量在并发情况下的安全性,原因可用如下场景举例:
    例如:定义volatile int count = 0; 两个线程同时执行count++操作,每个线程都执行500次,最终结果小于1000.
    原因是每个线程执行count++操作需要以下3个步骤:
    1 线程从主内存读取最新的count值
    2 执行引擎把count值加一,然后赋值给线程的工作内存
    3 线程的工作内存把count值保存到主内存
    有可能出现2个线程在步骤1读取到的值都是100,执行完步骤2得到的值都是101,最后同步了2次101保存到主内存
    
    有序性

    volatile对有序性的保证体现在防止指令重排序

    • 当程序执行到volatile变量的读操作或写操作时,在其前面的操作肯定都已经全部执行完成,且结果对volatile变量的操作可见,而其之后的操作肯定都还未执行。
    • 进行指令优化时,对volatile变量操作的语句前后的指令,不会变换顺序。即volatile变量操作前的语句,不会重排序到volatile变量操作之后执行。反之亦然。

    普通的变量仅仅保证程序的执行过程中,所有依赖其赋值结果的地方能够获得正确的结果,并不保证赋值操作的顺序和程序代码的执行顺序一致。

    volatile boolean initialized = false;
    
    
    // 下面代码线程A中执行
    // 读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
    doSomethingReadConfg();
    initialized = true;
    
    
    // 下面代码线程B中执行
    // 等待initialized 为true,代表线程A已经把配置信息初始化完成
    while (!initialized) {
         sleep();
    }
    // 使用线程A初始化好的配置信息
    doSomethingWithConfig();
    

    上面代码中如果定义initialized变量时没有使用volatile修饰,就有可能会由于指令重排序的优化,导致线程A中最后一句代码 "initialized = true" 在 “doSomethingReadConfg()” 之前被执行,这样会导致线程B中使用配置信息的代码就可能出现错误,而volatile关键字就禁止重排序的语义可以避免此类情况发生。

    原子性

    对于volatile的原子性,通常容易被误解:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型的变量,但是不能保证i++这种操作的原子性,因为本质上i++是一次读和一次写,两次操作。

    volatile的实现原理
    volatile关键字的实现原理是在编译器生成字节码时,会在指令序列中插入内存屏障来保证。具体如下图: image.png
    • 在每一个volatile写操作的前面插入StoreStore屏障,除了保证屏障之前的写操作和屏障之后的volatile写操作不能重排序,还会保证在volatile写操作执行之前,其他的任何读写操作都先于voolatile写操作之前被提交。
    • 在每一个volatile写操作的后面插入StoreLoad屏障,除了保证屏障之前volatile变量的写操作不会和屏障之后的读操作不会发生重排序之外,还会刷新处理器缓存,使volatile变量的写操作对其他线程可见。
    • 在每一个volatile读操作的前面插入LoadLoad屏障,除了保证屏障之前的读操作不会和屏障之后volatile变量的读操作发生重排序,还会刷新处理器缓存,使volatile变量获取到最新值。
    • 在每一个volatile读操作的后面插入LoadStore屏障,除了保证屏障之前的volatile读操作不会和屏障之后的写操作发生重排序,还会刷新处理器缓存,使其他线程volatile的写更新对volatile读操作的线程可见。
    关于“嗅探”协议

    缓存一致性协议有多种,但日常处理的大多数计算机设备都属于“嗅探”协议,它的基本思想是:
    所有内存(系统内存和处理器的高速缓存之间)数据的传输都发生在一条共享的总线上,而所有处理器都能看到这条总线;各个处理器的高速缓存本身是独立的,但系统内存是共享资源,所有内存访问都要经过仲裁(同一个指令周期中,只有一个处理器缓存可以读写系统内存)。
    处理器缓存不仅仅在内存数据传输时才与总线打交道,而是不停的嗅探总线上发生的数据交换,跟踪其他缓存在做什么,所以当一个处理器的高速缓存与系统内存进行数据传输时,其他处理器都会得到通知。它们以此来使自己缓存里的数据保持同步,只要某个处理器的缓存数据同步回系统内存,其他处理器马上就会知道它们缓存的同一个地址的数据已失效。

    既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性(内存屏障)?或者是只有加了volatile的变量在多核cpu执行的时候才会触发缓存一致性协议?
    两个解释结论:
    多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作;
    正常情况下,系统操作并不会进行缓存一致性的校验,只有变量被volatile修饰了,该变量所在的缓存行才被赋予缓存一致性的校验功能。

    关于volatile底层实现和嗅探协议,详细介绍可参考此处

    volatile的使用场景
    • 一处写入,到处读取。即某一线程负责更新变量,其他线程只读取变量而不修改。并根据变量值的变化执行相应的逻辑。例如状态标识位的更新,观察者模式下变量值发布。
    • 双重检查锁定。如单例模式的DLC实现。
    • 需要利用顺序性执行的场景。

    final变量

    final变量必须在声明时就初始化或在构造函数中初始化,final变量的可见性是指:被final修饰的变量声明时或构造方法执行时,一旦初始化完成,那么其他线程无须同步就能看到final变量的正确值。因为一旦初始化完成,final变量值就会立刻同步回主内存。

    synchronized关键字

    通过synchronized关键字关键字修饰的代码块或方法,对数据的读写进行以下控制:

    • 读数据 在线程进入该代码区域读取变量信息时,不能从工作内存中读取,只能从主内存中读取,保证读取到的都是变量同步的最新值。
    • 写数据 在同步区域对变量进行写入操作,在离开同步区域时必须将当前线程 的工作内存中修改过的变量同步回主内存中,保证修改后的变量对其他线程可见。

    synchronized实现同步的基础是:Java中的每个对象都可以作为锁,所以synchronized锁的是对象,只不过不同场景下锁定的对象不一样。

    • 对于普通同步方法,锁定的是当前实例对象。
    • 对于静态同步方法,锁定的是当前对象所属类的Class对象。
    • 对于同步代码块,锁定的是synchronized关键字后面括号里传入的对象。

    synchronized原理

    上面提到了Java的每个对象都可以作为锁,实际上是说,每个对象都有一个与之关联的monitor。确切的说,这个monitor是存放在每个对象的对象头的Mark Word字段中。Mark Word字段除了包含monitor外,还用于存储对象的运行时数据,比如hashCode、锁状态标志、GC分代年龄等。Java中每个对象的锁,其实指的就是与它关联的monitor。
    在JVM规范中规定了synchronized是通过monitor来实现方法和代码块的同步,只不过两者的实现细节略有不同。代码块同步使用的是monitorenter和monitorexit指令。方法的同步使用的是另一种方式实现,这个稍后再讲。其实方法的同步也可以使用monitorenter和monitorexit指令来实现。当一个对象的monitor对象被持有后,该对象处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象的monitor的所有权,即尝试获取对象的锁。
    反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:

    public class SynchronizedDemo {
        public void method() {
            synchronized (this) {
                System.out.println("Method 1 start");
            }
        }
    }
    

    反编译的结果是


    image.png

    monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit指令是插入到代码块的结束位置和异常处。JVM保证每一个monitorenter都有对应的monitorexit,同时需要注意的是,在查看指令时,一个monitorenter有时会对应多个monitorexit。这是因为,synchronized需要保证在执行出现异常时,也会执行monitorexit。当线程执行遇到monitorenter执行时,执行线程会先尝试获取该对象的monitor的所有权,获取成功后才会执行同步代码,执行完成后再释放monitor。在代码执行期间,其他线程无法获取同一个对象的monitor的所有权。
    Java中关于monitorenter和monitorexit的说明如下
    monitorenter

    Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
    • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
    • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
    • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
    

    monitorexit

    The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
    The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
    

    可以看到关于monitorenter和monitorexit的指令说明中,都提到了monitor。同时也可以看出,synchronized的语义底层是通过一个关联到该对象的monitor来完成的。其实在同步区域调用的wait()和notify()方法也依赖于monitor,这也是为什么只有在同步区域才能调用wait()和notify()方法,否则就会抛出IlllgalMonitorStateException异常的原因。
    synchronized方法的同步实现稍有不同

    public class SynchronizedMethod {
        public synchronized void method() {
            System.out.println("Hello World!");
        }
    }
    

    对上述代码进行反编译的结果:


    image.png

    从反编译的结果来看,方法对同步并没有通过monitorenter和monitorexit指令来实现(理论上也可以用这两个指令来实现),不过相比于普通方法,同步方法的常量池中多了一个ACC_SYNCHRONIZED标识符,JVM就是通过这个标识符来实现方法的同步。当虚拟机访问一个被标记为 ACC_SYNCHRONIZED 的方法时,会自动在方法的开始和结束(或异常)位置添加 monitorenter 和 monitorexit 指令。

    monitor
    关于monitor,可以理解为一个具体的锁。在这个锁中保存了两个比较重要的属性:计数器(_count)和指针(_owner)。用一张图表示如下: image.png

    其中计数器_count默认为0,当线程执行到monitorenter指令时,如果检测到_count值为0,说明这个锁没有被其他线程占有,就是将_count值加一,并将_owner指针指向自己。当其他线程执行到monitorenter,检测到_count值为1,就表示这个锁已经被其他线程获得。当获得锁的线程执行到monitorexit指令时,就会将_count值减一,即释放锁。
    synchronized通常是重量级锁,而当一个对象的Mark Word中的锁状态为重量级锁时,Mark Word会用30bit指向一个“互斥量”,这个互斥量就是monitor。monitor也可以把它理解为一个同步工具,或者同步机制。
    在Java中,通过new创建一个对象时,JVM会在堆内存中创建一个instanceOopDesc对象,这个对象包含我们所创建对象的对象头和实例数据。instanceOopDesc的基类是OopDesc,它包含一个markOop类型的成员变量_mark,这个_mark就是对象头中的Mark Word。其中包含所创建对象的hashCode、分代年龄、锁标识位、是否偏向锁等信息。Mark Word在创建时会通过monitor()方法创建一个ObjectMonitor对象,而ObjectMonitor对象就是Java对象的monitor的具体实现。因此每个Java对象都会有一个与之对应的ObjectMonitor对象,通常所说的线程持有某对象的锁,指的是线程持有该对象的ObjectMonitor对象,即该对象的ObjectMonitor对象的_owner指针指向获得锁的线程。
    实际上,ObjectMonitor的同步机制是JVM对操作系统级别的Mutex Lock(互斥锁)的管理过程,其间都会转入操作系统的内核态,也就是说synchronized实现的同步锁,在“重量级锁”状态下,当多个线程间切换上下文时,这是一个比较重量级的操作,比如线程的阻塞和唤醒,需要CPU从用户态切换到内核态,频繁的阻塞和唤醒对CPU来说,是一件负担很重的工作。

    synchronized的优化

    synchronized的优化,主要的目的就是尽量避免ObjectMonitor的访问,减少“重量级锁”的使用次数,并最终减少线程上下文切换的频率。

    锁自旋

    锁自旋的概念在Java4被引入,默认关闭,Java6之后默认开启。
    所谓自旋,就是让该线程等待一段时间,但不会被挂起,即线程不会进入等待状态。看当前持有锁的线程是否会很快释放锁,而这里的等待一段时间就是执行一段无意义的循环即可。
    自旋锁存在一定的缺陷,自旋锁会占用CPU,如果锁竞争的时间比较长,那么锁自旋的线程通常不能获得锁,还会白白浪费自旋占用的CPU资源。通常在锁持有时间长,且竞争激烈的场景下,应该主动禁用自旋锁。

    轻量级锁

    有时候Java虚拟机中会存在这样的情况,对于一块同步代码,虽然有多个线程去执行,但是这些线程是在不同的时间段交替请求这个锁,也就是彼此请求锁的时间完全错开,不存在锁竞争的情况。此时,锁会保持轻量级锁的状态,从而避免重量级锁的阻塞和唤醒操作。
    轻量级锁适用的场景是,线程交替执行同步代码的场合,如果存在同一时间访问一个锁的情况,就会导致轻量级锁膨胀为重量级锁。

    偏向锁

    轻量级锁是在没有竞争的情况下的锁状态,但还是在有些时候,锁不仅存在多线程竞争,而且总是由同一个线程获得,因此为了让线程获得锁的代价更低,引入了偏向锁的概念。偏向锁的意思是,如果一个线程获得了锁,而接下来的一段时间内没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或退出同步区域,不需要再次进行抢占锁和释放锁的操作。偏向锁的具体实现就是在对象的Mark Word中有一个ThreadId字段,默认情况下这个字段是空的,当第一次获得偏向锁时,线程会将自己的ThreadId写入锁对象的Mark Word中的ThreadId字段,同时将Mark Word中是否偏向锁的状态设置为01。这样,下次有线程进入同步区域时,直接检查锁对象Mark Word中的ThreadId是否和该线程的Threadid一致。如果一致,则认为该线程获得了锁,因此不需要再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。
    其实偏向锁并不适合所有应用场景,因为一旦出现锁竞争,偏向锁就会被撤销,膨胀为轻量级锁,而撤销操作(revoke)是比较重的操作。只有当存在较多不会真正竞争的synchronized同步锁时,才能体现出明显改善。因此实践中,还是需要考虑具体业务场景并测试后,在决定是否开启/关闭偏向锁。

    CAS

    全称是Compare And Swap。比较和替换。是一种通过硬件实现并发安全的常用技术,底层是通过CPU的CAS指令来对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。在Java中,CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。而synchronized是悲观锁,这种线程一旦得到锁,其他需要锁的线程就挂起的情况就是悲观锁。
    CAS的实现过程主要有3个操作数:内存中的当前值V,旧的预期值E,要修改的新值U。执行更新变量的操作时,当且仅当预期值E和内存里的当前值V相同时,才将内存里的当前值V修改为U,否则什么都不做。
    Java.util.concurrent.atomic包下的一些列以Atomic开头的原子操作类,,比如AtomicInteger、AtomicBoolean、AtomicLong等,他们分别用于Boolean、Integer、Long类型的原子性操作。这些原子操作的底层实现正是利用了CAS机制。

    本文参考

    理解Java内存模型
    volatile底层实现(CPU的缓存一致性协议MESI)
    内存屏障与synchronized、volatile的原理
    Java并发编程:Synchronized及其实现原理
    Java:CAS(乐观锁)
    Android 工程师进阶 34 讲:深入理解 AQS 和 CAS 原理
    Android 工程师进阶 34 讲:Java 线程优化 偏向锁,轻量级锁、重量级锁
    Android 工程师进阶 34 讲:既生 Synchronized,何生 ReentrantLock
    Android 工程师进阶 34 讲:Java 内存模型与线程

    相关文章

      网友评论

        本文标题:关于Java内存模型,Android开发需要了解的

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