黑马JVM课程——JAVA内存模型(四)
简单的说,JMM(java Memory model)定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
原子性
问题:
两个线程对初始值为0的静态变量一个做自增,一个做自减,各做5000次,结果是0吗?
伪代码如下所示:
1 | public class Test { |
问题分析
运行上面的代码,等到的结果可能是正数,可能是负数,也可能是0,为什么呢?
因为java中对静态变量的自增,自减并不是原子操作。
例如对
i++
而言(i为静态变量),实际会产生如下的JVM字节码指令:1
2
3
4getstatic i //获取静态变量i的值
iconst_1 //准备常量1
iadd //加法
putstatic i //将修改后的值存入静态变量i而对应的
i--
也是类似:1
2
3
4getstatic i //获取静态变量i的值
iconst_1 //准备常量1
isub //减法
putstatic i //将修改后的值存入静态变量i而java的内存模型如下,完成静态变量的自增,自减需要在主内存和线程内存中进行数据交换
当CPU执行两个线程的时候,并不一定就是同时运行两个线程,而是每次执行一个,交错运行
当一个线程读取数据,计算完毕,准备把数值覆盖回主内存时,也行另一个线程已经运行了多次,多次修改了主内存,然后再被覆盖,就会导致结果错误
如果是单线程执行上面的8行代码是顺序执行的(不会交错)也就没有问题了:
1
2
3
4
5
6
7
8
9//假设i的初始值是0
getstatic i //线程1-获取静态变量i的值 线程内值 i=0
iconst_1 //线程1-准备常量1
iadd //线程1-加法i=i+1 线程内i=1
putstatic i //线程1-将修改后的值存入静态变量i 静态变量 i=1
getstatic i //线程1-获取静态变量i的值 线程内i=1
iconst_1 //线程1-准备常量1
isub //线程1-减法 i=i-1 线程内i=0
putstatic i //线程1-将修改后的值存入静态变量i 静态变量i=0但多线程下,这8行代码可能是交错运行
出现负的情况
1
2
3
4
5
6
7
8
9//假设i的初始值是0
getstatic i //线程1-获取静态变量i的值 线程内值 i=0
getstatic i //线程2-获取静态变量i的值 线程内值 i=0
iconst_1 //线程1-准备常量1
iadd //线程1-加法i=i+1 线程内i=1
putstatic i //线程1-将修改后的值存入静态变量i 静态变量 i=1
iconst_1 //线程2-准备常量1
isub //线程2-减法 i=i-1 线程内i=-1
putstatic i //线程2-将修改后的值存入静态变量i 静态变量i=-1出现正数的情况
1
2
3
4
5
6
7
8
9//假设i的初始值是0
getstatic i //线程1-获取静态变量i的值 线程内值 i=0
getstatic i //线程2-获取静态变量i的值 线程内值 i=0
iconst_1 //线程1-准备常量1
iadd //线程1-加法i=i+1 线程内i=1
iconst_1 //线程2-准备常量1
isub //线程2-减法 i=i-1 线程内i=-1
putstatic i //线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i //线程1-将修改后的值存入静态变量i 静态变量 i=1
解决方法
synchronized(同步关键字)
语法:
1 | synchronized(对象){ |
用 synchronized 解决并发问题:
1 | public class Test { |
可见性
退不出的循环
先来看一个现象,
main
线程对run
变量的修改对于t
线程不可见,导致了t
线程无法停止
当我们循环一秒钟后,将run
的值修改为false
,理论上是应该能让循环停下来的,但实际却不行
1 | public class Test { |
为什么呢?分析一下:
初始状态,t线程刚开始从主内存读取
run
的值到工作内存因为 t 线程要频繁从主内存总读取
run的值
,JIT编译器会将run
的值缓存到自己工作内存中的高速缓存中,减少对主内存中run
的访问,提高效率一秒钟之后,
main
线程修改了run
的值,并同步到主内存,而t
是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决方法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作内存中查找变量的值,必须到主内存中获取它的值,线程操作
volatile
变量都是直接操作主存
1 | static volatile boolean run = true; |
可见性
前面的例子体现的就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
上例从字节码理解是这样的:
1 | getstatic run // 线程 t 获取 run true |
注意:
synchronized
语句块既可以保证代码块的原子性,也同时可以保证代码块内变量的可见性,但缺点是synchronized
是属于重量级操作,性能更低
有序性
诡异的结果
1 | int num = 0; |
Result 是一个对象,有一个熟悉 r1 用来保存结果,问,可能有几种结果?
- 线程1先执行,这个时候
ready = false
,所以进入else
分支,结果1 - 线程2先执行
num=2
,但没来得及执行ready = true
,线程1执行,还是进入else
分支,结果1 - 线程2线执行到
ready=true
,线程1执行,这次进入if
分支,结果为4(因为num=2
已经执行了)
但其实除了上面的情况,还会有另一种情况,最后结果是0
- 线程2先执行了
ready=true
,切换到线程1,进入if
分支,但是此时num=2
还没有执行,相加结果为0,再1切回线程2执行num=2
这种现象叫做指令重排,是JIT编译器在运行时的一些优化
这种现象需要通过大量测试才能复现,借助java并发压测工具 jcstress
code-tools/jcstress: 5fe2614f0b23 /jcstress-samples/src/main/java/org/openjdk/jcstress/samples/
添加如下依赖
1 | <dependency> |
测试代码如下:(I_Result是jcstress提供的)
1 |
|
可以通过maven命令创建项目
1 | mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0 |
或者通过idea创建maven项目
之后执行mvn clean install
命令,之后可以找到打出的包jcstress.jar
,之后执行java -jar jcstress.jar
运行该jar包即可
以下是带JVM参数和不带参数时运行的结果,可以看到,0出现的几率虽然小,但并不是没有,0.06%
1 | JVM args: [-XX:-TieredCompilation] |
解决方法
volatile
修饰变量,可以禁止指令重排
给 ready
变量加上 volatile
修饰,然后再次打包重新运行
1 | volatile boolean ready = false; |
Interesting tests: No matches.
已经没有感兴趣的结果了
1 | RUN RESULTS: |
有序性理解
同一个线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序:
1 | static int i; |
可以看到,先执行i
还是先执行j
,对于最终的结果不会产生影响,所以,上面的代码真正执行时,既可以是:
1 | i=...;//比较耗时的操作 |
也可以是:
1 | j=...; |
这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性
例如双层检查锁,看起来很完美,但是在多线程的情况下,new对象是会有问题的INSTANCE = new Singletion()
对应的字节码是
1 | 0: new #2 // class com/example/demo1/test16/Singleton |
0:给对象分配空间
3:把对象的空间引用放入操作数栈
4:调用构造方法
7:给静态变量赋值
可以看出,最后两步,也就是4和7,两步的顺序不是固定的,先执行哪一步都可以,JVM有时候就会进行优化,先赋值,再调用构造方法
如果两个线程t1和t2按如下时间顺序执行:
1 | 时间1 t1 线程执行到 INSTANCE = new Singletion() |
此时t1还未将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的将是一个未初始化完毕的单例
对 INSTANCE
使用 volatile
修饰即可,可以禁用指令重排,但是要注意在 JDK5 以上版本的 volatile
才会真正有效
happens-before
happens-before 规定了哪些操作对其他线程的读操作可见,他是可见性与有序性的一套规则总结:
- 线程解锁 lock 之前对变量的写,对于接下来对 lock 加锁的其他线程对该变量的读可见
- 线程对 volatile 变量的写,对接下来其他线程对该变量的读可见
- 线程start之前对变量的写,对该线程开始后对该变量的读可见
- 线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束)
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interupted或t2.isInerrupted)
- 对变量默认值(0,false,null)的写,对其他线程对该变量的读可见
- 具有传递性,如果x hb -> y 并且 y hb -> z 那么有 x hb-> z
CAS与原子类
CAS即Compare and Swap,它体现的是一种乐观锁的思想,比如多个线程要对一个共享整型变量执行+1操作
1 | //需要不同尝试 |
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下
- 因为没有使用 synchronized ,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想象到重试必然频繁发生,反而效率会受到影响
乐观锁悲观锁
- CAS 是基于乐观锁思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没有关系,我吃亏点再重试
- synchronized 是基于悲观锁思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会
原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,他们底层采用了CAS技术 + volatile来实现的
synchronized优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括class指针和Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄、当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程Id等内容
轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化
假设有两个方法同步块,利用同一个对象加锁:
1 | static Object obj = new Object(); |
每个线程得栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
线程1 | 对象Mark Word |
---|---|
访问同步块A,把Mark复制到线程1的锁记录 | 01 (无锁) |
CAS修改Mark为线程1锁记录地址 | 01(无锁) |
成功(加锁) | 00(轻量级锁) 线程1锁记录地址 |
执行同步代码A | 00(轻量级锁) 线程1锁记录地址 |
访问同步块B,把Mark复制到线程1的锁记录 | 00(轻量级锁) 线程1锁记录地址 |
CAS修改Mark为线程1锁记录地址 | 00(轻量级锁) 线程1锁记录地址 |
失败(发现是自己的锁) | 00(轻量级锁) 线程1锁记录地址 |
锁重入 | 00(轻量级锁) 线程1锁记录地址 |
执行同步快B | 00(轻量级锁) 线程1锁记录地址 |
同步块B执行完毕 | 00(轻量级锁) 线程1锁记录地址 |
同步块A执行完毕 | 00(轻量级锁) 线程1锁记录地址 |
成功(解锁) | 01(无锁) |
锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
1 | static Object obj = new Object(); |
线程1 | 对象Mark | 线程2 |
---|---|---|
访问同步块,把Mark复制到线程1的锁记录 | 01(无锁) | - |
CAS 修改Mark为线程1锁记录地址 | 01 (无锁) | - |
成功(加锁) | 00(轻量锁)线程1锁记录地址 | - |
执行同步快 | 00(轻量锁)线程1锁记录地址 | 访问同步块,把Mark复制到线程2 |
执行同步快 | 00(轻量锁)线程1锁记录地址 | CAS修改Mark为线程2锁记录地址 |
执行同步快 | 00(轻量锁)线程1锁记录地址 | 失败(发现别人已经占用了锁) |
执行同步快 | 00(轻量锁)线程1锁记录地址 | CAS修改Mark为重量级锁 |
执行同步快 | 10(重量锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
失败(解锁) | 10(重量锁)重量锁指针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 成功(加锁) |
… | … | … |
重量级锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这个时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
在java6之后,自旋锁是自适应的,比如对象刚刚一次自旋操作成功,那么认为这次自旋成功的可能性会高,就多自旋几次,反之,就少自旋甚至不自旋
- 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
- java7之后,不能控制是否开启自旋功能
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入任然需要执行CAS操作。
Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象头的MarkWord头,之后发现这个县城Id是自己的表示没有竞争,不用重新CAS
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的hashCode也会撤销偏向锁
- 如果对象芮然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向达到某个阈值,整个类的所有对象都会变为不可偏向的
- 可以使用 -XX:-UseBiasedLocking禁用偏向锁
其他优化
减少上锁时间
- 同步代码块中尽量短
减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
- ConcurrentHashMap
- LongAdder分为base和cells两部分。没有并发竞争的时候或者是cells数组正在初始化的时候,会使用CAS来累加值到base,有并发挣用,会初始化cells数组,数组有多少个cell,就允许有多个线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值
- LinkedBlokingQueue 入队和出队使用不同的锁,相对于 LinkedBlokingArray只有一个锁效率要高
锁粗化
- 多次循环进入同步代码块不如同步块内多次循环
- 另外JVM可能会做如下优化,把多次append的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
锁消除
- JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其他线程访问到,这个时候会被即时编译器忽略掉所有同步操作
读写分离
- CopyOnWriteArrayList
- CopyOnWriteSet
锁升级
无锁—>偏向锁
使用synchronized关键之锁住某个代码开的时候,当锁对象第一次被线程获得时候,进入偏向状态,标记为1 01,同时使用CAS操作将线程ID记录到Mark Word中,如果CAS操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向后恢复到未锁定状态或者轻量级锁状态,如果从头到尾就只有一个线程在使用锁,偏向几乎没有额外开销,性能极高。
在偏向锁状态下如果调用了对象的hahsCode,但此时偏向锁的对象MarkWord中存储的是线程ID,如果调用HashCode会导致偏向锁被撤销,而轻量级锁会在锁记录中记录HashCode重量级锁会在Monitor中记录HashCode,所以无影响。
偏向锁 —-> 轻量级锁
一旦有第二个线程加入锁竞争,偏向锁转换为轻量级锁(自旋锁)。
锁竞争:如果多个线程轮流获取一个锁,但是每次获取的时候都很顺利,没有发生阻塞,那么就不存在锁竞争,只有当某线程获取锁的时候,发现锁已经被占用,需要等待其释放,则说明发生了锁竞争。
在轻量级锁状态山继续锁竞争,没有抢到锁的线程进行自旋操作,即在一个循环中不停判断是否可以获取锁。获取锁的操作就是通过CAS操作修改对象头里面的标志位,先比较当前锁标志位是否为释放锁状态,如果是,将其设置为锁定状态,比较并设置是原子性操作,这个是JVM层面保证的,当前线程就算持有了锁,然后线程将昂前锁的持有者信息更改为自己。
假如我们获取到锁的线程操作时间很长,比如会进行复杂的计算,数据量很大的网络传输等;那么其他等待锁的线程就会进入长时间的自旋操作,这个操作过程是非常耗费资源的,其实这时候相当于有一个线程在有效地工作,其他的线程什么都干不了,在白白地消耗CPU,这种情况叫做忙等。
所以如果多个线程使用独占锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就是轻量级锁,允许短时间的忙等现象,这种折中的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销
每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word,让锁记录中Object reference指向锁对象,并尝试使用CAS替换Object的Mark Word,将Mark Word的值存入锁记录
如果CAS替换成成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁
如果CAS失败,有两种情况:
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,会先进入自旋状态,最后进入重量级锁状态
- 如果是自己执行了synchronized锁重入,那么再添加一条锁记录作为重入的计数(值为null)
当退出synchronized代码块(解锁时)如果有取值null的锁记录,表示由重入,这时重置锁记录,表示重入计数减一,相当于踢出一条null的锁记录
当退出synchronized代码块(解锁时)锁记录的值不为null,这时候cas将Mark Word的值恢复给对象头
成功,则解锁成功
失败,说明轻量级锁进行锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
轻量级锁—->重量级锁
显然,忙等是会有限度的(JVM有一个计数器记录自旋的次数,默认运行循环10次,可以修改)。
如果锁竞争情况严重,达到某个最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是通过CAS修改锁标志位,但不修改持有锁的线程ID)。
当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是上面说的忙等,即不会自旋),等待释放锁的线程去唤醒。
在JDK6之前,synchronized直接加重量级锁
JVM中,synchronized锁只能按照偏向锁、轻量级锁、重量级锁、的顺序逐渐升级(也有把这个称为锁膨胀的过程),不允许降级
- 假设Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
- 这时Thread-1加轻量级锁失败,进入所膨胀流程(这里我们就先忽略自旋)
- 即为Object对象申请Monitor锁,让Object指向重量级锁地址
- 然后自己进入Monitor的Entrylist BLOCKED
- 当Thread-0退出同步块解锁时,使用cas将MarkWord的值恢复给对象头,失败。这时会进入重量级锁解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒Entrylist BLOCKED线程
- 每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针
- 刚开始Monitor中的Owner为null
- 当Thread-2执行synchronized(ob)就会将Monitor的所持有者Owner设置为Thread-2,Monitor只能有一个Owner
- 在Thread-2上锁的过程中,如果Thread-3、Thread-4、Thread-5也执行synchronized(obj),就会进入Entrylist BLOCKED
- Thread-2执行完同步代码块中的内容,然后唤醒Entrylist 中等待的线程来竞争锁,竞争的时候是非公平的
补充
- synchronized只能随机唤醒一个线程,或者唤醒所有线程
- 偏向锁撤销时会导致STW,会暂停所有线程然后遍历线程,找到持有偏向锁的线程,如果偏向的线程还存活,并且还在执行同步代码块中的代码,则升级为轻量级锁,否则重偏向