多线程进阶

简介: 本文介绍了多种锁策略及其应用。首先区分了乐观锁与悲观锁:乐观锁假定冲突较少,悲观锁则预期频繁冲突。接着讨论了自旋锁与挂起等待锁,前者适合冲突少且持有时间短的场景,后者适用于长锁持有时间。随后对比了轻量级锁与重量级锁,前者开销小、效率高,后者开销大、效率低。此外,文章还探讨了公平锁与非公平锁的区别,以及可重入锁如何避免死锁。最后介绍了读写锁,其允许多个读操作并发,但写操作独占资源。通过详细解析各种锁机制的特点及适用场景,本文为读者提供了深入理解并发控制的基础。

1. 常见的锁策略

1.1. 乐观锁和悲观锁

乐观锁

加锁的时候,假设出现锁冲突的概率不大,接下来围绕加锁要展开的工作就很少

悲观锁

加锁的时候,假设出现锁冲突的概率很大,接下来围绕加锁要展开的工作就会很多

synchronized锁在初始情况下是乐观锁,预估接下来出现锁冲突的概率不大,同时会统计锁冲突的次数,达到一定程度之后就会转化为悲观锁

1.2. 挂起等待锁和自旋锁

挂起等待锁:当一个线程试图获取一个已经被其他线程持有的挂起等待锁时,该线程会被阻塞(挂起),操作系统会将其状态从运行态转换为阻塞态,并将 CPU 资源让给其他可运行的线程。这个被阻塞的线程会被放入一个等待队列中,直到持有锁的线程释放锁后,操作系统才会将该线程从等待队列中唤醒,重新将其状态转换为就绪态(可运行态),等待 CPU 分配时间片来继续执行。

适用于一个线程获取锁需要很长时间的情况

自旋锁:自旋锁是一种忙等待的锁机制。当一个线程试图获取一个已被其他线程持有的自旋锁时,它不会被阻塞,而是会在一个循环中不断地检查锁是否已经被释放。

适用于锁冲突概率小并且锁持有时间短的情况,否则CPU开销会非常大

1.3. 重量级锁和轻量级锁

重量级锁和轻量级锁是在加锁开销的角度定义的,也就是时间的开销

重量级锁:加锁的开销比较大,要做更多的工作(悲观锁做的重)

重量级锁就是基于挂起等待的方式实现的(调用操作系统api,在内核中实现的,效率低)

轻量级锁:加锁的开销比较小,要做的工作也相对少(乐观锁做的轻)

轻量级锁就是基于自旋锁的方式实现的,(JVM内部,用户态代码实现,效率高)

1.4. 公平锁和非公平锁

公平锁:遵循先到先得的原则实现

非公平锁:任何一个线程都可以获得锁,各凭本事争夺,但是也不是每个线程概率均等

1.5. 可重入锁和不可重入锁

可重入锁在之前提到过,synchronized就是可重入锁,如果一个线程针对同一把锁连续加锁两次,就可能出现死锁,如果把锁设定成可重入就可以避免死锁了,实现步骤大概有以下几个操作

  1. 记录当前是哪个线程持有了锁
  2. 在加锁的时候判定,当前申请锁的线程是否是锁的持有者线程
  3. 定义一个计数器来记录加锁的次数,从而确定何时真正的加锁

不可重入锁就是不能同时加锁两次的锁

1.6. 读写锁

读写锁用于在多线程环境下对共享资源进行并发访问的控制,读写锁将共享资源的访问分为读操作和写操作,并针对这两种操作进行不同的并发控制

读操作:允许两个线程同时获取读锁,并进行读操作,因为读操作并不会改变共享资源的状态

写操作:当一个线程获取写锁时,其他线程无论是要读还是写都不能再获取锁

在 Java 中,ReentrantReadWriteLock类提供了读写锁的实现,可以使用内部类ReadLock的readLock().lock()方法来获取读锁,使用writeLock().lock()方法来获取写锁

ReentrantReadWriteLock还支持可重入性,即同一个线程可以多次获取读锁或者写锁。例如,一个线程已经获取了读锁,在不释放读锁的情况下可以再次获取读锁;同样,一个线程已经获取了写锁,也可以再次获取写锁。

2. synchronized

synchronized 在以上锁策略中属于:

  1. 乐观锁&悲观锁(自适应)
  2. 轻量级锁&重量级锁(自适应)
  3. 自旋锁&挂起等待锁(自适应)
  4. 非公平锁
  5. 可重入锁
  6. 非读写锁

synchronized 的加锁过程:刚开始使用 synchronized 加锁,首先会处于“偏向锁”的状态,遇到线程之间的锁竞争,会升级到“轻量级锁”,之后进一步统计竞争出现的频次,达到一定程度之后,升级到“重量级锁”

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

上面锁升级的过程,对 JVM 来说是不可逆的

偏向锁:并不是真正的加锁(真正加锁时开销可能比较大,偏向锁只是做个标记),标记的过程非常轻量高效

3. 锁的优化机制

3.1. 锁消除

JVM 在编译时和运行时会进行代码分析,如果发现某些对象的锁操作是不必要的,就会将这些锁操作消除。例如,在一些局部对象上的锁操作,如果这个局部对象不会被多个线程共享,那么这些锁操作就是多余的,或者说在单线程环境下加锁,也是会被优化掉的。

3.2. 锁粗化

锁粗化是指将多个连续的、对同一个对象的同步块合并成一个更大的同步块。也就是把多个“细粒度”的锁合并成“粗粒度”的锁,例如,如果在一个循环体内多次对同一个对象进行锁操作,JVM 可能会将这些锁操作合并成一个锁操作,在循环开始前获取锁,在循环结束后释放锁。

4. CAS

CAS(Compare - And - Swap),即比较并交换,是一种用于实现多线程同步的原子操作机制

一个内存中的数据和两个寄存器中的数据进行操作(寄存器1,寄存器2):

比较内存和寄存器1中的值是否相等,如果相等,就交换寄存器2的值和内存中的值,这里一般都是关心内存交换后的内容,不关心寄存器2交换后存储的内容,虽然叫做交换,其实希望达成的效果是赋值

CAS 操作是原子性的,能够在多线程环境下确保数据的一致性。它不需要像传统的锁机制那样进行加锁、解锁操作,避免了锁带来的诸如死锁、线程阻塞和唤醒等复杂问题

应用场景:

4.1. 基于CAS实现的“原子类”

int / long 等类型进行 ++ -- 的操作时,并不是原子的,基于CAS实现的原子类,对 int / long 等这些类型进行了封装,从而可以使用原子的 ++ -- 操作

public class ThreadDemo26 {
    //private static int count = 0;
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count.getAndIncrement();
                // count.incrementAndGet(); ++ count
                // count.getAndDecrement(); count --
                // count.decrementAndGet(); -- count
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

来看一下CAS实现的原子类大概是怎么工作的:

4.2. 实现自旋锁

来看一下大概的逻辑

4.3. ABA问题

CAS 操作存在 ABA 问题:假设一个共享变量初始值为 A,线程 1 读取到这个值 A 后,被其他线程干扰,这个变量先被修改为 B,然后又被修改回 A。当线程 1 再次执行 CAS 操作时,它会认为这个变量没有被修改过(因为还是 A),从而可能导致一些潜在的逻辑错误。

解决办法:添加版本号或者时间戳等辅助手段来解决 ABA 问题。例如,将共享变量从单纯的数值 A 变成一个包含版本号的结构体,如<A, 1>,每次修改都会更新版本号,这样线程 1 再次执行 CAS 操作时就能发现这个变量实际上已经被修改过了。

产生ABA问题其实也就是变量加完之后又减回原来的值,出现了减法,引入版本号这样只能增加的标记,就可以解决了

5. JUC

5.1. Semaphore

Semaphore(信号量)是一个用于控制同时访问特定资源的线程数量的同步工具类。它维护了一个许可证(permit)的概念,线程在访问共享资源之前必须先获取许可证,在使用完共享资源后释放许可证。

public class ThreadDemo27 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(3);
        semaphore.acquire();
        System.out.println("申请一个资源");
        semaphore.acquire();
        System.out.println("申请一个资源");
        semaphore.acquire();
        System.out.println("申请一个资源");
        semaphore.release();
        System.out.println("释放一个资源");
        semaphore.acquire();
        System.out.println("申请一个资源");
    }
}

通过信号量,也可以解决之前的线程安全问题:

只需要把信号量的大小创建为 1 ,之后就相当于锁,每次执行代码时申请资源,执行完毕之后释放资源,由于资源只有一份,其他线程也拿不到

5.2. CountDownLatch

CountDownLatch是一个同步辅助类,用于协调多个线程之间的执行顺序, 它允许一个或多个线程等待其他线程完成一系列操作。CountDownLatch内部维护着一个计数器,这个计数器初始化为一个正整数,表示需要等待的事件数量。

await方法:  调用这个方法的线程会被阻塞,直到CountDownLatch的计数器值为 0

countDown方法:  这个方法用于将CountDownLatch的计数器值减 1。当计数器值达到 0 时,所有等待的线程(调用await方法被阻塞的线程)将被唤醒并继续执行。

public class ThreadDemo28 {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(4);
        CountDownLatch count = new CountDownLatch(20);
        for(int i = 0;i < 20;i++){
            int id = i;
            executorService.submit(()->{
                System.out.println("任务" + id + "开始执行");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
            System.out.println("任务" + id + "执行完毕");
            //每执行一次,任务数 -1
            count.countDown();
        }
        //等待所有任务完成
        count.await();
        System.out.println("执行完毕");
    }
}

6. 线程安全的集合类

在之前提到过,在使用 ArrayList,Queue 这样的集合时,它的线程是不安全的,虽然说 Java 中提供了 Vector ,Stack ,Hashtable 这样内置了 synchronized 集合,但是这几个东西并不常用,容易出现死锁

6.1. ArrayList / LinkedList 的优化

如果需要使用 ArrayList / LinkedList 这样的结构,Java 提供了一个带锁的 List

Collections.synchronizedList(new ArrayList<>());

这就相当于构造了一个核心方法中带 synchronized 的 List

还可以使用 CopyOnWrite 集合类,这个集合类没有采用加锁的方式,而是通过“写时拷贝”来实现线程安全的,以此避免两个线程同时修改一个变量,如果说只是读取,ArrayList 不需要进行任何改变,如果有其他线程修改上面的元素,此时不会直接进行修改,而是拷贝出一份新的 ArrayList ,在拷贝的过程中,读操作仍然是读取旧版本的内容,写操作则是在新版本上进行修改,然后引用再指向新版本。

但是写时拷贝无法应对多个线程同时修改的情况,如果涉及到的数据量很大,拷贝起来也比较费时间,拷贝机制其实主要应对的就是“多个线程读,一个线程写这样的场景”

也就类似于广告的服务器,在运行的时候涉及到很对配置,这些配置都是写在配置文件中的,服务器运行时加载配置文件中的配置项,根据配置项来打开 / 关闭 / 设置一些功能,如果此时想要修改配置文件,就可以用到上面的“写时拷贝”来实现

6.2. Queue 的优化

多线程环境下的队列其实就可以使用之前提到的 BlockingQueue 。

6.3. HashMap 的优化

虽然说 Java 提供了 Hashtable , 但是还有一个更优秀的集合类 , 也是比较推荐的

ConcurrentHashMap,相比于Hashtable 和 HashMap ,做出了很多优化

  1. 优化了锁的粒度。Hashtable 加锁,就是直接给所有的方法都加上 synchorized ,这样整个 Hashtable 就是一把锁,针对这个哈希表的操作都可能会发生锁的竞争,而ConcurrentHashMap 是给每一个哈希表中的 “链表”加锁(加了多把锁,“锁桶”),只有同时进行的两次修改,恰好在同一条链表上的元素时,才会触发锁竞争,大大降低了锁冲突的概率
  2. ConcurrentHashMap 引入了 CAS 原子操作,针对想 size 这样的操作,直接借助 CAS 完成,并不会加锁
  3. 针对读操作也做了特殊处理,上面的加锁都是针对写操作来加锁的,对于读操作,通过 Volatile 以及其他一些代码来实现,确保读操作不会读到“修改一半”的数据
  4. 针对哈希表的扩容机制进行了优化,普通的哈希表扩容是创建新的哈希表,把原来的数据搬进去,这一系列操作可能一次就搬运玩了,整体时间开销会很大,如果再使用锁,就出现长时间占用锁的情况,而ConcurrentHashMap 不会在一次操作中把全部的数据搬过去,而是只搬一部分,每次都搬一部分,最终全部搬运完成,这样的话新表和旧表同时存在,如果再涉及到插入操作,直接插入到新的空间中,如果是查询 / 修改 / 删除,就需要同时对两个表进行操作,不过由于哈希表的这些操作都是 O(1) 的时间复杂度,也不会有太大的消耗

相关文章
|
12月前
|
Linux API C++
|
12月前
|
关系型数据库 MySQL 编译器
C++进阶 多线程相关(下)
C++进阶 多线程相关(下)
56 0
|
安全 Java 调度
多线程【进阶版】(下)
多线程【进阶版】
60 0
|
安全
多线程【进阶版】(中)
多线程【进阶版】
50 0
|
5月前
|
安全 调度
多线程入门
多线程入门
128 1
|
5月前
|
安全 算法 Java
多线程知识点总结
多线程知识点总结
59 3
|
调度
多线程:笔记
多线程:笔记
59 0
|
12月前
|
算法 Ubuntu C++
[总结] C++ 知识点 《四》多线程相关
[总结] C++ 知识点 《四》多线程相关
|
存储 安全 算法
3.多线程(进阶)(三)
3.多线程(进阶)(三)
44 0