【javaEE】——多线程进阶(锁策略:面试相关考点)04

目录

一、常见的锁策略

1.1乐观锁 vs 悲观锁

1.2 读写锁

1.3重量级锁 vs 轻量级锁

1.4挂起等待锁和自旋锁

1.5公平锁 和非公平锁 

1.6可重入锁 和 不可重入锁

二、CAS(Compare and swap)比较并交换

2.1CAS的实现

2.2 CAS中的ABA问题

三、Synchronized 原理

3.1加锁过程

3.2 锁的其他优化(锁的粒度)

3.3 Callable 接口

3.4 ReentrantLock 可重入互斥锁.

四、信号量Semaphore

五、CountDownLatch倒计时的锁存器

六、线程安全的集合类

6.1多线程环境使用 ArrayList

6.2 多线程环境使用队列

6.3 多线程环境使用哈希表


一、常见的锁策略

锁策略不局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的.普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的.

1.1乐观锁 vs 悲观锁

悲观锁(重量级锁) 假设冲突概率高甚至每次加锁都会冲突(总是假设出现坏情况)

乐观锁(轻量级锁):假设冲突概率高甚至每次加锁都会冲突,发现冲突才去解决;引入版本号:提交版本号>当前版本号(线程内版本号>内存中版本号)

小数据量可考虑乐观锁,Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

1.2 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。

synchronized对读写没有区分,如果读写都有,使用就会互斥

一个线程对于数据的访问, 主要存在两种操作: 读数据、 写数据.

  • 两个线程都只是读一个数据,没有线程安全问题. 直接并发的读取即可.(读锁不互斥)
  • 两个线程都要写一个数据, 有线程安全问题.(写锁互斥)
  • 一个线程读另外一个线程写, 也有线程安全问题.(读写互斥)

1.3重量级锁 vs 轻量级锁

锁的核心特性 "原子性", 追根溯源是 CPU 这样的硬件设备提供的:

  • CPU 提供了 "原子操作指令".
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

悲观锁乐观锁描述的是应用场景,看锁冲不冲突;

重量级锁 和轻量级锁 与 应用场景  没啥关系,主要看  开销

重量级锁:重度依赖了 OS 提供了 mutex接口。加锁开销较大,往往在内核态完成,易引发线程调度
轻量级锁:尽可能不使用 mutex接口, 尽量在用户态代码完成, 再使用 mutex.加锁开销较小,往往在用户态完成。(少量的内核态用户态切换),用户态的代码更可控,更高效

1.4挂起等待锁和自旋锁

自旋锁、挂起等待锁,都是在synchronized内置的。

1.自旋锁: 线程获取不到锁,会再快速再试一次,节省了OS调度线程的开销,比挂起等待锁能更及时获取到锁(用户态实现,轻量级锁),没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.

2.挂起等待锁:获取不到锁就会阻塞等待,(啥时结束阻塞)取决于OS调度   --->线程挂起时,不占CPU(一般基于内核态的机制实现,较重),重量级锁

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

1.5公平锁 和非公平锁 

公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:不遵守 "先来后到". B 和 C 都有可能获取到锁.(抢占式执行)

实时操作系统:vxworks(凤河公司)线程调度可控;Windows、Linux:线程调度不可控

适用场合:
大部分情况使用---》非公平锁
期望线程调度的时间成本可控时,---》公平锁

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁;

实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.

1.6可重入锁 和 不可重入锁

可重入锁:“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。

比如一个递归函数里有加锁操作,递归过程中这个锁不会阻塞自己(可重入锁、递归锁)

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。 Linux 系统提供的 mutex 是不可重入锁.

synchronized锁策略:

  • 即是悲观锁也是乐观锁(根据锁竞争的激烈程度自适应)
  • 不是读写锁,只是普通的互斥锁
  • 既是轻量锁,也是重量锁(根据锁竞争的激烈程度自适应
  • 轻量级锁的实现基于自旋锁,重量级锁部分基于挂起等待锁来实现
  • 非公平锁
  • 可重入锁

二、CAS(Compare and swap)比较并交换

 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但并不会阻塞其他线程,其他线程只会收到操作失败的信号。

CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式),CAS也是多线程安全的一种实现手段。

2.1CAS的实现

假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。

比较  两个内存   或者  寄存器和内存,操作是原子的。

 伪代码(逻辑理解):

boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) {
        &address = swapValue;
        return true;
    }
    return false;
}

一条单独的CAS指令即可完成这个逻辑

(1)CAS 的实现

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理:

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

(2)CAS的应用

  • 1)实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.针对常用数据类型进行了疯涨,可基于CAS(不涉及线程等待)进行修改,并且线程安全(Atomic原子的)

  • CAS 是直接读写内存的, 而不是操作寄存器.
  • CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.
import java.util.concurrent.atomic.AtomicInteger;

public class suoDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                num.getAndIncrement();  //num自增
            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 5000; i++) {
                num.getAndIncrement();
            }
        });
        t.start();
        t2.start();
        t.join();
        t2.join();
    }
}

CAS操作步骤:

  1. 相等则直接赋值
  2. 不等则进入循环,重新读取内存中的值,再次进行比较,然后再赋值
  3. 各自线程返回交换前的数值
  • 2)实现自旋锁

伪代码实现:

CAS由CPU指令一次性完成比较和交换的过程,不加锁也可保证线程安全

若当前锁this.owner=null,则设置this.owner=Thread.currentThread()

public class SpinLock {    //自旋锁 SpinLock 
    private Thread owner = null;    // 通过 CAS 看当前锁是否被某个线程持有(null表示没加锁).
    public void lock(){
       while(!CAS(this.owner, null, Thread.currentThread())){
//循环调用 :锁已经被别的线程持有(不是null), 就自旋等待.  
//锁没有被别的线程持有(null), 那么就把 owner 设为当前尝试加锁的线程.
       }
    }
    public void unlock (){
        this.owner = null;
    }
}

2.2 CAS中的ABA问题

ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A。线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:

  • 先读取 num 的值, 记录到 oldNum 变量中.
  • 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z。

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A(ABA 问题引来的 BUG)

ABA问题如何解决???

数据从A-->B -->A:
引入版本号是否一致,一致才替换(乐观锁机制),修改变量的时候比较的是版本号


给要修改的数据引入版本号。 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。当前版本号和之前读到的版本号一致, 就执行修改操作, 并让版本号自增 如果发现当前版本号比之前读到的版本号大, 就认为操作失败

三、Synchronized 原理

3.1加锁过程

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

【javaEE】——多线程进阶(锁策略:面试相关考点)04_第1张图片

(1)偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态(给对象头中做一个 "偏向锁的标记",记录锁属于哪个线程.)。

  • 若后续无其他线程来竞争该锁,就不用进行其他同步操作了(避免了加锁解锁的开销)
  • 若后续有其他线程来竞争该锁(锁对象中记录了当前锁属于哪个线程, 很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质:相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.(延时加锁,提前先设置一个标记,无冲突则不加锁;有冲突时再加锁-->变为轻量级锁(基于CAS的自旋锁,用户态完成不涉及内核态切换和阻塞,会耗费CPU))

(2)轻量级锁

其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(CAS实现,自适应的自旋锁):

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)(自旋操作一直让 CPU 空转, 比较浪费 CPU 资源。因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋)

(3) 重量级锁
若竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁。重量级锁就是指用到内核提供的 mutex接口:

  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列后,锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.

3.2 锁的其他优化(锁的粒度)

编译器和JVM自行判断是否需要加锁(不需要时取消锁,需要时加锁)

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

锁的粒度:synchronized包含的代码多少:

  • 多:锁的粒度粗,无其他线程竞争锁,避免频繁释放锁,直接粗化(一次加锁,完成多个任务)
  • 少:锁的粒度细,持锁时间短,能很快释放,冲突概率小

实际开发过程中, 使用细粒度锁, 是期望释放锁时,其他线程能使用锁。但是实际上,可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.

3.3 Callable 接口

Callable 是一个 interface . 相当于把线程封装了一个 "返回值".

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000:

  • 创建一个匿名内部类, 实现 Callable ( 带泛型参数(返回值类型))接口
  • 重写 Callable 的 call 方法, 完成累加的过程(任务的执行)
  • 把 callable 实例使用 FutureTask 包装一下(辅助类)
  • 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  • 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class classDemo2 {
    public static void main(String[] args) {
        Callable callable = new Callable() {  //通过callable描述任务
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask task = new FutureTask<>(callable);  //使用中间类FutureTask辅助线程执行任务
        Thread t = new Thread(task);  //创建线程为了完成此处的计算工作
        t.start();
    }
}

Callable 和 Runnable 的区别:

  •  Callable 描述带有返回值的任务;Runnable 描述不带返回值的任务.
  • Callable 通常搭配 FutureTask (保存 Callable 的返回结果)来使用;Callable 往往是在另一个线程中执行的, 什么时候执行完并不确定.
  • FutureTask 就可以负责这个等待结果出来的工作.

3.4 ReentrantLock 可重入互斥锁.

可重入互斥锁,是对synchronized的补充,保证线程安全,分开了加锁、解锁,也是用来实现互斥效果。ReentrantLock 的用法:

  • lock():加锁, 如果获取不到锁就死等.
  • trylock(超时时间):加锁,  等待一定的时间之后获取不到就放弃加锁.(尝试加锁失败,直接返回失败,不会阻塞)
  • unlock(): 解锁

ReentrantLock和synchronized的区别:

  • 1.synchronized 是一个关键字, 是 JVM 内部实现的( C++ ); ReentrantLock 是标准库的一个类, 在 JVM 外实现的( Java ).
  • 2.synchronized 不需要手动释放锁,出了代码块锁自然释放; ReentrantLock 使用时需要手动释放,使用起来更灵活,但是也容易遗漏 unlock.
  • 3.synchronized在申请锁失败时, 会死等; ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • 4.synchronized:  非公平锁;ReentrantLock:  支持公平 和 非公平锁,且提供更强大的唤醒机制(Condition类)--->显式指定唤醒哪个线程
  • 5. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒.唤醒的是随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 更精确控制唤醒某个指定线程.

如何选择???

  • 锁竞争不激烈: 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈:使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock.

四、信号量Semaphore

信号量:表示 "可用资源的个数". 本质上是一个计数器。锁为一个”二元信号量“,可用资源就一个,计数器取值(非0即1)

信号量的 P 操作:申请资源,计数器 -1 (acquire)

信号量的 V 操作:释放资源,计数器 +1   (release)

当计数器为0时,在申请资源就会出现阻塞等待

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

import java.util.concurrent.Semaphore;

public class classDemo3 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(4);  //初始化表示可用资源4个
        semaphore.acquire();  //申请资源P操作
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
//        semaphore.acquire();  // 资源申请完毕,触发acquire等待机制
//        System.out.println("申请成功");
        semaphore.release();
        System.out.println("释放资源");
    }
}

五、CountDownLatch倒计时的锁存器

同时等待 N 个任务执行结束,待所有任务的线程执行完毕,主线程调用latch.await()

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
import java.util.concurrent.CountDownLatch;

public class classDemo4 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);  // CountDownLatch(10)是构造方法,10表示任务个数
        for (int i = 0; i < 10; i++) {
            Thread t1 = new Thread(() ->{
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "到达终点!");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t1.start();
        }
        latch.await(); //线程未执行完毕,await就阻塞,直至所有线程执行完毕,await才返回
    }
}

六、线程安全的集合类

6.1多线程环境使用 ArrayList

1) 使用同步机制 (synchronized 或者 ReentrantLock)

2) Collections.synchronizedList(new ArrayList);

  • synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
  • synchronizedList 的关键操作上都带有 synchronized

3) 使用 CopyOnWriteArrayList(写时拷贝:修改的时候会创建一个副本出来)双缓冲区策略

  • 修改文件时,创建一个副本对其进行修改,若在修改的同时又有读取操作,则先读取原文件的内容(非副本),当修改完毕后,源文件的引用指向副本(副本替换原文件)

对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器是一种读写分离的思想,读和写不同的容器

6.2 多线程环境使用队列

1) ArrayBlockingQueue:基于数组实现的阻塞队列
2) LinkedBlockingQueue:基于链表实现的阻塞队列
3) PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
4) TransferQueue:最多只包含一个元素的阻塞队列


6.3 多线程环境使用哈希表

HashMap 是线程不安全的。在多线程环境下使用哈希表可以使用:

  • Hashtable【不推荐使用】
  • ConcurrentHashMap

ConcurrentHashMap锁对象:针对每个元素(哈希桶)

(1)Hashtable

简单的给方法加锁(针对this加锁),会导致锁竞争加大,效率降低.直接针对 Hashtable 对象本身加锁

  • 一个HashTable只有一把锁,任意两个或多个都会出现锁竞争/冲突。
  • size 属性也是通过 synchronized 来控制同步, 比较慢
  • 一旦触发扩容, 就由该线程完成整个扩容过程(涉及到大量的元素拷贝, 效率会非常低)


 (2)ConcurrentHashMap

相较于Hashtable使用粒度更小的锁,对每个元素象进行加锁,降低了锁冲突的概率。

  • 读操作不加锁,只使用,只针对写操作加锁。
  • 减少了所冲突,不锁整个对象, 而是 "锁桶”(让锁加到每个对象的链表头节点上)
  • 更广泛使用 CAS 特性,提升效率(如维护 size操作, 避免出现重量级锁的情况)
  • 优化扩容方式:化整为零(1个锁变为多个锁)
    • 触发了扩容机制,每次只是搬运一点点,通过多次完成整个搬运过程。
    • 搬完最后一个元素再把老数组删掉;这个期间, 插入只往新数组加.这个期间, 查找需要同时查新数组和老数组

你可能感兴趣的:(JAVAWeb,java,jvm,servlet)