@TOC

一. 锁策略

1. 乐观锁和悲观锁

乐观锁: 对于同一个数据的并发操作, 乐观锁不认为数据发生修改, 并不会对数据进行加锁操作, 只有当提交数据更新的时候才会对数据是否产生并发冲突而进行检测.

悲观锁: 在并发同步的角度, 悲观锁认为对于同一个数据的并发操作, 它会认为数据是一定会发生修改, 哪怕没有被修改, 也会认为修改.

通过上面的理解, 可以看出乐观锁适合用于读操作, 悲观锁适合用于写操作. 乐观锁常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

2. 读写锁

当对数据进行读取操作时不会存在线程安全问题, 然而对读操作加锁会造成严重的性能问题.

线程对数据的访问分为两种方式: 读数据和写数据

  • 两个线程对同个数据进行读操作, 不需要加锁
  • 一个线程对同一个数据进行读操作, 另一个线程对数据进行写操作, 存在线程安全问题
  • 两个线程对同一个数据进行写操作, 存在线程安全问题

读写锁把读和写分开操作, 它适合频繁读, 少量写的场景

3. 重量级锁和轻量级锁

悲观锁一般都是重量级锁 乐观锁一般都是轻量级锁

如果锁是基于内核的一些功能来实现的(调用了操作系统提供的mutex接口), 此时一般认为是重量级锁

如果锁是用户态实现的, 一般认为是轻量级锁

4. 公平锁和非公平锁

公平锁: 多个线程按照申请锁的顺序来获取锁. 所有线程都能得到资源. 吞吐量下降, CPU唤醒阻塞线程开销比较大

非公平锁: 多个线程获取锁的顺序并不是按照申请锁的顺序. 吞吐量比公平锁大, 可以减少CPU唤醒线程的开销. 但是所有的线程都是抢占执行, 会导致在队列中的某些线程一直获取不到锁导致饿死的现象.

5. 可重入锁和不可重入锁

可重入锁表示一个线程已经获取到了某个锁, 当再次获取这个锁时不会出现死锁或阻塞状态. 在这个锁里面有一个计数器, 这个计数器记录了当前这个线程获取了几次某个锁, 每次加锁,计数器加一, 解锁时计数器则减一, 直到为0时, 才会释放该锁.

Java提供了两个可重入锁

  • synchronized
  • ReentrantLock

6. 自旋锁

自旋锁: 当一个线程获取锁失败时, 会循环获取锁直到获取为止, 一旦其他线程释放锁, 该线程会立即获取到该锁 如果其他线程很长时间没有释放锁, 会消耗CPU资源

挂起等待锁: 获取不到锁, 会阻塞等待, 操作系统调度时阻塞结束. 不会浪费CPU资源

二. CAS(compare and swag)

CAS就是将期望值和一个内存地址的值进行比较. 如果相等, 就将新值赋给内存地址上

CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。

下面用一个伪代码来理解, 并不代表CAS实现

boolean CAS(V, A, B) {
	// 当内存地址上的值和预期值相等
	if (V== A) {
		V= B;
		return true;
	}
	return false;
}

上述伪代码并不是原子的, CPU提供了一个单独的CAS指令, 通过这个指令, 可以完成上述伪代码的执行过程, 所以是原子的

import java.util.concurrent.atomic.AtomicInteger;
public class AtomicTest {
    public static void main(String[] args) throws InterruptedException {
    	// AtomicInteger 是一个原子类
        AtomicInteger integer = new AtomicInteger(0);

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                integer.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                integer.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(integer);
    }
}

多线程--锁策略 CAS Synchronized锁优化_数据

可以发现通过原子类线程是安全的.

cas是怎么操作的呢?

下面是两个线程t1和t2, 我们对于某种情况进行分析, 首先分别读取共享变量num的值为0

多线程--锁策略 CAS Synchronized锁优化_乐观锁_02

t1线程对num数据开始CAS操作, 对比线程和主内存中的值, 如果相等就加一, 然后交换两个内存的值.

此时主内存的值为1, 然后t2线程开始CAS操作, 对比线程和主内存中的值, 发现不相等, 开始循环读取内存中的最新值.

多线程--锁策略 CAS Synchronized锁优化_乐观锁_03

在线程t1没有修改值操作时, 当线程t2读取到主内存的值, 发现相等, 则对t2线程的值加一, 并且交换到主内存中.

多线程--锁策略 CAS Synchronized锁优化_加锁_04

我们可以看到只有t2的CAS操作执行完毕才开始继续执行t2的load操作, 如果CAS长时间不成功, 可能会给CAS带来很大的开销

ABA问题

CAS在操作值的时候会检查值有没有发送变化, 如果没有发送变化则更新, 但是如果一个值原来是A, 之后变成了B, 最后又变成了A, 如果使用CAS进行检查时发现并没有发生变化, 实际上已经变化了.

解决ABA问题的一个思路就是引入版本号, 每次修改变量时, 都将版本号加一, 由原来的 A - B - A 修改为 1A - 2B - 3C

三. Synchronized锁优化

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。锁可以升级但不能降级

偏向锁

偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.在没有实际竞争的情况下, 并且使用锁的线程只有一个, 维护轻量锁都是浪费的, 而偏向锁的目标就是: 减少没有竞争且只有一个线程使用锁的情况下, 使用轻量级锁产生的性能消耗. 当其他线程申请锁时, 偏向锁很快膨胀为轻量级锁.

其他优化操作

锁消除 编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除

我们知道StringBuffer会进行加锁操作, 但是在下面程序中, 并没有在多线程下, 所以加锁和解锁操作是没有必要的

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

锁粗化

如果对某个对象或者数据频繁加锁和解锁操作就会造成性能下降.

多线程--锁策略 CAS Synchronized锁优化_数据_05

使用细粒度锁, 期望释放锁的时候其他线程能使用锁, 如果并没有其他线程来抢占这个锁,这种情况JVM就会自动把锁粗化, 避免频繁申请释放锁.