简介
本系列为《Java并发编程的艺术》读书笔记。在原本的内容的基础上加入了自己的理解和笔记,欢迎交流!
chapter 2:Java并发机制的底层实现
之前学习Java多线程的时候,使用过
synchronized
,这种锁称为
重锁
,而
volatile
被称为
轻量锁
,具有可见性,可见性是指一个线程修改一个共享变量时,其他线程可以读取修改后的值 。
书中给出了一些术语,之后会频繁出现,作者介绍详尽,不再补充:
volatile关键字
在Java中,如果一个变量被该关键字修饰,那么所有线程看到这个变量的
值一定是相同的
(共享)。
//在类中声明
volatile instance = new Singleton();
// 使用工具查看生成的汇编代码,会发现在声明变量的后面多一句内容:
Lock addl $0x0, (%esp);
Lock
指令会引发CPU将指定位置的数据写回到内存中,一旦对应的数据发生了改变,所有cache中的对应的记录都会变成
脏数据
,
因为计算机中的高速缓存必须维持数据的版本一致
。这种协议称为
缓存一致协议
。
通常 来说,每个CPU有自己对应的cache,而且cache通常有多级。
当一个
Lock信号
在总线上广播时,别的CPU会拦截写回内存的地址,并去自己对应的缓存中检测是否命中,如果命中则将对应的缓存行的脏读位置为
1。总的来说,volatile实现的原则有两点:
- Lock指令触发处理器缓存写回到内存中;
- 一致性协议使得别的处理器的缓存中对应的缓存行失效,所以就必须去内存中读取值。
就目前而言,处理器的操作过程是:
所有持有该数据的处理器的cache会将对应的缓存行锁定(因为数据是被修改了,所有不允许别的处理器来访问这些脏数据),通过缓存一致性协议保证修改被写回到内存中
。该操作称为
缓存锁定
。
Synchronized关键字
Java中每个对象都是可以作为锁,具体情况可以划分成以下几种:
- 普通对象的锁是当前实例对象;
- 静态同步方法,锁是当前类的Class对象;
-
同步方法块,锁是
Synchronized
括号内的对象,即1,2。
JVM中的线程想要访问同步代码块必须获取锁,基于的是
Monitor
对象。代码的块的同步是使用
monitorenter
和
monitorexit
指令实现的,
这两个指令会在Java编译成字节码的时候被插入在指定的位置
。
同步代码块在编译时会在开头插入monitorenter,在结束处和异常处插入monitorexit。
Java对象头
每个对象都会有对象头,用来存储一些
元数据
。
synchronized锁的相关信息就存储在对象头。
之所以每个对象都可以作为一个锁,是因为在java对象头中保存了对应的信息。前面提到的CAS算法其实就用来修改Java对象头的。
数组对象的对象头包括3个字长,比非数组对象多一字长用于表示数组的长度。
Mark word的内容如下:
- 对象的HashCode
- 分代年龄
- 锁标记位
锁的升级与比较
在Java 1.6之后,锁一共有四种状态,对应的优先级从低到高:
1. 无锁
即没有加锁。
2. 偏向锁:只有一个线程访问锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的
锁记录
里
存储锁偏向的线程ID
,
以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。
如果发生了线程对数据的争用,那么偏向锁就会撤销
。
注意:偏向锁只是
加速了一个线程多次访问一个同步块
,且没有发生争用,适用于线程对锁的竞争度不高的情况。
偏向锁的作用主要是加速
线程争用情况不严重的场景
,锁标记指示当前对象持有的偏向锁,此时线程会检查markword中的线程ID是否是自己,如果不是自己那么去检查是否设置了偏向锁标志:
-
设置为1:使用CAS将当前线程id修改到线程ID:
-
成功:获取偏向锁; 此时 :
TID|Epoch|对象分代年龄|1|01
-
失败:撤销偏向锁;此时:
null|Epoch|对象分代年龄|0|01
。此后偏向锁失效,因为发生了锁争用。
-
成功:获取偏向锁; 此时 :
-
设置为0:那么就去竞争锁,此时偏向锁失效,偏向锁的撤销必须是在安全点。
3. 轻量级锁:适用于争用情况不严重的场景
加锁

线程在访问同步代码块之前,会在当前的线程的系统栈中创建
存储锁记录的空间(多个锁)
。
每访问一个同步代码块就会将这个锁对象(A对象)的
mark word
(理解为对象唯一标识)复制到该空间中,并使用CAS(原子性)操作将A对象的
mark word
中的对应的字段修改成
当前的线程锁记录空间的地址
。
这里的理解就是,一旦发现当前的
mark word
被修改了,那么别的线程使用CAS操作就无法修改,会进入自旋或者锁膨胀
。
这个过程就是将对象A的mark word(唯一标识)拿走,放到自己的私有区域(线程栈),并告诉别人现在这个锁被获取了(因为原本存储mark word的位置存储到了一个锁空间的地址)。
如果成功则获取锁,如果失败则使用自旋的方式不断尝试获取锁;
解锁
解锁就很好理解了。和加锁的过程相反,就是把原本拿走的
mark word
还回去,表示我用完了,当别的线程再次访问这个对象时,会发现这个位置的值确实是对象本身的
mark word
,此时就会获取成功。
如果此时有线程在争用这个锁,那么轻量级锁就会失效,升级成重量锁,那么拿到锁的线程在使用CAS解锁就会失败,
此时释放锁的线程需要唤醒因为该锁而阻塞的线程
。
4. 重量级锁:多个线程同时访问一把锁
由于锁的争用且自旋一段时间无法获取锁,此时线程就会将当前的锁修改成重量级锁。重锁会让当前线程进入
阻塞状态
,会被加入到
同步队列
中。
原子性操作
先明确一些术语:
首先我们来看一下处理器(32位的IA-32处理器)是如何实现原子性操作的:
-
对总线进行加锁
:处理器提供一个LOCK信号,当有一个
LOCK
信号被输出到总线上时,其他处理器的请求将被阻塞。
但是CPU和内存之间的通信也会被阻塞
。 -
对缓存机进行加锁
:
总线加锁使得其他的处理器不可以操作其他内存地址的数据
。如果一个内存地址的数据被缓存到了cache中,那么在执行锁操作期间,当发生了写回动作时会去更新缓存行中的数据,利用缓存一致性来保证数据的可靠性。 -
缓存一致性
: 当CPU写数据时,如果发现操作的变量是共享变量,
即在其他CPU中也存在该变量的副本
,会发出信号通知其他CPU
将该变量的缓存行置为无效状态
,因此当其他CPU需要读取这个变量时,发现自己缓存中的缓存该变量的缓存行是无效的,那么它就会重新从内存中读取。
作者详尽的表述了Java如何实现原子性操作的,总结起来就是锁和循环的CAS操作。不断地使用CAS操作尝试去修改状态,如果成功则代表获取到了锁。
CAS存在的问题和解决办法:
-
ABA问题
:引入版本号; -
循环时间过长
:使用pause指令- 延迟流水线的执行;
- 避免在退出循环时因为内存的冲突引发流水线清空;
-
无法保证多个变量的共享操作
:- 使用锁;
- 将多个共享变量