【Java SE】多线程(四):认识锁策略、CAS机制与JUC

文章目录

  • 一、常见锁策略
    • [1. 乐观锁 vs 悲观锁](#1. 乐观锁 vs 悲观锁)
    • [2. 轻量级锁 vs 重量级锁](#2. 轻量级锁 vs 重量级锁)
    • [3. 自旋锁 vs 挂起等待锁](#3. 自旋锁 vs 挂起等待锁)
    • [4. 普通互斥锁 vs 读写锁](#4. 普通互斥锁 vs 读写锁)
    • [5. 可重入锁 vs 不可重入锁](#5. 可重入锁 vs 不可重入锁)
    • [6. 公平锁 vs 非公平锁](#6. 公平锁 vs 非公平锁)
    • [7. synchronized锁策略详解](#7. synchronized锁策略详解)
      • (1)锁升级
      • [(2) 锁消除](#(2) 锁消除)
      • [(3) 锁粗化](#(3) 锁粗化)
  • 二、CAS
  • [三、JUC 常用核心类(java.util.concurrent)](#三、JUC 常用核心类(java.util.concurrent))
    • [1. Callable & FutureTask](#1. Callable & FutureTask)
    • [2. ReentrantLock:可重入锁](#2. ReentrantLock:可重入锁)
    • [3. Semaphore 信号量](#3. Semaphore 信号量)
    • [4. CountDownLatch](#4. CountDownLatch)
  • 四、线程安全集合类
    • [1. List 集合](#1. List 集合)
    • [2. 阻塞队列](#2. 阻塞队列)
    • [3. Map 集合](#3. Map 集合)

一、常见锁策略

锁是解决多线程并发安全的核心,主流锁策略适用于各类编程语言,Java中synchronizedReentrantLock均基于这些思想实现。

1. 乐观锁 vs 悲观锁

  • 乐观锁 :默认冲突概率低,不加锁直接访问,仅在数据提交时检测冲突,冲突则失败重试,适合低并发场景。
  • 悲观锁 :默认并发冲突概率高,访问数据前先加锁,其他线程阻塞等待,适合锁竞争激烈的场景。

JDK1.8的synchronized初始为乐观锁,锁竞争频繁时自动转为悲观锁。

2. 轻量级锁 vs 重量级锁

二者区别在于是否依赖操作系统内核互斥锁(mutex):

  • 轻量级锁 :适用于乐观的场景下。尽量在用户态完成操作,减少用户态/内核态切换,开销更小,更高效。
  • 重量级锁:适用悲观的场景。重度依赖OS的mutex,开销更大,更低效

synchronized无竞争时为轻量级锁,竞争加剧后膨胀为重量级锁。

3. 自旋锁 vs 挂起等待锁

  • 自旋锁轻量级锁 的典型实现。获取锁失败后不阻塞,循环重试抢锁(也就是忙等)。因为在乐观场景下,出现锁竞争的概率比较小,即使出现锁竞争,短时间内很快就能拿到锁。
    • 优点:锁释放后可立即获取,响应快;
    • 缺点:锁持有时间久会持续消耗CPU。
  • 挂起等待锁重量级锁的典型实现。抢锁失败后线程阻塞、释放CPU,由操作系统唤醒,无CPU消耗,但唤醒延迟高。

synchronized轻量级锁底层采用自适应自旋锁。

4. 普通互斥锁 vs 读写锁

1. 普通互斥锁:synchronized这样,只涉及加锁和解锁两种操作

2. 读写锁: 分为读锁和写锁。

多个线程多一个数据,是线程安全的;多个线程读取,一个或多个线程修改数据,就会涉及到线程安全问题。在大部分读操作,少部分写操作的场景下,使用synchronized就会有严重的锁冲突。

而读写锁确保读锁和读锁之间不互斥,读锁与写锁,写锁与写锁之间互斥

Java提供了读锁ReentrantReadWriteLock.ReadLock和写锁ReentrantReadWriteLock.WriteLock,都属于ReentrantReadWriteLock的内部类;synchronized并非读写锁。

5. 可重入锁 vs 不可重入锁

  • 可重入锁 :同一线程可多次获取同一把锁 ,不会造成死锁,synchronizedReentrantLock均为可重入锁,通过线程标识+计数器实现。
  • 不可重入锁:同一线程重复加锁会阻塞自身,引发死锁,Linux原生mutex就是不可重入锁。

可重入锁需要记录当前是哪个线程拿到了锁,并通过计数器记录当前加锁了多少次,在合适的时候进行解锁。

6. 公平锁 vs 非公平锁

  • 公平锁 :遵循先来后到,线程按等待顺序获取锁,需额外结构记录排队顺序,性能略低。
  • 非公平锁:不保证顺序,新线程可直接抢占锁,减少线程切换,默认锁均为非公平锁。

synchronized是非公平锁;ReentrantLock可手动开启公平模式。

7. synchronized锁策略详解

(1)锁升级

  1. 无锁:没有synchronized修饰的代码块处于无锁的状态
  2. 偏向锁:刚进入synchronized代码块内不会立即加锁,而是做一个简单的标记。如果没有其他线程来竞争这个锁,最终当前线程执行完毕解锁代码也只是简单的清楚标记。这个标记非常轻量,相比加锁解锁效率高很多。
  3. 轻量级锁:拿到偏向锁的线程运行过程中遇到了其他线程尝试抢这把锁,就会升级为轻量级锁。
  4. 重量级锁:JVM发现这把锁竞争非常激烈,就会升级成重量级锁。

(2) 锁消除

编译器会判定当前的代码逻辑是否真的需要锁。如果确实不需要这把锁,就会会把synchronized去掉。

(3) 锁粗化

锁的粒度:加锁和解锁之间,包含代码越多,就认为锁的粒度越粗;这里代码多不仅指行数,还要考虑实际执行的指令和时间

如果一块代码反复对细粒度的代码进行加锁,每一次解锁后再加锁都会涉及到竞争,编译器就可能优化成粗粒度的加锁,从而提高效率。


二、CAS

CAS就是指比较和交换(Compare and Swap),是CPU的一条指令。

1. 工作原理

伪代码:

cpp 复制代码
boolean CAS(address,expectValue,swapValue){
	if(address == expectValue){
			&address = swapValue;
			return true;
	}
	return false;
}

address表示内存地址,expectValue表示寄存器1的值,swapValue表示寄存器2的值。

  1. 比较内存值与寄存器1是否相等;
  2. 相等则把内存中的值和寄存器2进行交换。

一般情况只关心交换后内存中的值,不关心寄存器2的值,此处可以把交换的操作理解成赋值。本质上还是交换,基于交换实现了赋值。

通过上面的分析来看,虽然逻辑略显复杂,但是CAS是CPU的一条指令,而CPU是以指令为单位工作,也就是说CAS具有原子性的特性

2. 典型应用

CAS封装:CPU=>操作系统=>JVM=>Java 标准库=>Java程序员

原子类

java.util.concurrent.atomic包下所有类都是原子类,基于CAS实现无锁原子操作,避免加锁操作,性能更高。

  • AtomicInteger:原子类int
常用方法 功能说明
get() 获取当前值
set(int val) 直接设置值
incrementAndGet() 自增1,返回新值,类似++i
getAndIncrement() 自增1,返回旧值 ,类似i++
decrementAndGet() 自减1,返回新值
addAndGet(int delta) 加上增量,返回新值
  • AtomicBoolean:原子类boolean
常用方法 功能说明
AtomicBoolean get()、set(boolean val)
compareAndSet(boolean expect, boolean update) CAS更新布尔
getAndIncrement工作原理

伪代码:

java 复制代码
class MyAtomicInteger{
    private int value;
    public int getAndIncrement(){
        int oldValue = value;//理解成寄存器,把内存存到寄存器中
        while(!CAS(value,oldValue,oldValue+1)){
            oldValue = value;
        }
        return oldValue; 
    }
}
  1. 使用oldValue记录value,相当于把内存中的值存到寄存器中
  2. 调用CAS,如果value和oldValue相等,则让oldValue+1赋值给value,此时CAS返回true,循环条件不成立,就结束运行
  3. 调用CAS,如果value和oldValue不相等,CAS返回false,进入循环,更新寄存器中的结果。

    即使上述代码存在线程切换,进行自增之前,先判断当前寄存器中的值是否准确,不过不正确,就会重新读取。

自旋锁

伪代码:

java 复制代码
public class SpinLock{
  //如果是null 说明锁是空闲的
  //如果非null,说明已经被其他线程占用
	private Thread owner = null;
	
	public void lock{
	  //通过CAS判断当前这锁是否被某个线程持有
	  //如果被占有就自旋等待
	  //如果没有就尝试把owner设为当前尝试加锁的线程
		while(CAS(this.owner,null,Thread.currentThread())){\
		}
	}
	public void unlock(){
		//单个的赋值操作就是原子的
		this.owner = null;
	}
}

由于lock循环体是空的,整个循环过程非常快(也就是忙等),一旦其他线程释放了锁,就可以立即获取到锁

3. ABA问题

  • 问题:变量从A→B→A,CAS无法识别数据被修改过,导致逻辑异常。
  • 解决方案 :引入版本号 ,每次修改数据版本号自增,CAS同时校验数据值+版本号;Java中使用AtomicStampedReference实现带版本的CAS。

三、JUC 常用核心类(java.util.concurrent)

JUC包提供了大量并发工具,弥补synchronized灵活性不足的问题,是多线程开发主力。

1. Callable & FutureTask

CallableRunnable类似,都可以描述线程执行的任务。

  • Runnable没有返回值,不能抛出异常;
  • Callable有泛型返回值,可以抛出异常,用于线程执行任务后获取结果。

Callable需要搭配FutureTask使用,FutureTask负责包装Callable任务,交给Thread执行,Callable的返回结果交给FuturnTask,可以通过get()方法获取。

get()就是获取到FutureTask的返回值,来自Callablecall方法。如果线程执行完毕就可以拿到结果,否则get()就会阻塞。

  • 示例代码:
java 复制代码
public class demo33 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for(int i = 0;i<2000;i++){
                    result += 1;
                }
                return result;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<Integer>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        System.out.println(futureTask.get());
    }
}

创建线程的写法汇总:

  1. 继承Thread类:定义单独的类或匿名内部类
  2. 实现Runnable:定义单独的类或匿名内部类
  3. lambda表达式
  4. 实现Callable:定义单独的类或匿名内部类
  5. 线程池,ThreadFactory

2. ReentrantLock:可重入锁

synchronized功能类似,属于显式锁,灵活性更强,二者对比:

  1. synchronized是关键字、JVM隐式实现,自动释放锁;
  2. ReentrantLock是Java类,需手动调用lock()unlock()
  3. synchronized抢锁失败会永久等待;ReentrantLock支持tryLock()超时放弃。
  4. ReentrantLock默认是非公平锁,可实现公平锁
  5. ReentrantLock支持Condition精准唤醒指定线程,相比wait/notify 功能更强大。

使用规范:lock()放在try外,unlock()放在finally中,保证锁一定释放。

3. Semaphore 信号量

本质是计数器,表示"可用资源的个数",用来控制并发访问资源的线程数量,实现限流、资源池场景。

可以把信号量理解成停车场的指示牌,当驶入车辆,可用空位就会-1,驶出车辆,可用空位+1

  • P操作:申请资源,可用资源为空时阻塞等待
  • V操作:释放资源

PV操作都是原子的,可以在多线程环境下直接使用

特殊情况:信号量初始值为1,取值要么是1,要么是0,称为二元信号量,等价于锁。

4. CountDownLatch

多线程可以把一个大任务拆成多个子任务,CountDownLatch可以等待一组线程全部执行完毕后再继续主线程,类似"倒计时"。

  1. 构造方法中指定参数,描述拆成了多少个任务
  2. 每个任务执行完毕之后,调用一次countDown方法,计数器-1
  3. 主线程中调用await(),等待所有任务执行完毕.
  • 代码示例
java 复制代码
public class demo36 {
    public static void main(String[] args) throws InterruptedException {
        //十个子任务
        CountDownLatch countDownLatch = new CountDownLatch(10);
        ExecutorService executorService = Executors.newFixedThreadPool(4);
        for(int i = 0;i<10;i++){
            int id = i;
            executorService.submit(()->{
                System.out.println("子任务开始执行:"+id);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("子任务结束执行:"+id);
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        System.out.println("所有任务执行完毕");
        executorService.shutdown();
    }
}

四、线程安全集合类

传统集合(ArrayListHashMap)线程不安全,多线程环境需使用专用安全集合:

1. List 集合

  1. Vector/Stack:古老线程安全类,全局加锁,性能差。
  2. Collections.synchronizedList:基于synchronized封装,全方法加锁,性能略差。
  3. CopyOnWriteArrayList写时复制 ,读不加锁、写时复制新容器,适合读多写少场景;
    • 缺点是内存占用高、数据弱一致性;不适合多线程修改的情况

大多数情况还是需要手动上锁,自行判断哪些操作需要确保原子性。

2. 阻塞队列

常用于线程池、生产者-消费者模型:ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue

3. Map 集合

  1. Hashtable:对全局对象加锁,也就是说任意两个线程访问不同的元素,都要竞争。所有方法串行执行,并发效率极低,key/value不允许为null。
  2. ConcurrentHashMap :主流线程安全哈希表,优化点:
    • 锁哈希桶(也即是链表的头节点),大幅降低锁竞争;
    • 读操作无锁,利用volatile保证可见性;写操作加synchronized,扩容多线程协作完成,效率极高;key不允许为null。
    • 对于哈希表的长度size,使用原子类维护。