JavaEE之多线程进阶

JavaEE之多线程进阶

1.常见的锁策略

1.乐观锁和悲观锁

锁的实现者 , 预测接下来锁冲突的概率大不大 , 根据这个冲突的概率 , 来决定接下来该怎么做

乐观锁 : 预测接下来冲突概率不大

悲观锁 : 预测接下来冲突概率比较大

通常来说 : 悲观锁一般要做的工作更多一点 , 效率会低一点

乐观锁做的工作更少一些 , 效率更高一点

2.轻量级锁 和 重量级锁

轻量级锁 , 加锁解锁 , 过程更快更高效

重量级锁 , 加锁解锁 , 过程更慢 ,更低效

一个乐观锁很可能也是一个轻量级锁(不绝对)

一个悲观锁很可能也是一个重量级锁(不绝对)

3.自旋锁 和 挂起等待锁

自旋锁是轻量级锁的一种典型实现 : 通常是纯用户态的不需要经过内核态(时间相对更短) 一旦锁被释放 , 就能第一时间拿到锁 , 速度更快

挂起等待锁是重量级锁的一种典型实现 : 通过内核的机制来实现挂起等待(时间更长了) 如果锁被释放 , 不能第一时间拿到锁 , 可能需要过很久才能拿到锁

针对于上述三组锁策略 , synchroized 这把锁属于哪种:

synchronized 既是悲观锁 , 也是乐观锁 , 既是轻量级锁 , 也是重量级锁 . 轻量级锁部分基于自旋锁实现 , 重量级锁部分基于挂起等待锁实现

synchronized 会根据当前锁竞争的激烈程度 , 自适应 . 如果锁冲突不激烈 , 以轻量级锁/ 乐观锁 的状态运行 . 如果锁冲突激烈 , 以重量级锁/悲观锁的状态运行

4.互斥锁 和 读写锁

synchronized 就是互斥锁 , 只有两个操作 : 1.进入代码块 ,加锁 2.出了代码块 , 解锁

读写锁: 1.给读加锁 2.给写加锁 3.解锁

读写锁中约定 : 1. 读锁和读锁之间不会产生锁竞争 , 不会产生阻塞等待 , 不影响程序运行速度

​ 2.写锁和写锁之间 , 有锁竞争

​ 3.读锁和写锁之间 , 也有锁竞争

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

如果一个锁 , 在一个线程中 , 连续对该锁加锁两次 , 不死锁 , 就叫可重入锁 . 如果死锁了 , 就叫不可重入锁

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

Object locker = new Object();
synchronized (locker) {
	synchroized(locker) {
		
	}
}

类似于这个代码 , 就是加锁两次的情况 , 第二次尝试加锁 , 需要等待第一个锁释放 ;第一个锁释放 , 需要等待第二个锁加锁成功 , 逻辑矛盾了 ==> 死锁

class BlockingQueue {
	synchronized void put(int elem) {
		this.size();
		......
	}
	synchroized int size(){
		......
	}
}

以上这两代码都是针对 this 加锁 , 难道真的会死锁吗?

实际上在Java中并不会 , 因为 synchroized 是个 " 可重入锁 " , 这个场景中 , 不会死锁

死锁

死锁的四个必要条件:

  1. 互斥使用 : 一个线程拿到一把锁之后 , 另一个线程不能使用 (锁的基本特点)
  2. 不可抢占 : 一个线程拿到锁 , 只能自己主动释放 , 不能是被其他线程强行占有
  3. 请求和保持 : “吃着碗里的 , 看着锅里的”
  4. 循环等待 : 逻辑依赖循环的 . “钥匙锁车里了 , 车钥匙锁家里了”

如何避免死锁:

​ 针对锁进行编号 , 如果需要同时获取多把锁 , 约定加锁顺序 , 务必是先对小的编号加锁 , 后对大的编号加锁

6.公平锁 和 非公平锁

此处 , 约定 , 遵守先来后到 , 就是公平锁 . 不遵守先来后到 , 非公平锁

系统对于线程调度是随机的 . 自带的synchroized 这个锁 , 是非公平的 .

相关面试题

1)怎么理解乐观锁和悲观锁 , 具体是怎么实现的 ?

悲观锁认为多个线程访问同一个共享变量冲突的概率比较大 , 会在每次访问共享变量之前都去真正加锁

乐观锁认为多个线程访问同一个共享变量冲突的概率不大 , 并不会真的加锁 , 而是直接访问数据 . 在访问的同时识别当前的数据是否出现访问冲突

悲观锁的实现就是先加锁 , 获取到锁再操作数据 , 获取不到锁就等待

乐观锁的实现可以引入一个版本号 . 借助版本号识别出当前的数据访问是否冲突

2)介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁

读锁和读锁之间不互斥

写锁和写锁之间互斥

写锁和读锁之间互斥

读写锁最主要用在 “频繁读 , 不频繁写” 的场景中

3)什么是自旋锁 , 为什么要使用自旋锁策略 , 缺点是什么

如果获取锁失败 , 立即再尝试获取锁 , 无线循环 , 直到获取到锁为止 , 第一次获取锁失败 , 第二次尝试会在极短的时间内到来 , 一旦锁被其他线程释放 ,就能第一时间获取到锁

相比于挂起等待锁

优点 : 没有放弃CPU 资源, 一旦锁被释放就能第一时间获取到锁 , 更高效 , 在锁持有时间比较短的场景下非常有用

缺点 : 如果锁的持有时间较长 , 就会浪费CPU 资源

4)synchronized 是可重入锁吗?

是可重入锁

可重入锁指的是连续两次加锁不会导致死锁

实现的方式是在锁中记录该锁持有的线程身份 , 以及一个计数器(记录加锁次数) . 如果发现当前加锁的线程就是持有锁的线程 , 则直接计数自增

5)synchroized 锁的特点

既是乐观锁 , 也是悲观锁

既是轻量级锁 , 也是重量级锁

轻量级锁基于自旋实现 , 重量级锁基于挂起等待实现

不是读写锁

是可重入锁

是非公平锁

2.CAS

1.什么是CAS

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

寄存器 A 的值 和内存 M 的值进行对比 , 如果值相同 , 就把寄存器 B的值和 M 的值进行交换 .

伪代码:

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

这一段代码 , 非原子 , 运行过程中就可能随着线程调度有概率产生问题

address: 内存地址

expectValue : 寄存器A

swapValue : 寄存器B

更关键的是 , CAS 操作 , 是一条CPU指令 !!!并非上述这一段代码

CAS 实现了 不需要加锁 , 就能保证线程安全

2.CAS是怎么实现的

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

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

3.CAS应用

1.实现原子类

标准库里提供 AtomInteqer 类 , 能够保证 ++ – 操作的时候线程安全


    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //num++
                num.getAndIncrement();
                //++num
                //num.incrementAndGet();

            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(num.get());
    }

伪代码:

class AtomicInteger {
	private int value;
	public int getAndIncrement() {
		int oldValue = value;
		while(CAS(value,oldValue,oldValue+1) != true) {
			oldValue = value;
		}
		return oldValue;
	}
}

oldValue 可以视为是个寄存器 . Java 没法表示 在寄存器 中的值 , 此处用变量表示了

如果发现 value 和 oldValue 值相同 , 此时就把oldValue + 1 设置到 value 中 , 相当于 ++ . 然后 CAS 返回 true , 循环结束 , 反之 , 如果 value 和 oldValue 不相同 , CAS啥都不做并返回false , 进入循环 , 重新设置 oldValue 的值.

value 是成员变量 , 如果两个线程 同时调用 getAndIncrement 方法 , 其实此处的CAS 就是在确认 , 看当前 value 是不是变过 . 如果没变过 , 才能自增, 如果变过了 , 就先更新 , 再自增

之前的线程不安全 , 就是一个线程不能及时的感知到另一个线程对内存的修改

2.实现 自旋锁

反复检查当前的锁状态 , 看是否解开了

伪代码:

public class SpinLock {
	private Thread owner = null;//记录当前的锁被哪个线程持有,为null 没人持有
	public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
		while(!CAS(this.owner,null,Thread.currentThread())) {
		//如果当前owner 是null ,比较就成功 , 就把当前引用设置到 owner中,加锁完成,循环结束 . 比较不成功 , 意味着owner 非空 , 直接返回false , 循环继续执行
		}
	}
	public void unlock() {
		this.owner = null;
	}
}

4.CAS的ABA问题

ABA问题:

假设存在两个线程 t1 和 t2 .有一个共享变量 num, 初始值为A.

接下来 , 线程t1 想使用CAS 把num值改为Z, 就需要

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

CAS只能对比值是否相同 , 不能确定这个值是否中间发生过改变

如何解决这个ABA问题 ?

ABA关键是值会反复横跳 , 如果约定数据只能单方向变化 , 问题就解决了(只能增加 , 或者只能减小)

如果需求要求该数值 既能增加也能减小,? 可以引入另外一个 版本号 变量, 约定版本号只能增加 , 每次CAS对比的时候 , 就不是对比数值本身 , 而是对比版本号

CAS解释了自旋锁的实现

3.synchroized 原理

基本特点 :

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

synchroized 的关键策略 : 锁升级

无锁 --> 偏向锁 -->自旋锁 --> 重量级锁

加锁的升级过程: 刚开始加锁 , 是偏向锁状态 ,遇到锁竞争 , 就是自旋锁(轻量级锁),竞争更激烈 , 就会变成重量级锁(交给内核阻塞等待)

  1. 偏向锁
    第一个尝试加锁的线程, 优先进入偏向锁状态

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别
当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

  1. 轻量级锁
    随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
    此处的轻量级锁就是通过 CAS 来实现(大量消耗cpu)
  1. 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  2. 如果更新成功, 则认为加锁成功
  3. 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
  1. 重量级锁
    如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
    此处的重量级锁就是指用到内核提供的 mutex .
  1. 执行加锁操作, 先进入内核态.
  2. 在内核态判定当前锁是否已经被占用
  3. 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  4. 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  5. 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.

锁消除:

非必要 , 不加锁.

编译阶段 做的优化手段 , 检测当前代码是否是多线程执行/是否有必要加锁 . 如果没必要 , 又已经写锁 , 就会在编译过程中自动把锁去掉

锁粗化:

锁的粒度 : synchronized 代码块 , 包含代码的多少 . (代码越多 , 粒度越粗 , 代码越少 , 粒度越细)

一般写代码的时候 , 多数情况下 , 是希望锁的粒度更小一点 . (串行执行的代码少 , 并发执行的代码就多) . 实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.

4.JUC中的常见组件

1.Callable 用法

​ 非常类似于Runnable (Runnable 描述了一个任务 , 一个线程要干啥 . 通过 run 方法描述 , 返回值类型void)

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

public static void main(String[] args) throws ExecutionException, InterruptedException {
    //创建一个任务
    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;
        }
    };

    //完成这个任务
    //Thread 不能直接传callable , 需要包装一层 futuretask
    FutureTask<Integer> futureTask = new FutureTask<>(callable);
    Thread t = new Thread(futureTask);
    t.start();

    System.out.println(futureTask.get());
    //此处的get 就是获取到上述 任务 call 方法的返回值的结果(Integer)
}

如何保证 , 调用 get的时候 , t 线程 的 call 方法 是执行完毕了的呢???

​ get和join类似 , 都会阻塞等待

理解Callable :

Callable 和 Runnable 相对 , 都是描述一个 “任务” . Callable 描述的是带有返回值的任务 . Runnable 描述的是不带返回值的任务

Callable 通常需要搭配 FutureTask 来使用 . FutureTask 用来保存 Callable 的返回结果 . 因为 Callable 往往是在另一个线程中执行的 , 啥时候执行完不确定

FutureTask 就可以负责这个等待结果出来的工作

理解 FutureTask

“小票” , 凭票取东西

2.ReentrantLock 可重入锁

synchroized 关键字 , 是基于代码块的方式来控制加锁解锁的

ReentrantLock 则是提供了 lock 和 unlock 独立的方法 , 来进行加锁解锁

ReentrantLock 和 Synchroized 的区别:

  1. synchronized 只是加锁和解锁 . 加锁的时候如果发现锁被占用 , 只能阻塞等待

    ReentrantLock 还提供一个tryLock 方法 (如果加锁成功 , 没什么特殊 . 如果加锁失败 , 不会阻塞 , 直接返回 false )

  2. synchronized 是一个非公平锁 (概率均等 , 不蹲守先来后到)

    ReentrantLock 提供了 公平 和 非公平 两种工作模式. (在构造方法中 , 传入 true 开启公平锁)

  3. synchronized 搭配 wait notify 进行等待唤醒 . 如果多个线程 wait 同一个对象 , notify 的时候是随机唤醒

    ReentrantLock 则是搭配 Condition 这个类 . 这个类也能起到等待通知 , 可以功能更强大

如何选择使用哪个锁?

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

3.Semaphore 信号量

信号量是更广义的锁 , 不光能管理 非 0 即 1 的资源 , 也能管理多个资源

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

P 操作 , 申请资源 , 计数器 -1

V操作 , 释放资源 , 计数器 +1

如果计数器已经是 0 了 , 继续申请资源 , 就会阻塞等待

4.CountDownLatch

同时等待 N 个任务结束

主线程 , 进行10个线程

主线程创建一个 CountDownLatch 对象 , 构造方法写10

10个线程分别执行各自的操作 , 主线程使用 CountDownLatch.await 方法 , 来阻塞等待所有任务完成 , 10 个线程每个线程执行完 , 都调用一个 CountDownLatch.countDown 方法

await : 计算有几个 countDown 被调用了 , 当这 10 个人都调用过了之后 , 此时主线程 的 await 就阻塞解除 , 继续进行后续工作

5.线程安全集合类

常用的 ArrayList , LinkedList , HashMap , PriorityQueue …线程不安全

多线程环境使用哈希表

HashMap 本身不是线程安全的

在多线程环境下使用哈希表可以使用: Hashtable concurrentHashMap

高频面试题 : HashTable 和 ConcurrentHashMap 的区别

  1. 加锁粒度的不同 , 触发锁冲突的频率 . HashTable 是针对整个哈希表加锁 . 任何的增删改查 , 都会触发加锁 , 也就都会可能会有锁竞争

    插入元素:

    HashMap : 根据 key 计算 hash 值 -> 数组下标 把这个新的元素给挂到对应下标的链表上 , 如果是两个线程 , 插入两个元素 , 线程1 插入的元素 , 对应在下标为1 的链表上 , 线程2 插入的元素 , 对应在下标为 2 的链表上

    此时 , 虽然两个操作没有线程安全问题 , 但是由于 synchroized 是加到 this 上 , 仍然会针对于同一个对象产生锁竞争 , 产生阻塞等待

    ConcurrntHashMap : 不是只有一把锁 , 每个链表 (头结点) 作为一把锁

    每次进行操作 , 都是针对对应链表的锁进行加锁 , 操作不同链表就是针对不同的锁加锁 , 不会有锁冲突 . 导致大部分加锁操作实际上没有锁冲突 , 此时加锁的操作的开销就微乎其微

  2. 更充分的利用了CAS 机制 , 无锁编程 , 有的操作 , 比如获取/更新元素个数 , 就可以直接使用 CAS 完成 , 不比加锁 . CAS 也能保证线程安全 , 往往比锁更高效

  3. 优化了扩容策略 , 对于 HashTable , 如果元素太多 , 就会涉及到扩容

    扩容需要重新申请内存空间 , 搬运元素(把元素从旧的哈希表上删掉, 插入到新的哈希表上). ConcurrentHashMap 策略 , 化整为零 , 并不会一次性的把所有元素都搬运过去 , 而是每次搬运一部分 , 当put触发扩容 , 此时就会直接创建更大的内存空间 , 但是并不会直接把所有元素都搬运过去 , 而是搬运一小部分(速度比较快), 此时 , 相当于同时存在两份 hash 表.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值