# 一、synchronized
synchronized是Java中的关键字, 是一种同步锁。它修饰的对象有以下几种:
1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
```java
class SyncLock implements Runnable{
@Override
public void run() {
synchronized (this){
for (int i = 0; i < 5 ; i + + ) {
try {
System.out.println("线程名:"+Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
```
synchronized (this)锁定的是当前对象, 如果new一个其他对象的话, 会生成新锁。
2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
```java
public synchronized void run() {
{
for (int i = 0; i < 5 ; i + + ) {
try {
System.out.println("线程名:"+Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
```
在用synchronized修饰方法时要注意以下几点:
+ synchronized关键字不能继承
+ 在定义接口方法时不能使用synchronized关键字
+ 构造方法不能使用synchronized关键字, 但可以使用synchronized代码块来进行同步
3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
静态方法是属于类的而不属于对象的。同样的, synchronized修饰的静态方法锁定的是这个类的所有对象。
```java
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public synchronized static void method() {
for (int i = 0; i < 5 ; i + + ) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void run() {
method();
}
}
```
假如创建了2个SyncThread对象, 但是由于有静态方法锁且锁属于这个类, 无论创建多少对象, run()方法的锁依然生效。
4. 修饰一个类, 其作用的范围是synchronized后面括号括起来的部分, 作用主的对象是这个类的所有对象。
```java
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public static void method() {
synchronized(SyncThread.class) {
for (int i = 0; i < 5 ; i + + ) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public synchronized void run() {
method();
}
}
```
效果与修饰静态方法相同
# 二、volatile
不能用于自增操作, 例如count ++
作用如下:
1. 保证可见性,不保证原子性
当写一个volatile变量时, JMM会把该线程本地内存中的变量强制刷新到主内存中去;
这个写会操作会导致其他线程中的volatile变量缓存无效。
2. 禁止指令重排
重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果
假设线程A先执行writer方法, 线程B随后执行reader方法, 初始时线程的本地内存中flag和a都是初始状态, 下图是线程A执行volatile写后的状态图。
![[Snipaste_2023-02-28_13-09-43 2.png]]
当volatile变量写后, 线程中本地内存中共享变量就会置为失效的状态, 因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图。
![[Snipaste_2023-02-28_13-09-43 3.png]]
从横向来看, 线程A和线程B之间进行了一次通信, 线程A在写volatile变量时, 实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了, 然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了, 那线程B该怎么办了? 自然而然就只能去主内存去取啦。
# 三、ReentrantLock
```java
public class ReentrantLockDemo {
// 实例化一个非公平锁, 构造方法的参数为true表示公平锁, false为非公平锁。
private final ReentrantLock lock = new ReentrantLock(false);
private int i;
public void testLock() {
// 拿锁,如果拿不到会一直等待
lock.lock();
try {
// 再次尝试拿锁(可重入), 拿锁最多等待100毫秒
if (lock.tryLock(100, TimeUnit.MILLISECONDS))
i++;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
lock.unlock();
lock.unlock();
}
}
}
```
代码中创建了非公平锁, lock.lock()会进行拿锁操作, 如果拿不到锁则会一直等待。如果拿到锁之后则会执行try代码块中的代码。接下来在try代码块中又通过tryLock(100, TimeUnit.MILLISECONDS)方法尝试再次拿锁, 此时, 拿锁最多会等待100毫秒, 如果在100毫秒内能获得锁, 则tryLock方法返回true, 拿锁成功, 执行i++操作, 如果返回false, 获取锁失败, i++不会被执行。( 因为第一次线程已经拿到锁了, 由于ReentrantLock是可重入, 因此, 第二次必定能拿到锁。此处仅仅为了演示ReetranLock的使用, 不必纠结) 。
另外, 要注意被ReentrantLock加锁区域必须用try代码块包裹, 且释放锁需要在finally中来避免死锁。执行几次加锁, 就需要几次释放锁。
## 3.1 公平与非公平
**公平锁**是指多个线程按照申请锁的顺序来获取锁,线程直接进入同步队列中排队,队列中最先到的线程先获得锁。**非公平锁**是多个线程加锁时每个线程都会先去尝试获取锁,如果刚好获取到锁,那么线程无需等待,直接执行,如果获取不到锁才会被加入同步队列的队尾等待执行。
公平锁的优点在于各个线程公平平等, 每个线程等待一段时间后, 都有执行的机会, 而它的缺点相较于于非公平锁整体执行速度更慢, 吞吐量更低。同步队列中除第一个线程以外的所有线程都会阻塞, CPU唤醒阻塞线程的开销比非公平锁大。
而非公平锁非公平锁的优点是可以减少唤起线程的开销, 整体的吞吐效率高, 因为线程有几率不阻塞直接获得锁, CPU不必唤醒所有线程。它的缺点呢也比较明显, 即队列中等待的线程可能一直或者长时间获取不到锁。
## 3.2 可重入与非可重入
可重入锁不会阻塞
**可重入锁**又名递归锁, 是指同一个线程在获取外层同步方法锁的时候, 再进入该线程的内层同步方法会自动获取锁( 前提锁对象得是同一个对象或者class) , 不会因为之前已经获取过还没释放而阻塞。**非可重入锁**与可重入锁是对立的关系,即一个线程在获取到外层同步方法锁后,再进入该方法的内层同步方法无法获取到锁,即使锁是同一个对象。
synchronized与本篇讲的ReentrantLock都属于可重入锁。可重入锁可以有效避免死锁的产生。
# 四、CAS与原子类
原子操作类, 指的是java.util.concurrent.atomic包下, 一系列以Atomic开头的包装类。例如`AtomicBoolean`, `AtomicInteger`, `AtomicLong`。它们分别用于`Boolean`, `Integer`, `Long`类型的原子性操作。它们都是基于CAS实现。
## 4.1 CAS机制
CAS是英文单词Compare And Swap的缩写, 翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数: 内存地址V, 旧的预期值A, 要修改的新值B。
更新一个变量的时候, 只有当变量的预期值A和内存地址V当中的实际值相同时, 才会将内存地址V对应的值修改为B。
用伪代码解释就是:
if(V=A){ ----- 比较相等
swap() ----- 替换旧值, 此时V=B, A=B, 新的B需要到下一次修改
}else {
retry() ----- 重试(自旋)
}
在 Java 中, Java 并没有直接实现 CAS, CAS 相关的实现是通过 C++ 内联汇编的形式实现的。Java 代码需通过 JNI 才能调用。CAS 是一条 CPU 的原子指令( cmpxchg指令) , 不会造成所谓的数据不一致问题, Unsafe 提供的 CAS 方法( 如compareAndSwapXXX) 底层实现即为 CPU 指令 cmpxchg。
![[Snipaste_2023-02-28_13-09-43 5.png]]
```cpp
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx,
dest mov ecx,
exchange_value mov eax,
compare_value LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
```
如上面源代码所示, 程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行, 就为cmpxchg指令加上lock前缀( Lock Cmpxchg) 。反之, 如果程序是在单处理器上运行, 就省略lock前缀( 单处理器自身会维护单处理器内的顺序一致性, 不需要lock前缀提供的内存屏障效果) 。
## 4.2 示例
1. 在内存地址V中, 存储着值为10的变量
![[5630287-350bc3c474eef0e8.jpg]]
2. 此时线程1想要把变量的值增加1。对线程1来说, 旧的预期值A=10, 要修改的新值B=11。
![[5630287-350bc3c474eef0e8 1.jpg]]
3. 在线程1要提交更新之前, 另一个线程2抢先一步, 把内存地址V中的变量值率先更新成了11。
![[5630287-350bc3c474eef0e8 2.jpg]]
4. 线程1开始提交更新, 首先进行A和地址V的实际值比较( Compare) , 发现A不等于V的实际值, 提交失败。
![[5630287-350bc3c474eef0e8 3.jpg]]
5. 线程1重新获取内存地址V的当前值, 并重新计算想要修改的新值。此时对线程1来说, A=11, B=12。这个重新尝试的过程被称为自旋。
![[5630287-f638cadea7b6cb96.webp]]
6. 这一次比较幸运, 没有其他线程改变地址V的值。线程1进行Compare, 发现A和地址V的实际值是相等的
![[5630287-0a3d0b3926366d63.jpg]]
7. 线程1进行SWAP, 把地址V的值替换为B, 也就是12。
![[5630287-0a3d0b3926366d63 1.jpg]]
## 4.3 优缺点
#### 优点:
- 可以保证变量操作的原子性;
- 并发量不是很高的情况下, 使用CAS机制比使用锁机制效率更高;
- 在线程对共享资源占用时间较短的情况下, 使用CAS机制效率也会较高。
#### 缺点:
1. CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新(自旋锁
) 某一个变量, 却又一直更新不成功, 循环往复, 会给CPU带来很大的压力。
2. 不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作, 而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新, 就不得不使用Synchronized了。
3. ABA问题
CAS在操作的时候会检查变量的值是否被更改过, 如果没有则更新值, 但是带来一个问题, 最开始的值是A, 接着变成B, 最后又变成了A。经过检查这个值确实没有修改过, 因为最后的值还是A, 但是实际上这个值确实已经被修改过了。为了解决这个问题, 在每次进行操作的时候加上一个版本号, 每次操作的就是两个值, 一个版本号和某个值, A——>B——>A问题就变成了1A——>2B——>3A。在jdk中提供了AtomicStampedReference类解决ABA问题, 用Pair这个内部类实现, 包含两个属性, 分别代表版本号和引用, 在compareAndSet中先对当前引用进行检查, 再对版本号标志进行检查, 只有全部相等才更新值。
# 五、AQS
CAS中的自旋锁有2个缺点:
+ 第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
+ 第二是性能问题。在实际的多核上运行的自旋锁在锁竞争激烈时性能较差。
TASLock 和 TTASLock 与上文代码类似,都是针对**一个原子状态变量**轮询的自旋锁实现,显然,自旋锁的性能和理想情况相距甚远。这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率。
为了解决这2个问题就有了CLH锁, CLH是AQS的核心。
![[721070-20170504110246211-10684485.png]]
CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,**让每个线程在不同的状态变量中自旋**,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。
CLH 锁数据结构很简单, 类似一个链表队列, 所有请求获取锁的线程会排列在链表队列中, 自旋访问队列中前一个节点的状态。当一个节点释放锁时, 只有它的后一个节点才可以得到锁。CLH 锁本身有一个队尾指针 Tail, 它是一个原子变量, 指向队列最末端的 CLH 节点。每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。当一个线程要获取锁时,它会对 Tail 进行一个 getAndSet 的原子操作。该操作会返回 Tail 当前指向的节点,也就是当前队尾节点,然后使 Tail 指向这个线程对应的 CLH 节点,成为新的队尾节点。入队成功后,该线程会轮询上一个队尾节点的状态变量,当上一个节点释放锁后,它将得到这个锁。
AQS定义两种资源共享方式: Exclusive( 独占, 只有一个线程能执行, 如ReentrantLock) 和Share( 共享, 多个线程可同时执行, 如Semaphore/CountDownLatch) 。
不同的自定义同步器争用共享资源的方式也不同。**自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可**,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等) , AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
- isHeldExclusively(): 该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int): 独占方式。尝试获取资源, 成功则返回true, 失败则返回false。
- tryRelease(int): 独占方式。尝试释放资源, 成功则返回true, 失败则返回false。
- tryAcquireShared(int): 共享方式。尝试获取资源。负数表示失败; 0表示成功, 但没有剩余可用资源; 正数表示成功, 且有剩余资源。
- tryReleaseShared(int): 共享方式。尝试释放资源, 如果释放后允许唤醒后续等待结点返回true, 否则返回false。
**以ReentrantLock为例, state( 同步状态) 初始化为0, 表示未锁定状态。A线程lock()时, 会调用tryAcquire()独占该锁并将state+1。此后, 其他线程再tryAcquire()时就会失败, 直到A线程unlock()到state=0( 即释放锁) 为止, 其它线程才有机会获取该锁。当然, 释放锁之前, A线程自己是可以重复获取此锁的( state会累加) , 这就是可重入的概念。但要注意, 获取多少次就要释放多么次, 这样才能保证state是能回到零态的。
再以CountDownLatch以例, 任务分为N个子线程去执行, state也初始化为N( 注意N要与线程个数一致) 。这N个子线程是并行执行的, 每个子线程执行完后countDown()一次, state会CAS减1。等到所有子线程都执行完后(即state=0), 会unpark()主调用线程, 然后主调用线程就会从await()函数返回,继续后余动作。
## 5.1 CLH的数据结构
CLH中的节点表示待获取锁的对象, 里面包含6个方法及属性
| 方法和属性值 | 含义 |
| ------------ | ------------ |
| waitStatus | 当前节点在队列中的状态 |
| thread | 表示处于该节点的线程 |
| prev | 前驱指针 |
| predecessor | 返回前驱节点, 没有的话抛出npe|
| nextWaiter | 指向下一个处于CONDITION状态的节点|
| next | 后继指针 |
waitStatus有下面几个枚举值:
| 枚举 | 含义 |
| --------- | ---------------------------------------------- |
| 0 | 当一个Node被初始化的时候的默认值 |
| CANCELLED | 为1, 表示线程获取锁的请求已经取消了 |
| CONDITION | 为-2, 表示节点在等待队列中, 节点线程等待唤醒 |
| PROPAGATE | 为-3, 当前线程处在SHARED情况下, 该字段才会使用 |
| SIGNAL | 为-1, 表示线程已经准备好了, 就等资源释放了 |
## 5.2 同步状态State
AQS中维护了一个名为state的字段, 意为同步状态, 是由Volatile修饰的, 用于展示当前临界资源的获锁情况。
| 方法名 | 描述 |
| ------------------------------------------------------------------ | -------------------- |
| protected final int getState() | 获取State的值 |
| protected final void setState(int newState) | 设置State的值 |
| protected final boolean compareAndSetState(int expect, int update) | 使用CAS方式更新State |
可以通过修改State字段表示的同步状态来实现多线程的独占模式和共享模式( 加锁过程) 。
### 5.2.1 独占模式
![[27605d483e8935da683a93be015713f331378.png]]
以ReentrantLock为例, state( 同步状态) 初始化为0, 表示未锁定状态。A线程lock()时, 会调用tryAcquire()独占该锁并将state+1。此后, 其他线程再tryAcquire()时就会失败, 直到A线程unlock()到state=0( 即释放锁) 为止, 其它线程才有机会获取该锁。当然, 释放锁之前, A线程自己是可以重复获取此锁的( state会累加) , 这就是可重入的概念。但要注意, 获取多少次就要释放多么次, 这样才能保证state是能回到零态的。
举个例子:排队打饭,只有一个窗口(state), 第一个站住窗口( lock(), tryAcquire(), state+1) , 后一个人需要等待前人离开( unlock, state=0)
#### 线程加入等待队列
当其他线程tryAcquire()失败时, 会调用addWaiter加入到等待队列中去。总的来说, 一个线程获取锁失败了, 被放入等待队列, acquireQueued会把放入队列中的线程不断去获取锁, 直到获取成功或者不再需要获取( 中断) 。
### 5.2.2 共享模式
![[27605d483e8935da683a93be015713f331378 1.png]]
以CountDownLatch以例, 任务分为N个子线程去执行, state也初始化为N( 注意N要与线程个数一致) 。这N个子线程是并行执行的, 每个子线程执行完后countDown()一次, state会CAS减1。等到所有子线程都执行完后(即state=0), 会unpark()主调用线程, 然后主调用线程就会从await()函数返回,继续后余动作。