并发编程存在原子性、可见性、有序性问题。内存模型用于解决原子性、有序性、可见性问题。java内存模型是比顺序一致性模型和处理器内存模型约束更强,更易编程的跨平台模型,提供了一系列机制和原语保证原子性、可见性、有序性,包括synchronized、volatile等。
java内存模型知识导图
一 并发问题及含义
并发编程存在原子性、可见性、有序性问题。
- 原子性即一系列操作要么都执行,要么都不执行。
- 可见性,一个线程对共享变量的修改,另一个线程可能不会马上看到。由于多核CPU,每个CPU核都有高速缓存,会缓存共享变量,某个线程对共享变量的修改会改变高速缓存中的值,但却不会马上写入内存。另一个线程读到的是另一个核缓存的共享变量的值,出现缓存不一致问题。
- 有序性,即程序执行的顺序按照代码的先后顺序执行。编译器和处理器会对指令进行重排,以优化指令执行性能,重排不会改变单线程执行结果,但在多线程中可能会引起各种各样的问题。
二 内存模型
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。内存模型解决并发问题
主要采用两种方式:限制处理器优化和使用内存屏障。
顺序一致性内存模型是一种理论参考模型,提供了极强的内存可见性保证,具有两大特性:
- 一个线程的所有操作按照程序的顺序执行,而不能重排序。
- 所有线程只能看到单一的执行顺序。每个操作都必须原子执行且立刻对其它线程可见。
顺序一致性内存模型禁止很多处理器和编译器重排,影响执行性能,处理器内存模型和JMM对顺序一致性内存模型进行放松,执行性能:处理器内存模型>JMM>顺序一致性内存模型,易编程性:处理器内存模型<JMM<顺序一致性内存模型。
三 java内存模型
Java内存模型(Java Memory Model ,JMM)是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。主内存和工作内存可类比成计算机内存模型中的主存和缓存的概念。
3.1 java内存模型解决并发问题方法
原子性,在java中,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。在32位平台下,对64位数据的赋值是需要通过两个操作来完成,不能保证其原子性。要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock保证任一时刻只有一个线程执行该代码块,从而保证了原子性。
可见性,Java提供了volatile关键字来保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
JMM通过happens-before关系向程序员提供跨线程的内存可见性保证:
- 程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
- 锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
- volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
- 传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
- 线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
- 线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
有序性,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
3.2 java并发原语
Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
3.2.1 volatile
内存语义:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有共享变量刷新到主内存。
当读一个volatile变量,JMM会把该线程对应的本地内存置为无效,线程接下来从主内存中读取共享变量。
实现:
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
在每个volatile写操作前面插入一个StoreStore屏障。StoreStore屏障禁止上面的普通写和volatile写重排序,保障上面的普通写在volatile写之前刷新到主内存。
在每个volatile写操作后面插入一个StoreLoad屏障。避免volatile写与后面可能有的volatile读/写重排序。
在每个volatile读操作的后面插入一个LoadLoad屏障。禁止下面的普通读操作和上面的volatile读操作重排序
在每个volatile读操作的后面插入一个LoadStore屏障。禁止下面的普通写操作和上面的volatile读操作重排序
3.2.2 synchronized
内存语义:
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中.
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量.
实现:
java对象头组成:
- Mark Word
- 指向类的指针
- 数组长度(只有数组对象才有)
Mark Word用于加锁操作,结构如下:
图3.1 java对象头Mark Word
synchronized用的锁是存在Java对象头里,任何java对象都存在一个锁,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。代码块同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。
监视器锁(Monitor)本质依赖操作系统的Mutex Lock(互斥锁)来实现,如果互斥量已经上锁,调用线程会阻塞,阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。在jdk1.6中加入对锁的优化措施,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
偏向锁:
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
偏向锁的获取:
- 获取对象的Mark Word
- 判断对象是否处于可偏向状态,是否是偏向锁标志位为1,锁标志位为01。
- 如果Mark Word中的线程ID为空,用cas将此字段设为当前线程,若成功则进入同步块,否则进入步骤4;若线程ID为当前线程,则直接进入同步块,若为其它线程则进入步骤4
- 若执行cas失败,表示多个线程在竞争锁,到达全局安全点后,获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块。
偏向锁的撤销(只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁):
- 偏向锁的撤销动作必须等待全局安全点;
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态;
- 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态
轻量级锁:
轻量级锁是为了在线程近乎交替执行同步块时提高性能。多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁。
轻量级锁获取:
- markOop mark = obj->mark()方法获取对象的markOop数据mark;
- mark->is_neutral()方法判断mark是否为无锁状态:mark的偏向锁标志位为 0,锁标志位为 01;
- 如果mark处于无锁状态,则进入步骤(4),否则执行步骤(6);
- 把mark保存到BasicLock对象的_displaced_header字段;
- 通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行步骤(6);
- 如果当前mark处于加锁状态,且mark中的ptr指针指向当前线程的栈帧,则执行同步代码,否则说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁
轻量级锁撤销:
- 取出在获取轻量级锁时保存在线程栈的中的Mark Word
- 通过CAS尝试把保存的Mark Word替换到当前的Mark Word,如果CAS成功,说明成功的释放了锁,否则执行步骤(3)
- 如果CAS失败,说明有其它线程在尝试获取该锁,这时需要将该锁升级为重量级锁,并释放;
重量级锁:
重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高。
其它锁优化措施:锁消除、锁粗化、自旋锁(忙循环,适用持有锁的线程很快释放锁)、自适应的自旋锁(自旋次数不固定,前一次在同一个锁上的自旋时间及锁的拥有者的状态决定)。
锁膨胀过程:
- mark->has_monitor()方法判断当前是否为重量级锁,即Mark Word的锁标识位为 10,如果当前状态为重量级锁,执行步骤(2),否则执行步骤(3);
- mark->monitor()方法获取指向ObjectMonitor的指针,并返回,说明膨胀过程已经完成;
- 如果当前锁处于膨胀中,说明该锁正在被其它线程执行膨胀操作,则当前线程就进行自旋等待锁膨胀完成,这里需要注意一点,虽然是自旋操作,但不会一直占用cpu资源,每隔一段时间会通过os::NakedYield方法放弃cpu资源,或通过park方法挂起;如果其他线程完成锁的膨胀操作,则退出自旋并返回;
- 如果当前是轻量级锁状态,即锁标识位为 00,膨胀过程如下:
- 通过omAlloc方法,获取一个可用的ObjectMonitor monitor,并重置monitor数据;
- 通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中,如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成;
- 如果CAS成功,设置monitor的各个字段:_header、_owner和_object等,并返回;
monitor竞争:
- 如果还未有线程获取锁,通过CAS尝试把monitor的_owner字段设置为当前线程;
- 如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
- 如果线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
- 如果获取锁失败,则等待锁的释放;
monitor等待:
- monitor竞争失败的线程,通过自旋等待锁的释放
- 当前线程被封装成ObjectWaiter对象node,通过CAS把node节点push到ObjectMonitor中的cxq列表中
- node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒
monitor释放:
根据不同的策略(由QMode指定),从ObjectMonitor中的cxq或EntryList中获取头节点,唤醒该节点封装的线程,唤醒操作最终由unpark完成
3.2.3 final
写final域禁止把final域的写重排序到构造函数之外。对于引用类型:在构造函数内对final域引用对象的成员域的写入,与在构造函数外将这个被构造对象的引用赋值给引用变量,这两个操作不能重排序。防止对象构造完成,未被初始化的final域被访问(要达到此目的,还需确保被构造对象不能在构造函数中“逸出”)
读final域禁止初次读一个对象的引用和随后初次读这个对象包含的final域之间的重排序。确保在读一个对象的final域前,一定会先读包含这个final域对象的引用,如果引用不为空,引用对象的final域已经被初始化过。
实现:
JMM禁止编译器把final域的写重排序到构造函数之外。
编译器在final域的写之后,构造函数return之前,插入StoreStore屏障,禁止处理器把final域的写重排序到构造函数之外。
编译器会在读final域前面插入StoreStore屏障。