Android进阶之路Android开发经验谈Android技术知识

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

2020-05-14  本文已影响0人  zackyG

物理机的并发问题

Java内存模型的意义

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

Java内存模型的概念

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

Java内存模型的组成

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

Java内存操作的并发问题

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

指令重排序需要满足的条件

Java内存间的交互操作

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

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

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

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

原子性

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

可见性

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

有序性

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

内存交互的8个基本操作

关于主内存和工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存,如果从工作内存同步回主内存的过程细节,Java内存模型中定义了8种基本操作来完成。虚拟机实现时必须保证这8种基本操作都是原子性的,不可分的。 image.png
基本操作的同步规则

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

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

happen-before原则

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

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

内存屏障

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

Store1;
Store2;   
Load1;   
StoreLoad;  //内存屏障
Store3;   
Load2;   
Load3;

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

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

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 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
关于“嗅探”协议

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

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

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

volatile的使用场景

final变量

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

synchronized关键字

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

synchronized实现同步的基础是:Java中的每个对象都可以作为锁,所以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 内存模型与线程

上一篇下一篇

猜你喜欢

热点阅读