常见的锁策略

乐观锁和悲观锁
这不是两把具体的锁, 这是两类锁

乐观和悲观说的都不是绝对的, 唯一的区分就是看预测锁竞争激烈程度的结论, 这两种锁的背后工作是截然不同的,

轻量级锁和重量级锁

在大多数情况下,

自旋锁和挂起等待锁

自旋锁: 一旦锁释放了,自旋锁能第一时间感知到, 从而有机会获取到锁, 但是会占用大量的系统资源
挂起等待锁: 获取锁的实际可能会迟, 但是它把CPU省下来了

互斥锁和读写锁

这里的读加锁, 写加锁, 基于一个事实:多线程针对同一个变量并发读,这个是没有线程安全的, 也不需要加锁控制, 读写锁就是针对这种情况锁采取的特殊处理

当前代码中, 如果只是读操作, 加读锁就可以了, 如果有写操作就加写锁.
假设当前有一组线程都去读(加读锁), 这些线程是没有锁竞争的, 也没有安全问题, 又快又准.
假设当前有一组线程既有读又有写, 这才会产生锁竞争, 其实在很多实际开发中, 读操作非常高频, 比写操作多很多. 这样用写锁, 就会提高效率.

公平锁锁和非公平锁

那么什么是公平呢?
此处把公平定义为"先来后到"

操作系统和Java中 synchronized 原生都是"非公平锁"
操作系统这里针对加锁的控制, 本身就是依赖线程调度顺序的, 这个调度是随机的, 不会考虑这个线程等了多长时间.
要实现公平锁, 就得在这个基础上, 引入一些额外的东西(引入一个队列, 让这些加锁的线程去排队)

可冲入锁和不可重入锁

synchronized

  1. synchronized 既是一个悲观锁, 也是一个乐观锁
    synchronized 默认是一个乐观锁, 但是如果发现当前锁竞争比较激烈, 就会变成悲观锁

  2. synchronized 既是一个轻量级锁, 也是一个重量级锁
    synchronized默认是一个轻量级锁, 如果发现锁竞争比较激烈, 就会转换成重量级锁

  3. synchronized 这里的轻量级锁是基于自旋锁的方式实现的
    synchronized 这里的重量级锁是基于挂起等待锁的方式实现的

  4. synchronized 不是读写锁

  5. synchronized 是非公平锁

  6. synchronized 是可冲入锁

总结: 上面说的6种锁策略, 可以视为"锁的形容词"

CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“

那么上述的CAS过程, 就是这样一个简单的交换, 有什么特别之处呢?

这样的话, 咱们得线程安全问题除了加锁外, 就又有了一个新的方向.

总结:CAS 可以理解是 CPU 给咱们提供的一个特殊指令, 通过这个特殊指令, 就可以一定程度的处理线程安全问题.

**CAS 的应用场景 **

  1. 实现原子类
    Java 标准库中提供的类
		//定义了一个原子的变量count
        AtomicInteger count = new AtomicInteger(0);

下面举一个例子:
通过这个原子的变量, 来实现两个线程对同一个变量的++操作

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo12 {
    public static void main(String[] args) throws InterruptedException {
        // 这些原子类就是基于 CAS 实现了自增自减等操作, 不用加锁也是线程安全的.
        AtomicInteger count = new AtomicInteger(0);

        // 使用原子类来解决线程安全问题
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // 因为 Java 不支持运算符重载, 所以只能通过普通方法来表示自增自减
                count.getAndIncrement();   // count++
                //count.incrementAndGet();   // ++count
                //count.getAndDecrement();   // count--
                //count.decrementAndGet();   // --count
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();   // count++
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

CAS的典型问题: ABA问题

CAS 在运行中的核心, 检查 value 和 oldValue 的值, 如果一致, 就是为没有被修改过, 就进行下一步操作.

那么这里的一致, 可不可能是被修改过, 又还原回来了呢, 这是有可能的

那么这如何应对呢?
那就是加版本号(版本号直接增长, 不能降低)

Synchronized 原理
两个线程针对同一个线程加锁, 就会产生阻塞等待.
synchronized 内部其实还有一些优化机制, 目的就是为了让这个锁更加高效, 更好用.

  1. 锁升级/锁膨胀
  1. 无锁
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

当代码执行到这个代码块中, 加锁过程, 就可能会经历这几个阶段

进行加锁的时候, 首先会进入偏向锁 状态, (并不是真正的加锁, 而只是占个位置, 有需要再真正加锁, 没需要就算了)

这样做的好处就是:

当 synchronized 发生锁竞争的时候, 就会从偏向锁, 升级为轻量级锁, 此时, synchronized相当于是通过自旋的方式来进行加锁,

如果要是别人很快就释放锁了, 自旋是很划算的, 但是如果迟迟拿不到锁, 一直自旋, 并不划算.

锁消除

编译器智能的判定, 看当前代码是否需要真的加锁, 如果这个场景不需要加锁, 程序员加锁了, 就自动把锁给消除了.

锁粗化

锁的粒度, synchronized 包含的代码越多, 粒度就越粗, 包含的代码越少, 粒度就越细

通常情况下, 认为锁的粒度细一点比较好, 因为加锁的代码, 是不能并发执行的, 锁的粒度越细, 能并发的代码就越多; 反之就越少, 但是有的情况下, 锁的粒度粗一些, 反而更好.

如果两次加锁之间, 间隙非常小, 这种情况就不如一次大锁直接搞定

JUC(java.util.concurrent) 的常见类

Callable 接口

下面举个例子
使用 Callable 计算1+2+3+…+1000

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class ThreadDemo13 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 使用 Callable 计算1+2+3+...+1000
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        // 想当于一个小票, 使得返回给谁不会出错```
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        // get 方法就是获取结果, get会发生阻塞, 直到Callable 执行完毕, get才阻塞完毕, 才获取结果
        Integer result = futureTask.get();
        System.out.println(result);
        
    }
}

ReentrantLock

ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”
synchronized 是直接基于代码块来加锁解锁的
ReentrantLock 更传统, 使用 lock 和 unlock 方法加锁解锁

ReentrantLock reentrantLock = new ReentrantLock();
        reentrantLock.lock();

        reentrantLock.unlock();

这样就带来一个问题,
比如说, 这加锁的代码块中, 存在 return, 或者 异常, 就可能导致 unlock 执行不到

那么如何解决这个问题呢?
那就是使用

lock.lock(); 
try {  
// working  
} finally {  
lock.unlock()  
} 

那这个锁有什么优势呢?

  1. ReentrantLock 提供了公平锁版本的实现
 ReentrantLock reentrantLock = new ReentrantLock(true);
  1. 对于 synchronized 来说, 提供的加锁方式就是死等, 只要获取不到锁, 就一直阻塞等待.
    而 ReentrantLock 提供了更灵活的 等待方式.
        reentrantLock.tryLock();

这个是无参数版本, 能加上锁就加, 不能加上就放弃.

        reentrantLock.tryLock(5, TimeUnit.SECONDS);

这是有参数版本, 制定了超时时间, 加不上锁就等待一会, 如果时间到了也没等到就放弃

  1. ReentrantLock 提供了一个更强大, 更方便的等待通知机制
    synchronized 搭配的是 wait, notify , notify 的时候是随机唤醒一个wait 的线程
    ReentrantLock 搭配一个Condition 类. 进行唤醒的时候可以唤醒指定的线程

结论: 虽然 ReentrantLock 有一定的优势, 但是在实际开发中大部分还是使用 synchronized.

原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

以 AtomicInteger 举例,常见方法有

信号量 Semaphore

信号量本质是就是一个 “计数器” ,描述了可用资源的个数

p操作: 申请一个可用资源, 计数器就要 -1;
v操作: 释放一个可用资源, 计数器就要 +1;

p操作如果要是计数器为0了, 继续p操作, 就会阻塞等待

锁可以视为计数器为1的 信号量, 二元信号量, 锁是信号量的一种特殊情况

/**
 * @describe
 * @author chenhongfei
 * @version 1.0
 * @date 2023/10/21
 */
package thread;

import java.util.concurrent.Semaphore;

public class ThreadDemo15 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(3);
        semaphore.acquire();
        System.out.println("执行一次p操作");
        semaphore.acquire();
        System.out.println("执行一次p操作");
        semaphore.acquire();
        System.out.println("执行一次p操作");
        semaphore.acquire();
        System.out.println("执行一次p操作");
    }
}

最后一次p操作就阻塞等待了

代码中也是可以Semaphone 来实现类似锁的效果, 来保证线程安全

多线程使用哈希表

HashMap 是线程不安全的
HashTable 是线程安全的(给一些关键方法加了 synchronized)

更推荐使用 ConcurrentHashMap 更优化的线程安全哈希表

ConcurrentHashMap 进行了哪些优化, 比HashTable 好在哪里? 和 HashTable 的区别是啥

1. 最大的优化之处: ConcurrentHashMap 相比于 HashTable 大大缩小了锁冲突的概率. (把一把大锁, 转化为了多把小锁)
HashTable 做法是直接在方法上加 synchronized, 相当于是给 this 加锁,
只要操作哈希表上的任意元素, 都会产生加锁, 也就都可能发生锁冲突

ConcurrentHashMap 做法是, 每个链表有各自的的锁, (不是一起共用一个锁了)
具体来说, 就是使用每个链表的头结点, 作为锁对象, (两个线程针对同一个锁对象进行加锁, 才会产生锁竞争, 才会阻塞等待, 针对不同的对象, 就没有锁竞争了)(JDK1.8 及其以后版本是这样, 在JDK1.7和之前是使用"分段锁")
多线程-进阶-LMLPHP
多线程-进阶-LMLPHP

2. ConcurrentHashMap 做了一个激进的操作

读和读 之间没有冲突
写和写 之间有冲突
读和写 之间也没有冲突

这样就会出现一个问题, 很多场景下, 读写之间不加锁控制, 可能会读到一个写了一半的结果, 这里ConcurrentHashMap 做的优化是通过 volatile + 原子的写操作 来控制

3. ConcurrentHashMap 内部也充分的使用了CAS , 通过这个进一步来削减加锁的数目

4. 针对扩容, 采取了"化整为零"的方式

HashMap 和 HashTable 扩容是创建一个更大的数组空间, 把旧的数组上的链表上的每个元素搬运到新的数组上(删除+插入), 这个操作会在某次的put的时候触发
如果元素特别多, 就会导致这个搬运操作就会非常耗时, 就会出现某次put 比平时put 卡很多倍

ConcurrentHashMap 中, 采取的是每次搬运一小部分元素的方式,
创建一个新的数组, 旧的数组也保留
每次put操作, 都往新数组上添加, 同时搬运一小部分(把一部分旧的元素搬运到新的数组上)
每次get操作, 旧数组和新数组都查询
每次remove操作, 删了就行

经过一定时间后, 所有元素都搬运好了, 最终在释放旧元素

10-21 20:23