# volatile 关键字
CPU 并不是和内存直接交互,而是和 cache
高速缓存交互,当需要访问一个内存地址时,如果这个地址之前被读到 cache
中,就直接从 cache
中拿,这叫做缓存命中。同时,如果想要修改一个数据,而这个数据被读到了 cache
中,处理器则会将这个操作数写回到缓存中,而不是内存中,这叫做写命中。
这也导致,在多处理器中,多个线程各自运行在自己的处理器上,对共享变量各自都在 cache
中有一份副本,某一线程对该变量修改后是写回缓存中,而不是马上写回内存,即使写回内存,其他线程也不会读取新的值(因为自己的 cache
中始终处于缓存命中状态,除非该部分被其他内存地址覆盖)。
volatile
的作用有两个,假设被该关键词修饰的变量为 count
:
count
被某一个线程修改后,会立即写入内存中。- 这个写回操作会使其他 CPU 的
cache
缓存了该内存地址的数据无效。
# 实现原理
被 volatile
修饰的变量在进行写操作时,JVM 会向处理器发送一条 lock
前缀指令,该指令就会让变量所在的缓存行的数据写回到系统内存中。
在多处理器下,为了保证各个处理器的缓存是一致的,会实现缓存一致协议(MESI):每个处理器通过嗅探总线上传播的数据来检查自己缓存的值是否过期,从而设置自己缓存中的对应的数据为无效状态。
早期的 lock
前缀会使处理器执行当前指令时产生一个 LOCK#
信号,会对总线进行锁定,其他 CPU 对内存的读写请求会被阻塞,直到锁释放,但是开销比较大。后来加锁操作变成了高速缓存锁。缓存锁不是指给某一行缓存上锁,而是说某个 CPU 对缓存数据进行更改时,会通知缓存了该数据的其他 CPU 抛弃缓存的数据或者从内存重新读取。
# 缓存一致性
缓存是分段的,一个段对应一块存储空间,称之为缓存行,是 CPU 缓存中可分配的最小存储单元,通常是 64 字节, LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探 " 协议。 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁 (同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
之前也提到过, volatile
不能保证并发安全,多个线程对共享变量进行自增的例子:线程 1 刚刚将 a 的值更新为 100,这时线程 2 可能也已经执行到更新 a 的值这条指令了,已经刹不住车了,所以依然会将 a 的值再更新为一次 100。
# 优化
在 Java7 中并发包下新增了一个队列集合类 LinkedTransferQueue
,Doug lea 设计这个队列的节点时,用一个类将节点包装起来,再通过加入一些无用的引用 reference
来将包装类扩充到 64 字节大小,下面是我修改后,意思相近的代码:
// 队列代码 | |
private final Reference<QNode> head; | |
private final Reference<QNode> tail; | |
static final class Reference<V> { | |
private volatile V value; | |
Object p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;// 15 个引用,一个引用占 4 字节 | |
} |
加入队列中的是 Reference
,这是一个很神奇的事,它将共享变量扩充到了 64 字节。因为在英特尔酷睿 i7 等处理器,缓存行是 64 字节,如果头节点和尾节点加起来都不足 64byte
,那么他们可能会被分配到一个缓存行,当需要入队(修改尾节点)时,就会锁定整个缓存行,导致其他处理器的对应缓存行失效,本来应该只失效尾节点数据,但是此时头节点和尾节点在同一行中,导致头节点也失效。
将节点大小扩充到 64 字节后,避免了头节点和尾节点在同一缓存行中。
# synchronized 关键字
synchronized
是对一个 Java 对象上锁,底层原理是对一个 Java 对象的头部信息进行修改从而记录这个锁对象是否被获取,被哪个线程获取,锁类型(偏向,轻量,重量锁)等信息,因为 Java 对象中有一个很特别的 Class
对象,这也就导致了对象锁和类锁两种区别。
# 对象锁
包括方法锁和同步代码块,因为 synchronized
可以修饰方法,所以有了方法锁的定义,可以理解为整个方法都包含在了同步代码块中。
对于对象锁,作用范围仅在于某个实例对象
import lombok.Data; | |
public class Main { | |
public static void main(String[] args) throws Exception { | |
Solution test = new Solution(); | |
for(int i = 0; i < 10; i++) { | |
new Thread(test::func).start(); | |
} | |
Thread.sleep(1000); | |
System.out.println(test.getCount()); | |
} | |
@Data // 使用了 lombok 框架 | |
static class Solution { | |
private int count = 0; | |
public synchronized void func() { | |
for(int i = 0; i < 1000; i++) count++; | |
} | |
} | |
} |
上述代码是方法锁的例子,如果要将其改为同步代码块,只需要将 func
方法改为:
public void func() { | |
synchronized(this) { | |
for(int i = 0; i < 1000; i++) count++; | |
} | |
} |
通过引用 this
对象来实现同步代码块的上锁。
# 类锁
类锁作用域整个类,方法锁用于静态方法,同步・代码块的锁对象为 xxx.class
。
public class Main { | |
public static void main(String[] args) throws Exception { | |
for (int i = 0; i < 10; i++) { | |
new Thread(() -> { | |
new Solution().run(); | |
}).start(); | |
} | |
Thread.sleep(1000); | |
System.out.println(Solution.count); | |
} | |
static class Solution { | |
public static int count = 0; | |
public static synchronized void func() { | |
for (int i = 0; i < 1000; i++) count++; | |
} | |
// 实例化对象不能调用静态方法,所以需要通过另一个方法间接使用 | |
public void run() { | |
func(); | |
} | |
} | |
} |
同步代码块此处不再演示,本质上就是获得 Solution
类的 Class
对象锁。
对象锁和类锁的本质是没有什么区别的,都是将一个 Java 对象作为锁,线程去获取。我并不推荐这样的理解:
对象锁作用于指定的对象,类锁作用域所有对象。
读者需要理解的是, synchronized
到底需要拿到什么样的锁。因为 Class
对象全局唯一,所以所有同一个类的实例化对象调用类锁修饰的静态方法 / 同步代码块,都需要去竞争同一个 Class
对象,自然就导致类锁作用于所有实例化对象。
# 原理分析
使用 synchronized
,会在底层字节码文件中产生 Monitorenter
和 Monitorexit
两个指令,对让对象在执行,使其锁计数器加 1 / 减 1,一个对象在同一时间只与一个 monitor
(锁)相关联,而一个 monitor 在同一时间只能被一个线程获得。
线程尝试获取这个 Monitor
锁时, monitorenter
指令会发生 3 种情况:
- 计数器为 0,线程获取锁并将其 + 1,别的线程只能等待释放。
- 该线程已经有了锁的所有权,重入锁,计数器加 1。
- 等待其他线程释放锁(进入阻塞队列),线程状态变为
BLOCKED
。
而 monitorexit
指令则是将计数器 - 1,如果之前是重入了,就不需要释放锁,如果计数器变为 0,释放锁。
可重入一定需要保证单个线程执行时重新进入同一个子程序仍然是安全的。
# 锁的优化
这种优化是基于一种假设:大部分同步代码一般都处于无锁竞争状态,即单线程执行环境。之前的 monitorenter
和 monitorexit
指令需要依赖底层操作系统的 Mutex Lock
实现,该命令需要将当前线程挂起并从用户态切换到内核态,切换的代价非常贵。基于之前优化假设,如果锁一般处于单线程获取锁的情况,也就是大部分时间都只有同一个线程反复拿到这个锁,释放锁,再次拿锁。那么每次重新拿锁就会导致状态切换,开销很大。
所以之后出现了偏向锁,轻量锁来优化 synchronized
的性能。
还有几个其他的概念:
- 锁粗化:减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
- 锁消除:通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的 Stack 上进行对象空间的分配 (同时还可以减少 Heap 上的垃圾收集开销)。
在 jdk1.6 种, synchronized
同步锁一共有四种状态: 无锁
、 偏向锁
、 轻量级锁
、 重量级锁
,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁
# 自旋锁
在操作系统篇也讲到过这个锁的出现,它通过不断地 while
循环 CAS
操作尝试获取锁。自旋锁是最简单的锁,其缺点就是会消耗整个 CPU 时间片,直到拿到了锁。
有时这样的锁是不能接受的,但是 jdk 引入自旋锁,是因为有时共享数据的锁定状态只会持续很短时间,为了这段时间挂起,恢复阻塞线程并不值得,所以完全可以让没有获取到锁的线程在外面等待一会(自旋)。
自旋锁适用于竞争线程很少的情况下,如果大量线程竞争同一个自旋锁,或者同步代码块执行时间非常长,是不能接受的。所以 JDK 定义自旋锁默认自旋次数为 10 次,还没拿到锁就得去挂起线程。
-xx:PreBlockSpin
参数可以修改自旋次数。
在 JDK1.6 引入了自适应自旋锁,自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
# 偏向锁
了解整个 synchronized
的锁膨胀过程,需要了解 HotSpot
虚拟机种对象头的内存布局,对象头存在两部分:
Mark Word
:存储对象自身的运行时数据,HashCode
、GC Age
、锁标记位、是否为偏向锁。Klass Point
:存储的是指向方法区对象类型数据的指针,如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
在 jdk1.6 引入了偏向锁,当一个线程访问同步块并获取锁时,会在对象头和线程栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和推出同步块时不需要进行 CAS 操作来加锁和解锁。只需要测试一下对象头的 Mark Word
里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。
下面是《Java 并发编程的艺术》对偏向锁的解释:
只有当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等到 ** 全局安全点 (** 就是当前线程没有正在执行的字节码)。会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁(升级为轻量锁)。
偏向锁也可以重入,每次重入,尽管不会再次执行 moniterenter
,但是会在锁计数器 + 1。
# 轻量锁
在线程执行同步块之前,JVM 会先在当前线程的栈帧中创建一个名为锁记录 ( Lock Record
) 的空间,用于存储锁对象目前的 Mark Word
的拷贝 (JVM 会将对象头中的 Mark Word
拷贝到锁记录中,官方称为 Displaced Mark Ward
)
上图还没有将拷贝存储到 Lock Record
空间。
CAS 操作将 Mark Word
拷贝到所记录中,同时将 Mark Word
更新为指向 Lock Record
的指针,如果更新成功了,该线程就有了该对象的锁,并且对象的 Mark Word
最后两位更新为 00。
如果这个更新操作失败,JVM 会检查当前的 Mark Word
中是否存在指向当前线程的栈帧的 Lock Record
的指针:
- 如果有,相当于锁重入,就会再创建一个
Lock Record
作为重入的计数
- 没有,说明该锁被其他线程抢占,如果由两条以上的线程竞争同一个锁,就会直接膨胀为重量级锁。
膨胀过程中,会为锁对象申请一个 Monitor
锁,让 Mark Word
指向 Monitor
地址。
轻量锁解锁时,会使用 CAS 将 Displaced Mark Word
替换回到对象头中,如果成功,则没有发生竞争关系,如果失败,表示当前锁存在竞争关系,膨胀为重量级锁。
下图来自《Java 并发编程的艺术》:
从上图可以看到,线程 2 不断自旋获取锁,当自旋次数过长,导致线程 2 挂起不再自旋,此时线程 2 会修改对象头的 Martk Word
指向 Monitor
锁,并阻塞自己,所以线程 1 希望通过 CAS
替换释放锁时,就会失败,此时线程 1 会将指向重量级锁的指针设为空,将原有的锁释放并唤醒线程 2。
上述对图的解释来自于:https://ask.csdn.net/questions/4646227 评论区中 2022-11-03 的 **k??** 网友提出的,我也对自己的疑问也进行了提问。
作者存疑:
说实话,我是没搞懂为什么线程 1 在释放轻量锁时会将指针设为空,那还怎么找到 Monitor 对象?
如果读者有什么高见,劳烦发邮箱:laurensvfevaa@gmail.com 十分感谢
轻量锁的一个很大的特征就是其他线程会自旋等待一段时间,而重量锁会直接阻塞。
# final 关键字
final
关键字有很多知识都和 JMM
有关,所以本部分不会涉及太多相关内容,到了 JMM
篇再详细说明。
# 基础使用
- 修饰类:类不能被继承,
final
类所有方法都是隐式为final
,所以final
类中的方法不需要再加final
。
如果想要扩展 final
,要用到的设计模式为组合,一般想要扩展一个类,就是继承 / 实现和组合。
- 修饰方法:不能被重写,继承,但是可以重载,
private
方法是隐式的final
。 - 修饰参数:无法更改参数引用所指的对象
- 修饰变量:被
final
修饰的变量可以不是编译器常量,只是初始化后无法被更改。Java 允许允许生产blank final
也就是空白final
,被声明为final
但又没有给定值的字段,但是必须在该字段被使用之前被赋值。
如果 final
修饰的是一个指向对象的引用,那么该引用只是不能指向其他对象,但是当前对象的成员变量仍然可以被修改。
一个有趣的现象就是:
byte b1=1; | |
byte b2=3; | |
byte b3=b1+b2;// 会出错,运算时 java 虚拟机对它进行了转换,结果导致把一个 int 赋值给 byte |
如果用 final
修饰 b1
, b2
,就不会强转。
# 重排序规则
- 基本数据类型:
final域写
:禁止final
域写与构造方法重排序,即禁止final
域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final
域全部已经初始化过。final域读
:禁止初次读对象的引用与读该对象包含的final
域的重排序。
- 引用数据类型:
额外增加约束
:禁止在构造函数对一个final
修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序、
更多细节会留到
JMM
篇讲解。
# 参考
《Java 并发编程的艺术》第二章
https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html
https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
https://ask.csdn.net/questions/4646227
https://pdai.tech/md/java/thread/java-thread-x-key-final.html