参考 《Java并发编程深度解析与实战》
什么是线程不安全
就是当多个线程同时访问某个方法时,这个方法无法按照我们预期的行为来执行,那么我们认为这个方法是线程不安全的
导致线程不安全的原因
- 原子性
- 有序性
- 可见性
原子性问题
原子性是指一个或多个指令操作在CPU执行过程中不允许被中断
原子性问题的本质
cpu
时间片切换- 执行指令的原子性,即线程运行的程序或指令是否具备原子性
原子性问题的解决
- 不允许当前非原子指令在执行过程中被中断。比如保证
i++
操作在执行过程中不存在上下文切换 - 多线程并行执行导致的原子性问题可以通过互斥条件来实现串行执行
同步锁之 synchronized
synchronized 关键字的作用
- 作用在方法级别:锁定的是当前
class
- 作用在代码块:锁定某个对象实例
synchronized 需要实现的功能
synchronized
是同步排他锁,要想达到排他,就需要多个线程抢夺同一个资源- 同一个时刻只能有一个线程抢到锁,其他没有抢到锁的线程就需要等待
- 处于等待状态的线程不能一直占用
cpu
, 所以需要阻塞起来,并且释放cpu
资源 - 如果有多个线程被阻塞,就还需要一个容器来存储这些被阻塞的线程,当获得锁的线程执行完任务并释放锁之后,就需要从容器中唤醒阻塞的线程,被唤醒的线程再次尝试获取锁
synchronized 之锁资源
前面讲到 synchronized
可以修饰方法和修饰代码块
- 修饰方法时,对应的资源就是当前
class
(静态方法)或当前对象实例 - 修饰代码块时,是
synchronized(lock)
中的lock
就是资源
需要注意的是如果多个线程访问多个锁资源,就不存在竞争关系,也就达不到互斥的效果
抢到锁的标记之 Mark Word
前面讲到 同一个时刻只能有一个线程抢到锁 , 对于抢到锁的线程就需要进行标记到底是那个线程抢到了锁,这个标记就是存储在对象头中的
java 对象存储结构
java
对象存储结构分为三个部分
- 对象头
- 实例数据
- 对其填充
比如创建一个对象 Object lock = new Object();
, 在堆中 lock
实例最终的存储结构如下:
对象头
对象头包含三个部分
Mark Word
Klass Pointer
Lenght
Mark Word Mark Word
记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized
的同步操作时,锁标记和相关信息都是存储在 Mark Word
中的
Mark word存储结构 32位Mark word
64位Mark Word存储结构
- 不管在32位还是64位系统中,
Mark Word
中都会包含GC
分代年龄、锁状态标记 - 锁状态的字段,它包含五种状态分别是无锁、偏向锁、轻量级锁、重量级锁、GC标记
- 无锁和偏向锁的 锁标记位都是 01,但是有一个单独的
bit
存储 是否偏向锁
Klass Pointer Klass Pointer
记录了对象所属的 class
信息,当对象被创建时,会将 Klass Pointer
指向 class
信息
Length 表示数组长度,只有构建对象数组时才会有数组长度属性
内存中对象存储示例
比如下面代码
java
public class MarkWordExample {
private int id;
private String name;
public static void main(String[] args) {
MarkWordExample example = new MarkWordExample();
}
}
在 jvm
运行时内存中结构如下图所示
锁升级过程
偏向锁
偏向锁其实可以认为是在没有多线程竞争的情况下访问synchronized
修饰的代码块的加锁场景,也就是在单线程执行的情况下
线程在没有线程竞争的情况下去访问synchronized同步代码块时,会尝试先通过偏向锁来抢占访问资格,这个抢占过程是基于CAS来完成的,如果抢占锁成功,则直接修改对象头中的锁标记。其中,偏向锁标记为1,锁标记为01,以及存储当前获得锁的线程ID。而偏向的意思就是,如果线程X获得了偏向锁,那么当线程X后续再访问这个同步方法时,只需要判断对象头中的线程ID和线程X是否相等即可。如果相等,就不需要再次去抢占锁,直接获得访问资格即可
偏向锁的批量重偏向
java
public class BiasedLock {
public static void main(String[] args) {
List<BiasedLock> locks = new ArrayList<>();
BiasedLock lock = new BiasedLock();
Thread thread = new Thread(() -> {
for (int i = 0; i < 100; i++) {
BiasedLock lock = new BiasedLock();
synchronized (lock) {
locks.add(lock);
}
}
});
thread.start();
thead.join();
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 20; i++) {
BiasedLock lock2 = locks.get(i);
synchronized (lock2) {
}
}
});
}
}
上面代码创建了两个线程,第一个线程创建了 100个锁对象,第一个线程执行完成之后,第二个线程获取 100个锁对象中的前面20个进行加锁,这时会触发这些锁对象的升级过程,会从偏向锁升级到轻量级锁, 但是当执行到20循环后,这个锁对象又会变成偏向锁
在`JVM`中,以`class`(这里指 `BiasedLock`)为单位,为每个`class`维护了一个偏向锁撤销的计数器,当这个`class`的对象发生偏向撤销操作时,计数器会进行累加,当累加的值达到重偏向的阈值时,`JVM`会认为这个`class`的偏向锁有问题,需要重新偏向
偏向锁撤销并批量重偏向的触发阈值可以通过`XX:BiasedLockingBulkRebiasThreshold = 20`来配置,默认是20
轻量级锁
轻量级锁就是没有抢占到锁的线程,进行一定次数的重试(自旋)。比如线程第一次没抢到锁则重试几次,如果在重试的过程中抢占到了锁,那么这个线程就不需要阻塞,这种实现方式我们称为自旋锁
获得轻量级锁的过程中可能需要不断自旋操作,这也是有代价的,所以也不可能一直自旋下去
在JDK 1.6
中默认的自旋次数是10次,我们可以通过-XX:PreBlockSpin
参数来调整自旋次数。同时开发者在JDK 1.6
中还对自旋锁做了优化,引入了自适应自旋锁,自适应自旋锁的自旋次数不是固定的,而是根据前一次在同一个锁上的自旋次数及锁持有者的状态来决定的。如果在同一个锁对象上,通过自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么JVM
会认为此次自旋也有很大的机会获得锁,因此会将这个线程的自旋时间相对延长。反之,如果在一个锁对象中,通过自旋锁获得锁很少成功,那么JVM
会缩短自旋次数
重量级锁
轻量级锁能够通过一定次数的重试让没有获得锁的线程有可能抢占到锁资源,但是轻量级锁只有在获得锁的线程持有锁的时间较短的情况下才能起到提升同步锁性能的效果,如果没抢占到锁资源的线程通过一定次数的自旋后,发现仍然没有获得锁,就只能阻塞等待了,所以最终会升级到重量级锁,通过系统层面的互斥量来抢占锁资源
CAS原理
前面介绍 synchronized
的 轻量级锁就是通过 cas
实现的,cas
是一种无锁算法,它是通过 CPU 指令实现的,cas
指令是一条 CPU 指令,它包含三个操作数,分别是内存地址、预期的值、更新的值,当且仅当内存地址的值与预期的值相同时,将该值更新为更新值,否则不做任何操作。